From dc7a5b23175d5057a04a1f5675a8f8e9463ecbbe Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Mon, 4 Apr 2022 19:38:01 +0100 Subject: [PATCH 001/183] Adding discussion on format to bundle specification (#4047) * Adding discussion on format to bundle specification Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/mb_specification.rst | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/source/mb_specification.rst b/docs/source/mb_specification.rst index 5bdfa148e2..1d286052a5 100644 --- a/docs/source/mb_specification.rst +++ b/docs/source/mb_specification.rst @@ -61,8 +61,9 @@ This file contains the metadata information relating to the model, including wha Tensor format specifiers are used to define input and output tensors and their meanings, and must be a dictionary containing at least these keys: -* **type**: what sort of data the tensor represents: "image", "label", etc. -* **format**: what format of information is stored: "magnitude", "hounsfield", "kspace", "segmentation", "multiclass", etc. +* **type**: what sort of data the tensor represents: "image" for any spatial regular data whether an actual image or just data with that sort of shape, "series" for (time-) sequences of values such as signals, "tuples" for a series of items defined by a known number of values such as N-sized points in ND space, "probabilities" for a set of probabilities such as classifier output, this useful for interpreting what the dimensions and shape of the data represent and allow users to guess how to plot the data +* **format**: what format of information is stored, see below for list of known formats +* **modality**: describes the modality, protocol type, sort of capturing technology, or other property of the data not described by either it's type or format, known modalities are "MR", "CT", "US", "EKG", but can include any custom types or protocol types (eg. "T1"), default value is "n/a" * **num_channels**: number of channels the tensor has, assumed channel dimension first * **spatial_shape**: shape of the spatial dimensions of the form "[H]", "[H, W]", or "[H, W, D]", see below for possible values of H, W, and D * **dtype**: data type of tensor, eg. "float32", "int32" @@ -78,6 +79,22 @@ Optional keys: * **data_type**: type of source data used for training/validation. * **references**: list of published referenced relating to the model. +The format for tensors used as inputs and outputs can be used to specify semantic meaning of these values, and later is used by software handling bundles to determine how to process and interpret this data. There are various types of image data that MONAI is uses, and other data types such as point clouds, dictionary sequences, time signals, and others. The following list is provided as a set of supported definitions of what a tensor "format" is but is not exhaustive and users can provide their own which would be left up to the model users to interpret: + +* **magnitude**: ND field of continuous magnitude values with one or more channels, eg. MR T1 image having 1 channel or natural RGB image with 3 channels +* **hounsfield**: ND field of semi-categorical values given in Hounsfield, eg. CT image +* **kspace**: 2D/3D fourier transform image associated with MR imaging +* **raw**: ND field of values considered unprocessed from an image acquisition device, eg. directly from a MR scanner without reconstruction or other processing +* **labels**: ND categorical image with N one-hot channels for N-class segmentation/labels, the "channel_def" states in plain language what the interpretation of each channel is, for each pixel/voxel the predicted label is the index of the largest channel value +* **classes**: ND categorical image with N channels for N-class classes, the "channel_def" states in plain language what the interpretation of each channel is, this permits multi-class labeling as the channels need not be one-hot encoded +* **segmentation**: ND categorical image with one channel assigning each pixel/voxel to a label described in "channel_def" +* **points**: list of points/nodes/coordinates/vertices/vectors in ND space, so having a shape of [I, N] for I points with N dimensions +* **normals**: list of vectors (possible of unit length) in ND space, so having a shape of [I, N] for I vectors with N dimensions +* **indices**: list of indices into a vertices array and/or other array representing a set of shapes, so having a shape of [I, N] for I shapes defined by N values +* **sequence**: time-related sequence of values having one or more channels, such as a signal or dictionary lookup sentence, so having a shape of [C, N] for C channels of data at N time points. +* **latent**: ND tensor of data from the latent space from some layer of a network +* **gradient**: ND tensor of gradients from some layer of a network + Spatial shape definition can be complex for models accepting inputs of varying shapes, especially if there are specific conditions on what those shapes can be. Shapes are specified as lists of either positive integers for fixed sizes or strings containing expressions defining the condition a size depends on. This can be "*" to mean any size, or use an expression with Python mathematical operators and one character variables to represent dependence on an unknown quantity. For example, "2**n" represents a size which must be a power of 2, "2**n*m" must be a multiple of a power of 2. Variables are shared between dimension expressions, so a spatial shape of `["2**n", "2**n"]` states that the dimensions must be the same powers of 2 given by `n`. A JSON schema for this file can be found at https://github.com/Project-MONAI/MONAI/blob/3049e280f2424962bb2a69261389fcc0b98e0036/monai/apps/mmars/schema/metadata.json @@ -118,6 +135,7 @@ An example JSON metadata file: "image": { "type": "image", "format": "magnitude", + "modality": "MR", "num_channels": 1, "spatial_shape": [160, 160, 160], "dtype": "float32", @@ -129,11 +147,11 @@ An example JSON metadata file: "outputs":{ "pred": { "type": "image", - "format": "segmentation", + "format": "labels", "num_channels": 2, "spatial_shape": [160, 160, 160], "dtype": "float32", - "value_range": [0, 1], + "value_range": [], "is_patch_data": false, "channel_def": {0: "background", 1: "spleen"} } From c5bf12021a68fc02cbb8f0c91bfd73fe6245d4c4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Apr 2022 22:02:35 +0000 Subject: [PATCH 002/183] [pre-commit.ci] pre-commit suggestions (#4072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3fc762db46..9e8fa75854 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: - id: detect-private-key - repo: https://github.com/asottile/pyupgrade - rev: v2.31.0 + rev: v2.31.1 hooks: - id: pyupgrade args: [--py37-plus] From c51d85e7a878ba0a63305fe89e0165f6463d55a2 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 6 Apr 2022 00:47:53 +0800 Subject: [PATCH 003/183] 4060 Add PatchIter and PatchIterd transform (#4061) * [DLMED] change PatchIter to be a transform Signed-off-by: Nic Ma * [DLMED] add dict transform Signed-off-by: Nic Ma * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] store coords in dict Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] restore the doc-string Signed-off-by: Nic Ma * [DLMED] store more info Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/data.rst | 17 ++++-- monai/data/__init__.py | 2 +- monai/data/grid_dataset.py | 84 ++++++++++++++++++++++---- monai/transforms/croppad/dictionary.py | 2 +- tests/test_grid_dataset.py | 46 +++++++++++++- 5 files changed, 129 insertions(+), 22 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 2bdf401c7f..e76eb53f39 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -107,16 +107,23 @@ Patch-based dataset .. autoclass:: GridPatchDataset :members: -`PatchIter` -~~~~~~~~~~~ -.. autoclass:: PatchIter - :members: - `PatchDataset` ~~~~~~~~~~~~~~ .. autoclass:: PatchDataset :members: +`PatchIter` +""""""""""" +.. autoclass:: PatchIter + :members: + :special-members: __call__ + +`PatchIterd` +"""""""""""" +.. autoclass:: PatchIterd + :members: + :special-members: __call__ + Image reader ------------ diff --git a/monai/data/__init__.py b/monai/data/__init__.py index bed194d2f4..53aa3d3f46 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -32,7 +32,7 @@ load_decathlon_properties, ) from .folder_layout import FolderLayout -from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter +from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter, PatchIterd from .image_dataset import ImageDataset from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader, WSIReader from .image_writer import ( diff --git a/monai/data/grid_dataset.py b/monai/data/grid_dataset.py index 9eb84a58c9..33497b5a68 100644 --- a/monai/data/grid_dataset.py +++ b/monai/data/grid_dataset.py @@ -9,21 +9,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Dict, Iterable, Optional, Sequence, Union +from copy import deepcopy +from typing import Callable, Dict, Hashable, Iterable, Mapping, Optional, Sequence, Union +import numpy as np + +from monai.config import KeysCollection from monai.data.dataset import Dataset from monai.data.iterable_dataset import IterableDataset from monai.data.utils import iter_patch from monai.transforms import apply_transform -from monai.utils import NumpyPadMode, deprecated_arg, ensure_tuple, look_up_option +from monai.utils import NumpyPadMode, deprecated_arg, ensure_tuple, first, look_up_option -__all__ = ["PatchDataset", "GridPatchDataset", "PatchIter"] +__all__ = ["PatchDataset", "GridPatchDataset", "PatchIter", "PatchIterd"] class PatchIter: """ - A class to return a patch generator with predefined properties such as `patch_size`. + Return a patch generator with predefined properties such as `patch_size`. Typically used with :py:class:`monai.data.GridPatchDataset`. + """ def __init__( @@ -42,7 +47,8 @@ def __init__( ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} One of the listed string values or a user supplied function. Defaults to ``"wrap"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html - pad_opts: padding options, see numpy.pad + pad_opts: other arguments for the `np.pad` function. + note that `np.pad` treats channel dimension as the first dimension. Note: The `patch_size` is the size of the @@ -52,19 +58,20 @@ def __init__( specified by a `patch_size` of (10, 10, 10). """ - self.patch_size = (None,) + tuple(patch_size) + self.patch_size = (None,) + tuple(patch_size) # expand to have the channel dim self.start_pos = ensure_tuple(start_pos) self.mode: NumpyPadMode = look_up_option(mode, NumpyPadMode) self.pad_opts = pad_opts - def __call__(self, array): + def __call__(self, array: np.ndarray): """ Args: array: the image to generate patches from. + """ yield from iter_patch( array, - patch_size=self.patch_size, # expand to have the channel dim + patch_size=self.patch_size, # type: ignore start_pos=self.start_pos, copy_back=False, mode=self.mode, @@ -72,17 +79,68 @@ def __call__(self, array): ) +class PatchIterd: + """ + Dictionary-based wrapper of :py:class:`monai.data.PatchIter`. + Return a patch generator for dictionary data and the coordinate, Typically used + with :py:class:`monai.data.GridPatchDataset`. + Suppose all the expected fields specified by `keys` have same shape. + + Args: + keys: keys of the corresponding items to iterate patches. + patch_size: size of patches to generate slices for, 0/None selects whole dimension + start_pos: starting position in the array, default is 0 for each dimension + mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, + ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + One of the listed string values or a user supplied function. Defaults to ``"wrap"``. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + pad_opts: other arguments for the `np.pad` function. + note that `np.pad` treats channel dimension as the first dimension. + + """ + + coords_key = "patch_coords" + original_spatial_shape_key = "original_spatial_shape" + start_pos_key = "start_pos" + + def __init__( + self, + keys: KeysCollection, + patch_size: Sequence[int], + start_pos: Sequence[int] = (), + mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, + **pad_opts, + ): + self.keys = ensure_tuple(keys) + self.patch_iter = PatchIter(patch_size=patch_size, start_pos=start_pos, mode=mode, **pad_opts) + + def __call__(self, data: Mapping[Hashable, np.ndarray]): + d = dict(data) + original_spatial_shape = d[first(self.keys)].shape[1:] + + for patch in zip(*[self.patch_iter(d[key]) for key in self.keys]): + coords = patch[0][1] # use the coordinate of the first item + ret = {k: v[0] for k, v in zip(self.keys, patch)} + # fill in the extra keys with unmodified data + for k in set(d.keys()).difference(set(self.keys)): + ret[k] = deepcopy(d[k]) + # also store the `coordinate`, `spatial shape of original image`, `start position` in the dictionary + ret[self.coords_key] = coords + ret[self.original_spatial_shape_key] = original_spatial_shape + ret[self.start_pos_key] = self.patch_iter.start_pos + yield ret, coords + + class GridPatchDataset(IterableDataset): """ - Yields patches from images read from an image dataset. - Typically used with `PatchIter` so that the patches are chosen in a contiguous grid sampling scheme. + Yields patches from data read from an image dataset. + Typically used with `PatchIter` or `PatchIterd` so that the patches are chosen in a contiguous grid sampling scheme. .. code-block:: python import numpy as np - from monai.data import GridPatchDataset, DataLoader, PatchIter - from monai.transforms import RandShiftIntensity + from monai.data import GridPatchDataset, DataLoader, PatchIter, RandShiftIntensity # image-level dataset images = [np.arange(16, dtype=float).reshape(1, 4, 4), @@ -109,7 +167,7 @@ class GridPatchDataset(IterableDataset): data: the data source to read image data from. patch_iter: converts an input image (item from dataset) into a iterable of image patches. `patch_iter(dataset[idx])` must yield a tuple: (patches, coordinates). - see also: :py:class:`monai.data.PatchIter`. + see also: :py:class:`monai.data.PatchIter` or :py:class:`monai.data.PatchIterd`. transform: a callable data transform operates on the patches. with_coordinates: whether to yield the coordinates of each patch, default to `True`. diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 19ebe40b46..79f4040018 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -70,6 +70,7 @@ "RandCropByPosNegLabeld", "ResizeWithPadOrCropd", "BoundingRectd", + "RandCropByLabelClassesd", "SpatialPadD", "SpatialPadDict", "BorderPadD", @@ -98,7 +99,6 @@ "ResizeWithPadOrCropDict", "BoundingRectD", "BoundingRectDict", - "RandCropByLabelClassesd", "RandCropByLabelClassesD", "RandCropByLabelClassesDict", ] diff --git a/tests/test_grid_dataset.py b/tests/test_grid_dataset.py index 9361d82cdf..4d2d0d6948 100644 --- a/tests/test_grid_dataset.py +++ b/tests/test_grid_dataset.py @@ -14,8 +14,8 @@ import numpy as np -from monai.data import DataLoader, GridPatchDataset, PatchIter -from monai.transforms import RandShiftIntensity +from monai.data import DataLoader, GridPatchDataset, PatchIter, PatchIterd +from monai.transforms import RandShiftIntensity, RandShiftIntensityd from monai.utils import set_determinism @@ -76,6 +76,48 @@ def test_loading_array(self): item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5 ) + def test_loading_dict(self): + set_determinism(seed=1234) + # test sequence input data with dict + data = [ + { + "image": np.arange(16, dtype=float).reshape(1, 4, 4), + "label": np.arange(16, dtype=float).reshape(1, 4, 4), + "metadata": "test string", + }, + { + "image": np.arange(16, dtype=float).reshape(1, 4, 4), + "label": np.arange(16, dtype=float).reshape(1, 4, 4), + "metadata": "test string", + }, + ] + # image level + patch_intensity = RandShiftIntensityd(keys="image", offsets=1.0, prob=1.0) + patch_iter = PatchIterd(keys=["image", "label"], patch_size=(2, 2), start_pos=(0, 0)) + ds = GridPatchDataset(data=data, patch_iter=patch_iter, transform=patch_intensity, with_coordinates=True) + # use the grid patch dataset + for item in DataLoader(ds, batch_size=2, shuffle=False, num_workers=0): + np.testing.assert_equal(item[0]["image"].shape, (2, 1, 2, 2)) + np.testing.assert_equal(item[0]["label"].shape, (2, 1, 2, 2)) + self.assertListEqual(item[0]["metadata"], ["test string", "test string"]) + np.testing.assert_allclose( + item[0]["image"], + np.array([[[[1.4965, 2.4965], [5.4965, 6.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), + rtol=1e-4, + ) + np.testing.assert_allclose(item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) + if sys.platform != "win32": + for item in DataLoader(ds, batch_size=2, shuffle=False, num_workers=2): + np.testing.assert_equal(item[0]["image"].shape, (2, 1, 2, 2)) + np.testing.assert_allclose( + item[0]["image"], + np.array([[[[1.2548, 2.2548], [5.2548, 6.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), + rtol=1e-3, + ) + np.testing.assert_allclose( + item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5 + ) + if __name__ == "__main__": unittest.main() From b229e9c96f1a8b4b959d1559887d2ac56fe1da34 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 5 Apr 2022 13:48:36 -0400 Subject: [PATCH 004/183] Fix a logical error in level verification (#4075) * Fix a logical error in level verification Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/image_reader.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 502c6fb93b..ca77178e0b 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -820,7 +820,10 @@ def get_data( """ # Verify inputs if level is None: - level = self._check_level(img, level) + level = self.level + max_level = self._get_max_level(img) + if level > max_level: + raise ValueError(f"The maximum level of this image is {max_level} while level={level} is requested)!") # Extract a region or the entire image region = self._extract_region(img, location=location, size=size, level=level, dtype=dtype) @@ -844,21 +847,19 @@ def get_data( return patches, metadata - def _check_level(self, img, level): - level = self.level + def _get_max_level(self, img_obj): + """ + Return the maximum number of levels in the whole slide image + Args: + img: the whole slide image object - level_count = 0 + """ if self.backend == "openslide": - level_count = img.level_count - elif self.backend == "cucim": - level_count = img.resolutions["level_count"] - elif self.backend == "tifffile": - level_count = len(img.pages) - - if level > level_count - 1: - raise ValueError(f"The maximum level of this image is {level_count - 1} while level={level} is requested)!") - - return level + return img_obj.level_count - 1 + if self.backend == "cucim": + return img_obj.resolutions["level_count"] - 1 + if self.backend == "tifffile": + return len(img_obj.pages) - 1 def _get_image_size(self, img, size, level, location): """ From b5e7fe1dfe0900705345671f4d532b6c18fea29a Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 6 Apr 2022 20:30:49 +0800 Subject: [PATCH 005/183] [DLMED] add allow_missing_reference (#4079) Signed-off-by: Nic Ma --- monai/bundle/reference_resolver.py | 18 ++++++++++++++++-- tests/test_config_parser.py | 25 ++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/monai/bundle/reference_resolver.py b/monai/bundle/reference_resolver.py index f9f73c9c71..c169389e0d 100644 --- a/monai/bundle/reference_resolver.py +++ b/monai/bundle/reference_resolver.py @@ -9,7 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import re +import warnings from typing import Any, Dict, Optional, Sequence, Set from monai.bundle.config_item import ConfigComponent, ConfigExpression, ConfigItem @@ -50,6 +52,8 @@ class ReferenceResolver: ref = ID_REF_KEY # reference prefix # match a reference string, e.g. "@id#key", "@id#key#0", "@_target_#key" id_matcher = re.compile(rf"{ref}(?:\w*)(?:{sep}\w*)*") + # if `allow_missing_reference` and can't find a reference ID, will just raise a warning and don't update the config + allow_missing_reference = False if os.environ.get("MONAI_ALLOW_MISSING_REFERENCE", "0") == "0" else True def __init__(self, items: Optional[Sequence[ConfigItem]] = None): # save the items in a dictionary with the `ConfigItem.id` as key @@ -140,7 +144,12 @@ def _resolve_one_item(self, id: str, waiting_list: Optional[Set[str]] = None, ** try: look_up_option(d, self.items, print_all_options=False) except ValueError as err: - raise ValueError(f"the referring item `@{d}` is not defined in the config content.") from err + msg = f"the referring item `@{d}` is not defined in the config content." + if self.allow_missing_reference: + warnings.warn(msg) + continue + else: + raise ValueError(msg) from err # recursively resolve the reference first self._resolve_one_item(id=d, waiting_list=waiting_list, **kwargs) waiting_list.discard(d) @@ -210,7 +219,12 @@ def update_refs_pattern(cls, value: str, refs: Dict) -> str: for item in result: ref_id = item[len(cls.ref) :] # remove the ref prefix "@" if ref_id not in refs: - raise KeyError(f"can not find expected ID '{ref_id}' in the references.") + msg = f"can not find expected ID '{ref_id}' in the references." + if cls.allow_missing_reference: + warnings.warn(msg) + continue + else: + raise KeyError(msg) if value_is_expr: # replace with local code, will be used in the `evaluate` logic with `locals={"refs": ...}` value = value.replace(item, f"{cls._vars}['{ref_id}']") diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index 9c727c29ac..9ab002f7af 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -16,7 +16,7 @@ from parameterized import parameterized -from monai.bundle.config_parser import ConfigParser +from monai.bundle import ConfigParser, ReferenceResolver from monai.data import DataLoader, Dataset from monai.transforms import Compose, LoadImaged, RandTorchVisiond from monai.utils import min_version, optional_import @@ -86,6 +86,8 @@ def __call__(self, a, b): } ] +TEST_CASE_4 = [{"A": 1, "B": "@A", "C": "@D", "E": "$'test' + '@F'"}] + class TestConfigParser(unittest.TestCase): def test_config_content(self): @@ -154,6 +156,27 @@ def test_macro_replace(self): parser.resolve_macro_and_relative_ids() self.assertEqual(str(parser.get()), str({"A": {"B": 1, "C": 2}, "D": [3, 1, 3, 4]})) + @parameterized.expand([TEST_CASE_4]) + def test_allow_missing_reference(self, config): + default = ReferenceResolver.allow_missing_reference + ReferenceResolver.allow_missing_reference = True + parser = ConfigParser(config=config) + + for id in config: + item = parser.get_parsed_content(id=id) + if id in ("A", "B"): + self.assertEqual(item, 1) + elif id == "C": + self.assertEqual(item, "@D") + elif id == "E": + self.assertEqual(item, "test@F") + + # restore the default value + ReferenceResolver.allow_missing_reference = default + with self.assertRaises(ValueError): + parser.parse() + parser.get_parsed_content(id="E") + if __name__ == "__main__": unittest.main() From e8146e916e2fbe3f58a02f389ef85722ac06e42e Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Wed, 6 Apr 2022 20:36:26 +0000 Subject: [PATCH 006/183] autofixes: Refactor `if` expression and clangformat (#4081) * Refactor `if` expression * update clang format Signed-off-by: Wenqi Li * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: monai-bot --- monai/_extensions/gmm/gmm.cpp | 126 ++-- monai/_extensions/gmm/gmm.h | 31 +- monai/_extensions/gmm/gmm_cpu.cpp | 21 +- monai/_extensions/gmm/gmm_cuda.cu | 707 +++++++++++----------- monai/_extensions/gmm/gmm_cuda_linalg.cuh | 218 +++---- monai/bundle/config_item.py | 2 +- monai/bundle/reference_resolver.py | 2 +- monai/transforms/inverse.py | 2 +- runtests.sh | 2 + tests/test_cachedataset.py | 2 +- tests/test_efficientnet.py | 3 +- tests/test_integration_workflows.py | 6 +- 12 files changed, 556 insertions(+), 566 deletions(-) diff --git a/monai/_extensions/gmm/gmm.cpp b/monai/_extensions/gmm/gmm.cpp index 686fddb721..4087095340 100644 --- a/monai/_extensions/gmm/gmm.cpp +++ b/monai/_extensions/gmm/gmm.cpp @@ -15,75 +15,71 @@ limitations under the License. #include "gmm.h" -py::tuple init() -{ - torch::Tensor gmm_tensor = torch::zeros({GMM_COUNT, GMM_COMPONENT_COUNT}, torch::dtype(torch::kFloat32).device(torch::kCUDA)); - torch::Tensor scratch_tensor = torch::empty({1}, torch::dtype(torch::kFloat32).device(torch::kCUDA)); - return py::make_tuple(gmm_tensor, scratch_tensor); +py::tuple init() { + torch::Tensor gmm_tensor = + torch::zeros({GMM_COUNT, GMM_COMPONENT_COUNT}, torch::dtype(torch::kFloat32).device(torch::kCUDA)); + torch::Tensor scratch_tensor = torch::empty({1}, torch::dtype(torch::kFloat32).device(torch::kCUDA)); + return py::make_tuple(gmm_tensor, scratch_tensor); } -void learn(torch::Tensor gmm_tensor, torch::Tensor scratch_tensor, torch::Tensor input_tensor, torch::Tensor label_tensor) -{ - c10::DeviceType device_type = input_tensor.device().type(); - - unsigned int batch_count = input_tensor.size(0); - unsigned int element_count = input_tensor.stride(1); - - unsigned int scratch_size = batch_count * (element_count + GMM_COMPONENT_COUNT * GMM_COUNT * (element_count / (32 * 32))); - - if (scratch_tensor.size(0) < scratch_size) - { - scratch_tensor.resize_({scratch_size}); - } - - float* gmm = gmm_tensor.data_ptr(); - float* scratch = scratch_tensor.data_ptr(); - float* input = input_tensor.data_ptr(); - int* labels = label_tensor.data_ptr(); - - if(device_type == torch::kCUDA) - { - learn_cuda(input, labels, gmm, scratch, batch_count, element_count); - } - else - { - learn_cpu(input, labels, gmm, scratch, batch_count, element_count); - } +void learn( + torch::Tensor gmm_tensor, + torch::Tensor scratch_tensor, + torch::Tensor input_tensor, + torch::Tensor label_tensor) { + c10::DeviceType device_type = input_tensor.device().type(); + + unsigned int batch_count = input_tensor.size(0); + unsigned int element_count = input_tensor.stride(1); + + unsigned int scratch_size = + batch_count * (element_count + GMM_COMPONENT_COUNT * GMM_COUNT * (element_count / (32 * 32))); + + if (scratch_tensor.size(0) < scratch_size) { + scratch_tensor.resize_({scratch_size}); + } + + float* gmm = gmm_tensor.data_ptr(); + float* scratch = scratch_tensor.data_ptr(); + float* input = input_tensor.data_ptr(); + int* labels = label_tensor.data_ptr(); + + if (device_type == torch::kCUDA) { + learn_cuda(input, labels, gmm, scratch, batch_count, element_count); + } else { + learn_cpu(input, labels, gmm, scratch, batch_count, element_count); + } } -torch::Tensor apply(torch::Tensor gmm_tensor, torch::Tensor input_tensor) -{ - c10::DeviceType device_type = input_tensor.device().type(); - - unsigned int dim = input_tensor.dim(); - unsigned int batch_count = input_tensor.size(0); - unsigned int element_count = input_tensor.stride(1); - - long int* output_size = new long int[dim]; - memcpy(output_size, input_tensor.sizes().data(), dim * sizeof(long int)); - output_size[1] = MIXTURE_COUNT; - torch::Tensor output_tensor = torch::empty(c10::IntArrayRef(output_size, dim), torch::dtype(torch::kFloat32).device(device_type)); - delete output_size; - - const float* gmm = gmm_tensor.data_ptr(); - const float* input = input_tensor.data_ptr(); - float* output = output_tensor.data_ptr(); - - if(device_type == torch::kCUDA) - { - apply_cuda(gmm, input, output, batch_count, element_count); - } - else - { - apply_cpu(gmm, input, output, batch_count, element_count); - } - - return output_tensor; +torch::Tensor apply(torch::Tensor gmm_tensor, torch::Tensor input_tensor) { + c10::DeviceType device_type = input_tensor.device().type(); + + unsigned int dim = input_tensor.dim(); + unsigned int batch_count = input_tensor.size(0); + unsigned int element_count = input_tensor.stride(1); + + long int* output_size = new long int[dim]; + memcpy(output_size, input_tensor.sizes().data(), dim * sizeof(long int)); + output_size[1] = MIXTURE_COUNT; + torch::Tensor output_tensor = + torch::empty(c10::IntArrayRef(output_size, dim), torch::dtype(torch::kFloat32).device(device_type)); + delete output_size; + + const float* gmm = gmm_tensor.data_ptr(); + const float* input = input_tensor.data_ptr(); + float* output = output_tensor.data_ptr(); + + if (device_type == torch::kCUDA) { + apply_cuda(gmm, input, output, batch_count, element_count); + } else { + apply_cpu(gmm, input, output, batch_count, element_count); + } + + return output_tensor; } -PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) -{ - m.def("init", torch::wrap_pybind_function(init)); - m.def("learn", torch::wrap_pybind_function(learn)); - m.def("apply", torch::wrap_pybind_function(apply)); +PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { + m.def("init", torch::wrap_pybind_function(init)); + m.def("learn", torch::wrap_pybind_function(learn)); + m.def("apply", torch::wrap_pybind_function(apply)); } diff --git a/monai/_extensions/gmm/gmm.h b/monai/_extensions/gmm/gmm.h index 6317baa41a..09c0389ae6 100644 --- a/monai/_extensions/gmm/gmm.h +++ b/monai/_extensions/gmm/gmm.h @@ -24,9 +24,30 @@ limitations under the License. #define GMM_COMPONENT_COUNT (MATRIX_COMPONENT_COUNT + 1) #define GMM_COUNT (MIXTURE_COUNT * MIXTURE_SIZE) +void learn_cpu( + const float* input, + const int* labels, + float* gmm, + float* scratch_memory, + unsigned int batch_count, + unsigned int element_count); +void apply_cpu( + const float* gmm, + const float* input, + float* output, + unsigned int batch_count, + unsigned int element_count); -void learn_cpu(const float* input, const int* labels, float* gmm, float* scratch_memory, unsigned int batch_count, unsigned int element_count); -void apply_cpu(const float* gmm, const float* input, float* output, unsigned int batch_count, unsigned int element_count); - -void learn_cuda(const float* input, const int* labels, float* gmm, float* scratch_memory, unsigned int batch_count, unsigned int element_count); -void apply_cuda(const float* gmm, const float* input, float* output, unsigned int batch_count, unsigned int element_count); +void learn_cuda( + const float* input, + const int* labels, + float* gmm, + float* scratch_memory, + unsigned int batch_count, + unsigned int element_count); +void apply_cuda( + const float* gmm, + const float* input, + float* output, + unsigned int batch_count, + unsigned int element_count); diff --git a/monai/_extensions/gmm/gmm_cpu.cpp b/monai/_extensions/gmm/gmm_cpu.cpp index c9b55490eb..d7eedc07c8 100644 --- a/monai/_extensions/gmm/gmm_cpu.cpp +++ b/monai/_extensions/gmm/gmm_cpu.cpp @@ -15,12 +15,21 @@ limitations under the License. #include "gmm.h" -void learn_cpu(const float* input, const int* labels, float* gmm, float* scratch_memory, unsigned int batch_count, unsigned int element_count) -{ - throw std::invalid_argument("GMM received a cpu tensor but is not yet implemented for the cpu"); +void learn_cpu( + const float* input, + const int* labels, + float* gmm, + float* scratch_memory, + unsigned int batch_count, + unsigned int element_count) { + throw std::invalid_argument("GMM received a cpu tensor but is not yet implemented for the cpu"); } -void apply_cpu(const float* gmm, const float* input, float* output, unsigned int batch_count, unsigned int element_count) -{ - throw std::invalid_argument("GMM received a cpu tensor but is not yet implemented for the cpu"); +void apply_cpu( + const float* gmm, + const float* input, + float* output, + unsigned int batch_count, + unsigned int element_count) { + throw std::invalid_argument("GMM received a cpu tensor but is not yet implemented for the cpu"); } diff --git a/monai/_extensions/gmm/gmm_cuda.cu b/monai/_extensions/gmm/gmm_cuda.cu index 765ffe5b1c..2cf70a9920 100644 --- a/monai/_extensions/gmm/gmm_cuda.cu +++ b/monai/_extensions/gmm/gmm_cuda.cu @@ -20,434 +20,408 @@ limitations under the License. #define EPSILON 1e-5 #define BLOCK_SIZE 32 -#define TILE(SIZE, STRIDE) ((((SIZE) - 1)/(STRIDE)) + 1) +#define TILE(SIZE, STRIDE) ((((SIZE)-1) / (STRIDE)) + 1) -template -__global__ void CovarianceReductionKernel(int gaussian_index, const float* g_image, const int* g_alpha, float* g_matrices, int element_count) -{ - constexpr int block_size = warp_count * 32; +template +__global__ void CovarianceReductionKernel( + int gaussian_index, + const float* g_image, + const int* g_alpha, + float* g_matrices, + int element_count) { + constexpr int block_size = warp_count * 32; - __shared__ float s_matrix_component[warp_count]; + __shared__ float s_matrix_component[warp_count]; - int batch_index = blockIdx.z; + int batch_index = blockIdx.z; - const float* g_batch_image = g_image + batch_index * element_count * CHANNEL_COUNT; - const int* g_batch_alpha = g_alpha + batch_index * element_count; - float* g_batch_matrices = g_matrices + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT * gridDim.x; + const float* g_batch_image = g_image + batch_index * element_count * CHANNEL_COUNT; + const int* g_batch_alpha = g_alpha + batch_index * element_count; + float* g_batch_matrices = g_matrices + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT * gridDim.x; - int local_index = threadIdx.x; - int block_index = blockIdx.x; - int warp_index = local_index >> 5; - int lane_index = local_index & 31; - int global_index = local_index + block_index * block_size * load_count; - int matrix_offset = (gaussian_index * gridDim.x + block_index) * GMM_COMPONENT_COUNT; + int local_index = threadIdx.x; + int block_index = blockIdx.x; + int warp_index = local_index >> 5; + int lane_index = local_index & 31; + int global_index = local_index + block_index * block_size * load_count; + int matrix_offset = (gaussian_index * gridDim.x + block_index) * GMM_COMPONENT_COUNT; - float matrix[MATRIX_COMPONENT_COUNT]; + float matrix[MATRIX_COMPONENT_COUNT]; - for (int i = 0; i < MATRIX_COMPONENT_COUNT; i++) - { - matrix[i] = 0; - } + for (int i = 0; i < MATRIX_COMPONENT_COUNT; i++) { + matrix[i] = 0; + } + + for (int load = 0; load < load_count; load++) { + global_index += load * block_size; + + if (global_index < element_count) { + int my_alpha = g_batch_alpha[global_index]; + + if (my_alpha != -1) { + if (gaussian_index == (my_alpha & 15) + (my_alpha >> 4) * MIXTURE_COUNT) { + float feature[CHANNEL_COUNT + 1]; - for (int load = 0; load < load_count; load++) - { - global_index += load * block_size; - - if (global_index < element_count) - { - int my_alpha = g_batch_alpha[global_index]; - - if (my_alpha != -1) - { - if (gaussian_index == (my_alpha & 15) + (my_alpha >> 4) * MIXTURE_COUNT) - { - float feature[CHANNEL_COUNT + 1]; - - feature[0] = 1; - - for (int i = 0; i < CHANNEL_COUNT; i++) - { - feature[i + 1] = g_batch_image[global_index + i * element_count]; - } - - for (int index = 0, i = 0; i < CHANNEL_COUNT + 1; i++) - { - for (int j = i; j < CHANNEL_COUNT + 1; j++, index++) - { - matrix[index] += feature[i] * feature[j]; - } - } - } + feature[0] = 1; + + for (int i = 0; i < CHANNEL_COUNT; i++) { + feature[i + 1] = g_batch_image[global_index + i * element_count]; + } + + for (int index = 0, i = 0; i < CHANNEL_COUNT + 1; i++) { + for (int j = i; j < CHANNEL_COUNT + 1; j++, index++) { + matrix[index] += feature[i] * feature[j]; } + } } + } } + } - __syncthreads(); + __syncthreads(); - for (int i = 0; i < MATRIX_COMPONENT_COUNT; i++) - { - float matrix_component = matrix[i]; + for (int i = 0; i < MATRIX_COMPONENT_COUNT; i++) { + float matrix_component = matrix[i]; - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); - - if (lane_index == 0) - { - s_matrix_component[warp_index] = matrix_component; - } - - __syncthreads(); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); - if (warp_index == 0) - { - matrix_component = s_matrix_component[lane_index]; + if (lane_index == 0) { + s_matrix_component[warp_index] = matrix_component; + } - if (warp_count >= 32) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); } - if (warp_count >= 16) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); } - if (warp_count >= 8) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); } - if (warp_count >= 4) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); } - if (warp_count >= 2) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); } + __syncthreads(); - if (lane_index == 0) - { - g_batch_matrices[matrix_offset + i] = matrix_component; - } - } + if (warp_index == 0) { + matrix_component = s_matrix_component[lane_index]; - __syncthreads(); + if (warp_count >= 32) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); + } + if (warp_count >= 16) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); + } + if (warp_count >= 8) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); + } + if (warp_count >= 4) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); + } + if (warp_count >= 2) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); + } + + if (lane_index == 0) { + g_batch_matrices[matrix_offset + i] = matrix_component; + } } + + __syncthreads(); + } } -template -__global__ void CovarianceFinalizationKernel(const float* g_matrices, float* g_gmm, int matrix_count) -{ - constexpr int block_size = warp_count * 32; +template +__global__ void CovarianceFinalizationKernel(const float* g_matrices, float* g_gmm, int matrix_count) { + constexpr int block_size = warp_count * 32; - __shared__ float s_matrix_component[warp_count]; - __shared__ float s_gmm[GMM_COMPONENT_COUNT]; + __shared__ float s_matrix_component[warp_count]; + __shared__ float s_gmm[GMM_COMPONENT_COUNT]; - int batch_index = blockIdx.z; + int batch_index = blockIdx.z; - const float* g_batch_matrices = g_matrices + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT * matrix_count; - float* g_batch_gmm = g_gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; + const float* g_batch_matrices = g_matrices + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT * matrix_count; + float* g_batch_gmm = g_gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; - int local_index = threadIdx.x; - int warp_index = local_index >> 5; - int lane_index = local_index & 31; - int gmm_index = blockIdx.x; - int matrix_offset = gmm_index * matrix_count; + int local_index = threadIdx.x; + int warp_index = local_index >> 5; + int lane_index = local_index & 31; + int gmm_index = blockIdx.x; + int matrix_offset = gmm_index * matrix_count; - int load_count = TILE(matrix_count, block_size); + int load_count = TILE(matrix_count, block_size); - float norm_factor = 1.0f; + float norm_factor = 1.0f; - for (int index = 0, i = 0; i < CHANNEL_COUNT + 1; i++) - { - for (int j = i; j < CHANNEL_COUNT + 1; j++, index++) - { - float matrix_component = 0.0f; + for (int index = 0, i = 0; i < CHANNEL_COUNT + 1; i++) { + for (int j = i; j < CHANNEL_COUNT + 1; j++, index++) { + float matrix_component = 0.0f; - for(int load = 0; load < load_count; load++) - { - int matrix_index = local_index + load * block_size; + for (int load = 0; load < load_count; load++) { + int matrix_index = local_index + load * block_size; - if(matrix_index < matrix_count) - { - matrix_component += g_batch_matrices[(matrix_offset + matrix_index) * GMM_COMPONENT_COUNT + index]; - } - } + if (matrix_index < matrix_count) { + matrix_component += g_batch_matrices[(matrix_offset + matrix_index) * GMM_COMPONENT_COUNT + index]; + } + } - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); - matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); - if (lane_index == 0) - { - s_matrix_component[warp_index] = matrix_component; - } + if (lane_index == 0) { + s_matrix_component[warp_index] = matrix_component; + } - __syncthreads(); + __syncthreads(); - if (warp_index == 0) - { - matrix_component = s_matrix_component[lane_index]; + if (warp_index == 0) { + matrix_component = s_matrix_component[lane_index]; - if (warp_count >= 32) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); } - if (warp_count >= 16) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); } - if (warp_count >= 8) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); } - if (warp_count >= 4) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); } - if (warp_count >= 2) { matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); } - - if (lane_index == 0) - { - float constant = i == 0 ? 0.0f : s_gmm[i] * s_gmm[j]; + if (warp_count >= 32) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 16); + } + if (warp_count >= 16) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 8); + } + if (warp_count >= 8) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 4); + } + if (warp_count >= 4) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 2); + } + if (warp_count >= 2) { + matrix_component += __shfl_down_sync(0xffffffff, matrix_component, 1); + } - if (i != 0 && i == j) - { - constant -= EPSILON; - } + if (lane_index == 0) { + float constant = i == 0 ? 0.0f : s_gmm[i] * s_gmm[j]; - s_gmm[index] = norm_factor * matrix_component - constant; + if (i != 0 && i == j) { + constant -= EPSILON; + } - if (index == 0 && matrix_component > 0) - { - norm_factor = 1.0f / matrix_component; - } - } - } + s_gmm[index] = norm_factor * matrix_component - constant; - __syncthreads(); + if (index == 0 && matrix_component > 0) { + norm_factor = 1.0f / matrix_component; + } } + } + + __syncthreads(); } + } - float* matrix = s_gmm + (CHANNEL_COUNT + 1); - float* det_ptr = s_gmm + MATRIX_COMPONENT_COUNT; + float* matrix = s_gmm + (CHANNEL_COUNT + 1); + float* det_ptr = s_gmm + MATRIX_COMPONENT_COUNT; - if (local_index == 0) - { - float square_mat[CHANNEL_COUNT][CHANNEL_COUNT]; - float cholesky_mat[CHANNEL_COUNT][CHANNEL_COUNT]; + if (local_index == 0) { + float square_mat[CHANNEL_COUNT][CHANNEL_COUNT]; + float cholesky_mat[CHANNEL_COUNT][CHANNEL_COUNT]; - for(int i = 0; i < CHANNEL_COUNT; i++) - { - for(int j = 0; j < CHANNEL_COUNT; j++) - { - square_mat[i][j] = 0.0f; - cholesky_mat[i][j] = 0.0f; - } - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + for (int j = 0; j < CHANNEL_COUNT; j++) { + square_mat[i][j] = 0.0f; + cholesky_mat[i][j] = 0.0f; + } + } - to_square(matrix, square_mat); - cholesky(square_mat, cholesky_mat); + to_square(matrix, square_mat); + cholesky(square_mat, cholesky_mat); - *det_ptr = chol_det(cholesky_mat); + *det_ptr = chol_det(cholesky_mat); - if (invert_matrix) - { - chol_inv(cholesky_mat, square_mat); - to_triangle(square_mat, matrix); - } + if (invert_matrix) { + chol_inv(cholesky_mat, square_mat); + to_triangle(square_mat, matrix); } + } - if (local_index < GMM_COMPONENT_COUNT) - { - g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT + local_index] = s_gmm[local_index]; - } + if (local_index < GMM_COMPONENT_COUNT) { + g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT + local_index] = s_gmm[local_index]; + } } -struct GMMSplit_t -{ - int idx; - float threshold; - float eigenvector[CHANNEL_COUNT]; +struct GMMSplit_t { + int idx; + float threshold; + float eigenvector[CHANNEL_COUNT]; }; // 1 Block, 32xMIXTURE_COUNT -__global__ void GMMFindSplit(GMMSplit_t *gmmSplit, int gmmK, float *gmm) -{ - int batch_index = blockIdx.z; +__global__ void GMMFindSplit(GMMSplit_t* gmmSplit, int gmmK, float* gmm) { + int batch_index = blockIdx.z; - float* g_batch_gmm = gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; - GMMSplit_t* g_batch_gmmSplit = gmmSplit + batch_index * MIXTURE_COUNT; + float* g_batch_gmm = gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; + GMMSplit_t* g_batch_gmmSplit = gmmSplit + batch_index * MIXTURE_COUNT; - int gmm_idx = threadIdx.x * MIXTURE_COUNT + threadIdx.y; + int gmm_idx = threadIdx.x * MIXTURE_COUNT + threadIdx.y; - float eigenvalue = 0; - float eigenvector[CHANNEL_COUNT]; + float eigenvalue = 0; + float eigenvector[CHANNEL_COUNT]; - if (threadIdx.x < gmmK) - { - float* matrix = g_batch_gmm + gmm_idx * GMM_COMPONENT_COUNT + (CHANNEL_COUNT + 1); - largest_eigenpair(matrix, eigenvector, &eigenvalue); - } - - float max_value = eigenvalue; + if (threadIdx.x < gmmK) { + float* matrix = g_batch_gmm + gmm_idx * GMM_COMPONENT_COUNT + (CHANNEL_COUNT + 1); + largest_eigenpair(matrix, eigenvector, &eigenvalue); + } - max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 16)); - max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 8)); - max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 4)); - max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 2)); - max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 1)); + float max_value = eigenvalue; - if (max_value == eigenvalue) - { - GMMSplit_t split; + max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 16)); + max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 8)); + max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 4)); + max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 2)); + max_value = max(max_value, __shfl_xor_sync(0xffffffff, max_value, 1)); - float* average_feature = gmm + gmm_idx * GMM_COMPONENT_COUNT + 1; + if (max_value == eigenvalue) { + GMMSplit_t split; - split.idx = threadIdx.x; - split.threshold = scalar_prod(average_feature, eigenvector); + float* average_feature = gmm + gmm_idx * GMM_COMPONENT_COUNT + 1; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - split.eigenvector[i] = eigenvector[i]; - } + split.idx = threadIdx.x; + split.threshold = scalar_prod(average_feature, eigenvector); - g_batch_gmmSplit[threadIdx.y] = split; + for (int i = 0; i < CHANNEL_COUNT; i++) { + split.eigenvector[i] = eigenvector[i]; } + + g_batch_gmmSplit[threadIdx.y] = split; + } } #define DO_SPLIT_DEGENERACY 4 -__global__ void GMMDoSplit(const GMMSplit_t *gmmSplit, int k, const float *image, int *alpha, int element_count) -{ - __shared__ GMMSplit_t s_gmmSplit[MIXTURE_COUNT]; +__global__ void GMMDoSplit(const GMMSplit_t* gmmSplit, int k, const float* image, int* alpha, int element_count) { + __shared__ GMMSplit_t s_gmmSplit[MIXTURE_COUNT]; - int batch_index = blockIdx.z; + int batch_index = blockIdx.z; - const GMMSplit_t* g_batch_gmmSplit = gmmSplit + batch_index * MIXTURE_COUNT; - const float* g_batch_image = image + batch_index * element_count * CHANNEL_COUNT; - int* g_batch_alpha = alpha + batch_index * element_count; + const GMMSplit_t* g_batch_gmmSplit = gmmSplit + batch_index * MIXTURE_COUNT; + const float* g_batch_image = image + batch_index * element_count * CHANNEL_COUNT; + int* g_batch_alpha = alpha + batch_index * element_count; - int *s_linear = (int *) s_gmmSplit; - int *g_linear = (int *) g_batch_gmmSplit; + int* s_linear = (int*)s_gmmSplit; + int* g_linear = (int*)g_batch_gmmSplit; - if (threadIdx.x < MIXTURE_COUNT * sizeof(GMMSplit_t)) - { - s_linear[threadIdx.x] = g_linear[threadIdx.x]; - } + if (threadIdx.x < MIXTURE_COUNT * sizeof(GMMSplit_t)) { + s_linear[threadIdx.x] = g_linear[threadIdx.x]; + } - __syncthreads(); + __syncthreads(); - int index = threadIdx.x + blockIdx.x * BLOCK_SIZE * DO_SPLIT_DEGENERACY; + int index = threadIdx.x + blockIdx.x * BLOCK_SIZE * DO_SPLIT_DEGENERACY; - for (int i = 0; i < DO_SPLIT_DEGENERACY; i++) - { - index += BLOCK_SIZE; + for (int i = 0; i < DO_SPLIT_DEGENERACY; i++) { + index += BLOCK_SIZE; - if (index < element_count) - { - int my_alpha = g_batch_alpha[index]; + if (index < element_count) { + int my_alpha = g_batch_alpha[index]; - if(my_alpha != -1) - { - int select = my_alpha & 15; - int gmm_idx = my_alpha >> 4; + if (my_alpha != -1) { + int select = my_alpha & 15; + int gmm_idx = my_alpha >> 4; - if (gmm_idx == s_gmmSplit[select].idx) - { - // in the split cluster now - float feature[CHANNEL_COUNT]; + if (gmm_idx == s_gmmSplit[select].idx) { + // in the split cluster now + float feature[CHANNEL_COUNT]; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - feature[i] = g_batch_image[index + i * element_count]; - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + feature[i] = g_batch_image[index + i * element_count]; + } - float value = scalar_prod(s_gmmSplit[select].eigenvector, feature); + float value = scalar_prod(s_gmmSplit[select].eigenvector, feature); - if (value > s_gmmSplit[select].threshold) - { - // assign pixel to new cluster - g_batch_alpha[index] = k + select; - } - } - } + if (value > s_gmmSplit[select].threshold) { + // assign pixel to new cluster + g_batch_alpha[index] = k + select; + } } + } } + } } // Single block, 32xMIXTURE_COUNT -__global__ void GMMcommonTerm(float *g_gmm) -{ - int batch_index = blockIdx.z; +__global__ void GMMcommonTerm(float* g_gmm) { + int batch_index = blockIdx.z; - float* g_batch_gmm = g_gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; + float* g_batch_gmm = g_gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; - int gmm_index = (threadIdx.x * MIXTURE_COUNT) + threadIdx.y; + int gmm_index = (threadIdx.x * MIXTURE_COUNT) + threadIdx.y; - float gmm_n = threadIdx.x < MIXTURE_SIZE ? g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT] : 0.0f; + float gmm_n = threadIdx.x < MIXTURE_SIZE ? g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT] : 0.0f; - float sum = gmm_n; + float sum = gmm_n; - sum += __shfl_xor_sync(0xffffffff, sum, 1); - sum += __shfl_xor_sync(0xffffffff, sum, 2); - sum += __shfl_xor_sync(0xffffffff, sum, 4); - sum += __shfl_xor_sync(0xffffffff, sum, 8); - sum += __shfl_xor_sync(0xffffffff, sum, 16); + sum += __shfl_xor_sync(0xffffffff, sum, 1); + sum += __shfl_xor_sync(0xffffffff, sum, 2); + sum += __shfl_xor_sync(0xffffffff, sum, 4); + sum += __shfl_xor_sync(0xffffffff, sum, 8); + sum += __shfl_xor_sync(0xffffffff, sum, 16); - if (threadIdx.x < MIXTURE_SIZE) - { - float det = g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT + MATRIX_COMPONENT_COUNT] + EPSILON; - float commonTerm = det > 0.0f ? gmm_n / (sqrtf(det) * sum) : gmm_n / sum; + if (threadIdx.x < MIXTURE_SIZE) { + float det = g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT + MATRIX_COMPONENT_COUNT] + EPSILON; + float commonTerm = det > 0.0f ? gmm_n / (sqrtf(det) * sum) : gmm_n / sum; - g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT + MATRIX_COMPONENT_COUNT] = commonTerm; - } + g_batch_gmm[gmm_index * GMM_COMPONENT_COUNT + MATRIX_COMPONENT_COUNT] = commonTerm; + } } -__device__ float GMMTerm(float* feature, const float *gmm) -{ - const float* average_feature = gmm + 1; - const float* matrix = gmm + CHANNEL_COUNT + 1; +__device__ float GMMTerm(float* feature, const float* gmm) { + const float* average_feature = gmm + 1; + const float* matrix = gmm + CHANNEL_COUNT + 1; - float diff[CHANNEL_COUNT]; + float diff[CHANNEL_COUNT]; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - diff[i] = feature[i] - average_feature[i]; - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + diff[i] = feature[i] - average_feature[i]; + } - float value = 0.0f; + float value = 0.0f; - for (int index = 0, i = 0; i < CHANNEL_COUNT; i++) - { - for (int j = i; j < CHANNEL_COUNT; j++, index++) - { - float term = diff[i] * diff[j] * matrix[index]; + for (int index = 0, i = 0; i < CHANNEL_COUNT; i++) { + for (int j = i; j < CHANNEL_COUNT; j++, index++) { + float term = diff[i] * diff[j] * matrix[index]; - value += i == j ? term : 2 * term; - } + value += i == j ? term : 2 * term; } + } - return gmm[MATRIX_COMPONENT_COUNT] * expf(-0.5 * value); + return gmm[MATRIX_COMPONENT_COUNT] * expf(-0.5 * value); } -__global__ void GMMDataTermKernel(const float *image, const float *gmm, float* output, int element_count) -{ - int batch_index = blockIdx.z; +__global__ void GMMDataTermKernel(const float* image, const float* gmm, float* output, int element_count) { + int batch_index = blockIdx.z; - const float* g_batch_image = image + batch_index * element_count * CHANNEL_COUNT; - const float* g_batch_gmm = gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; - float* g_batch_output = output + batch_index * element_count * MIXTURE_COUNT; + const float* g_batch_image = image + batch_index * element_count * CHANNEL_COUNT; + const float* g_batch_gmm = gmm + batch_index * GMM_COUNT * GMM_COMPONENT_COUNT; + float* g_batch_output = output + batch_index * element_count * MIXTURE_COUNT; - int index = blockIdx.x * blockDim.x + threadIdx.x; + int index = blockIdx.x * blockDim.x + threadIdx.x; - if (index >= element_count) return; + if (index >= element_count) + return; - float feature[CHANNEL_COUNT]; + float feature[CHANNEL_COUNT]; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - feature[i] = g_batch_image[index + i * element_count]; - } - - float weights[MIXTURE_COUNT]; - float weight_total = 0.0f; + for (int i = 0; i < CHANNEL_COUNT; i++) { + feature[i] = g_batch_image[index + i * element_count]; + } - for(int i = 0; i < MIXTURE_COUNT; i++) - { - float mixture_weight = 0.0f; + float weights[MIXTURE_COUNT]; + float weight_total = 0.0f; - for(int j = 0; j < MIXTURE_SIZE; j++) - { - mixture_weight += GMMTerm(feature, &g_batch_gmm[(MIXTURE_COUNT * j + i) * GMM_COMPONENT_COUNT]); - } + for (int i = 0; i < MIXTURE_COUNT; i++) { + float mixture_weight = 0.0f; - weights[i] = mixture_weight; - weight_total += mixture_weight; + for (int j = 0; j < MIXTURE_SIZE; j++) { + mixture_weight += GMMTerm(feature, &g_batch_gmm[(MIXTURE_COUNT * j + i) * GMM_COMPONENT_COUNT]); } - for(int i = 0; i < MIXTURE_COUNT; i++) - { - // protecting against pixels with 0 in all mixtures - float final_weight = weight_total > 0.0f ? weights[i] / weight_total : 0.0f; - g_batch_output[index + i * element_count] = final_weight; - } + weights[i] = mixture_weight; + weight_total += mixture_weight; + } + + for (int i = 0; i < MIXTURE_COUNT; i++) { + // protecting against pixels with 0 in all mixtures + float final_weight = weight_total > 0.0f ? weights[i] / weight_total : 0.0f; + g_batch_output[index + i * element_count] = final_weight; + } } #define THREADS 512 @@ -455,67 +429,90 @@ __global__ void GMMDataTermKernel(const float *image, const float *gmm, float* o #define BLOCK (WARPS << 5) #define LOAD 4 -void GMMInitialize(const float *image, int *alpha, float *gmm, float *scratch_mem, unsigned int batch_count, unsigned int element_count) -{ - unsigned int block_count = TILE(element_count, BLOCK * LOAD); +void GMMInitialize( + const float* image, + int* alpha, + float* gmm, + float* scratch_mem, + unsigned int batch_count, + unsigned int element_count) { + unsigned int block_count = TILE(element_count, BLOCK * LOAD); - float* block_gmm_scratch = scratch_mem; - GMMSplit_t* gmm_split_scratch = (GMMSplit_t*) scratch_mem; + float* block_gmm_scratch = scratch_mem; + GMMSplit_t* gmm_split_scratch = (GMMSplit_t*)scratch_mem; - int gmm_N = MIXTURE_COUNT * MIXTURE_SIZE; + int gmm_N = MIXTURE_COUNT * MIXTURE_SIZE; - for (unsigned int k = MIXTURE_COUNT; k < gmm_N; k+=MIXTURE_COUNT) - { - for (unsigned int i = 0; i < k; ++i) - { - CovarianceReductionKernel<<<{block_count, 1, batch_count}, BLOCK>>>(i, image, alpha, block_gmm_scratch, element_count); - } + for (unsigned int k = MIXTURE_COUNT; k < gmm_N; k += MIXTURE_COUNT) { + for (unsigned int i = 0; i < k; ++i) { + CovarianceReductionKernel + <<<{block_count, 1, batch_count}, BLOCK>>>(i, image, alpha, block_gmm_scratch, element_count); + } - CovarianceFinalizationKernel<<<{k, 1, batch_count}, BLOCK>>>(block_gmm_scratch, gmm, block_count); + CovarianceFinalizationKernel<<<{k, 1, batch_count}, BLOCK>>>(block_gmm_scratch, gmm, block_count); - GMMFindSplit<<<{1, 1, batch_count}, dim3(BLOCK_SIZE, MIXTURE_COUNT)>>>(gmm_split_scratch, k / MIXTURE_COUNT, gmm); - GMMDoSplit<<<{TILE(element_count, BLOCK_SIZE * DO_SPLIT_DEGENERACY), 1, batch_count}, BLOCK_SIZE>>>(gmm_split_scratch, (k / MIXTURE_COUNT) << 4, image, alpha, element_count); - } + GMMFindSplit<<<{1, 1, batch_count}, dim3(BLOCK_SIZE, MIXTURE_COUNT)>>>(gmm_split_scratch, k / MIXTURE_COUNT, gmm); + GMMDoSplit<<<{TILE(element_count, BLOCK_SIZE * DO_SPLIT_DEGENERACY), 1, batch_count}, BLOCK_SIZE>>>( + gmm_split_scratch, (k / MIXTURE_COUNT) << 4, image, alpha, element_count); + } } -void GMMUpdate(const float *image, int *alpha, float *gmm, float *scratch_mem, unsigned int batch_count, unsigned int element_count) -{ - unsigned int block_count = TILE(element_count, BLOCK * LOAD); +void GMMUpdate( + const float* image, + int* alpha, + float* gmm, + float* scratch_mem, + unsigned int batch_count, + unsigned int element_count) { + unsigned int block_count = TILE(element_count, BLOCK * LOAD); - float* block_gmm_scratch = scratch_mem; + float* block_gmm_scratch = scratch_mem; - unsigned int gmm_N = MIXTURE_COUNT * MIXTURE_SIZE; + unsigned int gmm_N = MIXTURE_COUNT * MIXTURE_SIZE; - for (unsigned int i = 0; i < gmm_N; ++i) - { - CovarianceReductionKernel<<<{block_count, 1, batch_count}, BLOCK>>>(i, image, alpha, block_gmm_scratch, element_count); - } + for (unsigned int i = 0; i < gmm_N; ++i) { + CovarianceReductionKernel + <<<{block_count, 1, batch_count}, BLOCK>>>(i, image, alpha, block_gmm_scratch, element_count); + } - CovarianceFinalizationKernel<<<{gmm_N, 1, batch_count}, BLOCK>>>(block_gmm_scratch, gmm, block_count); + CovarianceFinalizationKernel<<<{gmm_N, 1, batch_count}, BLOCK>>>(block_gmm_scratch, gmm, block_count); - GMMcommonTerm<<<{1, 1, batch_count}, dim3(BLOCK_SIZE, MIXTURE_COUNT)>>>(gmm); + GMMcommonTerm<<<{1, 1, batch_count}, dim3(BLOCK_SIZE, MIXTURE_COUNT)>>>(gmm); } -void GMMDataTerm(const float *image, const float *gmm, float* output, unsigned int batch_count, unsigned int element_count) -{ - dim3 block(BLOCK_SIZE, 1); - dim3 grid(TILE(element_count, BLOCK_SIZE), 1, batch_count); +void GMMDataTerm( + const float* image, + const float* gmm, + float* output, + unsigned int batch_count, + unsigned int element_count) { + dim3 block(BLOCK_SIZE, 1); + dim3 grid(TILE(element_count, BLOCK_SIZE), 1, batch_count); - GMMDataTermKernel<<>>(image, gmm, output, element_count); + GMMDataTermKernel<<>>(image, gmm, output, element_count); } -void learn_cuda(const float* input, const int* labels, float* gmm, float* scratch_memory, unsigned int batch_count, unsigned int element_count) -{ - int* alpha = (int*)scratch_memory; - float* scratch_mem = scratch_memory + batch_count * element_count; +void learn_cuda( + const float* input, + const int* labels, + float* gmm, + float* scratch_memory, + unsigned int batch_count, + unsigned int element_count) { + int* alpha = (int*)scratch_memory; + float* scratch_mem = scratch_memory + batch_count * element_count; - cudaMemcpyAsync(alpha, labels, batch_count * element_count * sizeof(int), cudaMemcpyDeviceToDevice); + cudaMemcpyAsync(alpha, labels, batch_count * element_count * sizeof(int), cudaMemcpyDeviceToDevice); - GMMInitialize(input, alpha, gmm, scratch_mem, batch_count, element_count); - GMMUpdate(input, alpha, gmm, scratch_mem, batch_count, element_count); + GMMInitialize(input, alpha, gmm, scratch_mem, batch_count, element_count); + GMMUpdate(input, alpha, gmm, scratch_mem, batch_count, element_count); } -void apply_cuda(const float* gmm, const float* input, float* output, unsigned int batch_count, unsigned int element_count) -{ - GMMDataTerm(input, gmm, output, batch_count, element_count); +void apply_cuda( + const float* gmm, + const float* input, + float* output, + unsigned int batch_count, + unsigned int element_count) { + GMMDataTerm(input, gmm, output, batch_count, element_count); } diff --git a/monai/_extensions/gmm/gmm_cuda_linalg.cuh b/monai/_extensions/gmm/gmm_cuda_linalg.cuh index 9d54d80d3b..56c7c7ccdc 100644 --- a/monai/_extensions/gmm/gmm_cuda_linalg.cuh +++ b/monai/_extensions/gmm/gmm_cuda_linalg.cuh @@ -11,170 +11,134 @@ See the License for the specific language governing permissions and limitations under the License. */ -__device__ void to_square(float in[SUB_MATRIX_COMPONENT_COUNT], float out[CHANNEL_COUNT][CHANNEL_COUNT]) -{ - for (int index = 0, i = 0; i < CHANNEL_COUNT; i++) - { - for (int j = i; j < CHANNEL_COUNT; j++, index++) - { - out[i][j] = in[index]; - out[j][i] = in[index]; - } +__device__ void to_square(float in[SUB_MATRIX_COMPONENT_COUNT], float out[CHANNEL_COUNT][CHANNEL_COUNT]) { + for (int index = 0, i = 0; i < CHANNEL_COUNT; i++) { + for (int j = i; j < CHANNEL_COUNT; j++, index++) { + out[i][j] = in[index]; + out[j][i] = in[index]; } + } } -__device__ void to_triangle(float in[CHANNEL_COUNT][CHANNEL_COUNT], float out[SUB_MATRIX_COMPONENT_COUNT]) -{ - for (int index = 0, i = 0; i < CHANNEL_COUNT; i++) - { - for (int j = i; j < CHANNEL_COUNT; j++, index++) - { - out[index] = in[j][i]; - } +__device__ void to_triangle(float in[CHANNEL_COUNT][CHANNEL_COUNT], float out[SUB_MATRIX_COMPONENT_COUNT]) { + for (int index = 0, i = 0; i < CHANNEL_COUNT; i++) { + for (int j = i; j < CHANNEL_COUNT; j++, index++) { + out[index] = in[j][i]; } + } } -__device__ void cholesky(float in[CHANNEL_COUNT][CHANNEL_COUNT], float out[CHANNEL_COUNT][CHANNEL_COUNT]) -{ - for (int i = 0; i < CHANNEL_COUNT; i++) - { - for (int j = 0; j < i+1; j++) - { - float sum = 0.0f; - - for (int k = 0; k < j; k++) - { - sum += out[i][k] * out[j][k]; - } - - if (i == j) - { - out[i][j] = sqrtf(in[i][i] - sum); - } - else - { - out[i][j] = (in[i][j] - sum) / out[j][j]; - } - } +__device__ void cholesky(float in[CHANNEL_COUNT][CHANNEL_COUNT], float out[CHANNEL_COUNT][CHANNEL_COUNT]) { + for (int i = 0; i < CHANNEL_COUNT; i++) { + for (int j = 0; j < i + 1; j++) { + float sum = 0.0f; + + for (int k = 0; k < j; k++) { + sum += out[i][k] * out[j][k]; + } + + if (i == j) { + out[i][j] = sqrtf(in[i][i] - sum); + } else { + out[i][j] = (in[i][j] - sum) / out[j][j]; + } } + } } -__device__ float chol_det(float in[CHANNEL_COUNT][CHANNEL_COUNT]) -{ - float det = 1.0f; +__device__ float chol_det(float in[CHANNEL_COUNT][CHANNEL_COUNT]) { + float det = 1.0f; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - det *= in[i][i]; - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + det *= in[i][i]; + } - return det * det; + return det * det; } -__device__ void chol_inv(float in[CHANNEL_COUNT][CHANNEL_COUNT], float out[CHANNEL_COUNT][CHANNEL_COUNT]) -{ - // Invert cholesky matrix - for (int i = 0; i < CHANNEL_COUNT; i++) - { - in[i][i] = 1.0f / (in[i][i] + 0.0001f); +__device__ void chol_inv(float in[CHANNEL_COUNT][CHANNEL_COUNT], float out[CHANNEL_COUNT][CHANNEL_COUNT]) { + // Invert cholesky matrix + for (int i = 0; i < CHANNEL_COUNT; i++) { + in[i][i] = 1.0f / (in[i][i] + 0.0001f); - for (int j = 0; j < i; j++) - { - float sum = 0.0f; + for (int j = 0; j < i; j++) { + float sum = 0.0f; - for (int k = j; k < i; k++) - { - sum += in[i][k] * in[k][j]; - } + for (int k = j; k < i; k++) { + sum += in[i][k] * in[k][j]; + } - in[i][j] = -in[i][i] * sum; - } + in[i][j] = -in[i][i] * sum; } + } - // Dot with transpose of self - for (int i = 0; i < CHANNEL_COUNT; i++) - { - for (int j = 0; j < CHANNEL_COUNT; j++) - { - out[i][j] = 0.0f; - - for (int k = max(i, j); k < CHANNEL_COUNT; k++) - { - out[i][j] += in[k][i] * in[k][j]; - } - } + // Dot with transpose of self + for (int i = 0; i < CHANNEL_COUNT; i++) { + for (int j = 0; j < CHANNEL_COUNT; j++) { + out[i][j] = 0.0f; + + for (int k = max(i, j); k < CHANNEL_COUNT; k++) { + out[i][j] += in[k][i] * in[k][j]; + } } + } } -__device__ void normalize(float* v) -{ - float norm = 0.0f; +__device__ void normalize(float* v) { + float norm = 0.0f; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - norm += v[i] * v[i]; - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + norm += v[i] * v[i]; + } - norm = 1.0f / sqrtf(norm); + norm = 1.0f / sqrtf(norm); - for (int i = 0; i < CHANNEL_COUNT; i++) - { - v[i] *= norm; - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + v[i] *= norm; + } } -__device__ float scalar_prod(float* a, float* b) -{ - float product = 0.0f; +__device__ float scalar_prod(float* a, float* b) { + float product = 0.0f; - for (int i = 0; i < CHANNEL_COUNT; i++) - { - product += a[i] * b[i]; - } + for (int i = 0; i < CHANNEL_COUNT; i++) { + product += a[i] * b[i]; + } - return product; + return product; } -__device__ void largest_eigenpair(const float *M, float* evec, float* eval) -{ - float scratch[CHANNEL_COUNT]; - - for(int i = 0; i < CHANNEL_COUNT; i++) - { - scratch[i] = i + 1; - } +__device__ void largest_eigenpair(const float* M, float* evec, float* eval) { + float scratch[CHANNEL_COUNT]; - for (int itr = 0; itr < 10; itr++) - { - *eval = 0.0f; + for (int i = 0; i < CHANNEL_COUNT; i++) { + scratch[i] = i + 1; + } - for (int i = 0; i < CHANNEL_COUNT; i++) - { - int index = i; + for (int itr = 0; itr < 10; itr++) { + *eval = 0.0f; - evec[i] = 0.0f; + for (int i = 0; i < CHANNEL_COUNT; i++) { + int index = i; - for (int j = 0; j < CHANNEL_COUNT; j++) - { - evec[i] += M[index] * scratch[j]; + evec[i] = 0.0f; - if (j < i) - { - index += CHANNEL_COUNT - (j + 1); - } - else - { - index += 1; - } - } + for (int j = 0; j < CHANNEL_COUNT; j++) { + evec[i] += M[index] * scratch[j]; - *eval = max(*eval, evec[i]); + if (j < i) { + index += CHANNEL_COUNT - (j + 1); + } else { + index += 1; } + } - for (int i = 0; i < CHANNEL_COUNT; i++) - { - evec[i] /= *eval; - scratch[i] = evec[i]; - } + *eval = max(*eval, evec[i]); + } + + for (int i = 0; i < CHANNEL_COUNT; i++) { + evec[i] /= *eval; + scratch[i] = evec[i]; } + } } diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py index 3300fe91ff..a0e3ccca26 100644 --- a/monai/bundle/config_item.py +++ b/monai/bundle/config_item.py @@ -312,7 +312,7 @@ class ConfigExpression(ConfigItem): """ prefix = EXPR_KEY - run_eval = False if os.environ.get("MONAI_EVAL_EXPR", "1") == "0" else True + run_eval = not os.environ.get("MONAI_EVAL_EXPR", "1") == "0" def __init__(self, config: Any, id: str = "", globals: Optional[Dict] = None) -> None: super().__init__(config=config, id=id) diff --git a/monai/bundle/reference_resolver.py b/monai/bundle/reference_resolver.py index c169389e0d..656424a2a4 100644 --- a/monai/bundle/reference_resolver.py +++ b/monai/bundle/reference_resolver.py @@ -53,7 +53,7 @@ class ReferenceResolver: # match a reference string, e.g. "@id#key", "@id#key#0", "@_target_#key" id_matcher = re.compile(rf"{ref}(?:\w*)(?:{sep}\w*)*") # if `allow_missing_reference` and can't find a reference ID, will just raise a warning and don't update the config - allow_missing_reference = False if os.environ.get("MONAI_ALLOW_MISSING_REFERENCE", "0") == "0" else True + allow_missing_reference = not os.environ.get("MONAI_ALLOW_MISSING_REFERENCE", "0") == "0" def __init__(self, items: Optional[Sequence[ConfigItem]] = None): # save the items in a dictionary with the `ConfigItem.id` as key diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index c8bfeeca05..d2e5b2c6ba 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -38,7 +38,7 @@ class TraceableTransform(Transform): `MONAI_TRACE_TRANSFORM` when initializing the class. """ - tracing = False if os.environ.get("MONAI_TRACE_TRANSFORM", "1") == "0" else True + tracing = not os.environ.get("MONAI_TRACE_TRANSFORM", "1") == "0" def set_tracing(self, tracing: bool) -> None: """Set whether to trace transforms.""" diff --git a/runtests.sh b/runtests.sh index 5464f3d020..19a4a97444 100755 --- a/runtests.sh +++ b/runtests.sh @@ -146,6 +146,8 @@ function clang_format { exit 1 fi find monai/csrc -type f | while read i; do $clang_format_tool -style=file -i $i; done + find monai/_extensions -type f -name "*.cpp" -o -name "*.h" -o -name "*.cuh" -o -name "*.cu" |\ + while read i; do $clang_format_tool -style=file -i $i; done } function clean_py { diff --git a/tests/test_cachedataset.py b/tests/test_cachedataset.py index 7227f53e04..4b77d4a55a 100644 --- a/tests/test_cachedataset.py +++ b/tests/test_cachedataset.py @@ -80,7 +80,7 @@ def test_set_data(self): cache_rate=1.0, num_workers=4, progress=True, - copy_cache=False if sys.platform == "linux" else True, + copy_cache=not sys.platform == "linux", ) num_workers = 2 if sys.platform == "linux" else 0 diff --git a/tests/test_efficientnet.py b/tests/test_efficientnet.py index a2a5e30750..d56f901af7 100644 --- a/tests/test_efficientnet.py +++ b/tests/test_efficientnet.py @@ -367,7 +367,8 @@ def test_func_get_efficientnet_input_shape(self): self.assertEqual(result_shape, expected_shape) def test_script(self): - net = EfficientNetBN(model_name="efficientnet-b0", spatial_dims=2, in_channels=3, num_classes=1000) + with skip_if_downloading_fails(): + net = EfficientNetBN(model_name="efficientnet-b0", spatial_dims=2, in_channels=3, num_classes=1000) net.set_swish(memory_efficient=False) # at the moment custom memory efficient swish is not exportable with jit test_data = torch.randn(1, 3, 224, 224) test_script_save(net, test_data) diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index 432e5e90a0..2e515772d3 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -147,7 +147,7 @@ def _forward_completed(self, engine): additional_metrics={"val_acc": Accuracy(output_transform=from_engine(["pred", "label"]))}, metric_cmp_fn=lambda cur, prev: cur >= prev, # if greater or equal, treat as new best metric val_handlers=val_handlers, - amp=True if amp else False, + amp=bool(amp), ) train_postprocessing = Compose( @@ -200,7 +200,7 @@ def _model_completed(self, engine): postprocessing=train_postprocessing, key_train_metric={"train_acc": Accuracy(output_transform=from_engine(["pred", "label"]))}, train_handlers=train_handlers, - amp=True if amp else False, + amp=bool(amp), optim_set_to_none=True, ) trainer.run() @@ -271,7 +271,7 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor }, additional_metrics={"val_acc": Accuracy(output_transform=from_engine(["pred", "label"]))}, val_handlers=val_handlers, - amp=True if amp else False, + amp=bool(amp), ) evaluator.run() From b6b2cfba3839b4ae7333008b86f4e8547dc24ac4 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 7 Apr 2022 14:44:21 +0100 Subject: [PATCH 007/183] SplitDim (#3884) * SplitDim Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fixes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix update meta Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * update docs Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li --- docs/source/transforms.rst | 12 ++++ monai/transforms/__init__.py | 4 ++ monai/transforms/utility/array.py | 58 +++++++++++++------ monai/transforms/utility/dictionary.py | 66 +++++++++++++++++----- tests/min_tests.py | 1 + tests/test_splitdim.py | 50 +++++++++++++++++ tests/test_splitdimd.py | 78 ++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 30 deletions(-) create mode 100644 tests/test_splitdim.py create mode 100644 tests/test_splitdimd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 8fc832a253..78fb303093 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -803,6 +803,12 @@ Utility :members: :special-members: __call__ +`SplitDim` +"""""""""" +.. autoclass:: SplitDim + :members: + :special-members: __call__ + `SplitChannel` """""""""""""" .. autoclass:: SplitChannel @@ -1638,6 +1644,12 @@ Utility (Dict) :members: :special-members: __call__ +`SplitDimd` +""""""""""" +.. autoclass:: SplitDimd + :members: + :special-members: __call__ + `SplitChanneld` """"""""""""""" .. autoclass:: SplitChanneld diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index a3dc439a51..8e6ccc8b94 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -412,6 +412,7 @@ RepeatChannel, SimulateDelay, SplitChannel, + SplitDim, SqueezeDim, ToCupy, ToDevice, @@ -509,6 +510,9 @@ SplitChanneld, SplitChannelD, SplitChannelDict, + SplitDimd, + SplitDimD, + SplitDimDict, SqueezeDimd, SqueezeDimD, SqueezeDimDict, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 72f50f6894..bc0c09e949 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -37,6 +37,7 @@ convert_to_cupy, convert_to_numpy, convert_to_tensor, + deprecated, deprecated_arg, ensure_tuple, look_up_option, @@ -62,6 +63,7 @@ "EnsureType", "RepeatChannel", "RemoveRepeatedChannel", + "SplitDim", "SplitChannel", "CastToType", "ToTensor", @@ -281,33 +283,57 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: return img[:: self.repeats, :] -class SplitChannel(Transform): +class SplitDim(Transform): """ - Split Numpy array or PyTorch Tensor data according to the channel dim. - It can help applying different following transforms to different channels. + Given an image of size X along a certain dimension, return a list of length X containing + images. Useful for converting 3D images into a stack of 2D images, splitting multichannel inputs into + single channels, for example. - Args: - channel_dim: which dimension of input image is the channel, default to 0. + Note: `torch.split`/`np.split` is used, so the outputs are views of the input (shallow copy). + Args: + dim: dimension on which to split + keepdim: if `True`, output will have singleton in the split dimension. If `False`, this + dimension will be squeezed. """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, channel_dim: int = 0) -> None: - self.channel_dim = channel_dim + def __init__(self, dim: int = -1, keepdim: bool = True) -> None: + self.dim = dim + self.keepdim = keepdim def __call__(self, img: NdarrayOrTensor) -> List[NdarrayOrTensor]: - num_classes = img.shape[self.channel_dim] - if num_classes <= 1: - raise RuntimeError("input image does not contain multiple channels.") + """ + Apply the transform to `img`. + """ + n_out = img.shape[self.dim] + if n_out <= 1: + raise RuntimeError("Input image is singleton along dimension to be split.") + if isinstance(img, torch.Tensor): + outputs = list(torch.split(img, 1, self.dim)) + else: + outputs = np.split(img, n_out, self.dim) + if not self.keepdim: + outputs = [o.squeeze(self.dim) for o in outputs] + return outputs - outputs = [] - slices = [slice(None)] * len(img.shape) - for i in range(num_classes): - slices[self.channel_dim] = slice(i, i + 1) - outputs.append(img[tuple(slices)]) - return outputs +@deprecated(since="0.8", msg_suffix="please use `SplitDim` instead.") +class SplitChannel(SplitDim): + """ + Split Numpy array or PyTorch Tensor data according to the channel dim. + It can help applying different following transforms to different channels. + + Note: `torch.split`/`np.split` is used, so the outputs are views of the input (shallow copy). + + Args: + channel_dim: which dimension of input image is the channel, default to 0. + + """ + + def __init__(self, channel_dim: int = 0) -> None: + super().__init__(channel_dim) class CastToType(Transform): diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index ecf9aaffa4..564b2993e7 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -49,7 +49,7 @@ RemoveRepeatedChannel, RepeatChannel, SimulateDelay, - SplitChannel, + SplitDim, SqueezeDim, ToCupy, ToDevice, @@ -61,7 +61,7 @@ ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.transforms.utils_pytorch_numpy_unification import concatenate -from monai.utils import convert_to_numpy, deprecated_arg, ensure_tuple, ensure_tuple_rep +from monai.utils import convert_to_numpy, deprecated, deprecated_arg, ensure_tuple, ensure_tuple_rep from monai.utils.enums import PostFix, TraceKeys, TransformBackends from monai.utils.type_conversion import convert_to_dst_type @@ -150,6 +150,9 @@ "SplitChannelD", "SplitChannelDict", "SplitChanneld", + "SplitDimD", + "SplitDimDict", + "SplitDimd", "SqueezeDimD", "SqueezeDimDict", "SqueezeDimd", @@ -372,19 +375,14 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d -class SplitChanneld(MapTransform): - """ - Dictionary-based wrapper of :py:class:`monai.transforms.SplitChannel`. - All the input specified by `keys` should be split into same count of data. - """ - - backend = SplitChannel.backend - +class SplitDimd(MapTransform): def __init__( self, keys: KeysCollection, output_postfixes: Optional[Sequence[str]] = None, - channel_dim: int = 0, + dim: int = 0, + keepdim: bool = True, + update_meta: bool = True, allow_missing_keys: bool = False, ) -> None: """ @@ -395,13 +393,17 @@ def __init__( for example: if the key of input data is `pred` and split 2 classes, the output data keys will be: pred_(output_postfixes[0]), pred_(output_postfixes[1]) if None, using the index number: `pred_0`, `pred_1`, ... `pred_N`. - channel_dim: which dimension of input image is the channel, default to 0. + dim: which dimension of input image is the channel, default to 0. + keepdim: if `True`, output will have singleton in the split dimension. If `False`, this + dimension will be squeezed. + update_meta: if `True`, copy `[key]_meta_dict` for each output and update affine to + reflect the cropped image allow_missing_keys: don't raise exception if key is missing. - """ super().__init__(keys, allow_missing_keys) self.output_postfixes = output_postfixes - self.splitter = SplitChannel(channel_dim=channel_dim) + self.splitter = SplitDim(dim, keepdim) + self.update_meta = update_meta def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) @@ -415,9 +417,44 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N if split_key in d: raise RuntimeError(f"input data already contains key {split_key}.") d[split_key] = r + + if self.update_meta: + orig_meta = d.get(PostFix.meta(key), None) + if orig_meta is not None: + split_meta_key = PostFix.meta(split_key) + d[split_meta_key] = deepcopy(orig_meta) + dim = self.splitter.dim + if dim > 0: # don't update affine if channel dim + shift = np.eye(len(d[split_meta_key]["affine"])) # type: ignore + shift[dim - 1, -1] = i # type: ignore + d[split_meta_key]["affine"] = d[split_meta_key]["affine"] @ shift # type: ignore + return d +@deprecated(since="0.8", msg_suffix="please use `SplitDimd` instead.") +class SplitChanneld(SplitDimd): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.SplitChannel`. + All the input specified by `keys` should be split into same count of data. + """ + + def __init__( + self, + keys: KeysCollection, + output_postfixes: Optional[Sequence[str]] = None, + channel_dim: int = 0, + allow_missing_keys: bool = False, + ) -> None: + super().__init__( + keys, + output_postfixes=output_postfixes, + dim=channel_dim, + update_meta=False, # for backwards compatibility + allow_missing_keys=allow_missing_keys, + ) + + class CastToTyped(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.CastToType`. @@ -1637,6 +1674,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N RemoveRepeatedChannelD = RemoveRepeatedChannelDict = RemoveRepeatedChanneld RepeatChannelD = RepeatChannelDict = RepeatChanneld SplitChannelD = SplitChannelDict = SplitChanneld +SplitDimD = SplitDimDict = SplitDimd CastToTypeD = CastToTypeDict = CastToTyped ToTensorD = ToTensorDict = ToTensord EnsureTypeD = EnsureTypeDict = EnsureTyped diff --git a/tests/min_tests.py b/tests/min_tests.py index 9bf95f3f49..988a703e5a 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -143,6 +143,7 @@ def run_testsuit(): "test_smartcachedataset", "test_spacing", "test_spacingd", + "test_splitdimd", "test_surface_distance", "test_testtimeaugmentation", "test_torchvision", diff --git a/tests/test_splitdim.py b/tests/test_splitdim.py new file mode 100644 index 0000000000..623396a8fe --- /dev/null +++ b/tests/test_splitdim.py @@ -0,0 +1,50 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.utility.array import SplitDim +from tests.utils import TEST_NDARRAYS + +TESTS = [] +for p in TEST_NDARRAYS: + for keepdim in (True, False): + TESTS.append(((2, 10, 8, 7), keepdim, p)) + + +class TestSplitDim(unittest.TestCase): + @parameterized.expand(TESTS) + def test_correct_shape(self, shape, keepdim, im_type): + arr = im_type(np.random.rand(*shape)) + for dim in range(arr.ndim): + out = SplitDim(dim, keepdim)(arr) + self.assertIsInstance(out, (list, tuple)) + self.assertEqual(len(out), arr.shape[dim]) + expected_ndim = arr.ndim if keepdim else arr.ndim - 1 + self.assertEqual(out[0].ndim, expected_ndim) + # assert is a shallow copy + arr[0, 0, 0, 0] *= 2 + self.assertEqual(arr.flatten()[0], out[0].flatten()[0]) + + def test_error(self): + """Should fail because splitting along singleton dimension""" + shape = (2, 1, 8, 7) + for p in TEST_NDARRAYS: + arr = p(np.random.rand(*shape)) + with self.assertRaises(RuntimeError): + _ = SplitDim(dim=1)(arr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_splitdimd.py b/tests/test_splitdimd.py new file mode 100644 index 0000000000..6b164a3cb8 --- /dev/null +++ b/tests/test_splitdimd.py @@ -0,0 +1,78 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from copy import deepcopy + +import numpy as np +from parameterized import parameterized + +from monai.transforms import LoadImaged +from monai.transforms.utility.dictionary import SplitDimd +from tests.utils import TEST_NDARRAYS, assert_allclose, make_nifti_image, make_rand_affine + +TESTS = [] +for p in TEST_NDARRAYS: + for keepdim in (True, False): + for update_meta in (True, False): + TESTS.append((keepdim, p, update_meta)) + + +class TestSplitDimd(unittest.TestCase): + @classmethod + def setUpClass(cls): + arr = np.random.rand(2, 10, 8, 7) + affine = make_rand_affine() + data = {"i": make_nifti_image(arr, affine)} + + cls.data = LoadImaged("i")(data) + + @parameterized.expand(TESTS) + def test_correct(self, keepdim, im_type, update_meta): + data = deepcopy(self.data) + data["i"] = im_type(data["i"]) + arr = data["i"] + for dim in range(arr.ndim): + out = SplitDimd("i", dim=dim, keepdim=keepdim, update_meta=update_meta)(data) + self.assertIsInstance(out, dict) + num_new_keys = 2 if update_meta else 1 + self.assertEqual(len(out.keys()), len(data.keys()) + num_new_keys * arr.shape[dim]) + # if updating meta data, pick some random points and + # check same world coordinates between input and output + if update_meta: + for _ in range(10): + idx = [np.random.choice(i) for i in arr.shape] + split_im_idx = idx[dim] + split_idx = deepcopy(idx) + split_idx[dim] = 0 + # idx[1:] to remove channel and then add 1 for 4th element + real_world = data["i_meta_dict"]["affine"] @ (idx[1:] + [1]) + real_world2 = out[f"i_{split_im_idx}_meta_dict"]["affine"] @ (split_idx[1:] + [1]) + assert_allclose(real_world, real_world2) + + out = out["i_0"] + expected_ndim = arr.ndim if keepdim else arr.ndim - 1 + self.assertEqual(out.ndim, expected_ndim) + # assert is a shallow copy + arr[0, 0, 0, 0] *= 2 + self.assertEqual(arr.flatten()[0], out.flatten()[0]) + + def test_error(self): + """Should fail because splitting along singleton dimension""" + shape = (2, 1, 8, 7) + for p in TEST_NDARRAYS: + arr = p(np.random.rand(*shape)) + with self.assertRaises(RuntimeError): + _ = SplitDimd("i", dim=1)({"i": arr}) + + +if __name__ == "__main__": + unittest.main() From d68554e0f99400f5b25cb37faa69d7a31c1d2b20 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 8 Apr 2022 18:15:54 +0100 Subject: [PATCH 008/183] update mmar tests (#4091) * update mmar tests Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fixes pylint error Signed-off-by: Wenqi Li Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/ngc_mmar_loading.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/ngc_mmar_loading.py b/tests/ngc_mmar_loading.py index 5261dfd612..4bb89dde0e 100644 --- a/tests/ngc_mmar_loading.py +++ b/tests/ngc_mmar_loading.py @@ -10,6 +10,7 @@ # limitations under the License. import os +import sys import unittest import torch @@ -17,6 +18,7 @@ from monai.apps.mmars import MODEL_DESC, load_from_mmar from monai.config import print_debug_info +from monai.networks.utils import copy_model_state class TestAllDownloadingMMAR(unittest.TestCase): @@ -26,10 +28,33 @@ def setUp(self): @parameterized.expand((item,) for item in MODEL_DESC) def test_loading_mmar(self, item): + if item["name"] == "clara_pt_self_supervised_learning_segmentation": # test the byow model + default_model_file = os.path.join("ssl_models_2gpu", "best_metric_model.pt") + pretrained_weights = load_from_mmar( + item=item["name"], + mmar_dir="./", + map_location="cpu", + api=True, + model_file=default_model_file, + weights_only=True, + ) + pretrained_weights = {k.split(".", 1)[1]: v for k, v in pretrained_weights["state_dict"].items()} + sys.path.append(os.path.join(f"{item['name']}", "custom")) # custom model folder + from vit_network import ViTAutoEnc # pylint: disable=E0401 + + model = ViTAutoEnc( + in_channels=1, + img_size=(96, 96, 96), + patch_size=(16, 16, 16), + pos_embed="conv", + hidden_size=768, + mlp_dim=3072, + ) + _, loaded, not_loaded = copy_model_state(model, pretrained_weights) + self.assertTrue(len(loaded) > 0 and len(not_loaded) == 0) + return if item["name"] == "clara_pt_fed_learning_brain_tumor_mri_segmentation": default_model_file = os.path.join("models", "server", "best_FL_global_model.pt") - elif item["name"] == "clara_pt_self_supervised_learning_segmentation": - default_model_file = os.path.join("models_2gpu", "best_metric_model.pt") else: default_model_file = None pretrained_model = load_from_mmar( From 17529e7ae0a53db68f9ace7a130da2b611650cc3 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Sun, 10 Apr 2022 08:18:02 +0100 Subject: [PATCH 009/183] extending the mlp module (#4089) * extend mlp Signed-off-by: Wenqi Li * 0 mlp_dim Signed-off-by: Wenqi Li * update based on comments Signed-off-by: Wenqi Li --- monai/networks/blocks/mlp.py | 37 +++++++++++++++++++++++++++++++----- tests/test_mlp.py | 2 +- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/monai/networks/blocks/mlp.py b/monai/networks/blocks/mlp.py index a1728365cf..0feeb044f3 100644 --- a/monai/networks/blocks/mlp.py +++ b/monai/networks/blocks/mlp.py @@ -9,8 +9,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Tuple, Union + import torch.nn as nn +from monai.networks.layers import get_act_layer +from monai.utils import look_up_option + +SUPPORTED_DROPOUT_MODE = {"vit", "swin"} + class MLPBlock(nn.Module): """ @@ -18,12 +25,26 @@ class MLPBlock(nn.Module): An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale " """ - def __init__(self, hidden_size: int, mlp_dim: int, dropout_rate: float = 0.0) -> None: + def __init__( + self, + hidden_size: int, + mlp_dim: int, + dropout_rate: float = 0.0, + act: Union[Tuple, str] = "GELU", + dropout_mode="vit", + ) -> None: """ Args: hidden_size: dimension of hidden layer. - mlp_dim: dimension of feedforward layer. + mlp_dim: dimension of feedforward layer. If 0, `hidden_size` will be used. dropout_rate: faction of the input units to drop. + act: activation type and arguments. Defaults to GELU. + dropout_mode: dropout mode, can be "vit" or "swin". + "vit" mode uses two dropout instances as implemented in + https://github.com/google-research/vision_transformer/blob/main/vit_jax/models.py#L87 + "swin" corresponds to one instance as implemented in + https://github.com/microsoft/Swin-Transformer/blob/main/models/swin_mlp.py#L23 + """ @@ -31,12 +52,18 @@ def __init__(self, hidden_size: int, mlp_dim: int, dropout_rate: float = 0.0) -> if not (0 <= dropout_rate <= 1): raise ValueError("dropout_rate should be between 0 and 1.") - + mlp_dim = mlp_dim or hidden_size self.linear1 = nn.Linear(hidden_size, mlp_dim) self.linear2 = nn.Linear(mlp_dim, hidden_size) - self.fn = nn.GELU() + self.fn = get_act_layer(act) self.drop1 = nn.Dropout(dropout_rate) - self.drop2 = nn.Dropout(dropout_rate) + dropout_opt = look_up_option(dropout_mode, SUPPORTED_DROPOUT_MODE) + if dropout_opt == "vit": + self.drop2 = nn.Dropout(dropout_rate) + elif dropout_opt == "swin": + self.drop2 = self.drop1 + else: + raise ValueError(f"dropout_mode should be one of {SUPPORTED_DROPOUT_MODE}") def forward(self, x): x = self.fn(self.linear1(x)) diff --git a/tests/test_mlp.py b/tests/test_mlp.py index 6fec5b6854..737762cfb1 100644 --- a/tests/test_mlp.py +++ b/tests/test_mlp.py @@ -21,7 +21,7 @@ TEST_CASE_MLP = [] for dropout_rate in np.linspace(0, 1, 4): for hidden_size in [128, 256, 512, 768]: - for mlp_dim in [512, 1028, 2048, 3072]: + for mlp_dim in [0, 1028, 2048, 3072]: test_case = [ {"hidden_size": hidden_size, "mlp_dim": mlp_dim, "dropout_rate": dropout_rate}, From 6602303578cb7f331fce7d3bb4334396e0bfee2d Mon Sep 17 00:00:00 2001 From: Silvia Seidlitz Date: Sun, 10 Apr 2022 22:51:00 +0200 Subject: [PATCH 010/183] added 2D (normalized) surface dice metric (#4050) * added 2D (normalized) surface dice metric Signed-off-by: Silvia Seidlitz * exclude from min tests Signed-off-by: Wenqi Li * more detailled docstring Signed-off-by: Silvia Seidlitz --- docs/source/metrics.rst | 7 + monai/metrics/__init__.py | 1 + monai/metrics/surface_dice.py | 236 +++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_surface_dice.py | 292 ++++++++++++++++++++++++++++++++++ 5 files changed, 537 insertions(+) create mode 100644 monai/metrics/surface_dice.py create mode 100644 tests/test_surface_dice.py diff --git a/docs/source/metrics.rst b/docs/source/metrics.rst index 332571d345..1eb8935141 100644 --- a/docs/source/metrics.rst +++ b/docs/source/metrics.rst @@ -69,6 +69,13 @@ Metrics .. autoclass:: SurfaceDistanceMetric :members: +`Surface dice` +-------------- +.. autofunction:: compute_surface_dice + +.. autoclass:: SurfaceDiceMetric + :members: + `Mean squared error` -------------------- .. autoclass:: MSEMetric diff --git a/monai/metrics/__init__.py b/monai/metrics/__init__.py index d18c20f7b2..53d11893ed 100644 --- a/monai/metrics/__init__.py +++ b/monai/metrics/__init__.py @@ -17,5 +17,6 @@ from .metric import Cumulative, CumulativeIterationMetric, IterationMetric, Metric from .regression import MAEMetric, MSEMetric, PSNRMetric, RMSEMetric from .rocauc import ROCAUCMetric, compute_roc_auc +from .surface_dice import SurfaceDiceMetric, compute_surface_dice from .surface_distance import SurfaceDistanceMetric, compute_average_surface_distance from .utils import do_metric_reduction, get_mask_edges, get_surface_distance, ignore_background diff --git a/monai/metrics/surface_dice.py b/monai/metrics/surface_dice.py new file mode 100644 index 0000000000..5630af178d --- /dev/null +++ b/monai/metrics/surface_dice.py @@ -0,0 +1,236 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import warnings +from typing import List, Union + +import numpy as np +import torch + +from monai.metrics.utils import do_metric_reduction, get_mask_edges, get_surface_distance, ignore_background +from monai.utils import MetricReduction, convert_data_type + +from .metric import CumulativeIterationMetric + + +class SurfaceDiceMetric(CumulativeIterationMetric): + """ + Computes the Normalized Surface Distance (NSD) for each batch sample and class of + predicted segmentations `y_pred` and corresponding reference segmentations `y` according to equation :eq:`nsd`. + This implementation supports 2D images. For 3D images, please refer to DeepMind's implementation + https://github.com/deepmind/surface-distance. + + The class- and batch sample-wise NSD values can be aggregated with the function `aggregate`. + + Args: + class_thresholds: List of class-specific thresholds. + The thresholds relate to the acceptable amount of deviation in the segmentation boundary in pixels. + Each threshold needs to be a finite, non-negative number. + include_background: Whether to skip NSD computation on the first channel of the predicted output. + Defaults to ``False``. + distance_metric: The metric used to compute surface distances. + One of [``"euclidean"``, ``"chessboard"``, ``"taxicab"``]. + Defaults to ``"euclidean"``. + reduction: The mode to aggregate metrics. + One of [``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``, + ``"none"``]. + Defaults to ``"mean"``. + If ``"none"`` is chosen, no aggregation will be performed. + The aggregation will ignore nan values. + get_not_nans: whether to return the `not_nans` count. + Defaults to ``False``. + `not_nans` is the number of batch samples for which not all class-specific NSD values were nan values. + If set to ``True``, the function `aggregate` will return both the aggregated NSD and the `not_nans` count. + If set to ``False``, `aggregate` will only return the aggregated NSD. + """ + + def __init__( + self, + class_thresholds: List[float], + include_background: bool = False, + distance_metric: str = "euclidean", + reduction: Union[MetricReduction, str] = MetricReduction.MEAN, + get_not_nans: bool = False, + ) -> None: + super().__init__() + self.class_thresholds = class_thresholds + self.include_background = include_background + self.distance_metric = distance_metric + self.reduction = reduction + self.get_not_nans = get_not_nans + + def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignore + r""" + Args: + y_pred: Predicted segmentation, typically segmentation model output. + It must be a one-hot encoded, batch-first tensor [B,C,H,W]. + y: Reference segmentation. + It must be a one-hot encoded, batch-first tensor [B,C,H,W]. + + Returns: + Pytorch Tensor of shape [B,C], containing the NSD values :math:`\operatorname {NSD}_{b,c}` for each batch + index :math:`b` and class :math:`c`. + """ + return compute_surface_dice( + y_pred=y_pred, + y=y, + class_thresholds=self.class_thresholds, + include_background=self.include_background, + distance_metric=self.distance_metric, + ) + + def aggregate(self): + r""" + Aggregates the output of `_compute_tensor`. + + Returns: + If `get_not_nans` is set to ``True``, this function returns the aggregated NSD and the `not_nans` count. + If `get_not_nans` is set to ``False``, this function returns only the aggregated NSD. + """ + data = self.get_buffer() + if not isinstance(data, torch.Tensor): + raise ValueError("the data to aggregate must be PyTorch Tensor.") + + # do metric reduction + f, not_nans = do_metric_reduction(data, self.reduction) + return (f, not_nans) if self.get_not_nans else f + + +def compute_surface_dice( + y_pred: torch.Tensor, + y: torch.Tensor, + class_thresholds: List[float], + include_background: bool = False, + distance_metric: str = "euclidean", +): + r""" + This function computes the (Normalized) Surface Dice (NSD) between the two tensors `y_pred` (referred to as + :math:`\hat{Y}`) and `y` (referred to as :math:`Y`). This metric determines which fraction of a segmentation + boundary is correctly predicted. A boundary element is considered correctly predicted if the closest distance to the + reference boundary is smaller than or equal to the specified threshold related to the acceptable amount of deviation in + pixels. The NSD is bounded between 0 and 1. + + This implementation supports multi-class tasks with an individual threshold :math:`\tau_c` for each class :math:`c`. + The class-specific NSD for batch index :math:`b`, :math:`\operatorname {NSD}_{b,c}`, is computed using the function: + + .. math:: + \operatorname {NSD}_{b,c} \left(Y_{b,c}, \hat{Y}_{b,c}\right) = \frac{\left|\mathcal{D}_{Y_{b,c}}^{'}\right| + + \left| \mathcal{D}_{\hat{Y}_{b,c}}^{'} \right|}{\left|\mathcal{D}_{Y_{b,c}}\right| + + \left|\mathcal{D}_{\hat{Y}_{b,c}}\right|} + :label: nsd + + with :math:`\mathcal{D}_{Y_{b,c}}` and :math:`\mathcal{D}_{\hat{Y}_{b,c}}` being two sets of nearest-neighbor + distances. :math:`\mathcal{D}_{Y_{b,c}}` is computed from the predicted segmentation boundary towards the reference segmentation + boundary and vice-versa for :math:`\mathcal{D}_{\hat{Y}_{b,c}}`. :math:`\mathcal{D}_{Y_{b,c}}^{'}` and + :math:`\mathcal{D}_{\hat{Y}_{b,c}}^{'}` refer to the subsets of distances that are smaller or equal to the + acceptable distance :math:`\tau_c`: + + .. math:: + \mathcal{D}_{Y_{b,c}}^{'} = \{ d \in \mathcal{D}_{Y_{b,c}} \, | \, d \leq \tau_c \}. + + + In the case of a class neither being present in the predicted segmentation, nor in the reference segmentation, a nan value + will be returned for this class. In the case of a class being present in only one of predicted segmentation or + reference segmentation, the class NSD will be 0. + + This implementation is based on https://arxiv.org/abs/2111.05408 and supports 2D images. + Be aware that the computation of boundaries is different from DeepMind's implementation + https://github.com/deepmind/surface-distance. In this implementation, the length of a segmentation boundary is + interpreted as the number of its edge pixels. In DeepMind's implementation, the length of a segmentation boundary + depends on the local neighborhood (cf. https://arxiv.org/abs/1809.04430). + + Args: + y_pred: Predicted segmentation, typically segmentation model output. + It must be a one-hot encoded, batch-first tensor [B,C,H,W]. + y: Reference segmentation. + It must be a one-hot encoded, batch-first tensor [B,C,H,W]. + class_thresholds: List of class-specific thresholds. + The thresholds relate to the acceptable amount of deviation in the segmentation boundary in pixels. + Each threshold needs to be a finite, non-negative number. + include_background: Whether to skip the surface dice computation on the first channel of + the predicted output. Defaults to ``False``. + distance_metric: The metric used to compute surface distances. + One of [``"euclidean"``, ``"chessboard"``, ``"taxicab"``]. + Defaults to ``"euclidean"``. + + Raises: + ValueError: If `y_pred` and/or `y` are not PyTorch tensors. + ValueError: If `y_pred` and/or `y` do not have four dimensions. + ValueError: If `y_pred` and/or `y` have different shapes. + ValueError: If `y_pred` and/or `y` are not one-hot encoded + ValueError: If the number of channels of `y_pred` and/or `y` is different from the number of class thresholds. + ValueError: If any class threshold is not finite. + ValueError: If any class threshold is negative. + + Returns: + Pytorch Tensor of shape [B,C], containing the NSD values :math:`\operatorname {NSD}_{b,c}` for each batch index + :math:`b` and class :math:`c`. + """ + + if not include_background: + y_pred, y = ignore_background(y_pred=y_pred, y=y) + + if not isinstance(y_pred, torch.Tensor) or not isinstance(y, torch.Tensor): + raise ValueError("y_pred and y must be PyTorch Tensor.") + + if y_pred.ndimension() != 4 or y.ndimension() != 4: + raise ValueError("y_pred and y should have four dimensions: [B,C,H,W].") + + if y_pred.shape != y.shape: + raise ValueError( + f"y_pred and y should have same shape, but instead, shapes are {y_pred.shape} (y_pred) and {y.shape} (y)." + ) + + if not torch.all(y_pred.byte() == y_pred) or not torch.all(y.byte() == y): + raise ValueError("y_pred and y should be binarized tensors (e.g. torch.int64).") + if torch.any(y_pred > 1) or torch.any(y > 1): + raise ValueError("y_pred and y should be one-hot encoded.") + + y = y.float() + y_pred = y_pred.float() + + batch_size, n_class = y_pred.shape[:2] + + if n_class != len(class_thresholds): + raise ValueError( + f"number of classes ({n_class}) does not match number of class thresholds ({len(class_thresholds)})." + ) + + if any(~np.isfinite(class_thresholds)): + raise ValueError("All class thresholds need to be finite.") + + if any(np.array(class_thresholds) < 0): + raise ValueError("All class thresholds need to be >= 0.") + + nsd = np.empty((batch_size, n_class)) + + for b, c in np.ndindex(batch_size, n_class): + (edges_pred, edges_gt) = get_mask_edges(y_pred[b, c], y[b, c], crop=False) + if not np.any(edges_gt): + warnings.warn(f"the ground truth of class {c} is all 0, this may result in nan/inf distance.") + if not np.any(edges_pred): + warnings.warn(f"the prediction of class {c} is all 0, this may result in nan/inf distance.") + + distances_pred_gt = get_surface_distance(edges_pred, edges_gt, distance_metric=distance_metric) + distances_gt_pred = get_surface_distance(edges_gt, edges_pred, distance_metric=distance_metric) + + boundary_complete = len(distances_pred_gt) + len(distances_gt_pred) + boundary_correct = np.sum(distances_pred_gt <= class_thresholds[c]) + np.sum( + distances_gt_pred <= class_thresholds[c] + ) + + if boundary_complete == 0: + # the class is neither present in the prediction, nor in the reference segmentation + nsd[b, c] = np.nan + else: + nsd[b, c] = boundary_correct / boundary_complete + + return convert_data_type(nsd, torch.Tensor)[0] diff --git a/tests/min_tests.py b/tests/min_tests.py index 988a703e5a..66b6c9ff3d 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -145,6 +145,7 @@ def run_testsuit(): "test_spacingd", "test_splitdimd", "test_surface_distance", + "test_surface_dice", "test_testtimeaugmentation", "test_torchvision", "test_torchvisiond", diff --git a/tests/test_surface_dice.py b/tests/test_surface_dice.py new file mode 100644 index 0000000000..5252adafce --- /dev/null +++ b/tests/test_surface_dice.py @@ -0,0 +1,292 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +import torch.nn.functional as F + +from monai.metrics.surface_dice import SurfaceDiceMetric + + +class TestAllSurfaceDiceMetrics(unittest.TestCase): + def test_tolerance_euclidean_distance(self): + batch_size = 2 + n_class = 2 + predictions = torch.zeros((batch_size, 480, 640), dtype=torch.int64) + labels = torch.zeros((batch_size, 480, 640), dtype=torch.int64) + predictions[0, :, 50:] = 1 + labels[0, :, 60:] = 1 # 10 px shift + predictions_hot = F.one_hot(predictions, num_classes=n_class).permute(0, 3, 1, 2) + labels_hot = F.one_hot(labels, num_classes=n_class).permute(0, 3, 1, 2) + + sd0 = SurfaceDiceMetric(class_thresholds=[0, 0], include_background=True) + res0 = sd0(predictions_hot, labels_hot) + agg0 = sd0.aggregate() # aggregation: nanmean across image then nanmean across batch + sd0_nans = SurfaceDiceMetric(class_thresholds=[0, 0], include_background=True, get_not_nans=True) + res0_nans = sd0_nans(predictions_hot, labels_hot) + agg0_nans, not_nans = sd0_nans.aggregate() + + np.testing.assert_array_equal(res0, res0_nans) + np.testing.assert_array_equal(agg0, agg0_nans) + + res1 = SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot, labels_hot) + res9 = SurfaceDiceMetric(class_thresholds=[9, 9], include_background=True)(predictions_hot, labels_hot) + res10 = SurfaceDiceMetric(class_thresholds=[10, 10], include_background=True)(predictions_hot, labels_hot) + res11 = SurfaceDiceMetric(class_thresholds=[11, 11], include_background=True)(predictions_hot, labels_hot) + + for res in [res0, res9, res10, res11]: + assert res.shape == torch.Size([2, 2]) + + assert res0[0, 0] < res1[0, 0] < res9[0, 0] < res10[0, 0] + assert res0[0, 1] < res1[0, 1] < res9[0, 1] < res10[0, 1] + np.testing.assert_array_equal(res10, res11) + + expected_res0 = np.zeros((batch_size, n_class)) + expected_res0[0, 1] = 1 - (478 + 480 + 9 * 2) / (480 * 4 + 588 * 2 + 578 * 2) + expected_res0[0, 0] = 1 - (478 + 480 + 9 * 2) / (480 * 4 + 48 * 2 + 58 * 2) + expected_res0[1, 0] = 1 + expected_res0[1, 1] = np.nan + for b, c in np.ndindex(batch_size, n_class): + np.testing.assert_allclose(expected_res0[b, c], res0[b, c]) + np.testing.assert_array_equal(agg0, np.nanmean(np.nanmean(expected_res0, axis=1), axis=0)) + np.testing.assert_equal(not_nans, torch.tensor(2)) + + def test_tolerance_all_distances(self): + batch_size = 1 + n_class = 2 + predictions = torch.zeros((batch_size, 10, 10), dtype=torch.int64) + labels = torch.zeros((batch_size, 10, 10), dtype=torch.int64) + predictions[0, 1:4, 1] = 1 + """ + [[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]] + """ + labels[0, 5:8, 6] = 1 + """ + [[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]] + """ + predictions_hot = F.one_hot(predictions, num_classes=n_class).permute(0, 3, 1, 2) + labels_hot = F.one_hot(labels, num_classes=n_class).permute(0, 3, 1, 2) + + # Euclidean distance: + # background: + # 36 boundary pixels have 0 distances; non-zero distances: + # distances gt_pred: [3, np.sqrt(9+4), 2, 3, 2, 2, 2, 1] + # distances pred_gt: [1, 2, 2, 1] + # class 1: + # distances gt_pred: [sqrt(25+4), sqrt(25+9), sqrt(25+16)] = [5.38516481, 5.83095189, 6.40312424] + # distances pred_gt: [sqrt(25+16), sqrt(25+9), sqrt(25+4)] = [6.40312424, 5.83095189, 5.38516481] + + res = SurfaceDiceMetric(class_thresholds=[0, 0], include_background=True)(predictions_hot, labels_hot) + expected_res = [[1 - (8 + 4) / (36 * 2 + 8 + 4), 0]] + np.testing.assert_array_almost_equal(res, expected_res) + + res = SurfaceDiceMetric(class_thresholds=[2.8, 5.5], include_background=True)(predictions_hot, labels_hot) + expected_res = [[1 - 3 / (36 * 2 + 8 + 4), 1 - (2 + 2) / (3 + 3)]] + np.testing.assert_array_almost_equal(res, expected_res) + + res = SurfaceDiceMetric(class_thresholds=[3, 6], include_background=True)(predictions_hot, labels_hot) + expected_res = [[1 - 1 / (36 * 2 + 8 + 4), 1 - 2 / (3 + 3)]] + np.testing.assert_array_almost_equal(res, expected_res) + + # Chessboard distance: + # background: + # 36 boundary pixels have 0 distances; non-zero distances: + # distances gt_pred: [max(3,0), max(3,2), max(2,0), max(3,3), max(2,0), max(0,2), max(2,0), max(0,1)] = + # [3, 3, 2, 3, 2, 2, 2, 1] + # distances pred_gt: [max(0,1), max(2,0), max(2,0), max(1,0)] = [1, 2, 2, 1] + # class 1: + # distances gt_pred: [max(5,2), max(5,3), max(5,4)] = [5, 5, 5] + # distances pred_gt: [max(5,4), max(5,3), max(5,2)] = [5, 5, 5] + + res = SurfaceDiceMetric(class_thresholds=[0, 0], include_background=True, distance_metric="chessboard")( + predictions_hot, labels_hot + ) + expected_res = [[1 - (8 + 4) / (36 * 2 + 8 + 4), 0]] + np.testing.assert_array_almost_equal(res, expected_res) + + res = SurfaceDiceMetric(class_thresholds=[1, 4.999], include_background=True, distance_metric="chessboard")( + predictions_hot, labels_hot + ) + expected_res = [[1 - (7 + 2) / (36 * 2 + 8 + 4), 0]] + np.testing.assert_array_almost_equal(res, expected_res) + + res = SurfaceDiceMetric(class_thresholds=[2, 5], include_background=True, distance_metric="chessboard")( + predictions_hot, labels_hot + ) + expected_res = [[1 - 3 / (36 * 2 + 8 + 4), 1]] + np.testing.assert_array_almost_equal(res, expected_res) + + # Taxicab distance (= Manhattan distance): + # background: + # 36 boundary pixels have 0 distances; non-zero distances: + # distances gt_pred: [3+0, 4+0, 2+0, 0+3, 2+0, 0+2, 2+0, 0+1] = [3, 4, 2, 3, 2, 2, 2, 1] + # distances pred_gt: [0+1, 2+0, 2+0, 1+0] = [1, 2, 2, 1] + # class 1: + # distances gt_pred: [5+2, 5+3, 5+4] = [7, 8, 9] + # distances pred_gt: [5+4, 5+3, 5+2] = [9, 8, 7] + + res = SurfaceDiceMetric(class_thresholds=[0, 0], include_background=True, distance_metric="taxicab")( + predictions_hot, labels_hot + ) + expected_res = [[1 - (8 + 4) / (36 * 2 + 8 + 4), 0]] + np.testing.assert_array_almost_equal(res, expected_res) + + res = SurfaceDiceMetric(class_thresholds=[1, 7], include_background=True, distance_metric="taxicab")( + predictions_hot, labels_hot + ) + expected_res = [[1 - (7 + 2) / (36 * 2 + 8 + 4), 1 - (2 + 2) / (3 + 3)]] + np.testing.assert_array_almost_equal(res, expected_res) + + res = SurfaceDiceMetric(class_thresholds=[3, 9], include_background=True, distance_metric="taxicab")( + predictions_hot, labels_hot + ) + expected_res = [[1 - 1 / (36 * 2 + 8 + 4), 1]] + np.testing.assert_array_almost_equal(res, expected_res) + + def test_asserts(self): + batch_size = 1 + n_class = 2 + predictions = torch.zeros((batch_size, 80, 80), dtype=torch.int64) + labels = torch.zeros((batch_size, 80, 80), dtype=torch.int64) + predictions[0, 10:20, 10:20] = 1 + labels[0, 20:30, 20:30] = 1 + predictions_hot = F.one_hot(predictions, num_classes=n_class).permute(0, 3, 1, 2) + labels_hot = F.one_hot(labels, num_classes=n_class).permute(0, 3, 1, 2) + + # no torch tensor + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot.numpy(), labels_hot) + self.assertEqual( + "y_pred or y must be a list/tuple of `channel-first` Tensors or a `batch-first` Tensor.", + str(context.exception), + ) + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot, labels_hot.numpy()) + self.assertEqual("y_pred and y must be PyTorch Tensor.", str(context.exception)) + + # wrong dimensions + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions, labels_hot) + self.assertEqual("y_pred and y should have four dimensions: [B,C,H,W].", str(context.exception)) + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot, labels) + self.assertEqual("y_pred and y should have four dimensions: [B,C,H,W].", str(context.exception)) + + # mismatch of shape of input tensors + input_bad_shape = torch.clone(predictions_hot) + input_bad_shape = input_bad_shape[:, :, :, :50] + + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot, input_bad_shape) + self.assertEqual( + "y_pred and y should have same shape, but instead, shapes are torch.Size([1, 2, 80, 80]) (y_pred) and " + "torch.Size([1, 2, 80, 50]) (y).", + str(context.exception), + ) + + # input tensors not one-hot encoded + predictions_no_hot = torch.clone(predictions_hot) + predictions_no_hot[0, :, 0, 0] = torch.tensor([2, 0]) + + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_no_hot, predictions_hot) + self.assertEqual("y_pred and y should be one-hot encoded.", str(context.exception)) + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot, predictions_no_hot) + self.assertEqual("y_pred and y should be one-hot encoded.", str(context.exception)) + + predictions_no_hot = predictions_no_hot.float() + predictions_no_hot[0, :, 0, 0] = torch.tensor([0.5, 0]) + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_no_hot, predictions_hot) + self.assertEqual("y_pred and y should be binarized tensors (e.g. torch.int64).", str(context.exception)) + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1], include_background=True)(predictions_hot, predictions_no_hot) + self.assertEqual("y_pred and y should be binarized tensors (e.g. torch.int64).", str(context.exception)) + + # wrong number of class thresholds + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[1, 1, 1], include_background=True)(predictions_hot, labels_hot) + self.assertEqual("number of classes (2) does not match number of class thresholds (3).", str(context.exception)) + + # inf and nan values in class thresholds + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[np.inf, 1], include_background=True)(predictions_hot, labels_hot) + self.assertEqual("All class thresholds need to be finite.", str(context.exception)) + + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[np.nan, 1], include_background=True)(predictions_hot, labels_hot) + self.assertEqual("All class thresholds need to be finite.", str(context.exception)) + + # negative values in class thresholds: + with self.assertRaises(ValueError) as context: + SurfaceDiceMetric(class_thresholds=[-0.22, 1], include_background=True)(predictions_hot, labels_hot) + self.assertEqual("All class thresholds need to be >= 0.", str(context.exception)) + + def test_not_predicted_not_present(self): + # class is present in labels, but not in prediction -> nsd of 0 should be yielded for that class; class is + # neither present on labels, nor prediction -> nan should be yielded + batch_size = 1 + n_class = 4 + predictions = torch.zeros((batch_size, 80, 80), dtype=torch.int64) + labels = torch.zeros((batch_size, 80, 80), dtype=torch.int64) + predictions[0, 10:20, 10:20] = 1 + labels[0, 10:20, 10:20] = 2 + predictions_hot = F.one_hot(predictions, num_classes=n_class).permute(0, 3, 1, 2) + labels_hot = F.one_hot(labels, num_classes=n_class).permute(0, 3, 1, 2) + + # with and without background class + sur_metric_bgr = SurfaceDiceMetric(class_thresholds=[1, 1, 1, 1], include_background=True) + sur_metric = SurfaceDiceMetric(class_thresholds=[1, 1, 1], include_background=False) + + # test per-class results + res_bgr_classes = sur_metric_bgr(predictions_hot, labels_hot) + np.testing.assert_array_equal(res_bgr_classes, [[1, 0, 0, np.nan]]) + res_classes = sur_metric(predictions_hot, labels_hot) + np.testing.assert_array_equal(res_classes, [[0, 0, np.nan]]) + + # test aggregation + res_bgr = sur_metric_bgr.aggregate() + np.testing.assert_equal(res_bgr, torch.tensor([1 / 3], dtype=torch.float64)) + res = sur_metric.aggregate() + np.testing.assert_equal(res, torch.tensor([0], dtype=torch.float64)) + + predictions_empty = torch.zeros((2, 3, 1, 1)) + sur_metric_nans = SurfaceDiceMetric(class_thresholds=[1, 1, 1], include_background=True, get_not_nans=True) + res_classes = sur_metric_nans(predictions_empty, predictions_empty) + res, not_nans = sur_metric_nans.aggregate() + np.testing.assert_array_equal(res_classes, [[np.nan, np.nan, np.nan], [np.nan, np.nan, np.nan]]) + np.testing.assert_equal(res, torch.tensor([0], dtype=torch.float64)) + np.testing.assert_equal(not_nans, torch.tensor([0], dtype=torch.float64)) + + +if __name__ == "__main__": + unittest.main() From af6560a5facef14483e43cb516d8f07d72aa1ffe Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Mon, 11 Apr 2022 20:30:08 +0800 Subject: [PATCH 011/183] 4073 Enhance DynUNet doc-strings (#4102) * Fix doc strings error Signed-off-by: Yiheng Wang * remove duplicate places Signed-off-by: Yiheng Wang --- monai/networks/nets/dynunet.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index 337a99acd8..e858dcbb9b 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -74,10 +74,14 @@ class DynUNet(nn.Module): is no less than 3 in order to have at least one downsample and upsample blocks. To meet the requirements of the structure, the input size for each spatial dimension should be divisible - by `2 * the product of all strides in the corresponding dimension`. The output size for each spatial dimension - equals to the input size of the corresponding dimension divided by the stride in strides[0]. - For example, if `strides=((1, 2, 4), 2, 1, 1)`, the minimal spatial size of the input is `(8, 16, 32)`, and - the spatial size of the output is `(8, 8, 8)`. + by the product of all strides in the corresponding dimension. In addition, the minimal spatial size should have + at least one dimension that has twice the size of the product of all strides. + For example, if `strides=((1, 2, 4), 2, 2, 1)`, the spatial size should be divisible by `(4, 8, 16)`, + and the minimal spatial size is `(8, 8, 16)` or `(4, 16, 16)` or `(4, 8, 32)`. + + The output size for each spatial dimension equals to the input size of the corresponding dimension divided by the + stride in strides[0]. + For example, if `strides=((1, 2, 4), 2, 2, 1)` and the input size is `(64, 32, 32)`, the output size is `(64, 16, 8)`. For backwards compatibility with old weights, please set `strict=False` when calling `load_state_dict`. From 9c0a53870a1a7c3c3ee898496f9defcfeeb7d3fe Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 12 Apr 2022 11:08:52 +0100 Subject: [PATCH 012/183] 4105 drops pt16 support (#4106) * update sys req Signed-off-by: Wenqi Li * temp test Signed-off-by: Wenqi Li * update code for torch>=1.7 Signed-off-by: Wenqi Li * temp tests Signed-off-by: Wenqi Li * fixes tests Signed-off-by: Wenqi Li * autofix Signed-off-by: Wenqi Li * fixes import Signed-off-by: Wenqi Li * clear cache Signed-off-by: Wenqi Li * update based on comments Signed-off-by: Wenqi Li * remove temp cmd Signed-off-by: Wenqi Li --- .github/workflows/cron.yml | 14 ++--- .github/workflows/pythonapp-gpu.yml | 2 +- .github/workflows/pythonapp-min.yml | 8 ++- .github/workflows/pythonapp.yml | 2 +- monai/data/torchscript_utils.py | 28 ++-------- monai/engines/trainer.py | 19 ++----- monai/networks/layers/simplelayers.py | 16 +----- monai/networks/utils.py | 6 +-- monai/transforms/intensity/array.py | 6 --- .../utils_pytorch_numpy_unification.py | 52 +++---------------- pyproject.toml | 2 +- requirements.txt | 2 +- setup.cfg | 2 +- tests/test_torchscript_utils.py | 6 +-- tests/test_utils_pytorch_numpy_unification.py | 11 +--- 15 files changed, 37 insertions(+), 139 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index f76fe01699..734a84ff2f 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -15,7 +15,7 @@ jobs: runs-on: [self-hosted, linux, x64, common] strategy: matrix: - pytorch-version: [1.6.0, 1.7.1, 1.8.1, 1.9.1, latest] + pytorch-version: [1.7.1, 1.8.1, 1.9.1, 1.10.2, latest] steps: - uses: actions/checkout@v2 - name: Install the dependencies @@ -24,15 +24,15 @@ jobs: python -m pip install --upgrade pip wheel python -m pip uninstall -y torch torchvision if [ ${{ matrix.pytorch-version }} == "latest" ]; then - python -m pip install torch torchvision - elif [ ${{ matrix.pytorch-version }} == "1.6.0" ]; then - python -m pip install torch==1.6.0 torchvision==0.7.0 + python -m pip install torch torchvision --extra-index-url https://download.pytorch.org/whl/cu113 elif [ ${{ matrix.pytorch-version }} == "1.7.1" ]; then - python -m pip install torch==1.7.1 torchvision==0.8.2 + python -m pip install torch==1.7.1 torchvision==0.8.2 --extra-index-url https://download.pytorch.org/whl/cu113 elif [ ${{ matrix.pytorch-version }} == "1.8.1" ]; then - python -m pip install torch==1.8.1 torchvision==0.9.1 + python -m pip install torch==1.8.1 torchvision==0.9.1 --extra-index-url https://download.pytorch.org/whl/cu113 elif [ ${{ matrix.pytorch-version }} == "1.9.1" ]; then - python -m pip install torch==1.9.1 torchvision==0.10.1 + python -m pip install torch==1.9.1 torchvision==0.10.1 --extra-index-url https://download.pytorch.org/whl/cu113 + elif [ ${{ matrix.pytorch-version }} == "1.10.2" ]; then + python -m pip install torch==1.10.2 torchvision==0.11.3 --extra-index-url https://download.pytorch.org/whl/cu113 fi python -m pip install -r requirements-dev.txt python -m pip list diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index d19bbe437f..90b31e99ab 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -50,7 +50,7 @@ jobs: pytorch: "-h" base: "nvcr.io/nvidia/pytorch:22.03-py3" - environment: PT110+CUDA102 - pytorch: "torch==1.10.1 torchvision==0.11.2" + pytorch: "torch==1.10.2 torchvision==0.11.3" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - environment: PT111+CUDA102 pytorch: "torch==1.11.0 torchvision==0.12.0" diff --git a/.github/workflows/pythonapp-min.yml b/.github/workflows/pythonapp-min.yml index c3294c2b2a..e50f74d816 100644 --- a/.github/workflows/pythonapp-min.yml +++ b/.github/workflows/pythonapp-min.yml @@ -119,7 +119,7 @@ jobs: strategy: fail-fast: false matrix: - pytorch-version: [1.6.0, 1.7.1, 1.8.1, 1.9.1, 1.10.1, latest] + pytorch-version: [1.7.1, 1.8.1, 1.9.1, 1.10.2, latest] timeout-minutes: 40 steps: - uses: actions/checkout@v2 @@ -148,16 +148,14 @@ jobs: # min. requirements if [ ${{ matrix.pytorch-version }} == "latest" ]; then python -m pip install torch - elif [ ${{ matrix.pytorch-version }} == "1.6.0" ]; then - python -m pip install torch==1.6.0 elif [ ${{ matrix.pytorch-version }} == "1.7.1" ]; then python -m pip install torch==1.7.1 elif [ ${{ matrix.pytorch-version }} == "1.8.1" ]; then python -m pip install torch==1.8.1 elif [ ${{ matrix.pytorch-version }} == "1.9.1" ]; then python -m pip install torch==1.9.1 - elif [ ${{ matrix.pytorch-version }} == "1.10.1" ]; then - python -m pip install torch==1.10.1 + elif [ ${{ matrix.pytorch-version }} == "1.10.2" ]; then + python -m pip install torch==1.10.2 fi python -m pip install -r requirements-min.txt python -m pip list diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index cf251c2293..38c96b3b0d 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -137,7 +137,7 @@ jobs: # install the latest pytorch for testing # however, "pip install monai*.tar.gz" will build cpp/cuda with an isolated # fresh torch installation according to pyproject.toml - python -m pip install torch>=1.6 torchvision + python -m pip install torch>=1.7 torchvision - name: Check packages run: | pip uninstall monai diff --git a/monai/data/torchscript_utils.py b/monai/data/torchscript_utils.py index 61477e8ca9..ca46dd9dc4 100644 --- a/monai/data/torchscript_utils.py +++ b/monai/data/torchscript_utils.py @@ -18,7 +18,6 @@ from monai.config import get_config_values from monai.utils import JITMetadataKeys -from monai.utils.module import pytorch_after METADATA_FILENAME = "metadata.json" @@ -80,19 +79,10 @@ def save_net_with_metadata( json_data = json.dumps(metadict) - # Pytorch>1.6 can use dictionaries directly, otherwise need to use special map object - if pytorch_after(1, 7): - extra_files = {METADATA_FILENAME: json_data.encode()} + extra_files = {METADATA_FILENAME: json_data.encode()} - if more_extra_files is not None: - extra_files.update(more_extra_files) - else: - extra_files = torch._C.ExtraFilesMap() # type:ignore[attr-defined] - extra_files[METADATA_FILENAME] = json_data.encode() - - if more_extra_files is not None: - for k, v in more_extra_files.items(): - extra_files[k] = v + if more_extra_files is not None: + extra_files.update(more_extra_files) if isinstance(filename_prefix_or_stream, str): filename_no_ext, ext = os.path.splitext(filename_prefix_or_stream) @@ -123,16 +113,8 @@ def load_net_with_metadata( Returns: Triple containing loaded object, metadata dict, and extra files dict containing other file data if present """ - # Pytorch>1.6 can use dictionaries directly, otherwise need to use special map object - if pytorch_after(1, 7): - extra_files = {f: "" for f in more_extra_files} - extra_files[METADATA_FILENAME] = "" - else: - extra_files = torch._C.ExtraFilesMap() # type:ignore[attr-defined] - extra_files[METADATA_FILENAME] = "" - - for f in more_extra_files: - extra_files[f] = "" + extra_files = {f: "" for f in more_extra_files} + extra_files[METADATA_FILENAME] = "" jit_obj = torch.jit.load(filename_prefix_or_stream, map_location, extra_files) diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index 774e535e7f..a58387a5ef 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -26,7 +26,7 @@ from monai.engines.workflow import Workflow from monai.inferers import Inferer, SimpleInferer from monai.transforms import Transform -from monai.utils import min_version, optional_import, pytorch_after +from monai.utils import min_version, optional_import from monai.utils.enums import CommonKeys as Keys if TYPE_CHECKING: @@ -193,11 +193,7 @@ def _compute_pred_loss(): engine.fire_event(IterationEvents.LOSS_COMPLETED) self.network.train() - # `set_to_none` only work from PyTorch 1.7.0 - if not pytorch_after(1, 7): - self.optimizer.zero_grad() - else: - self.optimizer.zero_grad(set_to_none=self.optim_set_to_none) + self.optimizer.zero_grad(set_to_none=self.optim_set_to_none) if self.amp and self.scaler is not None: with torch.cuda.amp.autocast(): @@ -366,11 +362,7 @@ def _iteration( # Train Discriminator d_total_loss = torch.zeros(1) for _ in range(self.d_train_steps): - # `set_to_none` only work from PyTorch 1.7.0 - if not pytorch_after(1, 7): - self.d_optimizer.zero_grad() - else: - self.d_optimizer.zero_grad(set_to_none=self.optim_set_to_none) + self.d_optimizer.zero_grad(set_to_none=self.optim_set_to_none) dloss = self.d_loss_function(g_output, d_input) dloss.backward() self.d_optimizer.step() @@ -385,10 +377,7 @@ def _iteration( non_blocking=engine.non_blocking, # type: ignore ) g_output = self.g_inferer(g_input, self.g_network) - if not pytorch_after(1, 7): - self.g_optimizer.zero_grad() - else: - self.g_optimizer.zero_grad(set_to_none=self.optim_set_to_none) + self.g_optimizer.zero_grad(set_to_none=self.optim_set_to_none) g_loss = self.g_loss_function(g_output) g_loss.backward() self.g_optimizer.step() diff --git a/monai/networks/layers/simplelayers.py b/monai/networks/layers/simplelayers.py index 7a0a45cb64..3de4e75766 100644 --- a/monai/networks/layers/simplelayers.py +++ b/monai/networks/layers/simplelayers.py @@ -20,19 +20,11 @@ from monai.networks.layers.convutils import gaussian_1d from monai.networks.layers.factories import Conv -from monai.utils import ( - ChannelMatching, - InvalidPyTorchVersionError, - SkipMode, - look_up_option, - optional_import, - pytorch_after, -) +from monai.utils import ChannelMatching, SkipMode, look_up_option, optional_import, pytorch_after from monai.utils.misc import issequenceiterable _C, _ = optional_import("monai._C") -if pytorch_after(1, 7): - fft, _ = optional_import("torch.fft") +fft, _ = optional_import("torch.fft") __all__ = [ "ChannelPad", @@ -377,7 +369,6 @@ def _make_coeffs(window_length, order): class HilbertTransform(nn.Module): """ Determine the analytical signal of a Tensor along a particular axis. - Requires PyTorch 1.7.0+ and the PyTorch FFT module (which is not included in NVIDIA PyTorch Release 20.10). Args: axis: Axis along which to apply Hilbert transform. Default 2 (first spatial dimension). @@ -386,9 +377,6 @@ class HilbertTransform(nn.Module): def __init__(self, axis: int = 2, n: Union[int, None] = None) -> None: - if not pytorch_after(1, 7): - raise InvalidPyTorchVersionError("1.7.0", self.__class__.__name__) - super().__init__() self.axis = axis self.n = n diff --git a/monai/networks/utils.py b/monai/networks/utils.py index a6b0699107..b8c986268a 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -502,7 +502,6 @@ def convert_to_torchscript( filename_or_obj: if not None, specify a file-like object (has to implement write and flush) or a string containing a file path name to save the TorchScript model. extra_files: map from filename to contents which will be stored as part of the save model file. - works for PyTorch 1.7 or later. for more details: https://pytorch.org/docs/stable/generated/torch.jit.save.html. verify: whether to verify the input and output of TorchScript model. if `filename_or_obj` is not None, load the saved TorchScript model and verify. @@ -519,10 +518,7 @@ def convert_to_torchscript( with torch.no_grad(): script_module = torch.jit.script(model, **kwargs) if filename_or_obj is not None: - if not pytorch_after(1, 7): - torch.jit.save(m=script_module, f=filename_or_obj) - else: - torch.jit.save(m=script_module, f=filename_or_obj, _extra_files=extra_files) + torch.jit.save(m=script_module, f=filename_or_obj, _extra_files=extra_files) if verify: if device is None: diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index da46b105e1..06b8cfa108 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -30,14 +30,12 @@ from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array from monai.transforms.utils_pytorch_numpy_unification import clip, percentile, where from monai.utils import ( - InvalidPyTorchVersionError, convert_data_type, convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, - pytorch_after, ) from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends @@ -1085,7 +1083,6 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: class DetectEnvelope(Transform): """ Find the envelope of the input data along the requested axis using a Hilbert transform. - Requires PyTorch 1.7.0+ and the PyTorch FFT module (which is not included in NVIDIA PyTorch Release 20.10). Args: axis: Axis along which to detect the envelope. Default 1, i.e. the first spatial dimension. @@ -1098,9 +1095,6 @@ class DetectEnvelope(Transform): def __init__(self, axis: int = 1, n: Union[int, None] = None) -> None: - if not pytorch_after(1, 7): - raise InvalidPyTorchVersionError("1.7.0", self.__class__.__name__) - if axis < 0: raise ValueError("axis must be zero or positive.") diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 2103ccff58..2aedc77dd7 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -15,7 +15,7 @@ import torch from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor -from monai.utils.misc import ensure_tuple, is_module_ver_at_least +from monai.utils.misc import is_module_ver_at_least from monai.utils.type_conversion import convert_data_type, convert_to_dst_type __all__ = [ @@ -54,31 +54,12 @@ def allclose(a: NdarrayTensor, b: NdarrayOrTensor, rtol=1e-5, atol=1e-8, equal_n def moveaxis(x: NdarrayOrTensor, src: Union[int, Sequence[int]], dst: Union[int, Sequence[int]]) -> NdarrayOrTensor: - """`moveaxis` for pytorch and numpy, using `permute` for pytorch version < 1.7""" + """`moveaxis` for pytorch and numpy""" if isinstance(x, torch.Tensor): - if hasattr(torch, "movedim"): # `movedim` is new in torch 1.7.0 - # torch.moveaxis is a recent alias since torch 1.8.0 - return torch.movedim(x, src, dst) # type: ignore - return _moveaxis_with_permute(x, src, dst) + return torch.movedim(x, src, dst) # type: ignore return np.moveaxis(x, src, dst) -def _moveaxis_with_permute( - x: torch.Tensor, src: Union[int, Sequence[int]], dst: Union[int, Sequence[int]] -) -> torch.Tensor: - # get original indices - indices = list(range(x.ndim)) - len_indices = len(indices) - for s, d in zip(ensure_tuple(src), ensure_tuple(dst)): - # make src and dst positive - # remove desired index and insert it in new position - pos_s = len_indices + s if s < 0 else s - pos_d = len_indices + d if d < 0 else d - indices.pop(pos_s) - indices.insert(pos_d, pos_s) - return x.permute(indices) - - def in1d(x, y): """`np.in1d` with equivalent implementation for torch.""" if isinstance(x, np.ndarray): @@ -101,10 +82,7 @@ def percentile( ) -> Union[NdarrayOrTensor, float, int]: """`np.percentile` with equivalent implementation for torch. - Pytorch uses `quantile`, but this functionality is only available from v1.7. - For earlier methods, we calculate it ourselves. This doesn't do interpolation, - so is the equivalent of ``numpy.percentile(..., interpolation="nearest")``. - For more details, please refer to: + Pytorch uses `quantile`. For more details please refer to: https://pytorch.org/docs/stable/generated/torch.quantile.html. https://numpy.org/doc/stable/reference/generated/numpy.percentile.html. @@ -112,7 +90,7 @@ def percentile( x: input data q: percentile to compute (should in range 0 <= q <= 100) dim: the dim along which the percentiles are computed. default is to compute the percentile - along a flattened version of the array. only work for numpy array or Tensor with PyTorch >= 1.7.0. + along a flattened version of the array. keepdim: whether the output data has dim retained or not. kwargs: if `x` is numpy array, additional args for `np.percentile`, more details: https://numpy.org/doc/stable/reference/generated/numpy.percentile.html. @@ -130,18 +108,7 @@ def percentile( result = np.percentile(x, q, axis=dim, keepdims=keepdim, **kwargs) else: q = torch.tensor(q, device=x.device) - if hasattr(torch, "quantile"): # `quantile` is new in torch 1.7.0 - result = torch.quantile(x, q / 100.0, dim=dim, keepdim=keepdim) - else: - # Note that ``kthvalue()`` works one-based, i.e., the first sorted value - # corresponds to k=1, not k=0. Thus, we need the `1 +`. - k = 1 + (0.01 * q * (x.numel() - 1)).round().int() - if k.numel() > 1: - r = [x.view(-1).kthvalue(int(_k)).values.item() for _k in k] - result = torch.tensor(r, device=x.device) - else: - result = x.view(-1).kthvalue(int(k)).values.item() - + result = torch.quantile(x, q / 100.0, dim=dim, keepdim=keepdim) return result @@ -277,8 +244,6 @@ def any_np_pt(x: NdarrayOrTensor, axis: Union[int, Sequence[int]]) -> NdarrayOrT def maximum(a: NdarrayOrTensor, b: NdarrayOrTensor) -> NdarrayOrTensor: """`np.maximum` with equivalent implementation for torch. - `torch.maximum` only available from pt>1.6, else use `torch.stack` and `torch.max`. - Args: a: first array/tensor b: second array/tensor @@ -287,10 +252,7 @@ def maximum(a: NdarrayOrTensor, b: NdarrayOrTensor) -> NdarrayOrTensor: Element-wise maximum between two arrays/tensors. """ if isinstance(a, torch.Tensor) and isinstance(b, torch.Tensor): - # is torch and has torch.maximum (pt>1.6) - if hasattr(torch, "maximum"): # `maximum` is new in torch 1.7.0 - return torch.maximum(a, b) - return torch.stack((a, b)).max(dim=0)[0] + return torch.maximum(a, b) return np.maximum(a, b) diff --git a/pyproject.toml b/pyproject.toml index 03e9f49ab5..eea4ebf9b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ requires = [ "wheel", "setuptools", - "torch>=1.6", + "torch>=1.7", "ninja", ] diff --git a/requirements.txt b/requirements.txt index e4ea34b5d4..14eb2b30e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -torch>=1.6 +torch>=1.7 numpy>=1.17 diff --git a/setup.cfg b/setup.cfg index a7d597d6bd..12f974ca6d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,7 +24,7 @@ setup_requires = torch ninja install_requires = - torch>=1.6 + torch>=1.7 numpy>=1.17 [options.extras_require] diff --git a/tests/test_torchscript_utils.py b/tests/test_torchscript_utils.py index d6bea09ed6..cdf2f19eb3 100644 --- a/tests/test_torchscript_utils.py +++ b/tests/test_torchscript_utils.py @@ -18,7 +18,6 @@ from monai.config import get_config_values from monai.data import load_net_with_metadata, save_net_with_metadata from monai.utils import JITMetadataKeys -from monai.utils.module import pytorch_after class TestModule(torch.nn.Module): @@ -102,10 +101,7 @@ def test_save_load_more_extra_files(self): _, _, loaded_extra_files = load_net_with_metadata(f"{tempdir}/test.ts", more_extra_files=("test.txt",)) - if pytorch_after(1, 7): - self.assertEqual(more_extra_files["test.txt"], loaded_extra_files["test.txt"]) - else: - self.assertEqual(more_extra_files["test.txt"].decode(), loaded_extra_files["test.txt"]) + self.assertEqual(more_extra_files["test.txt"], loaded_extra_files["test.txt"]) if __name__ == "__main__": diff --git a/tests/test_utils_pytorch_numpy_unification.py b/tests/test_utils_pytorch_numpy_unification.py index b13378debe..6adf7093bf 100644 --- a/tests/test_utils_pytorch_numpy_unification.py +++ b/tests/test_utils_pytorch_numpy_unification.py @@ -12,7 +12,6 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms.utils_pytorch_numpy_unification import mode, percentile @@ -37,10 +36,7 @@ def test_percentile(self): for p in TEST_NDARRAYS: arr = p(np.arange(100 * 101).reshape(1, 100, 101).astype(np.float32)) results.append(percentile(arr, q)) - # pre torch 1.7, no `quantile`. Our own method doesn't interpolate, - # so we can only be accurate to 0.5 - atol = 0.5 if not hasattr(torch, "quantile") else 1e-4 - assert_allclose(results[0], results[-1], type_test=False, atol=atol) + assert_allclose(results[0], results[-1], type_test=False, atol=1e-4) def test_fails(self): for p in TEST_NDARRAYS: @@ -56,10 +52,7 @@ def test_dim(self): for p in TEST_NDARRAYS: arr = p(np.arange(6).reshape(1, 2, 3).astype(np.float32)) results.append(percentile(arr, q, dim=1)) - # pre torch 1.7, no `quantile`. Our own method doesn't interpolate, - # so we can only be accurate to 0.5 - atol = 0.5 if not hasattr(torch, "quantile") else 1e-4 - assert_allclose(results[0], results[-1], type_test=False, atol=atol) + assert_allclose(results[0], results[-1], type_test=False, atol=1e-4) @parameterized.expand(TEST_MODE) def test_mode(self, array, expected, to_long): From 4e7ca44c2451adaf1abf40d4f2f7d021314644f2 Mon Sep 17 00:00:00 2001 From: ramonemiliani93 Date: Tue, 12 Apr 2022 10:37:44 -0500 Subject: [PATCH 013/183] Make `pixelshuffle` scriptable (#4109) * Update the existing functionality to comply with the `torchscript.jit.script` function. Signed-off-by: Ramon Emiliani --- monai/networks/utils.py | 12 +++++++----- tests/test_subpixel_upsample.py | 8 ++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index b8c986268a..f22be31524 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -281,14 +281,16 @@ def pixelshuffle( f"divisible by scale_factor ** dimensions ({factor}**{dim}={scale_divisor})." ) - org_channels = channels // scale_divisor + org_channels = int(channels // scale_divisor) output_size = [batch_size, org_channels] + [d * factor for d in input_size[2:]] - indices = tuple(range(2, 2 + 2 * dim)) - indices_factor, indices_dim = indices[:dim], indices[dim:] - permute_indices = (0, 1) + sum(zip(indices_dim, indices_factor), ()) + indices = list(range(2, 2 + 2 * dim)) + indices = indices[dim:] + indices[:dim] + permute_indices = [0, 1] + for idx in range(dim): + permute_indices.extend(indices[idx::dim]) - x = x.reshape(batch_size, org_channels, *([factor] * dim + input_size[2:])) + x = x.reshape([batch_size, org_channels] + [factor] * dim + input_size[2:]) x = x.permute(permute_indices).reshape(output_size) return x diff --git a/tests/test_subpixel_upsample.py b/tests/test_subpixel_upsample.py index 0216f164c3..3e5370473c 100644 --- a/tests/test_subpixel_upsample.py +++ b/tests/test_subpixel_upsample.py @@ -18,6 +18,7 @@ from monai.networks import eval_mode from monai.networks.blocks import SubpixelUpsample from monai.networks.layers.factories import Conv +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save TEST_CASE_SUBPIXEL = [] for inch in range(1, 5): @@ -73,6 +74,13 @@ def test_subpixel_shape(self, input_param, input_shape, expected_shape): result = net.forward(torch.randn(input_shape)) self.assertEqual(result.shape, expected_shape) + @SkipIfBeforePyTorchVersion((1, 8, 1)) + def test_script(self): + input_param, input_shape, _ = TEST_CASE_SUBPIXEL[0] + net = SubpixelUpsample(**input_param) + test_data = torch.randn(input_shape) + test_script_save(net, test_data) + if __name__ == "__main__": unittest.main() From 1880d386ffa0faf4e0f27763b20cbd00059d0842 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 12 Apr 2022 19:18:12 +0100 Subject: [PATCH 014/183] meta tensor (#4077) * meta tensor Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- docs/source/data.rst | 11 ++ monai/data/__init__.py | 2 + monai/data/meta_obj.py | 207 ++++++++++++++++++++++++++++ monai/data/meta_tensor.py | 148 ++++++++++++++++++++ tests/test_meta_tensor.py | 275 ++++++++++++++++++++++++++++++++++++++ tests/utils.py | 9 +- 6 files changed, 650 insertions(+), 2 deletions(-) create mode 100644 monai/data/meta_obj.py create mode 100644 monai/data/meta_tensor.py create mode 100644 tests/test_meta_tensor.py diff --git a/docs/source/data.rst b/docs/source/data.rst index e76eb53f39..0910001783 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -271,3 +271,14 @@ ThreadDataLoader TestTimeAugmentation ~~~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.TestTimeAugmentation + + +Meta Object +----------- +.. automodule:: monai.data.meta_obj + :members: + +MetaTensor +---------- +.. autoclass:: monai.data.MetaTensor + :members: diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 53aa3d3f46..cdab2a1037 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -46,6 +46,8 @@ resolve_writer, ) from .iterable_dataset import CSVIterableDataset, IterableDataset, ShuffleBuffer +from .meta_obj import get_track_meta, get_track_transforms, set_track_meta, set_track_transforms +from .meta_tensor import MetaTensor from .nifti_saver import NiftiSaver from .nifti_writer import write_nifti from .png_saver import PNGSaver diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py new file mode 100644 index 0000000000..d60ec6e473 --- /dev/null +++ b/monai/data/meta_obj.py @@ -0,0 +1,207 @@ +# 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 + +from copy import deepcopy +from typing import Any, Callable, Sequence + +_TRACK_META = True +_TRACK_TRANSFORMS = True + +__all__ = ["get_track_meta", "get_track_transforms", "set_track_meta", "set_track_transforms", "MetaObj"] + + +def set_track_meta(val: bool) -> None: + """ + Boolean to set whether metadata is tracked. If `True`, metadata will be associated + its data by using subclasses of `MetaObj`. If `False`, then data will be returned + with empty metadata. + + If both `set_track_meta` and `set_track_transforms` are set to + `False`, then standard data objects will be returned (e.g., `torch.Tensor` and + `np.ndarray`) as opposed to our enhanced objects. + + By default, this is `True`, and most users will want to leave it this way. However, + if you are experiencing any problems regarding metadata, and aren't interested in + preserving metadata, then you can disable it. + """ + global _TRACK_META + _TRACK_META = val + + +def set_track_transforms(val: bool) -> None: + """ + Boolean to set whether transforms are tracked. If `True`, applied transforms will be + associated its data by using subclasses of `MetaObj`. If `False`, then transforms + won't be tracked. + + If both `set_track_meta` and `set_track_transforms` are set to + `False`, then standard data objects will be returned (e.g., `torch.Tensor` and + `np.ndarray`) as opposed to our enhanced objects. + + By default, this is `True`, and most users will want to leave it this way. However, + if you are experiencing any problems regarding transforms, and aren't interested in + preserving transforms, then you can disable it. + """ + global _TRACK_TRANSFORMS + _TRACK_TRANSFORMS = val + + +def get_track_meta() -> bool: + """ + Return the boolean as to whether metadata is tracked. If `True`, metadata will be + associated its data by using subclasses of `MetaObj`. If `False`, then data will be + returned with empty metadata. + + If both `set_track_meta` and `set_track_transforms` are set to + `False`, then standard data objects will be returned (e.g., `torch.Tensor` and + `np.ndarray`) as opposed to our enhanced objects. + + By default, this is `True`, and most users will want to leave it this way. However, + if you are experiencing any problems regarding metadata, and aren't interested in + preserving metadata, then you can disable it. + """ + return _TRACK_META + + +def get_track_transforms() -> bool: + """ + Return the boolean as to whether transforms are tracked. If `True`, applied + transforms will be associated its data by using subclasses of `MetaObj`. If `False`, + then transforms won't be tracked. + + If both `set_track_meta` and `set_track_transforms` are set to + `False`, then standard data objects will be returned (e.g., `torch.Tensor` and + `np.ndarray`) as opposed to our enhanced objects. + + By default, this is `True`, and most users will want to leave it this way. However, + if you are experiencing any problems regarding transforms, and aren't interested in + preserving transforms, then you can disable it. + """ + return _TRACK_TRANSFORMS + + +class MetaObj: + """ + Abstract base class that stores data as well as any extra metadata. + + This allows for subclassing `torch.Tensor` and `np.ndarray` through multiple + inheritance. + + Metadata is stored in the form of a dictionary. + + Behavior should be the same as extended class (e.g., `torch.Tensor` or `np.ndarray`) + aside from the extended meta functionality. + + Copying of information: + + * For `c = a + b`, then auxiliary data (e.g., metadata) will be copied from the + first instance of `MetaObj`. + + """ + + _meta: dict + + @staticmethod + def flatten_meta_objs(args: Sequence[Any]) -> list[MetaObj]: + """ + Recursively flatten input and return all instances of `MetaObj` as a single + list. This means that for both `torch.add(a, b)`, `torch.stack([a, b])` (and + their numpy equivalents), we return `[a, b]` if both `a` and `b` are of type + `MetaObj`. + + Args: + args: Sequence of inputs to be flattened. + Returns: + list of nested `MetaObj` from input. + """ + out = [] + for a in args: + if isinstance(a, (list, tuple)): + out += MetaObj.flatten_meta_objs(a) + elif isinstance(a, MetaObj): + out.append(a) + return out + + def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: + """ + Copy an attribute from the first in a list of `MetaObj`. In the case of + `torch.add(a, b)`, both `a` and `b` could be `MetaObj` or something else, so + check them all. Copy the first to `self`. + + We also perform a deep copy of the data if desired. + + Args: + attribute: string corresponding to attribute to be copied (e.g., `meta`). + input_objs: List of `MetaObj`. We'll copy the attribute from the first one + that contains that particular attribute. + default_fn: If none of `input_objs` have the attribute that we're + interested in, then use this default function (e.g., `lambda: {}`.) + deep_copy: Should the attribute be deep copied? See `_copy_meta`. + + Returns: + Returns `None`, but `self` should be updated to have the copied attribute. + """ + attributes = [getattr(i, attribute) for i in input_objs] + if len(attributes) > 0: + val = attributes[0] + if deep_copy: + val = deepcopy(val) + setattr(self, attribute, val) + else: + setattr(self, attribute, default_fn()) + + def _copy_meta(self, input_objs: list[MetaObj]) -> None: + """ + Copy metadata from a list of `MetaObj`. For a given attribute, we copy the + adjunct data from the first element in the list containing that attribute. + + If there has been a change in `id` (e.g., `a=b+c`), then deepcopy. Else (e.g., + `a+=1`), then don't. + + Args: + input_objs: list of `MetaObj` to copy data from. + + """ + id_in = id(input_objs[0]) if len(input_objs) > 0 else None + deep_copy = id(self) != id_in + self._copy_attr("meta", input_objs, self.get_default_meta, deep_copy) + + def get_default_meta(self) -> dict: + """Get the default meta. + + Returns: + default metadata. + """ + return {} + + def __repr__(self) -> str: + """String representation of class.""" + out: str = super().__repr__() + + out += "\nMetaData\n" + if self.meta is not None: + out += "".join(f"\t{k}: {v}\n" for k, v in self.meta.items()) + else: + out += "None" + + return out + + @property + def meta(self) -> dict: + """Get the meta.""" + return self._meta + + @meta.setter + def meta(self, d: dict) -> None: + """Set the meta.""" + self._meta = d diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py new file mode 100644 index 0000000000..c5b95f8d08 --- /dev/null +++ b/monai/data/meta_tensor.py @@ -0,0 +1,148 @@ +# 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 warnings +from typing import Callable + +import torch + +from monai.data.meta_obj import MetaObj, get_track_meta, get_track_transforms +from monai.utils.enums import PostFix + +__all__ = ["MetaTensor"] + + +class MetaTensor(MetaObj, torch.Tensor): + """ + Class that inherits from both `torch.Tensor` and `MetaObj`, adding support for metadata. + + Metadata is stored in the form of a dictionary. Nested, an affine matrix will be + stored. This should be in the form of `torch.Tensor`. + + Behavior should be the same as `torch.Tensor` aside from the extended + meta functionality. + + Copying of information: + + * For `c = a + b`, then auxiliary data (e.g., metadata) will be copied from the + first instance of `MetaTensor`. + + Example: + .. code-block:: python + + import torch + from monai.data import MetaTensor + + t = torch.tensor([1,2,3]) + affine = torch.eye(4) * 100 + meta = {"some": "info"} + m = MetaTensor(t, affine=affine, meta=meta) + m2 = m+m + assert isinstance(m2, MetaTensor) + assert m2.meta["some"] == "info" + assert m2.affine == affine + + Notes: + - Older versions of pytorch (<=1.8), `torch.jit.trace(net, im)` may + not work if `im` is of type `MetaTensor`. This can be resolved with + `torch.jit.trace(net, im.as_tensor())`. + - A warning will be raised if in the constructor `affine` is not `None` and + `meta` already contains the key `affine`. + """ + + @staticmethod + def __new__(cls, x, affine: torch.Tensor | None = None, meta: dict | None = None, *args, **kwargs) -> MetaTensor: + """ + If `meta` is given, use it. Else, if `meta` exists in the input tensor, use it. + Else, use the default value. Similar for the affine, except this could come from + four places. + Priority: `affine`, `meta["affine"]`, `x.affine`, `get_default_affine`. + """ + out: MetaTensor = torch.as_tensor(x, *args, **kwargs).as_subclass(cls) # type: ignore + # set meta + if meta is not None: + out.meta = meta + elif isinstance(x, MetaObj): + out.meta = x.meta + else: + out.meta = out.get_default_meta() + # set the affine + if affine is not None: + if "affine" in out.meta: + warnings.warn("Setting affine, but the applied meta contains an affine. " "This will be overwritten.") + out.affine = affine + elif "affine" in out.meta: + pass # nothing to do + elif isinstance(x, MetaTensor): + out.affine = x.affine + else: + out.affine = out.get_default_affine() + out.affine = out.affine.to(out.device) + + return out + + def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: + super()._copy_attr(attribute, input_objs, default_fn, deep_copy) + val = getattr(self, attribute) + if isinstance(val, torch.Tensor): + setattr(self, attribute, val.to(self.device)) + + @classmethod + def __torch_function__(cls, func, types, args=(), kwargs=None) -> torch.Tensor: + """Wraps all torch functions.""" + if kwargs is None: + kwargs = {} + ret: MetaTensor = super().__torch_function__(func, types, args, kwargs) + # e.g., __repr__ returns a string + if not isinstance(ret, torch.Tensor): + return ret + if not (get_track_meta() or get_track_transforms()): + return ret.as_tensor() + meta_args = MetaObj.flatten_meta_objs(list(args) + list(kwargs.values())) + ret._copy_meta(meta_args) + ret.affine = ret.affine.to(ret.device) + return ret + + def get_default_affine(self) -> torch.Tensor: + return torch.eye(4, device=self.device) + + def as_tensor(self) -> torch.Tensor: + """ + Return the `MetaTensor` as a `torch.Tensor`. + It is OS dependent as to whether this will be a deep copy or not. + """ + return self.as_subclass(torch.Tensor) # type: ignore + + def as_dict(self, key: str) -> dict: + """ + Get the object as a dictionary for backwards compatibility. + + Args: + key: Base key to store main data. The key for the metadata will be + determined using `PostFix.meta`. + + Return: + A dictionary consisting of two keys, the main data (stored under `key`) and + the metadata. + """ + return {key: self.as_tensor(), PostFix.meta(key): self.meta} + + @property + def affine(self) -> torch.Tensor: + """Get the affine.""" + return self.meta["affine"] # type: ignore + + @affine.setter + def affine(self, d: torch.Tensor) -> None: + """Set the affine.""" + self.meta["affine"] = d diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py new file mode 100644 index 0000000000..1721e7d2b9 --- /dev/null +++ b/tests/test_meta_tensor.py @@ -0,0 +1,275 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import random +import string +import tempfile +import unittest +import warnings +from copy import deepcopy +from typing import Optional, Union + +import torch +from parameterized import parameterized + +from monai.data.meta_obj import get_track_meta, get_track_transforms, set_track_meta, set_track_transforms +from monai.data.meta_tensor import MetaTensor +from monai.utils.enums import PostFix +from monai.utils.module import get_torch_version_tuple +from tests.utils import TEST_DEVICES, assert_allclose, skip_if_no_cuda + +PT_VER_MAJ, PT_VER_MIN = get_torch_version_tuple() + +DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32]] +TESTS = [] +for _device in TEST_DEVICES: + for _dtype in DTYPES: + TESTS.append((*_device, *_dtype)) + + +def rand_string(min_len=5, max_len=10): + str_size = random.randint(min_len, max_len) + chars = string.ascii_letters + string.punctuation + return "".join(random.choice(chars) for _ in range(str_size)) + + +class TestMetaTensor(unittest.TestCase): + @staticmethod + def get_im(shape=None, dtype=None, device=None): + if shape is None: + shape = shape = (1, 10, 8) + affine = torch.randint(0, 10, (4, 4)) + meta = {"fname": rand_string()} + t = torch.rand(shape) + if dtype is not None: + t = t.to(dtype) + if device is not None: + t = t.to(device) + m = MetaTensor(t.clone(), affine, meta) + return m, t + + def check_ids(self, a, b, should_match): + comp = self.assertEqual if should_match else self.assertNotEqual + comp(id(a), id(b)) + + def check( + self, + out: torch.Tensor, + orig: torch.Tensor, + *, + shape: bool = True, + vals: bool = True, + ids: bool = True, + device: Optional[Union[str, torch.device]] = None, + meta: bool = True, + check_ids: bool = True, + **kwargs, + ): + if device is None: + device = orig.device + + # check the image + self.assertIsInstance(out, type(orig)) + if shape: + assert_allclose(torch.as_tensor(out.shape), torch.as_tensor(orig.shape)) + if vals: + assert_allclose(out, orig, **kwargs) + if check_ids: + self.check_ids(out, orig, ids) + self.assertTrue(str(device) in str(out.device)) + + # check meta and affine are equal and affine is on correct device + if isinstance(orig, MetaTensor) and isinstance(out, MetaTensor) and meta: + orig_meta_no_affine = deepcopy(orig.meta) + del orig_meta_no_affine["affine"] + out_meta_no_affine = deepcopy(out.meta) + del out_meta_no_affine["affine"] + self.assertEqual(orig_meta_no_affine, out_meta_no_affine) + assert_allclose(out.affine, orig.affine) + self.assertTrue(str(device) in str(out.affine.device)) + if check_ids: + self.check_ids(out.affine, orig.affine, ids) + self.check_ids(out.meta, orig.meta, ids) + + @parameterized.expand(TESTS) + def test_as_tensor(self, device, dtype): + m, t = self.get_im(device=device, dtype=dtype) + t2 = m.as_tensor() + self.assertIsInstance(t2, torch.Tensor) + self.assertNotIsInstance(t2, MetaTensor) + self.assertIsInstance(m, MetaTensor) + self.check(t, t2, ids=False) + + def test_as_dict(self): + m, _ = self.get_im() + m_dict = m.as_dict("im") + im, meta = m_dict["im"], m_dict[PostFix.meta("im")] + affine = meta.pop("affine") + m2 = MetaTensor(im, affine, meta) + self.check(m2, m, check_ids=False) + + @parameterized.expand(TESTS) + def test_constructor(self, device, dtype): + m, t = self.get_im(device=device, dtype=dtype) + # construct from pre-existing + m1 = MetaTensor(m.clone()) + self.check(m, m1, ids=False, meta=False) + # meta already has affine + m2 = MetaTensor(t.clone(), meta=m.meta) + self.check(m, m2, ids=False, meta=False) + # meta dosen't have affine + affine = m.meta.pop("affine") + m3 = MetaTensor(t.clone(), affine=affine, meta=m.meta) + self.check(m, m3, ids=False, meta=False) + + @parameterized.expand(TESTS) + @skip_if_no_cuda + def test_to_cuda(self, device, dtype): + """Test `to`, `cpu` and `cuda`. For `to`, check args and kwargs.""" + orig, _ = self.get_im(device=device, dtype=dtype) + m = orig.clone() + m = m.to("cuda") + self.check(m, orig, ids=False, device="cuda") + m = m.cpu() + self.check(m, orig, ids=False, device="cpu") + m = m.cuda() + self.check(m, orig, ids=False, device="cuda") + m = m.to("cpu") + self.check(m, orig, ids=False, device="cpu") + m = m.to(device="cuda") + self.check(m, orig, ids=False, device="cuda") + m = m.to(device="cpu") + self.check(m, orig, ids=False, device="cpu") + + @skip_if_no_cuda + def test_affine_device(self): + m, _ = self.get_im() # device="cuda") + m.affine = torch.eye(4) + self.assertEqual(m.device, m.affine.device) + + @parameterized.expand(TESTS) + def test_copy(self, device, dtype): + m, _ = self.get_im(device=device, dtype=dtype) + # shallow copy + a = m + self.check(a, m, ids=True) + # deepcopy + a = deepcopy(m) + self.check(a, m, ids=False) + # clone + a = m.clone() + self.check(a, m, ids=False) + + @parameterized.expand(TESTS) + def test_add(self, device, dtype): + m1, t1 = self.get_im(device=device, dtype=dtype) + m2, t2 = self.get_im(device=device, dtype=dtype) + self.check(m1 + m2, t1 + t2, ids=False) + self.check(torch.add(m1, m2), t1 + t2, ids=False) + self.check(torch.add(input=m1, other=m2), t1 + t2, ids=False) + self.check(torch.add(m1, other=m2), t1 + t2, ids=False) + m3 = deepcopy(m2) + t3 = deepcopy(t2) + m3 += 3 + t3 += 3 + self.check(m3, t3, ids=False) + # check torch.Tensor+MetaTensor and MetaTensor+torch.Tensor + self.check(torch.add(m1, t2), t1 + t2, ids=False) + self.check(torch.add(t2, m1), t1 + t2, ids=False) + + @parameterized.expand(TEST_DEVICES) + def test_conv(self, device): + im, _ = self.get_im((1, 3, 10, 8, 12), device=device) + conv = torch.nn.Conv3d(im.shape[1], 5, 3) + conv.to(device) + out = conv(im) + self.check(out, im, shape=False, vals=False, ids=False) + + @parameterized.expand(TESTS) + def test_stack(self, device, dtype): + numel = 3 + ims = [self.get_im(device=device, dtype=dtype)[0] for _ in range(numel)] + stacked = torch.stack(ims) + self.assertIsInstance(stacked, MetaTensor) + orig_affine = ims[0].meta.pop("affine") + stacked_affine = stacked.meta.pop("affine") + assert_allclose(orig_affine, stacked_affine) + self.assertEqual(stacked.meta, ims[0].meta) + + def test_get_set_meta_fns(self): + set_track_meta(False) + self.assertEqual(get_track_meta(), False) + set_track_meta(True) + self.assertEqual(get_track_meta(), True) + set_track_transforms(False) + self.assertEqual(get_track_transforms(), False) + set_track_transforms(True) + self.assertEqual(get_track_transforms(), True) + + @parameterized.expand(TEST_DEVICES) + def test_torchscript(self, device): + shape = (1, 3, 10, 8) + im, _ = self.get_im(shape, device=device) + conv = torch.nn.Conv2d(im.shape[1], 5, 3) + conv.to(device) + im_conv = conv(im) + traced_fn = torch.jit.trace(conv, im.as_tensor()) + # save it, load it, use it + with tempfile.TemporaryDirectory() as tmp_dir: + fname = os.path.join(tmp_dir, "im.pt") + torch.jit.save(traced_fn, f=fname) + traced_fn = torch.jit.load(fname) + out = traced_fn(im) + self.assertIsInstance(out, torch.Tensor) + if not isinstance(out, MetaTensor) and PT_VER_MAJ == 1 and PT_VER_MIN <= 9: + warnings.warn( + "When calling `nn.Module(MetaTensor) on a module traced with " + "`torch.jit.trace`, your version of pytorch returns a " + "`torch.Tensor` instead of a `MetaTensor`. Consider upgrading " + "your pytorch version if this is important to you." + ) + im_conv = im_conv.as_tensor() + self.check(out, im_conv, ids=False) + + def test_pickling(self): + m, _ = self.get_im() + with tempfile.TemporaryDirectory() as tmp_dir: + fname = os.path.join(tmp_dir, "im.pt") + torch.save(m, fname) + m2 = torch.load(fname) + if not isinstance(m2, MetaTensor) and PT_VER_MAJ == 1 and PT_VER_MIN <= 7: + warnings.warn("Old version of pytorch. pickling converts `MetaTensor` to `torch.Tensor`.") + m = m.as_tensor() + self.check(m2, m, ids=False) + + @skip_if_no_cuda + def test_amp(self): + shape = (1, 3, 10, 8) + device = "cuda" + im, _ = self.get_im(shape, device=device) + conv = torch.nn.Conv2d(im.shape[1], 5, 3) + conv.to(device) + im_conv = conv(im) + with torch.cuda.amp.autocast(): + im_conv2 = conv(im) + self.check(im_conv2, im_conv, ids=False, rtol=1e-4, atol=1e-3) + + # TODO + # collate + # decollate + # dataset + # dataloader + # matplotlib + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/utils.py b/tests/utils.py index 3065f9b3df..c4f9bd1b70 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -102,8 +102,8 @@ def assert_allclose( if isinstance(desired, torch.Tensor) or isinstance(actual, torch.Tensor): if device_test: np.testing.assert_equal(str(actual.device), str(desired.device), "torch device check") # type: ignore - actual = actual.cpu().numpy() if isinstance(actual, torch.Tensor) else actual - desired = desired.cpu().numpy() if isinstance(desired, torch.Tensor) else desired + actual = actual.detach().cpu().numpy() if isinstance(actual, torch.Tensor) else actual + desired = desired.detach().cpu().numpy() if isinstance(desired, torch.Tensor) else desired np.testing.assert_allclose(actual, desired, *args, **kwargs) @@ -715,5 +715,10 @@ def query_memory(n=2): TEST_NDARRAYS = TEST_NDARRAYS + (gpu_tensor,) # type: ignore +TEST_DEVICES = [[torch.device("cpu")]] +if torch.cuda.is_available(): + TEST_DEVICES.append([torch.device("cuda")]) + + if __name__ == "__main__": print(query_memory()) From f8c265531c29e9add7ecf38f887474d2b815447d Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 13 Apr 2022 21:01:31 +0800 Subject: [PATCH 015/183] 4084 Add kwargs for `Tensor.to()` in engines (#4112) * [DLMED] add kwargs for to() API Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma Co-authored-by: monai-bot --- monai/engines/evaluator.py | 20 +++++++- monai/engines/trainer.py | 18 ++++++- monai/engines/utils.py | 62 ++++++++++++++++++------- monai/engines/workflow.py | 4 ++ tests/test_integration_workflows.py | 2 + tests/test_integration_workflows_gan.py | 1 + 6 files changed, 86 insertions(+), 21 deletions(-) diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index c3e8c456b7..f9dab35450 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -74,6 +74,8 @@ class Evaluator(Workflow): decollate: whether to decollate the batch-first data to a list of data after model computation, recommend `decollate=True` when `postprocessing` uses components from `monai.transforms`. default to `True`. + to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for + `device`, `non_blocking`. """ @@ -95,6 +97,7 @@ def __init__( event_names: Optional[List[Union[str, EventEnum]]] = None, event_to_attr: Optional[dict] = None, decollate: bool = True, + to_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -113,6 +116,7 @@ def __init__( event_names=event_names, event_to_attr=event_to_attr, decollate=decollate, + to_kwargs=to_kwargs, ) mode = look_up_option(mode, ForwardMode) if mode == ForwardMode.EVAL: @@ -181,6 +185,8 @@ class SupervisedEvaluator(Evaluator): decollate: whether to decollate the batch-first data to a list of data after model computation, recommend `decollate=True` when `postprocessing` uses components from `monai.transforms`. default to `True`. + to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for + `device`, `non_blocking`. """ @@ -204,6 +210,7 @@ def __init__( event_names: Optional[List[Union[str, EventEnum]]] = None, event_to_attr: Optional[dict] = None, decollate: bool = True, + to_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -222,6 +229,7 @@ def __init__( event_names=event_names, event_to_attr=event_to_attr, decollate=decollate, + to_kwargs=to_kwargs, ) self.network = network @@ -245,7 +253,9 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ if batchdata is None: raise ValueError("Must provide batch data for current iteration.") - batch = self.prepare_batch(batchdata, engine.state.device, engine.non_blocking) # type: ignore + batch = self.prepare_batch( + batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore + ) if len(batch) == 2: inputs, targets = batch args: Tuple = () @@ -314,6 +324,8 @@ class EnsembleEvaluator(Evaluator): decollate: whether to decollate the batch-first data to a list of data after model computation, recommend `decollate=True` when `postprocessing` uses components from `monai.transforms`. default to `True`. + to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for + `device`, `non_blocking`. """ @@ -338,6 +350,7 @@ def __init__( event_names: Optional[List[Union[str, EventEnum]]] = None, event_to_attr: Optional[dict] = None, decollate: bool = True, + to_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -356,6 +369,7 @@ def __init__( event_names=event_names, event_to_attr=event_to_attr, decollate=decollate, + to_kwargs=to_kwargs, ) self.networks = ensure_tuple(networks) @@ -387,7 +401,9 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ if batchdata is None: raise ValueError("Must provide batch data for current iteration.") - batch = self.prepare_batch(batchdata, engine.state.device, engine.non_blocking) # type: ignore + batch = self.prepare_batch( + batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore + ) if len(batch) == 2: inputs, targets = batch args: Tuple = () diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index a58387a5ef..16c50d4fa2 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -105,6 +105,8 @@ class SupervisedTrainer(Trainer): default to `True`. optim_set_to_none: when calling `optimizer.zero_grad()`, instead of setting to zero, set the grads to None. more details: https://pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html. + to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for + `device`, `non_blocking`. """ @@ -131,6 +133,7 @@ def __init__( event_to_attr: Optional[dict] = None, decollate: bool = True, optim_set_to_none: bool = False, + to_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -149,6 +152,7 @@ def __init__( event_names=event_names, event_to_attr=event_to_attr, decollate=decollate, + to_kwargs=to_kwargs, ) self.network = network @@ -176,7 +180,9 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ if batchdata is None: raise ValueError("Must provide batch data for current iteration.") - batch = self.prepare_batch(batchdata, engine.state.device, engine.non_blocking) # type: ignore + batch = self.prepare_batch( + batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore + ) if len(batch) == 2: inputs, targets = batch args: Tuple = () @@ -267,6 +273,8 @@ class GanTrainer(Trainer): default to `True`. optim_set_to_none: when calling `optimizer.zero_grad()`, instead of setting to zero, set the grads to None. more details: https://pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html. + to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for + `device`, `non_blocking`. """ @@ -298,6 +306,7 @@ def __init__( train_handlers: Optional[Sequence] = None, decollate: bool = True, optim_set_to_none: bool = False, + to_kwargs: Optional[Dict] = None, ): if not isinstance(train_data_loader, DataLoader): raise ValueError("train_data_loader must be PyTorch DataLoader.") @@ -317,6 +326,7 @@ def __init__( handlers=train_handlers, postprocessing=postprocessing, decollate=decollate, + to_kwargs=to_kwargs, ) self.g_network = g_network self.g_optimizer = g_optimizer @@ -349,13 +359,16 @@ def _iteration( if batchdata is None: raise ValueError("must provide batch data for current iteration.") - d_input = self.prepare_batch(batchdata, engine.state.device, engine.non_blocking) # type: ignore + d_input = self.prepare_batch( + batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore + ) batch_size = self.data_loader.batch_size # type: ignore g_input = self.g_prepare_batch( num_latents=batch_size, latent_size=self.latent_shape, device=engine.state.device, # type: ignore non_blocking=engine.non_blocking, # type: ignore + **engine.to_kwargs, # type: ignore ) g_output = self.g_inferer(g_input, self.g_network) @@ -375,6 +388,7 @@ def _iteration( latent_size=self.latent_shape, device=engine.state.device, # type: ignore non_blocking=engine.non_blocking, # type: ignore + **engine.to_kwargs, # type: ignore ) g_output = self.g_inferer(g_input, self.g_network) self.g_optimizer.zero_grad(set_to_none=self.optim_set_to_none) diff --git a/monai/engines/utils.py b/monai/engines/utils.py index 726dfc8e98..8f3a57beda 100644 --- a/monai/engines/utils.py +++ b/monai/engines/utils.py @@ -104,12 +104,16 @@ def get_devices_spec(devices: Optional[Sequence[torch.device]] = None) -> List[t def default_prepare_batch( - batchdata: Dict[str, torch.Tensor], device: Optional[Union[str, torch.device]] = None, non_blocking: bool = False + batchdata: Dict[str, torch.Tensor], + device: Optional[Union[str, torch.device]] = None, + non_blocking: bool = False, + **kwargs, ) -> Union[Tuple[torch.Tensor, Optional[torch.Tensor]], torch.Tensor]: """ Default function to prepare the data for current iteration. - Refer to ignite: https://pytorch.org/ignite/v0.4.5/generated/ignite.engine.create_supervised_trainer.html - #ignite.engine.create_supervised_trainer. + Args `batchdata`, `device`, `non_blocking` refer to the ignite API: + https://pytorch.org/ignite/v0.4.8/generated/ignite.engine.create_supervised_trainer.html. + `kwargs` supports other args for `Tensor.to()` API. Returns: image, label(optional). @@ -119,18 +123,21 @@ def default_prepare_batch( raise AssertionError("default prepare_batch expects dictionary input data.") if isinstance(batchdata.get(CommonKeys.LABEL), torch.Tensor): return ( - batchdata[CommonKeys.IMAGE].to(device=device, non_blocking=non_blocking), - batchdata[CommonKeys.LABEL].to(device=device, non_blocking=non_blocking), + batchdata[CommonKeys.IMAGE].to(device=device, non_blocking=non_blocking, **kwargs), + batchdata[CommonKeys.LABEL].to(device=device, non_blocking=non_blocking, **kwargs), ) if GanKeys.REALS in batchdata: - return batchdata[GanKeys.REALS].to(device=device, non_blocking=non_blocking) - return batchdata[CommonKeys.IMAGE].to(device=device, non_blocking=non_blocking), None + return batchdata[GanKeys.REALS].to(device=device, non_blocking=non_blocking, **kwargs) + return batchdata[CommonKeys.IMAGE].to(device=device, non_blocking=non_blocking, **kwargs), None class PrepareBatch(ABC): """ Interface of customized prepare_batch in the trainer or evaluator workflows. It takes the data of current batch, target device and non_blocking flag as input. + Args `batchdata`, `device`, `non_blocking` refer to the ignite API: + https://pytorch.org/ignite/v0.4.8/generated/ignite.engine.create_supervised_trainer.html. + `kwargs` supports other args for `Tensor.to()` API. """ @@ -140,6 +147,7 @@ def __call__( batchdata: Dict[str, torch.Tensor], device: Optional[Union[str, torch.device]] = None, non_blocking: bool = False, + **kwargs, ): raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -155,8 +163,15 @@ def __call__( batchdata: Dict[str, torch.Tensor], device: Optional[Union[str, torch.device]] = None, non_blocking: bool = False, + **kwargs, ): - return default_prepare_batch(batchdata, device, non_blocking) + """ + Args `batchdata`, `device`, `non_blocking` refer to the ignite API: + https://pytorch.org/ignite/v0.4.8/generated/ignite.engine.create_supervised_trainer.html. + `kwargs` supports other args for `Tensor.to()` API. + + """ + return default_prepare_batch(batchdata, device, non_blocking, **kwargs) class PrepareBatchExtraInput(PrepareBatch): @@ -181,29 +196,42 @@ def __call__( batchdata: Dict[str, torch.Tensor], device: Optional[Union[str, torch.device]] = None, non_blocking: bool = False, + **kwargs, ): - image, label = default_prepare_batch(batchdata, device, non_blocking) - args = list() - kwargs = dict() + """ + Args `batchdata`, `device`, `non_blocking` refer to the ignite API: + https://pytorch.org/ignite/v0.4.8/generated/ignite.engine.create_supervised_trainer.html. + `kwargs` supports other args for `Tensor.to()` API. + + """ + image, label = default_prepare_batch(batchdata, device, non_blocking, **kwargs) + args_ = list() + kwargs_ = dict() def _get_data(key: str): data = batchdata[key] - return data.to(device=device, non_blocking=non_blocking) if isinstance(data, torch.Tensor) else data + return ( + data.to(device=device, non_blocking=non_blocking, **kwargs) if isinstance(data, torch.Tensor) else data + ) if isinstance(self.extra_keys, (str, list, tuple)): for k in ensure_tuple(self.extra_keys): - args.append(_get_data(k)) + args_.append(_get_data(k)) elif isinstance(self.extra_keys, dict): for k, v in self.extra_keys.items(): - kwargs.update({k: _get_data(v)}) + kwargs_.update({k: _get_data(v)}) - return image, label, tuple(args), kwargs + return image, label, tuple(args_), kwargs_ def default_make_latent( - num_latents: int, latent_size: int, device: Optional[Union[str, torch.device]] = None, non_blocking: bool = False + num_latents: int, + latent_size: int, + device: Optional[Union[str, torch.device]] = None, + non_blocking: bool = False, + **kwargs, ) -> torch.Tensor: - return torch.randn(num_latents, latent_size).to(device=device, non_blocking=non_blocking) + return torch.randn(num_latents, latent_size).to(device=device, non_blocking=non_blocking, **kwargs) def engine_apply_transform(batch: Any, output: Any, transform: Callable[..., Dict]): diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 65bb313e53..4ea0a69d55 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -94,6 +94,8 @@ class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optiona decollate: whether to decollate the batch-first data to a list of data after model computation, recommend `decollate=True` when `postprocessing` uses components from `monai.transforms`. default to `True`. + to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for + `device`, `non_blocking`. Raises: TypeError: When ``device`` is not a ``torch.Device``. @@ -121,6 +123,7 @@ def __init__( event_names: Optional[List[Union[str, EventEnum]]] = None, event_to_attr: Optional[dict] = None, decollate: bool = True, + to_kwargs: Optional[Dict] = None, ) -> None: if iteration_update is not None: super().__init__(iteration_update) @@ -166,6 +169,7 @@ def set_sampler_epoch(engine: Engine): self.prepare_batch = prepare_batch self.metric_cmp_fn = metric_cmp_fn self.amp = amp + self.to_kwargs = {} if to_kwargs is None else to_kwargs self.scaler: Optional[torch.cuda.amp.GradScaler] = None if event_names is None: diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index 2e515772d3..fafdf43522 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -148,6 +148,7 @@ def _forward_completed(self, engine): metric_cmp_fn=lambda cur, prev: cur >= prev, # if greater or equal, treat as new best metric val_handlers=val_handlers, amp=bool(amp), + to_kwargs={"memory_format": torch.preserve_format}, ) train_postprocessing = Compose( @@ -202,6 +203,7 @@ def _model_completed(self, engine): train_handlers=train_handlers, amp=bool(amp), optim_set_to_none=True, + to_kwargs={"memory_format": torch.preserve_format}, ) trainer.run() diff --git a/tests/test_integration_workflows_gan.py b/tests/test_integration_workflows_gan.py index c9306b349f..f65a30450a 100644 --- a/tests/test_integration_workflows_gan.py +++ b/tests/test_integration_workflows_gan.py @@ -117,6 +117,7 @@ def generator_loss(gen_images): latent_shape=latent_size, key_train_metric=key_train_metric, train_handlers=train_handlers, + to_kwargs={"memory_format": torch.preserve_format, "dtype": torch.float32}, ) trainer.run() From ccac5ff8b48a4e813e1fb639598168aaf61780d0 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 13 Apr 2022 15:00:36 +0100 Subject: [PATCH 016/183] fixes pytorch version tests (#4127) Signed-off-by: Wenqi Li --- tests/test_meta_tensor.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 1721e7d2b9..7688950a4b 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -24,11 +24,9 @@ from monai.data.meta_obj import get_track_meta, get_track_transforms, set_track_meta, set_track_transforms from monai.data.meta_tensor import MetaTensor from monai.utils.enums import PostFix -from monai.utils.module import get_torch_version_tuple +from monai.utils.module import pytorch_after from tests.utils import TEST_DEVICES, assert_allclose, skip_if_no_cuda -PT_VER_MAJ, PT_VER_MIN = get_torch_version_tuple() - DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32]] TESTS = [] for _device in TEST_DEVICES: @@ -230,7 +228,7 @@ def test_torchscript(self, device): traced_fn = torch.jit.load(fname) out = traced_fn(im) self.assertIsInstance(out, torch.Tensor) - if not isinstance(out, MetaTensor) and PT_VER_MAJ == 1 and PT_VER_MIN <= 9: + if not isinstance(out, MetaTensor) and not pytorch_after(1, 9, 1): warnings.warn( "When calling `nn.Module(MetaTensor) on a module traced with " "`torch.jit.trace`, your version of pytorch returns a " @@ -246,7 +244,7 @@ def test_pickling(self): fname = os.path.join(tmp_dir, "im.pt") torch.save(m, fname) m2 = torch.load(fname) - if not isinstance(m2, MetaTensor) and PT_VER_MAJ == 1 and PT_VER_MIN <= 7: + if not isinstance(m2, MetaTensor) and not pytorch_after(1, 8, 1): warnings.warn("Old version of pytorch. pickling converts `MetaTensor` to `torch.Tensor`.") m = m.as_tensor() self.check(m2, m, ids=False) From 137164d2c977f6475af0461a6699b8c28ac9af4c Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 14 Apr 2022 08:51:43 +0100 Subject: [PATCH 017/183] update meta tensor api (#4131) * update meta tensor api Signed-off-by: Wenqi Li * update based on comments Signed-off-by: Wenqi Li --- monai/data/__init__.py | 2 +- monai/data/meta_obj.py | 3 ++- monai/data/meta_tensor.py | 31 +++++++++++++++---------------- tests/test_meta_tensor.py | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index cdab2a1037..19ca29eafa 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -46,7 +46,7 @@ resolve_writer, ) from .iterable_dataset import CSVIterableDataset, IterableDataset, ShuffleBuffer -from .meta_obj import get_track_meta, get_track_transforms, set_track_meta, set_track_transforms +from .meta_obj import MetaObj, get_track_meta, get_track_transforms, set_track_meta, set_track_transforms from .meta_tensor import MetaTensor from .nifti_saver import NiftiSaver from .nifti_writer import write_nifti diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index d60ec6e473..0e213f130b 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -109,7 +109,8 @@ class MetaObj: """ - _meta: dict + def __init__(self): + self._meta: dict = self.get_default_meta() @staticmethod def flatten_meta_objs(args: Sequence[Any]) -> list[MetaObj]: diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index c5b95f8d08..30270d89e2 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -62,34 +62,33 @@ class MetaTensor(MetaObj, torch.Tensor): @staticmethod def __new__(cls, x, affine: torch.Tensor | None = None, meta: dict | None = None, *args, **kwargs) -> MetaTensor: + return torch.as_tensor(x, *args, **kwargs).as_subclass(cls) # type: ignore + + def __init__(self, x, affine: torch.Tensor | None = None, meta: dict | None = None) -> None: """ If `meta` is given, use it. Else, if `meta` exists in the input tensor, use it. Else, use the default value. Similar for the affine, except this could come from four places. Priority: `affine`, `meta["affine"]`, `x.affine`, `get_default_affine`. """ - out: MetaTensor = torch.as_tensor(x, *args, **kwargs).as_subclass(cls) # type: ignore + super().__init__() # set meta if meta is not None: - out.meta = meta + self.meta = meta elif isinstance(x, MetaObj): - out.meta = x.meta - else: - out.meta = out.get_default_meta() + self.meta = x.meta # set the affine if affine is not None: - if "affine" in out.meta: - warnings.warn("Setting affine, but the applied meta contains an affine. " "This will be overwritten.") - out.affine = affine - elif "affine" in out.meta: + if "affine" in self.meta: + warnings.warn("Setting affine, but the applied meta contains an affine. This will be overwritten.") + self.affine = affine + elif "affine" in self.meta: pass # nothing to do elif isinstance(x, MetaTensor): - out.affine = x.affine + self.affine = x.affine else: - out.affine = out.get_default_affine() - out.affine = out.affine.to(out.device) - - return out + self.affine = self.get_default_affine() + self.affine = self.affine.to(self.device) def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: super()._copy_attr(attribute, input_objs, default_fn, deep_copy) @@ -113,8 +112,8 @@ def __torch_function__(cls, func, types, args=(), kwargs=None) -> torch.Tensor: ret.affine = ret.affine.to(ret.device) return ret - def get_default_affine(self) -> torch.Tensor: - return torch.eye(4, device=self.device) + def get_default_affine(self, dtype=torch.float64) -> torch.Tensor: + return torch.eye(4, device=self.device, dtype=dtype) def as_tensor(self) -> torch.Tensor: """ diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 7688950a4b..c18ef08b85 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -44,7 +44,7 @@ class TestMetaTensor(unittest.TestCase): @staticmethod def get_im(shape=None, dtype=None, device=None): if shape is None: - shape = shape = (1, 10, 8) + shape = (1, 10, 8) affine = torch.randint(0, 10, (4, 4)) meta = {"fname": rand_string()} t = torch.rand(shape) From 5e180c613449face6df3cde5243ea131f82e5e8d Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 14 Apr 2022 12:14:29 +0100 Subject: [PATCH 018/183] runtests.sh isort (#4134) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- runtests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtests.sh b/runtests.sh index 19a4a97444..6d2da4a6e7 100755 --- a/runtests.sh +++ b/runtests.sh @@ -407,7 +407,7 @@ then then install_deps fi - ${cmdPrefix}isort --version + ${cmdPrefix}${PY_EXE} -m isort --version if [ $doIsortFix = true ] then From 8544d9e8aa2fb6b3665998aa6fa1c1abb6e1305f Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 14 Apr 2022 13:19:29 +0100 Subject: [PATCH 019/183] update citation (#4133) Signed-off-by: Wenqi Li --- CITATION.cff | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 8cbf686ce6..dcce4af377 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,11 +6,15 @@ title: "MONAI: Medical Open Network for AI" abstract: "AI Toolkit for Healthcare Imaging" authors: - name: "MONAI Consortium" -date-released: 2020-03-28 -version: "0.6.0" -doi: "10.5281/zenodo.4323058" +date-released: 2022-02-16 +version: "0.8.1" +identifiers: + - description: "This DOI represents all versions of MONAI, and will always resolve to the latest one." + type: doi + value: "10.5281/zenodo.4323058" license: "Apache-2.0" repository-code: "https://github.com/Project-MONAI/MONAI" -cff-version: "1.1.0" +url: "https://monai.io" +cff-version: "1.2.0" message: "If you use this software, please cite it using these metadata." ... From 443fc0cf11e644d1b85ae2a6a43e7820ecdc604c Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 14 Apr 2022 15:50:21 +0100 Subject: [PATCH 020/183] `ToMetaTensor` and `FromMetaTensor` transforms (#4115) to and from meta --- docs/source/transforms.rst | 15 ++ monai/data/meta_tensor.py | 5 + monai/transforms/__init__.py | 8 + monai/transforms/meta_utility/__init__.py | 10 ++ monai/transforms/meta_utility/dictionary.py | 102 +++++++++++ monai/utils/enums.py | 18 +- tests/test_module_list.py | 1 + tests/test_to_from_meta_tensord.py | 182 ++++++++++++++++++++ 8 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 monai/transforms/meta_utility/__init__.py create mode 100644 monai/transforms/meta_utility/dictionary.py create mode 100644 tests/test_to_from_meta_tensord.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 78fb303093..676e0274fe 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -1842,6 +1842,21 @@ Utility (Dict) :members: :special-members: __call__ +MetaTensor +^^^^^^^^^^ + +`ToMetaTensord` +""""""""""""""" +.. autoclass:: ToMetaTensord + :members: + :special-members: __call__ + +`FromMetaTensord` +""""""""""""""""" +.. autoclass:: FromMetaTensord + :members: + :special-members: __call__ + Transform Adaptors ------------------ .. automodule:: monai.transforms.adaptors diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index 30270d89e2..ba80f93e74 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -12,6 +12,7 @@ from __future__ import annotations import warnings +from copy import deepcopy from typing import Callable import torch @@ -88,6 +89,10 @@ def __init__(self, x, affine: torch.Tensor | None = None, meta: dict | None = No self.affine = x.affine else: self.affine = self.get_default_affine() + + # if we are creating a new MetaTensor, then deep copy attributes + if isinstance(x, torch.Tensor) and not isinstance(x, MetaTensor): + self.meta = deepcopy(self.meta) self.affine = self.affine.to(self.device) def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 8e6ccc8b94..581e368ba0 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -206,6 +206,14 @@ from .inverse_batch_transform import BatchInverseTransform, Decollated, DecollateD, DecollateDict from .io.array import SUPPORTED_READERS, LoadImage, SaveImage from .io.dictionary import LoadImaged, LoadImageD, LoadImageDict, SaveImaged, SaveImageD, SaveImageDict +from .meta_utility.dictionary import ( + FromMetaTensord, + FromMetaTensorD, + FromMetaTensorDict, + ToMetaTensord, + ToMetaTensorD, + ToMetaTensorDict, +) from .nvtx import ( Mark, Markd, diff --git a/monai/transforms/meta_utility/__init__.py b/monai/transforms/meta_utility/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/transforms/meta_utility/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/transforms/meta_utility/dictionary.py b/monai/transforms/meta_utility/dictionary.py new file mode 100644 index 0000000000..1a9cf4c631 --- /dev/null +++ b/monai/transforms/meta_utility/dictionary.py @@ -0,0 +1,102 @@ +# 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. +""" +A collection of dictionary-based wrappers for moving between MetaTensor types and dictionaries of data. +These can be used to make backwards compatible code. + +Class names are ended with 'd' to denote dictionary-based transforms. +""" + +from copy import deepcopy +from typing import Dict, Hashable, Mapping + +from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_tensor import MetaTensor +from monai.transforms.inverse import InvertibleTransform +from monai.transforms.transform import MapTransform +from monai.utils.enums import PostFix, TransformBackends + +__all__ = [ + "FromMetaTensord", + "FromMetaTensorD", + "FromMetaTensorDict", + "ToMetaTensord", + "ToMetaTensorD", + "ToMetaTensorDict", +] + + +class FromMetaTensord(MapTransform, InvertibleTransform): + """ + Dictionary-based transform to convert MetaTensor to a dictionary. + + If input is `{"a": MetaTensor, "b": MetaTensor}`, then output will + have the form `{"a": torch.Tensor, "a_meta_dict": dict, "b": ...}`. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + self.push_transform(d, key) + im: MetaTensor = d[key] # type: ignore + d.update(im.as_dict(key)) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + # check transform + _ = self.get_most_recent_transform(d, key) + # do the inverse + im, meta = d[key], d.pop(PostFix.meta(key), None) + im = MetaTensor(im, meta=meta) # type: ignore + d[key] = im + # Remove the applied transform + self.pop_transform(d, key) + return d + + +class ToMetaTensord(MapTransform, InvertibleTransform): + """ + Dictionary-based transform to convert a dictionary to MetaTensor. + + If input is `{"a": torch.Tensor, "a_meta_dict": dict, "b": ...}`, then output will + have the form `{"a": MetaTensor, "b": MetaTensor}`. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + self.push_transform(d, key) + im, meta = d[key], d.pop(PostFix.meta(key), None) + im = MetaTensor(im, meta=meta) # type: ignore + d[key] = im + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + # check transform + _ = self.get_most_recent_transform(d, key) + # do the inverse + im: MetaTensor = d[key] # type: ignore + d.update(im.as_dict(key)) + # Remove the applied transform + self.pop_transform(d, key) + return d + + +FromMetaTensorD = FromMetaTensorDict = FromMetaTensord +ToMetaTensorD = ToMetaTensorDict = ToMetaTensord diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 4bc3d6ee84..1bfbdf824b 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -227,13 +227,13 @@ class ForwardMode(Enum): class TraceKeys: """Extra meta data keys used for traceable transforms.""" - CLASS_NAME = "class" - ID = "id" - ORIG_SIZE = "orig_size" - EXTRA_INFO = "extra_info" - DO_TRANSFORM = "do_transforms" - KEY_SUFFIX = "_transforms" - NONE = "none" + CLASS_NAME: str = "class" + ID: str = "id" + ORIG_SIZE: str = "orig_size" + EXTRA_INFO: str = "extra_info" + DO_TRANSFORM: str = "do_transforms" + KEY_SUFFIX: str = "_transforms" + NONE: str = "none" @deprecated(since="0.8.0", msg_suffix="use monai.utils.enums.TraceKeys instead.") @@ -287,6 +287,10 @@ def meta(key: Optional[str] = None): def orig_meta(key: Optional[str] = None): return PostFix._get_str(key, "orig_meta_dict") + @staticmethod + def transforms(key: Optional[str] = None): + return PostFix._get_str(key, TraceKeys.KEY_SUFFIX[1:]) + class TransformBackends(Enum): """ diff --git a/tests/test_module_list.py b/tests/test_module_list.py index 83c6979f30..d81d067c58 100644 --- a/tests/test_module_list.py +++ b/tests/test_module_list.py @@ -40,6 +40,7 @@ def test_transform_api(self): to_exclude = {"MapTransform"} # except for these transforms to_exclude_docs = {"Decollate", "Ensemble", "Invert", "SaveClassification", "RandTorchVision"} to_exclude_docs.update({"DeleteItems", "SelectItems", "CopyItems", "ConcatItems"}) + to_exclude_docs.update({"ToMetaTensor", "FromMetaTensor"}) xforms = { name: obj for name, obj in monai.transforms.__dict__.items() diff --git a/tests/test_to_from_meta_tensord.py b/tests/test_to_from_meta_tensord.py new file mode 100644 index 0000000000..9bbf4592ab --- /dev/null +++ b/tests/test_to_from_meta_tensord.py @@ -0,0 +1,182 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import string +import unittest +from copy import deepcopy +from typing import Optional, Union + +import torch +from parameterized import parameterized + +from monai.data.meta_tensor import MetaTensor +from monai.transforms import FromMetaTensord, ToMetaTensord +from monai.utils.enums import PostFix +from monai.utils.module import get_torch_version_tuple +from tests.utils import TEST_DEVICES, assert_allclose + +PT_VER_MAJ, PT_VER_MIN = get_torch_version_tuple() + +DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32]] +TESTS = [] +for _device in TEST_DEVICES: + for _dtype in DTYPES: + TESTS.append((*_device, *_dtype)) + + +def rand_string(min_len=5, max_len=10): + str_size = random.randint(min_len, max_len) + chars = string.ascii_letters + string.punctuation + return "".join(random.choice(chars) for _ in range(str_size)) + + +class TestToFromMetaTensord(unittest.TestCase): + @staticmethod + def get_im(shape=None, dtype=None, device=None): + if shape is None: + shape = shape = (1, 10, 8) + affine = torch.randint(0, 10, (4, 4)) + meta = {"fname": rand_string()} + t = torch.rand(shape) + if dtype is not None: + t = t.to(dtype) + if device is not None: + t = t.to(device) + m = MetaTensor(t.clone(), affine, meta) + return m + + def check_ids(self, a, b, should_match): + comp = self.assertEqual if should_match else self.assertNotEqual + comp(id(a), id(b)) + + def check( + self, + out: torch.Tensor, + orig: torch.Tensor, + *, + shape: bool = True, + vals: bool = True, + ids: bool = True, + device: Optional[Union[str, torch.device]] = None, + meta: bool = True, + check_ids: bool = True, + **kwargs, + ): + if device is None: + device = orig.device + + # check the image + self.assertIsInstance(out, type(orig)) + if shape: + assert_allclose(torch.as_tensor(out.shape), torch.as_tensor(orig.shape)) + if vals: + assert_allclose(out, orig, **kwargs) + if check_ids: + self.check_ids(out, orig, ids) + self.assertTrue(str(device) in str(out.device)) + + # check meta and affine are equal and affine is on correct device + if isinstance(orig, MetaTensor) and isinstance(out, MetaTensor) and meta: + orig_meta_no_affine = deepcopy(orig.meta) + del orig_meta_no_affine["affine"] + out_meta_no_affine = deepcopy(out.meta) + del out_meta_no_affine["affine"] + self.assertEqual(orig_meta_no_affine, out_meta_no_affine) + assert_allclose(out.affine, orig.affine) + self.assertTrue(str(device) in str(out.affine.device)) + if check_ids: + self.check_ids(out.affine, orig.affine, ids) + self.check_ids(out.meta, orig.meta, ids) + + @parameterized.expand(TESTS) + def test_from_to_meta_tensord(self, device, dtype): + m1 = self.get_im(device=device, dtype=dtype) + m2 = self.get_im(device=device, dtype=dtype) + m3 = self.get_im(device=device, dtype=dtype) + d_metas = {"m1": m1, "m2": m2, "m3": m3} + m1_meta = {k: v for k, v in m1.meta.items() if k != "affine"} + m1_aff = m1.affine + + # FROM -> forward + t_from_meta = FromMetaTensord(["m1", "m2"]) + d_dict = t_from_meta(d_metas) + + self.assertEqual( + sorted(d_dict.keys()), + [ + "m1", + PostFix.meta("m1"), + PostFix.transforms("m1"), + "m2", + PostFix.meta("m2"), + PostFix.transforms("m2"), + "m3", + ], + ) + self.check(d_dict["m3"], m3, ids=True) # unchanged + self.check(d_dict["m1"], m1.as_tensor(), ids=False) + meta_out = {k: v for k, v in d_dict["m1_meta_dict"].items() if k != "affine"} + aff_out = d_dict["m1_meta_dict"]["affine"] + self.check(aff_out, m1_aff, ids=True) + self.assertEqual(meta_out, m1_meta) + + # FROM -> inverse + d_meta_dict_meta = t_from_meta.inverse(d_dict) + self.assertEqual( + sorted(d_meta_dict_meta.keys()), ["m1", PostFix.transforms("m1"), "m2", PostFix.transforms("m2"), "m3"] + ) + self.check(d_meta_dict_meta["m3"], m3, ids=False) # unchanged (except deep copy in inverse) + self.check(d_meta_dict_meta["m1"], m1, ids=False) + meta_out = {k: v for k, v in d_meta_dict_meta["m1"].meta.items() if k != "affine"} + aff_out = d_meta_dict_meta["m1"].affine + self.check(aff_out, m1_aff, ids=False) + self.assertEqual(meta_out, m1_meta) + + # TO -> Forward + t_to_meta = ToMetaTensord(["m1", "m2"]) + del d_dict["m1_transforms"] + del d_dict["m2_transforms"] + d_dict_meta = t_to_meta(d_dict) + self.assertEqual( + sorted(d_dict_meta.keys()), ["m1", PostFix.transforms("m1"), "m2", PostFix.transforms("m2"), "m3"] + ) + self.check(d_dict_meta["m3"], m3, ids=True) # unchanged (except deep copy in inverse) + self.check(d_dict_meta["m1"], m1, ids=False) + meta_out = {k: v for k, v in d_dict_meta["m1"].meta.items() if k != "affine"} + aff_out = d_dict_meta["m1"].meta["affine"] + self.check(aff_out, m1_aff, ids=False) + self.assertEqual(meta_out, m1_meta) + + # TO -> Inverse + d_dict_meta_dict = t_to_meta.inverse(d_dict_meta) + self.assertEqual( + sorted(d_dict_meta_dict.keys()), + [ + "m1", + PostFix.meta("m1"), + PostFix.transforms("m1"), + "m2", + PostFix.meta("m2"), + PostFix.transforms("m2"), + "m3", + ], + ) + self.check(d_dict_meta_dict["m3"], m3.as_tensor(), ids=False) # unchanged (except deep copy in inverse) + self.check(d_dict_meta_dict["m1"], m1.as_tensor(), ids=False) + meta_out = {k: v for k, v in d_dict_meta_dict["m1_meta_dict"].items() if k != "affine"} + aff_out = d_dict_meta_dict["m1_meta_dict"]["affine"] + self.check(aff_out, m1_aff, ids=False) + self.assertEqual(meta_out, m1_meta) + + +if __name__ == "__main__": + unittest.main() From cc9c06649bbc94e3dda9bda36a99d369934bb7b0 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 14 Apr 2022 22:22:05 +0100 Subject: [PATCH 021/183] no skip if before pytorch 1.7 (#4139) * no skip if before pytorch 1.7 Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- tests/test_cachedataset_persistent_workers.py | 2 -- tests/test_detect_envelope.py | 13 ++---------- tests/test_dice_ce_loss.py | 3 +-- tests/test_dice_focal_loss.py | 3 +-- tests/test_dice_loss.py | 3 +-- tests/test_focal_loss.py | 3 +-- tests/test_gaussian_filter.py | 3 +-- tests/test_generalized_dice_loss.py | 3 +-- .../test_generalized_wasserstein_dice_loss.py | 3 +-- tests/test_handler_smartcache.py | 2 -- tests/test_hilbert_transform.py | 20 ++----------------- tests/test_integration_fast_train.py | 3 +-- .../test_keep_largest_connected_component.py | 3 +-- tests/test_masked_loss.py | 3 +-- tests/test_multi_scale.py | 3 +-- tests/test_randtorchvisiond.py | 2 -- tests/test_torchvision.py | 3 +-- tests/test_torchvisiond.py | 2 -- tests/test_tversky_loss.py | 3 +-- tests/test_utils_pytorch_numpy_unification.py | 3 +-- 20 files changed, 18 insertions(+), 65 deletions(-) diff --git a/tests/test_cachedataset_persistent_workers.py b/tests/test_cachedataset_persistent_workers.py index 4bea0486bc..8cef298be7 100644 --- a/tests/test_cachedataset_persistent_workers.py +++ b/tests/test_cachedataset_persistent_workers.py @@ -13,10 +13,8 @@ from monai.data import CacheDataset, DataLoader, create_test_image_2d from monai.transforms import Compose, RandAffined, Spacingd -from tests.utils import SkipIfBeforePyTorchVersion -@SkipIfBeforePyTorchVersion((1, 7)) class TestTransformsWCacheDatasetAndPersistentWorkers(unittest.TestCase): def test_duplicate_transforms(self): data = [{"img": create_test_image_2d(128, 128, num_seg_classes=1, channel_dim=0)[0]} for _ in range(2)] diff --git a/tests/test_detect_envelope.py b/tests/test_detect_envelope.py index f1b9c7ad1a..5ea82a463d 100644 --- a/tests/test_detect_envelope.py +++ b/tests/test_detect_envelope.py @@ -16,8 +16,8 @@ from parameterized import parameterized from monai.transforms import DetectEnvelope -from monai.utils import InvalidPyTorchVersionError, OptionalImportError -from tests.utils import SkipIfAtLeastPyTorchVersion, SkipIfBeforePyTorchVersion, SkipIfModule, SkipIfNoModule +from monai.utils import OptionalImportError +from tests.utils import SkipIfModule, SkipIfNoModule n_samples = 500 hann_windowed_sine = np.sin(2 * np.pi * 10 * np.linspace(0, 1, n_samples)) * np.hanning(n_samples) @@ -112,7 +112,6 @@ TEST_CASE_INVALID_OBJ = [{}, "a string", "__call__"] # method expected to raise exception -@SkipIfBeforePyTorchVersion((1, 7)) @SkipIfNoModule("torch.fft") class TestDetectEnvelope(unittest.TestCase): @parameterized.expand( @@ -147,19 +146,11 @@ def test_value_error(self, arguments, image, method): raise ValueError("Expected raising method invalid. Should be __init__ or __call__.") -@SkipIfBeforePyTorchVersion((1, 7)) @SkipIfModule("torch.fft") class TestHilbertTransformNoFFTMod(unittest.TestCase): def test_no_fft_module_error(self): self.assertRaises(OptionalImportError, DetectEnvelope(), np.random.rand(1, 10)) -@SkipIfAtLeastPyTorchVersion((1, 7)) -class TestDetectEnvelopeInvalidPyTorch(unittest.TestCase): - def test_invalid_pytorch_error(self): - with self.assertRaisesRegex(InvalidPyTorchVersionError, "version"): - DetectEnvelope() - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_dice_ce_loss.py b/tests/test_dice_ce_loss.py index ff2cd00b02..83ad5b8d9a 100644 --- a/tests/test_dice_ce_loss.py +++ b/tests/test_dice_ce_loss.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.losses import DiceCELoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save TEST_CASES = [ [ # shape: (2, 2, 3), (2, 1, 3) @@ -85,7 +85,6 @@ def test_ill_reduction(self): loss = DiceCELoss(reduction="none") loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = DiceCELoss() test_input = torch.ones(2, 1, 8, 8) diff --git a/tests/test_dice_focal_loss.py b/tests/test_dice_focal_loss.py index c611fe4160..b77a36e720 100644 --- a/tests/test_dice_focal_loss.py +++ b/tests/test_dice_focal_loss.py @@ -15,7 +15,7 @@ import torch from monai.losses import DiceFocalLoss, DiceLoss, FocalLoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save class TestDiceFocalLoss(unittest.TestCase): @@ -61,7 +61,6 @@ def test_ill_lambda(self): with self.assertRaisesRegex(ValueError, ""): DiceFocalLoss(lambda_dice=-1.0) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = DiceFocalLoss() test_input = torch.ones(2, 1, 8, 8) diff --git a/tests/test_dice_loss.py b/tests/test_dice_loss.py index 4e45393de6..223b09e624 100644 --- a/tests/test_dice_loss.py +++ b/tests/test_dice_loss.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.losses import DiceLoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save TEST_CASES = [ [ # shape: (1, 1, 2, 2), (1, 1, 2, 2) @@ -184,7 +184,6 @@ def test_input_warnings(self): loss = DiceLoss(to_onehot_y=True) loss.forward(chn_input, chn_target) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = DiceLoss() test_input = torch.ones(2, 1, 8, 8) diff --git a/tests/test_focal_loss.py b/tests/test_focal_loss.py index d8a9c8ab5b..5a063ba6c8 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -17,7 +17,7 @@ from monai.losses import FocalLoss from monai.networks import one_hot -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save class TestFocalLoss(unittest.TestCase): @@ -261,7 +261,6 @@ def test_ill_class_weight(self): with self.assertRaisesRegex(ValueError, ""): FocalLoss(include_background=False, weight=(1.0, 1.0, -1.0))(chn_input, chn_target) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = FocalLoss() test_input = torch.ones(2, 2, 8, 8) diff --git a/tests/test_gaussian_filter.py b/tests/test_gaussian_filter.py index 9d76e44cec..c4ffe56896 100644 --- a/tests/test_gaussian_filter.py +++ b/tests/test_gaussian_filter.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.networks.layers import GaussianFilter -from tests.utils import SkipIfBeforePyTorchVersion, skip_if_quick +from tests.utils import skip_if_quick TEST_CASES = [[{"type": "erf", "gt": 2.0}], [{"type": "scalespace", "gt": 3.0}], [{"type": "sampled", "gt": 5.0}]] TEST_CASES_GPU = [[{"type": "erf", "gt": 0.8, "device": "cuda"}], [{"type": "sampled", "gt": 5.0, "device": "cuda"}]] @@ -82,7 +82,6 @@ def code_to_run(self, input_args): ) @parameterized.expand(TEST_CASES + TEST_CASES_GPU + TEST_CASES_3d) - @SkipIfBeforePyTorchVersion((1, 7)) def test_train_quick(self, input_args): self.code_to_run(input_args) diff --git a/tests/test_generalized_dice_loss.py b/tests/test_generalized_dice_loss.py index fa301201e4..81f8f4c0b0 100644 --- a/tests/test_generalized_dice_loss.py +++ b/tests/test_generalized_dice_loss.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.losses import GeneralizedDiceLoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save TEST_CASES = [ [ # shape: (1, 1, 2, 2), (1, 1, 2, 2) @@ -193,7 +193,6 @@ def test_batch(self): loss = generalized_dice_loss(prediction, target) self.assertNotEqual(loss.grad_fn, None) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = GeneralizedDiceLoss() test_input = torch.ones(2, 1, 8, 8) diff --git a/tests/test_generalized_wasserstein_dice_loss.py b/tests/test_generalized_wasserstein_dice_loss.py index 2c33d365f4..49a5aa0556 100644 --- a/tests/test_generalized_wasserstein_dice_loss.py +++ b/tests/test_generalized_wasserstein_dice_loss.py @@ -18,7 +18,7 @@ import torch.optim as optim from monai.losses import GeneralizedWassersteinDiceLoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save class TestGeneralizedWassersteinDiceLoss(unittest.TestCase): @@ -216,7 +216,6 @@ def forward(self, x): # check that the predicted segmentation has improved self.assertGreater(diff_start, diff_end) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): target = torch.tensor([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]]) diff --git a/tests/test_handler_smartcache.py b/tests/test_handler_smartcache.py index ec96d47e3d..7bc9011c2d 100644 --- a/tests/test_handler_smartcache.py +++ b/tests/test_handler_smartcache.py @@ -17,10 +17,8 @@ from monai.data import SmartCacheDataset from monai.handlers import SmartCacheHandler -from tests.utils import SkipIfBeforePyTorchVersion -@SkipIfBeforePyTorchVersion((1, 7)) class TestHandlerSmartCache(unittest.TestCase): def test_content(self): data = [0, 1, 2, 3, 4, 5, 6, 7, 8] diff --git a/tests/test_hilbert_transform.py b/tests/test_hilbert_transform.py index 10aa83293f..f7954d6b24 100644 --- a/tests/test_hilbert_transform.py +++ b/tests/test_hilbert_transform.py @@ -16,14 +16,8 @@ from parameterized import parameterized from monai.networks.layers import HilbertTransform -from monai.utils import InvalidPyTorchVersionError, OptionalImportError -from tests.utils import ( - SkipIfAtLeastPyTorchVersion, - SkipIfBeforePyTorchVersion, - SkipIfModule, - SkipIfNoModule, - skip_if_no_cuda, -) +from monai.utils import OptionalImportError +from tests.utils import SkipIfModule, SkipIfNoModule, skip_if_no_cuda def create_expected_numpy_output(input_datum, **kwargs): @@ -164,7 +158,6 @@ def create_expected_numpy_output(input_datum, **kwargs): # TESTS CHECKING PADDING, AXIS SELECTION ETC ARE COVERED BY test_detect_envelope.py -@SkipIfBeforePyTorchVersion((1, 7)) @SkipIfNoModule("torch.fft") class TestHilbertTransformCPU(unittest.TestCase): @parameterized.expand( @@ -183,7 +176,6 @@ def test_value(self, arguments, image, expected_data, atol): @skip_if_no_cuda -@SkipIfBeforePyTorchVersion((1, 7)) @SkipIfNoModule("torch.fft") class TestHilbertTransformGPU(unittest.TestCase): @parameterized.expand( @@ -204,19 +196,11 @@ def test_value(self, arguments, image, expected_data, atol): np.testing.assert_allclose(result, expected_data.squeeze(), atol=atol) -@SkipIfBeforePyTorchVersion((1, 7)) @SkipIfModule("torch.fft") class TestHilbertTransformNoFFTMod(unittest.TestCase): def test_no_fft_module_error(self): self.assertRaises(OptionalImportError, HilbertTransform(), torch.randn(1, 1, 10)) -@SkipIfAtLeastPyTorchVersion((1, 7)) -class TestHilbertTransformInvalidPyTorch(unittest.TestCase): - def test_invalid_pytorch_error(self): - with self.assertRaisesRegex(InvalidPyTorchVersionError, "version"): - HilbertTransform() - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_integration_fast_train.py b/tests/test_integration_fast_train.py index 51b2ac1d3f..4dbb70b102 100644 --- a/tests/test_integration_fast_train.py +++ b/tests/test_integration_fast_train.py @@ -52,12 +52,11 @@ ToDeviced, ) from monai.utils import set_determinism -from tests.utils import DistTestCase, SkipIfBeforePyTorchVersion, TimedCall, skip_if_no_cuda, skip_if_quick +from tests.utils import DistTestCase, TimedCall, skip_if_no_cuda, skip_if_quick @skip_if_no_cuda @skip_if_quick -@SkipIfBeforePyTorchVersion((1, 7)) class IntegrationFastTrain(DistTestCase): def setUp(self): set_determinism(seed=0) diff --git a/tests/test_keep_largest_connected_component.py b/tests/test_keep_largest_connected_component.py index 5c96b62131..6419914be6 100644 --- a/tests/test_keep_largest_connected_component.py +++ b/tests/test_keep_largest_connected_component.py @@ -19,7 +19,7 @@ from monai.transforms import KeepLargestConnectedComponent from monai.transforms.utils_pytorch_numpy_unification import moveaxis from monai.utils.type_conversion import convert_to_dst_type -from tests.utils import TEST_NDARRAYS, SkipIfBeforePyTorchVersion, assert_allclose +from tests.utils import TEST_NDARRAYS, assert_allclose def to_onehot(x): @@ -353,7 +353,6 @@ def test_correct_results(self, _, args, input_image, expected): assert_allclose(result, expected, type_test=False) @parameterized.expand(TESTS) - @SkipIfBeforePyTorchVersion((1, 7)) def test_correct_results_before_after_onehot(self, _, args, input_image, expected): """ From torch==1.7, torch.argmax changes its mechanism that if there are multiple maximal values then the diff --git a/tests/test_masked_loss.py b/tests/test_masked_loss.py index 9f28d51aa4..a00b3ae7e7 100644 --- a/tests/test_masked_loss.py +++ b/tests/test_masked_loss.py @@ -17,7 +17,7 @@ from monai.losses.dice import DiceFocalLoss, DiceLoss from monai.losses.spatial_mask import MaskedLoss from monai.utils import set_determinism -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save device = "cuda" if torch.cuda.is_available() else "cpu" @@ -73,7 +73,6 @@ def test_ill_opts(self): masked = MaskedLoss(loss=dice_loss) masked(input=torch.zeros((3, 3, 2, 2)), target=torch.zeros((3, 2, 2, 2)), mask=torch.zeros((3, 3, 2, 2))) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): input_param, expected_val = TEST_CASES[0] size = [3, 3, 5, 5] diff --git a/tests/test_multi_scale.py b/tests/test_multi_scale.py index 963824f25e..f348c09512 100644 --- a/tests/test_multi_scale.py +++ b/tests/test_multi_scale.py @@ -16,7 +16,7 @@ from monai.losses import DiceLoss from monai.losses.multi_scale import MultiScaleLoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save dice_loss = DiceLoss(include_background=True, sigmoid=True, smooth_nr=1e-5, smooth_dr=1e-5) device = "cuda" if torch.cuda.is_available() else "cpu" @@ -67,7 +67,6 @@ def test_ill_opts(self): torch.ones((1, 1, 3), device=device), torch.ones((1, 1, 3), device=device) ) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): input_param, input_data, expected_val = TEST_CASES[0] loss = MultiScaleLoss(**input_param) diff --git a/tests/test_randtorchvisiond.py b/tests/test_randtorchvisiond.py index 2e96d723ee..4fc1a1e630 100644 --- a/tests/test_randtorchvisiond.py +++ b/tests/test_randtorchvisiond.py @@ -16,7 +16,6 @@ from monai.transforms import Randomizable, RandTorchVisiond from monai.utils import set_determinism -from tests.utils import SkipIfBeforePyTorchVersion TEST_CASE_1 = [ {"keys": "img", "name": "ColorJitter"}, @@ -49,7 +48,6 @@ ] -@SkipIfBeforePyTorchVersion((1, 7)) class TestRandTorchVisiond(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_value(self, input_param, input_data, expected_value): diff --git a/tests/test_torchvision.py b/tests/test_torchvision.py index e0844eb4b9..68b9413e65 100644 --- a/tests/test_torchvision.py +++ b/tests/test_torchvision.py @@ -15,7 +15,7 @@ from monai.transforms import TorchVision from monai.utils import set_determinism -from tests.utils import TEST_NDARRAYS, SkipIfBeforePyTorchVersion, assert_allclose +from tests.utils import TEST_NDARRAYS, assert_allclose TESTS = [] for p in TEST_NDARRAYS: @@ -52,7 +52,6 @@ ) -@SkipIfBeforePyTorchVersion((1, 7)) class TestTorchVision(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, input_param, input_data, expected_value): diff --git a/tests/test_torchvisiond.py b/tests/test_torchvisiond.py index 4c62c6e41a..def26fa26b 100644 --- a/tests/test_torchvisiond.py +++ b/tests/test_torchvisiond.py @@ -16,7 +16,6 @@ from monai.transforms import TorchVisiond from monai.utils import set_determinism -from tests.utils import SkipIfBeforePyTorchVersion TEST_CASE_1 = [ {"keys": "img", "name": "ColorJitter"}, @@ -49,7 +48,6 @@ ] -@SkipIfBeforePyTorchVersion((1, 7)) class TestTorchVisiond(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_value(self, input_param, input_data, expected_value): diff --git a/tests/test_tversky_loss.py b/tests/test_tversky_loss.py index 2bb2409360..22f57cc8c6 100644 --- a/tests/test_tversky_loss.py +++ b/tests/test_tversky_loss.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.losses import TverskyLoss -from tests.utils import SkipIfBeforePyTorchVersion, test_script_save +from tests.utils import test_script_save TEST_CASES = [ [ # shape: (1, 1, 2, 2), (1, 1, 2, 2) @@ -175,7 +175,6 @@ def test_input_warnings(self): loss = TverskyLoss(to_onehot_y=True) loss.forward(chn_input, chn_target) - @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = TverskyLoss() test_input = torch.ones(2, 1, 8, 8) diff --git a/tests/test_utils_pytorch_numpy_unification.py b/tests/test_utils_pytorch_numpy_unification.py index 6adf7093bf..1b08a5bd2f 100644 --- a/tests/test_utils_pytorch_numpy_unification.py +++ b/tests/test_utils_pytorch_numpy_unification.py @@ -16,7 +16,7 @@ from monai.transforms.utils_pytorch_numpy_unification import mode, percentile from monai.utils import set_determinism -from tests.utils import TEST_NDARRAYS, SkipIfBeforePyTorchVersion, assert_allclose +from tests.utils import TEST_NDARRAYS, assert_allclose TEST_MODE = [] for p in TEST_NDARRAYS: @@ -45,7 +45,6 @@ def test_fails(self): with self.assertRaises(ValueError): percentile(arr, q) - @SkipIfBeforePyTorchVersion((1, 7)) def test_dim(self): q = np.random.randint(0, 100, size=50) results = [] From e1a62f25552be0c40d0f745abbe558e39cdd5804 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 20 Apr 2022 06:08:04 +0800 Subject: [PATCH 022/183] [DLMED] fix file name in meta (#4145) Signed-off-by: Nic Ma --- monai/transforms/spatial/array.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 41d048fc19..37f1c3edc3 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -52,6 +52,7 @@ ) from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends +from monai.utils.misc import ImageMetaKey as Key from monai.utils.module import look_up_option from monai.utils.type_conversion import convert_data_type, convert_to_dst_type @@ -305,6 +306,7 @@ def __call__( # type: ignore ) dst_meta = deepcopy(dst_meta) dst_meta["affine"] = updated_affine + dst_meta[Key.FILENAME_OR_OBJ] = src_meta.get(Key.FILENAME_OR_OBJ) return img, dst_meta From 4a8f8150888b999f35535ffeb09e2559c48ca0c8 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 20 Apr 2022 06:51:30 +0800 Subject: [PATCH 023/183] 4116 Add support for advanced args of AMP (#4132) * [DLMED] fix typo in bundle scripts Signed-off-by: Nic Ma * [DLMED] add support for AMP args Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 Signed-off-by: Nic Ma Co-authored-by: monai-bot --- monai/bundle/scripts.py | 4 ++-- monai/engines/evaluator.py | 16 ++++++++++++++-- monai/engines/trainer.py | 10 +++++++++- monai/engines/workflow.py | 4 ++++ tests/test_integration_workflows.py | 2 ++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 64172c4541..b741e40e8d 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -363,7 +363,7 @@ def ckpt_export( .. code-block:: bash - python -m monai.bundle export network --filepath --ckpt_file ... + python -m monai.bundle ckpt_export network --filepath --ckpt_file ... Args: net_id: ID name of the network component in the config, it must be `torch.nn.Module`. @@ -390,7 +390,7 @@ def ckpt_export( key_in_ckpt=key_in_ckpt, **override, ) - _log_input_summary(tag="export", args=_args) + _log_input_summary(tag="ckpt_export", args=_args) filepath_, ckpt_file_, config_file_, net_id_, meta_file_, key_in_ckpt_ = _pop_args( _args, "filepath", "ckpt_file", "config_file", net_id="", meta_file=None, key_in_ckpt="" ) diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index f9dab35450..c69c0e0547 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -76,6 +76,8 @@ class Evaluator(Workflow): default to `True`. to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for `device`, `non_blocking`. + amp_kwargs: dict of the args for `torch.cuda.amp.autocast()` API, for more details: + https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.autocast. """ @@ -98,6 +100,7 @@ def __init__( event_to_attr: Optional[dict] = None, decollate: bool = True, to_kwargs: Optional[Dict] = None, + amp_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -117,6 +120,7 @@ def __init__( event_to_attr=event_to_attr, decollate=decollate, to_kwargs=to_kwargs, + amp_kwargs=amp_kwargs, ) mode = look_up_option(mode, ForwardMode) if mode == ForwardMode.EVAL: @@ -187,6 +191,8 @@ class SupervisedEvaluator(Evaluator): default to `True`. to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for `device`, `non_blocking`. + amp_kwargs: dict of the args for `torch.cuda.amp.autocast()` API, for more details: + https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.autocast. """ @@ -211,6 +217,7 @@ def __init__( event_to_attr: Optional[dict] = None, decollate: bool = True, to_kwargs: Optional[Dict] = None, + amp_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -230,6 +237,7 @@ def __init__( event_to_attr=event_to_attr, decollate=decollate, to_kwargs=to_kwargs, + amp_kwargs=amp_kwargs, ) self.network = network @@ -269,7 +277,7 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): # execute forward computation with self.mode(self.network): if self.amp: - with torch.cuda.amp.autocast(): + with torch.cuda.amp.autocast(**engine.amp_kwargs): # type: ignore engine.state.output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) # type: ignore else: engine.state.output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) # type: ignore @@ -326,6 +334,8 @@ class EnsembleEvaluator(Evaluator): default to `True`. to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for `device`, `non_blocking`. + amp_kwargs: dict of the args for `torch.cuda.amp.autocast()` API, for more details: + https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.autocast. """ @@ -351,6 +361,7 @@ def __init__( event_to_attr: Optional[dict] = None, decollate: bool = True, to_kwargs: Optional[Dict] = None, + amp_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -370,6 +381,7 @@ def __init__( event_to_attr=event_to_attr, decollate=decollate, to_kwargs=to_kwargs, + amp_kwargs=amp_kwargs, ) self.networks = ensure_tuple(networks) @@ -417,7 +429,7 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): for idx, network in enumerate(self.networks): with self.mode(network): if self.amp: - with torch.cuda.amp.autocast(): + with torch.cuda.amp.autocast(**engine.amp_kwargs): # type: ignore if isinstance(engine.state.output, dict): engine.state.output.update( {self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)} diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index 16c50d4fa2..12753765ef 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -107,6 +107,8 @@ class SupervisedTrainer(Trainer): more details: https://pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html. to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for `device`, `non_blocking`. + amp_kwargs: dict of the args for `torch.cuda.amp.autocast()` API, for more details: + https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.autocast. """ @@ -134,6 +136,7 @@ def __init__( decollate: bool = True, optim_set_to_none: bool = False, to_kwargs: Optional[Dict] = None, + amp_kwargs: Optional[Dict] = None, ) -> None: super().__init__( device=device, @@ -153,6 +156,7 @@ def __init__( event_to_attr=event_to_attr, decollate=decollate, to_kwargs=to_kwargs, + amp_kwargs=amp_kwargs, ) self.network = network @@ -202,7 +206,7 @@ def _compute_pred_loss(): self.optimizer.zero_grad(set_to_none=self.optim_set_to_none) if self.amp and self.scaler is not None: - with torch.cuda.amp.autocast(): + with torch.cuda.amp.autocast(**engine.amp_kwargs): # type: ignore _compute_pred_loss() self.scaler.scale(engine.state.output[Keys.LOSS]).backward() # type: ignore engine.fire_event(IterationEvents.BACKWARD_COMPLETED) @@ -275,6 +279,8 @@ class GanTrainer(Trainer): more details: https://pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html. to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for `device`, `non_blocking`. + amp_kwargs: dict of the args for `torch.cuda.amp.autocast()` API, for more details: + https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.autocast. """ @@ -307,6 +313,7 @@ def __init__( decollate: bool = True, optim_set_to_none: bool = False, to_kwargs: Optional[Dict] = None, + amp_kwargs: Optional[Dict] = None, ): if not isinstance(train_data_loader, DataLoader): raise ValueError("train_data_loader must be PyTorch DataLoader.") @@ -327,6 +334,7 @@ def __init__( postprocessing=postprocessing, decollate=decollate, to_kwargs=to_kwargs, + amp_kwargs=amp_kwargs, ) self.g_network = g_network self.g_optimizer = g_optimizer diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 4ea0a69d55..75123da153 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -96,6 +96,8 @@ class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optiona default to `True`. to_kwargs: dict of other args for `prepare_batch` API when converting the input data, except for `device`, `non_blocking`. + amp_kwargs: dict of the args for `torch.cuda.amp.autocast()` API, for more details: + https://pytorch.org/docs/stable/amp.html#torch.cuda.amp.autocast. Raises: TypeError: When ``device`` is not a ``torch.Device``. @@ -124,6 +126,7 @@ def __init__( event_to_attr: Optional[dict] = None, decollate: bool = True, to_kwargs: Optional[Dict] = None, + amp_kwargs: Optional[Dict] = None, ) -> None: if iteration_update is not None: super().__init__(iteration_update) @@ -170,6 +173,7 @@ def set_sampler_epoch(engine: Engine): self.metric_cmp_fn = metric_cmp_fn self.amp = amp self.to_kwargs = {} if to_kwargs is None else to_kwargs + self.amp_kwargs = {} if amp_kwargs is None else amp_kwargs self.scaler: Optional[torch.cuda.amp.GradScaler] = None if event_names is None: diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index fafdf43522..688f664089 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -149,6 +149,7 @@ def _forward_completed(self, engine): val_handlers=val_handlers, amp=bool(amp), to_kwargs={"memory_format": torch.preserve_format}, + amp_kwargs={"dtype": torch.float16 if bool(amp) else torch.float32}, ) train_postprocessing = Compose( @@ -204,6 +205,7 @@ def _model_completed(self, engine): amp=bool(amp), optim_set_to_none=True, to_kwargs={"memory_format": torch.preserve_format}, + amp_kwargs={"dtype": torch.float16 if bool(amp) else torch.float32}, ) trainer.run() From cb9040e89d4b2f7152f2816a80e6817cc40d123e Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 19 Apr 2022 22:56:06 -0400 Subject: [PATCH 024/183] New wsireader (#4147) --- docs/source/data.rst | 13 ++ monai/data/__init__.py | 3 +- monai/data/wsi_reader.py | 420 ++++++++++++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_wsireader_new.py | 218 +++++++++++++++++++ 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 monai/data/wsi_reader.py create mode 100644 tests/test_wsireader_new.py diff --git a/docs/source/data.rst b/docs/source/data.rst index 0910001783..c968d72945 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -152,11 +152,24 @@ PILReader .. autoclass:: PILReader :members: +Whole slide image reader +------------------------ + +BaseWSIReader +~~~~~~~~~~~~~ +.. autoclass:: BaseWSIReader + :members: + WSIReader ~~~~~~~~~ .. autoclass:: WSIReader :members: +CuCIMWSIReader +~~~~~~~~~~~~~~ +.. autoclass:: CuCIMWSIReader + :members: + Image writer ------------ diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 19ca29eafa..ca4be87ef6 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -34,7 +34,7 @@ from .folder_layout import FolderLayout from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter, PatchIterd from .image_dataset import ImageDataset -from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader, WSIReader +from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader from .image_writer import ( SUPPORTED_WRITERS, ImageWriter, @@ -87,3 +87,4 @@ worker_init_fn, zoom_affine, ) +from .wsi_reader import BaseWSIReader, CuCIMWSIReader, WSIReader diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py new file mode 100644 index 0000000000..4899fb8830 --- /dev/null +++ b/monai/data/wsi_reader.py @@ -0,0 +1,420 @@ +# 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 abc import abstractmethod +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np + +from monai.config import DtypeLike, PathLike +from monai.data.image_reader import ImageReader, _stack_images +from monai.data.utils import is_supported_format +from monai.transforms.utility.array import EnsureChannelFirst +from monai.utils import ensure_tuple, optional_import, require_pkg + +CuImage, _ = optional_import("cucim", name="CuImage") + +__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader"] + + +class BaseWSIReader(ImageReader): + """ + An abstract class that defines APIs to load patches from whole slide image files. + + Typical usage of a concrete implementation of this class is: + + .. code-block:: python + + image_reader = MyWSIReader() + wsi = image_reader.read(, **kwargs) + img_data, meta_data = image_reader.get_data(wsi) + + - The `read` call converts an image filename into whole slide image object, + - The `get_data` call fetches the image data, as well as meta data. + + The following methods needs to be implemented for any concrete implementation of this class: + + - `read` reads a whole slide image object from a given file + - `get_size` returns the size of the whole slide image of a given wsi object at a given level. + - `get_level_count` returns the number of levels in the whole slide image + - `get_patch` extracts and returns a patch image form the whole slide image + - `get_metadata` extracts and returns metadata for a whole slide image and a specific patch. + + + """ + + supported_suffixes: List[str] = [] + + def __init__(self, level: int, **kwargs): + super().__init__() + self.level = level + self.kwargs = kwargs + self.metadata: Dict[Any, Any] = {} + + @abstractmethod + def get_size(self, wsi, level: int) -> Tuple[int, int]: + """ + Returns the size of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + @abstractmethod + def get_level_count(self, wsi) -> int: + """ + Returns the number of levels in the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + @abstractmethod + def get_patch( + self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str + ) -> np.ndarray: + """ + Extracts and returns a patch image form the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file or a lis of such objects + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + dtype: the data type of output image + mode: the output image mode, 'RGB' or 'RGBA' + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + @abstractmethod + def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: + """ + Extracts and returns metadata form the whole slide image. + + Args: + patch: extracted patch from whole slide image + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + def get_data( + self, + wsi, + location: Tuple[int, int] = (0, 0), + size: Optional[Tuple[int, int]] = None, + level: Optional[int] = None, + dtype: DtypeLike = np.uint8, + mode: str = "RGB", + ) -> Tuple[np.ndarray, Dict]: + """ + Verifies inputs, extracts patches from WSI image and generates metadata, and return them. + + Args: + wsi: a whole slide image object loaded from a file or a list of such objects + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + dtype: the data type of output image + mode: the output image mode, 'RGB' or 'RGBA' + + Returns: + a tuples, where the first element is an image patch [CxHxW] or stack of patches, + and second element is a dictionary of metadata + """ + patch_list: List = [] + metadata = {} + # CuImage object is iterable, so ensure_tuple won't work on single object + if not isinstance(wsi, List): + wsi = [wsi] + for each_wsi in ensure_tuple(wsi): + # Verify magnification level + if level is None: + level = self.level + max_level = self.get_level_count(each_wsi) - 1 + if level > max_level: + raise ValueError(f"The maximum level of this image is {max_level} while level={level} is requested)!") + + # Verify location + if location is None: + location = (0, 0) + wsi_size = self.get_size(each_wsi, level) + if location[0] > wsi_size[0] or location[1] > wsi_size[1]: + raise ValueError(f"Location is outside of the image: location={location}, image size={wsi_size}") + + # Verify size + if size is None: + if location != (0, 0): + raise ValueError("Patch size should be defined to exctract patches.") + size = self.get_size(each_wsi, level) + else: + if size[0] <= 0 or size[1] <= 0: + raise ValueError(f"Patch size should be greater than zero, provided: patch size = {size}") + + # Extract a patch or the entire image + patch = self.get_patch(each_wsi, location=location, size=size, level=level, dtype=dtype, mode=mode) + + # check if the image has three dimensions (2D + color) + if patch.ndim != 3: + raise ValueError( + f"The image dimension should be 3 but has {patch.ndim}. " + "`WSIReader` is designed to work only with 2D images with color channel." + ) + + # Create a list of patches + patch_list.append(patch) + + # Set patch-related metadata + each_meta = self.get_metadata(patch=patch, location=location, size=size, level=level) + metadata.update(each_meta) + + return _stack_images(patch_list, metadata), metadata + + def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: + """ + Verify whether the specified file or files format is supported by WSI reader. + + The list of supported suffixes are read from `self.supported_suffixes`. + + Args: + filename: filename or a list of filenames to read. + + """ + return is_supported_format(filename, self.supported_suffixes) + + +class WSIReader(BaseWSIReader): + """ + Read whole slide images and extract patches using different backend libraries + + Args: + backend: the name of backend whole slide image reader library, the default is cuCIM. + level: the level at which patches are extracted. + kwargs: additional arguments to be passed to the backend library + + """ + + def __init__(self, backend="cucim", level: int = 0, **kwargs): + super().__init__(level, **kwargs) + self.backend = backend.lower() + # Any new backend can be added below + if self.backend == "cucim": + self.reader = CuCIMWSIReader(level=level, **kwargs) + else: + raise ValueError("The supported backends are: cucim") + self.supported_suffixes = self.reader.supported_suffixes + + def get_level_count(self, wsi) -> int: + """ + Returns the number of levels in the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file + + """ + return self.reader.get_level_count(wsi) + + def get_size(self, wsi, level) -> Tuple[int, int]: + """ + Returns the size of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + return self.reader.get_size(wsi, level) + + def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: + """ + Extracts and returns metadata form the whole slide image. + + Args: + patch: extracted patch from whole slide image + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + + """ + return self.reader.get_metadata(patch=patch, size=size, location=location, level=level) + + def get_patch( + self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str + ) -> np.ndarray: + """ + Extracts and returns a patch image form the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file or a lis of such objects + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + dtype: the data type of output image + mode: the output image mode, 'RGB' or 'RGBA' + + """ + return self.reader.get_patch(wsi=wsi, location=location, size=size, level=level, dtype=dtype, mode=mode) + + def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): + """ + Read whole slide image objects from given file or list of files. + + Args: + data: file name or a list of file names to read. + kwargs: additional args for the reader module (overrides `self.kwargs` for existing keys). + + Returns: + whole slide image object or list of such objects + + """ + return self.reader.read(data=data, **kwargs) + + +@require_pkg(pkg_name="cucim") +class CuCIMWSIReader(BaseWSIReader): + """ + Read whole slide images and extract patches without loading the whole slide image into the memory. + + Args: + level: the whole slide image level at which the image is extracted. (default=0) + This is overridden if the level argument is provided in `get_data`. + kwargs: additional args for `cucim.CuImage` module: + https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h + + """ + + supported_suffixes = ["tif", "tiff", "svs"] + + def __init__(self, level: int = 0, **kwargs): + super().__init__(level, **kwargs) + + @staticmethod + def get_level_count(wsi) -> int: + """ + Returns the number of levels in the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file + + """ + return wsi.resolutions["level_count"] # type: ignore + + @staticmethod + def get_size(wsi, level) -> Tuple[int, int]: + """ + Returns the size of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + return (wsi.resolutions["level_dimensions"][level][1], wsi.resolutions["level_dimensions"][level][0]) + + def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: + """ + Extracts and returns metadata form the whole slide image. + + Args: + patch: extracted patch from whole slide image + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + + """ + metadata: Dict = { + "backend": "cucim", + "spatial_shape": np.asarray(patch.shape[1:]), + "original_channel_dim": 0, + "location": location, + "size": size, + "level": level, + } + return metadata + + def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): + """ + Read whole slide image objects from given file or list of files. + + Args: + data: file name or a list of file names to read. + kwargs: additional args that overrides `self.kwargs` for existing keys. + For more details look at https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h + + Returns: + whole slide image object or list of such objects + + """ + wsi_list: List = [] + + filenames: Sequence[PathLike] = ensure_tuple(data) + kwargs_ = self.kwargs.copy() + kwargs_.update(kwargs) + for filename in filenames: + wsi = CuImage(filename, **kwargs_) + wsi_list.append(wsi) + + return wsi_list if len(filenames) > 1 else wsi_list[0] + + def get_patch( + self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str + ) -> np.ndarray: + """ + Extracts and returns a patch image form the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file or a lis of such objects + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + dtype: the data type of output image + mode: the output image mode, 'RGB' or 'RGBA' + + """ + # Extract a patch or the entire image + # (reverse the order of location and size to become WxH for cuCIM) + patch: np.ndarray = wsi.read_region(location=location[::-1], size=size[::-1], level=level) + + # Convert to numpy + patch = np.asarray(patch, dtype=dtype) + + # Make it channel first + patch = EnsureChannelFirst()(patch, {"original_channel_dim": -1}) # type: ignore + + # Check if the color channel is 3 (RGB) or 4 (RGBA) + if mode == "RGBA" and patch.shape[0] != 4: + raise ValueError( + f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." + ) + + if mode in "RGB": + if patch.shape[0] not in [3, 4]: + raise ValueError( + f"The image is expected to have three or four color channels in '{mode}' mode but has {patch.shape[0]}. " + ) + patch = patch[:3] + + return patch diff --git a/tests/min_tests.py b/tests/min_tests.py index 66b6c9ff3d..25acbccb41 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -157,6 +157,7 @@ def run_testsuit(): "test_vitautoenc", "test_write_metrics_reports", "test_wsireader", + "test_wsireader_new", "test_zoom", "test_zoom_affine", "test_zoomd", diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py new file mode 100644 index 0000000000..7b288f6040 --- /dev/null +++ b/tests/test_wsireader_new.py @@ -0,0 +1,218 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +import torch +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import DataLoader, Dataset +from monai.data.wsi_reader import WSIReader +from monai.transforms import Compose, LoadImaged, ToTensord +from monai.utils import first, optional_import +from monai.utils.enums import PostFix +from tests.utils import download_url_or_skip_test, testing_data_config + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +HEIGHT = 32914 +WIDTH = 46000 + +TEST_CASE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] + +TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] + +TEST_CASE_1 = [ + FILE_PATH, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), +] + +TEST_CASE_2 = [ + FILE_PATH, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), +] + +TEST_CASE_3 = [ + [FILE_PATH, FILE_PATH], + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.concatenate( + [ + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + ], + axis=0, + ), +] + +TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW + +TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW + +TEST_CASE_ERROR_GRAY = [np.ones((16, 16), dtype=np.uint8)] # no color channel +TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color + + +def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): + """ + Save numpy array into a TIFF RGB/RGBA file + + Args: + array: numpy ndarray with the shape of CxHxW and C==3 representing a RGB image + filename: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename. + mode: RGB or RGBA + """ + if mode == "RGBA": + array = np.concatenate([array, 255 * np.ones_like(array[0])[np.newaxis]]).astype(np.uint8) + + img_rgb = array.transpose(1, 2, 0) + imwrite(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16)) + + return filename + + +def save_gray_tiff(array: np.ndarray, filename: str): + """ + Save numpy array into a TIFF file + + Args: + array: numpy ndarray with any shape + filename: the filename to be used for the tiff file. + """ + img_gray = array + imwrite(filename, img_gray, shape=img_gray.shape, photometric="rgb") + + return filename + + +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): # noqa: N802 + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class WSIReaderTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0]) + def test_read_whole_image(self, file_path, level, expected_shape): + reader = WSIReader(self.backend, level=level) + with reader.read(file_path) as img_obj: + img = reader.get_data(img_obj)[0] + self.assertTupleEqual(img.shape, expected_shape) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) + def test_read_region(self, file_path, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + reader = WSIReader(self.backend, **kwargs) + with reader.read(file_path, **kwargs) as img_obj: + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj, **patch_info)[0] + else: + # Read twice to check multiple calls + img = reader.get_data(img_obj, **patch_info)[0] + img2 = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + + @parameterized.expand([TEST_CASE_3]) + def test_read_region_multi_wsi(self, file_path, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + reader = WSIReader(self.backend, **kwargs) + img_obj = reader.read(file_path, **kwargs) + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj, **patch_info)[0] + else: + # Read twice to check multiple calls + img = reader.get_data(img_obj, **patch_info)[0] + img2 = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + + @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) + @skipUnless(has_tiff, "Requires tifffile.") + def test_read_rgba(self, img_expected): + # skip for OpenSlide since not working with images without tiles + if self.backend == "openslide": + return + image = {} + reader = WSIReader(self.backend) + for mode in ["RGB", "RGBA"]: + file_path = save_rgba_tiff( + img_expected, + os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"), + mode=mode, + ) + with reader.read(file_path) as img_obj: + image[mode], _ = reader.get_data(img_obj) + + self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) + self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) + + @parameterized.expand([TEST_CASE_ERROR_GRAY, TEST_CASE_ERROR_3D]) + @skipUnless(has_tiff, "Requires tifffile.") + def test_read_malformats(self, img_expected): + reader = WSIReader(self.backend) + file_path = save_gray_tiff( + img_expected, os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") + ) + with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): + with reader.read(file_path) as img_obj: + reader.get_data(img_obj) + + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}], transform=train_transform) + data_loader = DataLoader(dataset) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + torch.testing.assert_allclose(s, expected_spatial_shape) + self.assertTupleEqual(data["image"].shape, expected_shape) + + +@skipUnless(has_cucim, "Requires cucim") +class TestCuCIM(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +if __name__ == "__main__": + unittest.main() From 6dfb6a8f1c960e55aeeb7e22cc7910fc7d06c45d Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 20 Apr 2022 17:12:25 +0100 Subject: [PATCH 025/183] `MetaTensor`: collate; decollate; dataset; dataloader; out=; indexing and iterating across batches (#4137) `MetaTensor`: collate; decollate; dataset; dataloader; out=; indexing and iterating across batches (#4137) --- monai/data/meta_obj.py | 13 +++ monai/data/meta_tensor.py | 104 ++++++++++++++++++++--- monai/data/utils.py | 21 ++++- tests/test_meta_tensor.py | 168 +++++++++++++++++++++++++++++++++++--- 4 files changed, 279 insertions(+), 27 deletions(-) diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index 0e213f130b..e38e009e96 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -111,6 +111,7 @@ class MetaObj: def __init__(self): self._meta: dict = self.get_default_meta() + self._is_batch: bool = False @staticmethod def flatten_meta_objs(args: Sequence[Any]) -> list[MetaObj]: @@ -176,6 +177,7 @@ def _copy_meta(self, input_objs: list[MetaObj]) -> None: id_in = id(input_objs[0]) if len(input_objs) > 0 else None deep_copy = id(self) != id_in self._copy_attr("meta", input_objs, self.get_default_meta, deep_copy) + self.is_batch = input_objs[0].is_batch if len(input_objs) > 0 else False def get_default_meta(self) -> dict: """Get the default meta. @@ -194,6 +196,7 @@ def __repr__(self) -> str: out += "".join(f"\t{k}: {v}\n" for k, v in self.meta.items()) else: out += "None" + out += f"\nIs batch?: {self.is_batch}" return out @@ -206,3 +209,13 @@ def meta(self) -> dict: def meta(self, d: dict) -> None: """Set the meta.""" self._meta = d + + @property + def is_batch(self) -> bool: + """Return whether object is part of batch or not.""" + return self._is_batch + + @is_batch.setter + def is_batch(self, val: bool) -> None: + """Set whether object is part of batch or not.""" + self._is_batch = val diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index ba80f93e74..9196f0186c 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -13,11 +13,12 @@ import warnings from copy import deepcopy -from typing import Callable +from typing import Any, Callable, Sequence import torch from monai.data.meta_obj import MetaObj, get_track_meta, get_track_transforms +from monai.data.utils import decollate_batch, list_data_collate from monai.utils.enums import PostFix __all__ = ["MetaTensor"] @@ -54,11 +55,20 @@ class MetaTensor(MetaObj, torch.Tensor): assert m2.affine == affine Notes: + - Requires pytorch 1.9 or newer for full compatibility. - Older versions of pytorch (<=1.8), `torch.jit.trace(net, im)` may not work if `im` is of type `MetaTensor`. This can be resolved with `torch.jit.trace(net, im.as_tensor())`. - A warning will be raised if in the constructor `affine` is not `None` and `meta` already contains the key `affine`. + - You can query whether the `MetaTensor` is a batch with the `is_batch` attribute. + - With a batch of data, `batch[0]` will return the 0th image + with the 0th metadata. When the batch dimension is non-singleton, e.g., + `batch[:, 0]`, `batch[..., -1]` and `batch[1:3]`, then all (or a subset in the + last example) of the metadata will be returned, and `is_batch` will return `True`. + - When creating a batch with this class, use `monai.data.DataLoader` as opposed + to `torch.utils.data.DataLoader`, as this will take care of collating the + metadata properly. """ @staticmethod @@ -101,21 +111,93 @@ def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Call if isinstance(val, torch.Tensor): setattr(self, attribute, val.to(self.device)) + @staticmethod + def update_meta(rets: Sequence, func, args, kwargs): + """Update the metadata from the output of `__torch_function__`. + The output could be a single object, or a sequence of them. Hence, they get + converted to a sequence if necessary and then processed by iterating across them. + + For each element, if not of type `MetaTensor`, then nothing to do + """ + out = [] + metas = None + for idx, ret in enumerate(rets): + # if not `MetaTensor`, nothing to do. + if not isinstance(ret, MetaTensor): + pass + # if not tracking, convert to `torch.Tensor`. + elif not (get_track_meta() or get_track_transforms()): + ret = ret.as_tensor() + # else, handle the `MetaTensor` metadata. + else: + meta_args = MetaObj.flatten_meta_objs(list(args) + list(kwargs.values())) + ret._copy_meta(meta_args) + + # If we have a batch of data, then we need to be careful if a slice of + # the data is returned. Depending on how the data are indexed, we return + # some or all of the metadata, and the return object may or may not be a + # batch of data (e.g., `batch[:,-1]` versus `batch[0]`). + if ret.is_batch: + # only decollate metadata once + if metas is None: + metas = decollate_batch(ret.meta) + # if indexing e.g., `batch[0]` + if func == torch.Tensor.__getitem__: + idx = args[1] + if isinstance(idx, Sequence): + idx = idx[0] + # if using e.g., `batch[:, -1]` or `batch[..., -1]`, then the + # first element will be `slice(None, None, None)` and `Ellipsis`, + # respectively. Don't need to do anything with the metadata. + if idx not in (slice(None, None, None), Ellipsis): + meta = metas[idx] + # if using e.g., `batch[0:2]`, then `is_batch` should still be + # `True`. Also re-collate the remaining elements. + if isinstance(meta, list) and len(meta) > 1: + ret.meta = list_data_collate(meta) + # if using e.g., `batch[0]` or `batch[0, 1]`, then return single + # element from batch, and set `is_batch` to `False`. + else: + ret.meta = meta + ret.is_batch = False + # `unbind` is used for `next(iter(batch))`. Also for `decollate_batch`. + # But we only want to split the batch if the `unbind` is along the 0th + # dimension. + elif func == torch.Tensor.unbind: + if len(args) > 1: + dim = args[1] + elif "dim" in kwargs: + dim = kwargs["dim"] + else: + dim = 0 + if dim == 0: + ret.meta = metas[idx] + ret.is_batch = False + + ret.affine = ret.affine.to(ret.device) + out.append(ret) + # if the input was a tuple, then return it as a tuple + return tuple(out) if isinstance(rets, tuple) else out + @classmethod - def __torch_function__(cls, func, types, args=(), kwargs=None) -> torch.Tensor: + def __torch_function__(cls, func, types, args=(), kwargs=None) -> Any: """Wraps all torch functions.""" if kwargs is None: kwargs = {} - ret: MetaTensor = super().__torch_function__(func, types, args, kwargs) - # e.g., __repr__ returns a string - if not isinstance(ret, torch.Tensor): + ret = super().__torch_function__(func, types, args, kwargs) + # if `out` has been used as argument, metadata is not copied, nothing to do. + if "out" in kwargs: return ret - if not (get_track_meta() or get_track_transforms()): - return ret.as_tensor() - meta_args = MetaObj.flatten_meta_objs(list(args) + list(kwargs.values())) - ret._copy_meta(meta_args) - ret.affine = ret.affine.to(ret.device) - return ret + # we might have 1 or multiple outputs. Might be MetaTensor, might be something + # else (e.g., `__repr__` returns a string). + # Convert to list (if necessary), process, and at end remove list if one was added. + if not isinstance(ret, Sequence): + ret = [ret] + unpack = True + else: + unpack = False + ret = MetaTensor.update_meta(ret, func, args, kwargs) + return ret[0] if unpack else ret def get_default_affine(self, dtype=torch.float64) -> torch.Tensor: return torch.eye(4, device=self.device, dtype=dtype) diff --git a/monai/data/utils.py b/monai/data/utils.py index 495daf15e2..2bd7b49731 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -28,6 +28,7 @@ from torch.utils.data._utils.collate import default_collate from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor, PathLike +from monai.data.meta_obj import MetaObj from monai.networks.layers.simplelayers import GaussianFilter from monai.utils import ( MAX_SEED, @@ -346,9 +347,17 @@ def list_data_collate(batch: Sequence): ret = {} for k in elem: key = k - ret[key] = default_collate([d[key] for d in data]) - return ret - return default_collate(data) + data_for_batch = [d[key] for d in data] + ret[key] = default_collate(data_for_batch) + if isinstance(ret[key], MetaObj) and all(isinstance(d, MetaObj) for d in data_for_batch): + ret[key].meta = list_data_collate([i.meta for i in data_for_batch]) + ret[key].is_batch = True + else: + ret = default_collate(data) + if isinstance(ret, MetaObj) and all(isinstance(d, MetaObj) for d in data): + ret.meta = list_data_collate([i.meta for i in data]) + ret.is_batch = True + return ret except RuntimeError as re: re_str = str(re) if "equal size" in re_str: @@ -466,6 +475,12 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None): if batch.ndim == 0: return batch.item() if detach else batch out_list = torch.unbind(batch, dim=0) + # if of type MetaObj, decollate the metadata + if isinstance(batch, MetaObj) and all(isinstance(i, MetaObj) for i in out_list): + metas = decollate_batch(batch.meta) + for i in range(len(out_list)): + out_list[i].meta = metas[i] # type: ignore + out_list[i].is_batch = False # type: ignore if out_list[0].ndim == 0 and detach: return [t.item() for t in out_list] return list(out_list) diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index c18ef08b85..05356fcc84 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -21,11 +21,13 @@ import torch from parameterized import parameterized +from monai.data import DataLoader, Dataset from monai.data.meta_obj import get_track_meta, get_track_transforms, set_track_meta, set_track_transforms from monai.data.meta_tensor import MetaTensor +from monai.data.utils import decollate_batch, list_data_collate from monai.utils.enums import PostFix from monai.utils.module import pytorch_after -from tests.utils import TEST_DEVICES, assert_allclose, skip_if_no_cuda +from tests.utils import TEST_DEVICES, SkipIfBeforePyTorchVersion, assert_allclose, skip_if_no_cuda DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32]] TESTS = [] @@ -59,6 +61,17 @@ def check_ids(self, a, b, should_match): comp = self.assertEqual if should_match else self.assertNotEqual comp(id(a), id(b)) + def check_meta(self, a: MetaTensor, b: MetaTensor) -> None: + self.assertEqual(a.is_batch, b.is_batch) + meta_a, meta_b = a.meta, b.meta + # need to split affine from rest of metadata + aff_a = meta_a.get("affine", None) + aff_b = meta_b.get("affine", None) + assert_allclose(aff_a, aff_b) + meta_a = {k: v for k, v in meta_a.items() if k != "affine"} + meta_b = {k: v for k, v in meta_b.items() if k != "affine"} + self.assertEqual(meta_a, meta_b) + def check( self, out: torch.Tensor, @@ -87,12 +100,7 @@ def check( # check meta and affine are equal and affine is on correct device if isinstance(orig, MetaTensor) and isinstance(out, MetaTensor) and meta: - orig_meta_no_affine = deepcopy(orig.meta) - del orig_meta_no_affine["affine"] - out_meta_no_affine = deepcopy(out.meta) - del out_meta_no_affine["affine"] - self.assertEqual(orig_meta_no_affine, out_meta_no_affine) - assert_allclose(out.affine, orig.affine) + self.check_meta(orig, out) self.assertTrue(str(device) in str(out.affine.device)) if check_ids: self.check_ids(out.affine, orig.affine, ids) @@ -261,12 +269,146 @@ def test_amp(self): im_conv2 = conv(im) self.check(im_conv2, im_conv, ids=False, rtol=1e-4, atol=1e-3) - # TODO - # collate - # decollate - # dataset - # dataloader - # matplotlib + def test_out(self): + """Test when `out` is given as an argument.""" + m1, _ = self.get_im() + m1_orig = deepcopy(m1) + m2, _ = self.get_im() + m3, _ = self.get_im() + torch.add(m2, m3, out=m1) + m1_add = m2 + m3 + + assert_allclose(m1, m1_add) + self.check_meta(m1, m1_orig) + + @parameterized.expand(TESTS) + def test_collate(self, device, dtype): + numel = 3 + ims = [self.get_im(device=device, dtype=dtype)[0] for _ in range(numel)] + collated = list_data_collate(ims) + # tensor + self.assertIsInstance(collated, MetaTensor) + expected_shape = (numel,) + tuple(ims[0].shape) + self.assertTupleEqual(tuple(collated.shape), expected_shape) + for i, im in enumerate(ims): + self.check(im, ims[i], ids=True) + # affine + self.assertIsInstance(collated.affine, torch.Tensor) + expected_shape = (numel,) + tuple(ims[0].affine.shape) + self.assertTupleEqual(tuple(collated.affine.shape), expected_shape) + + @parameterized.expand(TESTS) + def test_dataset(self, device, dtype): + ims = [self.get_im(device=device, dtype=dtype)[0] for _ in range(4)] + ds = Dataset(ims) + for i, im in enumerate(ds): + self.check(im, ims[i], ids=True) + + @parameterized.expand(DTYPES) + @SkipIfBeforePyTorchVersion((1, 8)) + def test_dataloader(self, dtype): + batch_size = 5 + ims = [self.get_im(dtype=dtype)[0] for _ in range(batch_size * 2)] + ds = Dataset(ims) + im_shape = tuple(ims[0].shape) + affine_shape = tuple(ims[0].affine.shape) + expected_im_shape = (batch_size,) + im_shape + expected_affine_shape = (batch_size,) + affine_shape + dl = DataLoader(ds, num_workers=batch_size, batch_size=batch_size) + for batch in dl: + self.assertIsInstance(batch, MetaTensor) + self.assertTupleEqual(tuple(batch.shape), expected_im_shape) + self.assertTupleEqual(tuple(batch.affine.shape), expected_affine_shape) + + @SkipIfBeforePyTorchVersion((1, 9)) + def test_indexing(self): + """ + Check the metadata is returned in the expected format depending on whether + the input `MetaTensor` is a batch of data or not. + """ + ims = [self.get_im()[0] for _ in range(5)] + data = list_data_collate(ims) + + # check that when using non-batch data, metadata is copied wholly when indexing + # or iterating across data. + im = ims[0] + self.check_meta(im[0], im) + self.check_meta(next(iter(im)), im) + + # index + d = data[0] + self.check(d, ims[0], ids=False) + + # iter + d = next(iter(data)) + self.check(d, ims[0], ids=False) + + # complex indexing + + # `is_batch==True`, should have subset of image and metadata. + d = data[1:3] + self.check(d, list_data_collate(ims[1:3]), ids=False) + + # is_batch==True, should have subset of image and same metadata as `[1:3]`. + d = data[1:3, 0] + self.check(d, list_data_collate([i[0] for i in ims[1:3]]), ids=False) + + # `is_batch==False`, should have first metadata and subset of first image. + d = data[0, 0] + self.check(d, ims[0][0], ids=False) + + # `is_batch==True`, should have all metadata and subset of all images. + d = data[:, 0] + self.check(d, list_data_collate([i[0] for i in ims]), ids=False) + + # `is_batch==True`, should have all metadata and subset of all images. + d = data[..., -1] + self.check(d, list_data_collate([i[..., -1] for i in ims]), ids=False) + + # `is_batch==False`, tuple split along batch dim. Should have individual + # metadata. + d = data.unbind(0) + self.assertIsInstance(d, tuple) + self.assertEqual(len(d), len(ims)) + for _d, _im in zip(d, ims): + self.check(_d, _im, ids=False) + + # `is_batch==False`, tuple split along batch dim. Should have individual + # metadata. + d = data.unbind(dim=0) + self.assertIsInstance(d, tuple) + self.assertEqual(len(d), len(ims)) + for _d, _im in zip(d, ims): + self.check(_d, _im, ids=False) + + # `is_batch==True`, tuple split along non-batch dim. Should have all metadata. + d = data.unbind(-1) + self.assertIsInstance(d, tuple) + self.assertEqual(len(d), ims[0].shape[-1]) + for _d in d: + self.check_meta(_d, data) + + # `is_batch==True`, tuple split along non-batch dim. Should have all metadata. + d = data.unbind(dim=-1) + self.assertIsInstance(d, tuple) + self.assertEqual(len(d), ims[0].shape[-1]) + for _d in d: + self.check_meta(_d, data) + + @parameterized.expand(DTYPES) + @SkipIfBeforePyTorchVersion((1, 8)) + def test_decollate(self, dtype): + batch_size = 3 + ims = [self.get_im(dtype=dtype)[0] for _ in range(batch_size * 2)] + ds = Dataset(ims) + dl = DataLoader(ds, num_workers=batch_size, batch_size=batch_size) + batch = next(iter(dl)) + decollated = decollate_batch(batch) + self.assertIsInstance(decollated, list) + self.assertEqual(len(decollated), batch_size) + for elem, im in zip(decollated, ims): + self.assertIsInstance(elem, MetaTensor) + self.check(elem, im, ids=False) if __name__ == "__main__": From ed1c281a0f1b0d9c364ed59020a69ab2a821a5f2 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 21 Apr 2022 01:06:47 +0800 Subject: [PATCH 026/183] 4148 Enhance engine iteration logic and the typehints (#4150) * [DLMED] update Workflow.py Signed-off-by: Nic Ma * [DLMED] update all the engines Signed-off-by: Nic Ma * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [DLMED] fix flake8 Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/engines/evaluator.py | 135 ++++++++++++++++++------------------ monai/engines/trainer.py | 138 ++++++++++++++++++------------------- monai/engines/workflow.py | 12 ++-- 3 files changed, 141 insertions(+), 144 deletions(-) diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index c69c0e0547..b058935f67 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -9,7 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence import torch from torch.utils.data import DataLoader @@ -84,23 +86,23 @@ class Evaluator(Workflow): def __init__( self, device: torch.device, - val_data_loader: Union[Iterable, DataLoader], - epoch_length: Optional[int] = None, + val_data_loader: Iterable | DataLoader, + epoch_length: int | None = None, non_blocking: bool = False, prepare_batch: Callable = default_prepare_batch, - iteration_update: Optional[Callable[[Engine, Any], Any]] = None, - postprocessing: Optional[Transform] = None, - key_val_metric: Optional[Dict[str, Metric]] = None, - additional_metrics: Optional[Dict[str, Metric]] = None, + iteration_update: Callable[[Engine, Any], Any] | None = None, + postprocessing: Transform | None = None, + key_val_metric: dict[str, Metric] | None = None, + additional_metrics: dict[str, Metric] | None = None, metric_cmp_fn: Callable = default_metric_cmp_fn, - val_handlers: Optional[Sequence] = None, + val_handlers: Sequence | None = None, amp: bool = False, - mode: Union[ForwardMode, str] = ForwardMode.EVAL, - event_names: Optional[List[Union[str, EventEnum]]] = None, - event_to_attr: Optional[dict] = None, + mode: ForwardMode | str = ForwardMode.EVAL, + event_names: list[str | EventEnum] | None = None, + event_to_attr: dict | None = None, decollate: bool = True, - to_kwargs: Optional[Dict] = None, - amp_kwargs: Optional[Dict] = None, + to_kwargs: dict | None = None, + amp_kwargs: dict | None = None, ) -> None: super().__init__( device=device, @@ -144,7 +146,7 @@ def run(self, global_epoch: int = 1) -> None: self.state.iteration = 0 super().run() - def get_validation_stats(self) -> Dict[str, float]: + def get_validation_stats(self) -> dict[str, float]: return {"best_validation_metric": self.state.best_metric, "best_validation_epoch": self.state.best_metric_epoch} @@ -199,25 +201,25 @@ class SupervisedEvaluator(Evaluator): def __init__( self, device: torch.device, - val_data_loader: Union[Iterable, DataLoader], + val_data_loader: Iterable | DataLoader, network: torch.nn.Module, - epoch_length: Optional[int] = None, + epoch_length: int | None = None, non_blocking: bool = False, prepare_batch: Callable = default_prepare_batch, - iteration_update: Optional[Callable[[Engine, Any], Any]] = None, - inferer: Optional[Inferer] = None, - postprocessing: Optional[Transform] = None, - key_val_metric: Optional[Dict[str, Metric]] = None, - additional_metrics: Optional[Dict[str, Metric]] = None, + iteration_update: Callable[[Engine, Any], Any] | None = None, + inferer: Inferer | None = None, + postprocessing: Transform | None = None, + key_val_metric: dict[str, Metric] | None = None, + additional_metrics: dict[str, Metric] | None = None, metric_cmp_fn: Callable = default_metric_cmp_fn, - val_handlers: Optional[Sequence] = None, + val_handlers: Sequence | None = None, amp: bool = False, - mode: Union[ForwardMode, str] = ForwardMode.EVAL, - event_names: Optional[List[Union[str, EventEnum]]] = None, - event_to_attr: Optional[dict] = None, + mode: ForwardMode | str = ForwardMode.EVAL, + event_names: list[str | EventEnum] | None = None, + event_to_attr: dict | None = None, decollate: bool = True, - to_kwargs: Optional[Dict] = None, - amp_kwargs: Optional[Dict] = None, + to_kwargs: dict | None = None, + amp_kwargs: dict | None = None, ) -> None: super().__init__( device=device, @@ -243,7 +245,7 @@ def __init__( self.network = network self.inferer = SimpleInferer() if inferer is None else inferer - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): + def _iteration(self, engine: SupervisedEvaluator, batchdata: dict[str, torch.Tensor]): """ callback function for the Supervised Evaluation processing logic of 1 iteration in Ignite Engine. Return below items in a dictionary: @@ -252,7 +254,7 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): - PRED: prediction result of model. Args: - engine: Ignite Engine, it can be a trainer, validator or evaluator. + engine: `SupervisedEvaluator` to execute operation for an iteration. batchdata: input data for this iteration, usually can be dictionary or tuple of Tensor data. Raises: @@ -261,26 +263,25 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ if batchdata is None: raise ValueError("Must provide batch data for current iteration.") - batch = self.prepare_batch( - batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore - ) + batch = engine.prepare_batch(batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs) if len(batch) == 2: inputs, targets = batch - args: Tuple = () - kwargs: Dict = {} + args: tuple = () + kwargs: dict = {} else: inputs, targets, args, kwargs = batch # put iteration outputs into engine.state - engine.state.output = {Keys.IMAGE: inputs, Keys.LABEL: targets} # type: ignore + engine.state.output = {Keys.IMAGE: inputs, Keys.LABEL: targets} # execute forward computation - with self.mode(self.network): - if self.amp: - with torch.cuda.amp.autocast(**engine.amp_kwargs): # type: ignore - engine.state.output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) # type: ignore + with engine.mode(engine.network): + + if engine.amp: + with torch.cuda.amp.autocast(**engine.amp_kwargs): + engine.state.output[Keys.PRED] = engine.inferer(inputs, engine.network, *args, **kwargs) else: - engine.state.output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) # type: ignore + engine.state.output[Keys.PRED] = engine.inferer(inputs, engine.network, *args, **kwargs) engine.fire_event(IterationEvents.FORWARD_COMPLETED) engine.fire_event(IterationEvents.MODEL_COMPLETED) @@ -342,26 +343,26 @@ class EnsembleEvaluator(Evaluator): def __init__( self, device: torch.device, - val_data_loader: Union[Iterable, DataLoader], + val_data_loader: Iterable | DataLoader, networks: Sequence[torch.nn.Module], - pred_keys: Optional[KeysCollection] = None, - epoch_length: Optional[int] = None, + pred_keys: KeysCollection | None = None, + epoch_length: int | None = None, non_blocking: bool = False, prepare_batch: Callable = default_prepare_batch, - iteration_update: Optional[Callable[[Engine, Any], Any]] = None, - inferer: Optional[Inferer] = None, - postprocessing: Optional[Transform] = None, - key_val_metric: Optional[Dict[str, Metric]] = None, - additional_metrics: Optional[Dict[str, Metric]] = None, + iteration_update: Callable[[Engine, Any], Any] | None = None, + inferer: Inferer | None = None, + postprocessing: Transform | None = None, + key_val_metric: dict[str, Metric] | None = None, + additional_metrics: dict[str, Metric] | None = None, metric_cmp_fn: Callable = default_metric_cmp_fn, - val_handlers: Optional[Sequence] = None, + val_handlers: Sequence | None = None, amp: bool = False, - mode: Union[ForwardMode, str] = ForwardMode.EVAL, - event_names: Optional[List[Union[str, EventEnum]]] = None, - event_to_attr: Optional[dict] = None, + mode: ForwardMode | str = ForwardMode.EVAL, + event_names: list[str | EventEnum] | None = None, + event_to_attr: dict | None = None, decollate: bool = True, - to_kwargs: Optional[Dict] = None, - amp_kwargs: Optional[Dict] = None, + to_kwargs: dict | None = None, + amp_kwargs: dict | None = None, ) -> None: super().__init__( device=device, @@ -392,7 +393,7 @@ def __init__( raise ValueError("length of `pred_keys` must be same as the length of `networks`.") self.inferer = SimpleInferer() if inferer is None else inferer - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): + def _iteration(self, engine: EnsembleEvaluator, batchdata: dict[str, torch.Tensor]): """ callback function for the Supervised Evaluation processing logic of 1 iteration in Ignite Engine. Return below items in a dictionary: @@ -404,7 +405,7 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): - pred_keys[N]: prediction result of network N. Args: - engine: Ignite Engine, it can be a trainer, validator or evaluator. + engine: `EnsembleEvaluator` to execute operation for an iteration. batchdata: input data for this iteration, usually can be dictionary or tuple of Tensor data. Raises: @@ -413,31 +414,29 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ if batchdata is None: raise ValueError("Must provide batch data for current iteration.") - batch = self.prepare_batch( - batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore - ) + batch = engine.prepare_batch(batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs) if len(batch) == 2: inputs, targets = batch - args: Tuple = () - kwargs: Dict = {} + args: tuple = () + kwargs: dict = {} else: inputs, targets, args, kwargs = batch # put iteration outputs into engine.state - engine.state.output = {Keys.IMAGE: inputs, Keys.LABEL: targets} # type: ignore + engine.state.output = {Keys.IMAGE: inputs, Keys.LABEL: targets} - for idx, network in enumerate(self.networks): - with self.mode(network): - if self.amp: - with torch.cuda.amp.autocast(**engine.amp_kwargs): # type: ignore + for idx, network in enumerate(engine.networks): + with engine.mode(network): + if engine.amp: + with torch.cuda.amp.autocast(**engine.amp_kwargs): if isinstance(engine.state.output, dict): engine.state.output.update( - {self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)} + {engine.pred_keys[idx]: engine.inferer(inputs, network, *args, **kwargs)} ) else: if isinstance(engine.state.output, dict): engine.state.output.update( - {self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)} + {engine.pred_keys[idx]: engine.inferer(inputs, network, *args, **kwargs)} ) engine.fire_event(IterationEvents.FORWARD_COMPLETED) engine.fire_event(IterationEvents.MODEL_COMPLETED) diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index 12753765ef..fbdb309bb5 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -9,7 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence import torch from torch.optim.optimizer import Optimizer @@ -55,7 +57,7 @@ def run(self) -> None: self.scaler = torch.cuda.amp.GradScaler() if self.amp else None super().run() - def get_train_stats(self) -> Dict[str, float]: + def get_train_stats(self) -> dict[str, float]: return {"total_epochs": self.state.max_epochs, "total_iterations": self.state.epoch_length} @@ -116,27 +118,27 @@ def __init__( self, device: torch.device, max_epochs: int, - train_data_loader: Union[Iterable, DataLoader], + train_data_loader: Iterable | DataLoader, network: torch.nn.Module, optimizer: Optimizer, loss_function: Callable, - epoch_length: Optional[int] = None, + epoch_length: int | None = None, non_blocking: bool = False, prepare_batch: Callable = default_prepare_batch, - iteration_update: Optional[Callable[[Engine, Any], Any]] = None, - inferer: Optional[Inferer] = None, - postprocessing: Optional[Transform] = None, - key_train_metric: Optional[Dict[str, Metric]] = None, - additional_metrics: Optional[Dict[str, Metric]] = None, + iteration_update: Callable[[Engine, Any], Any] | None = None, + inferer: Inferer | None = None, + postprocessing: Transform | None = None, + key_train_metric: dict[str, Metric] | None = None, + additional_metrics: dict[str, Metric] | None = None, metric_cmp_fn: Callable = default_metric_cmp_fn, - train_handlers: Optional[Sequence] = None, + train_handlers: Sequence | None = None, amp: bool = False, - event_names: Optional[List[Union[str, EventEnum]]] = None, - event_to_attr: Optional[dict] = None, + event_names: list[str | EventEnum] | None = None, + event_to_attr: dict | None = None, decollate: bool = True, optim_set_to_none: bool = False, - to_kwargs: Optional[Dict] = None, - amp_kwargs: Optional[Dict] = None, + to_kwargs: dict | None = None, + amp_kwargs: dict | None = None, ) -> None: super().__init__( device=device, @@ -165,7 +167,7 @@ def __init__( self.inferer = SimpleInferer() if inferer is None else inferer self.optim_set_to_none = optim_set_to_none - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): + def _iteration(self, engine: SupervisedTrainer, batchdata: dict[str, torch.Tensor]): """ Callback function for the Supervised Training processing logic of 1 iteration in Ignite Engine. Return below items in a dictionary: @@ -175,7 +177,7 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): - LOSS: loss value computed by loss function. Args: - engine: Ignite Engine, it can be a trainer, validator or evaluator. + engine: `SupervisedTrainer` to execute operation for an iteration. batchdata: input data for this iteration, usually can be dictionary or tuple of Tensor data. Raises: @@ -184,39 +186,37 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ if batchdata is None: raise ValueError("Must provide batch data for current iteration.") - batch = self.prepare_batch( - batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore - ) + batch = engine.prepare_batch(batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs) if len(batch) == 2: inputs, targets = batch - args: Tuple = () - kwargs: Dict = {} + args: tuple = () + kwargs: dict = {} else: inputs, targets, args, kwargs = batch # put iteration outputs into engine.state - engine.state.output = {Keys.IMAGE: inputs, Keys.LABEL: targets} # type: ignore + engine.state.output = {Keys.IMAGE: inputs, Keys.LABEL: targets} def _compute_pred_loss(): - engine.state.output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) + engine.state.output[Keys.PRED] = engine.inferer(inputs, engine.network, *args, **kwargs) engine.fire_event(IterationEvents.FORWARD_COMPLETED) - engine.state.output[Keys.LOSS] = self.loss_function(engine.state.output[Keys.PRED], targets).mean() + engine.state.output[Keys.LOSS] = engine.loss_function(engine.state.output[Keys.PRED], targets).mean() engine.fire_event(IterationEvents.LOSS_COMPLETED) - self.network.train() - self.optimizer.zero_grad(set_to_none=self.optim_set_to_none) + engine.network.train() + engine.optimizer.zero_grad(set_to_none=engine.optim_set_to_none) - if self.amp and self.scaler is not None: - with torch.cuda.amp.autocast(**engine.amp_kwargs): # type: ignore + if engine.amp and engine.scaler is not None: + with torch.cuda.amp.autocast(**engine.amp_kwargs): _compute_pred_loss() - self.scaler.scale(engine.state.output[Keys.LOSS]).backward() # type: ignore + engine.scaler.scale(engine.state.output[Keys.LOSS]).backward() engine.fire_event(IterationEvents.BACKWARD_COMPLETED) - self.scaler.step(self.optimizer) - self.scaler.update() + engine.scaler.step(engine.optimizer) + engine.scaler.update() else: _compute_pred_loss() - engine.state.output[Keys.LOSS].backward() # type: ignore + engine.state.output[Keys.LOSS].backward() engine.fire_event(IterationEvents.BACKWARD_COMPLETED) - self.optimizer.step() + engine.optimizer.step() engine.fire_event(IterationEvents.MODEL_COMPLETED) return engine.state.output @@ -295,25 +295,25 @@ def __init__( d_network: torch.nn.Module, d_optimizer: Optimizer, d_loss_function: Callable, - epoch_length: Optional[int] = None, - g_inferer: Optional[Inferer] = None, - d_inferer: Optional[Inferer] = None, + epoch_length: int | None = None, + g_inferer: Inferer | None = None, + d_inferer: Inferer | None = None, d_train_steps: int = 1, latent_shape: int = 64, non_blocking: bool = False, d_prepare_batch: Callable = default_prepare_batch, g_prepare_batch: Callable = default_make_latent, g_update_latents: bool = True, - iteration_update: Optional[Callable[[Engine, Any], Any]] = None, - postprocessing: Optional[Transform] = None, - key_train_metric: Optional[Dict[str, Metric]] = None, - additional_metrics: Optional[Dict[str, Metric]] = None, + iteration_update: Callable[[Engine, Any], Any] | None = None, + postprocessing: Transform | None = None, + key_train_metric: dict[str, Metric] | None = None, + additional_metrics: dict[str, Metric] | None = None, metric_cmp_fn: Callable = default_metric_cmp_fn, - train_handlers: Optional[Sequence] = None, + train_handlers: Sequence | None = None, decollate: bool = True, optim_set_to_none: bool = False, - to_kwargs: Optional[Dict] = None, - amp_kwargs: Optional[Dict] = None, + to_kwargs: dict | None = None, + amp_kwargs: dict | None = None, ): if not isinstance(train_data_loader, DataLoader): raise ValueError("train_data_loader must be PyTorch DataLoader.") @@ -351,13 +351,13 @@ def __init__( self.optim_set_to_none = optim_set_to_none def _iteration( - self, engine: Engine, batchdata: Union[Dict, Sequence] - ) -> Dict[str, Union[torch.Tensor, int, float, bool]]: + self, engine: GanTrainer, batchdata: dict | Sequence + ) -> dict[str, torch.Tensor | int | float | bool]: """ Callback function for Adversarial Training processing logic of 1 iteration in Ignite Engine. Args: - engine: Ignite Engine, it can be a trainer, validator or evaluator. + engine: `GanTrainer` to execute operation for an iteration. batchdata: input data for this iteration, usually can be dictionary or tuple of Tensor data. Raises: @@ -367,42 +367,40 @@ def _iteration( if batchdata is None: raise ValueError("must provide batch data for current iteration.") - d_input = self.prepare_batch( - batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs # type: ignore - ) - batch_size = self.data_loader.batch_size # type: ignore - g_input = self.g_prepare_batch( + d_input = engine.prepare_batch(batchdata, engine.state.device, engine.non_blocking, **engine.to_kwargs) + batch_size = engine.data_loader.batch_size # type: ignore + g_input = engine.g_prepare_batch( num_latents=batch_size, - latent_size=self.latent_shape, - device=engine.state.device, # type: ignore - non_blocking=engine.non_blocking, # type: ignore - **engine.to_kwargs, # type: ignore + latent_size=engine.latent_shape, + device=engine.state.device, + non_blocking=engine.non_blocking, + **engine.to_kwargs, ) - g_output = self.g_inferer(g_input, self.g_network) + g_output = engine.g_inferer(g_input, engine.g_network) # Train Discriminator d_total_loss = torch.zeros(1) - for _ in range(self.d_train_steps): - self.d_optimizer.zero_grad(set_to_none=self.optim_set_to_none) - dloss = self.d_loss_function(g_output, d_input) + for _ in range(engine.d_train_steps): + engine.d_optimizer.zero_grad(set_to_none=engine.optim_set_to_none) + dloss = engine.d_loss_function(g_output, d_input) dloss.backward() - self.d_optimizer.step() + engine.d_optimizer.step() d_total_loss += dloss.item() # Train Generator - if self.g_update_latents: - g_input = self.g_prepare_batch( + if engine.g_update_latents: + g_input = engine.g_prepare_batch( num_latents=batch_size, - latent_size=self.latent_shape, - device=engine.state.device, # type: ignore - non_blocking=engine.non_blocking, # type: ignore - **engine.to_kwargs, # type: ignore + latent_size=engine.latent_shape, + device=engine.state.device, + non_blocking=engine.non_blocking, + **engine.to_kwargs, ) - g_output = self.g_inferer(g_input, self.g_network) - self.g_optimizer.zero_grad(set_to_none=self.optim_set_to_none) - g_loss = self.g_loss_function(g_output) + g_output = engine.g_inferer(g_input, engine.g_network) + engine.g_optimizer.zero_grad(set_to_none=engine.optim_set_to_none) + g_loss = engine.g_loss_function(g_output) g_loss.backward() - self.g_optimizer.step() + engine.g_optimizer.step() return { GanKeys.REALS: d_input, diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 75123da153..5b3cb556a5 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -250,8 +250,8 @@ def _register_metrics(self, k_metric: Dict, add_metrics: Optional[Dict] = None): metric.attach(self, name) @self.on(Events.EPOCH_COMPLETED) - def _compare_metrics(engine: Engine) -> None: - key_metric_name = engine.state.key_metric_name # type: ignore + def _compare_metrics(engine: Workflow) -> None: + key_metric_name = engine.state.key_metric_name if key_metric_name is not None: current_val_metric = engine.state.metrics[key_metric_name] if not is_scalar(current_val_metric): @@ -261,10 +261,10 @@ def _compare_metrics(engine: Engine) -> None: ) return - if self.metric_cmp_fn(current_val_metric, engine.state.best_metric): # type: ignore + if self.metric_cmp_fn(current_val_metric, engine.state.best_metric): self.logger.info(f"Got new best metric of {key_metric_name}: {current_val_metric}") - engine.state.best_metric = current_val_metric # type: ignore - engine.state.best_metric_epoch = engine.state.epoch # type: ignore + engine.state.best_metric = current_val_metric + engine.state.best_metric_epoch = engine.state.epoch def _register_handlers(self, handlers: Sequence): """ @@ -289,7 +289,7 @@ def run(self) -> None: return super().run(data=self.data_loader, max_epochs=self.state.max_epochs) - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): + def _iteration(self, engine, batchdata: Dict[str, torch.Tensor]): """ Abstract callback function for the processing logic of 1 iteration in Ignite Engine. Need subclass to implement different logics, like SupervisedTrainer/Evaluator, GANTrainer, etc. From ed70e11da73d9f980d8576a84fa8586329b25936 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 22 Apr 2022 05:19:07 -0400 Subject: [PATCH 027/183] Add OpenSlide support for new WSIReader (#4155) * Implement WSIReader with OpenSlide backend . Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittest for openslide Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update imports and few fixes Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Fix x,y in docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Address comments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove x and y Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/data.rst | 42 +++++---- monai/data/__init__.py | 2 +- monai/data/image_reader.py | 2 +- monai/data/wsi_reader.py | 165 ++++++++++++++++++++++++++++++++---- tests/test_wsireader_new.py | 7 ++ 5 files changed, 182 insertions(+), 36 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index c968d72945..f92c390815 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -152,23 +152,6 @@ PILReader .. autoclass:: PILReader :members: -Whole slide image reader ------------------------- - -BaseWSIReader -~~~~~~~~~~~~~ -.. autoclass:: BaseWSIReader - :members: - -WSIReader -~~~~~~~~~ -.. autoclass:: WSIReader - :members: - -CuCIMWSIReader -~~~~~~~~~~~~~~ -.. autoclass:: CuCIMWSIReader - :members: Image writer ------------ @@ -295,3 +278,28 @@ MetaTensor ---------- .. autoclass:: monai.data.MetaTensor :members: + + + +Whole slide image reader +------------------------ + +BaseWSIReader +~~~~~~~~~~~~~ +.. autoclass:: monai.data.BaseWSIReader + :members: + +WSIReader +~~~~~~~~~ +.. autoclass:: monai.data.WSIReader + :members: + +CuCIMWSIReader +~~~~~~~~~~~~~~ +.. autoclass:: monai.data.CuCIMWSIReader + :members: + +OpenSlideWSIReader +~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.OpenSlideWSIReader + :members: diff --git a/monai/data/__init__.py b/monai/data/__init__.py index ca4be87ef6..b10f0e034e 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -87,4 +87,4 @@ worker_init_fn, zoom_affine, ) -from .wsi_reader import BaseWSIReader, CuCIMWSIReader, WSIReader +from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index ca77178e0b..7e1db7ef7d 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -809,7 +809,7 @@ def get_data( Args: img: a WSIReader image object loaded from a file, or list of CuImage objects - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame, + location: (top, left) tuple giving the top left pixel in the level 0 reference frame, or list of tuples (default=(0, 0)) size: (height, width) tuple giving the region size, or list of tuples (default to full image size) This is the size of image at the given level (`level`) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 4899fb8830..ad5141787c 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -17,12 +17,13 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import EnsureChannelFirst +from monai.transforms.utility.array import AsChannelFirst from monai.utils import ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") +OpenSlide, _ = optional_import("openslide", name="OpenSlide") -__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader"] +__all__ = ["BaseWSIReader", "WSIReader", "CuCIMWSIReader", "OpenSlideWSIReader"] class BaseWSIReader(ImageReader): @@ -91,7 +92,7 @@ def get_patch( Args: wsi: a whole slide image object loaded from a file or a lis of such objects - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -104,11 +105,11 @@ def get_patch( @abstractmethod def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: """ - Extracts and returns metadata form the whole slide image. + Returns metadata of the extracted patch from the whole slide image. Args: patch: extracted patch from whole slide image - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -130,7 +131,7 @@ def get_data( Args: wsi: a whole slide image object loaded from a file or a list of such objects - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -216,9 +217,11 @@ class WSIReader(BaseWSIReader): def __init__(self, backend="cucim", level: int = 0, **kwargs): super().__init__(level, **kwargs) self.backend = backend.lower() - # Any new backend can be added below + self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader] if self.backend == "cucim": self.reader = CuCIMWSIReader(level=level, **kwargs) + elif self.backend == "openslide": + self.reader = OpenSlideWSIReader(level=level, **kwargs) else: raise ValueError("The supported backends are: cucim") self.supported_suffixes = self.reader.supported_suffixes @@ -233,7 +236,7 @@ def get_level_count(self, wsi) -> int: """ return self.reader.get_level_count(wsi) - def get_size(self, wsi, level) -> Tuple[int, int]: + def get_size(self, wsi, level: int) -> Tuple[int, int]: """ Returns the size of the whole slide image at a given level. @@ -246,11 +249,11 @@ def get_size(self, wsi, level) -> Tuple[int, int]: def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: """ - Extracts and returns metadata form the whole slide image. + Returns metadata of the extracted patch from the whole slide image. Args: patch: extracted patch from whole slide image - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -266,7 +269,7 @@ def get_patch( Args: wsi: a whole slide image object loaded from a file or a lis of such objects - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -294,7 +297,7 @@ def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): @require_pkg(pkg_name="cucim") class CuCIMWSIReader(BaseWSIReader): """ - Read whole slide images and extract patches without loading the whole slide image into the memory. + Read whole slide images and extract patches using cuCIM library. Args: level: the whole slide image level at which the image is extracted. (default=0) @@ -321,7 +324,7 @@ def get_level_count(wsi) -> int: return wsi.resolutions["level_count"] # type: ignore @staticmethod - def get_size(wsi, level) -> Tuple[int, int]: + def get_size(wsi, level: int) -> Tuple[int, int]: """ Returns the size of the whole slide image at a given level. @@ -334,11 +337,11 @@ def get_size(wsi, level) -> Tuple[int, int]: def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: """ - Extracts and returns metadata form the whole slide image. + Returns metadata of the extracted patch from the whole slide image. Args: patch: extracted patch from whole slide image - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -386,7 +389,7 @@ def get_patch( Args: wsi: a whole slide image object loaded from a file or a lis of such objects - location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). If None, it is set to the full image size at the given level. level: the level number. Defaults to 0 @@ -402,7 +405,7 @@ def get_patch( patch = np.asarray(patch, dtype=dtype) # Make it channel first - patch = EnsureChannelFirst()(patch, {"original_channel_dim": -1}) # type: ignore + patch = AsChannelFirst()(patch) # type: ignore # Check if the color channel is 3 (RGB) or 4 (RGBA) if mode == "RGBA" and patch.shape[0] != 4: @@ -418,3 +421,131 @@ def get_patch( patch = patch[:3] return patch + + +@require_pkg(pkg_name="openslide") +class OpenSlideWSIReader(BaseWSIReader): + """ + Read whole slide images and extract patches using OpenSlide library. + + Args: + level: the whole slide image level at which the image is extracted. (default=0) + This is overridden if the level argument is provided in `get_data`. + kwargs: additional args for `openslide.OpenSlide` module. + + """ + + supported_suffixes = ["tif", "tiff", "svs"] + + def __init__(self, level: int = 0, **kwargs): + super().__init__(level, **kwargs) + + @staticmethod + def get_level_count(wsi) -> int: + """ + Returns the number of levels in the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file + + """ + return wsi.level_count # type: ignore + + @staticmethod + def get_size(wsi, level: int) -> Tuple[int, int]: + """ + Returns the size of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + return (wsi.level_dimensions[level][1], wsi.level_dimensions[level][0]) + + def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: + """ + Returns metadata of the extracted patch from the whole slide image. + + Args: + patch: extracted patch from whole slide image + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + + """ + metadata: Dict = { + "backend": "openslide", + "spatial_shape": np.asarray(patch.shape[1:]), + "original_channel_dim": 0, + "location": location, + "size": size, + "level": level, + } + return metadata + + def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): + """ + Read whole slide image objects from given file or list of files. + + Args: + data: file name or a list of file names to read. + kwargs: additional args that overrides `self.kwargs` for existing keys. + + Returns: + whole slide image object or list of such objects + + """ + wsi_list: List = [] + + filenames: Sequence[PathLike] = ensure_tuple(data) + kwargs_ = self.kwargs.copy() + kwargs_.update(kwargs) + for filename in filenames: + wsi = OpenSlide(filename, **kwargs_) + wsi_list.append(wsi) + + return wsi_list if len(filenames) > 1 else wsi_list[0] + + def get_patch( + self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str + ) -> np.ndarray: + """ + Extracts and returns a patch image form the whole slide image. + + Args: + wsi: a whole slide image object loaded from a file or a lis of such objects + location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). + size: (height, width) tuple giving the patch size at the given level (`level`). + If None, it is set to the full image size at the given level. + level: the level number. Defaults to 0 + dtype: the data type of output image + mode: the output image mode, 'RGB' or 'RGBA' + + """ + # Extract a patch or the entire image + # (reverse the order of location and size to become WxH for OpenSlide) + pil_patch = wsi.read_region(location=location[::-1], size=size[::-1], level=level) + + # convert to RGB/RGBA + pil_patch = pil_patch.convert(mode) + + # Convert to numpy + patch = np.asarray(pil_patch, dtype=dtype) + + # Make it channel first + patch = AsChannelFirst()(patch) # type: ignore + + # Check if the color channel is 3 (RGB) or 4 (RGBA) + if mode == "RGBA" and patch.shape[0] != 4: + raise ValueError( + f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." + ) + + elif mode in "RGB" and patch.shape[0] != 3: + raise ValueError( + f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " + ) + + return patch diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 7b288f6040..456f5a9453 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -214,5 +214,12 @@ def setUpClass(cls): cls.backend = "cucim" +@skipUnless(has_osl, "Requires openslide") +class TestOpenSlide(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + if __name__ == "__main__": unittest.main() From 65d7296129b7a9b53389a89630133cdbbc255947 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 22 Apr 2022 10:37:50 -0400 Subject: [PATCH 028/183] Redesign PatchWSIDataset (#4152) * Implement PatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Reorder imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * formatting: Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Address comments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update to be compatible with Dataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update reader to accept str, class, object Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add test cases for various reader and level arguments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update comment about OpenSlide cache Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Rename reader_name to backend Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add new test cases Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for openslide Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add new test cases Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * sorts Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add docstring for kwargs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/data.rst | 8 ++ monai/data/__init__.py | 1 + monai/data/wsi_datasets.py | 135 ++++++++++++++++++++++ tests/test_patch_wsi_dataset_new.py | 169 ++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 monai/data/wsi_datasets.py create mode 100644 tests/test_patch_wsi_dataset_new.py diff --git a/docs/source/data.rst b/docs/source/data.rst index f92c390815..02e8031117 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -303,3 +303,11 @@ OpenSlideWSIReader ~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.OpenSlideWSIReader :members: + +Whole slide image datasets +-------------------------- + +PatchWSIDataset +~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.PatchWSIDataset + :members: diff --git a/monai/data/__init__.py b/monai/data/__init__.py index b10f0e034e..d9af568508 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -87,4 +87,5 @@ worker_init_fn, zoom_affine, ) +from .wsi_datasets import PatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py new file mode 100644 index 0000000000..a895e8aa45 --- /dev/null +++ b/monai/data/wsi_datasets.py @@ -0,0 +1,135 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +from typing import Callable, Dict, List, Optional, Tuple, Union + +import numpy as np + +from monai.data import Dataset +from monai.data.wsi_reader import BaseWSIReader, WSIReader +from monai.transforms import apply_transform +from monai.utils import ensure_tuple_rep + +__all__ = ["PatchWSIDataset"] + + +class PatchWSIDataset(Dataset): + """ + This dataset extracts patches from whole slide images (without loading the whole image) + It also reads labels for each patch and provides each patch with its associated class labels. + + Args: + data: the list of input samples including image, location, and label (see the note below for more details). + size: the size of patch to be extracted from the whole slide image. + level: the level at which the patches to be extracted (default to 0). + transform: transforms to be executed on input data. + reader: the module to be used for loading whole slide imaging, + - if `reader` is a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. + - if `reader` is a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader. + - if `reader` is an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class + + Note: + The input data has the following form as an example: + + .. code-block:: python + + [ + {"image": "path/to/image1.tiff", "location": [200, 500], "label": 0}, + {"image": "path/to/image2.tiff", "location": [100, 700], "label": 1} + ] + + """ + + def __init__( + self, + data: List, + size: Optional[Union[int, Tuple[int, int]]] = None, + level: Optional[int] = None, + transform: Optional[Callable] = None, + reader="cuCIM", + **kwargs, + ): + super().__init__(data, transform) + + # Ensure patch size is a two dimensional tuple + if size is None: + self.size = None + else: + self.size = ensure_tuple_rep(size, 2) + + # Create a default level that override all levels if it is not None + self.level = level + # Set the default WSIReader's level to 0 if level is not provided + if level is None: + level = 0 + + # Setup the WSI reader + self.wsi_reader: Union[WSIReader, BaseWSIReader] + self.backend = "" + if isinstance(reader, str): + self.backend = reader.lower() + self.wsi_reader = WSIReader(backend=self.backend, level=level, **kwargs) + elif inspect.isclass(reader) and issubclass(reader, BaseWSIReader): + self.wsi_reader = reader(level=level, **kwargs) + elif isinstance(reader, BaseWSIReader): + self.wsi_reader = reader + else: + raise ValueError(f"Unsupported reader type: {reader}.") + + # Initialized an empty whole slide image object dict + self.wsi_object_dict: Dict = {} + + def _get_wsi_object(self, sample: Dict): + image_path = sample["image"] + if image_path not in self.wsi_object_dict: + self.wsi_object_dict[image_path] = self.wsi_reader.read(image_path) + return self.wsi_object_dict[image_path] + + def _get_label(self, sample: Dict): + return np.array(sample["label"], dtype=np.float32) + + def _get_location(self, sample: Dict): + size = self._get_size(sample) + return [sample["location"][i] - size[i] // 2 for i in range(len(size))] + + def _get_level(self, sample: Dict): + if self.level is None: + return sample.get("level", 0) + return self.level + + def _get_size(self, sample: Dict): + if self.size is None: + return ensure_tuple_rep(sample.get("size"), 2) + return self.size + + def _get_data(self, sample: Dict): + # Don't store OpenSlide objects to avoid issues with OpenSlide internal cache + if self.backend == "openslide": + self.wsi_object_dict = {} + wsi_obj = self._get_wsi_object(sample) + location = self._get_location(sample) + level = self._get_level(sample) + size = self._get_size(sample) + return self.wsi_reader.get_data(wsi=wsi_obj, location=location, size=size, level=level) + + def _transform(self, index: int): + # Get a single entry of data + sample: Dict = self.data[index] + # Extract patch image and associated metadata + image, metadata = self._get_data(sample) + # Get the label + label = self._get_label(sample) + + # Create put all patch information together and apply transforms + patch = {"image": image, "label": label, "metadata": metadata} + return apply_transform(self.transform, patch) if self.transform else patch diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py new file mode 100644 index 0000000000..0be30536de --- /dev/null +++ b/tests/test_patch_wsi_dataset_new.py @@ -0,0 +1,169 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import PatchWSIDataset +from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader +from monai.utils import optional_import +from tests.utils import download_url_or_skip_test, testing_data_config + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1], "level": 0}], "size": (1, 1)}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L1 = [ + {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L2 = [ + {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] +TEST_CASE_1 = [ + {"data": [{"image": FILE_PATH, "location": [0, 0], "size": 1, "label": [1]}]}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_2 = [ + {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": 1, "level": 0}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_3 = [ + {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "size": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, +] + +TEST_CASE_4 = [ + { + "data": [ + {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + ], + "size": 1, + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + +TEST_CASE_5 = [ + { + "data": [ + {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]], "size": 1, "level": 1}, + {"image": FILE_PATH, "location": [100, 100], "label": [[[1, 0], [0, 0]]], "size": 1, "level": 1}, + ] + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + + +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): # noqa: N802 + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class PatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_str(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_class(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader + elif self.backend == "cucim": + reader = CuCIMWSIReader + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_object(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader(level=input_parameters.get("level", 0)) + elif self.backend == "cucim": + reader = CuCIMWSIReader(level=input_parameters.get("level", 0)) + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_read_patches_str_multi(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + for i in range(len(dataset)): + self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) + + +@skipUnless(has_cucim, "Requires cucim") +class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires cucim") +class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() From e0e2e65c28c2ba2a8a18c379ed052e18bde793cc Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Sat, 23 Apr 2022 01:03:14 +0800 Subject: [PATCH 029/183] 4095 Add bundle download (#4114) * draft download Signed-off-by: Yiheng Wang * update bundle download Signed-off-by: Yiheng Wang * add url and load Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * rename args and remove a few places Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix flake8 issue Signed-off-by: Yiheng Wang * enhance with reviews Signed-off-by: Yiheng Wang * add instantiate for load Signed-off-by: Yiheng Wang * fix black error Signed-off-by: Yiheng Wang * add unittest Signed-off-by: Yiheng Wang * add load to docs Signed-off-by: Yiheng Wang * add skip Signed-off-by: Yiheng Wang * add schemaerror Signed-off-by: Yiheng Wang * fix partial places Signed-off-by: Yiheng Wang * download zip bundle Signed-off-by: Yiheng Wang * [DLMED] restore Exception for test Signed-off-by: Nic Ma * update ts features Signed-off-by: Yiheng Wang * add config_files test case Signed-off-by: Yiheng Wang * enhance docstring example for args_file Signed-off-by: Yiheng Wang Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Nic Ma --- docs/source/bundle.rst | 2 + monai/bundle/__init__.py | 2 +- monai/bundle/__main__.py | 2 +- monai/bundle/scripts.py | 184 +++++++++++++++++++++++++++++++++- tests/test_bundle_download.py | 173 ++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 tests/test_bundle_download.py diff --git a/docs/source/bundle.rst b/docs/source/bundle.rst index 297409cd7e..a28db04091 100644 --- a/docs/source/bundle.rst +++ b/docs/source/bundle.rst @@ -36,6 +36,8 @@ Model Bundle `Scripts` --------- .. autofunction:: ckpt_export +.. autofunction:: download +.. autofunction:: load .. autofunction:: run .. autofunction:: verify_metadata .. autofunction:: verify_net_in_out diff --git a/monai/bundle/__init__.py b/monai/bundle/__init__.py index d6a452b5a4..f30ee9c40c 100644 --- a/monai/bundle/__init__.py +++ b/monai/bundle/__init__.py @@ -12,5 +12,5 @@ from .config_item import ComponentLocator, ConfigComponent, ConfigExpression, ConfigItem, Instantiable from .config_parser import ConfigParser from .reference_resolver import ReferenceResolver -from .scripts import ckpt_export, run, verify_metadata, verify_net_in_out +from .scripts import ckpt_export, download, load, run, verify_metadata, verify_net_in_out from .utils import EXPR_KEY, ID_REF_KEY, ID_SEP_KEY, MACRO_KEY diff --git a/monai/bundle/__main__.py b/monai/bundle/__main__.py index d77b396e79..3e3534ef74 100644 --- a/monai/bundle/__main__.py +++ b/monai/bundle/__main__.py @@ -10,7 +10,7 @@ # limitations under the License. -from monai.bundle.scripts import ckpt_export, run, verify_metadata, verify_net_in_out +from monai.bundle.scripts import ckpt_export, download, run, verify_metadata, verify_net_in_out if __name__ == "__main__": from monai.utils import optional_import diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index b741e40e8d..33affcf31b 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -11,24 +11,28 @@ import ast import json +import os import pprint import re from logging.config import fileConfig +from pathlib import Path from typing import Dict, Optional, Sequence, Tuple, Union import torch from torch.cuda import is_available -from monai.apps.utils import download_url, get_logger +from monai.apps.utils import _basename, download_url, extractall, get_logger +from monai.bundle.config_item import ConfigComponent from monai.bundle.config_parser import ConfigParser from monai.config import IgniteInfo, PathLike -from monai.data import save_net_with_metadata +from monai.data import load_net_with_metadata, save_net_with_metadata from monai.networks import convert_to_torchscript, copy_model_state from monai.utils import check_parent_dir, get_equivalent_dtype, min_version, optional_import validate, _ = optional_import("jsonschema", name="validate") ValidationError, _ = optional_import("jsonschema.exceptions", name="ValidationError") Checkpoint, has_ignite = optional_import("ignite.handlers", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Checkpoint") +requests_get, has_requests = optional_import("requests", name="get") logger = get_logger(module_name=__name__) @@ -116,6 +120,182 @@ def _get_fake_spatial_shape(shape: Sequence[Union[str, int]], p: int = 1, n: int return tuple(ret) +def _get_git_release_url(repo_owner: str, repo_name: str, tag_name: str, filename: str): + return f"https://github.com/{repo_owner}/{repo_name}/releases/download/{tag_name}/{filename}" + + +def _download_from_github(repo: str, download_path: Path, filename: str, progress: bool = True): + if len(repo.split("/")) != 3: + raise ValueError("if source is `github`, repo should be in the form of `repo_owner/repo_name/release_tag`.") + repo_owner, repo_name, tag_name = repo.split("/") + if ".zip" not in filename: + filename += ".zip" + url = _get_git_release_url(repo_owner, repo_name, tag_name=tag_name, filename=filename) + filepath = download_path / f"{filename}" + download_url(url=url, filepath=filepath, hash_val=None, progress=progress) + extractall(filepath=filepath, output_dir=download_path, has_base=True) + + +def _process_bundle_dir(bundle_dir: Optional[PathLike] = None): + if bundle_dir is None: + get_dir, has_home = optional_import("torch.hub", name="get_dir") + if has_home: + bundle_dir = Path(get_dir()) / "bundle" + else: + raise ValueError("bundle_dir=None, but no suitable default directory computed. Upgrade Pytorch to 1.6+ ?") + return Path(bundle_dir) + + +def download( + name: Optional[str] = None, + bundle_dir: Optional[PathLike] = None, + source: str = "github", + repo: Optional[str] = None, + url: Optional[str] = None, + progress: bool = True, + args_file: Optional[str] = None, +): + """ + download bundle from the specified source or url. The bundle should be a zip file and it + will be extracted after downloading. + This function refers to: + https://pytorch.org/docs/stable/_modules/torch/hub.html + + Typical usage examples: + + .. code-block:: bash + + # Execute this module as a CLI entry, and download bundle: + python -m monai.bundle download --name "bundle_name" --source "github" --repo "repo_owner/repo_name/release_tag" + + # Execute this module as a CLI entry, and download bundle via URL: + python -m monai.bundle download --name "bundle_name" --url + + # Set default args of `run` in a JSON / YAML file, help to record and simplify the command line. + # Other args still can override the default args at runtime. + # The content of the JSON / YAML file is a dictionary. For example: + # {"name": "spleen", "bundle_dir": "download", "source": ""} + # then do the following command for downloading: + python -m monai.bundle download --args_file "args.json" --source "github" + + Args: + name: bundle name. If `None` and `url` is `None`, it must be provided in `args_file`. + bundle_dir: target directory to store the downloaded data. + Default is `bundle` subfolder under`torch.hub get_dir()`. + source: place that saved the bundle. + If `source` is `github`, the bundle should be within the releases. + repo: repo name. If `None` and `url` is `None`, it must be provided in `args_file`. + If `source` is `github`, it should be in the form of `repo_owner/repo_name/release_tag`. + For example: `Project-MONAI/MONAI-extra-test-data/0.8.1`. + url: url to download the data. If not `None`, data will be downloaded directly + and `source` will not be checked. + If `name` is `None`, filename is determined by `monai.apps.utils._basename(url)`. + progress: whether to display a progress bar. + args_file: a JSON or YAML file to provide default values for all the args in this function. + so that the command line inputs can be simplified. + + """ + _args = _update_args( + args=args_file, name=name, bundle_dir=bundle_dir, source=source, repo=repo, url=url, progress=progress + ) + + _log_input_summary(tag="download", args=_args) + name_, bundle_dir_, source_, repo_, url_, progress_ = _pop_args( + _args, name=None, bundle_dir=None, source="github", repo=None, url=None, progress=True + ) + + bundle_dir_ = _process_bundle_dir(bundle_dir_) + + if url_ is not None: + if name is not None: + filepath = bundle_dir_ / f"{name}.zip" + else: + filepath = bundle_dir_ / f"{_basename(url_)}" + download_url(url=url_, filepath=filepath, hash_val=None, progress=progress_) + extractall(filepath=filepath, output_dir=bundle_dir_, has_base=True) + elif source_ == "github": + if name_ is None or repo_ is None: + raise ValueError( + f"To download from source: Github, `name` and `repo` must be provided, got {name_} and {repo_}." + ) + _download_from_github(repo=repo_, download_path=bundle_dir_, filename=name_, progress=progress_) + else: + raise NotImplementedError( + f"Currently only download from provided URL in `url` or Github is implemented, got source: {source_}." + ) + + +def load( + name: str, + model_file: Optional[str] = None, + load_ts_module: bool = False, + bundle_dir: Optional[PathLike] = None, + source: str = "github", + repo: Optional[str] = None, + progress: bool = True, + device: Optional[str] = None, + config_files: Sequence[str] = (), + net_name: Optional[str] = None, + **net_kwargs, +): + """ + Load model weights or TorchScript module of a bundle. + + Args: + name: bundle name. + model_file: the relative path of the model weights or TorchScript module within bundle. + If `None`, "models/model.pt" or "models/model.ts" will be used. + load_ts_module: a flag to specify if loading the TorchScript module. + bundle_dir: the directory the weights/TorchScript module will be loaded from. + Default is `bundle` subfolder under`torch.hub get_dir()`. + source: the place that saved the bundle. + If `source` is `github`, the bundle should be within the releases. + repo: the repo name. If the weights file does not exist locally and `url` is `None`, it must be provided. + If `source` is `github`, it should be in the form of `repo_owner/repo_name/release_tag`. + For example: `Project-MONAI/MONAI-extra-test-data/0.8.1`. + progress: whether to display a progress bar when downloading. + device: target device of returned weights or module, if `None`, prefer to "cuda" if existing. + config_files: extra filenames would be loaded. The argument only works when loading a TorchScript module, + see `_extra_files` in `torch.jit.load` for more details. + net_name: if not `None`, a corresponding network will be instantiated and load the achieved weights. + This argument only works when loading weights. + net_kwargs: other arguments that are used to instantiate the network class defined by `net_name`. + + Returns: + 1. If `load_ts_module` is `False` and `net_name` is `None`, return model weights. + 2. If `load_ts_module` is `False` and `net_name` is not `None`, + return an instantiated network that loaded the weights. + 3. If `load_ts_module` is `True`, return a triple that include a TorchScript module, + the corresponding metadata dict, and extra files dict. + please check `monai.data.load_net_with_metadata` for more details. + + """ + bundle_dir_ = _process_bundle_dir(bundle_dir) + + if model_file is None: + model_file = os.path.join("models", "model.ts" if load_ts_module is True else "model.pt") + full_path = os.path.join(bundle_dir_, name, model_file) + if not os.path.exists(full_path): + download(name=name, bundle_dir=bundle_dir_, source=source, repo=repo, progress=progress) + + if device is None: + device = "cuda:0" if is_available() else "cpu" + # loading with `torch.jit.load` + if load_ts_module is True: + return load_net_with_metadata(full_path, map_location=torch.device(device), more_extra_files=config_files) + # loading with `torch.load` + model_dict = torch.load(full_path, map_location=torch.device(device)) + + if net_name is None: + return model_dict + net_kwargs["_target_"] = net_name + configer = ConfigComponent(config=net_kwargs) + model = configer.instantiate() + model.to(device) # type: ignore + model.load_state_dict(model_dict) # type: ignore + return model + + def run( runner_id: Optional[str] = None, meta_file: Optional[Union[str, Sequence[str]]] = None, diff --git a/tests/test_bundle_download.py b/tests/test_bundle_download.py new file mode 100644 index 0000000000..921399bc54 --- /dev/null +++ b/tests/test_bundle_download.py @@ -0,0 +1,173 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +import subprocess +import tempfile +import unittest + +import torch +from parameterized import parameterized + +import monai.networks.nets as nets +from monai.apps import check_hash +from monai.bundle import ConfigParser, load +from tests.utils import skip_if_downloading_fails, skip_if_quick, skip_if_windows + +TEST_CASE_1 = [ + ["model.pt", "model.ts", "network.json", "test_output.pt", "test_input.pt"], + "test_bundle", + "Project-MONAI/MONAI-extra-test-data/0.8.1", + "a131d39a0af717af32d19e565b434928", +] + +TEST_CASE_2 = [ + ["model.pt", "model.ts", "network.json", "test_output.pt", "test_input.pt"], + "test_bundle", + "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/test_bundle.zip", + "a131d39a0af717af32d19e565b434928", +] + +TEST_CASE_3 = [ + ["model.pt", "model.ts", "network.json", "test_output.pt", "test_input.pt"], + "test_bundle", + "Project-MONAI/MONAI-extra-test-data/0.8.1", + "cuda" if torch.cuda.is_available() else "cpu", + "model.pt", +] + +TEST_CASE_4 = [ + ["test_output.pt", "test_input.pt"], + "test_bundle", + "Project-MONAI/MONAI-extra-test-data/0.8.1", + "cuda" if torch.cuda.is_available() else "cpu", + "model.ts", +] + + +@skip_if_windows +class TestDownload(unittest.TestCase): + @parameterized.expand([TEST_CASE_1]) + @skip_if_quick + def test_download_bundle(self, bundle_files, bundle_name, repo, hash_val): + with skip_if_downloading_fails(): + # download a whole bundle from github releases + with tempfile.TemporaryDirectory() as tempdir: + cmd = ["coverage", "run", "-m", "monai.bundle", "download", "--name", bundle_name, "--source", "github"] + cmd += ["--bundle_dir", tempdir, "--repo", repo, "--progress", "False"] + subprocess.check_call(cmd) + for file in bundle_files: + file_path = os.path.join(tempdir, bundle_name, file) + self.assertTrue(os.path.exists(file_path)) + if file == "network.json": + self.assertTrue(check_hash(filepath=file_path, val=hash_val)) + + @parameterized.expand([TEST_CASE_2]) + @skip_if_quick + def test_url_download_bundle(self, bundle_files, bundle_name, url, hash_val): + with skip_if_downloading_fails(): + # download a single file from url, also use `args_file` + with tempfile.TemporaryDirectory() as tempdir: + def_args = {"name": bundle_name, "bundle_dir": tempdir, "url": ""} + def_args_file = os.path.join(tempdir, "def_args.json") + parser = ConfigParser() + parser.export_config_file(config=def_args, filepath=def_args_file) + cmd = ["coverage", "run", "-m", "monai.bundle", "download", "--args_file", def_args_file] + cmd += ["--url", url] + subprocess.check_call(cmd) + for file in bundle_files: + file_path = os.path.join(tempdir, bundle_name, file) + self.assertTrue(os.path.exists(file_path)) + if file == "network.json": + self.assertTrue(check_hash(filepath=file_path, val=hash_val)) + + +class TestLoad(unittest.TestCase): + @parameterized.expand([TEST_CASE_3]) + @skip_if_quick + def test_load_weights(self, bundle_files, bundle_name, repo, device, model_file): + with skip_if_downloading_fails(): + # download bundle, and load weights from the downloaded path + with tempfile.TemporaryDirectory() as tempdir: + # load weights + weights = load( + name=bundle_name, + model_file=model_file, + bundle_dir=tempdir, + repo=repo, + progress=False, + device=device, + ) + + # prepare network + with open(os.path.join(tempdir, bundle_name, bundle_files[2])) as f: + net_args = json.load(f)["network_def"] + model_name = net_args["_target_"] + del net_args["_target_"] + model = nets.__dict__[model_name](**net_args) + model.to(device) + model.load_state_dict(weights) + model.eval() + + # prepare data and test + input_tensor = torch.load(os.path.join(tempdir, bundle_name, bundle_files[4]), map_location=device) + output = model.forward(input_tensor) + expected_output = torch.load(os.path.join(tempdir, bundle_name, bundle_files[3]), map_location=device) + torch.testing.assert_allclose(output, expected_output) + + # load instantiated model directly and test, since the bundle has been downloaded, + # there is no need to input `repo` + model_2 = load( + name=bundle_name, + model_file=model_file, + bundle_dir=tempdir, + progress=False, + device=device, + net_name=model_name, + **net_args, + ) + model_2.eval() + output_2 = model_2.forward(input_tensor) + torch.testing.assert_allclose(output_2, expected_output) + + @parameterized.expand([TEST_CASE_4]) + @skip_if_quick + def test_load_ts_module(self, bundle_files, bundle_name, repo, device, model_file): + with skip_if_downloading_fails(): + # load ts module + with tempfile.TemporaryDirectory() as tempdir: + # load ts module + model_ts, metadata, extra_file_dict = load( + name=bundle_name, + model_file=model_file, + load_ts_module=True, + bundle_dir=tempdir, + repo=repo, + progress=False, + device=device, + config_files=("test_config.txt",), + ) + + # prepare and test ts + input_tensor = torch.load(os.path.join(tempdir, bundle_name, bundle_files[1]), map_location=device) + output = model_ts.forward(input_tensor) + expected_output = torch.load(os.path.join(tempdir, bundle_name, bundle_files[0]), map_location=device) + torch.testing.assert_allclose(output, expected_output) + # test metadata + self.assertTrue(metadata["foo"] == [1, 2]) + self.assertTrue(metadata["bar"] == "string") + # test extra_file_dict + self.assertTrue("test_config.txt" in extra_file_dict.keys()) + + +if __name__ == "__main__": + unittest.main() From 710781bf2e4bcb5e63100e1dbb9632862d2df1ad Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 23 Apr 2022 23:48:34 +0800 Subject: [PATCH 030/183] Disable pylint error and fix CI tests of new tifffile (#4162) * workaround Signed-off-by: Nic Ma * [DLMED] fix tifffile issue Signed-off-by: Nic Ma --- monai/bundle/scripts.py | 2 +- tests/test_lesion_froc.py | 2 +- tests/test_wsireader.py | 4 ++-- tests/test_wsireader_new.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 33affcf31b..f4652fb3e7 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -429,7 +429,7 @@ def verify_metadata( try: # the rest key-values in the _args are for `validate` API validate(instance=metadata, schema=schema, **_args) - except ValidationError as e: + except ValidationError as e: # pylint: disable=E0712 # as the error message is very long, only extract the key information logger.info(re.compile(r".*Failed validating", re.S).findall(str(e))[0] + f" against schema `{url}`.") return diff --git a/tests/test_lesion_froc.py b/tests/test_lesion_froc.py index 6b4989a9d5..b135b3eaeb 100644 --- a/tests/test_lesion_froc.py +++ b/tests/test_lesion_froc.py @@ -31,7 +31,7 @@ def save_as_tif(filename, array): if not filename.endswith(".tif"): filename += ".tif" file_path = os.path.join("tests", "testing_data", filename) - imwrite(file_path, array, compress="jpeg", tile=(16, 16)) + imwrite(file_path, array, compression="jpeg", tile=(16, 16)) def around(val, interval=3): diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 6ee02143b8..3655100dab 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -84,7 +84,7 @@ TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW -TEST_CASE_ERROR_GRAY = [np.ones((16, 16), dtype=np.uint8)] # no color channel +TEST_CASE_ERROR_GRAY = [np.ones((16, 16, 2), dtype=np.uint8)] # wrong color channel TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color @@ -115,7 +115,7 @@ def save_gray_tiff(array: np.ndarray, filename: str): filename: the filename to be used for the tiff file. """ img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape, photometric="rgb") + imwrite(filename, img_gray, shape=img_gray.shape, photometric="minisblack") return filename diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 456f5a9453..63d61dfeb3 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -72,7 +72,7 @@ TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW -TEST_CASE_ERROR_GRAY = [np.ones((16, 16), dtype=np.uint8)] # no color channel +TEST_CASE_ERROR_GRAY = [np.ones((16, 16, 2), dtype=np.uint8)] # wrong color channel TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color @@ -103,7 +103,7 @@ def save_gray_tiff(array: np.ndarray, filename: str): filename: the filename to be used for the tiff file. """ img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape, photometric="rgb") + imwrite(filename, img_gray, shape=img_gray.shape, photometric="minisblack") return filename From d69c5ee2865bd87c4a12a23037ec6f2ed3168720 Mon Sep 17 00:00:00 2001 From: dongyang0122 Date: Sat, 23 Apr 2022 10:37:46 -0600 Subject: [PATCH 031/183] Fixed an error in DiNTS model implementation and enabled act and norm layer options (#4157) * fixed a bug Signed-off-by: dongy * autofix Signed-off-by: dongy * update test case Signed-off-by: dongy Co-authored-by: dongy --- monai/networks/blocks/dints_block.py | 8 +- monai/networks/nets/dints.py | 138 ++++++++++++++++++++++----- tests/test_dints_cell.py | 40 +++++++- tests/test_dints_network.py | 4 +- 4 files changed, 156 insertions(+), 34 deletions(-) diff --git a/monai/networks/blocks/dints_block.py b/monai/networks/blocks/dints_block.py index f76e125fe0..b7365f50e3 100644 --- a/monai/networks/blocks/dints_block.py +++ b/monai/networks/blocks/dints_block.py @@ -31,7 +31,7 @@ def __init__( out_channel: int, spatial_dims: int = 3, act_name: Union[Tuple, str] = "RELU", - norm_name: Union[Tuple, str] = "INSTANCE", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), ): """ Args: @@ -82,7 +82,7 @@ def __init__( out_channel: int, spatial_dims: int = 3, act_name: Union[Tuple, str] = "RELU", - norm_name: Union[Tuple, str] = "INSTANCE", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), ): """ Args: @@ -150,7 +150,7 @@ def __init__( padding: int, mode: int = 0, act_name: Union[Tuple, str] = "RELU", - norm_name: Union[Tuple, str] = "INSTANCE", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), ): """ Args: @@ -235,7 +235,7 @@ def __init__( padding: int = 1, spatial_dims: int = 3, act_name: Union[Tuple, str] = "RELU", - norm_name: Union[Tuple, str] = "INSTANCE", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), ): """ Args: diff --git a/monai/networks/nets/dints.py b/monai/networks/nets/dints.py index 978695c5d0..b7f3921a47 100644 --- a/monai/networks/nets/dints.py +++ b/monai/networks/nets/dints.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import warnings from typing import List, Optional, Tuple, Union @@ -96,22 +97,47 @@ class _ActiConvNormBlockWithRAMCost(ActiConvNormBlock): ram_cost = total_ram/output_size = 2 * in_channel/out_channel + 1 """ - def __init__(self, in_channel: int, out_channel: int, kernel_size: int, padding: int, spatial_dims: int = 3): - super().__init__(in_channel, out_channel, kernel_size, padding, spatial_dims) + def __init__( + self, + in_channel: int, + out_channel: int, + kernel_size: int, + padding: int, + spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), + ): + super().__init__(in_channel, out_channel, kernel_size, padding, spatial_dims, act_name, norm_name) self.ram_cost = 1 + in_channel / out_channel * 2 class _P3DActiConvNormBlockWithRAMCost(P3DActiConvNormBlock): - def __init__(self, in_channel: int, out_channel: int, kernel_size: int, padding: int, p3dmode: int = 0): - super().__init__(in_channel, out_channel, kernel_size, padding, p3dmode) + def __init__( + self, + in_channel: int, + out_channel: int, + kernel_size: int, + padding: int, + p3dmode: int = 0, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), + ): + super().__init__(in_channel, out_channel, kernel_size, padding, p3dmode, act_name, norm_name) # 1 in_channel (activation) + 1 in_channel (convolution) + # 1 out_channel (convolution) + 1 out_channel (normalization) self.ram_cost = 2 + 2 * in_channel / out_channel class _FactorizedIncreaseBlockWithRAMCost(FactorizedIncreaseBlock): - def __init__(self, in_channel: int, out_channel: int, spatial_dims: int = 3): - super().__init__(in_channel, out_channel, spatial_dims) + def __init__( + self, + in_channel: int, + out_channel: int, + spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), + ): + super().__init__(in_channel, out_channel, spatial_dims, act_name, norm_name) # s0 is upsampled 2x from s1, representing feature sizes at two resolutions. # 2 * in_channel * s0 (upsample + activation) + 2 * out_channel * s0 (conv + normalization) # s0 = output_size/out_channel @@ -119,8 +145,15 @@ def __init__(self, in_channel: int, out_channel: int, spatial_dims: int = 3): class _FactorizedReduceBlockWithRAMCost(FactorizedReduceBlock): - def __init__(self, in_channel: int, out_channel: int, spatial_dims: int = 3): - super().__init__(in_channel, out_channel, spatial_dims) + def __init__( + self, + in_channel: int, + out_channel: int, + spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), + ): + super().__init__(in_channel, out_channel, spatial_dims, act_name, norm_name) # s0 is upsampled 2x from s1, representing feature sizes at two resolutions. # in_channel * s0 (activation) + 3 * out_channel * s1 (convolution, concatenation, normalization) # s0 = s1 * 2^(spatial_dims) = output_size / out_channel * 2^(spatial_dims) @@ -182,14 +215,6 @@ class Cell(CellInterface): # \ # - Downsample - # Define connection operation set, parameterized by the number of channels - ConnOPS = { - "up": _FactorizedIncreaseBlockWithRAMCost, - "down": _FactorizedReduceBlockWithRAMCost, - "identity": _IdentityWithRAMCost, - "align_channels": _ActiConvNormBlockWithRAMCost, - } - # Define 2D operation set, parameterized by the number of channels OPS2D = { "skip_connect": lambda _c: _IdentityWithRAMCost(), @@ -205,18 +230,69 @@ class Cell(CellInterface): "conv_1x3x3": lambda c: _P3DActiConvNormBlockWithRAMCost(c, c, 3, padding=1, p3dmode=2), } - def __init__(self, c_prev: int, c: int, rate: int, arch_code_c=None, spatial_dims: int = 3): + # Define connection operation set, parameterized by the number of channels + ConnOPS = { + "up": _FactorizedIncreaseBlockWithRAMCost, + "down": _FactorizedReduceBlockWithRAMCost, + "identity": _IdentityWithRAMCost, + "align_channels": _ActiConvNormBlockWithRAMCost, + } + + def __init__( + self, + c_prev: int, + c: int, + rate: int, + arch_code_c=None, + spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), + ): super().__init__() self._spatial_dims = spatial_dims + self._act_name = act_name + self._norm_name = norm_name + if rate == -1: # downsample - self.preprocess = self.ConnOPS["down"](c_prev, c, spatial_dims=self._spatial_dims) + self.preprocess = self.ConnOPS["down"]( + c_prev, c, spatial_dims=self._spatial_dims, act_name=self._act_name, norm_name=self._norm_name + ) elif rate == 1: # upsample - self.preprocess = self.ConnOPS["up"](c_prev, c, spatial_dims=self._spatial_dims) + self.preprocess = self.ConnOPS["up"]( + c_prev, c, spatial_dims=self._spatial_dims, act_name=self._act_name, norm_name=self._norm_name + ) else: if c_prev == c: self.preprocess = self.ConnOPS["identity"]() else: - self.preprocess = self.ConnOPS["align_channels"](c_prev, c, 1, 0, spatial_dims=self._spatial_dims) + self.preprocess = self.ConnOPS["align_channels"]( + c_prev, c, 1, 0, spatial_dims=self._spatial_dims, act_name=self._act_name, norm_name=self._norm_name + ) + + # Define 2D operation set, parameterized by the number of channels + self.OPS2D = { + "skip_connect": lambda _c: _IdentityWithRAMCost(), + "conv_3x3": lambda c: _ActiConvNormBlockWithRAMCost( + c, c, 3, padding=1, spatial_dims=2, act_name=self._act_name, norm_name=self._norm_name + ), + } + + # Define 3D operation set, parameterized by the number of channels + self.OPS3D = { + "skip_connect": lambda _c: _IdentityWithRAMCost(), + "conv_3x3x3": lambda c: _ActiConvNormBlockWithRAMCost( + c, c, 3, padding=1, spatial_dims=3, act_name=self._act_name, norm_name=self._norm_name + ), + "conv_3x3x1": lambda c: _P3DActiConvNormBlockWithRAMCost( + c, c, 3, padding=1, p3dmode=0, act_name=self._act_name, norm_name=self._norm_name + ), + "conv_3x1x3": lambda c: _P3DActiConvNormBlockWithRAMCost( + c, c, 3, padding=1, p3dmode=1, act_name=self._act_name, norm_name=self._norm_name + ), + "conv_1x3x3": lambda c: _P3DActiConvNormBlockWithRAMCost( + c, c, 3, padding=1, p3dmode=2, act_name=self._act_name, norm_name=self._norm_name + ), + } self.OPS = {} if self._spatial_dims == 2: @@ -283,7 +359,7 @@ def __init__( in_channels: int, num_classes: int, act_name: Union[Tuple, str] = "RELU", - norm_name: Union[Tuple, str] = "INSTANCE", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), spatial_dims: int = 3, use_downsample: bool = True, node_a=None, @@ -398,7 +474,9 @@ def __init__( bias=False, dilation=1, ), - get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=self.filter_nums[res_idx - 1]), + get_norm_layer( + name=norm_name, spatial_dims=spatial_dims, channels=self.filter_nums[max(res_idx - 1, 0)] + ), nn.Upsample(scale_factor=2 ** (res_idx != 0), mode=mode, align_corners=True), ) @@ -484,6 +562,8 @@ def __init__( num_blocks: int = 6, num_depths: int = 3, spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), use_downsample: bool = True, device: str = "cpu", ): @@ -494,6 +574,8 @@ def __init__( self.num_blocks = num_blocks self.num_depths = num_depths self._spatial_dims = spatial_dims + self._act_name = act_name + self._norm_name = norm_name self.use_downsample = use_downsample self.device = device self.num_cell_ops = 0 @@ -535,6 +617,8 @@ def __init__( self.arch_code2ops[res_idx], self.arch_code_c[blk_idx, res_idx], self._spatial_dims, + self._act_name, + self._norm_name, ) def forward(self, x): @@ -555,6 +639,8 @@ def __init__( num_blocks: int = 6, num_depths: int = 3, spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), use_downsample: bool = True, device: str = "cpu", ): @@ -571,6 +657,8 @@ def __init__( num_blocks=num_blocks, num_depths=num_depths, spatial_dims=spatial_dims, + act_name=act_name, + norm_name=norm_name, use_downsample=use_downsample, device=device, ) @@ -591,7 +679,7 @@ def forward(self, x: List[torch.Tensor]) -> List[torch.Tensor]: x=inputs[self.arch_code2in[res_idx]], weight=torch.ones_like(self.arch_code_c[blk_idx, res_idx]) ) outputs[self.arch_code2out[res_idx]] = outputs[self.arch_code2out[res_idx]] + _out - inputs = outputs + inputs = outputs return inputs @@ -650,6 +738,8 @@ def __init__( num_blocks: int = 6, num_depths: int = 3, spatial_dims: int = 3, + act_name: Union[Tuple, str] = "RELU", + norm_name: Union[Tuple, str] = ("INSTANCE", {"affine": True}), use_downsample: bool = True, device: str = "cpu", ): @@ -663,6 +753,8 @@ def __init__( num_blocks=num_blocks, num_depths=num_depths, spatial_dims=spatial_dims, + act_name=act_name, + norm_name=norm_name, use_downsample=use_downsample, device=device, ) diff --git a/tests/test_dints_cell.py b/tests/test_dints_cell.py index d480235b70..a5da39bae9 100644 --- a/tests/test_dints_cell.py +++ b/tests/test_dints_cell.py @@ -32,21 +32,28 @@ (2, 4, 64, 32, 16), ], [ - {"c_prev": 8, "c": 8, "rate": 0, "arch_code_c": None}, + {"c_prev": 8, "c": 8, "rate": 0, "arch_code_c": None, "act_name": "SELU", "norm_name": "BATCH"}, torch.tensor([1, 1, 1, 1, 1]), torch.tensor([0, 0, 0, 1, 0]), (2, 8, 32, 16, 8), (2, 8, 32, 16, 8), ], [ - {"c_prev": 8, "c": 8, "rate": -1, "arch_code_c": None}, + { + "c_prev": 8, + "c": 8, + "rate": -1, + "arch_code_c": None, + "act_name": "PRELU", + "norm_name": ("BATCH", {"affine": False}), + }, torch.tensor([1, 1, 1, 1, 1]), torch.tensor([1, 1, 1, 1, 1]), (2, 8, 32, 16, 8), (2, 8, 16, 8, 4), ], [ - {"c_prev": 8, "c": 8, "rate": -1, "arch_code_c": [1, 0, 0, 0, 1]}, + {"c_prev": 8, "c": 8, "rate": -1, "arch_code_c": [1, 0, 0, 0, 1], "act_name": "RELU", "norm_name": "INSTANCE"}, torch.tensor([1, 0, 0, 0, 1]), torch.tensor([0.2, 0.2, 0.2, 0.2, 0.2]), (2, 8, 32, 16, 8), @@ -56,12 +63,35 @@ TEST_CASES_2D = [ [ - {"c_prev": 8, "c": 7, "rate": -1, "arch_code_c": [1, 0, 0, 0, 1], "spatial_dims": 2}, + { + "c_prev": 8, + "c": 7, + "rate": -1, + "arch_code_c": [1, 0, 0, 0, 1], + "spatial_dims": 2, + "act_name": "PRELU", + "norm_name": ("BATCH", {"affine": False}), + }, torch.tensor([1, 0]), torch.tensor([0.2, 0.2]), (2, 8, 16, 8), (2, 7, 8, 4), - ] + ], + [ + { + "c_prev": 8, + "c": 8, + "rate": -1, + "arch_code_c": None, + "spatial_dims": 2, + "act_name": "SELU", + "norm_name": "INSTANCE", + }, + torch.tensor([1, 0]), + torch.tensor([0.2, 0.2]), + (2, 8, 16, 8), + (2, 8, 8, 4), + ], ] diff --git a/tests/test_dints_network.py b/tests/test_dints_network.py index 8be5eb7ccd..08e75fab98 100644 --- a/tests/test_dints_network.py +++ b/tests/test_dints_network.py @@ -33,7 +33,7 @@ "in_channels": 1, "num_classes": 3, "act_name": "RELU", - "norm_name": "INSTANCE", + "norm_name": ("INSTANCE", {"affine": True}), "use_downsample": False, "spatial_dims": 3, }, @@ -101,7 +101,7 @@ "in_channels": 1, "num_classes": 4, "act_name": "RELU", - "norm_name": "INSTANCE", + "norm_name": ("INSTANCE", {"affine": True}), "use_downsample": False, "spatial_dims": 2, }, From 0cdec837a6ae43cdc46d2ce80befb0362b07e47c Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sat, 23 Apr 2022 13:30:35 -0400 Subject: [PATCH 032/183] Split transform (#4153) * Redesign whole slide image reading (#4107) * Redesign BaseWSIReader, WSIReader, CuCIMWSIReader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for WSIReader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add image mode for output validation Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update references to new WSIReader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove legacy WSIReader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * sort imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Clean up imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs and docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix a typo Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove redundant checking Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update read and other methods Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update wsireader to support multi image and update docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Make workaround for CuImage objects Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for multi image reading Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update a note about cucim Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update type hints and docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Implement Split transform Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Implement SplitDict Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for SplitDict Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove images from docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Address all comments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add example and size check Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Revert references to new wsireader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add missing comma Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 14 ++++ monai/transforms/__init__.py | 4 + monai/transforms/spatial/array.py | 90 ++++++++++++++++++++++ monai/transforms/spatial/dictionary.py | 39 ++++++++++ tests/test_grid_split.py | 84 +++++++++++++++++++++ tests/test_grid_splitd.py | 100 +++++++++++++++++++++++++ 6 files changed, 331 insertions(+) create mode 100644 tests/test_grid_split.py create mode 100644 tests/test_grid_splitd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 676e0274fe..a93c48984c 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -737,6 +737,13 @@ Spatial :members: :special-members: __call__ +`GridSplit` +""""""""""" +.. autoclass:: GridSplit + :members: + :special-members: __call__ + + Smooth Field ^^^^^^^^^^^^ @@ -1506,6 +1513,13 @@ Spatial (Dict) :members: :special-members: __call__ +`GridSplitd` +"""""""""""" +.. autoclass:: GridSplitd + :members: + :special-members: __call__ + + `RandRotate90d` """"""""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/RandRotate90d.png diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 581e368ba0..c2385499b3 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -311,6 +311,7 @@ AffineGrid, Flip, GridDistortion, + GridSplit, Orientation, Rand2DElastic, Rand3DElastic, @@ -342,6 +343,9 @@ GridDistortiond, GridDistortionD, GridDistortionDict, + GridSplitd, + GridSplitD, + GridSplitDict, Orientationd, OrientationD, OrientationDict, diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 37f1c3edc3..6b67762b95 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -18,6 +18,7 @@ import numpy as np import torch +from numpy.lib.stride_tricks import as_strided from monai.config import USE_COMPILED, DtypeLike from monai.config.type_definitions import NdarrayOrTensor @@ -65,6 +66,7 @@ "Orientation", "Flip", "GridDistortion", + "GridSplit", "Resize", "Rotate", "Zoom", @@ -2462,3 +2464,91 @@ def __call__( if not self._do_transform: return img return self.grid_distortion(img, distort_steps=self.distort_steps, mode=mode, padding_mode=padding_mode) + + +class GridSplit(Transform): + """ + Split the image into patches based on the provided grid in 2D. + + Args: + grid: a tuple define the shape of the grid upon which the image is split. Defaults to (2, 2) + size: a tuple or an integer that defines the output patch sizes. + If it's an integer, the value will be repeated for each dimension. + The default is None, where the patch size will be inferred from the grid shape. + + Example: + Given an image (torch.Tensor or numpy.ndarray) with size of (3, 10, 10) and a grid of (2, 2), + it will return a Tensor or array with the size of (4, 3, 5, 5). + Here, if the `size` is provided, the returned shape will be (4, 3, size, size) + + Note: This transform currently support only image with two spatial dimensions. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, grid: Tuple[int, int] = (2, 2), size: Optional[Union[int, Tuple[int, int]]] = None): + # Grid size + self.grid = grid + + # Patch size + self.size = None if size is None else ensure_tuple_rep(size, len(self.grid)) + + def __call__(self, image: NdarrayOrTensor) -> NdarrayOrTensor: + if self.grid == (1, 1) and self.size is None: + if isinstance(image, torch.Tensor): + return torch.stack([image]) + elif isinstance(image, np.ndarray): + return np.stack([image]) # type: ignore + else: + raise ValueError(f"Input type [{type(image)}] is not supported.") + + size, steps = self._get_params(image.shape[1:]) + patches: NdarrayOrTensor + if isinstance(image, torch.Tensor): + patches = ( + image.unfold(1, size[0], steps[0]) + .unfold(2, size[1], steps[1]) + .flatten(1, 2) + .transpose(0, 1) + .contiguous() + ) + elif isinstance(image, np.ndarray): + x_step, y_step = steps + c_stride, x_stride, y_stride = image.strides + n_channels = image.shape[0] + patches = as_strided( + image, + shape=(*self.grid, n_channels, size[0], size[1]), + strides=(x_stride * x_step, y_stride * y_step, c_stride, x_stride, y_stride), + writeable=False, + ) + # flatten the first two dimensions + patches = patches.reshape(np.prod(patches.shape[:2]), *patches.shape[2:]) + # make it a contiguous array + patches = np.ascontiguousarray(patches) + else: + raise ValueError(f"Input type [{type(image)}] is not supported.") + + return patches + + def _get_params(self, image_size: Union[Sequence[int], np.ndarray]): + """ + Calculate the size and step required for splitting the image + Args: + The size of the input image + """ + if self.size is not None: + # Set the split size to the given default size + if any(self.size[i] > image_size[i] for i in range(len(self.grid))): + raise ValueError("The image size ({image_size})is smaller than the requested split size ({self.size})") + split_size = self.size + else: + # infer each sub-image size from the image size and the grid + split_size = tuple(image_size[i] // self.grid[i] for i in range(len(self.grid))) + + steps = tuple( + (image_size[i] - split_size[i]) // (self.grid[i] - 1) if self.grid[i] > 1 else image_size[i] + for i in range(len(self.grid)) + ) + + return split_size, steps diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d42a11fd2f..47fe05700e 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -34,6 +34,7 @@ AffineGrid, Flip, GridDistortion, + GridSplit, Orientation, Rand2DElastic, Rand3DElastic, @@ -129,6 +130,9 @@ "ZoomDict", "RandZoomD", "RandZoomDict", + "GridSplitd", + "GridSplitD", + "GridSplitDict", ] GridSampleModeSequence = Union[Sequence[Union[GridSampleMode, str]], GridSampleMode, str] @@ -2149,6 +2153,40 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +class GridSplitd(MapTransform): + """ + Split the image into patches based on the provided grid in 2D. + + Args: + keys: keys of the corresponding items to be transformed. + grid: a tuple define the shape of the grid upon which the image is split. Defaults to (2, 2) + size: a tuple or an integer that defines the output patch sizes. + If it's an integer, the value will be repeated for each dimension. + The default is None, where the patch size will be inferred from the grid shape. + allow_missing_keys: don't raise exception if key is missing. + + Note: This transform currently support only image with two spatial dimensions. + """ + + backend = GridSplit.backend + + def __init__( + self, + keys: KeysCollection, + grid: Tuple[int, int] = (2, 2), + size: Optional[Union[int, Tuple[int, int]]] = None, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.splitter = GridSplit(grid=grid, size=size) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.splitter(d[key]) + return d + + SpatialResampleD = SpatialResampleDict = SpatialResampled ResampleToMatchD = ResampleToMatchDict = ResampleToMatchd SpacingD = SpacingDict = Spacingd @@ -2169,3 +2207,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N RandRotateD = RandRotateDict = RandRotated ZoomD = ZoomDict = Zoomd RandZoomD = RandZoomDict = RandZoomd +GridSplitD = GridSplitDict = GridSplitd diff --git a/tests/test_grid_split.py b/tests/test_grid_split.py new file mode 100644 index 0000000000..6f0525029d --- /dev/null +++ b/tests/test_grid_split.py @@ -0,0 +1,84 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.transforms import GridSplit +from tests.utils import TEST_NDARRAYS, assert_allclose + +A11 = torch.randn(3, 2, 2) +A12 = torch.randn(3, 2, 2) +A21 = torch.randn(3, 2, 2) +A22 = torch.randn(3, 2, 2) + +A1 = torch.cat([A11, A12], 2) +A2 = torch.cat([A21, A22], 2) +A = torch.cat([A1, A2], 1) + +TEST_CASE_0 = [{"grid": (2, 2)}, A, torch.stack([A11, A12, A21, A22])] +TEST_CASE_1 = [{"grid": (2, 1)}, A, torch.stack([A1, A2])] +TEST_CASE_2 = [{"grid": (1, 2)}, A1, torch.stack([A11, A12])] +TEST_CASE_3 = [{"grid": (1, 2)}, A2, torch.stack([A21, A22])] +TEST_CASE_4 = [{"grid": (1, 1), "size": (2, 2)}, A, torch.stack([A11])] +TEST_CASE_5 = [{"grid": (1, 1), "size": 4}, A, torch.stack([A])] +TEST_CASE_6 = [{"grid": (2, 2), "size": 2}, A, torch.stack([A11, A12, A21, A22])] +TEST_CASE_7 = [{"grid": (1, 1)}, A, torch.stack([A])] +TEST_CASE_8 = [ + {"grid": (2, 2), "size": 2}, + torch.arange(12).reshape(1, 3, 4).to(torch.float32), + torch.Tensor([[[[0, 1], [4, 5]]], [[[2, 3], [6, 7]]], [[[4, 5], [8, 9]]], [[[6, 7], [10, 11]]]]).to(torch.float32), +] + +TEST_SINGLE = [] +for p in TEST_NDARRAYS: + TEST_SINGLE.append([p, *TEST_CASE_0]) + TEST_SINGLE.append([p, *TEST_CASE_1]) + TEST_SINGLE.append([p, *TEST_CASE_2]) + TEST_SINGLE.append([p, *TEST_CASE_3]) + TEST_SINGLE.append([p, *TEST_CASE_4]) + TEST_SINGLE.append([p, *TEST_CASE_5]) + TEST_SINGLE.append([p, *TEST_CASE_6]) + TEST_SINGLE.append([p, *TEST_CASE_7]) + TEST_SINGLE.append([p, *TEST_CASE_8]) + +TEST_CASE_MC_0 = [{"grid": (2, 2)}, [A, A], [torch.stack([A11, A12, A21, A22]), torch.stack([A11, A12, A21, A22])]] +TEST_CASE_MC_1 = [{"grid": (2, 1)}, [A] * 5, [torch.stack([A1, A2])] * 5] +TEST_CASE_MC_2 = [{"grid": (1, 2)}, [A1, A2], [torch.stack([A11, A12]), torch.stack([A21, A22])]] + +TEST_MULTIPLE = [] +for p in TEST_NDARRAYS: + TEST_MULTIPLE.append([p, *TEST_CASE_MC_0]) + TEST_MULTIPLE.append([p, *TEST_CASE_MC_1]) + TEST_MULTIPLE.append([p, *TEST_CASE_MC_2]) + + +class TestGridSplit(unittest.TestCase): + @parameterized.expand(TEST_SINGLE) + def test_split_patch_single_call(self, in_type, input_parameters, image, expected): + input_image = in_type(image) + splitter = GridSplit(**input_parameters) + output = splitter(input_image) + assert_allclose(output, expected, type_test=False) + + @parameterized.expand(TEST_MULTIPLE) + def test_split_patch_multiple_call(self, in_type, input_parameters, img_list, expected_list): + splitter = GridSplit(**input_parameters) + for image, expected in zip(img_list, expected_list): + input_image = in_type(image) + output = splitter(input_image) + assert_allclose(output, expected, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_grid_splitd.py b/tests/test_grid_splitd.py new file mode 100644 index 0000000000..f325a16946 --- /dev/null +++ b/tests/test_grid_splitd.py @@ -0,0 +1,100 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.transforms import GridSplitd +from tests.utils import TEST_NDARRAYS, assert_allclose + +A11 = torch.randn(3, 2, 2) +A12 = torch.randn(3, 2, 2) +A21 = torch.randn(3, 2, 2) +A22 = torch.randn(3, 2, 2) + +A1 = torch.cat([A11, A12], 2) +A2 = torch.cat([A21, A22], 2) +A = torch.cat([A1, A2], 1) + +TEST_CASE_0 = [{"keys": "image", "grid": (2, 2)}, {"image": A}, torch.stack([A11, A12, A21, A22])] +TEST_CASE_1 = [{"keys": "image", "grid": (2, 1)}, {"image": A}, torch.stack([A1, A2])] +TEST_CASE_2 = [{"keys": "image", "grid": (1, 2)}, {"image": A1}, torch.stack([A11, A12])] +TEST_CASE_3 = [{"keys": "image", "grid": (1, 2)}, {"image": A2}, torch.stack([A21, A22])] +TEST_CASE_4 = [{"keys": "image", "grid": (1, 1), "size": (2, 2)}, {"image": A}, torch.stack([A11])] +TEST_CASE_5 = [{"keys": "image", "grid": (1, 1), "size": 4}, {"image": A}, torch.stack([A])] +TEST_CASE_6 = [{"keys": "image", "grid": (2, 2), "size": 2}, {"image": A}, torch.stack([A11, A12, A21, A22])] +TEST_CASE_7 = [{"keys": "image", "grid": (1, 1)}, {"image": A}, torch.stack([A])] +TEST_CASE_8 = [ + {"keys": "image", "grid": (2, 2), "size": 2}, + {"image": torch.arange(12).reshape(1, 3, 4).to(torch.float32)}, + torch.Tensor([[[[0, 1], [4, 5]]], [[[2, 3], [6, 7]]], [[[4, 5], [8, 9]]], [[[6, 7], [10, 11]]]]).to(torch.float32), +] + +TEST_SINGLE = [] +for p in TEST_NDARRAYS: + TEST_SINGLE.append([p, *TEST_CASE_0]) + TEST_SINGLE.append([p, *TEST_CASE_1]) + TEST_SINGLE.append([p, *TEST_CASE_2]) + TEST_SINGLE.append([p, *TEST_CASE_3]) + TEST_SINGLE.append([p, *TEST_CASE_4]) + TEST_SINGLE.append([p, *TEST_CASE_5]) + TEST_SINGLE.append([p, *TEST_CASE_6]) + TEST_SINGLE.append([p, *TEST_CASE_7]) + TEST_SINGLE.append([p, *TEST_CASE_8]) + +TEST_CASE_MC_0 = [ + {"keys": "image", "grid": (2, 2)}, + [{"image": A}, {"image": A}], + [torch.stack([A11, A12, A21, A22]), torch.stack([A11, A12, A21, A22])], +] +TEST_CASE_MC_1 = [ + {"keys": "image", "grid": (2, 1)}, + [{"image": A}, {"image": A}, {"image": A}], + [torch.stack([A1, A2])] * 3, +] +TEST_CASE_MC_2 = [ + {"keys": "image", "grid": (1, 2)}, + [{"image": A1}, {"image": A2}], + [torch.stack([A11, A12]), torch.stack([A21, A22])], +] + +TEST_MULTIPLE = [] +for p in TEST_NDARRAYS: + TEST_MULTIPLE.append([p, *TEST_CASE_MC_0]) + TEST_MULTIPLE.append([p, *TEST_CASE_MC_1]) + TEST_MULTIPLE.append([p, *TEST_CASE_MC_2]) + + +class TestGridSplitd(unittest.TestCase): + @parameterized.expand(TEST_SINGLE) + def test_split_patch_single_call(self, in_type, input_parameters, img_dict, expected): + input_dict = {} + for k, v in img_dict.items(): + input_dict[k] = in_type(v) + splitter = GridSplitd(**input_parameters) + output = splitter(input_dict)[input_parameters["keys"]] + assert_allclose(output, expected, type_test=False) + + @parameterized.expand(TEST_MULTIPLE) + def test_split_patch_multiple_call(self, in_type, input_parameters, img_list, expected_list): + splitter = GridSplitd(**input_parameters) + for img_dict, expected in zip(img_list, expected_list): + input_dict = {} + for k, v in img_dict.items(): + input_dict[k] = in_type(v) + output = splitter(input_dict)[input_parameters["keys"]] + assert_allclose(output, expected, type_test=False) + + +if __name__ == "__main__": + unittest.main() From 8f5c9924df73f6f3dbb222d6a1263d71b7abd0cd Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Mon, 25 Apr 2022 20:41:52 +0800 Subject: [PATCH 033/183] fix bundle download test issue (#4169) Signed-off-by: Yiheng Wang --- tests/test_bundle_download.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_bundle_download.py b/tests/test_bundle_download.py index 921399bc54..7e609a7b31 100644 --- a/tests/test_bundle_download.py +++ b/tests/test_bundle_download.py @@ -21,7 +21,7 @@ import monai.networks.nets as nets from monai.apps import check_hash from monai.bundle import ConfigParser, load -from tests.utils import skip_if_downloading_fails, skip_if_quick, skip_if_windows +from tests.utils import SkipIfBeforePyTorchVersion, skip_if_downloading_fails, skip_if_quick, skip_if_windows TEST_CASE_1 = [ ["model.pt", "model.ts", "network.json", "test_output.pt", "test_input.pt"], @@ -141,6 +141,7 @@ def test_load_weights(self, bundle_files, bundle_name, repo, device, model_file) @parameterized.expand([TEST_CASE_4]) @skip_if_quick + @SkipIfBeforePyTorchVersion((1, 7, 1)) def test_load_ts_module(self, bundle_files, bundle_name, repo, device, model_file): with skip_if_downloading_fails(): # load ts module @@ -154,7 +155,7 @@ def test_load_ts_module(self, bundle_files, bundle_name, repo, device, model_fil repo=repo, progress=False, device=device, - config_files=("test_config.txt",), + config_files=("network.json",), ) # prepare and test ts @@ -163,10 +164,9 @@ def test_load_ts_module(self, bundle_files, bundle_name, repo, device, model_fil expected_output = torch.load(os.path.join(tempdir, bundle_name, bundle_files[0]), map_location=device) torch.testing.assert_allclose(output, expected_output) # test metadata - self.assertTrue(metadata["foo"] == [1, 2]) - self.assertTrue(metadata["bar"] == "string") + self.assertTrue(metadata["pytorch_version"] == "1.7.1") # test extra_file_dict - self.assertTrue("test_config.txt" in extra_file_dict.keys()) + self.assertTrue("network.json" in extra_file_dict.keys()) if __name__ == "__main__": From e68898902b237a3ae6482bbf012b2dbaec11f0ab Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 26 Apr 2022 08:15:41 +0800 Subject: [PATCH 034/183] 4094 Enhance `ckpt_export` to save config files (#4159) * [DLMED] enhance checkpoint export Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/bundle/scripts.py | 19 ++++++++++++++++--- tests/test_bundle_ckpt_export.py | 14 +++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index f4652fb3e7..00496dba66 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -28,6 +28,7 @@ from monai.data import load_net_with_metadata, save_net_with_metadata from monai.networks import convert_to_torchscript, copy_model_state from monai.utils import check_parent_dir, get_equivalent_dtype, min_version, optional_import +from monai.utils.misc import ensure_tuple validate, _ = optional_import("jsonschema", name="validate") ValidationError, _ = optional_import("jsonschema.exceptions", name="ValidationError") @@ -550,8 +551,10 @@ def ckpt_export( filepath: filepath to export, if filename has no extension it becomes `.ts`. ckpt_file: filepath of the model checkpoint to load. meta_file: filepath of the metadata file, if it is a list of file paths, the content of them will be merged. - config_file: filepath of the config file, if `None`, must be provided in `args_file`. - if it is a list of file paths, the content of them will be merged. + config_file: filepath of the config file to save in TorchScript model and extract network information, + the saved key in the TorchScript model is the config filename without extension, and the saved config + value is always serialized in JSON format no matter the original file format is JSON or YAML. + it can be a single file or a list of files. if `None`, must be provided in `args_file`. key_in_ckpt: for nested checkpoint like `{"model": XXX, "optimizer": XXX, ...}`, specify the key of model weights. if not nested checkpoint, no need to set. args_file: a JSON or YAML file to provide default values for `meta_file`, `config_file`, @@ -595,12 +598,22 @@ def ckpt_export( # convert to TorchScript model and save with meta data, config content net = convert_to_torchscript(model=net) + extra_files: Dict = {} + for i in ensure_tuple(config_file_): + # split the filename and directory + filename = os.path.basename(i) + # remove extension + filename, _ = os.path.splitext(filename) + if filename in extra_files: + raise ValueError(f"filename '{filename}' is given multiple times in config file list.") + extra_files[filename] = json.dumps(ConfigParser.load_config_file(i)).encode() + save_net_with_metadata( jit_obj=net, filename_prefix_or_stream=filepath_, include_config_vals=False, append_timestamp=False, meta_values=parser.get().pop("_meta_", None), - more_extra_files={"config": json.dumps(parser.get()).encode()}, + more_extra_files=extra_files, ) logger.info(f"exported to TorchScript file: {filepath_}.") diff --git a/tests/test_bundle_ckpt_export.py b/tests/test_bundle_ckpt_export.py index 0f7d0f7d35..36aa7319f0 100644 --- a/tests/test_bundle_ckpt_export.py +++ b/tests/test_bundle_ckpt_export.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os import subprocess import tempfile @@ -17,6 +18,7 @@ from parameterized import parameterized from monai.bundle import ConfigParser +from monai.data import load_net_with_metadata from monai.networks import save_state from tests.utils import skip_if_windows @@ -33,7 +35,8 @@ def test_export(self, key_in_ckpt): config_file = os.path.join(os.path.dirname(__file__), "testing_data", "inference.json") with tempfile.TemporaryDirectory() as tempdir: def_args = {"meta_file": "will be replaced by `meta_file` arg"} - def_args_file = os.path.join(tempdir, "def_args.json") + def_args_file = os.path.join(tempdir, "def_args.yaml") + ckpt_file = os.path.join(tempdir, "model.pt") ts_file = os.path.join(tempdir, "model.ts") @@ -44,11 +47,16 @@ def test_export(self, key_in_ckpt): save_state(src=net if key_in_ckpt == "" else {key_in_ckpt: net}, path=ckpt_file) cmd = ["coverage", "run", "-m", "monai.bundle", "ckpt_export", "network_def", "--filepath", ts_file] - cmd += ["--meta_file", meta_file, "--config_file", config_file, "--ckpt_file", ckpt_file] - cmd += ["--key_in_ckpt", key_in_ckpt, "--args_file", def_args_file] + cmd += ["--meta_file", meta_file, "--config_file", f"['{config_file}','{def_args_file}']", "--ckpt_file"] + cmd += [ckpt_file, "--key_in_ckpt", key_in_ckpt, "--args_file", def_args_file] subprocess.check_call(cmd) self.assertTrue(os.path.exists(ts_file)) + _, metadata, extra_files = load_net_with_metadata(ts_file, more_extra_files=["inference", "def_args"]) + self.assertTrue("schema" in metadata) + self.assertTrue("meta_file" in json.loads(extra_files["def_args"])) + self.assertTrue("network_def" in json.loads(extra_files["inference"])) + if __name__ == "__main__": unittest.main() From d63f4906688bc740067807a7d4e2112f1b006706 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 25 Apr 2022 23:19:09 -0400 Subject: [PATCH 035/183] Move RGB/RGBA checks to base class (#4171) Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> Co-authored-by: Nic Ma --- monai/data/wsi_reader.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index ad5141787c..02032a0ae6 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -180,7 +180,16 @@ def get_data( f"The image dimension should be 3 but has {patch.ndim}. " "`WSIReader` is designed to work only with 2D images with color channel." ) - + # Check if there are four color channels for RGBA + if mode == "RGBA" and patch.shape[0] != 4: + raise ValueError( + f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." + ) + # Check if there are three color channels for RGB + elif mode in "RGB" and patch.shape[0] != 3: + raise ValueError( + f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " + ) # Create a list of patches patch_list.append(patch) @@ -408,11 +417,6 @@ def get_patch( patch = AsChannelFirst()(patch) # type: ignore # Check if the color channel is 3 (RGB) or 4 (RGBA) - if mode == "RGBA" and patch.shape[0] != 4: - raise ValueError( - f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." - ) - if mode in "RGB": if patch.shape[0] not in [3, 4]: raise ValueError( @@ -537,15 +541,4 @@ def get_patch( # Make it channel first patch = AsChannelFirst()(patch) # type: ignore - # Check if the color channel is 3 (RGB) or 4 (RGBA) - if mode == "RGBA" and patch.shape[0] != 4: - raise ValueError( - f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." - ) - - elif mode in "RGB" and patch.shape[0] != 3: - raise ValueError( - f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " - ) - return patch From 10cbeffa4af24c22ac0e519ca6a66f8ad55b66dd Mon Sep 17 00:00:00 2001 From: Peixin Date: Tue, 26 Apr 2022 21:26:52 +0800 Subject: [PATCH 036/183] [CICD] To support temp dgx runner (#4175) * Support new temp dgx runner Signed-off-by: Peixin Li * atol 1e-5 Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li --- .github/workflows/integration.yml | 2 +- tests/test_add_extreme_points_channel.py | 2 +- tests/test_add_extreme_points_channeld.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index c38b66eda4..767b58a792 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -8,7 +8,7 @@ jobs: integration-py3: container: image: nvcr.io/nvidia/pytorch:21.12-py3 # CUDA 11.5 - options: --gpus all + options: --gpus all # shm-size 4g works fine runs-on: [self-hosted, linux, x64, common] steps: # checkout the pull request branch diff --git a/tests/test_add_extreme_points_channel.py b/tests/test_add_extreme_points_channel.py index d2c8a627b6..116f96126f 100644 --- a/tests/test_add_extreme_points_channel.py +++ b/tests/test_add_extreme_points_channel.py @@ -71,7 +71,7 @@ class TestAddExtremePointsChannel(unittest.TestCase): def test_correct_results(self, input_data, expected): add_extreme_points_channel = AddExtremePointsChannel() result = add_extreme_points_channel(**input_data) - assert_allclose(result[IMG_CHANNEL], expected, rtol=1e-4) + assert_allclose(result[IMG_CHANNEL], expected, rtol=1e-4, atol=1e-5) if __name__ == "__main__": diff --git a/tests/test_add_extreme_points_channeld.py b/tests/test_add_extreme_points_channeld.py index 39d221596f..f9837e9ef4 100644 --- a/tests/test_add_extreme_points_channeld.py +++ b/tests/test_add_extreme_points_channeld.py @@ -68,7 +68,7 @@ def test_correct_results(self, input_data, expected): keys="img", label_key="label", sigma=1.0, rescale_min=0.0, rescale_max=1.0 ) result = add_extreme_points_channel(input_data) - assert_allclose(result["img"][IMG_CHANNEL], expected, rtol=1e-4) + assert_allclose(result["img"][IMG_CHANNEL], expected, rtol=1e-4, atol=1e-5) if __name__ == "__main__": From 639392725fe9bd300e698e7f93e8369d5a463cb3 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Tue, 26 Apr 2022 16:43:44 +0100 Subject: [PATCH 037/183] Test fix for AMP kwargs (#4178) Signed-off-by: Eric Kerfoot --- tests/test_integration_workflows.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index 688f664089..f748eb8732 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -54,7 +54,7 @@ from monai.utils import set_determinism from monai.utils.enums import PostFix from tests.testing_data.integration_answers import test_integration_value -from tests.utils import DistTestCase, TimedCall, skip_if_quick +from tests.utils import DistTestCase, TimedCall, pytorch_after, skip_if_quick TASK = "integration_workflows" @@ -149,7 +149,7 @@ def _forward_completed(self, engine): val_handlers=val_handlers, amp=bool(amp), to_kwargs={"memory_format": torch.preserve_format}, - amp_kwargs={"dtype": torch.float16 if bool(amp) else torch.float32}, + amp_kwargs={"dtype": torch.float16 if bool(amp) else torch.float32} if pytorch_after(1, 10, 0) else {}, ) train_postprocessing = Compose( @@ -205,7 +205,7 @@ def _model_completed(self, engine): amp=bool(amp), optim_set_to_none=True, to_kwargs={"memory_format": torch.preserve_format}, - amp_kwargs={"dtype": torch.float16 if bool(amp) else torch.float32}, + amp_kwargs={"dtype": torch.float16 if bool(amp) else torch.float32} if pytorch_after(1, 10, 0) else {}, ) trainer.run() From cc17f38594c0b476f81bb776adfb595c727bdbd8 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 27 Apr 2022 15:02:29 +0800 Subject: [PATCH 038/183] 4167 enhance bundle `run` logic (#4168) * [DLMED] enhance CLI runner_id Signed-off-by: Nic Ma * [DLMED] optimize config logic Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] fix list order Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/bundle/reference_resolver.py | 22 ++++++++++++---------- monai/bundle/scripts.py | 20 ++++++++------------ tests/test_config_parser.py | 13 +++++++++++++ tests/test_integration_bundle_run.py | 14 ++++++++++---- tests/testing_data/inference.json | 8 +++++--- tests/testing_data/inference.yaml | 5 +++-- 6 files changed, 51 insertions(+), 31 deletions(-) diff --git a/monai/bundle/reference_resolver.py b/monai/bundle/reference_resolver.py index 656424a2a4..29424e94bf 100644 --- a/monai/bundle/reference_resolver.py +++ b/monai/bundle/reference_resolver.py @@ -134,7 +134,7 @@ def _resolve_one_item(self, id: str, waiting_list: Optional[Set[str]] = None, ** and v.is_import_statement(v.get_config()) ): self.resolved_content[t] = v.evaluate() if kwargs.get("eval_expr", True) else v - for d in self.find_refs_in_config(config=item_config, id=id): + for d in self.find_refs_in_config(config=item_config, id=id).keys(): # if current item has reference already in the waiting list, that's circular references if d in waiting_list: raise ValueError(f"detected circular references '{d}' for id='{id}' in the config content.") @@ -182,7 +182,7 @@ def get_resolved_content(self, id: str, **kwargs): return self._resolve_one_item(id=id, **kwargs) @classmethod - def match_refs_pattern(cls, value: str) -> Set[str]: + def match_refs_pattern(cls, value: str) -> Dict[str, int]: """ Match regular expression for the input string to find the references. The reference string starts with ``"@"``, like: ``"@XXX#YYY#ZZZ"``. @@ -191,14 +191,15 @@ def match_refs_pattern(cls, value: str) -> Set[str]: value: input value to match regular expression. """ - refs: Set[str] = set() + refs: Dict[str, int] = {} # regular expression pattern to match "@XXX" or "@XXX#YYY" result = cls.id_matcher.findall(value) value_is_expr = ConfigExpression.is_expression(value) for item in result: if value_is_expr or value == item: # only check when string starts with "$" or the whole content is "@XXX" - refs.add(item[len(cls.ref) :]) + id = item[len(cls.ref) :] + refs[id] = refs.get(id, 0) + 1 return refs @classmethod @@ -234,7 +235,7 @@ def update_refs_pattern(cls, value: str, refs: Dict) -> str: return value @classmethod - def find_refs_in_config(cls, config, id: str, refs: Optional[Set[str]] = None) -> Set[str]: + def find_refs_in_config(cls, config, id: str, refs: Optional[Dict[str, int]] = None) -> Dict[str, int]: """ Recursively search all the content of input config item to get the ids of references. References mean: the IDs of other config items (``"@XXX"`` in this config item), or the @@ -244,18 +245,19 @@ def find_refs_in_config(cls, config, id: str, refs: Optional[Set[str]] = None) - Args: config: input config content to search. id: ID name for the input config item. - refs: list of the ID name of found references, default to `None`. + refs: dict of the ID name and count of found references, default to `None`. """ - refs_: Set[str] = refs or set() + refs_: Dict[str, int] = refs or {} if isinstance(config, str): - return refs_.union(cls.match_refs_pattern(value=config)) + for id, count in cls.match_refs_pattern(value=config).items(): + refs_[id] = refs_.get(id, 0) + count if not isinstance(config, (list, dict)): return refs_ for k, v in config.items() if isinstance(config, dict) else enumerate(config): sub_id = f"{id}{cls.sep}{k}" if id != "" else f"{k}" - if ConfigComponent.is_instantiable(v) or ConfigExpression.is_expression(v): - refs_.add(sub_id) + if ConfigComponent.is_instantiable(v) or ConfigExpression.is_expression(v) and sub_id not in refs_: + refs_[sub_id] = 1 refs_ = cls.find_refs_in_config(v, sub_id, refs_) return refs_ diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 00496dba66..e5b306a90d 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -298,7 +298,7 @@ def load( def run( - runner_id: Optional[str] = None, + runner_id: Optional[Union[str, Sequence[str]]] = None, meta_file: Optional[Union[str, Sequence[str]]] = None, config_file: Optional[Union[str, Sequence[str]]] = None, logging_file: Optional[str] = None, @@ -313,23 +313,23 @@ def run( .. code-block:: bash # Execute this module as a CLI entry: - python -m monai.bundle run trainer --meta_file --config_file + python -m monai.bundle run training --meta_file --config_file # Override config values at runtime by specifying the component id and its new value: - python -m monai.bundle run trainer --net#input_chns 1 ... + python -m monai.bundle run training --net#input_chns 1 ... # Override config values with another config file `/path/to/another.json`: - python -m monai.bundle run evaluator --net %/path/to/another.json ... + python -m monai.bundle run evaluating --net %/path/to/another.json ... # Override config values with part content of another config file: - python -m monai.bundle run trainer --net %/data/other.json#net_arg ... + python -m monai.bundle run training --net %/data/other.json#net_arg ... # Set default args of `run` in a JSON / YAML file, help to record and simplify the command line. # Other args still can override the default args at runtime: python -m monai.bundle run --args_file "/workspace/data/args.json" --config_file Args: - runner_id: ID name of the runner component or workflow, it must have a `run` method. Defaults to ``""``. + runner_id: ID name of the expected config expression to run, can also be a list of IDs to run in order. meta_file: filepath of the metadata file, if it is a list of file paths, the content of them will be merged. config_file: filepath of the config file, if `None`, must be provided in `args_file`. if it is a list of file paths, the content of them will be merged. @@ -369,12 +369,8 @@ def run( for k, v in _args.items(): parser[k] = v - workflow = parser.get_parsed_content(id=runner_id_) - if not hasattr(workflow, "run"): - raise ValueError( - f"The parsed workflow {type(workflow)} (id={runner_id_}) does not have a `run` method.\n{run.__doc__}" - ) - return workflow.run() + # resolve and execute the specified runner expressions in the config, return the results + return [parser.get_parsed_content(i, lazy=True, eval_expr=True, instantiate=True) for i in ensure_tuple(runner_id_)] def verify_metadata( diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index 9ab002f7af..047b7c59bd 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -14,6 +14,7 @@ import unittest from unittest import skipUnless +import numpy as np from parameterized import parameterized from monai.bundle import ConfigParser, ReferenceResolver @@ -177,6 +178,18 @@ def test_allow_missing_reference(self, config): parser.parse() parser.get_parsed_content(id="E") + def test_list_expressions(self): + config = { + "transform": { + "_target_": "Compose", + "transforms": [{"_target_": "RandScaleIntensity", "factors": 0.5, "prob": 1.0}], + }, + "training": ["$monai.utils.set_determinism(seed=123)", "$@transform(np.asarray([1, 2]))"], + } + parser = ConfigParser(config=config) + parser.get_parsed_content("training", lazy=True, instantiate=True, eval_expr=True) + np.testing.assert_allclose(parser.get_parsed_content("training#1", lazy=True), [0.7942, 1.5885], atol=1e-4) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_integration_bundle_run.py b/tests/test_integration_bundle_run.py index 7579858bbc..3813e63d7f 100644 --- a/tests/test_integration_bundle_run.py +++ b/tests/test_integration_bundle_run.py @@ -48,8 +48,14 @@ def tearDown(self): def test_tiny(self): config_file = os.path.join(self.data_dir, "tiny_config.json") with open(config_file, "w") as f: - json.dump({"": {"_target_": "tests.test_integration_bundle_run._Runnable42", "val": 42}}, f) - cmd = ["coverage", "run", "-m", "monai.bundle", "run", "--config_file", config_file] + json.dump( + { + "trainer": {"_target_": "tests.test_integration_bundle_run._Runnable42", "val": 42}, + "training": "$@trainer.run()", + }, + f, + ) + cmd = ["coverage", "run", "-m", "monai.bundle", "run", "training", "--config_file", config_file] subprocess.check_call(cmd) @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) @@ -88,7 +94,7 @@ def test_shape(self, config_file, expected_shape): else: override = f"--network %{overridefile1}#move_net --dataset#_target_ %{overridefile2}" # test with `monai.bundle` as CLI entry directly - cmd = f"-m monai.bundle run evaluator --postprocessing#transforms#2#output_postfix seg {override}" + cmd = f"-m monai.bundle run evaluating --postprocessing#transforms#2#output_postfix seg {override}" la = ["coverage", "run"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file] test_env = os.environ.copy() print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES")) @@ -96,7 +102,7 @@ def test_shape(self, config_file, expected_shape): self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape) # here test the script with `google fire` tool as CLI - cmd = "-m fire monai.bundle.scripts run --runner_id evaluator" + cmd = "-m fire monai.bundle.scripts run --runner_id evaluating" cmd += f" --evaluator#amp False {override}" la = ["coverage", "run"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file] subprocess.check_call(la, env=test_env) diff --git a/tests/testing_data/inference.json b/tests/testing_data/inference.json index cc9ddef866..8ef12c0d85 100644 --- a/tests/testing_data/inference.json +++ b/tests/testing_data/inference.json @@ -2,7 +2,6 @@ "dataset_dir": "/workspace/data/Task09_Spleen", "import_glob": "$import glob", "device": "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')", - "set_seed": "$monai.utils.set_determinism(0)", "print_test_name": "$print('json_test')", "print_glob_file": "$print(glob.__file__)", "network_def": { @@ -99,7 +98,6 @@ "evaluator": { "_target_": "SupervisedEvaluator", "_requires_": [ - "@set_seed", "@print_test_name", "@print_glob_file", "$print('test_in_line_json')" @@ -110,5 +108,9 @@ "inferer": "@inferer", "postprocessing": "@postprocessing", "amp": false - } + }, + "evaluating": [ + "$monai.utils.set_determinism(0)", + "$@evaluator.run()" + ] } diff --git a/tests/testing_data/inference.yaml b/tests/testing_data/inference.yaml index 4973d4473f..c63c10517f 100644 --- a/tests/testing_data/inference.yaml +++ b/tests/testing_data/inference.yaml @@ -1,7 +1,6 @@ --- dataset_dir: "/workspace/data/Task09_Spleen" device: "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')" -set_seed: "$monai.utils.set_determinism(0)" print_test_name: "$print('yaml_test')" network_def: _target_: UNet @@ -71,7 +70,6 @@ evaluator: _target_: SupervisedEvaluator _requires_: - "$print('test_in_line_yaml')" - - "@set_seed" - "@print_test_name" device: "@device" val_data_loader: "@dataloader" @@ -79,3 +77,6 @@ evaluator: inferer: "@inferer" postprocessing: "@postprocessing" amp: false +evaluating: + - "$monai.utils.set_determinism(0)" + - "$@evaluator.run()" From 5c15138f484ba027b90fd12f7038a519f740d213 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 27 Apr 2022 10:01:10 +0100 Subject: [PATCH 039/183] 4172 aggregate with buffer copy (#4174) * fixes #4172 aggregate with no in-place buffer change Signed-off-by: Wenqi Li * update reduction option Signed-off-by: Wenqi Li * fixes flake8 Signed-off-by: Wenqi Li * update Signed-off-by: Wenqi Li * adds tests Signed-off-by: Wenqi Li --- monai/metrics/confusion_matrix.py | 17 ++++++++++++----- monai/metrics/hausdorff_distance.py | 11 ++++++++--- monai/metrics/meandice.py | 11 ++++++++--- monai/metrics/metric.py | 5 ++++- monai/metrics/regression.py | 12 +++++++++--- monai/metrics/rocauc.py | 12 ++++++++---- monai/metrics/surface_dice.py | 18 ++++++++++-------- monai/metrics/surface_distance.py | 11 ++++++++--- monai/metrics/utils.py | 3 +-- monai/utils/enums.py | 2 +- tests/test_compute_confusion_matrix.py | 2 +- tests/test_compute_meandice.py | 4 ++-- tests/test_compute_regression_metrics.py | 8 ++++---- tests/test_compute_roc_auc.py | 2 ++ tests/test_cumulative.py | 2 ++ tests/test_hausdorff_distance.py | 2 +- tests/test_surface_dice.py | 2 +- tests/test_surface_distance.py | 2 +- 18 files changed, 83 insertions(+), 43 deletions(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 320f657537..780377c0f8 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -47,7 +47,7 @@ class ConfusionMatrixMetric(CumulativeIterationMetric): returned with the same order as input names when calling the class. compute_sample: when reducing, if ``True``, each sample's metric will be computed based on each confusion matrix first. if ``False``, compute reduction on the confusion matrices first, defaults to ``False``. - reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values, + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns [(metric, not_nans), ...]. If False, @@ -102,10 +102,17 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor return get_confusion_matrix(y_pred=y_pred, y=y, include_background=self.include_background) - def aggregate(self): + def aggregate(self, compute_sample: bool = False, reduction: Union[MetricReduction, str, None] = None): # type: ignore """ Execute reduction for the confusion matrix values. + Args: + compute_sample: when reducing, if ``True``, each sample's metric will be computed based on each confusion matrix first. + if ``False``, compute reduction on the confusion matrices first, defaults to ``False``. + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction. + """ data = self.get_buffer() if not isinstance(data, torch.Tensor): @@ -113,11 +120,11 @@ def aggregate(self): results = [] for metric_name in self.metric_name: - if self.compute_sample: + if compute_sample or self.compute_sample: sub_confusion_matrix = compute_confusion_matrix_metric(metric_name, data) - f, not_nans = do_metric_reduction(sub_confusion_matrix, self.reduction) + f, not_nans = do_metric_reduction(sub_confusion_matrix, reduction or self.reduction) else: - f, not_nans = do_metric_reduction(data, self.reduction) + f, not_nans = do_metric_reduction(data, reduction or self.reduction) f = compute_confusion_matrix_metric(metric_name, f) if self.get_not_nans: results.append((f, not_nans)) diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 5ce739d1f4..ab4ed0f821 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -42,7 +42,7 @@ class HausdorffDistanceMetric(CumulativeIterationMetric): percentile of the Hausdorff Distance rather than the maximum result will be achieved. Defaults to ``None``. directed: whether to calculate directed Hausdorff distance. Defaults to ``False``. - reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values, + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns (metric, not_nans). @@ -99,17 +99,22 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor directed=self.directed, ) - def aggregate(self): + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore """ Execute reduction logic for the output of `compute_hausdorff_distance`. + Args: + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction. + """ data = self.get_buffer() if not isinstance(data, torch.Tensor): raise ValueError("the data to aggregate must be PyTorch Tensor.") # do metric reduction - f, not_nans = do_metric_reduction(data, self.reduction) + f, not_nans = do_metric_reduction(data, reduction or self.reduction) return (f, not_nans) if self.get_not_nans else f diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index 4179420804..aabb3b42a0 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -35,7 +35,7 @@ class DiceMetric(CumulativeIterationMetric): Args: include_background: whether to skip Dice computation on the first channel of the predicted output. Defaults to ``True``. - reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values, + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns (metric, not_nans). @@ -79,17 +79,22 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor # compute dice (BxC) for each channel for each batch return compute_meandice(y_pred=y_pred, y=y, include_background=self.include_background) - def aggregate(self): + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore """ Execute reduction logic for the output of `compute_meandice`. + Args: + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction. + """ data = self.get_buffer() if not isinstance(data, torch.Tensor): raise ValueError("the data to aggregate must be PyTorch Tensor.") # do metric reduction - f, not_nans = do_metric_reduction(data, self.reduction) + f, not_nans = do_metric_reduction(data, reduction or self.reduction) return (f, not_nans) if self.get_not_nans else f diff --git a/monai/metrics/metric.py b/monai/metrics/metric.py index 7782c4c468..fa8b3354de 100644 --- a/monai/metrics/metric.py +++ b/monai/metrics/metric.py @@ -274,7 +274,10 @@ def get_buffer(self): """ self._sync() - return self._synced_tensors[0] if len(self._synced_tensors) == 1 else self._synced_tensors + if self._synced_tensors is None: + return self._synced_tensors + buffers = [x.detach().clone() if isinstance(x, torch.Tensor) else x for x in self._synced_tensors] + return buffers[0] if len(buffers) == 1 else buffers class CumulativeIterationMetric(Cumulative, IterationMetric): diff --git a/monai/metrics/regression.py b/monai/metrics/regression.py index d5733eee97..62f5fa939e 100644 --- a/monai/metrics/regression.py +++ b/monai/metrics/regression.py @@ -30,7 +30,7 @@ class RegressionMetric(CumulativeIterationMetric): `y_preds` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]). Args: - reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values, + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns (metric, not_nans). @@ -45,12 +45,18 @@ def __init__( self.reduction = reduction self.get_not_nans = get_not_nans - def aggregate(self): + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore + """ + Args: + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction. + """ data = self.get_buffer() if not isinstance(data, torch.Tensor): raise ValueError("the data to aggregate must be PyTorch Tensor.") - f, not_nans = do_metric_reduction(data, self.reduction) + f, not_nans = do_metric_reduction(data, reduction or self.reduction) return (f, not_nans) if self.get_not_nans else f def _check_shape(self, y_pred: torch.Tensor, y: torch.Tensor) -> None: diff --git a/monai/metrics/rocauc.py b/monai/metrics/rocauc.py index 221fc50272..bd3cdb1203 100644 --- a/monai/metrics/rocauc.py +++ b/monai/metrics/rocauc.py @@ -49,10 +49,14 @@ def __init__(self, average: Union[Average, str] = Average.MACRO) -> None: def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignore return y_pred, y - def aggregate(self): + def aggregate(self, average: Union[Average, str, None] = None): # type: ignore """ - As AUC metric needs to execute on the overall data, so usually users accumulate `y_pred` and `y` - of every iteration, then execute real computation and reduction on the accumulated data. + Typically `y_pred` and `y` are stored in the cumulative buffers at each iteration, + This function reads the buffers and computes the area under the ROC. + + Args: + average: {``"macro"``, ``"weighted"``, ``"micro"``, ``"none"``} + Type of averaging performed if not binary classification. Defaults to `self.average`. """ y_pred, y = self.get_buffer() @@ -60,7 +64,7 @@ def aggregate(self): if not isinstance(y_pred, torch.Tensor) or not isinstance(y, torch.Tensor): raise ValueError("y_pred and y must be PyTorch Tensor.") - return compute_roc_auc(y_pred=y_pred, y=y, average=self.average) + return compute_roc_auc(y_pred=y_pred, y=y, average=average or self.average) def _calculate(y_pred: torch.Tensor, y: torch.Tensor) -> float: diff --git a/monai/metrics/surface_dice.py b/monai/metrics/surface_dice.py index 5630af178d..f964b0472a 100644 --- a/monai/metrics/surface_dice.py +++ b/monai/metrics/surface_dice.py @@ -39,12 +39,9 @@ class SurfaceDiceMetric(CumulativeIterationMetric): distance_metric: The metric used to compute surface distances. One of [``"euclidean"``, ``"chessboard"``, ``"taxicab"``]. Defaults to ``"euclidean"``. - reduction: The mode to aggregate metrics. - One of [``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``, - ``"none"``]. - Defaults to ``"mean"``. - If ``"none"`` is chosen, no aggregation will be performed. - The aggregation will ignore nan values. + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count. Defaults to ``False``. `not_nans` is the number of batch samples for which not all class-specific NSD values were nan values. @@ -87,10 +84,15 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor distance_metric=self.distance_metric, ) - def aggregate(self): + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore r""" Aggregates the output of `_compute_tensor`. + Args: + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction. + Returns: If `get_not_nans` is set to ``True``, this function returns the aggregated NSD and the `not_nans` count. If `get_not_nans` is set to ``False``, this function returns only the aggregated NSD. @@ -100,7 +102,7 @@ def aggregate(self): raise ValueError("the data to aggregate must be PyTorch Tensor.") # do metric reduction - f, not_nans = do_metric_reduction(data, self.reduction) + f, not_nans = do_metric_reduction(data, reduction or self.reduction) return (f, not_nans) if self.get_not_nans else f diff --git a/monai/metrics/surface_distance.py b/monai/metrics/surface_distance.py index 2c84bb9e7c..65651e3e51 100644 --- a/monai/metrics/surface_distance.py +++ b/monai/metrics/surface_distance.py @@ -37,7 +37,7 @@ class SurfaceDistanceMetric(CumulativeIterationMetric): `seg_pred` and `seg_gt`. Defaults to ``False``. distance_metric: : [``"euclidean"``, ``"chessboard"``, ``"taxicab"``] the metric used to compute surface distance. Defaults to ``"euclidean"``. - reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values, + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns (metric, not_nans). @@ -91,17 +91,22 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor distance_metric=self.distance_metric, ) - def aggregate(self): + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore """ Execute reduction logic for the output of `compute_average_surface_distance`. + Args: + reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, + available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, + ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction. + """ data = self.get_buffer() if not isinstance(data, torch.Tensor): raise ValueError("the data to aggregate must be PyTorch Tensor.") # do metric reduction - f, not_nans = do_metric_reduction(data, self.reduction) + f, not_nans = do_metric_reduction(data, reduction or self.reduction) return (f, not_nans) if self.get_not_nans else f diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index fc42100d6f..3e3a29d468 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -50,11 +50,10 @@ def do_metric_reduction(f: torch.Tensor, reduction: Union[MetricReduction, str] Args: f: a tensor that contains the calculated metric scores per batch and per class. The first two dims should be batch and class. - reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values, + reduction: define the mode to reduce metrics, will only apply reduction on `not-nan` values, available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", return the input f tensor and not_nans. - Define the mode to reduce computation result of 1 batch data. Defaults to ``"mean"``. Raises: ValueError: When ``reduction`` is not one of diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 1bfbdf824b..cbb2f053a5 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -141,7 +141,7 @@ class Average(Enum): class MetricReduction(Enum): """ - See also: :py:class:`monai.metrics.meandice.DiceMetric` + See also: :py:func:`monai.metrics.utils.do_metric_reduction` """ NONE = "none" diff --git a/tests/test_compute_confusion_matrix.py b/tests/test_compute_confusion_matrix.py index 1212715548..0e38357d12 100644 --- a/tests/test_compute_confusion_matrix.py +++ b/tests/test_compute_confusion_matrix.py @@ -262,7 +262,7 @@ def test_clf_with_nan(self, input_data, expected_value): metric = ConfusionMatrixMetric(**params) result = metric(**vals) np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4) - result, _ = metric.aggregate()[0] + result, _ = metric.aggregate(reduction="mean_channel")[0] expected_value, _ = do_metric_reduction(expected_value, "mean_channel") expected_value = compute_confusion_matrix_metric("tpr", expected_value) np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4) diff --git a/tests/test_compute_meandice.py b/tests/test_compute_meandice.py index ad66ed672a..c4daf7c5a9 100644 --- a/tests/test_compute_meandice.py +++ b/tests/test_compute_meandice.py @@ -192,9 +192,9 @@ def test_value_class(self, input_data, expected_value): vals = {} vals["y_pred"] = input_data.pop("y_pred") vals["y"] = input_data.pop("y") - dice_metric = DiceMetric(**input_data, reduction="none") + dice_metric = DiceMetric(**input_data) dice_metric(**vals) - result = dice_metric.aggregate() + result = dice_metric.aggregate(reduction="none") np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7, TEST_CASE_8]) diff --git a/tests/test_compute_regression_metrics.py b/tests/test_compute_regression_metrics.py index 65ca73a4ec..cab1184812 100644 --- a/tests/test_compute_regression_metrics.py +++ b/tests/test_compute_regression_metrics.py @@ -75,9 +75,9 @@ def test_shape_reduction(self): out_tensor = mt.aggregate() self.assertTrue(len(out_tensor.shape) == 0) - mt = mt_fn(reduction="mean_channel") + mt = mt_fn(reduction="sum") # test reduction arg overriding mt(in_tensor, in_tensor) - out_tensor = mt.aggregate() + out_tensor = mt.aggregate(reduction="mean_channel") self.assertTrue(len(out_tensor.shape) == 1 and out_tensor.shape[0] == batch) mt = mt_fn(reduction="sum_channel") @@ -109,9 +109,9 @@ def test_compare_numpy(self): # check metrics for mt_fn, mt_fn_np in zip(metrics, metrics_np): - mt = mt_fn(reduction="mean") + mt = mt_fn() mt(y_pred=in_tensor_a, y=in_tensor_b) - out_tensor = mt.aggregate() + out_tensor = mt.aggregate(reduction="mean") out_np = mt_fn_np(y_pred=in_tensor_a.cpu().numpy(), y=in_tensor_b.cpu().numpy()) np.testing.assert_allclose(out_tensor.cpu().numpy(), out_np, atol=1e-4) diff --git a/tests/test_compute_roc_auc.py b/tests/test_compute_roc_auc.py index 887db08c7c..2c9135024f 100644 --- a/tests/test_compute_roc_auc.py +++ b/tests/test_compute_roc_auc.py @@ -141,6 +141,8 @@ def test_class_value(self, y_pred, y, softmax, to_onehot, average, expected_valu metric = ROCAUCMetric(average=average) metric(y_pred=y_pred, y=y) result = metric.aggregate() + np.testing.assert_allclose(expected_value, result, rtol=1e-5) + result = metric.aggregate(average=average) # test optional argument metric.reset() np.testing.assert_allclose(expected_value, result, rtol=1e-5) diff --git a/tests/test_cumulative.py b/tests/test_cumulative.py index 12a6a5e5e7..16f5c1d1f5 100644 --- a/tests/test_cumulative.py +++ b/tests/test_cumulative.py @@ -35,6 +35,8 @@ def test_multi(self): c.append() c.extend() self.assertEqual(c.get_buffer(), []) + c.get_buffer().append(1) + self.assertEqual(c.get_buffer(), []) # no in-place update for the buffer c.reset() diff --git a/tests/test_hausdorff_distance.py b/tests/test_hausdorff_distance.py index 79a2c84b37..44c011fe13 100644 --- a/tests/test_hausdorff_distance.py +++ b/tests/test_hausdorff_distance.py @@ -127,7 +127,7 @@ def test_value(self, input_data, expected_value): batch_seg_1 = seg_1.unsqueeze(0).unsqueeze(0).repeat([batch, n_class, 1, 1, 1]) batch_seg_2 = seg_2.unsqueeze(0).unsqueeze(0).repeat([batch, n_class, 1, 1, 1]) hd_metric(batch_seg_1, batch_seg_2) - result = hd_metric.aggregate() + result = hd_metric.aggregate(reduction="mean") expected_value_curr = expected_value[ct] np.testing.assert_allclose(expected_value_curr, result, rtol=1e-7) ct += 1 diff --git a/tests/test_surface_dice.py b/tests/test_surface_dice.py index 5252adafce..ccc6242e1e 100644 --- a/tests/test_surface_dice.py +++ b/tests/test_surface_dice.py @@ -274,7 +274,7 @@ def test_not_predicted_not_present(self): np.testing.assert_array_equal(res_classes, [[0, 0, np.nan]]) # test aggregation - res_bgr = sur_metric_bgr.aggregate() + res_bgr = sur_metric_bgr.aggregate(reduction="mean") np.testing.assert_equal(res_bgr, torch.tensor([1 / 3], dtype=torch.float64)) res = sur_metric.aggregate() np.testing.assert_equal(res, torch.tensor([0], dtype=torch.float64)) diff --git a/tests/test_surface_distance.py b/tests/test_surface_distance.py index edfe9e8663..4cd70b43aa 100644 --- a/tests/test_surface_distance.py +++ b/tests/test_surface_distance.py @@ -134,7 +134,7 @@ def test_nans(self, input_data): batch_seg_1 = [seg_1.unsqueeze(0)] batch_seg_2 = [seg_2.unsqueeze(0)] sur_metric(batch_seg_1, batch_seg_2) - result, not_nans = sur_metric.aggregate() + result, not_nans = sur_metric.aggregate(reduction="mean") np.testing.assert_allclose(0, result, rtol=1e-5) np.testing.assert_allclose(0, not_nans, rtol=1e-5) From 641a079fe13fbacb536dfb09a0a56e3b9d8e56b5 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 27 Apr 2022 20:31:11 +0800 Subject: [PATCH 040/183] 4173 Enhance decathlon datalist for `test` section format (#4186) * [DLMED] enhance test datalist Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 Signed-off-by: Nic Ma Co-authored-by: monai-bot --- monai/data/decathlon_datalist.py | 3 ++- monai/metrics/confusion_matrix.py | 4 +++- tests/test_load_decathlon_datalist.py | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/monai/data/decathlon_datalist.py b/monai/data/decathlon_datalist.py index d2a9c3d220..c1bbabceb9 100644 --- a/monai/data/decathlon_datalist.py +++ b/monai/data/decathlon_datalist.py @@ -122,7 +122,8 @@ def load_decathlon_datalist( if data_list_key not in json_data: raise ValueError(f'Data list {data_list_key} not specified in "{data_list_file_path}".') expected_data = json_data[data_list_key] - if data_list_key == "test": + if data_list_key == "test" and not isinstance(expected_data[0], dict): + # decathlon datalist may save the test images in a list directly instead of dict expected_data = [{"image": i} for i in expected_data] if base_dir is None: diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 780377c0f8..9d5af51a58 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -102,7 +102,9 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor return get_confusion_matrix(y_pred=y_pred, y=y, include_background=self.include_background) - def aggregate(self, compute_sample: bool = False, reduction: Union[MetricReduction, str, None] = None): # type: ignore + def aggregate( # type: ignore + self, compute_sample: bool = False, reduction: Union[MetricReduction, str, None] = None + ): """ Execute reduction for the confusion matrix values. diff --git a/tests/test_load_decathlon_datalist.py b/tests/test_load_decathlon_datalist.py index 91d144d84f..a58ba73ece 100644 --- a/tests/test_load_decathlon_datalist.py +++ b/tests/test_load_decathlon_datalist.py @@ -29,7 +29,7 @@ def test_seg_values(self): {"image": "spleen_19.nii.gz", "label": "spleen_19.nii.gz"}, {"image": "spleen_31.nii.gz", "label": "spleen_31.nii.gz"}, ], - "test": ["spleen_15.nii.gz", "spleen_23.nii.gz"], + "test": [{"image": "spleen_15.nii.gz"}, {"image": "spleen_23.nii.gz"}], } json_str = json.dumps(test_data) file_path = os.path.join(tempdir, "test_data.json") @@ -38,6 +38,8 @@ def test_seg_values(self): result = load_decathlon_datalist(file_path, True, "training", tempdir) self.assertEqual(result[0]["image"], os.path.join(tempdir, "spleen_19.nii.gz")) self.assertEqual(result[0]["label"], os.path.join(tempdir, "spleen_19.nii.gz")) + result = load_decathlon_datalist(file_path, True, "test", None) + self.assertEqual(result[0]["image"], os.path.join(tempdir, "spleen_15.nii.gz")) def test_cls_values(self): with tempfile.TemporaryDirectory() as tempdir: @@ -81,6 +83,8 @@ def test_seg_no_basedir(self): result = load_decathlon_datalist(file_path, True, "training", None) self.assertEqual(result[0]["image"], os.path.join(tempdir, "spleen_19.nii.gz")) self.assertEqual(result[0]["label"], os.path.join(tempdir, "spleen_19.nii.gz")) + result = load_decathlon_datalist(file_path, True, "test", None) + self.assertEqual(result[0]["image"], os.path.join(tempdir, "spleen_15.nii.gz")) def test_seg_no_labels(self): with tempfile.TemporaryDirectory() as tempdir: From 5777041c878e3fca00af041815aad6a2a548fbd5 Mon Sep 17 00:00:00 2001 From: Andres Diaz-Pinto Date: Wed, 27 Apr 2022 15:51:55 +0100 Subject: [PATCH 041/183] Add DeepEdit transforms and interaction (#4164) * Add DeepEdit transforms and interaction Signed-off-by: Andres Diaz-Pinto --- monai/apps/deepedit/interaction.py | 100 ++++ monai/apps/deepedit/transforms.py | 844 ++++++++++++++++++++++++++--- tests/min_tests.py | 1 + tests/test_deepedit_interaction.py | 119 ++++ tests/test_deepedit_transforms.py | 287 ++++++++-- 5 files changed, 1245 insertions(+), 106 deletions(-) create mode 100644 monai/apps/deepedit/interaction.py create mode 100644 tests/test_deepedit_interaction.py diff --git a/monai/apps/deepedit/interaction.py b/monai/apps/deepedit/interaction.py new file mode 100644 index 0000000000..04fabec06d --- /dev/null +++ b/monai/apps/deepedit/interaction.py @@ -0,0 +1,100 @@ +# 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 typing import Callable, Dict, Sequence, Union + +import numpy as np +import torch + +from monai.data import decollate_batch, list_data_collate +from monai.engines import SupervisedEvaluator, SupervisedTrainer +from monai.engines.utils import IterationEvents +from monai.transforms import Compose +from monai.utils.enums import CommonKeys + + +class Interaction: + """ + Ignite process_function used to introduce interactions (simulation of clicks) for DeepEdit Training/Evaluation. + + More details about this can be found at: + + Diaz-Pinto et al., MONAI Label: A framework for AI-assisted Interactive + Labeling of 3D Medical Images. (2022) https://arxiv.org/abs/2203.12362 + + Args: + deepgrow_probability: probability of simulating clicks in an iteration + transforms: execute additional transformation during every iteration (before train). + Typically, several Tensor based transforms composed by `Compose`. + train: True for training mode or False for evaluation mode + click_probability_key: key to click/interaction probability + label_names: Dict of label names + """ + + def __init__( + self, + deepgrow_probability: float, + transforms: Union[Sequence[Callable], Callable], + train: bool, + label_names: Dict[str, int], + click_probability_key: str = "probability", + ) -> None: + + self.deepgrow_probability = deepgrow_probability + self.transforms = Compose(transforms) if not isinstance(transforms, Compose) else transforms + self.train = train + self.label_names = label_names + self.click_probability_key = click_probability_key + + def __call__(self, engine: Union[SupervisedTrainer, SupervisedEvaluator], batchdata: Dict[str, torch.Tensor]): + + if batchdata is None: + raise ValueError("Must provide batch data for current iteration.") + + if np.random.choice([True, False], p=[self.deepgrow_probability, 1 - self.deepgrow_probability]): + + # Run the inner loop only once + inputs, _ = engine.prepare_batch(batchdata) + inputs = inputs.to(engine.state.device) + + engine.fire_event(IterationEvents.INNER_ITERATION_STARTED) + + engine.network.eval() + with torch.no_grad(): + if engine.amp: + with torch.cuda.amp.autocast(): + predictions = engine.inferer(inputs, engine.network) + else: + predictions = engine.inferer(inputs, engine.network) + batchdata.update({CommonKeys.PRED: predictions}) + + # decollate/collate batchdata to execute click transforms + batchdata_list = decollate_batch(batchdata, detach=True) + + for i in range(len(batchdata_list)): + batchdata_list[i][self.click_probability_key] = 1.0 + batchdata_list[i] = self.transforms(batchdata_list[i]) + + batchdata = list_data_collate(batchdata_list) + + engine.fire_event(IterationEvents.INNER_ITERATION_COMPLETED) + + else: + # zero out input guidance channels + batchdata_list = decollate_batch(batchdata, detach=True) + for i in range(1, len(batchdata_list[0][CommonKeys.IMAGE])): + batchdata_list[0][CommonKeys.IMAGE][i] *= 0 + batchdata = list_data_collate(batchdata_list) + + # first item in batch only + engine.state.batch = batchdata + + return engine._iteration(engine, batchdata) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index 0fcf4c2286..9f29ff2cd2 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -11,14 +11,19 @@ import json import logging -from typing import Dict, Hashable, Mapping, Tuple +import random +import warnings +from typing import Dict, Hashable, Mapping, Optional import numpy as np +import torch from monai.config import KeysCollection +from monai.networks.layers import GaussianFilter from monai.transforms.transform import MapTransform, Randomizable, Transform -from monai.utils import optional_import -from monai.utils.enums import PostFix +from monai.utils import min_version, optional_import + +measure, _ = optional_import("skimage.measure", "0.14.2", min_version) logger = logging.getLogger(__name__) @@ -27,23 +32,39 @@ class DiscardAddGuidanced(MapTransform): - def __init__(self, keys: KeysCollection, probability: float = 1.0, allow_missing_keys: bool = False): + def __init__( + self, + keys: KeysCollection, + number_intensity_ch: int = 1, + probability: float = 1.0, + label_names=None, + allow_missing_keys: bool = False, + ): """ - Discard positive and negative points randomly or Add the two channels for inference time + Discard positive and negative points according to discard probability - :param probability: Discard probability; For inference it will be always 1.0 + Args: + keys: The ``keys`` parameter will be used to get and set the actual data item to transform + number_intensity_ch: number of intensity channels + probability: probability of discarding clicks """ super().__init__(keys, allow_missing_keys) - self.probability = probability + + self.number_intensity_ch = number_intensity_ch + self.discard_probability = probability + self.label_names = label_names def _apply(self, image): - if self.probability >= 1.0 or np.random.choice([True, False], p=[self.probability, 1 - self.probability]): - signal = np.zeros((1, image.shape[-3], image.shape[-2], image.shape[-1]), dtype=np.float32) - if image.shape[0] == 3: - image[1] = signal - image[2] = signal + if self.discard_probability >= 1.0 or np.random.choice( + [True, False], p=[self.discard_probability, 1 - self.discard_probability] + ): + signal = np.zeros( + (len(self.label_names), image.shape[-3], image.shape[-2], image.shape[-1]), dtype=np.float32 + ) + if image.shape[0] == self.number_intensity_ch + len(self.label_names): + image[self.number_intensity_ch :, ...] = signal else: - image = np.concatenate((image, signal, signal), axis=0) + image = np.concatenate([image, signal], axis=0) return image def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: @@ -56,51 +77,428 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class ResizeGuidanceCustomd(Transform): +class NormalizeLabelsInDatasetd(MapTransform): + def __init__(self, keys: KeysCollection, label_names=None, allow_missing_keys: bool = False): + """ + Normalize label values according to label names dictionary + + Args: + keys: The ``keys`` parameter will be used to get and set the actual data item to transform + label_names: all label names + """ + super().__init__(keys, allow_missing_keys) + + self.label_names = label_names + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + # Dictionary containing new label numbers + new_label_names = {} + label = np.zeros(d[key].shape) + # Making sure the range values and number of labels are the same + for idx, (key_label, val_label) in enumerate(self.label_names.items(), start=1): + if key_label != "background": + new_label_names[key_label] = idx + label[d[key] == val_label] = idx + if key_label == "background": + new_label_names["background"] = 0 + + d["label_names"] = new_label_names + d[key] = label + else: + warnings.warn("This transform only applies to the label") + return d + + +class SingleLabelSelectiond(MapTransform): + def __init__(self, keys: KeysCollection, label_names=None, allow_missing_keys: bool = False): + """ + Selects one label at a time to train the DeepEdit + + Args: + keys: The ``keys`` parameter will be used to get and set the actual data item to transform + label_names: all label names + """ + super().__init__(keys, allow_missing_keys) + + self.label_names = label_names + self.all_label_values = { + "spleen": 1, + "right kidney": 2, + "left kidney": 3, + "gallbladder": 4, + "esophagus": 5, + "liver": 6, + "stomach": 7, + "aorta": 8, + "inferior vena cava": 9, + "portal_vein": 10, + "splenic_vein": 11, + "pancreas": 12, + "right adrenal gland": 13, + "left adrenal gland": 14, + } + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + # Taking one label at a time + t_label = np.random.choice(self.label_names) + d["current_label"] = t_label + d[key][d[key] != self.all_label_values[t_label]] = 0.0 + # Convert label to index values following label_names argument + max_label_val = self.label_names.index(t_label) + 1 + d[key][d[key] > 0] = max_label_val + print(f"Using label {t_label} with number: {d[key].max()}") + else: + warnings.warn("This transform only applies to the label") + return d + + +class AddGuidanceSignalDeepEditd(MapTransform): """ - Resize the guidance based on cropped vs resized image. + Add Guidance signal for input image. Multilabel DeepEdit + + Based on the "guidance" points, apply Gaussian to them and add them as new channel for input image. + + Args: + guidance: key to store guidance. + sigma: standard deviation for Gaussian kernel. + number_intensity_ch: channel index. """ - def __init__(self, guidance: str, ref_image: str) -> None: + def __init__( + self, + keys: KeysCollection, + guidance: str = "guidance", + sigma: int = 3, + number_intensity_ch: int = 1, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) self.guidance = guidance - self.ref_image = ref_image + self.sigma = sigma + self.number_intensity_ch = number_intensity_ch + + def _get_signal(self, image, guidance): + dimensions = 3 if len(image.shape) > 3 else 2 + guidance = guidance.tolist() if isinstance(guidance, np.ndarray) else guidance + guidance = json.loads(guidance) if isinstance(guidance, str) else guidance + + # In inference the user may not provide clicks for some channels/labels + if len(guidance): + if dimensions == 3: + # Assume channel is first and depth is last CHWD + signal = np.zeros((1, image.shape[-3], image.shape[-2], image.shape[-1]), dtype=np.float32) + else: + signal = np.zeros((1, image.shape[-2], image.shape[-1]), dtype=np.float32) + + sshape = signal.shape + for point in guidance: # TO DO: make the guidance a list only - it is currently a list of list + if np.any(np.asarray(point) < 0): + continue + + if dimensions == 3: + # Making sure points fall inside the image dimension + p1 = max(0, min(int(point[-3]), sshape[-3] - 1)) + p2 = max(0, min(int(point[-2]), sshape[-2] - 1)) + p3 = max(0, min(int(point[-1]), sshape[-1] - 1)) + signal[:, p1, p2, p3] = 1.0 + else: + p1 = max(0, min(int(point[-2]), sshape[-2] - 1)) + p2 = max(0, min(int(point[-1]), sshape[-1] - 1)) + signal[:, p1, p2] = 1.0 + + # Apply a Gaussian filter to the signal + if np.max(signal[0]) > 0: + signal_tensor = torch.tensor(signal[0]) + pt_gaussian = GaussianFilter(len(signal_tensor.shape), sigma=self.sigma) + signal_tensor = pt_gaussian(signal_tensor.unsqueeze(0).unsqueeze(0)) + signal_tensor = signal_tensor.squeeze(0).squeeze(0) + signal[0] = signal_tensor.detach().cpu().numpy() + signal[0] = (signal[0] - np.min(signal[0])) / (np.max(signal[0]) - np.min(signal[0])) + return signal + else: + if dimensions == 3: + signal = np.zeros((1, image.shape[-3], image.shape[-2], image.shape[-1]), dtype=np.float32) + else: + signal = np.zeros((1, image.shape[-2], image.shape[-1]), dtype=np.float32) + return signal + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "image": + image = d[key] + tmp_image = image[0 : 0 + self.number_intensity_ch, ...] + guidance = d[self.guidance] + for key_label in guidance.keys(): + # Getting signal based on guidance + signal = self._get_signal(image, guidance[key_label]) + tmp_image = np.concatenate([tmp_image, signal], axis=0) + d[key] = tmp_image + return d + else: + print("This transform only applies to image key") + return d + + +class FindAllValidSlicesDeepEditd(MapTransform): + """ + Find/List all valid slices in the labels. + Label is assumed to be a 4D Volume with shape CHWD, where C=1. + + Args: + sids: key to store slices indices having valid label map. + """ + + def __init__(self, keys: KeysCollection, sids="sids", allow_missing_keys: bool = False): + super().__init__(keys, allow_missing_keys) + self.sids = sids + + def _apply(self, label, d): + sids = {} + for key_label in d["label_names"].keys(): + l_ids = [] + for sid in range(label.shape[-1]): # Assume channel is first and depth is last CHWD + if d["label_names"][key_label] in label[0][..., sid]: + l_ids.append(sid) + sids[key_label] = l_ids + return sids + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + label = d[key] + if label.shape[0] != 1: + raise ValueError("Only supports single channel labels!") + + if len(label.shape) != 4: # only for 3D + raise ValueError("Only supports label with shape CHWD!") + + sids = self._apply(label, d) + if sids is not None and len(sids.keys()): + d[self.sids] = sids + return d + else: + print("This transform only applies to label key") + return d + + +class AddInitialSeedPointDeepEditd(Randomizable, MapTransform): + """ + Add random guidance as initial seed point for a given label. + + Note that the label is of size (C, D, H, W) or (C, H, W) + + The guidance is of size (2, N, # of dims) where N is number of guidance added. + # of dims = 4 when C, D, H, W; # of dims = 3 when (C, H, W) + + Args: + guidance: key to store guidance. + sids: key that represents lists of valid slice indices for the given label. + sid: key that represents the slice to add initial seed point. If not present, random sid will be chosen. + connected_regions: maximum connected regions to use for adding initial points. + """ + + def __init__( + self, + keys: KeysCollection, + guidance: str = "guidance", + sids: str = "sids", + sid: str = "sid", + connected_regions: int = 5, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.sids_key = sids + self.sid_key = sid + self.sid: Dict[str, int] = dict() + self.guidance = guidance + self.connected_regions = connected_regions + + def _apply(self, label, sid, key_label): + dimensions = 3 if len(label.shape) > 3 else 2 + self.default_guidance = [-1] * (dimensions + 1) + + dims = dimensions + if sid is not None and dimensions == 3: + dims = 2 + label = label[0][..., sid][np.newaxis] # Assume channel is first and depth is last CHWD + + # THERE MAY BE MULTIPLE BLOBS FOR SINGLE LABEL IN THE SELECTED SLICE + label = (label > 0.5).astype(np.float32) + # measure.label: Label connected regions of an integer array - Two pixels are connected + # when they are neighbors and have the same value + blobs_labels = measure.label(label.astype(int), background=0) if dims == 2 else label + if np.max(blobs_labels) <= 0: + raise AssertionError(f"SLICES NOT FOUND FOR LABEL: {key_label}") + + pos_guidance = [] + for ridx in range(1, 2 if dims == 3 else self.connected_regions + 1): + if dims == 2: + label = (blobs_labels == ridx).astype(np.float32) + if np.sum(label) == 0: + pos_guidance.append(self.default_guidance) + continue + + # The distance transform provides a metric or measure of the separation of points in the image. + # This function calculates the distance between each pixel that is set to off (0) and + # the nearest nonzero pixel for binary images - http://matlab.izmiran.ru/help/toolbox/images/morph14.html + distance = distance_transform_cdt(label).flatten() + probability = np.exp(distance) - 1.0 + + idx = np.where(label.flatten() > 0)[0] + seed = self.R.choice(idx, size=1, p=probability[idx] / np.sum(probability[idx])) + dst = distance[seed] + + g = np.asarray(np.unravel_index(seed, label.shape)).transpose().tolist()[0] + g[0] = dst[0] # for debug + if dimensions == 2 or dims == 3: + pos_guidance.append(g) + else: + # Clicks are created using this convention Channel Height Width Depth (CHWD) + pos_guidance.append([g[0], g[-2], g[-1], sid]) # Assume channel is first and depth is last CHWD + + return np.asarray([pos_guidance]) + + def _randomize(self, d, key_label): + sids = d.get(self.sids_key).get(key_label) if d.get(self.sids_key) is not None else None + sid = d.get(self.sid_key).get(key_label) if d.get(self.sid_key) is not None else None + if sids is not None and sids: + if sid is None or sid not in sids: + sid = self.R.choice(sids, replace=False) + else: + logger.info(f"Not slice IDs for label: {key_label}") + sid = None + self.sid[key_label] = sid + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + label_guidances = {} + for key_label in d["sids"].keys(): + # Randomize: Select a random slice + self._randomize(d, key_label) + # Generate guidance base on selected slice + tmp_label = np.copy(d[key]) + # Taking one label to create the guidance + if key_label != "background": + tmp_label[tmp_label != float(d["label_names"][key_label])] = 0 + else: + tmp_label[tmp_label != float(d["label_names"][key_label])] = 1 + tmp_label = 1 - tmp_label + label_guidances[key_label] = json.dumps( + self._apply(tmp_label, self.sid.get(key_label), key_label).astype(int).tolist() + ) + d[self.guidance] = label_guidances + return d + else: + print("This transform only applies to label key") + return d - def __call__(self, data): - d = dict(data) - current_shape = d[self.ref_image].shape[1:] - factor = np.divide(current_shape, d[PostFix.meta("image")]["dim"][1:4]) - pos_clicks, neg_clicks = d["foreground"], d["background"] +class FindDiscrepancyRegionsDeepEditd(MapTransform): + """ + Find discrepancy between prediction and actual during click interactions during training. - pos = np.multiply(pos_clicks, factor).astype(int, copy=False).tolist() if len(pos_clicks) else [] - neg = np.multiply(neg_clicks, factor).astype(int, copy=False).tolist() if len(neg_clicks) else [] + Args: + pred: key to prediction source. + discrepancy: key to store discrepancies found between label and prediction. + """ - d[self.guidance] = [pos, neg] + def __init__( + self, + keys: KeysCollection, + pred: str = "pred", + discrepancy: str = "discrepancy", + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.pred = pred + self.discrepancy = discrepancy + + @staticmethod + def disparity(label, pred): + disparity = label - pred + # Negative ONES mean predicted label is not part of the ground truth + # Positive ONES mean predicted label missed that region of the ground truth + pos_disparity = (disparity > 0).astype(np.float32) + neg_disparity = (disparity < 0).astype(np.float32) + return [pos_disparity, neg_disparity] + + def _apply(self, label, pred): + return self.disparity(label, pred) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + all_discrepancies = {} + for _, (key_label, val_label) in enumerate(d["label_names"].items()): + if key_label != "background": + # Taking single label + label = np.copy(d[key]) + label[label != val_label] = 0 + # Label should be represented in 1 + label = (label > 0.5).astype(np.float32) + # Taking single prediction + pred = np.copy(d[self.pred]) + pred[pred != val_label] = 0 + # Prediction should be represented in one + pred = (pred > 0.5).astype(np.float32) + else: + # Taking single label + label = np.copy(d[key]) + label[label != val_label] = 1 + label = 1 - label + # Label should be represented in 1 + label = (label > 0.5).astype(np.float32) + # Taking single prediction + pred = np.copy(d[self.pred]) + pred[pred != val_label] = 1 + pred = 1 - pred + # Prediction should be represented in one + pred = (pred > 0.5).astype(np.float32) + all_discrepancies[key_label] = self._apply(label, pred) + d[self.discrepancy] = all_discrepancies + return d + else: + print("This transform only applies to 'label' key") return d -class ClickRatioAddRandomGuidanced(Randomizable, Transform): +class AddRandomGuidanceDeepEditd(Randomizable, MapTransform): """ Add random guidance based on discrepancies that were found between label and prediction. + Args: guidance: key to guidance source, shape (2, N, # of dim) - discrepancy: key that represents discrepancies found between label and prediction, shape (2, C, D, H, W) or (2, C, H, W) - probability: key that represents click/interaction probability, shape (1) - fn_fp_click_ratio: ratio of clicks between FN and FP + discrepancy: key to discrepancy map between label and prediction shape (2, C, H, W, D) or (2, C, H, W) + probability: key to click/interaction probability, shape (1) """ def __init__( self, + keys: KeysCollection, guidance: str = "guidance", discrepancy: str = "discrepancy", probability: str = "probability", - fn_fp_click_ratio: Tuple[float, float] = (1.0, 1.0), + allow_missing_keys: bool = False, ): + super().__init__(keys, allow_missing_keys) self.guidance = guidance self.discrepancy = discrepancy self.probability = probability - self.fn_fp_click_ratio = fn_fp_click_ratio self._will_interact = None + self.is_pos = None + self.is_other = None + self.default_guidance = None def randomize(self, data=None): probability = data[self.probability] @@ -108,7 +506,7 @@ def randomize(self, data=None): def find_guidance(self, discrepancy): distance = distance_transform_cdt(discrepancy).flatten() - probability = np.exp(distance) - 1.0 + probability = np.exp(distance.flatten()) - 1.0 idx = np.where(discrepancy.flatten() > 0)[0] if np.sum(discrepancy > 0) > 0: @@ -120,51 +518,365 @@ def find_guidance(self, discrepancy): return g return None - def add_guidance(self, discrepancy, will_interact): - if not will_interact: - return None, None + def add_guidance(self, guidance, discrepancy, label_names, labels): - pos_discr = discrepancy[0] - neg_discr = discrepancy[1] + # Positive clicks of the segment in the iteration + pos_discr = discrepancy[0] # idx 0 is positive discrepancy and idx 1 is negative discrepancy - can_be_positive = np.sum(pos_discr) > 0 - can_be_negative = np.sum(neg_discr) > 0 + # Check the areas that belong to other segments + other_discrepancy_areas = {} + for _, (key_label, val_label) in enumerate(label_names.items()): + if key_label != "background": + tmp_label = np.copy(labels) + tmp_label[tmp_label != val_label] = 0 + tmp_label = (tmp_label > 0.5).astype(np.float32) + other_discrepancy_areas[key_label] = np.sum(discrepancy[1] * tmp_label) + else: + tmp_label = np.copy(labels) + tmp_label[tmp_label != val_label] = 1 + tmp_label = 1 - tmp_label + other_discrepancy_areas[key_label] = np.sum(discrepancy[1] * tmp_label) + + # Add guidance to the current key label + if np.sum(pos_discr) > 0: + guidance.append(self.find_guidance(pos_discr)) + self.is_pos = True + + # Add guidance to the other areas + for key_label in label_names.keys(): + # Areas that cover more than 50 voxels + if other_discrepancy_areas[key_label] > 50: + self.is_other = True + if key_label != "background": + tmp_label = np.copy(labels) + tmp_label[tmp_label != label_names[key_label]] = 0 + tmp_label = (tmp_label > 0.5).astype(np.float32) + self.tmp_guidance[key_label].append(self.find_guidance(discrepancy[1] * tmp_label)) + else: + tmp_label = np.copy(labels) + tmp_label[tmp_label != label_names[key_label]] = 1 + tmp_label = 1 - tmp_label + self.tmp_guidance[key_label].append(self.find_guidance(discrepancy[1] * tmp_label)) - pos_prob = self.fn_fp_click_ratio[0] / (self.fn_fp_click_ratio[0] + self.fn_fp_click_ratio[1]) - neg_prob = self.fn_fp_click_ratio[1] / (self.fn_fp_click_ratio[0] + self.fn_fp_click_ratio[1]) + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + guidance = d[self.guidance] + discrepancy = d[self.discrepancy] + self.randomize(data) + if self._will_interact: + # Convert all guidance to lists so new guidance can be easily appended + self.tmp_guidance = {} + for key_label in d["label_names"].keys(): + tmp_gui = guidance[key_label] + tmp_gui = tmp_gui.tolist() if isinstance(tmp_gui, np.ndarray) else tmp_gui + tmp_gui = json.loads(tmp_gui) if isinstance(tmp_gui, str) else tmp_gui + self.tmp_guidance[key_label] = [j for j in tmp_gui if -1 not in j] + + # Add guidance according to discrepancy + for key_label in d["label_names"].keys(): + # Add guidance based on discrepancy + self.add_guidance(self.tmp_guidance[key_label], discrepancy[key_label], d["label_names"], d["label"]) + + # Checking the number of clicks + num_clicks = random.randint(1, 10) + counter = 0 + keep_guidance = [] + while True: + aux_label = random.choice(list(d["label_names"].keys())) + if aux_label in keep_guidance: + pass + else: + keep_guidance.append(aux_label) + counter = counter + len(self.tmp_guidance[aux_label]) + # If collected clicks is bigger than max clicks, discard the others + if counter >= num_clicks: + for key_label in d["label_names"].keys(): + if key_label not in keep_guidance: + self.tmp_guidance[key_label] = [] + logger.info(f"Number of simulated clicks: {counter}") + break + + # Breaking once all labels are covered + if len(keep_guidance) == len(d["label_names"].keys()): + logger.info(f"Number of simulated clicks: {counter}") + break - correct_pos = self.R.choice([True, False], p=[pos_prob, neg_prob]) + return d - if can_be_positive and not can_be_negative: - return self.find_guidance(pos_discr), None - if not can_be_positive and can_be_negative: - return None, self.find_guidance(neg_discr) +class AddGuidanceFromPointsDeepEditd(Transform): + """ + Add guidance based on user clicks. ONLY WORKS FOR 3D - if correct_pos and can_be_positive: - return self.find_guidance(pos_discr), None + We assume the input is loaded by LoadImaged and has the shape of (H, W, D) originally. + Clicks always specify the coordinates in (H, W, D) - if not correct_pos and can_be_negative: - return None, self.find_guidance(neg_discr) - return None, None + Args: + ref_image: key to reference image to fetch current and original image details. + guidance: output key to store guidance. + meta_keys: explicitly indicate the key of the meta data dictionary of `ref_image`. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the meta data is a dictionary object which contains: filename, original_shape, etc. + if None, will try to construct meta_keys by `{ref_image}_{meta_key_postfix}`. + meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to to fetch the meta data according + to the key data, default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. - def _apply(self, guidance, discrepancy): - guidance = guidance.tolist() if isinstance(guidance, np.ndarray) else guidance - guidance = json.loads(guidance) if isinstance(guidance, str) else guidance - pos, neg = self.add_guidance(discrepancy, self._will_interact) - if pos: - guidance[0].append(pos) - guidance[1].append([-1] * len(pos)) - if neg: - guidance[0].append([-1] * len(neg)) - guidance[1].append(neg) + """ - return json.dumps(np.asarray(guidance, dtype=int).tolist()) + def __init__( + self, + ref_image, + guidance: str = "guidance", + label_names=None, + meta_keys: Optional[str] = None, + meta_key_postfix: str = "meta_dict", + ): + self.ref_image = ref_image + self.guidance = guidance + self.label_names = label_names + self.meta_keys = meta_keys + self.meta_key_postfix = meta_key_postfix + + @staticmethod + def _apply(clicks, factor): + if len(clicks): + guidance = np.multiply(clicks, factor).astype(int).tolist() + return guidance + else: + return [] def __call__(self, data): d = dict(data) - guidance = d[self.guidance] - discrepancy = d[self.discrepancy] - self.randomize(data) - d[self.guidance] = self._apply(guidance, discrepancy) + meta_dict_key = self.meta_keys or f"{self.ref_image}_{self.meta_key_postfix}" + if meta_dict_key not in d: + raise RuntimeError(f"Missing meta_dict {meta_dict_key} in data!") + if "spatial_shape" not in d[meta_dict_key]: + raise RuntimeError('Missing "spatial_shape" in meta_dict!') + + # Assume channel is first and depth is last CHWD + original_shape = d[meta_dict_key]["spatial_shape"] + current_shape = list(d[self.ref_image].shape)[1:] + + # in here we assume the depth dimension is in the last dimension of "original_shape" and "current_shape" + factor = np.array(current_shape) / original_shape + + # Creating guidance for all clicks + all_guidances = {} + for key_label in self.label_names.keys(): + clicks = d.get(key_label, []) + clicks = list(np.array(clicks).astype(int)) + all_guidances[key_label] = self._apply(clicks, factor) + d[self.guidance] = all_guidances + return d + + +class ResizeGuidanceMultipleLabelDeepEditd(Transform): + """ + Resize the guidance based on cropped vs resized image. + + """ + + def __init__(self, guidance: str, ref_image: str) -> None: + self.guidance = guidance + self.ref_image = ref_image + + def __call__(self, data): + d = dict(data) + # Assume channel is first and depth is last CHWD + current_shape = d[self.ref_image].shape[1:] + original_shape = d["image_meta_dict"]["spatial_shape"] + + # original_shape = np.roll(original_shape, 1) + + factor = np.divide(current_shape, original_shape) + all_guidances = {} + for key_label in d[self.guidance].keys(): + guidance = ( + np.multiply(d[self.guidance][key_label], factor).astype(int).tolist() + if len(d[self.guidance][key_label]) + else [] + ) + all_guidances[key_label] = guidance + + d[self.guidance] = all_guidances + return d + + +class SplitPredsLabeld(MapTransform): + """ + Split preds and labels for individual evaluation + + """ + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "pred": + for idx, (key_label, _) in enumerate(d["label_names"].items()): + if key_label != "background": + d[f"pred_{key_label}"] = d[key][idx + 1, ...][None] + d[f"label_{key_label}"] = d["label"][idx + 1, ...][None] + elif key != "pred": + logger.info("This is only for pred key") + return d + + +class AddInitialSeedPointMissingLabelsd(Randomizable, MapTransform): + """ + Add random guidance as initial seed point for a given label. + Note that the label is of size (C, D, H, W) or (C, H, W) + The guidance is of size (2, N, # of dims) where N is number of guidance added. + # of dims = 4 when C, D, H, W; # of dims = 3 when (C, H, W) + Args: + guidance: key to store guidance. + sids: key that represents lists of valid slice indices for the given label. + sid: key that represents the slice to add initial seed point. If not present, random sid will be chosen. + connected_regions: maximum connected regions to use for adding initial points. + """ + + def __init__( + self, + keys: KeysCollection, + guidance: str = "guidance", + sids: str = "sids", + sid: str = "sid", + connected_regions: int = 5, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.sids_key = sids + self.sid_key = sid + self.sid: Dict[str, int] = dict() + self.guidance = guidance + self.connected_regions = connected_regions + + def _apply(self, label, sid): + dimensions = 3 if len(label.shape) > 3 else 2 + self.default_guidance = [-1] * (dimensions + 1) + + dims = dimensions + if sid is not None and dimensions == 3: + dims = 2 + label = label[0][..., sid][np.newaxis] # Assume channel is first and depth is last CHWD + + # THERE MAY BE MULTIPLE BLOBS FOR SINGLE LABEL IN THE SELECTED SLICE + label = (label > 0.5).astype(np.float32) + # measure.label: Label connected regions of an integer array - Two pixels are connected + # when they are neighbors and have the same value + blobs_labels = measure.label(label.astype(int), background=0) if dims == 2 else label + + label_guidance = [] + # If there are is presence of that label in this slice + if np.max(blobs_labels) <= 0: + label_guidance.append(self.default_guidance) + else: + for ridx in range(1, 2 if dims == 3 else self.connected_regions + 1): + if dims == 2: + label = (blobs_labels == ridx).astype(np.float32) + if np.sum(label) == 0: + label_guidance.append(self.default_guidance) + continue + + # The distance transform provides a metric or measure of the separation of points in the image. + # This function calculates the distance between each pixel that is set to off (0) and + # the nearest nonzero pixel for binary images + # http://matlab.izmiran.ru/help/toolbox/images/morph14.html + distance = distance_transform_cdt(label).flatten() + probability = np.exp(distance) - 1.0 + + idx = np.where(label.flatten() > 0)[0] + seed = self.R.choice(idx, size=1, p=probability[idx] / np.sum(probability[idx])) + dst = distance[seed] + + g = np.asarray(np.unravel_index(seed, label.shape)).transpose().tolist()[0] + g[0] = dst[0] # for debug + if dimensions == 2 or dims == 3: + label_guidance.append(g) + else: + # Clicks are created using this convention Channel Height Width Depth (CHWD) + label_guidance.append([g[0], g[-2], g[-1], sid]) # Assume channel is first and depth is last CHWD + + return np.asarray(label_guidance) + + def _randomize(self, d, key_label): + sids = d.get(self.sids_key).get(key_label) if d.get(self.sids_key) is not None else None + sid = d.get(self.sid_key).get(key_label) if d.get(self.sid_key) is not None else None + if sids is not None and sids: + if sid is None or sid not in sids: + sid = self.R.choice(sids, replace=False) + else: + logger.info(f"Not slice IDs for label: {key_label}") + sid = None + self.sid[key_label] = sid + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + label_guidances = {} + for key_label in d["sids"].keys(): + # Randomize: Select a random slice + self._randomize(d, key_label) + # Generate guidance base on selected slice + tmp_label = np.copy(d[key]) + # Taking one label to create the guidance + if key_label != "background": + tmp_label[tmp_label != float(d["label_names"][key_label])] = 0 + else: + tmp_label[tmp_label != float(d["label_names"][key_label])] = 1 + tmp_label = 1 - tmp_label + label_guidances[key_label] = json.dumps( + self._apply(tmp_label, self.sid.get(key_label)).astype(int).tolist() + ) + d[self.guidance] = label_guidances + return d + else: + print("This transform only applies to label key") + return d + + +class FindAllValidSlicesMissingLabelsd(MapTransform): + """ + Find/List all valid slices in the labels. + Label is assumed to be a 4D Volume with shape CHWD, where C=1. + Args: + sids: key to store slices indices having valid label map. + """ + + def __init__(self, keys: KeysCollection, sids="sids", allow_missing_keys: bool = False): + super().__init__(keys, allow_missing_keys) + self.sids = sids + + def _apply(self, label, d): + sids = {} + for key_label in d["label_names"].keys(): + l_ids = [] + for sid in range(label.shape[-1]): # Assume channel is first and depth is last CHWD + if d["label_names"][key_label] in label[0][..., sid]: + l_ids.append(sid) + # If there are not slices with the label + if l_ids == []: + l_ids = [-1] * 10 + sids[key_label] = l_ids + return sids + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "label": + label = d[key] + if label.shape[0] != 1: + raise ValueError("Only supports single channel labels!") + + if len(label.shape) != 4: # only for 3D + raise ValueError("Only supports label with shape CHWD!") + + sids = self._apply(label, d) + if sids is not None and len(sids.keys()): + d[self.sids] = sids + return d + else: + print("This transform only applies to label key") return d diff --git a/tests/min_tests.py b/tests/min_tests.py index 25acbccb41..6549fdcd4b 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -41,6 +41,7 @@ def run_testsuit(): "test_dataset", "test_dataset_summary", "test_deepedit_transforms", + "test_deepedit_interaction", "test_deepgrow_dataset", "test_deepgrow_interaction", "test_deepgrow_transforms", diff --git a/tests/test_deepedit_interaction.py b/tests/test_deepedit_interaction.py new file mode 100644 index 0000000000..6bb723268f --- /dev/null +++ b/tests/test_deepedit_interaction.py @@ -0,0 +1,119 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch + +from monai.apps.deepedit.interaction import Interaction +from monai.apps.deepedit.transforms import ( + AddGuidanceSignalDeepEditd, + AddInitialSeedPointMissingLabelsd, + AddRandomGuidanceDeepEditd, + FindAllValidSlicesMissingLabelsd, + FindDiscrepancyRegionsDeepEditd, + SplitPredsLabeld, +) +from monai.data import Dataset +from monai.engines import SupervisedTrainer +from monai.engines.utils import IterationEvents +from monai.losses import DiceCELoss +from monai.transforms import Activationsd, AsDiscreted, Compose, ToTensord + + +def add_one(engine): + if engine.state.best_metric == -1: + engine.state.best_metric = 0 + else: + engine.state.best_metric = engine.state.best_metric + 1 + + +class TestInteractions(unittest.TestCase): + def run_interaction(self, train): + label_names = {"spleen": 1, "background": 0} + np.random.seed(0) + data = [ + { + "image": np.random.randint(0, 256, size=(1, 15, 15, 15)).astype(np.float32), + "label": np.random.randint(0, 2, size=(1, 15, 15, 15)), + "label_names": label_names, + } + for _ in range(5) + ] + network = torch.nn.Conv3d(3, len(label_names), 1) + lr = 1e-3 + opt = torch.optim.Adam(network.parameters(), lr) + loss = DiceCELoss(to_onehot_y=True, softmax=True) + pre_transforms = Compose( + [ + FindAllValidSlicesMissingLabelsd(keys="label", sids="sids"), + AddInitialSeedPointMissingLabelsd(keys="label", guidance="guidance", sids="sids"), + AddGuidanceSignalDeepEditd(keys="image", guidance="guidance", number_intensity_ch=1), + ToTensord(keys=("image", "label")), + ] + ) + dataset = Dataset(data, transform=pre_transforms) + data_loader = torch.utils.data.DataLoader(dataset, batch_size=5) + + iteration_transforms = [ + FindDiscrepancyRegionsDeepEditd(keys="label", pred="pred", discrepancy="discrepancy"), + AddRandomGuidanceDeepEditd( + keys="NA", guidance="guidance", discrepancy="discrepancy", probability="probability" + ), + AddGuidanceSignalDeepEditd(keys="image", guidance="guidance", number_intensity_ch=1), + ToTensord(keys=("image", "label")), + ] + post_transforms = [ + Activationsd(keys="pred", softmax=True), + AsDiscreted(keys=("pred", "label"), argmax=(True, False), to_onehot=len(label_names)), + SplitPredsLabeld(keys="pred"), + ToTensord(keys=("image", "label")), + ] + iteration_transforms = Compose(iteration_transforms) + post_transforms = Compose(post_transforms) + + i = Interaction( + deepgrow_probability=1.0, + transforms=iteration_transforms, + click_probability_key="probability", + train=train, + label_names=label_names, + ) + self.assertEqual(len(i.transforms.transforms), 4, "Mismatch in expected transforms") + + # set up engine + engine = SupervisedTrainer( + device=torch.device("cpu"), + max_epochs=1, + train_data_loader=data_loader, + network=network, + optimizer=opt, + loss_function=loss, + postprocessing=post_transforms, + iteration_update=i, + ) + engine.add_event_handler(IterationEvents.INNER_ITERATION_STARTED, add_one) + engine.add_event_handler(IterationEvents.INNER_ITERATION_COMPLETED, add_one) + + engine.run() + self.assertIsNotNone(engine.state.batch[0].get("guidance"), "guidance is missing") + self.assertEqual(engine.state.best_metric, 1) + + def test_train_interaction(self): + self.run_interaction(train=True) + + def test_val_interaction(self): + self.run_interaction(train=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deepedit_transforms.py b/tests/test_deepedit_transforms.py index a5c5f0fe2f..225b2fc60b 100644 --- a/tests/test_deepedit_transforms.py +++ b/tests/test_deepedit_transforms.py @@ -14,81 +14,288 @@ import numpy as np from parameterized import parameterized -from monai.apps.deepedit.transforms import ClickRatioAddRandomGuidanced, DiscardAddGuidanced, ResizeGuidanceCustomd +from monai.apps.deepedit.transforms import ( + AddGuidanceFromPointsDeepEditd, + AddGuidanceSignalDeepEditd, + AddInitialSeedPointMissingLabelsd, + AddRandomGuidanceDeepEditd, + DiscardAddGuidanced, + FindAllValidSlicesMissingLabelsd, + FindDiscrepancyRegionsDeepEditd, + NormalizeLabelsInDatasetd, + ResizeGuidanceMultipleLabelDeepEditd, + SingleLabelSelectiond, + SplitPredsLabeld, +) +from monai.utils import min_version, optional_import, set_determinism from monai.utils.enums import PostFix -IMAGE = np.array([[[[1, 0, 2, 0, 1], [0, 1, 2, 1, 0], [2, 2, 3, 2, 2], [0, 1, 2, 1, 0], [1, 0, 2, 0, 1]]]]) -LABEL = np.array([[[[0, 0, 0, 0, 0], [0, 1, 0, 1, 0], [0, 0, 1, 0, 0], [0, 1, 0, 1, 0], [0, 0, 0, 0, 0]]]]) +measure, _ = optional_import("skimage.measure", "0.14.2", min_version) + +set_determinism(seed=0) +IMAGE = np.random.randint(0, 256, size=(1, 10, 10, 10)) +THREE_CHAN_IMAGE = np.random.randint(0, 255, size=(3, 10, 10, 10)) +LABEL = np.random.randint(0, 2, size=(10, 10, 10)) +PRED = np.random.randint(0, 2, size=(10, 10, 10)) +LABEL_NAMES = {"spleen": 1, "background": 0} +DISCREPANCY = { + "spleen": np.random.randint(0, 2, size=(10, 10, 10)), + "background": np.random.randint(0, 2, size=(10, 10, 10)), +} +set_determinism(None) DATA_1 = { "image": IMAGE, "label": LABEL, - PostFix.meta("image"): {"dim": IMAGE.shape}, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, PostFix.meta("label"): {}, - "foreground": [0, 0, 0], - "background": [0, 0, 0], } -DISCARD_ADD_GUIDANCE_TEST_CASE = [{"image": IMAGE, "label": LABEL}, DATA_1, (3, 1, 5, 5)] - DATA_2 = { "image": IMAGE, "label": LABEL, - "guidance": np.array([[[1, 0, 2, 2]], [[-1, -1, -1, -1]]]), - "discrepancy": np.array( - [ - [[[[0, 0, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]]], - [[[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]]], - ] - ), + "label_names": LABEL_NAMES, + "guidance": {"spleen": [[3, 5, 4, 6], [-1, -1, -1, -1]], "background": [[-1, -1, -1, -1], [-1, -1, -1, -1]]}, + "discrepancy": DISCREPANCY, "probability": 1.0, } -CLICK_RATIO_ADD_RANDOM_GUIDANCE_TEST_CASE_1 = [ - {"guidance": "guidance", "discrepancy": "discrepancy", "probability": "probability"}, - DATA_2, - "[[[1, 0, 2, 2], [-1, -1, -1, -1]], [[-1, -1, -1, -1], [1, 0, 2, 1]]]", -] - DATA_3 = { - "image": np.arange(1000).reshape((1, 5, 10, 20)), - PostFix.meta("image"): {"foreground_cropped_shape": (1, 10, 20, 40), "dim": [3, 512, 512, 128]}, - "guidance": [[[6, 10, 14], [8, 10, 14]], [[8, 10, 16]]], - "foreground": [[10, 14, 6], [10, 14, 8]], - "background": [[10, 16, 8]], + "image": IMAGE, + "label": LABEL, + "guidance": { + "spleen": np.array([[1, 0, 2, 2], [-1, -1, -1, -1]]), + "background": np.array([[1, 0, 2, 2], [-1, -1, -1, -1]]), + }, + "probability": 1.0, + "label_names": LABEL_NAMES, + "pred": PRED, +} + +DATA_4 = { + "image": IMAGE, + "label": LABEL, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "current_label": "spleen", + "probability": 1.0, + "label_names": LABEL_NAMES, + "spleen": [[0, 4, 3], [0, 0, 3], [0, 1, 3]], + "sids": {"spleen": []}, + "pred": PRED, +} + +DATA_5 = { + "image": IMAGE, + "label": LABEL, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "current_label": "spleen", + "probability": 1.0, + "label_names": LABEL_NAMES, + "sids": {"spleen": [2, 3, 4], "background": [0, 1, 5]}, +} + +DATA_6 = { + "image": IMAGE, + "label": LABEL[None], + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "current_label": "spleen", + "label_names": LABEL_NAMES, +} + +DATA_7 = { + "image": IMAGE, + "label": LABEL, + "pred": PRED, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "current_label": "spleen", + "probability": 1.0, + "label_names": LABEL_NAMES, + "guidance": { + "spleen": np.array([[1, 0, 2, 2], [-1, -1, -1, -1]]), + "background": np.array([[1, 0, 2, 2], [-1, -1, -1, -1]]), + }, +} + +DATA_8 = { + "image": IMAGE, + "label": LABEL, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "label_names": LABEL_NAMES, +} + +DATA_9 = { + "image": IMAGE, + "label": LABEL, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "label_names": LABEL_NAMES, + "guidance": {"spleen": np.array([0, 2, 2]), "background": np.array([-1, -1, -1])}, +} + +DATA_10 = { + "image": IMAGE, + "label": LABEL, + PostFix.meta("image"): {"dim": IMAGE.shape, "spatial_shape": IMAGE[0, ...].shape}, + "current_label": "spleen", } -RESIZE_GUIDANCE_TEST_CASE_1 = [ - {"ref_image": "image", "guidance": "guidance"}, - DATA_3, - [[[0, 0, 0], [0, 0, 1]], [[0, 0, 1]]], +DATA_11 = {"image": IMAGE, "label": LABEL, "label_names": LABEL_NAMES, "pred": PRED} + + +ADD_GUIDANCE_FROM_POINTS_TEST_CASE = [ + {"ref_image": "image", "guidance": "guidance", "label_names": LABEL_NAMES}, # arguments + DATA_4, # input_data + [0, 4, 3], # expected_result ] +ADD_GUIDANCE_CUSTOM_TEST_CASE = [ + {"keys": "image", "guidance": "guidance"}, # arguments + DATA_3, # input_data + 3, # expected_result +] -class TestDiscardAddGuidanced(unittest.TestCase): - @parameterized.expand([DISCARD_ADD_GUIDANCE_TEST_CASE]) +ADD_INITIAL_POINT_TEST_CASE = [ + {"keys": "label", "guidance": "guidance", "sids": "sids"}, # arguments + DATA_5, # input_data + { + "spleen": "[[1, 0, 7], [-1, -1, -1], [-1, -1, -1], [-1, -1, -1], [-1, -1, -1]]", + "background": "[[1, 5, 3], [-1, -1, -1], [-1, -1, -1], [-1, -1, -1], [-1, -1, -1]]", + }, # expected_result +] + +ADD_RANDOM_GUIDANCE_TEST_CASE = [ + {"keys": "NA", "guidance": "guidance", "discrepancy": "discrepancy", "probability": "probability"}, # arguments + DATA_2, # input_data + {"spleen": [[3, 5, 4, 6], [-1, -1, -1, -1]], "background": [[-1, -1, -1, -1], [-1, -1, -1, -1]]}, # expected_result +] + +DISCARD_ADD_GUIDANCE_TEST_CASE = [ + {"keys": "image", "label_names": LABEL_NAMES}, # arguments + DATA_1, # input_data + (3, 10, 10, 10), # expected_result +] + +FIND_DISCREPANCY_TEST_CASE = [ + {"keys": "label", "pred": "pred", "discrepancy": "discrepancy"}, # arguments + DATA_7, # input_data + 240, # expected_result +] + +FIND_SLICE_TEST_CASE = [ + {"keys": "label", "sids": "sids"}, # arguments + DATA_6, # input_data + {"spleen": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], "background": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}, # expected_result +] + +NormalizeLabelsDatasetd_TEST_CASE = [ + {"keys": "label", "label_names": LABEL_NAMES}, # arguments + DATA_8, # input_data + len(LABEL_NAMES), # expected_result +] + +RESIZE_GUIDANCE_TEST_CASE = [ + {"guidance": "guidance", "ref_image": "image"}, # arguments + DATA_9, # input_data + {"spleen": [0, 2, 2], "background": [-1, -1, -1]}, # expected_result +] + +SingleLabelSelectiond_TEST_CASE = [ + {"keys": "label", "label_names": ["spleen"]}, # arguments + DATA_10, # input_data + "spleen", # expected_result +] + +SplitPredsLabeld_TEST_CASE = [{"keys": "pred"}, DATA_11, (1, 10, 10)] # arguments # input_data # expected_result + + +class TestAddGuidanceFromPointsCustomd(unittest.TestCase): + @parameterized.expand([ADD_GUIDANCE_FROM_POINTS_TEST_CASE]) def test_correct_results(self, arguments, input_data, expected_result): - add_fn = DiscardAddGuidanced(arguments) + add_fn = AddGuidanceFromPointsDeepEditd(**arguments) result = add_fn(input_data) - self.assertEqual(result["image"].shape, expected_result) + self.assertEqual(result[arguments["guidance"]]["spleen"][0], expected_result) -class TestClickRatioAddRandomGuidanced(unittest.TestCase): - @parameterized.expand([CLICK_RATIO_ADD_RANDOM_GUIDANCE_TEST_CASE_1]) +class TestAddGuidanceSignalCustomd(unittest.TestCase): + @parameterized.expand([ADD_GUIDANCE_CUSTOM_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = AddGuidanceSignalDeepEditd(**arguments) + result = add_fn(input_data) + self.assertEqual(result["image"].shape[0], expected_result) + + +class TestAddInitialSeedPointMissingLabelsd(unittest.TestCase): + @parameterized.expand([ADD_INITIAL_POINT_TEST_CASE]) def test_correct_results(self, arguments, input_data, expected_result): seed = 0 - add_fn = ClickRatioAddRandomGuidanced(**arguments) + add_fn = AddInitialSeedPointMissingLabelsd(**arguments) add_fn.set_random_state(seed) result = add_fn(input_data) self.assertEqual(result[arguments["guidance"]], expected_result) -class TestResizeGuidanced(unittest.TestCase): - @parameterized.expand([RESIZE_GUIDANCE_TEST_CASE_1]) +class TestAddRandomGuidanceCustomd(unittest.TestCase): + @parameterized.expand([ADD_RANDOM_GUIDANCE_TEST_CASE]) def test_correct_results(self, arguments, input_data, expected_result): - result = ResizeGuidanceCustomd(**arguments)(input_data) + add_fn = AddRandomGuidanceDeepEditd(**arguments) + result = add_fn(input_data) self.assertEqual(result[arguments["guidance"]], expected_result) +class TestDiscardAddGuidanced(unittest.TestCase): + @parameterized.expand([DISCARD_ADD_GUIDANCE_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = DiscardAddGuidanced(**arguments) + result = add_fn(input_data) + self.assertEqual(result["image"].shape, expected_result) + + +class TestFindAllValidSlicesMissingLabelsd(unittest.TestCase): + @parameterized.expand([FIND_SLICE_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = FindAllValidSlicesMissingLabelsd(**arguments) + result = add_fn(input_data) + self.assertEqual(result[arguments["sids"]], expected_result) + + +class TestFindDiscrepancyRegionsCustomd(unittest.TestCase): + @parameterized.expand([FIND_DISCREPANCY_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = FindDiscrepancyRegionsDeepEditd(**arguments) + result = add_fn(input_data) + self.assertEqual(np.sum(result[arguments["discrepancy"]]["spleen"][0]), expected_result) + + +class TestNormalizeLabelsDatasetd(unittest.TestCase): + @parameterized.expand([NormalizeLabelsDatasetd_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = NormalizeLabelsInDatasetd(**arguments) + result = add_fn(input_data) + self.assertEqual(len(np.unique(result["label"])), expected_result) + + +class TestResizeGuidanceMultipleLabelCustomd(unittest.TestCase): + @parameterized.expand([RESIZE_GUIDANCE_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = ResizeGuidanceMultipleLabelDeepEditd(**arguments) + result = add_fn(input_data) + self.assertEqual(result[arguments["guidance"]], expected_result) + + +class TestSingleLabelSelectiond(unittest.TestCase): + @parameterized.expand([SingleLabelSelectiond_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = SingleLabelSelectiond(**arguments) + result = add_fn(input_data) + self.assertEqual(result["current_label"], expected_result) + + +class TestSplitPredsLabeld(unittest.TestCase): + @parameterized.expand([SplitPredsLabeld_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = SplitPredsLabeld(**arguments) + result = add_fn(input_data) + self.assertEqual(result["pred_spleen"].shape, expected_result) + + if __name__ == "__main__": unittest.main() From c9b2d060916565732d3d724287c8f617155ec590 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Wed, 27 Apr 2022 17:28:33 +0100 Subject: [PATCH 042/183] Remove commented out code (#4187) * Remove commented out code * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: monai-bot --- monai/apps/deepedit/transforms.py | 2 -- tests/test_bending_energy.py | 1 - tests/test_data_stats.py | 1 - tests/test_data_statsd.py | 1 - tests/test_focal_loss.py | 3 --- tests/test_fourier.py | 2 -- tests/test_print_transform_backends.py | 1 - tests/test_rand_cucim_dict_transform.py | 8 -------- tests/test_rand_cucim_transform.py | 8 -------- 9 files changed, 27 deletions(-) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index 9f29ff2cd2..bb62d36e75 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -688,8 +688,6 @@ def __call__(self, data): current_shape = d[self.ref_image].shape[1:] original_shape = d["image_meta_dict"]["spatial_shape"] - # original_shape = np.roll(original_shape, 1) - factor = np.divide(current_shape, original_shape) all_guidances = {} for key_label in d[self.guidance].keys(): diff --git a/tests/test_bending_energy.py b/tests/test_bending_energy.py index 318b1905df..18f88ba759 100644 --- a/tests/test_bending_energy.py +++ b/tests/test_bending_energy.py @@ -60,7 +60,6 @@ def test_ill_shape(self): loss.forward(torch.ones((1, 3), device=device)) with self.assertRaisesRegex(ValueError, "Expecting 3-d, 4-d or 5-d"): loss.forward(torch.ones((1, 4, 5, 5, 5, 5), device=device)) - # spatial_dim < 5 with self.assertRaisesRegex(ValueError, "All spatial dimensions"): loss.forward(torch.ones((1, 3, 4, 5, 5), device=device)) with self.assertRaisesRegex(ValueError, "All spatial dimensions"): diff --git a/tests/test_data_stats.py b/tests/test_data_stats.py index c18abfcedc..6c11b9bb0f 100644 --- a/tests/test_data_stats.py +++ b/tests/test_data_stats.py @@ -137,7 +137,6 @@ class TestDataStats(unittest.TestCase): def test_value(self, input_param, input_data, expected_print): transform = DataStats(**input_param) _ = transform(input_data) - # self.assertEqual(transform.output, expected_print) @parameterized.expand([TEST_CASE_8]) def test_file(self, input_data, expected_print): diff --git a/tests/test_data_statsd.py b/tests/test_data_statsd.py index 28da936cd0..9c878addf5 100644 --- a/tests/test_data_statsd.py +++ b/tests/test_data_statsd.py @@ -161,7 +161,6 @@ class TestDataStatsd(unittest.TestCase): def test_value(self, input_param, input_data, expected_print): transform = DataStatsd(**input_param) _ = transform(input_data) - # self.assertEqual(transform.printer.output, expected_print) @parameterized.expand([TEST_CASE_9]) def test_file(self, input_data, expected_print): diff --git a/tests/test_focal_loss.py b/tests/test_focal_loss.py index 5a063ba6c8..6ac23fef36 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -67,11 +67,8 @@ def test_consistency_with_cross_entropy_2d_no_reduction(self): b = output1.cpu().detach().numpy() error = np.abs(a - b) max_error = np.maximum(error, max_error) - # if np.all(np.abs(a - b) > max_error): - # max_error = np.abs(a - b) assert np.allclose(max_error, 0) - # self.assertAlmostEqual(max_error, 0.0, places=3) def test_consistency_with_cross_entropy_2d_onehot_label(self): """For gamma=0 the focal loss reduces to the cross entropy loss""" diff --git a/tests/test_fourier.py b/tests/test_fourier.py index e1b5f3089d..b500f266d7 100644 --- a/tests/test_fourier.py +++ b/tests/test_fourier.py @@ -21,8 +21,6 @@ from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [((128, 64),), ((64, 48, 80),)] -# for shape in ((128, 64), (64, 48, 80)): -# TEST_CASES.append(shape) @SkipIfBeforePyTorchVersion((1, 8)) diff --git a/tests/test_print_transform_backends.py b/tests/test_print_transform_backends.py index 2db00fea39..e714003769 100644 --- a/tests/test_print_transform_backends.py +++ b/tests/test_print_transform_backends.py @@ -22,6 +22,5 @@ def test_get_number_of_conversions(self): if __name__ == "__main__": - # unittest.main() a = TestPrintTransformBackends() a.test_get_number_of_conversions() diff --git a/tests/test_rand_cucim_dict_transform.py b/tests/test_rand_cucim_dict_transform.py index 2f27dd5f1f..a109bee845 100644 --- a/tests/test_rand_cucim_dict_transform.py +++ b/tests/test_rand_cucim_dict_transform.py @@ -91,12 +91,10 @@ class TestRandCuCIMDict(unittest.TestCase): ) def test_tramsforms_numpy_single(self, params, input, expected): input = {"image": np.copy(input)} - # apply_prob=1.0 output = RandCuCIMd(keys="image", apply_prob=1.0, **params)(input)["image"] self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, np.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIMd(keys="image", apply_prob=0.0, **params)(input)["image"] self.assertTrue(output.dtype == input["image"].dtype) self.assertTrue(isinstance(output, np.ndarray)) @@ -117,12 +115,10 @@ def test_tramsforms_numpy_single(self, params, input, expected): def test_tramsforms_numpy_batch(self, params, input, expected): input = {"image": np.copy(input[cp.newaxis, ...])} expected = expected[cp.newaxis, ...] - # apply_prob=1.0 output = RandCuCIMd(keys="image", apply_prob=1.0, **params)(input)["image"] self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, np.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIMd(keys="image", apply_prob=0.0, **params)(input)["image"] self.assertTrue(output.dtype == input["image"].dtype) self.assertTrue(isinstance(output, np.ndarray)) @@ -143,12 +139,10 @@ def test_tramsforms_numpy_batch(self, params, input, expected): def test_tramsforms_cupy_single(self, params, input, expected): input = {"image": cp.asarray(input)} expected = cp.asarray(expected) - # apply_prob=1.0 output = RandCuCIMd(keys="image", apply_prob=1.0, **params)(input)["image"] self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, cp.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIMd(keys="image", apply_prob=0.0, **params)(input)["image"] self.assertTrue(output.dtype == input["image"].dtype) self.assertTrue(isinstance(output, cp.ndarray)) @@ -169,12 +163,10 @@ def test_tramsforms_cupy_single(self, params, input, expected): def test_tramsforms_cupy_batch(self, params, input, expected): input = {"image": cp.asarray(input)[cp.newaxis, ...]} expected = cp.asarray(expected)[cp.newaxis, ...] - # apply_prob=1.0 output = RandCuCIMd(keys="image", **params)(input)["image"] self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, cp.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIMd(keys="image", apply_prob=0.0, **params)(input)["image"] self.assertTrue(output.dtype == input["image"].dtype) self.assertTrue(isinstance(output, cp.ndarray)) diff --git a/tests/test_rand_cucim_transform.py b/tests/test_rand_cucim_transform.py index c11ca5cd31..30164e4170 100644 --- a/tests/test_rand_cucim_transform.py +++ b/tests/test_rand_cucim_transform.py @@ -90,13 +90,11 @@ class TestRandCuCIM(unittest.TestCase): ] ) def test_tramsforms_numpy_single(self, params, input, expected): - # apply_prob=1.0 input = np.copy(input) output = RandCuCIM(apply_prob=1.0, **params)(input) self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, np.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIM(apply_prob=0.0, **params)(input) self.assertTrue(output.dtype == input.dtype) self.assertTrue(isinstance(output, np.ndarray)) @@ -117,12 +115,10 @@ def test_tramsforms_numpy_single(self, params, input, expected): def test_tramsforms_numpy_batch(self, params, input, expected): input = np.copy(input[cp.newaxis, ...]) expected = expected[cp.newaxis, ...] - # apply_prob=1.0 output = RandCuCIM(apply_prob=1.0, **params)(input) self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, np.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIM(apply_prob=0.0, **params)(input) self.assertTrue(output.dtype == input.dtype) self.assertTrue(isinstance(output, np.ndarray)) @@ -143,12 +139,10 @@ def test_tramsforms_numpy_batch(self, params, input, expected): def test_tramsforms_cupy_single(self, params, input, expected): input = cp.asarray(input) expected = cp.asarray(expected) - # apply_prob=1.0 output = RandCuCIM(apply_prob=1.0, **params)(input) self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, cp.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIM(apply_prob=0.0, **params)(input) self.assertTrue(output.dtype == input.dtype) self.assertTrue(isinstance(output, cp.ndarray)) @@ -169,12 +163,10 @@ def test_tramsforms_cupy_single(self, params, input, expected): def test_tramsforms_cupy_batch(self, params, input, expected): input = cp.asarray(input)[cp.newaxis, ...] expected = cp.asarray(expected)[cp.newaxis, ...] - # apply_prob=1.0 output = RandCuCIM(**params)(input) self.assertTrue(output.dtype == expected.dtype) self.assertTrue(isinstance(output, cp.ndarray)) cp.testing.assert_allclose(output, expected) - # apply_prob=0.0 output = RandCuCIM(apply_prob=0.0, **params)(input) self.assertTrue(output.dtype == input.dtype) self.assertTrue(isinstance(output, cp.ndarray)) From be3d22f76891ee475595d86e325ce6dc6fb1f558 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Wed, 27 Apr 2022 18:51:01 +0000 Subject: [PATCH 043/183] Remove unused global variable (#4189) * Remove unused global variable --- tests/test_rand_affine_grid.py | 2 +- tests/utils.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index 60ac40f468..722bafb0e5 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -18,7 +18,7 @@ from monai.transforms import RandAffineGrid from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env -_rtol = 1e-1 if is_tf32_env else 1e-4 +_rtol = 1e-1 if is_tf32_env() else 1e-4 TESTS = [] for p in TEST_NDARRAYS: diff --git a/tests/utils.py b/tests/utils.py index c4f9bd1b70..1a547fc2d2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -586,13 +586,11 @@ def _wrapper(*args, **kwargs): def _cache_original_func(obj) -> None: """cache the original function by name, so that the decorator doesn't shadow it.""" - global _original_funcs _original_funcs[obj.__name__] = obj def _del_original_func(obj): """pop the original function from cache.""" - global _original_funcs _original_funcs.pop(obj.__name__, None) if torch.cuda.is_available(): # clean up the cached function torch.cuda.synchronize() From df177fd3d992f40d549c198c8a08addf7bd686c6 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 27 Apr 2022 23:33:09 +0100 Subject: [PATCH 044/183] add adn unet (#4185) Signed-off-by: Wenqi Li Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/networks/blocks/acti_norm.py | 4 ++-- monai/networks/nets/unet.py | 8 ++++++++ tests/test_unet.py | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/monai/networks/blocks/acti_norm.py b/monai/networks/blocks/acti_norm.py index 65b662ac32..6aeaa7d275 100644 --- a/monai/networks/blocks/acti_norm.py +++ b/monai/networks/blocks/acti_norm.py @@ -18,8 +18,8 @@ class ADN(nn.Sequential): """ - Constructs a sequential module of optional activation, dropout, and normalization layers - (with an arbitrary order):: + Constructs a sequential module of optional activation (A), dropout (D), and normalization (N) layers + with an arbitrary order:: -- (Norm) -- (Dropout) -- (Acti) -- diff --git a/monai/networks/nets/unet.py b/monai/networks/nets/unet.py index 25ce61ab3a..faccddee45 100644 --- a/monai/networks/nets/unet.py +++ b/monai/networks/nets/unet.py @@ -68,6 +68,8 @@ class UNet(nn.Module): bias: whether to have a bias term in convolution blocks. Defaults to True. According to `Performance Tuning Guide `_, if a conv layer is directly followed by a batch norm layer, bias should be False. + adn_ordering: a string representing the ordering of activation (A), normalization (N), and dropout (D). + Defaults to "NDA". See also: :py:class:`monai.networks.blocks.ADN`. Examples:: @@ -122,6 +124,7 @@ def __init__( norm: Union[Tuple, str] = Norm.INSTANCE, dropout: float = 0.0, bias: bool = True, + adn_ordering: str = "NDA", dimensions: Optional[int] = None, ) -> None: @@ -155,6 +158,7 @@ def __init__( self.norm = norm self.dropout = dropout self.bias = bias + self.adn_ordering = adn_ordering def _create_block( inc: int, outc: int, channels: Sequence[int], strides: Sequence[int], is_top: bool @@ -229,6 +233,7 @@ def _get_down_layer(self, in_channels: int, out_channels: int, strides: int, is_ norm=self.norm, dropout=self.dropout, bias=self.bias, + adn_ordering=self.adn_ordering, ) return mod mod = Convolution( @@ -241,6 +246,7 @@ def _get_down_layer(self, in_channels: int, out_channels: int, strides: int, is_ norm=self.norm, dropout=self.dropout, bias=self.bias, + adn_ordering=self.adn_ordering, ) return mod @@ -279,6 +285,7 @@ def _get_up_layer(self, in_channels: int, out_channels: int, strides: int, is_to bias=self.bias, conv_only=is_top and self.num_res_units == 0, is_transposed=True, + adn_ordering=self.adn_ordering, ) if self.num_res_units > 0: @@ -294,6 +301,7 @@ def _get_up_layer(self, in_channels: int, out_channels: int, strides: int, is_to dropout=self.dropout, bias=self.bias, last_conv_only=is_top, + adn_ordering=self.adn_ordering, ) conv = nn.Sequential(conv, ru) diff --git a/tests/test_unet.py b/tests/test_unet.py index 5f126fed97..a90e32230b 100644 --- a/tests/test_unet.py +++ b/tests/test_unet.py @@ -96,6 +96,7 @@ "strides": (2, 2), "num_res_units": 1, "act": (Act.LEAKYRELU, {"negative_slope": 0.2}), + "adn_ordering": "NA", }, (16, 4, 32, 64, 48), (16, 3, 32, 64, 48), From 05b9d3110ca68d6550e7ecf4553b4adeae997305 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 28 Apr 2022 19:17:39 +0800 Subject: [PATCH 045/183] [DLMED] enhance load_files (#4192) Signed-off-by: Nic Ma --- monai/bundle/config_parser.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index 4f919a383a..770896ebef 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -338,9 +338,12 @@ def load_config_file(cls, filepath: PathLike, **kwargs): raise ValueError(f"only support JSON or YAML config file so far, got name {_filepath}.") @classmethod - def load_config_files(cls, files: Union[PathLike, Sequence[PathLike], dict], **kwargs) -> dict: + def load_config_files(cls, files: Union[PathLike, Sequence[PathLike], dict], **kwargs) -> Dict: """ Load config files into a single config dict. + The latter config file in the list will override or add the former config file. + ``"#"`` in the config keys are interpreted as special characters to go one level + further into the nested structures. Args: files: path of target files to load, supported postfixes: `.json`, `.yml`, `.yaml`. @@ -348,10 +351,11 @@ def load_config_files(cls, files: Union[PathLike, Sequence[PathLike], dict], **k """ if isinstance(files, dict): # already a config dict return files - content = {} + parser = ConfigParser(config={}) for i in ensure_tuple(files): - content.update(cls.load_config_file(i, **kwargs)) - return content + for k, v in (cls.load_config_file(i, **kwargs)).items(): + parser[k] = v + return parser.get() # type: ignore @classmethod def export_config_file(cls, config: Dict, filepath: PathLike, fmt="json", **kwargs): From 9af6332cf954b75d62633ee28ad0f63f48e64f62 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Sat, 30 Apr 2022 02:52:33 +0800 Subject: [PATCH 046/183] enhance mean dice (#4163) Signed-off-by: Yiheng Wang --- monai/metrics/meandice.py | 20 +++++++++++++++++--- tests/test_compute_meandice.py | 12 +++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index aabb3b42a0..b64c556d05 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -40,6 +40,9 @@ class DiceMetric(CumulativeIterationMetric): ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction. get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns (metric, not_nans). Here `not_nans` count the number of not nans for the metric, thus its shape equals to the shape of the metric. + ignore_empty: whether to ignore empty ground truth cases during calculation. + If `True`, NaN value will be set for empty ground truth cases. + If `False`, 1 will be set if the predictions of empty ground truth cases are also empty. """ @@ -48,11 +51,13 @@ def __init__( include_background: bool = True, reduction: Union[MetricReduction, str] = MetricReduction.MEAN, get_not_nans: bool = False, + ignore_empty: bool = True, ) -> None: super().__init__() self.include_background = include_background self.reduction = reduction self.get_not_nans = get_not_nans + self.ignore_empty = ignore_empty def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignore """ @@ -77,7 +82,9 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor if dims < 3: raise ValueError("y_pred should have at least three dimensions.") # compute dice (BxC) for each channel for each batch - return compute_meandice(y_pred=y_pred, y=y, include_background=self.include_background) + return compute_meandice( + y_pred=y_pred, y=y, include_background=self.include_background, ignore_empty=self.ignore_empty + ) def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore """ @@ -98,7 +105,9 @@ def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # typ return (f, not_nans) if self.get_not_nans else f -def compute_meandice(y_pred: torch.Tensor, y: torch.Tensor, include_background: bool = True) -> torch.Tensor: +def compute_meandice( + y_pred: torch.Tensor, y: torch.Tensor, include_background: bool = True, ignore_empty: bool = True +) -> torch.Tensor: """Computes Dice score metric from full size Tensor and collects average. Args: @@ -109,6 +118,9 @@ def compute_meandice(y_pred: torch.Tensor, y: torch.Tensor, include_background: The values should be binarized. include_background: whether to skip Dice computation on the first channel of the predicted output. Defaults to True. + ignore_empty: whether to ignore empty ground truth cases during calculation. + If `True`, NaN value will be set for empty ground truth cases. + If `False`, 1 will be set if the predictions of empty ground truth cases are also empty. Returns: Dice scores per batch and per class, (shape [batch_size, num_classes]). @@ -136,4 +148,6 @@ def compute_meandice(y_pred: torch.Tensor, y: torch.Tensor, include_background: y_pred_o = torch.sum(y_pred, dim=reduce_axis) denominator = y_o + y_pred_o - return torch.where(y_o > 0, (2.0 * intersection) / denominator, torch.tensor(float("nan"), device=y_o.device)) + if ignore_empty is True: + return torch.where(y_o > 0, (2.0 * intersection) / denominator, torch.tensor(float("nan"), device=y_o.device)) + return torch.where(denominator > 0, (2.0 * intersection) / denominator, torch.tensor(1.0, device=y_o.device)) diff --git a/tests/test_compute_meandice.py b/tests/test_compute_meandice.py index c4daf7c5a9..c925c6f148 100644 --- a/tests/test_compute_meandice.py +++ b/tests/test_compute_meandice.py @@ -172,9 +172,19 @@ [[1.0000, 1.0000], [1.0000, 1.0000]], ] +TEST_CASE_11 = [ + {"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.zeros((2, 2, 3, 3)), "ignore_empty": False}, + [[1.0000, 1.0000], [1.0000, 1.0000]], +] + +TEST_CASE_12 = [ + {"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3)), "ignore_empty": False}, + [[0.0000, 0.0000], [0.0000, 0.0000]], +] + class TestComputeMeanDice(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_9]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_9, TEST_CASE_11, TEST_CASE_12]) def test_value(self, input_data, expected_value): result = compute_meandice(**input_data) np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) From 13314320c4c85e2f3da50a71e3d8ec54befe7cd7 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 29 Apr 2022 16:29:31 -0400 Subject: [PATCH 047/183] Make transforms optional (#4190) * Make all transforms optional Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/dataset.py | 8 ++++---- tests/test_cachedataset.py | 6 ++++++ tests/test_smartcachedataset.py | 11 +++++++++++ tests/test_wsireader.py | 28 +++++++++------------------- tests/test_wsireader_new.py | 16 ++++++++++------ 5 files changed, 40 insertions(+), 29 deletions(-) diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 3c1fc0abed..29342742cb 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -669,7 +669,7 @@ class CacheDataset(Dataset): def __init__( self, data: Sequence, - transform: Union[Sequence[Callable], Callable], + transform: Optional[Union[Sequence[Callable], Callable]] = None, cache_num: int = sys.maxsize, cache_rate: float = 1.0, num_workers: Optional[int] = 1, @@ -856,7 +856,7 @@ class SmartCacheDataset(Randomizable, CacheDataset): Args: data: input data to load and transform to generate dataset for model. transform: transforms to execute operations on input data. - replace_rate: percentage of the cached items to be replaced in every epoch. + replace_rate: percentage of the cached items to be replaced in every epoch (default to 0.1). cache_num: number of items to be cached. Default is `sys.maxsize`. will take the minimum of (cache_num, data_length x cache_rate, data_length). cache_rate: percentage of cached data in total, default is 1.0 (cache all). @@ -883,8 +883,8 @@ class SmartCacheDataset(Randomizable, CacheDataset): def __init__( self, data: Sequence, - transform: Union[Sequence[Callable], Callable], - replace_rate: float, + transform: Optional[Union[Sequence[Callable], Callable]] = None, + replace_rate: float = 0.1, cache_num: int = sys.maxsize, cache_rate: float = 1.0, num_init_workers: Optional[int] = 1, diff --git a/tests/test_cachedataset.py b/tests/test_cachedataset.py index 4b77d4a55a..4fa1b5ea69 100644 --- a/tests/test_cachedataset.py +++ b/tests/test_cachedataset.py @@ -55,6 +55,12 @@ def test_shape(self, transform, expected_shape): data4 = dataset[-1] self.assertEqual(len(data3), 1) + if transform is None: + # Check without providing transfrom + dataset2 = CacheDataset(data=test_data, cache_rate=0.5, as_contiguous=True) + for k in ["image", "label", "extra"]: + self.assertEqual(dataset[0][k], dataset2[0][k]) + if transform is None: self.assertEqual(data1["image"], os.path.join(tempdir, "image1.nii.gz")) self.assertEqual(data2["label"], os.path.join(tempdir, "label2.nii.gz")) diff --git a/tests/test_smartcachedataset.py b/tests/test_smartcachedataset.py index e7d51be63a..6eca6113f0 100644 --- a/tests/test_smartcachedataset.py +++ b/tests/test_smartcachedataset.py @@ -56,6 +56,17 @@ def test_shape(self, replace_rate, num_replace_workers, transform): num_init_workers=4, num_replace_workers=num_replace_workers, ) + if transform is None: + # Check without providing transfrom + dataset2 = SmartCacheDataset( + data=test_data, + replace_rate=replace_rate, + cache_num=16, + num_init_workers=4, + num_replace_workers=num_replace_workers, + ) + for k in ["image", "label", "extra"]: + self.assertEqual(dataset[0][k], dataset2[0][k]) self.assertEqual(len(dataset._cache), dataset.cache_num) for i in range(dataset.cache_num): diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 3655100dab..5d092c4ce5 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -84,7 +84,9 @@ TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW -TEST_CASE_ERROR_GRAY = [np.ones((16, 16, 2), dtype=np.uint8)] # wrong color channel +TEST_CASE_ERROR_0C = [np.ones((16, 16), dtype=np.uint8)] # no color channel +TEST_CASE_ERROR_1C = [np.ones((16, 16, 1), dtype=np.uint8)] # one color channel +TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color @@ -106,20 +108,6 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): return filename -def save_gray_tiff(array: np.ndarray, filename: str): - """ - Save numpy array into a TIFF file - - Args: - array: numpy ndarray with any shape - filename: the filename to be used for the tiff file. - """ - img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape, photometric="minisblack") - - return filename - - @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") def setUpModule(): # noqa: N802 hash_type = testing_data_config("images", FILE_KEY, "hash_type") @@ -187,13 +175,15 @@ def test_read_rgba(self, img_expected): self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) - @parameterized.expand([TEST_CASE_ERROR_GRAY, TEST_CASE_ERROR_3D]) + @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D]) @skipUnless(has_tiff, "Requires tifffile.") def test_read_malformats(self, img_expected): + if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1): + # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230 + return reader = WSIReader(self.backend) - file_path = save_gray_tiff( - img_expected, os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") - ) + file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") + imwrite(file_path, img_expected, shape=img_expected.shape) with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): with reader.read(file_path) as img_obj: reader.get_data(img_obj) diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 63d61dfeb3..2ac4125f97 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -72,7 +72,9 @@ TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW -TEST_CASE_ERROR_GRAY = [np.ones((16, 16, 2), dtype=np.uint8)] # wrong color channel +TEST_CASE_ERROR_0C = [np.ones((16, 16), dtype=np.uint8)] # no color channel +TEST_CASE_ERROR_1C = [np.ones((16, 16, 1), dtype=np.uint8)] # one color channel +TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color @@ -103,7 +105,7 @@ def save_gray_tiff(array: np.ndarray, filename: str): filename: the filename to be used for the tiff file. """ img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape, photometric="minisblack") + imwrite(filename, img_gray, shape=img_gray.shape) return filename @@ -180,13 +182,15 @@ def test_read_rgba(self, img_expected): self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) - @parameterized.expand([TEST_CASE_ERROR_GRAY, TEST_CASE_ERROR_3D]) + @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D]) @skipUnless(has_tiff, "Requires tifffile.") def test_read_malformats(self, img_expected): + if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1): + # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230 + return reader = WSIReader(self.backend) - file_path = save_gray_tiff( - img_expected, os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") - ) + file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") + imwrite(file_path, img_expected, shape=img_expected.shape) with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): with reader.read(file_path) as img_obj: reader.get_data(img_obj) From 0db84a0f3f052c6c570cd513c959765968821d43 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 29 Apr 2022 22:35:15 +0100 Subject: [PATCH 048/183] 4200 workaround for ci apt (#4202) * temp tests Signed-off-by: Wenqi Li * update Signed-off-by: Wenqi Li * workaround Signed-off-by: Wenqi Li * fixes typo Signed-off-by: Wenqi Li --- .github/workflows/pythonapp-gpu.yml | 7 +++++++ tests/test_delete_itemsd.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 90b31e99ab..50bbe13062 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -63,6 +63,13 @@ jobs: - uses: actions/checkout@v2 - name: apt install run: | + # workaround for https://github.com/Project-MONAI/MONAI/issues/4200 + apt-key del 7fa2af80 && rm -rf /etc/apt/sources.list.d/nvidia-ml.list /etc/apt/sources.list.d/cuda.list + apt-get update + apt-get install -y wget + wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/cuda-keyring_1.0-1_all.deb + dpkg -i cuda-keyring_1.0-1_all.deb + if [ ${{ matrix.environment }} = "PT17+CUDA102" ] || \ [ ${{ matrix.environment }} = "PT18+CUDA102" ] || \ [ ${{ matrix.environment }} = "PT110+CUDA102" ] || \ diff --git a/tests/test_delete_itemsd.py b/tests/test_delete_itemsd.py index 450abb40db..99d05fe787 100644 --- a/tests/test_delete_itemsd.py +++ b/tests/test_delete_itemsd.py @@ -48,7 +48,7 @@ def test_re(self, input_param): input_data = {"image": [1, 2, 3], PostFix.meta(): {"0008|0005": 1, "0008|1050": 2, "0008test": 3}} result = DeleteItemsd(**input_param)(input_data) self.assertEqual(result[PostFix.meta()]["0008test"], 3) - self.assertTrue(len(result[PostFix.meta()]), 1) + self.assertEqual(len(result[PostFix.meta()]), 1) if __name__ == "__main__": From d4a924cb7098c6027cd7cd44f29d377bda40d0c1 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 30 Apr 2022 18:20:01 +0800 Subject: [PATCH 049/183] Update the default value of `lazy` arg in the ConfigParser to `True` (#4203) --- monai/bundle/config_parser.py | 6 +++--- tests/test_config_parser.py | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index 770896ebef..448b5ecf20 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -211,7 +211,7 @@ def get_parsed_content(self, id: str = "", **kwargs): Use digits indexing from "0" for list or other strings for dict. For example: ``"xform#5"``, ``"net#channels"``. ``""`` indicates the entire ``self.config``. kwargs: additional keyword arguments to be passed to ``_resolve_one_item``. - Currently support ``lazy`` (whether to retain the current config cache, default to `False`), + Currently support ``lazy`` (whether to retain the current config cache, default to `True`), ``instantiate`` (whether to instantiate the `ConfigComponent`, default to `True`) and ``eval_expr`` (whether to evaluate the `ConfigExpression`, default to `True`). @@ -219,8 +219,8 @@ def get_parsed_content(self, id: str = "", **kwargs): if not self.ref_resolver.is_resolved(): # not parsed the config source yet, parse it self.parse(reset=True) - elif not kwargs.get("lazy", False): - self.parse(reset=not kwargs.get("lazy", False)) + elif not kwargs.get("lazy", True): + self.parse(reset=not kwargs.get("lazy", True)) return self.ref_resolver.get_resolved_content(id=id, **kwargs) def read_meta(self, f: Union[PathLike, Sequence[PathLike], Dict], **kwargs): diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index 047b7c59bd..da230e7794 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -114,7 +114,12 @@ def test_parse(self, config, expected_ids, output_types): parser = ConfigParser(config=config, globals={"monai": "monai"}) # test lazy instantiation with original config content parser["transform"]["transforms"][0]["keys"] = "label1" - self.assertEqual(parser.get_parsed_content(id="transform#transforms#0").keys[0], "label1") + trans = parser.get_parsed_content(id="transform#transforms#0") + self.assertEqual(trans.keys[0], "label1") + # test re-use the parsed content or not with the `lazy` option + self.assertEqual(trans, parser.get_parsed_content(id="transform#transforms#0")) + self.assertEqual(trans, parser.get_parsed_content(id="transform#transforms#0", lazy=True)) + self.assertNotEqual(trans, parser.get_parsed_content(id="transform#transforms#0", lazy=False)) # test nested id parser["transform#transforms#0#keys"] = "label2" self.assertEqual(parser.get_parsed_content(id="transform#transforms#0").keys[0], "label2") From 30ad90d457127edf4eec88b813d592aeafdbba70 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 2 May 2022 11:26:12 +0100 Subject: [PATCH 050/183] udpate md5 (#4213) Signed-off-by: Wenqi Li --- tests/testing_data/data_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json index e337a2d2e7..5b2c6ac23a 100644 --- a/tests/testing_data/data_config.json +++ b/tests/testing_data/data_config.json @@ -72,7 +72,7 @@ "test_meta_file": { "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/meta_schema_20220324.json", "hash_type": "md5", - "hash_val": "e12813de2c15672a8c8fa8466b3dfc95" + "hash_val": "662135097106b71067cd1fc657f8720f" } } } From e093d0b1572f8b5b49265f856955cb63fcd39bad Mon Sep 17 00:00:00 2001 From: Ali Hatamizadeh Date: Tue, 3 May 2022 16:27:38 -0700 Subject: [PATCH 051/183] add swin_unetr model (#4074) * add swin_unetr model --- monai/networks/blocks/__init__.py | 2 +- monai/networks/blocks/patchembedding.py | 109 ++- monai/networks/layers/__init__.py | 2 + monai/networks/layers/drop_path.py | 45 ++ monai/networks/layers/weight_init.py | 64 ++ monai/networks/nets/__init__.py | 1 + monai/networks/nets/swin_unetr.py | 982 ++++++++++++++++++++++++ tests/test_drop_path.py | 43 ++ tests/test_patchembedding.py | 37 +- tests/test_swin_unetr.py | 89 +++ tests/test_weight_init.py | 47 ++ 11 files changed, 1397 insertions(+), 24 deletions(-) create mode 100644 monai/networks/layers/drop_path.py create mode 100644 monai/networks/layers/weight_init.py create mode 100644 monai/networks/nets/swin_unetr.py create mode 100644 tests/test_drop_path.py create mode 100644 tests/test_swin_unetr.py create mode 100644 tests/test_weight_init.py diff --git a/monai/networks/blocks/__init__.py b/monai/networks/blocks/__init__.py index 0fdc944760..b6328734b0 100644 --- a/monai/networks/blocks/__init__.py +++ b/monai/networks/blocks/__init__.py @@ -20,7 +20,7 @@ from .fcn import FCN, GCN, MCFCN, Refine from .localnet_block import LocalNetDownSampleBlock, LocalNetFeatureExtractorBlock, LocalNetUpSampleBlock from .mlp import MLPBlock -from .patchembedding import PatchEmbeddingBlock +from .patchembedding import PatchEmbed, PatchEmbeddingBlock from .regunet_block import RegistrationDownSampleBlock, RegistrationExtractionBlock, RegistrationResidualConvBlock from .segresnet_block import ResBlock from .selfattention import SABlock diff --git a/monai/networks/blocks/patchembedding.py b/monai/networks/blocks/patchembedding.py index 4c7263c6d5..f02f6342e8 100644 --- a/monai/networks/blocks/patchembedding.py +++ b/monai/networks/blocks/patchembedding.py @@ -9,15 +9,15 @@ # See the License for the specific language governing permissions and # limitations under the License. - -import math -from typing import Sequence, Union +from typing import Sequence, Type, Union import numpy as np import torch import torch.nn as nn +import torch.nn.functional as F +from torch.nn import LayerNorm -from monai.networks.layers import Conv +from monai.networks.layers import Conv, trunc_normal_ from monai.utils import ensure_tuple_rep, optional_import from monai.utils.module import look_up_option @@ -98,34 +98,18 @@ def __init__( ) self.position_embeddings = nn.Parameter(torch.zeros(1, self.n_patches, hidden_size)) self.dropout = nn.Dropout(dropout_rate) - self.trunc_normal_(self.position_embeddings, mean=0.0, std=0.02, a=-2.0, b=2.0) + trunc_normal_(self.position_embeddings, mean=0.0, std=0.02, a=-2.0, b=2.0) self.apply(self._init_weights) def _init_weights(self, m): if isinstance(m, nn.Linear): - self.trunc_normal_(m.weight, mean=0.0, std=0.02, a=-2.0, b=2.0) + trunc_normal_(m.weight, mean=0.0, std=0.02, a=-2.0, b=2.0) if isinstance(m, nn.Linear) and m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.LayerNorm): nn.init.constant_(m.bias, 0) nn.init.constant_(m.weight, 1.0) - def trunc_normal_(self, tensor, mean, std, a, b): - # From PyTorch official master until it's in a few official releases - RW - # Method based on https://people.sc.fsu.edu/~jburkardt/presentations/truncated_normal.pdf - def norm_cdf(x): - return (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0 - - with torch.no_grad(): - l = norm_cdf((a - mean) / std) - u = norm_cdf((b - mean) / std) - tensor.uniform_(2 * l - 1, 2 * u - 1) - tensor.erfinv_() - tensor.mul_(std * math.sqrt(2.0)) - tensor.add_(mean) - tensor.clamp_(min=a, max=b) - return tensor - def forward(self, x): x = self.patch_embeddings(x) if self.pos_embed == "conv": @@ -133,3 +117,84 @@ def forward(self, x): embeddings = x + self.position_embeddings embeddings = self.dropout(embeddings) return embeddings + + +class PatchEmbed(nn.Module): + """ + Patch embedding block based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + + Unlike ViT patch embedding block: (1) input is padded to satisfy window size requirements (2) normalized if + specified (3) position embedding is not used. + + Example:: + + >>> from monai.networks.blocks import PatchEmbed + >>> PatchEmbed(patch_size=2, in_chans=1, embed_dim=48, norm_layer=nn.LayerNorm, spatial_dims=3) + """ + + def __init__( + self, + patch_size: Union[Sequence[int], int] = 2, + in_chans: int = 1, + embed_dim: int = 48, + norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + spatial_dims: int = 3, + ) -> None: + """ + Args: + patch_size: dimension of patch size. + in_chans: dimension of input channels. + embed_dim: number of linear projection output channels. + norm_layer: normalization layer. + spatial_dims: spatial dimension. + """ + + super().__init__() + + if not (spatial_dims == 2 or spatial_dims == 3): + raise ValueError("spatial dimension should be 2 or 3.") + + patch_size = ensure_tuple_rep(patch_size, spatial_dims) + self.patch_size = patch_size + self.embed_dim = embed_dim + self.proj = Conv[Conv.CONV, spatial_dims]( + in_channels=in_chans, out_channels=embed_dim, kernel_size=patch_size, stride=patch_size + ) + if norm_layer is not None: + self.norm = norm_layer(embed_dim) + else: + self.norm = None + + def forward(self, x): + x_shape = x.size() + if len(x_shape) == 5: + _, _, d, h, w = x_shape + if w % self.patch_size[2] != 0: + x = F.pad(x, (0, self.patch_size[2] - w % self.patch_size[2])) + if h % self.patch_size[1] != 0: + x = F.pad(x, (0, 0, 0, self.patch_size[1] - h % self.patch_size[1])) + if d % self.patch_size[0] != 0: + x = F.pad(x, (0, 0, 0, 0, 0, self.patch_size[0] - d % self.patch_size[0])) + + elif len(x_shape) == 4: + _, _, h, w = x.size() + if w % self.patch_size[1] != 0: + x = F.pad(x, (0, self.patch_size[1] - w % self.patch_size[1])) + if h % self.patch_size[0] != 0: + x = F.pad(x, (0, 0, 0, self.patch_size[0] - h % self.patch_size[0])) + + x = self.proj(x) + if self.norm is not None: + x_shape = x.size() + x = x.flatten(2).transpose(1, 2) + x = self.norm(x) + if len(x_shape) == 5: + d, wh, ww = x_shape[2], x_shape[3], x_shape[4] + x = x.transpose(1, 2).view(-1, self.embed_dim, d, wh, ww) + elif len(x_shape) == 4: + wh, ww = x_shape[2], x_shape[3] + x = x.transpose(1, 2).view(-1, self.embed_dim, wh, ww) + return x diff --git a/monai/networks/layers/__init__.py b/monai/networks/layers/__init__.py index 5115c00af3..f122dccee6 100644 --- a/monai/networks/layers/__init__.py +++ b/monai/networks/layers/__init__.py @@ -10,6 +10,7 @@ # limitations under the License. from .convutils import calculate_out_shape, gaussian_1d, polyval, same_padding, stride_minus_kernel_padding +from .drop_path import DropPath from .factories import Act, Conv, Dropout, LayerFactory, Norm, Pad, Pool, split_args from .filtering import BilateralFilter, PHLFilter from .gmm import GaussianMixtureModel @@ -27,3 +28,4 @@ ) from .spatial_transforms import AffineTransform, grid_count, grid_grad, grid_pull, grid_push from .utils import get_act_layer, get_dropout_layer, get_norm_layer, get_pool_layer +from .weight_init import _no_grad_trunc_normal_, trunc_normal_ diff --git a/monai/networks/layers/drop_path.py b/monai/networks/layers/drop_path.py new file mode 100644 index 0000000000..7bb209ed25 --- /dev/null +++ b/monai/networks/layers/drop_path.py @@ -0,0 +1,45 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import torch.nn as nn + + +class DropPath(nn.Module): + """Stochastic drop paths per sample for residual blocks. + Based on: + https://github.com/rwightman/pytorch-image-models + """ + + def __init__(self, drop_prob: float = 0.0, scale_by_keep: bool = True) -> None: + """ + Args: + drop_prob: drop path probability. + scale_by_keep: scaling by non-dropped probability. + """ + super().__init__() + self.drop_prob = drop_prob + self.scale_by_keep = scale_by_keep + + if not (0 <= drop_prob <= 1): + raise ValueError("Drop path prob should be between 0 and 1.") + + def drop_path(self, x, drop_prob: float = 0.0, training: bool = False, scale_by_keep: bool = True): + if drop_prob == 0.0 or not training: + return x + keep_prob = 1 - drop_prob + shape = (x.shape[0],) + (1,) * (x.ndim - 1) + random_tensor = x.new_empty(shape).bernoulli_(keep_prob) + if keep_prob > 0.0 and scale_by_keep: + random_tensor.div_(keep_prob) + return x * random_tensor + + def forward(self, x): + return self.drop_path(x, self.drop_prob, self.training, self.scale_by_keep) diff --git a/monai/networks/layers/weight_init.py b/monai/networks/layers/weight_init.py new file mode 100644 index 0000000000..9b81ef17f8 --- /dev/null +++ b/monai/networks/layers/weight_init.py @@ -0,0 +1,64 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import torch + + +def _no_grad_trunc_normal_(tensor, mean, std, a, b): + """Tensor initialization with truncated normal distribution. + Based on: + https://people.sc.fsu.edu/~jburkardt/presentations/truncated_normal.pdf + https://github.com/rwightman/pytorch-image-models + + Args: + tensor: an n-dimensional `torch.Tensor`. + mean: the mean of the normal distribution. + std: the standard deviation of the normal distribution. + a: the minimum cutoff value. + b: the maximum cutoff value. + """ + + def norm_cdf(x): + return (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0 + + with torch.no_grad(): + l = norm_cdf((a - mean) / std) + u = norm_cdf((b - mean) / std) + tensor.uniform_(2 * l - 1, 2 * u - 1) + tensor.erfinv_() + tensor.mul_(std * math.sqrt(2.0)) + tensor.add_(mean) + tensor.clamp_(min=a, max=b) + return tensor + + +def trunc_normal_(tensor, mean=0.0, std=1.0, a=-2.0, b=2.0): + """Tensor initialization with truncated normal distribution. + Based on: + https://github.com/rwightman/pytorch-image-models + + Args: + tensor: an n-dimensional `torch.Tensor` + mean: the mean of the normal distribution + std: the standard deviation of the normal distribution + a: the minimum cutoff value + b: the maximum cutoff value + """ + + if not std > 0: + raise ValueError("the standard deviation should be greater than zero.") + + if a >= b: + raise ValueError("minimum cutoff value (a) should be smaller than maximum cutoff value (b).") + + return _no_grad_trunc_normal_(tensor, mean, std, a, b) diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index 16686fa25c..394ff51907 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -80,6 +80,7 @@ seresnext50, seresnext101, ) +from .swin_unetr import SwinUNETR from .torchvision_fc import TorchVisionFCModel, TorchVisionFullyConvModel from .transchex import BertAttention, BertMixedLayer, BertOutput, BertPreTrainedModel, MultiModal, Pooler, Transchex from .unet import UNet, Unet diff --git a/monai/networks/nets/swin_unetr.py b/monai/networks/nets/swin_unetr.py new file mode 100644 index 0000000000..d898da9884 --- /dev/null +++ b/monai/networks/nets/swin_unetr.py @@ -0,0 +1,982 @@ +# Copyright 2020 - 2022 -> (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 typing import Sequence, Tuple, Type, Union + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.utils.checkpoint as checkpoint +from torch.nn import LayerNorm + +from monai.networks.blocks import MLPBlock as Mlp +from monai.networks.blocks import PatchEmbed, UnetOutBlock, UnetrBasicBlock, UnetrUpBlock +from monai.networks.layers import DropPath, trunc_normal_ +from monai.utils import ensure_tuple_rep, optional_import + +rearrange, _ = optional_import("einops", name="rearrange") + + +class SwinUNETR(nn.Module): + """ + Swin UNETR based on: "Hatamizadeh et al., + Swin UNETR: Swin Transformers for Semantic Segmentation of Brain Tumors in MRI Images + " + """ + + def __init__( + self, + img_size: Union[Sequence[int], int], + in_channels: int, + out_channels: int, + depths: Sequence[int] = (2, 2, 2, 2), + num_heads: Sequence[int] = (3, 6, 12, 24), + feature_size: int = 48, + norm_name: Union[Tuple, str] = "instance", + drop_rate: float = 0.0, + attn_drop_rate: float = 0.0, + dropout_path_rate: float = 0.0, + normalize: bool = False, + use_checkpoint: bool = False, + spatial_dims: int = 3, + ) -> None: + """ + Args: + img_size: dimension of input image. + in_channels: dimension of input channels. + out_channels: dimension of output channels. + feature_size: dimension of network feature size. + depths: number of layers in each stage. + num_heads: number of attention heads. + norm_name: feature normalization type and arguments. + drop_rate: dropout rate. + attn_drop_rate: attention dropout rate. + dropout_path_rate: drop path rate. + normalize: normalize output intermediate features in each stage. + use_checkpoint: use gradient checkpointing for reduced memory usage. + spatial_dims: number of spatial dims. + + Examples:: + + # for 3D single channel input with size (96,96,96), 4-channel output and feature size of 48. + >>> net = SwinUNETR(img_size=(96,96,96), in_channels=1, out_channels=4, feature_size=48) + + # for 3D 4-channel input with size (128,128,128), 3-channel output and (2,4,2,2) layers in each stage. + >>> net = SwinUNETR(img_size=(128,128,128), in_channels=4, out_channels=3, depths=(2,4,2,2)) + + # for 2D single channel input with size (96,96), 2-channel output and gradient checkpointing. + >>> net = SwinUNETR(img_size=(96,96), in_channels=3, out_channels=2, use_checkpoint=True, spatial_dims=2) + + """ + + super().__init__() + + img_size = ensure_tuple_rep(img_size, spatial_dims) + patch_size = ensure_tuple_rep(2, spatial_dims) + window_size = ensure_tuple_rep(7, spatial_dims) + + if not (spatial_dims == 2 or spatial_dims == 3): + raise ValueError("spatial dimension should be 2 or 3.") + + for m, p in zip(img_size, patch_size): + for i in range(5): + if m % np.power(p, i + 1) != 0: + raise ValueError("input image size (img_size) should be divisible by stage-wise image resolution.") + + if not (0 <= drop_rate <= 1): + raise ValueError("dropout rate should be between 0 and 1.") + + if not (0 <= attn_drop_rate <= 1): + raise ValueError("attention dropout rate should be between 0 and 1.") + + if not (0 <= dropout_path_rate <= 1): + raise ValueError("drop path rate should be between 0 and 1.") + + if feature_size % 12 != 0: + raise ValueError("feature_size should be divisible by 12.") + + self.normalize = normalize + + self.swinViT = SwinTransformer( + in_chans=in_channels, + embed_dim=feature_size, + window_size=window_size, + patch_size=patch_size, + depths=depths, + num_heads=num_heads, + mlp_ratio=4.0, + qkv_bias=True, + drop_rate=drop_rate, + attn_drop_rate=attn_drop_rate, + drop_path_rate=dropout_path_rate, + norm_layer=nn.LayerNorm, + use_checkpoint=use_checkpoint, + spatial_dims=spatial_dims, + ) + + self.encoder1 = UnetrBasicBlock( + spatial_dims=spatial_dims, + in_channels=in_channels, + out_channels=feature_size, + kernel_size=3, + stride=1, + norm_name=norm_name, + res_block=True, + ) + + self.encoder2 = UnetrBasicBlock( + spatial_dims=spatial_dims, + in_channels=feature_size, + out_channels=feature_size, + kernel_size=3, + stride=1, + norm_name=norm_name, + res_block=True, + ) + + self.encoder3 = UnetrBasicBlock( + spatial_dims=spatial_dims, + in_channels=2 * feature_size, + out_channels=2 * feature_size, + kernel_size=3, + stride=1, + norm_name=norm_name, + res_block=True, + ) + + self.encoder4 = UnetrBasicBlock( + spatial_dims=spatial_dims, + in_channels=4 * feature_size, + out_channels=4 * feature_size, + kernel_size=3, + stride=1, + norm_name=norm_name, + res_block=True, + ) + + self.encoder10 = UnetrBasicBlock( + spatial_dims=spatial_dims, + in_channels=16 * feature_size, + out_channels=16 * feature_size, + kernel_size=3, + stride=1, + norm_name=norm_name, + res_block=True, + ) + + self.decoder5 = UnetrUpBlock( + spatial_dims=spatial_dims, + in_channels=16 * feature_size, + out_channels=8 * feature_size, + kernel_size=3, + upsample_kernel_size=2, + norm_name=norm_name, + res_block=True, + ) + + self.decoder4 = UnetrUpBlock( + spatial_dims=spatial_dims, + in_channels=feature_size * 8, + out_channels=feature_size * 4, + kernel_size=3, + upsample_kernel_size=2, + norm_name=norm_name, + res_block=True, + ) + + self.decoder3 = UnetrUpBlock( + spatial_dims=spatial_dims, + in_channels=feature_size * 4, + out_channels=feature_size * 2, + kernel_size=3, + upsample_kernel_size=2, + norm_name=norm_name, + res_block=True, + ) + self.decoder2 = UnetrUpBlock( + spatial_dims=spatial_dims, + in_channels=feature_size * 2, + out_channels=feature_size, + kernel_size=3, + upsample_kernel_size=2, + norm_name=norm_name, + res_block=True, + ) + + self.decoder1 = UnetrUpBlock( + spatial_dims=spatial_dims, + in_channels=feature_size, + out_channels=feature_size, + kernel_size=3, + upsample_kernel_size=2, + norm_name=norm_name, + res_block=True, + ) + + self.out = UnetOutBlock( + spatial_dims=spatial_dims, in_channels=feature_size, out_channels=out_channels + ) # type: ignore + + def load_from(self, weights): + + with torch.no_grad(): + self.swinViT.patch_embed.proj.weight.copy_(weights["state_dict"]["module.patch_embed.proj.weight"]) + self.swinViT.patch_embed.proj.bias.copy_(weights["state_dict"]["module.patch_embed.proj.bias"]) + for bname, block in self.swinViT.layers1[0].blocks.named_children(): + block.load_from(weights, n_block=bname, layer="layers1") + self.swinViT.layers1[0].downsample.reduction.weight.copy_( + weights["state_dict"]["module.layers1.0.downsample.reduction.weight"] + ) + self.swinViT.layers1[0].downsample.norm.weight.copy_( + weights["state_dict"]["module.layers1.0.downsample.norm.weight"] + ) + self.swinViT.layers1[0].downsample.norm.bias.copy_( + weights["state_dict"]["module.layers1.0.downsample.norm.bias"] + ) + for bname, block in self.swinViT.layers2[0].blocks.named_children(): + block.load_from(weights, n_block=bname, layer="layers2") + self.swinViT.layers2[0].downsample.reduction.weight.copy_( + weights["state_dict"]["module.layers2.0.downsample.reduction.weight"] + ) + self.swinViT.layers2[0].downsample.norm.weight.copy_( + weights["state_dict"]["module.layers2.0.downsample.norm.weight"] + ) + self.swinViT.layers2[0].downsample.norm.bias.copy_( + weights["state_dict"]["module.layers2.0.downsample.norm.bias"] + ) + for bname, block in self.swinViT.layers3[0].blocks.named_children(): + block.load_from(weights, n_block=bname, layer="layers3") + self.swinViT.layers3[0].downsample.reduction.weight.copy_( + weights["state_dict"]["module.layers3.0.downsample.reduction.weight"] + ) + self.swinViT.layers3[0].downsample.norm.weight.copy_( + weights["state_dict"]["module.layers3.0.downsample.norm.weight"] + ) + self.swinViT.layers3[0].downsample.norm.bias.copy_( + weights["state_dict"]["module.layers3.0.downsample.norm.bias"] + ) + for bname, block in self.swinViT.layers4[0].blocks.named_children(): + block.load_from(weights, n_block=bname, layer="layers4") + self.swinViT.layers4[0].downsample.reduction.weight.copy_( + weights["state_dict"]["module.layers4.0.downsample.reduction.weight"] + ) + self.swinViT.layers4[0].downsample.norm.weight.copy_( + weights["state_dict"]["module.layers4.0.downsample.norm.weight"] + ) + self.swinViT.layers4[0].downsample.norm.bias.copy_( + weights["state_dict"]["module.layers4.0.downsample.norm.bias"] + ) + self.swinViT.norm.weight.copy_(weights["state_dict"]["module.norm.weight"]) + self.swinViT.norm.bias.copy_(weights["state_dict"]["module.norm.bias"]) + + def forward(self, x_in): + hidden_states_out = self.swinViT(x_in, self.normalize) + enc0 = self.encoder1(x_in) + enc1 = self.encoder2(hidden_states_out[0]) + enc2 = self.encoder3(hidden_states_out[1]) + enc3 = self.encoder4(hidden_states_out[2]) + dec4 = self.encoder10(hidden_states_out[4]) + dec3 = self.decoder5(dec4, hidden_states_out[3]) + dec2 = self.decoder4(dec3, enc3) + dec1 = self.decoder3(dec2, enc2) + dec0 = self.decoder2(dec1, enc1) + out = self.decoder1(dec0, enc0) + logits = self.out(out) + return logits + + +def window_partition(x, window_size): + """window partition operation based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + + Args: + x: input tensor. + window_size: local window size. + """ + x_shape = x.size() + if len(x_shape) == 5: + b, d, h, w, c = x_shape + x = x.view( + b, + d // window_size[0], + window_size[0], + h // window_size[1], + window_size[1], + w // window_size[2], + window_size[2], + c, + ) + windows = ( + x.permute(0, 1, 3, 5, 2, 4, 6, 7).contiguous().view(-1, window_size[0] * window_size[1] * window_size[2], c) + ) + elif len(x_shape) == 4: + b, h, w, c = x.shape + x = x.view(b, h // window_size[0], window_size[0], w // window_size[1], window_size[1], c) + windows = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(-1, window_size[0] * window_size[1], c) + return windows + + +def window_reverse(windows, window_size, dims): + """window reverse operation based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + + Args: + windows: windows tensor. + window_size: local window size. + dims: dimension values. + """ + if len(dims) == 4: + b, d, h, w = dims + x = windows.view( + b, + d // window_size[0], + h // window_size[1], + w // window_size[2], + window_size[0], + window_size[1], + window_size[2], + -1, + ) + x = x.permute(0, 1, 4, 2, 5, 3, 6, 7).contiguous().view(b, d, h, w, -1) + + elif len(dims) == 3: + b, h, w = dims + x = windows.view(b, h // window_size[0], w // window_size[0], window_size[0], window_size[1], -1) + x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(b, h, w, -1) + return x + + +def get_window_size(x_size, window_size, shift_size=None): + """Computing window size based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + + Args: + x_size: input size. + window_size: local window size. + shift_size: window shifting size. + """ + + use_window_size = list(window_size) + if shift_size is not None: + use_shift_size = list(shift_size) + for i in range(len(x_size)): + if x_size[i] <= window_size[i]: + use_window_size[i] = x_size[i] + if shift_size is not None: + use_shift_size[i] = 0 + + if shift_size is None: + return tuple(use_window_size) + else: + return tuple(use_window_size), tuple(use_shift_size) + + +class WindowAttention(nn.Module): + """ + Window based multi-head self attention module with relative position bias based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + """ + + def __init__( + self, + dim: int, + num_heads: int, + window_size: Sequence[int], + qkv_bias: bool = False, + attn_drop: float = 0.0, + proj_drop: float = 0.0, + ) -> None: + """ + Args: + dim: number of feature channels. + num_heads: number of attention heads. + window_size: local window size. + qkv_bias: add a learnable bias to query, key, value. + attn_drop: attention dropout rate. + proj_drop: dropout rate of output. + """ + + super().__init__() + self.dim = dim + self.window_size = window_size + self.num_heads = num_heads + head_dim = dim // num_heads + self.scale = head_dim**-0.5 + mesh_args = torch.meshgrid.__kwdefaults__ + + if len(self.window_size) == 3: + self.relative_position_bias_table = nn.Parameter( + torch.zeros( + (2 * self.window_size[0] - 1) * (2 * self.window_size[1] - 1) * (2 * self.window_size[2] - 1), + num_heads, + ) + ) + coords_d = torch.arange(self.window_size[0]) + coords_h = torch.arange(self.window_size[1]) + coords_w = torch.arange(self.window_size[2]) + if mesh_args is not None: + coords = torch.stack(torch.meshgrid(coords_d, coords_h, coords_w, indexing="ij")) + else: + coords = torch.stack(torch.meshgrid(coords_d, coords_h, coords_w)) + coords_flatten = torch.flatten(coords, 1) + relative_coords = coords_flatten[:, :, None] - coords_flatten[:, None, :] + relative_coords = relative_coords.permute(1, 2, 0).contiguous() + relative_coords[:, :, 0] += self.window_size[0] - 1 + relative_coords[:, :, 1] += self.window_size[1] - 1 + relative_coords[:, :, 2] += self.window_size[2] - 1 + relative_coords[:, :, 0] *= (2 * self.window_size[1] - 1) * (2 * self.window_size[2] - 1) + relative_coords[:, :, 1] *= 2 * self.window_size[2] - 1 + elif len(self.window_size) == 2: + self.relative_position_bias_table = nn.Parameter( + torch.zeros((2 * window_size[0] - 1) * (2 * window_size[1] - 1), num_heads) + ) + coords_h = torch.arange(self.window_size[0]) + coords_w = torch.arange(self.window_size[1]) + if mesh_args is not None: + coords = torch.stack(torch.meshgrid(coords_h, coords_w, indexing="ij")) + else: + coords = torch.stack(torch.meshgrid(coords_h, coords_w)) + coords_flatten = torch.flatten(coords, 1) + relative_coords = coords_flatten[:, :, None] - coords_flatten[:, None, :] + relative_coords = relative_coords.permute(1, 2, 0).contiguous() + relative_coords[:, :, 0] += self.window_size[0] - 1 + relative_coords[:, :, 1] += self.window_size[1] - 1 + relative_coords[:, :, 0] *= 2 * self.window_size[1] - 1 + + relative_position_index = relative_coords.sum(-1) + self.register_buffer("relative_position_index", relative_position_index) + self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) + self.attn_drop = nn.Dropout(attn_drop) + self.proj = nn.Linear(dim, dim) + self.proj_drop = nn.Dropout(proj_drop) + trunc_normal_(self.relative_position_bias_table, std=0.02) + self.softmax = nn.Softmax(dim=-1) + + def forward(self, x, mask): + b, n, c = x.shape + qkv = self.qkv(x).reshape(b, n, 3, self.num_heads, c // self.num_heads).permute(2, 0, 3, 1, 4) + q, k, v = qkv[0], qkv[1], qkv[2] + q = q * self.scale + attn = q @ k.transpose(-2, -1) + relative_position_bias = self.relative_position_bias_table[ + self.relative_position_index[:n, :n].reshape(-1) + ].reshape(n, n, -1) + relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous() + attn = attn + relative_position_bias.unsqueeze(0) + if mask is not None: + nw = mask.shape[0] + attn = attn.view(b // nw, nw, self.num_heads, n, n) + mask.unsqueeze(1).unsqueeze(0) + attn = attn.view(-1, self.num_heads, n, n) + attn = self.softmax(attn) + else: + attn = self.softmax(attn) + + attn = self.attn_drop(attn) + x = (attn @ v).transpose(1, 2).reshape(b, n, c) + x = self.proj(x) + x = self.proj_drop(x) + return x + + +class SwinTransformerBlock(nn.Module): + """ + Swin Transformer block based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + """ + + def __init__( + self, + dim: int, + num_heads: int, + window_size: Sequence[int], + shift_size: Sequence[int], + mlp_ratio: float = 4.0, + qkv_bias: bool = True, + drop: float = 0.0, + attn_drop: float = 0.0, + drop_path: float = 0.0, + act_layer: str = "GELU", + norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + use_checkpoint: bool = False, + ) -> None: + """ + Args: + dim: number of feature channels. + num_heads: number of attention heads. + window_size: local window size. + shift_size: window shift size. + mlp_ratio: ratio of mlp hidden dim to embedding dim. + qkv_bias: add a learnable bias to query, key, value. + drop: dropout rate. + attn_drop: attention dropout rate. + drop_path: stochastic depth rate. + act_layer: activation layer. + norm_layer: normalization layer. + use_checkpoint: use gradient checkpointing for reduced memory usage. + """ + + super().__init__() + self.dim = dim + self.num_heads = num_heads + self.window_size = window_size + self.shift_size = shift_size + self.mlp_ratio = mlp_ratio + self.use_checkpoint = use_checkpoint + self.norm1 = norm_layer(dim) + self.attn = WindowAttention( + dim, + window_size=self.window_size, + num_heads=num_heads, + qkv_bias=qkv_bias, + attn_drop=attn_drop, + proj_drop=drop, + ) + + self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity() + self.norm2 = norm_layer(dim) + mlp_hidden_dim = int(dim * mlp_ratio) + self.mlp = Mlp(hidden_size=dim, mlp_dim=mlp_hidden_dim, act=act_layer, dropout_rate=drop, dropout_mode="swin") + + def forward_part1(self, x, mask_matrix): + x_shape = x.size() + x = self.norm1(x) + if len(x_shape) == 5: + b, d, h, w, c = x.shape + window_size, shift_size = get_window_size((d, h, w), self.window_size, self.shift_size) + pad_l = pad_t = pad_d0 = 0 + pad_d1 = (window_size[0] - d % window_size[0]) % window_size[0] + pad_b = (window_size[1] - h % window_size[1]) % window_size[1] + pad_r = (window_size[2] - w % window_size[2]) % window_size[2] + x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b, pad_d0, pad_d1)) + _, dp, hp, wp, _ = x.shape + dims = [b, dp, hp, wp] + + elif len(x_shape) == 4: + b, h, w, c = x.shape + window_size, shift_size = get_window_size((h, w), self.window_size, self.shift_size) + pad_l = pad_t = 0 + pad_r = (window_size[0] - h % window_size[0]) % window_size[0] + pad_b = (window_size[1] - w % window_size[1]) % window_size[1] + x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b)) + _, hp, wp, _ = x.shape + dims = [b, hp, wp] + + if any(i > 0 for i in shift_size): + if len(x_shape) == 5: + shifted_x = torch.roll(x, shifts=(-shift_size[0], -shift_size[1], -shift_size[2]), dims=(1, 2, 3)) + elif len(x_shape) == 4: + shifted_x = torch.roll(x, shifts=(-shift_size[0], -shift_size[1]), dims=(1, 2)) + attn_mask = mask_matrix + else: + shifted_x = x + attn_mask = None + x_windows = window_partition(shifted_x, window_size) + attn_windows = self.attn(x_windows, mask=attn_mask) + attn_windows = attn_windows.view(-1, *(window_size + (c,))) + shifted_x = window_reverse(attn_windows, window_size, dims) + if any(i > 0 for i in shift_size): + if len(x_shape) == 5: + x = torch.roll(shifted_x, shifts=(shift_size[0], shift_size[1], shift_size[2]), dims=(1, 2, 3)) + elif len(x_shape) == 4: + x = torch.roll(shifted_x, shifts=(shift_size[0], shift_size[1]), dims=(1, 2)) + else: + x = shifted_x + + if len(x_shape) == 5: + if pad_d1 > 0 or pad_r > 0 or pad_b > 0: + x = x[:, :d, :h, :w, :].contiguous() + elif len(x_shape) == 4: + if pad_r > 0 or pad_b > 0: + x = x[:, :h, :w, :].contiguous() + + return x + + def forward_part2(self, x): + return self.drop_path(self.mlp(self.norm2(x))) + + def load_from(self, weights, n_block, layer): + root = f"module.{layer}.0.blocks.{n_block}." + block_names = [ + "norm1.weight", + "norm1.bias", + "attn.relative_position_bias_table", + "attn.relative_position_index", + "attn.qkv.weight", + "attn.qkv.bias", + "attn.proj.weight", + "attn.proj.bias", + "norm2.weight", + "norm2.bias", + "mlp.linear1.weight", + "mlp.linear1.bias", + "mlp.linear2.weight", + "mlp.linear2.bias", + ] + with torch.no_grad(): + self.norm1.weight.copy_(weights["state_dict"][root + block_names[0]]) + self.norm1.bias.copy_(weights["state_dict"][root + block_names[1]]) + self.attn.relative_position_bias_table.copy_(weights["state_dict"][root + block_names[2]]) + self.attn.relative_position_index.copy_(weights["state_dict"][root + block_names[3]]) + self.attn.qkv.weight.copy_(weights["state_dict"][root + block_names[4]]) + self.attn.qkv.bias.copy_(weights["state_dict"][root + block_names[5]]) + self.attn.proj.weight.copy_(weights["state_dict"][root + block_names[6]]) + self.attn.proj.bias.copy_(weights["state_dict"][root + block_names[7]]) + self.norm2.weight.copy_(weights["state_dict"][root + block_names[8]]) + self.norm2.bias.copy_(weights["state_dict"][root + block_names[9]]) + self.mlp.linear1.weight.copy_(weights["state_dict"][root + block_names[10]]) + self.mlp.linear1.bias.copy_(weights["state_dict"][root + block_names[11]]) + self.mlp.linear2.weight.copy_(weights["state_dict"][root + block_names[12]]) + self.mlp.linear2.bias.copy_(weights["state_dict"][root + block_names[13]]) + + def forward(self, x, mask_matrix): + shortcut = x + if self.use_checkpoint: + x = checkpoint.checkpoint(self.forward_part1, x, mask_matrix) + else: + x = self.forward_part1(x, mask_matrix) + x = shortcut + self.drop_path(x) + if self.use_checkpoint: + x = x + checkpoint.checkpoint(self.forward_part2, x) + else: + x = x + self.forward_part2(x) + return x + + +class PatchMerging(nn.Module): + """ + Patch merging layer based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + """ + + def __init__( + self, dim: int, norm_layer: Type[LayerNorm] = nn.LayerNorm, spatial_dims: int = 3 + ) -> None: # type: ignore + """ + Args: + dim: number of feature channels. + norm_layer: normalization layer. + spatial_dims: number of spatial dims. + """ + + super().__init__() + self.dim = dim + if spatial_dims == 3: + self.reduction = nn.Linear(8 * dim, 2 * dim, bias=False) + self.norm = norm_layer(8 * dim) + elif spatial_dims == 2: + self.reduction = nn.Linear(4 * dim, 2 * dim, bias=False) + self.norm = norm_layer(4 * dim) + + def forward(self, x): + + x_shape = x.size() + if len(x_shape) == 5: + b, d, h, w, c = x_shape + pad_input = (h % 2 == 1) or (w % 2 == 1) or (d % 2 == 1) + if pad_input: + x = F.pad(x, (0, 0, 0, d % 2, 0, w % 2, 0, h % 2)) + x0 = x[:, 0::2, 0::2, 0::2, :] + x1 = x[:, 1::2, 0::2, 0::2, :] + x2 = x[:, 0::2, 1::2, 0::2, :] + x3 = x[:, 0::2, 0::2, 1::2, :] + x4 = x[:, 1::2, 0::2, 1::2, :] + x5 = x[:, 0::2, 1::2, 0::2, :] + x6 = x[:, 0::2, 0::2, 1::2, :] + x7 = x[:, 1::2, 1::2, 1::2, :] + x = torch.cat([x0, x1, x2, x3, x4, x5, x6, x7], -1) + + elif len(x_shape) == 4: + b, h, w, c = x_shape + pad_input = (h % 2 == 1) or (w % 2 == 1) + if pad_input: + x = F.pad(x, (0, 0, 0, w % 2, 0, h % 2)) + x0 = x[:, 0::2, 0::2, :] + x1 = x[:, 1::2, 0::2, :] + x2 = x[:, 0::2, 1::2, :] + x3 = x[:, 1::2, 1::2, :] + x = torch.cat([x0, x1, x2, x3], -1) + + x = self.norm(x) + x = self.reduction(x) + return x + + +def compute_mask(dims, window_size, shift_size, device): + """Computing region masks based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + + Args: + dims: dimension values. + window_size: local window size. + shift_size: shift size. + device: device. + """ + + cnt = 0 + + if len(dims) == 3: + d, h, w = dims + img_mask = torch.zeros((1, d, h, w, 1), device=device) + for d in slice(-window_size[0]), slice(-window_size[0], -shift_size[0]), slice(-shift_size[0], None): + for h in slice(-window_size[1]), slice(-window_size[1], -shift_size[1]), slice(-shift_size[1], None): + for w in slice(-window_size[2]), slice(-window_size[2], -shift_size[2]), slice(-shift_size[2], None): + img_mask[:, d, h, w, :] = cnt + cnt += 1 + + elif len(dims) == 2: + h, w = dims + img_mask = torch.zeros((1, h, w, 1), device=device) + for h in slice(-window_size[0]), slice(-window_size[0], -shift_size[0]), slice(-shift_size[0], None): + for w in slice(-window_size[1]), slice(-window_size[1], -shift_size[1]), slice(-shift_size[1], None): + img_mask[:, h, w, :] = cnt + cnt += 1 + + mask_windows = window_partition(img_mask, window_size) + mask_windows = mask_windows.squeeze(-1) + attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2) + attn_mask = attn_mask.masked_fill(attn_mask != 0, float(-100.0)).masked_fill(attn_mask == 0, float(0.0)) + + return attn_mask + + +class BasicLayer(nn.Module): + """ + Basic Swin Transformer layer in one stage based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + """ + + def __init__( + self, + dim: int, + depth: int, + num_heads: int, + window_size: Sequence[int], + drop_path: list, + mlp_ratio: float = 4.0, + qkv_bias: bool = False, + drop: float = 0.0, + attn_drop: float = 0.0, + norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + downsample: isinstance = None, # type: ignore + use_checkpoint: bool = False, + ) -> None: + """ + Args: + dim: number of feature channels. + depths: number of layers in each stage. + num_heads: number of attention heads. + window_size: local window size. + drop_path: stochastic depth rate. + mlp_ratio: ratio of mlp hidden dim to embedding dim. + qkv_bias: add a learnable bias to query, key, value. + drop: dropout rate. + attn_drop: attention dropout rate. + norm_layer: normalization layer. + downsample: downsample layer at the end of the layer. + use_checkpoint: use gradient checkpointing for reduced memory usage. + """ + + super().__init__() + self.window_size = window_size + self.shift_size = tuple(i // 2 for i in window_size) + self.no_shift = tuple(0 for i in window_size) + self.depth = depth + self.use_checkpoint = use_checkpoint + self.blocks = nn.ModuleList( + [ + SwinTransformerBlock( + dim=dim, + num_heads=num_heads, + window_size=self.window_size, + shift_size=self.no_shift if (i % 2 == 0) else self.shift_size, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + drop=drop, + attn_drop=attn_drop, + drop_path=drop_path[i] if isinstance(drop_path, list) else drop_path, + norm_layer=norm_layer, + use_checkpoint=use_checkpoint, + ) + for i in range(depth) + ] + ) + self.downsample = downsample + if self.downsample is not None: + self.downsample = downsample(dim=dim, norm_layer=norm_layer, spatial_dims=len(self.window_size)) + + def forward(self, x): + x_shape = x.size() + if len(x_shape) == 5: + b, c, d, h, w = x_shape + window_size, shift_size = get_window_size((d, h, w), self.window_size, self.shift_size) + x = rearrange(x, "b c d h w -> b d h w c") + dp = int(np.ceil(d / window_size[0])) * window_size[0] + hp = int(np.ceil(h / window_size[1])) * window_size[1] + wp = int(np.ceil(w / window_size[2])) * window_size[2] + attn_mask = compute_mask([dp, hp, wp], window_size, shift_size, x.device) + for blk in self.blocks: + x = blk(x, attn_mask) + x = x.view(b, d, h, w, -1) + if self.downsample is not None: + x = self.downsample(x) + x = rearrange(x, "b d h w c -> b c d h w") + + elif len(x_shape) == 4: + b, c, h, w = x_shape + window_size, shift_size = get_window_size((h, w), self.window_size, self.shift_size) + x = rearrange(x, "b c h w -> b h w c") + hp = int(np.ceil(h / window_size[0])) * window_size[0] + wp = int(np.ceil(w / window_size[1])) * window_size[1] + attn_mask = compute_mask([hp, wp], window_size, shift_size, x.device) + for blk in self.blocks: + x = blk(x, attn_mask) + x = x.view(b, h, w, -1) + if self.downsample is not None: + x = self.downsample(x) + x = rearrange(x, "b h w c -> b c h w") + return x + + +class SwinTransformer(nn.Module): + """ + Swin Transformer based on: "Liu et al., + Swin Transformer: Hierarchical Vision Transformer using Shifted Windows + " + https://github.com/microsoft/Swin-Transformer + """ + + def __init__( + self, + in_chans: int, + embed_dim: int, + window_size: Sequence[int], + patch_size: Sequence[int], + depths: Sequence[int], + num_heads: Sequence[int], + mlp_ratio: float = 4.0, + qkv_bias: bool = True, + drop_rate: float = 0.0, + attn_drop_rate: float = 0.0, + drop_path_rate: float = 0.0, + norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + patch_norm: bool = False, + use_checkpoint: bool = False, + spatial_dims: int = 3, + ) -> None: + """ + Args: + in_chans: dimension of input channels. + embed_dim: number of linear projection output channels. + window_size: local window size. + patch_size: patch size. + depths: number of layers in each stage. + num_heads: number of attention heads. + mlp_ratio: ratio of mlp hidden dim to embedding dim. + qkv_bias: add a learnable bias to query, key, value. + drop_rate: dropout rate. + attn_drop_rate: attention dropout rate. + drop_path_rate: stochastic depth rate. + norm_layer: normalization layer. + patch_norm: add normalization after patch embedding. + use_checkpoint: use gradient checkpointing for reduced memory usage. + spatial_dims: spatial dimension. + """ + + super().__init__() + self.num_layers = len(depths) + self.embed_dim = embed_dim + self.patch_norm = patch_norm + self.window_size = window_size + self.patch_size = patch_size + self.patch_embed = PatchEmbed( + patch_size=self.patch_size, + in_chans=in_chans, + embed_dim=embed_dim, + norm_layer=norm_layer if self.patch_norm else None, # type: ignore + spatial_dims=spatial_dims, + ) + self.pos_drop = nn.Dropout(p=drop_rate) + dpr = [x.item() for x in torch.linspace(0, drop_path_rate, sum(depths))] + self.layers1 = nn.ModuleList() + self.layers2 = nn.ModuleList() + self.layers3 = nn.ModuleList() + self.layers4 = nn.ModuleList() + for i_layer in range(self.num_layers): + layer = BasicLayer( + dim=int(embed_dim * 2**i_layer), + depth=depths[i_layer], + num_heads=num_heads[i_layer], + window_size=self.window_size, + drop_path=dpr[sum(depths[:i_layer]) : sum(depths[: i_layer + 1])], + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + drop=drop_rate, + attn_drop=attn_drop_rate, + norm_layer=norm_layer, + downsample=PatchMerging, + use_checkpoint=use_checkpoint, + ) + if i_layer == 0: + self.layers1.append(layer) + elif i_layer == 1: + self.layers2.append(layer) + elif i_layer == 2: + self.layers3.append(layer) + elif i_layer == 3: + self.layers4.append(layer) + self.num_features = int(embed_dim * 2 ** (self.num_layers - 1)) + self.norm = norm_layer(self.num_features) + + def proj_out(self, x, normalize=False): + if normalize: + x_shape = x.size() + if len(x_shape) == 5: + n, ch, d, h, w = x_shape + x = rearrange(x, "n c d h w -> n d h w c") + x = F.layer_norm(x, [ch]) + x = rearrange(x, "n d h w c -> n c d h w") + elif len(x_shape) == 4: + n, ch, h, w = x_shape + x = rearrange(x, "n c h w -> n h w c") + x = F.layer_norm(x, [ch]) + x = rearrange(x, "n h w c -> n c h w") + return x + + def forward(self, x, normalize=False): + x0 = self.patch_embed(x) + x0 = self.pos_drop(x0) + x0_out = self.proj_out(x0, normalize) + x1 = self.layers1[0](x0.contiguous()) + x1_out = self.proj_out(x1, normalize) + x2 = self.layers2[0](x1.contiguous()) + x2_out = self.proj_out(x2, normalize) + x3 = self.layers3[0](x2.contiguous()) + x3_out = self.proj_out(x3, normalize) + x4 = self.layers4[0](x3.contiguous()) + x4_out = self.proj_out(x4, normalize) + return [x0_out, x1_out, x2_out, x3_out, x4_out] diff --git a/tests/test_drop_path.py b/tests/test_drop_path.py new file mode 100644 index 0000000000..f8ea454228 --- /dev/null +++ b/tests/test_drop_path.py @@ -0,0 +1,43 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.networks.layers import DropPath + +TEST_CASES = [ + [{"drop_prob": 0.0, "scale_by_keep": True}, (1, 8, 8)], + [{"drop_prob": 0.7, "scale_by_keep": False}, (2, 16, 16, 16)], + [{"drop_prob": 0.3, "scale_by_keep": True}, (6, 16, 12)], +] + +TEST_ERRORS = [[{"drop_prob": 2, "scale_by_keep": False}, (1, 24, 6)]] + + +class TestDropPath(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_shape(self, input_param, input_shape): + im = torch.rand(input_shape) + dr_path = DropPath(**input_param) + out = dr_path(im) + self.assertEqual(out.shape, input_shape) + + @parameterized.expand(TEST_ERRORS) + def test_ill_arg(self, input_param, input_shape): + with self.assertRaises(ValueError): + DropPath(**input_param) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_patchembedding.py b/tests/test_patchembedding.py index 4af2b47ba5..6971eb0463 100644 --- a/tests/test_patchembedding.py +++ b/tests/test_patchembedding.py @@ -13,10 +13,11 @@ from unittest import skipUnless import torch +import torch.nn as nn from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.blocks.patchembedding import PatchEmbeddingBlock +from monai.networks.blocks.patchembedding import PatchEmbed, PatchEmbeddingBlock from monai.utils import optional_import einops, has_einops = optional_import("einops") @@ -48,6 +49,26 @@ test_case[0]["spatial_dims"] = 2 # type: ignore TEST_CASE_PATCHEMBEDDINGBLOCK.append(test_case) +TEST_CASE_PATCHEMBED = [] +for patch_size in [2]: + for in_chans in [1, 4]: + for img_size in [96]: + for embed_dim in [6, 12]: + for norm_layer in [nn.LayerNorm]: + for nd in [2, 3]: + test_case = [ + { + "patch_size": (patch_size,) * nd, + "in_chans": in_chans, + "embed_dim": embed_dim, + "norm_layer": norm_layer, + "spatial_dims": nd, + }, + (2, in_chans, *([img_size] * nd)), + (2, embed_dim, *([img_size // patch_size] * nd)), + ] + TEST_CASE_PATCHEMBED.append(test_case) + class TestPatchEmbeddingBlock(unittest.TestCase): @parameterized.expand(TEST_CASE_PATCHEMBEDDINGBLOCK) @@ -115,5 +136,19 @@ def test_ill_arg(self): ) +class TestPatchEmbed(unittest.TestCase): + @parameterized.expand(TEST_CASE_PATCHEMBED) + @skipUnless(has_einops, "Requires einops") + def test_shape(self, input_param, input_shape, expected_shape): + net = PatchEmbed(**input_param) + with eval_mode(net): + result = net(torch.randn(input_shape)) + self.assertEqual(result.shape, expected_shape) + + def test_ill_arg(self): + with self.assertRaises(ValueError): + PatchEmbed(patch_size=(2, 2, 2), in_chans=1, embed_dim=24, norm_layer=nn.LayerNorm, spatial_dims=5) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_swin_unetr.py b/tests/test_swin_unetr.py new file mode 100644 index 0000000000..0d48e99c44 --- /dev/null +++ b/tests/test_swin_unetr.py @@ -0,0 +1,89 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest import skipUnless + +import torch +from parameterized import parameterized + +from monai.networks import eval_mode +from monai.networks.nets.swin_unetr import SwinUNETR +from monai.utils import optional_import + +einops, has_einops = optional_import("einops") + +TEST_CASE_SWIN_UNETR = [] +for attn_drop_rate in [0.4]: + for in_channels in [1]: + for depth in [[2, 1, 1, 1], [1, 2, 1, 1]]: + for out_channels in [2]: + for img_size in [64]: + for feature_size in [12]: + for norm_name in ["instance"]: + for nd in (2, 3): + test_case = [ + { + "in_channels": in_channels, + "out_channels": out_channels, + "img_size": (img_size,) * nd, + "feature_size": feature_size, + "depths": depth, + "norm_name": norm_name, + "attn_drop_rate": attn_drop_rate, + }, + (2, in_channels, *([img_size] * nd)), + (2, out_channels, *([img_size] * nd)), + ] + if nd == 2: + test_case[0]["spatial_dims"] = 2 # type: ignore + TEST_CASE_SWIN_UNETR.append(test_case) + + +class TestSWINUNETR(unittest.TestCase): + @parameterized.expand(TEST_CASE_SWIN_UNETR) + @skipUnless(has_einops, "Requires einops") + def test_shape(self, input_param, input_shape, expected_shape): + net = SwinUNETR(**input_param) + with eval_mode(net): + result = net(torch.randn(input_shape)) + self.assertEqual(result.shape, expected_shape) + + def test_ill_arg(self): + with self.assertRaises(ValueError): + SwinUNETR( + in_channels=1, + out_channels=3, + img_size=(128, 128, 128), + feature_size=24, + norm_name="instance", + attn_drop_rate=4, + ) + + with self.assertRaises(ValueError): + SwinUNETR(in_channels=1, out_channels=2, img_size=(96, 96), feature_size=48, norm_name="instance") + + with self.assertRaises(ValueError): + SwinUNETR(in_channels=1, out_channels=4, img_size=(96, 96, 96), feature_size=50, norm_name="instance") + + with self.assertRaises(ValueError): + SwinUNETR( + in_channels=1, + out_channels=3, + img_size=(85, 85, 85), + feature_size=24, + norm_name="instance", + drop_rate=0.4, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_weight_init.py b/tests/test_weight_init.py new file mode 100644 index 0000000000..c850ff4ce6 --- /dev/null +++ b/tests/test_weight_init.py @@ -0,0 +1,47 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.networks.layers import trunc_normal_ + +TEST_CASES = [ + [{"mean": 0.0, "std": 1.0, "a": 2, "b": 4}, (6, 12, 3, 1, 7)], + [{"mean": 0.3, "std": 0.6, "a": -1.0, "b": 1.3}, (1, 4, 4, 4)], + [{"mean": 0.1, "std": 0.4, "a": 1.3, "b": 1.8}, (5, 7, 7, 8, 9)], +] + +TEST_ERRORS = [ + [{"mean": 0.0, "std": 1.0, "a": 5, "b": 1.1}, (1, 1, 8, 8, 8)], + [{"mean": 0.3, "std": -0.1, "a": 1.0, "b": 2.0}, (8, 5, 2, 6, 9)], + [{"mean": 0.7, "std": 0.0, "a": 0.1, "b": 2.0}, (4, 12, 23, 17)], +] + + +class TestWeightInit(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_shape(self, input_param, input_shape): + im = torch.rand(input_shape) + trunc_normal_(im, **input_param) + self.assertEqual(im.shape, input_shape) + + @parameterized.expand(TEST_ERRORS) + def test_ill_arg(self, input_param, input_shape): + with self.assertRaises(ValueError): + im = torch.rand(input_shape) + trunc_normal_(im, **input_param) + + +if __name__ == "__main__": + unittest.main() From b10364c331ed134aacf17901e3f7731d7f28ad3d Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 5 May 2022 12:37:41 +0800 Subject: [PATCH 052/183] 4217 Update PyTorch docker to 22.04 (#4218) * [DLMED] update to 22.04 Signed-off-by: Nic Ma * fixes unit test tests.test_lr_finder Signed-off-by: Wenqi Li * test new_empty Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- .github/workflows/cron.yml | 6 +++--- .github/workflows/pythonapp-gpu.yml | 4 ++-- Dockerfile | 2 +- monai/data/meta_obj.py | 2 +- monai/data/meta_tensor.py | 15 +++++++++++++-- tests/test_meta_tensor.py | 3 +-- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 734a84ff2f..08065147e5 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -62,7 +62,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.03"] # 21.02, 21.10 for backward comp. + container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.04"] # 21.02, 21.10 for backward comp. container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -106,7 +106,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.03"] # 21.02, 21.10 for backward comp. + container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.04"] # 21.02, 21.10 for backward comp. container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -204,7 +204,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' needs: cron-gpu # so that monai itself is verified first container: - image: nvcr.io/nvidia/pytorch:22.03-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:22.04-py3 # testing with the latest pytorch base image options: "--gpus all --ipc=host" runs-on: [self-hosted, linux, x64, common] steps: diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 50bbe13062..2fdfa5a80f 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -46,9 +46,9 @@ jobs: base: "nvcr.io/nvidia/pytorch:21.10-py3" - environment: PT111+CUDA116 # we explicitly set pytorch to -h to avoid pip install error - # 22.03: 1.12.0a0+2c916ef + # 22.04: 1.12.0a0+bd13bc6 pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:22.03-py3" + base: "nvcr.io/nvidia/pytorch:22.04-py3" - environment: PT110+CUDA102 pytorch: "torch==1.10.2 torchvision==0.11.3" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" diff --git a/Dockerfile b/Dockerfile index dc76584d5a..1b022fc92e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # To build with a different base image # please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.03-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.04-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index e38e009e96..3e35a6dd4b 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -153,7 +153,7 @@ def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Call Returns: Returns `None`, but `self` should be updated to have the copied attribute. """ - attributes = [getattr(i, attribute) for i in input_objs] + attributes = [getattr(i, attribute) for i in input_objs if hasattr(i, attribute)] if len(attributes) > 0: val = attributes[0] if deep_copy: diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index 9196f0186c..d44b780a5e 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -186,8 +186,8 @@ def __torch_function__(cls, func, types, args=(), kwargs=None) -> Any: kwargs = {} ret = super().__torch_function__(func, types, args, kwargs) # if `out` has been used as argument, metadata is not copied, nothing to do. - if "out" in kwargs: - return ret + # if "out" in kwargs: + # return ret # we might have 1 or multiple outputs. Might be MetaTensor, might be something # else (e.g., `__repr__` returns a string). # Convert to list (if necessary), process, and at end remove list if one was added. @@ -232,3 +232,14 @@ def affine(self) -> torch.Tensor: def affine(self, d: torch.Tensor) -> None: """Set the affine.""" self.meta["affine"] = d + + def new_empty(self, size, dtype=None, device=None, requires_grad=False): + """ + must be defined for deepcopy to work + + See: + - https://pytorch.org/docs/stable/generated/torch.Tensor.new_empty.html#torch-tensor-new-empty + """ + return type(self)( + self.as_tensor().new_empty(size=size, dtype=dtype, device=device, requires_grad=requires_grad) + ) diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 05356fcc84..fb6d922218 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -272,14 +272,13 @@ def test_amp(self): def test_out(self): """Test when `out` is given as an argument.""" m1, _ = self.get_im() - m1_orig = deepcopy(m1) m2, _ = self.get_im() m3, _ = self.get_im() torch.add(m2, m3, out=m1) m1_add = m2 + m3 assert_allclose(m1, m1_add) - self.check_meta(m1, m1_orig) + # self.check_meta(m1, m2) # meta is from first input tensor @parameterized.expand(TESTS) def test_collate(self, device, dtype): From 66f3280d9fb6b14dd23e5c088c590ca3937b2055 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 5 May 2022 13:26:52 +0800 Subject: [PATCH 053/183] Add InstanceNorm3dNVFuser support (#4194) * implement the base class Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add unittest Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * autofix Signed-off-by: Yiheng Wang * switch to call apex directly Signed-off-by: Yiheng Wang * uncomment unittest Signed-off-by: Yiheng Wang * add apex install link in docstring Signed-off-by: Yiheng Wang * add channels_last_3d test case Signed-off-by: Yiheng Wang * rewrite types Signed-off-by: Yiheng Wang * change types Signed-off-by: Yiheng Wang * add docstrings Signed-off-by: Yiheng Wang Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- monai/networks/layers/factories.py | 29 ++++++++++++++++++++++++++- monai/networks/nets/dynunet.py | 2 ++ tests/test_dynunet.py | 32 +++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/monai/networks/layers/factories.py b/monai/networks/layers/factories.py index 6379f49449..b808c24de0 100644 --- a/monai/networks/layers/factories.py +++ b/monai/networks/layers/factories.py @@ -60,11 +60,14 @@ def use_factory(fact_args): layer = use_factory( (fact.TEST, kwargs) ) """ +import warnings from typing import Any, Callable, Dict, Tuple, Type, Union import torch.nn as nn -from monai.utils import look_up_option +from monai.utils import look_up_option, optional_import + +InstanceNorm3dNVFuser, has_nvfuser = optional_import("apex.normalization", name="InstanceNorm3dNVFuser") __all__ = ["LayerFactory", "Dropout", "Norm", "Act", "Conv", "Pool", "Pad", "split_args"] @@ -242,6 +245,30 @@ def sync_batch_factory(_dim) -> Type[nn.SyncBatchNorm]: return nn.SyncBatchNorm +@Norm.factory_function("instance_nvfuser") +def instance_nvfuser_factory(dim): + """ + `InstanceNorm3dNVFuser` is a faster verison of InstanceNorm layer and implemented in `apex`. + It only supports 3d tensors as the input. It also requires to use with CUDA and non-Windows OS. + In this function, if the required library `apex.normalization.InstanceNorm3dNVFuser` does not exist, + `nn.InstanceNorm3d` will be returned instead. + This layer is based on a customized autograd function, which is not supported in TorchScript currently. + Please switch to use `nn.InstanceNorm3d` if TorchScript is necessary. + + Please check the following link for more details about how to install `apex`: + https://github.com/NVIDIA/apex#installation + + """ + types = (nn.InstanceNorm1d, nn.InstanceNorm2d) + if dim != 3: + warnings.warn(f"`InstanceNorm3dNVFuser` only supports 3d cases, use {types[dim - 1]} instead.") + return types[dim - 1] + if not has_nvfuser: + warnings.warn("`apex.normalization.InstanceNorm3dNVFuser` is not found, use nn.InstanceNorm3d instead.") + return nn.InstanceNorm3d + return InstanceNorm3dNVFuser + + Act.add_factory_callable("elu", lambda: nn.modules.ELU) Act.add_factory_callable("relu", lambda: nn.modules.ReLU) Act.add_factory_callable("leakyrelu", lambda: nn.modules.LeakyReLU) diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index e858dcbb9b..053ab255b8 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -104,6 +104,8 @@ class DynUNet(nn.Module): If not specified, the way which nnUNet used will be employed. Defaults to ``None``. dropout: dropout ratio. Defaults to no dropout. norm_name: feature normalization type and arguments. Defaults to ``INSTANCE``. + `INSTANCE_NVFUSER` is a faster version of the instance norm layer, it can be used when: + 1) `spatial_dims=3`, 2) CUDA device is available, 3) `apex` is installed and 4) non-Windows OS is used. act_name: activation layer type and arguments. Defaults to ``leakyrelu``. 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 diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index 36ac9d0309..14006b96e6 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -17,7 +17,10 @@ from monai.networks import eval_mode from monai.networks.nets import DynUNet -from tests.utils import test_script_save +from monai.utils import optional_import +from tests.utils import skip_if_no_cuda, skip_if_windows, test_script_save + +_, has_nvfuser = optional_import("apex.normalization", name="InstanceNorm3dNVFuser") device = "cuda" if torch.cuda.is_available() else "cpu" @@ -118,6 +121,33 @@ def test_script(self): test_script_save(net, test_data) +@skip_if_no_cuda +@skip_if_windows +@unittest.skipUnless(has_nvfuser, "To use `instance_nvfuser`, `apex.normalization.InstanceNorm3dNVFuser` is needed.") +class TestDynUNetWithInstanceNorm3dNVFuser(unittest.TestCase): + @parameterized.expand([TEST_CASE_DYNUNET_3D[0]]) + def test_consistency(self, input_param, input_shape, _): + for eps in [1e-4, 1e-5]: + for momentum in [0.1, 0.01]: + for affine in [True, False]: + norm_param = {"eps": eps, "momentum": momentum, "affine": affine} + input_param["norm_name"] = ("instance", norm_param) + input_param_fuser = input_param.copy() + input_param_fuser["norm_name"] = ("instance_nvfuser", norm_param) + for memory_format in [torch.contiguous_format, torch.channels_last_3d]: + net = DynUNet(**input_param).to("cuda:0", memory_format=memory_format) + net_fuser = DynUNet(**input_param_fuser).to("cuda:0", memory_format=memory_format) + net_fuser.load_state_dict(net.state_dict()) + + input_tensor = torch.randn(input_shape).to("cuda:0", memory_format=memory_format) + with eval_mode(net): + result = net(input_tensor) + with eval_mode(net_fuser): + result_fuser = net_fuser(input_tensor) + + torch.testing.assert_close(result, result_fuser) + + class TestDynUNetDeepSupervision(unittest.TestCase): @parameterized.expand(TEST_CASE_DEEP_SUPERVISION) def test_shape(self, input_param, input_shape, expected_shape): From 5f00bf19182d32afbdf8113cd3a5a2870c7ddd00 Mon Sep 17 00:00:00 2001 From: Ryan Clanton <55164720+ryancinsight@users.noreply.github.com> Date: Fri, 6 May 2022 07:10:59 -0400 Subject: [PATCH 054/183] Update dice.py (#4234) * Update dice.py reduce redundant operations in DiceFocalLoss, initially caused oom Signed-off-by: Ryan Clanton <55164720+ryancinsight@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Signed-off-by: Ryan Clanton <55164720+ryancinsight@users.noreply.github.com> * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: monai-bot --- monai/losses/dice.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 610327ef63..67802abc0a 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -796,8 +796,6 @@ def __init__( """ super().__init__() self.dice = DiceLoss( - include_background=include_background, - to_onehot_y=to_onehot_y, sigmoid=sigmoid, softmax=softmax, other_act=other_act, @@ -808,19 +806,15 @@ def __init__( smooth_dr=smooth_dr, batch=batch, ) - self.focal = FocalLoss( - include_background=include_background, - to_onehot_y=to_onehot_y, - gamma=gamma, - weight=focal_weight, - reduction=reduction, - ) + self.focal = FocalLoss(gamma=gamma, weight=focal_weight, reduction=reduction) if lambda_dice < 0.0: raise ValueError("lambda_dice should be no less than 0.0.") if lambda_focal < 0.0: raise ValueError("lambda_focal should be no less than 0.0.") self.lambda_dice = lambda_dice self.lambda_focal = lambda_focal + self.to_onehot_y = to_onehot_y + self.include_background = include_background def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """ @@ -837,6 +831,22 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: if len(input.shape) != len(target.shape): raise ValueError("the number of dimensions for input and target should be the same.") + n_pred_ch = input.shape[1] + + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + target = one_hot(target, num_classes=n_pred_ch) + + if not self.include_background: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `include_background=False` ignored.") + else: + # if skipping background, removing first channel + target = target[:, 1:] + input = input[:, 1:] + dice_loss = self.dice(input, target) focal_loss = self.focal(input, target) total_loss: torch.Tensor = self.lambda_dice * dice_loss + self.lambda_focal * focal_loss From 209db9e08129855df878634639d4c2700d9acd83 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Fri, 6 May 2022 15:28:08 -0400 Subject: [PATCH 055/183] Bug fix and improvement in WSI (#4216) * Make all transforms optional Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update wsireader tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove optional from PersistentDataset and its derivatives Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for cache without transform Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add default replace_rate Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add default value Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Set default replace_rate to 0.1 Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update metadata to include path Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Adds SmartCachePatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for SmartCachePatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update references Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove smart cache Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove unused imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add path metadata for OpenSlide Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update metadata to be unified across different backends Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update wsi metadata for multi wsi objects Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for wsi metadata Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 16 ++--- monai/data/wsi_reader.py | 118 +++++++++++++++++------------------- tests/test_wsireader_new.py | 29 ++++++--- 3 files changed, 85 insertions(+), 78 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index a895e8aa45..750b3fda20 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -10,7 +10,7 @@ # limitations under the License. import inspect -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, Optional, Sequence, Tuple, Union import numpy as np @@ -32,10 +32,12 @@ class PatchWSIDataset(Dataset): size: the size of patch to be extracted from the whole slide image. level: the level at which the patches to be extracted (default to 0). transform: transforms to be executed on input data. - reader: the module to be used for loading whole slide imaging, - - if `reader` is a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. - - if `reader` is a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader. - - if `reader` is an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + reader: the module to be used for loading whole slide imaging. If `reader` is + + - a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. + - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader. + - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class Note: @@ -45,14 +47,14 @@ class PatchWSIDataset(Dataset): [ {"image": "path/to/image1.tiff", "location": [200, 500], "label": 0}, - {"image": "path/to/image2.tiff", "location": [100, 700], "label": 1} + {"image": "path/to/image2.tiff", "location": [100, 700], "size": [20, 20], "level": 2, "label": 1} ] """ def __init__( self, - data: List, + data: Sequence, size: Optional[Union[int, Tuple[int, int]]] = None, level: Optional[int] = None, transform: Optional[Callable] = None, diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 02032a0ae6..8dee1f453e 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -10,6 +10,7 @@ # limitations under the License. from abc import abstractmethod +from os.path import abspath from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import numpy as np @@ -53,6 +54,7 @@ class BaseWSIReader(ImageReader): """ supported_suffixes: List[str] = [] + backend = "" def __init__(self, level: int, **kwargs): super().__init__() @@ -63,7 +65,7 @@ def __init__(self, level: int, **kwargs): @abstractmethod def get_size(self, wsi, level: int) -> Tuple[int, int]: """ - Returns the size of the whole slide image at a given level. + Returns the size (height, width) of the whole slide image at a given level. Args: wsi: a whole slide image object loaded from a file @@ -83,6 +85,11 @@ def get_level_count(self, wsi) -> int: """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + @abstractmethod + def get_file_path(self, wsi) -> str: + """Return the file path for the WSI object""" + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + @abstractmethod def get_patch( self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str @@ -102,12 +109,14 @@ def get_patch( """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") - @abstractmethod - def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: + def get_metadata( + self, wsi, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int + ) -> Dict: """ Returns metadata of the extracted patch from the whole slide image. Args: + wsi: the whole slide image object, from which the patch is loaded patch: extracted patch from whole slide image location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). size: (height, width) tuple giving the patch size at the given level (`level`). @@ -115,7 +124,14 @@ def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple level: the level number. Defaults to 0 """ - raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + metadata: Dict = { + "backend": self.backend, + "original_channel_dim": 0, + "spatial_shape": np.asarray(patch.shape[1:]), + "wsi": {"path": self.get_file_path(wsi)}, + "patch": {"location": location, "size": size, "level": level}, + } + return metadata def get_data( self, @@ -194,8 +210,26 @@ def get_data( patch_list.append(patch) # Set patch-related metadata - each_meta = self.get_metadata(patch=patch, location=location, size=size, level=level) - metadata.update(each_meta) + each_meta = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) + + if len(wsi) == 1: + metadata = each_meta + else: + if not metadata: + metadata = { + "backend": each_meta["backend"], + "original_channel_dim": each_meta["original_channel_dim"], + "spatial_shape": each_meta["spatial_shape"], + "wsi": [each_meta["wsi"]], + "patch": [each_meta["patch"]], + } + else: + if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: + raise ValueError("original_channel_dim is not consistent across wsi objects.") + if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): + raise ValueError("spatial_shape is not consistent across wsi objects.") + metadata["wsi"].append(each_meta["wsi"]) + metadata["patch"].append(each_meta["patch"]) return _stack_images(patch_list, metadata), metadata @@ -247,7 +281,7 @@ def get_level_count(self, wsi) -> int: def get_size(self, wsi, level: int) -> Tuple[int, int]: """ - Returns the size of the whole slide image at a given level. + Returns the size (height, width) of the whole slide image at a given level. Args: wsi: a whole slide image object loaded from a file @@ -256,19 +290,9 @@ def get_size(self, wsi, level: int) -> Tuple[int, int]: """ return self.reader.get_size(wsi, level) - def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: - """ - Returns metadata of the extracted patch from the whole slide image. - - Args: - patch: extracted patch from whole slide image - location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). - size: (height, width) tuple giving the patch size at the given level (`level`). - If None, it is set to the full image size at the given level. - level: the level number. Defaults to 0 - - """ - return self.reader.get_metadata(patch=patch, size=size, location=location, level=level) + def get_file_path(self, wsi) -> str: + """Return the file path for the WSI object""" + return self.reader.get_file_path(wsi) def get_patch( self, wsi, location: Tuple[int, int], size: Tuple[int, int], level: int, dtype: DtypeLike, mode: str @@ -317,6 +341,7 @@ class CuCIMWSIReader(BaseWSIReader): """ supported_suffixes = ["tif", "tiff", "svs"] + backend = "cucim" def __init__(self, level: int = 0, **kwargs): super().__init__(level, **kwargs) @@ -335,7 +360,7 @@ def get_level_count(wsi) -> int: @staticmethod def get_size(wsi, level: int) -> Tuple[int, int]: """ - Returns the size of the whole slide image at a given level. + Returns the size (height, width) of the whole slide image at a given level. Args: wsi: a whole slide image object loaded from a file @@ -344,27 +369,9 @@ def get_size(wsi, level: int) -> Tuple[int, int]: """ return (wsi.resolutions["level_dimensions"][level][1], wsi.resolutions["level_dimensions"][level][0]) - def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: - """ - Returns metadata of the extracted patch from the whole slide image. - - Args: - patch: extracted patch from whole slide image - location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). - size: (height, width) tuple giving the patch size at the given level (`level`). - If None, it is set to the full image size at the given level. - level: the level number. Defaults to 0 - - """ - metadata: Dict = { - "backend": "cucim", - "spatial_shape": np.asarray(patch.shape[1:]), - "original_channel_dim": 0, - "location": location, - "size": size, - "level": level, - } - return metadata + def get_file_path(self, wsi) -> str: + """Return the file path for the WSI object""" + return str(abspath(wsi.path)) def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): """ @@ -440,6 +447,7 @@ class OpenSlideWSIReader(BaseWSIReader): """ supported_suffixes = ["tif", "tiff", "svs"] + backend = "openslide" def __init__(self, level: int = 0, **kwargs): super().__init__(level, **kwargs) @@ -458,7 +466,7 @@ def get_level_count(wsi) -> int: @staticmethod def get_size(wsi, level: int) -> Tuple[int, int]: """ - Returns the size of the whole slide image at a given level. + Returns the size (height, width) of the whole slide image at a given level. Args: wsi: a whole slide image object loaded from a file @@ -467,27 +475,9 @@ def get_size(wsi, level: int) -> Tuple[int, int]: """ return (wsi.level_dimensions[level][1], wsi.level_dimensions[level][0]) - def get_metadata(self, patch: np.ndarray, location: Tuple[int, int], size: Tuple[int, int], level: int) -> Dict: - """ - Returns metadata of the extracted patch from the whole slide image. - - Args: - patch: extracted patch from whole slide image - location: (top, left) tuple giving the top left pixel in the level 0 reference frame. Defaults to (0, 0). - size: (height, width) tuple giving the patch size at the given level (`level`). - If None, it is set to the full image size at the given level. - level: the level number. Defaults to 0 - - """ - metadata: Dict = { - "backend": "openslide", - "spatial_shape": np.asarray(patch.shape[1:]), - "original_channel_dim": 0, - "location": location, - "size": size, - "level": level, - } - return metadata + def get_file_path(self, wsi) -> str: + """Return the file path for the WSI object""" + return str(abspath(wsi._filename)) def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): """ diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 2ac4125f97..4faec53978 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -125,8 +125,13 @@ class Tests(unittest.TestCase): def test_read_whole_image(self, file_path, level, expected_shape): reader = WSIReader(self.backend, level=level) with reader.read(file_path) as img_obj: - img = reader.get_data(img_obj)[0] + img, meta = reader.get_data(img_obj) self.assertTupleEqual(img.shape, expected_shape) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["wsi"]["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch"]["level"], level) + self.assertTupleEqual(meta["patch"]["size"], expected_shape[1:]) + self.assertTupleEqual(meta["patch"]["location"], (0, 0)) @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) def test_read_region(self, file_path, patch_info, expected_img): @@ -138,29 +143,39 @@ def test_read_region(self, file_path, patch_info, expected_img): reader.get_data(img_obj, **patch_info)[0] else: # Read twice to check multiple calls - img = reader.get_data(img_obj, **patch_info)[0] + img, meta = reader.get_data(img_obj, **patch_info) img2 = reader.get_data(img_obj, **patch_info)[0] self.assertTupleEqual(img.shape, img2.shape) self.assertIsNone(assert_array_equal(img, img2)) self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["wsi"]["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch"]["level"], patch_info["level"]) + self.assertTupleEqual(meta["patch"]["size"], expected_img.shape[1:]) + self.assertTupleEqual(meta["patch"]["location"], patch_info["location"]) @parameterized.expand([TEST_CASE_3]) - def test_read_region_multi_wsi(self, file_path, patch_info, expected_img): + def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} reader = WSIReader(self.backend, **kwargs) - img_obj = reader.read(file_path, **kwargs) + img_obj_list = reader.read(file_path_list, **kwargs) if self.backend == "tifffile": with self.assertRaises(ValueError): - reader.get_data(img_obj, **patch_info)[0] + reader.get_data(img_obj_list, **patch_info)[0] else: # Read twice to check multiple calls - img = reader.get_data(img_obj, **patch_info)[0] - img2 = reader.get_data(img_obj, **patch_info)[0] + img, meta = reader.get_data(img_obj_list, **patch_info) + img2 = reader.get_data(img_obj_list, **patch_info)[0] self.assertTupleEqual(img.shape, img2.shape) self.assertIsNone(assert_array_equal(img, img2)) self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["wsi"][0]["path"], str(os.path.abspath(file_path_list[0]))) + self.assertEqual(meta["patch"][0]["level"], patch_info["level"]) + self.assertTupleEqual(meta["patch"][0]["size"], expected_img.shape[1:]) + self.assertTupleEqual(meta["patch"][0]["location"], patch_info["location"]) @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") From 5536cc31fd8a820487e0de6ea79450624deb7b00 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 9 May 2022 15:57:21 +0100 Subject: [PATCH 056/183] Replace module (#4245) * replace modules Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * replace_module -> replace_modules Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/networks/__init__.py | 2 + monai/networks/utils.py | 104 ++++++++++++++++++++++++++++++++++- tests/test_replace_module.py | 97 ++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/test_replace_module.py diff --git a/monai/networks/__init__.py b/monai/networks/__init__.py index 76223dfaef..0543b11632 100644 --- a/monai/networks/__init__.py +++ b/monai/networks/__init__.py @@ -20,6 +20,8 @@ one_hot, pixelshuffle, predict_segmentation, + replace_modules, + replace_modules_temp, save_state, slice_channels, to_norm_affine, diff --git a/monai/networks/utils.py b/monai/networks/utils.py index f22be31524..34ea4f716e 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -15,7 +15,8 @@ import warnings from collections import OrderedDict from contextlib import contextmanager -from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Union +from copy import deepcopy +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union import torch import torch.nn as nn @@ -41,6 +42,8 @@ "save_state", "convert_to_torchscript", "meshgrid_ij", + "replace_modules", + "replace_modules_temp", ] @@ -551,3 +554,102 @@ def meshgrid_ij(*tensors): if pytorch_after(1, 10): return torch.meshgrid(*tensors, indexing="ij") return torch.meshgrid(*tensors) + + +def _replace_modules( + parent: torch.nn.Module, + name: str, + new_module: torch.nn.Module, + out: List[Tuple[str, torch.nn.Module]], + strict_match: bool = True, + match_device: bool = True, +) -> None: + """ + Helper function for :py:class:`monai.networks.utils.replace_modules`. + """ + if match_device: + devices = list({i.device for i in parent.parameters()}) + # if only one device for whole of model + if len(devices) == 1: + new_module.to(devices[0]) + idx = name.find(".") + # if there is "." in name, call recursively + if idx != -1: + parent_name = name[:idx] + parent = getattr(parent, parent_name) + name = name[idx + 1 :] + _out: List[Tuple[str, torch.nn.Module]] = [] + _replace_modules(parent, name, new_module, _out) + # prepend the parent name + out += [(f"{parent_name}.{r[0]}", r[1]) for r in _out] + # no "." in module name, do the actual replacing + else: + if strict_match: + old_module = getattr(parent, name) + setattr(parent, name, new_module) + out += [(name, old_module)] + else: + for mod_name, _ in parent.named_modules(): + if name in mod_name: + _replace_modules(parent, mod_name, deepcopy(new_module), out, strict_match=True) + + +def replace_modules( + parent: torch.nn.Module, + name: str, + new_module: torch.nn.Module, + strict_match: bool = True, + match_device: bool = True, +) -> List[Tuple[str, torch.nn.Module]]: + """ + Replace sub-module(s) in a parent module. + + The name of the module to be replace can be nested e.g., + `features.denseblock1.denselayer1.layers.relu1`. If this is the case (there are "." + in the module name), then this function will recursively call itself. + + Args: + parent: module that contains the module to be replaced + name: name of module to be replaced. Can include ".". + new_module: `torch.nn.Module` to be placed at position `name` inside `parent`. This will + be deep copied if `strict_match == False` multiple instances are independent. + strict_match: if `True`, module name must `== name`. If false then + `name in named_modules()` will be used. `True` can be used to change just + one module, whereas `False` can be used to replace all modules with similar + name (e.g., `relu`). + match_device: if `True`, the device of the new module will match the model. Requires all + of `parent` to be on the same device. + + Returns: + List of tuples of replaced modules. Element 0 is module name, element 1 is the replaced module. + + Raises: + AttributeError: if `strict_match` is `True` and `name` is not a named module in `parent`. + """ + out: List[Tuple[str, torch.nn.Module]] = [] + _replace_modules(parent, name, new_module, out, strict_match, match_device) + return out + + +@contextmanager +def replace_modules_temp( + parent: torch.nn.Module, + name: str, + new_module: torch.nn.Module, + strict_match: bool = True, + match_device: bool = True, +): + """ + Temporarily replace sub-module(s) in a parent module (context manager). + + See :py:class:`monai.networks.utils.replace_modules`. + """ + replaced: List[Tuple[str, torch.nn.Module]] = [] + try: + # replace + _replace_modules(parent, name, new_module, replaced, strict_match, match_device) + yield + finally: + # revert + for name, module in replaced: + _replace_modules(parent, name, module, [], strict_match=True, match_device=match_device) diff --git a/tests/test_replace_module.py b/tests/test_replace_module.py new file mode 100644 index 0000000000..4cb4443410 --- /dev/null +++ b/tests/test_replace_module.py @@ -0,0 +1,97 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from typing import Optional, Type + +import torch +from parameterized import parameterized + +from monai.networks.nets import DenseNet121 +from monai.networks.utils import replace_modules, replace_modules_temp +from tests.utils import TEST_DEVICES + +TESTS = [] +for device in TEST_DEVICES: + for match_device in (True, False): + # replace 1 + TESTS.append(("features.denseblock1.denselayer1.layers.relu1", True, match_device, *device)) + # replace 1 (but not strict) + TESTS.append(("features.denseblock1.denselayer1.layers.relu1", False, match_device, *device)) + # replace multiple + TESTS.append(("relu", False, match_device, *device)) + + +class TestReplaceModule(unittest.TestCase): + def setUp(self): + self.net = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) + self.num_relus = self.get_num_modules(torch.nn.ReLU) + self.total = self.get_num_modules() + self.assertGreater(self.num_relus, 0) + + def get_num_modules(self, mod: Optional[Type[torch.nn.Module]] = None) -> int: + m = [m for _, m in self.net.named_modules()] + if mod is not None: + m = [_m for _m in m if isinstance(_m, mod)] + return len(m) + + def check_replaced_modules(self, name, match_device): + # total num modules should remain the same + self.assertEqual(self.total, self.get_num_modules()) + num_relus_mod = self.get_num_modules(torch.nn.ReLU) + num_softmax = self.get_num_modules(torch.nn.Softmax) + # list of returned modules should be as long as number of softmax + self.assertEqual(self.num_relus, num_relus_mod + num_softmax) + if name == "relu": + # at least 2 softmaxes + self.assertGreaterEqual(num_softmax, 2) + else: + # one softmax + self.assertEqual(num_softmax, 1) + if match_device: + self.assertEqual(len(list({i.device for i in self.net.parameters()})), 1) + + @parameterized.expand(TESTS) + def test_replace(self, name, strict_match, match_device, device): + self.net.to(device) + # replace module(s) + replaced = replace_modules(self.net, name, torch.nn.Softmax(), strict_match, match_device) + self.check_replaced_modules(name, match_device) + # number of returned modules should equal number of softmax modules + self.assertEqual(len(replaced), self.get_num_modules(torch.nn.Softmax)) + # all replaced modules should be ReLU + for r in replaced: + self.assertIsInstance(r[1], torch.nn.ReLU) + # if a specfic module was named, check that the name matches exactly + if name == "features.denseblock1.denselayer1.layers.relu1": + self.assertEqual(replaced[0][0], name) + + @parameterized.expand(TESTS) + def test_replace_context_manager(self, name, strict_match, match_device, device): + self.net.to(device) + with replace_modules_temp(self.net, name, torch.nn.Softmax(), strict_match, match_device): + self.check_replaced_modules(name, match_device) + # Check that model was correctly reverted + self.assertEqual(self.get_num_modules(), self.total) + self.assertEqual(self.get_num_modules(torch.nn.ReLU), self.num_relus) + self.assertEqual(self.get_num_modules(torch.nn.Softmax), 0) + + def test_raises(self): + # name doesn't exist in module + with self.assertRaises(AttributeError): + replace_modules(self.net, "non_existent_module", torch.nn.Softmax(), strict_match=True) + with self.assertRaises(AttributeError): + with replace_modules_temp(self.net, "non_existent_module", torch.nn.Softmax(), strict_match=True): + pass + + +if __name__ == "__main__": + unittest.main() From 7fbf3c4483437f80160bffdcbe857b6fe202211f Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 10 May 2022 12:22:22 -0400 Subject: [PATCH 057/183] Add GaussianSmooth as antialiasing filter in Resize (#4249) Signed-off-by: Can Zhao --- monai/transforms/spatial/array.py | 42 +++++++++++++++++++++++++++++++ tests/test_resize.py | 33 +++++++++++++++++++----- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 6b67762b95..65df5d2b1b 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -26,6 +26,7 @@ from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.networks.utils import meshgrid_ij, normalize_transform from monai.transforms.croppad.array import CenterSpatialCrop, Pad +from monai.transforms.intensity.array import GaussianSmooth from monai.transforms.transform import Randomizable, RandomizableTransform, ThreadUnsafe, Transform from monai.transforms.utils import ( create_control_grid, @@ -622,6 +623,15 @@ class Resize(Transform): align_corners: This only has an effect when mode is 'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + anti_aliasing: bool + Whether to apply a Gaussian filter to smooth the image prior + to downsampling. It is crucial to filter when downsampling + the image to avoid aliasing artifacts. See also ``skimage.transform.resize`` + anti_aliasing_sigma: {float, tuple of floats}, optional + Standard deviation for Gaussian filtering used when anti-aliasing. + By default, this value is chosen as (s - 1) / 2 where s is the + downsampling factor, where s > 1. For the up-size case, s < 1, no + anti-aliasing is performed prior to rescaling. """ backend = [TransformBackends.TORCH] @@ -632,17 +642,23 @@ def __init__( size_mode: str = "all", mode: Union[InterpolateMode, str] = InterpolateMode.AREA, align_corners: Optional[bool] = None, + anti_aliasing: bool = False, + anti_aliasing_sigma: Union[Sequence[float], float, None] = None, ) -> None: self.size_mode = look_up_option(size_mode, ["all", "longest"]) self.spatial_size = spatial_size self.mode: InterpolateMode = look_up_option(mode, InterpolateMode) self.align_corners = align_corners + self.anti_aliasing = anti_aliasing + self.anti_aliasing_sigma = anti_aliasing_sigma def __call__( self, img: NdarrayOrTensor, mode: Optional[Union[InterpolateMode, str]] = None, align_corners: Optional[bool] = None, + anti_aliasing: Optional[bool] = None, + anti_aliasing_sigma: Union[Sequence[float], float, None] = None, ) -> NdarrayOrTensor: """ Args: @@ -653,11 +669,23 @@ def __call__( align_corners: This only has an effect when mode is 'linear', 'bilinear', 'bicubic' or 'trilinear'. Defaults to ``self.align_corners``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + anti_aliasing: bool, optional + Whether to apply a Gaussian filter to smooth the image prior + to downsampling. It is crucial to filter when downsampling + the image to avoid aliasing artifacts. See also ``skimage.transform.resize`` + anti_aliasing_sigma: {float, tuple of floats}, optional + Standard deviation for Gaussian filtering used when anti-aliasing. + By default, this value is chosen as (s - 1) / 2 where s is the + downsampling factor, where s > 1. For the up-size case, s < 1, no + anti-aliasing is performed prior to rescaling. Raises: ValueError: When ``self.spatial_size`` length is less than ``img`` spatial dimensions. """ + anti_aliasing = self.anti_aliasing if anti_aliasing is None else anti_aliasing + anti_aliasing_sigma = self.anti_aliasing_sigma if anti_aliasing_sigma is None else anti_aliasing_sigma + img_, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float) if self.size_mode == "all": input_ndim = img_.ndim - 1 # spatial ndim @@ -677,6 +705,20 @@ def __call__( raise ValueError("spatial_size must be an int number if size_mode is 'longest'.") scale = self.spatial_size / max(img_size) spatial_size_ = tuple(int(round(s * scale)) for s in img_size) + + if anti_aliasing and any(x < y for x, y in zip(spatial_size_, img_.shape[1:])): + factors = torch.div(torch.Tensor(list(img_.shape[1:])), torch.Tensor(spatial_size_)) + if anti_aliasing_sigma is None: + # if sigma is not given, use the default sigma in skimage.transform.resize + anti_aliasing_sigma = torch.maximum(torch.zeros(factors.shape), (factors - 1) / 2).tolist() + else: + # if sigma is given, use the given value for downsampling axis + anti_aliasing_sigma = list(ensure_tuple_rep(anti_aliasing_sigma, len(spatial_size_))) + for axis in range(len(spatial_size_)): + anti_aliasing_sigma[axis] = anti_aliasing_sigma[axis] * int(factors[axis] > 1) + anti_aliasing_filter = GaussianSmooth(sigma=anti_aliasing_sigma) + img_ = anti_aliasing_filter(img_) + resized = torch.nn.functional.interpolate( input=img_.unsqueeze(0), size=spatial_size_, diff --git a/tests/test_resize.py b/tests/test_resize.py index 06246b2358..cb24cf2cc3 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -13,6 +13,7 @@ import numpy as np import skimage.transform +import torch from parameterized import parameterized from monai.transforms import Resize @@ -24,6 +25,10 @@ TEST_CASE_2 = [{"spatial_size": 6, "mode": "trilinear", "align_corners": True}, (2, 4, 6)] +TEST_CASE_3 = [{"spatial_size": 15, "anti_aliasing": True}, (6, 10, 15)] + +TEST_CASE_4 = [{"spatial_size": 6, "anti_aliasing": True, "anti_aliasing_sigma": 2.0}, (2, 4, 6)] + class TestResize(NumpyImageTestCase2D): def test_invalid_inputs(self): @@ -36,10 +41,15 @@ def test_invalid_inputs(self): resize(self.imt[0]) @parameterized.expand( - [((32, -1), "area"), ((32, 32), "area"), ((32, 32, 32), "trilinear"), ((256, 256), "bilinear")] + [ + ((32, -1), "area", True), + ((32, 32), "area", False), + ((32, 32, 32), "trilinear", True), + ((256, 256), "bilinear", False), + ] ) - def test_correct_results(self, spatial_size, mode): - resize = Resize(spatial_size, mode=mode) + def test_correct_results(self, spatial_size, mode, anti_aliasing): + resize = Resize(spatial_size, mode=mode, anti_aliasing=anti_aliasing) _order = 0 if mode.endswith("linear"): _order = 1 @@ -47,7 +57,7 @@ def test_correct_results(self, spatial_size, mode): spatial_size = (32, 64) expected = [ skimage.transform.resize( - channel, spatial_size, order=_order, clip=False, preserve_range=False, anti_aliasing=False + channel, spatial_size, order=_order, clip=False, preserve_range=False, anti_aliasing=anti_aliasing ) for channel in self.imt[0] ] @@ -55,9 +65,20 @@ def test_correct_results(self, spatial_size, mode): expected = np.stack(expected).astype(np.float32) for p in TEST_NDARRAYS: out = resize(p(self.imt[0])) - assert_allclose(out, expected, type_test=False, atol=0.9) + if not anti_aliasing: + assert_allclose(out, expected, type_test=False, atol=0.9) + else: + # skimage uses reflect padding for anti-aliasing filter. + # Our implementation reuses GaussianSmooth() as anti-aliasing filter, which uses zero padding instead. + # Thus their results near the image boundary will be different. + if isinstance(out, torch.Tensor): + out = out.cpu().detach().numpy() + good = np.sum(np.isclose(expected, out, atol=0.9)) + self.assertLessEqual( + np.abs(good - expected.size) / float(expected.size), 0.2, "at most 20 percent mismatch " + ) - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2]) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_longest_shape(self, input_param, expected_shape): input_data = np.random.randint(0, 2, size=[3, 4, 7, 10]) input_param["size_mode"] = "longest" From 557c12940e3e31be3f01f8031afcd6c027a8870d Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Wed, 11 May 2022 17:10:47 +0800 Subject: [PATCH 058/183] 4235 fix 2204 nvfuser issue (#4241) * reproduce issue Signed-off-by: Yiheng Wang * remove 22.01 02 Signed-off-by: Yiheng Wang * remove other workflows Signed-off-by: Yiheng Wang * run on pull request Signed-off-by: Yiheng Wang * remove sleep Signed-off-by: Yiheng Wang * test single layer forward Signed-off-by: Yiheng Wang * add has_nvfuser Signed-off-by: Yiheng Wang * add check within factory Signed-off-by: Yiheng Wang * revert to original cron.yml Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix old pt issue Signed-off-by: Yiheng Wang * change to return directly if no cuda Signed-off-by: Yiheng Wang Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/networks/layers/factories.py | 19 +++++++++++++++++-- tests/test_dynunet.py | 11 ++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/monai/networks/layers/factories.py b/monai/networks/layers/factories.py index b808c24de0..89fe1912a5 100644 --- a/monai/networks/layers/factories.py +++ b/monai/networks/layers/factories.py @@ -63,12 +63,14 @@ def use_factory(fact_args): import warnings from typing import Any, Callable, Dict, Tuple, Type, Union +import torch import torch.nn as nn from monai.utils import look_up_option, optional_import InstanceNorm3dNVFuser, has_nvfuser = optional_import("apex.normalization", name="InstanceNorm3dNVFuser") + __all__ = ["LayerFactory", "Dropout", "Norm", "Act", "Conv", "Pool", "Pad", "split_args"] @@ -263,8 +265,21 @@ def instance_nvfuser_factory(dim): if dim != 3: warnings.warn(f"`InstanceNorm3dNVFuser` only supports 3d cases, use {types[dim - 1]} instead.") return types[dim - 1] - if not has_nvfuser: - warnings.warn("`apex.normalization.InstanceNorm3dNVFuser` is not found, use nn.InstanceNorm3d instead.") + # test InstanceNorm3dNVFuser installation with a basic example + has_nvfuser_flag = has_nvfuser + if not torch.cuda.is_available(): + return nn.InstanceNorm3d + try: + layer = InstanceNorm3dNVFuser(num_features=1, affine=True).to("cuda:0") + inp = torch.randn([1, 1, 1, 1, 1]).to("cuda:0") + out = layer(inp) + del inp, out, layer + except Exception: + has_nvfuser_flag = False + if not has_nvfuser_flag: + warnings.warn( + "`apex.normalization.InstanceNorm3dNVFuser` is not installed properly, use nn.InstanceNorm3d instead." + ) return nn.InstanceNorm3d return InstanceNorm3dNVFuser diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index 14006b96e6..ff5d5efbef 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -17,11 +17,9 @@ from monai.networks import eval_mode from monai.networks.nets import DynUNet -from monai.utils import optional_import +from monai.utils.module import pytorch_after from tests.utils import skip_if_no_cuda, skip_if_windows, test_script_save -_, has_nvfuser = optional_import("apex.normalization", name="InstanceNorm3dNVFuser") - device = "cuda" if torch.cuda.is_available() else "cpu" strides: Sequence[Union[Sequence[int], int]] @@ -123,7 +121,6 @@ def test_script(self): @skip_if_no_cuda @skip_if_windows -@unittest.skipUnless(has_nvfuser, "To use `instance_nvfuser`, `apex.normalization.InstanceNorm3dNVFuser` is needed.") class TestDynUNetWithInstanceNorm3dNVFuser(unittest.TestCase): @parameterized.expand([TEST_CASE_DYNUNET_3D[0]]) def test_consistency(self, input_param, input_shape, _): @@ -145,7 +142,11 @@ def test_consistency(self, input_param, input_shape, _): with eval_mode(net_fuser): result_fuser = net_fuser(input_tensor) - torch.testing.assert_close(result, result_fuser) + # torch.testing.assert_allclose() is deprecated since 1.12 and will be removed in 1.14 + if pytorch_after(1, 12): + torch.testing.assert_close(result, result_fuser) + else: + torch.testing.assert_allclose(result, result_fuser) class TestDynUNetDeepSupervision(unittest.TestCase): From 56190ddb5881c263f495cac32753057b183d180d Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Wed, 11 May 2022 14:28:52 +0100 Subject: [PATCH 059/183] Update to Bundle Specifiation (#4250) * Update to bundle specifiation Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Adding description in spec discussing the saved Torchscript object's file storage behaviour, and tweaking ckpt_export to add .json extension Signed-off-by: Eric Kerfoot * Annotating optional bundle files Signed-off-by: Eric Kerfoot * Adjusted ckpt_export test Signed-off-by: Eric Kerfoot * Fix Signed-off-by: Eric Kerfoot Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/mb_specification.rst | 23 ++++++++++++++++------- monai/bundle/scripts.py | 7 ++++++- tests/test_bundle_ckpt_export.py | 8 +++++--- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/source/mb_specification.rst b/docs/source/mb_specification.rst index 1d286052a5..a88096f274 100644 --- a/docs/source/mb_specification.rst +++ b/docs/source/mb_specification.rst @@ -6,7 +6,7 @@ MONAI Bundle Specification Overview ======== -This is the specification for the MONAI Bundle (MB) format of portable described deep learning models. The objective of a MB is to define a packaged network or model which includes the critical information necessary to allow users and programs to understand how the model is used and for what purpose. A bundle includes the stored weights of a model as a pickled state dictionary and/or a Torchscript object. Additional JSON files are included to store metadata about the model, information for constructing training, inference, and post-processing transform sequences, plain-text description, legal information, and other data the model creator wishes to include. +This is the specification for the MONAI Bundle (MB) format of portable described deep learning models. The objective of a MB is to define a packaged network or model which includes the critical information necessary to allow users and programs to understand how the model is used and for what purpose. A bundle includes the stored weights of a single network as a pickled state dictionary plus optionally a Torchscript object and/or an ONNX object. Additional JSON files are included to store metadata about the model, information for constructing training, inference, and post-processing transform sequences, plain-text description, legal information, and other data the model creator wishes to include. This specification defines the directory structure a bundle must have and the necessary files it must contain. Additional files may be included and the directory packaged into a zip file or included as extra files directly in a Torchscript file. @@ -22,26 +22,35 @@ A MONAI Bundle is defined primarily as a directory with a set of specifically na ┃ ┗━ metadata.json ┣━ models ┃ ┣━ model.pt - ┃ ┗━ model.ts + ┃ ┣━ *model.ts + ┃ ┗━ *model.onnx ┗━ docs - ┣━ README.md - ┗━ license.txt + ┣━ *README.md + ┗━ *license.txt -These files mostly are required to be present with the given names for the directory to define a valid bundle: +The following files are **required** to be present with the given filenames for the directory to define a valid bundle: * **metadata.json**: metadata information in JSON format relating to the type of model, definition of input and output tensors, versions of the model and used software, and other information described below. * **model.pt**: the state dictionary of a saved model, the information to instantiate the model must be found in the metadata file. + +The following files are optional but must have these names in the directory given above: + * **model.ts**: the Torchscript saved model if the model is compatible with being saved correctly in this format. +* **model.onnx**: the ONNX model if the model is compatible with being saved correctly in this format. * **README.md**: plain-language information on the model, how to use it, author information, etc. in Markdown format. * **license.txt**: software license attached to the model, can be left blank if no license needed. +Other files can be included in any of the above directories. For example, `configs` can contain further configuration JSON or YAML files to define scripts for training or inference, overriding configuration values, environment definitions such as network instantiations, and so forth. One common file to include is `inference.json` which is used to define a basic inference script which uses input files with the stored network to produce prediction output files. + Archive Format ============== -The bundle directory and its contents can be compressed into a zip file to constitute a single file package. When unzipped into a directory this file will reproduce the above directory structure, and should itself also be named after the model it contains. +The bundle directory and its contents can be compressed into a zip file to constitute a single file package. When unzipped into a directory this file will reproduce the above directory structure, and should itself also be named after the model it contains. For example, `ModelName.zip` would contain at least `ModelName/configs/metadata.json` and `ModelName/models/model.pt`, thus when unzipped would place files into the directory `ModelName` rather than into the current working directory. + +The Torchscript file format is also just a zip file with a specific structure. When creating such an archive with `save_net_with_metadata` a MB-compliant Torchscript file can be created by including the contents of `metadata.json` as the `meta_values` argument of the function, and other files included as `more_extra_files` entries. These will be stored in a `extras` directory in the zip file and can be retrieved with `load_net_with_metadata` or with any other library/tool that can read zip data. In this format the `model.*` files are obviously not needed but `README.md` and `license.txt` as well as any others provided can be added as more extra files. -The Torchscript file format is also just a zip file with a specific structure. When creating such an archive with `save_net_with_metadata` a MB-compliant Torchscript file can be created by including the contents of `metadata.json` as the `meta_values` argument of the function, and other files included as `more_extra_files` entries. These will be stored in a `extras` directory in the zip file and can be retrieved with `load_net_with_metadata` or with any other library/tool that can read zip data. In this format the `model.*` files are obviously not needed by `README.md` and `license.txt` can be added as more extra files. +The `bundle` submodule of MONAI contains a number of command line programs. To produce a Torchscript bundle use `ckpt_export` with a set of specified components such as the saved weights file and metadata file. Config files can be provided as JSON or YAML dictionaries defining Python constructs used by the `ConfigParser`, however regardless of format the produced bundle Torchscript object will store the files as JSON. metadata.json File ================== diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index e5b306a90d..3c838d55a0 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -600,10 +600,15 @@ def ckpt_export( filename = os.path.basename(i) # remove extension filename, _ = os.path.splitext(filename) + # because all files are stored as JSON their name parts without extension must be unique if filename in extra_files: - raise ValueError(f"filename '{filename}' is given multiple times in config file list.") + raise ValueError(f"Filename part '{filename}' is given multiple times in config file list.") + # the file may be JSON or YAML but will get loaded and dumped out again as JSON extra_files[filename] = json.dumps(ConfigParser.load_config_file(i)).encode() + # add .json extension to all extra files which are always encoded as JSON + extra_files = {k + ".json": v for k, v in extra_files.items()} + save_net_with_metadata( jit_obj=net, filename_prefix_or_stream=filepath_, diff --git a/tests/test_bundle_ckpt_export.py b/tests/test_bundle_ckpt_export.py index 36aa7319f0..a7cbff22f0 100644 --- a/tests/test_bundle_ckpt_export.py +++ b/tests/test_bundle_ckpt_export.py @@ -52,10 +52,12 @@ def test_export(self, key_in_ckpt): subprocess.check_call(cmd) self.assertTrue(os.path.exists(ts_file)) - _, metadata, extra_files = load_net_with_metadata(ts_file, more_extra_files=["inference", "def_args"]) + _, metadata, extra_files = load_net_with_metadata( + ts_file, more_extra_files=["inference.json", "def_args.json"] + ) self.assertTrue("schema" in metadata) - self.assertTrue("meta_file" in json.loads(extra_files["def_args"])) - self.assertTrue("network_def" in json.loads(extra_files["inference"])) + self.assertTrue("meta_file" in json.loads(extra_files["def_args.json"])) + self.assertTrue("network_def" in json.loads(extra_files["inference.json"])) if __name__ == "__main__": From 0bbeb1e084d1c06a5f14af70ad340c798a1567aa Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 11 May 2022 18:08:20 +0100 Subject: [PATCH 060/183] gradient based saliency (#4236) * gradient based saliency Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fixes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * replace modules Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * reuse modelwithhooks Signed-off-by: Wenqi Li * fixes unit test Signed-off-by: Wenqi Li * support of empty target Signed-off-by: Wenqi Li * remove module.backward, add docstring Signed-off-by: Wenqi Li Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Co-authored-by: Wenqi Li --- monai/visualize/__init__.py | 1 + monai/visualize/class_activation_maps.py | 6 +- monai/visualize/gradient_based.py | 137 +++++++++++++++++++++++ tests/test_vis_gradbased.py | 50 +++++++++ tests/test_vis_gradcam.py | 2 +- 5 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 monai/visualize/gradient_based.py create mode 100644 tests/test_vis_gradbased.py diff --git a/monai/visualize/__init__.py b/monai/visualize/__init__.py index cd980846b3..49a628b66f 100644 --- a/monai/visualize/__init__.py +++ b/monai/visualize/__init__.py @@ -10,6 +10,7 @@ # limitations under the License. from .class_activation_maps import CAM, GradCAM, GradCAMpp, ModelWithHooks, default_normalizer +from .gradient_based import GuidedBackpropGrad, GuidedBackpropSmoothGrad, SmoothGrad, VanillaGrad from .img2tensorboard import add_animated_gif, make_animated_gif_summary, plot_2d_or_3d_image from .occlusion_sensitivity import OcclusionSensitivity from .utils import blend_images, matshow3d diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index 16fb64cb46..ba1f5d2589 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -89,7 +89,7 @@ def __init__( mod.register_backward_hook(self.backward_hook(name)) if self.register_forward: mod.register_forward_hook(self.forward_hook(name)) - if len(_registered) != len(self.target_layers): + if self.target_layers and (len(_registered) != len(self.target_layers)): warnings.warn(f"Not all target_layers exist in the network module: targets: {self.target_layers}.") def backward_hook(self, name): @@ -139,10 +139,10 @@ def __call__(self, x, class_idx=None, retain_graph=False): self.score.sum().backward(retain_graph=retain_graph) for layer in self.target_layers: if layer not in self.gradients: - raise RuntimeError( + warnings.warn( f"Backward hook for {layer} is not triggered; `requires_grad` of {layer} should be `True`." ) - grad = tuple(self.gradients[layer] for layer in self.target_layers) + grad = tuple(self.gradients[layer] for layer in self.target_layers if layer in self.gradients) if train: self.model.train() return logits, acti, grad diff --git a/monai/visualize/gradient_based.py b/monai/visualize/gradient_based.py new file mode 100644 index 0000000000..32b8110b6d --- /dev/null +++ b/monai/visualize/gradient_based.py @@ -0,0 +1,137 @@ +# 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 + +from functools import partial +from typing import Callable + +import torch + +from monai.networks.utils import replace_modules_temp +from monai.utils.module import optional_import +from monai.visualize.class_activation_maps import ModelWithHooks + +trange, has_trange = optional_import("tqdm", name="trange") + + +__all__ = ["VanillaGrad", "SmoothGrad", "GuidedBackpropGrad", "GuidedBackpropSmoothGrad"] + + +class _AutoGradReLU(torch.autograd.Function): + @staticmethod + def forward(ctx, x): + pos_mask = (x > 0).type_as(x) + output = torch.mul(x, pos_mask) + ctx.save_for_backward(x, output) + return output + + @staticmethod + def backward(ctx, grad_output): + x, _ = ctx.saved_tensors + pos_mask_1 = (x > 0).type_as(grad_output) + pos_mask_2 = (grad_output > 0).type_as(grad_output) + y = torch.mul(grad_output, pos_mask_1) + grad_input = torch.mul(y, pos_mask_2) + return grad_input + + +class _GradReLU(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + out: torch.Tensor = _AutoGradReLU.apply(x) + return out + + +class VanillaGrad: + def __init__(self, model: torch.nn.Module) -> None: + if not isinstance(model, ModelWithHooks): # Convert to model with hooks if necessary + self._model = ModelWithHooks(model, target_layer_names=(), register_backward=True) + else: + self._model = model + + @property + def model(self): + return self._model.model + + @model.setter + def model(self, m): + if not isinstance(m, ModelWithHooks): # regular model as ModelWithHooks + self._model.model = m + else: + self._model = m # replace the ModelWithHooks + + def get_grad(self, x: torch.Tensor, index: torch.Tensor | int | None, retain_graph=True) -> torch.Tensor: + if x.shape[0] != 1: + raise ValueError("expect batch size of 1") + x.requires_grad = True + + self._model(x, class_idx=index, retain_graph=retain_graph) + grad: torch.Tensor = x.grad.detach() + return grad + + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + return self.get_grad(x, index) + + +class SmoothGrad(VanillaGrad): + """ + See also: + - Smilkov et al. SmoothGrad: removing noise by adding noise https://arxiv.org/abs/1706.03825 + """ + + def __init__( + self, + model: torch.nn.Module, + stdev_spread: float = 0.15, + n_samples: int = 25, + magnitude: bool = True, + verbose: bool = True, + ) -> None: + super().__init__(model) + self.stdev_spread = stdev_spread + self.n_samples = n_samples + self.magnitude = magnitude + self.range: Callable + if verbose and has_trange: + self.range = partial(trange, desc=f"Computing {self.__class__.__name__}") + else: + self.range = range + + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + stdev = (self.stdev_spread * (x.max() - x.min())).item() + total_gradients = torch.zeros_like(x) + for _ in self.range(self.n_samples): + # create noisy image + noise = torch.normal(0, stdev, size=x.shape, dtype=torch.float32, device=x.device) + x_plus_noise = x + noise + x_plus_noise = x_plus_noise.detach() + + # get gradient and accumulate + grad = self.get_grad(x_plus_noise, index) + total_gradients += (grad * grad) if self.magnitude else grad + + # average + if self.magnitude: + total_gradients = total_gradients**0.5 + + return total_gradients / self.n_samples + + +class GuidedBackpropGrad(VanillaGrad): + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + with replace_modules_temp(self.model, "relu", _GradReLU(), strict_match=False): + return super().__call__(x, index) + + +class GuidedBackpropSmoothGrad(SmoothGrad): + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + with replace_modules_temp(self.model, "relu", _GradReLU(), strict_match=False): + return super().__call__(x, index) diff --git a/tests/test_vis_gradbased.py b/tests/test_vis_gradbased.py new file mode 100644 index 0000000000..7655ca661e --- /dev/null +++ b/tests/test_vis_gradbased.py @@ -0,0 +1,50 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 +from monai.visualize import GuidedBackpropGrad, GuidedBackpropSmoothGrad, SmoothGrad, VanillaGrad + +DENSENET2D = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) +DENSENET3D = DenseNet(spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,)) +SENET2D = SEResNet50(spatial_dims=2, in_channels=3, num_classes=4) +SENET3D = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) + +TESTS = [] +for type in (VanillaGrad, SmoothGrad, GuidedBackpropGrad, GuidedBackpropSmoothGrad): + # 2D densenet + TESTS.append([type, DENSENET2D, (1, 1, 48, 64), (1, 1, 48, 64)]) + # 3D densenet + TESTS.append([type, DENSENET3D, (1, 1, 6, 6, 6), (1, 1, 6, 6, 6)]) + # 2D senet + TESTS.append([type, SENET2D, (1, 3, 64, 64), (1, 1, 64, 64)]) + # 3D senet + TESTS.append([type, SENET3D, (1, 3, 8, 8, 48), (1, 1, 8, 8, 48)]) + + +class TestGradientClassActivationMap(unittest.TestCase): + @parameterized.expand(TESTS) + def test_shape(self, vis_type, model, shape, expected_shape): + device = "cuda:0" if torch.cuda.is_available() else "cpu" + model.to(device) + model.eval() + vis = vis_type(model) + x = torch.rand(shape, device=device) + result = vis(x) + self.assertTupleEqual(result.shape, x.shape) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vis_gradcam.py b/tests/test_vis_gradcam.py index acca06d405..755f4d49ae 100644 --- a/tests/test_vis_gradcam.py +++ b/tests/test_vis_gradcam.py @@ -85,7 +85,7 @@ def test_ill(self): x.requires_grad = False cam = GradCAM(nn_module=model, target_layers="class_layers.relu") image = torch.rand((2, 1, 48, 64)) - with self.assertRaises(RuntimeError): + with self.assertRaises(IndexError): cam(x=image) From 812275ae5f4a1bc8a69b70a9823632b6f30a5724 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 12 May 2022 18:37:08 +0800 Subject: [PATCH 061/183] 4256 Fix wrong spatial shape in SampleCrop transforms (#4263) * [DLMED] fix spatial shape issue Signed-off-by: Nic Ma * fixes codeql analysis Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> Co-authored-by: Wenqi Li --- .github/workflows/codeql-analysis.yml | 6 +++--- monai/transforms/croppad/array.py | 12 ++++++------ monai/transforms/croppad/dictionary.py | 20 +++++++++----------- tests/test_rand_crop_by_pos_neg_label.py | 1 + tests/test_rand_crop_by_pos_neg_labeld.py | 1 + 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4e72a7dbcf..3f8bfb345d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) # - name: Autobuild - # uses: github/codeql-action/autobuild@v1 + # uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: BUILD_MONAI=1 ./runtests.sh --build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 55718117b4..330c5124ee 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -903,7 +903,7 @@ def __init__( bg_indices: Optional[NdarrayOrTensor] = None, allow_smaller: bool = False, ) -> None: - self.spatial_size = ensure_tuple(spatial_size) + self.spatial_size = spatial_size self.label = label if pos < 0 or neg < 0: raise ValueError(f"pos and neg must be nonnegative, got pos={pos} neg={neg}.") @@ -925,7 +925,6 @@ def randomize( bg_indices: Optional[NdarrayOrTensor] = None, image: Optional[NdarrayOrTensor] = None, ) -> None: - self.spatial_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) if fg_indices is None or bg_indices is None: if self.fg_indices is not None and self.bg_indices is not None: fg_indices_ = self.fg_indices @@ -979,7 +978,8 @@ def __call__( results: List[NdarrayOrTensor] = [] if self.centers is not None: for center in self.centers: - cropper = SpatialCrop(roi_center=center, roi_size=self.spatial_size) + roi_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) + cropper = SpatialCrop(roi_center=center, roi_size=roi_size) results.append(cropper(img)) return results @@ -1061,7 +1061,7 @@ def __init__( indices: Optional[List[NdarrayOrTensor]] = None, allow_smaller: bool = False, ) -> None: - self.spatial_size = ensure_tuple(spatial_size) + self.spatial_size = spatial_size self.ratios = ratios self.label = label self.num_classes = num_classes @@ -1078,7 +1078,6 @@ def randomize( indices: Optional[List[NdarrayOrTensor]] = None, image: Optional[NdarrayOrTensor] = None, ) -> None: - self.spatial_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) indices_: Sequence[NdarrayOrTensor] if indices is None: if self.indices is not None: @@ -1119,7 +1118,8 @@ def __call__( results: List[NdarrayOrTensor] = [] if self.centers is not None: for center in self.centers: - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) + roi_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) results.append(cropper(img)) return results diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 79f4040018..d00a89821b 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -1144,7 +1144,6 @@ def randomize( bg_indices: Optional[NdarrayOrTensor] = None, image: Optional[NdarrayOrTensor] = None, ) -> None: - self.spatial_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) if fg_indices is None or bg_indices is None: fg_indices_, bg_indices_ = map_binary_to_indices(label, image, self.image_threshold) else: @@ -1169,8 +1168,6 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab bg_indices = d.pop(self.bg_indices_key, None) if self.bg_indices_key is not None else None self.randomize(label, fg_indices, bg_indices, image) - if not isinstance(self.spatial_size, tuple): - raise ValueError("spatial_size must be a valid tuple.") if self.centers is None: raise ValueError("no available ROI centers to crop.") @@ -1183,8 +1180,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab results[i][key] = deepcopy(d[key]) for key in self.key_iterator(d): img = d[key] - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) orig_size = img.shape[1:] + roi_size = fall_back_tuple(self.spatial_size, default=orig_size) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) results[i][key] = cropper(img) self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) # add `patch_index` to the meta data @@ -1204,7 +1202,8 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd orig_size = np.asarray(transform[TraceKeys.ORIG_SIZE]) current_size = np.asarray(d[key].shape[1:]) center = transform[TraceKeys.EXTRA_INFO]["center"] - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore + roi_size = fall_back_tuple(self.spatial_size, default=orig_size) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) # type: ignore # get required pad to start and end pad_to_start = np.array([s.indices(o)[0] for s, o in zip(cropper.slices, orig_size)]) pad_to_end = orig_size - current_size - pad_to_start @@ -1318,7 +1317,7 @@ def __init__( ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) self.label_key = label_key - self.spatial_size: Union[Tuple[int, ...], Sequence[int], int] = spatial_size + self.spatial_size = spatial_size self.ratios = ratios self.num_classes = num_classes self.num_samples = num_samples @@ -1338,7 +1337,6 @@ def randomize( indices: Optional[List[NdarrayOrTensor]] = None, image: Optional[NdarrayOrTensor] = None, ) -> None: - self.spatial_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) if indices is None: indices_ = map_classes_to_indices(label, self.num_classes, image, self.image_threshold) else: @@ -1354,8 +1352,6 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, NdarrayO indices = d.pop(self.indices_key, None) if self.indices_key is not None else None self.randomize(label, indices, image) - if not isinstance(self.spatial_size, tuple): - raise ValueError("spatial_size must be a valid tuple.") if self.centers is None: raise ValueError("no available ROI centers to crop.") @@ -1368,8 +1364,9 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, NdarrayO results[i][key] = deepcopy(d[key]) for key in self.key_iterator(d): img = d[key] - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) orig_size = img.shape[1:] + roi_size = fall_back_tuple(self.spatial_size, default=orig_size) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) results[i][key] = cropper(img) self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) # add `patch_index` to the meta data @@ -1389,7 +1386,8 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd orig_size = np.asarray(transform[TraceKeys.ORIG_SIZE]) current_size = np.asarray(d[key].shape[1:]) center = transform[TraceKeys.EXTRA_INFO]["center"] - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore + roi_size = fall_back_tuple(self.spatial_size, default=orig_size) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) # type: ignore # get required pad to start and end pad_to_start = np.array([s.indices(o)[0] for s, o in zip(cropper.slices, orig_size)]) pad_to_end = orig_size - current_size - pad_to_start diff --git a/tests/test_rand_crop_by_pos_neg_label.py b/tests/test_rand_crop_by_pos_neg_label.py index f8b8a77a45..1cdc3d8c76 100644 --- a/tests/test_rand_crop_by_pos_neg_label.py +++ b/tests/test_rand_crop_by_pos_neg_label.py @@ -118,6 +118,7 @@ def test_type_shape(self, input_param, input_data, expected_shape): cropper = RandCropByPosNegLabel(**input_param_mod) cropper.set_random_state(0) result = cropper(**input_data_mod) + self.assertListEqual(cropper.spatial_size, input_param["spatial_size"]) self.assertIsInstance(result, list) self.assertTupleEqual(result[0].shape, expected_shape) diff --git a/tests/test_rand_crop_by_pos_neg_labeld.py b/tests/test_rand_crop_by_pos_neg_labeld.py index df85b29b00..5a1eacfa93 100644 --- a/tests/test_rand_crop_by_pos_neg_labeld.py +++ b/tests/test_rand_crop_by_pos_neg_labeld.py @@ -137,6 +137,7 @@ def test_type_shape(self, input_param, input_data, expected_shape): cropper = RandCropByPosNegLabeld(**input_param_mod) cropper.set_random_state(0) result = cropper(input_data_mod) + self.assertListEqual(cropper.spatial_size, input_param["spatial_size"]) self.assertIsInstance(result, list) From 1dfeb7430c37524c0dab02427a5489c329f77b66 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Fri, 13 May 2022 08:05:48 -0400 Subject: [PATCH 062/183] Split patch functionality for `PatchWSIReader` (#4251) * Add split patch support to patch wsi dataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Rename the split test Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update GridSplit and revert PatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add support for various sizes to split different keys Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Few fixes Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Fix imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * simplify return for grid (1, 1) Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docsting for size in gridsplitd Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 8 ++-- monai/data/wsi_reader.py | 2 +- monai/transforms/spatial/array.py | 60 +++++++++++++------------- monai/transforms/spatial/dictionary.py | 19 +++++--- tests/test_grid_split.py | 28 ++++++------ tests/test_grid_splitd.py | 42 ++++++++---------- tests/test_patch_wsi_dataset_new.py | 2 +- 7 files changed, 83 insertions(+), 78 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 750b3fda20..665cbd196c 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -127,11 +127,13 @@ def _get_data(self, sample: Dict): def _transform(self, index: int): # Get a single entry of data sample: Dict = self.data[index] + # Extract patch image and associated metadata image, metadata = self._get_data(sample) + # Get the label label = self._get_label(sample) - # Create put all patch information together and apply transforms - patch = {"image": image, "label": label, "metadata": metadata} - return apply_transform(self.transform, patch) if self.transform else patch + # Apply transforms and output + output = {"image": image, "label": label, "metadata": metadata} + return apply_transform(self.transform, output) if self.transform else output diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 8dee1f453e..00277ee0af 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -174,7 +174,7 @@ def get_data( # Verify location if location is None: location = (0, 0) - wsi_size = self.get_size(each_wsi, level) + wsi_size = self.get_size(each_wsi, 0) if location[0] > wsi_size[0] or location[1] > wsi_size[1]: raise ValueError(f"Location is outside of the image: location={location}, image size={wsi_size}") diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 65df5d2b1b..6568e2c5d0 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2535,62 +2535,62 @@ def __init__(self, grid: Tuple[int, int] = (2, 2), size: Optional[Union[int, Tup # Patch size self.size = None if size is None else ensure_tuple_rep(size, len(self.grid)) - def __call__(self, image: NdarrayOrTensor) -> NdarrayOrTensor: - if self.grid == (1, 1) and self.size is None: - if isinstance(image, torch.Tensor): - return torch.stack([image]) - elif isinstance(image, np.ndarray): - return np.stack([image]) # type: ignore - else: - raise ValueError(f"Input type [{type(image)}] is not supported.") + def __call__( + self, image: NdarrayOrTensor, size: Optional[Union[int, Tuple[int, int], np.ndarray]] = None + ) -> List[NdarrayOrTensor]: + input_size = self.size if size is None else ensure_tuple_rep(size, len(self.grid)) + + if self.grid == (1, 1) and input_size is None: + return [image] - size, steps = self._get_params(image.shape[1:]) - patches: NdarrayOrTensor + split_size, steps = self._get_params(image.shape[1:], input_size) + patches: List[NdarrayOrTensor] if isinstance(image, torch.Tensor): - patches = ( - image.unfold(1, size[0], steps[0]) - .unfold(2, size[1], steps[1]) + unfolded_image = ( + image.unfold(1, split_size[0], steps[0]) + .unfold(2, split_size[1], steps[1]) .flatten(1, 2) .transpose(0, 1) - .contiguous() ) + # Make a list of contiguous patches + patches = [p.contiguous() for p in unfolded_image] elif isinstance(image, np.ndarray): x_step, y_step = steps c_stride, x_stride, y_stride = image.strides n_channels = image.shape[0] - patches = as_strided( + strided_image = as_strided( image, - shape=(*self.grid, n_channels, size[0], size[1]), + shape=(*self.grid, n_channels, split_size[0], split_size[1]), strides=(x_stride * x_step, y_stride * y_step, c_stride, x_stride, y_stride), writeable=False, ) - # flatten the first two dimensions - patches = patches.reshape(np.prod(patches.shape[:2]), *patches.shape[2:]) - # make it a contiguous array - patches = np.ascontiguousarray(patches) + # Flatten the first two dimensions + strided_image = strided_image.reshape(-1, *strided_image.shape[2:]) + # Make a list of contiguous patches + patches = [np.ascontiguousarray(p) for p in strided_image] else: raise ValueError(f"Input type [{type(image)}] is not supported.") return patches - def _get_params(self, image_size: Union[Sequence[int], np.ndarray]): + def _get_params( + self, image_size: Union[Sequence[int], np.ndarray], size: Optional[Union[Sequence[int], np.ndarray]] = None + ): """ Calculate the size and step required for splitting the image Args: The size of the input image """ - if self.size is not None: - # Set the split size to the given default size - if any(self.size[i] > image_size[i] for i in range(len(self.grid))): - raise ValueError("The image size ({image_size})is smaller than the requested split size ({self.size})") - split_size = self.size - else: + if size is None: # infer each sub-image size from the image size and the grid - split_size = tuple(image_size[i] // self.grid[i] for i in range(len(self.grid))) + size = tuple(image_size[i] // self.grid[i] for i in range(len(self.grid))) + + if any(size[i] > image_size[i] for i in range(len(self.grid))): + raise ValueError(f"The image size ({image_size})is smaller than the requested split size ({size})") steps = tuple( - (image_size[i] - split_size[i]) // (self.grid[i] - 1) if self.grid[i] > 1 else image_size[i] + (image_size[i] - size[i]) // (self.grid[i] - 1) if self.grid[i] > 1 else image_size[i] for i in range(len(self.grid)) ) - return split_size, steps + return size, steps diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 47fe05700e..d354c89dbc 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -2160,7 +2160,8 @@ class GridSplitd(MapTransform): Args: keys: keys of the corresponding items to be transformed. grid: a tuple define the shape of the grid upon which the image is split. Defaults to (2, 2) - size: a tuple or an integer that defines the output patch sizes. + size: a tuple or an integer that defines the output patch sizes, + or a dictionary that define it seperately for each key, like {"image": 3, "mask", (2, 2)}. If it's an integer, the value will be repeated for each dimension. The default is None, where the patch size will be inferred from the grid shape. allow_missing_keys: don't raise exception if key is missing. @@ -2174,17 +2175,23 @@ def __init__( self, keys: KeysCollection, grid: Tuple[int, int] = (2, 2), - size: Optional[Union[int, Tuple[int, int]]] = None, + size: Optional[Union[int, Tuple[int, int], Dict[Hashable, Union[int, Tuple[int, int], None]]]] = None, allow_missing_keys: bool = False, ): super().__init__(keys, allow_missing_keys) - self.splitter = GridSplit(grid=grid, size=size) + self.grid = grid + self.size = size if isinstance(size, dict) else {key: size for key in self.keys} + self.splitter = GridSplit(grid=grid) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashable, NdarrayOrTensor]]: d = dict(data) + n_outputs = np.prod(self.grid) + output: List[Dict[Hashable, NdarrayOrTensor]] = [dict(d) for _ in range(n_outputs)] for key in self.key_iterator(d): - d[key] = self.splitter(d[key]) - return d + result = self.splitter(d[key], self.size[key]) + for i in range(n_outputs): + output[i][key] = result[i] + return output SpatialResampleD = SpatialResampleDict = SpatialResampled diff --git a/tests/test_grid_split.py b/tests/test_grid_split.py index 6f0525029d..82734ffd93 100644 --- a/tests/test_grid_split.py +++ b/tests/test_grid_split.py @@ -26,14 +26,14 @@ A2 = torch.cat([A21, A22], 2) A = torch.cat([A1, A2], 1) -TEST_CASE_0 = [{"grid": (2, 2)}, A, torch.stack([A11, A12, A21, A22])] -TEST_CASE_1 = [{"grid": (2, 1)}, A, torch.stack([A1, A2])] -TEST_CASE_2 = [{"grid": (1, 2)}, A1, torch.stack([A11, A12])] -TEST_CASE_3 = [{"grid": (1, 2)}, A2, torch.stack([A21, A22])] -TEST_CASE_4 = [{"grid": (1, 1), "size": (2, 2)}, A, torch.stack([A11])] -TEST_CASE_5 = [{"grid": (1, 1), "size": 4}, A, torch.stack([A])] -TEST_CASE_6 = [{"grid": (2, 2), "size": 2}, A, torch.stack([A11, A12, A21, A22])] -TEST_CASE_7 = [{"grid": (1, 1)}, A, torch.stack([A])] +TEST_CASE_0 = [{"grid": (2, 2)}, A, [A11, A12, A21, A22]] +TEST_CASE_1 = [{"grid": (2, 1)}, A, [A1, A2]] +TEST_CASE_2 = [{"grid": (1, 2)}, A1, [A11, A12]] +TEST_CASE_3 = [{"grid": (1, 2)}, A2, [A21, A22]] +TEST_CASE_4 = [{"grid": (1, 1), "size": (2, 2)}, A, [A11]] +TEST_CASE_5 = [{"grid": (1, 1), "size": 4}, A, [A]] +TEST_CASE_6 = [{"grid": (2, 2), "size": 2}, A, [A11, A12, A21, A22]] +TEST_CASE_7 = [{"grid": (1, 1)}, A, [A]] TEST_CASE_8 = [ {"grid": (2, 2), "size": 2}, torch.arange(12).reshape(1, 3, 4).to(torch.float32), @@ -52,9 +52,9 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) -TEST_CASE_MC_0 = [{"grid": (2, 2)}, [A, A], [torch.stack([A11, A12, A21, A22]), torch.stack([A11, A12, A21, A22])]] -TEST_CASE_MC_1 = [{"grid": (2, 1)}, [A] * 5, [torch.stack([A1, A2])] * 5] -TEST_CASE_MC_2 = [{"grid": (1, 2)}, [A1, A2], [torch.stack([A11, A12]), torch.stack([A21, A22])]] +TEST_CASE_MC_0 = [{"grid": (2, 2)}, [A, A], [[A11, A12, A21, A22], [A11, A12, A21, A22]]] +TEST_CASE_MC_1 = [{"grid": (2, 1)}, [A] * 5, [[A1, A2]] * 5] +TEST_CASE_MC_2 = [{"grid": (1, 2)}, [A1, A2], [[A11, A12], [A21, A22]]] TEST_MULTIPLE = [] for p in TEST_NDARRAYS: @@ -69,7 +69,8 @@ def test_split_patch_single_call(self, in_type, input_parameters, image, expecte input_image = in_type(image) splitter = GridSplit(**input_parameters) output = splitter(input_image) - assert_allclose(output, expected, type_test=False) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch, expected_patch, type_test=False) @parameterized.expand(TEST_MULTIPLE) def test_split_patch_multiple_call(self, in_type, input_parameters, img_list, expected_list): @@ -77,7 +78,8 @@ def test_split_patch_multiple_call(self, in_type, input_parameters, img_list, ex for image, expected in zip(img_list, expected_list): input_image = in_type(image) output = splitter(input_image) - assert_allclose(output, expected, type_test=False) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch, expected_patch, type_test=False) if __name__ == "__main__": diff --git a/tests/test_grid_splitd.py b/tests/test_grid_splitd.py index f325a16946..086dd2691d 100644 --- a/tests/test_grid_splitd.py +++ b/tests/test_grid_splitd.py @@ -26,16 +26,16 @@ A2 = torch.cat([A21, A22], 2) A = torch.cat([A1, A2], 1) -TEST_CASE_0 = [{"keys": "image", "grid": (2, 2)}, {"image": A}, torch.stack([A11, A12, A21, A22])] -TEST_CASE_1 = [{"keys": "image", "grid": (2, 1)}, {"image": A}, torch.stack([A1, A2])] -TEST_CASE_2 = [{"keys": "image", "grid": (1, 2)}, {"image": A1}, torch.stack([A11, A12])] -TEST_CASE_3 = [{"keys": "image", "grid": (1, 2)}, {"image": A2}, torch.stack([A21, A22])] -TEST_CASE_4 = [{"keys": "image", "grid": (1, 1), "size": (2, 2)}, {"image": A}, torch.stack([A11])] -TEST_CASE_5 = [{"keys": "image", "grid": (1, 1), "size": 4}, {"image": A}, torch.stack([A])] -TEST_CASE_6 = [{"keys": "image", "grid": (2, 2), "size": 2}, {"image": A}, torch.stack([A11, A12, A21, A22])] -TEST_CASE_7 = [{"keys": "image", "grid": (1, 1)}, {"image": A}, torch.stack([A])] +TEST_CASE_0 = [{"keys": "image", "grid": (2, 2)}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_1 = [{"keys": "image", "grid": (2, 1)}, {"image": A}, [A1, A2]] +TEST_CASE_2 = [{"keys": "image", "grid": (1, 2)}, {"image": A1}, [A11, A12]] +TEST_CASE_3 = [{"keys": "image", "grid": (1, 2)}, {"image": A2}, [A21, A22]] +TEST_CASE_4 = [{"keys": "image", "grid": (1, 1), "size": {"image": (2, 2)}}, {"image": A}, [A11]] +TEST_CASE_5 = [{"keys": "image", "grid": (1, 1), "size": {"image": 4}}, {"image": A}, [A]] +TEST_CASE_6 = [{"keys": "image", "grid": (2, 2), "size": {"image": 2}}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_7 = [{"keys": "image", "grid": (1, 1)}, {"image": A}, [A]] TEST_CASE_8 = [ - {"keys": "image", "grid": (2, 2), "size": 2}, + {"keys": "image", "grid": (2, 2), "size": {"image": 2}}, {"image": torch.arange(12).reshape(1, 3, 4).to(torch.float32)}, torch.Tensor([[[[0, 1], [4, 5]]], [[[2, 3], [6, 7]]], [[[4, 5], [8, 9]]], [[[6, 7], [10, 11]]]]).to(torch.float32), ] @@ -55,18 +55,10 @@ TEST_CASE_MC_0 = [ {"keys": "image", "grid": (2, 2)}, [{"image": A}, {"image": A}], - [torch.stack([A11, A12, A21, A22]), torch.stack([A11, A12, A21, A22])], -] -TEST_CASE_MC_1 = [ - {"keys": "image", "grid": (2, 1)}, - [{"image": A}, {"image": A}, {"image": A}], - [torch.stack([A1, A2])] * 3, -] -TEST_CASE_MC_2 = [ - {"keys": "image", "grid": (1, 2)}, - [{"image": A1}, {"image": A2}], - [torch.stack([A11, A12]), torch.stack([A21, A22])], + [[A11, A12, A21, A22], [A11, A12, A21, A22]], ] +TEST_CASE_MC_1 = [{"keys": "image", "grid": (2, 1)}, [{"image": A}, {"image": A}, {"image": A}], [[A1, A2]] * 3] +TEST_CASE_MC_2 = [{"keys": "image", "grid": (1, 2)}, [{"image": A1}, {"image": A2}], [[A11, A12], [A21, A22]]] TEST_MULTIPLE = [] for p in TEST_NDARRAYS: @@ -82,8 +74,9 @@ def test_split_patch_single_call(self, in_type, input_parameters, img_dict, expe for k, v in img_dict.items(): input_dict[k] = in_type(v) splitter = GridSplitd(**input_parameters) - output = splitter(input_dict)[input_parameters["keys"]] - assert_allclose(output, expected, type_test=False) + output = splitter(input_dict) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch[input_parameters["keys"]], expected_patch, type_test=False) @parameterized.expand(TEST_MULTIPLE) def test_split_patch_multiple_call(self, in_type, input_parameters, img_list, expected_list): @@ -92,8 +85,9 @@ def test_split_patch_multiple_call(self, in_type, input_parameters, img_list, ex input_dict = {} for k, v in img_dict.items(): input_dict[k] = in_type(v) - output = splitter(input_dict)[input_parameters["keys"]] - assert_allclose(output, expected, type_test=False) + output = splitter(input_dict) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch[input_parameters["keys"]], expected_patch, type_test=False) if __name__ == "__main__": diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py index 0be30536de..d128d45262 100644 --- a/tests/test_patch_wsi_dataset_new.py +++ b/tests/test_patch_wsi_dataset_new.py @@ -158,7 +158,7 @@ def setUpClass(cls): cls.backend = "cucim" -@skipUnless(has_osl, "Requires cucim") +@skipUnless(has_osl, "Requires openslide") class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): @classmethod def setUpClass(cls): From 0c243b937e94a628c6a1c612e278962bd2e1ebf4 Mon Sep 17 00:00:00 2001 From: Keno <37253540+kbressem@users.noreply.github.com> Date: Fri, 13 May 2022 19:47:51 +0200 Subject: [PATCH 063/183] Implement NrrdReader (#4259) * add swin_unetr model (#4074) * add swin_unetr model Signed-off-by: kbressem * 4217 Update PyTorch docker to 22.04 (#4218) * [DLMED] update to 22.04 Signed-off-by: Nic Ma * fixes unit test tests.test_lr_finder Signed-off-by: Wenqi Li * test new_empty Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> Signed-off-by: kbressem * Add InstanceNorm3dNVFuser support (#4194) * implement the base class Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add unittest Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * autofix Signed-off-by: Yiheng Wang * switch to call apex directly Signed-off-by: Yiheng Wang * uncomment unittest Signed-off-by: Yiheng Wang * add apex install link in docstring Signed-off-by: Yiheng Wang * add channels_last_3d test case Signed-off-by: Yiheng Wang * rewrite types Signed-off-by: Yiheng Wang * change types Signed-off-by: Yiheng Wang * add docstrings Signed-off-by: Yiheng Wang Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> Signed-off-by: kbressem * Update dice.py (#4234) * Update dice.py reduce redundant operations in DiceFocalLoss, initially caused oom Signed-off-by: Ryan Clanton <55164720+ryancinsight@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Signed-off-by: Ryan Clanton <55164720+ryancinsight@users.noreply.github.com> * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: monai-bot Signed-off-by: kbressem * Bug fix and improvement in WSI (#4216) * Make all transforms optional Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update wsireader tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove optional from PersistentDataset and its derivatives Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for cache without transform Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add default replace_rate Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add default value Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Set default replace_rate to 0.1 Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update metadata to include path Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Adds SmartCachePatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for SmartCachePatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update references Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove smart cache Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove unused imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add path metadata for OpenSlide Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update metadata to be unified across different backends Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update wsi metadata for multi wsi objects Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for wsi metadata Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> Signed-off-by: kbressem * Replace module (#4245) * replace modules Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * replace_module -> replace_modules Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> Signed-off-by: kbressem * Add GaussianSmooth as antialiasing filter in Resize (#4249) Signed-off-by: Can Zhao Signed-off-by: kbressem * 4235 fix 2204 nvfuser issue (#4241) * reproduce issue Signed-off-by: Yiheng Wang * remove 22.01 02 Signed-off-by: Yiheng Wang * remove other workflows Signed-off-by: Yiheng Wang * run on pull request Signed-off-by: Yiheng Wang * remove sleep Signed-off-by: Yiheng Wang * test single layer forward Signed-off-by: Yiheng Wang * add has_nvfuser Signed-off-by: Yiheng Wang * add check within factory Signed-off-by: Yiheng Wang * revert to original cron.yml Signed-off-by: Yiheng Wang * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix old pt issue Signed-off-by: Yiheng Wang * change to return directly if no cuda Signed-off-by: Yiheng Wang Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Signed-off-by: kbressem * Update to Bundle Specifiation (#4250) * Update to bundle specifiation Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Adding description in spec discussing the saved Torchscript object's file storage behaviour, and tweaking ckpt_export to add .json extension Signed-off-by: Eric Kerfoot * Annotating optional bundle files Signed-off-by: Eric Kerfoot * Adjusted ckpt_export test Signed-off-by: Eric Kerfoot * Fix Signed-off-by: Eric Kerfoot Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Signed-off-by: kbressem * Implement NrrdReader and NrrdImage classes Signed-off-by: kbressem * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Signed-off-by: kbressem * run auto style fixes on image_reader.py Signed-off-by: kbressem * add NrrdReader to monai/data/__init__.py Signed-off-by: kbressem * Change the way spatial information is handled in NrrdReader Signed-off-by: kbressem * add tests for NrrdReader Signed-off-by: kbressem * Add NrrdReader to list of possible readers for LoadImage Signed-off-by: kbressem * autofix formating Signed-off-by: kbressem * autofix formating Signed-off-by: kbressem * change NrrdImage class to namedtuple and make flake8 happy Signed-off-by: kbressem * Add pynrrd to requirements Signed-off-by: kbressem * correct typing for namedtumple make flake8 happy Signed-off-by: kbressem * Add pynrrd info to `get_optional_config_values` Changed NrrdImage to dataclass Signed-off-by: kbressem * exclude test_nrrd_reader.py from min tests Signed-off-by: kbressem * add pynrrd to config files Signed-off-by: kbressem * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Change the way space is handled in the header. Now, if space is not in header, it is assumed to be LPS and converted to RAS. If space is defined and not LPS, nothing is done to prevent wrong conversions. Signed-off-by: kbressem * add `TestLoadSaveNrrd` where it is tested if a nrrd file, created by ITKWriter can be loaded again. 2D and 3D files with no channels are tested Signed-off-by: kbressem * autofix format Co-authored-by: kbressem --- docs/requirements.txt | 1 + docs/source/installation.md | 4 +- environment-dev.yml | 1 + monai/config/deviceconfig.py | 1 + monai/data/__init__.py | 2 +- monai/data/image_reader.py | 162 ++++++++++++++++++++++++++++++++++- monai/transforms/io/array.py | 6 +- requirements-dev.txt | 1 + setup.cfg | 3 + tests/min_tests.py | 1 + tests/test_image_rw.py | 35 +++++++- tests/test_init_reader.py | 8 +- tests/test_nrrd_reader.py | 122 ++++++++++++++++++++++++++ 13 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 tests/test_nrrd_reader.py diff --git a/docs/requirements.txt b/docs/requirements.txt index f9749e9e36..b7edff27fa 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -28,3 +28,4 @@ tifffile; platform_system == "Linux" pyyaml fire jsonschema +pynrrd diff --git a/docs/source/installation.md b/docs/source/installation.md index 12bf544cba..76c9166566 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -190,9 +190,9 @@ Since MONAI v0.2.0, the extras syntax such as `pip install 'monai[nibabel]'` is - The options are ``` -[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema] +[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema, pynrrd] ``` which correspond to `nibabel`, `scikit-image`, `pillow`, `tensorboard`, -`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, respectively. +`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, `pynrrd`, respectively. - `pip install 'monai[all]'` installs all the optional dependencies. diff --git a/environment-dev.yml b/environment-dev.yml index a361262930..9eef775b78 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -45,6 +45,7 @@ dependencies: - pyyaml - fire - jsonschema + - pynrrd - pip - pip: # pip for itk as conda-forge version only up to v5.1 diff --git a/monai/config/deviceconfig.py b/monai/config/deviceconfig.py index fd7ca572e6..8d6383ed97 100644 --- a/monai/config/deviceconfig.py +++ b/monai/config/deviceconfig.py @@ -75,6 +75,7 @@ def get_optional_config_values(): output["einops"] = get_package_version("einops") output["transformers"] = get_package_version("transformers") output["mlflow"] = get_package_version("mlflow") + output["pynrrd"] = get_package_version("nrrd") return output diff --git a/monai/data/__init__.py b/monai/data/__init__.py index d9af568508..63aa29df65 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -34,7 +34,7 @@ from .folder_layout import FolderLayout from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter, PatchIterd from .image_dataset import ImageDataset -from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader +from .image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader from .image_writer import ( SUPPORTED_WRITERS, ImageWriter, diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 7e1db7ef7d..af098c0fa3 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -11,6 +11,7 @@ import warnings from abc import ABC, abstractmethod +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Union @@ -25,21 +26,23 @@ if TYPE_CHECKING: import itk import nibabel as nib + import nrrd from nibabel.nifti1 import Nifti1Image from PIL import Image as PILImage - has_itk = has_nib = has_pil = True + has_nrrd = has_itk = has_nib = has_pil = True else: itk, has_itk = optional_import("itk", allow_namespace_pkg=True) nib, has_nib = optional_import("nibabel") Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image") PILImage, has_pil = optional_import("PIL.Image") + nrrd, has_nrrd = optional_import("nrrd", allow_namespace_pkg=True) OpenSlide, _ = optional_import("openslide", name="OpenSlide") CuImage, _ = optional_import("cucim", name="CuImage") TiffFile, _ = optional_import("tifffile", name="TiffFile") -__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader"] +__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader", "NrrdReader"] class ImageReader(ABC): @@ -976,3 +979,158 @@ def _extract_patches( idx += 1 return flat_patch_grid + + +@dataclass +class NrrdImage: + """Class to wrap nrrd image array and metadata header""" + + array: np.ndarray + header: dict + + +@require_pkg(pkg_name="nrrd") +class NrrdReader(ImageReader): + """ + Load NRRD format images based on pynrrd library. + + Args: + channel_dim: the channel dimension of the input image, default is None. + This is used to set original_channel_dim in the meta data, EnsureChannelFirstD reads this field. + If None, `original_channel_dim` will be either `no_channel` or `0`. + NRRD files are usually "channel first". + dtype: dtype of the data array when loading image. + index_order: Specify whether the returned data array should be in C-order (‘C’) or Fortran-order (‘F’). + Numpy is usually in C-order, but default on the NRRD header is F + kwargs: additional args for `nrrd.read` API. more details about available args: + https://github.com/mhe/pynrrd/blob/master/nrrd/reader.py + + """ + + def __init__( + self, + channel_dim: Optional[int] = None, + dtype: Union[np.dtype, type, str, None] = np.float32, + index_order: str = "F", + **kwargs, + ): + self.channel_dim = channel_dim + self.dtype = dtype + self.index_order = index_order + self.kwargs = kwargs + + def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: + """ + Verify whether the specified `filename` is supported by pynrrd reader. + + Args: + filename: file name or a list of file names to read. + if a list of files, verify all the suffixes. + + """ + suffixes: Sequence[str] = ["nrrd", "seg.nrrd"] + return has_nrrd and is_supported_format(filename, suffixes) + + def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs) -> Union[Sequence[Any], Any]: + """ + Read image data from specified file or files. + Note that it returns a data object or a sequence of data objects. + + Args: + data: file name or a list of file names to read. + kwargs: additional args for actual `read` API of 3rd party libs. + + """ + img_: List = [] + filenames: Sequence[PathLike] = ensure_tuple(data) + kwargs_ = self.kwargs.copy() + kwargs_.update(kwargs) + for name in filenames: + nrrd_image = NrrdImage(*nrrd.read(name, index_order=self.index_order, *kwargs_)) + img_.append(nrrd_image) + return img_ if len(filenames) > 1 else img_[0] + + def get_data(self, img: Union[NrrdImage, List[NrrdImage]]) -> Tuple[np.ndarray, Dict]: + """ + Extract data array and meta data from loaded image and return them. + This function must return two objects, the first is a numpy array of image data, + the second is a dictionary of meta data. + + Args: + img: a `NrrdImage` loaded from an image file or a list of image objects. + + """ + img_array: List[np.ndarray] = [] + compatible_meta: Dict = {} + + for i in ensure_tuple(img): + data = i.array.astype(self.dtype) + img_array.append(data) + header = dict(i.header) + if self.index_order == "C": + header = self._convert_f_to_c_order(header) + header["original_affine"] = self._get_affine(i) + header = self._switch_lps_ras(header) + header["affine"] = header["original_affine"].copy() + header["spatial_shape"] = header["sizes"] + [header.pop(k) for k in ("sizes", "space origin", "space directions")] # rm duplicated data in header + + if self.channel_dim is None: # default to "no_channel" or -1 + header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else 0 + else: + header["original_channel_dim"] = self.channel_dim + _copy_compatible_dict(header, compatible_meta) + + return _stack_images(img_array, compatible_meta), compatible_meta + + def _get_affine(self, img: NrrdImage) -> np.ndarray: + """ + Get the affine matrix of the image, it can be used to correct + spacing, orientation or execute spatial transforms. + + Args: + img: A `NrrdImage` loaded from image file + + """ + direction = img.header["space directions"] + origin = img.header["space origin"] + + x, y = direction.shape + affine_diam = min(x, y) + 1 + affine: np.ndarray = np.eye(affine_diam) + affine[:x, :y] = direction + affine[: (affine_diam - 1), -1] = origin # len origin is always affine_diam - 1 + return affine + + def _switch_lps_ras(self, header: dict) -> dict: + """ + For compatibility with nibabel, switch from LPS to RAS. Adapt affine matrix and + `space` argument in header accordingly. If no information of space is given in the header, + LPS is assumed and thus converted to RAS. If information about space is given, + but is not LPS, the unchanged header is returned. + + Args: + header: The image meta data as dict + + """ + if "space" not in header or header["space"] == "left-posterior-superior": + header["space"] = "right-anterior-superior" + header["original_affine"] = orientation_ras_lps(header["original_affine"]) + return header + + def _convert_f_to_c_order(self, header: dict) -> dict: + """ + All header fields of a NRRD are specified in `F` (Fortran) order, even if the image was read as C-ordered array. + 1D arrays of header['space origin'] and header['sizes'] become inverted, e.g, [1,2,3] -> [3,2,1] + The 2D Array for header['space directions'] is transposed: [[1,0,0],[0,2,0],[0,0,3]] -> [[3,0,0],[0,2,0],[0,0,1]] + For more details refer to: https://pynrrd.readthedocs.io/en/latest/user-guide.html#index-ordering + + Args: + header: The image meta data as dict + + """ + + header["space directions"] = np.rot90(np.flip(header["space directions"], 0)) + header["space origin"] = header["space origin"][::-1] + header["sizes"] = header["sizes"][::-1] + return header diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 5bafd84eaf..fc34985903 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -28,7 +28,7 @@ from monai.config import DtypeLike, NdarrayOrTensor, PathLike from monai.data import image_writer from monai.data.folder_layout import FolderLayout -from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader +from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader from monai.transforms.transform import Transform from monai.transforms.utility.array import EnsureChannelFirst from monai.utils import GridSampleMode, GridSamplePadMode @@ -37,11 +37,13 @@ nib, _ = optional_import("nibabel") Image, _ = optional_import("PIL.Image") +nrrd, _ = optional_import("nrrd") __all__ = ["LoadImage", "SaveImage", "SUPPORTED_READERS"] SUPPORTED_READERS = { "itkreader": ITKReader, + "nrrdreader": NrrdReader, "numpyreader": NumpyReader, "pilreader": PILReader, "nibabelreader": NibabelReader, @@ -85,7 +87,7 @@ class LoadImage(Transform): - User-specified reader in the constructor of `LoadImage`. - Readers from the last to the first in the registered list. - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), - (npz, npy -> NumpyReader), (DICOM file -> ITKReader). + (npz, npy -> NumpyReader), (nrrd -> NrrdReader), (DICOM file -> ITKReader). See also: diff --git a/requirements-dev.txt b/requirements-dev.txt index 651a99eba9..271e8db9e3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -47,3 +47,4 @@ types-PyYAML pyyaml fire jsonschema +pynrrd diff --git a/setup.cfg b/setup.cfg index 12f974ca6d..914e404b2d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ all = pyyaml fire jsonschema + pynrrd nibabel = nibabel skimage = @@ -101,6 +102,8 @@ fire = fire jsonschema = jsonschema +pynrrd = + pynrrd [flake8] select = B,C,E,F,N,P,T4,W,B9 diff --git a/tests/min_tests.py b/tests/min_tests.py index 6549fdcd4b..f17aaa85b0 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -114,6 +114,7 @@ def run_testsuit(): "test_nifti_header_revise", "test_nifti_rw", "test_nifti_saver", + "test_nrrd_reader", "test_occlusion_sensitivity", "test_orientation", "test_orientationd", diff --git a/tests/test_image_rw.py b/tests/test_image_rw.py index 62b1147aa5..7975349109 100644 --- a/tests/test_image_rw.py +++ b/tests/test_image_rw.py @@ -18,7 +18,7 @@ import numpy as np from parameterized import parameterized -from monai.data.image_reader import ITKReader, NibabelReader, PILReader +from monai.data.image_reader import ITKReader, NibabelReader, NrrdReader, PILReader from monai.data.image_writer import ITKWriter, NibabelWriter, PILWriter, register_writer, resolve_writer from monai.transforms import LoadImage, SaveImage, moveaxis from monai.utils import OptionalImportError @@ -132,5 +132,38 @@ def test_1_new(self): self.assertEqual(resolve_writer("new")[0](0), 1) +class TestLoadSaveNrrd(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def nrrd_rw(self, test_data, reader, writer, dtype, resample=True): + test_data = test_data.astype(dtype) + ndim = len(test_data.shape) + for p in TEST_NDARRAYS: + output_ext = ".nrrd" + filepath = f"testfile_{ndim}d" + saver = SaveImage( + output_dir=self.test_dir, output_ext=output_ext, resample=resample, separate_folder=False, writer=writer + ) + saver(p(test_data), {"filename_or_obj": f"{filepath}{output_ext}", "spatial_shape": test_data.shape}) + saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) + loader = LoadImage(reader=reader) + data, meta = loader(saved_path) + assert_allclose(data, test_data) + + @parameterized.expand(itertools.product([NrrdReader, ITKReader], [ITKWriter, ITKWriter])) + def test_2d(self, reader, writer): + test_data = np.random.randn(8, 8).astype(np.float32) + self.nrrd_rw(test_data, reader, writer, np.float32) + + @parameterized.expand(itertools.product([NrrdReader, ITKReader], [ITKWriter, ITKWriter])) + def test_3d(self, reader, writer): + test_data = np.random.randn(8, 8, 8).astype(np.float32) + self.nrrd_rw(test_data, reader, writer, np.float32) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_init_reader.py b/tests/test_init_reader.py index 03a63cc375..df055e571c 100644 --- a/tests/test_init_reader.py +++ b/tests/test_init_reader.py @@ -11,7 +11,7 @@ import unittest -from monai.data import ITKReader, NibabelReader, NumpyReader, PILReader +from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader from monai.transforms import LoadImage, LoadImaged from tests.utils import SkipIfNoModule @@ -23,13 +23,14 @@ def test_load_image(self): self.assertIsInstance(instance1, LoadImage) self.assertIsInstance(instance2, LoadImage) - for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", None]: + for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", None]: inst = LoadImaged("image", reader=r) self.assertIsInstance(inst, LoadImaged) @SkipIfNoModule("itk") @SkipIfNoModule("nibabel") @SkipIfNoModule("PIL") + @SkipIfNoModule("nrrd") def test_readers(self): inst = ITKReader() self.assertIsInstance(inst, ITKReader) @@ -47,6 +48,9 @@ def test_readers(self): inst = PILReader() self.assertIsInstance(inst, PILReader) + inst = NrrdReader() + self.assertIsInstance(inst, NrrdReader) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_nrrd_reader.py b/tests/test_nrrd_reader.py new file mode 100644 index 0000000000..5561d471ba --- /dev/null +++ b/tests/test_nrrd_reader.py @@ -0,0 +1,122 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tempfile +import unittest + +import nrrd +import numpy as np +from parameterized import parameterized + +from monai.data import NrrdReader + +TEST_CASE_1 = [(4, 4), "test_image.nrrd", (4, 4), np.uint8] +TEST_CASE_2 = [(4, 4, 4), "test_image.nrrd", (4, 4, 4), np.uint16] +TEST_CASE_3 = [(4, 4, 4, 4), "test_image.nrrd", (4, 4, 4, 4), np.uint32] +TEST_CASE_4 = [(1, 2, 3, 4, 5), "test_image.nrrd", (1, 2, 3, 4, 5), np.uint64] +TEST_CASE_5 = [(6, 5, 4, 3, 2, 1), "test_image.nrrd", (6, 5, 4, 3, 2, 1), np.float32] +TEST_CASE_6 = [(4,), "test_image.nrrd", (4,), np.float64] +TEST_CASE_7 = [(4, 4), ["test_image.nrrd", "test_image2.nrrd", "test_image3.nrrd"], (4, 4), np.float32] +TEST_CASE_8 = [ + (3, 4, 4, 1), + "test_image.nrrd", + (3, 4, 4, 1), + np.float32, + { + "dimension": 4, + "space": "left-posterior-superior", + "sizes": [3, 4, 4, 1], + "space directions": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + "space origin": [0.0, 0.0, 0.0], + }, +] + + +class TestNrrdReader(unittest.TestCase): + def test_verify_suffix(self): + reader = NrrdReader() + self.assertFalse(reader.verify_suffix("test_image.nrd")) + reader.verify_suffix("test_image.nrrd") + reader.verify_suffix("test_image.seg.nrrd") + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_int(self, data_shape, filename, expected_shape, dtype): + min_val, max_val = np.iinfo(dtype).min, np.iinfo(dtype).max + test_image = np.random.randint(min_val, max_val, size=data_shape, dtype=dtype) + with tempfile.TemporaryDirectory() as tempdir: + filename = os.path.join(tempdir, filename) + nrrd.write(filename, test_image.astype(dtype)) + reader = NrrdReader() + result = reader.read(filename) + self.assertEqual(result.array.dtype, dtype) + self.assertTupleEqual(result.array.shape, expected_shape) + self.assertTupleEqual(tuple(result.header["sizes"]), expected_shape) + np.testing.assert_allclose(result.array, test_image) + + @parameterized.expand([TEST_CASE_5, TEST_CASE_6]) + def test_read_float(self, data_shape, filename, expected_shape, dtype): + test_image = np.random.rand(*data_shape).astype(dtype) + with tempfile.TemporaryDirectory() as tempdir: + filename = os.path.join(tempdir, filename) + nrrd.write(filename, test_image.astype(dtype)) + reader = NrrdReader() + result = reader.read(filename) + self.assertEqual(result.array.dtype, dtype) + self.assertTupleEqual(result.array.shape, expected_shape) + self.assertTupleEqual(tuple(result.header["sizes"]), expected_shape) + np.testing.assert_allclose(result.array, test_image) + + @parameterized.expand([TEST_CASE_7]) + def test_read_list(self, data_shape, filenames, expected_shape, dtype): + test_image = np.random.rand(*data_shape).astype(dtype) + with tempfile.TemporaryDirectory() as tempdir: + for i, filename in enumerate(filenames): + filenames[i] = os.path.join(tempdir, filename) + nrrd.write(filenames[i], test_image.astype(dtype)) + reader = NrrdReader() + results = reader.read(filenames) + for result in results: + self.assertTupleEqual(result.array.shape, expected_shape) + self.assertTupleEqual(tuple(result.header["sizes"]), expected_shape) + np.testing.assert_allclose(result.array, test_image) + + @parameterized.expand([TEST_CASE_8]) + def test_read_with_header(self, data_shape, filename, expected_shape, dtype, reference_header): + test_image = np.random.rand(*data_shape).astype(dtype) + with tempfile.TemporaryDirectory() as tempdir: + filename = os.path.join(tempdir, filename) + nrrd.write(filename, test_image.astype(dtype), header=reference_header) + reader = NrrdReader() + image_array, image_header = reader.get_data(reader.read(filename)) + self.assertIsInstance(image_array, np.ndarray) + self.assertEqual(image_array.dtype, dtype) + self.assertTupleEqual(image_array.shape, expected_shape) + np.testing.assert_allclose(image_array, test_image) + self.assertIsInstance(image_header, dict) + self.assertTupleEqual(tuple(image_header["spatial_shape"]), expected_shape) + + @parameterized.expand([TEST_CASE_8]) + def test_read_with_header_index_order_c(self, data_shape, filename, expected_shape, dtype, reference_header): + test_image = np.random.rand(*data_shape).astype(dtype) + with tempfile.TemporaryDirectory() as tempdir: + filename = os.path.join(tempdir, filename) + nrrd.write(filename, test_image.astype(dtype), header=reference_header) + reader = NrrdReader(index_order="C") + image_array, image_header = reader.get_data(reader.read(filename)) + self.assertIsInstance(image_array, np.ndarray) + self.assertEqual(image_array.dtype, dtype) + self.assertTupleEqual(image_array.shape, expected_shape[::-1]) + self.assertTupleEqual(image_array.shape, tuple(image_header["spatial_shape"])) + + +if __name__ == "__main__": + unittest.main() From db34747611600143735a877b4bf40d7f01fc8a0c Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Sat, 14 May 2022 00:13:07 +0100 Subject: [PATCH 064/183] 4269 - fixes resample mode typos (#4272) fixes resample mode typos Signed-off-by: Wenqi Li --- monai/transforms/spatial/array.py | 8 ++++---- tests/test_resample_to_match.py | 2 ++ tests/test_spatial_resample.py | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 6568e2c5d0..222aee2d90 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -137,8 +137,8 @@ def __call__( src_affine: Optional[NdarrayOrTensor] = None, dst_affine: Optional[NdarrayOrTensor] = None, spatial_size: Optional[Union[Sequence[int], np.ndarray, int]] = None, - mode: Union[GridSampleMode, str, None] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str, None] = GridSamplePadMode.BORDER, + mode: Union[GridSampleMode, str, None] = None, + padding_mode: Union[GridSamplePadMode, str, None] = None, align_corners: Optional[bool] = False, dtype: DtypeLike = None, ) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: @@ -282,8 +282,8 @@ def __call__( # type: ignore img: NdarrayOrTensor, src_meta: Optional[Dict] = None, dst_meta: Optional[Dict] = None, - mode: Union[GridSampleMode, str, None] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str, None] = GridSamplePadMode.BORDER, + mode: Union[GridSampleMode, str, None] = None, + padding_mode: Union[GridSamplePadMode, str, None] = None, align_corners: Optional[bool] = False, dtype: DtypeLike = None, ): diff --git a/tests/test_resample_to_match.py b/tests/test_resample_to_match.py index e1f6a28998..b65a1ea319 100644 --- a/tests/test_resample_to_match.py +++ b/tests/test_resample_to_match.py @@ -44,6 +44,8 @@ def test_correct(self, reader, writer): loader = Compose([LoadImaged(("im1", "im2"), reader=reader), EnsureChannelFirstd(("im1", "im2"))]) data = loader({"im1": self.fnames[0], "im2": self.fnames[1]}) + with self.assertRaises(ValueError): + ResampleToMatch(mode=None)(data["im2"], data["im2_meta_dict"], data["im1_meta_dict"]) im_mod, meta = ResampleToMatch()(data["im2"], data["im2_meta_dict"], data["im1_meta_dict"]) current_dims = copy.deepcopy(meta.get("dim")) saver = SaveImaged("im3", output_dir=temp_dir, output_postfix="", separate_folder=False, writer=writer) diff --git a/tests/test_spatial_resample.py b/tests/test_spatial_resample.py index 9ee84de85b..a288ab9c0d 100644 --- a/tests/test_spatial_resample.py +++ b/tests/test_spatial_resample.py @@ -140,6 +140,8 @@ def test_ill_affine(self): SpatialResample()(img=img, src_affine=np.eye(4), dst_affine=ill_affine) with self.assertRaises(ValueError): SpatialResample()(img=img, src_affine=ill_affine, dst_affine=np.eye(3)) + with self.assertRaises(ValueError): + SpatialResample(mode=None)(img=img, src_affine=np.eye(4), dst_affine=0.1 * np.eye(4)) if __name__ == "__main__": From e8d2d4f4018217347dc41b8b87eb13bfe75bf3be Mon Sep 17 00:00:00 2001 From: Keno <37253540+kbressem@users.noreply.github.com> Date: Sat, 14 May 2022 12:41:56 +0200 Subject: [PATCH 065/183] unify spelling of metdata in docstrings (#4273) Changed `meta data` -> `metadata`, `meta-data` -> `metadata`, `Meta data` -> `Metadata`, `Meta-data` -> `Metadata` in docstrings of .py files Signed-off-by: kbressem --- monai/apps/deepedit/transforms.py | 8 ++-- monai/apps/deepgrow/transforms.py | 48 +++++++++++------------ monai/bundle/scripts.py | 2 +- monai/data/csv_saver.py | 4 +- monai/data/dataset_summary.py | 14 +++---- monai/data/image_dataset.py | 2 +- monai/data/image_reader.py | 50 ++++++++++++------------ monai/data/nifti_saver.py | 6 +-- monai/data/png_saver.py | 6 +-- monai/data/test_time_augmentation.py | 8 ++-- monai/data/wsi_reader.py | 2 +- monai/handlers/segmentation_saver.py | 8 ++-- monai/transforms/compose.py | 4 +- monai/transforms/croppad/dictionary.py | 44 ++++++++++----------- monai/transforms/intensity/dictionary.py | 24 ++++++------ monai/transforms/io/array.py | 12 +++--- monai/transforms/io/dictionary.py | 16 ++++---- monai/transforms/post/dictionary.py | 24 ++++++------ monai/transforms/spatial/array.py | 2 +- monai/transforms/spatial/dictionary.py | 26 ++++++------ monai/transforms/transform.py | 4 +- monai/transforms/utility/array.py | 6 +-- monai/transforms/utility/dictionary.py | 16 ++++---- monai/utils/enums.py | 4 +- monai/utils/misc.py | 2 +- 25 files changed, 171 insertions(+), 171 deletions(-) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index bb62d36e75..5a7068291a 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -614,12 +614,12 @@ class AddGuidanceFromPointsDeepEditd(Transform): Args: ref_image: key to reference image to fetch current and original image details. guidance: output key to store guidance. - meta_keys: explicitly indicate the key of the meta data dictionary of `ref_image`. + meta_keys: explicitly indicate the key of the metadata dictionary of `ref_image`. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. if None, will try to construct meta_keys by `{ref_image}_{meta_key_postfix}`. - meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index 6da614f46c..c439469aea 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -372,13 +372,13 @@ class SpatialCropForegroundd(MapTransform): allow_smaller: when computing box size with `margin`, whether allow the image size to be smaller than box size, default to `True`. if the margined size is bigger than image size, will pad with specified `mode`. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `{key}_{meta_key_postfix}` to fetch/store the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `{key}_{meta_key_postfix}` to fetch/store the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. start_coord_key: key to record the start coordinate of spatial bounding box for foreground. @@ -475,12 +475,12 @@ class AddGuidanceFromPointsd(Transform): depth_first: if depth (slices) is positioned at first dimension. spatial_dims: dimensions based on model used for deepgrow (2D vs 3D). slice_key: key that represents applicable slice to add guidance. - meta_keys: explicitly indicate the key of the meta data dictionary of `ref_image`. + meta_keys: explicitly indicate the key of the metadata dictionary of `ref_image`. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. if None, will try to construct meta_keys by `{ref_image}_{meta_key_postfix}`. - meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. @@ -589,13 +589,13 @@ class SpatialCropGuidanced(MapTransform): guidance: key to the guidance. It is used to generate the bounding box of foreground spatial_size: minimal spatial size of the image patch e.g. [128, 128, 128] to fit in. margin: add margin value to spatial dims of the bounding box, if only 1 value provided, use it for all dims. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. start_coord_key: key to record the start coordinate of spatial bounding box for foreground. @@ -712,12 +712,12 @@ class ResizeGuidanced(Transform): Args: guidance: key to guidance ref_image: key to reference image to fetch current and original image details - meta_keys: explicitly indicate the key of the meta data dictionary of `ref_image`. + meta_keys: explicitly indicate the key of the metadata dictionary of `ref_image`. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. if None, will try to construct meta_keys by `{ref_image}_{meta_key_postfix}`. - meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_key is None, use `{ref_image}_{meta_key_postfix}` to to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. cropped_shape_key: key that records cropped shape for foreground. @@ -787,13 +787,13 @@ class RestoreLabeld(MapTransform): align_corners: Geometrically, we consider the pixels of the input as squares rather than points. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html It also can be a sequence of bool, each element corresponds to a key in ``keys``. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_key is None, use `key_{meta_key_postfix} to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_key is None, use `key_{meta_key_postfix} to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. start_coord_key: key that records the start coordinate of spatial bounding box for foreground. @@ -897,13 +897,13 @@ class Fetch2DSliced(MapTransform): keys: keys of the corresponding items to be transformed. guidance: key that represents guidance. axis: axis that represents slice in 3D volume. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: use `key_{meta_key_postfix}` to fetch the meta data according to the key data, - default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: use `key_{meta_key_postfix}` to fetch the metadata according to the key data, + default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. allow_missing_keys: don't raise exception if key is missing. diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 3c838d55a0..b64f26a091 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -591,7 +591,7 @@ def ckpt_export( else: copy_model_state(dst=net, src=ckpt_file_ if key_in_ckpt_ == "" else ckpt_file_[key_in_ckpt_]) - # convert to TorchScript model and save with meta data, config content + # convert to TorchScript model and save with metadata, config content net = convert_to_torchscript(model=net) extra_files: Dict = {} diff --git a/monai/data/csv_saver.py b/monai/data/csv_saver.py index e938bdabf8..36d159d4be 100644 --- a/monai/data/csv_saver.py +++ b/monai/data/csv_saver.py @@ -27,7 +27,7 @@ class CSVSaver: Save the data in a dictionary format cache, and write to a CSV file finally. Typically, the data can be classification predictions, call `save` for single data or call `save_batch` to save a batch of data together, and call `finalize` to write - the cached data into CSV file. If no meta data provided, use index from 0 to save data. + the cached data into CSV file. If no metadata provided, use index from 0 to save data. Note that this saver can't support multi-processing because it reads / writes single CSV file and can't guarantee the data order in multi-processing situation. @@ -88,7 +88,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] Args: data: target data content that save into cache. - meta_data: the meta data information corresponding to the data. + meta_data: the metadata information corresponding to the data. """ save_key = meta_data[Key.FILENAME_OR_OBJ] if meta_data else str(self._data_index) diff --git a/monai/data/dataset_summary.py b/monai/data/dataset_summary.py index b447585d3e..14024c0ff9 100644 --- a/monai/data/dataset_summary.py +++ b/monai/data/dataset_summary.py @@ -54,12 +54,12 @@ def __init__( dataset: dataset from which to load the data. image_key: key name of images (default: ``image``). label_key: key name of labels (default: ``label``). - meta_key: explicitly indicate the key of the corresponding meta data dictionary. + meta_key: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + the metadata is a dictionary object which contains: filename, affine, original_shape, etc. if None, will try to construct meta_keys by `{image_key}_{meta_key_postfix}`. - meta_key_postfix: use `{image_key}_{meta_key_postfix}` to fetch the meta data from dict, - the meta data is a dictionary object (default: ``meta_dict``). + meta_key_postfix: use `{image_key}_{meta_key_postfix}` to fetch the metadata from dict, + the metadata is a dictionary object (default: ``meta_dict``). num_workers: how many subprocesses to use for data loading. ``0`` means that the data will be loaded in the main process (default: ``0``). kwargs: other parameters (except `batch_size` and `num_workers`) for DataLoader, @@ -76,12 +76,12 @@ def __init__( def collect_meta_data(self): """ - This function is used to collect the meta data for all images of the dataset. + This function is used to collect the metadata for all images of the dataset. """ for data in self.data_loader: if self.meta_key not in data: - raise ValueError(f"To collect meta data for the dataset, key `{self.meta_key}` must exist in `data`.") + raise ValueError(f"To collect metadata for the dataset, key `{self.meta_key}` must exist in `data`.") self.all_meta_data.append(data[self.meta_key]) def get_target_spacing(self, spacing_key: str = "pixdim", anisotropic_threshold: int = 3, percentile: float = 10.0): @@ -93,7 +93,7 @@ def get_target_spacing(self, spacing_key: str = "pixdim", anisotropic_threshold: After loading with `monai.DataLoader`, "pixdim" is in the form of `torch.Tensor` with size `(batch_size, 8)`. Args: - spacing_key: key of spacing in meta data (default: ``pixdim``). + spacing_key: key of spacing in metadata (default: ``pixdim``). anisotropic_threshold: threshold to decide if the target spacing is anisotropic (default: ``3``). percentile: for anisotropic target spacing, use the percentile of all spacings of the anisotropic axis to replace that axis. diff --git a/monai/data/image_dataset.py b/monai/data/image_dataset.py index 51f4e04959..89694a4bb8 100644 --- a/monai/data/image_dataset.py +++ b/monai/data/image_dataset.py @@ -59,7 +59,7 @@ def __init__( image_only: if True return only the image volume, otherwise, return image volume and the metadata. transform_with_metadata: if True, the metadata will be passed to the transforms whenever possible. dtype: if not None convert the loaded image to this data type. - reader: register reader to load image file and meta data, if None, will use the default readers. + reader: register reader to load image file and metadata, if None, will use the default readers. If a string of reader name provided, will construct a reader object with the `*args` and `**kwargs` parameters, supported reader name: "NibabelReader", "PILReader", "ITKReader", "NumpyReader" args: additional parameters for reader if providing a reader name. diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index af098c0fa3..13872d5c61 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -58,7 +58,7 @@ class ImageReader(ABC): img_data, meta_data = image_reader.get_data(img_obj) - The `read` call converts image filenames into image objects, - - The `get_data` call fetches the image data, as well as meta data. + - The `get_data` call fetches the image data, as well as metadata. - A reader should implement `verify_suffix` with the logic of checking the input filename by the filename extensions. @@ -94,9 +94,9 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs) -> Union[Seq @abstractmethod def get_data(self, img) -> Tuple[np.ndarray, Dict]: """ - Extract data array and meta data from loaded image and return them. + Extract data array and metadata from loaded image and return them. This function must return two objects, the first is a numpy array of image data, - the second is a dictionary of meta data. + the second is a dictionary of metadata. Args: img: an image object loaded from an image file or a list of image objects. @@ -150,7 +150,7 @@ class ITKReader(ImageReader): Args: channel_dim: the channel dimension of the input image, default is None. - This is used to set original_channel_dim in the meta data, EnsureChannelFirstD reads this field. + This is used to set original_channel_dim in the metadata, EnsureChannelFirstD reads this field. If None, `original_channel_dim` will be either `no_channel` or `-1`. - Nifti file is usually "channel last", so there is no need to specify this argument. @@ -251,11 +251,11 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): def get_data(self, img): """ - Extract data array and meta data from loaded image and return them. - This function returns two objects, first is numpy array of image data, second is dict of meta data. + Extract data array and metadata from loaded image and return them. + This function returns two objects, first is numpy array of image data, second is dict of metadata. It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. When loading a list of files, they are stacked together at a new dimension as the first dimension, - and the meta data of the first image is used to represent the output meta data. + and the metadata of the first image is used to represent the output metadata. Args: img: an ITK image object loaded from an image file or a list of ITK image objects. @@ -281,7 +281,7 @@ def get_data(self, img): def _get_meta_dict(self, img) -> Dict: """ - Get all the meta data of the image and convert to dict type. + Get all the metadata of the image and convert to dict type. Args: img: an ITK image object loaded from an image file. @@ -363,7 +363,7 @@ class NibabelReader(ImageReader): as_closest_canonical: if True, load the image as closest to canonical axis format. squeeze_non_spatial_dims: if True, non-spatial singletons will be squeezed, e.g. (256,256,1,3) -> (256,256,3) channel_dim: the channel dimension of the input image, default is None. - this is used to set original_channel_dim in the meta data, EnsureChannelFirstD reads this field. + this is used to set original_channel_dim in the metadata, EnsureChannelFirstD reads this field. if None, `original_channel_dim` will be either `no_channel` or `-1`. most Nifti files are usually "channel last", no need to specify this argument for them. dtype: dtype of the output data array when loading with Nibabel library. @@ -425,11 +425,11 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): def get_data(self, img): """ - Extract data array and meta data from loaded image and return them. - This function returns two objects, first is numpy array of image data, second is dict of meta data. + Extract data array and metadata from loaded image and return them. + This function returns two objects, first is numpy array of image data, second is dict of metadata. It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. When loading a list of files, they are stacked together at a new dimension as the first dimension, - and the meta data of the first image is used to present the output meta data. + and the metadata of the first image is used to present the output metadata. Args: img: a Nibabel image object loaded from an image file or a list of Nibabel image objects. @@ -463,7 +463,7 @@ def get_data(self, img): def _get_meta_dict(self, img) -> Dict: """ - Get the all the meta data of the image and convert to dict type. + Get the all the metadata of the image and convert to dict type. Args: img: a Nibabel image object loaded from an image file. @@ -590,11 +590,11 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): def get_data(self, img): """ - Extract data array and meta data from loaded image and return them. - This function returns two objects, first is numpy array of image data, second is dict of meta data. + Extract data array and metadata from loaded image and return them. + This function returns two objects, first is numpy array of image data, second is dict of metadata. It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. When loading a list of files, they are stacked together at a new dimension as the first dimension, - and the meta data of the first image is used to represent the output meta data. + and the metadata of the first image is used to represent the output metadata. Args: img: a Numpy array loaded from a file or a list of Numpy arrays. @@ -676,11 +676,11 @@ def read(self, data: Union[Sequence[PathLike], PathLike, np.ndarray], **kwargs): def get_data(self, img): """ - Extract data array and meta data from loaded image and return them. - This function returns two objects, first is numpy array of image data, second is dict of meta data. + Extract data array and metadata from loaded image and return them. + This function returns two objects, first is numpy array of image data, second is dict of metadata. It computes `spatial_shape` and stores it in meta dict. When loading a list of files, they are stacked together at a new dimension as the first dimension, - and the meta data of the first image is used to represent the output meta data. + and the metadata of the first image is used to represent the output metadata. Note that it will swap axis 0 and 1 after loading the array because the `HW` definition in PIL is different from other common medical packages. @@ -703,7 +703,7 @@ def get_data(self, img): def _get_meta_dict(self, img) -> Dict: """ - Get the all the meta data of the image and convert to dict type. + Get the all the metadata of the image and convert to dict type. Args: img: a PIL Image object loaded from an image file. @@ -996,7 +996,7 @@ class NrrdReader(ImageReader): Args: channel_dim: the channel dimension of the input image, default is None. - This is used to set original_channel_dim in the meta data, EnsureChannelFirstD reads this field. + This is used to set original_channel_dim in the metadata, EnsureChannelFirstD reads this field. If None, `original_channel_dim` will be either `no_channel` or `0`. NRRD files are usually "channel first". dtype: dtype of the data array when loading image. @@ -1052,9 +1052,9 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs) -> Union[Seq def get_data(self, img: Union[NrrdImage, List[NrrdImage]]) -> Tuple[np.ndarray, Dict]: """ - Extract data array and meta data from loaded image and return them. + Extract data array and metadata from loaded image and return them. This function must return two objects, the first is a numpy array of image data, - the second is a dictionary of meta data. + the second is a dictionary of metadata. Args: img: a `NrrdImage` loaded from an image file or a list of image objects. @@ -1110,7 +1110,7 @@ def _switch_lps_ras(self, header: dict) -> dict: but is not LPS, the unchanged header is returned. Args: - header: The image meta data as dict + header: The image metadata as dict """ if "space" not in header or header["space"] == "left-posterior-superior": @@ -1126,7 +1126,7 @@ def _convert_f_to_c_order(self, header: dict) -> dict: For more details refer to: https://pynrrd.readthedocs.io/en/latest/user-guide.html#index-ordering Args: - header: The image meta data as dict + header: The image metadata as dict """ diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index 3fdc0aa3e8..89f58aa23b 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -29,8 +29,8 @@ class NiftiSaver: Typically, the data can be segmentation predictions, call `save` for single data or call `save_batch` to save a batch of data together. The name of saved file will be `{input_image_name}_{output_postfix}{output_ext}`, - where the input image name is extracted from the provided meta data dictionary. - If no meta data provided, use index from 0 as the filename prefix. + where the input image name is extracted from the provided metadata dictionary. + If no metadata provided, use index from 0 as the filename prefix. Note: image should include channel dimension: [B],C,H,W,[D]. @@ -129,7 +129,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] Args: data: target data content that to be saved as a NIfTI format file. Assuming the data shape starts with a channel dimension and followed by spatial dimensions. - meta_data: the meta data information corresponding to the data. + meta_data: the metadata information corresponding to the data. See Also :py:meth:`monai.data.nifti_writer.write_nifti` diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py index 9a1ade0efa..efe46603fb 100644 --- a/monai/data/png_saver.py +++ b/monai/data/png_saver.py @@ -28,8 +28,8 @@ class PNGSaver: Typically, the data can be segmentation predictions, call `save` for single data or call `save_batch` to save a batch of data together. The name of saved file will be `{input_image_name}_{output_postfix}{output_ext}`, - where the input image name is extracted from the provided meta data dictionary. - If no meta data provided, use index from 0 as the filename prefix. + where the input image name is extracted from the provided metadata dictionary. + If no metadata provided, use index from 0 as the filename prefix. .. deprecated:: 0.8 Use :py:class:`monai.transforms.SaveImage` instead. @@ -103,7 +103,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] Assuming the data shape are spatial dimensions. Shape of the spatial dimensions (C,H,W). C should be 1, 3 or 4 - meta_data: the meta data information corresponding to the data. + meta_data: the metadata information corresponding to the data. Raises: ValueError: When ``data`` channels is not one of [1, 3, 4]. diff --git a/monai/data/test_time_augmentation.py b/monai/data/test_time_augmentation.py index 0b97c9febf..3ce1fec2d4 100644 --- a/monai/data/test_time_augmentation.py +++ b/monai/data/test_time_augmentation.py @@ -72,11 +72,11 @@ class TestTimeAugmentation: image_key: key used to extract image from input dictionary. orig_key: the key of the original input data in the dict. will get the applied transform information for this input data, then invert them for the expected data with `image_key`. - orig_meta_keys: the key of the meta data of original input data, will get the `affine`, `data_shape`, etc. - the meta data is a dictionary object which contains: filename, original_shape, etc. + orig_meta_keys: the key of the metadata of original input data, will get the `affine`, `data_shape`, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. if None, will try to construct meta_keys by `{orig_key}_{meta_key_postfix}`. - meta_key_postfix: use `key_{postfix}` to fetch the meta data according to the key data, - default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: use `key_{postfix}` to fetch the metadata according to the key data, + default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. this arg only works when `meta_keys=None`. diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 00277ee0af..b29ac3848f 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -40,7 +40,7 @@ class BaseWSIReader(ImageReader): img_data, meta_data = image_reader.get_data(wsi) - The `read` call converts an image filename into whole slide image object, - - The `get_data` call fetches the image data, as well as meta data. + - The `get_data` call fetches the image data, as well as metadata. The following methods needs to be implemented for any concrete implementation of this class: diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index 40bb5f8bed..24bd3edb5c 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -30,10 +30,10 @@ class SegmentationSaver: """ Event handler triggered on completing every iteration to save the segmentation predictions into files. - It can extract the input image meta data(filename, affine, original_shape, etc.) and resample the predictions - based on the meta data. + It can extract the input image metadata(filename, affine, original_shape, etc.) and resample the predictions + based on the metadata. The name of saved file will be `{input_image_name}_{output_postfix}{output_ext}`, - where the input image name is extracted from the meta data dictionary. If no meta data provided, + where the input image name is extracted from the metadata dictionary. If no metadata provided, use index from 0 as the filename prefix. The predictions can be PyTorch Tensor with [B, C, H, W, [D]] shape or a list of Tensor without batch dim. @@ -111,7 +111,7 @@ def __init__( `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as: `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`. batch_transform: a callable that is used to extract the `meta_data` dictionary of the input images - from `ignite.engine.state.batch`. the purpose is to extract necessary information from the meta data: + from `ignite.engine.state.batch`. the purpose is to extract necessary information from the metadata: filename, affine, original_shape, etc. `engine.state` and `batch_transform` inherit from the ignite concept: https://pytorch.org/ignite/concepts.html#state, explanation and usage example are in the tutorial: diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index bc55af0b15..1d60c34c3e 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -109,7 +109,7 @@ class Compose(Randomizable, InvertibleTransform): defaults to `False`. log_stats: whether to log the detailed information of data and applied transform when error happened, for NumPy array and PyTorch Tensor, log the data shape and value range, - for other meta data, log the values directly. default to `False`. + for other metadata, log the values directly. default to `False`. """ @@ -199,7 +199,7 @@ class OneOf(Compose): defaults to `False`. log_stats: whether to log the detailed information of data and applied transform when error happened, for NumPy array and PyTorch Tensor, log the data shape and value range, - for other meta data, log the values directly. default to `False`. + for other metadata, log the values directly. default to `False`. """ diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index d00a89821b..d136340669 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -706,7 +706,7 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform, InvertibleTransform): Crop image with random size or specific size ROI to generate a list of N samples. It can crop at a random position as center or at the image center. And allows to set the minimum size to limit the randomly generated ROI. Suppose all the expected fields - specified by `keys` have same shape, and add `patch_index` to the corresponding meta data. + specified by `keys` have same shape, and add `patch_index` to the corresponding metadata. It will return a list of dictionaries for all the cropped images. Note: even `random_size=False`, if a dimension of the expected ROI size is bigger than the input image size, @@ -729,14 +729,14 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform, InvertibleTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to add `patch_index` to the meta dict. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to add `patch_index` to the meta dict. allow_missing_keys: don't raise exception if key is missing. @@ -791,7 +791,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab for key in self.key_iterator(cropped): cropped[self.trace_key(key)][-1][TraceKeys.CLASS_NAME] = self.__class__.__name__ # type: ignore cropped[self.trace_key(key)][-1][TraceKeys.ID] = id(self) # type: ignore - # add `patch_index` to the meta data + # add `patch_index` to the metadata for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): meta_key = meta_key or f"{key}_{meta_key_postfix}" if meta_key not in cropped: @@ -936,14 +936,14 @@ class RandWeightedCropd(Randomizable, MapTransform, InvertibleTransform): If its components have non-positive values, the corresponding size of `img` will be used. num_samples: number of samples (image patches) to take in the returned list. center_coord_key: if specified, the actual sampling location will be stored with the corresponding key. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to add `patch_index` to the meta dict. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to add `patch_index` to the meta dict. allow_missing_keys: don't raise exception if key is missing. @@ -1007,7 +1007,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab results[i][self.center_coord_key] = center # fill in the extra keys with unmodified data for i in range(self.num_samples): - # add `patch_index` to the meta data + # add `patch_index` to the metadata for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): meta_key = meta_key or f"{key}_{meta_key_postfix}" if meta_key not in results[i]: @@ -1045,7 +1045,7 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform, InvertibleTransform): Crop random fixed sized regions with the center being a foreground or background voxel based on the Pos Neg Ratio. Suppose all the expected fields specified by `keys` have same shape, - and add `patch_index` to the corresponding meta data. + and add `patch_index` to the corresponding metadata. And will return a list of dictionaries for all the cropped images. If a dimension of the expected spatial size is bigger than the input image size, @@ -1078,14 +1078,14 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform, InvertibleTransform): `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `FgBgToIndicesd` transform first and cache the results. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to add `patch_index` to the meta dict. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to add `patch_index` to the meta dict. allow_smaller: if `False`, an exception will be raised if the image is smaller than the requested ROI in any dimension. If `True`, any smaller dimensions will be set to @@ -1185,7 +1185,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) results[i][key] = cropper(img) self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) - # add `patch_index` to the meta data + # add `patch_index` to the metadata for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): meta_key = meta_key or f"{key}_{meta_key_postfix}" if meta_key not in results[i]: @@ -1281,14 +1281,14 @@ class RandCropByLabelClassesd(Randomizable, MapTransform, InvertibleTransform): `image_threshold`, and randomly select crop centers based on them, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `ClassesToIndices` transform first and cache the results for better performance. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to add `patch_index` to the meta dict. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to add `patch_index` to the meta dict. allow_smaller: if `False`, an exception will be raised if the image is smaller than the requested ROI in any dimension. If `True`, any smaller dimensions will remain @@ -1369,7 +1369,7 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, NdarrayO cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) results[i][key] = cropper(img) self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) - # add `patch_index` to the meta data + # add `patch_index` to the metadata for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): meta_key = meta_key or f"{key}_{meta_key_postfix}" if meta_key not in results[i]: diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index b0f5149456..67dc73f93e 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -297,18 +297,18 @@ def __init__( See also: :py:class:`monai.transforms.compose.MapTransform` offset: offset value to shift the intensity of image. factor_key: if not None, use it as the key to extract a value from the corresponding - meta data dictionary of `key` at runtime, and multiply the `offset` to shift intensity. + metadata dictionary of `key` at runtime, and multiply the `offset` to shift intensity. Usually, `IntensityStatsd` transform can pre-compute statistics of intensity values - and store in the meta data. + and store in the metadata. it also can be a sequence of strings, map to `keys`. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to extract the factor value is `factor_key` is not None. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to extract the factor value is `factor_key` is not None. allow_missing_keys: don't raise exception if key is missing. """ @@ -356,18 +356,18 @@ def __init__( offsets: offset range to randomly shift. if single number, offset value is picked from (-offsets, offsets). factor_key: if not None, use it as the key to extract a value from the corresponding - meta data dictionary of `key` at runtime, and multiply the random `offset` to shift intensity. + metadata dictionary of `key` at runtime, and multiply the random `offset` to shift intensity. Usually, `IntensityStatsd` transform can pre-compute statistics of intensity values - and store in the meta data. + and store in the metadata. it also can be a sequence of strings, map to `keys`. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to extract the factor value is `factor_key` is not None. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to extract the factor value is `factor_key` is not None. prob: probability of rotating. (Default 0.1, with 10% probability it returns a rotated array.) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index fc34985903..87f9c6676d 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -106,7 +106,7 @@ def __init__( ) -> None: """ Args: - reader: reader to load image file and meta data + reader: reader to load image file and metadata - if `reader` is None, a default set of `SUPPORTED_READERS` will be used. - if `reader` is a string, it's treated as a class name or dotted path (such as ``"monai.data.ITKReader"``), the supported built-in reader classes are @@ -115,7 +115,7 @@ def __init__( - if `reader` is a reader class/instance, it will be registered to this loader accordingly. image_only: if True return only the image volume, otherwise return image data array and header dict. dtype: if not None convert the loaded image to this data type. - ensure_channel_first: if `True` and loaded both image array and meta data, automatically convert + ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert the image array shape to `channel first`. default to `False`. args: additional parameters for reader if providing a reader name. kwargs: additional parameters for reader if providing a reader name. @@ -123,7 +123,7 @@ def __init__( Note: - The transform returns an image data array if `image_only` is True, - or a tuple of two elements containing the data array, and the meta data in a dictionary format otherwise. + or a tuple of two elements containing the data array, and the metadata in a dictionary format otherwise. - If `reader` is specified, the loader will attempt to use the specified readers and the default supported readers. This might introduce overheads when handling the exceptions of trying the incompatible loaders. In this case, it is therefore recommended setting the most appropriate reader as @@ -176,7 +176,7 @@ def __init__( def register(self, reader: ImageReader): """ - Register image reader to load image file and meta data. + Register image reader to load image file and metadata. Args: reader: reader instance to be registered with this loader. @@ -188,7 +188,7 @@ def register(self, reader: ImageReader): def __call__(self, filename: Union[Sequence[PathLike], PathLike], reader: Optional[ImageReader] = None): """ - Load image file and meta data from the given filename(s). + Load image file and metadata from the given filename(s). If `reader` is not specified, this class automatically chooses readers based on the reversed order of registered readers `self.readers`. @@ -199,7 +199,7 @@ def __call__(self, filename: Union[Sequence[PathLike], PathLike], reader: Option and will stack them together as multi-channels data. if provided directory path instead of file path, will treat it as DICOM images series and read. - reader: runtime reader to load image file and meta data. + reader: runtime reader to load image file and metadata. """ filename = tuple(f"{Path(s).expanduser()}" for s in ensure_tuple(filename)) # allow Path objects diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 30dedc7810..46dcda469e 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -38,7 +38,7 @@ class LoadImaged(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.LoadImage`, It can load both image data and metadata. When loading a list of files in one key, the arrays will be stacked and a new dimension will be added as the first dimension - In this case, the meta data of the first image will be used to represent the stacked result. + In this case, the metadata of the first image will be used to represent the stacked result. The affine transform of all the stacked images should be same. The output metadata field will be created as ``meta_keys`` or ``key_{meta_key_postfix}``. @@ -82,7 +82,7 @@ def __init__( Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` - reader: reader to load image file and meta data + reader: reader to load image file and metadata - if `reader` is None, a default set of `SUPPORTED_READERS` will be used. - if `reader` is a string, it's treated as a class name or dotted path (such as ``"monai.data.ITKReader"``), the supported built-in reader classes are @@ -90,18 +90,18 @@ def __init__( a reader instance will be constructed with the `*args` and `**kwargs` parameters. - if `reader` is a reader class/instance, it will be registered to this loader accordingly. dtype: if not None, convert the loaded image data to this data type. - meta_keys: explicitly indicate the key to store the corresponding meta data dictionary. - the meta data is a dictionary object which contains: filename, original_shape, etc. + meta_keys: explicitly indicate the key to store the corresponding metadata dictionary. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. meta_key_postfix: if meta_keys is None, use `key_{postfix}` to store the metadata of the nifti image, - default is `meta_dict`. The meta data is a dictionary object. + default is `meta_dict`. The metadata is a dictionary object. For example, load nifti file for `image`, store the metadata into `image_meta_dict`. - overwriting: whether allow overwriting existing meta data of same key. + overwriting: whether allow overwriting existing metadata of same key. default is False, which will raise exception if encountering existing key. image_only: if True return dictionary containing just only the image volumes, otherwise return dictionary containing image data array and header dict per input key. - ensure_channel_first: if `True` and loaded both image array and meta data, automatically convert + ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert the image array shape to `channel first`. default to `False`. allow_missing_keys: don't raise exception if key is missing. args: additional parameters for reader if providing a reader name. @@ -141,7 +141,7 @@ def __call__(self, data, reader: Optional[ImageReader] = None): raise ValueError("metadata must be a dict.") meta_key = meta_key or f"{key}_{meta_key_postfix}" if meta_key in d and not self.overwriting: - raise KeyError(f"Meta data with key {meta_key} already exists and overwriting=False.") + raise KeyError(f"Metadata with key {meta_key} already exists and overwriting=False.") d[meta_key] = data[1] return d diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 00ffe7edf7..6625a9d791 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -561,17 +561,17 @@ def __init__( orig_keys: the key of the original input data in the dict. the transform trace information of ``transforms`` should be stored at ``{orig_keys}_transforms``. It can also be a list of keys, each matches the ``keys``. - meta_keys: The key to output the inverted meta data dictionary. - The meta data is a dictionary optionally containing: filename, original_shape. + meta_keys: The key to output the inverted metadata dictionary. + The metadata is a dictionary optionally containing: filename, original_shape. It can be a sequence of strings, maps to ``keys``. - If None, will try to create a meta data dict with the default key: `{key}_{meta_key_postfix}`. - orig_meta_keys: the key of the meta data of original input data. - The meta data is a dictionary optionally containing: filename, original_shape. + If None, will try to create a metadata dict with the default key: `{key}_{meta_key_postfix}`. + orig_meta_keys: the key of the metadata of original input data. + The metadata is a dictionary optionally containing: filename, original_shape. It can be a sequence of strings, maps to the `keys`. - If None, will try to create a meta data dict with the default key: `{orig_key}_{meta_key_postfix}`. - This meta data dict will also be included in the inverted dict, stored in `meta_keys`. + If None, will try to create a metadata dict with the default key: `{orig_key}_{meta_key_postfix}`. + This metadata dict will also be included in the inverted dict, stored in `meta_keys`. meta_key_postfix: if `orig_meta_keys` is None, use `{orig_key}_{meta_key_postfix}` to fetch the - meta data from dict, if `meta_keys` is None, use `{key}_{meta_key_postfix}`. Default: ``"meta_dict"``. + metadata from dict, if `meta_keys` is None, use `{key}_{meta_key_postfix}`. Default: ``"meta_dict"``. nearest_interp: whether to use `nearest` interpolation mode when inverting the spatial transforms, default to `True`. If `False`, use the same interpolation mode as the original transform. It also can be a list of bool, each matches to the `keys` data. @@ -660,7 +660,7 @@ def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: class SaveClassificationd(MapTransform): """ - Save the classification results and meta data into CSV file or other storage. + Save the classification results and metadata into CSV file or other storage. """ @@ -681,16 +681,16 @@ def __init__( Args: keys: keys of the corresponding items to model output, this transform only supports 1 key. See also: :py:class:`monai.transforms.compose.MapTransform` - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. will extract the filename of input image to save classification results. meta_key_postfix: `key_{postfix}` was used to store the metadata in `LoadImaged`. so need the key to extract the metadata of input image, like filename, etc. default is `meta_dict`. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. this arg only works when `meta_keys=None`. if no corresponding metadata, set to `None`. saver: the saver instance to save classification results, if None, create a CSVSaver internally. the saver must provide `save(data, meta_data)` and `finalize()` APIs. diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 222aee2d90..3bae992859 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -274,7 +274,7 @@ def __call__( class ResampleToMatch(SpatialResample): - """Resample an image to match given meta data. The affine matrix will be aligned, + """Resample an image to match given metadata. The affine matrix will be aligned, and the size of the output image will match.""" def __call__( # type: ignore diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d354c89dbc..d00dd4463c 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -190,13 +190,13 @@ def __init__( If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. It also can be a sequence of dtypes, each element corresponds to a key in ``keys``. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + the metadata is a dictionary object which contains: filename, affine, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys=None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys=None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. meta_src_keys: the key of the corresponding ``src_affine`` in the metadata dictionary. @@ -314,7 +314,7 @@ def __init__( """ Args: keys: keys of the corresponding items to be transformed. - template_key: key to meta data that output should be resampled to match. + template_key: key to metadata that output should be resampled to match. mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -474,13 +474,13 @@ def __init__( If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. It also can be a sequence of dtypes, each element corresponds to a key in ``keys``. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + the metadata is a dictionary object which contains: filename, affine, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys=None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys=None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. allow_missing_keys: don't raise exception if key is missing. @@ -612,13 +612,13 @@ def __init__( labels: optional, None or sequence of (2,) sequences (2,) sequences are labels for (beginning, end) of output axis. Defaults to ``(('L', 'R'), ('P', 'A'), ('I', 'S'))``. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + the metadata is a dictionary object which contains: filename, affine, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. allow_missing_keys: don't raise exception if key is missing. diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 8537f7eb89..65bb13e6b8 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -75,7 +75,7 @@ def apply_transform( unpack_items: whether to unpack parameters using `*`. Defaults to False. log_stats: whether to log the detailed information of data and applied transform when error happened, for NumPy array and PyTorch Tensor, log the data shape and value range, - for other meta data, log the values directly. default to `False`. + for other metadata, log the values directly. default to `False`. Raises: Exception: When ``transform`` raises an exception. @@ -102,7 +102,7 @@ def _log_stats(data, prefix: Optional[str] = "Data"): # log data type, shape, range for array datastats(img=data, data_shape=True, value_range=True, prefix=prefix) else: - # log data type and value for other meta data + # log data type and value for other metadata datastats(img=data, data_value=True, prefix=prefix) if isinstance(data, dict): diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index bc0c09e949..0d5bafb026 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -1105,7 +1105,7 @@ def __call__(self, img: NdarrayOrTensor): class IntensityStats(Transform): """ - Compute statistics for the intensity values of input image and store into the meta data dictionary. + Compute statistics for the intensity values of input image and store into the metadata dictionary. For example: if `ops=[lambda x: np.mean(x), "max"]` and `key_prefix="orig"`, may generate below stats: `{"orig_custom_0": 1.5, "orig_max": 3.0}`. @@ -1115,7 +1115,7 @@ class IntensityStats(Transform): mapping to `np.nanmean`, `np.nanmedian`, `np.nanmax`, `np.nanmin`, `np.nanstd`. if a callable function, will execute the function on input image. key_prefix: the prefix to combine with `ops` name to generate the key to store the results in the - meta data dictionary. if some `ops` are callable functions, will use "{key_prefix}_custom_{index}" + metadata dictionary. if some `ops` are callable functions, will use "{key_prefix}_custom_{index}" as the key, where index counts from 0. channel_wise: whether to compute statistics for every channel of input image separately. if True, return a list of values for every operation, default to False. @@ -1137,7 +1137,7 @@ def __call__( Args: img: input image to compute intensity stats. - meta_data: meta data dictionary to store the statistics data, if None, will create an empty dictionary. + meta_data: metadata dictionary to store the statistics data, if None, will create an empty dictionary. mask: if not None, mask the image to extract only the interested area to compute statistics. mask must have the same shape as input `img`. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 564b2993e7..87d1becaa4 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -302,9 +302,9 @@ def __init__( Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. meta_key_postfix: if meta_keys is None and `key_{postfix}` was used to store the metadata in `LoadImaged`. @@ -1457,7 +1457,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N class IntensityStatsd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.IntensityStats`. - Compute statistics for the intensity values of input image and store into the meta data dictionary. + Compute statistics for the intensity values of input image and store into the metadata dictionary. For example: if `ops=[lambda x: np.mean(x), "max"]` and `key_prefix="orig"`, may generate below stats: `{"orig_custom_0": 1.5, "orig_max": 3.0}`. @@ -1469,21 +1469,21 @@ class IntensityStatsd(MapTransform): mapping to `np.nanmean`, `np.nanmedian`, `np.nanmax`, `np.nanmin`, `np.nanstd`. if a callable function, will execute the function on input image. key_prefix: the prefix to combine with `ops` name to generate the key to store the results in the - meta data dictionary. if some `ops` are callable functions, will use "{key_prefix}_custom_{index}" + metadata dictionary. if some `ops` are callable functions, will use "{key_prefix}_custom_{index}" as the key, where index counts from 0. mask_keys: if not None, specify the mask array for the image to extract only the interested area to compute statistics, mask must have the same shape as the image. it should be a sequence of strings or None, map to the `keys`. channel_wise: whether to compute statistics for every channel of input image separately. if True, return a list of values for every operation, default to False. - meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. used to store the computed statistics to the meta dict. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, original_shape, etc. + the metadata is a dictionary object which contains: filename, original_shape, etc. it can be a sequence of string, map to the `keys`. if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. used to store the computed statistics to the meta dict. allow_missing_keys: don't raise exception if key is missing. diff --git a/monai/utils/enums.py b/monai/utils/enums.py index cbb2f053a5..8920f51a88 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -225,7 +225,7 @@ class ForwardMode(Enum): class TraceKeys: - """Extra meta data keys used for traceable transforms.""" + """Extra metadata keys used for traceable transforms.""" CLASS_NAME: str = "class" ID: str = "id" @@ -239,7 +239,7 @@ class TraceKeys: @deprecated(since="0.8.0", msg_suffix="use monai.utils.enums.TraceKeys instead.") class InverseKeys: """ - Extra meta data keys used for inverse transforms. + Extra metadata keys used for inverse transforms. .. deprecated:: 0.8.0 Use :class:`monai.utils.enums.TraceKeys` instead. diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 5f074289bc..521968a87d 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -354,7 +354,7 @@ def copy_to_device( class ImageMetaKey: """ - Common key names in the meta data header of images + Common key names in the metadata header of images """ FILENAME_OR_OBJ = "filename_or_obj" From f4ff8e2a07b5af29a62ef6ed14b92bd6806adc61 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 16 May 2022 13:32:02 +0100 Subject: [PATCH 066/183] optional nrrd (#4279) * optional nrrd Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- tests/test_nrrd_reader.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_nrrd_reader.py b/tests/test_nrrd_reader.py index 5561d471ba..03bfbfe156 100644 --- a/tests/test_nrrd_reader.py +++ b/tests/test_nrrd_reader.py @@ -12,12 +12,15 @@ import os import tempfile import unittest +from unittest.case import skipUnless -import nrrd import numpy as np from parameterized import parameterized from monai.data import NrrdReader +from monai.utils.module import optional_import + +nrrd, has_nrrd = optional_import("nrrd", allow_namespace_pkg=True) TEST_CASE_1 = [(4, 4), "test_image.nrrd", (4, 4), np.uint8] TEST_CASE_2 = [(4, 4, 4), "test_image.nrrd", (4, 4, 4), np.uint16] @@ -41,6 +44,7 @@ ] +@skipUnless(has_nrrd, "nrrd required") class TestNrrdReader(unittest.TestCase): def test_verify_suffix(self): reader = NrrdReader() From cd77e910400eed5b596e739fb588e118677c813f Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 18 May 2022 14:01:47 +0100 Subject: [PATCH 067/183] 4281 - adds zero_centered option to `AffineTransform` (#4282) * update norm xform Signed-off-by: Wenqi Li * consistent API Signed-off-by: Wenqi Li * adds unit tests Signed-off-by: Wenqi Li * update docstring Signed-off-by: Wenqi Li * fixes unit tests Signed-off-by: Wenqi Li * update based on comment Signed-off-by: Wenqi Li --- monai/networks/layers/spatial_transforms.py | 16 ++++++++- monai/networks/utils.py | 27 +++++++++++---- monai/transforms/spatial/array.py | 19 +++++++---- tests/test_affine_transform.py | 37 +++++++++++++++++---- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/monai/networks/layers/spatial_transforms.py b/monai/networks/layers/spatial_transforms.py index c1bb951c4d..07ddb3ce9d 100644 --- a/monai/networks/layers/spatial_transforms.py +++ b/monai/networks/layers/spatial_transforms.py @@ -420,6 +420,7 @@ def __init__( padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.ZEROS, align_corners: bool = False, reverse_indexing: bool = True, + zero_centered: Optional[bool] = None, ) -> None: """ Apply affine transformations with a batch of affine matrices. @@ -454,6 +455,12 @@ def __init__( reverse_indexing: whether to reverse the spatial indexing of image and coordinates. set to `False` if `theta` follows pytorch's default "D, H, W" convention. set to `True` if `theta` follows `scipy.ndimage` default "i, j, k" convention. + zero_centered: whether the affine is applied to coordinates in a zero-centered value range. + With `zero_centered=True`, for example, the center of rotation will be the + spatial center of the input; with `zero_centered=False`, the center of rotation will be the + origin of the input. This option is only available when `normalized=False`, + where the default behaviour is `False` if unspecified. + See also: :py:func:`monai.networks.utils.normalize_transform`. """ super().__init__() self.spatial_size = ensure_tuple(spatial_size) if spatial_size is not None else None @@ -462,6 +469,9 @@ def __init__( self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) self.align_corners = align_corners self.reverse_indexing = reverse_indexing + if zero_centered is not None and self.normalized: + raise ValueError("`normalized=True` is not compatible with the `zero_centered` option.") + self.zero_centered = zero_centered if zero_centered is not None else False def forward( self, src: torch.Tensor, theta: torch.Tensor, spatial_size: Optional[Union[Sequence[int], int]] = None @@ -525,7 +535,11 @@ def forward( # reverse and normalize theta if needed if not self.normalized: theta = to_norm_affine( - affine=theta, src_size=src_size[2:], dst_size=dst_size[2:], align_corners=self.align_corners + affine=theta, + src_size=src_size[2:], + dst_size=dst_size[2:], + align_corners=self.align_corners, + zero_centered=self.zero_centered, ) if self.reverse_indexing: rev_idx = torch.as_tensor(range(sr - 1, -1, -1), device=src.device) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 34ea4f716e..6992d27309 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -138,11 +138,17 @@ def normalize_transform( device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None, align_corners: bool = False, + zero_centered: bool = False, ) -> torch.Tensor: """ Compute an affine matrix according to the input shape. The transform normalizes the homogeneous image coordinates to the - range of `[-1, 1]`. + range of `[-1, 1]`. Currently the following source coordinates are supported: + + - `align_corners=False`, `zero_centered=False`, normalizing from ``[-0.5, d-0.5]``. + - `align_corners=True`, `zero_centered=False`, normalizing from ``[0, d-1]``. + - `align_corners=False`, `zero_centered=True`, normalizing from ``[-(d+1)/2, (d-1)/2]``. + - `align_corners=True`, `zero_centered=True`, normalizing from ``[-(d-1)/2, (d-1)/2]``. Args: shape: input spatial shape @@ -151,25 +157,32 @@ def normalize_transform( align_corners: if True, consider -1 and 1 to refer to the centers of the corner pixels rather than the image corners. See also: https://pytorch.org/docs/stable/nn.functional.html#torch.nn.functional.grid_sample + zero_centered: whether the coordinates are normalized from a zero-centered range, default to `False`. + Setting this flag and `align_corners` will jointly specify the normalization source range. """ norm = torch.tensor(shape, dtype=torch.float64, device=device) # no in-place change if align_corners: norm[norm <= 1.0] = 2.0 norm = 2.0 / (norm - 1.0) norm = torch.diag(torch.cat((norm, torch.ones((1,), dtype=torch.float64, device=device)))) - norm[:-1, -1] = -1.0 + if not zero_centered: # else shift is 0 + norm[:-1, -1] = -1.0 else: norm[norm <= 0.0] = 2.0 norm = 2.0 / norm norm = torch.diag(torch.cat((norm, torch.ones((1,), dtype=torch.float64, device=device)))) - norm[:-1, -1] = 1.0 / torch.tensor(shape, dtype=torch.float64, device=device) - 1.0 + norm[:-1, -1] = 1.0 / torch.tensor(shape, dtype=torch.float64, device=device) - (0.0 if zero_centered else 1.0) norm = norm.unsqueeze(0).to(dtype=dtype) norm.requires_grad = False return norm def to_norm_affine( - affine: torch.Tensor, src_size: Sequence[int], dst_size: Sequence[int], align_corners: bool = False + affine: torch.Tensor, + src_size: Sequence[int], + dst_size: Sequence[int], + align_corners: bool = False, + zero_centered: bool = False, ) -> torch.Tensor: """ Given ``affine`` defined for coordinates in the pixel space, compute the corresponding affine @@ -182,6 +195,8 @@ def to_norm_affine( align_corners: if True, consider -1 and 1 to refer to the centers of the corner pixels rather than the image corners. See also: https://pytorch.org/docs/stable/nn.functional.html#torch.nn.functional.grid_sample + zero_centered: whether the coordinates are normalized from a zero-centered range, default to `False`. + See also: :py:func:`monai.networks.utils.normalize_transform`. Raises: TypeError: When ``affine`` is not a ``torch.Tensor``. @@ -197,8 +212,8 @@ def to_norm_affine( if sr != len(src_size) or sr != len(dst_size): raise ValueError(f"affine suggests {sr}D, got src={len(src_size)}D, dst={len(dst_size)}D.") - src_xform = normalize_transform(src_size, affine.device, affine.dtype, align_corners) - dst_xform = normalize_transform(dst_size, affine.device, affine.dtype, align_corners) + src_xform = normalize_transform(src_size, affine.device, affine.dtype, align_corners, zero_centered) + dst_xform = normalize_transform(dst_size, affine.device, affine.dtype, align_corners, zero_centered) return src_xform @ affine @ torch.inverse(dst_xform) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 3bae992859..44a0e88ce0 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -253,7 +253,7 @@ def __call__( ) xform = _t_l @ xform # type: ignore affine_xform = Affine( - affine=xform, spatial_size=spatial_size, norm_coords=False, image_only=True, dtype=_dtype + affine=xform, spatial_size=spatial_size, normalized=True, image_only=True, dtype=_dtype ) output_data = affine_xform(img_, mode=mode, padding_mode=padding_mode) else: @@ -1733,6 +1733,7 @@ class Affine(Transform): backend = list(set(AffineGrid.backend) & set(Resample.backend)) @deprecated_arg(name="as_tensor_output", since="0.6") + @deprecated_arg(name="norm_coords", since="0.8") def __init__( self, rotate_params: Optional[Union[Sequence[float], float]] = None, @@ -1743,6 +1744,7 @@ def __init__( spatial_size: Optional[Union[Sequence[int], int]] = None, mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, + normalized: bool = False, norm_coords: bool = True, as_tensor_output: bool = True, device: Optional[torch.device] = None, @@ -1787,11 +1789,11 @@ def __init__( padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``} Padding mode for outside grid values. Defaults to ``"reflection"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html - norm_coords: whether to normalize the coordinates from `[-(size-1)/2, (size-1)/2]` to - `[0, size - 1]` or `[-1, 1]` to be compatible with the underlying resampling API. - If the coordinates are generated by ``monai.transforms.utils.create_grid`` - and the ``affine`` doesn't include the normalization, this argument should be set to ``True``. - If the output `self.affine_grid` is already normalized, this argument should be set to ``False``. + normalized: indicating whether the provided `affine` is defined to include a normalization + transform converting the coordinates from `[-(size-1)/2, (size-1)/2]` (defined in ``create_grid``) to + `[0, size - 1]` or `[-1, 1]` in order to be compatible with the underlying resampling API. + If `normalized=False`, additional coordinate normalization will be applied before resampling. + See also: :py:func:`monai.networks.utils.normalize_transform`. device: device on which the tensor will be allocated. dtype: data type for resampling computation. Defaults to ``np.float32``. If ``None``, use the data type of input data. To be compatible with other modules, @@ -1800,6 +1802,9 @@ def __init__( .. deprecated:: 0.6.0 ``as_tensor_output`` is deprecated. + .. deprecated:: 0.8.1 + ``norm_coords`` is deprecated, please use ``normalized`` instead + (the new flag is a negation, i.e., ``norm_coords == not normalized``). """ self.affine_grid = AffineGrid( @@ -1812,7 +1817,7 @@ def __init__( device=device, ) self.image_only = image_only - self.resampler = Resample(norm_coords=norm_coords, device=device, dtype=dtype) + self.resampler = Resample(norm_coords=not normalized, device=device, dtype=dtype) self.spatial_size = spatial_size self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) diff --git a/tests/test_affine_transform.py b/tests/test_affine_transform.py index 5170ab4260..902437350a 100644 --- a/tests/test_affine_transform.py +++ b/tests/test_affine_transform.py @@ -23,12 +23,14 @@ TEST_NORM_CASES = [ [(4, 5), True, [[[0.666667, 0, -1], [0, 0.5, -1], [0, 0, 1]]]], + [(4, 5), True, [[[0.666667, 0, 0], [0, 0.5, 0], [0, 0, 1]]], True], [ (2, 4, 5), True, [[[2.0, 0.0, 0.0, -1.0], [0.0, 0.6666667, 0.0, -1.0], [0.0, 0.0, 0.5, -1.0], [0.0, 0.0, 0.0, 1.0]]], ], [(4, 5), False, [[[0.5, 0.0, -0.75], [0.0, 0.4, -0.8], [0.0, 0.0, 1.0]]]], + [(4, 5), False, [[[0.5, 0.0, 0.25], [0.0, 0.4, 0.2], [0.0, 0.0, 1.0]]], True], [(2, 4, 5), False, [[[1.0, 0.0, 0.0, -0.5], [0.0, 0.5, 0.0, -0.75], [0.0, 0.0, 0.4, -0.8], [0.0, 0.0, 0.0, 1.0]]]], ] @@ -61,6 +63,14 @@ False, [[[1.5, 0.0, 0.0, 0.5], [0.0, 1.25, 0.0, 0.25], [0.0, 0.0, 0.5, -0.5], [0.0, 0.0, 0.0, 1.0]]], ], + [ + [[[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]], + (2, 4, 6), + (3, 5, 3), + False, + [[[1.5, 0.0, 0.0, 0.0], [0.0, 1.25, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]]], + True, + ], ] TEST_ILL_TO_NORM_AFFINE_CASES = [ @@ -72,15 +82,23 @@ class TestNormTransform(unittest.TestCase): @parameterized.expand(TEST_NORM_CASES) - def test_norm_xform(self, input_shape, align_corners, expected): + def test_norm_xform(self, input_shape, align_corners, expected, zero_centered=False): norm = normalize_transform( - input_shape, device=torch.device("cpu:0"), dtype=torch.float32, align_corners=align_corners + input_shape, + device=torch.device("cpu:0"), + dtype=torch.float32, + align_corners=align_corners, + zero_centered=zero_centered, ) norm = norm.detach().cpu().numpy() np.testing.assert_allclose(norm, expected, atol=1e-6) if torch.cuda.is_available(): norm = normalize_transform( - input_shape, device=torch.device("cuda:0"), dtype=torch.float32, align_corners=align_corners + input_shape, + device=torch.device("cuda:0"), + dtype=torch.float32, + align_corners=align_corners, + zero_centered=zero_centered, ) norm = norm.detach().cpu().numpy() np.testing.assert_allclose(norm, expected, atol=1e-4) @@ -88,15 +106,15 @@ def test_norm_xform(self, input_shape, align_corners, expected): class TestToNormAffine(unittest.TestCase): @parameterized.expand(TEST_TO_NORM_AFFINE_CASES) - def test_to_norm_affine(self, affine, src_size, dst_size, align_corners, expected): + def test_to_norm_affine(self, affine, src_size, dst_size, align_corners, expected, zero_centered=False): affine = torch.as_tensor(affine, device=torch.device("cpu:0"), dtype=torch.float32) - new_affine = to_norm_affine(affine, src_size, dst_size, align_corners) + new_affine = to_norm_affine(affine, src_size, dst_size, align_corners, zero_centered) new_affine = new_affine.detach().cpu().numpy() np.testing.assert_allclose(new_affine, expected, atol=1e-6) if torch.cuda.is_available(): affine = torch.as_tensor(affine, device=torch.device("cuda:0"), dtype=torch.float32) - new_affine = to_norm_affine(affine, src_size, dst_size, align_corners) + new_affine = to_norm_affine(affine, src_size, dst_size, align_corners, zero_centered) new_affine = new_affine.detach().cpu().numpy() np.testing.assert_allclose(new_affine, expected, atol=1e-5, rtol=_rtol) @@ -155,6 +173,13 @@ def test_zoom_2(self): expected = [[[[1, 3]]]] np.testing.assert_allclose(out, expected, atol=1e-5, rtol=_rtol) + def test_zoom_zero_center(self): + affine = torch.as_tensor([[2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], dtype=torch.float32) + image = torch.arange(1.0, 13.0).view(1, 1, 3, 4).to(device=torch.device("cpu:0")) + out = AffineTransform((1, 2), zero_centered=True)(image, affine) + expected = [[[[3, 5]]]] + np.testing.assert_allclose(out, expected, atol=1e-5, rtol=_rtol) + def test_affine_transform_minimum(self): t = np.pi / 3 affine = [[np.cos(t), -np.sin(t), 0], [np.sin(t), np.cos(t), 0], [0, 0, 1]] From db320950a6901ce3558a5e5ee2fc12db562581bf Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Wed, 18 May 2022 12:50:47 -0400 Subject: [PATCH 068/183] Recursive NVTX Range (#4270) * Make Range to be recuresivley applied Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Deferred import Compose Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docstring Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * address comment Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/utils/nvtx.py | 15 +++++++++ tests/test_nvtx_decorator.py | 63 +++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/monai/utils/nvtx.py b/monai/utils/nvtx.py index 691f900c7d..fefab380f1 100644 --- a/monai/utils/nvtx.py +++ b/monai/utils/nvtx.py @@ -43,6 +43,8 @@ class Range: Otherwise, it look up predefined methods: "forward", "__call__", "__next__", "__getitem__" append_method_name: if append the name of the methods to be decorated to the range's name If None (default), it appends the method's name only if we are annotating more than one method. + recursive: if set to True, it will recursively annotate every individual module in a list + or in a chain of modules (chained using Compose). Default to False. """ @@ -53,12 +55,25 @@ def __init__( name: Optional[str] = None, methods: Optional[Union[str, Tuple[str, ...]]] = None, append_method_name: Optional[bool] = None, + recursive: bool = False, ) -> None: self.name = name self.methods = methods self.append_method_name = append_method_name + self.recursive = recursive def __call__(self, obj: Any): + if self.recursive is True: + if isinstance(obj, (list, tuple)): + return type(obj)(Range(recursive=True)(t) for t in obj) + + from monai.transforms.compose import Compose + + if isinstance(obj, Compose): + obj.transforms = Range(recursive=True)(obj.transforms) + + self.recursive = False + # Define the name to be associated to the range if not provided if self.name is None: name = type(obj).__name__ diff --git a/tests/test_nvtx_decorator.py b/tests/test_nvtx_decorator.py index e81c72efcf..9932b678c9 100644 --- a/tests/test_nvtx_decorator.py +++ b/tests/test_nvtx_decorator.py @@ -19,16 +19,18 @@ Compose, CuCIM, Flip, - FlipD, + Flipd, + OneOf, RandAdjustContrast, RandCuCIM, RandFlip, Randomizable, Rotate90, ToCupy, + ToNumpy, TorchVision, ToTensor, - ToTensorD, + ToTensord, ) from monai.utils import Range, optional_import from tests.utils import HAS_CUPY @@ -49,8 +51,32 @@ TEST_CASE_WRAPPER = [np.random.randn(3, 10, 10)] +TEST_CASE_RECURSIVE_0 = [ + torch.randn(3, 3), + Compose([ToNumpy(), Flip(), RandAdjustContrast(prob=0.0), RandFlip(prob=1.0), ToTensor()]), +] +TEST_CASE_RECURSIVE_1 = [ + torch.randn(3, 3), + Compose([ToNumpy(), Flip(), Compose([RandAdjustContrast(prob=0.0), RandFlip(prob=1.0)]), ToTensor()]), +] +TEST_CASE_RECURSIVE_2 = [ + torch.randn(3, 3), + Compose( + [ + ToNumpy(), + Flip(), + OneOf([RandAdjustContrast(prob=0.0), RandFlip(prob=1.0)], weights=[0, 1], log_stats=True), + ToTensor(), + ] + ), +] +TEST_CASE_RECURSIVE_LIST = [ + torch.randn(3, 3), + [ToNumpy(), Flip(), RandAdjustContrast(prob=0.0), RandFlip(prob=1.0), ToTensor()], +] + -@unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") +@unittest.skipUnless(has_nvtx, "Required torch._C._nvtx for NVTX Range!") class TestNVTXRangeDecorator(unittest.TestCase): @parameterized.expand([TEST_CASE_ARRAY_0, TEST_CASE_ARRAY_1]) def test_tranform_array(self, input): @@ -79,7 +105,7 @@ def test_tranform_array(self, input): @parameterized.expand([TEST_CASE_DICT_0, TEST_CASE_DICT_1]) def test_tranform_dict(self, input): - transforms = Compose([Range("random flip dict")(FlipD(keys="image")), Range()(ToTensorD("image"))]) + transforms = Compose([Range("random flip dict")(Flipd(keys="image")), Range()(ToTensord("image"))]) # Apply transforms output = transforms(input)["image"] @@ -127,6 +153,35 @@ def test_wrapper_tranforms(self, input): # Check the outputs np.testing.assert_equal(output.get(), output_r.get()) + @parameterized.expand([TEST_CASE_RECURSIVE_0, TEST_CASE_RECURSIVE_1, TEST_CASE_RECURSIVE_2]) + def test_recursive_tranforms(self, input, transforms): + transforms_range = Range(name="Recursive Compose", recursive=True)(transforms) + + # Apply transforms + output = transforms(input) + + # Apply transforms with Range + output_r = transforms_range(input) + + # Check the outputs + self.assertEqual(transforms.map_items, transforms_range.map_items) + self.assertEqual(transforms.unpack_items, transforms_range.unpack_items) + self.assertEqual(transforms.log_stats, transforms_range.log_stats) + np.testing.assert_equal(output.numpy(), output_r.numpy()) + + @parameterized.expand([TEST_CASE_RECURSIVE_LIST]) + def test_recursive_list_tranforms(self, input, transform_list): + transforms_list_range = Range(recursive=True)(transform_list) + + # Apply transforms + output = Compose(transform_list)(input) + + # Apply transforms with Range + output_r = Compose(transforms_list_range)(input) + + # Check the outputs + np.testing.assert_equal(output.numpy(), output_r.numpy()) + @parameterized.expand([TEST_CASE_ARRAY_1]) def test_tranform_randomized(self, input): # Compose deterministic and randomized transforms From 6eaa3ab1b0f5ba568d69785cefbdb1cde74d259a Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Wed, 18 May 2022 15:03:25 -0400 Subject: [PATCH 069/183] add box mode convert in box_utils (#4254) * add box_utils and unit tests Signed-off-by: Can Zhao --- docs/source/data.rst | 19 ++ monai/data/__init__.py | 1 + monai/data/box_utils.py | 574 +++++++++++++++++++++++++++++++++ monai/utils/__init__.py | 2 + monai/utils/enums.py | 17 + monai/utils/type_conversion.py | 12 + tests/test_box_utils.py | 157 +++++++++ 7 files changed, 782 insertions(+) create mode 100644 monai/data/box_utils.py create mode 100644 tests/test_box_utils.py diff --git a/docs/source/data.rst b/docs/source/data.rst index 02e8031117..fad640b81c 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -311,3 +311,22 @@ PatchWSIDataset ~~~~~~~~~~~~~~~ .. autoclass:: monai.data.PatchWSIDataset :members: + +Bounding box +------------ + +Box mode +~~~~~~~~~~ +.. autoclass:: monai.data.box_utils.BoxMode + :members: +.. autoclass:: monai.data.box_utils.CornerCornerModeTypeA +.. autoclass:: monai.data.box_utils.CornerCornerModeTypeB +.. autoclass:: monai.data.box_utils.CornerCornerModeTypeC +.. autoclass:: monai.data.box_utils.CornerSizeMode +.. autoclass:: monai.data.box_utils.CenterSizeMode + +Box mode converter +~~~~~~~~~~~~~~~~~~ +.. autofunction:: monai.data.box_utils.get_boxmode +.. autofunction:: monai.data.box_utils.convert_box_mode +.. autofunction:: monai.data.box_utils.convert_box_to_standard_mode diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 63aa29df65..60574cb565 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .box_utils import convert_box_mode, convert_box_to_standard_mode from .csv_saver import CSVSaver from .dataloader import DataLoader from .dataset import ( diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py new file mode 100644 index 0000000000..ca8dcd284a --- /dev/null +++ b/monai/data/box_utils.py @@ -0,0 +1,574 @@ +# 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. + +""" +This utility module mainly supports rectangular bounding boxes with a few +different parameterizations and methods for converting between them. It +provides reliable access to the spatial coordinates of the box vertices in the +"canonical ordering": +[xmin, ymin, xmax, ymax] for 2D and [xmin, ymin, zmin, xmax, ymax, zmax] for 3D. +We currently define this ordering as `monai.data.box_utils.StandardMode` and +the rest of the detection pipelines mainly assumes boxes in `StandardMode`. +""" + +import inspect +import warnings +from abc import ABC, abstractmethod +from copy import deepcopy +from typing import Dict, Sequence, Tuple, Type, Union + +import numpy as np +import torch + +from monai.config.type_definitions import NdarrayOrTensor +from monai.utils import look_up_option +from monai.utils.enums import BoxModeName +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type + +# We support 2-D or 3-D bounding boxes +SUPPORTED_SPATIAL_DIMS = [2, 3] + + +# TO_REMOVE = 0.0 if the bottom-right corner pixel/voxel is not included in the box, +# i.e., when xmin=1., xmax=2., we have w = 1. +# TO_REMOVE = 1.0 if the bottom-right corner pixel/voxel is included in the box, +# i.e., when xmin=1., xmax=2., we have w = 2. +# Currently, only `TO_REMOVE = 0.0` is supported +TO_REMOVE = 0.0 # xmax-xmin = w -TO_REMOVE. + + +class BoxMode(ABC): + """ + An abstract class of a ``BoxMode``. + + A ``BoxMode`` is callable that converts box mode of ``boxes``, which are Nx4 (2D) or Nx6 (3D) torch tensor or ndarray. + ``BoxMode`` has several subclasses that represents different box modes, including + + - :class:`~monai.data.box_utils.CornerCornerModeTypeA`: + represents [xmin, ymin, xmax, ymax] for 2D and [xmin, ymin, zmin, xmax, ymax, zmax] for 3D + - :class:`~monai.data.box_utils.CornerCornerModeTypeB`: + represents [xmin, xmax, ymin, ymax] for 2D and [xmin, xmax, ymin, ymax, zmin, zmax] for 3D + - :class:`~monai.data.box_utils.CornerCornerModeTypeC`: + represents [xmin, ymin, xmax, ymax] for 2D and [xmin, ymin, xmax, ymax, zmin, zmax] for 3D + - :class:`~monai.data.box_utils.CornerSizeMode`: + represents [xmin, ymin, xsize, ysize] for 2D and [xmin, ymin, zmin, xsize, ysize, zsize] for 3D + - :class:`~monai.data.box_utils.CenterSizeMode`: + represents [xcenter, ycenter, xsize, ysize] for 2D and [xcenter, ycenter, zcenter, xsize, ysize, zsize] for 3D + + We currently define ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + and monai detection pipelines mainly assume ``boxes`` are in ``StandardMode``. + + The implementation should be aware of: + + - remember to define class variable ``name``, + a dictionary that maps ``spatial_dims`` to :class:`~monai.utils.enums.BoxModeName`. + - :func:`~monai.data.box_utils.BoxMode.boxes_to_corners` and :func:`~monai.data.box_utils.BoxMode.corners_to_boxes` + should not modify inputs in place. + """ + + # a dictionary that maps spatial_dims to monai.utils.enums.BoxModeName. + name: Dict[int, BoxModeName] = {} + + @classmethod + def get_name(cls, spatial_dims: int) -> str: + """ + Get the mode name for the given spatial dimension using class variable ``name``. + + Args: + spatial_dims: number of spatial dimensions of the bounding box. + + Returns: + ``str``: mode string name + """ + return cls.name[spatial_dims].value + + @abstractmethod + def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: + """ + Convert the bounding boxes of the current mode to corners. + + Args: + boxes: bounding box, Nx4 or Nx6 torch tensor + + Returns: + ``Tuple``: corners of boxes, 4-element or 6-element tuple, each element is a Nx1 torch tensor. + It represents (xmin, ymin, xmax, ymax) or (xmin, ymin, zmin, xmax, ymax, zmax) + + Example: + .. code-block:: python + + boxes = torch.ones(10,6) + boxmode.boxes_to_corners(boxes) will return a 6-element tuple, each element is a 10x1 tensor + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + @abstractmethod + def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: + """ + Convert the given box corners to the bounding boxes of the current mode. + + Args: + corners: corners of boxes, 4-element or 6-element tuple, each element is a Nx1 torch tensor. + It represents (xmin, ymin, xmax, ymax) or (xmin, ymin, zmin, xmax, ymax, zmax) + + Returns: + ``Tensor``: bounding box, Nx4 or Nx6 torch tensor + + Example: + .. code-block:: python + + corners = (torch.ones(10,1), torch.ones(10,1), torch.ones(10,1), torch.ones(10,1)) + boxmode.corners_to_boxes(corners) will return a 10x4 tensor + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + +class CornerCornerModeTypeA(BoxMode): + """ + A subclass of ``BoxMode``. + + Also represented as "xyxy" or "xyzxyz", with format of + [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Example: + .. code-block:: python + + CornerCornerModeTypeA.get_name(spatial_dims=2) # will return "xyxy" + CornerCornerModeTypeA.get_name(spatial_dims=3) # will return "xyzxyz" + """ + + name = {2: BoxModeName.XYXY, 3: BoxModeName.XYZXYZ} + + def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: + corners: Tuple + corners = boxes.split(1, dim=-1) + return corners + + def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: + boxes: torch.Tensor + boxes = torch.cat(tuple(corners), dim=-1) + return boxes + + +class CornerCornerModeTypeB(BoxMode): + """ + A subclass of ``BoxMode``. + + Also represented as "xxyy" or "xxyyzz", with format of + [xmin, xmax, ymin, ymax] or [xmin, xmax, ymin, ymax, zmin, zmax]. + + Example: + .. code-block:: python + + CornerCornerModeTypeB.get_name(spatial_dims=2) # will return "xxyy" + CornerCornerModeTypeB.get_name(spatial_dims=3) # will return "xxyyzz" + """ + + name = {2: BoxModeName.XXYY, 3: BoxModeName.XXYYZZ} + + def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: + corners: Tuple + spatial_dims = get_spatial_dims(boxes=boxes) + if spatial_dims == 3: + xmin, xmax, ymin, ymax, zmin, zmax = boxes.split(1, dim=-1) + corners = xmin, ymin, zmin, xmax, ymax, zmax + elif spatial_dims == 2: + xmin, xmax, ymin, ymax = boxes.split(1, dim=-1) + corners = xmin, ymin, xmax, ymax + return corners + + def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: + boxes: torch.Tensor + spatial_dims = get_spatial_dims(corners=corners) + if spatial_dims == 3: + boxes = torch.cat((corners[0], corners[3], corners[1], corners[4], corners[2], corners[5]), dim=-1) + elif spatial_dims == 2: + boxes = torch.cat((corners[0], corners[2], corners[1], corners[3]), dim=-1) + return boxes + + +class CornerCornerModeTypeC(BoxMode): + """ + A subclass of ``BoxMode``. + + Also represented as "xyxy" or "xyxyzz", with format of + [xmin, ymin, xmax, ymax] or [xmin, ymin, xmax, ymax, zmin, zmax]. + + Example: + .. code-block:: python + + CornerCornerModeTypeC.get_name(spatial_dims=2) # will return "xyxy" + CornerCornerModeTypeC.get_name(spatial_dims=3) # will return "xyxyzz" + """ + + name = {2: BoxModeName.XYXY, 3: BoxModeName.XYXYZZ} + + def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: + corners: Tuple + spatial_dims = get_spatial_dims(boxes=boxes) + if spatial_dims == 3: + xmin, ymin, xmax, ymax, zmin, zmax = boxes.split(1, dim=-1) + corners = xmin, ymin, zmin, xmax, ymax, zmax + elif spatial_dims == 2: + corners = boxes.split(1, dim=-1) + return corners + + def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: + boxes: torch.Tensor + spatial_dims = get_spatial_dims(corners=corners) + if spatial_dims == 3: + boxes = torch.cat((corners[0], corners[1], corners[3], corners[4], corners[2], corners[5]), dim=-1) + elif spatial_dims == 2: + boxes = torch.cat(tuple(corners), dim=-1) + return boxes + + +class CornerSizeMode(BoxMode): + """ + A subclass of ``BoxMode``. + + Also represented as "xywh" or "xyzwhd", with format of + [xmin, ymin, xsize, ysize] or [xmin, ymin, zmin, xsize, ysize, zsize]. + + Example: + .. code-block:: python + + CornerSizeMode.get_name(spatial_dims=2) # will return "xywh" + CornerSizeMode.get_name(spatial_dims=3) # will return "xyzwhd" + """ + + name = {2: BoxModeName.XYWH, 3: BoxModeName.XYZWHD} + + def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: + corners: Tuple + # convert to float32 when computing torch.clamp, which does not support float16 + box_dtype = boxes.dtype + compute_dtype = torch.float32 + + spatial_dims = get_spatial_dims(boxes=boxes) + if spatial_dims == 3: + xmin, ymin, zmin, w, h, d = boxes.split(1, dim=-1) + xmax = xmin + (w - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + ymax = ymin + (h - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + zmax = zmin + (d - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + corners = xmin, ymin, zmin, xmax, ymax, zmax + elif spatial_dims == 2: + xmin, ymin, w, h = boxes.split(1, dim=-1) + xmax = xmin + (w - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + ymax = ymin + (h - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + corners = xmin, ymin, xmax, ymax + return corners + + def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: + boxes: torch.Tensor + spatial_dims = get_spatial_dims(corners=corners) + if spatial_dims == 3: + xmin, ymin, zmin, xmax, ymax, zmax = corners[0], corners[1], corners[2], corners[3], corners[4], corners[5] + boxes = torch.cat( + (xmin, ymin, zmin, xmax - xmin + TO_REMOVE, ymax - ymin + TO_REMOVE, zmax - zmin + TO_REMOVE), dim=-1 + ) + elif spatial_dims == 2: + xmin, ymin, xmax, ymax = corners[0], corners[1], corners[2], corners[3] + boxes = torch.cat((xmin, ymin, xmax - xmin + TO_REMOVE, ymax - ymin + TO_REMOVE), dim=-1) + return boxes + + +class CenterSizeMode(BoxMode): + """ + A subclass of ``BoxMode``. + + Also represented as "ccwh" or "cccwhd", with format of + [xmin, ymin, xsize, ysize] or [xmin, ymin, zmin, xsize, ysize, zsize]. + + Example: + .. code-block:: python + + CenterSizeMode.get_name(spatial_dims=2) # will return "ccwh" + CenterSizeMode.get_name(spatial_dims=3) # will return "cccwhd" + """ + + name = {2: BoxModeName.CCWH, 3: BoxModeName.CCCWHD} + + def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: + corners: Tuple + # convert to float32 when computing torch.clamp, which does not support float16 + box_dtype = boxes.dtype + compute_dtype = torch.float32 + + spatial_dims = get_spatial_dims(boxes=boxes) + if spatial_dims == 3: + xc, yc, zc, w, h, d = boxes.split(1, dim=-1) + xmin = xc - ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + xmax = xc + ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + ymin = yc - ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + ymax = yc + ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + zmin = zc - ((d - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + zmax = zc + ((d - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + corners = xmin, ymin, zmin, xmax, ymax, zmax + elif spatial_dims == 2: + xc, yc, w, h = boxes.split(1, dim=-1) + xmin = xc - ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + xmax = xc + ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + ymin = yc - ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + ymax = yc + ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + corners = xmin, ymin, xmax, ymax + return corners + + def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: + boxes: torch.Tensor + spatial_dims = get_spatial_dims(corners=corners) + if spatial_dims == 3: + xmin, ymin, zmin, xmax, ymax, zmax = corners[0], corners[1], corners[2], corners[3], corners[4], corners[5] + boxes = torch.cat( + ( + (xmin + xmax + TO_REMOVE) / 2.0, + (ymin + ymax + TO_REMOVE) / 2.0, + (zmin + zmax + TO_REMOVE) / 2.0, + xmax - xmin + TO_REMOVE, + ymax - ymin + TO_REMOVE, + zmax - zmin + TO_REMOVE, + ), + dim=-1, + ) + elif spatial_dims == 2: + xmin, ymin, xmax, ymax = corners[0], corners[1], corners[2], corners[3] + boxes = torch.cat( + ( + (xmin + xmax + TO_REMOVE) / 2.0, + (ymin + ymax + TO_REMOVE) / 2.0, + xmax - xmin + TO_REMOVE, + ymax - ymin + TO_REMOVE, + ), + dim=-1, + ) + return boxes + + +# We support the conversion between several box modes, i.e., representation of a bounding boxes +SUPPORTED_MODES = [CornerCornerModeTypeA, CornerCornerModeTypeB, CornerCornerModeTypeC, CornerSizeMode, CenterSizeMode] +# The standard box mode we use in all the box util functions +StandardMode = CornerCornerModeTypeA + + +def get_spatial_dims( + boxes: Union[torch.Tensor, np.ndarray, None] = None, + points: Union[torch.Tensor, np.ndarray, None] = None, + corners: Union[Sequence, None] = None, + spatial_size: Union[Sequence[int], torch.Tensor, np.ndarray, None] = None, +) -> int: + """ + Get spatial dimension for the giving setting and check the validity of them. + Missing input is allowed. But at least one of the input value should be given. + It raises ValueError if the dimensions of multiple inputs do not match with each other. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray + points: point coordinates, [x, y] or [x, y, z], Nx2 or Nx3 torch tensor or ndarray + corners: corners of boxes, 4-element or 6-element tuple, each element is a Nx1 torch tensor or ndarray + spatial_size: The spatial size of the image where the boxes are attached. + len(spatial_size) should be in [2, 3]. + + Returns: + ``int``: spatial_dims, number of spatial dimensions of the bounding boxes. + + Example: + .. code-block:: python + + boxes = torch.ones(10,6) + get_spatial_dims(boxes, spatial_size=[100,200,200]) # will return 3 + get_spatial_dims(boxes, spatial_size=[100,200]) # will raise ValueError + get_spatial_dims(boxes) # will return 3 + """ + spatial_dims_set = set() + + # Check the validity of each input and add its corresponding spatial_dims to spatial_dims_set + if boxes is not None: + if int(boxes.shape[1]) not in [4, 6]: + raise ValueError( + f"Currently we support only boxes with shape [N,4] or [N,6], got boxes with shape {boxes.shape}." + ) + spatial_dims_set.add(int(boxes.shape[1] / 2)) + if points is not None: + if int(points.shape[1]) not in SUPPORTED_SPATIAL_DIMS: + raise ValueError( + f"Currently we support only points with shape [N,2] or [N,3], got boxes with shape {points.shape}." + ) + spatial_dims_set.add(int(points.shape[1])) + if corners is not None: + if len(corners) not in [4, 6]: + raise ValueError( + f"Currently we support only boxes with shape [N,4] or [N,6], got box corner tuple with length {len(corners)}." + ) + spatial_dims_set.add(len(corners) // 2) + if spatial_size is not None: + if len(spatial_size) not in SUPPORTED_SPATIAL_DIMS: + raise ValueError( + f"Currently we support only boxes on 2-D and 3-D images, got image spatial_size {spatial_size}." + ) + spatial_dims_set.add(len(spatial_size)) + + # Get spatial_dims from spatial_dims_set, which contains only unique values + spatial_dims_list = list(spatial_dims_set) + if len(spatial_dims_list) == 0: + raise ValueError("At least one of the inputs needs to be non-empty.") + + if len(spatial_dims_list) == 1: + spatial_dims = int(spatial_dims_list[0]) + spatial_dims = look_up_option(spatial_dims, supported=[2, 3]) + return int(spatial_dims) + + raise ValueError("The dimensions of multiple inputs should match with each other.") + + +def get_boxmode(mode: Union[str, BoxMode, Type[BoxMode], None] = None, *args, **kwargs) -> BoxMode: + """ + This function that return a :class:`~monai.data.box_utils.BoxMode` object giving a representation of box mode + + Args: + mode: a representation of box mode. If it is not given, this func will assume it is ``StandardMode()``. + + Note: + ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + also represented as "xyxy" for 2D and "xyzxyz" for 3D. + + mode can be: + #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example, + - "xyxy": boxes has format [xmin, ymin, xmax, ymax] + - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax] + - "xxyy": boxes has format [xmin, xmax, ymin, ymax] + - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax] + - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax] + - "xywh": boxes has format [xmin, ymin, xsize, ysize] + - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize] + - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize] + - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize] + #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode: equivalent to "xywh" or "xyzwhd" + - CenterSizeMode: equivalent to "ccwh" or "cccwhd" + #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode(): equivalent to "xywh" or "xyzwhd" + - CenterSizeMode(): equivalent to "ccwh" or "cccwhd" + #. None: will assume mode is ``StandardMode()`` + + Returns: + BoxMode object + + Example: + .. code-block:: python + + mode = "xyzxyz" + get_boxmode(mode) # will return CornerCornerModeTypeA() + """ + if isinstance(mode, BoxMode): + return mode + + if inspect.isclass(mode) and issubclass(mode, BoxMode): + return mode(*args, **kwargs) + + if isinstance(mode, str): + for m in SUPPORTED_MODES: + for n in SUPPORTED_SPATIAL_DIMS: + if inspect.isclass(m) and issubclass(m, BoxMode) and m.get_name(n) == mode: + return m(*args, **kwargs) + + if mode is not None: + raise ValueError(f"Unsupported box mode: {mode}.") + return StandardMode(*args, **kwargs) + + +def convert_box_mode( + boxes: NdarrayOrTensor, + src_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None, +) -> NdarrayOrTensor: + """ + This function converts the boxes in src_mode to the dst_mode. + + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. + src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``mode`` in :func:`~monai.data.box_utils.get_boxmode`. + dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``mode`` in :func:`~monai.data.box_utils.get_boxmode`. + + Returns: + bounding boxes with target mode, with same data type as ``boxes``, does not share memory with ``boxes`` + + Example: + .. code-block:: python + + boxes = torch.ones(10,4) + # The following three lines are equivalent + # They convert boxes with format [xmin, ymin, xmax, ymax] to [xcenter, ycenter, xsize, ysize]. + box_convert_mode(boxes=boxes, src_mode="xyxy", dst_mode="ccwh") + box_convert_mode(boxes=boxes, src_mode="xyxy", dst_mode=monai.data.box_utils.CenterSizeMode) + box_convert_mode(boxes=boxes, src_mode="xyxy", dst_mode=monai.data.box_utils.CenterSizeMode()) + """ + src_boxmode = get_boxmode(src_mode) + dst_boxmode = get_boxmode(dst_mode) + + # if mode not changed, deepcopy the original boxes + if isinstance(src_boxmode, type(dst_boxmode)): + return deepcopy(boxes) + + # convert box mode + # convert numpy to tensor if needed + boxes_t, *_ = convert_data_type(boxes, torch.Tensor) + + # convert boxes to corners + corners = src_boxmode.boxes_to_corners(boxes_t) + + # check validity of corners + spatial_dims = get_spatial_dims(boxes=boxes_t) + for axis in range(0, spatial_dims): + if (corners[spatial_dims + axis] < corners[axis]).sum() > 0: + warnings.warn("Given boxes has invalid values. The box size must be non-negative.") + + # convert corners to boxes + boxes_t_dst = dst_boxmode.corners_to_boxes(corners) + + # convert tensor back to numpy if needed + boxes_dst, *_ = convert_to_dst_type(src=boxes_t_dst, dst=boxes) + return boxes_dst + + +def convert_box_to_standard_mode( + boxes: NdarrayOrTensor, mode: Union[str, BoxMode, Type[BoxMode], None] = None +) -> NdarrayOrTensor: + """ + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. + mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``mode`` in :func:`~monai.data.box_utils.get_boxmode`. + + Returns: + bounding boxes with standard mode, with same data type as ``boxes``, does not share memory with ``boxes`` + + Example: + .. code-block:: python + + boxes = torch.ones(10,6) + # The following two lines are equivalent + # They convert boxes with format [xmin, xmax, ymin, ymax, zmin, zmax] to [xmin, ymin, zmin, xmax, ymax, zmax] + box_convert_standard_mode(boxes=boxes, mode="xxyyzz") + box_convert_mode(boxes=boxes, src_mode="xxyyzz", dst_mode="xyzxyz") + """ + return convert_box_mode(boxes=boxes, src_mode=mode, dst_mode=StandardMode()) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 429183b1a0..76a05940cb 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -17,6 +17,7 @@ from .enums import ( Average, BlendMode, + BoxModeName, ChannelMatching, CommonKeys, DiceCEReduction, @@ -88,6 +89,7 @@ convert_data_type, convert_to_cupy, convert_to_dst_type, + convert_to_list, convert_to_numpy, convert_to_tensor, dtype_numpy_to_torch, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 8920f51a88..0871e04c1f 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -36,6 +36,7 @@ "PostFix", "ForwardMode", "TransformBackends", + "BoxModeName", ] @@ -311,3 +312,19 @@ class JITMetadataKeys(Enum): TIMESTAMP = "timestamp" VERSION = "version" DESCRIPTION = "description" + + +class BoxModeName(Enum): + """ + Box mode names. + """ + + XYXY = "xyxy" # [xmin, ymin, xmax, ymax] + XYZXYZ = "xyzxyz" # [xmin, ymin, zmin, xmax, ymax, zmax] + XXYY = "xxyy" # [xmin, xmax, ymin, ymax] + XXYYZZ = "xxyyzz" # [xmin, xmax, ymin, ymax, zmin, zmax] + XYXYZZ = "xyxyzz" # [xmin, ymin, xmax, ymax, zmin, zmax] + XYWH = "xywh" # [xmin, ymin, xsize, ysize] + XYZWHD = "xyzwhd" # [xmin, ymin, zmin, xsize, ysize, zsize] + CCWH = "ccwh" # [xcenter, ycenter, xsize, ysize] + CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index d5944e265b..f03e4f52b1 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -301,3 +301,15 @@ def convert_to_dst_type( else: output_type = type(dst) return convert_data_type(data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence) + + +def convert_to_list(data: Union[Sequence, torch.Tensor, np.ndarray]) -> list: + """ + Convert to list from `torch.Tensor`/`np.ndarray`/`list`/`tuple` etc. + Args: + data: data to be converted + Returns: + a list + + """ + return data.tolist() if isinstance(data, (torch.Tensor, np.ndarray)) else list(data) diff --git a/tests/test_box_utils.py b/tests/test_box_utils.py new file mode 100644 index 0000000000..7dee6a6e60 --- /dev/null +++ b/tests/test_box_utils.py @@ -0,0 +1,157 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.data.box_utils import ( + CenterSizeMode, + CornerCornerModeTypeA, + CornerCornerModeTypeB, + CornerCornerModeTypeC, + CornerSizeMode, + convert_box_mode, + convert_box_to_standard_mode, +) +from monai.utils.type_conversion import convert_data_type +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] +for p in TEST_NDARRAYS: + boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]] + spatial_size = [4, 4, 4] + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "cccwhd", "half": False}, + CornerSizeMode, + p([[0, 0, 0, 0, 0, 0], [-1, 0, -1.5, 2, 2, 3], [-1, 0, -0.5, 2, 2, 3]]), + p([0, 12, 12]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xyzwhd", "half": False}, + CornerSizeMode, + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]), + p([0, 12, 12]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xyzwhd", "half": True}, + "xyzxyz", + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([0, 12, 12]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xyzwhd", "half": False}, + "xxyyzz", + p([[0, 0, 0, 0, 0, 0], [0, 2, 1, 3, 0, 3], [0, 2, 1, 3, 1, 4]]), + p([0, 12, 12]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xyzwhd", "half": False}, + CornerCornerModeTypeC, + p([[0, 0, 0, 0, 0, 0], [0, 1, 2, 3, 0, 3], [0, 1, 2, 3, 1, 4]]), + p([0, 12, 12]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": CornerCornerModeTypeA(), "half": False}, + "xyzwhd", + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 1, 3], [0, 1, 1, 2, 1, 2]]), + p([0, 6, 4]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": CornerCornerModeTypeA, "half": True}, + CornerCornerModeTypeA, + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]), + p([0, 6, 4]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xyzxyz", "half": False}, + CornerCornerModeTypeB(), + p([[0, 0, 0, 0, 0, 0], [0, 2, 1, 2, 0, 3], [0, 2, 1, 2, 1, 3]]), + p([0, 6, 4]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xxyyzz", "half": False}, + "xxyyzz", + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]), + p([0, 2, 1]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xxyyzz", "half": True}, + "xyzxyz", + p([[0, 0, 0, 0, 0, 0], [0, 0, 2, 1, 2, 3], [0, 1, 2, 1, 2, 3]]), + p([0, 2, 1]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xxyyzz", "half": False}, + "xyzwhd", + p([[0, 0, 0, 0, 0, 0], [0, 0, 2, 1, 2, 1], [0, 1, 2, 1, 1, 1]]), + p([0, 2, 1]), + ] + ) + TESTS.append( + [ + {"boxes": p(boxes), "spatial_size": spatial_size, "mode": "xxyyzz", "half": False}, + CenterSizeMode(), + p([[0, 0, 0, 0, 0, 0], [0.5, 1, 2.5, 1, 2, 1], [0.5, 1.5, 2.5, 1, 1, 1]]), + p([0, 2, 1]), + ] + ) + + +class TestCreateBoxList(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, input_data, mode2, expected_box, expected_area): + expected_box = convert_data_type(expected_box, dtype=np.float32)[0] + boxes1 = convert_data_type(input_data["boxes"], dtype=np.float32)[0] + mode1 = input_data["mode"] + half_bool = input_data["half"] + + # test float16 + if half_bool: + boxes1 = convert_data_type(boxes1, dtype=np.float16)[0] + expected_box = convert_data_type(expected_box, dtype=np.float16)[0] + + # test convert_box_mode, convert_box_to_standard_mode + result2 = convert_box_mode(boxes=boxes1, src_mode=mode1, dst_mode=mode2) + assert_allclose(result2, expected_box, type_test=True, device_test=True, atol=0.0) + + result1 = convert_box_mode(boxes=result2, src_mode=mode2, dst_mode=mode1) + assert_allclose(result1, boxes1, type_test=True, device_test=True, atol=0.0) + + result_standard = convert_box_to_standard_mode(boxes=boxes1, mode=mode1) + expected_box_standard = convert_box_to_standard_mode(boxes=expected_box, mode=mode2) + assert_allclose(result_standard, expected_box_standard, type_test=True, device_test=True, atol=0.0) + + +if __name__ == "__main__": + unittest.main() From 2dfee17df252dbdfe9e996f833f054d1515397f1 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 19 May 2022 12:30:56 +0100 Subject: [PATCH 070/183] cron tutorial master to main (#4291) cron master to main Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- .github/workflows/cron.yml | 2 +- monai/optimizers/utils.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 08065147e5..7a57c5e662 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -223,7 +223,7 @@ jobs: - name: Checkout tutorials and install their requirements run: | cd /opt - git clone --depth 1 --branch master --single-branch https://github.com/Project-MONAI/tutorials.git # latest commit of master branch + git clone --depth 1 --branch main --single-branch https://github.com/Project-MONAI/tutorials.git # latest commit of main branch cd tutorials python -m pip install -r requirements.txt - name: Run tutorial notebooks diff --git a/monai/optimizers/utils.py b/monai/optimizers/utils.py index 1a040927d8..5444aca191 100644 --- a/monai/optimizers/utils.py +++ b/monai/optimizers/utils.py @@ -37,7 +37,7 @@ def generate_param_groups( for "select", the parameters will be `select_func(network).parameters()`. for "filter", the parameters will be - `map(lambda x: x[1], filter(filter_func, network.named_parameters()))` + `(x[1] for x in filter(f, network.named_parameters()))` match_types: a list of tags to identify the matching type corresponding to the `layer_matches` functions, can be "select" or "filter". lr_values: a list of LR values corresponding to the `layer_matches` functions. @@ -76,7 +76,7 @@ def _select(): def _get_filter(f): def _filter(): # should eventually generate a list of network parameters - return map(lambda x: x[1], filter(f, network.named_parameters())) + return (x[1] for x in filter(f, network.named_parameters())) return _filter @@ -91,7 +91,7 @@ def _filter(): raise ValueError(f"unsupported layer match type: {ty}.") params.append({"params": layer_params(), "lr": lr}) - _layers.extend(list(map(id, layer_params()))) + _layers.extend([id(x) for x in layer_params()]) if include_others: params.append({"params": filter(lambda p: id(p) not in _layers, network.parameters())}) From d7767cd31e89fa9490ce958e97b253e27c3b62f8 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 19 May 2022 13:24:57 +0100 Subject: [PATCH 071/183] track transforms (#4284) * track transforms Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * transforms -> applied operations Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/data/__init__.py | 2 +- monai/data/meta_obj.py | 81 ++++++++++----------- monai/data/meta_tensor.py | 40 ++++++++-- monai/transforms/inverse.py | 27 +++++-- monai/transforms/meta_utility/dictionary.py | 16 ++-- tests/test_meta_tensor.py | 72 ++++++++++++++++-- tests/test_to_from_meta_tensord.py | 12 +-- 7 files changed, 170 insertions(+), 80 deletions(-) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 60574cb565..462ed1c8fa 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -47,7 +47,7 @@ resolve_writer, ) from .iterable_dataset import CSVIterableDataset, IterableDataset, ShuffleBuffer -from .meta_obj import MetaObj, get_track_meta, get_track_transforms, set_track_meta, set_track_transforms +from .meta_obj import MetaObj, get_track_meta, set_track_meta from .meta_tensor import MetaTensor from .nifti_saver import NiftiSaver from .nifti_writer import write_nifti diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index 3e35a6dd4b..6c56dfe34b 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -15,9 +15,8 @@ from typing import Any, Callable, Sequence _TRACK_META = True -_TRACK_TRANSFORMS = True -__all__ = ["get_track_meta", "get_track_transforms", "set_track_meta", "set_track_transforms", "MetaObj"] +__all__ = ["get_track_meta", "set_track_meta", "MetaObj"] def set_track_meta(val: bool) -> None: @@ -26,9 +25,8 @@ def set_track_meta(val: bool) -> None: its data by using subclasses of `MetaObj`. If `False`, then data will be returned with empty metadata. - If both `set_track_meta` and `set_track_transforms` are set to - `False`, then standard data objects will be returned (e.g., `torch.Tensor` and - `np.ndarray`) as opposed to our enhanced objects. + If `set_track_meta` is `False`, then standard data objects will be returned (e.g., + `torch.Tensor` and `np.ndarray`) as opposed to our enhanced objects. By default, this is `True`, and most users will want to leave it this way. However, if you are experiencing any problems regarding metadata, and aren't interested in @@ -38,33 +36,14 @@ def set_track_meta(val: bool) -> None: _TRACK_META = val -def set_track_transforms(val: bool) -> None: - """ - Boolean to set whether transforms are tracked. If `True`, applied transforms will be - associated its data by using subclasses of `MetaObj`. If `False`, then transforms - won't be tracked. - - If both `set_track_meta` and `set_track_transforms` are set to - `False`, then standard data objects will be returned (e.g., `torch.Tensor` and - `np.ndarray`) as opposed to our enhanced objects. - - By default, this is `True`, and most users will want to leave it this way. However, - if you are experiencing any problems regarding transforms, and aren't interested in - preserving transforms, then you can disable it. - """ - global _TRACK_TRANSFORMS - _TRACK_TRANSFORMS = val - - def get_track_meta() -> bool: """ Return the boolean as to whether metadata is tracked. If `True`, metadata will be associated its data by using subclasses of `MetaObj`. If `False`, then data will be returned with empty metadata. - If both `set_track_meta` and `set_track_transforms` are set to - `False`, then standard data objects will be returned (e.g., `torch.Tensor` and - `np.ndarray`) as opposed to our enhanced objects. + If `set_track_meta` is `False`, then standard data objects will be returned (e.g., + `torch.Tensor` and `np.ndarray`) as opposed to our enhanced objects. By default, this is `True`, and most users will want to leave it this way. However, if you are experiencing any problems regarding metadata, and aren't interested in @@ -73,23 +52,6 @@ def get_track_meta() -> bool: return _TRACK_META -def get_track_transforms() -> bool: - """ - Return the boolean as to whether transforms are tracked. If `True`, applied - transforms will be associated its data by using subclasses of `MetaObj`. If `False`, - then transforms won't be tracked. - - If both `set_track_meta` and `set_track_transforms` are set to - `False`, then standard data objects will be returned (e.g., `torch.Tensor` and - `np.ndarray`) as opposed to our enhanced objects. - - By default, this is `True`, and most users will want to leave it this way. However, - if you are experiencing any problems regarding transforms, and aren't interested in - preserving transforms, then you can disable it. - """ - return _TRACK_TRANSFORMS - - class MetaObj: """ Abstract base class that stores data as well as any extra metadata. @@ -177,6 +139,7 @@ def _copy_meta(self, input_objs: list[MetaObj]) -> None: id_in = id(input_objs[0]) if len(input_objs) > 0 else None deep_copy = id(self) != id_in self._copy_attr("meta", input_objs, self.get_default_meta, deep_copy) + self._copy_attr("applied_operations", input_objs, self.get_default_applied_operations, deep_copy) self.is_batch = input_objs[0].is_batch if len(input_objs) > 0 else False def get_default_meta(self) -> dict: @@ -187,6 +150,14 @@ def get_default_meta(self) -> dict: """ return {} + def get_default_applied_operations(self) -> list: + """Get the default applied operations. + + Returns: + default applied operations. + """ + return [] + def __repr__(self) -> str: """String representation of class.""" out: str = super().__repr__() @@ -196,6 +167,14 @@ def __repr__(self) -> str: out += "".join(f"\t{k}: {v}\n" for k, v in self.meta.items()) else: out += "None" + + out += "\nApplied operations\n" + if self.applied_operations is not None: + for i in self.applied_operations: + out += f"\t{str(i)}\n" + else: + out += "None" + out += f"\nIs batch?: {self.is_batch}" return out @@ -210,6 +189,22 @@ def meta(self, d: dict) -> None: """Set the meta.""" self._meta = d + @property + def applied_operations(self) -> list: + """Get the applied operations.""" + return self._applied_operations + + @applied_operations.setter + def applied_operations(self, t: list) -> None: + """Set the applied operations.""" + self._applied_operations = t + + def push_applied_operation(self, t: Any) -> None: + self._applied_operations.append(t) + + def pop_applied_operation(self) -> Any: + return self._applied_operations.pop() + @property def is_batch(self) -> bool: """Return whether object is part of batch or not.""" diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index d44b780a5e..b63209ed1d 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -17,7 +17,8 @@ import torch -from monai.data.meta_obj import MetaObj, get_track_meta, get_track_transforms +from monai.config.type_definitions import NdarrayTensor +from monai.data.meta_obj import MetaObj, get_track_meta from monai.data.utils import decollate_batch, list_data_collate from monai.utils.enums import PostFix @@ -72,10 +73,20 @@ class MetaTensor(MetaObj, torch.Tensor): """ @staticmethod - def __new__(cls, x, affine: torch.Tensor | None = None, meta: dict | None = None, *args, **kwargs) -> MetaTensor: + def __new__( + cls, + x, + affine: torch.Tensor | None = None, + meta: dict | None = None, + applied_operations: list | None = None, + *args, + **kwargs, + ) -> MetaTensor: return torch.as_tensor(x, *args, **kwargs).as_subclass(cls) # type: ignore - def __init__(self, x, affine: torch.Tensor | None = None, meta: dict | None = None) -> None: + def __init__( + self, x, affine: torch.Tensor | None = None, meta: dict | None = None, applied_operations: list | None = None + ) -> None: """ If `meta` is given, use it. Else, if `meta` exists in the input tensor, use it. Else, use the default value. Similar for the affine, except this could come from @@ -94,15 +105,24 @@ def __init__(self, x, affine: torch.Tensor | None = None, meta: dict | None = No warnings.warn("Setting affine, but the applied meta contains an affine. This will be overwritten.") self.affine = affine elif "affine" in self.meta: - pass # nothing to do + # by using the setter function, we ensure it is converted to torch.Tensor if not already + self.affine = self.meta["affine"] elif isinstance(x, MetaTensor): self.affine = x.affine else: self.affine = self.get_default_affine() + # applied_operations + if applied_operations is not None: + self.applied_operations = applied_operations + elif isinstance(x, MetaTensor): + self.applied_operations = x.applied_operations + else: + self.applied_operations = self.get_default_applied_operations() # if we are creating a new MetaTensor, then deep copy attributes if isinstance(x, torch.Tensor) and not isinstance(x, MetaTensor): self.meta = deepcopy(self.meta) + self.applied_operations = deepcopy(self.applied_operations) self.affine = self.affine.to(self.device) def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: @@ -126,7 +146,7 @@ def update_meta(rets: Sequence, func, args, kwargs): if not isinstance(ret, MetaTensor): pass # if not tracking, convert to `torch.Tensor`. - elif not (get_track_meta() or get_track_transforms()): + elif not get_track_meta(): ret = ret.as_tensor() # else, handle the `MetaTensor` metadata. else: @@ -221,7 +241,11 @@ def as_dict(self, key: str) -> dict: A dictionary consisting of two keys, the main data (stored under `key`) and the metadata. """ - return {key: self.as_tensor(), PostFix.meta(key): self.meta} + return { + key: self.as_tensor(), + PostFix.meta(key): deepcopy(self.meta), + PostFix.transforms(key): deepcopy(self.applied_operations), + } @property def affine(self) -> torch.Tensor: @@ -229,9 +253,9 @@ def affine(self) -> torch.Tensor: return self.meta["affine"] # type: ignore @affine.setter - def affine(self, d: torch.Tensor) -> None: + def affine(self, d: NdarrayTensor) -> None: """Set the affine.""" - self.meta["affine"] = d + self.meta["affine"] = torch.as_tensor(d, device=self.device) def new_empty(self, size, dtype=None, device=None, requires_grad=False): """ diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index d2e5b2c6ba..bdaa2f9b40 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -13,6 +13,7 @@ import torch +from monai.data.meta_tensor import MetaTensor from monai.transforms.transform import Transform from monai.utils.enums import TraceKeys @@ -54,7 +55,8 @@ def trace_key(key: Hashable = None): def push_transform( self, data: Mapping, key: Hashable = None, extra_info: Optional[dict] = None, orig_size: Optional[Tuple] = None ) -> None: - """PUsh to a stack of applied transforms for that key.""" + """Push to a stack of applied transforms for that key.""" + if not self.tracing: return info = {TraceKeys.CLASS_NAME: self.__class__.__name__, TraceKeys.ID: id(self)} @@ -67,17 +69,23 @@ def push_transform( # If class is randomizable transform, store whether the transform was actually performed (based on `prob`) if hasattr(self, "_do_transform"): # RandomizableTransform info[TraceKeys.DO_TRANSFORM] = self._do_transform # type: ignore - # If this is the first, create list - if self.trace_key(key) not in data: - if not isinstance(data, dict): - data = dict(data) - data[self.trace_key(key)] = [] - data[self.trace_key(key)].append(info) + + if key in data and isinstance(data[key], MetaTensor): + data[key].push_applied_operation(info) + else: + # If this is the first, create list + if self.trace_key(key) not in data: + if not isinstance(data, dict): + data = dict(data) + data[self.trace_key(key)] = [] + data[self.trace_key(key)].append(info) def pop_transform(self, data: Mapping, key: Hashable = None): """Remove the most recent applied transform.""" if not self.tracing: return + if key in data and isinstance(data[key], MetaTensor): + return data[key].pop_applied_operation() return data.get(self.trace_key(key), []).pop() @@ -133,7 +141,10 @@ def get_most_recent_transform(self, data: Mapping, key: Hashable = None): """Get most recent transform.""" if not self.tracing: raise RuntimeError("Transform Tracing must be enabled to get the most recent transform.") - transform = data[self.trace_key(key)][-1] + if isinstance(data[key], MetaTensor): + transform = data[key].applied_operations[-1] + else: + transform = data[self.trace_key(key)][-1] self.check_transforms_match(transform) return transform diff --git a/monai/transforms/meta_utility/dictionary.py b/monai/transforms/meta_utility/dictionary.py index 1a9cf4c631..1dcdf3483c 100644 --- a/monai/transforms/meta_utility/dictionary.py +++ b/monai/transforms/meta_utility/dictionary.py @@ -39,7 +39,7 @@ class FromMetaTensord(MapTransform, InvertibleTransform): Dictionary-based transform to convert MetaTensor to a dictionary. If input is `{"a": MetaTensor, "b": MetaTensor}`, then output will - have the form `{"a": torch.Tensor, "a_meta_dict": dict, "b": ...}`. + have the form `{"a": torch.Tensor, "a_meta_dict": dict, "a_transforms": list, "b": ...}`. """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] @@ -47,9 +47,9 @@ class FromMetaTensord(MapTransform, InvertibleTransform): def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): - self.push_transform(d, key) im: MetaTensor = d[key] # type: ignore d.update(im.as_dict(key)) + self.push_transform(d, key) return d def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: @@ -58,8 +58,10 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd # check transform _ = self.get_most_recent_transform(d, key) # do the inverse - im, meta = d[key], d.pop(PostFix.meta(key), None) - im = MetaTensor(im, meta=meta) # type: ignore + im = d[key] + meta = d.pop(PostFix.meta(key), None) + transforms = d.pop(PostFix.transforms(key), None) + im = MetaTensor(im, meta=meta, applied_operations=transforms) # type: ignore d[key] = im # Remove the applied transform self.pop_transform(d, key) @@ -80,8 +82,10 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) for key in self.key_iterator(d): self.push_transform(d, key) - im, meta = d[key], d.pop(PostFix.meta(key), None) - im = MetaTensor(im, meta=meta) # type: ignore + im = d[key] + meta = d.pop(PostFix.meta(key), None) + transforms = d.pop(PostFix.transforms(key), None) + im = MetaTensor(im, meta=meta, applied_operations=transforms) # type: ignore d[key] = im return d diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index fb6d922218..87226a585d 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -22,9 +22,10 @@ from parameterized import parameterized from monai.data import DataLoader, Dataset -from monai.data.meta_obj import get_track_meta, get_track_transforms, set_track_meta, set_track_transforms +from monai.data.meta_obj import get_track_meta, set_track_meta from monai.data.meta_tensor import MetaTensor from monai.data.utils import decollate_batch, list_data_collate +from monai.transforms import BorderPadd, Compose, DivisiblePadd, FromMetaTensord, ToMetaTensord from monai.utils.enums import PostFix from monai.utils.module import pytorch_after from tests.utils import TEST_DEVICES, SkipIfBeforePyTorchVersion, assert_allclose, skip_if_no_cuda @@ -216,10 +217,6 @@ def test_get_set_meta_fns(self): self.assertEqual(get_track_meta(), False) set_track_meta(True) self.assertEqual(get_track_meta(), True) - set_track_transforms(False) - self.assertEqual(get_track_transforms(), False) - set_track_transforms(True) - self.assertEqual(get_track_transforms(), True) @parameterized.expand(TEST_DEVICES) def test_torchscript(self, device): @@ -409,6 +406,71 @@ def test_decollate(self, dtype): self.assertIsInstance(elem, MetaTensor) self.check(elem, im, ids=False) + def test_transforms(self): + key = "im" + _, im = self.get_im() + tr = Compose([BorderPadd(key, 1), DivisiblePadd(key, 16), ToMetaTensord(key), FromMetaTensord(key)]) + num_tr = len(tr.transforms) + data = {key: im, PostFix.meta(key): {"affine": torch.eye(4)}} + + # apply one at a time + is_meta = isinstance(im, MetaTensor) + for i, _tr in enumerate(tr.transforms): + data = _tr(data) + is_meta = isinstance(_tr, ToMetaTensord) + if is_meta: + self.assertEqual(len(data), 1) # im + self.assertIsInstance(data[key], MetaTensor) + n_applied = len(data[key].applied_operations) + else: + self.assertEqual(len(data), 3) # im, im_meta_dict, im_transforms + self.assertIsInstance(data[key], torch.Tensor) + self.assertNotIsInstance(data[key], MetaTensor) + n_applied = len(data[PostFix.transforms(key)]) + + self.assertEqual(n_applied, i + 1) + + # inverse one at a time + is_meta = isinstance(im, MetaTensor) + for i, _tr in enumerate(tr.transforms[::-1]): + data = _tr.inverse(data) + is_meta = isinstance(_tr, FromMetaTensord) + if is_meta: + self.assertEqual(len(data), 1) # im + self.assertIsInstance(data[key], MetaTensor) + n_applied = len(data[key].applied_operations) + else: + self.assertEqual(len(data), 3) # im, im_meta_dict, im_transforms + self.assertIsInstance(data[key], torch.Tensor) + self.assertNotIsInstance(data[key], MetaTensor) + n_applied = len(data[PostFix.transforms(key)]) + + self.assertEqual(n_applied, num_tr - i - 1) + + # apply all in one go + data = tr({key: im, PostFix.meta(key): {"affine": torch.eye(4)}}) + self.assertEqual(len(data), 3) # im, im_meta_dict, im_transforms + self.assertIsInstance(data[key], torch.Tensor) + self.assertNotIsInstance(data[key], MetaTensor) + n_applied = len(data[PostFix.transforms(key)]) + self.assertEqual(n_applied, num_tr) + + # inverse all in one go + data = tr.inverse(data) + self.assertEqual(len(data), 3) # im, im_meta_dict, im_transforms + self.assertIsInstance(data[key], torch.Tensor) + self.assertNotIsInstance(data[key], MetaTensor) + n_applied = len(data[PostFix.transforms(key)]) + self.assertEqual(n_applied, 0) + + def test_construct_with_pre_applied_transforms(self): + key = "im" + _, im = self.get_im() + tr = Compose([BorderPadd(key, 1), DivisiblePadd(key, 16)]) + data = tr({key: im}) + m = MetaTensor(im, applied_operations=data[PostFix.transforms(key)]) + self.assertEqual(len(m.applied_operations), len(tr.transforms)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_to_from_meta_tensord.py b/tests/test_to_from_meta_tensord.py index 9bbf4592ab..6f46055d6a 100644 --- a/tests/test_to_from_meta_tensord.py +++ b/tests/test_to_from_meta_tensord.py @@ -126,14 +126,12 @@ def test_from_to_meta_tensord(self, device, dtype): self.check(d_dict["m1"], m1.as_tensor(), ids=False) meta_out = {k: v for k, v in d_dict["m1_meta_dict"].items() if k != "affine"} aff_out = d_dict["m1_meta_dict"]["affine"] - self.check(aff_out, m1_aff, ids=True) + self.check(aff_out, m1_aff, ids=False) self.assertEqual(meta_out, m1_meta) # FROM -> inverse d_meta_dict_meta = t_from_meta.inverse(d_dict) - self.assertEqual( - sorted(d_meta_dict_meta.keys()), ["m1", PostFix.transforms("m1"), "m2", PostFix.transforms("m2"), "m3"] - ) + self.assertEqual(sorted(d_meta_dict_meta.keys()), ["m1", "m2", "m3"]) self.check(d_meta_dict_meta["m3"], m3, ids=False) # unchanged (except deep copy in inverse) self.check(d_meta_dict_meta["m1"], m1, ids=False) meta_out = {k: v for k, v in d_meta_dict_meta["m1"].meta.items() if k != "affine"} @@ -143,12 +141,8 @@ def test_from_to_meta_tensord(self, device, dtype): # TO -> Forward t_to_meta = ToMetaTensord(["m1", "m2"]) - del d_dict["m1_transforms"] - del d_dict["m2_transforms"] d_dict_meta = t_to_meta(d_dict) - self.assertEqual( - sorted(d_dict_meta.keys()), ["m1", PostFix.transforms("m1"), "m2", PostFix.transforms("m2"), "m3"] - ) + self.assertEqual(sorted(d_dict_meta.keys()), ["m1", "m2", "m3"]) self.check(d_dict_meta["m3"], m3, ids=True) # unchanged (except deep copy in inverse) self.check(d_dict_meta["m1"], m1, ids=False) meta_out = {k: v for k, v in d_dict_meta["m1"].meta.items() if k != "affine"} From fdec959cc2fbe9f09ed8d95a26bf24a206de8113 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 19 May 2022 13:58:12 -0400 Subject: [PATCH 072/183] add box center related funcs to box_utils (#4290) * add box center related funcs to box_utils Signed-off-by: Can Zhao --- docs/source/data.rst | 6 +++ monai/data/__init__.py | 8 +++- monai/data/box_utils.py | 92 +++++++++++++++++++++++++++++++++++++++++ tests/test_box_utils.py | 20 +++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index fad640b81c..706371a8c0 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -330,3 +330,9 @@ Box mode converter .. autofunction:: monai.data.box_utils.get_boxmode .. autofunction:: monai.data.box_utils.convert_box_mode .. autofunction:: monai.data.box_utils.convert_box_to_standard_mode + +Box center +~~~~~~~~~~ +.. autofunction:: monai.data.box_utils.box_centers +.. autofunction:: monai.data.box_utils.centers_in_boxes +.. autofunction:: monai.data.box_utils.boxes_center_distance diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 462ed1c8fa..3346531386 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -9,7 +9,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .box_utils import convert_box_mode, convert_box_to_standard_mode +from .box_utils import ( + box_centers, + boxes_center_distance, + centers_in_boxes, + convert_box_mode, + convert_box_to_standard_mode, +) from .csv_saver import CSVSaver from .dataloader import DataLoader from .dataset import ( diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index ca8dcd284a..50dd46837d 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -572,3 +572,95 @@ def convert_box_to_standard_mode( box_convert_mode(boxes=boxes, src_mode="xxyyzz", dst_mode="xyzxyz") """ return convert_box_mode(boxes=boxes, src_mode=mode, dst_mode=StandardMode()) + + +def box_centers(boxes: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Compute center points of boxes + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + + Returns: + center points with size of (N, spatial_dims) + + """ + spatial_dims = get_spatial_dims(boxes=boxes) + return convert_box_mode(boxes=boxes, src_mode=StandardMode, dst_mode=CenterSizeMode)[:, :spatial_dims] + + +def centers_in_boxes(centers: NdarrayOrTensor, boxes: NdarrayOrTensor, eps: float = 0.01) -> NdarrayOrTensor: + """ + Checks which center points are within boxes + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``. + centers: center points, Nx2 or Nx3 torch tensor or ndarray. + eps: minimum distance to border of boxes. + + Returns: + boolean array indicating which center points are within the boxes, sized (N,). + + Reference: + https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/ops.py + + """ + spatial_dims = get_spatial_dims(boxes=boxes) + + # compute relative position of centers compared to borders + # should be non-negative if centers are within boxes + center_to_border = [centers[:, axis] - boxes[:, axis] for axis in range(spatial_dims)] + [ + boxes[:, axis + spatial_dims] - centers[:, axis] for axis in range(spatial_dims) + ] + + if isinstance(boxes, np.ndarray): + min_center_to_border: np.ndarray = np.stack(center_to_border, axis=1).min(axis=1) + return min_center_to_border > eps # array[bool] + + compute_dtype = torch.float32 + return torch.stack(center_to_border, dim=1).to(compute_dtype).min(dim=1)[0] > eps # Tensor[bool] + + +def boxes_center_distance( + boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor, euclidean: bool = True +) -> Tuple[NdarrayOrTensor, NdarrayOrTensor, NdarrayOrTensor]: + """ + Distance of center points between two sets of boxes + + Args: + boxes1: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + boxes2: bounding boxes, Mx4 or Mx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + euclidean: computed the euclidean distance otherwise it uses the l1 distance + + Returns: + - The pairwise distances for every element in boxes1 and boxes2, + with size of (N,M) and same data type as ``boxes1``. + - Center points of boxes1, with size of (N,spatial_dims) and same data type as ``boxes1``. + - Center points of boxes2, with size of (M,spatial_dims) and same data type as ``boxes1``. + + Reference: + https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/ops.py + + """ + + if not isinstance(boxes1, type(boxes2)): + warnings.warn(f"boxes1 is {type(boxes1)}, while boxes2 is {type(boxes2)}. The result will be {type(boxes1)}.") + + # convert numpy to tensor if needed + boxes1_t, *_ = convert_data_type(boxes1, torch.Tensor) + boxes2_t, *_ = convert_data_type(boxes2, torch.Tensor) + + compute_dtype = torch.float32 + + center1 = box_centers(boxes1_t.to(compute_dtype)) # (N, spatial_dims) + center2 = box_centers(boxes2_t.to(compute_dtype)) # (M, spatial_dims) + + if euclidean: + dists = (center1[:, None] - center2[None]).pow(2).sum(-1).sqrt() + else: + # before sum: (N, M, spatial_dims) + dists = (center1[:, None] - center2[None]).sum(-1) + + # convert tensor back to numpy if needed + (dists, center1, center2), *_ = convert_to_dst_type(src=(dists, center1, center2), dst=boxes1) + return dists, center1, center2 diff --git a/tests/test_box_utils.py b/tests/test_box_utils.py index 7dee6a6e60..b5e8537af2 100644 --- a/tests/test_box_utils.py +++ b/tests/test_box_utils.py @@ -20,6 +20,9 @@ CornerCornerModeTypeB, CornerCornerModeTypeC, CornerSizeMode, + box_centers, + boxes_center_distance, + centers_in_boxes, convert_box_mode, convert_box_to_standard_mode, ) @@ -152,6 +155,23 @@ def test_value(self, input_data, mode2, expected_box, expected_area): expected_box_standard = convert_box_to_standard_mode(boxes=expected_box, mode=mode2) assert_allclose(result_standard, expected_box_standard, type_test=True, device_test=True, atol=0.0) + # test box_centers, centers_in_boxes, boxes_center_distance + result_standard_center = box_centers(result_standard) + expected_center = convert_box_mode(boxes=boxes1, src_mode=mode1, dst_mode="cccwhd")[:, :3] + assert_allclose(result_standard_center, expected_center, type_test=True, device_test=True, atol=0.0) + + center = expected_center + center[2, :] += 10 + result_centers_in_boxes = centers_in_boxes(centers=center, boxes=result_standard) + assert_allclose(result_centers_in_boxes, np.array([False, True, False]), type_test=False) + + center_dist, _, _ = boxes_center_distance(boxes1=result_standard[1:2, :], boxes2=result_standard[1:1, :]) + assert_allclose(center_dist, np.array([[]]), type_test=False) + center_dist, _, _ = boxes_center_distance(boxes1=result_standard[1:2, :], boxes2=result_standard[1:2, :]) + assert_allclose(center_dist, np.array([[0.0]]), type_test=False) + center_dist, _, _ = boxes_center_distance(boxes1=result_standard[0:1, :], boxes2=result_standard[0:1, :]) + assert_allclose(center_dist, np.array([[0.0]]), type_test=False) + if __name__ == "__main__": unittest.main() From 1ff12b94aef1ca867f99a79b0d0641e1d8e7ee84 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Thu, 19 May 2022 14:45:40 -0400 Subject: [PATCH 073/183] Make the order of slices row-major (#4297) * Make the order of slices row-major Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove a comment Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/utils.py | 4 ++-- tests/test_grid_dataset.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 2bd7b49731..ef71e3cae0 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -145,8 +145,8 @@ def iter_patch_slices( ranges = tuple(starmap(range, zip(start_pos, dims, patch_size_))) # choose patches by applying product to the ranges - for position in product(*ranges[::-1]): # reverse ranges order to iterate in index order - yield tuple(slice(s, s + p) for s, p in zip(position[::-1], patch_size_)) + for position in product(*ranges): + yield tuple(slice(s, s + p) for s, p in zip(position, patch_size_)) def dense_patch_slices( diff --git a/tests/test_grid_dataset.py b/tests/test_grid_dataset.py index 4d2d0d6948..529e679142 100644 --- a/tests/test_grid_dataset.py +++ b/tests/test_grid_dataset.py @@ -60,20 +60,20 @@ def test_loading_array(self): np.testing.assert_equal(tuple(item[0].shape), (2, 1, 2, 2)) np.testing.assert_allclose( item[0], - np.array([[[[1.4965, 2.4965], [5.4965, 6.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), + np.array([[[[7.4965, 8.4965], [11.4965, 12.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), rtol=1e-4, ) - np.testing.assert_allclose(item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) + np.testing.assert_allclose(item[1], np.array([[[0, 1], [2, 4], [0, 2]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) if sys.platform != "win32": for item in DataLoader(ds, batch_size=2, shuffle=False, num_workers=2): np.testing.assert_equal(tuple(item[0].shape), (2, 1, 2, 2)) np.testing.assert_allclose( item[0], - np.array([[[[1.2548, 2.2548], [5.2548, 6.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), + np.array([[[[7.2548, 8.2548], [11.2548, 12.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), rtol=1e-3, ) np.testing.assert_allclose( - item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5 + item[1], np.array([[[0, 1], [2, 4], [0, 2]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5 ) def test_loading_dict(self): @@ -102,20 +102,20 @@ def test_loading_dict(self): self.assertListEqual(item[0]["metadata"], ["test string", "test string"]) np.testing.assert_allclose( item[0]["image"], - np.array([[[[1.4965, 2.4965], [5.4965, 6.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), + np.array([[[[7.4965, 8.4965], [11.4965, 12.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), rtol=1e-4, ) - np.testing.assert_allclose(item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) + np.testing.assert_allclose(item[1], np.array([[[0, 1], [2, 4], [0, 2]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) if sys.platform != "win32": for item in DataLoader(ds, batch_size=2, shuffle=False, num_workers=2): np.testing.assert_equal(item[0]["image"].shape, (2, 1, 2, 2)) np.testing.assert_allclose( item[0]["image"], - np.array([[[[1.2548, 2.2548], [5.2548, 6.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), + np.array([[[[7.2548, 8.2548], [11.2548, 12.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), rtol=1e-3, ) np.testing.assert_allclose( - item[1], np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5 + item[1], np.array([[[0, 1], [2, 4], [0, 2]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5 ) From 5a5d47fadee6cf88893617eab01dfa71a5959ebc Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 19 May 2022 20:58:19 +0100 Subject: [PATCH 074/183] 4293 local pre-commit check support (#4292) * local pre-commit Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * indent 4 Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 5 +++++ requirements-dev.txt | 1 + runtests.sh | 30 +++++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e8fa75854..ede0366829 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,11 @@ repos: - id: check-added-large-files args: ['--maxkb=1024'] - id: detect-private-key + - id: forbid-new-submodules + - id: pretty-format-json + args: ['--autofix', '--no-sort-keys', '--indent=4'] + - id: end-of-file-fixer + - id: mixed-line-ending - repo: https://github.com/asottile/pyupgrade rev: v2.31.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index 271e8db9e3..ac8b3730d8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -48,3 +48,4 @@ pyyaml fire jsonschema pynrrd +pre-commit diff --git a/runtests.sh b/runtests.sh index 6d2da4a6e7..01d04dafd8 100755 --- a/runtests.sh +++ b/runtests.sh @@ -51,6 +51,7 @@ doPytypeFormat=false doMypyFormat=false doCleanup=false doDistTests=false +doPrecommit=false NUM_PARALLEL=1 @@ -59,7 +60,7 @@ PY_EXE=${MONAI_PY_EXE:-$(which python)} function print_usage { echo "runtests.sh [--codeformat] [--autofix] [--black] [--isort] [--flake8] [--pylint] [--clangformat] [--pytype] [--mypy]" echo " [--unittests] [--disttests] [--coverage] [--quick] [--min] [--net] [--dryrun] [-j number] [--list_tests]" - echo " [--copyright] [--build] [--clean] [--help] [--version]" + echo " [--copyright] [--build] [--clean] [--precommit] [--help] [--version]" echo "" echo "MONAI unit testing utilities." echo "" @@ -78,6 +79,7 @@ function print_usage { echo " --flake8 : perform \"flake8\" code format checks" echo " --pylint : perform \"pylint\" code format checks" echo " --clangformat : format csrc code using \"clang-format\"" + echo " --precommit : perform source code format check and fix using \"pre-commit\"" echo "" echo "Python type check options:" echo " --pytype : perform \"pytype\" static type checks" @@ -275,6 +277,9 @@ do --pylint) doPylintFormat=true ;; + --precommit) + doPrecommit=true + ;; --pytype) doPytypeFormat=true ;; @@ -489,6 +494,29 @@ then set -e # enable exit on failure fi +if [ $doPrecommit = true ] +then + set +e # disable exit on failure so that diagnostics can be given on failure + echo "${separator}${blue}pre-commit${noColor}" + + # ensure that the necessary packages for code format testing are installed + if ! is_pip_installed pre_commit + then + install_deps + fi + ${cmdPrefix}${PY_EXE} -m pre_commit run --all-files + + pre_commit_status=$? + if [ ${pre_commit_status} -ne 0 ] + then + print_style_fail_msg + exit ${pre_commit_status} + else + echo "${green}passed!${noColor}" + fi + set -e # enable exit on failure +fi + if [ $doPylintFormat = true ] then set +e # disable exit on failure so that diagnostics can be given on failure From 068b507a646a012b1823a8421f91b66536aedbb0 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 19 May 2022 22:42:37 +0100 Subject: [PATCH 075/183] merge certain files from feature/MetaTensor to dev (#4298) * merge certain files from feature/MetaTensor to dev Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * remove_extra_metadata Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/data/__init__.py | 3 ++ monai/data/meta_obj.py | 4 +-- monai/data/meta_tensor.py | 70 +++++++++++++++++++++++++++++++++++---- monai/data/utils.py | 66 ++++++++++++++++++++++++++++++++++++ tests/test_meta_tensor.py | 17 ++++++++++ 5 files changed, 150 insertions(+), 10 deletions(-) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 3346531386..72c4320617 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -72,6 +72,7 @@ create_file_basename, decollate_batch, dense_patch_slices, + get_extra_metadata_keys, get_random_patch, get_valid_patch_size, is_supported_format, @@ -85,6 +86,8 @@ partition_dataset_classes, pickle_hashing, rectify_header_sform_qform, + remove_extra_metadata, + remove_keys, reorient_spatial_axes, resample_datalist, select_cross_validation_folds, diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index 6c56dfe34b..2e5c38938a 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -160,9 +160,7 @@ def get_default_applied_operations(self) -> list: def __repr__(self) -> str: """String representation of class.""" - out: str = super().__repr__() - - out += "\nMetaData\n" + out: str = "\nMetaData\n" if self.meta is not None: out += "".join(f"\t{k}: {v}\n" for k, v in self.meta.items()) else: diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index b63209ed1d..30187684b1 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -19,7 +19,7 @@ from monai.config.type_definitions import NdarrayTensor from monai.data.meta_obj import MetaObj, get_track_meta -from monai.data.utils import decollate_batch, list_data_collate +from monai.data.utils import decollate_batch, list_data_collate, remove_extra_metadata from monai.utils.enums import PostFix __all__ = ["MetaTensor"] @@ -132,12 +132,35 @@ def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Call setattr(self, attribute, val.to(self.device)) @staticmethod - def update_meta(rets: Sequence, func, args, kwargs): - """Update the metadata from the output of `__torch_function__`. - The output could be a single object, or a sequence of them. Hence, they get - converted to a sequence if necessary and then processed by iterating across them. + def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: + """ + Update the metadata from the output of `MetaTensor.__torch_function__`. + + The output of `torch.Tensor.__torch_function__` could be a single object or a + sequence of them. Hence, in `MetaTensor.__torch_function__` we convert them to a + list of not already, and then we loop across each element, processing metadata + as necessary. For each element, if not of type `MetaTensor`, then nothing to do. - For each element, if not of type `MetaTensor`, then nothing to do + Args: + rets: the output from `torch.Tensor.__torch_function__`, which has been + converted to a list in `MetaTensor.__torch_function__` if it wasn't + already a `Sequence`. + func: the torch function that was applied. Examples might be `torch.squeeze` + or `torch.Tensor.__add__`. We need this since the metadata need to be + treated differently if a batch of data is considered. For example, + slicing (`torch.Tensor.__getitem__`) the ith element of the 0th + dimension of a batch of data should return a ith tensor with the ith + metadata. + args: positional arguments that were passed to `func`. + kwargs: keyword arguments that were passed to `func`. + + Returns: + A sequence with the same number of elements as `rets`. For each element, if + the input type was not `MetaTensor`, then no modifications will have been + made. If global parameters have been set to false (e.g., + `not get_track_meta()`), then any `MetaTensor` will be converted to + `torch.Tensor`. Else, metadata will be propogated as necessary (see + :py:func:`MetaTensor._copy_meta`). """ out = [] metas = None @@ -211,7 +234,7 @@ def __torch_function__(cls, func, types, args=(), kwargs=None) -> Any: # we might have 1 or multiple outputs. Might be MetaTensor, might be something # else (e.g., `__repr__` returns a string). # Convert to list (if necessary), process, and at end remove list if one was added. - if not isinstance(ret, Sequence): + if isinstance(ret, (str, bytes)) or not isinstance(ret, Sequence): ret = [ret] unpack = True else: @@ -267,3 +290,36 @@ def new_empty(self, size, dtype=None, device=None, requires_grad=False): return type(self)( self.as_tensor().new_empty(size=size, dtype=dtype, device=device, requires_grad=requires_grad) ) + + @staticmethod + def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict): + """ + Convert the image to `torch.Tensor`. If `affine` is in the `meta` dictionary, + convert that to `torch.Tensor`, too. Remove any superfluous metadata. + + Args: + im: Input image (`np.ndarray` or `torch.Tensor`) + meta: Metadata dictionary. + + Returns: + By default, a `MetaTensor` is returned. + However, if `get_track_meta()` is `False`, a `torch.Tensor` is returned. + """ + img = torch.as_tensor(im) + + # if not tracking metadata, return `torch.Tensor` + if not get_track_meta() or meta is None: + return img + + # ensure affine is of type `torch.Tensor` + if "affine" in meta: + meta["affine"] = torch.as_tensor(meta["affine"]) + + # remove any superfluous metadata. + remove_extra_metadata(meta) + + # return the `MetaTensor` + return MetaTensor(img, meta=meta) + + def __repr__(self, *, tensor_contents=None): + return self.as_tensor().__repr__() + super().__repr__() diff --git a/monai/data/utils.py b/monai/data/utils.py index ef71e3cae0..ca91006817 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -85,6 +85,9 @@ "to_affine_nd", "worker_init_fn", "zoom_affine", + "remove_keys", + "remove_extra_metadata", + "get_extra_metadata_keys", ] # module to be used by `torch.save` @@ -1320,3 +1323,66 @@ def orientation_ras_lps(affine: NdarrayTensor) -> NdarrayTensor: if isinstance(affine, torch.Tensor): return torch.diag(torch.as_tensor(flip_diag).to(affine)) @ affine # type: ignore return np.diag(flip_diag).astype(affine.dtype) @ affine # type: ignore + + +def remove_keys(data: dict, keys: List[str]) -> None: + """ + Remove keys from a dictionary. Operates in-place so nothing is returned. + + Args: + data: dictionary to be modified. + keys: keys to be deleted from dictionary. + + Returns: + `None` + """ + for k in keys: + _ = data.pop(k, None) + + +def remove_extra_metadata(meta: dict) -> None: + """ + Remove extra metadata from the dictionary. Operates in-place so nothing is returned. + + Args: + meta: dictionary containing metadata to be modified. + + Returns: + `None` + """ + keys = get_extra_metadata_keys() + remove_keys(data=meta, keys=keys) + + +def get_extra_metadata_keys() -> List[str]: + """ + Get a list of unnecessary keys for metadata that can be removed. + + Returns: + List of keys to be removed. + """ + keys = [ + "srow_x", + "srow_y", + "srow_z", + "quatern_b", + "quatern_c", + "quatern_d", + "qoffset_x", + "qoffset_y", + "qoffset_z", + "dim", + "pixdim", + *[f"dim[{i}]" for i in range(8)], + *[f"pixdim[{i}]" for i in range(8)], + ] + + # TODO: it would be good to remove these, but they are currently being used in the + # codebase. + # keys += [ + # "original_affine", + # "spatial_shape", + # "spacing", + # ] + + return keys diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 87226a585d..ab1a7ea3d1 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -406,6 +406,23 @@ def test_decollate(self, dtype): self.assertIsInstance(elem, MetaTensor) self.check(elem, im, ids=False) + def test_str(self): + t = MetaTensor([1.0], affine=torch.tensor(1), meta={"fname": "filename"}) + s1 = str(t) + s2 = t.__repr__() + expected_out = ( + "tensor([1.])\n" + + "MetaData\n" + + "\tfname: filename\n" + + "\taffine: 1\n" + + "\n" + + "Applied operations\n" + + "\n" + + "Is batch?: False" + ) + for s in (s1, s2): + self.assertEqual(s, expected_out) + def test_transforms(self): key = "im" _, im = self.get_im() From a129499cb7e4d4a1a668aa9cc38c55bd981ec788 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Fri, 20 May 2022 06:16:27 -0400 Subject: [PATCH 076/183] add box iou into box utils (#4289) * add box iou funcs Signed-off-by: Can Zhao * add box iou funcs to init Signed-off-by: Can Zhao --- docs/source/data.rst | 9 +- monai/data/__init__.py | 4 + monai/data/box_utils.py | 278 +++++++++++++++++++++++++++++++++++++++- tests/test_box_utils.py | 20 +++ 4 files changed, 305 insertions(+), 6 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 706371a8c0..00ca9944cb 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -316,7 +316,7 @@ Bounding box ------------ Box mode -~~~~~~~~~~ +~~~~~~~~ .. autoclass:: monai.data.box_utils.BoxMode :members: .. autoclass:: monai.data.box_utils.CornerCornerModeTypeA @@ -331,6 +331,13 @@ Box mode converter .. autofunction:: monai.data.box_utils.convert_box_mode .. autofunction:: monai.data.box_utils.convert_box_to_standard_mode +Box IoU +~~~~~~~ +.. autofunction:: monai.data.box_utils.box_area +.. autofunction:: monai.data.box_utils.box_iou +.. autofunction:: monai.data.box_utils.box_giou +.. autofunction:: monai.data.box_utils.box_pair_giou + Box center ~~~~~~~~~~ .. autofunction:: monai.data.box_utils.box_centers diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 72c4320617..f62dd93b08 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -10,7 +10,11 @@ # limitations under the License. from .box_utils import ( + box_area, box_centers, + box_giou, + box_iou, + box_pair_giou, boxes_center_distance, centers_in_boxes, convert_box_mode, diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 50dd46837d..37fc51c33d 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -37,9 +37,9 @@ SUPPORTED_SPATIAL_DIMS = [2, 3] -# TO_REMOVE = 0.0 if the bottom-right corner pixel/voxel is not included in the box, +# TO_REMOVE = 0.0 if the bottom-right corner pixel/voxel is not included in the boxes, # i.e., when xmin=1., xmax=2., we have w = 1. -# TO_REMOVE = 1.0 if the bottom-right corner pixel/voxel is included in the box, +# TO_REMOVE = 1.0 if the bottom-right corner pixel/voxel is included in the boxes, # i.e., when xmin=1., xmax=2., we have w = 2. # Currently, only `TO_REMOVE = 0.0` is supported TO_REMOVE = 0.0 # xmax-xmin = w -TO_REMOVE. @@ -83,7 +83,7 @@ def get_name(cls, spatial_dims: int) -> str: Get the mode name for the given spatial dimension using class variable ``name``. Args: - spatial_dims: number of spatial dimensions of the bounding box. + spatial_dims: number of spatial dimensions of the bounding boxes. Returns: ``str``: mode string name @@ -96,7 +96,7 @@ def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: Convert the bounding boxes of the current mode to corners. Args: - boxes: bounding box, Nx4 or Nx6 torch tensor + boxes: bounding boxes, Nx4 or Nx6 torch tensor Returns: ``Tuple``: corners of boxes, 4-element or 6-element tuple, each element is a Nx1 torch tensor. @@ -120,7 +120,7 @@ def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: It represents (xmin, ymin, xmax, ymax) or (xmin, ymin, zmin, xmax, ymax, zmax) Returns: - ``Tensor``: bounding box, Nx4 or Nx6 torch tensor + ``Tensor``: bounding boxes, Nx4 or Nx6 torch tensor Example: .. code-block:: python @@ -664,3 +664,271 @@ def boxes_center_distance( # convert tensor back to numpy if needed (dists, center1, center2), *_ = convert_to_dst_type(src=(dists, center1, center2), dst=boxes1) return dists, center1, center2 + + +def is_valid_box_values(boxes: NdarrayOrTensor) -> bool: + """ + This function checks whether the box size is non-negative. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + + Returns: + whether ``boxes`` is valid + """ + spatial_dims = get_spatial_dims(boxes=boxes) + for axis in range(0, spatial_dims): + if (boxes[:, spatial_dims + axis] < boxes[:, axis]).sum() > 0: + return False + return True + + +def box_area(boxes: NdarrayOrTensor) -> NdarrayOrTensor: + """ + This function computes the area (2D) or volume (3D) of each box. + Half precision is not recommended for this function as it may cause overflow, especially for 3D images. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + + Returns: + area (2D) or volume (3D) of boxes, with size of (N,). + + Example: + .. code-block:: python + + boxes = torch.ones(10,6) + # we do computation with torch.float32 to avoid overflow + compute_dtype = torch.float32 + area = box_area(boxes=boxes.to(dtype=compute_dtype)) # torch.float32, size of (10,) + """ + + if not is_valid_box_values(boxes): + raise ValueError("Given boxes has invalid values. The box size must be non-negative.") + + spatial_dims = get_spatial_dims(boxes=boxes) + + area = boxes[:, spatial_dims] - boxes[:, 0] + TO_REMOVE + for axis in range(1, spatial_dims): + area = area * (boxes[:, axis + spatial_dims] - boxes[:, axis] + TO_REMOVE) + + # convert numpy to tensor if needed + area_t, *_ = convert_data_type(area, torch.Tensor) + + # check if NaN or Inf, especially for half precision + if area_t.isnan().any() or area_t.isinf().any(): + if area_t.dtype is torch.float16: + raise ValueError("Box area is NaN or Inf. boxes is float16. Please change to float32 and test it again.") + else: + raise ValueError("Box area is NaN or Inf.") + + return area + + +def _box_inter_union( + boxes1_t: torch.Tensor, boxes2_t: torch.Tensor, compute_dtype: torch.dtype = torch.float32 +) -> Tuple[torch.Tensor, torch.Tensor]: + """ + This internal function computes the intersection and union area of two set of boxes. + + Args: + boxes1: bounding boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + boxes2: bounding boxes, Mx4 or Mx6 torch tensor. The box mode is assumed to be ``StandardMode`` + compute_dtype: default torch.float32, dtype with which the results will be computed + + Returns: + inter, with size of (N,M) and dtype of ``compute_dtype``. + union, with size of (N,M) and dtype of ``compute_dtype``. + + """ + spatial_dims = get_spatial_dims(boxes=boxes1_t) + + # compute area with float32 + area1 = box_area(boxes=boxes1_t.to(dtype=compute_dtype)) # (N,) + area2 = box_area(boxes=boxes2_t.to(dtype=compute_dtype)) # (M,) + + # get the left top and right bottom points for the NxM combinations + lt = torch.max(boxes1_t[:, None, :spatial_dims], boxes2_t[:, :spatial_dims]).to( + dtype=compute_dtype + ) # (N,M,spatial_dims) left top + rb = torch.min(boxes1_t[:, None, spatial_dims:], boxes2_t[:, spatial_dims:]).to( + dtype=compute_dtype + ) # (N,M,spatial_dims) right bottom + + # compute size for the intersection region for the NxM combinations + wh = (rb - lt + TO_REMOVE).clamp(min=0) # (N,M,spatial_dims) + inter = torch.prod(wh, dim=-1, keepdim=False) # (N,M) + + union = area1[:, None] + area2 - inter + return inter, union + + +def box_iou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Compute the intersection over union (IoU) of two set of boxes. + + Args: + boxes1: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + boxes2: bounding boxes, Mx4 or Mx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + + Returns: + IoU, with size of (N,M) and same data type as ``boxes1`` + + """ + + if not isinstance(boxes1, type(boxes2)): + warnings.warn(f"boxes1 is {type(boxes1)}, while boxes2 is {type(boxes2)}. The result will be {type(boxes1)}.") + + # convert numpy to tensor if needed + boxes1_t, *_ = convert_data_type(boxes1, torch.Tensor) + boxes2_t, *_ = convert_data_type(boxes2, torch.Tensor) + + # we do computation with compute_dtype to avoid overflow + box_dtype = boxes1_t.dtype + compute_dtype = torch.float32 + + inter, union = _box_inter_union(boxes1_t, boxes2_t, compute_dtype=compute_dtype) + + # compute IoU and convert back to original box_dtype + iou_t = inter / (union + torch.finfo(compute_dtype).eps) # (N,M) + iou_t = iou_t.to(dtype=box_dtype) + + # check if NaN or Inf + if torch.isnan(iou_t).any() or torch.isinf(iou_t).any(): + raise ValueError("Box IoU is NaN or Inf.") + + # convert tensor back to numpy if needed + iou, *_ = convert_to_dst_type(src=iou_t, dst=boxes1) + return iou + + +def box_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Compute the generalized intersection over union (GIoU) of two sets of boxes. + The two inputs can have different shapes and the func return an NxM matrix, + (in contrary to ``box_pair_giou``, which requires the inputs to have the same + shape and returns ``N`` values). + + Returns: + GIoU, with size of (N,M) and same data type as ``boxes1`` + + Reference: + https://giou.stanford.edu/GIoU.pdf + + """ + + if not isinstance(boxes1, type(boxes2)): + warnings.warn(f"boxes1 is {type(boxes1)}, while boxes2 is {type(boxes2)}. The result will be {type(boxes1)}.") + + # convert numpy to tensor if needed + boxes1_t, *_ = convert_data_type(boxes1, torch.Tensor) + boxes2_t, *_ = convert_data_type(boxes2, torch.Tensor) + + spatial_dims = get_spatial_dims(boxes=boxes1_t) + + # we do computation with compute_dtype to avoid overflow + box_dtype = boxes1_t.dtype + compute_dtype = torch.float32 + + inter, union = _box_inter_union(boxes1_t, boxes2_t, compute_dtype=compute_dtype) + iou = inter / (union + torch.finfo(compute_dtype).eps) # (N,M) + + # Enclosure + # get the left top and right bottom points for the NxM combinations + lt = torch.min(boxes1_t[:, None, :spatial_dims], boxes2_t[:, :spatial_dims]).to( + dtype=compute_dtype + ) # (N,M,spatial_dims) left top + rb = torch.max(boxes1_t[:, None, spatial_dims:], boxes2_t[:, spatial_dims:]).to( + dtype=compute_dtype + ) # (N,M,spatial_dims) right bottom + + # compute size for the enclosure region for the NxM combinations + wh = (rb - lt + TO_REMOVE).clamp(min=0) # (N,M,spatial_dims) + enclosure = torch.prod(wh, dim=-1, keepdim=False) # (N,M) + + # GIoU + giou_t = iou - (enclosure - union) / (enclosure + torch.finfo(compute_dtype).eps) + giou_t = giou_t.to(dtype=box_dtype) + if torch.isnan(giou_t).any() or torch.isinf(giou_t).any(): + raise ValueError("Box GIoU is NaN or Inf.") + + # convert tensor back to numpy if needed + giou, *_ = convert_to_dst_type(src=giou_t, dst=boxes1) + return giou + + +def box_pair_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Compute the generalized intersection over union (GIoU) of a pair of boxes. + The two inputs should have the same shape. + + Args: + boxes1: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be StandardMode + boxes2: bounding boxes, same shape with boxes1. The box mode is assumed to be StandardMode + + Returns: + paired GIoU, with size of (N,) and same data type as ``boxes1`` + + Reference: + https://giou.stanford.edu/GIoU.pdf + + """ + + if not isinstance(boxes1, type(boxes2)): + warnings.warn(f"boxes1 is {type(boxes1)}, while boxes2 is {type(boxes2)}. The result will be {type(boxes1)}.") + + # convert numpy to tensor if needed + boxes1_t, *_ = convert_data_type(boxes1, torch.Tensor) + boxes2_t, *_ = convert_data_type(boxes2, torch.Tensor) + + if boxes1_t.shape != boxes2_t.shape: + raise ValueError("boxes1 and boxes2 should be paired and have same shape.") + + spatial_dims = get_spatial_dims(boxes=boxes1_t) + + # we do computation with compute_dtype to avoid overflow + box_dtype = boxes1_t.dtype + compute_dtype = torch.float32 + + # compute area + area1 = box_area(boxes=boxes1_t.to(dtype=compute_dtype)) # (N,) + area2 = box_area(boxes=boxes2_t.to(dtype=compute_dtype)) # (N,) + + # Intersection + # get the left top and right bottom points for the boxes pair + lt = torch.max(boxes1_t[:, :spatial_dims], boxes2_t[:, :spatial_dims]).to( + dtype=compute_dtype + ) # (N,spatial_dims) left top + rb = torch.min(boxes1_t[:, spatial_dims:], boxes2_t[:, spatial_dims:]).to( + dtype=compute_dtype + ) # (N,spatial_dims) right bottom + + # compute size for the intersection region for the boxes pair + wh = (rb - lt + TO_REMOVE).clamp(min=0) # (N,spatial_dims) + inter = torch.prod(wh, dim=-1, keepdim=False) # (N,) + + # compute IoU and convert back to original box_dtype + union = area1 + area2 - inter + iou = inter / (union + torch.finfo(compute_dtype).eps) # (N,) + + # Enclosure + # get the left top and right bottom points for the boxes pair + lt = torch.min(boxes1_t[:, :spatial_dims], boxes2_t[:, :spatial_dims]).to( + dtype=compute_dtype + ) # (N,spatial_dims) left top + rb = torch.max(boxes1_t[:, spatial_dims:], boxes2_t[:, spatial_dims:]).to( + dtype=compute_dtype + ) # (N,spatial_dims) right bottom + + # compute size for the enclose region for the boxes pair + wh = (rb - lt + TO_REMOVE).clamp(min=0) # (N,spatial_dims) + enclosure = torch.prod(wh, dim=-1, keepdim=False) # (N,) + + giou_t = iou - (enclosure - union) / (enclosure + torch.finfo(compute_dtype).eps) + giou_t = giou_t.to(dtype=box_dtype) # (N,spatial_dims) + if torch.isnan(giou_t).any() or torch.isinf(giou_t).any(): + raise ValueError("Box GIoU is NaN or Inf.") + + # convert tensor back to numpy if needed + giou, *_ = convert_to_dst_type(src=giou_t, dst=boxes1) + return giou diff --git a/tests/test_box_utils.py b/tests/test_box_utils.py index b5e8537af2..891baa20c5 100644 --- a/tests/test_box_utils.py +++ b/tests/test_box_utils.py @@ -20,7 +20,11 @@ CornerCornerModeTypeB, CornerCornerModeTypeC, CornerSizeMode, + box_area, box_centers, + box_giou, + box_iou, + box_pair_giou, boxes_center_distance, centers_in_boxes, convert_box_mode, @@ -155,6 +159,22 @@ def test_value(self, input_data, mode2, expected_box, expected_area): expected_box_standard = convert_box_to_standard_mode(boxes=expected_box, mode=mode2) assert_allclose(result_standard, expected_box_standard, type_test=True, device_test=True, atol=0.0) + # test box_area, box_iou, box_giou, box_pair_giou + assert_allclose(box_area(result_standard), expected_area, type_test=True, device_test=True, atol=0.0) + iou_metrics = (box_iou, box_giou) # type: ignore + for p in iou_metrics: + self_iou = p(boxes1=result_standard[1:2, :], boxes2=result_standard[1:1, :]) + assert_allclose(self_iou, np.array([[]]), type_test=False) + + self_iou = p(boxes1=result_standard[1:2, :], boxes2=result_standard[1:2, :]) + assert_allclose(self_iou, np.array([[1.0]]), type_test=False) + + self_iou = box_pair_giou(boxes1=result_standard[1:1, :], boxes2=result_standard[1:1, :]) + assert_allclose(self_iou, np.array([]), type_test=False) + + self_iou = box_pair_giou(boxes1=result_standard[1:2, :], boxes2=result_standard[1:2, :]) + assert_allclose(self_iou, np.array([1.0]), type_test=False) + # test box_centers, centers_in_boxes, boxes_center_distance result_standard_center = box_centers(result_standard) expected_center = convert_box_mode(boxes=boxes1, src_mode=mode1, dst_mode="cccwhd")[:, :3] From 1d5e85aaa958b95ae217260dc1c4df5edbb7c9e8 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 20 May 2022 12:07:34 +0100 Subject: [PATCH 077/183] fix RandAffine with randomize==False (#4304) * fix RandAffine with randomize==False Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/spatial/array.py | 12 +++++++++--- tests/test_rand_affine.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 44a0e88ce0..69f98aa2a0 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1520,17 +1520,23 @@ def randomize(self, data: Optional[Any] = None) -> None: self.scale_params = self._get_rand_param(self.scale_range, 1.0) def __call__( - self, spatial_size: Optional[Sequence[int]] = None, grid: Optional[NdarrayOrTensor] = None + self, + spatial_size: Optional[Sequence[int]] = None, + grid: Optional[NdarrayOrTensor] = None, + randomize: bool = True, ) -> NdarrayOrTensor: """ 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. + randomize: boolean as to whether the grid parameters governing the grid + should be randomized. Returns: a 2D (3xHxW) or 3D (4xHxWxD) grid. """ - self.randomize() + if randomize: + self.randomize() affine_grid = AffineGrid( rotate_params=self.rotate_params, shear_params=self.shear_params, @@ -2035,7 +2041,7 @@ def __call__( img, *_ = convert_data_type(img, dtype=torch.float32, device=self.resampler.device) grid = self.get_identity_grid(sp_size) if self._do_transform: - grid = self.rand_affine_grid(grid=grid) + grid = self.rand_affine_grid(grid=grid, randomize=randomize) out: NdarrayOrTensor = self.resampler( img=img, grid=grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode ) diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index 8de408ab84..dcfe193213 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -130,6 +130,11 @@ for in_dtype in (np.int32, np.float32): TEST_CASES_SKIPPED_CONSISTENCY.append((p(np.arange(9 * 10).reshape(1, 9, 10)), in_dtype)) +TEST_RANDOMIZE = [] +for cache_grid in (False, True): + for initial_randomize in (False, True): + TEST_RANDOMIZE.append((initial_randomize, cache_grid)) + class TestRandAffine(unittest.TestCase): @parameterized.expand(TESTS) @@ -162,6 +167,31 @@ def test_skipped_transform_consistency(self, im, in_dtype): # check matching dtype self.assertEqual(out1.dtype, out2.dtype) + @parameterized.expand(TEST_RANDOMIZE) + def test_no_randomize(self, initial_randomize, cache_grid): + rand_affine = RandAffine( + prob=1, + rotate_range=(np.pi / 6, 0, 0), + translate_range=((-2, 2), (-2, 2), (-2, 2)), + scale_range=((-0.1, 0.1), (-0.1, 0.1), (-0.1, 0.1)), + spatial_size=(16, 16, 16), + cache_grid=cache_grid, + padding_mode="zeros", + ) + if initial_randomize: + rand_affine.randomize(None) + + arr = torch.randn((1, 16, 16, 16)) * 100 + + arr1 = rand_affine(arr, randomize=False) + m1 = rand_affine.rand_affine_grid.get_transformation_matrix() + + arr2 = rand_affine(arr, randomize=False) + m2 = rand_affine.rand_affine_grid.get_transformation_matrix() + + assert_allclose(m1, m2) + assert_allclose(arr1, arr2) + if __name__ == "__main__": unittest.main() From e58086d9ef0f344eb1ee1f23ffbfbd3e26887c41 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Fri, 20 May 2022 13:09:29 -0400 Subject: [PATCH 078/183] add box mode convert transform (#4302) * add box transform Signed-off-by: Can Zhao --- docs/source/apps.rst | 10 ++ monai/apps/detection/__init__.py | 10 ++ monai/apps/detection/transforms/__init__.py | 10 ++ monai/apps/detection/transforms/array.py | 128 +++++++++++++++ monai/apps/detection/transforms/dictionary.py | 148 ++++++++++++++++++ tests/test_box_transform.py | 52 ++++++ 6 files changed, 358 insertions(+) create mode 100644 monai/apps/detection/__init__.py create mode 100644 monai/apps/detection/transforms/__init__.py create mode 100644 monai/apps/detection/transforms/array.py create mode 100644 monai/apps/detection/transforms/dictionary.py create mode 100644 tests/test_box_transform.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 239ae9eb17..f4599ce20c 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -122,3 +122,13 @@ Applications :members: .. autoclass:: TileOnGridd :members: + +`Detection` +----------- + +`Transforms` +~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.transforms.array + :members: +.. automodule:: monai.apps.detection.transforms.dictionary + :members: diff --git a/monai/apps/detection/__init__.py b/monai/apps/detection/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/apps/detection/transforms/__init__.py b/monai/apps/detection/transforms/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/transforms/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py new file mode 100644 index 0000000000..c3ea959eb8 --- /dev/null +++ b/monai/apps/detection/transforms/array.py @@ -0,0 +1,128 @@ +# 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. +""" +A collection of "vanilla" transforms for box operations +https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design +""" + +from typing import Type, Union + +from monai.config.type_definitions import NdarrayOrTensor +from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode +from monai.transforms.transform import Transform +from monai.utils.enums import TransformBackends + +__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode"] + + +class ConvertBoxMode(Transform): + """ + This transform converts the boxes in src_mode to the dst_mode. + + Example: + .. code-block:: python + + boxes = torch.ones(10,4) + # convert boxes with format [xmin, ymin, xmax, ymax] to [xcenter, ycenter, xsize, ysize]. + box_converter = ConvertBoxMode(src_mode="xyxy", dst_mode="ccwh") + box_converter(boxes) + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + src_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + ) -> None: + """ + + Args: + src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. + + Note: + ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + also represented as "xyxy" for 2D and "xyzxyz" for 3D. + + src_mode and dst_mode can be: + #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example, + - "xyxy": boxes has format [xmin, ymin, xmax, ymax] + - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax] + - "xxyy": boxes has format [xmin, xmax, ymin, ymax] + - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax] + - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax] + - "xywh": boxes has format [xmin, ymin, xsize, ysize] + - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize] + - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize] + - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize] + #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode: equivalent to "xywh" or "xyzwhd" + - CenterSizeMode: equivalent to "ccwh" or "cccwhd" + #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode(): equivalent to "xywh" or "xyzwhd" + - CenterSizeMode(): equivalent to "ccwh" or "cccwhd" + #. None: will assume mode is ``StandardMode()`` + """ + self.src_mode = src_mode + self.dst_mode = dst_mode + + def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Converts the boxes in src_mode to the dst_mode. + + Returns: + bounding boxes with target mode, with same data type as ``boxes``, does not share memory with ``boxes`` + """ + return convert_box_mode(boxes, src_mode=self.src_mode, dst_mode=self.dst_mode) + + +class ConvertBoxToStandardMode(Transform): + """ + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Example: + .. code-block:: python + + boxes = torch.ones(10,6) + # convert boxes with format [xmin, xmax, ymin, ymax, zmin, zmax] to [xmin, ymin, zmin, xmax, ymax, zmax] + box_converter = ConvertBoxToStandardMode(mode="xxyyzz") + box_converter(boxes) + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, mode: Union[str, BoxMode, Type[BoxMode], None] = None) -> None: + """ + Args: + mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + """ + self.mode = mode + + def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Returns: + bounding boxes with standard mode, with same data type as ``boxes``, does not share memory with ``boxes`` + """ + return convert_box_to_standard_mode(boxes, mode=self.mode) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py new file mode 100644 index 0000000000..a0b4c76224 --- /dev/null +++ b/monai/apps/detection/transforms/dictionary.py @@ -0,0 +1,148 @@ +# 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. +""" +A collection of dictionary-based wrappers around the "vanilla" transforms for box operations +defined in :py:class:`monai.apps.detection.transforms.array`. + +Class names are ended with 'd' to denote dictionary-based transforms. +""" + +from copy import deepcopy +from typing import Dict, Hashable, Mapping, Type, Union + +from monai.apps.detection.transforms.array import ConvertBoxMode, ConvertBoxToStandardMode +from monai.config import KeysCollection +from monai.config.type_definitions import NdarrayOrTensor +from monai.data.box_utils import BoxMode +from monai.transforms.inverse import InvertibleTransform +from monai.transforms.transform import MapTransform + +__all__ = [ + "ConvertBoxModed", + "ConvertBoxModeD", + "ConvertBoxModeDict", + "ConvertBoxToStandardModed", + "ConvertBoxToStandardModeD", + "ConvertBoxToStandardModeDict", +] + + +class ConvertBoxModed(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.ConvertBoxMode`. + + This transform converts the boxes in src_mode to the dst_mode. + + Example: + .. code-block:: python + + data = {"boxes": torch.ones(10,4)} + # convert boxes with format [xmin, ymin, xmax, ymax] to [xcenter, ycenter, xsize, ysize]. + box_converter = ConvertBoxModed(box_keys=["boxes"], src_mode="xyxy", dst_mode="ccwh") + box_converter(data) + """ + + def __init__( + self, + box_keys: KeysCollection, + src_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + box_keys: Keys to pick data for transformation. + src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + allow_missing_keys: don't raise exception if key is missing. + + See also :py:class:`monai.apps.detection,transforms.array.ConvertBoxMode` + """ + super().__init__(box_keys, allow_missing_keys) + self.converter = ConvertBoxMode(src_mode=src_mode, dst_mode=dst_mode) + self.inverse_converter = ConvertBoxMode(src_mode=dst_mode, dst_mode=src_mode) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + self.push_transform(d, key) + d[key] = self.converter(d[key]) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + _ = self.get_most_recent_transform(d, key) + # Inverse is same as forward + d[key] = self.inverse_converter(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d + + +class ConvertBoxToStandardModed(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.ConvertBoxToStandardMode`. + + Convert given boxes to standard mode. + Standard mode is "xyxy" or "xyzxyz", + representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + + Example: + .. code-block:: python + + data = {"boxes": torch.ones(10,6)} + # convert boxes with format [xmin, xmax, ymin, ymax, zmin, zmax] to [xmin, ymin, zmin, xmax, ymax, zmax] + box_converter = ConvertBoxToStandardModed(box_keys=["boxes"], mode="xxyyzz") + box_converter(data) + """ + + def __init__( + self, + box_keys: KeysCollection, + mode: Union[str, BoxMode, Type[BoxMode], None] = None, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + box_keys: Keys to pick data for transformation. + mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + allow_missing_keys: don't raise exception if key is missing. + + See also :py:class:`monai.apps.detection,transforms.array.ConvertBoxToStandardMode` + """ + super().__init__(box_keys, allow_missing_keys) + self.converter = ConvertBoxToStandardMode(mode=mode) + self.inverse_converter = ConvertBoxMode(src_mode=None, dst_mode=mode) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + self.push_transform(d, key) + d[key] = self.converter(d[key]) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + _ = self.get_most_recent_transform(d, key) + # Inverse is same as forward + d[key] = self.inverse_converter(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d + + +ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed +ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py new file mode 100644 index 0000000000..a679dd3c95 --- /dev/null +++ b/tests/test_box_transform.py @@ -0,0 +1,52 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.detection.transforms.dictionary import ConvertBoxToStandardModed +from monai.transforms import Invertd +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] + +boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]] +image_size = [1, 5, 6, 4] +image = np.zeros(image_size) + +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"box_keys": "boxes", "mode": "xyzwhd"}, + {"boxes": p(boxes), "image": p(image)}, + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([[0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([[0, 1, 1, 2, 3, 4], [0, 1, 0, 2, 3, 3]]), + ] + ) + + +class TestBoxTransform(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, keys, data, expected_standard_result, expected_clip_result, expected_flip_result): + transform_convert_mode = ConvertBoxToStandardModed(**keys) + result = transform_convert_mode(data) + assert_allclose(result["boxes"], expected_standard_result, type_test=True, device_test=True, atol=0.0) + + invert_transform_convert_mode = Invertd(keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"]) + data_back = invert_transform_convert_mode(result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.0) + + +if __name__ == "__main__": + unittest.main() From 98adb506ea034ac7c1445a038d0042263dae43d5 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Sun, 22 May 2022 06:27:49 -0400 Subject: [PATCH 079/183] add box ops for affine and zoom (#4307) * add box_ops for zoom box transforms Signed-off-by: Can Zhao --- docs/source/apps.rst | 2 + monai/apps/detection/transforms/box_ops.py | 153 +++++++++++++++++++++ tests/test_resize.py | 2 +- 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 monai/apps/detection/transforms/box_ops.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index f4599ce20c..1a11fc62c6 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -128,6 +128,8 @@ Applications `Transforms` ~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.transforms.box_ops + :members: .. automodule:: monai.apps.detection.transforms.array :members: .. automodule:: monai.apps.detection.transforms.dictionary diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py new file mode 100644 index 0000000000..13911a837a --- /dev/null +++ b/monai/apps/detection/transforms/box_ops.py @@ -0,0 +1,153 @@ +# 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 typing import Sequence, Union + +import torch + +from monai.config.type_definitions import NdarrayOrTensor +from monai.data.box_utils import get_spatial_dims +from monai.transforms.utils import create_scale +from monai.utils.misc import ensure_tuple_rep +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type + + +def _apply_affine_to_points(points: torch.Tensor, affine: torch.Tensor, include_shift: bool = True) -> torch.Tensor: + """ + This internal function applies affine matrixs to the point coordinate + + Args: + points: point coordinates, Nx2 or Nx3 torch tensor or ndarray, representing [x, y] or [x, y, z] + affine: affine matrix to be applied to the point coordinates, sized (spatial_dims+1,spatial_dims+1) + include_shift: default True, whether the function apply translation (shift) in the affine transform + + Returns: + transformed point coordinates, with same data type as ``points``, does not share memory with ``points`` + """ + + spatial_dims = get_spatial_dims(points=points) + + # compute new points + if include_shift: + # append 1 to form Nx(spatial_dims+1) vector, then transpose + points_affine = torch.cat( + [points, torch.ones(points.shape[0], 1, device=points.device, dtype=points.dtype)], dim=1 + ).transpose(0, 1) + # apply affine + points_affine = torch.matmul(affine, points_affine) + # remove appended 1 and transpose back + points_affine = points_affine[:spatial_dims, :].transpose(0, 1) + else: + points_affine = points.transpose(0, 1) + points_affine = torch.matmul(affine[:spatial_dims, :spatial_dims], points_affine) + points_affine = points_affine.transpose(0, 1) + + return points_affine + + +def apply_affine_to_boxes(boxes: NdarrayOrTensor, affine: NdarrayOrTensor) -> NdarrayOrTensor: + """ + This function applies affine matrixs to the boxes + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be StandardMode + affine: affine matrix to be applied to the box coordinates, sized (spatial_dims+1,spatial_dims+1) + + Returns: + returned affine transformed boxes, with same data type as ``boxes``, does not share memory with ``boxes`` + """ + + # convert numpy to tensor if needed + boxes_t, *_ = convert_data_type(boxes, torch.Tensor) + + # some operation does not support torch.float16 + # convert to float32 + compute_dtype = torch.float32 + + boxes_t = boxes_t.to(dtype=compute_dtype) + affine_t, *_ = convert_to_dst_type(src=affine, dst=boxes_t) + + spatial_dims = get_spatial_dims(boxes=boxes_t) + + # affine transform left top and bottom right points + # might flipped, thus lt may not be left top any more + lt: torch.Tensor = _apply_affine_to_points(boxes_t[:, :spatial_dims], affine_t, include_shift=True) + rb: torch.Tensor = _apply_affine_to_points(boxes_t[:, spatial_dims:], affine_t, include_shift=True) + + # make sure lt_new is left top, and rb_new is bottom right + lt_new, _ = torch.min(torch.stack([lt, rb], dim=2), dim=2) + rb_new, _ = torch.max(torch.stack([lt, rb], dim=2), dim=2) + + boxes_t_affine = torch.cat([lt_new, rb_new], dim=1) + + # convert tensor back to numpy if needed + boxes_affine: NdarrayOrTensor + boxes_affine, *_ = convert_to_dst_type(src=boxes_t_affine, dst=boxes) + return boxes_affine + + +def zoom_boxes(boxes: NdarrayOrTensor, zoom: Union[Sequence[float], float]) -> NdarrayOrTensor: + """ + Zoom boxes + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be StandardMode + zoom: The zoom factor along the spatial axes. + If a float, zoom is the same for each spatial axis. + If a sequence, zoom should contain one value for each spatial axis. + + Returns: + zoomed boxes, with same data type as ``boxes``, does not share memory with ``boxes`` + + Example: + .. code-block:: python + + boxes = torch.ones(1,4) + zoom_boxes(boxes, zoom=[0.5,2.2]) # will return tensor([[0.5, 2.2, 0.5, 2.2]]) + """ + spatial_dims = get_spatial_dims(boxes=boxes) + + # generate affine transform corresponding to ``zoom`` + affine = create_scale(spatial_dims=spatial_dims, scaling_factor=zoom) + + return apply_affine_to_boxes(boxes=boxes, affine=affine) + + +def resize_boxes( + boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int], dst_spatial_size: Union[Sequence[int], int] +) -> NdarrayOrTensor: + """ + Resize boxes when the corresponding image is resized + + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + src_spatial_size: source image spatial size. + dst_spatial_size: target image spatial size. + + Returns: + resized boxes, with same data type as ``boxes``, does not share memory with ``boxes`` + + Example: + .. code-block:: python + + boxes = torch.ones(1,4) + src_spatial_size = [100, 100] + dst_spatial_size = [128, 256] + resize_boxes(boxes, src_spatial_size, dst_spatial_size) # will return tensor([[1.28, 2.56, 1.28, 2.56]]) + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + + src_spatial_size = ensure_tuple_rep(src_spatial_size, spatial_dims) + dst_spatial_size = ensure_tuple_rep(dst_spatial_size, spatial_dims) + + zoom = [dst_spatial_size[axis] / float(src_spatial_size[axis]) for axis in range(spatial_dims)] + + return zoom_boxes(boxes=boxes, zoom=zoom) diff --git a/tests/test_resize.py b/tests/test_resize.py index cb24cf2cc3..9214c44cf0 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -75,7 +75,7 @@ def test_correct_results(self, spatial_size, mode, anti_aliasing): out = out.cpu().detach().numpy() good = np.sum(np.isclose(expected, out, atol=0.9)) self.assertLessEqual( - np.abs(good - expected.size) / float(expected.size), 0.2, "at most 20 percent mismatch " + np.abs(good - expected.size) / float(expected.size), 0.21, "at most 21 percent mismatch " ) @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) From 655c8e7f358b94a491b275bd00e124021af08f56 Mon Sep 17 00:00:00 2001 From: Ali Hatamizadeh Date: Sun, 22 May 2022 04:16:34 -0700 Subject: [PATCH 080/183] update swin_unetr block parameter names (#4311) * update swin_unetr block name Signed-off-by: ahatamizadeh --- monai/networks/nets/swin_unetr.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/monai/networks/nets/swin_unetr.py b/monai/networks/nets/swin_unetr.py index d898da9884..79a7936433 100644 --- a/monai/networks/nets/swin_unetr.py +++ b/monai/networks/nets/swin_unetr.py @@ -40,12 +40,12 @@ def __init__( out_channels: int, depths: Sequence[int] = (2, 2, 2, 2), num_heads: Sequence[int] = (3, 6, 12, 24), - feature_size: int = 48, + feature_size: int = 24, norm_name: Union[Tuple, str] = "instance", drop_rate: float = 0.0, attn_drop_rate: float = 0.0, dropout_path_rate: float = 0.0, - normalize: bool = False, + normalize: bool = True, use_checkpoint: bool = False, spatial_dims: int = 3, ) -> None: @@ -275,8 +275,6 @@ def load_from(self, weights): self.swinViT.layers4[0].downsample.norm.bias.copy_( weights["state_dict"]["module.layers4.0.downsample.norm.bias"] ) - self.swinViT.norm.weight.copy_(weights["state_dict"]["module.norm.weight"]) - self.swinViT.norm.bias.copy_(weights["state_dict"]["module.norm.bias"]) def forward(self, x_in): hidden_states_out = self.swinViT(x_in, self.normalize) @@ -626,10 +624,10 @@ def load_from(self, weights, n_block, layer): "attn.proj.bias", "norm2.weight", "norm2.bias", - "mlp.linear1.weight", - "mlp.linear1.bias", - "mlp.linear2.weight", - "mlp.linear2.bias", + "mlp.fc1.weight", + "mlp.fc1.bias", + "mlp.fc2.weight", + "mlp.fc2.bias", ] with torch.no_grad(): self.norm1.weight.copy_(weights["state_dict"][root + block_names[0]]) @@ -950,7 +948,6 @@ def __init__( elif i_layer == 3: self.layers4.append(layer) self.num_features = int(embed_dim * 2 ** (self.num_layers - 1)) - self.norm = norm_layer(self.num_features) def proj_out(self, x, normalize=False): if normalize: @@ -967,7 +964,7 @@ def proj_out(self, x, normalize=False): x = rearrange(x, "n h w c -> n c h w") return x - def forward(self, x, normalize=False): + def forward(self, x, normalize=True): x0 = self.patch_embed(x) x0 = self.pos_drop(x0) x0_out = self.proj_out(x0, normalize) From 65f1f548018dece1e44c56f6203d6286437b8e61 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Sun, 22 May 2022 20:13:07 +0800 Subject: [PATCH 081/183] 4312 Fix squeeze issue of `get_mask_edges` (#4313) * fix squeeze issue Signed-off-by: Yiheng Wang --- monai/metrics/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index 3e3a29d468..2a9c8affc9 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -155,10 +155,13 @@ def get_mask_edges( if not np.any(seg_pred | seg_gt): return np.zeros_like(seg_pred), np.zeros_like(seg_gt) - seg_pred, seg_gt = np.expand_dims(seg_pred, 0), np.expand_dims(seg_gt, 0) + channel_dim = 0 + seg_pred, seg_gt = np.expand_dims(seg_pred, axis=channel_dim), np.expand_dims(seg_gt, axis=channel_dim) box_start, box_end = generate_spatial_bounding_box(np.asarray(seg_pred | seg_gt)) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) - seg_pred, seg_gt = np.squeeze(cropper(seg_pred)), np.squeeze(cropper(seg_gt)) + seg_pred, seg_gt = np.squeeze(cropper(seg_pred), axis=channel_dim), np.squeeze( + cropper(seg_gt), axis=channel_dim + ) # Do binary erosion and use XOR to get edges edges_pred = binary_erosion(seg_pred) ^ seg_pred From 46f3074c7297429fc810a901c212678ebd3b0afa Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Mon, 23 May 2022 16:08:07 +0800 Subject: [PATCH 082/183] 4314 Enhance UnetResBlock (#4317) modify unetresblock Signed-off-by: Yiheng Wang --- monai/networks/blocks/dynunet_block.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/monai/networks/blocks/dynunet_block.py b/monai/networks/blocks/dynunet_block.py index 8b22cb16a9..0df4980dcd 100644 --- a/monai/networks/blocks/dynunet_block.py +++ b/monai/networks/blocks/dynunet_block.py @@ -62,17 +62,18 @@ def __init__( self.conv2 = get_conv_layer( spatial_dims, out_channels, out_channels, kernel_size=kernel_size, stride=1, dropout=dropout, conv_only=True ) - self.conv3 = get_conv_layer( - spatial_dims, in_channels, out_channels, kernel_size=1, stride=stride, dropout=dropout, conv_only=True - ) self.lrelu = get_act_layer(name=act_name) self.norm1 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) self.norm2 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) - self.norm3 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) self.downsample = in_channels != out_channels stride_np = np.atleast_1d(stride) if not np.all(stride_np == 1): self.downsample = True + if self.downsample: + self.conv3 = get_conv_layer( + spatial_dims, in_channels, out_channels, kernel_size=1, stride=stride, dropout=dropout, conv_only=True + ) + self.norm3 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) def forward(self, inp): residual = inp @@ -81,8 +82,9 @@ def forward(self, inp): out = self.lrelu(out) out = self.conv2(out) out = self.norm2(out) - if self.downsample: + if hasattr(self, "conv3"): residual = self.conv3(residual) + if hasattr(self, "norm3"): residual = self.norm3(residual) out += residual out = self.lrelu(out) From 07de215caf79dabec20ac53fb38d9f2df36f182a Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 23 May 2022 18:41:30 +0100 Subject: [PATCH 083/183] multiprocessing test metatensor (#4308) * fixes multiprocessing fixes unit test update reduce fixes mypy Signed-off-by: Wenqi Li * update based on comments Signed-off-by: Wenqi Li * remove unused Signed-off-by: Wenqi Li --- monai/data/__init__.py | 20 ++++++++++++++++++++ monai/data/meta_tensor.py | 12 ++++++++++-- tests/test_meta_tensor.py | 21 +++++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index f62dd93b08..8ab4742a75 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -9,6 +9,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import contextlib + from .box_utils import ( box_area, box_centers, @@ -103,3 +105,21 @@ ) from .wsi_datasets import PatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader + +with contextlib.suppress(BaseException): + from multiprocessing.reduction import ForkingPickler + + def _rebuild_meta(cls, storage, metadata): + storage_offset, size, stride, meta_obj = metadata + t = cls([], meta=meta_obj, dtype=storage.dtype, device=storage.device) + t.set_(storage._untyped() if hasattr(storage, "_untyped") else storage, storage_offset, size, stride) + return t + + def reduce_meta_tensor(meta_tensor): + storage = meta_tensor.storage() + if storage.is_cuda: + raise NotImplementedError("sharing CUDA metatensor across processes not implemented") + metadata = (meta_tensor.storage_offset(), meta_tensor.size(), meta_tensor.stride(), meta_tensor.meta) + return _rebuild_meta, (type(meta_tensor), storage, metadata) + + ForkingPickler.register(MetaTensor, reduce_meta_tensor) diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index 30187684b1..9ba86300a7 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -60,6 +60,7 @@ class MetaTensor(MetaObj, torch.Tensor): - Older versions of pytorch (<=1.8), `torch.jit.trace(net, im)` may not work if `im` is of type `MetaTensor`. This can be resolved with `torch.jit.trace(net, im.as_tensor())`. + - For pytorch < 1.8, sharing `MetaTensor` instances across processes may not be supported. - A warning will be raised if in the constructor `affine` is not `None` and `meta` already contains the key `affine`. - You can query whether the `MetaTensor` is a batch with the `is_batch` attribute. @@ -82,10 +83,17 @@ def __new__( *args, **kwargs, ) -> MetaTensor: - return torch.as_tensor(x, *args, **kwargs).as_subclass(cls) # type: ignore + _kwargs = {"device": kwargs.pop("device", None), "dtype": kwargs.pop("dtype", None)} if kwargs else {} + return torch.as_tensor(x, *args, **_kwargs).as_subclass(cls) # type: ignore def __init__( - self, x, affine: torch.Tensor | None = None, meta: dict | None = None, applied_operations: list | None = None + self, + x, + affine: torch.Tensor | None = None, + meta: dict | None = None, + applied_operations: list | None = None, + *_args, + **_kwargs, ) -> None: """ If `meta` is given, use it. Else, if `meta` exists in the input tensor, use it. diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index ab1a7ea3d1..ca5c4be33b 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import io import os import random import string @@ -16,9 +17,11 @@ import unittest import warnings from copy import deepcopy +from multiprocessing.reduction import ForkingPickler from typing import Optional, Union import torch +import torch.multiprocessing from parameterized import parameterized from monai.data import DataLoader, Dataset @@ -30,11 +33,11 @@ from monai.utils.module import pytorch_after from tests.utils import TEST_DEVICES, SkipIfBeforePyTorchVersion, assert_allclose, skip_if_no_cuda -DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32]] +DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32], [None]] TESTS = [] for _device in TEST_DEVICES: for _dtype in DTYPES: - TESTS.append((*_device, *_dtype)) + TESTS.append((*_device, *_dtype)) # type: ignore def rand_string(min_len=5, max_len=10): @@ -488,6 +491,20 @@ def test_construct_with_pre_applied_transforms(self): m = MetaTensor(im, applied_operations=data[PostFix.transforms(key)]) self.assertEqual(len(m.applied_operations), len(tr.transforms)) + @parameterized.expand(TESTS) + def test_multiprocessing(self, device=None, dtype=None): + """multiprocessing sharing with 'device' and 'dtype'""" + buf = io.BytesIO() + t = MetaTensor([0.0, 0.0], device=device, dtype=dtype) + if t.is_cuda: + with self.assertRaises(NotImplementedError): + ForkingPickler(buf).dump(t) + return + ForkingPickler(buf).dump(t) + obj = ForkingPickler.loads(buf.getvalue()) + self.assertIsInstance(obj, MetaTensor) + assert_allclose(obj.as_tensor(), t) + if __name__ == "__main__": unittest.main() From a1dc54a75a8773ce894a0d06ed059fe4e367db87 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 24 May 2022 04:52:13 -0400 Subject: [PATCH 084/183] add box transforms for affine and zoom (#4309) * add box transforms for affine and zoom Signed-off-by: Can Zhao * add missed alias Signed-off-by: Can Zhao * add in all Signed-off-by: Can Zhao * add comment Signed-off-by: Can Zhao * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * move inver affine from array to dict Signed-off-by: Can Zhao * only for debugging Signed-off-by: Can Zhao * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * debugged for inverse transform, add unit test Signed-off-by: Can Zhao * debugged Signed-off-by: Can Zhao * debugged inverse transform Signed-off-by: Can Zhao * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reformat Signed-off-by: Can Zhao * typo Signed-off-by: Can Zhao Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/apps/detection/transforms/array.py | 224 +++++++-- monai/apps/detection/transforms/dictionary.py | 427 +++++++++++++++++- tests/test_box_transform.py | 105 ++++- 3 files changed, 688 insertions(+), 68 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index c3ea959eb8..901ed60615 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -13,20 +13,58 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ -from typing import Type, Union +from typing import Sequence, Type, Union + +import numpy as np from monai.config.type_definitions import NdarrayOrTensor -from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode +from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode, get_spatial_dims from monai.transforms.transform import Transform +from monai.utils import ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option from monai.utils.enums import TransformBackends -__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode"] +from .box_ops import apply_affine_to_boxes, resize_boxes, zoom_boxes + +__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode", "AffineBox", "ZoomBox", "ResizeBox"] class ConvertBoxMode(Transform): """ This transform converts the boxes in src_mode to the dst_mode. + Args: + src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. + + Note: + ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + also represented as "xyxy" for 2D and "xyzxyz" for 3D. + + src_mode and dst_mode can be: + #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example, + - "xyxy": boxes has format [xmin, ymin, xmax, ymax] + - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax] + - "xxyy": boxes has format [xmin, xmax, ymin, ymax] + - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax] + - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax] + - "xywh": boxes has format [xmin, ymin, xsize, ysize] + - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize] + - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize] + - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize] + #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode: equivalent to "xywh" or "xyzwhd" + - CenterSizeMode: equivalent to "ccwh" or "cccwhd" + #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, + - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz" + - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz" + - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz" + - CornerSizeMode(): equivalent to "xywh" or "xyzwhd" + - CenterSizeMode(): equivalent to "ccwh" or "cccwhd" + #. None: will assume mode is ``StandardMode()`` + Example: .. code-block:: python @@ -43,41 +81,6 @@ def __init__( src_mode: Union[str, BoxMode, Type[BoxMode], None] = None, dst_mode: Union[str, BoxMode, Type[BoxMode], None] = None, ) -> None: - """ - - Args: - src_mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. - dst_mode: target box mode. If it is not given, this func will assume it is ``StandardMode()``. - - Note: - ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, - also represented as "xyxy" for 2D and "xyzxyz" for 3D. - - src_mode and dst_mode can be: - #. str: choose from :class:`~monai.utils.enums.BoxModeName`, for example, - - "xyxy": boxes has format [xmin, ymin, xmax, ymax] - - "xyzxyz": boxes has format [xmin, ymin, zmin, xmax, ymax, zmax] - - "xxyy": boxes has format [xmin, xmax, ymin, ymax] - - "xxyyzz": boxes has format [xmin, xmax, ymin, ymax, zmin, zmax] - - "xyxyzz": boxes has format [xmin, ymin, xmax, ymax, zmin, zmax] - - "xywh": boxes has format [xmin, ymin, xsize, ysize] - - "xyzwhd": boxes has format [xmin, ymin, zmin, xsize, ysize, zsize] - - "ccwh": boxes has format [xcenter, ycenter, xsize, ysize] - - "cccwhd": boxes has format [xcenter, ycenter, zcenter, xsize, ysize, zsize] - #. BoxMode class: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, - - CornerCornerModeTypeA: equivalent to "xyxy" or "xyzxyz" - - CornerCornerModeTypeB: equivalent to "xxyy" or "xxyyzz" - - CornerCornerModeTypeC: equivalent to "xyxy" or "xyxyzz" - - CornerSizeMode: equivalent to "xywh" or "xyzwhd" - - CenterSizeMode: equivalent to "ccwh" or "cccwhd" - #. BoxMode object: choose from the subclasses of :class:`~monai.data.box_utils.BoxMode`, for example, - - CornerCornerModeTypeA(): equivalent to "xyxy" or "xyzxyz" - - CornerCornerModeTypeB(): equivalent to "xxyy" or "xxyyzz" - - CornerCornerModeTypeC(): equivalent to "xyxy" or "xyxyzz" - - CornerSizeMode(): equivalent to "xywh" or "xyzwhd" - - CenterSizeMode(): equivalent to "ccwh" or "cccwhd" - #. None: will assume mode is ``StandardMode()`` - """ self.src_mode = src_mode self.dst_mode = dst_mode @@ -85,6 +88,9 @@ def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: """ Converts the boxes in src_mode to the dst_mode. + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + Returns: bounding boxes with target mode, with same data type as ``boxes``, does not share memory with ``boxes`` """ @@ -97,6 +103,10 @@ class ConvertBoxToStandardMode(Transform): Standard mode is "xyxy" or "xyzxyz", representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + Args: + mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. + It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . + Example: .. code-block:: python @@ -109,11 +119,6 @@ class ConvertBoxToStandardMode(Transform): backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__(self, mode: Union[str, BoxMode, Type[BoxMode], None] = None) -> None: - """ - Args: - mode: source box mode. If it is not given, this func will assume it is ``StandardMode()``. - It follows the same format with ``src_mode`` in :class:`~monai.apps.detection.transforms.array.ConvertBoxMode` . - """ self.mode = mode def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: @@ -122,7 +127,140 @@ def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: Standard mode is "xyxy" or "xyzxyz", representing box format of [xmin, ymin, xmax, ymax] or [xmin, ymin, zmin, xmax, ymax, zmax]. + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + Returns: bounding boxes with standard mode, with same data type as ``boxes``, does not share memory with ``boxes`` """ return convert_box_to_standard_mode(boxes, mode=self.mode) + + +class AffineBox(Transform): + """ + Applys affine matrix to the boxes + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, boxes: NdarrayOrTensor, affine: Union[NdarrayOrTensor, None]) -> NdarrayOrTensor: # type: ignore + """ + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + affine: affine matric to be applied to the box coordinate + """ + if affine is None: + return boxes + + return apply_affine_to_boxes(boxes, affine=affine) + + +class ZoomBox(Transform): + """ + Zooms an ND Box with same padding or slicing setting with Zoom(). + + Args: + zoom: The zoom factor along the spatial axes. + If a float, zoom is the same for each spatial axis. + If a sequence, zoom should contain one value for each spatial axis. + keep_size: Should keep original size (padding/slicing if needed), default is True. + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, zoom: Union[Sequence[float], float], keep_size: bool = False, **kwargs) -> None: + self.zoom = zoom + self.keep_size = keep_size + self.kwargs = kwargs + + def __call__( + self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int, None] = None + ) -> NdarrayOrTensor: # type: ignore + """ + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + src_spatial_size: original image spatial size before zooming, used only when keep_size=True. + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + self._zoom = ensure_tuple_rep(self.zoom, spatial_dims) # match the spatial image dim + + if not self.keep_size: + return zoom_boxes(boxes, self._zoom) + + if src_spatial_size is None: + raise ValueError("keep_size=True, src_spatial_size must be provided.") + + src_spatial_size = ensure_tuple_rep(src_spatial_size, spatial_dims) + dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self._zoom, src_spatial_size)] + self._zoom = tuple(ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)) + zoomed_boxes = zoom_boxes(boxes, self._zoom) + + # See also keep_size in monai.transforms.spatial.array.Zoom() + if not np.allclose(np.array(src_spatial_size), np.array(dst_spatial_size)): + for axis, (od, zd) in enumerate(zip(src_spatial_size, dst_spatial_size)): + diff = od - zd + half = abs(diff) // 2 + if diff > 0: # need padding (half, diff - half) + zoomed_boxes[:, axis] = zoomed_boxes[:, axis] + half + zoomed_boxes[:, axis + spatial_dims] = zoomed_boxes[:, axis + spatial_dims] + half + elif diff < 0: # need slicing (half, half + od) + zoomed_boxes[:, axis] = zoomed_boxes[:, axis] - half + zoomed_boxes[:, axis + spatial_dims] = zoomed_boxes[:, axis + spatial_dims] - half + return zoomed_boxes + + +class ResizeBox(Transform): + """ + Resize the input boxes when the corresponding image is + resized to given spatial size (with scaling, not cropping/padding). + + Args: + spatial_size: expected shape of spatial dimensions after resize operation. + if some components of the `spatial_size` are non-positive values, the transform will use the + corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted + to `(32, 64)` if the second spatial dimension size of img is `64`. + size_mode: should be "all" or "longest", if "all", will use `spatial_size` for all the spatial dims, + if "longest", rescale the image so that only the longest side is equal to specified `spatial_size`, + which must be an int number in this case, keeping the aspect ratio of the initial image, refer to: + https://albumentations.ai/docs/api_reference/augmentations/geometric/resize/ + #albumentations.augmentations.geometric.resize.LongestMaxSize. + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, spatial_size: Union[Sequence[int], int], size_mode: str = "all", **kwargs) -> None: + self.size_mode = look_up_option(size_mode, ["all", "longest"]) + self.spatial_size = spatial_size + + def __call__(self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int]) -> NdarrayOrTensor: # type: ignore + """ + Args: + boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + src_spatial_size: original image spatial size before resizing. + + Raises: + ValueError: When ``self.spatial_size`` length is less than ``boxes`` spatial dimensions. + """ + input_ndim = get_spatial_dims(boxes=boxes) # spatial ndim + src_spatial_size_ = ensure_tuple_rep(src_spatial_size, input_ndim) + + if self.size_mode == "all": + # spatial_size must be a Sequence if size_mode is 'all' + output_ndim = len(ensure_tuple(self.spatial_size)) + if output_ndim != input_ndim: + raise ValueError( + "len(spatial_size) must be greater or equal to img spatial dimensions, " + f"got spatial_size={output_ndim} img={input_ndim}." + ) + spatial_size_ = fall_back_tuple(self.spatial_size, src_spatial_size_) + else: # for the "longest" mode + if not isinstance(self.spatial_size, int): + raise ValueError("spatial_size must be an int number if size_mode is 'longest'.") + scale = self.spatial_size / max(src_spatial_size_) + spatial_size_ = tuple(int(round(s * scale)) for s in src_spatial_size_) + + return resize_boxes(boxes, src_spatial_size_, spatial_size_) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index a0b4c76224..41acc34149 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -16,14 +16,23 @@ """ from copy import deepcopy -from typing import Dict, Hashable, Mapping, Type, Union +from enum import Enum +from typing import Dict, Hashable, List, Mapping, Optional, Sequence, Type, Union -from monai.apps.detection.transforms.array import ConvertBoxMode, ConvertBoxToStandardMode +import numpy as np +import torch + +from monai.apps.detection.transforms.array import AffineBox, ConvertBoxMode, ConvertBoxToStandardMode, ZoomBox from monai.config import KeysCollection from monai.config.type_definitions import NdarrayOrTensor from monai.data.box_utils import BoxMode +from monai.data.utils import orientation_ras_lps +from monai.transforms import RandZoom, SpatialPad, Zoom from monai.transforms.inverse import InvertibleTransform -from monai.transforms.transform import MapTransform +from monai.transforms.transform import MapTransform, RandomizableTransform +from monai.utils import InterpolateMode, NumpyPadMode, PytorchPadMode, ensure_tuple, ensure_tuple_rep +from monai.utils.enums import PostFix, TraceKeys +from monai.utils.type_conversion import convert_data_type __all__ = [ "ConvertBoxModed", @@ -32,8 +41,21 @@ "ConvertBoxToStandardModed", "ConvertBoxToStandardModeD", "ConvertBoxToStandardModeDict", + "AffineBoxToImageCoordinated", + "AffineBoxToImageCoordinateD", + "AffineBoxToImageCoordinateDict", + "ZoomBoxd", + "ZoomBoxD", + "ZoomBoxDict", + "RandZoomBoxd", + "RandZoomBoxD", + "RandZoomBoxDict", ] +DEFAULT_POST_FIX = PostFix.meta() +InterpolateModeSequence = Union[Sequence[Union[InterpolateMode, str]], InterpolateMode, str] +PadModeSequence = Union[Sequence[Union[NumpyPadMode, PytorchPadMode, str]], NumpyPadMode, PytorchPadMode, str] + class ConvertBoxModed(MapTransform, InvertibleTransform): """ @@ -70,21 +92,22 @@ def __init__( """ super().__init__(box_keys, allow_missing_keys) self.converter = ConvertBoxMode(src_mode=src_mode, dst_mode=dst_mode) - self.inverse_converter = ConvertBoxMode(src_mode=dst_mode, dst_mode=src_mode) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): - self.push_transform(d, key) + self.push_transform(d, key, extra_info={"src": self.converter.src_mode, "dst": self.converter.dst_mode}) d[key] = self.converter(d[key]) return d def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - _ = self.get_most_recent_transform(d, key) + tr = self.get_most_recent_transform(d, key) + src_mode, dst_mode = tr[TraceKeys.EXTRA_INFO]["src"], tr[TraceKeys.EXTRA_INFO]["dst"] + inverse_converter = ConvertBoxMode(src_mode=dst_mode, dst_mode=src_mode) # Inverse is same as forward - d[key] = self.inverse_converter(d[key]) + d[key] = inverse_converter(d[key]) # Remove the applied transform self.pop_transform(d, key) return d @@ -124,25 +147,407 @@ def __init__( """ super().__init__(box_keys, allow_missing_keys) self.converter = ConvertBoxToStandardMode(mode=mode) - self.inverse_converter = ConvertBoxMode(src_mode=None, dst_mode=mode) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): - self.push_transform(d, key) + self.push_transform(d, key, extra_info={"mode": self.converter.mode}) d[key] = self.converter(d[key]) return d def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - _ = self.get_most_recent_transform(d, key) + tr = self.get_most_recent_transform(d, key) + original_mode = tr[TraceKeys.EXTRA_INFO]["mode"] + inverse_converter = ConvertBoxMode(src_mode=None, dst_mode=original_mode) # Inverse is same as forward - d[key] = self.inverse_converter(d[key]) + d[key] = inverse_converter(d[key]) # Remove the applied transform self.pop_transform(d, key) return d +class AffineBoxToImageCoordinated(MapTransform, InvertibleTransform): + """ + Dictionary-based transfrom that converts box in world coordinate to image coordinate. + + Args: + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: The single key that represents the reference image to which ``box_keys`` are attached. + remove_empty: whether to remove the boxes that are actually empty + allow_missing_keys: don't raise exception if key is missing. + image_meta_key: explicitly indicate the key of the corresponding meta data dictionary. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + it is a string, map to the `box_ref_image_key`. + if None, will try to construct meta_keys by `box_ref_image_key_{meta_key_postfix}`. + image_meta_key_postfix: if image_meta_keys=None, use `box_ref_image_key_{postfix}` to fetch the meta data according + to the key data, default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + affine_lps_to_ras: default ``False``. Yet if 1) the image is read by ITKReader, + and 2) the ITKReader has affine_lps_to_ras=True, and 3) the box is in world coordinate, + then set ``affine_lps_to_ras=True``. + """ + + def __init__( + self, + box_keys: KeysCollection, + box_ref_image_keys: str, + allow_missing_keys: bool = False, + image_meta_key: Union[str, None] = None, + image_meta_key_postfix: Union[str, None] = DEFAULT_POST_FIX, + affine_lps_to_ras=False, + ) -> None: + super().__init__(box_keys, allow_missing_keys) + box_ref_image_keys_tuple = ensure_tuple(box_ref_image_keys) + if len(box_ref_image_keys_tuple) > 1: + raise ValueError( + "Please provide a single key for box_ref_image_keys.\ + All boxes of box_keys are attached to box_ref_image_keys." + ) + self.image_meta_key = image_meta_key or f"{box_ref_image_keys}_{image_meta_key_postfix}" + self.converter_to_image_coordinate = AffineBox() + self.affine_lps_to_ras = affine_lps_to_ras + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + meta_key = self.image_meta_key + # extract affine matrix from meta_data + if meta_key not in d: + raise ValueError(f"{meta_key} is not found. Please check whether it is the correct the image meta key.") + if "affine" not in d[meta_key]: + raise ValueError( + f"'affine' is not found in {meta_key}. \ + Please check whether it is the correct the image meta key." + ) + affine: NdarrayOrTensor = d[meta_key]["affine"] # type: ignore + if self.affine_lps_to_ras: # RAS affine + affine = orientation_ras_lps(affine) + + # when convert boxes from world coordinate to image coordinate, + # we apply inverse affine transform + affine_t, *_ = convert_data_type(affine, torch.Tensor) + inv_affine_t = torch.inverse(affine_t) + + for key in self.key_iterator(d): + self.push_transform(d, key, extra_info={"affine": affine}) + d[key] = self.converter_to_image_coordinate(d[key], affine=inv_affine_t) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + affine = transform["extra_info"]["affine"] + d[key] = AffineBox()(d[key], affine=affine) + self.pop_transform(d, key) + return d + + +class ZoomBoxd(MapTransform, InvertibleTransform): + """ + Zooms input arrays with given probability within given zoom range. + + Args: + image_keys: Keys to pick image data for transformation. + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + zoom: The zoom factor along the spatial axes. + If a float, zoom is the same for each spatial axis. + If a sequence, zoom should contain one value for each spatial axis. + mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + The interpolation mode. Defaults to ``"area"``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + It also can be a sequence of string, each element corresponds to a key in ``keys``. + padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. + The mode to pad data after zooming. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + align_corners: This only has an effect when mode is + 'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. + keep_size: Should keep original size (pad if needed), default is True. + allow_missing_keys: don't raise exception if key is missing. + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. + """ + + def __init__( + self, + image_keys: KeysCollection, + box_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + zoom: Union[Sequence[float], float], + mode: InterpolateModeSequence = InterpolateMode.AREA, + padding_mode: PadModeSequence = NumpyPadMode.EDGE, + align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, + keep_size: bool = True, + allow_missing_keys: bool = False, + **kwargs, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + self.box_keys = ensure_tuple(box_keys) + super().__init__(self.image_keys + self.box_keys, allow_missing_keys) + + self.mode = ensure_tuple_rep(mode, len(self.image_keys)) + self.padding_mode = ensure_tuple_rep(padding_mode, len(self.image_keys)) + self.align_corners = ensure_tuple_rep(align_corners, len(self.image_keys)) + self.zoomer = Zoom(zoom=zoom, keep_size=keep_size, **kwargs) + + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + self.keep_size = keep_size + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + # zoom box + for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): + src_spatial_size = d[box_ref_image_key].shape[1:] + dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self.zoomer.zoom, src_spatial_size)] # type: ignore + self.zoomer.zoom = [ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)] + self.push_transform( + d, + box_key, + extra_info={"zoom": self.zoomer.zoom, "src_spatial_size": src_spatial_size, "type": "box_key"}, + ) + d[box_key] = ZoomBox(zoom=self.zoomer.zoom, keep_size=self.keep_size)( + d[box_key], src_spatial_size=src_spatial_size + ) + + # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd + for key, mode, padding_mode, align_corners in zip( + self.image_keys, self.mode, self.padding_mode, self.align_corners + ): + self.push_transform( + d, + key, + extra_info={ + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, + "original_shape": d[key].shape[1:], + "type": "image_key", + }, + ) + d[key] = self.zoomer(d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners) + + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + key_type = transform[TraceKeys.EXTRA_INFO]["type"] + # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd + if key_type == "image_key": + # Create inverse transform + zoom = np.array(self.zoomer.zoom) + inverse_transform = Zoom(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size) + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] + # Apply inverse + d[key] = inverse_transform( + d[key], + mode=mode, + padding_mode=padding_mode, + align_corners=None if align_corners == TraceKeys.NONE else align_corners, + ) + # Size might be out by 1 voxel so pad + d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + # zoom boxes + if key_type == "box_key": + zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"]) + src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"] + box_inverse_transform = ZoomBox(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size) + d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size) + # Remove the applied transform + self.pop_transform(d, key) + + return d + + +class RandZoomBoxd(RandomizableTransform, MapTransform, InvertibleTransform): + """ + Randomly zooms input arrays with given probability within given zoom range. + + Args: + image_keys: Keys to pick image data for transformation. + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + prob: Probability of zooming. + min_zoom: Min zoom factor. Can be float or sequence same size as image. + If a float, select a random factor from `[min_zoom, max_zoom]` then apply to all spatial dims + to keep the original spatial shape ratio. + If a sequence, min_zoom should contain one value for each spatial axis. + If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio. + max_zoom: Max zoom factor. Can be float or sequence same size as image. + If a float, select a random factor from `[min_zoom, max_zoom]` then apply to all spatial dims + to keep the original spatial shape ratio. + If a sequence, max_zoom should contain one value for each spatial axis. + If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio. + mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + The interpolation mode. Defaults to ``"area"``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + It also can be a sequence of string, each element corresponds to a key in ``keys``. + padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. + The mode to pad data after zooming. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + align_corners: This only has an effect when mode is + 'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. + keep_size: Should keep original size (pad if needed), default is True. + allow_missing_keys: don't raise exception if key is missing. + kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ + + backend = RandZoom.backend + + def __init__( + self, + image_keys: KeysCollection, + box_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + prob: float = 0.1, + min_zoom: Union[Sequence[float], float] = 0.9, + max_zoom: Union[Sequence[float], float] = 1.1, + mode: InterpolateModeSequence = InterpolateMode.AREA, + padding_mode: PadModeSequence = NumpyPadMode.EDGE, + align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, + keep_size: bool = True, + allow_missing_keys: bool = False, + **kwargs, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + self.box_keys = ensure_tuple(box_keys) + MapTransform.__init__(self, self.image_keys + self.box_keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + + self.rand_zoom = RandZoom(prob=1.0, min_zoom=min_zoom, max_zoom=max_zoom, keep_size=keep_size, **kwargs) + self.mode = ensure_tuple_rep(mode, len(self.image_keys)) + self.padding_mode = ensure_tuple_rep(padding_mode, len(self.image_keys)) + self.align_corners = ensure_tuple_rep(align_corners, len(self.image_keys)) + + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + self.keep_size = keep_size + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandZoomBoxd": + super().set_random_state(seed, state) + self.rand_zoom.set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + first_key: Union[Hashable, List] = self.first_key(d) + if first_key == []: + return d + + self.randomize(None) + + # all the keys share the same random zoom factor + self.rand_zoom.randomize(d[first_key]) # type: ignore + + # zoom box + for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): + if self._do_transform: + src_spatial_size = d[box_ref_image_key].shape[1:] + dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self.rand_zoom._zoom, src_spatial_size)] + self.rand_zoom._zoom = [ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)] + + self.push_transform( + d, + box_key, + extra_info={"zoom": self.rand_zoom._zoom, "src_spatial_size": src_spatial_size, "type": "box_key"}, + ) + d[box_key] = ZoomBox(zoom=self.rand_zoom._zoom, keep_size=self.keep_size)( + d[box_key], src_spatial_size=src_spatial_size + ) + + # zoom image, copied from monai.transforms.spatial.dictionary.RandZoomd + for key, mode, padding_mode, align_corners in zip( + self.image_keys, self.mode, self.padding_mode, self.align_corners + ): + if self._do_transform: + self.push_transform( + d, + key, + extra_info={ + "zoom": self.rand_zoom._zoom, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, + "original_shape": d[key].shape[1:], + "type": "image_key", + }, + ) + d[key] = self.rand_zoom( + d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, randomize=False + ) + + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + + # zoom image, copied from monai.transforms.spatial.dictionary.RandZoomd + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + key_type = transform[TraceKeys.EXTRA_INFO]["type"] + # Check if random transform was actually performed (based on `prob`) + if transform[TraceKeys.DO_TRANSFORM]: + # Create inverse transform + + # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd + if key_type == "image_key": + zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"]) + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] + inverse_transform = Zoom(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size) + d[key] = inverse_transform( + d[key], + mode=mode, + padding_mode=padding_mode, + align_corners=None if align_corners == TraceKeys.NONE else align_corners, + ) + # Size might be out by 1 voxel so pad + d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + # zoom boxes + if key_type == "box_key": + # Create inverse transform + zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"]) + src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"] + box_inverse_transform = ZoomBox(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size) + d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size) + # Remove the applied transform + self.pop_transform(d, key) + return d + + ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed +ZoomBoxD = ZoomBoxDict = ZoomBoxd +RandZoomBoxD = RandZoomBoxDict = RandZoomBoxd +AffineBoxToImageCoordinateD = AffineBoxToImageCoordinateDict = AffineBoxToImageCoordinated diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index a679dd3c95..10e311971e 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -12,24 +12,32 @@ import unittest import numpy as np +import torch from parameterized import parameterized -from monai.apps.detection.transforms.dictionary import ConvertBoxToStandardModed -from monai.transforms import Invertd +from monai.apps.detection.transforms.dictionary import ( + AffineBoxToImageCoordinated, + ConvertBoxModed, + RandZoomBoxd, + ZoomBoxd, +) +from monai.transforms import CastToTyped, Invertd from tests.utils import TEST_NDARRAYS, assert_allclose TESTS = [] -boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]] -image_size = [1, 5, 6, 4] +boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]] +image_size = [1, 4, 6, 4] image = np.zeros(image_size) for p in TEST_NDARRAYS: TESTS.append( [ - {"box_keys": "boxes", "mode": "xyzwhd"}, + {"box_keys": "boxes", "dst_mode": "xyzwhd"}, {"boxes": p(boxes), "image": p(image)}, - p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]), + p([[0, 0, 0, 0, 0, 0], [0, 3, 0, 1, 9, 4.5], [0, 3, 1.5, 1, 9, 6]]), + p([[1, -6, -1, 1, -6, -1], [1, -3, -1, 2, 3, 3.5], [1, -3, 0.5, 2, 3, 5]]), p([[0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), p([[0, 1, 1, 2, 3, 4], [0, 1, 0, 2, 3, 3]]), ] @@ -38,14 +46,83 @@ class TestBoxTransform(unittest.TestCase): @parameterized.expand(TESTS) - def test_value(self, keys, data, expected_standard_result, expected_clip_result, expected_flip_result): - transform_convert_mode = ConvertBoxToStandardModed(**keys) - result = transform_convert_mode(data) - assert_allclose(result["boxes"], expected_standard_result, type_test=True, device_test=True, atol=0.0) - - invert_transform_convert_mode = Invertd(keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"]) - data_back = invert_transform_convert_mode(result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.0) + def test_value( + self, + keys, + data, + expected_convert_result, + expected_zoom_result, + expected_zoom_keepsize_result, + expected_clip_result, + expected_flip_result, + ): + test_dtype = [torch.float32] + for dtype in test_dtype: + data = CastToTyped(keys=["image", "boxes"], dtype=dtype)(data) + # test ConvertBoxToStandardModed + transform_convert_mode = ConvertBoxModed(**keys) + convert_result = transform_convert_mode(data) + assert_allclose( + convert_result["boxes"], expected_convert_result, type_test=True, device_test=True, atol=1e-3 + ) + + invert_transform_convert_mode = Invertd( + keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"] + ) + data_back = invert_transform_convert_mode(convert_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + + # test ZoomBoxd + transform_zoom = ZoomBoxd( + image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=False + ) + zoom_result = transform_zoom(data) + assert_allclose(zoom_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=1e-3) + invert_transform_zoom = Invertd( + keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"] + ) + data_back = invert_transform_zoom(zoom_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + + transform_zoom = ZoomBoxd( + image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=True + ) + zoom_result = transform_zoom(data) + assert_allclose( + zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3 + ) + + # test ZoomBoxd + transform_zoom = RandZoomBoxd( + image_keys="image", + box_keys="boxes", + box_ref_image_keys="image", + prob=1.0, + min_zoom=(0.3,) * 3, + max_zoom=(3.0,) * 3, + keep_size=False, + ) + zoom_result = transform_zoom(data) + invert_transform_zoom = Invertd( + keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"] + ) + data_back = invert_transform_zoom(zoom_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) + assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + + # test AffineBoxToImageCoordinated + transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image") + with self.assertRaises(Exception) as context: + transform_affine(data) + self.assertTrue("Please check whether it is the correct the image meta key." in str(context.exception)) + + data["image_meta_dict"] = {"affine": torch.diag(1.0 / torch.Tensor([0.5, 3, 1.5, 1]))} + affine_result = transform_affine(data) + assert_allclose(affine_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=0.01) + invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"]) + data_back = invert_transform_affine(affine_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) if __name__ == "__main__": From 2bd56dc7b35422a60fbd39bed38e094c423a986b Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 24 May 2022 11:47:53 +0100 Subject: [PATCH 085/183] revise dice metric docstring, fixes testing data (#4329) * improve test cases Signed-off-by: Wenqi Li * revise docstring Signed-off-by: Wenqi Li --- monai/metrics/confusion_matrix.py | 2 +- monai/metrics/meandice.py | 8 ++++---- tests/testing_data/CT_DICOM/{17106 => 7106} | Bin 3810 -> 3808 bytes tests/testing_data/CT_DICOM/{17136 => 7136} | Bin 3812 -> 3810 bytes tests/testing_data/CT_DICOM/{17166 => 7166} | Bin 3812 -> 3810 bytes tests/testing_data/CT_DICOM/{17196 => 7196} | Bin 3812 -> 3810 bytes 6 files changed, 5 insertions(+), 5 deletions(-) rename tests/testing_data/CT_DICOM/{17106 => 7106} (92%) rename tests/testing_data/CT_DICOM/{17136 => 7136} (92%) rename tests/testing_data/CT_DICOM/{17166 => 7166} (92%) rename tests/testing_data/CT_DICOM/{17196 => 7196} (92%) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 9d5af51a58..38834ee8cf 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -30,7 +30,7 @@ class ConfusionMatrixMetric(CumulativeIterationMetric): The `include_background` parameter can be set to ``False`` for an instance to exclude the first category (channel index 0) which is by convention assumed to be background. If the non-background segmentations are small compared to the total image size they can get overwhelmed by the signal from the - background so excluding it in such cases helps convergence. + background. Args: include_background: whether to skip metric computation on the first channel of diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index b64c556d05..c450e17c5f 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -22,14 +22,14 @@ class DiceMetric(CumulativeIterationMetric): """ - Compute average Dice loss between two tensors. It can support both multi-classes and multi-labels tasks. + Compute average Dice score between two tensors. It can support both multi-classes and multi-labels tasks. Input `y_pred` is compared with ground truth `y`. `y_preds` is expected to have binarized predictions and `y` should be in one-hot format. You can use suitable transforms in ``monai.transforms.post`` first to achieve binarized values. - The `include_background` parameter can be set to ``False`` for an instance of DiceLoss to exclude + The `include_background` parameter can be set to ``False`` to exclude the first category (channel index 0) which is by convention assumed to be background. If the non-background segmentations are small compared to the total image size they can get overwhelmed by the signal from the - background so excluding it in such cases helps convergence. + background. `y_preds` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]). Args: @@ -80,7 +80,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor warnings.warn("y should be a binarized tensor.") dims = y_pred.ndimension() if dims < 3: - raise ValueError("y_pred should have at least three dimensions.") + raise ValueError(f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {dims}.") # compute dice (BxC) for each channel for each batch return compute_meandice( y_pred=y_pred, y=y, include_background=self.include_background, ignore_empty=self.ignore_empty diff --git a/tests/testing_data/CT_DICOM/17106 b/tests/testing_data/CT_DICOM/7106 similarity index 92% rename from tests/testing_data/CT_DICOM/17106 rename to tests/testing_data/CT_DICOM/7106 index 34c96591477a9aa24ed8cda79931908aaad240a7..727bea124b15505483d036c4f57e648ff62ec16c 100644 GIT binary patch delta 26 ecmaDP`#^R>3o|bdgRj4zqh}nL+&r1tpBn&pC}3o}0-gG+vDoMTaPMrKlCPRizq%>LW}l_v?} diff --git a/tests/testing_data/CT_DICOM/17136 b/tests/testing_data/CT_DICOM/7136 similarity index 92% rename from tests/testing_data/CT_DICOM/17136 rename to tests/testing_data/CT_DICOM/7136 index 81949d10775852eb1aa25a2b00f0d5ff3b8dc6cc..ed1222f80d06afcf018b2027f9e0d87837fdfa6d 100644 GIT binary patch delta 26 ecmaDN`$%>}3o|bdgRj4zqh}nL+&q~%kQ)GakO({g delta 28 jcmaDP`$Tp_3o}0-gG+vDoMTaPMrKlCPRizq%z@khm6r+V diff --git a/tests/testing_data/CT_DICOM/17166 b/tests/testing_data/CT_DICOM/7166 similarity index 92% rename from tests/testing_data/CT_DICOM/17166 rename to tests/testing_data/CT_DICOM/7166 index e9cbc38b01c742fc5b2646e55f8a4252c723a72a..edadf552b2d70b646f61a287103a78239d45bebb 100644 GIT binary patch delta 26 ecmaDN`$%>}3o|bdgRj4zqh}nL+&q~%kQ)GakO({g delta 28 jcmaDP`$Tp_3o}0-gG+vDoMTaPMrKlCPRizq%z@khm6r+V diff --git a/tests/testing_data/CT_DICOM/17196 b/tests/testing_data/CT_DICOM/7196 similarity index 92% rename from tests/testing_data/CT_DICOM/17196 rename to tests/testing_data/CT_DICOM/7196 index cc579d5425975c388bb6aab998508ba836984409..d4c0f0795e3044e68891e83e29b14aafffc4a293 100644 GIT binary patch delta 26 ecmaDN`$%>}3o|bdgRj4zqh}nL+&q~%kQ)GakO({g delta 28 jcmaDP`$Tp_3o}0-gG+vDoMTaPMrKlCPRizq%z@khm6r+V From 6c8e4ab163cf1ab99d2156541516879de2f941ee Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 24 May 2022 14:10:31 -0400 Subject: [PATCH 086/183] Add spatial crop and clip box to Box utils (#4316) * add clip box to image into box_utils Signed-off-by: Can Zhao --- docs/source/data.rst | 5 ++ monai/apps/detection/transforms/array.py | 4 +- monai/data/box_utils.py | 88 ++++++++++++++++++++++-- tests/test_box_utils.py | 11 +++ tests/test_meta_tensor.py | 2 +- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 00ca9944cb..6158a564cf 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -343,3 +343,8 @@ Box center .. autofunction:: monai.data.box_utils.box_centers .. autofunction:: monai.data.box_utils.centers_in_boxes .. autofunction:: monai.data.box_utils.boxes_center_distance + +Spatial crop box +~~~~~~~~~~~~~~~~ +.. autofunction:: monai.data.box_utils.spatial_crop_boxes +.. autofunction:: monai.data.box_utils.clip_boxes_to_image diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 901ed60615..ee7b0dac0a 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -236,7 +236,9 @@ def __init__(self, spatial_size: Union[Sequence[int], int], size_mode: str = "al self.size_mode = look_up_option(size_mode, ["all", "longest"]) self.spatial_size = spatial_size - def __call__(self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int]) -> NdarrayOrTensor: # type: ignore + def __call__( # type: ignore + self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int] # type: ignore + ) -> NdarrayOrTensor: """ Args: boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 37fc51c33d..8af21a31e9 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -806,9 +806,13 @@ def box_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTenso """ Compute the generalized intersection over union (GIoU) of two sets of boxes. The two inputs can have different shapes and the func return an NxM matrix, - (in contrary to ``box_pair_giou``, which requires the inputs to have the same + (in contrary to :func:`~monai.data.box_utils.box_pair_giou` , which requires the inputs to have the same shape and returns ``N`` values). + Args: + boxes1: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + boxes2: bounding boxes, Mx4 or Mx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + Returns: GIoU, with size of (N,M) and same data type as ``boxes1`` @@ -860,11 +864,13 @@ def box_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTenso def box_pair_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTensor: """ Compute the generalized intersection over union (GIoU) of a pair of boxes. - The two inputs should have the same shape. + The two inputs should have the same shape and the func return an (N,) array, + (in contrary to :func:`~monai.data.box_utils.box_giou` , which does not require the inputs to have the same + shape and returns ``NxM`` matrix). Args: - boxes1: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be StandardMode - boxes2: bounding boxes, same shape with boxes1. The box mode is assumed to be StandardMode + boxes1: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + boxes2: bounding boxes, same shape with boxes1. The box mode is assumed to be ``StandardMode`` Returns: paired GIoU, with size of (N,) and same data type as ``boxes1`` @@ -932,3 +938,77 @@ def box_pair_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOr # convert tensor back to numpy if needed giou, *_ = convert_to_dst_type(src=giou_t, dst=boxes1) return giou + + +def spatial_crop_boxes( + boxes: NdarrayOrTensor, + roi_start: Union[Sequence[int], NdarrayOrTensor], + roi_end: Union[Sequence[int], NdarrayOrTensor], + remove_empty: bool = True, +) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: + """ + This function generate the new boxes when the corresponding image is cropped to the given ROI. + When ``remove_empty=True``, it makes sure the bounding boxes are within the new cropped image. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + roi_start: voxel coordinates for start of the crop ROI, negative values allowed. + roi_end: voxel coordinates for end of the crop ROI, negative values allowed. + remove_empty: whether to remove the boxes that are actually empty + + Returns: + - cropped boxes, boxes[keep], does not share memory with original boxes + - ``keep``, it indicates whether each box in ``boxes`` are kept when ``remove_empty=True``. + """ + + roi_start_torch, *_ = convert_data_type( + data=roi_start, output_type=torch.Tensor, dtype=torch.int16, wrap_sequence=True + ) + roi_end_torch, *_ = convert_to_dst_type(src=roi_end, dst=roi_start_torch, wrap_sequence=True) + roi_end_torch = torch.maximum(roi_end_torch, roi_start_torch) + + # convert numpy to tensor if needed + boxes_t, *_ = convert_data_type(deepcopy(boxes), torch.Tensor) + + # convert to float32 since torch.clamp_ does not support float16 + compute_dtype = torch.float32 + boxes_t = boxes_t.to(dtype=compute_dtype) + + # makes sure the bounding boxes are within the patch + spatial_dims = get_spatial_dims(boxes=boxes, spatial_size=roi_end) + for axis in range(0, spatial_dims): + boxes_t[:, axis].clamp_(min=roi_start_torch[axis], max=roi_end_torch[axis] - TO_REMOVE) + boxes_t[:, axis + spatial_dims].clamp_(min=roi_start_torch[axis], max=roi_end_torch[axis] - TO_REMOVE) + boxes_t[:, axis] -= roi_start_torch[axis] + boxes_t[:, axis + spatial_dims] -= roi_start_torch[axis] + + # remove the boxes that are actually empty + if remove_empty: + keep_t = boxes_t[:, spatial_dims] >= boxes_t[:, 0] + 1 - TO_REMOVE + for axis in range(1, spatial_dims): + keep_t = keep_t & (boxes_t[:, axis + spatial_dims] >= boxes_t[:, axis] + 1 - TO_REMOVE) + boxes_t = boxes_t[keep_t] + + # convert tensor back to numpy if needed + boxes_keep, *_ = convert_to_dst_type(src=boxes_t, dst=boxes) + keep, *_ = convert_to_dst_type(src=keep_t, dst=boxes, dtype=keep_t.dtype) + + return boxes_keep, keep + + +def clip_boxes_to_image( + boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], NdarrayOrTensor], remove_empty: bool = True +) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: + """ + This function clips the ``boxes`` to makes sure the bounding boxes are within the image. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + spatial_size: The spatial size of the image where the boxes are attached. len(spatial_size) should be in [2, 3]. + remove_empty: whether to remove the boxes that are actually empty + + Returns: + updated box + """ + spatial_dims = get_spatial_dims(boxes=boxes, spatial_size=spatial_size) + return spatial_crop_boxes(boxes, roi_start=[0] * spatial_dims, roi_end=spatial_size, remove_empty=remove_empty) diff --git a/tests/test_box_utils.py b/tests/test_box_utils.py index 891baa20c5..94731a2eb1 100644 --- a/tests/test_box_utils.py +++ b/tests/test_box_utils.py @@ -27,6 +27,7 @@ box_pair_giou, boxes_center_distance, centers_in_boxes, + clip_boxes_to_image, convert_box_mode, convert_box_to_standard_mode, ) @@ -142,6 +143,7 @@ def test_value(self, input_data, mode2, expected_box, expected_area): boxes1 = convert_data_type(input_data["boxes"], dtype=np.float32)[0] mode1 = input_data["mode"] half_bool = input_data["half"] + spatial_size = input_data["spatial_size"] # test float16 if half_bool: @@ -192,6 +194,15 @@ def test_value(self, input_data, mode2, expected_box, expected_area): center_dist, _, _ = boxes_center_distance(boxes1=result_standard[0:1, :], boxes2=result_standard[0:1, :]) assert_allclose(center_dist, np.array([[0.0]]), type_test=False) + # test clip_boxes_to_image + clipped_boxes, keep = clip_boxes_to_image(expected_box_standard, spatial_size, remove_empty=True) + assert_allclose( + expected_box_standard[keep, :], expected_box_standard[1:, :], type_test=True, device_test=True, atol=0.0 + ) + assert_allclose( + id(clipped_boxes) != id(expected_box_standard), True, type_test=False, device_test=False, atol=0.0 + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index ca5c4be33b..217c3479a4 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -267,7 +267,7 @@ def test_amp(self): im_conv = conv(im) with torch.cuda.amp.autocast(): im_conv2 = conv(im) - self.check(im_conv2, im_conv, ids=False, rtol=1e-4, atol=1e-3) + self.check(im_conv2, im_conv, ids=False, rtol=1e-2, atol=1e-2) def test_out(self): """Test when `out` is given as an argument.""" From 4a0a3364778d0c27884e977985014940ecb2cb0e Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 25 May 2022 03:57:11 +0800 Subject: [PATCH 087/183] 4330 Fix GPU out of memory issue in tests (#4333) * [DLMED] enhance test cases Signed-off-by: Nic Ma --- monai/apps/detection/transforms/array.py | 2 +- tests/test_bundle_verify_net.py | 4 ++-- tests/test_global_mutual_information_loss.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index ee7b0dac0a..87c352cd33 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -237,7 +237,7 @@ def __init__(self, spatial_size: Union[Sequence[int], int], size_mode: str = "al self.spatial_size = spatial_size def __call__( # type: ignore - self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int] # type: ignore + self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int] ) -> NdarrayOrTensor: """ Args: diff --git a/tests/test_bundle_verify_net.py b/tests/test_bundle_verify_net.py index 33d480d83f..05a0731b43 100644 --- a/tests/test_bundle_verify_net.py +++ b/tests/test_bundle_verify_net.py @@ -35,8 +35,8 @@ def test_verify(self, meta_file, config_file): ConfigParser.export_config_file(config=def_args, filepath=def_args_file) cmd = ["coverage", "run", "-m", "monai.bundle", "verify_net_in_out", "network_def", "--meta_file"] - cmd += [meta_file, "--config_file", config_file, "-n", "2", "--any", "32", "--args_file", def_args_file] - cmd += ["--_meta_#network_data_format#inputs#image#spatial_shape", "[32,'*','4**p*n']"] + cmd += [meta_file, "--config_file", config_file, "-n", "4", "--any", "16", "--args_file", def_args_file] + cmd += ["--_meta_#network_data_format#inputs#image#spatial_shape", "[16,'*','2**p*n']"] test_env = os.environ.copy() print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES")) diff --git a/tests/test_global_mutual_information_loss.py b/tests/test_global_mutual_information_loss.py index b2a52f139b..73395d0572 100644 --- a/tests/test_global_mutual_information_loss.py +++ b/tests/test_global_mutual_information_loss.py @@ -18,7 +18,7 @@ from monai.losses.image_dissimilarity import GlobalMutualInformationLoss from tests.utils import SkipIfBeforePyTorchVersion, download_url_or_skip_test, skip_if_quick, testing_data_config -device = "cuda" if torch.cuda.is_available() else "cpu" +device = "cuda:0" if torch.cuda.is_available() else "cpu" FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + "mri.nii") From 9e8fbafe0dd73404eeb4b8ad1e06cbe00815adb5 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 24 May 2022 17:05:17 -0400 Subject: [PATCH 088/183] add box flip transforms (#4334) * add box flip transforms Signed-off-by: Can Zhao --- monai/apps/detection/transforms/array.py | 36 +++- monai/apps/detection/transforms/box_ops.py | 42 +++- monai/apps/detection/transforms/dictionary.py | 181 ++++++++++++++++-- tests/test_box_transform.py | 38 +++- 4 files changed, 269 insertions(+), 28 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 87c352cd33..84377b11f0 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -13,7 +13,7 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ -from typing import Sequence, Type, Union +from typing import Optional, Sequence, Type, Union import numpy as np @@ -23,9 +23,9 @@ from monai.utils import ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option from monai.utils.enums import TransformBackends -from .box_ops import apply_affine_to_boxes, resize_boxes, zoom_boxes +from .box_ops import apply_affine_to_boxes, flip_boxes, resize_boxes, zoom_boxes -__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode", "AffineBox", "ZoomBox", "ResizeBox"] +__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode", "AffineBox", "ZoomBox", "ResizeBox", "FlipBox"] class ConvertBoxMode(Transform): @@ -266,3 +266,33 @@ def __call__( # type: ignore spatial_size_ = tuple(int(round(s * scale)) for s in src_spatial_size_) return resize_boxes(boxes, src_spatial_size_, spatial_size_) + + +class FlipBox(Transform): + """ + Reverses the box coordinates along the given spatial axis. Preserves shape. + + Args: + spatial_axis: spatial axes along which to flip over. Default is None. + The default `axis=None` will flip over all of the axes of the input array. + If axis is negative it counts from the last to the first axis. + If axis is a tuple of ints, flipping is performed on all of the axes + specified in the tuple. + + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: + self.spatial_axis = spatial_axis + + def __call__( # type: ignore + self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int] + ) -> NdarrayOrTensor: + """ + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + spatial_size: image spatial size. + """ + + return flip_boxes(boxes, spatial_size=spatial_size, flip_axes=self.spatial_axis) diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index 13911a837a..7800edcbf0 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -9,14 +9,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Sequence, Union +from copy import deepcopy +from typing import Optional, Sequence, Union import torch from monai.config.type_definitions import NdarrayOrTensor -from monai.data.box_utils import get_spatial_dims +from monai.data.box_utils import TO_REMOVE, get_spatial_dims from monai.transforms.utils import create_scale -from monai.utils.misc import ensure_tuple_rep +from monai.utils.misc import ensure_tuple, ensure_tuple_rep from monai.utils.type_conversion import convert_data_type, convert_to_dst_type @@ -151,3 +152,38 @@ def resize_boxes( zoom = [dst_spatial_size[axis] / float(src_spatial_size[axis]) for axis in range(spatial_dims)] return zoom_boxes(boxes=boxes, zoom=zoom) + + +def flip_boxes( + boxes: NdarrayOrTensor, + spatial_size: Union[Sequence[int], int], + flip_axes: Optional[Union[Sequence[int], int]] = None, +) -> NdarrayOrTensor: + """ + Flip boxes when the corresponding image is flipped + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + spatial_size: image spatial size. + flip_axes: spatial axes along which to flip over. Default is None. + The default `axis=None` will flip over all of the axes of the input array. + If axis is negative it counts from the last to the first axis. + If axis is a tuple of ints, flipping is performed on all of the axes + specified in the tuple. + + Returns: + flipped boxes, with same data type as ``boxes``, does not share memory with ``boxes`` + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + spatial_size = ensure_tuple_rep(spatial_size, spatial_dims) + if flip_axes is None: + flip_axes = tuple(range(0, spatial_dims)) + flip_axes = ensure_tuple(flip_axes) + + # flip box + flip_boxes = deepcopy(boxes) + for axis in flip_axes: + flip_boxes[:, axis + spatial_dims] = spatial_size[axis] - boxes[:, axis] - TO_REMOVE + flip_boxes[:, axis] = spatial_size[axis] - boxes[:, axis + spatial_dims] - TO_REMOVE + + return flip_boxes diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index 41acc34149..7f57de3943 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -22,12 +22,12 @@ import numpy as np import torch -from monai.apps.detection.transforms.array import AffineBox, ConvertBoxMode, ConvertBoxToStandardMode, ZoomBox +from monai.apps.detection.transforms.array import AffineBox, ConvertBoxMode, ConvertBoxToStandardMode, FlipBox, ZoomBox from monai.config import KeysCollection from monai.config.type_definitions import NdarrayOrTensor from monai.data.box_utils import BoxMode from monai.data.utils import orientation_ras_lps -from monai.transforms import RandZoom, SpatialPad, Zoom +from monai.transforms import Flip, RandFlip, RandZoom, SpatialPad, Zoom from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import MapTransform, RandomizableTransform from monai.utils import InterpolateMode, NumpyPadMode, PytorchPadMode, ensure_tuple, ensure_tuple_rep @@ -50,6 +50,12 @@ "RandZoomBoxd", "RandZoomBoxD", "RandZoomBoxDict", + "FlipBoxd", + "FlipBoxD", + "FlipBoxDict", + "RandFlipBoxd", + "RandFlipBoxD", + "RandFlipBoxDict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -249,7 +255,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd class ZoomBoxd(MapTransform, InvertibleTransform): """ - Zooms input arrays with given probability within given zoom range. + Dictionary-based transfrom that zooms input boxes and images with the given zoom scale. Args: image_keys: Keys to pick image data for transformation. @@ -295,13 +301,12 @@ def __init__( self.image_keys = ensure_tuple(image_keys) self.box_keys = ensure_tuple(box_keys) super().__init__(self.image_keys + self.box_keys, allow_missing_keys) + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) self.mode = ensure_tuple_rep(mode, len(self.image_keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.image_keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.image_keys)) self.zoomer = Zoom(zoom=zoom, keep_size=keep_size, **kwargs) - - self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) self.keep_size = keep_size def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: @@ -363,8 +368,6 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd ) # Size might be out by 1 voxel so pad d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key]) - # Remove the applied transform - self.pop_transform(d, key) # zoom boxes if key_type == "box_key": @@ -372,15 +375,16 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"] box_inverse_transform = ZoomBox(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size) d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size) - # Remove the applied transform - self.pop_transform(d, key) + + # Remove the applied transform + self.pop_transform(d, key) return d class RandZoomBoxd(RandomizableTransform, MapTransform, InvertibleTransform): """ - Randomly zooms input arrays with given probability within given zoom range. + Dictionary-based transfrom that randomly zooms input boxes and images with given probability within given zoom range. Args: image_keys: Keys to pick image data for transformation. @@ -439,13 +443,12 @@ def __init__( self.box_keys = ensure_tuple(box_keys) MapTransform.__init__(self, self.image_keys + self.box_keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) self.rand_zoom = RandZoom(prob=1.0, min_zoom=min_zoom, max_zoom=max_zoom, keep_size=keep_size, **kwargs) self.mode = ensure_tuple_rep(mode, len(self.image_keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.image_keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.image_keys)) - - self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) self.keep_size = keep_size def set_random_state( @@ -508,14 +511,11 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) - # zoom image, copied from monai.transforms.spatial.dictionary.RandZoomd for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) key_type = transform[TraceKeys.EXTRA_INFO]["type"] # Check if random transform was actually performed (based on `prob`) if transform[TraceKeys.DO_TRANSFORM]: - # Create inverse transform - # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd if key_type == "image_key": zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"]) @@ -531,8 +531,6 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd ) # Size might be out by 1 voxel so pad d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key]) - # Remove the applied transform - self.pop_transform(d, key) # zoom boxes if key_type == "box_key": @@ -541,8 +539,151 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"] box_inverse_transform = ZoomBox(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size) d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size) - # Remove the applied transform - self.pop_transform(d, key) + + # Remove the applied transform + self.pop_transform(d, key) + return d + + +class FlipBoxd(MapTransform, InvertibleTransform): + """ + Dictionary-based transfrom that flip boxes and images. + + Args: + image_keys: Keys to pick image data for transformation. + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + spatial_axis: Spatial axes along which to flip over. Default is None. + allow_missing_keys: don't raise exception if key is missing. + """ + + backend = Flip.backend + + def __init__( + self, + image_keys: KeysCollection, + box_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + spatial_axis: Optional[Union[Sequence[int], int]] = None, + allow_missing_keys: bool = False, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + self.box_keys = ensure_tuple(box_keys) + super().__init__(self.image_keys + self.box_keys, allow_missing_keys) + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + + self.flipper = Flip(spatial_axis=spatial_axis) + self.box_flipper = FlipBox(spatial_axis=self.flipper.spatial_axis) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for key in self.image_keys: + d[key] = self.flipper(d[key]) + self.push_transform(d, key, extra_info={"type": "image_key"}) + + for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): + spatial_size = d[box_ref_image_key].shape[1:] + d[box_key] = self.box_flipper(d[box_key], spatial_size) + self.push_transform(d, box_key, extra_info={"spatial_size": spatial_size, "type": "box_key"}) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + key_type = transform[TraceKeys.EXTRA_INFO]["type"] + + # flip image, copied from monai.transforms.spatial.dictionary.Flipd + if key_type == "image_key": + d[key] = self.flipper(d[key]) + + # flip boxes + if key_type == "box_key": + spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"] + d[key] = self.box_flipper(d[key], spatial_size) + + # Remove the applied transform + self.pop_transform(d, key) + return d + + +class RandFlipBoxd(RandomizableTransform, MapTransform, InvertibleTransform): + """ + Dictionary-based transfrom that randomly flip boxes and images with the given probabilities. + + Args: + image_keys: Keys to pick image data for transformation. + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + prob: Probability of flipping. + spatial_axis: Spatial axes along which to flip over. Default is None. + allow_missing_keys: don't raise exception if key is missing. + """ + + backend = RandFlip.backend + + def __init__( + self, + image_keys: KeysCollection, + box_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + prob: float = 0.1, + spatial_axis: Optional[Union[Sequence[int], int]] = None, + allow_missing_keys: bool = False, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + self.box_keys = ensure_tuple(box_keys) + MapTransform.__init__(self, self.image_keys + self.box_keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + + self.flipper = RandFlip(prob=1.0, spatial_axis=spatial_axis) + self.box_flipper = FlipBox(spatial_axis=spatial_axis) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandFlipBoxd": + super().set_random_state(seed, state) + self.flipper.set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + self.randomize(None) + + for key in self.image_keys: + if self._do_transform: + d[key] = self.flipper(d[key], randomize=False) + self.push_transform(d, key, extra_info={"type": "image_key"}) + + for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): + spatial_size = d[box_ref_image_key].shape[1:] + if self._do_transform: + d[box_key] = self.box_flipper(d[box_key], spatial_size) + self.push_transform(d, box_key, extra_info={"spatial_size": spatial_size, "type": "box_key"}) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + key_type = transform[TraceKeys.EXTRA_INFO]["type"] + # Check if random transform was actually performed (based on `prob`) + if transform[TraceKeys.DO_TRANSFORM]: + # flip image, copied from monai.transforms.spatial.dictionary.RandFlipd + if key_type == "image_key": + d[key] = self.flipper(d[key], randomize=False) + + # flip boxes + if key_type == "box_key": + spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"] + d[key] = self.box_flipper(d[key], spatial_size) + + # Remove the applied transform + self.pop_transform(d, key) return d @@ -551,3 +692,5 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd ZoomBoxD = ZoomBoxDict = ZoomBoxd RandZoomBoxD = RandZoomBoxDict = RandZoomBoxd AffineBoxToImageCoordinateD = AffineBoxToImageCoordinateDict = AffineBoxToImageCoordinated +FlipBoxD = FlipBoxDict = FlipBoxd +RandFlipBoxD = RandFlipBoxDict = RandFlipBoxd diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index 10e311971e..447dd2e749 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -18,6 +18,8 @@ from monai.apps.detection.transforms.dictionary import ( AffineBoxToImageCoordinated, ConvertBoxModed, + FlipBoxd, + RandFlipBoxd, RandZoomBoxd, ZoomBoxd, ) @@ -38,8 +40,8 @@ p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]), p([[0, 0, 0, 0, 0, 0], [0, 3, 0, 1, 9, 4.5], [0, 3, 1.5, 1, 9, 6]]), p([[1, -6, -1, 1, -6, -1], [1, -3, -1, 2, 3, 3.5], [1, -3, 0.5, 2, 3, 5]]), + p([[4, 6, 4, 4, 6, 4], [2, 3, 1, 4, 5, 4], [2, 3, 0, 4, 5, 3]]), p([[0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), - p([[0, 1, 1, 2, 3, 4], [0, 1, 0, 2, 3, 3]]), ] ) @@ -53,8 +55,8 @@ def test_value( expected_convert_result, expected_zoom_result, expected_zoom_keepsize_result, - expected_clip_result, expected_flip_result, + expected_clip_result, ): test_dtype = [torch.float32] for dtype in test_dtype: @@ -93,7 +95,7 @@ def test_value( zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3 ) - # test ZoomBoxd + # test RandZoomBoxd transform_zoom = RandZoomBoxd( image_keys="image", box_keys="boxes", @@ -124,6 +126,36 @@ def test_value( data_back = invert_transform_affine(affine_result) assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) + # test FlipBoxd + transform_flip = FlipBoxd( + image_keys="image", box_keys="boxes", box_ref_image_keys="image", spatial_axis=[0, 1, 2] + ) + flip_result = transform_flip(data) + assert_allclose(flip_result["boxes"], expected_flip_result, type_test=True, device_test=True, atol=1e-3) + invert_transform_flip = Invertd( + keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"] + ) + data_back = invert_transform_flip(flip_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + + # test RandFlipBoxd + for spatial_axis in [(0,), (1,), (2,), (0, 1), (1, 2)]: + transform_flip = RandFlipBoxd( + image_keys="image", + box_keys="boxes", + box_ref_image_keys="image", + prob=1.0, + spatial_axis=spatial_axis, + ) + flip_result = transform_flip(data) + invert_transform_flip = Invertd( + keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"] + ) + data_back = invert_transform_flip(flip_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + if __name__ == "__main__": unittest.main() From f8d862ece7461d31c8ec17910895a535453eab92 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Wed, 25 May 2022 19:00:51 +0800 Subject: [PATCH 089/183] 4255 Update bundle download (#4331) * update bundle download Signed-off-by: Yiheng Wang * modify a few doc strings Signed-off-by: Yiheng Wang * ignore mypy error of net object Signed-off-by: Yiheng Wang * update docstrings Signed-off-by: Yiheng Wang * rewrite source and repo descriptions Signed-off-by: Yiheng Wang * refine docstrings Signed-off-by: Yiheng Wang * change progress pop arg format Signed-off-by: Yiheng Wang * add more descriptions about load fun Signed-off-by: Yiheng Wang * add get_state_dict according to comments Signed-off-by: Yiheng Wang * [MONAI] code formatting Signed-off-by: monai-bot * revert mis-modified space Signed-off-by: Yiheng Wang Co-authored-by: monai-bot --- monai/bundle/scripts.py | 51 ++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index b64f26a091..9ae0fae6c6 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -14,9 +14,10 @@ import os import pprint import re +import warnings from logging.config import fileConfig from pathlib import Path -from typing import Dict, Optional, Sequence, Tuple, Union +from typing import Dict, Mapping, Optional, Sequence, Tuple, Union import torch from torch.cuda import is_available @@ -26,7 +27,7 @@ from monai.bundle.config_parser import ConfigParser from monai.config import IgniteInfo, PathLike from monai.data import load_net_with_metadata, save_net_with_metadata -from monai.networks import convert_to_torchscript, copy_model_state +from monai.networks import convert_to_torchscript, copy_model_state, get_state_dict from monai.utils import check_parent_dir, get_equivalent_dtype, min_version, optional_import from monai.utils.misc import ensure_tuple @@ -151,7 +152,7 @@ def download( name: Optional[str] = None, bundle_dir: Optional[PathLike] = None, source: str = "github", - repo: Optional[str] = None, + repo: str = "Project-MONAI/model-zoo/hosting_storage_v1", url: Optional[str] = None, progress: bool = True, args_file: Optional[str] = None, @@ -182,12 +183,11 @@ def download( Args: name: bundle name. If `None` and `url` is `None`, it must be provided in `args_file`. bundle_dir: target directory to store the downloaded data. - Default is `bundle` subfolder under`torch.hub get_dir()`. - source: place that saved the bundle. - If `source` is `github`, the bundle should be within the releases. - repo: repo name. If `None` and `url` is `None`, it must be provided in `args_file`. - If `source` is `github`, it should be in the form of `repo_owner/repo_name/release_tag`. - For example: `Project-MONAI/MONAI-extra-test-data/0.8.1`. + Default is `bundle` subfolder under `torch.hub.get_dir()`. + source: storage location name. This argument is used when `url` is `None`. + "github" is currently the only supported value. + repo: repo name. This argument is used when `url` is `None`. + If `source` is "github", it should be in the form of "repo_owner/repo_name/release_tag". url: url to download the data. If not `None`, data will be downloaded directly and `source` will not be checked. If `name` is `None`, filename is determined by `monai.apps.utils._basename(url)`. @@ -201,8 +201,8 @@ def download( ) _log_input_summary(tag="download", args=_args) - name_, bundle_dir_, source_, repo_, url_, progress_ = _pop_args( - _args, name=None, bundle_dir=None, source="github", repo=None, url=None, progress=True + source_, repo_, progress_, name_, bundle_dir_, url_ = _pop_args( + _args, "source", "repo", "progress", name=None, bundle_dir=None, url=None ) bundle_dir_ = _process_bundle_dir(bundle_dir_) @@ -215,10 +215,8 @@ def download( download_url(url=url_, filepath=filepath, hash_val=None, progress=progress_) extractall(filepath=filepath, output_dir=bundle_dir_, has_base=True) elif source_ == "github": - if name_ is None or repo_ is None: - raise ValueError( - f"To download from source: Github, `name` and `repo` must be provided, got {name_} and {repo_}." - ) + if name_ is None: + raise ValueError(f"To download from source: Github, `name` must be provided, got {name_}.") _download_from_github(repo=repo_, download_path=bundle_dir_, filename=name_, progress=progress_) else: raise NotImplementedError( @@ -232,9 +230,10 @@ def load( load_ts_module: bool = False, bundle_dir: Optional[PathLike] = None, source: str = "github", - repo: Optional[str] = None, + repo: str = "Project-MONAI/model-zoo/hosting_storage_v1", progress: bool = True, device: Optional[str] = None, + key_in_ckpt: Optional[str] = None, config_files: Sequence[str] = (), net_name: Optional[str] = None, **net_kwargs, @@ -247,15 +246,16 @@ def load( model_file: the relative path of the model weights or TorchScript module within bundle. If `None`, "models/model.pt" or "models/model.ts" will be used. load_ts_module: a flag to specify if loading the TorchScript module. - bundle_dir: the directory the weights/TorchScript module will be loaded from. - Default is `bundle` subfolder under`torch.hub get_dir()`. - source: the place that saved the bundle. - If `source` is `github`, the bundle should be within the releases. - repo: the repo name. If the weights file does not exist locally and `url` is `None`, it must be provided. - If `source` is `github`, it should be in the form of `repo_owner/repo_name/release_tag`. - For example: `Project-MONAI/MONAI-extra-test-data/0.8.1`. + bundle_dir: directory the weights/TorchScript module will be loaded from. + Default is `bundle` subfolder under `torch.hub.get_dir()`. + source: storage location name. This argument is used when `model_file` is not existing locally and need to be + downloaded first. "github" is currently the only supported value. + repo: repo name. This argument is used when `model_file` is not existing locally and need to be + downloaded first. If `source` is "github", it should be in the form of "repo_owner/repo_name/release_tag". progress: whether to display a progress bar when downloading. device: target device of returned weights or module, if `None`, prefer to "cuda" if existing. + key_in_ckpt: for nested checkpoint like `{"model": XXX, "optimizer": XXX, ...}`, specify the key of model + weights. if not nested checkpoint, no need to set. config_files: extra filenames would be loaded. The argument only works when loading a TorchScript module, see `_extra_files` in `torch.jit.load` for more details. net_name: if not `None`, a corresponding network will be instantiated and load the achieved weights. @@ -286,6 +286,9 @@ def load( return load_net_with_metadata(full_path, map_location=torch.device(device), more_extra_files=config_files) # loading with `torch.load` model_dict = torch.load(full_path, map_location=torch.device(device)) + if not isinstance(model_dict, Mapping): + warnings.warn(f"the state dictionary from {full_path} should be a dictionary but got {type(model_dict)}.") + model_dict = get_state_dict(model_dict) if net_name is None: return model_dict @@ -293,7 +296,7 @@ def load( configer = ConfigComponent(config=net_kwargs) model = configer.instantiate() model.to(device) # type: ignore - model.load_state_dict(model_dict) # type: ignore + copy_model_state(dst=model, src=model_dict if key_in_ckpt is None else model_dict[key_in_ckpt]) # type: ignore return model From eb60b9caf0a103af53ab5f3d620ceb2c116a4851 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Wed, 25 May 2022 08:20:08 -0400 Subject: [PATCH 090/183] add box non_max_suppression (#4337) * add nms Signed-off-by: Can Zhao * add COMPUTE_DTYPE Signed-off-by: Can Zhao --- docs/source/data.rst | 34 +--- monai/apps/detection/transforms/array.py | 4 +- monai/apps/detection/transforms/box_ops.py | 9 +- monai/apps/detection/transforms/dictionary.py | 18 +- monai/data/box_utils.py | 157 +++++++++++++----- tests/test_box_utils.py | 12 ++ 6 files changed, 140 insertions(+), 94 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 6158a564cf..aeeba539c5 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -314,37 +314,5 @@ PatchWSIDataset Bounding box ------------ - -Box mode -~~~~~~~~ -.. autoclass:: monai.data.box_utils.BoxMode +.. automodule:: monai.data.box_utils :members: -.. autoclass:: monai.data.box_utils.CornerCornerModeTypeA -.. autoclass:: monai.data.box_utils.CornerCornerModeTypeB -.. autoclass:: monai.data.box_utils.CornerCornerModeTypeC -.. autoclass:: monai.data.box_utils.CornerSizeMode -.. autoclass:: monai.data.box_utils.CenterSizeMode - -Box mode converter -~~~~~~~~~~~~~~~~~~ -.. autofunction:: monai.data.box_utils.get_boxmode -.. autofunction:: monai.data.box_utils.convert_box_mode -.. autofunction:: monai.data.box_utils.convert_box_to_standard_mode - -Box IoU -~~~~~~~ -.. autofunction:: monai.data.box_utils.box_area -.. autofunction:: monai.data.box_utils.box_iou -.. autofunction:: monai.data.box_utils.box_giou -.. autofunction:: monai.data.box_utils.box_pair_giou - -Box center -~~~~~~~~~~ -.. autofunction:: monai.data.box_utils.box_centers -.. autofunction:: monai.data.box_utils.centers_in_boxes -.. autofunction:: monai.data.box_utils.boxes_center_distance - -Spatial crop box -~~~~~~~~~~~~~~~~ -.. autofunction:: monai.data.box_utils.spatial_crop_boxes -.. autofunction:: monai.data.box_utils.clip_boxes_to_image diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 84377b11f0..2f03bb48b7 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -138,7 +138,7 @@ def __call__(self, boxes: NdarrayOrTensor) -> NdarrayOrTensor: class AffineBox(Transform): """ - Applys affine matrix to the boxes + Applies affine matrix to the boxes """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] @@ -147,7 +147,7 @@ def __call__(self, boxes: NdarrayOrTensor, affine: Union[NdarrayOrTensor, None]) """ Args: boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` - affine: affine matric to be applied to the box coordinate + affine: affine matrix to be applied to the box coordinate """ if affine is None: return boxes diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index 7800edcbf0..ef8d248c02 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -15,7 +15,7 @@ import torch from monai.config.type_definitions import NdarrayOrTensor -from monai.data.box_utils import TO_REMOVE, get_spatial_dims +from monai.data.box_utils import COMPUTE_DTYPE, TO_REMOVE, get_spatial_dims from monai.transforms.utils import create_scale from monai.utils.misc import ensure_tuple, ensure_tuple_rep from monai.utils.type_conversion import convert_data_type, convert_to_dst_type @@ -23,7 +23,7 @@ def _apply_affine_to_points(points: torch.Tensor, affine: torch.Tensor, include_shift: bool = True) -> torch.Tensor: """ - This internal function applies affine matrixs to the point coordinate + This internal function applies affine matrices to the point coordinate Args: points: point coordinates, Nx2 or Nx3 torch tensor or ndarray, representing [x, y] or [x, y, z] @@ -56,7 +56,7 @@ def _apply_affine_to_points(points: torch.Tensor, affine: torch.Tensor, include_ def apply_affine_to_boxes(boxes: NdarrayOrTensor, affine: NdarrayOrTensor) -> NdarrayOrTensor: """ - This function applies affine matrixs to the boxes + This function applies affine matrices to the boxes Args: boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be StandardMode @@ -71,9 +71,8 @@ def apply_affine_to_boxes(boxes: NdarrayOrTensor, affine: NdarrayOrTensor) -> Nd # some operation does not support torch.float16 # convert to float32 - compute_dtype = torch.float32 - boxes_t = boxes_t.to(dtype=compute_dtype) + boxes_t = boxes_t.to(dtype=COMPUTE_DTYPE) affine_t, *_ = convert_to_dst_type(src=affine, dst=boxes_t) spatial_dims = get_spatial_dims(boxes=boxes_t) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index 7f57de3943..96ec9b6dcc 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -176,20 +176,20 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd class AffineBoxToImageCoordinated(MapTransform, InvertibleTransform): """ - Dictionary-based transfrom that converts box in world coordinate to image coordinate. + Dictionary-based transform that converts box in world coordinate to image coordinate. Args: box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. box_ref_image_keys: The single key that represents the reference image to which ``box_keys`` are attached. remove_empty: whether to remove the boxes that are actually empty allow_missing_keys: don't raise exception if key is missing. - image_meta_key: explicitly indicate the key of the corresponding meta data dictionary. + image_meta_key: explicitly indicate the key of the corresponding metadata dictionary. for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + the metadata is a dictionary object which contains: filename, affine, original_shape, etc. it is a string, map to the `box_ref_image_key`. if None, will try to construct meta_keys by `box_ref_image_key_{meta_key_postfix}`. - image_meta_key_postfix: if image_meta_keys=None, use `box_ref_image_key_{postfix}` to fetch the meta data according - to the key data, default is `meta_dict`, the meta data is a dictionary object. + image_meta_key_postfix: if image_meta_keys=None, use `box_ref_image_key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. affine_lps_to_ras: default ``False``. Yet if 1) the image is read by ITKReader, @@ -255,7 +255,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd class ZoomBoxd(MapTransform, InvertibleTransform): """ - Dictionary-based transfrom that zooms input boxes and images with the given zoom scale. + Dictionary-based transform that zooms input boxes and images with the given zoom scale. Args: image_keys: Keys to pick image data for transformation. @@ -384,7 +384,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd class RandZoomBoxd(RandomizableTransform, MapTransform, InvertibleTransform): """ - Dictionary-based transfrom that randomly zooms input boxes and images with given probability within given zoom range. + Dictionary-based transform that randomly zooms input boxes and images with given probability within given zoom range. Args: image_keys: Keys to pick image data for transformation. @@ -547,7 +547,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd class FlipBoxd(MapTransform, InvertibleTransform): """ - Dictionary-based transfrom that flip boxes and images. + Dictionary-based transform that flip boxes and images. Args: image_keys: Keys to pick image data for transformation. @@ -611,7 +611,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd class RandFlipBoxd(RandomizableTransform, MapTransform, InvertibleTransform): """ - Dictionary-based transfrom that randomly flip boxes and images with the given probabilities. + Dictionary-based transform that randomly flip boxes and images with the given probabilities. Args: image_keys: Keys to pick image data for transformation. diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 8af21a31e9..ebe3de9bfd 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -23,7 +23,7 @@ import warnings from abc import ABC, abstractmethod from copy import deepcopy -from typing import Dict, Sequence, Tuple, Type, Union +from typing import Callable, Dict, Sequence, Tuple, Type, Union import numpy as np import torch @@ -44,6 +44,10 @@ # Currently, only `TO_REMOVE = 0.0` is supported TO_REMOVE = 0.0 # xmax-xmin = w -TO_REMOVE. +# Some torch functions do not support half precision. +# We therefore compute those functions under COMPUTE_DTYPE +COMPUTE_DTYPE = torch.float32 + class BoxMode(ABC): """ @@ -251,19 +255,18 @@ def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: corners: Tuple # convert to float32 when computing torch.clamp, which does not support float16 box_dtype = boxes.dtype - compute_dtype = torch.float32 spatial_dims = get_spatial_dims(boxes=boxes) if spatial_dims == 3: xmin, ymin, zmin, w, h, d = boxes.split(1, dim=-1) - xmax = xmin + (w - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - ymax = ymin + (h - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - zmax = zmin + (d - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + xmax = xmin + (w - TO_REMOVE).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + ymax = ymin + (h - TO_REMOVE).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + zmax = zmin + (d - TO_REMOVE).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) corners = xmin, ymin, zmin, xmax, ymax, zmax elif spatial_dims == 2: xmin, ymin, w, h = boxes.split(1, dim=-1) - xmax = xmin + (w - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - ymax = ymin + (h - TO_REMOVE).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + xmax = xmin + (w - TO_REMOVE).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + ymax = ymin + (h - TO_REMOVE).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) corners = xmin, ymin, xmax, ymax return corners @@ -301,24 +304,23 @@ def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: corners: Tuple # convert to float32 when computing torch.clamp, which does not support float16 box_dtype = boxes.dtype - compute_dtype = torch.float32 spatial_dims = get_spatial_dims(boxes=boxes) if spatial_dims == 3: xc, yc, zc, w, h, d = boxes.split(1, dim=-1) - xmin = xc - ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - xmax = xc + ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - ymin = yc - ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - ymax = yc + ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - zmin = zc - ((d - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - zmax = zc + ((d - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + xmin = xc - ((w - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + xmax = xc + ((w - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + ymin = yc - ((h - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + ymax = yc + ((h - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + zmin = zc - ((d - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + zmax = zc + ((d - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) corners = xmin, ymin, zmin, xmax, ymax, zmax elif spatial_dims == 2: xc, yc, w, h = boxes.split(1, dim=-1) - xmin = xc - ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - xmax = xc + ((w - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - ymin = yc - ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) - ymax = yc + ((h - TO_REMOVE) / 2.0).to(dtype=compute_dtype).clamp(min=0).to(dtype=box_dtype) + xmin = xc - ((w - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + xmax = xc + ((w - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + ymin = yc - ((h - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) + ymax = yc + ((h - TO_REMOVE) / 2.0).to(dtype=COMPUTE_DTYPE).clamp(min=0).to(dtype=box_dtype) corners = xmin, ymin, xmax, ymax return corners @@ -617,8 +619,7 @@ def centers_in_boxes(centers: NdarrayOrTensor, boxes: NdarrayOrTensor, eps: floa min_center_to_border: np.ndarray = np.stack(center_to_border, axis=1).min(axis=1) return min_center_to_border > eps # array[bool] - compute_dtype = torch.float32 - return torch.stack(center_to_border, dim=1).to(compute_dtype).min(dim=1)[0] > eps # Tensor[bool] + return torch.stack(center_to_border, dim=1).to(COMPUTE_DTYPE).min(dim=1)[0] > eps # Tensor[bool] def boxes_center_distance( @@ -650,10 +651,8 @@ def boxes_center_distance( boxes1_t, *_ = convert_data_type(boxes1, torch.Tensor) boxes2_t, *_ = convert_data_type(boxes2, torch.Tensor) - compute_dtype = torch.float32 - - center1 = box_centers(boxes1_t.to(compute_dtype)) # (N, spatial_dims) - center2 = box_centers(boxes2_t.to(compute_dtype)) # (M, spatial_dims) + center1 = box_centers(boxes1_t.to(COMPUTE_DTYPE)) # (N, spatial_dims) + center2 = box_centers(boxes2_t.to(COMPUTE_DTYPE)) # (M, spatial_dims) if euclidean: dists = (center1[:, None] - center2[None]).pow(2).sum(-1).sqrt() @@ -785,12 +784,11 @@ def box_iou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTensor # we do computation with compute_dtype to avoid overflow box_dtype = boxes1_t.dtype - compute_dtype = torch.float32 - inter, union = _box_inter_union(boxes1_t, boxes2_t, compute_dtype=compute_dtype) + inter, union = _box_inter_union(boxes1_t, boxes2_t, compute_dtype=COMPUTE_DTYPE) # compute IoU and convert back to original box_dtype - iou_t = inter / (union + torch.finfo(compute_dtype).eps) # (N,M) + iou_t = inter / (union + torch.finfo(COMPUTE_DTYPE).eps) # (N,M) iou_t = iou_t.to(dtype=box_dtype) # check if NaN or Inf @@ -832,18 +830,17 @@ def box_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTenso # we do computation with compute_dtype to avoid overflow box_dtype = boxes1_t.dtype - compute_dtype = torch.float32 - inter, union = _box_inter_union(boxes1_t, boxes2_t, compute_dtype=compute_dtype) - iou = inter / (union + torch.finfo(compute_dtype).eps) # (N,M) + inter, union = _box_inter_union(boxes1_t, boxes2_t, compute_dtype=COMPUTE_DTYPE) + iou = inter / (union + torch.finfo(COMPUTE_DTYPE).eps) # (N,M) # Enclosure # get the left top and right bottom points for the NxM combinations lt = torch.min(boxes1_t[:, None, :spatial_dims], boxes2_t[:, :spatial_dims]).to( - dtype=compute_dtype + dtype=COMPUTE_DTYPE ) # (N,M,spatial_dims) left top rb = torch.max(boxes1_t[:, None, spatial_dims:], boxes2_t[:, spatial_dims:]).to( - dtype=compute_dtype + dtype=COMPUTE_DTYPE ) # (N,M,spatial_dims) right bottom # compute size for the enclosure region for the NxM combinations @@ -851,7 +848,7 @@ def box_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOrTenso enclosure = torch.prod(wh, dim=-1, keepdim=False) # (N,M) # GIoU - giou_t = iou - (enclosure - union) / (enclosure + torch.finfo(compute_dtype).eps) + giou_t = iou - (enclosure - union) / (enclosure + torch.finfo(COMPUTE_DTYPE).eps) giou_t = giou_t.to(dtype=box_dtype) if torch.isnan(giou_t).any() or torch.isinf(giou_t).any(): raise ValueError("Box GIoU is NaN or Inf.") @@ -894,19 +891,18 @@ def box_pair_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOr # we do computation with compute_dtype to avoid overflow box_dtype = boxes1_t.dtype - compute_dtype = torch.float32 # compute area - area1 = box_area(boxes=boxes1_t.to(dtype=compute_dtype)) # (N,) - area2 = box_area(boxes=boxes2_t.to(dtype=compute_dtype)) # (N,) + area1 = box_area(boxes=boxes1_t.to(dtype=COMPUTE_DTYPE)) # (N,) + area2 = box_area(boxes=boxes2_t.to(dtype=COMPUTE_DTYPE)) # (N,) # Intersection # get the left top and right bottom points for the boxes pair lt = torch.max(boxes1_t[:, :spatial_dims], boxes2_t[:, :spatial_dims]).to( - dtype=compute_dtype + dtype=COMPUTE_DTYPE ) # (N,spatial_dims) left top rb = torch.min(boxes1_t[:, spatial_dims:], boxes2_t[:, spatial_dims:]).to( - dtype=compute_dtype + dtype=COMPUTE_DTYPE ) # (N,spatial_dims) right bottom # compute size for the intersection region for the boxes pair @@ -915,22 +911,22 @@ def box_pair_giou(boxes1: NdarrayOrTensor, boxes2: NdarrayOrTensor) -> NdarrayOr # compute IoU and convert back to original box_dtype union = area1 + area2 - inter - iou = inter / (union + torch.finfo(compute_dtype).eps) # (N,) + iou = inter / (union + torch.finfo(COMPUTE_DTYPE).eps) # (N,) # Enclosure # get the left top and right bottom points for the boxes pair lt = torch.min(boxes1_t[:, :spatial_dims], boxes2_t[:, :spatial_dims]).to( - dtype=compute_dtype + dtype=COMPUTE_DTYPE ) # (N,spatial_dims) left top rb = torch.max(boxes1_t[:, spatial_dims:], boxes2_t[:, spatial_dims:]).to( - dtype=compute_dtype + dtype=COMPUTE_DTYPE ) # (N,spatial_dims) right bottom # compute size for the enclose region for the boxes pair wh = (rb - lt + TO_REMOVE).clamp(min=0) # (N,spatial_dims) enclosure = torch.prod(wh, dim=-1, keepdim=False) # (N,) - giou_t = iou - (enclosure - union) / (enclosure + torch.finfo(compute_dtype).eps) + giou_t = iou - (enclosure - union) / (enclosure + torch.finfo(COMPUTE_DTYPE).eps) giou_t = giou_t.to(dtype=box_dtype) # (N,spatial_dims) if torch.isnan(giou_t).any() or torch.isinf(giou_t).any(): raise ValueError("Box GIoU is NaN or Inf.") @@ -971,8 +967,7 @@ def spatial_crop_boxes( boxes_t, *_ = convert_data_type(deepcopy(boxes), torch.Tensor) # convert to float32 since torch.clamp_ does not support float16 - compute_dtype = torch.float32 - boxes_t = boxes_t.to(dtype=compute_dtype) + boxes_t = boxes_t.to(dtype=COMPUTE_DTYPE) # makes sure the bounding boxes are within the patch spatial_dims = get_spatial_dims(boxes=boxes, spatial_size=roi_end) @@ -1008,7 +1003,79 @@ def clip_boxes_to_image( remove_empty: whether to remove the boxes that are actually empty Returns: - updated box + - clipped boxes, boxes[keep], does not share memory with original boxes + - ``keep``, it indicates whether each box in ``boxes`` are kept when ``remove_empty=True``. """ spatial_dims = get_spatial_dims(boxes=boxes, spatial_size=spatial_size) return spatial_crop_boxes(boxes, roi_start=[0] * spatial_dims, roi_end=spatial_size, remove_empty=remove_empty) + + +def non_max_suppression( + boxes: NdarrayOrTensor, + scores: NdarrayOrTensor, + nms_thresh: float, + max_proposals: int = -1, + box_overlap_metric: Callable = box_iou, +) -> NdarrayOrTensor: + """ + Non-maximum suppression (NMS). + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + scores: prediction scores of the boxes, sized (N,). This function keeps boxes with higher scores. + nms_thresh: threshold of NMS. For boxes with overlap more than nms_thresh, + we only keep the one with the highest score. + max_proposals: maximum number of boxes it keeps. + If ``max_proposals`` = -1, there is no limit on the number of boxes that are kept. + box_overlap_metric: the metric to compute overlap between boxes. + + Returns: + Indexes of ``boxes`` that are kept after NMS. + + Example: + keep = non_max_suppression(boxes, scores, num_thresh=0.1) + boxes_after_nms = boxes[keep] + """ + + # returns empty array if boxes is empty + if boxes.shape[0] == 0: + return convert_to_dst_type(src=np.array([]), dst=boxes)[0] + + if boxes.shape[0] != scores.shape[0]: + raise ValueError( + f"boxes and scores should have same length, got boxes shape {boxes.shape}, scores shape {scores.shape}" + ) + + # convert tensor to numpy if needed + boxes_t, *_ = convert_data_type(boxes, torch.Tensor) + scores_t, *_ = convert_to_dst_type(scores, boxes_t) + + # sort boxes in desending order according to the scores + _, sort_idxs = torch.sort(scores_t, descending=True) + boxes_sort = deepcopy(boxes_t)[sort_idxs, :] + + # initialize the list of picked indexes + pick = [] + idxs = torch.Tensor(list(range(0, boxes_sort.shape[0]))).to(torch.long) + + # keep looping while some indexes still remain in the indexes list + while len(idxs) > 0: + # pick the first index in the indexes list and add the index value to the list of picked indexes + i = int(idxs[0].item()) + pick.append(i) + if len(pick) >= max_proposals >= 1: + break + + # compute the IoU between the rest of the boxes and the box just picked + box_overlap = box_overlap_metric(boxes_sort[idxs, :], boxes_sort[i : i + 1, :]) + + # keep only indexes from the index list that have overlap < nms_thresh + to_keep_idx = (box_overlap <= nms_thresh).flatten() + to_keep_idx[0] = False # always remove idxs[0] + idxs = idxs[to_keep_idx] + + # return only the bounding boxes that were picked using the integer data type + pick_idx = sort_idxs[pick] + + # convert numpy back to tensor if needed + return convert_to_dst_type(src=pick_idx, dst=boxes, dtype=pick_idx.dtype)[0] diff --git a/tests/test_box_utils.py b/tests/test_box_utils.py index 94731a2eb1..2a71cd7d5b 100644 --- a/tests/test_box_utils.py +++ b/tests/test_box_utils.py @@ -30,6 +30,7 @@ clip_boxes_to_image, convert_box_mode, convert_box_to_standard_mode, + non_max_suppression, ) from monai.utils.type_conversion import convert_data_type from tests.utils import TEST_NDARRAYS, assert_allclose @@ -203,6 +204,17 @@ def test_value(self, input_data, mode2, expected_box, expected_area): id(clipped_boxes) != id(expected_box_standard), True, type_test=False, device_test=False, atol=0.0 ) + # test non_max_suppression + nms_box = non_max_suppression( + boxes=result_standard, scores=boxes1[:, 1] / 2.0, nms_thresh=1.0, box_overlap_metric=box_giou + ) + assert_allclose(nms_box, [1, 2, 0], type_test=False) + + nms_box = non_max_suppression( + boxes=result_standard, scores=boxes1[:, 1] / 2.0, nms_thresh=-1.0, box_overlap_metric=box_iou + ) + assert_allclose(nms_box, [1], type_test=False) + if __name__ == "__main__": unittest.main() From 15eda2adcdd3d461cf2d103f5aa5dfa6d660dfee Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 26 May 2022 08:21:44 +0100 Subject: [PATCH 091/183] 4343 adds interp mode (#4344) * adds interp mode Signed-off-by: Wenqi Li * update docstrings Signed-off-by: Wenqi Li --- monai/apps/detection/transforms/dictionary.py | 4 ++-- monai/data/png_writer.py | 2 +- monai/transforms/spatial/array.py | 14 ++++++++------ monai/transforms/spatial/dictionary.py | 6 +++--- monai/utils/enums.py | 1 + tests/test_resize.py | 4 +++- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index 96ec9b6dcc..b4bdcc8dc6 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -264,7 +264,7 @@ class ZoomBoxd(MapTransform, InvertibleTransform): zoom: The zoom factor along the spatial axes. If a float, zoom is the same for each spatial axis. If a sequence, zoom should contain one value for each spatial axis. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html It also can be a sequence of string, each element corresponds to a key in ``keys``. @@ -401,7 +401,7 @@ class RandZoomBoxd(RandomizableTransform, MapTransform, InvertibleTransform): to keep the original spatial shape ratio. If a sequence, max_zoom should contain one value for each spatial axis. If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html It also can be a sequence of string, each element corresponds to a key in ``keys``. diff --git a/monai/data/png_writer.py b/monai/data/png_writer.py index 5d05536923..b1fe7eb327 100644 --- a/monai/data/png_writer.py +++ b/monai/data/png_writer.py @@ -38,7 +38,7 @@ def write_png( data: input data to write to file. file_name: expected file name that saved on disk. output_spatial_shape: spatial shape of the output image. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"bicubic"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html scale: {``255``, ``65535``} postprocess data by clipping to [0, 1] and scaling to diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 69f98aa2a0..e2176b4e7d 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -617,7 +617,7 @@ class Resize(Transform): which must be an int number in this case, keeping the aspect ratio of the initial image, refer to: https://albumentations.ai/docs/api_reference/augmentations/geometric/resize/ #albumentations.augmentations.geometric.resize.LongestMaxSize. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html align_corners: This only has an effect when mode is @@ -663,7 +663,8 @@ def __call__( """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]). - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, + ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``self.mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html align_corners: This only has an effect when mode is @@ -856,7 +857,7 @@ class Zoom(Transform): zoom: The zoom factor along the spatial axes. If a float, zoom is the same for each spatial axis. If a sequence, zoom should contain one value for each spatial axis. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, @@ -903,7 +904,8 @@ def __call__( """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]). - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, + ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``self.mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, @@ -1231,7 +1233,7 @@ class RandZoom(RandomizableTransform): to keep the original spatial shape ratio. If a sequence, max_zoom should contain one value for each spatial axis. If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, @@ -1299,7 +1301,7 @@ def __call__( """ Args: img: channel first array, must have shape 2D: (nchannels, H, W), or 3D: (nchannels, H, W, D). - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``self.mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d00dd4463c..28d9c66448 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -806,7 +806,7 @@ class Resized(MapTransform, InvertibleTransform): which must be an int number in this case, keeping the aspect ratio of the initial image, refer to: https://albumentations.ai/docs/api_reference/augmentations/geometric/resize/ #albumentations.augmentations.geometric.resize.LongestMaxSize. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html It also can be a sequence of string, each element corresponds to a key in ``keys``. @@ -1829,7 +1829,7 @@ class Zoomd(MapTransform, InvertibleTransform): zoom: The zoom factor along the spatial axes. If a float, zoom is the same for each spatial axis. If a sequence, zoom should contain one value for each spatial axis. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html It also can be a sequence of string, each element corresponds to a key in ``keys``. @@ -1929,7 +1929,7 @@ class RandZoomd(RandomizableTransform, MapTransform, InvertibleTransform): to keep the original spatial shape ratio. If a sequence, max_zoom should contain one value for each spatial axis. If 2 values provided for 3D data, use the first value for both H & W dims to keep the same zoom ratio. - mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html It also can be a sequence of string, each element corresponds to a key in ``keys``. diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 0871e04c1f..af044f30fe 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -82,6 +82,7 @@ class InterpolateMode(Enum): """ NEAREST = "nearest" + NEAREST_EXACT = "nearest-exact" LINEAR = "linear" BILINEAR = "bilinear" BICUBIC = "bicubic" diff --git a/tests/test_resize.py b/tests/test_resize.py index 9214c44cf0..a4a5d0f2ea 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -17,7 +17,7 @@ from parameterized import parameterized from monai.transforms import Resize -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose, pytorch_after TEST_CASE_0 = [{"spatial_size": 15}, (6, 10, 15)] @@ -46,9 +46,11 @@ def test_invalid_inputs(self): ((32, 32), "area", False), ((32, 32, 32), "trilinear", True), ((256, 256), "bilinear", False), + ((256, 256), "nearest-exact" if pytorch_after(1, 11) else "nearest", False), ] ) def test_correct_results(self, spatial_size, mode, anti_aliasing): + """resize 'spatial_size' and 'mode'""" resize = Resize(spatial_size, mode=mode, anti_aliasing=anti_aliasing) _order = 0 if mode.endswith("linear"): From 29c62198d24b38688c6dd656bac3ea3a5c96af5b Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 26 May 2022 13:37:13 +0100 Subject: [PATCH 092/183] 4354 workaround protobuf issue (#4355) * workaround protobuf issue Signed-off-by: Wenqi Li * fixes tests Signed-off-by: Wenqi Li * update env var Signed-off-by: Wenqi Li * config env Signed-off-by: Wenqi Li * [DLMED] update for test Signed-off-by: Nic Ma * revert init workaround Signed-off-by: Wenqi Li * [DLMED] update visualize Signed-off-by: Nic Ma * revert init change Signed-off-by: Wenqi Li Co-authored-by: Nic Ma --- .github/workflows/pythonapp.yml | 3 +++ docs/Makefile | 3 +++ monai/handlers/tensorboard_handlers.py | 19 +++++++++---------- monai/visualize/img2tensorboard.py | 7 +++---- runtests.sh | 3 +++ 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 38c96b3b0d..4226ea7d99 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -105,6 +105,7 @@ jobs: python -m unittest -v env: QUICKTEST: True + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python # https://github.com/Project-MONAI/MONAI/issues/4354 packaging: runs-on: ubuntu-latest @@ -189,6 +190,8 @@ jobs: ls -al python -m pip install -r requirements-dev.txt python -m unittest -v + env: + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python # https://github.com/Project-MONAI/MONAI/issues/4354 build-docs: runs-on: ubuntu-latest diff --git a/docs/Makefile b/docs/Makefile index 1a3b22d020..d9a870064a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -8,6 +8,9 @@ SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build +# https://github.com/Project-MONAI/MONAI/issues/4354 +export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION := python + # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/monai/handlers/tensorboard_handlers.py b/monai/handlers/tensorboard_handlers.py index 0105e7c8ca..445e3e76ca 100644 --- a/monai/handlers/tensorboard_handlers.py +++ b/monai/handlers/tensorboard_handlers.py @@ -20,13 +20,12 @@ from monai.visualize import plot_2d_or_3d_image Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") +SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") if TYPE_CHECKING: from ignite.engine import Engine - from torch.utils.tensorboard import SummaryWriter else: Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") - SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") DEFAULT_TAG = "Loss" @@ -42,7 +41,7 @@ class TensorBoardHandler: """ - def __init__(self, summary_writer: Optional[SummaryWriter] = None, log_dir: str = "./runs"): + def __init__(self, summary_writer=None, log_dir: str = "./runs"): if summary_writer is None: self._writer = SummaryWriter(log_dir=log_dir) self.internal_writer = True @@ -82,13 +81,13 @@ class TensorBoardStatsHandler(TensorBoardHandler): def __init__( self, - summary_writer: Optional[SummaryWriter] = None, + summary_writer=None, log_dir: str = "./runs", iteration_log: bool = True, epoch_log: bool = True, - epoch_event_writer: Optional[Callable[[Engine, SummaryWriter], Any]] = None, + epoch_event_writer: Optional[Callable[[Engine, Any], Any]] = None, epoch_interval: int = 1, - iteration_event_writer: Optional[Callable[[Engine, SummaryWriter], Any]] = None, + iteration_event_writer: Optional[Callable[[Engine, Any], Any]] = None, iteration_interval: int = 1, output_transform: Callable = lambda x: x[0], global_epoch_transform: Callable = lambda x: x, @@ -179,7 +178,7 @@ def iteration_completed(self, engine: Engine) -> None: else: self._default_iteration_writer(engine, self._writer) - def _write_scalar(self, _engine: Engine, writer: SummaryWriter, tag: str, value: Any, step: int) -> None: + def _write_scalar(self, _engine: Engine, writer, tag: str, value: Any, step: int) -> None: """ Write scale value into TensorBoard. Default to call `SummaryWriter.add_scalar()`. @@ -194,7 +193,7 @@ def _write_scalar(self, _engine: Engine, writer: SummaryWriter, tag: str, value: """ writer.add_scalar(tag, value, step) - def _default_epoch_writer(self, engine: Engine, writer: SummaryWriter) -> None: + def _default_epoch_writer(self, engine: Engine, writer) -> None: """ Execute epoch level event write operation. Default to write the values from Ignite `engine.state.metrics` dict and @@ -216,7 +215,7 @@ def _default_epoch_writer(self, engine: Engine, writer: SummaryWriter) -> None: self._write_scalar(engine, writer, attr, getattr(engine.state, attr, None), current_epoch) writer.flush() - def _default_iteration_writer(self, engine: Engine, writer: SummaryWriter) -> None: + def _default_iteration_writer(self, engine: Engine, writer) -> None: """ Execute iteration level event write operation based on Ignite `engine.state.output` data. Extract the values from `self.output_transform(engine.state.output)`. @@ -295,7 +294,7 @@ class TensorBoardImageHandler(TensorBoardHandler): def __init__( self, - summary_writer: Optional[SummaryWriter] = None, + summary_writer=None, log_dir: str = "./runs", interval: int = 1, epoch_level: bool = True, diff --git a/monai/visualize/img2tensorboard.py b/monai/visualize/img2tensorboard.py index 0af05adf32..6dd7abcbf1 100644 --- a/monai/visualize/img2tensorboard.py +++ b/monai/visualize/img2tensorboard.py @@ -21,14 +21,13 @@ PIL, _ = optional_import("PIL") GifImage, _ = optional_import("PIL.GifImagePlugin", name="Image") SummaryX, _ = optional_import("tensorboardX.proto.summary_pb2", name="Summary") +SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") SummaryWriterX, has_tensorboardx = optional_import("tensorboardX", name="SummaryWriter") if TYPE_CHECKING: from tensorboard.compat.proto.summary_pb2 import Summary - from torch.utils.tensorboard import SummaryWriter else: Summary, _ = optional_import("tensorboard.compat.proto.summary_pb2", name="Summary") - SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") __all__ = ["make_animated_gif_summary", "add_animated_gif", "plot_2d_or_3d_image"] @@ -104,7 +103,7 @@ def make_animated_gif_summary( def add_animated_gif( - writer: SummaryWriter, + writer, tag: str, image_tensor: Union[np.ndarray, torch.Tensor], max_out: int = 3, @@ -136,7 +135,7 @@ def add_animated_gif( def plot_2d_or_3d_image( data: Union[NdarrayTensor, List[NdarrayTensor]], step: int, - writer: SummaryWriter, + writer, index: int = 0, max_channels: int = 1, frame_dim: int = -3, diff --git a/runtests.sh b/runtests.sh index 01d04dafd8..ec9493f6b2 100755 --- a/runtests.sh +++ b/runtests.sh @@ -14,6 +14,9 @@ # script for running all tests set -e +# FIXME: https://github.com/Project-MONAI/MONAI/issues/4354 +export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + # output formatting separator="" blue="" From 95b7c4f1452c63e2d097ff29d444f9f51e10eedf Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 10:50:05 -0400 Subject: [PATCH 093/183] Sliding Patch WSI Dataset (#4239) Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/data.rst | 5 + monai/data/__init__.py | 3 +- monai/data/grid_dataset.py | 1 + monai/data/utils.py | 81 ++++++-- monai/data/wsi_datasets.py | 132 +++++++++++- monai/data/wsi_reader.py | 47 +++++ tests/test_sliding_patch_wsi_dataset.py | 258 ++++++++++++++++++++++++ 7 files changed, 509 insertions(+), 18 deletions(-) create mode 100644 tests/test_sliding_patch_wsi_dataset.py diff --git a/docs/source/data.rst b/docs/source/data.rst index aeeba539c5..7f59e587ec 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -312,6 +312,11 @@ PatchWSIDataset .. autoclass:: monai.data.PatchWSIDataset :members: +SlidingPatchWSIDataset +~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.SlidingPatchWSIDataset + :members: + Bounding box ------------ .. automodule:: monai.data.box_utils diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 8ab4742a75..40ee3cfc29 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -83,6 +83,7 @@ get_valid_patch_size, is_supported_format, iter_patch, + iter_patch_position, iter_patch_slices, json_hashing, list_data_collate, @@ -103,7 +104,7 @@ worker_init_fn, zoom_affine, ) -from .wsi_datasets import PatchWSIDataset +from .wsi_datasets import PatchWSIDataset, SlidingPatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader with contextlib.suppress(BaseException): diff --git a/monai/data/grid_dataset.py b/monai/data/grid_dataset.py index 33497b5a68..2e389d9e0b 100644 --- a/monai/data/grid_dataset.py +++ b/monai/data/grid_dataset.py @@ -73,6 +73,7 @@ def __call__(self, array: np.ndarray): array, patch_size=self.patch_size, # type: ignore start_pos=self.start_pos, + overlap=0.0, copy_back=False, mode=self.mode, **self.pad_opts, diff --git a/monai/data/utils.py b/monai/data/utils.py index ca91006817..e56d8b86e9 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -67,6 +67,7 @@ "get_valid_patch_size", "is_supported_format", "iter_patch", + "iter_patch_position", "iter_patch_slices", "json_hashing", "list_data_collate", @@ -123,32 +124,36 @@ def get_random_patch( def iter_patch_slices( - dims: Sequence[int], patch_size: Union[Sequence[int], int], start_pos: Sequence[int] = () + image_size: Sequence[int], + patch_size: Union[Sequence[int], int], + start_pos: Sequence[int] = (), + overlap: Union[Sequence[float], float] = 0.0, + padded: bool = True, ) -> Generator[Tuple[slice, ...], None, None]: """ - Yield successive tuples of slices defining patches of size `patch_size` from an array of dimensions `dims`. The - iteration starts from position `start_pos` in the array, or starting at the origin if this isn't provided. Each - patch is chosen in a contiguous grid using a first dimension as least significant ordering. + Yield successive tuples of slices defining patches of size `patch_size` from an array of dimensions `image_size`. + The iteration starts from position `start_pos` in the array, or starting at the origin if this isn't provided. Each + patch is chosen in a contiguous grid using a rwo-major ordering. Args: - dims: dimensions of array to iterate over + image_size: dimensions of array to iterate over patch_size: size of patches to generate slices for, 0 or None selects whole dimension start_pos: starting position in the array, default is 0 for each dimension + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. + padded: if the image is padded so the patches can go beyond the borders. Defaults to False. Yields: Tuples of slice objects defining each patch """ - # ensure patchSize and startPos are the right length - ndim = len(dims) - patch_size_ = get_valid_patch_size(dims, patch_size) - start_pos = ensure_tuple_size(start_pos, ndim) + # ensure patch_size has the right length + patch_size_ = get_valid_patch_size(image_size, patch_size) - # collect the ranges to step over each dimension - ranges = tuple(starmap(range, zip(start_pos, dims, patch_size_))) - - # choose patches by applying product to the ranges - for position in product(*ranges): + # create slices based on start position of each patch + for position in iter_patch_position( + image_size=image_size, patch_size=patch_size_, start_pos=start_pos, overlap=overlap, padded=padded + ): yield tuple(slice(s, s + p) for s, p in zip(position, patch_size_)) @@ -192,10 +197,54 @@ def dense_patch_slices( return [tuple(slice(s, s + patch_size[d]) for d, s in enumerate(x)) for x in out] +def iter_patch_position( + image_size: Sequence[int], + patch_size: Union[Sequence[int], int], + start_pos: Sequence[int] = (), + overlap: Union[Sequence[float], float] = 0.0, + padded: bool = False, +): + """ + Yield successive tuples of upper left corner of patches of size `patch_size` from an array of dimensions `image_size`. + The iteration starts from position `start_pos` in the array, or starting at the origin if this isn't provided. Each + patch is chosen in a contiguous grid using a rwo-major ordering. + + Args: + image_size: dimensions of array to iterate over + patch_size: size of patches to generate slices for, 0 or None selects whole dimension + start_pos: starting position in the array, default is 0 for each dimension + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. + padded: if the image is padded so the patches can go beyond the borders. Defaults to False. + + Yields: + Tuples of positions defining the upper left corner of each patch + """ + + # ensure patchSize and startPos are the right length + ndim = len(image_size) + patch_size_ = get_valid_patch_size(image_size, patch_size) + start_pos = ensure_tuple_size(start_pos, ndim) + overlap = ensure_tuple_rep(overlap, ndim) + + # calculate steps, which depends on the amount of overlap + steps = tuple(round(p * (1.0 - o)) for p, o in zip(patch_size_, overlap)) + + # calculate the last starting location (depending on the padding) + end_pos = image_size if padded else tuple(s - round(p) + 1 for s, p in zip(image_size, patch_size_)) + + # collect the ranges to step over each dimension + ranges = starmap(range, zip(start_pos, end_pos, steps)) + + # choose patches by applying product to the ranges + return product(*ranges) + + def iter_patch( arr: np.ndarray, patch_size: Union[Sequence[int], int] = 0, start_pos: Sequence[int] = (), + overlap: Union[Sequence[float], float] = 0.0, copy_back: bool = True, mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, **pad_opts: Dict, @@ -209,6 +258,8 @@ def iter_patch( arr: array to iterate over patch_size: size of patches to generate slices for, 0 or None selects whole dimension start_pos: starting position in the array, default is 0 for each dimension + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. copy_back: if True data from the yielded patches is copied back to `arr` once the generator completes mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} @@ -243,7 +294,7 @@ def iter_patch( # patches which are only in the padded regions iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) - for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded): + for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap): # compensate original image padding coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) yield arrpad[slices], np.asarray(coords_no_pad) # data and coords (in numpy; works with torch loader) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 665cbd196c..6fe5435d57 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -15,11 +15,12 @@ import numpy as np from monai.data import Dataset +from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader -from monai.transforms import apply_transform +from monai.transforms import Randomizable, apply_transform from monai.utils import ensure_tuple_rep -__all__ = ["PatchWSIDataset"] +__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset"] class PatchWSIDataset(Dataset): @@ -137,3 +138,130 @@ def _transform(self, index: int): # Apply transforms and output output = {"image": image, "label": label, "metadata": metadata} return apply_transform(self.transform, output) if self.transform else output + + +class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): + """ + This dataset extracts patches from whole slide images (without loading the whole image) + It also reads labels for each patch and provides each patch with its associated class labels. + + Args: + data: the list of input samples including image, location, and label (see the note below for more details). + size: the size of patch to be extracted from the whole slide image. + level: the level at which the patches to be extracted (default to 0). + offset: the offset of image to extract patches (the starting position of the upper left patch). + offset_limits: if offset is set to "random", a tuple of integers defining the lower and upper limit of the + random offset for all dimensions, or a tuple of tuples that defines the limits for each dimension. + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. + transform: transforms to be executed on input data. + reader: the module to be used for loading whole slide imaging. Defaults to cuCIM. If `reader` is + + - a string, it defines the backend of `monai.data.WSIReader`. + - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader, + - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + + seed: random seed to randomly generate offsets. Defaults to 0. + kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class + + Note: + The input data has the following form as an example: + + .. code-block:: python + + [ + {"image": "path/to/image1.tiff"}, + {"image": "path/to/image2.tiff", "size": [20, 20], "level": 2} + ] + + """ + + def __init__( + self, + data: Sequence, + size: Optional[Union[int, Tuple[int, int]]] = None, + level: Optional[int] = None, + overlap: Union[Tuple[float, float], float] = 0.0, + offset: Union[Tuple[int, int], int, str] = (0, 0), + offset_limits: Optional[Union[Tuple[Tuple[int, int], Tuple[int, int]], Tuple[int, int]]] = None, + transform: Optional[Callable] = None, + reader="cuCIM", + seed: int = 0, + **kwargs, + ): + super().__init__(data=data, size=size, level=level, transform=transform, reader=reader, **kwargs) + self.overlap = overlap + self.set_random_state(seed) + # Set the offset config + self.random_offset = False + if isinstance(offset, str): + if offset == "random": + self.random_offset = True + self.offset_limits: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] + if offset_limits is None: + self.offset_limits = None + elif isinstance(offset_limits, tuple): + if isinstance(offset_limits[0], int): + self.offset_limits = (offset_limits, offset_limits) + elif isinstance(offset_limits[0], tuple): + self.offset_limits = offset_limits + else: + ValueError( + "The offset limits should be either a tuple of integers or tuple of tuple of integers." + ) + else: + ValueError("The offset limits should be a tuple.") + else: + ValueError( + f'Invalid string for offset "{offset}". It should be either "random" as a string,' + "an integer, or a tuple of integers defining the offset." + ) + else: + self.offset = ensure_tuple_rep(offset, 2) + + # Create single sample for each patch (in a sliding window manner) + self.data = [] + for sample in data: + sliding_samples = self._evaluate_patch_coordinates(sample) + self.data.extend(sliding_samples) + + def _get_offset(self, sample): + if self.random_offset: + if self.offset_limits is None: + offset_limits = tuple((-s, s) for s in self._get_size(sample)) + else: + offset_limits = self.offset_limits + return tuple(self.R.randint(low, high) for low, high in offset_limits) + return self.offset + + def _evaluate_patch_coordinates(self, sample): + """Define the location for each patch based on sliding-window approach""" + patch_size = self._get_size(sample) + level = self._get_level(sample) + start_pos = self._get_offset(sample) + + wsi_obj = self._get_wsi_object(sample) + wsi_size = self.wsi_reader.get_size(wsi_obj, 0) + downsample = self.wsi_reader.get_downsample_ratio(wsi_obj, level) + patch_size_ = tuple(p * downsample for p in patch_size) # patch size at level 0 + locations = list( + iter_patch_position( + image_size=wsi_size, patch_size=patch_size_, start_pos=start_pos, overlap=self.overlap, padded=False + ) + ) + sample["size"] = patch_size + sample["level"] = level + n_patches = len(locations) + return [{**sample, "location": loc, "num_patches": n_patches} for loc in locations] + + def _get_location(self, sample: Dict): + return sample["location"] + + def _transform(self, index: int): + # Get a single entry of data + sample: Dict = self.data[index] + # Extract patch image and associated metadata + image, metadata = self._get_data(sample) + # Create put all patch information together and apply transforms + patch = {"image": image, "metadata": metadata} + return apply_transform(self.transform, patch) if self.transform else patch diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index b29ac3848f..fdf7de3d63 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -85,6 +85,18 @@ def get_level_count(self, wsi) -> int: """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + @abstractmethod + def get_downsample_ratio(self, wsi, level: int) -> float: + """ + Returns the down-sampling ratio of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + @abstractmethod def get_file_path(self, wsi) -> str: """Return the file path for the WSI object""" @@ -290,6 +302,17 @@ def get_size(self, wsi, level: int) -> Tuple[int, int]: """ return self.reader.get_size(wsi, level) + def get_downsample_ratio(self, wsi, level: int) -> float: + """ + Returns the down-sampling ratio of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + return self.reader.get_downsample_ratio(wsi, level) + def get_file_path(self, wsi) -> str: """Return the file path for the WSI object""" return self.reader.get_file_path(wsi) @@ -369,6 +392,18 @@ def get_size(wsi, level: int) -> Tuple[int, int]: """ return (wsi.resolutions["level_dimensions"][level][1], wsi.resolutions["level_dimensions"][level][0]) + @staticmethod + def get_downsample_ratio(wsi, level: int) -> float: + """ + Returns the down-sampling ratio of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + return wsi.resolutions["level_downsamples"][level] # type: ignore + def get_file_path(self, wsi) -> str: """Return the file path for the WSI object""" return str(abspath(wsi.path)) @@ -475,6 +510,18 @@ def get_size(wsi, level: int) -> Tuple[int, int]: """ return (wsi.level_dimensions[level][1], wsi.level_dimensions[level][0]) + @staticmethod + def get_downsample_ratio(wsi, level: int) -> float: + """ + Returns the down-sampling ratio of the whole slide image at a given level. + + Args: + wsi: a whole slide image object loaded from a file + level: the level number where the size is calculated + + """ + return wsi.level_downsamples[level] # type: ignore + def get_file_path(self, wsi) -> str: """Return the file path for the WSI object""" return str(abspath(wsi._filename)) diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py new file mode 100644 index 0000000000..1eaa0292c5 --- /dev/null +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -0,0 +1,258 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from parameterized import parameterized + +from monai.data import SlidingPatchWSIDataset +from monai.utils import optional_import, set_determinism +from tests.utils import download_url_or_skip_test, testing_data_config + +set_determinism(0) + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +FILE_PATH_SMALL_0 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_wsi_inference_0.tiff") +FILE_PATH_SMALL_1 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_wsi_inference_1.tiff") +ARRAY_SMALL_0 = np.random.randint(low=0, high=255, size=(3, 4, 4), dtype=np.uint8) +ARRAY_SMALL_1 = np.random.randint(low=0, high=255, size=(3, 5, 5), dtype=np.uint8) + +TEST_CASE_SMALL_0 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2)}, + [ + {"image": ARRAY_SMALL_0[:, :2, :2]}, + {"image": ARRAY_SMALL_0[:, :2, 2:]}, + {"image": ARRAY_SMALL_0[:, 2:, :2]}, + {"image": ARRAY_SMALL_0[:, 2:, 2:]}, + ], +] + +TEST_CASE_SMALL_1 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}]}, + [ + {"image": ARRAY_SMALL_0[:, :2, :2]}, + {"image": ARRAY_SMALL_0[:, :2, 2:]}, + {"image": ARRAY_SMALL_0[:, 2:, :2]}, + {"image": ARRAY_SMALL_0[:, 2:, 2:]}, + ], +] + +TEST_CASE_SMALL_2 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2), "overlap": 0.5}, + [ + {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, + {"image": ARRAY_SMALL_0[:, 0:2, 1:3]}, + {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, + {"image": ARRAY_SMALL_0[:, 1:3, 0:2]}, + {"image": ARRAY_SMALL_0[:, 1:3, 1:3]}, + {"image": ARRAY_SMALL_0[:, 1:3, 2:4]}, + {"image": ARRAY_SMALL_0[:, 2:4, 0:2]}, + {"image": ARRAY_SMALL_0[:, 2:4, 1:3]}, + {"image": ARRAY_SMALL_0[:, 2:4, 2:4]}, + ], +] + +TEST_CASE_SMALL_3 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (3, 3), "overlap": 2.0 / 3.0}, + [ + {"image": ARRAY_SMALL_0[:, :3, :3]}, + {"image": ARRAY_SMALL_0[:, :3, 1:]}, + {"image": ARRAY_SMALL_0[:, 1:, :3]}, + {"image": ARRAY_SMALL_0[:, 1:, 1:]}, + ], +] + +TEST_CASE_SMALL_4 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}, {"image": FILE_PATH_SMALL_1, "level": 0}], "size": (2, 2)}, + [ + {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, + {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, + {"image": ARRAY_SMALL_0[:, 2:4, 0:2]}, + {"image": ARRAY_SMALL_0[:, 2:4, 2:4]}, + {"image": ARRAY_SMALL_1[:, 0:2, 0:2]}, + {"image": ARRAY_SMALL_1[:, 0:2, 2:4]}, + {"image": ARRAY_SMALL_1[:, 2:4, 0:2]}, + {"image": ARRAY_SMALL_1[:, 2:4, 2:4]}, + ], +] + +TEST_CASE_SMALL_5 = [ + { + "data": [ + {"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}, + {"image": FILE_PATH_SMALL_1, "level": 0, "size": (3, 3)}, + ] + }, + [ + {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, + {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, + {"image": ARRAY_SMALL_0[:, 2:4, 0:2]}, + {"image": ARRAY_SMALL_0[:, 2:4, 2:4]}, + {"image": ARRAY_SMALL_1[:, 0:3, 0:3]}, + ], +] + +TEST_CASE_SMALL_6 = [ + { + "data": [ + {"image": FILE_PATH_SMALL_0, "level": 1, "size": (1, 1)}, + {"image": FILE_PATH_SMALL_1, "level": 2, "size": (4, 4)}, + ], + "size": (2, 2), + "level": 0, + }, + [ + {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, + {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, + {"image": ARRAY_SMALL_0[:, 2:4, 0:2]}, + {"image": ARRAY_SMALL_0[:, 2:4, 2:4]}, + {"image": ARRAY_SMALL_1[:, 0:2, 0:2]}, + {"image": ARRAY_SMALL_1[:, 0:2, 2:4]}, + {"image": ARRAY_SMALL_1[:, 2:4, 0:2]}, + {"image": ARRAY_SMALL_1[:, 2:4, 2:4]}, + ], +] + + +TEST_CASE_SMALL_7 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": (1, 0)}, + [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], +] + +TEST_CASE_SMALL_8 = [ + {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": "random", "offset_limits": (0, 2)}, + [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], +] + +TEST_CASE_SMALL_9 = [ + { + "data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], + "offset": "random", + "offset_limits": ((0, 3), (0, 2)), + }, + [{"image": ARRAY_SMALL_0[:, :2, 1:3]}, {"image": ARRAY_SMALL_0[:, 2:, 1:3]}], +] + +TEST_CASE_LARGE_0 = [ + {"data": [{"image": FILE_PATH, "level": 8, "size": (64, 50)}]}, + [ + {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + ], +] + +TEST_CASE_LARGE_1 = [ + { + "data": [ + {"image": FILE_PATH, "level": 8, "size": (64, 50)}, + {"image": FILE_PATH, "level": 7, "size": (125, 110)}, + ] + }, + [ + {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + ], +] + + +@skipUnless(has_cucim or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): # noqa: N802 + for info in [(ARRAY_SMALL_0, FILE_PATH_SMALL_0), (ARRAY_SMALL_1, FILE_PATH_SMALL_1)]: + array = info[0].transpose([1, 2, 0]) + imwrite(info[1], array, shape=array.shape, photometric="rgb") + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class SlidingPatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand( + [ + TEST_CASE_SMALL_0, + TEST_CASE_SMALL_1, + TEST_CASE_SMALL_2, + TEST_CASE_SMALL_3, + TEST_CASE_SMALL_4, + TEST_CASE_SMALL_5, + TEST_CASE_SMALL_6, + TEST_CASE_SMALL_7, + TEST_CASE_SMALL_8, + TEST_CASE_SMALL_9, + ] + ) + def test_read_patches(self, input_parameters, expected): + if self.backend == "openslide": + return + dataset = SlidingPatchWSIDataset(reader=self.backend, **input_parameters) + self.assertEqual(len(dataset), len(expected)) + for i, sample in enumerate(dataset): + self.assertTupleEqual(sample["image"].shape, expected[i]["image"].shape) + + @parameterized.expand([TEST_CASE_LARGE_0, TEST_CASE_LARGE_1]) + def test_read_patches_large(self, input_parameters, expected): + dataset = SlidingPatchWSIDataset(reader=self.backend, **input_parameters) + self.assertEqual(len(dataset), len(expected)) + for i, sample in enumerate(dataset): + self.assertEqual(sample["metadata"]["patch"]["level"], expected[i]["level"]) + self.assertTupleEqual(sample["metadata"]["patch"]["size"], expected[i]["size"]) + steps = [round(expected[i]["ratio"] * s) for s in expected[i]["size"]] + expected_location = tuple(expected[i]["step_loc"][j] * steps[j] for j in range(len(steps))) + self.assertTupleEqual(sample["metadata"]["patch"]["location"], expected_location) + + +@skipUnless(has_cucim, "Requires cucim") +class TestSlidingPatchWSIDatasetCuCIM(SlidingPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestSlidingPatchWSIDatasetOpenSlide(SlidingPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() From a5ca74933dda8432c77be7491f8088c375123c89 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 26 May 2022 14:06:23 -0400 Subject: [PATCH 094/183] add feature pyramid network (FPN) for detection (#4342) * add FPN Signed-off-by: Can Zhao --- docs/source/networks.rst | 13 + monai/data/box_utils.py | 2 +- monai/networks/blocks/__init__.py | 2 + monai/networks/blocks/backbone_fpn_utils.py | 171 ++++++++++++ .../blocks/feature_pyramid_network.py | 261 ++++++++++++++++++ tests/test_fpn_block.py | 50 ++++ 6 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 monai/networks/blocks/backbone_fpn_utils.py create mode 100644 monai/networks/blocks/feature_pyramid_network.py create mode 100644 tests/test_fpn_block.py diff --git a/docs/source/networks.rst b/docs/source/networks.rst index 7607cd2701..a9cd6a0735 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -40,6 +40,19 @@ Blocks .. autoclass:: MemoryEfficientSwish :members: +`FPN` +~~~~~ +.. autoclass:: ExtraFPNBlock + :members: +.. autoclass:: FeaturePyramidNetwork + :members: +.. autoclass:: LastLevelMaxPool + :members: +.. autoclass:: LastLevelP6P7 + :members: +.. autoclass:: BackboneWithFPN + :members: + `Mish` ~~~~~~ .. autoclass:: Mish diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index ebe3de9bfd..dfe9f3798b 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -1051,7 +1051,7 @@ def non_max_suppression( scores_t, *_ = convert_to_dst_type(scores, boxes_t) # sort boxes in desending order according to the scores - _, sort_idxs = torch.sort(scores_t, descending=True) + sort_idxs = torch.argsort(scores_t, dim=0, descending=True) boxes_sort = deepcopy(boxes_t)[sort_idxs, :] # initialize the list of picked indexes diff --git a/monai/networks/blocks/__init__.py b/monai/networks/blocks/__init__.py index b6328734b0..27feffea10 100644 --- a/monai/networks/blocks/__init__.py +++ b/monai/networks/blocks/__init__.py @@ -12,12 +12,14 @@ from .acti_norm import ADN from .activation import MemoryEfficientSwish, Mish, Swish from .aspp import SimpleASPP +from .backbone_fpn_utils import BackboneWithFPN from .convolutions import Convolution, ResidualUnit from .crf import CRF from .dints_block import ActiConvNormBlock, FactorizedIncreaseBlock, FactorizedReduceBlock, P3DActiConvNormBlock from .downsample import MaxAvgPool from .dynunet_block import UnetBasicBlock, UnetOutBlock, UnetResBlock, UnetUpBlock, get_output_padding, get_padding from .fcn import FCN, GCN, MCFCN, Refine +from .feature_pyramid_network import ExtraFPNBlock, FeaturePyramidNetwork, LastLevelMaxPool, LastLevelP6P7 from .localnet_block import LocalNetDownSampleBlock, LocalNetFeatureExtractorBlock, LocalNetUpSampleBlock from .mlp import MLPBlock from .patchembedding import PatchEmbed, PatchEmbeddingBlock diff --git a/monai/networks/blocks/backbone_fpn_utils.py b/monai/networks/blocks/backbone_fpn_utils.py new file mode 100644 index 0000000000..8975a09654 --- /dev/null +++ b/monai/networks/blocks/backbone_fpn_utils.py @@ -0,0 +1,171 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/backbone_utils.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE +# +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +This script is modified from from torchvision to support N-D images, +by overriding the definition of convolutional layers and pooling layers. + +https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/backbone_utils.py +""" + +from typing import Dict, List, Optional, Union + +from torch import Tensor, nn + +from monai.networks.nets import resnet +from monai.utils import optional_import + +from .feature_pyramid_network import ExtraFPNBlock, FeaturePyramidNetwork, LastLevelMaxPool + +torchvision_models, _ = optional_import("torchvision.models") + +__all__ = ["BackboneWithFPN"] + + +class BackboneWithFPN(nn.Module): + """ + Adds an FPN on top of a model. + Internally, it uses torchvision.models._utils.IntermediateLayerGetter to + extract a submodel that returns the feature maps specified in return_layers. + The same limitations of IntermediateLayerGetter apply here. + + Same code as https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/backbone_utils.py + Except that this class uses spatial_dims + + Args: + backbone: backbone network + return_layers: a dict containing the names + of the modules for which the activations will be returned as + the key of the dict, and the value of the dict is the name + of the returned activation (which the user can specify). + in_channels_list: number of channels for each feature map + that is returned, in the order they are present in the OrderedDict + out_channels: number of channels in the FPN. + """ + + def __init__( + self, + backbone: nn.Module, + return_layers: Dict[str, str], + in_channels_list: List[int], + out_channels: int, + spatial_dims: Union[int, None] = None, + extra_blocks: Optional[ExtraFPNBlock] = None, + ) -> None: + super().__init__() + + if extra_blocks is None: + extra_blocks = LastLevelMaxPool() + + # if spatial_dims is not specified, try to find it from backbone. + if spatial_dims is None: + if hasattr(backbone, "spatial_dims") and isinstance(backbone.spatial_dims, int): + spatial_dims = backbone.spatial_dims + elif isinstance(backbone.conv1, nn.Conv2d): + spatial_dims = 2 + elif isinstance(backbone.conv1, nn.Conv3d): + spatial_dims = 3 + else: + raise ValueError("Could not find spatial_dims of backbone, please specify it.") + + self.body = torchvision_models._utils.IntermediateLayerGetter(backbone, return_layers=return_layers) + self.fpn = FeaturePyramidNetwork( + spatial_dims=spatial_dims, + in_channels_list=in_channels_list, + out_channels=out_channels, + extra_blocks=extra_blocks, + ) + self.out_channels = out_channels + + def forward(self, x: Tensor) -> Dict[str, Tensor]: + """ + Computes the resulted feature maps of the network. + + Args: + x: input images + + Returns: + feature maps after FPN layers. They are ordered from highest resolution first. + """ + x = self.body(x) # backbone + y: Dict[str, Tensor] = self.fpn(x) # FPN + return y + + +def _resnet_fpn_extractor( + backbone: resnet.ResNet, + trainable_layers: int, + returned_layers: Optional[List[int]] = None, + extra_blocks: Optional[ExtraFPNBlock] = None, +) -> BackboneWithFPN: + """ + Same code as https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/backbone_utils.py + Except that ``in_channels_stage2 = backbone.in_planes // 8`` instead of ``in_channels_stage2 = backbone.inplanes // 8`` + """ + + # select layers that wont be frozen + if trainable_layers < 0 or trainable_layers > 5: + raise ValueError(f"Trainable layers should be in the range [0,5], got {trainable_layers}") + layers_to_train = ["layer4", "layer3", "layer2", "layer1", "conv1"][:trainable_layers] + if trainable_layers == 5: + layers_to_train.append("bn1") + for name, parameter in backbone.named_parameters(): + if all([not name.startswith(layer) for layer in layers_to_train]): + parameter.requires_grad_(False) + + if extra_blocks is None: + extra_blocks = LastLevelMaxPool() + + if returned_layers is None: + returned_layers = [1, 2, 3, 4] + if min(returned_layers) <= 0 or max(returned_layers) >= 5: + raise ValueError(f"Each returned layer should be in the range [1,4]. Got {returned_layers}") + return_layers = {f"layer{k}": str(v) for v, k in enumerate(returned_layers)} + + in_channels_stage2 = backbone.in_planes // 8 + in_channels_list = [in_channels_stage2 * 2 ** (i - 1) for i in returned_layers] + out_channels = 256 + return BackboneWithFPN(backbone, return_layers, in_channels_list, out_channels, extra_blocks=extra_blocks) diff --git a/monai/networks/blocks/feature_pyramid_network.py b/monai/networks/blocks/feature_pyramid_network.py new file mode 100644 index 0000000000..33c289b083 --- /dev/null +++ b/monai/networks/blocks/feature_pyramid_network.py @@ -0,0 +1,261 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/release/0.12/torchvision/ops/feature_pyramid_network.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE +# +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +This script is modified from from torchvision to support N-D images, +by overriding the definition of convolutional layers and pooling layers. + +https://github.com/pytorch/vision/blob/release/0.12/torchvision/ops/feature_pyramid_network.py +""" + +from collections import OrderedDict +from typing import Callable, Dict, List, Optional, Tuple, Type, Union + +import torch.nn.functional as F +from torch import Tensor, nn + +from monai.networks.layers.factories import Conv, Pool + +__all__ = ["ExtraFPNBlock", "LastLevelMaxPool", "LastLevelP6P7", "FeaturePyramidNetwork"] + + +class ExtraFPNBlock(nn.Module): + """ + Base class for the extra block in the FPN. + + Same code as https://github.com/pytorch/vision/blob/release/0.12/torchvision/ops/feature_pyramid_network.py + """ + + def forward(self, results: List[Tensor], x: List[Tensor], names: List[str]) -> Tuple[List[Tensor], List[str]]: + """ + Compute extended set of results of the FPN and their names. + + Args: + results: the result of the FPN + x: the original feature maps + names: the names for each one of the original feature maps + + Returns: + - the extended set of results of the FPN + - the extended set of names for the results + """ + pass + + +class LastLevelMaxPool(ExtraFPNBlock): + """ + Applies a max_pool2d or max_pool3d on top of the last feature map. Serves as an ``extra_blocks`` + in :class:`~monai.networks.blocks.feature_pyramid_network.FeaturePyramidNetwork` . + """ + + def forward(self, results: List[Tensor], x: List[Tensor], names: List[str]) -> Tuple[List[Tensor], List[str]]: + spatial_dims = len(results[0].shape) - 2 + pool_type: Type[Union[nn.MaxPool1d, nn.MaxPool2d, nn.MaxPool3d]] = Pool[Pool.MAX, spatial_dims] + self.maxpool = pool_type(kernel_size=1, stride=2, padding=0) + + names.append("pool") + results.append(self.maxpool(results[-1])) + return results, names + + +class LastLevelP6P7(ExtraFPNBlock): + """ + This module is used in RetinaNet to generate extra layers, P6 and P7. + Serves as an ``extra_blocks`` + in :class:`~monai.networks.blocks.feature_pyramid_network.FeaturePyramidNetwork` . + """ + + def __init__(self, spatial_dims: int, in_channels: int, out_channels: int): + super().__init__() + conv_type: Callable = Conv[Conv.CONV, spatial_dims] + self.p6 = conv_type(in_channels, out_channels, kernel_size=3, stride=2, padding=1) + self.p7 = conv_type(out_channels, out_channels, kernel_size=3, stride=2, padding=1) + for module in [self.p6, self.p7]: + nn.init.kaiming_uniform_(module.weight, a=1) + nn.init.constant_(module.bias, 0) + self.use_P5 = in_channels == out_channels + + def forward(self, results: List[Tensor], x: List[Tensor], names: List[str]) -> Tuple[List[Tensor], List[str]]: + p5, c5 = results[-1], x[-1] + x5 = p5 if self.use_P5 else c5 + p6 = self.p6(x5) + p7 = self.p7(F.relu(p6)) + results.extend([p6, p7]) + names.extend(["p6", "p7"]) + return results, names + + +class FeaturePyramidNetwork(nn.Module): + """ + Module that adds a FPN from on top of a set of feature maps. This is based on + `"Feature Pyramid Network for Object Detection" `_. + + The feature maps are currently supposed to be in increasing depth + order. + + The input to the model is expected to be an OrderedDict[Tensor], containing + the feature maps on top of which the FPN will be added. + + Args: + spatial_dims: 2D or 3D images + in_channels_list: number of channels for each feature map that + is passed to the module + out_channels: number of channels of the FPN representation + extra_blocks: if provided, extra operations will + be performed. It is expected to take the fpn features, the original + features and the names of the original features as input, and returns + a new list of feature maps and their corresponding names + + Examples:: + + >>> m = FeaturePyramidNetwork(2, [10, 20, 30], 5) + >>> # get some dummy data + >>> x = OrderedDict() + >>> x['feat0'] = torch.rand(1, 10, 64, 64) + >>> x['feat2'] = torch.rand(1, 20, 16, 16) + >>> x['feat3'] = torch.rand(1, 30, 8, 8) + >>> # compute the FPN on top of x + >>> output = m(x) + >>> print([(k, v.shape) for k, v in output.items()]) + >>> # returns + >>> [('feat0', torch.Size([1, 5, 64, 64])), + >>> ('feat2', torch.Size([1, 5, 16, 16])), + >>> ('feat3', torch.Size([1, 5, 8, 8]))] + + """ + + def __init__( + self, + spatial_dims: int, + in_channels_list: List[int], + out_channels: int, + extra_blocks: Optional[ExtraFPNBlock] = None, + ): + super().__init__() + + conv_type: Callable = Conv[Conv.CONV, spatial_dims] + + self.inner_blocks = nn.ModuleList() + self.layer_blocks = nn.ModuleList() + for in_channels in in_channels_list: + if in_channels == 0: + raise ValueError("in_channels=0 is currently not supported") + inner_block_module = conv_type(in_channels, out_channels, 1) + layer_block_module = conv_type(out_channels, out_channels, 3, padding=1) + self.inner_blocks.append(inner_block_module) + self.layer_blocks.append(layer_block_module) + + # initialize parameters now to avoid modifying the initialization of top_blocks + conv_type_: Type[nn.Module] = Conv[Conv.CONV, spatial_dims] + for m in self.modules(): + if isinstance(m, conv_type_): + nn.init.kaiming_uniform_(m.weight, a=1) + nn.init.constant_(m.bias, 0.0) # type: ignore + + if extra_blocks is not None: + assert isinstance(extra_blocks, ExtraFPNBlock) + self.extra_blocks = extra_blocks + + def get_result_from_inner_blocks(self, x: Tensor, idx: int) -> Tensor: + """ + This is equivalent to self.inner_blocks[idx](x), + but torchscript doesn't support this yet + """ + num_blocks = len(self.inner_blocks) + if idx < 0: + idx += num_blocks + out = x + for i, module in enumerate(self.inner_blocks): + if i == idx: + out = module(x) + return out + + def get_result_from_layer_blocks(self, x: Tensor, idx: int) -> Tensor: + """ + This is equivalent to self.layer_blocks[idx](x), + but torchscript doesn't support this yet + """ + num_blocks = len(self.layer_blocks) + if idx < 0: + idx += num_blocks + out = x + for i, module in enumerate(self.layer_blocks): + if i == idx: + out = module(x) + return out + + def forward(self, x: Dict[str, Tensor]) -> Dict[str, Tensor]: + """ + Computes the FPN for a set of feature maps. + + Args: + x: feature maps for each feature level. + + Returns: + feature maps after FPN layers. They are ordered from highest resolution first. + """ + # unpack OrderedDict into two lists for easier handling + names = list(x.keys()) + x_values: List = list(x.values()) + + last_inner = self.get_result_from_inner_blocks(x_values[-1], -1) + results = [] + results.append(self.get_result_from_layer_blocks(last_inner, -1)) + + for idx in range(len(x_values) - 2, -1, -1): + inner_lateral = self.get_result_from_inner_blocks(x_values[idx], idx) + feat_shape = inner_lateral.shape[2:] + inner_top_down = F.interpolate(last_inner, size=feat_shape, mode="nearest") + last_inner = inner_lateral + inner_top_down + results.insert(0, self.get_result_from_layer_blocks(last_inner, idx)) + + if self.extra_blocks is not None: + results, names = self.extra_blocks(results, x_values, names) + + # make it back an OrderedDict + out = OrderedDict([(k, v) for k, v in zip(names, results)]) + + return out diff --git a/tests/test_fpn_block.py b/tests/test_fpn_block.py new file mode 100644 index 0000000000..af09d38e54 --- /dev/null +++ b/tests/test_fpn_block.py @@ -0,0 +1,50 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from collections import OrderedDict + +import torch +from parameterized import parameterized + +from monai.networks.blocks.feature_pyramid_network import FeaturePyramidNetwork + +TEST_CASES = [] +TEST_CASES.append( + [ + {"spatial_dims": 3, "in_channels_list": [32, 64], "out_channels": 6}, + ((7, 32, 16, 32, 64), (7, 64, 8, 16, 32)), + ((7, 6, 16, 32, 64), (7, 6, 8, 16, 32)), + ] +) +TEST_CASES.append( + [ + {"spatial_dims": 2, "in_channels_list": [32, 64], "out_channels": 6}, + ((7, 32, 16, 32), (7, 64, 8, 16)), + ((7, 6, 16, 32), (7, 6, 8, 16)), + ] +) + + +class TestFPNBlock(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_fpn_block(self, input_param, input_shape, expected_shape): + net = FeaturePyramidNetwork(**input_param) + data = OrderedDict() + data["feat0"] = torch.rand(input_shape[0]) + data["feat1"] = torch.rand(input_shape[1]) + result = net(data) + self.assertEqual(result["feat0"].shape, expected_shape[0]) + self.assertEqual(result["feat1"].shape, expected_shape[1]) + + +if __name__ == "__main__": + unittest.main() From 0dac288f893d4357c1aca72603c498560f17f0d3 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 26 May 2022 16:12:33 -0400 Subject: [PATCH 095/183] add box clip transforms (#4338) * fix the cases of multiple labels Signed-off-by: Can Zhao * add examples Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao * docstring and remove inverse Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao --- monai/apps/detection/transforms/array.py | 83 ++++++++++++++++++- monai/apps/detection/transforms/dictionary.py | 74 ++++++++++++++++- tests/test_box_transform.py | 20 ++++- 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 2f03bb48b7..b2587a213a 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -13,19 +13,36 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ -from typing import Optional, Sequence, Type, Union +from copy import deepcopy +from typing import Optional, Sequence, Tuple, Type, Union import numpy as np +import torch from monai.config.type_definitions import NdarrayOrTensor -from monai.data.box_utils import BoxMode, convert_box_mode, convert_box_to_standard_mode, get_spatial_dims +from monai.data.box_utils import ( + BoxMode, + clip_boxes_to_image, + convert_box_mode, + convert_box_to_standard_mode, + get_spatial_dims, +) from monai.transforms.transform import Transform from monai.utils import ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option from monai.utils.enums import TransformBackends +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type from .box_ops import apply_affine_to_boxes, flip_boxes, resize_boxes, zoom_boxes -__all__ = ["ConvertBoxToStandardMode", "ConvertBoxMode", "AffineBox", "ZoomBox", "ResizeBox", "FlipBox"] +__all__ = [ + "ConvertBoxToStandardMode", + "ConvertBoxMode", + "AffineBox", + "ZoomBox", + "ResizeBox", + "FlipBox", + "ClipBoxToImage", +] class ConvertBoxMode(Transform): @@ -296,3 +313,63 @@ def __call__( # type: ignore """ return flip_boxes(boxes, spatial_size=spatial_size, flip_axes=self.spatial_axis) + + +class ClipBoxToImage(Transform): + """ + Clip the bounding boxes and the associated labels/scores to makes sure they are within the image. + There might be multiple arryas of labels/scores associated with one array of boxes. + + Args: + remove_empty: whether to remove the boxes and corresponding labels that are actually empty + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, remove_empty: bool = False) -> None: + self.remove_empty = remove_empty + + def __call__( # type: ignore + self, + boxes: NdarrayOrTensor, + labels: Union[Sequence[NdarrayOrTensor], NdarrayOrTensor], + spatial_size: Union[Sequence[int], int], + ) -> Tuple[NdarrayOrTensor, Tuple]: + """ + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + labels: Sequence of array. Each element represents classification labels or scores + corresponding to ``boxes``, sized (N,). + spatial_size: The spatial size of the image where the boxes are attached. len(spatial_size) should be in [2, 3]. + + Returns: + - clipped boxes, does not share memory with original boxes + - clipped labels, does not share memory with original labels + + Example: + .. code-block:: python + + box_clipper = ClipBoxToImage(remove_empty=True) + boxes = torch.ones(2, 6) + class_labels = torch.Tensor([0, 1]) + pred_scores = torch.Tensor([[0.4,0.3,0.3], [0.5,0.1,0.4]]) + labels = (class_labels, pred_scores) + spatial_size = [32, 32, 32] + boxes_clip, labels_clip_tuple = box_clipper(boxes, labels, spatial_size) + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + spatial_size = ensure_tuple_rep(spatial_size, spatial_dims) # match the spatial image dim + + boxes_clip, keep = clip_boxes_to_image(boxes, spatial_size, self.remove_empty) + + labels_tuple = ensure_tuple(labels) + labels_clip_list = [] + + keep_t: torch.Tensor = convert_data_type(keep, torch.Tensor)[0] + for i in range(len(labels_tuple)): + labels_t: torch.Tensor = convert_data_type(labels_tuple[i], torch.Tensor)[0] + if boxes.shape[0] != labels_t.shape[0]: + raise ValueError("boxes.shape[0] should be equal to the number of labels or scores.") + labels_t = deepcopy(labels_t[keep_t, ...]) + labels_clip_list.append(convert_to_dst_type(src=labels_t, dst=labels_tuple[i])[0]) + return boxes_clip, tuple(labels_clip_list) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index b4bdcc8dc6..b802ebcfe2 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -22,7 +22,14 @@ import numpy as np import torch -from monai.apps.detection.transforms.array import AffineBox, ConvertBoxMode, ConvertBoxToStandardMode, FlipBox, ZoomBox +from monai.apps.detection.transforms.array import ( + AffineBox, + ClipBoxToImage, + ConvertBoxMode, + ConvertBoxToStandardMode, + FlipBox, + ZoomBox, +) from monai.config import KeysCollection from monai.config.type_definitions import NdarrayOrTensor from monai.data.box_utils import BoxMode @@ -56,6 +63,9 @@ "RandFlipBoxd", "RandFlipBoxD", "RandFlipBoxDict", + "ClipBoxToImaged", + "ClipBoxToImageD", + "ClipBoxToImageDict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -687,6 +697,67 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd return d +class ClipBoxToImaged(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.ClipBoxToImage`. + + Clip the bounding boxes and the associated labels/scores to makes sure they are within the image. + There might be multiple keys of labels/scores associated with one key of boxes. + + Args: + box_keys: The single key to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + label_keys: Keys that represents the lables corresponding to the ``box_keys``. Multiple keys are allowed. + box_ref_image_keys: The single key that represents the reference image + to which ``box_keys`` and ``label_keys`` are attached. + remove_empty: whether to remove the boxes that are actually empty + allow_missing_keys: don't raise exception if key is missing. + + Example: + .. code-block:: python + + ClipBoxToImaged( + box_keys="boxes", box_ref_image_keys="image", label_keys=["labels", "scores"], remove_empty=True + ) + """ + + def __init__( + self, + box_keys: KeysCollection, + label_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + remove_empty: bool = True, + allow_missing_keys: bool = False, + ) -> None: + box_keys_tuple = ensure_tuple(box_keys) + if len(box_keys_tuple) != 1: + raise ValueError( + "Please provide a single key for box_keys.\ + All label_keys are attached to this box_keys." + ) + box_ref_image_keys_tuple = ensure_tuple(box_ref_image_keys) + if len(box_ref_image_keys_tuple) != 1: + raise ValueError( + "Please provide a single key for box_ref_image_keys.\ + All box_keys and label_keys are attached to this box_ref_image_keys." + ) + self.label_keys = ensure_tuple(label_keys) + super().__init__(box_keys_tuple, allow_missing_keys) + + self.box_keys = box_keys_tuple[0] + self.box_ref_image_keys = box_ref_image_keys_tuple[0] + self.clipper = ClipBoxToImage(remove_empty=remove_empty) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + spatial_size = d[self.box_ref_image_keys].shape[1:] + labels = [d[label_key] for label_key in self.label_keys] # could be multiple arrays + d[self.box_keys], clipped_labels = self.clipper(d[self.box_keys], labels, spatial_size) + + for label_key, clipped_labels_i in zip(self.label_keys, clipped_labels): + d[label_key] = clipped_labels_i + return d + + ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed ZoomBoxD = ZoomBoxDict = ZoomBoxd @@ -694,3 +765,4 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd AffineBoxToImageCoordinateD = AffineBoxToImageCoordinateDict = AffineBoxToImageCoordinated FlipBoxD = FlipBoxDict = FlipBoxd RandFlipBoxD = RandFlipBoxDict = RandFlipBoxd +ClipBoxToImageD = ClipBoxToImageDict = ClipBoxToImaged diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index 447dd2e749..f290ce5726 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -17,6 +17,7 @@ from monai.apps.detection.transforms.dictionary import ( AffineBoxToImageCoordinated, + ClipBoxToImaged, ConvertBoxModed, FlipBoxd, RandFlipBoxd, @@ -29,6 +30,8 @@ TESTS = [] boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]] +labels = [1, 1, 0] +scores = [[0.2, 0.8], [0.3, 0.7], [0.6, 0.4]] image_size = [1, 4, 6, 4] image = np.zeros(image_size) @@ -36,7 +39,7 @@ TESTS.append( [ {"box_keys": "boxes", "dst_mode": "xyzwhd"}, - {"boxes": p(boxes), "image": p(image)}, + {"boxes": p(boxes), "image": p(image), "labels": p(labels), "scores": p(scores)}, p([[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 2, 3], [0, 1, 1, 2, 2, 3]]), p([[0, 0, 0, 0, 0, 0], [0, 3, 0, 1, 9, 4.5], [0, 3, 1.5, 1, 9, 6]]), p([[1, -6, -1, 1, -6, -1], [1, -3, -1, 2, 3, 3.5], [1, -3, 0.5, 2, 3, 5]]), @@ -156,6 +159,21 @@ def test_value( assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # test ClipBoxToImaged + transform_clip = ClipBoxToImaged( + box_keys="boxes", box_ref_image_keys="image", label_keys=["labels", "scores"], remove_empty=True + ) + clip_result = transform_clip(data) + assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) + assert_allclose(clip_result["labels"], data["labels"][1:], type_test=True, device_test=True, atol=1e-3) + assert_allclose(clip_result["scores"], data["scores"][1:], type_test=True, device_test=True, atol=1e-3) + + transform_clip = ClipBoxToImaged( + box_keys="boxes", box_ref_image_keys="image", label_keys=[], remove_empty=True + ) # corner case when label_keys is empty + clip_result = transform_clip(data) + assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) + if __name__ == "__main__": unittest.main() From b892aa579b82b4348f0a69cffca840267ced734b Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Thu, 26 May 2022 22:13:31 +0100 Subject: [PATCH 096/183] Adding Bundle Utility Functions (#4324) * Adding tests Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Signed-off-by: Eric Kerfoot * Fix Signed-off-by: Eric Kerfoot * Trying to read output from failed process Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Updates Signed-off-by: Eric Kerfoot * Marking bundle_init as excluded Signed-off-by: Eric Kerfoot * Marking bundle_init as excluded Signed-off-by: Eric Kerfoot * Forgot a file Signed-off-by: Eric Kerfoot * Marking bundle_utils as excluded Signed-off-by: Eric Kerfoot * Marking bundle_init as excluded Signed-off-by: Eric Kerfoot * Update Signed-off-by: Eric Kerfoot * Updates Signed-off-by: Eric Kerfoot * Updates Signed-off-by: Eric Kerfoot Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/bundle/__init__.py | 2 +- monai/bundle/__main__.py | 2 +- monai/bundle/config_parser.py | 15 ++- monai/bundle/scripts.py | 82 +++++++++++++++- monai/bundle/utils.py | 154 +++++++++++++++++++++++++++++++ tests/min_tests.py | 2 + tests/test_bundle_init_bundle.py | 41 ++++++++ tests/test_bundle_utils.py | 117 +++++++++++++++++++++++ tests/test_config_parser.py | 16 ++++ 9 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 tests/test_bundle_init_bundle.py create mode 100644 tests/test_bundle_utils.py diff --git a/monai/bundle/__init__.py b/monai/bundle/__init__.py index f30ee9c40c..62e754e135 100644 --- a/monai/bundle/__init__.py +++ b/monai/bundle/__init__.py @@ -13,4 +13,4 @@ from .config_parser import ConfigParser from .reference_resolver import ReferenceResolver from .scripts import ckpt_export, download, load, run, verify_metadata, verify_net_in_out -from .utils import EXPR_KEY, ID_REF_KEY, ID_SEP_KEY, MACRO_KEY +from .utils import EXPR_KEY, ID_REF_KEY, ID_SEP_KEY, MACRO_KEY, load_bundle_config diff --git a/monai/bundle/__main__.py b/monai/bundle/__main__.py index 3e3534ef74..ace3701d19 100644 --- a/monai/bundle/__main__.py +++ b/monai/bundle/__main__.py @@ -10,7 +10,7 @@ # limitations under the License. -from monai.bundle.scripts import ckpt_export, download, run, verify_metadata, verify_net_in_out +from monai.bundle.scripts import ckpt_export, download, init_bundle, run, verify_metadata, verify_net_in_out if __name__ == "__main__": from monai.utils import optional_import diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index 448b5ecf20..edbe4d1923 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -183,6 +183,19 @@ def set(self, config: Any, id: str = ""): """ self[id] = config + def __contains__(self, id: Union[str, int]) -> bool: + """ + Returns True if `id` is stored in this configuration. + + Args: + id: id to specify the expected position. See also :py:meth:`__getitem__`. + """ + try: + _ = self[id] + return True + except KeyError: + return False + def parse(self, reset: bool = True): """ Recursively resolve `self.config` to replace the macro tokens with target content. @@ -230,7 +243,7 @@ def read_meta(self, f: Union[PathLike, Sequence[PathLike], Dict], **kwargs): Args: f: filepath of the metadata file, the content must be a dictionary, - if providing a list of files, wil merge the content of them. + if providing a list of files, will merge the content of them. if providing a dictionary directly, use it as metadata. kwargs: other arguments for ``json.load`` or ``yaml.safe_load``, depends on the file format. diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 9ae0fae6c6..1b5118e0bb 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -17,6 +17,8 @@ import warnings from logging.config import fileConfig from pathlib import Path +from shutil import copyfile +from textwrap import dedent from typing import Dict, Mapping, Optional, Sequence, Tuple, Union import torch @@ -25,9 +27,10 @@ from monai.apps.utils import _basename, download_url, extractall, get_logger from monai.bundle.config_item import ConfigComponent from monai.bundle.config_parser import ConfigParser +from monai.bundle.utils import DEFAULT_INFERENCE, DEFAULT_METADATA from monai.config import IgniteInfo, PathLike from monai.data import load_net_with_metadata, save_net_with_metadata -from monai.networks import convert_to_torchscript, copy_model_state, get_state_dict +from monai.networks import convert_to_torchscript, copy_model_state, get_state_dict, save_state from monai.utils import check_parent_dir, get_equivalent_dtype, min_version, optional_import from monai.utils.misc import ensure_tuple @@ -621,3 +624,80 @@ def ckpt_export( more_extra_files=extra_files, ) logger.info(f"exported to TorchScript file: {filepath_}.") + + +def init_bundle( + bundle_dir: PathLike, + ckpt_file: Optional[PathLike] = None, + network: Optional[torch.nn.Module] = None, + metadata_str: Union[Dict, str] = DEFAULT_METADATA, + inference_str: Union[Dict, str] = DEFAULT_INFERENCE, +): + """ + Initialise a new bundle directory with some default configuration files and optionally network weights. + + Typical usage example: + + .. code-block:: bash + + python -m monai.bundle init_bundle /path/to/bundle_dir network_ckpt.pt + + Args: + bundle_dir: directory name to create, must not exist but parent direct must exist + ckpt_file: optional checkpoint file to copy into bundle + network: if given instead of ckpt_file this network's weights will be stored in bundle + """ + + bundle_dir = Path(bundle_dir).absolute() + + if bundle_dir.exists(): + raise ValueError(f"Specified bundle directory '{str(bundle_dir)}' already exists") + + if not bundle_dir.parent.is_dir(): + raise ValueError(f"Parent directory of specified bundle directory '{str(bundle_dir)}' does not exist") + + configs_dir = bundle_dir / "configs" + models_dir = bundle_dir / "models" + docs_dir = bundle_dir / "docs" + + bundle_dir.mkdir() + configs_dir.mkdir() + models_dir.mkdir() + docs_dir.mkdir() + + if isinstance(metadata_str, dict): + metadata_str = json.dumps(metadata_str, indent=4) + + if isinstance(inference_str, dict): + inference_str = json.dumps(inference_str, indent=4) + + with open(str(configs_dir / "metadata.json"), "w") as o: + o.write(metadata_str) + + with open(str(configs_dir / "inference.json"), "w") as o: + o.write(inference_str) + + with open(str(docs_dir / "README.md"), "w") as o: + readme = """ + # Your Model Name + + Describe your model here and how to run it, for example using `inference.json`: + + ``` + python -m monai.bundle run evaluating \ + --meta_file /path/to/bundle/configs/metadata.json \ + --config_file /path/to/bundle/configs/inference.json \ + --dataset_dir ./input \ + --bundle_root /path/to/bundle + ``` + """ + + o.write(dedent(readme)) + + with open(str(docs_dir / "license.txt"), "w") as o: + o.write("Select a license and place its terms here\n") + + if ckpt_file is not None: + copyfile(str(ckpt_file), str(models_dir / "model.pt")) + elif network is not None: + save_state(network, str(models_dir / "model.pt")) diff --git a/monai/bundle/utils.py b/monai/bundle/utils.py index ba5c2729e7..e3eb362941 100644 --- a/monai/bundle/utils.py +++ b/monai/bundle/utils.py @@ -9,6 +9,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json +import os +import zipfile +from typing import Any + +from monai.config.deviceconfig import get_config_values +from monai.utils import optional_import + +yaml, _ = optional_import("yaml") + __all__ = ["ID_REF_KEY", "ID_SEP_KEY", "EXPR_KEY", "MACRO_KEY"] @@ -16,3 +26,147 @@ ID_SEP_KEY = "#" # separator for the ID of a ConfigItem EXPR_KEY = "$" # start of a ConfigExpression MACRO_KEY = "%" # start of a macro of a config + + +_conf_values = get_config_values() + +DEFAULT_METADATA = { + "version": "0.0.1", + "changelog": {"0.0.1": "Initial version"}, + "monai_version": _conf_values["MONAI"], + "pytorch_version": _conf_values["Pytorch"], + "numpy_version": _conf_values["Numpy"], + "optional_packages_version": {}, + "task": "Describe what the network predicts", + "description": "A longer description of what the network does, use context, inputs, outputs, etc.", + "authors": "Your Name Here", + "copyright": "Copyright (c) Your Name Here", + "network_data_format": {"inputs": {}, "outputs": {}}, +} + +DEFAULT_INFERENCE = { + "imports": ["$import glob"], + "device": "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')", + "ckpt_path": "$@bundle_root + '/models/model.pt'", + "dataset_dir": "/workspace/data", + "datalist": "$list(sorted(glob.glob(@dataset_dir + '/*.jpeg')))", + "network_def": {"_target_": "???", "spatial_dims": 2}, + "network": "$@network_def.to(@device)", + "preprocessing": { + "_target_": "Compose", + "transforms": [ + {"_target_": "LoadImaged", "keys": "image"}, + {"_target_": "AddChanneld", "keys": "image"}, + {"_target_": "ScaleIntensityd", "keys": "image"}, + {"_target_": "EnsureTyped", "keys": "image", "device": "@device"}, + ], + }, + "dataset": {"_target_": "Dataset", "data": "$[{'image': i} for i in @datalist]", "transform": "@preprocessing"}, + "dataloader": { + "_target_": "DataLoader", + "dataset": "@dataset", + "batch_size": 1, + "shuffle": False, + "num_workers": 0, + }, + "inferer": {"_target_": "SimpleInferer"}, + "postprocessing": { + "_target_": "Compose", + "transforms": [ + {"_target_": "Activationsd", "keys": "pred", "softmax": True}, + {"_target_": "AsDiscreted", "keys": "pred", "argmax": True}, + ], + }, + "handlers": [ + { + "_target_": "CheckpointLoader", + "_disabled_": "$not os.path.exists(@ckpt_path)", + "load_path": "@ckpt_path", + "load_dict": {"model": "@network"}, + } + ], + "evaluator": { + "_target_": "SupervisedEvaluator", + "device": "@device", + "val_data_loader": "@dataloader", + "network": "@network", + "inferer": "@inferer", + "postprocessing": "@postprocessing", + "val_handlers": "@handlers", + }, + "evaluating": ["$@evaluator.run()"], +} + + +def load_bundle_config(bundle_path: str, *config_names, **load_kw_args) -> Any: + """ + Load the metadata and nominated configuration files from a MONAI bundle without loading the network itself. + + This function will load the information from the bundle, which can be a directory or a zip file containing a + directory or a Torchscript bundle, and return the parser object with the information. This saves having to load + the model if only the information is wanted, and can work on any sort of bundle format. + + Args: + bundle_path: path to the bundle directory or zip file + config_names: names of configuration files with extensions to load, should not be full paths but just name+ext + load_kw_args: keyword arguments to pass to the ConfigParser object when loading + + Returns: + ConfigParser object containing the parsed information + """ + + from monai.bundle.config_parser import ConfigParser # avoids circular import + + parser = ConfigParser() + + if not os.path.exists(bundle_path): + raise ValueError(f"Cannot find bundle file/directory '{bundle_path}'") + + # bundle is a directory, read files directly + if os.path.isdir(bundle_path): + conf_data = [] + parser.read_meta(f=os.path.join(bundle_path, "configs", "metadata.json"), **load_kw_args) + + for cname in config_names: + cpath = os.path.join(bundle_path, "configs", cname) + if not os.path.exists(cpath): + raise ValueError(f"Cannot find config file '{cpath}'") + + conf_data.append(cpath) + + parser.read_config(f=conf_data, **load_kw_args) + else: + # bundle is a zip file which is either a zipped directory or a Torchscript archive + + name, _ = os.path.splitext(os.path.basename(bundle_path)) + + archive = zipfile.ZipFile(bundle_path, "r") + + all_files = archive.namelist() + + zip_meta_name = f"{name}/configs/metadata.json" + + if zip_meta_name in all_files: + prefix = f"{name}/configs/" # zipped directory location for files + else: + zip_meta_name = f"{name}/extra/metadata.json" + prefix = f"{name}/extra/" # Torchscript location for files + + meta_json = json.loads(archive.read(zip_meta_name)) + parser.read_meta(f=meta_json) + + for cname in config_names: + full_cname = prefix + cname + if full_cname not in all_files: + raise ValueError(f"Cannot find config file '{full_cname}'") + + ardata = archive.read(full_cname) + + if full_cname.lower().endswith("json"): + cdata = json.loads(ardata, **load_kw_args) + elif full_cname.lower().endswith(("yaml", "yml")): + cdata = yaml.safe_load(ardata, **load_kw_args) + + parser.read_config(f=cdata) + + return parser diff --git a/tests/min_tests.py b/tests/min_tests.py index f17aaa85b0..f7e563dc79 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -168,6 +168,8 @@ def run_testsuit(): "test_bundle_verify_metadata", "test_bundle_verify_net", "test_bundle_ckpt_export", + "test_bundle_utils", + "test_bundle_init_bundle", ] assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}" diff --git a/tests/test_bundle_init_bundle.py b/tests/test_bundle_init_bundle.py new file mode 100644 index 0000000000..24fc425c31 --- /dev/null +++ b/tests/test_bundle_init_bundle.py @@ -0,0 +1,41 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import subprocess +import tempfile +import unittest + +import torch + +from monai.networks.nets import UNet +from tests.utils import skip_if_windows + + +@skip_if_windows +class TestBundleInit(unittest.TestCase): + def test_bundle(self): + with tempfile.TemporaryDirectory() as tempdir: + net = UNet(2, 1, 1, [4, 8], [2]) + torch.save(net.state_dict(), tempdir + "/test.pt") + + bundle_root = tempdir + "/test_bundle" + + cmd = ["coverage", "run", "-m", "monai.bundle", "init_bundle", bundle_root, tempdir + "/test.pt"] + subprocess.check_call(cmd) + + self.assertTrue(os.path.exists(bundle_root + "/configs/metadata.json")) + self.assertTrue(os.path.exists(bundle_root + "/configs/inference.json")) + self.assertTrue(os.path.exists(bundle_root + "/models/model.pt")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bundle_utils.py b/tests/test_bundle_utils.py new file mode 100644 index 0000000000..46b29651cd --- /dev/null +++ b/tests/test_bundle_utils.py @@ -0,0 +1,117 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import shutil +import subprocess +import tempfile +import unittest + +import torch + +from monai.bundle.utils import load_bundle_config +from monai.networks.nets import UNet +from tests.utils import skip_if_windows + +metadata = """ +{ + "test_value": 1, + "test_list": [2,3] +} +""" + +test_json = """ +{ + "test_dict": { + "a": 3, + "b": "c" + }, + "network_def": { + "_target_": "UNet", + "spatial_dims": 2, + "in_channels": 1, + "out_channels": 1, + "channels": [4,8], + "strides": [2] + } +} +""" + + +@skip_if_windows +class TestLoadBundleConfig(unittest.TestCase): + def setUp(self): + self.bundle_dir = tempfile.TemporaryDirectory() + self.dir_name = os.path.join(self.bundle_dir.name, "TestBundle") + self.configs_name = os.path.join(self.dir_name, "configs") + self.models_name = os.path.join(self.dir_name, "models") + self.metadata_name = os.path.join(self.configs_name, "metadata.json") + self.test_name = os.path.join(self.configs_name, "test.json") + self.modelpt_name = os.path.join(self.models_name, "model.pt") + + self.zip_file = os.path.join(self.bundle_dir.name, "TestBundle.zip") + self.ts_file = os.path.join(self.bundle_dir.name, "TestBundle.ts") + + # create the directories for the bundle + os.mkdir(self.dir_name) + os.mkdir(self.configs_name) + os.mkdir(self.models_name) + + # fill bundle configs + + with open(self.metadata_name, "w") as o: + o.write(metadata) + + with open(self.test_name, "w") as o: + o.write(test_json) + + # save network + net = UNet(2, 1, 1, [4, 8], [2]) + torch.save(net.state_dict(), self.modelpt_name) + + def tearDown(self): + self.bundle_dir.cleanup() + + def test_load_config_dir(self): + p = load_bundle_config(self.dir_name, "test.json") + + self.assertEqual(p["_meta_"]["test_value"], 1) + + self.assertEqual(p["test_dict"]["b"], "c") + + def test_load_config_zip(self): + # create a zip of the bundle + shutil.make_archive(self.zip_file[:-4], "zip", self.bundle_dir.name) + + p = load_bundle_config(self.zip_file, "test.json") + + self.assertEqual(p["_meta_"]["test_value"], 1) + + self.assertEqual(p["test_dict"]["b"], "c") + + def test_load_config_ts(self): + # create a Torchscript zip of the bundle + cmd = ["python", "-m", "monai.bundle", "ckpt_export", "network_def", "--filepath", self.ts_file] + cmd += ["--meta_file", self.metadata_name] + cmd += ["--config_file", self.test_name] + cmd += ["--ckpt_file", self.modelpt_name] + + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + + p = load_bundle_config(self.ts_file, "test.json") + + self.assertEqual(p["_meta_"]["test_value"], 1) + + self.assertEqual(p["test_dict"]["b"], "c") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index da230e7794..ffb6ebc634 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -195,6 +195,22 @@ def test_list_expressions(self): parser.get_parsed_content("training", lazy=True, instantiate=True, eval_expr=True) np.testing.assert_allclose(parser.get_parsed_content("training#1", lazy=True), [0.7942, 1.5885], atol=1e-4) + def test_contains(self): + empty_parser = ConfigParser({}) + empty_parser.parse() + + parser = ConfigParser({"value": 1, "entry": "string content"}) + parser.parse() + + with self.subTest("Testing empty parser"): + self.assertFalse("something" in empty_parser) + + with self.subTest("Testing with keys"): + self.assertTrue("value" in parser) + self.assertFalse("value1" in parser) + self.assertTrue("entry" in parser) + self.assertFalse("entr" in parser) + if __name__ == "__main__": unittest.main() From 6b4d9ebfa0e5d7bc13c75867deb88c06d68ea7c1 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 26 May 2022 23:15:36 +0100 Subject: [PATCH 097/183] 4323 deprecate SegmentationSaver, TorchVisionFullyConvModel (#4347) * deprecate SegmentationSaver, TorchVisionFullyConvModel Signed-off-by: Wenqi Li * fixes mintests Signed-off-by: Wenqi Li * remove typing Signed-off-by: Wenqi Li --- docs/source/handlers.rst | 6 - docs/source/networks.rst | 5 - monai/engines/evaluator.py | 6 +- monai/engines/trainer.py | 4 +- monai/engines/workflow.py | 2 +- monai/handlers/__init__.py | 1 - monai/handlers/segmentation_saver.py | 173 --------------------- monai/networks/nets/__init__.py | 2 +- monai/networks/nets/torchvision_fc.py | 47 +----- tests/min_tests.py | 1 - tests/test_handler_segmentation_saver.py | 90 ----------- tests/test_integration_sliding_window.py | 20 +-- tests/test_integration_workflows.py | 7 - tests/test_torchvision_fully_conv_model.py | 81 ---------- 14 files changed, 21 insertions(+), 424 deletions(-) delete mode 100644 monai/handlers/segmentation_saver.py delete mode 100644 tests/test_handler_segmentation_saver.py delete mode 100644 tests/test_torchvision_fully_conv_model.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index d32b6d88e3..0f0a4f6b44 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -95,12 +95,6 @@ Metric logger :members: -Segmentation saver ------------------- -.. autoclass:: SegmentationSaver - :members: - - Training stats handler ---------------------- .. autoclass:: StatsHandler diff --git a/docs/source/networks.rst b/docs/source/networks.rst index a9cd6a0735..dc9f559700 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -593,11 +593,6 @@ Nets .. autoclass:: TorchVisionFCModel :members: -`TorchVisionFullyConvModel` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: TorchVisionFullyConvModel - :members: - `MILModel` ~~~~~~~~~~ .. autoclass:: MILModel diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index b058935f67..7999bb9bd6 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -64,7 +64,7 @@ class Evaluator(Workflow): it must accept 2 args (current_metric, previous_best) and return a bool result: if `True`, will update `best_metric` and `best_metric_epoch` with current metric and epoch, default to `greater than`. val_handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: - CheckpointHandler, StatsHandler, SegmentationSaver, etc. + CheckpointHandler, StatsHandler, etc. amp: whether to enable auto-mixed-precision evaluation, default is False. mode: model forward mode during evaluation, should be 'eval' or 'train', which maps to `model.eval()` or `model.train()`, default to 'eval'. @@ -179,7 +179,7 @@ class SupervisedEvaluator(Evaluator): it must accept 2 args (current_metric, previous_best) and return a bool result: if `True`, will update `best_metric` and `best_metric_epoch` with current metric and epoch, default to `greater than`. val_handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: - CheckpointHandler, StatsHandler, SegmentationSaver, etc. + CheckpointHandler, StatsHandler, etc. amp: whether to enable auto-mixed-precision evaluation, default is False. mode: model forward mode during evaluation, should be 'eval' or 'train', which maps to `model.eval()` or `model.train()`, default to 'eval'. @@ -321,7 +321,7 @@ class EnsembleEvaluator(Evaluator): it must accept 2 args (current_metric, previous_best) and return a bool result: if `True`, will update `best_metric` and `best_metric_epoch` with current metric and epoch, default to `greater than`. val_handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: - CheckpointHandler, StatsHandler, SegmentationSaver, etc. + CheckpointHandler, StatsHandler, etc. amp: whether to enable auto-mixed-precision evaluation, default is False. mode: model forward mode during evaluation, should be 'eval' or 'train', which maps to `model.eval()` or `model.train()`, default to 'eval'. diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index fbdb309bb5..2b7a1acd2a 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -95,7 +95,7 @@ class SupervisedTrainer(Trainer): it must accept 2 args (current_metric, previous_best) and return a bool result: if `True`, will update `best_metric` and `best_metric_epoch` with current metric and epoch, default to `greater than`. train_handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: - CheckpointHandler, StatsHandler, SegmentationSaver, etc. + CheckpointHandler, StatsHandler, etc. amp: whether to enable auto-mixed-precision training, default is False. event_names: additional custom ignite events that will register to the engine. new events can be a list of str or `ignite.engine.events.EventEnum`. @@ -271,7 +271,7 @@ class GanTrainer(Trainer): it must accept 2 args (current_metric, previous_best) and return a bool result: if `True`, will update `best_metric` and `best_metric_epoch` with current metric and epoch, default to `greater than`. train_handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: - CheckpointHandler, StatsHandler, SegmentationSaver, etc. + CheckpointHandler, StatsHandler, etc. decollate: whether to decollate the batch-first data to a list of data after model computation, recommend `decollate=True` when `postprocessing` uses components from `monai.transforms`. default to `True`. diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 5b3cb556a5..8349ff82ab 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -84,7 +84,7 @@ class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optiona it must accept 2 args (current_metric, previous_best) and return a bool result: if `True`, will update `best_metric` and `best_metric_epoch` with current metric and epoch, default to `greater than`. handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: - CheckpointHandler, StatsHandler, SegmentationSaver, etc. + CheckpointHandler, StatsHandler, etc. amp: whether to enable auto-mixed-precision training or inference, default is False. event_names: additional custom ignite events that will register to the engine. new events can be a list of str or `ignite.engine.events.EventEnum`. diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 649cc3cae6..43fd634b49 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -28,7 +28,6 @@ from .postprocessing import PostProcessing from .regression_metrics import MeanAbsoluteError, MeanSquaredError, PeakSignalToNoiseRatio, RootMeanSquaredError from .roc_auc import ROCAUC -from .segmentation_saver import SegmentationSaver from .smartcache_handler import SmartCacheHandler from .stats_handler import StatsHandler from .surface_distance import SurfaceDistance diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py deleted file mode 100644 index 24bd3edb5c..0000000000 --- a/monai/handlers/segmentation_saver.py +++ /dev/null @@ -1,173 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import logging -from typing import TYPE_CHECKING, Callable, Optional, Union - -import numpy as np - -from monai.config import DtypeLike, IgniteInfo -from monai.data import decollate_batch -from monai.transforms import SaveImage -from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, deprecated, min_version, optional_import - -Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") -if TYPE_CHECKING: - from ignite.engine import Engine -else: - Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") - - -@deprecated(since="0.6.0", removed="0.9.0", msg_suffix="Please consider using `SaveImage[d]` transform instead.") -class SegmentationSaver: - """ - Event handler triggered on completing every iteration to save the segmentation predictions into files. - It can extract the input image metadata(filename, affine, original_shape, etc.) and resample the predictions - based on the metadata. - The name of saved file will be `{input_image_name}_{output_postfix}{output_ext}`, - where the input image name is extracted from the metadata dictionary. If no metadata provided, - use index from 0 as the filename prefix. - The predictions can be PyTorch Tensor with [B, C, H, W, [D]] shape or a list of Tensor without batch dim. - - .. deprecated:: 0.6.0 - Use :class:`monai.transforms.SaveImage` or :class:`monai.transforms.SaveImaged` instead. - - """ - - def __init__( - self, - output_dir: str = "./", - output_postfix: str = "seg", - output_ext: str = ".nii.gz", - resample: bool = True, - mode: Union[GridSampleMode, InterpolateMode, str] = "nearest", - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, - scale: Optional[int] = None, - dtype: DtypeLike = np.float64, - output_dtype: DtypeLike = np.float32, - squeeze_end_dims: bool = True, - data_root_dir: str = "", - separate_folder: bool = True, - batch_transform: Callable = lambda x: x, - output_transform: Callable = lambda x: x, - name: Optional[str] = None, - ) -> None: - """ - Args: - output_dir: output image directory. - output_postfix: a string appended to all output file names, default to `seg`. - output_ext: output file extension name, available extensions: `.nii.gz`, `.nii`, `.png`. - resample: whether to resample before saving the data array. - if saving PNG format image, based on the `spatial_shape` from metadata. - if saving NIfTI format image, based on the `original_affine` from metadata. - mode: This option is used when ``resample = True``. Defaults to ``"nearest"``. - - - NIfTI files {``"bilinear"``, ``"nearest"``} - Interpolation mode to calculate output values. - See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html - - PNG files {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} - The interpolation mode. - See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html - - padding_mode: This option is used when ``resample = True``. Defaults to ``"border"``. - - - NIfTI files {``"zeros"``, ``"border"``, ``"reflection"``} - Padding mode for outside grid values. - See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html - - PNG files - This option is ignored. - - scale: {``255``, ``65535``} postprocess data by clipping to [0, 1] and scaling - [0, 255] (uint8) or [0, 65535] (uint16). Default is None to disable scaling. - It's used for PNG format only. - dtype: data type for resampling computation. Defaults to ``np.float64`` for best precision. - If None, use the data type of input data. - It's used for Nifti format only. - output_dtype: data type for saving data. Defaults to ``np.float32``, it's used for Nifti format only. - squeeze_end_dims: if True, any trailing singleton dimensions will be removed (after the channel - has been moved to the end). So if input is (C,H,W,D), this will be altered to (H,W,D,C), and - then if C==1, it will be saved as (H,W,D). If D also ==1, it will be saved as (H,W). If false, - image will always be saved as (H,W,D,C). - it's used for NIfTI format only. - data_root_dir: if not empty, it specifies the beginning parts of the input file's - absolute path. it's used to compute `input_file_rel_path`, the relative path to the file from - `data_root_dir` to preserve folder structure when saving in case there are files in different - folders with the same file names. for example: - input_file_name: /foo/bar/test1/image.nii, - output_postfix: seg - output_ext: nii.gz - output_dir: /output, - data_root_dir: /foo/bar, - output will be: /output/test1/image/image_seg.nii.gz - separate_folder: whether to save every file in a separate folder, for example: if input filename is - `image.nii`, postfix is `seg` and folder_path is `output`, if `True`, save as: - `output/image/image_seg.nii`, if `False`, save as `output/image_seg.nii`. default to `True`. - batch_transform: a callable that is used to extract the `meta_data` dictionary of the input images - from `ignite.engine.state.batch`. the purpose is to extract necessary information from the metadata: - filename, affine, original_shape, etc. - `engine.state` and `batch_transform` inherit from the ignite concept: - https://pytorch.org/ignite/concepts.html#state, explanation and usage example are in the tutorial: - https://github.com/Project-MONAI/tutorials/blob/master/modules/batch_output_transform.ipynb. - output_transform: a callable that is used to extract the model prediction data from - `ignite.engine.state.output`. the first dimension of its output will be treated as the batch dimension. - each item in the batch will be saved individually. - `engine.state` and `output_transform` inherit from the ignite concept: - https://pytorch.org/ignite/concepts.html#state, explanation and usage example are in the tutorial: - https://github.com/Project-MONAI/tutorials/blob/master/modules/batch_output_transform.ipynb. - name: identifier of logging.logger to use, defaulting to `engine.logger`. - - """ - self._saver = SaveImage( - output_dir=output_dir, - output_postfix=output_postfix, - output_ext=output_ext, - resample=resample, - mode=mode, - padding_mode=padding_mode, - scale=scale, - dtype=dtype, - output_dtype=output_dtype, - squeeze_end_dims=squeeze_end_dims, - data_root_dir=data_root_dir, - separate_folder=separate_folder, - ) - self.batch_transform = batch_transform - self.output_transform = output_transform - - self.logger = logging.getLogger(name) - self._name = name - - def attach(self, engine: Engine) -> None: - """ - Args: - engine: Ignite Engine, it can be a trainer, validator or evaluator. - """ - if self._name is None: - self.logger = engine.logger - if not engine.has_event_handler(self, Events.ITERATION_COMPLETED): - engine.add_event_handler(Events.ITERATION_COMPLETED, self) - - def __call__(self, engine: Engine) -> None: - """ - This method assumes self.batch_transform will extract metadata from the input batch. - Output file datatype is determined from ``engine.state.output.dtype``. - - Args: - engine: Ignite Engine, it can be a trainer, validator or evaluator. - """ - meta_data = self.batch_transform(engine.state.batch) - if isinstance(meta_data, dict): - # decollate the `dictionary of list` to `list of dictionaries` - meta_data = decollate_batch(meta_data) - engine_output = self.output_transform(engine.state.output) - for m, o in zip(meta_data, engine_output): - self._saver(o, m) - self.logger.info("model outputs saved into files.") diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index 394ff51907..a85d55769c 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -81,7 +81,7 @@ seresnext101, ) from .swin_unetr import SwinUNETR -from .torchvision_fc import TorchVisionFCModel, TorchVisionFullyConvModel +from .torchvision_fc import TorchVisionFCModel from .transchex import BertAttention, BertMixedLayer, BertOutput, BertPreTrainedModel, MultiModal, Pooler, Transchex from .unet import UNet, Unet from .unetr import UNETR diff --git a/monai/networks/nets/torchvision_fc.py b/monai/networks/nets/torchvision_fc.py index e93019d050..ddfee6e041 100644 --- a/monai/networks/nets/torchvision_fc.py +++ b/monai/networks/nets/torchvision_fc.py @@ -9,15 +9,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple from monai.networks.nets import NetAdapter -from monai.utils import deprecated, deprecated_arg, optional_import +from monai.utils import deprecated_arg, optional_import models, _ = optional_import("torchvision.models") -__all__ = ["TorchVisionFCModel", "TorchVisionFullyConvModel"] +__all__ = ["TorchVisionFCModel"] class TorchVisionFCModel(NetAdapter): @@ -72,44 +72,3 @@ def __init__( pool=pool, bias=bias, ) - - -@deprecated(since="0.6.0", removed="0.9.0", msg_suffix="Please consider using `TorchVisionFCModel` instead.") -class TorchVisionFullyConvModel(TorchVisionFCModel): - """ - Customize TorchVision models to replace fully connected layer by convolutional layer. - - Args: - model_name: name of any torchvision with adaptive avg pooling and fully connected layer at the end. - ``resnet18`` (default), ``resnet34``, ``resnet50``, ``resnet101``, ``resnet152``, - ``resnext50_32x4d``, ``resnext101_32x8d``, ``wide_resnet50_2``, ``wide_resnet101_2``. - num_classes: number of classes for the last classification layer. Default to 1. - pool_size: the kernel size for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to (7, 7). - pool_stride: the stride for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to 1. - pretrained: whether to use the imagenet pretrained weights. Default to False. - - .. deprecated:: 0.6.0 - Use :class:`monai.networks.nets.TorchVisionFCModel` instead. - - """ - - @deprecated_arg("n_classes", since="0.6") - def __init__( - self, - model_name: str = "resnet18", - num_classes: int = 1, - pool_size: Union[int, Tuple[int, int]] = (7, 7), - pool_stride: Union[int, Tuple[int, int]] = 1, - pretrained: bool = False, - n_classes: Optional[int] = None, - ): - # in case the new num_classes is default but you still call deprecated n_classes - if n_classes is not None and num_classes == 1: - num_classes = n_classes - super().__init__( - model_name=model_name, - num_classes=num_classes, - use_conv=True, - pool=("avg", {"kernel_size": pool_size, "stride": pool_stride}), - pretrained=pretrained, - ) diff --git a/tests/min_tests.py b/tests/min_tests.py index f7e563dc79..b52dc2a73d 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -77,7 +77,6 @@ def run_testsuit(): "test_handler_regression_metrics_dist", "test_handler_rocauc", "test_handler_rocauc_dist", - "test_handler_segmentation_saver", "test_handler_smartcache", "test_handler_stats", "test_handler_surface_distance", diff --git a/tests/test_handler_segmentation_saver.py b/tests/test_handler_segmentation_saver.py deleted file mode 100644 index ee6566f6cb..0000000000 --- a/tests/test_handler_segmentation_saver.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import tempfile -import unittest - -import numpy as np -import torch -from ignite.engine import Engine -from parameterized import parameterized - -from monai.data import decollate_batch -from monai.handlers import SegmentationSaver - -TEST_CASE_0 = [".nii.gz"] - -TEST_CASE_1 = [".png"] - - -class TestHandlerSegmentationSaver(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1]) - def test_saved_content(self, output_ext): - with tempfile.TemporaryDirectory() as tempdir: - - # set up engine - def _train_func(engine, batch): - engine.state.batch = decollate_batch(batch) - return [torch.randint(0, 255, (1, 2, 2)).float() for _ in range(8)] - - engine = Engine(_train_func) - - # set up testing handler - saver = SegmentationSaver( - output_dir=tempdir, output_postfix="seg", output_ext=output_ext, scale=255, output_dtype=np.uint8 - ) - saver.attach(engine) - - data = [ - { - "filename_or_obj": ["testfile" + str(i) + ".nii.gz" for i in range(8)], - "patch_index": torch.tensor(list(range(8))), - } - ] - engine.run(data, max_epochs=1) - for i in range(8): - filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_seg" + f"_{i}" + output_ext) - self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_1]) - def test_save_resized_content(self, output_ext): - with tempfile.TemporaryDirectory() as tempdir: - - # set up engine - def _train_func(engine, batch): - engine.state.batch = decollate_batch(batch) - return [torch.randint(0, 255, (1, 2, 2)).float() for _ in range(8)] - - engine = Engine(_train_func) - - # set up testing handler - saver = SegmentationSaver( - output_dir=tempdir, output_postfix="seg", output_ext=output_ext, scale=255, output_dtype=np.uint8 - ) - saver.attach(engine) - - data = [ - { - "filename_or_obj": ["testfile" + str(i) + ".nii.gz" for i in range(8)], - "spatial_shape": torch.tensor([[28, 28] for _ in range(8)]), - "affine": torch.tensor([np.diag(np.ones(4)) * 5 for _ in range(8)]), - "original_affine": torch.tensor([np.diag(np.ones(4)) * 1.0 for _ in range(8)]), - } - ] - engine.run(data, max_epochs=1) - for i in range(8): - filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_seg" + output_ext) - self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_integration_sliding_window.py b/tests/test_integration_sliding_window.py index af49e3db77..3c21edd9c3 100644 --- a/tests/test_integration_sliding_window.py +++ b/tests/test_integration_sliding_window.py @@ -16,15 +16,14 @@ import nibabel as nib import numpy as np import torch -from ignite.engine import Engine +from ignite.engine import Engine, Events from torch.utils.data import DataLoader -from monai.data import ImageDataset, create_test_image_3d -from monai.handlers import SegmentationSaver +from monai.data import ImageDataset, create_test_image_3d, decollate_batch from monai.inferers import sliding_window_inference from monai.networks import eval_mode, predict_segmentation from monai.networks.nets import UNet -from monai.transforms import AddChannel +from monai.transforms import AddChannel, SaveImage from monai.utils import set_determinism from tests.utils import DistTestCase, TimedCall, make_nifti_image, skip_if_quick @@ -39,18 +38,21 @@ def run_test(batch_size, img_name, seg_name, output_dir, device="cuda:0"): roi_size = (16, 32, 48) sw_batch_size = batch_size + saver = SaveImage(output_dir=output_dir, output_ext=".nii.gz", output_postfix="seg") + def _sliding_window_processor(_engine, batch): img = batch[0] # first item from ImageDataset is the input image with eval_mode(net): seg_probs = sliding_window_inference(img.to(device), roi_size, sw_batch_size, net, device=device) return predict_segmentation(seg_probs) - infer_engine = Engine(_sliding_window_processor) - - SegmentationSaver( # 3rd item for image batch meta data - output_dir=output_dir, output_ext=".nii.gz", output_postfix="seg", batch_transform=lambda x: x[2] - ).attach(infer_engine) + def save_func(engine): + meta_data = decollate_batch(engine.state.batch[2]) + for m, o in zip(meta_data, engine.state.output): + saver(o, m) + infer_engine = Engine(_sliding_window_processor) + infer_engine.add_event_handler(Events.ITERATION_COMPLETED, save_func) infer_engine.run(loader) basename = os.path.basename(img_name)[: -len(".nii.gz")] diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index f748eb8732..c6f9531ef3 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -30,7 +30,6 @@ CheckpointSaver, LrScheduleHandler, MeanDice, - SegmentationSaver, StatsHandler, TensorBoardImageHandler, TensorBoardStatsHandler, @@ -256,12 +255,6 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor val_handlers = [ StatsHandler(iteration_log=False), CheckpointLoader(load_path=f"{model_file}", load_dict={"net": net}), - SegmentationSaver( - output_dir=root_dir, - output_postfix="seg_handler", - batch_transform=from_engine(PostFix.meta("image")), - output_transform=from_engine("pred"), - ), ] evaluator = SupervisedEvaluator( diff --git a/tests/test_torchvision_fully_conv_model.py b/tests/test_torchvision_fully_conv_model.py deleted file mode 100644 index 34a61ce9fa..0000000000 --- a/tests/test_torchvision_fully_conv_model.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import unittest -from unittest import skipUnless - -import torch -from parameterized import parameterized - -from monai.networks import eval_mode -from monai.networks.nets import TorchVisionFullyConvModel -from monai.utils import optional_import - -_, has_tv = optional_import("torchvision") - -device = "cuda" if torch.cuda.is_available() else "cpu" - -TEST_CASE_0 = [{"model_name": "resnet18", "num_classes": 1, "pretrained": False}, (2, 3, 224, 224), (2, 1, 1, 1)] - -TEST_CASE_1 = [{"model_name": "resnet18", "num_classes": 1, "pretrained": False}, (2, 3, 256, 256), (2, 1, 2, 2)] - -TEST_CASE_2 = [{"model_name": "resnet101", "num_classes": 5, "pretrained": False}, (2, 3, 256, 256), (2, 5, 2, 2)] - -TEST_CASE_3 = [ - {"model_name": "resnet101", "num_classes": 5, "pool_size": 6, "pretrained": False}, - (2, 3, 224, 224), - (2, 5, 2, 2), -] - -TEST_CASE_PRETRAINED_0 = [ - {"model_name": "resnet18", "num_classes": 1, "pretrained": True}, - (2, 3, 224, 224), - (2, 1, 1, 1), - -0.010419349186122417, -] - -TEST_CASE_PRETRAINED_1 = [ - {"model_name": "resnet18", "num_classes": 1, "pretrained": True}, - (2, 3, 256, 256), - (2, 1, 2, 2), - -0.010419349186122417, -] - -TEST_CASE_PRETRAINED_2 = [ - {"model_name": "resnet18", "num_classes": 5, "pretrained": True}, - (2, 3, 256, 256), - (2, 5, 2, 2), - -0.010419349186122417, -] - - -class TestTorchVisionFullyConvModel(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - @skipUnless(has_tv, "Requires TorchVision.") - def test_without_pretrained(self, input_param, input_shape, expected_shape): - net = TorchVisionFullyConvModel(**input_param).to(device) - with eval_mode(net): - result = net.forward(torch.randn(input_shape).to(device)) - self.assertEqual(result.shape, expected_shape) - - @parameterized.expand([TEST_CASE_PRETRAINED_0, TEST_CASE_PRETRAINED_1, TEST_CASE_PRETRAINED_2]) - @skipUnless(has_tv, "Requires TorchVision.") - def test_with_pretrained(self, input_param, input_shape, expected_shape, expected_value): - net = TorchVisionFullyConvModel(**input_param).to(device) - with eval_mode(net): - result = net.forward(torch.randn(input_shape).to(device)) - value = next(net.parameters())[0, 0, 0, 0].item() - self.assertEqual(value, expected_value) - self.assertEqual(result.shape, expected_shape) - - -if __name__ == "__main__": - unittest.main() From b6302d764974b2c8289dc92f0b16af8c6d23575e Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 27 May 2022 00:45:51 +0100 Subject: [PATCH 098/183] improve dtype conversion (#4357) improve dtype conversion --- monai/utils/__init__.py | 2 ++ monai/utils/type_conversion.py | 38 +++++++++++++----------------- tests/test_get_equivalent_dtype.py | 12 +++++++++- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 76a05940cb..e7ecab077d 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -96,4 +96,6 @@ dtype_torch_to_numpy, get_dtype, get_equivalent_dtype, + get_numpy_dtype_from_string, + get_torch_dtype_from_string, ) diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index f03e4f52b1..a6cd2522d7 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -17,12 +17,13 @@ from monai.config.type_definitions import DtypeLike, NdarrayTensor from monai.utils import optional_import -from monai.utils.module import look_up_option cp, has_cp = optional_import("cupy") cp_ndarray, _ = optional_import("cupy", name="ndarray") __all__ = [ + "get_numpy_dtype_from_string", + "get_torch_dtype_from_string", "dtype_torch_to_numpy", "dtype_numpy_to_torch", "get_equivalent_dtype", @@ -35,37 +36,32 @@ ] -_torch_to_np_dtype = { - torch.bool: np.dtype(bool), - torch.uint8: np.dtype(np.uint8), - torch.int8: np.dtype(np.int8), - torch.int16: np.dtype(np.int16), - torch.int32: np.dtype(np.int32), - torch.int64: np.dtype(np.int64), - torch.float16: np.dtype(np.float16), - torch.float32: np.dtype(np.float32), - torch.float64: np.dtype(np.float64), - torch.complex64: np.dtype(np.complex64), - torch.complex128: np.dtype(np.complex128), -} -_np_to_torch_dtype = {value: key for key, value in _torch_to_np_dtype.items()} +def get_numpy_dtype_from_string(dtype: str) -> np.dtype: + """Get a numpy dtype (e.g., `np.float32`) from its string (e.g., `"float32"`).""" + return np.zeros([], dtype=dtype).dtype # type: ignore -def dtype_torch_to_numpy(dtype): +def get_torch_dtype_from_string(dtype: str) -> torch.dtype: + """Get a torch dtype (e.g., `torch.float32`) from its string (e.g., `"float32"`).""" + return dtype_numpy_to_torch(get_numpy_dtype_from_string(dtype)) + + +def dtype_torch_to_numpy(dtype: torch.dtype) -> np.dtype: """Convert a torch dtype to its numpy equivalent.""" - return look_up_option(dtype, _torch_to_np_dtype) + return torch.zeros([], dtype=dtype).numpy().dtype # type: ignore -def dtype_numpy_to_torch(dtype): +def dtype_numpy_to_torch(dtype: np.dtype) -> torch.dtype: """Convert a numpy dtype to its torch equivalent.""" - # np dtypes can be given as np.float32 and np.dtype(np.float32) so unify them - dtype = np.dtype(dtype) if isinstance(dtype, (type, str)) else dtype - return look_up_option(dtype, _np_to_torch_dtype) + return torch.from_numpy(np.zeros([], dtype=dtype)).dtype def get_equivalent_dtype(dtype, data_type): """Convert to the `dtype` that corresponds to `data_type`. + The input dtype can also be a string. e.g., `"float32"` becomes `torch.float32` or + `np.float32` as necessary. + Example:: im = torch.tensor(1) diff --git a/tests/test_get_equivalent_dtype.py b/tests/test_get_equivalent_dtype.py index fc0867523d..a4df3ac2ac 100644 --- a/tests/test_get_equivalent_dtype.py +++ b/tests/test_get_equivalent_dtype.py @@ -15,7 +15,7 @@ import torch from parameterized import parameterized -from monai.utils.type_conversion import get_equivalent_dtype +from monai.utils.type_conversion import get_equivalent_dtype, get_numpy_dtype_from_string, get_torch_dtype_from_string from tests.utils import TEST_NDARRAYS DTYPES = [torch.float32, np.float32, np.dtype(np.float32)] @@ -40,6 +40,16 @@ def test_native_type(self): out_dtype = get_equivalent_dtype(n, type(im_dtype)) self.assertEqual(out_dtype, n) + @parameterized.expand([["float", np.float64], ["float32", np.float32], ["float64", np.float64]]) + def test_from_string(self, dtype_str, expected_np): + expected_pt = get_equivalent_dtype(expected_np, torch.Tensor) + # numpy + dtype = get_numpy_dtype_from_string(dtype_str) + self.assertEqual(dtype, expected_np) + # torch + dtype = get_torch_dtype_from_string(dtype_str) + self.assertEqual(dtype, expected_pt) + if __name__ == "__main__": unittest.main() From 1cfec6855cbcfc3f0a80886a0a6bdd06c4662a6c Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 27 May 2022 10:53:21 +0800 Subject: [PATCH 099/183] 4348 Improve doc and unit tests for "correct center" of crop transforms (#4362) [DLMED] improve doc and tests Signed-off-by: Nic Ma --- monai/transforms/croppad/array.py | 4 ++++ monai/transforms/croppad/dictionary.py | 4 ++++ tests/test_rand_crop_by_pos_neg_labeld.py | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 330c5124ee..1245daa62c 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -851,6 +851,8 @@ class RandCropByPosNegLabel(Randomizable, Transform): If a dimension of the expected spatial size is bigger than the input image size, will not crop that dimension. So the cropped result may be smaller than expected size, and the cropped results of several images may not have exactly same shape. + And if the crop ROI is partly out of the image, will automatically adjust the crop center to ensure the + valid crop ROI. Args: spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. @@ -1021,6 +1023,8 @@ class RandCropByLabelClasses(Randomizable, Transform): If a dimension of the expected spatial size is bigger than the input image size, will not crop that dimension. So the cropped result may be smaller than expected size, and the cropped results of several images may not have exactly same shape. + And if the crop ROI is partly out of the image, will automatically adjust the crop center to ensure the + valid crop ROI. Args: spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index d136340669..57b257b428 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -1051,6 +1051,8 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform, InvertibleTransform): If a dimension of the expected spatial size is bigger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected size, and the cropped results of several images may not have exactly the same shape. + And if the crop ROI is partly out of the image, will automatically adjust the crop center + to ensure the valid crop ROI. Args: keys: keys of the corresponding items to be transformed. @@ -1259,6 +1261,8 @@ class RandCropByLabelClassesd(Randomizable, MapTransform, InvertibleTransform): If a dimension of the expected spatial size is bigger than the input image size, will not crop that dimension. So the cropped result may be smaller than expected size, and the cropped results of several images may not have exactly same shape. + And if the crop ROI is partly out of the image, will automatically adjust the crop center to ensure the + valid crop ROI. Args: keys: keys of the corresponding items to be transformed. diff --git a/tests/test_rand_crop_by_pos_neg_labeld.py b/tests/test_rand_crop_by_pos_neg_labeld.py index 5a1eacfa93..a2808bd65d 100644 --- a/tests/test_rand_crop_by_pos_neg_labeld.py +++ b/tests/test_rand_crop_by_pos_neg_labeld.py @@ -148,6 +148,13 @@ def test_type_shape(self, input_param, input_data, expected_shape): for i, item in enumerate(result): self.assertEqual(item[PostFix.meta(k)]["patch_index"], i) + def test_correct_center(self): + cropper = RandCropByPosNegLabeld(keys="label", label_key="label", spatial_size=[3, 3]) + cropper.set_random_state(0) + test_image = {"label": np.asarray([[[1, 0, 0, 1], [0, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 1]]])} + result = cropper(test_image) + np.testing.assert_allclose(result[0]["label"], np.asarray([[[0, 0, 1], [0, 0, 0], [0, 0, 0]]])) + if __name__ == "__main__": unittest.main() From b96f8bdb3b5d7c61ba2abf94ab26ccc36cec8ec3 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 27 May 2022 15:49:55 +0100 Subject: [PATCH 100/183] resize skip interpolation if spatial shapes match (#4369) skip resizing if spatial shape matches Signed-off-by: Wenqi Li --- monai/transforms/spatial/array.py | 14 ++++++++------ tests/test_resize.py | 7 +++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index e2176b4e7d..f833f57ebb 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -687,26 +687,28 @@ def __call__( anti_aliasing = self.anti_aliasing if anti_aliasing is None else anti_aliasing anti_aliasing_sigma = self.anti_aliasing_sigma if anti_aliasing_sigma is None else anti_aliasing_sigma - img_, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float) if self.size_mode == "all": - input_ndim = img_.ndim - 1 # spatial ndim + input_ndim = img.ndim - 1 # spatial ndim output_ndim = len(ensure_tuple(self.spatial_size)) if output_ndim > input_ndim: - input_shape = ensure_tuple_size(img_.shape, output_ndim + 1, 1) - img_ = img_.reshape(input_shape) + input_shape = ensure_tuple_size(img.shape, output_ndim + 1, 1) + img = img.reshape(input_shape) elif output_ndim < input_ndim: raise ValueError( "len(spatial_size) must be greater or equal to img spatial dimensions, " f"got spatial_size={output_ndim} img={input_ndim}." ) - spatial_size_ = fall_back_tuple(self.spatial_size, img_.shape[1:]) + spatial_size_ = fall_back_tuple(self.spatial_size, img.shape[1:]) else: # for the "longest" mode - img_size = img_.shape[1:] + img_size = img.shape[1:] if not isinstance(self.spatial_size, int): raise ValueError("spatial_size must be an int number if size_mode is 'longest'.") scale = self.spatial_size / max(img_size) spatial_size_ = tuple(int(round(s * scale)) for s in img_size) + if tuple(img.shape[1:]) == spatial_size_: # spatial shape is already the desired + return img + img_, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float) if anti_aliasing and any(x < y for x, y in zip(spatial_size_, img_.shape[1:])): factors = torch.div(torch.Tensor(list(img_.shape[1:])), torch.Tensor(spatial_size_)) if anti_aliasing_sigma is None: diff --git a/tests/test_resize.py b/tests/test_resize.py index a4a5d0f2ea..5f946a13e3 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -17,7 +17,7 @@ from parameterized import parameterized from monai.transforms import Resize -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose, pytorch_after +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose, is_tf32_env, pytorch_after TEST_CASE_0 = [{"spatial_size": 15}, (6, 10, 15)] @@ -29,6 +29,8 @@ TEST_CASE_4 = [{"spatial_size": 6, "anti_aliasing": True, "anti_aliasing_sigma": 2.0}, (2, 4, 6)] +diff_t = 0.3 if is_tf32_env() else 0.2 + class TestResize(NumpyImageTestCase2D): def test_invalid_inputs(self): @@ -47,6 +49,7 @@ def test_invalid_inputs(self): ((32, 32, 32), "trilinear", True), ((256, 256), "bilinear", False), ((256, 256), "nearest-exact" if pytorch_after(1, 11) else "nearest", False), + ((128, 64), "area", True), # already in a good shape ] ) def test_correct_results(self, spatial_size, mode, anti_aliasing): @@ -77,7 +80,7 @@ def test_correct_results(self, spatial_size, mode, anti_aliasing): out = out.cpu().detach().numpy() good = np.sum(np.isclose(expected, out, atol=0.9)) self.assertLessEqual( - np.abs(good - expected.size) / float(expected.size), 0.21, "at most 21 percent mismatch " + np.abs(good - expected.size) / float(expected.size), diff_t, f"at most {diff_t} percent mismatch " ) @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) From d46904b563d81f4eda48dc38ff4df4330b521e38 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 27 May 2022 19:50:38 +0100 Subject: [PATCH 101/183] fixes integration test (#4373) Signed-off-by: Wenqi Li --- tests/test_integration_workflows.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index c6f9531ef3..852228efc4 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -19,11 +19,12 @@ import nibabel as nib import numpy as np import torch +from ignite.engine import Events from ignite.metrics import Accuracy from torch.utils.tensorboard import SummaryWriter import monai -from monai.data import create_test_image_3d +from monai.data import create_test_image_3d, decollate_batch from monai.engines import IterationEvents, SupervisedEvaluator, SupervisedTrainer from monai.handlers import ( CheckpointLoader, @@ -46,6 +47,7 @@ LoadImaged, RandCropByPosNegLabeld, RandRotate90d, + SaveImage, SaveImaged, ScaleIntensityd, ToTensord, @@ -257,6 +259,15 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor CheckpointLoader(load_path=f"{model_file}", load_dict={"net": net}), ] + saver = SaveImage(output_dir=root_dir, output_postfix="seg_handler") + + def save_func(engine): + meta_data = from_engine(PostFix.meta("image"))(engine.state.batch) + if isinstance(meta_data, dict): + meta_data = decollate_batch(meta_data) + for m, o in zip(meta_data, from_engine("pred")(engine.state.output)): + saver(o, m) + evaluator = SupervisedEvaluator( device=device, val_data_loader=val_loader, @@ -270,6 +281,7 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor val_handlers=val_handlers, amp=bool(amp), ) + evaluator.add_event_handler(Events.ITERATION_COMPLETED, save_func) evaluator.run() return evaluator.state.best_metric From f76dd1c4a6f8caf5af6fb8a4928b96d0c65aaeca Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Fri, 27 May 2022 16:54:44 -0400 Subject: [PATCH 102/183] add box coder for detection (#4365) * add box coder Signed-off-by: Can Zhao * doc Signed-off-by: Can Zhao * add security check Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao * Update test_box_coder.py * docstring Signed-off-by: Can Zhao docstring Signed-off-by: Can Zhao docstring Signed-off-by: Can Zhao Update test_box_coder.py Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao docstring Signed-off-by: Can Zhao docstring Signed-off-by: Can Zhao Update test_box_coder.py Signed-off-by: Can Zhao docstring Signed-off-by: Can Zhao Update test_box_coder.py Signed-off-by: Can Zhao * update docstring Signed-off-by: Can Zhao --- docs/source/apps.rst | 5 + monai/apps/detection/utils/__init__.py | 10 + monai/apps/detection/utils/box_coder.py | 246 ++++++++++++++++++++++++ tests/test_box_coder.py | 41 ++++ 4 files changed, 302 insertions(+) create mode 100644 monai/apps/detection/utils/__init__.py create mode 100644 monai/apps/detection/utils/box_coder.py create mode 100644 tests/test_box_coder.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 1a11fc62c6..4b62abd2e3 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -134,3 +134,8 @@ Applications :members: .. automodule:: monai.apps.detection.transforms.dictionary :members: + +`Box coder` +~~~~~~~~~~~ +.. automodule:: monai.apps.detection.utils.box_coder + :members: diff --git a/monai/apps/detection/utils/__init__.py b/monai/apps/detection/utils/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/utils/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/apps/detection/utils/box_coder.py b/monai/apps/detection/utils/box_coder.py new file mode 100644 index 0000000000..e8b430af67 --- /dev/null +++ b/monai/apps/detection/utils/box_coder.py @@ -0,0 +1,246 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/main/torchvision/models/detection/_utils.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE +# +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +This script is modified from torchvision to support N-D images, + +https://github.com/pytorch/vision/blob/main/torchvision/models/detection/_utils.py +""" + +import math +from typing import Sequence, Tuple, Union + +import torch +from torch import Tensor + +from monai.data.box_utils import ( + COMPUTE_DTYPE, + CenterSizeMode, + CornerCornerModeTypeB, + StandardMode, + convert_box_mode, + convert_box_to_standard_mode, + is_valid_box_values, +) +from monai.utils.module import look_up_option + + +def encode_boxes(gt_boxes: Tensor, proposals: Tensor, weights: Tensor) -> Tensor: + """ + Encode a set of proposals with respect to some reference ground truth (gt) boxes. + + Args: + gt_boxes: gt boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + proposals: boxes to be encoded, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + weights: the weights for ``(cx, cy, w, h) or (cx,cy,cz, w,h,d)`` + + Return: + encoded gt, target of box regression that is used to convert proposals into gt_boxes, Nx4 or Nx6 torch tensor. + """ + + if gt_boxes.shape[0] != proposals.shape[0]: + raise ValueError("gt_boxes.shape[0] should be equal to proposals.shape[0].") + spatial_dims = look_up_option(len(weights), [4, 6]) // 2 + + if not is_valid_box_values(gt_boxes): + raise ValueError("gt_boxes is not valid. Please check if it contains empty boxes.") + if not is_valid_box_values(proposals): + raise ValueError("proposals is not valid. Please check if it contains empty boxes.") + + # implementation starts here + ex_cccwhd: Tensor = convert_box_mode(proposals, src_mode=StandardMode, dst_mode=CenterSizeMode) # type: ignore + gt_cccwhd: Tensor = convert_box_mode(gt_boxes, src_mode=StandardMode, dst_mode=CenterSizeMode) # type: ignore + targets_dxyz = ( + weights[:spatial_dims].unsqueeze(0) + * (gt_cccwhd[:, :spatial_dims] - ex_cccwhd[:, :spatial_dims]) + / ex_cccwhd[:, spatial_dims:] + ) + targets_dwhd = weights[spatial_dims:].unsqueeze(0) * torch.log( + gt_cccwhd[:, spatial_dims:] / ex_cccwhd[:, spatial_dims:] + ) + + targets = torch.cat((targets_dxyz, targets_dwhd), dim=1) + # torch.log may cause NaN or Inf + if torch.isnan(targets).any() or torch.isinf(targets).any(): + raise ValueError("targets is NaN or Inf.") + return targets + + +class BoxCoder: + """ + This class encodes and decodes a set of bounding boxes into + the representation used for training the regressors. + + Args: + weights: 4-element tuple or 6-element tuple + boxes_xform_clip: high threshold to prevent sending too large values into torch.exp() + + Example: + .. code-block:: python + + box_coder = BoxCoder(weights=[1., 1., 1., 1., 1., 1.]) + gt_boxes = torch.tensor([[1,2,1,4,5,6],[1,3,2,7,8,9]]) + proposals = gt_boxes + torch.rand(gt_boxes.shape) + rel_gt_boxes = box_coder.encode_single(gt_boxes, proposals) + gt_back = box_coder.decode_single(rel_gt_boxes, proposals) + # We expect gt_back to be equal to gt_boxes + """ + + def __init__(self, weights: Tuple[float], boxes_xform_clip: Union[float, None] = None) -> None: + if boxes_xform_clip is None: + boxes_xform_clip = math.log(1000.0 / 16) + self.spatial_dims = look_up_option(len(weights), [4, 6]) // 2 + self.weights = weights + self.boxes_xform_clip = boxes_xform_clip + + def encode(self, gt_boxes: Sequence[Tensor], proposals: Sequence[Tensor]) -> Tuple[Tensor]: + """ + Encode a set of proposals with respect to some ground truth (gt) boxes. + + Args: + gt_boxes: list of gt boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + proposals: list of boxes to be encoded, each element is Mx4 or Mx6 torch tensor. + The box mode is assumed to be ``StandardMode`` + + Return: + A tuple of encoded gt, target of box regression that is used to + convert proposals into gt_boxes, Nx4 or Nx6 torch tensor. + """ + boxes_per_image = [len(b) for b in gt_boxes] + # concat the lists to do computation + concat_gt_boxes = torch.cat(tuple(gt_boxes), dim=0) + concat_proposals = torch.cat(tuple(proposals), dim=0) + concat_targets = self.encode_single(concat_gt_boxes, concat_proposals) + # split to tuple + targets: Tuple[Tensor] = concat_targets.split(boxes_per_image, 0) + return targets + + def encode_single(self, gt_boxes: Tensor, proposals: Tensor) -> Tensor: + """ + Encode proposals with respect to ground truth (gt) boxes. + + Args: + gt_boxes: gt boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + proposals: boxes to be encoded, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + + Return: + encoded gt, target of box regression that is used to convert proposals into gt_boxes, Nx4 or Nx6 torch tensor. + """ + dtype = gt_boxes.dtype + device = gt_boxes.device + weights = torch.as_tensor(self.weights, dtype=dtype, device=device) + targets = encode_boxes(gt_boxes, proposals, weights) + return targets + + def decode(self, rel_codes: Tensor, reference_boxes: Sequence[Tensor]) -> Tensor: + """ + From a set of original reference_boxes and encoded relative box offsets, + + Args: + rel_codes: encoded boxes, Nx4 or Nx6 torch tensor. + boxes: a list of reference boxes, each element is Mx4 or Mx6 torch tensor. + The box mode is assumed to be ``StandardMode`` + + Return: + decoded boxes, Nx1x4 or Nx1x6 torch tensor. The box mode will be ``StandardMode`` + """ + if not isinstance(reference_boxes, Sequence) or (not isinstance(rel_codes, torch.Tensor)): + raise ValueError("Input arguments wrong type.") + boxes_per_image = [b.size(0) for b in reference_boxes] + # concat the lists to do computation + concat_boxes = torch.cat(tuple(reference_boxes), dim=0) + box_sum = 0 + for val in boxes_per_image: + box_sum += val + if box_sum > 0: + rel_codes = rel_codes.reshape(box_sum, -1) + pred_boxes = self.decode_single(rel_codes, concat_boxes) + if box_sum > 0: + pred_boxes = pred_boxes.reshape(box_sum, -1, 2 * self.spatial_dims) + return pred_boxes + + def decode_single(self, rel_codes: Tensor, reference_boxes: Tensor) -> Tensor: + """ + From a set of original boxes and encoded relative box offsets, + + Args: + rel_codes: encoded boxes, Nx4 or Nx6 torch tensor. + reference_boxes: reference boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + + Return: + decoded boxes, Nx4 or Nx6 torch tensor. The box mode will to be ``StandardMode`` + """ + reference_boxes = reference_boxes.to(rel_codes.dtype) + + pred_boxes = [] + boxes_cccwhd = convert_box_mode(reference_boxes, src_mode=StandardMode, dst_mode=CenterSizeMode) + for axis in range(self.spatial_dims): + whd_axis = boxes_cccwhd[:, axis + self.spatial_dims] + ctr_xyz_axis = boxes_cccwhd[:, axis] + dxyz_axis = rel_codes[:, axis] / self.weights[axis] + dwhd_axis = rel_codes[:, self.spatial_dims + axis] / self.weights[axis + self.spatial_dims] + + # Prevent sending too large values into torch.exp() + dwhd_axis = torch.clamp(dwhd_axis.to(COMPUTE_DTYPE), max=self.boxes_xform_clip) + + pred_ctr_xyx_axis = dxyz_axis * whd_axis + ctr_xyz_axis + pred_whd_axis = torch.exp(dwhd_axis) * whd_axis + pred_whd_axis = pred_whd_axis.to(dxyz_axis.dtype) + + # When convert float32 to float16, Inf or Nan may occur + if torch.isnan(pred_whd_axis).any() or torch.isinf(pred_whd_axis).any(): + raise ValueError("pred_whd_axis is NaN or Inf.") + + # Distance from center to box's corner. + c_to_c_whd_axis = ( + torch.tensor(0.5, dtype=pred_ctr_xyx_axis.dtype, device=pred_whd_axis.device) * pred_whd_axis + ) + + pred_boxes.append(pred_ctr_xyx_axis - c_to_c_whd_axis) + pred_boxes.append(pred_ctr_xyx_axis + c_to_c_whd_axis) + + pred_boxes_xxyyzz = torch.stack(pred_boxes, dim=1) + return convert_box_to_standard_mode(pred_boxes_xxyyzz, mode=CornerCornerModeTypeB) # type: ignore diff --git a/tests/test_box_coder.py b/tests/test_box_coder.py new file mode 100644 index 0000000000..86ca7a98c2 --- /dev/null +++ b/tests/test_box_coder.py @@ -0,0 +1,41 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch + +from monai.apps.detection.utils.box_coder import BoxCoder +from monai.transforms import CastToType +from tests.utils import assert_allclose + + +class TestBoxTransform(unittest.TestCase): + def test_value(self): + box_coder = BoxCoder(weights=[1, 1, 1, 1, 1, 1]) + test_dtype = [torch.float32, torch.float16] + for dtype in test_dtype: + gt_boxes_0 = torch.rand((10, 3)).abs() + gt_boxes_1 = gt_boxes_0 + torch.rand((10, 3)).abs() + 10 + gt_boxes = torch.cat((gt_boxes_0, gt_boxes_1), dim=1) + gt_boxes = CastToType(dtype=dtype)(gt_boxes) + + proposals_0 = (gt_boxes_0 + torch.rand(gt_boxes_0.shape)).abs() + proposals_1 = proposals_0 + torch.rand(gt_boxes_0.shape).abs() + 10 + proposals = torch.cat((proposals_0, proposals_1), dim=1) + + rel_gt_boxes = box_coder.encode_single(gt_boxes, proposals) + gt_back = box_coder.decode_single(rel_gt_boxes, proposals) + assert_allclose(gt_back, gt_boxes, type_test=True, device_test=True, atol=0.1) + + +if __name__ == "__main__": + unittest.main() From 14f74cb9872a49a486069e859d0db5bd3f1e86ca Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Sat, 28 May 2022 15:49:25 +0100 Subject: [PATCH 103/183] 4375 workaround mlflow integration (#4376) workaround for 4375 Signed-off-by: Wenqi Li --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ac8b3730d8..99f6cc3678 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -40,7 +40,7 @@ pandas requests einops transformers -mlflow +mlflow!=1.26.1 # https://github.com/Project-MONAI/MONAI/issues/4375 matplotlib!=3.5.0 tensorboardX types-PyYAML From 827e1e5314bf74363237687a6ed8abba1ee340fc Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 28 May 2022 23:46:47 +0800 Subject: [PATCH 104/183] 4346 Enhance contribution guide for optional package (#4367) * [DLMED] add to doc Signed-off-by: Nic Ma * [DLMED] add min tests Signed-off-by: Nic Ma * update docs Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li --- CONTRIBUTING.md | 43 +++++++++++++++++++++++++++++++++++++ docs/source/installation.md | 4 ++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 129a839fd7..ac373bad75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ 1. [Unit testing](#unit-testing) 1. [Building the documentation](#building-the-documentation) 1. [Automatic code formatting](#automatic-code-formatting) + 1. [Adding new optional dependencies](#adding-new-optional-dependencies) 1. [Signing your work](#signing-your-work) 1. [Utility functions](#utility-functions) 1. [Backwards compatibility](#backwards-compatibility) @@ -171,6 +172,48 @@ The first line of the comment must be `/black` so that it will be interpreted by - [Auto] After the formatting commit, the GitHub action adds an emoji to the comment that triggered the process. - Repeat the above steps if necessary. +#### Adding new optional dependencies +In addition to the minimal requirements of PyTorch and Numpy, MONAI's core modules are built optionally based on 3rd-party packages. +The current set of dependencies is listed in [installing dependencies](https://docs.monai.io/en/stable/installation.html#installing-the-recommended-dependencies). + +To allow for flexible integration of MONAI with other systems and environments, +the optional dependency APIs are always invoked lazily. For example, +```py +from monai.utils import optional_import +itk, _ = optional_import("itk", ...) + +class ITKReader(ImageReader): + ... + def read(self, ...): + return itk.imread(...) +``` +The availability of the external `itk.imread` API is not required unless `monai.data.ITKReader.read` is called by the user. +Integration tests with minimal requirements are deployed to ensure this strategy. + +To add new optional dependencies, please communicate with the core team during pull request reviews, +and add the necessary information (at least) to the following files: +- [setup.cfg](https://github.com/Project-MONAI/MONAI/blob/dev/setup.cfg) (for package's `[options.extras_require]` config) +- [docs/requirements.txt](https://github.com/Project-MONAI/MONAI/blob/dev/docs/requirements.txt) (pip requirements.txt file) +- [environment-dev.yml](https://github.com/Project-MONAI/MONAI/blob/dev/environment-dev.yml) (conda environment file) +- [installation.md](https://github.com/Project-MONAI/MONAI/blob/dev/docs/source/installation.md) (documentation) + +When writing unit tests that use 3rd-party packages, it is a good practice to always consider +an appropriate fallback default behaviour when the packages are not installed in +the testing environment. For example: +```py +from monai.utils import optional_import +plt, has_matplotlib = optional_import("matplotlib.pyplot") + +@skipUnless(has_matplotlib, "Matplotlib required") +class TestBlendImages(unittest.TestCase): +``` +It skips the test cases when `matplotlib.pyplot` APIs are not available. + +Alternatively, add the test file name to the ``exclude_cases`` in `tests/min_tests.py` to completely skip the test +cases when running in a minimal setup. + + + #### Signing your work MONAI enforces the [Developer Certificate of Origin](https://developercertificate.org/) (DCO) on all pull requests. All commit messages should contain the `Signed-off-by` line with an email address. The [GitHub DCO app](https://github.com/apps/dco) is deployed on MONAI. The pull request's status will be `failed` if commits do not contain a valid `Signed-off-by` line. diff --git a/docs/source/installation.md b/docs/source/installation.md index 76c9166566..393205cd1b 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -11,7 +11,7 @@ 3. [Validating the install](#validating-the-install) 4. [MONAI version string](#monai-version-string) 5. [From DockerHub](#from-dockerhub) -6. [Installing the recommended dependencies](#Installing-the-recommended-dependencies) +6. [Installing the recommended dependencies](#installing-the-recommended-dependencies) --- @@ -50,7 +50,7 @@ python -c "import monai; print(monai.__version__); print(monai.__commit_id__)" ## From conda-forge -To install the [current milestone release](https://pypi.org/project/monai/): +To install the [current milestone release](https://anaconda.org/conda-forge/monai): ```bash conda install -c conda-forge monai ``` From 95c1e80cb093a2b4625d977ca69f3620e26f7f7b Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Sat, 28 May 2022 12:36:37 -0400 Subject: [PATCH 105/183] Add transform between box and mask (#4366) * add box -- mask converter Signed-off-by: Can Zhao * add box mask transform Signed-off-by: Can Zhao * add examples Signed-off-by: Can Zhao * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add examples Signed-off-by: Can Zhao * add import Signed-off-by: Can Zhao * add import Signed-off-by: Can Zhao * add docstring Signed-off-by: Can Zhao * update test Signed-off-by: Can Zhao * update import Signed-off-by: Can Zhao * update test Signed-off-by: Can Zhao * change func name Signed-off-by: Can Zhao * change func name Signed-off-by: Can Zhao * change func name Signed-off-by: Can Zhao * change func name Signed-off-by: Can Zhao * [MONAI] code formatting Signed-off-by: monai-bot * corrent a corner case in NMS Signed-off-by: Can Zhao * update docstring, simplify convert_box_to_mask, add test cases Signed-off-by: Wenqi Li Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: monai-bot Co-authored-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- monai/apps/detection/transforms/array.py | 81 +++++++- monai/apps/detection/transforms/box_ops.py | 142 +++++++++++++- monai/apps/detection/transforms/dictionary.py | 183 +++++++++++++++++- monai/data/box_utils.py | 2 +- tests/test_box_transform.py | 67 ++++++- 5 files changed, 461 insertions(+), 14 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index b2587a213a..42aeda71cf 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -32,7 +32,14 @@ from monai.utils.enums import TransformBackends from monai.utils.type_conversion import convert_data_type, convert_to_dst_type -from .box_ops import apply_affine_to_boxes, flip_boxes, resize_boxes, zoom_boxes +from .box_ops import ( + apply_affine_to_boxes, + convert_box_to_mask, + convert_mask_to_box, + flip_boxes, + resize_boxes, + zoom_boxes, +) __all__ = [ "ConvertBoxToStandardMode", @@ -42,6 +49,8 @@ "ResizeBox", "FlipBox", "ClipBoxToImage", + "BoxToMask", + "MaskToBox", ] @@ -373,3 +382,73 @@ def __call__( # type: ignore labels_t = deepcopy(labels_t[keep_t, ...]) labels_clip_list.append(convert_to_dst_type(src=labels_t, dst=labels_tuple[i])[0]) return boxes_clip, tuple(labels_clip_list) + + +class BoxToMask(Transform): + """ + Convert box to int16 mask image, which has the same size with the input image. + + Args: + bg_label: background labels for the output mask image, make sure it is smaller than any foreground(fg) labels. + ellipse_mask: bool. + + - If True, it assumes the object shape is close to ellipse or ellipsoid. + - If False, it assumes the object shape is close to rectangle or cube and well occupies the bounding box. + - If the users are going to apply random rotation as data augmentation, we suggest setting ellipse_mask=True + See also Kalra et al. "Towards Rotation Invariance in Object Detection", ICCV 2021. + """ + + backend = [TransformBackends.NUMPY] + + def __init__(self, bg_label: int = -1, ellipse_mask: bool = False) -> None: + self.bg_label = bg_label + self.ellipse_mask = ellipse_mask + + def __call__( # type: ignore + self, boxes: NdarrayOrTensor, labels: NdarrayOrTensor, spatial_size: Union[Sequence[int], int] + ) -> NdarrayOrTensor: + """ + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``. + labels: classification foreground(fg) labels corresponding to `boxes`, dtype should be int, sized (N,). + spatial_size: image spatial size. + + Return: + - int16 array, sized (num_box, H, W). Each channel represents a box. + The foreground region in channel c has intensity of labels[c]. + The background intensity is bg_label. + """ + return convert_box_to_mask(boxes, labels, spatial_size, self.bg_label, self.ellipse_mask) + + +class MaskToBox(Transform): + """ + Convert int16 mask image to box, which has the same size with the input image. + Pairs with :py:class:`monai.apps.detection.transforms.array.BoxToMask`. + Please make sure the same ``min_fg_label`` is used when using the two transforms in pairs. + + Args: + bg_label: background labels for the output mask image, make sure it is smaller than any foreground(fg) labels. + box_dtype: output dtype for boxes + label_dtype: output dtype for labels + """ + + backend = [TransformBackends.NUMPY] + + def __init__(self, bg_label: int = -1, box_dtype=torch.float32, label_dtype=torch.long) -> None: + self.bg_label = bg_label + self.box_dtype = box_dtype + self.label_dtype = label_dtype + + def __call__(self, boxes_mask: NdarrayOrTensor) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: + """ + Args: + boxes_mask: int16 array, sized (num_box, H, W). Each channel represents a box. + The foreground region in channel c has intensity of labels[c]. + The background intensity is bg_label. + + Return: + - bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``. + - classification foreground(fg) labels, dtype should be int, sized (N,). + """ + return convert_mask_to_box(boxes_mask, self.bg_label, self.box_dtype, self.label_dtype) diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index ef8d248c02..6b9c2ac87b 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -10,16 +10,21 @@ # limitations under the License. from copy import deepcopy -from typing import Optional, Sequence, Union +from typing import Optional, Sequence, Tuple, Union +import numpy as np import torch from monai.config.type_definitions import NdarrayOrTensor from monai.data.box_utils import COMPUTE_DTYPE, TO_REMOVE, get_spatial_dims +from monai.transforms import Resize from monai.transforms.utils import create_scale +from monai.utils import look_up_option, optional_import from monai.utils.misc import ensure_tuple, ensure_tuple_rep from monai.utils.type_conversion import convert_data_type, convert_to_dst_type +scipy, _ = optional_import("scipy") + def _apply_affine_to_points(points: torch.Tensor, affine: torch.Tensor, include_shift: bool = True) -> torch.Tensor: """ @@ -180,9 +185,136 @@ def flip_boxes( flip_axes = ensure_tuple(flip_axes) # flip box - flip_boxes = deepcopy(boxes) + _flip_boxes = deepcopy(boxes) for axis in flip_axes: - flip_boxes[:, axis + spatial_dims] = spatial_size[axis] - boxes[:, axis] - TO_REMOVE - flip_boxes[:, axis] = spatial_size[axis] - boxes[:, axis + spatial_dims] - TO_REMOVE + _flip_boxes[:, axis + spatial_dims] = spatial_size[axis] - boxes[:, axis] - TO_REMOVE + _flip_boxes[:, axis] = spatial_size[axis] - boxes[:, axis + spatial_dims] - TO_REMOVE + + return _flip_boxes + + +def convert_box_to_mask( + boxes: NdarrayOrTensor, + labels: NdarrayOrTensor, + spatial_size: Union[Sequence[int], int], + bg_label: int = -1, + ellipse_mask: bool = False, +) -> NdarrayOrTensor: + """ + Convert box to int16 mask image, which has the same size with the input image. - return flip_boxes + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``. + labels: classification foreground(fg) labels corresponding to `boxes`, dtype should be int, sized (N,). + spatial_size: image spatial size. + bg_label: background labels for the output mask image, make sure it is smaller than any fg labels. + ellipse_mask: bool. + + - If True, it assumes the object shape is close to ellipse or ellipsoid. + - If False, it assumes the object shape is close to rectangle or cube and well occupies the bounding box. + - If the users are going to apply random rotation as data augmentation, we suggest setting ellipse_mask=True + See also Kalra et al. "Towards Rotation Invariance in Object Detection", ICCV 2021. + + Return: + - int16 array, sized (num_box, H, W). Each channel represents a box. + The foreground region in channel c has intensity of labels[c]. + The background intensity is bg_label. + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + spatial_size = ensure_tuple_rep(spatial_size, spatial_dims) + + # if no box, return empty mask + if len(labels) == 0: + boxes_mask_np = np.ones((1,) + spatial_size, dtype=np.int16) * np.int16(bg_label) + boxes_mask, *_ = convert_to_dst_type(src=boxes_mask_np, dst=boxes, dtype=torch.int16) + return boxes_mask + + # bg_label should be smaller than labels + if bg_label >= min(labels): + raise ValueError( + f"bg_label should be smaller than any foreground box labels.\n" + f"min(labels)={min(labels)}, while bg_label={bg_label}" + ) + + if labels.shape[0] != boxes.shape[0]: + raise ValueError("Number of labels should equal to number of boxes.") + + # allocate memory for boxes_mask_np + boxes_mask_np = np.ones((labels.shape[0],) + spatial_size, dtype=np.int16) * np.int16(bg_label) + + boxes_np: np.ndarray = convert_data_type(boxes, np.ndarray, dtype=np.int32)[0] + labels_np, *_ = convert_to_dst_type(src=labels, dst=boxes_np) + for b in range(boxes_np.shape[0]): + # generate a foreground mask + box_size = [boxes_np[b, axis + spatial_dims] - boxes_np[b, axis] for axis in range(spatial_dims)] + if ellipse_mask: + # initialize a square/cube mask + max_box_size = max(box_size) + radius = max_box_size / 2.0 + center = (max_box_size - 1) / 2.0 + boxes_only_mask = np.ones([max_box_size] * spatial_dims, dtype=np.int16) * np.int16(bg_label) + # apply label intensity to circle/ball foreground + ranges = tuple(slice(0, max_box_size) for _ in range(spatial_dims)) + dist_from_center = sum((grid - center) ** 2 for grid in np.ogrid[ranges]) + boxes_only_mask[dist_from_center <= radius**2] = np.int16(labels_np[b]) + # squeeze it to a ellipse/ellipsoid mask + resizer = Resize(spatial_size=box_size, mode="nearest", anti_aliasing=False) + boxes_only_mask = resizer(boxes_only_mask[None])[0] # type: ignore + else: + # generate a rect mask + boxes_only_mask = np.ones(box_size, dtype=np.int16) * np.int16(labels_np[b]) # type: ignore + # apply to global mask + slicing = [b] + slicing.extend(slice(boxes_np[b, d], boxes_np[b, d + spatial_dims]) for d in range(spatial_dims)) # type:ignore + boxes_mask_np[tuple(slicing)] = boxes_only_mask + return convert_to_dst_type(src=boxes_mask_np, dst=boxes, dtype=torch.int16)[0] + + +def convert_mask_to_box( + boxes_mask: NdarrayOrTensor, bg_label: int = -1, box_dtype=torch.float32, label_dtype=torch.long +) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: + """ + Convert int16 mask image to box, which has the same size with the input image + + Args: + boxes_mask: int16 array, sized (num_box, H, W). Each channel represents a box. + The foreground region in channel c has intensity of labels[c]. + The background intensity is bg_label. + bg_label: background labels for the boxes_mask + box_dtype: output dtype for boxes + label_dtype: output dtype for labels + + Return: + - bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode``. + - classification foreground(fg) labels, dtype should be int, sized (N,). + """ + look_up_option(len(boxes_mask.shape), [3, 4]) + spatial_size = list(boxes_mask.shape[1:]) + spatial_dims = get_spatial_dims(spatial_size=spatial_size) + + boxes_mask_np, *_ = convert_data_type(boxes_mask, np.ndarray) + + boxes_list = [] + labels_list = [] + for b in range(boxes_mask_np.shape[0]): + fg_indices = np.nonzero(boxes_mask_np[b, ...] - bg_label) + if fg_indices[0].shape[0] == 0: + continue + boxes_b = [] + for fd_i in fg_indices: + boxes_b.append(min(fd_i)) # top left corner + for fd_i in fg_indices: + boxes_b.append(max(fd_i) + 1 - TO_REMOVE) # bottom right corner + if spatial_dims == 2: + labels_list.append(boxes_mask_np[b, boxes_b[0], boxes_b[1]]) + if spatial_dims == 3: + labels_list.append(boxes_mask_np[b, boxes_b[0], boxes_b[1], boxes_b[2]]) + boxes_list.append(boxes_b) + + if len(boxes_list) == 0: + boxes_np, labels_np = np.zeros([0, 2 * spatial_dims]), np.zeros([0]) + else: + boxes_np, labels_np = np.asarray(boxes_list), np.asarray(labels_list) + boxes, *_ = convert_to_dst_type(src=boxes_np, dst=boxes_mask, dtype=box_dtype) + labels, *_ = convert_to_dst_type(src=labels_np, dst=boxes_mask, dtype=label_dtype) + return boxes, labels diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index b802ebcfe2..b0d9c5f1f9 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -24,15 +24,17 @@ from monai.apps.detection.transforms.array import ( AffineBox, + BoxToMask, ClipBoxToImage, ConvertBoxMode, ConvertBoxToStandardMode, FlipBox, + MaskToBox, ZoomBox, ) from monai.config import KeysCollection from monai.config.type_definitions import NdarrayOrTensor -from monai.data.box_utils import BoxMode +from monai.data.box_utils import COMPUTE_DTYPE, BoxMode from monai.data.utils import orientation_ras_lps from monai.transforms import Flip, RandFlip, RandZoom, SpatialPad, Zoom from monai.transforms.inverse import InvertibleTransform @@ -66,6 +68,12 @@ "ClipBoxToImaged", "ClipBoxToImageD", "ClipBoxToImageDict", + "BoxToMaskd", + "BoxToMaskD", + "BoxToMaskDict", + "MaskToBoxd", + "MaskToBoxD", + "MaskToBoxDict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -246,7 +254,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N # when convert boxes from world coordinate to image coordinate, # we apply inverse affine transform affine_t, *_ = convert_data_type(affine, torch.Tensor) - inv_affine_t = torch.inverse(affine_t) + # torch.inverse should not run in half precision + inv_affine_t = torch.inverse(affine_t.to(COMPUTE_DTYPE)) for key in self.key_iterator(d): self.push_transform(d, key, extra_info={"affine": affine}) @@ -758,6 +767,174 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +class BoxToMaskd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.BoxToMask`. + Pairs with :py:class:`monai.apps.detection.transforms.dictionary.MaskToBoxd` . + Please make sure the same ``min_fg_label`` is used when using the two transforms in pairs. + The output ``d[box_mask_key]`` will have background intensity 0, since the following operations + may pad 0 on the border. + + This is the general solution for transforms that need to be applied on images and boxes simultaneously. + It is performed with the following steps. + + 1) use ``BoxToMaskd`` to covert boxes and labels to box_masks; + 2) do transforms, e.g., rotation or cropping, on images and box_masks together; + 3) use ``MaskToBoxd`` to convert box_masks back to boxes and labels. + + Args: + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_mask_keys: Keys to store output box mask results for transformation. Same length with ``box_keys``. + label_keys: Keys that represents the labels corresponding to the ``box_keys``. Same length with ``box_keys``. + box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + min_fg_label: min foreground box label. + ellipse_mask: bool. + + - If True, it assumes the object shape is close to ellipse or ellipsoid. + - If False, it assumes the object shape is close to rectangle or cube and well occupies the bounding box. + - If the users are going to apply random rotation as data augmentation, we suggest setting ellipse_mask=True + See also Kalra et al. "Towards Rotation Invariance in Object Detection", ICCV 2021. + allow_missing_keys: don't raise exception if key is missing. + + Example: + .. code-block:: python + + # This code snippet creates transforms (random rotation and cropping) on boxes, labels, and image together. + import numpy as np + from monai.transforms import Compose, RandRotated, RandSpatialCropd, DeleteItemsd + transforms = Compose( + [ + BoxToMaskd( + box_keys="boxes", label_keys="labels", + box_mask_keys="box_mask", box_ref_image_keys="image", + min_fg_label=0, ellipse_mask=True + ), + RandRotated(keys=["image","box_mask"],mode=["nearest","nearest"], + prob=0.2,range_x=np.pi/6,range_y=np.pi/6,range_z=np.pi/6, + keep_size=True,padding_mode="zeros" + ), + RandSpatialCropd(keys=["image","box_mask"],roi_size=128, random_size=False), + MaskToBoxd( + box_mask_keys="box_mask", box_keys="boxes", + label_keys="labels", min_fg_label=0 + ) + DeleteItemsd(keys=["box_mask"]), + ] + ) + + """ + + def __init__( + self, + box_keys: KeysCollection, + box_mask_keys: KeysCollection, + label_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + min_fg_label: int, + ellipse_mask: bool = False, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(box_keys, allow_missing_keys) + self.box_keys = ensure_tuple(box_keys) + self.label_keys = ensure_tuple(label_keys) + self.box_mask_keys = ensure_tuple(box_mask_keys) + if not len(self.label_keys) == len(self.box_keys) == len(self.box_mask_keys): + raise ValueError("Please make sure len(label_keys)==len(box_keys)==len(box_mask_keys)!") + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + self.bg_label = min_fg_label - 1 # make sure background label is always smaller than fg labels. + self.converter = BoxToMask(bg_label=self.bg_label, ellipse_mask=ellipse_mask) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for box_key, label_key, box_mask_key, box_ref_image_key in zip( + self.box_keys, self.label_keys, self.box_mask_keys, self.box_ref_image_keys + ): + spatial_size = d[box_ref_image_key].shape[1:] + d[box_mask_key] = self.converter(d[box_key], d[label_key], spatial_size) + # make box mask background intensity to be 0, since the following operations may pad 0 on the border. + d[box_mask_key] -= self.bg_label + return d + + +class MaskToBoxd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.apps.detection.transforms.array.MaskToBox`. + Pairs with :py:class:`monai.apps.detection.transforms.dictionary.BoxToMaskd` . + Please make sure the same ``min_fg_label`` is used when using the two transforms in pairs. + + This is the general solution for transforms that need to be applied on images and boxes simultaneously. + It is performed with the following steps. + + 1) use ``BoxToMaskd`` to covert boxes and labels to box_masks; + 2) do transforms, e.g., rotation or cropping, on images and box_masks together; + 3) use ``MaskToBoxd`` to convert box_masks back to boxes and labels. + + Args: + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_mask_keys: Keys to store output box mask results for transformation. Same length with ``box_keys``. + label_keys: Keys that represents the labels corresponding to the ``box_keys``. Same length with ``box_keys``. + min_fg_label: min foreground box label. + box_dtype: output dtype for box_keys + label_dtype: output dtype for label_keys + allow_missing_keys: don't raise exception if key is missing. + + Example: + .. code-block:: python + + # This code snippet creates transforms (random rotation and cropping) on boxes, labels, and images together. + import numpy as np + from monai.transforms import Compose, RandRotated, RandSpatialCropd, DeleteItemsd + transforms = Compose( + [ + BoxToMaskd( + box_keys="boxes", label_keys="labels", + box_mask_keys="box_mask", box_ref_image_keys="image", + min_fg_label=0, ellipse_mask=True + ), + RandRotated(keys=["image","box_mask"],mode=["nearest","nearest"], + prob=0.2,range_x=np.pi/6,range_y=np.pi/6,range_z=np.pi/6, + keep_size=True,padding_mode="zeros" + ), + RandSpatialCropd(keys=["image","box_mask"],roi_size=128, random_size=False), + MaskToBoxd( + box_mask_keys="box_mask", box_keys="boxes", + label_keys="labels", min_fg_label=0 + ) + DeleteItemsd(keys=["box_mask"]), + ] + ) + """ + + def __init__( + self, + box_keys: KeysCollection, + box_mask_keys: KeysCollection, + label_keys: KeysCollection, + min_fg_label: int, + box_dtype=torch.float32, + label_dtype=torch.long, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(box_keys, allow_missing_keys) + self.box_keys = ensure_tuple(box_keys) + self.label_keys = ensure_tuple(label_keys) + self.box_mask_keys = ensure_tuple(box_mask_keys) + if not len(self.label_keys) == len(self.box_keys) == len(self.box_mask_keys): + raise ValueError("Please make sure len(label_keys)==len(box_keys)==len(box_mask_keys)!") + self.bg_label = min_fg_label - 1 # make sure background label is always smaller than fg labels. + self.converter = MaskToBox(bg_label=self.bg_label, box_dtype=box_dtype, label_dtype=label_dtype) + self.box_dtype = box_dtype + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for box_key, label_key, box_mask_key in zip(self.box_keys, self.label_keys, self.box_mask_keys): + d[box_mask_key] += self.bg_label # pairs with the operation in BoxToMaskd + d[box_key], d[label_key] = self.converter(d[box_mask_key]) + return d + + ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed ZoomBoxD = ZoomBoxDict = ZoomBoxd @@ -766,3 +943,5 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N FlipBoxD = FlipBoxDict = FlipBoxd RandFlipBoxD = RandFlipBoxDict = RandFlipBoxd ClipBoxToImageD = ClipBoxToImageDict = ClipBoxToImaged +BoxToMaskD = BoxToMaskDict = BoxToMaskd +MaskToBoxD = MaskToBoxDict = MaskToBoxd diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index dfe9f3798b..6bfd89080a 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -1039,7 +1039,7 @@ def non_max_suppression( # returns empty array if boxes is empty if boxes.shape[0] == 0: - return convert_to_dst_type(src=np.array([]), dst=boxes)[0] + return convert_to_dst_type(src=np.array([]), dst=boxes, dtype=torch.long)[0] if boxes.shape[0] != scores.shape[0]: raise ValueError( diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index f290ce5726..0def9e1458 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -17,9 +17,11 @@ from monai.apps.detection.transforms.dictionary import ( AffineBoxToImageCoordinated, + BoxToMaskd, ClipBoxToImaged, ConvertBoxModed, FlipBoxd, + MaskToBoxd, RandFlipBoxd, RandZoomBoxd, ZoomBoxd, @@ -27,8 +29,7 @@ from monai.transforms import CastToTyped, Invertd from tests.utils import TEST_NDARRAYS, assert_allclose -TESTS = [] - +TESTS_3D = [] boxes = [[0, 0, 0, 0, 0, 0], [0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]] labels = [1, 1, 0] scores = [[0.2, 0.8], [0.3, 0.7], [0.6, 0.4]] @@ -36,7 +37,7 @@ image = np.zeros(image_size) for p in TEST_NDARRAYS: - TESTS.append( + TESTS_3D.append( [ {"box_keys": "boxes", "dst_mode": "xyzwhd"}, {"boxes": p(boxes), "image": p(image), "labels": p(labels), "scores": p(scores)}, @@ -48,10 +49,66 @@ ] ) +TESTS_2D = [] +boxes = [[0, 1, 2, 2], [0, 0, 1, 1]] +labels = [1, 0] +image_size = [1, 2, 2] +image = np.zeros(image_size) +for p in TEST_NDARRAYS: + TESTS_2D.append( + [{"boxes": p(boxes), "image": p(image), "labels": p(labels)}, p([[[0, 2], [0, 2]], [[1, 0], [0, 0]]])] + ) + class TestBoxTransform(unittest.TestCase): - @parameterized.expand(TESTS) - def test_value( + @parameterized.expand(TESTS_2D) + def test_value_2d(self, data, expected_mask): + test_dtype = [torch.float32, torch.float16] + for dtype in test_dtype: + data = CastToTyped(keys=["image", "boxes"], dtype=dtype)(data) + transform_to_mask = BoxToMaskd( + box_keys="boxes", + box_mask_keys="box_mask", + box_ref_image_keys="image", + label_keys="labels", + min_fg_label=0, + ellipse_mask=False, + ) + transform_to_box = MaskToBoxd( + box_keys="boxes", box_mask_keys="box_mask", label_keys="labels", min_fg_label=0 + ) + data_mask = transform_to_mask(data) + assert_allclose(data_mask["box_mask"], expected_mask, type_test=True, device_test=True, atol=1e-3) + data_back = transform_to_box(data_mask) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["labels"], data["labels"], type_test=False, device_test=False, atol=1e-3) + + def test_value_3d_mask(self): + test_dtype = [torch.float32, torch.float16] + image = np.zeros((1, 32, 33, 34)) + boxes = np.array([[7, 8, 9, 10, 12, 13], [1, 3, 5, 2, 5, 9], [0, 0, 0, 1, 1, 1]]) + data = {"image": image, "boxes": boxes, "labels": np.array((1, 0, 3))} + for dtype in test_dtype: + data = CastToTyped(keys=["image", "boxes"], dtype=dtype)(data) + transform_to_mask = BoxToMaskd( + box_keys="boxes", + box_mask_keys="box_mask", + box_ref_image_keys="image", + label_keys="labels", + min_fg_label=0, + ellipse_mask=False, + ) + transform_to_box = MaskToBoxd( + box_keys="boxes", box_mask_keys="box_mask", label_keys="labels", min_fg_label=0 + ) + data_mask = transform_to_mask(data) + assert_allclose(data_mask["box_mask"].shape, (3, 32, 33, 34), type_test=True, device_test=True, atol=1e-3) + data_back = transform_to_box(data_mask) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["labels"], data["labels"], type_test=False, device_test=False, atol=1e-3) + + @parameterized.expand(TESTS_3D) + def test_value_3d( self, keys, data, From ba2309cd383dde170aad5b31500e22a21f730049 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Sat, 28 May 2022 13:42:56 -0400 Subject: [PATCH 106/183] Sliding window inference that supports multiple zoomed outputs (#4363) --- monai/data/utils.py | 7 +- monai/inferers/inferer.py | 31 ++++- monai/inferers/utils.py | 186 ++++++++++++++++++++----- tests/test_sliding_window_inference.py | 67 +++++++++ 4 files changed, 245 insertions(+), 46 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index e56d8b86e9..bc4f60516d 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -974,7 +974,7 @@ def compute_importance_map( mode = look_up_option(mode, BlendMode) device = torch.device(device) if mode == BlendMode.CONSTANT: - importance_map = torch.ones(patch_size, device=device).float() + importance_map = torch.ones(patch_size, device=device, dtype=torch.float) elif mode == BlendMode.GAUSSIAN: center_coords = [i // 2 for i in patch_size] sigma_scale = ensure_tuple_rep(sigma_scale, len(patch_size)) @@ -987,15 +987,10 @@ def compute_importance_map( importance_map = importance_map.squeeze(0).squeeze(0) importance_map = importance_map / torch.max(importance_map) importance_map = importance_map.float() - - # importance_map cannot be 0, otherwise we may end up with nans! - min_non_zero = importance_map[importance_map != 0].min().item() - importance_map = torch.clamp(importance_map, min=min_non_zero) else: raise ValueError( f"Unsupported mode: {mode}, available options are [{BlendMode.CONSTANT}, {BlendMode.CONSTANT}]." ) - return importance_map diff --git a/monai/inferers/inferer.py b/monai/inferers/inferer.py index 331637ba94..89863a7c3b 100644 --- a/monai/inferers/inferer.py +++ b/monai/inferers/inferer.py @@ -9,13 +9,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from abc import ABC, abstractmethod -from typing import Any, Callable, Optional, Sequence, Union +from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union import torch import torch.nn as nn -from monai.inferers.utils import sliding_window_inference +from monai.inferers.utils import compute_importance_map, sliding_window_inference from monai.utils import BlendMode, PytorchPadMode, ensure_tuple from monai.visualize import CAM, GradCAM, GradCAMpp @@ -120,6 +121,7 @@ class SlidingWindowInferer(Inferer): set to device=torch.device('cpu') the gpu memory consumption is less and independent of the `inputs` and `roi_size`. Output is on the `device`. progress: whether to print a tqdm progress bar. + cache_roi_weight_map: whether to precompute the ROI weight map. Note: ``sw_batch_size`` denotes the max number of windows per network inference iteration, @@ -139,6 +141,7 @@ def __init__( sw_device: Union[torch.device, str, None] = None, device: Union[torch.device, str, None] = None, progress: bool = False, + cache_roi_weight_map: bool = False, ) -> None: Inferer.__init__(self) self.roi_size = roi_size @@ -152,9 +155,26 @@ def __init__( self.device = device self.progress = progress + # compute_importance_map takes long time when computing on cpu. We thus + # compute it once if it's static and then save it for future usage + self.roi_weight_map = None + try: + if cache_roi_weight_map and isinstance(roi_size, Sequence) and min(roi_size) > 0: # non-dynamic roi size + if device is None: + device = "cpu" + self.roi_weight_map = compute_importance_map( + ensure_tuple(self.roi_size), mode=mode, sigma_scale=sigma_scale, device=device + ) + if cache_roi_weight_map and self.roi_weight_map is None: + warnings.warn("cache_roi_weight_map=True, but cache is not created. (dynamic roi_size?)") + except BaseException as e: + raise RuntimeError( + "Seems to be OOM. Please try smaller roi_size, or use mode='constant' instead of mode='gaussian'. " + ) from e + def __call__( self, inputs: torch.Tensor, network: Callable[..., torch.Tensor], *args: Any, **kwargs: Any - ) -> torch.Tensor: + ) -> Union[torch.Tensor, Tuple[torch.Tensor], Dict[Any, torch.Tensor]]: """ Args: @@ -165,7 +185,7 @@ def __call__( kwargs: optional keyword args to be passed to ``network``. """ - return sliding_window_inference( + return sliding_window_inference( # type: ignore inputs, self.roi_size, self.sw_batch_size, @@ -178,6 +198,7 @@ def __call__( self.sw_device, self.device, self.progress, + self.roi_weight_map, *args, **kwargs, ) @@ -251,7 +272,7 @@ def __init__(self, spatial_dim: int = 0, *args, **kwargs) -> None: def __call__( self, inputs: torch.Tensor, network: Callable[..., torch.Tensor], *args: Any, **kwargs: Any - ) -> torch.Tensor: + ) -> Union[torch.Tensor, Tuple[torch.Tensor], Dict[Any, torch.Tensor]]: """ Args: inputs: 3D input for inference diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index 36e4377bd6..736eef8309 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -9,13 +9,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Callable, List, Sequence, Tuple, Union +import warnings +from typing import Any, Callable, Dict, List, Mapping, Sequence, Tuple, Union import torch import torch.nn.functional as F from monai.data.utils import compute_importance_map, dense_patch_slices, get_valid_patch_size -from monai.utils import BlendMode, PytorchPadMode, fall_back_tuple, look_up_option, optional_import +from monai.transforms import Resize +from monai.utils import ( + BlendMode, + PytorchPadMode, + convert_data_type, + ensure_tuple, + fall_back_tuple, + look_up_option, + optional_import, +) tqdm, _ = optional_import("tqdm", name="tqdm") @@ -35,12 +45,21 @@ def sliding_window_inference( sw_device: Union[torch.device, str, None] = None, device: Union[torch.device, str, None] = None, progress: bool = False, + roi_weight_map: Union[torch.Tensor, None] = None, *args: Any, **kwargs: Any, -) -> torch.Tensor: +) -> Union[torch.Tensor, Tuple[torch.Tensor], Dict[Any, torch.Tensor]]: """ Sliding window inference on `inputs` with `predictor`. + The outputs of `predictor` could be a tensor, a tuple, or a dictionary of tensors. + Each output in the tuple or dict value is allowed to have different resolutions with respect to the input. + e.g., the input patch spatial size is [128,128,128], the output (a tuple of two patches) patch sizes + could be ([128,64,256], [64,32,128]). + In this case, the parameter `overlap` and `roi_size` need to be carefully chosen to ensure the output ROI is still + an integer. If the predictor's input and output spatial sizes are not equal, we recommend choosing the parameters + so that `overlap*roi_size*output_size/input_size` is an integer (for each spatial dimension). + When roi_size is larger than the inputs' spatial size, the input image are padded during inference. To maintain the same spatial sizes, the output image will be cropped to the original input size. @@ -52,9 +71,16 @@ def sliding_window_inference( corresponding components of img size. For example, `roi_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. sw_batch_size: the batch size to run window slices. - predictor: given input tensor `patch_data` in shape NCHW[D], `predictor(patch_data)` - should return a prediction with the same spatial shape and batch_size, i.e. NMHW[D]; - where HW[D] represents the patch spatial size, M is the number of output channels, N is `sw_batch_size`. + predictor: given input tensor ``patch_data`` in shape NCHW[D], + The outputs of the function call ``predictor(patch_data)`` should be a tensor, a tuple, or a dictionary + with Tensor values. Each output in the tuple or dict value should have the same batch_size, i.e. NM'H'W'[D']; + where H'W'[D'] represents the output patch's spatial size, M is the number of output channels, + N is `sw_batch_size`, e.g., the input shape is (7, 1, 128,128,128), + the output could be a tuple of two tensors, with shapes: ((7, 5, 128, 64, 256), (7, 4, 64, 32, 128)). + In this case, the parameter `overlap` and `roi_size` need to be carefully chosen + to ensure the scaled output ROI sizes are still integers. + If the `predictor`'s input and output spatial sizes are different, + we recommend choosing the parameters so that ``overlap*roi_size*zoom_scale`` is an integer for each dimension. overlap: Amount of overlap between scans. mode: {``"constant"``, ``"gaussian"``} How to blend output of overlapping windows. Defaults to ``"constant"``. @@ -78,6 +104,8 @@ def sliding_window_inference( set to device=torch.device('cpu') the gpu memory consumption is less and independent of the `inputs` and `roi_size`. Output is on the `device`. progress: whether to print a `tqdm` progress bar. + roi_weight_map: pre-computed (non-negative) weight map for each ROI. + If not given, and ``mode`` is not `constant`, this map will be computed on the fly. args: optional args to be passed to ``predictor``. kwargs: optional keyword args to be passed to ``predictor``. @@ -85,14 +113,14 @@ def sliding_window_inference( - input must be channel-first and have a batch dim, supports N-D sliding window. """ + compute_dtype = inputs.dtype num_spatial_dims = len(inputs.shape) - 2 if overlap < 0 or overlap >= 1: - raise AssertionError("overlap must be >= 0 and < 1.") + raise ValueError("overlap must be >= 0 and < 1.") # determine image spatial size and batch size # Note: all input images must have the same image size and batch size - image_size_ = list(inputs.shape[2:]) - batch_size = inputs.shape[0] + batch_size, _, *image_size_ = inputs.shape if device is None: device = inputs.device @@ -117,13 +145,27 @@ def sliding_window_inference( total_slices = num_win * batch_size # total number of windows # Create window-level importance map - importance_map = compute_importance_map( - get_valid_patch_size(image_size, roi_size), mode=mode, sigma_scale=sigma_scale, device=device - ) + valid_patch_size = get_valid_patch_size(image_size, roi_size) + if valid_patch_size == roi_size and (roi_weight_map is not None): + importance_map = roi_weight_map + else: + try: + importance_map = compute_importance_map(valid_patch_size, mode=mode, sigma_scale=sigma_scale, device=device) + except BaseException as e: + raise RuntimeError( + "Seems to be OOM. Please try smaller patch size or mode='constant' instead of mode='gaussian'." + ) from e + importance_map = convert_data_type(importance_map, torch.Tensor, device=device, dtype=compute_dtype)[0] # type: ignore + # handle non-positive weights + min_non_zero = max(importance_map[importance_map != 0].min().item(), 1e-3) + importance_map = torch.clamp(importance_map.to(torch.float32), min=min_non_zero).to(compute_dtype) # Perform predictions - output_image, count_map = torch.tensor(0.0, device=device), torch.tensor(0.0, device=device) - _initialized = False + dict_key, output_image_list, count_map_list = None, [], [] + _initialized_ss = -1 + is_tensor_output = True # whether the predictor's output is a tensor (instead of dict/tuple) + + # for each patch for slice_g in tqdm(range(0, total_slices, sw_batch_size)) if progress else range(0, total_slices, sw_batch_size): slice_range = range(slice_g, min(slice_g + sw_batch_size, total_slices)) unravel_slice = [ @@ -131,31 +173,105 @@ def sliding_window_inference( for idx in slice_range ] window_data = torch.cat([inputs[win_slice] for win_slice in unravel_slice]).to(sw_device) - seg_prob = predictor(window_data, *args, **kwargs).to(device) # batched patch segmentation + seg_prob_out = predictor(window_data, *args, **kwargs) # batched patch segmentation + + # convert seg_prob_out to tuple seg_prob_tuple, this does not allocate new memory. + if isinstance(seg_prob_out, torch.Tensor): + seg_prob_tuple = (seg_prob_out,) + elif isinstance(seg_prob_out, Mapping): + if dict_key is None: + dict_key = sorted(seg_prob_out.keys()) # track predictor's output keys + seg_prob_tuple = tuple(seg_prob_out[k] for k in dict_key) + is_tensor_output = False + else: + seg_prob_tuple = ensure_tuple(seg_prob_out) + is_tensor_output = False + + # for each output in multi-output list + for ss, seg_prob in enumerate(seg_prob_tuple): + seg_prob = seg_prob.to(device) # BxCxMxNxP or BxCxMxN + + # compute zoom scale: out_roi_size/in_roi_size + zoom_scale = [] + for axis, (img_s_i, out_w_i, in_w_i) in enumerate( + zip(image_size, seg_prob.shape[2:], window_data.shape[2:]) + ): + _scale = out_w_i / float(in_w_i) + if not (img_s_i * _scale).is_integer(): + warnings.warn( + f"For spatial axis: {axis}, output[{ss}] will have non-integer shape. Spatial " + f"zoom_scale between output[{ss}] and input is {_scale}. Please pad inputs." + ) + zoom_scale.append(_scale) - if not _initialized: # init. buffer at the first iteration - output_classes = seg_prob.shape[1] - output_shape = [batch_size, output_classes] + list(image_size) - # allocate memory to store the full output and the count for overlapping parts - output_image = torch.zeros(output_shape, dtype=torch.float32, device=device) - count_map = torch.zeros(output_shape, dtype=torch.float32, device=device) - _initialized = True + if _initialized_ss < ss: # init. the ss-th buffer at the first iteration + # construct multi-resolution outputs + output_classes = seg_prob.shape[1] + output_shape = [batch_size, output_classes] + [ + int(image_size_d * zoom_scale_d) for image_size_d, zoom_scale_d in zip(image_size, zoom_scale) + ] + # allocate memory to store the full output and the count for overlapping parts + output_image_list.append(torch.zeros(output_shape, dtype=compute_dtype, device=device)) + count_map_list.append(torch.zeros([1, 1] + output_shape[2:], dtype=compute_dtype, device=device)) + _initialized_ss += 1 - # store the result in the proper location of the full output. Apply weights from importance map. - for idx, original_idx in zip(slice_range, unravel_slice): - output_image[original_idx] += importance_map * seg_prob[idx - slice_g] - count_map[original_idx] += importance_map + # resizing the importance_map + resizer = Resize(spatial_size=seg_prob.shape[2:], mode="nearest", anti_aliasing=False) + + # store the result in the proper location of the full output. Apply weights from importance map. + for idx, original_idx in zip(slice_range, unravel_slice): + # zoom roi + original_idx_zoom = list(original_idx) # 4D for 2D image, 5D for 3D image + for axis in range(2, len(original_idx_zoom)): + zoomed_start = original_idx[axis].start * zoom_scale[axis - 2] + zoomed_end = original_idx[axis].stop * zoom_scale[axis - 2] + if not zoomed_start.is_integer() or (not zoomed_end.is_integer()): + warnings.warn( + f"For axis-{axis-2} of output[{ss}], the output roi range is not int. " + f"Input roi range is ({original_idx[axis].start}, {original_idx[axis].stop}). " + f"Spatial zoom_scale between output[{ss}] and input is {zoom_scale[axis - 2]}. " + f"Corresponding output roi range is ({zoomed_start}, {zoomed_end}).\n" + f"Please change overlap ({overlap}) or roi_size ({roi_size[axis-2]}) for axis-{axis-2}. " + "Tips: if overlap*roi_size*zoom_scale is an integer, it usually works." + ) + original_idx_zoom[axis] = slice(int(zoomed_start), int(zoomed_end), None) + importance_map_zoom = resizer(importance_map.unsqueeze(0))[0].to(compute_dtype) + # store results and weights + output_image_list[ss][original_idx_zoom] += importance_map_zoom * seg_prob[idx - slice_g] + count_map_list[ss][original_idx_zoom] += ( + importance_map_zoom.unsqueeze(0).unsqueeze(0).expand(count_map_list[ss][original_idx_zoom].shape) + ) # account for any overlapping sections - output_image = output_image / count_map - - final_slicing: List[slice] = [] - for sp in range(num_spatial_dims): - slice_dim = slice(pad_size[sp * 2], image_size_[num_spatial_dims - sp - 1] + pad_size[sp * 2]) - final_slicing.insert(0, slice_dim) - while len(final_slicing) < len(output_image.shape): - final_slicing.insert(0, slice(None)) - return output_image[final_slicing] + for ss in range(len(output_image_list)): + output_image_list[ss] = (output_image_list[ss] / count_map_list.pop(0)).to(compute_dtype) + + # remove padding if image_size smaller than roi_size + for ss, output_i in enumerate(output_image_list): + if torch.isnan(output_i).any() or torch.isinf(output_i).any(): + warnings.warn("Sliding window inference results contain NaN or Inf.") + + zoom_scale = [ + seg_prob_map_shape_d / roi_size_d for seg_prob_map_shape_d, roi_size_d in zip(output_i.shape[2:], roi_size) + ] + + final_slicing: List[slice] = [] + for sp in range(num_spatial_dims): + slice_dim = slice(pad_size[sp * 2], image_size_[num_spatial_dims - sp - 1] + pad_size[sp * 2]) + slice_dim = slice( + int(round(slice_dim.start * zoom_scale[num_spatial_dims - sp - 1])), + int(round(slice_dim.stop * zoom_scale[num_spatial_dims - sp - 1])), + ) + final_slicing.insert(0, slice_dim) + while len(final_slicing) < len(output_i.shape): + final_slicing.insert(0, slice(None)) + output_image_list[ss] = output_i[final_slicing] + + if dict_key is not None: # if output of predictor is a dict + final_output = dict(zip(dict_key, output_image_list)) + else: + final_output = tuple(output_image_list) + return final_output[0] if is_tensor_output else final_output # type: ignore def _get_scan_interval( diff --git a/tests/test_sliding_window_inference.py b/tests/test_sliding_window_inference.py index 5b6995c1ea..e1fa7d600e 100644 --- a/tests/test_sliding_window_inference.py +++ b/tests/test_sliding_window_inference.py @@ -177,6 +177,11 @@ def compute(self, data): ) np.testing.assert_allclose(result.cpu().numpy(), expected, rtol=1e-4) + result = SlidingWindowInferer( + roi_shape, sw_batch_size, overlap=0.5, mode="gaussian", sigma_scale=[1.0, 1.0], cache_roi_weight_map=True + )(inputs, _Pred().compute) + np.testing.assert_allclose(result.cpu().numpy(), expected, rtol=1e-4) + def test_cval(self): device = "cuda" if torch.cuda.is_available() else "cpu:0" inputs = torch.ones((1, 1, 3, 3)).to(device=device) @@ -227,6 +232,7 @@ def compute(data, test1, test2): device, device, has_tqdm, + None, t1, test2=t2, ) @@ -238,6 +244,67 @@ def compute(data, test1, test2): )(inputs, compute, t1, test2=t2) np.testing.assert_allclose(result.cpu().numpy(), expected, rtol=1e-4) + def test_multioutput(self): + device = "cuda" if torch.cuda.is_available() else "cpu:0" + inputs = torch.ones((1, 6, 20, 20)).to(device=device) + roi_shape = (8, 8) + sw_batch_size = 10 + + def compute(data): + return data + 1, data[:, ::3, ::2, ::2] + 2, data[:, ::2, ::4, ::4] + 3 + + def compute_dict(data): + return {1: data + 1, 2: data[:, ::3, ::2, ::2] + 2, 3: data[:, ::2, ::4, ::4] + 3} + + result = sliding_window_inference( + inputs, + roi_shape, + sw_batch_size, + compute, + 0.5, + "constant", + 1.0, + "constant", + 0.0, + device, + device, + has_tqdm, + None, + ) + result_dict = sliding_window_inference( + inputs, + roi_shape, + sw_batch_size, + compute_dict, + 0.5, + "constant", + 1.0, + "constant", + 0.0, + device, + device, + has_tqdm, + None, + ) + expected = (np.ones((1, 6, 20, 20)) + 1, np.ones((1, 2, 10, 10)) + 2, np.ones((1, 3, 5, 5)) + 3) + expected_dict = {1: np.ones((1, 6, 20, 20)) + 1, 2: np.ones((1, 2, 10, 10)) + 2, 3: np.ones((1, 3, 5, 5)) + 3} + for rr, ee in zip(result, expected): + np.testing.assert_allclose(rr.cpu().numpy(), ee, rtol=1e-4) + for rr, _ in zip(result_dict, expected_dict): + np.testing.assert_allclose(result_dict[rr].cpu().numpy(), expected_dict[rr], rtol=1e-4) + + result = SlidingWindowInferer( + roi_shape, sw_batch_size, overlap=0.5, mode="constant", cval=-1, progress=has_tqdm + )(inputs, compute) + for rr, ee in zip(result, expected): + np.testing.assert_allclose(rr.cpu().numpy(), ee, rtol=1e-4) + + result_dict = SlidingWindowInferer( + roi_shape, sw_batch_size, overlap=0.5, mode="constant", cval=-1, progress=has_tqdm + )(inputs, compute_dict) + for rr, _ in zip(result_dict, expected_dict): + np.testing.assert_allclose(result_dict[rr].cpu().numpy(), expected_dict[rr], rtol=1e-4) + if __name__ == "__main__": unittest.main() From 61a0dc35b8a5edf78487b30c02c7aa0e51fc3409 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Sat, 28 May 2022 15:10:58 -0400 Subject: [PATCH 107/183] add ATSS matcher for detection (#4350) --- docs/source/apps.rst | 5 + monai/apps/detection/transforms/dictionary.py | 2 +- monai/apps/detection/utils/ATSS_matcher.py | 288 ++++++++++++++++++ monai/inferers/utils.py | 2 +- tests/test_atss_box_matcher.py | 44 +++ 5 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 monai/apps/detection/utils/ATSS_matcher.py create mode 100644 tests/test_atss_box_matcher.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 4b62abd2e3..bdbf1ceb94 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -135,6 +135,11 @@ Applications .. automodule:: monai.apps.detection.transforms.dictionary :members: +`Matcher` +~~~~~~~~~ +.. automodule:: monai.apps.detection.utils.ATSS_matcher + :members: + `Box coder` ~~~~~~~~~~~ .. automodule:: monai.apps.detection.utils.box_coder diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index b0d9c5f1f9..96c918eda1 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -715,7 +715,7 @@ class ClipBoxToImaged(MapTransform): Args: box_keys: The single key to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - label_keys: Keys that represents the lables corresponding to the ``box_keys``. Multiple keys are allowed. + label_keys: Keys that represents the labels corresponding to the ``box_keys``. Multiple keys are allowed. box_ref_image_keys: The single key that represents the reference image to which ``box_keys`` and ``label_keys`` are attached. remove_empty: whether to remove the boxes that are actually empty diff --git a/monai/apps/detection/utils/ATSS_matcher.py b/monai/apps/detection/utils/ATSS_matcher.py new file mode 100644 index 0000000000..c34fe436e7 --- /dev/null +++ b/monai/apps/detection/utils/ATSS_matcher.py @@ -0,0 +1,288 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/matcher.py +# which has the following license... +# https://github.com/MIC-DKFZ/nnDetection/blob/main/LICENSE +# +# Copyright 2020 Division of Medical Image Computing, German Cancer Research Center (DKFZ), Heidelberg, Germany +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/main/torchvision/models/detection/_utils.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE +# +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +The functions in this script are adapted from nnDetection, +https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/matcher.py +which is adapted from torchvision. + +These are the changes compared with nndetection: +1) comments and docstrings; +2) reformat; +3) add a debug option to ATSSMatcher to help the users to tune parameters; +4) add a corner case return in ATSSMatcher.compute_matches; +5) add support for float16 cpu +""" + +import logging +from abc import ABC +from typing import Callable, Sequence, Tuple, TypeVar + +import torch +from torch import Tensor + +from monai.data.box_utils import COMPUTE_DTYPE, box_iou, boxes_center_distance, centers_in_boxes +from monai.utils.type_conversion import convert_to_tensor + +# -INF should be smaller than the lower bound of similarity_fn output. +INF = float("inf") + + +class Matcher(ABC): + """ + Base class of Matcher, which matches boxes and anchors to each other + + Args: + similarity_fn: function for similarity computation between + boxes and anchors + """ + + BELOW_LOW_THRESHOLD: int = -1 + BETWEEN_THRESHOLDS: int = -2 + + def __init__(self, similarity_fn: Callable[[Tensor, Tensor], Tensor] = box_iou): # type: ignore + self.similarity_fn = similarity_fn + + def __call__( + self, boxes: torch.Tensor, anchors: torch.Tensor, num_anchors_per_level: Sequence[int], num_anchors_per_loc: int + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Compute matches for a single image + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + anchors: anchors to match Mx4 or Mx6, also assumed to be ``StandardMode``. + num_anchors_per_level: number of anchors per feature pyramid level + num_anchors_per_loc: number of anchors per position + + Returns: + - matrix which contains the similarity from each boxes to each anchor [N, M] + - vector which contains the matched box index for all + anchors (if background `BELOW_LOW_THRESHOLD` is used + and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] + + Note: + ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + also represented as "xyxy" ([xmin, ymin, xmax, ymax]) for 2D + and "xyzxyz" ([xmin, ymin, zmin, xmax, ymax, zmax]) for 3D. + """ + if boxes.numel() == 0: + # no ground truth + num_anchors = anchors.shape[0] + match_quality_matrix = torch.tensor([]).to(anchors) + matches = torch.empty(num_anchors, dtype=torch.int64).fill_(self.BELOW_LOW_THRESHOLD) + return match_quality_matrix, matches + # at least one ground truth + return self.compute_matches( + boxes=boxes, + anchors=anchors, + num_anchors_per_level=num_anchors_per_level, + num_anchors_per_loc=num_anchors_per_loc, + ) + + def compute_matches( + self, boxes: torch.Tensor, anchors: torch.Tensor, num_anchors_per_level: Sequence[int], num_anchors_per_loc: int + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Compute matches + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + anchors: anchors to match Mx4 or Mx6, also assumed to be ``StandardMode``. + num_anchors_per_level: number of anchors per feature pyramid level + num_anchors_per_loc: number of anchors per position + + Returns: + - matrix which contains the similarity from each boxes to each anchor [N, M] + - vector which contains the matched box index for all + anchors (if background `BELOW_LOW_THRESHOLD` is used + and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] + """ + raise NotImplementedError + + +class ATSSMatcher(Matcher): + def __init__( + self, + num_candidates: int = 4, + similarity_fn: Callable[[Tensor, Tensor], Tensor] = box_iou, # type: ignore + center_in_gt: bool = True, + debug: bool = False, + ): + """ + Compute matching based on ATSS https://arxiv.org/abs/1912.02424 + `Bridging the Gap Between Anchor-based and Anchor-free Detection + via Adaptive Training Sample Selection` + + Args: + num_candidates: number of positions to select candidates from. + Smaller value will result in a higher matcher threshold and less matched candidates. + similarity_fn: function for similarity computation between boxes and anchors + center_in_gt: If False (default), matched anchor center points do not need + to lie withing the ground truth box. Recommend False for small objects. + If True, will result in a strict matcher and less matched candidates. + debug: if True, will print the matcher threshold in order to + tune ``num_candidates`` and ``center_in_gt``. + """ + super().__init__(similarity_fn=similarity_fn) + self.num_candidates = num_candidates + self.min_dist = 0.01 + self.center_in_gt = center_in_gt + self.debug = debug + logging.info( + f"Running ATSS Matching with num_candidates={self.num_candidates} " f"and center_in_gt {self.center_in_gt}." + ) + + def compute_matches( + self, boxes: torch.Tensor, anchors: torch.Tensor, num_anchors_per_level: Sequence[int], num_anchors_per_loc: int + ) -> Tuple[torch.Tensor, torch.Tensor]: + """ + Compute matches according to ATTS for a single image + Adapted from + (https://github.com/sfzhang15/ATSS/blob/79dfb28bd1/atss_core/modeling/rpn/atss/loss.py#L180-L184) + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + anchors: anchors to match Mx4 or Mx6, also assumed to be ``StandardMode``. + num_anchors_per_level: number of anchors per feature pyramid level + num_anchors_per_loc: number of anchors per position + + Returns: + Tensor: matrix which contains the similarity from each boxes to each anchor [N, M] + Tensor: vector which contains the matched box index for all + anchors (if background `BELOW_LOW_THRESHOLD` is used + and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] + + Note: + ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, + also represented as "xyxy" ([xmin, ymin, xmax, ymax]) for 2D + and "xyzxyz" ([xmin, ymin, zmin, xmax, ymax, zmax]) for 3D. + """ + num_gt = boxes.shape[0] + num_anchors = anchors.shape[0] + + distances_, _, anchors_center = boxes_center_distance(boxes, anchors) # num_boxes x anchors + distances = convert_to_tensor(distances_) + + # select candidates based on center distance + candidate_idx_list = [] + start_idx = 0 + for _, apl in enumerate(num_anchors_per_level): + end_idx = start_idx + apl * num_anchors_per_loc + + # topk: total number of candidates per position + topk = min(self.num_candidates * num_anchors_per_loc, apl) + # torch.topk() does not support float16 cpu, need conversion to float32 or float64 + _, idx = distances[:, start_idx:end_idx].to(COMPUTE_DTYPE).topk(topk, dim=1, largest=False) + # idx: shape [num_boxes x topk] + candidate_idx_list.append(idx + start_idx) + + start_idx = end_idx + # [num_boxes x num_candidates] (index of candidate anchors) + candidate_idx = torch.cat(candidate_idx_list, dim=1) + + match_quality_matrix = self.similarity_fn(boxes, anchors) # [num_boxes x anchors] + candidate_ious = match_quality_matrix.gather(1, candidate_idx) # [num_boxes, n_candidates] + + # corner case, n_candidates<=1 will make iou_std_per_gt NaN + if candidate_idx.shape[1] <= 1: + matches = -1 * torch.ones((num_anchors,), dtype=torch.long, device=boxes.device) + matches[candidate_idx] = 0 + return match_quality_matrix, matches + + # compute adaptive iou threshold + iou_mean_per_gt = candidate_ious.mean(dim=1) # [num_boxes] + iou_std_per_gt = candidate_ious.std(dim=1) # [num_boxes] + iou_thresh_per_gt = iou_mean_per_gt + iou_std_per_gt # [num_boxes] + is_pos = candidate_ious >= iou_thresh_per_gt[:, None] # [num_boxes x n_candidates] + if self.debug: + print(f"Anchor matcher threshold: {iou_thresh_per_gt}") + + if self.center_in_gt: # can discard all candidates in case of very small objects :/ + # center point of selected anchors needs to lie within the ground truth + boxes_idx = ( + torch.arange(num_gt, device=boxes.device, dtype=torch.long)[:, None] + .expand_as(candidate_idx) + .contiguous() + ) # [num_boxes x n_candidates] + is_in_gt_ = centers_in_boxes( + anchors_center[candidate_idx.view(-1)], boxes[boxes_idx.view(-1)], eps=self.min_dist + ) + is_in_gt = convert_to_tensor(is_in_gt_) + is_pos = is_pos & is_in_gt.view_as(is_pos) # [num_boxes x n_candidates] + + # in case on anchor is assigned to multiple boxes, use box with highest IoU + # TODO: think about a better way to do this + for ng in range(num_gt): + candidate_idx[ng, :] += ng * num_anchors + ious_inf = torch.full_like(match_quality_matrix, -INF).view(-1) + index = candidate_idx.view(-1)[is_pos.view(-1)] + ious_inf[index] = match_quality_matrix.view(-1)[index] + ious_inf = ious_inf.view_as(match_quality_matrix) + + matched_vals, matches = ious_inf.to(COMPUTE_DTYPE).max(dim=0) + matches[matched_vals == -INF] = self.BELOW_LOW_THRESHOLD + # print(f"Num matches {(matches >= 0).sum()}, Adapt IoU {iou_thresh_per_gt}") + return match_quality_matrix, matches + + +MatcherType = TypeVar("MatcherType", bound=Matcher) diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index 736eef8309..0f084c1ff5 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -155,7 +155,7 @@ def sliding_window_inference( raise RuntimeError( "Seems to be OOM. Please try smaller patch size or mode='constant' instead of mode='gaussian'." ) from e - importance_map = convert_data_type(importance_map, torch.Tensor, device=device, dtype=compute_dtype)[0] # type: ignore + importance_map = convert_data_type(importance_map, torch.Tensor, device, compute_dtype)[0] # type: ignore # handle non-positive weights min_non_zero = max(importance_map[importance_map != 0].min().item(), 1e-3) importance_map = torch.clamp(importance_map.to(torch.float32), min=min_non_zero).to(compute_dtype) diff --git a/tests/test_atss_box_matcher.py b/tests/test_atss_box_matcher.py new file mode 100644 index 0000000000..093641bb2f --- /dev/null +++ b/tests/test_atss_box_matcher.py @@ -0,0 +1,44 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.utils.ATSS_matcher import ATSSMatcher +from monai.data.box_utils import box_iou +from tests.utils import assert_allclose + +TEST_CASES = [ + [ + {"num_candidates": 2, "similarity_fn": box_iou, "center_in_gt": False}, + torch.tensor([[0, 1, 2, 3, 2, 5]], dtype=torch.float16), + torch.tensor([[0, 1, 2, 3, 2, 5], [0, 1, 1, 3, 2, 5], [0, 1, 2, 3, 2, 4]], dtype=torch.float16), + [3], + 3, + torch.tensor([0, -1, -1]), + ] +] + + +class TestATSS(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_atss(self, input_param, boxes, anchors, num_anchors_per_level, num_anchors_per_loc, expected_matches): + matcher = ATSSMatcher(**input_param, debug=True) + match_quality_matrix, matches = matcher.compute_matches( + boxes, anchors, num_anchors_per_level, num_anchors_per_loc + ) + assert_allclose(matches, expected_matches, type_test=True, device_test=True, atol=0) + + +if __name__ == "__main__": + unittest.main() From 2c78bb0c5b8223ad4ff420d11821027871393c30 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 30 May 2022 16:19:39 +0100 Subject: [PATCH 108/183] 4323 - review readme (#4390) review readme Signed-off-by: Wenqi Li --- README.md | 6 ++++-- docs/source/index.rst | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bb217b7247..5701d1786e 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ To install [the current release](https://pypi.org/project/monai/), you can simpl pip install monai ``` -For other installation methods (using the default GitHub branch, using Docker, etc.), please refer to [the installation guide](https://docs.monai.io/en/latest/installation.html). +Please refer to [the installation guide](https://docs.monai.io/en/latest/installation.html) for other installation options. ## Getting Started @@ -56,12 +56,14 @@ Ask and answer questions over on [MONAI's GitHub Discussions tab](https://github ## Links - Website: https://monai.io/ -- API documentation: https://docs.monai.io +- API documentation (milestone): https://docs.monai.io/ +- API documentation (latest dev): https://docs.monai.io/en/latest/ - Code: https://github.com/Project-MONAI/MONAI - Project tracker: https://github.com/Project-MONAI/MONAI/projects - Issue tracker: https://github.com/Project-MONAI/MONAI/issues - Wiki: https://github.com/Project-MONAI/MONAI/wiki - Test status: https://github.com/Project-MONAI/MONAI/actions - PyPI package: https://pypi.org/project/monai/ +- conda-forge: https://anaconda.org/conda-forge/monai - Weekly previews: https://pypi.org/project/monai-weekly/ - Docker Hub: https://hub.docker.com/r/projectmonai/monai diff --git a/docs/source/index.rst b/docs/source/index.rst index 6c99feac11..6bd8097ed7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -76,7 +76,8 @@ Links ----- - Website: https://monai.io/ -- API documentation: https://docs.monai.io +- API documentation (milestone): https://docs.monai.io/ +- API documentation (latest dev): https://docs.monai.io/en/latest/ - Code: https://github.com/Project-MONAI/MONAI - Project tracker: https://github.com/Project-MONAI/MONAI/projects - Issue tracker: https://github.com/Project-MONAI/MONAI/issues @@ -85,6 +86,7 @@ Links - FAQ: https://github.com/Project-MONAI/MONAI/wiki/Frequently-asked-questions-and-answers - Test status: https://github.com/Project-MONAI/MONAI/actions - PyPI package: https://pypi.org/project/monai/ +- conda-forge: https://anaconda.org/conda-forge/monai - Weekly previews: https://pypi.org/project/monai-weekly/ - Docker Hub: https://hub.docker.com/r/projectmonai/monai From 9133e35e0fbdd73bc6c0e3e21a7429a0e625ae36 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Mon, 30 May 2022 12:22:14 -0400 Subject: [PATCH 109/183] add RetinaNet network (#4378) --- docs/source/apps.rst | 5 + monai/apps/detection/networks/__init__.py | 10 + .../detection/networks/retinanet_network.py | 378 ++++++++++++++++++ monai/networks/blocks/backbone_fpn_utils.py | 19 +- .../blocks/feature_pyramid_network.py | 7 +- tests/test_fpn_block.py | 38 ++ tests/test_retinanet.py | 165 ++++++++ 7 files changed, 612 insertions(+), 10 deletions(-) create mode 100644 monai/apps/detection/networks/__init__.py create mode 100644 monai/apps/detection/networks/retinanet_network.py create mode 100644 tests/test_retinanet.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index bdbf1ceb94..82198421bf 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -126,6 +126,11 @@ Applications `Detection` ----------- +`RetinaNet` +~~~~~~~~~~~ +.. automodule:: monai.apps.detection.networks.retinanet_network + :members: + `Transforms` ~~~~~~~~~~~~ .. automodule:: monai.apps.detection.transforms.box_ops diff --git a/monai/apps/detection/networks/__init__.py b/monai/apps/detection/networks/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/networks/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/apps/detection/networks/retinanet_network.py b/monai/apps/detection/networks/retinanet_network.py new file mode 100644 index 0000000000..aca1a1ef83 --- /dev/null +++ b/monai/apps/detection/networks/retinanet_network.py @@ -0,0 +1,378 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +from typing import Callable, Dict, List, Sequence, Union + +import torch +from torch import Tensor, nn + +from monai.networks.blocks.backbone_fpn_utils import _resnet_fpn_extractor +from monai.networks.layers.factories import Conv +from monai.networks.nets import resnet +from monai.utils import ensure_tuple_rep, look_up_option, optional_import + +_validate_trainable_layers, _ = optional_import( + "torchvision.models.detection.backbone_utils", name="_validate_trainable_layers" +) + + +class RetinaNetClassificationHead(nn.Module): + """ + A classification head for use in RetinaNet. + + This head takes a list of feature maps as inputs, and outputs a list of classification maps. + Each output map has same spatial size with the corresponding input feature map, + and the number of output channel is num_anchors * num_classes. + + Args: + in_channels: number of channels of the input feature + num_anchors: number of anchors to be predicted + num_classes: number of classes to be predicted + spatial_dims: spatial dimension of the network, should be 2 or 3. + prior_probability: prior probability to initialize classification convolutional layers. + """ + + def __init__( + self, in_channels: int, num_anchors: int, num_classes: int, spatial_dims: int, prior_probability: float = 0.01 + ): + super().__init__() + + conv_type: Callable = Conv[Conv.CONV, spatial_dims] + conv = [] + for _ in range(4): + conv.append(conv_type(in_channels, in_channels, kernel_size=3, stride=1, padding=1)) + conv.append(nn.GroupNorm(num_groups=8, num_channels=in_channels)) + conv.append(nn.ReLU()) + self.conv = nn.Sequential(*conv) + + for layer in self.conv.children(): + if isinstance(layer, conv_type): # type: ignore + torch.nn.init.normal_(layer.weight, std=0.01) # type: ignore + torch.nn.init.constant_(layer.bias, 0) # type: ignore + + self.cls_logits = conv_type(in_channels, num_anchors * num_classes, kernel_size=3, stride=1, padding=1) + torch.nn.init.normal_(self.cls_logits.weight, std=0.01) + torch.nn.init.constant_(self.cls_logits.bias, -math.log((1 - prior_probability) / prior_probability)) + + self.num_classes = num_classes + self.num_anchors = num_anchors + + def forward(self, x: Union[List[Tensor], Tensor]) -> List[Tensor]: + """ + It takes a list of feature maps as inputs, and outputs a list of classification maps. + Each output classification map has same spatial size with the corresponding input feature map, + and the number of output channel is num_anchors * num_classes. + + Args: + x: list of feature map, x[i] is a (B, in_channels, H_i, W_i) or (B, in_channels, H_i, W_i, D_i) Tensor. + + Return: + cls_logits_maps, list of classification map. cls_logits_maps[i] is a + (B, num_anchors * num_classes, H_i, W_i) or (B, num_anchors * num_classes, H_i, W_i, D_i) Tensor. + + """ + cls_logits_maps = [] + + if isinstance(x, Tensor): + feature_maps = [x] + else: + feature_maps = x + + for features in feature_maps: + cls_logits = self.conv(features) + cls_logits = self.cls_logits(cls_logits) + + cls_logits_maps.append(cls_logits) + + if torch.isnan(cls_logits).any() or torch.isinf(cls_logits).any(): + raise ValueError("cls_logits is NaN or Inf.") + + return cls_logits_maps + + +class RetinaNetRegressionHead(nn.Module): + """ + A regression head for use in RetinaNet. + + This head takes a list of feature maps as inputs, and outputs a list of box regression maps. + Each output box regression map has same spatial size with the corresponding input feature map, + and the number of output channel is num_anchors * 2 * spatial_dims. + + Args: + in_channels: number of channels of the input feature + num_anchors: number of anchors to be predicted + spatial_dims: spatial dimension of the network, should be 2 or 3. + """ + + def __init__(self, in_channels: int, num_anchors: int, spatial_dims: int): + super().__init__() + + conv_type: Callable = Conv[Conv.CONV, spatial_dims] + + conv = [] + for _ in range(4): + conv.append(conv_type(in_channels, in_channels, kernel_size=3, stride=1, padding=1)) + conv.append(nn.GroupNorm(num_groups=8, num_channels=in_channels)) + conv.append(nn.ReLU()) + + self.conv = nn.Sequential(*conv) + + self.bbox_reg = conv_type(in_channels, num_anchors * 2 * spatial_dims, kernel_size=3, stride=1, padding=1) + torch.nn.init.normal_(self.bbox_reg.weight, std=0.01) + torch.nn.init.zeros_(self.bbox_reg.bias) + + for layer in self.conv.children(): + if isinstance(layer, conv_type): # type: ignore + torch.nn.init.normal_(layer.weight, std=0.01) # type: ignore + torch.nn.init.zeros_(layer.bias) # type: ignore + + def forward(self, x: Union[List[Tensor], Tensor]) -> List[Tensor]: + """ + It takes a list of feature maps as inputs, and outputs a list of box regression maps. + Each output box regression map has same spatial size with the corresponding input feature map, + and the number of output channel is num_anchors * 2 * spatial_dims. + + Args: + x: list of feature map, x[i] is a (B, in_channels, H_i, W_i) or (B, in_channels, H_i, W_i, D_i) Tensor. + + Return: + box_regression_maps, list of box regression map. cls_logits_maps[i] is a + (B, num_anchors * 2 * spatial_dims, H_i, W_i) or (B, num_anchors * 2 * spatial_dims, H_i, W_i, D_i) Tensor. + + """ + box_regression_maps = [] + + if isinstance(x, Tensor): + feature_maps = [x] + else: + feature_maps = x + + for features in feature_maps: + box_regression = self.conv(features) + box_regression = self.bbox_reg(box_regression) + + box_regression_maps.append(box_regression) + + if torch.isnan(box_regression).any() or torch.isinf(box_regression).any(): + raise ValueError("box_regression is NaN or Inf.") + + return box_regression_maps + + +class RetinaNet(nn.Module): + """ + The network used in RetinaNet. + + It takes an image tensor as inputs, and outputs a dictionary ``head_outputs``. + ``head_outputs[self.cls_key]`` is the predicted classification maps, a list of Tensor. + ``head_outputs[self.box_reg_key]`` is the predicted box regression maps, a list of Tensor. + + Args: + spatial_dims: number of spatial dimensions of the images. We support both 2D and 3D images. + num_classes: number of output classes of the model (excluding the background). + num_anchors: number of anchors at each location. + feature_extractor: a network that outputs feature maps from the input images, + each feature map corresponds to a different resolution. + Its output can have format of Tensor, Dict[Any, Tensor], or Sequence[Tensor]. + It can be the output of ``resnet_fpn_feature_extractor(*args, **kwargs)``. + size_divisible: the spatial size of the network input should be divisible by size_divisible, + decided by the feature_extractor. + + Example: + + .. code-block:: python + + from monai.networks.nets import resnet + spatial_dims = 3 # 3D network + conv1_t_stride = (2,2,1) # stride of first convolutional layer in backbone + backbone = resnet.ResNet( + spatial_dims = spatial_dims, + block = resnet.ResNetBottleneck, + layers = [3, 4, 6, 3], + block_inplanes = resnet.get_inplanes(), + n_input_channels= 1, + conv1_t_stride = conv1_t_stride, + conv1_t_size = (7,7,7), + ) + # This feature_extractor outputs 4-level feature maps. + # number of output feature maps is len(returned_layers)+1 + returned_layers = [1,2,3] # returned layer from feature pyramid network + feature_extractor = resnet_fpn_feature_extractor( + backbone = backbone, + spatial_dims = spatial_dims, + pretrained_backbone = False, + trainable_backbone_layers = None, + returned_layers = returned_layers, + ) + # This feature_extractor requires input imgage spatial size + # to be divisible by (32, 32, 16). + size_divisible = tuple(2*s*2**max(returned_layers) for s in conv1_t_stride) + model = RetinaNet( + spatial_dims = spatial_dims, + num_classes = 5, + num_anchors = 6, + feature_extractor=feature_extractor, + size_divisible = size_divisible, + ).to(device) + result = model(torch.rand(2, 1, 128,128,128)) + cls_logits_maps = result["cls_logits"] # a list of len(returned_layers)+1 Tensor + box_regression_maps = result["box_regression"] # a list of len(returned_layers)+1 Tensor + """ + + def __init__( + self, + spatial_dims: int, + num_classes: int, + num_anchors: int, + feature_extractor, + size_divisible: Union[Sequence[int], int] = 1, + ): + super().__init__() + + self.spatial_dims = look_up_option(spatial_dims, supported=[1, 2, 3]) + self.num_classes = num_classes + self.size_divisible = ensure_tuple_rep(size_divisible, self.spatial_dims) + + if not hasattr(feature_extractor, "out_channels"): + raise ValueError( + "feature_extractor should contain an attribute out_channels " + "specifying the number of output channels (assumed to be the " + "same for all the levels)" + ) + self.feature_extractor = feature_extractor + + self.feature_map_channels: int = self.feature_extractor.out_channels + self.num_anchors = num_anchors + self.classification_head = RetinaNetClassificationHead( + self.feature_map_channels, self.num_anchors, self.num_classes, spatial_dims=self.spatial_dims + ) + self.regression_head = RetinaNetRegressionHead( + self.feature_map_channels, self.num_anchors, spatial_dims=self.spatial_dims + ) + + self.cls_key: str = "classification" + self.box_reg_key: str = "box_regression" + + def forward(self, images: Tensor) -> Dict[str, List[Tensor]]: + """ + It takes an image tensor as inputs, and outputs a dictionary ``head_outputs``. + ``head_outputs[self.cls_key]`` is the predicted classification maps, a list of Tensor. + ``head_outputs[self.box_reg_key]`` is the predicted box regression maps, a list of Tensor. + + Args: + images: input images, sized (B, img_channels, H, W) or (B, img_channels, H, W, D). + + Return: + a dictionary ``head_outputs`` with keys including self.cls_key and self.box_reg_key. + ``head_outputs[self.cls_key]`` is the predicted classification maps, a list of Tensor. + ``head_outputs[self.box_reg_key]`` is the predicted box regression maps, a list of Tensor. + + """ + # compute features maps list from the input images. + features = self.feature_extractor(images) + if isinstance(features, Tensor): + feature_maps = [features] + elif torch.jit.isinstance(features, Dict[str, Tensor]): + feature_maps = list(features.values()) + else: + feature_maps = list(features) + + if not isinstance(feature_maps[0], Tensor): + raise ValueError("feature_extractor output format must be Tensor, Dict[str, Tensor], or Sequence[Tensor].") + + # compute classification and box regression maps from the feature maps + # expandable for mask prediction in the future + + head_outputs: Dict[str, List[Tensor]] = {self.cls_key: self.classification_head(feature_maps)} + head_outputs[self.box_reg_key] = self.regression_head(feature_maps) + + return head_outputs + + +def resnet_fpn_feature_extractor( + backbone: resnet.ResNet, + spatial_dims: int, + pretrained_backbone: bool = False, + returned_layers: Sequence[int] = (1, 2, 3), + trainable_backbone_layers: Union[int, None] = None, +): + """ + Constructs a feature extractor network with a ResNet-FPN backbone, used as feature_extractor in RetinaNet. + + Reference: `"Focal Loss for Dense Object Detection" `_. + + The returned feature_extractor network takes an image tensor as inputs, + and outputs a dictionary that maps string to the extracted feature maps (Tensor). + + The input to the returned feature_extractor is expected to be a list of tensors, + each of shape ``[C, H, W]`` or ``[C, H, W, D]``, + one for each image. Different images can have different sizes. + + + Args: + backbone: a ResNet model, used as backbone. + spatial_dims: number of spatial dimensions of the images. We support both 2D and 3D images. + pretrained_backbone: whether the backbone has been pre-trained. + returned_layers: returned layers to extract feature maps. Each returned layer should be in the range [1,4]. + len(returned_layers)+1 will be the number of extracted feature maps. + There is an extra maxpooling layer LastLevelMaxPool() appended. + trainable_backbone_layers: number of trainable (not frozen) resnet layers starting from final block. + Valid values are between 0 and 5, with 5 meaning all backbone layers are trainable. + When pretrained_backbone is False, this value is set to be 5. + When pretrained_backbone is True, if ``None`` is passed (the default) this value is set to 3. + + Example: + + .. code-block:: python + + from monai.networks.nets import resnet + spatial_dims = 3 # 3D network + backbone = resnet.ResNet( + spatial_dims = spatial_dims, + block = resnet.ResNetBottleneck, + layers = [3, 4, 6, 3], + block_inplanes = resnet.get_inplanes(), + n_input_channels= 1, + conv1_t_stride = (2,2,1), + conv1_t_size = (7,7,7), + ) + # This feature_extractor outputs 4-level feature maps. + # number of output feature maps is len(returned_layers)+1 + feature_extractor = resnet_fpn_feature_extractor( + backbone = backbone, + spatial_dims = spatial_dims, + pretrained_backbone = False, + trainable_backbone_layers = None, + returned_layers = [1,2,3], + ) + model = RetinaNet( + spatial_dims = spatial_dims, + num_classes = 5, + num_anchors = 6, + feature_extractor=feature_extractor, + size_divisible = 32, + ).to(device) + """ + # If pretrained_backbone is False, valid_trainable_backbone_layers = 5. + # If pretrained_backbone is True, valid_trainable_backbone_layers = trainable_backbone_layers or 3 if None. + valid_trainable_backbone_layers: int = _validate_trainable_layers( + pretrained_backbone, trainable_backbone_layers, max_value=5, default_value=3 + ) + + feature_extractor = _resnet_fpn_extractor( + backbone, + spatial_dims, + valid_trainable_backbone_layers, + returned_layers=list(returned_layers), + extra_blocks=None, + ) + return feature_extractor diff --git a/monai/networks/blocks/backbone_fpn_utils.py b/monai/networks/blocks/backbone_fpn_utils.py index 8975a09654..c663485583 100644 --- a/monai/networks/blocks/backbone_fpn_utils.py +++ b/monai/networks/blocks/backbone_fpn_utils.py @@ -84,6 +84,7 @@ class BackboneWithFPN(nn.Module): in_channels_list: number of channels for each feature map that is returned, in the order they are present in the OrderedDict out_channels: number of channels in the FPN. + spatial_dims: 2D or 3D images """ def __init__( @@ -97,9 +98,6 @@ def __init__( ) -> None: super().__init__() - if extra_blocks is None: - extra_blocks = LastLevelMaxPool() - # if spatial_dims is not specified, try to find it from backbone. if spatial_dims is None: if hasattr(backbone, "spatial_dims") and isinstance(backbone.spatial_dims, int): @@ -111,6 +109,9 @@ def __init__( else: raise ValueError("Could not find spatial_dims of backbone, please specify it.") + if extra_blocks is None: + extra_blocks = LastLevelMaxPool(spatial_dims) + self.body = torchvision_models._utils.IntermediateLayerGetter(backbone, return_layers=return_layers) self.fpn = FeaturePyramidNetwork( spatial_dims=spatial_dims, @@ -137,13 +138,15 @@ def forward(self, x: Tensor) -> Dict[str, Tensor]: def _resnet_fpn_extractor( backbone: resnet.ResNet, - trainable_layers: int, + spatial_dims: int, + trainable_layers: int = 5, returned_layers: Optional[List[int]] = None, extra_blocks: Optional[ExtraFPNBlock] = None, ) -> BackboneWithFPN: """ Same code as https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/backbone_utils.py - Except that ``in_channels_stage2 = backbone.in_planes // 8`` instead of ``in_channels_stage2 = backbone.inplanes // 8`` + Except that ``in_channels_stage2 = backbone.in_planes // 8`` instead of ``in_channels_stage2 = backbone.inplanes // 8``, + and it requires spatial_dims: 2D or 3D images. """ # select layers that wont be frozen @@ -157,7 +160,7 @@ def _resnet_fpn_extractor( parameter.requires_grad_(False) if extra_blocks is None: - extra_blocks = LastLevelMaxPool() + extra_blocks = LastLevelMaxPool(spatial_dims) if returned_layers is None: returned_layers = [1, 2, 3, 4] @@ -168,4 +171,6 @@ def _resnet_fpn_extractor( in_channels_stage2 = backbone.in_planes // 8 in_channels_list = [in_channels_stage2 * 2 ** (i - 1) for i in returned_layers] out_channels = 256 - return BackboneWithFPN(backbone, return_layers, in_channels_list, out_channels, extra_blocks=extra_blocks) + return BackboneWithFPN( + backbone, return_layers, in_channels_list, out_channels, extra_blocks=extra_blocks, spatial_dims=spatial_dims + ) diff --git a/monai/networks/blocks/feature_pyramid_network.py b/monai/networks/blocks/feature_pyramid_network.py index 33c289b083..09ed2c6e20 100644 --- a/monai/networks/blocks/feature_pyramid_network.py +++ b/monai/networks/blocks/feature_pyramid_network.py @@ -91,11 +91,12 @@ class LastLevelMaxPool(ExtraFPNBlock): in :class:`~monai.networks.blocks.feature_pyramid_network.FeaturePyramidNetwork` . """ - def forward(self, results: List[Tensor], x: List[Tensor], names: List[str]) -> Tuple[List[Tensor], List[str]]: - spatial_dims = len(results[0].shape) - 2 + def __init__(self, spatial_dims: int): + super().__init__() pool_type: Type[Union[nn.MaxPool1d, nn.MaxPool2d, nn.MaxPool3d]] = Pool[Pool.MAX, spatial_dims] self.maxpool = pool_type(kernel_size=1, stride=2, padding=0) + def forward(self, results: List[Tensor], x: List[Tensor], names: List[str]) -> Tuple[List[Tensor], List[str]]: names.append("pool") results.append(self.maxpool(results[-1])) return results, names @@ -239,7 +240,7 @@ def forward(self, x: Dict[str, Tensor]) -> Dict[str, Tensor]: """ # unpack OrderedDict into two lists for easier handling names = list(x.keys()) - x_values: List = list(x.values()) + x_values: List[Tensor] = list(x.values()) last_inner = self.get_result_from_inner_blocks(x_values[-1], -1) results = [] diff --git a/tests/test_fpn_block.py b/tests/test_fpn_block.py index af09d38e54..531df6aba8 100644 --- a/tests/test_fpn_block.py +++ b/tests/test_fpn_block.py @@ -15,7 +15,13 @@ import torch from parameterized import parameterized +from monai.networks.blocks.backbone_fpn_utils import _resnet_fpn_extractor from monai.networks.blocks.feature_pyramid_network import FeaturePyramidNetwork +from monai.networks.nets.resnet import resnet50 +from monai.utils import optional_import +from tests.utils import test_script_save + +_, has_torchvision = optional_import("torchvision") TEST_CASES = [] TEST_CASES.append( @@ -33,6 +39,11 @@ ] ) +TEST_CASES2 = [] +TEST_CASES2.append( + [{"spatial_dims": 3, "returned_layers": [1]}, (7, 3, 32, 64, 32), ((7, 256, 16, 32, 16), (7, 256, 8, 16, 8))] +) + class TestFPNBlock(unittest.TestCase): @parameterized.expand(TEST_CASES) @@ -45,6 +56,33 @@ def test_fpn_block(self, input_param, input_shape, expected_shape): self.assertEqual(result["feat0"].shape, expected_shape[0]) self.assertEqual(result["feat1"].shape, expected_shape[1]) + @parameterized.expand(TEST_CASES) + def test_script(self, input_param, input_shape, expected_shape): + # test whether support torchscript + net = FeaturePyramidNetwork(**input_param) + data = OrderedDict() + data["feat0"] = torch.rand(input_shape[0]) + data["feat1"] = torch.rand(input_shape[1]) + test_script_save(net, data) + + +@unittest.skipUnless(has_torchvision, "Requires torchvision") +class TestFPN(unittest.TestCase): + @parameterized.expand(TEST_CASES2) + def test_fpn(self, input_param, input_shape, expected_shape): + net = _resnet_fpn_extractor(backbone=resnet50(), spatial_dims=input_param["spatial_dims"], returned_layers=[1]) + data = torch.rand(input_shape) + result = net(data) + self.assertEqual(result["0"].shape, expected_shape[0]) + self.assertEqual(result["pool"].shape, expected_shape[1]) + + @parameterized.expand(TEST_CASES2) + def test_script(self, input_param, input_shape, expected_shape): + # test whether support torchscript + net = _resnet_fpn_extractor(backbone=resnet50(), spatial_dims=input_param["spatial_dims"], returned_layers=[1]) + data = torch.rand(input_shape) + test_script_save(net, data) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_retinanet.py b/tests/test_retinanet.py new file mode 100644 index 0000000000..13d172ada0 --- /dev/null +++ b/tests/test_retinanet.py @@ -0,0 +1,165 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.networks.retinanet_network import RetinaNet, resnet_fpn_feature_extractor +from monai.networks import eval_mode +from monai.networks.nets import resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200 +from monai.utils import ensure_tuple, optional_import +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save + +_, has_torchvision = optional_import("torchvision") + + +device = "cuda" if torch.cuda.is_available() else "cpu" +num_anchors = 7 + +TEST_CASE_1 = [ # 3D, batch 3, 2 input channel + { + "pretrained": False, + "spatial_dims": 3, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": 7, + "conv1_t_stride": (2, 2, 2), + }, + (3, 2, 32, 64, 48), +] + +TEST_CASE_2 = [ # 2D, batch 2, 1 input channel + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "conv1_t_size": [7, 7], + "conv1_t_stride": [2, 2], + }, + (2, 1, 32, 64), +] + +TEST_CASE_2_A = [ # 2D, batch 2, 1 input channel, shortcut type A + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "shortcut_type": "A", + "conv1_t_size": (7, 7), + "conv1_t_stride": 2, + }, + (2, 1, 32, 64), +] + +TEST_CASE_3 = [ # 1D, batch 1, 2 input channels + { + "pretrained": False, + "spatial_dims": 1, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": [3], + "conv1_t_stride": 1, + }, + (1, 2, 32), +] + +TEST_CASE_3_A = [ # 1D, batch 1, 2 input channels + {"pretrained": False, "spatial_dims": 1, "n_input_channels": 2, "num_classes": 3, "shortcut_type": "A"}, + (1, 2, 32), +] + +TEST_CASE_4 = [ # 2D, batch 2, 1 input channel + {"pretrained": False, "spatial_dims": 2, "n_input_channels": 1, "num_classes": 3, "feed_forward": False}, + (2, 1, 32, 64), +] + +TEST_CASES = [] +for case in [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_2_A, TEST_CASE_3_A]: + for model in [resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200]: + TEST_CASES.append([model, *case]) + +TEST_CASES_TS = [] +for case in [TEST_CASE_1]: + for model in [resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200]: + TEST_CASES_TS.append([model, *case]) + + +@SkipIfBeforePyTorchVersion((1, 9)) +@unittest.skipUnless(has_torchvision, "Requires torchvision") +class TestRetinaNet(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_retina_shape(self, model, input_param, input_shape): + backbone = model(**input_param) + feature_extractor = resnet_fpn_feature_extractor( + backbone=backbone, + spatial_dims=input_param["spatial_dims"], + pretrained_backbone=input_param["pretrained"], + trainable_backbone_layers=None, + returned_layers=[1, 2], + ) + net = RetinaNet( + spatial_dims=input_param["spatial_dims"], + num_classes=input_param["num_classes"], + num_anchors=num_anchors, + feature_extractor=feature_extractor, + size_divisible=32, + ).to(device) + + with eval_mode(net): + result = net.forward(torch.randn(input_shape).to(device)) + + base_stride = ensure_tuple(input_param["conv1_t_stride"])[0] if "conv1_t_stride" in input_param else 1 + expected_cls_channel = input_param["num_classes"] * num_anchors + expected_cls_shape = tuple( + (input_shape[0], expected_cls_channel) + + tuple(input_shape[2 + a] // s // base_stride for a in range(input_param["spatial_dims"])) + for s in [2, 4, 8] + ) + expected_box_channel = 2 * input_param["spatial_dims"] * num_anchors + expected_box_shape = tuple( + (input_shape[0], expected_box_channel) + + tuple(input_shape[2 + a] // s // base_stride for a in range(input_param["spatial_dims"])) + for s in [2, 4, 8] + ) + + self.assertEqual(tuple(cc.shape for cc in result[net.cls_key]), expected_cls_shape) + self.assertEqual(tuple(cc.shape for cc in result[net.box_reg_key]), expected_box_shape) + + @parameterized.expand(TEST_CASES_TS) + def test_script(self, model, input_param, input_shape): + # test whether support torchscript + data = torch.randn(input_shape).to(device) + backbone = model(**input_param).to(device) + test_script_save(backbone, data) + feature_extractor = resnet_fpn_feature_extractor( + backbone=backbone, + spatial_dims=input_param["spatial_dims"], + pretrained_backbone=input_param["pretrained"], + trainable_backbone_layers=None, + returned_layers=[1, 2], + ).to(device) + test_script_save(feature_extractor, data) + net = RetinaNet( + spatial_dims=input_param["spatial_dims"], + num_classes=input_param["num_classes"], + num_anchors=num_anchors, + feature_extractor=feature_extractor, + size_divisible=32, + ).to(device) + test_script_save(net, data) + + +if __name__ == "__main__": + unittest.main() From 8cdd692ec0f68be28b1c32cfe4217b4940cd1d06 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 31 May 2022 02:58:12 +0800 Subject: [PATCH 110/183] Enhance expression logic of bundle config (#4377) * [DLMED] support globals in eval(lambda) Signed-off-by: Nic Ma * [DLMED] add more tests Signed-off-by: Nic Ma * [DLMED] update test Signed-off-by: Nic Ma * [DLMED] update test Signed-off-by: Nic Ma * [DLMED] fix pickle issue Signed-off-by: Nic Ma * [DLMED] fix pickle issue Signed-off-by: Nic Ma * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] add more test Signed-off-by: Nic Ma Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/bundle/config_item.py | 11 +++++++++-- monai/bundle/reference_resolver.py | 5 +++-- tests/test_config_item.py | 15 +++++++++++---- tests/test_config_parser.py | 10 ++++++++++ tests/testing_data/inference.json | 7 +++++++ tests/testing_data/inference.yaml | 5 +++++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py index a0e3ccca26..f507025e1b 100644 --- a/monai/bundle/config_item.py +++ b/monai/bundle/config_item.py @@ -337,12 +337,13 @@ def _parse_import_string(self, import_string: str): return self.globals[asname] return None - def evaluate(self, locals: Optional[Dict] = None): + def evaluate(self, globals: Optional[Dict] = None, locals: Optional[Dict] = None): """ Execute the current config content and return the result if it is expression, based on Python `eval()`. For more details: https://docs.python.org/3/library/functions.html#eval. Args: + globals: besides ``self.globals``, other global symbols used in the expression at runtime. locals: besides ``globals``, may also have some local symbols used in the expression at runtime. """ @@ -354,7 +355,13 @@ def evaluate(self, locals: Optional[Dict] = None): return optional_module if not self.run_eval: return f"{value[len(self.prefix) :]}" - return eval(value[len(self.prefix) :], self.globals, locals) + globals_ = dict(self.globals) + if globals is not None: + for k, v in globals.items(): + if k in globals_: + warnings.warn(f"the new global variable `{k}` conflicts with `self.globals`, override it.") + globals_[k] = v + return eval(value[len(self.prefix) :], globals_, locals) @classmethod def is_expression(cls, config: Union[Dict, List, str]) -> bool: diff --git a/monai/bundle/reference_resolver.py b/monai/bundle/reference_resolver.py index 29424e94bf..02c6902151 100644 --- a/monai/bundle/reference_resolver.py +++ b/monai/bundle/reference_resolver.py @@ -163,7 +163,7 @@ def _resolve_one_item(self, id: str, waiting_list: Optional[Set[str]] = None, ** elif isinstance(item, ConfigExpression): run_eval = kwargs.get("eval_expr", True) self.resolved_content[id] = ( - item.evaluate(locals={f"{self._vars}": self.resolved_content}) if run_eval else item + item.evaluate(globals={f"{self._vars}": self.resolved_content}) if run_eval else item ) else: self.resolved_content[id] = new_config @@ -227,7 +227,8 @@ def update_refs_pattern(cls, value: str, refs: Dict) -> str: else: raise KeyError(msg) if value_is_expr: - # replace with local code, will be used in the `evaluate` logic with `locals={"refs": ...}` + # replace with local code, `{"__local_refs": self.resolved_content}` will be added to + # the `globals` argument of python `eval` in the `evaluate` value = value.replace(item, f"{cls._vars}['{ref_id}']") elif value == item: # the whole content is "@XXX", it will avoid the case that regular string contains "@" diff --git a/tests/test_config_item.py b/tests/test_config_item.py index fbd76e7be7..4d9df0a870 100644 --- a/tests/test_config_item.py +++ b/tests/test_config_item.py @@ -43,8 +43,10 @@ ] # test execute some function in args, test pre-imported global packages `monai` TEST_CASE_9 = ["collate_fn", "$monai.data.list_data_collate"] -# test lambda function, should not execute the lambda function, just change the string +# test lambda function TEST_CASE_10 = ["collate_fn", "$lambda x: monai.data.list_data_collate(x) + torch.tensor(var)"] +# test regular expression with reference +TEST_CASE_11 = ["collate_fn", "$var + 100"] class TestConfigItem(unittest.TestCase): @@ -72,12 +74,17 @@ def test_component(self, test_input, output_type): if isinstance(ret, LoadImaged): self.assertEqual(ret.keys[0], "image") - @parameterized.expand([TEST_CASE_9, TEST_CASE_10]) + @parameterized.expand([TEST_CASE_9, TEST_CASE_10, TEST_CASE_11]) def test_expression(self, id, test_input): configer = ConfigExpression(id=id, config=test_input, globals={"monai": monai, "torch": torch}) var = 100 - ret = configer.evaluate(locals={"var": var}) - self.assertTrue(isinstance(ret, Callable)) + ret = configer.evaluate(globals={"var": var, "monai": monai}) # `{"monai": monai}` to verify the warning + if isinstance(ret, Callable): + self.assertTrue(isinstance(ret([torch.tensor(1), torch.tensor(2)]), torch.Tensor)) + else: + # also test the `locals` for regular expressions + ret = configer.evaluate(locals={"var": var}) + self.assertEqual(ret, 200) def test_lazy_instantiation(self): config = {"_target_": "DataLoader", "dataset": Dataset(data=[1, 2]), "batch_size": 2} diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index ffb6ebc634..dc541d8a77 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -211,6 +211,16 @@ def test_contains(self): self.assertTrue("entry" in parser) self.assertFalse("entr" in parser) + def test_lambda_reference(self): + configs = { + "patch_size": [8, 8], + "transform": {"_target_": "Lambda", "func": "$lambda x: x.reshape((1, *@patch_size))"}, + } + parser = ConfigParser(config=configs) + trans = parser.get_parsed_content(id="transform") + result = trans(np.ones(64)) + self.assertTupleEqual(result.shape, (1, 8, 8)) + if __name__ == "__main__": unittest.main() diff --git a/tests/testing_data/inference.json b/tests/testing_data/inference.json index 8ef12c0d85..031757c0f2 100644 --- a/tests/testing_data/inference.json +++ b/tests/testing_data/inference.json @@ -1,5 +1,6 @@ { "dataset_dir": "/workspace/data/Task09_Spleen", + "prediction_shape": "prediction shape:", "import_glob": "$import glob", "device": "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')", "print_test_name": "$print('json_test')", @@ -92,6 +93,12 @@ "keys": "pred", "meta_keys": "image_meta_dict", "output_dir": "@_meta_#output_dir" + }, + { + "_target_": "Lambdad", + "keys": "pred", + "func": "$lambda x: print(@prediction_shape + str(x.shape))", + "overwrite": false } ] }, diff --git a/tests/testing_data/inference.yaml b/tests/testing_data/inference.yaml index c63c10517f..1f8c4fc6d9 100644 --- a/tests/testing_data/inference.yaml +++ b/tests/testing_data/inference.yaml @@ -1,5 +1,6 @@ --- dataset_dir: "/workspace/data/Task09_Spleen" +prediction_shape: "prediction shape:" device: "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')" print_test_name: "$print('yaml_test')" network_def: @@ -66,6 +67,10 @@ postprocessing: keys: pred meta_keys: image_meta_dict output_dir: "@_meta_#output_dir" + - _target_: Lambdad + keys: pred + func: "$lambda x: print(@prediction_shape + str(x.shape))" + overwrite: false evaluator: _target_: SupervisedEvaluator _requires_: From d6353394b35e5d93303e9695de291f3be18567f0 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Mon, 30 May 2022 16:03:49 -0400 Subject: [PATCH 111/183] add anchor generators used for detection (#4336) --- .pre-commit-config.yaml | 6 + docs/source/apps.rst | 5 + monai/apps/detection/utils/anchor_utils.py | 409 +++++++++++++++++++++ monai/networks/utils.py | 5 +- pyproject.toml | 4 + tests/test_anchor_box.py | 89 +++++ 6 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 monai/apps/detection/utils/anchor_utils.py create mode 100644 tests/test_anchor_box.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ede0366829..153617b3dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,6 +57,12 @@ repos: docs/source/conf.py )$ + - repo: https://github.com/hadialqattan/pycln + rev: v1.3.3 + hooks: + - id: pycln + args: [--config=pyproject.toml] + #- repo: https://github.com/PyCQA/isort # rev: 5.9.3 # hooks: diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 82198421bf..bc437b6be4 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -140,6 +140,11 @@ Applications .. automodule:: monai.apps.detection.transforms.dictionary :members: +`Anchor` +~~~~~~~~ +.. automodule:: monai.apps.detection.utils.anchor_utils + :members: + `Matcher` ~~~~~~~~~ .. automodule:: monai.apps.detection.utils.ATSS_matcher diff --git a/monai/apps/detection/utils/anchor_utils.py b/monai/apps/detection/utils/anchor_utils.py new file mode 100644 index 0000000000..7f11302d0a --- /dev/null +++ b/monai/apps/detection/utils/anchor_utils.py @@ -0,0 +1,409 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/anchor_utils.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE + +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +""" +This script is adapted from +https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/anchor_utils.py +""" + +from typing import List, Sequence, Union + +import torch +from torch import Tensor, nn + +from monai.utils import ensure_tuple +from monai.utils.misc import issequenceiterable +from monai.utils.module import look_up_option + + +class AnchorGenerator(nn.Module): + """ + This module is modified from torchvision to support both 2D and 3D images. + + Module that generates anchors for a set of feature maps and + image sizes. + + The module support computing anchors at multiple sizes and aspect ratios + per feature map. + + sizes and aspect_ratios should have the same number of elements, and it should + correspond to the number of feature maps. + + sizes[i] and aspect_ratios[i] can have an arbitrary number of elements. + For 2D images, anchor width and height w:h = 1:aspect_ratios[i,j] + For 3D images, anchor width, height, and depth w:h:d = 1:aspect_ratios[i,j,0]:aspect_ratios[i,j,1] + + AnchorGenerator will output a set of sizes[i] * aspect_ratios[i] anchors + per spatial location for feature map i. + + Args: + sizes: base size of each anchor. + len(sizes) is the number of feature maps, i.e., the number of output levels for + the feature pyramid network (FPN). + Each elment of ``sizes`` is a Sequence which represents several anchor sizes for each feature map. + aspect_ratios: the aspect ratios of anchors. ``len(aspect_ratios) = len(sizes)``. + For 2D images, each element of ``aspect_ratios[i]`` is a Sequence of float. + For 3D images, each element of ``aspect_ratios[i]`` is a Sequence of 2 value Sequence. + indexing: choose from {``'ij'``, ``'xy'``}, optional, + Matrix (``'ij'``, default and recommended) or Cartesian (``'xy'``) indexing of output. + + - Matrix (``'ij'``, default and recommended) indexing keeps the original axis not changed. + - To use other monai detection components, please set ``indexing = 'ij'``. + - Cartesian (``'xy'``) indexing swaps axis 0 and 1. + - For 2D cases, monai ``AnchorGenerator(sizes, aspect_ratios, indexing='xy')`` and + ``torchvision.models.detection.anchor_utils.AnchorGenerator(sizes, aspect_ratios)`` are equivalent. + + + Reference:. + https://github.com/pytorch/vision/blob/release/0.12/torchvision/models/detection/anchor_utils.py + + Example: + .. code-block:: python + + # 2D example inputs for a 2-level feature maps + sizes = ((10,12,14,16), (20,24,28,32)) + base_aspect_ratios = (1., 0.5, 2.) + aspect_ratios = (base_aspect_ratios, base_aspect_ratios) + anchor_generator = AnchorGenerator(sizes, aspect_ratios) + + # 3D example inputs for a 2-level feature maps + sizes = ((10,12,14,16), (20,24,28,32)) + base_aspect_ratios = ((1., 1.), (1., 0.5), (0.5, 1.), (2., 2.)) + aspect_ratios = (base_aspect_ratios, base_aspect_ratios) + anchor_generator = AnchorGenerator(sizes, aspect_ratios) + """ + + __annotations__ = {"cell_anchors": List[torch.Tensor]} + + def __init__( + self, + sizes: Sequence[Sequence[int]] = ((20, 30, 40),), + aspect_ratios: Sequence = (((0.5, 1), (1, 0.5)),), + indexing: str = "ij", + ) -> None: + super().__init__() + + if not issequenceiterable(sizes[0]): + self.sizes = tuple((s,) for s in sizes) + else: + self.sizes = ensure_tuple(sizes) + if not issequenceiterable(aspect_ratios[0]): + aspect_ratios = (aspect_ratios,) * len(self.sizes) + + if len(self.sizes) != len(aspect_ratios): + raise ValueError( + "len(sizes) and len(aspect_ratios) should be equal. \ + It represents the number of feature maps." + ) + + spatial_dims = len(ensure_tuple(aspect_ratios[0][0])) + 1 + spatial_dims = look_up_option(spatial_dims, [2, 3]) + self.spatial_dims = spatial_dims + + self.indexing = look_up_option(indexing, ["ij", "xy"]) + + self.aspect_ratios = aspect_ratios + self.cell_anchors = [ + self.generate_anchors(size, aspect_ratio) for size, aspect_ratio in zip(self.sizes, aspect_ratios) + ] + + # This comment comes from torchvision. + # TODO: https://github.com/pytorch/pytorch/issues/26792 + # For every (aspect_ratios, scales) combination, output a zero-centered anchor with those values. + # (scales, aspect_ratios) are usually an element of zip(self.scales, self.aspect_ratios) + # This method assumes aspect ratio = height / width for an anchor. + def generate_anchors( + self, + scales: Sequence, + aspect_ratios: Sequence, + dtype: torch.dtype = torch.float32, + device: Union[torch.device, None] = None, + ) -> torch.Tensor: + """ + Compute cell anchor shapes at multiple sizes and aspect ratios for the current feature map. + + Args: + scales: a sequence which represents several anchor sizes for the current feature map. + aspect_ratios: a sequence which represents several aspect_ratios for the current feature map. + For 2D images, it is a Sequence of float aspect_ratios[j], + anchor width and height w:h = 1:aspect_ratios[j]. + For 3D images, it is a Sequence of 2 value Sequence aspect_ratios[j,0] and aspect_ratios[j,1], + anchor width, height, and depth w:h:d = 1:aspect_ratios[j,0]:aspect_ratios[j,1] + dtype: target data type of the output Tensor. + device: target device to put the output Tensor data. + + Returns: + For each s in scales, returns [s, s*aspect_ratios[j]] for 2D images, + and [s, s*aspect_ratios[j,0],s*aspect_ratios[j,1]] for 3D images. + """ + scales_t = torch.as_tensor(scales, dtype=dtype, device=device) # sized (N,) + aspect_ratios_t = torch.as_tensor(aspect_ratios, dtype=dtype, device=device) # sized (M,) or (M,2) + if (self.spatial_dims >= 3) and (len(aspect_ratios_t.shape) != 2): + ValueError( + f"In {self.spatial_dims}-D image, aspect_ratios for each level should be \ + {len(aspect_ratios_t.shape)-1}-D. But got aspect_ratios with shape {aspect_ratios_t.shape}." + ) + + if (self.spatial_dims >= 3) and (aspect_ratios_t.shape[1] != self.spatial_dims - 1): + ValueError( + f"In {self.spatial_dims}-D image, aspect_ratios for each level should has \ + shape (_,{self.spatial_dims-1}). But got aspect_ratios with shape {aspect_ratios_t.shape}." + ) + + # if 2d, w:h = 1:aspect_ratios + if self.spatial_dims == 2: + area_scale = torch.sqrt(aspect_ratios_t) + w_ratios = 1 / area_scale + h_ratios = area_scale + # if 3d, w:h:d = 1:aspect_ratios[:,0]:aspect_ratios[:,1] + elif self.spatial_dims == 3: + area_scale = torch.pow(aspect_ratios_t[:, 0] * aspect_ratios_t[:, 1], 1 / 3.0) + w_ratios = 1 / area_scale + h_ratios = aspect_ratios_t[:, 0] / area_scale + d_ratios = aspect_ratios_t[:, 1] / area_scale + + ws = (w_ratios[:, None] * scales_t[None, :]).view(-1) + hs = (h_ratios[:, None] * scales_t[None, :]).view(-1) + if self.spatial_dims == 2: + base_anchors = torch.stack([-ws, -hs, ws, hs], dim=1) / 2.0 + elif self.spatial_dims == 3: + ds = (d_ratios[:, None] * scales_t[None, :]).view(-1) + base_anchors = torch.stack([-ws, -hs, -ds, ws, hs, ds], dim=1) / 2.0 + + return base_anchors.round() + + def set_cell_anchors(self, dtype: torch.dtype, device: torch.device): + """ + Convert each element in self.cell_anchors to ``dtype`` and send to ``device``. + """ + self.cell_anchors = [cell_anchor.to(dtype=dtype, device=device) for cell_anchor in self.cell_anchors] + + def num_anchors_per_location(self): + """ + Return number of anchor shapes for each feature map. + """ + return [c.shape[0] for c in self.cell_anchors] + + def grid_anchors(self, grid_sizes: List[List[int]], strides: List[List[Tensor]]) -> List[Tensor]: + """ + Every combination of (a, (g, s), i) in (self.cell_anchors, zip(grid_sizes, strides), 0:spatial_dims) + corresponds to a feature map. + It outputs g[i] anchors that are s[i] distance apart in direction i, with the same dimensions as a. + + Args: + grid_sizes: spatial size of the feature maps + strides: strides of the feature maps regarding to the original image + + Example: + .. code-block:: python + + grid_sizes = [[100,100],[50,50]] + strides = [[torch.tensor(2),torch.tensor(2)], [torch.tensor(4),torch.tensor(4)]] + """ + anchors = [] + cell_anchors = self.cell_anchors + assert cell_anchors is not None + + if not (len(grid_sizes) == len(strides) == len(cell_anchors)): + raise ValueError( + "Anchors should be Tuple[Tuple[int]] because each feature " + "map could potentially have different sizes and aspect ratios. " + "There needs to be a match between the number of " + "feature maps passed and the number of sizes / aspect ratios specified." + ) + + for size, stride, base_anchors in zip(grid_sizes, strides, cell_anchors): + # for each feature map + device = base_anchors.device + + # compute anchor centers regarding to the image. + # shifts_centers is [x_center, y_center] or [x_center, y_center, z_center] + shifts_centers = [ + torch.arange(0, size[axis], dtype=torch.int32, device=device) * stride[axis] + for axis in range(self.spatial_dims) + ] + + # to support torchscript, cannot directly use torch.meshgrid(shifts_centers). + shifts_centers = list(torch.meshgrid(shifts_centers[: self.spatial_dims], indexing="ij")) + + for axis in range(self.spatial_dims): + # each element of shifts_centers is sized (HW,) or (HWD,) + shifts_centers[axis] = shifts_centers[axis].reshape(-1) + + # Expand to [x_center, y_center, x_center, y_center], + # or [x_center, y_center, z_center, x_center, y_center, z_center] + if self.indexing == "xy": + # Cartesian ('xy') indexing swaps axis 0 and 1. + shifts_centers[1], shifts_centers[0] = shifts_centers[0], shifts_centers[1] + shifts = torch.stack(shifts_centers * 2, dim=1) # sized (HW,4) or (HWD,6) + + # For every (base anchor, output anchor) pair, + # offset each zero-centered base anchor by the center of the output anchor. + anchors.append( + (shifts.view(-1, 1, self.spatial_dims * 2) + base_anchors.view(1, -1, self.spatial_dims * 2)).reshape( + -1, self.spatial_dims * 2 + ) # each element sized (AHWD,4) or (AHWD,6) + ) + + return anchors + + def forward(self, images: Tensor, feature_maps: List[Tensor]) -> List[Tensor]: + """ + Generate anchor boxes for each image. + + Args: + images: sized (B, C, W, H) or (B, C, W, H, D) + feature_maps: for FPN level i, feature_maps[i] is sizec (B, C_i, W_i, H_i) or (B, C_i, W_i, H_i, D_i). + This input argument does not have to be the actual feature maps. + Any list variable with the same (C_i, W_i, H_i) or (C_i, W_i, H_i, D_i) as feature maps works. + + Return: + A list with length of B. Each element represents the anchors for this image. + The B elements are identical. + + Example: + .. code-block:: python + + images = torch.zeros((3,1,128,128,128)) + feature_maps = [torch.zeros((3,6,64,64,32)), torch.zeros((3,6,32,32,16))] + anchor_generator(images, feature_maps) + """ + grid_sizes = [list(feature_map.shape[-self.spatial_dims :]) for feature_map in feature_maps] + image_size = images.shape[-self.spatial_dims :] + batchsize = images.shape[0] + dtype, device = feature_maps[0].dtype, feature_maps[0].device + strides = [ + [ + torch.tensor(image_size[axis] // g[axis], dtype=torch.int64, device=device) + for axis in range(self.spatial_dims) + ] + for g in grid_sizes + ] + + self.set_cell_anchors(dtype, device) + anchors_over_all_feature_maps = self.grid_anchors(grid_sizes, strides) + + anchors_per_image = torch.cat(list(anchors_over_all_feature_maps)) + return [anchors_per_image] * batchsize + + +class AnchorGeneratorWithAnchorShape(AnchorGenerator): + """ + Module that generates anchors for a set of feature maps and + image sizes, inherited from :py:class:`~monai.apps.detection.networks.utils.anchor_utils.AnchorGenerator` + + The module support computing anchors at multiple base anchor shapes + per feature map. + + ``feature_map_scales`` should have the same number of elements with the number of feature maps. + + base_anchor_shapes can have an arbitrary number of elements. + For 2D images, each element represents anchor width and height [w,h]. + For 2D images, each element represents anchor width, height, and depth [w,h,d]. + + AnchorGenerator will output a set of ``len(base_anchor_shapes)`` anchors + per spatial location for feature map ``i``. + + Args: + feature_map_scales: scale of anchors for each feature map, i.e., each output level of + the feature pyramid network (FPN). ``len(feature_map_scales)`` is the number of feature maps. + ``scale[i]*base_anchor_shapes`` represents the anchor shapes for feature map ``i``. + base_anchor_shapes: a sequence which represents several anchor shapes for one feature map. + For N-D images, it is a Sequence of N value Sequence. + indexing: choose from {'xy', 'ij'}, optional + Cartesian ('xy') or matrix ('ij', default) indexing of output. + Cartesian ('xy') indexing swaps axis 0 and 1, which is the setting inside torchvision. + matrix ('ij', default) indexing keeps the original axis not changed. + See also indexing in https://pytorch.org/docs/stable/generated/torch.meshgrid.html + + Example: + .. code-block:: python + + # 2D example inputs for a 2-level feature maps + feature_map_scales = (1, 2) + base_anchor_shapes = ((10, 10), (6, 12), (12, 6)) + anchor_generator = AnchorGeneratorWithAnchorShape(feature_map_scales, base_anchor_shapes) + + # 3D example inputs for a 2-level feature maps + feature_map_scales = (1, 2) + base_anchor_shapes = ((10, 10, 10), (12, 12, 8), (10, 10, 6), (16, 16, 10)) + anchor_generator = AnchorGeneratorWithAnchorShape(feature_map_scales, base_anchor_shapes) + """ + + __annotations__ = {"cell_anchors": List[torch.Tensor]} + + def __init__( + self, + feature_map_scales: Union[Sequence[int], Sequence[float]] = (1, 2, 4, 8), + base_anchor_shapes: Union[Sequence[Sequence[int]], Sequence[Sequence[float]]] = ( + (32, 32, 32), + (48, 20, 20), + (20, 48, 20), + (20, 20, 48), + ), + indexing: str = "ij", + ) -> None: + + nn.Module.__init__(self) + + spatial_dims = len(base_anchor_shapes[0]) + spatial_dims = look_up_option(spatial_dims, [2, 3]) + self.spatial_dims = spatial_dims + + self.indexing = look_up_option(indexing, ["ij", "xy"]) + + base_anchor_shapes_t = torch.Tensor(base_anchor_shapes) + self.cell_anchors = [self.generate_anchors_using_shape(s * base_anchor_shapes_t) for s in feature_map_scales] + + @staticmethod + def generate_anchors_using_shape( + anchor_shapes: torch.Tensor, dtype: torch.dtype = torch.float32, device: Union[torch.device, None] = None + ) -> torch.Tensor: + """ + Compute cell anchor shapes at multiple sizes and aspect ratios for the current feature map. + + Args: + anchor_shapes: [w, h] or [w, h, d], sized (N, spatial_dims), + represents N anchor shapes for the current feature map. + dtype: target data type of the output Tensor. + device: target device to put the output Tensor data. + + Returns: + For 2D images, returns [-w/2, -h/2, w/2, h/2]; + For 3D images, returns [-w/2, -h/2, -d/2, w/2, h/2, d/2] + """ + half_anchor_shapes = anchor_shapes / 2.0 + base_anchors = torch.cat([-half_anchor_shapes, half_anchor_shapes], dim=1) + return base_anchors.round().to(dtype=dtype, device=device) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 6992d27309..728a290563 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -24,7 +24,6 @@ from monai.config import PathLike from monai.utils.deprecate_utils import deprecated, deprecated_arg from monai.utils.misc import ensure_tuple, save_obj, set_determinism -from monai.utils.module import pytorch_after __all__ = [ "one_hot", @@ -566,8 +565,8 @@ def convert_to_torchscript( def meshgrid_ij(*tensors): - if pytorch_after(1, 10): - return torch.meshgrid(*tensors, indexing="ij") + if torch.meshgrid.__kwdefaults__ is not None and "indexing" in torch.meshgrid.__kwdefaults__: + return torch.meshgrid(*tensors, indexing="ij") # new api pytorch after 1.10 return torch.meshgrid(*tensors) diff --git a/pyproject.toml b/pyproject.toml index eea4ebf9b1..59db27e134 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,7 @@ exclude = ''' | monai/_version.py ) ''' + +[tool.pycln] +all = true +exclude = "monai/bundle/__main__.py" diff --git a/tests/test_anchor_box.py b/tests/test_anchor_box.py new file mode 100644 index 0000000000..c927f7d6f3 --- /dev/null +++ b/tests/test_anchor_box.py @@ -0,0 +1,89 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.utils.anchor_utils import AnchorGenerator, AnchorGeneratorWithAnchorShape +from monai.utils import optional_import +from tests.utils import SkipIfBeforePyTorchVersion, assert_allclose, test_script_save + +_, has_torchvision = optional_import("torchvision") + +TEST_CASES_2D = [] +TEST_CASES_2D.append( + [ + {"sizes": ((10, 12, 14, 16), (20, 24, 28, 32)), "aspect_ratios": ((1.0, 0.5, 2.0), (1.0, 0.5, 2.0))}, + (5, 3, 128, 128), + ((5, 7, 64, 32), (5, 7, 32, 16)), + ] +) + +TEST_CASES_SHAPE_3D = [] +TEST_CASES_SHAPE_3D.append( + [ + {"feature_map_scales": (1, 2), "base_anchor_shapes": ((4, 3, 6), (8, 2, 4))}, + (5, 3, 128, 128, 128), + ((5, 7, 64, 32, 32), (5, 7, 32, 16, 16)), + ] +) + + +@SkipIfBeforePyTorchVersion((1, 11)) +@unittest.skipUnless(has_torchvision, "Requires torchvision") +class TestAnchorGenerator(unittest.TestCase): + @parameterized.expand(TEST_CASES_2D) + def test_anchor_2d(self, input_param, image_shape, feature_maps_shapes): + + torch_anchor_utils, _ = optional_import("torchvision.models.detection.anchor_utils") + image_list, _ = optional_import("torchvision.models.detection.image_list") + + # test it behaves the same with torchvision for 2d + anchor = AnchorGenerator(**input_param, indexing="xy") + anchor_ref = torch_anchor_utils.AnchorGenerator(**input_param) + for a, a_f in zip(anchor.cell_anchors, anchor_ref.cell_anchors): + assert_allclose(a, a_f, type_test=True, device_test=False, atol=1e-3) + for a, a_f in zip(anchor.num_anchors_per_location(), anchor_ref.num_anchors_per_location()): + assert_allclose(a, a_f, type_test=True, device_test=False, atol=1e-3) + + grid_sizes = [[2, 2], [1, 1]] + strides = [[torch.tensor(1), torch.tensor(2)], [torch.tensor(2), torch.tensor(4)]] + for a, a_f in zip(anchor.grid_anchors(grid_sizes, strides), anchor_ref.grid_anchors(grid_sizes, strides)): + assert_allclose(a, a_f, type_test=True, device_test=False, atol=1e-3) + + images = torch.rand(image_shape) + feature_maps = tuple(torch.rand(fs) for fs in feature_maps_shapes) + result = anchor(images, feature_maps) + result_ref = anchor_ref(image_list.ImageList(images, ([123, 122],)), feature_maps) + for a, a_f in zip(result, result_ref): + assert_allclose(a, a_f, type_test=True, device_test=False, atol=0.1) + + @parameterized.expand(TEST_CASES_2D) + def test_script_2d(self, input_param, image_shape, feature_maps_shapes): + # test whether support torchscript + anchor = AnchorGenerator(**input_param, indexing="xy") + images = torch.rand(image_shape) + feature_maps = tuple(torch.rand(fs) for fs in feature_maps_shapes) + test_script_save(anchor, images, feature_maps) + + @parameterized.expand(TEST_CASES_SHAPE_3D) + def test_script_3d(self, input_param, image_shape, feature_maps_shapes): + # test whether support torchscript + anchor = AnchorGeneratorWithAnchorShape(**input_param, indexing="ij") + images = torch.rand(image_shape) + feature_maps = tuple(torch.rand(fs) for fs in feature_maps_shapes) + test_script_save(anchor, images, feature_maps) + + +if __name__ == "__main__": + unittest.main() From 4dbbd86e39bce30bfc42c23ba92eadf37c6e33bc Mon Sep 17 00:00:00 2001 From: Harutaka Kawamura Date: Tue, 31 May 2022 05:59:29 +0900 Subject: [PATCH 112/183] Define `PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION` only when `protobuf>=4.0.0` is used (#4397) --- requirements-dev.txt | 2 +- runtests.sh | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 99f6cc3678..ac8b3730d8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -40,7 +40,7 @@ pandas requests einops transformers -mlflow!=1.26.1 # https://github.com/Project-MONAI/MONAI/issues/4375 +mlflow matplotlib!=3.5.0 tensorboardX types-PyYAML diff --git a/runtests.sh b/runtests.sh index ec9493f6b2..f7403ff05b 100755 --- a/runtests.sh +++ b/runtests.sh @@ -15,7 +15,11 @@ set -e # FIXME: https://github.com/Project-MONAI/MONAI/issues/4354 -export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python +protobuf_major_version=$(pip list | grep '^protobuf ' | tr -s ' ' | cut -d' ' -f2 | cut -d'.' -f1) +if [ "$protobuf_major_version" -ge "4" ] +then + export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python +fi # output formatting separator="" From a75dd05ce3554adad3e1fbf2f209704c2b28f5a1 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 31 May 2022 04:39:48 -0400 Subject: [PATCH 113/183] add hard_negative_sampler.py for detection (#4301) --- docs/source/apps.rst | 5 + .../detection/networks/retinanet_network.py | 4 +- .../detection/utils/hard_negative_sampler.py | 306 ++++++++++++++++++ tests/test_hardnegsampler.py | 53 +++ tests/test_retinanet.py | 24 +- 5 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 monai/apps/detection/utils/hard_negative_sampler.py create mode 100644 tests/test_hardnegsampler.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index bc437b6be4..49d2e97f94 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -126,6 +126,11 @@ Applications `Detection` ----------- +`Hard Negative Sampler` +~~~~~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.utils.hard_negative_sampler + :members: + `RetinaNet` ~~~~~~~~~~~ .. automodule:: monai.apps.detection.networks.retinanet_network diff --git a/monai/apps/detection/networks/retinanet_network.py b/monai/apps/detection/networks/retinanet_network.py index aca1a1ef83..39dbcd7b49 100644 --- a/monai/apps/detection/networks/retinanet_network.py +++ b/monai/apps/detection/networks/retinanet_network.py @@ -66,7 +66,7 @@ def __init__( self.num_classes = num_classes self.num_anchors = num_anchors - def forward(self, x: Union[List[Tensor], Tensor]) -> List[Tensor]: + def forward(self, x: List[Tensor]) -> List[Tensor]: """ It takes a list of feature maps as inputs, and outputs a list of classification maps. Each output classification map has same spatial size with the corresponding input feature map, @@ -135,7 +135,7 @@ def __init__(self, in_channels: int, num_anchors: int, spatial_dims: int): torch.nn.init.normal_(layer.weight, std=0.01) # type: ignore torch.nn.init.zeros_(layer.bias) # type: ignore - def forward(self, x: Union[List[Tensor], Tensor]) -> List[Tensor]: + def forward(self, x: List[Tensor]) -> List[Tensor]: """ It takes a list of feature maps as inputs, and outputs a list of box regression maps. Each output box regression map has same spatial size with the corresponding input feature map, diff --git a/monai/apps/detection/utils/hard_negative_sampler.py b/monai/apps/detection/utils/hard_negative_sampler.py new file mode 100644 index 0000000000..e572a3bb3a --- /dev/null +++ b/monai/apps/detection/utils/hard_negative_sampler.py @@ -0,0 +1,306 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/sampler.py +# which has the following license... +# https://github.com/MIC-DKFZ/nnDetection/blob/main/LICENSE +# +# Copyright 2020 Division of Medical Image Computing, German Cancer Research Center (DKFZ), Heidelberg, Germany +# 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. + +""" +The functions in this script are adapted from nnDetection, +https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/sampler.py +""" + +import logging +from abc import ABC +from typing import List, Tuple + +import torch +from torch import Tensor + + +class HardNegativeSamplerBase(ABC): + """ + Base class of hard negative sampler. + + Hard negative sampler is used to suppress false positive rate in classification tasks. + During training, it select negative samples with high prediction scores. + + The training workflow is described as the follows: + 1) forward network and get prediction scores (classification prob/logits) for all the samples; + 2) use hard negative sampler to choose negative samples with high prediction scores and some positive samples; + 3) compute classification loss for the selected samples; + 4) do back propagation. + + Args: + pool_size: when we need ``num_neg`` hard negative samples, they will be randomly selected from + ``num_neg * pool_size`` negative samples with the highest prediction scores. + Larger ``pool_size`` gives more randomness, yet selects negative samples that are less 'hard', + i.e., negative samples with lower prediction scores. + """ + + def __init__(self, pool_size: float = 10) -> None: + self.pool_size = pool_size + + def select_negatives(self, negative: Tensor, num_neg: int, fg_probs: Tensor) -> Tensor: + """ + Select hard negative samples. + + Args: + negative: indices of all the negative samples, sized (P,), + where P is the number of negative samples + num_neg: number of negative samples to sample + fg_probs: maximum foreground prediction scores (probability) across all the classes + for each sample, sized (A,), where A is the the number of samples. + + Returns: + binary mask of negative samples to choose, sized (A,), + where A is the the number of samples in one image + """ + if negative.numel() > fg_probs.numel(): + raise ValueError("The number of negative samples should not be larger than the number of all samples.") + + # sample pool size is ``num_neg * self.pool_size`` + pool = int(num_neg * self.pool_size) + pool = min(negative.numel(), pool) # protect against not enough negatives + + # create a sample pool of highest scoring negative samples + _, negative_idx_pool = fg_probs[negative].to(torch.float32).topk(pool, dim=0, sorted=True) + hard_negative = negative[negative_idx_pool] + + # select negatives from pool + perm2 = torch.randperm(hard_negative.numel(), device=hard_negative.device)[:num_neg] + selected_neg_idx = hard_negative[perm2] + + # output a binary mask with same size of fg_probs that indicates selected negative samples. + neg_mask = torch.zeros_like(fg_probs, dtype=torch.uint8) + neg_mask[selected_neg_idx] = 1 + return neg_mask + + +class HardNegativeSampler(HardNegativeSamplerBase): + """ + HardNegativeSampler is used to suppress false positive rate in classification tasks. + During training, it select negative samples with high prediction scores. + + The training workflow is described as the follows: + 1) forward network and get prediction scores (classification prob/logits) for all the samples; + 2) use hard negative sampler to choose negative samples with high prediction scores and some positive samples; + 3) compute classification loss for the selected samples; + 4) do back propagation. + + Args: + select_sample_size_per_image: number of training samples to be randomly selected per image + positive_fraction: percentage of positive elements in the selected samples + min_neg: minimum number of negative samples to select if possible. + pool_size: when we need ``num_neg`` hard negative samples, they will be randomly selected from + ``num_neg * pool_size`` negative samples with the highest prediction scores. + Larger ``pool_size`` gives more randomness, yet selects negative samples that are less 'hard', + i.e., negative samples with lower prediction scores. + """ + + def __init__( + self, select_sample_size_per_image: int, positive_fraction: float, min_neg: int = 1, pool_size: float = 10 + ) -> None: + super().__init__(pool_size=pool_size) + self.min_neg = min_neg + self.select_sample_size_per_image = select_sample_size_per_image + self.positive_fraction = positive_fraction + logging.info("Sampling hard negatives on a per batch basis") + + def __call__(self, target_labels: List[Tensor], concat_fg_probs: Tensor) -> Tuple[List[Tensor], List[Tensor]]: + """ + Select positives and hard negatives from list samples per image. + Hard negative sampler will be applied to each image independently. + + Args: + target_labels: list of labels per image. + For image i in the batch, target_labels[i] is a Tensor sized (A_i,), + where A_i is the number of samples in image i. + Positive samples have positive labels, negative samples have label 0. + concat_fg_probs: concatenated maximum foreground probability for all the images, sized (R,), + where R is the sum of all samples inside one batch, i.e., R = A_0 + A_1 + ... + + Returns: + - list of binary mask for positive samples + - list of binary mask for negative samples + + Example: + .. code-block:: python + + sampler = HardNegativeSampler( + select_sample_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 + ) + # two images with different number of samples + target_labels = [ torch.tensor([0,1]), torch.tensor([1,0,2,1])] + concat_fg_probs = torch.rand(6) + pos_idx_list, neg_idx_list = sampler(target_labels, concat_fg_probs) + """ + samples_per_image = [samples_in_image.shape[0] for samples_in_image in target_labels] + fg_probs = concat_fg_probs.split(samples_per_image, 0) + return self.select_samples_img_list(target_labels, fg_probs) + + def select_samples_img_list( + self, target_labels: List[Tensor], fg_probs: List[Tensor] + ) -> Tuple[List[Tensor], List[Tensor]]: + """ + Select positives and hard negatives from list samples per image. + Hard negative sampler will be applied to each image independently. + + Args: + target_labels: list of labels per image. + For image i in the batch, target_labels[i] is a Tensor sized (A_i,), + where A_i is the number of samples in image i. + Positive samples have positive labels, negative samples have label 0. + fg_probs: list of maximum foreground probability per images, + For image i in the batch, target_labels[i] is a Tensor sized (A_i,), + where A_i is the number of samples in image i. + + Returns: + - list of binary mask for positive samples + - list binary mask for negative samples + + Example: + .. code-block:: python + + sampler = HardNegativeSampler( + select_sample_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 + ) + # two images with different number of samples + target_labels = [ torch.tensor([0,1]), torch.tensor([1,0,2,1])] + fg_probs = [ torch.rand(2), torch.rand(4)] + pos_idx_list, neg_idx_list = sampler.select_samples_img_list(target_labels, fg_probs) + """ + pos_idx = [] + neg_idx = [] + + if len(target_labels) != len(fg_probs): + raise ValueError( + "Require len(target_labels) == len(fg_probs). " + f"Got len(target_labels)={len(target_labels)}, len(fg_probs)={len(fg_probs)}." + ) + for labels_per_img, fg_probs_per_img in zip(target_labels, fg_probs): + pos_idx_per_image_mask, neg_idx_per_image_mask = self.select_samples_per_img( + labels_per_img, fg_probs_per_img + ) + pos_idx.append(pos_idx_per_image_mask) + neg_idx.append(neg_idx_per_image_mask) + + return pos_idx, neg_idx + + def select_samples_per_img(self, labels_per_img: Tensor, fg_probs_per_img: Tensor) -> Tuple[Tensor, Tensor]: + """ + Select positives and hard negatives from samples. + + Args: + labels_per_img: labels, sized (A,). + Positive samples have positive labels, negative samples have label 0. + fg_probs_per_img: maximum foreground probability, sized (A,) + + Returns: + - binary mask for positive samples, sized (A,) + - binary mask for negative samples, sized (A,) + + Example: + .. code-block:: python + + sampler = HardNegativeSampler( + select_sample_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 + ) + # two images with different number of samples + target_labels = torch.tensor([1,0,2,1]) + fg_probs = torch.rand(4) + pos_idx, neg_idx = sampler.select_samples_per_img(target_labels, fg_probs) + """ + # for each image, find positive sample incides and negative sample indices + if labels_per_img.numel() != fg_probs_per_img.numel(): + raise ValueError("labels_per_img and fg_probs_per_img should have same number of elements.") + + positive = torch.where(labels_per_img >= 1)[0] + negative = torch.where(labels_per_img == 0)[0] + + num_pos = self.get_num_pos(positive) + pos_idx_per_image_mask = self.select_positives(positive, num_pos, labels_per_img) + + num_neg = self.get_num_neg(negative, num_pos) + neg_idx_per_image_mask = self.select_negatives(negative, num_neg, fg_probs_per_img) + + return pos_idx_per_image_mask, neg_idx_per_image_mask + + def get_num_pos(self, positive: torch.Tensor) -> int: + """ + Number of positive samples to draw + + Args: + positive: indices of positive samples + + Returns: + number of postive sample + """ + # positive sample sampling + num_pos = int(self.select_sample_size_per_image * self.positive_fraction) + # protect against not enough positive examples + num_pos = min(positive.numel(), num_pos) + return num_pos + + def get_num_neg(self, negative: torch.Tensor, num_pos: int) -> int: + """ + Sample enough negatives to fill up ``self.select_sample_size_per_image`` + + Args: + negative: indices of positive samples + num_pos: number of positive samples to draw + + Returns: + number of negative samples + """ + # always assume at least one pos sample was sampled + num_neg = int(max(1, num_pos) * abs(1 - 1.0 / float(self.positive_fraction))) + # protect against not enough negative examples and sample at least self.min_neg if possible + num_neg = min(negative.numel(), max(num_neg, self.min_neg)) + return num_neg + + def select_positives(self, positive: Tensor, num_pos: int, labels: Tensor) -> Tensor: + """ + Select positive samples + + Args: + positive: indices of positive samples, sized (P,), + where P is the number of positive samples + num_pos: number of positive samples to sample + labels: labels for all samples, sized (A,), + where A is the number of samples. + + Returns: + binary mask of positive samples to choose, sized (A,), + where A is the the number of samples in one image + """ + if positive.numel() > labels.numel(): + raise ValueError("The number of positive samples should not be larger than the number of all samples.") + + perm1 = torch.randperm(positive.numel(), device=positive.device)[:num_pos] + pos_idx_per_image = positive[perm1] + + # output a binary mask with same size of labels that indicates selected positive samples. + pos_idx_per_image_mask = torch.zeros_like(labels, dtype=torch.uint8) + pos_idx_per_image_mask[pos_idx_per_image] = 1 + return pos_idx_per_image_mask diff --git a/tests/test_hardnegsampler.py b/tests/test_hardnegsampler.py new file mode 100644 index 0000000000..0c952954a1 --- /dev/null +++ b/tests/test_hardnegsampler.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. + +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.utils.hard_negative_sampler import HardNegativeSampler +from tests.utils import assert_allclose + +TEST_CASE = [] +TEST_CASE.append([[], [], [], [torch.tensor([]), torch.tensor([])], [torch.tensor([]), torch.tensor([])]]) +TEST_CASE.append( + [ + [0, 1], + [1, 0, 2, 3], + [0.1, 0.9, 0.4, 0.3, 0.3, 0.5], + [torch.tensor([0, 1]), torch.tensor([1, 0, 1, 1])], + [torch.tensor([1, 0]), torch.tensor([0, 1, 0, 0])], + ] +) + +select_sample_size_per_image = 6 +positive_fraction = 0.5 +min_neg = 1 +pool_size = 2 + + +class TestSampleSlices(unittest.TestCase): + @parameterized.expand(TEST_CASE) + def test_shape(self, target_label0, target_label1, concat_fg_probs, expected_result_pos, expected_result_neg): + compute_dtypes = [torch.float16, torch.float32] + for compute_dtype in compute_dtypes: + sampler = HardNegativeSampler(select_sample_size_per_image, positive_fraction, min_neg, pool_size) + target_labels = [torch.tensor(target_label0), torch.tensor(target_label1)] + result_pos, result_neg = sampler(target_labels, torch.tensor(concat_fg_probs, dtype=compute_dtype)) + for r, er in zip(result_pos, expected_result_pos): + assert_allclose(r, er) + for r, er in zip(result_neg, expected_result_neg): + assert_allclose(r, er) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_retinanet.py b/tests/test_retinanet.py index 13d172ada0..3c136a4cf2 100644 --- a/tests/test_retinanet.py +++ b/tests/test_retinanet.py @@ -139,26 +139,36 @@ def test_retina_shape(self, model, input_param, input_shape): @parameterized.expand(TEST_CASES_TS) def test_script(self, model, input_param, input_shape): + try: + idx = int(self.id().split("test_script_")[-1]) + except BaseException: + idx = 0 + idx %= 3 # test whether support torchscript - data = torch.randn(input_shape).to(device) - backbone = model(**input_param).to(device) - test_script_save(backbone, data) + data = torch.randn(input_shape) + backbone = model(**input_param) + if idx == 0: + test_script_save(backbone, data) + return feature_extractor = resnet_fpn_feature_extractor( backbone=backbone, spatial_dims=input_param["spatial_dims"], pretrained_backbone=input_param["pretrained"], trainable_backbone_layers=None, returned_layers=[1, 2], - ).to(device) - test_script_save(feature_extractor, data) + ) + if idx == 1: + test_script_save(feature_extractor, data) + return net = RetinaNet( spatial_dims=input_param["spatial_dims"], num_classes=input_param["num_classes"], num_anchors=num_anchors, feature_extractor=feature_extractor, size_divisible=32, - ).to(device) - test_script_save(net, data) + ) + if idx == 2: + test_script_save(net, data) if __name__ == "__main__": From 2f589c6c24a80edfd5fc15bef9ab89a62711f790 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 10:37:27 -0400 Subject: [PATCH 114/183] `GridPatch` to Extract Tiles/Patches (#4321) * Implement GridPatch and RandGridPatch Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 24 +++ .../pathology/transforms/spatial/array.py | 4 +- .../transforms/spatial/dictionary.py | 3 + monai/data/wsi_datasets.py | 10 +- monai/data/wsi_reader.py | 2 +- monai/transforms/__init__.py | 8 + monai/transforms/spatial/array.py | 176 ++++++++++++++++- monai/transforms/spatial/dictionary.py | 186 +++++++++++++++++- monai/utils/__init__.py | 1 + monai/utils/enums.py | 11 ++ tests/test_grid_patch.py | 78 ++++++++ tests/test_grid_patchd.py | 83 ++++++++ tests/test_rand_grid_patch.py | 86 ++++++++ tests/test_rand_grid_patchd.py | 91 +++++++++ 14 files changed, 751 insertions(+), 12 deletions(-) create mode 100644 tests/test_grid_patch.py create mode 100644 tests/test_grid_patchd.py create mode 100644 tests/test_rand_grid_patch.py create mode 100644 tests/test_rand_grid_patchd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index a93c48984c..bf5ed2b180 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -737,6 +737,18 @@ Spatial :members: :special-members: __call__ +`GridPatch` +""""""""""" +.. autoclass:: GridPatch + :members: + :special-members: __call__ + +`RandGridPatch` +""""""""""""""" +.. autoclass:: RandGridPatch + :members: + :special-members: __call__ + `GridSplit` """"""""""" .. autoclass:: GridSplit @@ -1513,6 +1525,18 @@ Spatial (Dict) :members: :special-members: __call__ +`GridPatchd` +"""""""""""" +.. autoclass:: GridPatchd + :members: + :special-members: __call__ + +`RandGridPatchd` +"""""""""""""""" +.. autoclass:: RandGridPatchd + :members: + :special-members: __call__ + `GridSplitd` """""""""""" .. autoclass:: GridSplitd diff --git a/monai/apps/pathology/transforms/spatial/array.py b/monai/apps/pathology/transforms/spatial/array.py index a44dce1e3f..34cabac50c 100644 --- a/monai/apps/pathology/transforms/spatial/array.py +++ b/monai/apps/pathology/transforms/spatial/array.py @@ -17,12 +17,13 @@ from monai.config.type_definitions import NdarrayOrTensor from monai.transforms.transform import Randomizable, Transform -from monai.utils import convert_data_type, convert_to_dst_type +from monai.utils import convert_data_type, convert_to_dst_type, deprecated from monai.utils.enums import TransformBackends __all__ = ["SplitOnGrid", "TileOnGrid"] +@deprecated(since="0.8", msg_suffix="use `monai.transforms.GridSplit` instead.") class SplitOnGrid(Transform): """ Split the image into patches based on the provided grid shape. @@ -107,6 +108,7 @@ def get_params(self, image_size): return patch_size, steps +@deprecated(since="0.8", msg_suffix="use `monai.transforms.GridPatch` or `monai.transforms.RandGridPatch` instead.") class TileOnGrid(Randomizable, Transform): """ Tile the 2D image into patches on a grid and maintain a subset of it. diff --git a/monai/apps/pathology/transforms/spatial/dictionary.py b/monai/apps/pathology/transforms/spatial/dictionary.py index d5c34a0840..022d82a053 100644 --- a/monai/apps/pathology/transforms/spatial/dictionary.py +++ b/monai/apps/pathology/transforms/spatial/dictionary.py @@ -15,12 +15,14 @@ from monai.config import KeysCollection from monai.config.type_definitions import NdarrayOrTensor from monai.transforms.transform import MapTransform, Randomizable +from monai.utils import deprecated from .array import SplitOnGrid, TileOnGrid __all__ = ["SplitOnGridd", "SplitOnGridD", "SplitOnGridDict", "TileOnGridd", "TileOnGridD", "TileOnGridDict"] +@deprecated(since="0.8", msg_suffix="use `monai.transforms.GridSplitd` instead.") class SplitOnGridd(MapTransform): """ Split the image into patches based on the provided grid shape. @@ -55,6 +57,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +@deprecated(since="0.8", msg_suffix="use `monai.transforms.GridPatchd` or `monai.transforms.RandGridPatchd` instead.") class TileOnGridd(Randomizable, MapTransform): """ Tile the 2D image into patches on a grid and maintain a subset of it. diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 6fe5435d57..83b4fd9fe3 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -206,13 +206,13 @@ def __init__( elif isinstance(offset_limits[0], tuple): self.offset_limits = offset_limits else: - ValueError( + raise ValueError( "The offset limits should be either a tuple of integers or tuple of tuple of integers." ) else: - ValueError("The offset limits should be a tuple.") + raise ValueError("The offset limits should be a tuple.") else: - ValueError( + raise ValueError( f'Invalid string for offset "{offset}". It should be either "random" as a string,' "an integer, or a tuple of integers defining the offset." ) @@ -238,7 +238,7 @@ def _evaluate_patch_coordinates(self, sample): """Define the location for each patch based on sliding-window approach""" patch_size = self._get_size(sample) level = self._get_level(sample) - start_pos = self._get_offset(sample) + offset = self._get_offset(sample) wsi_obj = self._get_wsi_object(sample) wsi_size = self.wsi_reader.get_size(wsi_obj, 0) @@ -246,7 +246,7 @@ def _evaluate_patch_coordinates(self, sample): patch_size_ = tuple(p * downsample for p in patch_size) # patch size at level 0 locations = list( iter_patch_position( - image_size=wsi_size, patch_size=patch_size_, start_pos=start_pos, overlap=self.overlap, padded=False + image_size=wsi_size, patch_size=patch_size_, start_pos=offset, overlap=self.overlap, padded=False ) ) sample["size"] = patch_size diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index fdf7de3d63..955651999a 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -278,7 +278,7 @@ def __init__(self, backend="cucim", level: int = 0, **kwargs): elif self.backend == "openslide": self.reader = OpenSlideWSIReader(level=level, **kwargs) else: - raise ValueError("The supported backends are: cucim") + raise ValueError(f"The supported backends are cucim and openslide, '{self.backend}' was given.") self.supported_suffixes = self.reader.supported_suffixes def get_level_count(self, wsi) -> int: diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index c2385499b3..18459c1b7b 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -311,6 +311,7 @@ AffineGrid, Flip, GridDistortion, + GridPatch, GridSplit, Orientation, Rand2DElastic, @@ -321,6 +322,7 @@ RandDeformGrid, RandFlip, RandGridDistortion, + RandGridPatch, RandRotate, RandRotate90, RandZoom, @@ -343,6 +345,9 @@ GridDistortiond, GridDistortionD, GridDistortionDict, + GridPatchd, + GridPatchD, + GridPatchDict, GridSplitd, GridSplitD, GridSplitDict, @@ -367,6 +372,9 @@ RandGridDistortiond, RandGridDistortionD, RandGridDistortionDict, + RandGridPatchd, + RandGridPatchD, + RandGridPatchDict, RandRotate90d, RandRotate90D, RandRotate90Dict, diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index f833f57ebb..eb854c8d23 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -22,13 +22,21 @@ from monai.config import USE_COMPILED, DtypeLike from monai.config.type_definitions import NdarrayOrTensor -from monai.data.utils import AFFINE_TOL, compute_shape_offset, reorient_spatial_axes, to_affine_nd, zoom_affine +from monai.data.utils import ( + AFFINE_TOL, + compute_shape_offset, + iter_patch, + reorient_spatial_axes, + to_affine_nd, + zoom_affine, +) from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.networks.utils import meshgrid_ij, normalize_transform from monai.transforms.croppad.array import CenterSpatialCrop, Pad from monai.transforms.intensity.array import GaussianSmooth from monai.transforms.transform import Randomizable, RandomizableTransform, ThreadUnsafe, Transform from monai.transforms.utils import ( + convert_pad_mode, create_control_grid, create_grid, create_rotate, @@ -44,6 +52,7 @@ InterpolateMode, NumpyPadMode, PytorchPadMode, + convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, @@ -53,10 +62,10 @@ pytorch_after, ) from monai.utils.deprecate_utils import deprecated_arg -from monai.utils.enums import TransformBackends +from monai.utils.enums import GridPatchSort, TransformBackends from monai.utils.misc import ImageMetaKey as Key from monai.utils.module import look_up_option -from monai.utils.type_conversion import convert_data_type, convert_to_dst_type +from monai.utils.type_conversion import convert_data_type nib, has_nib = optional_import("nibabel") @@ -68,6 +77,8 @@ "Flip", "GridDistortion", "GridSplit", + "GridPatch", + "RandGridPatch", "Resize", "Rotate", "Zoom", @@ -2577,7 +2588,6 @@ def __call__( image, shape=(*self.grid, n_channels, split_size[0], split_size[1]), strides=(x_stride * x_step, y_stride * y_step, c_stride, x_stride, y_stride), - writeable=False, ) # Flatten the first two dimensions strided_image = strided_image.reshape(-1, *strided_image.shape[2:]) @@ -2609,3 +2619,161 @@ def _get_params( ) return size, steps + + +class GridPatch(Transform): + """ + Extract all the patches sweeping the entire image in a row-major sliding-window manner with possible overlaps. + It can sort the patches and return all or a subset of them. + + Args: + patch_size: size of patches to generate slices for, 0 or None selects whole dimension + offset: offset of starting position in the array, default is 0 for each dimension. + num_patches: number of patches to return. Defaults to None, which returns all the available patches. + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. + sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it + will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", + which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions + and channels. Also "random" creates a random order of patches. + By default no sorting is being done and patches are returned in a row-major order. + pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + patch_size: Sequence[int], + offset: Sequence[int] = (), + num_patches: Optional[int] = None, + overlap: Union[Sequence[float], float] = 0.0, + sort_fn: Optional[Union[Callable, str]] = None, + pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + **pad_kwargs, + ): + self.patch_size = ensure_tuple(patch_size) + self.offset = ensure_tuple(offset) + self.pad_mode: NumpyPadMode = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) + self.pad_kwargs = pad_kwargs + self.overlap = overlap + self.num_patches = num_patches + self.sort_fn: Optional[Callable] + if isinstance(sort_fn, str): + if sort_fn == GridPatchSort.RANDOM.value: + self.sort_fn = np.random.random + elif sort_fn == GridPatchSort.MIN.value: + self.sort_fn = self.get_patch_sum + elif sort_fn == GridPatchSort.MAX.value: + self.sort_fn = self.get_negative_patch_sum + else: + raise ValueError( + f'sort_fn should be one of the following values, "{sort_fn}" was given:', + [enum.value for enum in GridPatchSort], + ) + else: + self.sort_fn = sort_fn + + @staticmethod + def get_patch_sum(x): + return x[0].sum() + + @staticmethod + def get_negative_patch_sum(x): + return -x[0].sum() + + def __call__(self, array: NdarrayOrTensor): + # create the patch iterator which sweeps the image row-by-row + array_np, *_ = convert_data_type(array, np.ndarray) + patch_iterator = iter_patch( + array_np, + patch_size=(None,) + self.patch_size, # expand to have the channel dim + start_pos=(0,) + self.offset, # expand to have the channel dim + overlap=self.overlap, + copy_back=False, + mode=self.pad_mode, + **self.pad_kwargs, + ) + if self.sort_fn is not None: + output = sorted(patch_iterator, key=self.sort_fn) + else: + output = list(patch_iterator) + if self.num_patches: + output = output[: self.num_patches] + if len(output) < self.num_patches: + patch = np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)) + slices = np.zeros((3, len(self.patch_size))) + output += [(patch, slices)] * (self.num_patches - len(output)) + + output = [convert_to_dst_type(src=patch, dst=array)[0] for patch in output] + + return output + + +class RandGridPatch(GridPatch, RandomizableTransform): + """ + Extract all the patches sweeping the entire image in a row-major sliding-window manner with possible overlaps, + and with random offset for the minimal corner of the image, (0,0) for 2D and (0,0,0) for 3D. + It can sort the patches and return all or a subset of them. + + Args: + patch_size: size of patches to generate slices for, 0 or None selects whole dimension + min_offset: the minimum range of offset to be selected randomly. Defaults to 0. + max_offset: the maximum range of offset to be selected randomly. + Defaults to image size modulo patch size. + num_patches: number of patches to return. Defaults to None, which returns all the available patches. + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. + sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it + will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", + which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions + and channels. Also "random" creates a random order of patches. + By default no sorting is being done and patches are returned in a row-major order. + pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + patch_size: Sequence[int], + min_offset: Optional[Union[Sequence[int], int]] = None, + max_offset: Optional[Union[Sequence[int], int]] = None, + num_patches: Optional[int] = None, + overlap: Union[Sequence[float], float] = 0.0, + sort_fn: Optional[Union[Callable, str]] = None, + pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + **pad_kwargs, + ): + super().__init__( + patch_size=patch_size, + offset=(), + num_patches=num_patches, + overlap=overlap, + sort_fn=sort_fn, + pad_mode=pad_mode, + **pad_kwargs, + ) + self.min_offset = min_offset + self.max_offset = max_offset + + def randomize(self, array): + if self.min_offset is None: + min_offset = (0,) * len(self.patch_size) + else: + min_offset = ensure_tuple_rep(self.min_offset, len(self.patch_size)) + if self.max_offset is None: + max_offset = tuple(s % p for s, p in zip(array.shape[1:], self.patch_size)) + else: + max_offset = ensure_tuple_rep(self.max_offset, len(self.patch_size)) + + self.offset = tuple(self.R.randint(low=low, high=high + 1) for low, high in zip(min_offset, max_offset)) + + def __call__(self, array: NdarrayOrTensor, randomize: bool = True): + if randomize: + self.randomize(array) + return super().__call__(array) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 28d9c66448..4a38dbbb59 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -17,7 +17,7 @@ from copy import deepcopy from enum import Enum -from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -34,6 +34,7 @@ AffineGrid, Flip, GridDistortion, + GridPatch, GridSplit, Orientation, Rand2DElastic, @@ -42,6 +43,7 @@ RandAxisFlip, RandFlip, RandGridDistortion, + RandGridPatch, RandRotate, RandZoom, ResampleToMatch, @@ -63,6 +65,7 @@ ensure_tuple, ensure_tuple_rep, fall_back_tuple, + first, ) from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import PostFix, TraceKeys @@ -133,6 +136,12 @@ "GridSplitd", "GridSplitD", "GridSplitDict", + "GridPatchd", + "GridPatchD", + "GridPatchDict", + "RandGridPatchd", + "RandGridPatchD", + "RandGridPatchDict", ] GridSampleModeSequence = Union[Sequence[Union[GridSampleMode, str]], GridSampleMode, str] @@ -2194,6 +2203,179 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab return output +class GridPatchd(MapTransform): + """ + Extract all the patches sweeping the entire image in a row-major sliding-window manner with possible overlaps. + It can sort the patches and return all or a subset of them. + + Args: + keys: keys of the corresponding items to be transformed. + patch_size: size of patches to generate slices for, 0 or None selects whole dimension + offset: starting position in the array, default is 0 for each dimension. + np.random.randint(0, patch_size, 2) creates random start between 0 and `patch_size` for a 2D image. + num_patches: number of patches to return. Defaults to None, which returns all the available patches. + overlap: amount of overlap between patches in each dimension. Default to 0.0. + sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it + will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", + which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions + and channels. Also "random" creates a random order of patches. + By default no sorting is being done and patches are returned in a row-major order. + pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + allow_missing_keys: don't raise exception if key is missing. + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + + Returns: + a list of dictionaries, each of which contains the all the original key/value with the values for `keys` + replaced by the patches. It also add the following new keys: + + "slices": slices from the image that defines the patch, + "patch_size": size of the extracted patch + "num_patches": total number of patches in the image + "offset": the amount of offset for the patches in the image (starting position of upper left patch) + """ + + backend = GridPatch.backend + + def __init__( + self, + keys: KeysCollection, + patch_size: Sequence[int], + offset: Sequence[int] = (), + num_patches: Optional[int] = None, + overlap: float = 0.0, + sort_fn: Optional[Union[Callable, str]] = None, + pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, + **pad_kwargs, + ): + super().__init__(keys, allow_missing_keys) + self.patcher = GridPatch( + patch_size=patch_size, + offset=offset, + num_patches=num_patches, + overlap=overlap, + sort_fn=sort_fn, + pad_mode=pad_mode, + **pad_kwargs, + ) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: + d = dict(data) + original_spatial_shape = d[first(self.keys)].shape[1:] + output = [] + results = [self.patcher(d[key]) for key in self.keys] + num_patches = min(len(r) for r in results) + for patch in zip(*results): + new_dict = {k: v[0] for k, v in zip(self.keys, patch)} + # fill in the extra keys with unmodified data + for k in set(d.keys()).difference(set(self.keys)): + new_dict[k] = deepcopy(d[k]) + # fill additional metadata + new_dict["original_spatial_shape"] = original_spatial_shape + new_dict["slices"] = patch[0][1] # use the coordinate of the first item + new_dict["patch_size"] = self.patcher.patch_size + new_dict["num_patches"] = num_patches + new_dict["offset"] = self.patcher.offset + output.append(new_dict) + return output + + +class RandGridPatchd(RandomizableTransform, MapTransform): + """ + Extract all the patches sweeping the entire image in a row-major sliding-window manner with possible overlaps, + and with random offset for the minimal corner of the image, (0,0) for 2D and (0,0,0) for 3D. + It can sort the patches and return all or a subset of them. + + Args: + keys: keys of the corresponding items to be transformed. + patch_size: size of patches to generate slices for, 0 or None selects whole dimension + min_offset: the minimum range of starting position to be selected randomly. Defaults to 0. + max_offset: the maximum range of starting position to be selected randomly. + Defaults to image size modulo patch size. + num_patches: number of patches to return. Defaults to None, which returns all the available patches. + overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). + If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. + sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it + will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", + which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions + and channels. Also "random" creates a random order of patches. + By default no sorting is being done and patches are returned in a row-major order. + pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + allow_missing_keys: don't raise exception if key is missing. + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + + Returns: + a list of dictionaries, each of which contains the all the original key/value with the values for `keys` + replaced by the patches. It also add the following new keys: + + "slices": slices from the image that defines the patch, + "patch_size": size of the extracted patch + "num_patches": total number of patches in the image + "offset": the amount of offset for the patches in the image (starting position of the first patch) + + """ + + backend = RandGridPatch.backend + + def __init__( + self, + keys: KeysCollection, + patch_size: Sequence[int], + min_offset: Optional[Union[Sequence[int], int]] = None, + max_offset: Optional[Union[Sequence[int], int]] = None, + num_patches: Optional[int] = None, + overlap: float = 0.0, + sort_fn: Optional[Union[Callable, str]] = None, + pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, + **pad_kwargs, + ): + MapTransform.__init__(self, keys, allow_missing_keys) + self.patcher = RandGridPatch( + patch_size=patch_size, + min_offset=min_offset, + max_offset=max_offset, + num_patches=num_patches, + overlap=overlap, + sort_fn=sort_fn, + pad_mode=pad_mode, + **pad_kwargs, + ) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandGridPatchd": + super().set_random_state(seed, state) + self.patcher.set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: + d = dict(data) + original_spatial_shape = d[first(self.keys)].shape[1:] + # all the keys share the same random noise + first_key: Union[Hashable, List] = self.first_key(d) + if first_key == []: + return [d] + self.patcher.randomize(d[first_key]) # type: ignore + results = [self.patcher(d[key], randomize=False) for key in self.keys] + + num_patches = min(len(r) for r in results) + output = [] + for patch in zip(*results): + new_dict = {k: v[0] for k, v in zip(self.keys, patch)} + # fill in the extra keys with unmodified data + for k in set(d.keys()).difference(set(self.keys)): + new_dict[k] = deepcopy(d[k]) + # fill additional metadata + new_dict["original_spatial_shape"] = original_spatial_shape + new_dict["slices"] = patch[0][1] # use the coordinate of the first item + new_dict["patch_size"] = self.patcher.patch_size + new_dict["num_patches"] = num_patches + new_dict["offset"] = self.patcher.offset + output.append(new_dict) + return output + + SpatialResampleD = SpatialResampleDict = SpatialResampled ResampleToMatchD = ResampleToMatchDict = ResampleToMatchd SpacingD = SpacingDict = Spacingd @@ -2215,3 +2397,5 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab ZoomD = ZoomDict = Zoomd RandZoomD = RandZoomDict = RandZoomd GridSplitD = GridSplitDict = GridSplitd +GridPatchD = GridPatchDict = GridPatchd +RandGridPatchD = RandGridPatchDict = RandGridPatchd diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index e7ecab077d..cd8555d173 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -22,6 +22,7 @@ CommonKeys, DiceCEReduction, ForwardMode, + GridPatchSort, GridSampleMode, GridSamplePadMode, InterpolateMode, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index af044f30fe..50b55560f9 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -37,6 +37,7 @@ "ForwardMode", "TransformBackends", "BoxModeName", + "GridPatchSort", ] @@ -329,3 +330,13 @@ class BoxModeName(Enum): XYZWHD = "xyzwhd" # [xmin, ymin, zmin, xsize, ysize, zsize] CCWH = "ccwh" # [xcenter, ycenter, xsize, ysize] CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] + + +class GridPatchSort(Enum): + """ + The sorting method for the generated patches in `GridPatch` + """ + + RANDOM = "random" + MIN = "min" + MAX = "max" diff --git a/tests/test_grid_patch.py b/tests/test_grid_patch.py new file mode 100644 index 0000000000..c1d73f262f --- /dev/null +++ b/tests/test_grid_patch.py @@ -0,0 +1,78 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.spatial.array import GridPatch +from tests.utils import TEST_NDARRAYS, assert_allclose + +A = np.arange(16).repeat(3).reshape(4, 4, 3).transpose(2, 0, 1) +A11 = A[:, :2, :2] +A12 = A[:, :2, 2:] +A21 = A[:, 2:, :2] +A22 = A[:, 2:, 2:] + +TEST_CASE_0 = [{"patch_size": (2, 2)}, A, [A11, A12, A21, A22]] +TEST_CASE_1 = [{"patch_size": (2, 2), "num_patches": 3}, A, [A11, A12, A21]] +TEST_CASE_2 = [{"patch_size": (2, 2), "num_patches": 5}, A, [A11, A12, A21, A22, np.zeros((3, 2, 2))]] +TEST_CASE_3 = [{"patch_size": (2, 2), "offset": (0, 0)}, A, [A11, A12, A21, A22]] +TEST_CASE_4 = [{"patch_size": (2, 2), "offset": (0, 0)}, A, [A11, A12, A21, A22]] +TEST_CASE_5 = [{"patch_size": (2, 2), "offset": (2, 2)}, A, [A22]] +TEST_CASE_6 = [{"patch_size": (2, 2), "offset": (0, 2)}, A, [A12, A22]] +TEST_CASE_7 = [{"patch_size": (2, 2), "offset": (2, 0)}, A, [A21, A22]] +TEST_CASE_8 = [{"patch_size": (2, 2), "num_patches": 3, "sort_fn": "max"}, A, [A22, A21, A12]] +TEST_CASE_9 = [{"patch_size": (2, 2), "num_patches": 4, "sort_fn": "min"}, A, [A11, A12, A21, A22]] +TEST_CASE_10 = [{"patch_size": (2, 2), "overlap": 0.5, "num_patches": 3}, A, [A11, A[:, :2, 1:3], A12]] +TEST_CASE_11 = [ + {"patch_size": (3, 3), "num_patches": 2, "constant_values": 255}, + A, + [A[:, :3, :3], np.pad(A[:, :3, 3:], ((0, 0), (0, 0), (0, 2)), mode="constant", constant_values=255)], +] +TEST_CASE_12 = [ + {"patch_size": (3, 3), "offset": (-2, -2), "num_patches": 2}, + A, + [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], +] + + +TEST_SINGLE = [] +for p in TEST_NDARRAYS: + TEST_SINGLE.append([p, *TEST_CASE_0]) + TEST_SINGLE.append([p, *TEST_CASE_1]) + TEST_SINGLE.append([p, *TEST_CASE_2]) + TEST_SINGLE.append([p, *TEST_CASE_3]) + TEST_SINGLE.append([p, *TEST_CASE_4]) + TEST_SINGLE.append([p, *TEST_CASE_5]) + TEST_SINGLE.append([p, *TEST_CASE_6]) + TEST_SINGLE.append([p, *TEST_CASE_7]) + TEST_SINGLE.append([p, *TEST_CASE_8]) + TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) + TEST_SINGLE.append([p, *TEST_CASE_11]) + TEST_SINGLE.append([p, *TEST_CASE_12]) + + +class TestGridPatch(unittest.TestCase): + @parameterized.expand(TEST_SINGLE) + def test_grid_patch(self, in_type, input_parameters, image, expected): + input_image = in_type(image) + splitter = GridPatch(**input_parameters) + output = list(splitter(input_image)) + self.assertEqual(len(output), len(expected)) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch[0], expected_patch, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_grid_patchd.py b/tests/test_grid_patchd.py new file mode 100644 index 0000000000..a9eec8a2f6 --- /dev/null +++ b/tests/test_grid_patchd.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. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.spatial.dictionary import GridPatchd +from tests.utils import TEST_NDARRAYS, assert_allclose + +A = np.arange(16).repeat(3).reshape(4, 4, 3).transpose(2, 0, 1) +A11 = A[:, :2, :2] +A12 = A[:, :2, 2:] +A21 = A[:, 2:, :2] +A22 = A[:, 2:, 2:] + +TEST_CASE_0 = [{"patch_size": (2, 2)}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_1 = [{"patch_size": (2, 2), "num_patches": 3}, {"image": A}, [A11, A12, A21]] +TEST_CASE_2 = [{"patch_size": (2, 2), "num_patches": 5}, {"image": A}, [A11, A12, A21, A22, np.zeros((3, 2, 2))]] +TEST_CASE_3 = [{"patch_size": (2, 2), "offset": (0, 0)}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_4 = [{"patch_size": (2, 2), "offset": (0, 0)}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_5 = [{"patch_size": (2, 2), "offset": (2, 2)}, {"image": A}, [A22]] +TEST_CASE_6 = [{"patch_size": (2, 2), "offset": (0, 2)}, {"image": A}, [A12, A22]] +TEST_CASE_7 = [{"patch_size": (2, 2), "offset": (2, 0)}, {"image": A}, [A21, A22]] +TEST_CASE_8 = [{"patch_size": (2, 2), "num_patches": 3, "sort_fn": "max"}, {"image": A}, [A22, A21, A12]] +TEST_CASE_9 = [{"patch_size": (2, 2), "num_patches": 4, "sort_fn": "min"}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_10 = [{"patch_size": (2, 2), "overlap": 0.5, "num_patches": 3}, {"image": A}, [A11, A[:, :2, 1:3], A12]] +TEST_CASE_11 = [ + {"patch_size": (3, 3), "num_patches": 2, "constant_values": 255}, + {"image": A}, + [A[:, :3, :3], np.pad(A[:, :3, 3:], ((0, 0), (0, 0), (0, 2)), mode="constant", constant_values=255)], +] +TEST_CASE_12 = [ + {"patch_size": (3, 3), "offset": (-2, -2), "num_patches": 2}, + {"image": A}, + [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], +] + + +TEST_SINGLE = [] +for p in TEST_NDARRAYS: + TEST_SINGLE.append([p, *TEST_CASE_0]) + TEST_SINGLE.append([p, *TEST_CASE_1]) + TEST_SINGLE.append([p, *TEST_CASE_2]) + TEST_SINGLE.append([p, *TEST_CASE_3]) + TEST_SINGLE.append([p, *TEST_CASE_4]) + TEST_SINGLE.append([p, *TEST_CASE_5]) + TEST_SINGLE.append([p, *TEST_CASE_6]) + TEST_SINGLE.append([p, *TEST_CASE_7]) + TEST_SINGLE.append([p, *TEST_CASE_8]) + TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) + TEST_SINGLE.append([p, *TEST_CASE_11]) + TEST_SINGLE.append([p, *TEST_CASE_12]) + + +class TestGridPatchd(unittest.TestCase): + @parameterized.expand(TEST_SINGLE) + def test_grid_patchd(self, in_type, input_parameters, image_dict, expected): + image_key = "image" + input_dict = {} + for k, v in image_dict.items(): + input_dict[k] = v + if k == image_key: + input_dict[k] = in_type(v) + splitter = GridPatchd(keys=image_key, **input_parameters) + output = list(splitter(input_dict)) + self.assertEqual(len(output), len(expected)) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch[image_key], expected_patch, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_grid_patch.py b/tests/test_rand_grid_patch.py new file mode 100644 index 0000000000..36da899982 --- /dev/null +++ b/tests/test_rand_grid_patch.py @@ -0,0 +1,86 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.spatial.array import RandGridPatch +from monai.utils import set_determinism +from tests.utils import TEST_NDARRAYS, assert_allclose + +set_determinism(1234) + +A = np.arange(16).repeat(3).reshape(4, 4, 3).transpose(2, 0, 1) +A11 = A[:, :2, :2] +A12 = A[:, :2, 2:] +A21 = A[:, 2:, :2] +A22 = A[:, 2:, 2:] + +TEST_CASE_0 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0}, A, [A11, A12, A21, A22]] +TEST_CASE_1 = [{"patch_size": (2, 2), "min_offset": 0, "num_patches": 3}, A, [A11, A12, A21]] +TEST_CASE_2 = [ + {"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "num_patches": 5}, + A, + [A11, A12, A21, A22, np.zeros((3, 2, 2))], +] +TEST_CASE_3 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0}, A, [A11, A12, A21, A22]] +TEST_CASE_4 = [{"patch_size": (2, 2)}, A, [A11, A12, A21, A22]] +TEST_CASE_5 = [{"patch_size": (2, 2), "min_offset": 2, "max_offset": 2}, A, [A22]] +TEST_CASE_6 = [{"patch_size": (2, 2), "min_offset": (0, 2), "max_offset": (0, 2)}, A, [A12, A22]] +TEST_CASE_7 = [{"patch_size": (2, 2), "min_offset": 1, "max_offset": 2}, A, [A22]] +TEST_CASE_8 = [ + {"patch_size": (2, 2), "min_offset": 0, "max_offset": 1, "num_patches": 1, "sort_fn": "max"}, + A, + [A[:, 1:3, 1:3]], +] +TEST_CASE_9 = [ + { + "patch_size": (3, 3), + "min_offset": -3, + "max_offset": -1, + "sort_fn": "min", + "num_patches": 1, + "constant_values": 255, + }, + A, + [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], +] + +TEST_SINGLE = [] +for p in TEST_NDARRAYS: + TEST_SINGLE.append([p, *TEST_CASE_0]) + TEST_SINGLE.append([p, *TEST_CASE_1]) + TEST_SINGLE.append([p, *TEST_CASE_2]) + TEST_SINGLE.append([p, *TEST_CASE_3]) + TEST_SINGLE.append([p, *TEST_CASE_4]) + TEST_SINGLE.append([p, *TEST_CASE_5]) + TEST_SINGLE.append([p, *TEST_CASE_6]) + TEST_SINGLE.append([p, *TEST_CASE_7]) + TEST_SINGLE.append([p, *TEST_CASE_8]) + TEST_SINGLE.append([p, *TEST_CASE_9]) + + +class TestRandGridPatch(unittest.TestCase): + @parameterized.expand(TEST_SINGLE) + def test_rand_grid_patch(self, in_type, input_parameters, image, expected): + input_image = in_type(image) + splitter = RandGridPatch(**input_parameters) + splitter.set_random_state(1234) + output = list(splitter(input_image)) + self.assertEqual(len(output), len(expected)) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch[0], expected_patch, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_grid_patchd.py b/tests/test_rand_grid_patchd.py new file mode 100644 index 0000000000..6f89a3d155 --- /dev/null +++ b/tests/test_rand_grid_patchd.py @@ -0,0 +1,91 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.spatial.dictionary import RandGridPatchd +from monai.utils import set_determinism +from tests.utils import TEST_NDARRAYS, assert_allclose + +set_determinism(1234) + +A = np.arange(16).repeat(3).reshape(4, 4, 3).transpose(2, 0, 1) +A11 = A[:, :2, :2] +A12 = A[:, :2, 2:] +A21 = A[:, 2:, :2] +A22 = A[:, 2:, 2:] + +TEST_CASE_0 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_1 = [{"patch_size": (2, 2), "min_offset": 0, "num_patches": 3}, {"image": A}, [A11, A12, A21]] +TEST_CASE_2 = [ + {"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "num_patches": 5}, + {"image": A}, + [A11, A12, A21, A22, np.zeros((3, 2, 2))], +] +TEST_CASE_3 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_4 = [{"patch_size": (2, 2)}, {"image": A}, [A11, A12, A21, A22]] +TEST_CASE_5 = [{"patch_size": (2, 2), "min_offset": 2, "max_offset": 2}, {"image": A}, [A22]] +TEST_CASE_6 = [{"patch_size": (2, 2), "min_offset": (0, 2), "max_offset": (0, 2)}, {"image": A}, [A12, A22]] +TEST_CASE_7 = [{"patch_size": (2, 2), "min_offset": 1, "max_offset": 2}, {"image": A}, [A22]] +TEST_CASE_8 = [ + {"patch_size": (2, 2), "min_offset": 0, "max_offset": 1, "num_patches": 1, "sort_fn": "max"}, + {"image": A}, + [A[:, 1:3, 1:3]], +] +TEST_CASE_9 = [ + { + "patch_size": (3, 3), + "min_offset": -3, + "max_offset": -1, + "sort_fn": "min", + "num_patches": 1, + "constant_values": 255, + }, + {"image": A}, + [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], +] + +TEST_SINGLE = [] +for p in TEST_NDARRAYS: + TEST_SINGLE.append([p, *TEST_CASE_0]) + TEST_SINGLE.append([p, *TEST_CASE_1]) + TEST_SINGLE.append([p, *TEST_CASE_2]) + TEST_SINGLE.append([p, *TEST_CASE_3]) + TEST_SINGLE.append([p, *TEST_CASE_4]) + TEST_SINGLE.append([p, *TEST_CASE_5]) + TEST_SINGLE.append([p, *TEST_CASE_6]) + TEST_SINGLE.append([p, *TEST_CASE_7]) + TEST_SINGLE.append([p, *TEST_CASE_8]) + TEST_SINGLE.append([p, *TEST_CASE_9]) + + +class TestRandGridPatchd(unittest.TestCase): + @parameterized.expand(TEST_SINGLE) + def test_rand_grid_patchd(self, in_type, input_parameters, image_dict, expected): + image_key = "image" + input_dict = {} + for k, v in image_dict.items(): + input_dict[k] = v + if k == image_key: + input_dict[k] = in_type(v) + splitter = RandGridPatchd(keys=image_key, **input_parameters) + splitter.set_random_state(1234) + output = list(splitter(input_dict)) + self.assertEqual(len(output), len(expected)) + for output_patch, expected_patch in zip(output, expected): + assert_allclose(output_patch[image_key], expected_patch, type_test=False) + + +if __name__ == "__main__": + unittest.main() From 27c37f24b87abf68ecc6200e2bb444d9770d536f Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 12:18:36 -0400 Subject: [PATCH 115/183] Foreground Tissue Mask (#4379) * Implement foreground mask Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 16 +++ monai/transforms/__init__.py | 4 + monai/transforms/intensity/array.py | 113 ++++++++++++++++-- monai/transforms/intensity/dictionary.py | 51 ++++++++ .../transforms/utils_create_transform_ims.py | 4 + monai/utils/__init__.py | 1 + monai/utils/enums.py | 12 ++ requirements-dev.txt | 2 +- tests/min_tests.py | 2 + tests/test_foreground_mask.py | 96 +++++++++++++++ tests/test_foreground_maskd.py | 104 ++++++++++++++++ 11 files changed, 394 insertions(+), 11 deletions(-) create mode 100644 tests/test_foreground_mask.py create mode 100644 tests/test_foreground_maskd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index bf5ed2b180..2eb2537b49 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -446,6 +446,15 @@ Intensity :members: :special-members: __call__ + +`ForegroundMask` +"""""""""""""""" +.. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/ForegroundMask.png + :alt: example of ForegroundMask +.. autoclass:: ForegroundMask + :members: + :special-members: __call__ + IO ^^ @@ -1339,6 +1348,13 @@ Intensity (Dict) :members: :special-members: __call__ +`ForegroundMaskd` +""""""""""""""""" +.. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/ForegroundMaskd.png + :alt: example of ForegroundMaskd +.. autoclass:: ForegroundMaskd + :members: + :special-members: __call__ IO (Dict) ^^^^^^^^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 18459c1b7b..d4f09474de 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -81,6 +81,7 @@ from .intensity.array import ( AdjustContrast, DetectEnvelope, + ForegroundMask, GaussianSharpen, GaussianSmooth, GibbsNoise, @@ -117,6 +118,9 @@ AdjustContrastd, AdjustContrastD, AdjustContrastDict, + ForegroundMaskd, + ForegroundMaskD, + ForegroundMaskDict, GaussianSharpend, GaussianSharpenD, GaussianSharpenDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 06b8cfa108..43ed2df62a 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -16,7 +16,7 @@ from abc import abstractmethod from collections.abc import Iterable from functools import partial -from typing import Any, Callable, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union from warnings import warn import numpy as np @@ -29,17 +29,13 @@ from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array from monai.transforms.utils_pytorch_numpy_unification import clip, percentile, where -from monai.utils import ( - convert_data_type, - convert_to_dst_type, - ensure_tuple, - ensure_tuple_rep, - ensure_tuple_size, - fall_back_tuple, -) from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends -from monai.utils.type_conversion import convert_to_tensor, get_equivalent_dtype +from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils.module import min_version, optional_import +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor, get_equivalent_dtype + +skimage, _ = optional_import("skimage", "0.19.0", min_version) __all__ = [ "RandGaussianNoise", @@ -2161,3 +2157,100 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: img = IntensityRemap(self.kernel_size, self.R.choice([-self.slope, self.slope]))(img) return img + + +class ForegroundMask(Transform): + """ + Creates a binary mask that defines the foreground based on thresholds in RGB or HSV color space. + This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has + low values (dark) while the background has high values (white). Otherwise, set `invert` argument to `True`. + + Args: + threshold: an int or a float number that defines the threshold that values less than that are foreground. + It also can be a callable that receives each dimension of the image and calculate the threshold, + or a string that defines such callable from `skimage.filter.threshold_...`. For the list of available + threshold functions, please refer to https://scikit-image.org/docs/stable/api/skimage.filters.html + Moreover, a dictionary can be passed that defines such thresholds for each channel, like + {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} + hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). + Unlike RBG, in HSV, value greater than `hsv_threshold` are considered foreground. + invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, + and vice-versa. + + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + threshold: Union[Dict, Callable, str, float, int] = "otsu", + hsv_threshold: Optional[Union[Dict, Callable, str, float, int]] = None, + invert: bool = False, + ) -> None: + self.thresholds: Dict[str, Union[Callable, float]] = {} + if threshold is not None: + if isinstance(threshold, dict): + for mode, th in threshold.items(): + self._set_threshold(th, mode.upper()) + else: + self._set_threshold(threshold, "R") + self._set_threshold(threshold, "G") + self._set_threshold(threshold, "B") + if hsv_threshold is not None: + if isinstance(hsv_threshold, dict): + for mode, th in hsv_threshold.items(): + self._set_threshold(th, mode.upper()) + else: + self._set_threshold(hsv_threshold, "H") + self._set_threshold(hsv_threshold, "S") + self._set_threshold(hsv_threshold, "V") + + self.thresholds = {k: v for k, v in self.thresholds.items() if v is not None} + if self.thresholds.keys().isdisjoint(set("RGBHSV")): + raise ValueError( + f"Threshold for at least one channel of RGB or HSV needs to be set. {self.thresholds} is provided." + ) + + self.invert = invert + + def _set_threshold(self, threshold, mode): + if callable(threshold): + self.thresholds[mode] = threshold + elif isinstance(threshold, str): + self.thresholds[mode] = getattr(skimage.filters, "threshold_" + threshold.lower()) + elif isinstance(threshold, (float, int)): + self.thresholds[mode] = float(threshold) + else: + raise ValueError( + f"`threshold` should be either a callable, string, or float number, {type(threshold)} was given." + ) + + def _get_threshold(self, image, mode): + threshold = self.thresholds.get(mode) + if callable(threshold): + return threshold(image) + return threshold + + def __call__(self, image: NdarrayOrTensor): + img_rgb, *_ = convert_data_type(image, np.ndarray) + if self.invert: + img_rgb = skimage.util.invert(img_rgb) + foregrounds = [] + if not self.thresholds.keys().isdisjoint(set("RGB")): + rgb_foreground = np.zeros_like(img_rgb[:1]) + for img, mode in zip(img_rgb, "RGB"): + threshold = self._get_threshold(img, mode) + if threshold: + rgb_foreground = np.logical_or(rgb_foreground, img <= threshold) + foregrounds.append(rgb_foreground) + if not self.thresholds.keys().isdisjoint(set("HSV")): + img_hsv = skimage.color.rgb2hsv(img_rgb, channel_axis=0) + hsv_foreground = np.zeros_like(img_rgb[:1]) + for img, mode in zip(img_hsv, "HSV"): + threshold = self._get_threshold(img, mode) + if threshold: + hsv_foreground = np.logical_or(hsv_foreground, img > threshold) + foregrounds.append(hsv_foreground) + + mask = np.stack(foregrounds).all(axis=0) + return convert_to_dst_type(src=mask, dst=image)[0] diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 67dc73f93e..25cf261fe1 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -23,6 +23,7 @@ from monai.config.type_definitions import NdarrayOrTensor from monai.transforms.intensity.array import ( AdjustContrast, + ForegroundMask, GaussianSharpen, GaussianSmooth, GibbsNoise, @@ -88,6 +89,7 @@ "RandCoarseDropoutd", "RandCoarseShuffled", "HistogramNormalized", + "ForegroundMaskd", "RandGaussianNoiseD", "RandGaussianNoiseDict", "ShiftIntensityD", @@ -146,6 +148,8 @@ "HistogramNormalizeDict", "RandKSpaceSpikeNoiseD", "RandKSpaceSpikeNoiseDict", + "ForegroundMaskD", + "ForegroundMaskDict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -1654,6 +1658,52 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +class ForegroundMaskd(MapTransform): + """ + Creates a binary mask that defines the foreground based on thresholds in RGB or HSV color space. + This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has + low values (dark) while the background is white. + + Args: + keys: keys of the corresponding items to be transformed. + threshold: an int or a float number that defines the threshold that values less than that are foreground. + It also can be a callable that receives each dimension of the image and calculate the threshold, + or a string that defines such callable from `skimage.filter.threshold_...`. For the list of available + threshold functions, please refer to https://scikit-image.org/docs/stable/api/skimage.filters.html + Moreover, a dictionary can be passed that defines such thresholds for each channel, like + {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} + hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). + Unlike RBG, in HSV, value greater than `hsv_threshold` are considered foreground. + invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, + and vice-versa. + new_key_prefix: this prefix be prepended to the key to create a new key for the output and keep the value of + key intact. By default not prefix is set and the corresponding array to the key will be replaced. + allow_missing_keys: do not raise exception if key is missing. + + """ + + def __init__( + self, + keys: KeysCollection, + threshold: Union[Dict, Callable, str, float] = "otsu", + hsv_threshold: Optional[Union[Dict, Callable, str, float, int]] = None, + invert: bool = False, + new_key_prefix: Optional[str] = None, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.transform = ForegroundMask(threshold=threshold, hsv_threshold=hsv_threshold, invert=invert) + self.new_key_prefix = new_key_prefix + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + new_key = key if self.new_key_prefix is None else self.new_key_prefix + key + d[new_key] = self.transform(d[key]) + + return d + + RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised RandRicianNoiseD = RandRicianNoiseDict = RandRicianNoised ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd @@ -1683,3 +1733,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N RandCoarseDropoutD = RandCoarseDropoutDict = RandCoarseDropoutd HistogramNormalizeD = HistogramNormalizeDict = HistogramNormalized RandCoarseShuffleD = RandCoarseShuffleDict = RandCoarseShuffled +ForegroundMaskD = ForegroundMaskDict = ForegroundMaskd diff --git a/monai/transforms/utils_create_transform_ims.py b/monai/transforms/utils_create_transform_ims.py index b096e1b93d..6165496599 100644 --- a/monai/transforms/utils_create_transform_ims.py +++ b/monai/transforms/utils_create_transform_ims.py @@ -85,6 +85,7 @@ ) from monai.transforms.intensity.array import ( AdjustContrast, + ForegroundMask, GaussianSharpen, GaussianSmooth, GibbsNoise, @@ -115,6 +116,7 @@ ) from monai.transforms.intensity.dictionary import ( AdjustContrastd, + ForegroundMaskd, GaussianSharpend, GaussianSmoothd, GibbsNoised, @@ -584,6 +586,8 @@ def create_transform_im( create_transform_im( MaskIntensityd, dict(keys=CommonKeys.IMAGE, mask_key=CommonKeys.IMAGE, select_fn=lambda x: x > 0.3), data ) + create_transform_im(ForegroundMask, dict(invert=True), data) + create_transform_im(ForegroundMaskd, dict(keys=CommonKeys.IMAGE, invert=True), data) create_transform_im(GaussianSmooth, dict(sigma=2), data) create_transform_im(GaussianSmoothd, dict(keys=CommonKeys.IMAGE, sigma=2), data) create_transform_im(RandGaussianSmooth, dict(prob=1.0, sigma_x=(1, 2)), data) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index cd8555d173..0d5d8bf92d 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -33,6 +33,7 @@ MetricReduction, NumpyPadMode, PostFix, + ProbMapKeys, PytorchPadMode, SkipMode, TraceKeys, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 50b55560f9..9fb4e480f6 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -332,6 +332,18 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] +class ProbMapKeys(Enum): + """ + The keys to be used for generating the probability maps from patches + """ + + LOCATION = "mask_location" + SIZE = "mask_size" + COUNT = "num_patches" + PATH = "path" + PRE_PATH = "image" + + class GridPatchSort(Enum): """ The sorting method for the generated patches in `GridPatch` diff --git a/requirements-dev.txt b/requirements-dev.txt index ac8b3730d8..7bc06b8039 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ itk>=5.2 nibabel pillow!=8.3.0 # https://github.com/python-pillow/Pillow/issues/5571 tensorboard -scikit-image>=0.14.2 +scikit-image>=0.19.0 tqdm>=4.47.0 lmdb flake8>=3.8.1 diff --git a/tests/min_tests.py b/tests/min_tests.py index b52dc2a73d..cc35cf687f 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -53,6 +53,8 @@ def run_testsuit(): "test_ensure_channel_firstd", "test_fill_holes", "test_fill_holesd", + "test_foreground_mask", + "test_foreground_maskd", "test_global_mutual_information_loss", "test_handler_checkpoint_loader", "test_handler_checkpoint_saver", diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py new file mode 100644 index 0000000000..c18e87fe53 --- /dev/null +++ b/tests/test_foreground_mask.py @@ -0,0 +1,96 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.intensity.array import ForegroundMask +from monai.utils import min_version, optional_import, set_determinism +from tests.utils import TEST_NDARRAYS, assert_allclose + +skimage, has_skimage = optional_import("skimage", "0.19.0", min_version) +set_determinism(1234) + +A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) +A3D = np.random.randint(64, 128, (3, 3, 2, 2)).astype(np.uint8) +B = np.ones_like(A[:1]) +B3D = np.ones_like(A3D[:1]) +MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +MASK3D = np.pad(B3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=0) +IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) +IMAGE3D = np.pad(A3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=255) +IMAGE2 = np.copy(IMAGE1) +IMAGE2[0] = 0 +IMAGE3 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=0) +TEST_CASE_0 = [{}, IMAGE1, MASK] +TEST_CASE_1 = [{"threshold": "otsu"}, IMAGE1, MASK] +TEST_CASE_2 = [{"threshold": "otsu"}, IMAGE2, MASK] +TEST_CASE_3 = [{"threshold": 140}, IMAGE1, MASK] +TEST_CASE_4 = [{"threshold": "otsu", "invert": True}, IMAGE3, MASK] +TEST_CASE_5 = [{"threshold": 0.5}, MASK, np.logical_not(MASK)] +TEST_CASE_6 = [{"threshold": 140}, IMAGE2, np.ones_like(MASK)] +TEST_CASE_7 = [{"threshold": {"R": "otsu", "G": "otsu", "B": "otsu"}}, IMAGE2, MASK] +TEST_CASE_8 = [{"threshold": {"R": 140, "G": "otsu", "B": "otsu"}}, IMAGE2, np.ones_like(MASK)] +TEST_CASE_9 = [{"threshold": {"R": 140, "G": skimage.filters.threshold_otsu, "B": "otsu"}}, IMAGE2, np.ones_like(MASK)] +TEST_CASE_10 = [{"threshold": skimage.filters.threshold_mean}, IMAGE1, MASK] +TEST_CASE_11 = [{"threshold": None, "hsv_threshold": "otsu"}, IMAGE1, np.ones_like(MASK)] +TEST_CASE_12 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] +TEST_CASE_13 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] +TEST_CASE_14 = [{}, IMAGE3D, MASK3D] +TEST_CASE_15 = [{"hsv_threshold": {"S": 0.1}}, IMAGE3D, MASK3D] + +TEST_CASE_ERROR_1 = [{"threshold": None}, IMAGE1] +TEST_CASE_ERROR_2 = [{"threshold": {"K": 1}}, IMAGE1] + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, *TEST_CASE_0]) + TESTS.append([p, *TEST_CASE_1]) + TESTS.append([p, *TEST_CASE_2]) + TESTS.append([p, *TEST_CASE_3]) + TESTS.append([p, *TEST_CASE_4]) + TESTS.append([p, *TEST_CASE_5]) + TESTS.append([p, *TEST_CASE_6]) + TESTS.append([p, *TEST_CASE_7]) + TESTS.append([p, *TEST_CASE_8]) + TESTS.append([p, *TEST_CASE_9]) + TESTS.append([p, *TEST_CASE_10]) + TESTS.append([p, *TEST_CASE_11]) + TESTS.append([p, *TEST_CASE_12]) + TESTS.append([p, *TEST_CASE_13]) + TESTS.append([p, *TEST_CASE_14]) + TESTS.append([p, *TEST_CASE_15]) + +TESTS_ERROR = [] +for p in TEST_NDARRAYS: + TESTS_ERROR.append([p, *TEST_CASE_ERROR_1]) + TESTS_ERROR.append([p, *TEST_CASE_ERROR_2]) + + +@unittest.skipUnless(has_skimage, "Requires sci-kit image") +class TestForegroundMask(unittest.TestCase): + @parameterized.expand(TESTS) + def test_foreground_mask(self, in_type, arguments, image, mask): + input_image = in_type(image) + result = ForegroundMask(**arguments)(input_image) + assert_allclose(result, mask, type_test=False) + + @parameterized.expand(TESTS_ERROR) + def test_foreground_mask_error(self, in_type, arguments, image): + input_image = in_type(image) + with self.assertRaises(ValueError): + ForegroundMask(**arguments)(input_image) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py new file mode 100644 index 0000000000..3c8aa08d7f --- /dev/null +++ b/tests/test_foreground_maskd.py @@ -0,0 +1,104 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.intensity.dictionary import ForegroundMaskd +from monai.utils import min_version, optional_import, set_determinism +from tests.utils import TEST_NDARRAYS, assert_allclose + +skimage, has_skimage = optional_import("skimage", "0.19.0", min_version) +set_determinism(1234) + +A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) +A3D = np.random.randint(64, 128, (3, 3, 2, 2)).astype(np.uint8) +B = np.ones_like(A[:1]) +B3D = np.ones_like(A3D[:1]) +MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +MASK3D = np.pad(B3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=0) +IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) +IMAGE3D = np.pad(A3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=255) +IMAGE2 = np.copy(IMAGE1) +IMAGE2[0] = 0 +IMAGE3 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=0) +TEST_CASE_0 = [{"keys": "image"}, {"image": IMAGE1}, MASK] +TEST_CASE_1 = [{"keys": "image", "threshold": "otsu"}, {"image": IMAGE1}, MASK] +TEST_CASE_2 = [{"keys": "image", "threshold": "otsu"}, {"image": IMAGE2}, MASK] +TEST_CASE_3 = [{"keys": "image", "threshold": 140}, {"image": IMAGE1}, MASK] +TEST_CASE_4 = [{"keys": "image", "threshold": "otsu", "invert": True}, {"image": IMAGE3}, MASK] +TEST_CASE_5 = [{"keys": "image", "threshold": 0.5}, {"image": MASK}, np.logical_not(MASK)] +TEST_CASE_6 = [{"keys": "image", "threshold": 140}, {"image": IMAGE2}, np.ones_like(MASK)] +TEST_CASE_7 = [{"keys": "image", "threshold": {"R": "otsu", "G": "otsu", "B": "otsu"}}, {"image": IMAGE2}, MASK] +TEST_CASE_8 = [ + {"keys": "image", "threshold": {"R": 140, "G": "otsu", "B": "otsu"}}, + {"image": IMAGE2}, + np.ones_like(MASK), +] +TEST_CASE_9 = [ + {"keys": "image", "threshold": {"R": 140, "G": skimage.filters.threshold_otsu, "B": "otsu"}}, + {"image": IMAGE2}, + np.ones_like(MASK), +] +TEST_CASE_10 = [{"keys": "image", "threshold": skimage.filters.threshold_mean}, {"image": IMAGE1}, MASK] +TEST_CASE_11 = [{"keys": "image", "threshold": None, "hsv_threshold": "otsu"}, {"image": IMAGE1}, np.ones_like(MASK)] +TEST_CASE_12 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] +TEST_CASE_13 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] +TEST_CASE_14 = [{"keys": "image"}, {"image": IMAGE3D}, MASK3D] +TEST_CASE_15 = [{"keys": "image", "hsv_threshold": {"S": 0.1}}, {"image": IMAGE3D}, MASK3D] + +TEST_CASE_ERROR_1 = [{"keys": "image", "threshold": None}, {"image": IMAGE1}] +TEST_CASE_ERROR_2 = [{"keys": "image", "threshold": {"K": 1}}, {"image": IMAGE1}] + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, *TEST_CASE_0]) + TESTS.append([p, *TEST_CASE_1]) + TESTS.append([p, *TEST_CASE_2]) + TESTS.append([p, *TEST_CASE_3]) + TESTS.append([p, *TEST_CASE_4]) + TESTS.append([p, *TEST_CASE_5]) + TESTS.append([p, *TEST_CASE_6]) + TESTS.append([p, *TEST_CASE_7]) + TESTS.append([p, *TEST_CASE_8]) + TESTS.append([p, *TEST_CASE_9]) + TESTS.append([p, *TEST_CASE_10]) + TESTS.append([p, *TEST_CASE_11]) + TESTS.append([p, *TEST_CASE_12]) + TESTS.append([p, *TEST_CASE_13]) + TESTS.append([p, *TEST_CASE_14]) + TESTS.append([p, *TEST_CASE_15]) + +TESTS_ERROR = [] +for p in TEST_NDARRAYS: + TESTS_ERROR.append([p, *TEST_CASE_ERROR_1]) + TESTS_ERROR.append([p, *TEST_CASE_ERROR_2]) + + +@unittest.skipUnless(has_skimage, "Requires sci-kit image") +class TestForegroundMaskd(unittest.TestCase): + @parameterized.expand(TESTS) + def test_foreground_mask(self, in_type, arguments, data_dict, mask): + data_dict[arguments["keys"]] = in_type(data_dict[arguments["keys"]]) + result = ForegroundMaskd(**arguments)(data_dict)[arguments["keys"]] + assert_allclose(result, mask, type_test=False) + + @parameterized.expand(TESTS_ERROR) + def test_foreground_mask_error(self, in_type, arguments, data_dict): + data_dict[arguments["keys"]] = in_type(data_dict[arguments["keys"]]) + with self.assertRaises(ValueError): + ForegroundMaskd(**arguments)(data_dict)[arguments["keys"]] + + +if __name__ == "__main__": + unittest.main() From 554de62dfe884513af392d858aba5a41e2ef9c13 Mon Sep 17 00:00:00 2001 From: Vishwesh Date: Tue, 31 May 2022 12:36:01 -0500 Subject: [PATCH 116/183] PR: NuClick Transforms Addition (#4266) * Try 1 Signed-off-by: vnath * dataset prep addition Signed-off-by: vnath * Refactoring of transforms as there were loose hanging functions Signed-off-by: vnath * Minor changes to transforms Signed-off-by: vnath * Added test cases for all transforms Signed-off-by: vnath * Removed dataset prep, it will be a part of tutorial, added opencv to dev requirements Signed-off-by: vnath * Added Init for NuClick Signed-off-by: vnath * Code formatting changes Signed-off-by: vnath * Linting & Formatting Signed-off-by: vnath * Fixed Flake 8 & opencv addition to requirement & env files Signed-off-by: vnath * Fixed Flake 8 & opencv addition to requirement & env files Signed-off-by: vnath * Fixed Flake 8 & opencv addition to requirement & env files Signed-off-by: vnath * Fixed Flake 8 & opencv addition to requirement & env files Signed-off-by: vnath * Minor in line docs need to be updated Signed-off-by: vnath * Minor in line docs need to be updated Signed-off-by: vnath * Adding Two Transforms & Test Cases Signed-off-by: vnath * Formatted the PR Signed-off-by: vnath * opencv changes Signed-off-by: vnath * opencv changes Signed-off-by: vnath * More optional import based changes Signed-off-by: vnath * Removed opencv-python-headles as that does not workout Signed-off-by: vnath * adding sk image back to requirement.txt till we figure out an alternative Signed-off-by: vnath * Replaced MapTransform instead of Transform for SplitLabeld Signed-off-by: vnath * Added skimage and cv2 requirements.txt as tests fail otherwise Signed-off-by: vnath * Added MapTransform Inheritance to AddClickSignalsd Signed-off-by: vnath * Spatial Pad Incorporated to ExtractPatchd Signed-off-by: vnath * Remvoed comments and added doc-strings Signed-off-by: vnath * codeformat Signed-off-by: vnath * Adding NuClick Test to Min tests and removing opencv & scikit from requirements.txt Signed-off-by: vnath * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Removed cv2 dependency after modification of FlattenLabeld Transform to use skimage functionality instead Signed-off-by: vnath * Minor addition to doc string Signed-off-by: vnath * update based on comments Signed-off-by: Wenqi Li * adds unit test case Signed-off-by: Wenqi Li * remove unused import Signed-off-by: Wenqi Li * fixes unit test Signed-off-by: Wenqi Li * Adding changes as docs and init arguments, 2 additional test cases Signed-off-by: vnath * Minor changes Signed-off-by: vnath * kwargs added to ExtractPatchd Signed-off-by: vnath * Added a unit test for kwargs of SpatialPad in ExtractPatchd and a doc-string Signed-off-by: vnath Co-authored-by: vnath Co-authored-by: Nic Ma Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li Co-authored-by: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- .pre-commit-config.yaml | 4 + monai/apps/nuclick/__init__.py | 10 + monai/apps/nuclick/transforms.py | 535 +++++++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_nuclick_transforms.py | 218 +++++++++++++ 5 files changed, 768 insertions(+) create mode 100644 monai/apps/nuclick/__init__.py create mode 100644 monai/apps/nuclick/transforms.py create mode 100644 tests/test_nuclick_transforms.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 153617b3dd..cb802867b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,6 +56,10 @@ repos: monai/__init__.py| docs/source/conf.py )$ + - repo: https://github.com/hadialqattan/pycln + rev: v1.3.3 + hooks: + - id: pycln - repo: https://github.com/hadialqattan/pycln rev: v1.3.3 diff --git a/monai/apps/nuclick/__init__.py b/monai/apps/nuclick/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/nuclick/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/apps/nuclick/transforms.py b/monai/apps/nuclick/transforms.py new file mode 100644 index 0000000000..d6be1a84fa --- /dev/null +++ b/monai/apps/nuclick/transforms.py @@ -0,0 +1,535 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +import random +from enum import Enum +from typing import Any, Tuple, Union + +import numpy as np + +from monai.config import KeysCollection +from monai.transforms import MapTransform, Randomizable, SpatialPad +from monai.utils import optional_import + +measure, _ = optional_import("skimage.measure") +morphology, _ = optional_import("skimage.morphology") + + +class NuclickKeys(Enum): + """ + Keys for nuclick transforms. + """ + + IMAGE = "image" + LABEL = "label" + OTHERS = "others" # key of other labels from the binary mask which are not being used for training + FOREGROUND = "foreground" + + CENTROID = "centroid" # key where the centroid values are stored + MASK_VALUE = "mask_value" + LOCATION = "location" + + NUC_POINTS = "nuc_points" + BOUNDING_BOXES = "bounding_boxes" + IMG_HEIGHT = "img_height" + IMG_WIDTH = "img_width" + + +class FlattenLabeld(MapTransform): + """ + FlattenLabeld creates labels per closed object contour (defined by a connectivity). For e.g if there are + 12 small regions of 1's it will delineate them into 12 different label classes + + Args: + connectivity: Max no. of orthogonal hops to consider a pixel/voxel as a neighbor. Refer skimage.measure.label + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__(self, keys: KeysCollection, connectivity: int = 1, allow_missing_keys: bool = False): + super().__init__(keys, allow_missing_keys) + self.connectivity = connectivity + + def __call__(self, data): + d = dict(data) + for key in self.keys: + d[key] = measure.label(d[key], connectivity=self.connectivity).astype(np.uint8) + return d + + +class ExtractPatchd(MapTransform): + """ + Extracts a patch from the given image and label, however it is based on the centroid location. + The centroid location is a 2D coordinate (H, W). The extracted patch is extracted around the centroid, + if the centroid is towards the edge, the centroid will not be the center of the image as the patch will be + extracted from the edges onwards + + Args: + keys: image, label + centroid_key: key where the centroid values are stored, defaults to ``"centroid"`` + patch_size: size of the extracted patch + allow_missing_keys: don't raise exception if key is missing. + pad_kwargs: other arguments for the SpatialPad transform + """ + + def __init__( + self, + keys: KeysCollection, + centroid_key: str = NuclickKeys.CENTROID.value, + patch_size: Union[Tuple[int, int], int] = 128, + allow_missing_keys: bool = False, + **kwargs: Any, + ): + super().__init__(keys, allow_missing_keys) + self.centroid_key = centroid_key + self.patch_size = patch_size + self.kwargs = kwargs + + def __call__(self, data): + d = dict(data) + + centroid = d[self.centroid_key] # create mask based on centroid (select nuclei based on centroid) + roi_size = (self.patch_size, self.patch_size) + + for key in self.keys: + img = d[key] + x_start, x_end, y_start, y_end = self.bbox(self.patch_size, centroid, img.shape[-2:]) + cropped = img[:, x_start:x_end, y_start:y_end] + d[key] = SpatialPad(spatial_size=roi_size, **self.kwargs)(cropped) + return d + + def bbox(self, patch_size, centroid, size): + x, y = centroid + m, n = size + + x_start = int(max(x - patch_size / 2, 0)) + y_start = int(max(y - patch_size / 2, 0)) + x_end = x_start + patch_size + y_end = y_start + patch_size + if x_end > m: + x_end = m + x_start = m - patch_size + if y_end > n: + y_end = n + y_start = n - patch_size + return x_start, x_end, y_start, y_end + + +class SplitLabeld(MapTransform): + """ + Extracts a single label from all the given classes, the single label is defined by mask_value, the remaining + labels are kept in others + + Args: + label: key of the label source + others: other labels storage key, defaults to ``"others"`` + mask_value: the mask_value that will be kept for binarization of the label, defaults to ``"mask_value"`` + min_area: The smallest allowable object size. + """ + + def __init__( + self, + keys: KeysCollection, + # label: str = NuclickKeys.LABEL.value, + others: str = NuclickKeys.OTHERS.value, + mask_value: str = NuclickKeys.MASK_VALUE.value, + min_area: int = 5, + ): + + # self.label = label + super().__init__(keys, allow_missing_keys=False) + self.others = others + self.mask_value = mask_value + self.min_area = min_area + + def __call__(self, data): + d = dict(data) + + if len(self.keys) > 1: + print("Only 'label' key is supported, more than 1 key was found") + return None + + for key in self.keys: + self.label = key + + label = d[self.label] + mask_value = d[self.mask_value] + mask = np.uint8(label == mask_value) + others = (1 - mask) * label + others = self._mask_relabeling(others[0], min_area=self.min_area)[np.newaxis] + d[self.label] = mask + d[self.others] = others + return d + + def _mask_relabeling(self, mask, min_area=5): + res = np.zeros_like(mask) + for l in np.unique(mask): + if l == 0: + continue + + m = measure.label(mask == l, connectivity=1) + for stat in measure.regionprops(m): + if stat.area > min_area: + res[stat.coords[:, 0], stat.coords[:, 1]] = l + return res + + +class FilterImaged(MapTransform): + """ + Filters Green and Gray channel of the image using an allowable object size, this pre-processing transform + is specific towards NuClick training process. More details can be referred in this paper Koohbanani, + Navid Alemi, et al. "NuClick: a deep learning framework for interactive segmentation of microscopic images." + Medical Image Analysis 65 (2020): 101771. + + Args: + min_size: The smallest allowable object size + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__(self, keys: KeysCollection, min_size: int = 500, allow_missing_keys: bool = False): + super().__init__(keys, allow_missing_keys) + self.min_size = min_size + + def __call__(self, data): + d = dict(data) + for key in self.keys: + img = d[key] + d[key] = self.filter(img) + return d + + def filter(self, rgb): + mask_not_green = self.filter_green_channel(rgb) + mask_not_gray = self.filter_grays(rgb) + mask_gray_green = mask_not_gray & mask_not_green + mask = ( + self.filter_remove_small_objects(mask_gray_green, min_size=self.min_size) + if self.min_size + else mask_gray_green + ) + + return rgb * np.dstack([mask, mask, mask]) + + def filter_green_channel( + self, img_np, green_thresh=200, avoid_overmask=True, overmask_thresh=90, output_type="bool" + ): + g = img_np[:, :, 1] + gr_ch_mask = (g < green_thresh) & (g > 0) + mask_percentage = self.mask_percent(gr_ch_mask) + if (mask_percentage >= overmask_thresh) and (green_thresh < 255) and (avoid_overmask is True): + new_green_thresh = math.ceil((255 - green_thresh) / 2 + green_thresh) + gr_ch_mask = self.filter_green_channel( + img_np, new_green_thresh, avoid_overmask, overmask_thresh, output_type + ) + return gr_ch_mask + + def filter_grays(self, rgb, tolerance=15): + rg_diff = abs(rgb[:, :, 0] - rgb[:, :, 1]) <= tolerance + rb_diff = abs(rgb[:, :, 0] - rgb[:, :, 2]) <= tolerance + gb_diff = abs(rgb[:, :, 1] - rgb[:, :, 2]) <= tolerance + return ~(rg_diff & rb_diff & gb_diff) + + def mask_percent(self, img_np): + if (len(img_np.shape) == 3) and (img_np.shape[2] == 3): + np_sum = img_np[:, :, 0] + img_np[:, :, 1] + img_np[:, :, 2] + mask_percentage = 100 - np.count_nonzero(np_sum) / np_sum.size * 100 + else: + mask_percentage = 100 - np.count_nonzero(img_np) / img_np.size * 100 + return mask_percentage + + def filter_remove_small_objects(self, img_np, min_size=3000, avoid_overmask=True, overmask_thresh=95): + rem_sm = morphology.remove_small_objects(img_np.astype(bool), min_size=min_size) + mask_percentage = self.mask_percent(rem_sm) + if (mask_percentage >= overmask_thresh) and (min_size >= 1) and (avoid_overmask is True): + new_min_size = round(min_size / 2) + rem_sm = self.filter_remove_small_objects(img_np, new_min_size, avoid_overmask, overmask_thresh) + return rem_sm + + +class AddPointGuidanceSignald(Randomizable, MapTransform): + """ + Adds Guidance Signal to the input image + + Args: + image: key of source image, defaults to ``"image"`` + label: key of source label, defaults to ``"label"`` + others: source others (other labels from the binary mask which are not being used for training) + defaults to ``"others"`` + drop_rate: probability of dropping the signal, defaults to ``0.5`` + jitter_range: noise added to the points in the point mask for exclusion mask, defaults to ``3`` + """ + + def __init__( + self, + image: str = NuclickKeys.IMAGE.value, + label: str = NuclickKeys.LABEL.value, + others: str = NuclickKeys.OTHERS.value, + drop_rate: float = 0.5, + jitter_range: int = 3, + ): + MapTransform.__init__(self, image) + + self.image = image + self.label = label + self.others = others + self.drop_rate = drop_rate + self.jitter_range = jitter_range + + def __call__(self, data): + d = dict(data) + + image = d[self.image] + mask = d[self.label] + others = d[self.others] + + inc_sig = self.inclusion_map(mask[0]) + exc_sig = self.exclusion_map(others[0], drop_rate=self.drop_rate, jitter_range=self.jitter_range) + + image = np.concatenate((image, inc_sig[np.newaxis, ...], exc_sig[np.newaxis, ...]), axis=0) + d[self.image] = image + return d + + def inclusion_map(self, mask): + point_mask = np.zeros_like(mask) + indices = np.argwhere(mask > 0) + if len(indices) > 0: + idx = np.random.randint(0, len(indices)) + point_mask[indices[idx, 0], indices[idx, 1]] = 1 + + return point_mask + + def exclusion_map(self, others, jitter_range=3, drop_rate=0.5): + point_mask = np.zeros_like(others) + if drop_rate == 1.0: + return point_mask + + max_x = point_mask.shape[0] - 1 + max_y = point_mask.shape[1] - 1 + stats = measure.regionprops(others) + for stat in stats: + x, y = stat.centroid + if np.random.choice([True, False], p=[drop_rate, 1 - drop_rate]): + continue + + # random jitter + x = int(math.floor(x)) + random.randint(a=-jitter_range, b=jitter_range) + y = int(math.floor(y)) + random.randint(a=-jitter_range, b=jitter_range) + x = min(max(0, x), max_x) + y = min(max(0, y), max_y) + point_mask[x, y] = 1 + + return point_mask + + +class AddClickSignalsd(MapTransform): + """ + Adds Click Signal to the input image + + Args: + image: source image, defaults to ``"image"`` + foreground: 2D click indices as list, defaults to ``"foreground"`` + bb_size: single integer size, defines a bounding box like (bb_size, bb_size) + """ + + def __init__( + self, image: str = NuclickKeys.IMAGE.value, foreground: str = NuclickKeys.FOREGROUND.value, bb_size: int = 128 + ): + self.image = image + self.foreground = foreground + self.bb_size = bb_size + + def __call__(self, data): + d = dict(data) + + location = d.get(NuclickKeys.LOCATION.value, (0, 0)) + tx, ty = location[0], location[1] + pos = d.get(self.foreground) + pos = (np.array(pos) - (tx, ty)).astype(int).tolist() if pos else [] + + cx = [xy[0] for xy in pos] + cy = [xy[1] for xy in pos] + + img = d[self.image].astype(np.uint8) + img_width = img.shape[-1] + img_height = img.shape[-2] + + click_map, bounding_boxes = self.get_clickmap_boundingbox( + cx=cx, cy=cy, m=img_height, n=img_width, bb=self.bb_size + ) + + patches, nuc_points, other_points = self.get_patches_and_signals( + img=img, + click_map=click_map, + bounding_boxes=bounding_boxes, + cx=cx, + cy=cy, + m=img_height, + n=img_width, + bb=self.bb_size, + ) + patches = patches / 255 + + d[NuclickKeys.BOUNDING_BOXES.value] = bounding_boxes + d[NuclickKeys.IMG_WIDTH.value] = img_width + d[NuclickKeys.IMG_HEIGHT.value] = img_height + d[NuclickKeys.NUC_POINTS.value] = nuc_points + + d[self.image] = np.concatenate((patches, nuc_points, other_points), axis=1).astype(dtype=np.float32) + return d + + def get_clickmap_boundingbox(self, cx, cy, m, n, bb=128): + click_map = np.zeros((m, n), dtype=np.uint8) + + x_del_indices = {i for i in range(len(cx)) if cx[i] >= n or cx[i] < 0} + y_del_indices = {i for i in range(len(cy)) if cy[i] >= m or cy[i] < 0} + del_indices = list(x_del_indices.union(y_del_indices)) + cx = np.delete(cx, del_indices) + cy = np.delete(cy, del_indices) + + click_map[cy, cx] = 1 + bounding_boxes = [] + for i in range(len(cx)): + x_start = cx[i] - bb // 2 + y_start = cy[i] - bb // 2 + if x_start < 0: + x_start = 0 + if y_start < 0: + y_start = 0 + x_end = x_start + bb - 1 + y_end = y_start + bb - 1 + if x_end > n - 1: + x_end = n - 1 + x_start = x_end - bb + 1 + if y_end > m - 1: + y_end = m - 1 + y_start = y_end - bb + 1 + bounding_boxes.append([x_start, y_start, x_end, y_end]) + return click_map, bounding_boxes + + def get_patches_and_signals(self, img, click_map, bounding_boxes, cx, cy, m, n, bb=128): + + total = len(bounding_boxes) + img = np.array([img]) + click_map = np.array([click_map]) + click_map = click_map[:, np.newaxis, ...] + + patches = np.ndarray((total, 3, bb, bb), dtype=np.uint8) + nuc_points = np.ndarray((total, 1, bb, bb), dtype=np.uint8) + other_points = np.ndarray((total, 1, bb, bb), dtype=np.uint8) + + x_del_indices = {i for i in range(len(cx)) if cx[i] >= n or cx[i] < 0} + y_del_indices = {i for i in range(len(cy)) if cy[i] >= m or cy[i] < 0} + del_indices = list(x_del_indices.union(y_del_indices)) + cx = np.delete(cx, del_indices) + cy = np.delete(cy, del_indices) + + for i in range(len(bounding_boxes)): + bounding_box = bounding_boxes[i] + x_start = bounding_box[0] + y_start = bounding_box[1] + x_end = bounding_box[2] + y_end = bounding_box[3] + + patches[i] = img[0, :, y_start : y_end + 1, x_start : x_end + 1] + + this_click_map = np.zeros((1, 1, m, n), dtype=np.uint8) + this_click_map[0, 0, cy[i], cx[i]] = 1 + + others_click_map = np.uint8((click_map - this_click_map) > 0) + + nuc_points[i] = this_click_map[0, :, y_start : y_end + 1, x_start : x_end + 1] + other_points[i] = others_click_map[0, :, y_start : y_end + 1, x_start : x_end + 1] + + return patches, nuc_points, other_points + + +class PostFilterLabeld(MapTransform): + """ + Performs Filtering of Labels on the predicted probability map + + Args: + thresh: probability threshold for classifying a pixel as a mask + min_size: min_size objects that will be removed from the image, refer skimage remove_small_objects + min_hole: min_hole that will be removed from the image, refer skimage remove_small_holes + do_reconstruction: Boolean Flag, Perform a morphological reconstruction of an image, refer skimage + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__( + self, + keys: KeysCollection, + nuc_points: str = NuclickKeys.NUC_POINTS.value, + bounding_boxes: str = NuclickKeys.BOUNDING_BOXES.value, + img_height: str = NuclickKeys.IMG_HEIGHT.value, + img_width: str = NuclickKeys.IMG_WIDTH.value, + thresh: float = 0.33, + min_size: int = 10, + min_hole: int = 30, + do_reconstruction: bool = False, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.nuc_points = nuc_points + self.bounding_boxes = bounding_boxes + self.img_height = img_height + self.img_width = img_width + + self.thresh = thresh + self.min_size = min_size + self.min_hole = min_hole + self.do_reconstruction = do_reconstruction + + def __call__(self, data): + d = dict(data) + + nuc_points = d[self.nuc_points] + bounding_boxes = d[self.bounding_boxes] + img_height = d[self.img_height] + img_width = d[self.img_width] + + for key in self.keys: + label = d[key].astype(np.uint8) + masks = self.post_processing( + label, + thresh=self.thresh, + min_size=self.min_size, + min_hole=self.min_hole, + do_reconstruction=self.do_reconstruction, + nuc_points=nuc_points, + ) + + d[key] = self.gen_instance_map(masks, bounding_boxes, img_height, img_width).astype(np.uint8) + return d + + def post_processing(self, preds, thresh=0.33, min_size=10, min_hole=30, do_reconstruction=False, nuc_points=None): + masks = preds > thresh + masks = morphology.remove_small_objects(masks, min_size=min_size) + masks = morphology.remove_small_holes(masks, area_threshold=min_hole) + if do_reconstruction: + for i in range(len(masks)): + this_mask = masks[i] + this_marker = nuc_points[i, 0, :, :] > 0 + + try: + this_mask = morphology.reconstruction(this_marker, this_mask, footprint=morphology.disk(1)) + masks[i] = np.array([this_mask]) + except BaseException: + print("Nuclei reconstruction error #" + str(i)) + return masks + + def gen_instance_map(self, masks, bounding_boxes, m, n, flatten=True): + instance_map = np.zeros((m, n), dtype=np.uint16) + for i in range(len(masks)): + this_bb = bounding_boxes[i] + this_mask_pos = np.argwhere(masks[i] > 0) + this_mask_pos[:, 0] = this_mask_pos[:, 0] + this_bb[1] + this_mask_pos[:, 1] = this_mask_pos[:, 1] + this_bb[0] + instance_map[this_mask_pos[:, 0], this_mask_pos[:, 1]] = 1 if flatten else i + 1 + return instance_map diff --git a/tests/min_tests.py b/tests/min_tests.py index cc35cf687f..898d1b7b00 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -115,6 +115,7 @@ def run_testsuit(): "test_nifti_header_revise", "test_nifti_rw", "test_nifti_saver", + "test_nuclick_transforms", "test_nrrd_reader", "test_occlusion_sensitivity", "test_orientation", diff --git a/tests/test_nuclick_transforms.py b/tests/test_nuclick_transforms.py new file mode 100644 index 0000000000..58876c3c36 --- /dev/null +++ b/tests/test_nuclick_transforms.py @@ -0,0 +1,218 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.apps.nuclick.transforms import ( + AddClickSignalsd, + AddPointGuidanceSignald, + ExtractPatchd, + FilterImaged, + FlattenLabeld, + PostFilterLabeld, + SplitLabeld, +) + +# Data Definitions +RGB_IMAGE_1 = np.array( + [[[0, 0, 0], [0, 1, 0], [0, 0, 1]], [[2, 0, 2], [0, 1, 0], [1, 0, 1]], [[3, 0, 2], [0, 1, 0], [1, 3, 1]]] +) + +LABEL_1 = np.array( + [ + [1, 1, 1, 0, 0, 0, 0], + [1, 1, 1, 0, 0, 0, 0], + [1, 1, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1], + ], + dtype=np.uint8, +) + +LABEL_1_1 = np.array( + [ + [1, 1, 1, 0, 0, 1, 1], + [1, 1, 1, 0, 0, 1, 1], + [1, 1, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 1, 1, 0, 2, 2, 2], + [1, 1, 1, 0, 2, 2, 2], + [1, 1, 1, 0, 2, 2, 2], + ], + dtype=np.uint8, +) + +LABEL_2 = np.array([[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], dtype=np.uint8) + +LABEL_3 = np.array([[[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3], [4, 4, 4, 4]]], dtype=np.uint8) + +LABEL_4 = np.array([[[4, 4, 4, 4], [4, 4, 4, 4], [4, 4, 4, 4], [4, 4, 4, 4]]], dtype=np.uint8) + +IL_IMAGE_1 = np.array( + [ + [[0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1]], + [[0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1]], + [[0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1]], + ] +) + +IL_FG_IMAGE_1 = np.array([[0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1], [0, 0, 1, 1, 1]]) + +IL_LABEL_1 = np.array( + [[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]], dtype=np.uint8 +) + +IL_OTHERS_1 = np.array( + [[[1, 1, 1, 1, 1], [2, 0, 0, 0, 2], [3, 0, 0, 0, 3], [4, 0, 0, 0, 4], [5, 5, 5, 5, 5]]], dtype=np.uint8 +) + +IL_IMAGE_2 = np.array( + [[[0, 0, 0], [0, 1, 0], [0, 0, 1]], [[0, 0, 0], [0, 1, 0], [0, 0, 1]], [[0, 0, 0], [0, 1, 0], [0, 0, 1]]] +) + +IL_LABEL_2 = np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]]], dtype=np.uint8) + +PRED_1 = np.array( + [[[1, 1, 1, 1, 1], [2, 0, 0, 0, 2], [3, 0, 0, 0, 3], [4, 0, 0, 0, 4], [5, 5, 5, 5, 5]]], dtype=np.float32 +) + +NUC_POINTS_1 = np.array( + [ + [[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]]], + [[[0, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]], + ], + dtype=np.float32, +) +BB_1 = np.array([[1, 1, 3, 3], [0, 0, 2, 2]], dtype=np.uint8) + +DATA_FILTER_1 = {"image": RGB_IMAGE_1} + +DATA_FLATTEN_1 = {"label": LABEL_1} +DATA_FLATTEN_2 = {"label": LABEL_2} + +DATA_EXTRACT_1 = {"image": IL_IMAGE_1, "label": IL_LABEL_1, "centroid": (2, 2)} +DATA_EXTRACT_2 = {"image": IL_IMAGE_2, "label": IL_LABEL_2, "centroid": (1, 1)} + +DATA_SPLIT_1 = {"label": LABEL_3, "mask_value": 1} +DATA_SPLIT_2 = {"label": LABEL_4, "mask_value": 4} + +DATA_GUIDANCE_1 = {"image": IL_IMAGE_1, "label": IL_LABEL_1, "others": IL_OTHERS_1, "centroid": (2, 2)} + +DATA_CLICK_1 = {"image": IL_IMAGE_1, "foreground": [[2, 2], [1, 1]]} + +DATA_LABEL_FILTER_1 = { + "pred": PRED_1, + "nuc_points": NUC_POINTS_1, + "bounding_boxes": BB_1, + "img_height": 6, + "img_width": 6, +} + +# Result Definitions +EXTRACT_RESULT_TC1 = np.array([[[0, 0, 0], [0, 0, 0], [0, 0, 1]]], dtype=np.uint8) +EXTRACT_RESULT_TC2 = np.array([[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]], dtype=np.uint8) + +SPLIT_RESULT_TC1 = np.array([[[1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]], dtype=np.uint8) +SPLIT_RESULT_TC2 = np.array([[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]], dtype=np.uint8) + +# Test Case Definitions +FILTER_IMAGE_TEST_CASE_1 = [{"keys": "image", "min_size": 1}, DATA_FILTER_1, [3, 3, 3]] + +FLATTEN_LABEL_TEST_CASE_1 = [{"keys": "label"}, DATA_FLATTEN_1, [0, 1, 2, 3]] +FLATTEN_LABEL_TEST_CASE_2 = [{"keys": "label"}, DATA_FLATTEN_2, [0]] +FLATTEN_LABEL_TEST_CASE_3 = [{"keys": "label"}, {"label": LABEL_1_1}, [0, 1, 2, 3, 4]] + +EXTRACT_TEST_CASE_1 = [{"keys": ["image", "label"], "patch_size": 3}, DATA_EXTRACT_1, [1, 3, 3]] +EXTRACT_TEST_CASE_2 = [{"keys": ["image", "label"], "patch_size": 5}, DATA_EXTRACT_1, [1, 5, 5]] +EXTRACT_TEST_CASE_3 = [{"keys": ["image", "label"], "patch_size": 1}, DATA_EXTRACT_2, [1, 1, 1]] + +EXTRACT_RESULT_TEST_CASE_1 = [{"keys": ["image", "label"], "patch_size": 3}, DATA_EXTRACT_1, EXTRACT_RESULT_TC1] +EXTRACT_RESULT_TEST_CASE_2 = [{"keys": ["image", "label"], "patch_size": 4}, DATA_EXTRACT_2, EXTRACT_RESULT_TC2] + +EXTRACT_KW_TEST_CASE_1 = [ + {"keys": ["image", "label"], "patch_size": 3, "mode": "constant"}, + DATA_EXTRACT_1, + EXTRACT_RESULT_TC1, +] + +SPLIT_TEST_CASE_1 = [{"keys": ["label"], "mask_value": "mask_value", "min_area": 1}, DATA_SPLIT_1, SPLIT_RESULT_TC1] +SPLIT_TEST_CASE_2 = [{"keys": ["label"], "mask_value": "mask_value", "min_area": 3}, DATA_SPLIT_2, SPLIT_RESULT_TC2] + +GUIDANCE_TEST_CASE_1 = [{"image": "image", "label": "label", "others": "others"}, DATA_GUIDANCE_1, [5, 5, 5]] + +CLICK_TEST_CASE_1 = [{"image": "image", "foreground": "foreground", "bb_size": 4}, DATA_CLICK_1, [2, 5, 4, 4]] + +LABEL_FILTER_TEST_CASE_1 = [{"keys": ["pred"]}, DATA_LABEL_FILTER_1, [6, 6]] + +# Test Case Classes + + +class TestFilterImaged(unittest.TestCase): + @parameterized.expand([FILTER_IMAGE_TEST_CASE_1]) + def test_correct_shape(self, arguments, input_data, expected_shape): + result = FilterImaged(**arguments)(input_data) + np.testing.assert_equal(result["image"].shape, expected_shape) + + +class TestFlattenLabeld(unittest.TestCase): + @parameterized.expand([FLATTEN_LABEL_TEST_CASE_1, FLATTEN_LABEL_TEST_CASE_2, FLATTEN_LABEL_TEST_CASE_3]) + def test_correct_num_labels(self, arguments, input_data, expected_result): + result = FlattenLabeld(**arguments)(input_data) + np.testing.assert_equal(np.unique(result["label"]), expected_result) + + +class TestExtractPatchd(unittest.TestCase): + @parameterized.expand([EXTRACT_TEST_CASE_1, EXTRACT_TEST_CASE_2, EXTRACT_TEST_CASE_3]) + def test_correct_patch_size(self, arguments, input_data, expected_shape): + result = ExtractPatchd(**arguments)(input_data) + np.testing.assert_equal(result["label"].shape, expected_shape) + + @parameterized.expand([EXTRACT_RESULT_TEST_CASE_1, EXTRACT_RESULT_TEST_CASE_2, EXTRACT_KW_TEST_CASE_1]) + def test_correct_results(self, arguments, input_data, expected_result): + result = ExtractPatchd(**arguments)(input_data) + np.testing.assert_equal(result["label"], expected_result) + + +class TestSplitLabelsd(unittest.TestCase): + @parameterized.expand([SPLIT_TEST_CASE_1, SPLIT_TEST_CASE_2]) + def test_correct_results(self, arguments, input_data, expected_result): + result = SplitLabeld(**arguments)(input_data) + np.testing.assert_equal(result["label"], expected_result) + + +class TestGuidanceSignal(unittest.TestCase): + @parameterized.expand([GUIDANCE_TEST_CASE_1]) + def test_correct_shape(self, arguments, input_data, expected_shape): + result = AddPointGuidanceSignald(**arguments)(input_data) + np.testing.assert_equal(result["image"].shape, expected_shape) + + +class TestClickSignal(unittest.TestCase): + @parameterized.expand([CLICK_TEST_CASE_1]) + def test_correct_shape(self, arguments, input_data, expected_shape): + result = AddClickSignalsd(**arguments)(input_data) + np.testing.assert_equal(result["image"].shape, expected_shape) + + +class TestPostFilterLabel(unittest.TestCase): + @parameterized.expand([LABEL_FILTER_TEST_CASE_1]) + def test_correct_shape(self, arguments, input_data, expected_shape): + result = PostFilterLabeld(**arguments)(input_data) + np.testing.assert_equal(result["pred"].shape, expected_shape) + + +if __name__ == "__main__": + unittest.main() From f540e34f742e31bfd676a3462ab44f5af265c982 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 31 May 2022 19:40:51 +0100 Subject: [PATCH 117/183] 4401 improve parser error message (#4402) --- .pre-commit-config.yaml | 4 ---- monai/bundle/config_item.py | 5 ++++- monai/utils/module.py | 13 ++++++++----- tests/test_config_parser.py | 6 ++++++ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb802867b5..153617b3dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,10 +56,6 @@ repos: monai/__init__.py| docs/source/conf.py )$ - - repo: https://github.com/hadialqattan/pycln - rev: v1.3.3 - hooks: - - id: pycln - repo: https://github.com/hadialqattan/pycln rev: v1.3.3 diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py index f507025e1b..b410e5f641 100644 --- a/monai/bundle/config_item.py +++ b/monai/bundle/config_item.py @@ -281,7 +281,10 @@ def instantiate(self, **kwargs) -> object: # type: ignore modname = self.resolve_module_name() args = self.resolve_args() args.update(kwargs) - return instantiate(modname, **args) + try: + return instantiate(modname, **args) + except Exception as e: + raise RuntimeError(f"Failed to instantiate {self}.") from e class ConfigExpression(ConfigItem): diff --git a/monai/utils/module.py b/monai/utils/module.py index 065cc8f7c8..747c985af7 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -213,11 +213,14 @@ def instantiate(path: str, **kwargs): component = locate(path) if component is None: raise ModuleNotFoundError(f"Cannot locate class or function path: '{path}'.") - if isclass(component): - return component(**kwargs) - # support regular function, static method and class method - if isfunction(component) or (ismethod(component) and isclass(getattr(component, "__self__", None))): - return partial(component, **kwargs) + try: + if isclass(component): + return component(**kwargs) + # support regular function, static method and class method + if isfunction(component) or (ismethod(component) and isclass(getattr(component, "__self__", None))): + return partial(component, **kwargs) + except Exception as e: + raise RuntimeError(f"Failed to instantiate '{path}' with kwargs: {kwargs}") from e warnings.warn(f"Component to instantiate must represent a valid class or function, but got {path}.") return component diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index dc541d8a77..14c1579686 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -221,6 +221,12 @@ def test_lambda_reference(self): result = trans(np.ones(64)) self.assertTupleEqual(result.shape, (1, 8, 8)) + def test_error_instance(self): + config = {"transform": {"_target_": "Compose", "transforms_wrong_key": []}} + parser = ConfigParser(config=config) + with self.assertRaises(RuntimeError): + parser.get_parsed_content("transform", instantiate=True, eval_expr=True) + if __name__ == "__main__": unittest.main() From a737d5855a2afdcf6c3bbf7d9a39cd2ef61337c1 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 1 Jun 2022 03:35:51 +0800 Subject: [PATCH 118/183] Minor enhancement for several places (#4409) --- docs/source/bundle.rst | 1 + docs/source/mb_specification.rst | 7 +++-- monai/bundle/__init__.py | 2 +- monai/data/utils.py | 11 ++++--- monai/transforms/croppad/array.py | 43 ++++++++++++++------------ monai/transforms/croppad/batch.py | 14 ++++----- monai/transforms/croppad/dictionary.py | 33 ++++++++++---------- tests/testing_data/metadata.json | 2 +- 8 files changed, 61 insertions(+), 52 deletions(-) diff --git a/docs/source/bundle.rst b/docs/source/bundle.rst index a28db04091..fc64d40f5a 100644 --- a/docs/source/bundle.rst +++ b/docs/source/bundle.rst @@ -41,3 +41,4 @@ Model Bundle .. autofunction:: run .. autofunction:: verify_metadata .. autofunction:: verify_net_in_out +.. autofunction:: init_bundle diff --git a/docs/source/mb_specification.rst b/docs/source/mb_specification.rst index a88096f274..142f250550 100644 --- a/docs/source/mb_specification.rst +++ b/docs/source/mb_specification.rst @@ -104,21 +104,22 @@ The format for tensors used as inputs and outputs can be used to specify semanti * **latent**: ND tensor of data from the latent space from some layer of a network * **gradient**: ND tensor of gradients from some layer of a network -Spatial shape definition can be complex for models accepting inputs of varying shapes, especially if there are specific conditions on what those shapes can be. Shapes are specified as lists of either positive integers for fixed sizes or strings containing expressions defining the condition a size depends on. This can be "*" to mean any size, or use an expression with Python mathematical operators and one character variables to represent dependence on an unknown quantity. For example, "2**n" represents a size which must be a power of 2, "2**n*m" must be a multiple of a power of 2. Variables are shared between dimension expressions, so a spatial shape of `["2**n", "2**n"]` states that the dimensions must be the same powers of 2 given by `n`. +Spatial shape definition can be complex for models accepting inputs of varying shapes, especially if there are specific conditions on what those shapes can be. Shapes are specified as lists of either positive integers for fixed sizes or strings containing expressions defining the condition a size depends on. This can be "*" to mean any size, or use an expression with Python mathematical operators and one character variables to represent dependence on an unknown quantity. For example, "2**p" represents a size which must be a power of 2, "2**p*n" must be a multiple of a power of 2. Variables are shared between dimension expressions, a spatial shape example: `["*", "16*n", "2**p*n"]`. -A JSON schema for this file can be found at https://github.com/Project-MONAI/MONAI/blob/3049e280f2424962bb2a69261389fcc0b98e0036/monai/apps/mmars/schema/metadata.json +The download link of a JSON schema to verify this file can be found within it with key "schema". An example JSON metadata file: :: { + "schema": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/meta_schema_20220324.json", "version": "0.1.0", "changelog": { "0.1.0": "complete the model package", "0.0.1": "initialize the model package structure" }, - "monai_version": "0.8.0", + "monai_version": "0.9.0", "pytorch_version": "1.10.0", "numpy_version": "1.21.2", "optional_packages_version": {"nibabel": "3.2.1"}, diff --git a/monai/bundle/__init__.py b/monai/bundle/__init__.py index 62e754e135..2a658a3c51 100644 --- a/monai/bundle/__init__.py +++ b/monai/bundle/__init__.py @@ -12,5 +12,5 @@ from .config_item import ComponentLocator, ConfigComponent, ConfigExpression, ConfigItem, Instantiable from .config_parser import ConfigParser from .reference_resolver import ReferenceResolver -from .scripts import ckpt_export, download, load, run, verify_metadata, verify_net_in_out +from .scripts import ckpt_export, download, init_bundle, load, run, verify_metadata, verify_net_in_out from .utils import EXPR_KEY, ID_REF_KEY, ID_SEP_KEY, MACRO_KEY, load_bundle_config diff --git a/monai/data/utils.py b/monai/data/utils.py index bc4f60516d..93f96feae9 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -35,6 +35,7 @@ BlendMode, Method, NumpyPadMode, + PytorchPadMode, convert_data_type, convert_to_dst_type, ensure_tuple, @@ -557,8 +558,8 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None): def pad_list_data_collate( batch: Sequence, method: Union[Method, str] = Method.SYMMETRIC, - mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, - **np_kwargs, + mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + **kwargs, ): """ Function version of :py:class:`monai.transforms.croppad.batch.PadListDataCollate`. @@ -576,13 +577,13 @@ def pad_list_data_collate( batch: batch of data to pad-collate method: padding method (see :py:class:`monai.transforms.SpatialPad`) mode: padding mode (see :py:class:`monai.transforms.SpatialPad`) - np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. - more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ from monai.transforms.croppad.batch import PadListDataCollate # needs to be here to avoid circular import - return PadListDataCollate(method=method, mode=mode, **np_kwargs)(batch) + return PadListDataCollate(method=method, mode=mode, **kwargs)(batch) def no_collation(x): diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 1245daa62c..6537cf3e21 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -695,7 +695,7 @@ def __init__( return_coords: bool = False, k_divisible: Union[Sequence[int], int] = 1, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = NumpyPadMode.CONSTANT, - **np_kwargs, + **pad_kwargs, ) -> None: """ Args: @@ -715,8 +715,8 @@ def __init__( One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html - np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. - more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ self.select_fn = select_fn @@ -726,7 +726,7 @@ def __init__( self.return_coords = return_coords self.k_divisible = k_divisible self.mode: NumpyPadMode = look_up_option(mode, NumpyPadMode) - self.np_kwargs = np_kwargs + self.pad_kwargs = pad_kwargs def compute_bounding_box(self, img: NdarrayOrTensor): """ @@ -762,9 +762,9 @@ def crop_pad( pad_to_start = np.maximum(-box_start, 0) pad_to_end = np.maximum(box_end - np.asarray(img.shape[1:]), 0) pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - return BorderPad(spatial_border=pad, mode=mode or self.mode, **self.np_kwargs)(cropped) + return BorderPad(spatial_border=pad, mode=mode or self.mode, **self.pad_kwargs)(cropped) - def __call__(self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, str]] = None): + def __call__(self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None): """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't change the channel dim. @@ -1139,14 +1139,16 @@ class ResizeWithPadOrCrop(Transform): Args: spatial_size: the spatial size of output data after padding or crop. If has non-positive values, the corresponding size of input image will be used (no padding). - mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, - ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``. + mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html method: {``"symmetric"``, ``"end"``} Pad image symmetrically on every side or only pad at the end sides. Defaults to ``"symmetric"``. - np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. - more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ @@ -1155,23 +1157,26 @@ class ResizeWithPadOrCrop(Transform): def __init__( self, spatial_size: Union[Sequence[int], int], - mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, + mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, method: Union[Method, str] = Method.SYMMETRIC, - **np_kwargs, + **pad_kwargs, ): - self.padder = SpatialPad(spatial_size=spatial_size, method=method, mode=mode, **np_kwargs) + self.padder = SpatialPad(spatial_size=spatial_size, method=method, mode=mode, **pad_kwargs) self.cropper = CenterSpatialCrop(roi_size=spatial_size) - def __call__(self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, str]] = None) -> NdarrayOrTensor: + def __call__( + self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None + ) -> NdarrayOrTensor: """ Args: img: data to pad or crop, assuming `img` is channel-first and padding or cropping doesn't apply to the channel dim. - mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, - ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - One of the listed string values or a user supplied function for padding. - If None, defaults to the ``mode`` in construction. + mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html """ return self.padder(self.cropper(img), mode=mode) diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py index 52d0c7be3b..ab16633fde 100644 --- a/monai/transforms/croppad/batch.py +++ b/monai/transforms/croppad/batch.py @@ -22,7 +22,7 @@ from monai.data.utils import list_data_collate from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad from monai.transforms.inverse import InvertibleTransform -from monai.utils.enums import Method, NumpyPadMode, TraceKeys +from monai.utils.enums import Method, NumpyPadMode, PytorchPadMode, TraceKeys __all__ = ["PadListDataCollate"] @@ -57,20 +57,20 @@ class PadListDataCollate(InvertibleTransform): Args: method: padding method (see :py:class:`monai.transforms.SpatialPad`) mode: padding mode (see :py:class:`monai.transforms.SpatialPad`) - np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. - more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ def __init__( self, method: Union[Method, str] = Method.SYMMETRIC, - mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, - **np_kwargs, + mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + **kwargs, ) -> None: self.method = method self.mode = mode - self.np_kwargs = np_kwargs + self.kwargs = kwargs def __call__(self, batch: Any): """ @@ -96,7 +96,7 @@ def __call__(self, batch: Any): continue # Use `SpatialPad` to match sizes, Default params are central padding, padding with 0's - padder = SpatialPad(spatial_size=max_shape, method=self.method, mode=self.mode, **self.np_kwargs) + padder = SpatialPad(spatial_size=max_shape, method=self.method, mode=self.mode, **self.kwargs) for idx, batch_i in enumerate(batch): orig_size = batch_i[key_or_idx].shape[1:] padded = padder(batch_i[key_or_idx]) diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 57b257b428..50cc767cab 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -103,7 +103,6 @@ "RandCropByLabelClassesDict", ] -NumpyPadModeSequence = Union[Sequence[Union[NumpyPadMode, str]], NumpyPadMode, str] PadModeSequence = Union[Sequence[Union[NumpyPadMode, PytorchPadMode, str]], NumpyPadMode, PytorchPadMode, str] DEFAULT_POST_FIX = PostFix.meta() @@ -287,8 +286,8 @@ def __init__( ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. One of the listed string values or a user supplied function. Defaults to ``"constant"``. - See also: https://numpy.org/doc/1.18/reference/ghttps://pytorch.orgenerated/numpy.pad.html - /docs/stable/generated/torch.nn.functional.pad.html + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html It also can be a sequence of string, each element corresponds to a key in ``keys``. method: {``"symmetric"``, ``"end"``} Pad image symmetrically on every side or only pad at the end sides. Defaults to ``"symmetric"``. @@ -836,11 +835,11 @@ def __init__( margin: Union[Sequence[int], int] = 0, allow_smaller: bool = True, k_divisible: Union[Sequence[int], int] = 1, - mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = NumpyPadMode.CONSTANT, + mode: PadModeSequence = NumpyPadMode.CONSTANT, start_coord_key: str = "foreground_start_coord", end_coord_key: str = "foreground_end_coord", allow_missing_keys: bool = False, - **np_kwargs, + **pad_kwargs, ) -> None: """ Args: @@ -866,8 +865,8 @@ def __init__( start_coord_key: key to record the start coordinate of spatial bounding box for foreground. end_coord_key: key to record the end coordinate of spatial bounding box for foreground. allow_missing_keys: don't raise exception if key is missing. - np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. - more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ super().__init__(keys, allow_missing_keys) @@ -880,7 +879,7 @@ def __init__( margin=margin, allow_smaller=allow_smaller, k_divisible=k_divisible, - **np_kwargs, + **pad_kwargs, ) self.mode = ensure_tuple_rep(mode, len(self.keys)) @@ -1415,16 +1414,18 @@ class ResizeWithPadOrCropd(MapTransform, InvertibleTransform): See also: monai.transforms.MapTransform spatial_size: the spatial size of output data after padding or crop. If has non-positive values, the corresponding size of input image will be used (no padding). - mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, - ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``. + mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html It also can be a sequence of string, each element corresponds to a key in ``keys``. allow_missing_keys: don't raise exception if key is missing. method: {``"symmetric"``, ``"end"``} Pad image symmetrically on every side or only pad at the end sides. Defaults to ``"symmetric"``. - np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. - more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ @@ -1434,14 +1435,14 @@ def __init__( self, keys: KeysCollection, spatial_size: Union[Sequence[int], int], - mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, + mode: PadModeSequence = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, method: Union[Method, str] = Method.SYMMETRIC, - **np_kwargs, + **pad_kwargs, ) -> None: super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) - self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, method=method, **np_kwargs) + self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, method=method, **pad_kwargs) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) diff --git a/tests/testing_data/metadata.json b/tests/testing_data/metadata.json index c12ae411f1..98a17b73c5 100644 --- a/tests/testing_data/metadata.json +++ b/tests/testing_data/metadata.json @@ -5,7 +5,7 @@ "0.1.0": "complete the model package", "0.0.1": "initialize the model package structure" }, - "monai_version": "0.8.0", + "monai_version": "0.9.0", "pytorch_version": "1.10.0", "numpy_version": "1.21.2", "optional_packages_version": { From 38b7943a1a48eba702dd1f339bcaf746ef8efbc3 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Tue, 31 May 2022 22:57:29 +0000 Subject: [PATCH 119/183] Move append items to list definition (#4417) --- monai/apps/detection/utils/anchor_utils.py | 3 ++- .../blocks/feature_pyramid_network.py | 3 ++- tests/test_anchor_box.py | 10 ++++---- tests/test_fpn_block.py | 16 +++++-------- ...est_generate_pos_neg_label_crop_centers.py | 5 ++-- tests/test_hardnegsampler.py | 9 ++++---- tests/test_optim_novograd.py | 7 +++--- tests/test_rand_crop_by_pos_neg_label.py | 23 ++++++------------- tests/test_to_tensor.py | 6 ++--- tests/test_upsample_block.py | 2 +- 10 files changed, 34 insertions(+), 50 deletions(-) diff --git a/monai/apps/detection/utils/anchor_utils.py b/monai/apps/detection/utils/anchor_utils.py index 7f11302d0a..5821c0c353 100644 --- a/monai/apps/detection/utils/anchor_utils.py +++ b/monai/apps/detection/utils/anchor_utils.py @@ -234,7 +234,8 @@ def grid_anchors(self, grid_sizes: List[List[int]], strides: List[List[Tensor]]) """ anchors = [] cell_anchors = self.cell_anchors - assert cell_anchors is not None + if cell_anchors is None: + raise AssertionError if not (len(grid_sizes) == len(strides) == len(cell_anchors)): raise ValueError( diff --git a/monai/networks/blocks/feature_pyramid_network.py b/monai/networks/blocks/feature_pyramid_network.py index 09ed2c6e20..2f7b903a19 100644 --- a/monai/networks/blocks/feature_pyramid_network.py +++ b/monai/networks/blocks/feature_pyramid_network.py @@ -197,7 +197,8 @@ def __init__( nn.init.constant_(m.bias, 0.0) # type: ignore if extra_blocks is not None: - assert isinstance(extra_blocks, ExtraFPNBlock) + if not isinstance(extra_blocks, ExtraFPNBlock): + raise AssertionError self.extra_blocks = extra_blocks def get_result_from_inner_blocks(self, x: Tensor, idx: int) -> Tensor: diff --git a/tests/test_anchor_box.py b/tests/test_anchor_box.py index c927f7d6f3..a6abbc0200 100644 --- a/tests/test_anchor_box.py +++ b/tests/test_anchor_box.py @@ -20,23 +20,21 @@ _, has_torchvision = optional_import("torchvision") -TEST_CASES_2D = [] -TEST_CASES_2D.append( +TEST_CASES_2D = [ [ {"sizes": ((10, 12, 14, 16), (20, 24, 28, 32)), "aspect_ratios": ((1.0, 0.5, 2.0), (1.0, 0.5, 2.0))}, (5, 3, 128, 128), ((5, 7, 64, 32), (5, 7, 32, 16)), ] -) +] -TEST_CASES_SHAPE_3D = [] -TEST_CASES_SHAPE_3D.append( +TEST_CASES_SHAPE_3D = [ [ {"feature_map_scales": (1, 2), "base_anchor_shapes": ((4, 3, 6), (8, 2, 4))}, (5, 3, 128, 128, 128), ((5, 7, 64, 32, 32), (5, 7, 32, 16, 16)), ] -) +] @SkipIfBeforePyTorchVersion((1, 11)) diff --git a/tests/test_fpn_block.py b/tests/test_fpn_block.py index 531df6aba8..420fd04367 100644 --- a/tests/test_fpn_block.py +++ b/tests/test_fpn_block.py @@ -23,26 +23,22 @@ _, has_torchvision = optional_import("torchvision") -TEST_CASES = [] -TEST_CASES.append( +TEST_CASES = [ [ {"spatial_dims": 3, "in_channels_list": [32, 64], "out_channels": 6}, ((7, 32, 16, 32, 64), (7, 64, 8, 16, 32)), ((7, 6, 16, 32, 64), (7, 6, 8, 16, 32)), - ] -) -TEST_CASES.append( + ], [ {"spatial_dims": 2, "in_channels_list": [32, 64], "out_channels": 6}, ((7, 32, 16, 32), (7, 64, 8, 16)), ((7, 6, 16, 32), (7, 6, 8, 16)), - ] -) + ], +] -TEST_CASES2 = [] -TEST_CASES2.append( +TEST_CASES2 = [ [{"spatial_dims": 3, "returned_layers": [1]}, (7, 3, 32, 64, 32), ((7, 256, 16, 32, 16), (7, 256, 8, 16, 8))] -) +] class TestFPNBlock(unittest.TestCase): diff --git a/tests/test_generate_pos_neg_label_crop_centers.py b/tests/test_generate_pos_neg_label_crop_centers.py index e1d9398fe3..91db0e9d96 100644 --- a/tests/test_generate_pos_neg_label_crop_centers.py +++ b/tests/test_generate_pos_neg_label_crop_centers.py @@ -18,8 +18,7 @@ from monai.utils.misc import set_determinism from tests.utils import TEST_NDARRAYS, assert_allclose -TESTS = [] -TESTS.append( +TESTS = [ [ { "spatial_size": [2, 2, 2], @@ -33,7 +32,7 @@ 2, 3, ] -) +] class TestGeneratePosNegLabelCropCenters(unittest.TestCase): diff --git a/tests/test_hardnegsampler.py b/tests/test_hardnegsampler.py index 0c952954a1..f4eff81810 100644 --- a/tests/test_hardnegsampler.py +++ b/tests/test_hardnegsampler.py @@ -17,17 +17,16 @@ from monai.apps.detection.utils.hard_negative_sampler import HardNegativeSampler from tests.utils import assert_allclose -TEST_CASE = [] -TEST_CASE.append([[], [], [], [torch.tensor([]), torch.tensor([])], [torch.tensor([]), torch.tensor([])]]) -TEST_CASE.append( +TEST_CASE = [ + [[], [], [], [torch.tensor([]), torch.tensor([])], [torch.tensor([]), torch.tensor([])]], [ [0, 1], [1, 0, 2, 3], [0.1, 0.9, 0.4, 0.3, 0.3, 0.5], [torch.tensor([0, 1]), torch.tensor([1, 0, 1, 1])], [torch.tensor([1, 0]), torch.tensor([0, 1, 0, 0])], - ] -) + ], +] select_sample_size_per_image = 6 positive_fraction = 0.5 diff --git a/tests/test_optim_novograd.py b/tests/test_optim_novograd.py index 0cf4c35cb6..c1e63182e6 100644 --- a/tests/test_optim_novograd.py +++ b/tests/test_optim_novograd.py @@ -32,9 +32,10 @@ def build_test_cases(data): {"params": [bias], "lr": 1e-2, "amsgrad": True, "grad_averaging": True, "weight_decay": 0.1}, ] - test_cases = [] - test_cases.append([test_case_same_param, default_params, weight, bias, input]) - test_cases.append([test_case_diff_param, default_params, weight, bias, input]) + test_cases = [ + [test_case_same_param, default_params, weight, bias, input], + [test_case_diff_param, default_params, weight, bias, input], + ] return test_cases diff --git a/tests/test_rand_crop_by_pos_neg_label.py b/tests/test_rand_crop_by_pos_neg_label.py index 1cdc3d8c76..1d9e2612c7 100644 --- a/tests/test_rand_crop_by_pos_neg_label.py +++ b/tests/test_rand_crop_by_pos_neg_label.py @@ -18,8 +18,7 @@ from monai.transforms import RandCropByPosNegLabel from tests.utils import TEST_NDARRAYS -TESTS = [] -TESTS.append( +TESTS = [ [ { "label": np.random.randint(0, 2, size=[3, 3, 3, 3]), @@ -32,9 +31,7 @@ }, {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, (3, 2, 2, 3), - ] -) -TESTS.append( + ], [ { "label": np.random.randint(0, 2, size=[3, 3, 3, 3]), @@ -47,9 +44,7 @@ }, {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, (3, 2, 2, 2), - ] -) -TESTS.append( + ], [ { "label": None, @@ -66,9 +61,7 @@ "image": np.random.randint(0, 2, size=[3, 3, 3, 3]), }, (3, 2, 2, 2), - ] -) -TESTS.append( + ], [ { "label": np.random.randint(0, 2, size=[3, 3, 3, 3]), @@ -81,9 +74,7 @@ }, {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, (3, 3, 3, 2), - ] -) -TESTS.append( + ], [ { "label": np.random.randint(0, 2, size=[3, 3, 3, 3]), @@ -96,8 +87,8 @@ }, {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, (3, 3, 3, 3), - ] -) + ], +] class TestRandCropByPosNegLabel(unittest.TestCase): diff --git a/tests/test_to_tensor.py b/tests/test_to_tensor.py index bfc61cdb19..fe6c4fb0d4 100644 --- a/tests/test_to_tensor.py +++ b/tests/test_to_tensor.py @@ -21,13 +21,11 @@ im = [[1, 2], [3, 4]] -TESTS = [] -TESTS.append((im, (2, 2))) +TESTS = [(im, (2, 2))] for p in TEST_NDARRAYS: TESTS.append((p(im), (2, 2))) -TESTS_SINGLE = [] -TESTS_SINGLE.append([5]) +TESTS_SINGLE = [[5]] for p in TEST_NDARRAYS: TESTS_SINGLE.append([p(5)]) diff --git a/tests/test_upsample_block.py b/tests/test_upsample_block.py index aa4141aabc..6e2e548042 100644 --- a/tests/test_upsample_block.py +++ b/tests/test_upsample_block.py @@ -76,8 +76,8 @@ test_case = [ {"dimensions": 3, "in_channels": 3, "out_channels": 5, "mode": t, "scale_factor": s, "align_corners": True}, (16, 3, 4, 5, 6), + expected_shape, ] - test_case.append(expected_shape) TEST_CASES_EQ.append(test_case) From 40373b2b41a4357a4103ddcdd5a6947833141108 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 09:13:18 -0400 Subject: [PATCH 120/183] Refactor Probability Map Producer Handler (#4382) * Update wsi metadata names Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Implement prob map handler Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/handlers.rst | 5 + .../pathology/handlers/prob_map_producer.py | 6 +- monai/data/wsi_datasets.py | 39 ++++-- monai/data/wsi_reader.py | 14 +- monai/handlers/__init__.py | 1 + monai/handlers/probability_maps.py | 121 ++++++++++++++++++ tests/test_handler_prob_map_producer.py | 32 +++-- tests/test_sliding_patch_wsi_dataset.py | 6 +- tests/test_wsireader_new.py | 40 ++++-- 9 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 monai/handlers/probability_maps.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index 0f0a4f6b44..0172529f40 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -168,3 +168,8 @@ Utilities --------- .. automodule:: monai.handlers.utils :members: + +Probability Map Handlers +------------------------ +.. automodule:: monai.handlers.probability_maps + :members: diff --git a/monai/apps/pathology/handlers/prob_map_producer.py b/monai/apps/pathology/handlers/prob_map_producer.py index 0615b44b8f..62507dc0cb 100644 --- a/monai/apps/pathology/handlers/prob_map_producer.py +++ b/monai/apps/pathology/handlers/prob_map_producer.py @@ -16,7 +16,7 @@ import numpy as np from monai.config import DtypeLike, IgniteInfo -from monai.utils import min_version, optional_import +from monai.utils import deprecated, min_version, optional_import Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") if TYPE_CHECKING: @@ -25,6 +25,10 @@ Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") +@deprecated( + since="0.8", + msg_suffix="use `monai.handler.ProbMapProducer` (with `monai.data.wsi_dataset.SlidingPatchWSIDataset`) instead.", +) class ProbMapProducer: """ Event handler triggered on completing every iteration to save the probability map diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 83b4fd9fe3..5e4cae6d90 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -161,6 +161,7 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader, - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + map_level: the resolution level at which the output map is created. seed: random seed to randomly generate offsets. Defaults to 0. kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class @@ -186,10 +187,11 @@ def __init__( offset_limits: Optional[Union[Tuple[Tuple[int, int], Tuple[int, int]], Tuple[int, int]]] = None, transform: Optional[Callable] = None, reader="cuCIM", + map_level: int = 0, seed: int = 0, **kwargs, ): - super().__init__(data=data, size=size, level=level, transform=transform, reader=reader, **kwargs) + super().__init__(data=[], size=size, level=level, transform=transform, reader=reader, **kwargs) self.overlap = overlap self.set_random_state(seed) # Set the offset config @@ -219,11 +221,13 @@ def __init__( else: self.offset = ensure_tuple_rep(offset, 2) + self.map_level = map_level # Create single sample for each patch (in a sliding window manner) self.data = [] - for sample in data: - sliding_samples = self._evaluate_patch_coordinates(sample) - self.data.extend(sliding_samples) + self.image_data = data + for sample in self.image_data: + patch_samples = self._evaluate_patch_coordinates(sample) + self.data.extend(patch_samples) def _get_offset(self, sample): if self.random_offset: @@ -235,24 +239,37 @@ def _get_offset(self, sample): return self.offset def _evaluate_patch_coordinates(self, sample): - """Define the location for each patch based on sliding-window approach""" + """Define the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) level = self._get_level(sample) offset = self._get_offset(sample) wsi_obj = self._get_wsi_object(sample) wsi_size = self.wsi_reader.get_size(wsi_obj, 0) - downsample = self.wsi_reader.get_downsample_ratio(wsi_obj, level) - patch_size_ = tuple(p * downsample for p in patch_size) # patch size at level 0 + ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, level) + patch_size_ = tuple(p * ratio for p in patch_size) # patch size at level 0 locations = list( iter_patch_position( image_size=wsi_size, patch_size=patch_size_, start_pos=offset, overlap=self.overlap, padded=False ) ) - sample["size"] = patch_size - sample["level"] = level n_patches = len(locations) - return [{**sample, "location": loc, "num_patches": n_patches} for loc in locations] + sample["size"] = np.array(patch_size) + sample["level"] = level + sample["num_patches"] = n_patches + sample["map_level"] = self.map_level + sample["map_size"] = np.array(self.wsi_reader.get_size(wsi_obj, self.map_level)) + return [ + {**sample, "location": np.array(loc), "map_center": self.downsample_center(loc, patch_size, ratio)} + for loc in locations + ] + + def downsample_center(self, location: Tuple[int, int], patch_size: Tuple[int, int], ratio: float) -> np.ndarray: + """ + For a given location at level=0, evaluate the corresponding center position of patch at level=`level` + """ + center_location = [int((l + p // 2) / ratio) for l, p in zip(location, patch_size)] + return np.array(center_location) def _get_location(self, sample: Dict): return sample["location"] @@ -262,6 +279,8 @@ def _transform(self, index: int): sample: Dict = self.data[index] # Extract patch image and associated metadata image, metadata = self._get_data(sample) + for k in ["num_patches", "map_level", "map_size", "map_center"]: + metadata[k] = sample[k] # Create put all patch information together and apply transforms patch = {"image": image, "metadata": metadata} return apply_transform(self.transform, patch) if self.transform else patch diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 955651999a..434edfd9cc 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -140,8 +140,10 @@ def get_metadata( "backend": self.backend, "original_channel_dim": 0, "spatial_shape": np.asarray(patch.shape[1:]), - "wsi": {"path": self.get_file_path(wsi)}, - "patch": {"location": location, "size": size, "level": level}, + "path": self.get_file_path(wsi), + "patch_location": np.asarray(location), + "patch_size": np.asarray(size), + "patch_level": level, } return metadata @@ -232,16 +234,16 @@ def get_data( "backend": each_meta["backend"], "original_channel_dim": each_meta["original_channel_dim"], "spatial_shape": each_meta["spatial_shape"], - "wsi": [each_meta["wsi"]], - "patch": [each_meta["patch"]], } + for k in ["path", "patch_size", "patch_level", "patch_location"]: + metadata[k] = [each_meta[k]] else: if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: raise ValueError("original_channel_dim is not consistent across wsi objects.") if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): raise ValueError("spatial_shape is not consistent across wsi objects.") - metadata["wsi"].append(each_meta["wsi"]) - metadata["patch"].append(each_meta["patch"]) + for k in ["path", "patch_size", "patch_level", "patch_location"]: + metadata[k].append(each_meta[k]) return _stack_images(patch_list, metadata), metadata diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 43fd634b49..cffbe46391 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -26,6 +26,7 @@ from .nvtx_handlers import MarkHandler, RangeHandler, RangePopHandler, RangePushHandler from .parameter_scheduler import ParamSchedulerHandler from .postprocessing import PostProcessing +from .probability_maps import ProbMapProducer from .regression_metrics import MeanAbsoluteError, MeanSquaredError, PeakSignalToNoiseRatio, RootMeanSquaredError from .roc_auc import ROCAUC from .smartcache_handler import SmartCacheHandler diff --git a/monai/handlers/probability_maps.py b/monai/handlers/probability_maps.py new file mode 100644 index 0000000000..03588e605b --- /dev/null +++ b/monai/handlers/probability_maps.py @@ -0,0 +1,121 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import threading +from typing import TYPE_CHECKING, Dict, Optional + +import numpy as np + +from monai.config import DtypeLike, IgniteInfo +from monai.utils import ProbMapKeys, min_version, optional_import + +Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") +if TYPE_CHECKING: + from ignite.engine import Engine +else: + Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") + + +class ProbMapProducer: + """ + Event handler triggered on completing every iteration to calculate and save the probability map + """ + + def __init__( + self, + output_dir: str = "./", + output_postfix: str = "", + prob_key: str = "pred", + dtype: DtypeLike = np.float64, + name: Optional[str] = None, + ) -> None: + """ + Args: + output_dir: output directory to save probability maps. + output_postfix: a string appended to all output file names. + prob_key: the key associated to the probability output of the model + dtype: the data type in which the probability map is stored. Default np.float64. + name: identifier of logging.logger to use, defaulting to `engine.logger`. + + """ + self.logger = logging.getLogger(name) + self._name = name + self.output_dir = output_dir + self.output_postfix = output_postfix + self.prob_key = prob_key + self.dtype = dtype + self.prob_map: Dict[str, np.ndarray] = {} + self.counter: Dict[str, int] = {} + self.num_done_images: int = 0 + self.num_images: int = 0 + self.lock = threading.Lock() + + def attach(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + + image_data = engine.data_loader.dataset.image_data # type: ignore + self.num_images = len(image_data) + + # Initialized probability maps for all the images + for sample in image_data: + name = sample[ProbMapKeys.PRE_PATH.value] + self.counter[name] = sample[ProbMapKeys.COUNT.value] + self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE.value], dtype=self.dtype) + + if self._name is None: + self.logger = engine.logger + if not engine.has_event_handler(self, Events.ITERATION_COMPLETED): + engine.add_event_handler(Events.ITERATION_COMPLETED, self) + if not engine.has_event_handler(self.finalize, Events.COMPLETED): + engine.add_event_handler(Events.COMPLETED, self.finalize) + + def __call__(self, engine: Engine) -> None: + """ + This method assumes self.batch_transform will extract metadata from the input batch. + + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + if not isinstance(engine.state.batch, dict) or not isinstance(engine.state.output, dict): + raise ValueError("engine.state.batch and engine.state.output must be dictionaries.") + names = engine.state.batch["metadata"][ProbMapKeys.PATH.value] + locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION.value] + probs = engine.state.output[self.prob_key] + for name, loc, prob in zip(names, locs, probs): + self.prob_map[name][loc] = prob + with self.lock: + self.counter[name] -= 1 + if self.counter[name] == 0: + self.save_prob_map(name) + + def save_prob_map(self, name: str) -> None: + """ + This method save the probability map for an image, when its inference is finished, + and delete that probability map from memory. + + Args: + name: the name of image to be saved. + """ + file_path = os.path.join(self.output_dir, name) + np.save(file_path + self.output_postfix + ".npy", self.prob_map[name]) + + self.num_done_images += 1 + self.logger.info(f"Inference of '{name}' is done [{self.num_done_images}/{self.num_images}]!") + del self.prob_map[name] + del self.counter[name] + + def finalize(self, engine: Engine): + self.logger.info(f"Probability map is created for {self.num_done_images}/{self.num_images} images!") diff --git a/tests/test_handler_prob_map_producer.py b/tests/test_handler_prob_map_producer.py index b3f79cf587..38ab7044b2 100644 --- a/tests/test_handler_prob_map_producer.py +++ b/tests/test_handler_prob_map_producer.py @@ -18,30 +18,44 @@ from parameterized import parameterized from torch.utils.data import DataLoader -from monai.apps.pathology.handlers import ProbMapProducer from monai.data.dataset import Dataset from monai.engines import Evaluator -from monai.handlers import ValidationHandler +from monai.handlers import ProbMapProducer, ValidationHandler +from monai.utils.enums import ProbMapKeys TEST_CASE_0 = ["temp_image_inference_output_1", 2] TEST_CASE_1 = ["temp_image_inference_output_2", 9] -TEST_CASE_2 = ["temp_image_inference_output_3", 1000] +TEST_CASE_2 = ["temp_image_inference_output_3", 100] class TestDataset(Dataset): def __init__(self, name, size): super().__init__( data=[ - {"name": name, "mask_shape": (size, size), "mask_locations": [[i, i] for i in range(size)], "level": 0} + { + "image": name, + ProbMapKeys.COUNT.value: size, + ProbMapKeys.SIZE.value: np.array([size, size]), + ProbMapKeys.LOCATION.value: np.array([i, i]), + } + for i in range(size) ] ) - self.len = size - - def __len__(self): - return self.len + self.image_data = [ + {"image": name, ProbMapKeys.COUNT.value: size, ProbMapKeys.SIZE.value: np.array([size, size])} + ] def __getitem__(self, index): - return {"name": self.data[0]["name"], "mask_location": self.data[0]["mask_locations"][index], "pred": index + 1} + return { + "image": np.zeros((3, 2, 2)), + ProbMapKeys.COUNT.value: self.data[index][ProbMapKeys.COUNT.value], + "metadata": { + "path": self.data[index]["image"], + ProbMapKeys.SIZE.value: self.data[index][ProbMapKeys.SIZE.value], + ProbMapKeys.LOCATION.value: self.data[index][ProbMapKeys.LOCATION.value], + }, + "pred": index + 1, + } class TestEvaluator(Evaluator): diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index 1eaa0292c5..ded0a9213c 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -233,11 +233,11 @@ def test_read_patches_large(self, input_parameters, expected): dataset = SlidingPatchWSIDataset(reader=self.backend, **input_parameters) self.assertEqual(len(dataset), len(expected)) for i, sample in enumerate(dataset): - self.assertEqual(sample["metadata"]["patch"]["level"], expected[i]["level"]) - self.assertTupleEqual(sample["metadata"]["patch"]["size"], expected[i]["size"]) + self.assertEqual(sample["metadata"]["patch_level"], expected[i]["level"]) + self.assertTupleEqual(tuple(sample["metadata"]["patch_size"]), expected[i]["size"]) steps = [round(expected[i]["ratio"] * s) for s in expected[i]["size"]] expected_location = tuple(expected[i]["step_loc"][j] * steps[j] for j in range(len(steps))) - self.assertTupleEqual(sample["metadata"]["patch"]["location"], expected_location) + self.assertTupleEqual(tuple(sample["metadata"]["patch_location"]), expected_location) @skipUnless(has_cucim, "Requires cucim") diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 4faec53978..35aaccad26 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -128,10 +128,10 @@ def test_read_whole_image(self, file_path, level, expected_shape): img, meta = reader.get_data(img_obj) self.assertTupleEqual(img.shape, expected_shape) self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["wsi"]["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch"]["level"], level) - self.assertTupleEqual(meta["patch"]["size"], expected_shape[1:]) - self.assertTupleEqual(meta["patch"]["location"], (0, 0)) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], level) + assert_array_equal(meta["patch_size"], expected_shape[1:]) + assert_array_equal(meta["patch_location"], (0, 0)) @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) def test_read_region(self, file_path, patch_info, expected_img): @@ -150,10 +150,10 @@ def test_read_region(self, file_path, patch_info, expected_img): self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["wsi"]["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch"]["level"], patch_info["level"]) - self.assertTupleEqual(meta["patch"]["size"], expected_img.shape[1:]) - self.assertTupleEqual(meta["patch"]["location"], patch_info["location"]) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], patch_info["level"]) + assert_array_equal(meta["patch_size"], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"], patch_info["location"]) @parameterized.expand([TEST_CASE_3]) def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): @@ -172,10 +172,10 @@ def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["wsi"][0]["path"], str(os.path.abspath(file_path_list[0]))) - self.assertEqual(meta["patch"][0]["level"], patch_info["level"]) - self.assertTupleEqual(meta["patch"][0]["size"], expected_img.shape[1:]) - self.assertTupleEqual(meta["patch"][0]["location"], patch_info["location"]) + self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) + self.assertEqual(meta["patch_level"][0], patch_info["level"]) + assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"][0], patch_info["location"]) @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") @@ -225,6 +225,22 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte torch.testing.assert_allclose(s, expected_spatial_shape) self.assertTupleEqual(data["image"].shape, expected_shape) + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) + batch_size = 2 + data_loader = DataLoader(dataset, batch_size=batch_size) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + torch.testing.assert_allclose(s, expected_spatial_shape) + self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) + @skipUnless(has_cucim, "Requires cucim") class TestCuCIM(WSIReaderTests.Tests): From d8d24eed0ee7705afd55050af0709613fddacb96 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 2 Jun 2022 02:58:27 +0800 Subject: [PATCH 121/183] 4426 Enhance download_url and fixes error msg (#4427) * enhance download Signed-off-by: Yiheng Wang * [MONAI] code formatting Signed-off-by: monai-bot * fixes https://github.com/Project-MONAI/MONAI/issues/4432 Signed-off-by: Wenqi Li Co-authored-by: monai-bot Co-authored-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- environment-dev.yml | 2 +- monai/apps/utils.py | 15 ++++++++++++--- monai/networks/utils.py | 2 +- monai/transforms/spatial/array.py | 2 +- requirements-dev.txt | 2 +- runtests.sh | 4 ++-- setup.cfg | 4 ++-- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index 9eef775b78..dc9a0970cd 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -10,7 +10,7 @@ dependencies: - parameterized - setuptools>=50.3.0,!=60.0.0 - ignite==0.4.8 - - gdown>=3.6.4 + - gdown>=4.4.0 - scipy - nibabel - pillow!=8.3.0 # https://github.com/python-pillow/Pillow/issues/5571 diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 209dc796cf..cbfdcd7423 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -27,7 +27,7 @@ from monai.config.type_definitions import PathLike from monai.utils import look_up_option, min_version, optional_import -gdown, has_gdown = optional_import("gdown", "3.6") +gdown, has_gdown = optional_import("gdown", "4.4") if TYPE_CHECKING: from tqdm import tqdm @@ -147,7 +147,12 @@ def check_hash(filepath: PathLike, val: Optional[str] = None, hash_type: str = " def download_url( - url: str, filepath: PathLike = "", hash_val: Optional[str] = None, hash_type: str = "md5", progress: bool = True + url: str, + filepath: PathLike = "", + hash_val: Optional[str] = None, + hash_type: str = "md5", + progress: bool = True, + **gdown_kwargs, ) -> None: """ Download file from specified URL link, support process bar and hash check. @@ -160,6 +165,10 @@ def download_url( if None, skip hash validation. hash_type: 'md5' or 'sha1', defaults to 'md5'. progress: whether to display a progress bar. + gdown_kwargs: other args for `gdown` except for the `url`, `output` and `quiet`. + these args will only be used if download from google drive. + details of the args of it: + https://github.com/wkentaro/gdown/blob/main/gdown/download.py Raises: RuntimeError: When the hash validation of the ``filepath`` existing file fails. @@ -189,7 +198,7 @@ def download_url( if urlparse(url).netloc == "drive.google.com": if not has_gdown: raise RuntimeError("To download files from Google Drive, please install the gdown dependency.") - gdown.download(url, f"{tmp_name}", quiet=not progress) + gdown.download(url, f"{tmp_name}", quiet=not progress, **gdown_kwargs) else: _download_with_progress(url, tmp_name, progress=progress) if not tmp_name.exists(): diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 728a290563..f9018b0c39 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -486,7 +486,7 @@ def save_state(src: Union[torch.nn.Module, Dict], path: PathLike, **kwargs): src: input data to save, can be `nn.Module`, `state_dict`, a dictionary of `nn.Module` or `state_dict`. path: target file path to save the input object. kwargs: other args for the `save_obj` except for the `obj` and `path`. - default `func` is `torch.save()`, details of the args of it: + default `func` is `torch.save()`, details of the args: https://pytorch.org/docs/stable/generated/torch.save.html. """ diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index eb854c8d23..92cbf8b580 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -234,7 +234,7 @@ def __call__( else torch.solve(dst_affine, src_affine).solution # type: ignore ) except (np.linalg.LinAlgError, RuntimeError) as e: - raise ValueError(f"src affine is not invertible: {src_affine}") from e + raise ValueError("src affine is not invertible.") from e xform = to_affine_nd(spatial_rank, xform) # no resampling if it's identity transform if allclose(xform, np.diag(np.ones(len(xform))), atol=AFFINE_TOL) and allclose(spatial_size, in_spatial_size): diff --git a/requirements-dev.txt b/requirements-dev.txt index 7bc06b8039..df1fe67f5e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ # Full requirements for developments -r requirements-min.txt pytorch-ignite==0.4.8 -gdown>=3.6.4 +gdown>=4.4.0 scipy itk>=5.2 nibabel diff --git a/runtests.sh b/runtests.sh index f7403ff05b..a69c408e3c 100755 --- a/runtests.sh +++ b/runtests.sh @@ -536,8 +536,8 @@ then fi ${cmdPrefix}${PY_EXE} -m pylint --version - ignore_codes="E1101,E1102,E0601,E1130,E1123,E0102,E1120,E1137,E1136" - ${cmdPrefix}${PY_EXE} -m pylint monai tests -E --disable=$ignore_codes -j $NUM_PARALLEL + ignore_codes="C,R,W,E1101,E1102,E0601,E1130,E1123,E0102,E1120,E1137,E1136" + ${cmdPrefix}${PY_EXE} -m pylint monai tests --disable=$ignore_codes -j $NUM_PARALLEL pylint_status=$? if [ ${pylint_status} -ne 0 ] diff --git a/setup.cfg b/setup.cfg index 914e404b2d..a25a64797c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,7 +33,7 @@ all = scikit-image>=0.14.2 pillow tensorboard - gdown>=3.6.4 + gdown>=4.4.0 pytorch-ignite==0.4.8 torchvision itk>=5.2 @@ -63,7 +63,7 @@ pillow = tensorboard = tensorboard gdown = - gdown>=3.6.4 + gdown>=4.4.0 ignite = pytorch-ignite==0.4.8 torchvision = From 4434a3f28d34094ce386598cbd890372a8c42c46 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:34:13 -0400 Subject: [PATCH 122/183] Add detector utils (#4434) --- docs/source/apps.rst | 5 + .../detection/networks/retinanet_network.py | 29 +++ monai/apps/detection/utils/ATSS_matcher.py | 12 +- monai/apps/detection/utils/detector_utils.py | 193 ++++++++++++++++++ monai/inferers/inferer.py | 37 +++- monai/inferers/utils.py | 7 +- tests/test_detector_utils.py | 94 +++++++++ 7 files changed, 361 insertions(+), 16 deletions(-) create mode 100644 monai/apps/detection/utils/detector_utils.py create mode 100644 tests/test_detector_utils.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 49d2e97f94..e688624151 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -159,3 +159,8 @@ Applications ~~~~~~~~~~~ .. automodule:: monai.apps.detection.utils.box_coder :members: + +`Detector utils` +~~~~~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.utils.detector_utils + :members: diff --git a/monai/apps/detection/networks/retinanet_network.py b/monai/apps/detection/networks/retinanet_network.py index 39dbcd7b49..63a109e2b5 100644 --- a/monai/apps/detection/networks/retinanet_network.py +++ b/monai/apps/detection/networks/retinanet_network.py @@ -9,6 +9,35 @@ # See the License for the specific language governing permissions and # limitations under the License. +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE + +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +""" +Part of this script is adapted from +https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py +""" + import math from typing import Callable, Dict, List, Sequence, Union diff --git a/monai/apps/detection/utils/ATSS_matcher.py b/monai/apps/detection/utils/ATSS_matcher.py index c34fe436e7..8f92f50f89 100644 --- a/monai/apps/detection/utils/ATSS_matcher.py +++ b/monai/apps/detection/utils/ATSS_matcher.py @@ -154,8 +154,8 @@ def compute_matches( Returns: - matrix which contains the similarity from each boxes to each anchor [N, M] - vector which contains the matched box index for all - anchors (if background `BELOW_LOW_THRESHOLD` is used - and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] + anchors (if background `BELOW_LOW_THRESHOLD` is used + and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] """ raise NotImplementedError @@ -207,10 +207,10 @@ def compute_matches( num_anchors_per_loc: number of anchors per position Returns: - Tensor: matrix which contains the similarity from each boxes to each anchor [N, M] - Tensor: vector which contains the matched box index for all - anchors (if background `BELOW_LOW_THRESHOLD` is used - and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] + - matrix which contains the similarity from each boxes to each anchor [N, M] + - vector which contains the matched box index for all + anchors (if background `BELOW_LOW_THRESHOLD` is used + and if it should be ignored `BETWEEN_THRESHOLDS` is used) [M] Note: ``StandardMode`` = :class:`~monai.data.box_utils.CornerCornerModeTypeA`, diff --git a/monai/apps/detection/utils/detector_utils.py b/monai/apps/detection/utils/detector_utils.py new file mode 100644 index 0000000000..e63a732f25 --- /dev/null +++ b/monai/apps/detection/utils/detector_utils.py @@ -0,0 +1,193 @@ +# 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 typing import Dict, List, Sequence, Tuple, Union + +import torch +import torch.nn.functional as F +from torch import Tensor + +from monai.transforms.croppad.array import SpatialPad +from monai.transforms.utils import compute_divisible_spatial_size, convert_pad_mode +from monai.utils import PytorchPadMode, ensure_tuple_rep + + +def check_input_images(input_images: Union[List[Tensor], Tensor], spatial_dims: int) -> None: + """ + Validate the input dimensionality (raise a `ValueError` if invalid). + + Args: + input_images: It can be 1) a tensor sized (B, C, H, W) or (B, C, H, W, D), + or 2) a list of image tensors, each image i may have different size (C, H_i, W_i) or (C, H_i, W_i, D_i). + spatial_dims: number of spatial dimensions of the images, 2 or 3. + """ + if isinstance(input_images, Tensor): + if len(input_images.shape) != spatial_dims + 2: + raise ValueError( + "When input_images is a Tensor, its need to be (spatial_dims + 2)-D." + f"In this case, it should be a {(spatial_dims + 2)}-D Tensor, got Tensor shape {input_images.shape}." + ) + elif isinstance(input_images, List): + for img in input_images: + if len(img.shape) != spatial_dims + 1: + raise ValueError( + "When input_images is a List[Tensor], each element should have be (spatial_dims + 1)-D." + f"In this case, it should be a {(spatial_dims + 1)}-D Tensor, got Tensor shape {img.shape}." + ) + else: + raise ValueError("input_images needs to be a List[Tensor] or Tensor.") + return + + +def check_training_targets( + input_images: Union[List[Tensor], Tensor], + targets: Union[List[Dict[str, Tensor]], None], + spatial_dims: int, + target_label_key: str, + target_box_key: str, +) -> None: + """ + Validate the input images/targets during training (raise a `ValueError` if invalid). + + Args: + input_images: It can be 1) a tensor sized (B, C, H, W) or (B, C, H, W, D), + or 2) a list of image tensors, each image i may have different size (C, H_i, W_i) or (C, H_i, W_i, D_i). + targets: a list of dict. Each dict with two keys: target_box_key and target_label_key, + ground-truth boxes present in the image. + spatial_dims: number of spatial dimensions of the images, 2 or 3. + target_label_key: the expected key of target labels. + target_box_key: the expected key of target boxes. + """ + if targets is None: + raise ValueError("Please provide ground truth targets during training.") + + if len(input_images) != len(targets): + raise ValueError(f"len(input_images) should equal to len(targets), got {len(input_images)}, {len(targets)}.") + + for target in targets: + if (target_label_key not in target.keys()) or (target_box_key not in target.keys()): + raise ValueError( + f"{target_label_key} and {target_box_key} are expected keys in targets. Got {target.keys()}." + ) + + boxes = target[target_box_key] + if not isinstance(boxes, torch.Tensor): + raise ValueError(f"Expected target boxes to be of type Tensor, got {type(boxes)}.") + if len(boxes.shape) != 2 or boxes.shape[-1] != 2 * spatial_dims: + raise ValueError( + f"Expected target boxes to be a tensor " f"of shape [N, {2* spatial_dims}], got {boxes.shape}." + ) + return + + +def pad_images( + input_images: Union[List[Tensor], Tensor], + spatial_dims: int, + size_divisible: Union[int, Sequence[int]], + mode: Union[PytorchPadMode, str] = PytorchPadMode.CONSTANT, + **kwargs, +) -> Tuple[Tensor, List[List[int]]]: + """ + Pad the input images, so that the output spatial sizes are divisible by `size_divisible`. + It pad them at the end to create a (B, C, H, W) or (B, C, H, W, D) Tensor. + Padded size (H, W) or (H, W, D) is divisible by size_divisible. + Default padding uses constant padding with value 0.0 + + Args: + input_images: It can be 1) a tensor sized (B, C, H, W) or (B, C, H, W, D), + or 2) a list of image tensors, each image i may have different size (C, H_i, W_i) or (C, H_i, W_i, D_i). + spatial_dims: number of spatial dimensions of the images, 2D or 3D. + size_divisible: int or Sequence[int], is the expected pattern on the input image shape. + If an int, the same `size_divisible` will be applied to all the input spatial dimensions. + mode: available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + kwargs: other arguments for `torch.pad` function. + + Return: + - images, a (B, C, H, W) or (B, C, H, W, D) Tensor + - image_sizes, the original spatial size of each image + """ + size_divisible = ensure_tuple_rep(size_divisible, spatial_dims) + + # If input_images: Tensor + if isinstance(input_images, Tensor): + orig_size = list(input_images.shape[-spatial_dims:]) + new_size = compute_divisible_spatial_size(spatial_shape=orig_size, k=size_divisible) + all_pad_width = [(0, max(sp_i - orig_size[i], 0)) for i, sp_i in enumerate(new_size)] + pt_pad_width = [val for sublist in all_pad_width for val in sublist[::-1]][::-1] + if max(pt_pad_width) == 0: + # if there is no need to pad + return input_images, [orig_size] * input_images.shape[0] + mode_: str = convert_pad_mode(dst=input_images, mode=mode).value + return F.pad(input_images, pt_pad_width, mode=mode_, **kwargs), [orig_size] * input_images.shape[0] + + # If input_images: List[Tensor]) + image_sizes = [img.shape[-spatial_dims:] for img in input_images] + in_channels = input_images[0].shape[0] + dtype = input_images[0].dtype + device = input_images[0].device + + # compute max_spatial_size + image_sizes_t = torch.tensor(image_sizes) + max_spatial_size_t, _ = torch.max(image_sizes_t, dim=0) + + if len(max_spatial_size_t) != spatial_dims or len(size_divisible) != spatial_dims: + raise ValueError(" Require len(max_spatial_size_t) == spatial_dims ==len(size_divisible).") + + max_spatial_size = compute_divisible_spatial_size(spatial_shape=list(max_spatial_size_t), k=size_divisible) + + # allocate memory for the padded images + images = torch.zeros([len(image_sizes), in_channels] + max_spatial_size, dtype=dtype, device=device) + + # Use `SpatialPad` to match sizes, padding in the end will not affect boxes + padder = SpatialPad(spatial_size=max_spatial_size, method="end", mode=mode, **kwargs) + for idx, img in enumerate(input_images): + images[idx, ...] = padder(img) # type: ignore + + return images, [list(ss) for ss in image_sizes] + + +def preprocess_images( + input_images: Union[List[Tensor], Tensor], + spatial_dims: int, + size_divisible: Union[int, Sequence[int]], + mode: Union[PytorchPadMode, str] = PytorchPadMode.CONSTANT, + **kwargs, +) -> Tuple[Tensor, List[List[int]]]: + """ + Preprocess the input images, including + + - validate of the inputs + - pad the inputs so that the output spatial sizes are divisible by `size_divisible`. + It pads them at the end to create a (B, C, H, W) or (B, C, H, W, D) Tensor. + Padded size (H, W) or (H, W, D) is divisible by size_divisible. + Default padding uses constant padding with value 0.0 + + Args: + input_images: It can be 1) a tensor sized (B, C, H, W) or (B, C, H, W, D), + or 2) a list of image tensors, each image i may have different size (C, H_i, W_i) or (C, H_i, W_i, D_i). + spatial_dims: number of spatial dimensions of the images, 2 or 3. + size_divisible: int or Sequence[int], is the expected pattern on the input image shape. + If an int, the same `size_divisible` will be applied to all the input spatial dimensions. + mode: available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + kwargs: other arguments for `torch.pad` function. + + Return: + - images, a (B, C, H, W) or (B, C, H, W, D) Tensor + - image_sizes, the original spatial size of each image + """ + check_input_images(input_images, spatial_dims) + size_divisible = ensure_tuple_rep(size_divisible, spatial_dims) + + return pad_images(input_images, spatial_dims, size_divisible, mode, **kwargs) diff --git a/monai/inferers/inferer.py b/monai/inferers/inferer.py index 89863a7c3b..1b50f7d7e8 100644 --- a/monai/inferers/inferer.py +++ b/monai/inferers/inferer.py @@ -11,7 +11,7 @@ import warnings from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Tuple, Union import torch import torch.nn as nn @@ -173,8 +173,12 @@ def __init__( ) from e def __call__( - self, inputs: torch.Tensor, network: Callable[..., torch.Tensor], *args: Any, **kwargs: Any - ) -> Union[torch.Tensor, Tuple[torch.Tensor], Dict[Any, torch.Tensor]]: + self, + inputs: torch.Tensor, + network: Callable[..., Union[torch.Tensor, Sequence[torch.Tensor], Dict[Any, torch.Tensor]]], + *args: Any, + **kwargs: Any, + ) -> Union[torch.Tensor, Tuple[torch.Tensor, ...], Dict[Any, torch.Tensor]]: """ Args: @@ -271,8 +275,12 @@ def __init__(self, spatial_dim: int = 0, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def __call__( - self, inputs: torch.Tensor, network: Callable[..., torch.Tensor], *args: Any, **kwargs: Any - ) -> Union[torch.Tensor, Tuple[torch.Tensor], Dict[Any, torch.Tensor]]: + self, + inputs: torch.Tensor, + network: Callable[..., Union[torch.Tensor, Sequence[torch.Tensor], Dict[Any, torch.Tensor]]], + *args: Any, + **kwargs: Any, + ) -> Union[torch.Tensor, Tuple[torch.Tensor, ...], Dict[Any, torch.Tensor]]: """ Args: inputs: 3D input for inference @@ -293,13 +301,28 @@ def __call__( return super().__call__(inputs=inputs, network=lambda x: self.network_wrapper(network, x, *args, **kwargs)) - def network_wrapper(self, network: Callable[..., torch.Tensor], x: torch.Tensor, *args, **kwargs) -> torch.Tensor: + def network_wrapper( + self, + network: Callable[..., Union[torch.Tensor, Sequence[torch.Tensor], Dict[Any, torch.Tensor]]], + x: torch.Tensor, + *args, + **kwargs, + ) -> Union[torch.Tensor, Tuple[torch.Tensor, ...], Dict[Any, torch.Tensor]]: """ Wrapper handles inference for 2D models over 3D volume inputs. """ # Pass 4D input [N, C, H, W]/[N, C, D, W]/[N, C, D, H] to the model as it is 2D. x = x.squeeze(dim=self.spatial_dim + 2) out = network(x, *args, **kwargs) + # Unsqueeze the network output so it is [N, C, D, H, W] as expected by # the default SlidingWindowInferer class - return out.unsqueeze(dim=self.spatial_dim + 2) + if isinstance(out, torch.Tensor): + return out.unsqueeze(dim=self.spatial_dim + 2) + + if isinstance(out, Mapping): + for k in out.keys(): + out[k] = out[k].unsqueeze(dim=self.spatial_dim + 2) + return out + + return tuple(out_i.unsqueeze(dim=self.spatial_dim + 2) for out_i in out) diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index 0f084c1ff5..c4c4bd891c 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -36,7 +36,7 @@ def sliding_window_inference( inputs: torch.Tensor, roi_size: Union[Sequence[int], int], sw_batch_size: int, - predictor: Callable[..., torch.Tensor], + predictor: Callable[..., Union[torch.Tensor, Sequence[torch.Tensor], Dict[Any, torch.Tensor]]], overlap: float = 0.25, mode: Union[BlendMode, str] = BlendMode.CONSTANT, sigma_scale: Union[Sequence[float], float] = 0.125, @@ -48,7 +48,7 @@ def sliding_window_inference( roi_weight_map: Union[torch.Tensor, None] = None, *args: Any, **kwargs: Any, -) -> Union[torch.Tensor, Tuple[torch.Tensor], Dict[Any, torch.Tensor]]: +) -> Union[torch.Tensor, Tuple[torch.Tensor, ...], Dict[Any, torch.Tensor]]: """ Sliding window inference on `inputs` with `predictor`. @@ -176,6 +176,7 @@ def sliding_window_inference( seg_prob_out = predictor(window_data, *args, **kwargs) # batched patch segmentation # convert seg_prob_out to tuple seg_prob_tuple, this does not allocate new memory. + seg_prob_tuple: Tuple[torch.Tensor, ...] if isinstance(seg_prob_out, torch.Tensor): seg_prob_tuple = (seg_prob_out,) elif isinstance(seg_prob_out, Mapping): @@ -270,7 +271,7 @@ def sliding_window_inference( if dict_key is not None: # if output of predictor is a dict final_output = dict(zip(dict_key, output_image_list)) else: - final_output = tuple(output_image_list) + final_output = tuple(output_image_list) # type: ignore return final_output[0] if is_tensor_output else final_output # type: ignore diff --git a/tests/test_detector_utils.py b/tests/test_detector_utils.py new file mode 100644 index 0000000000..b8ae390016 --- /dev/null +++ b/tests/test_detector_utils.py @@ -0,0 +1,94 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.utils.detector_utils import preprocess_images +from monai.utils import ensure_tuple +from tests.utils import assert_allclose + +TEST_CASE_1 = [ # 3D, batch 3, 2 input channel + { + "pretrained": False, + "spatial_dims": 3, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": 7, + "conv1_t_stride": (2, 2, 2), + }, + (3, 2, 32, 64, 48), + (3, 2, 64, 64, 64), +] + +TEST_CASE_2 = [ # 2D, batch 2, 1 input channel + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "conv1_t_size": [7, 7], + "conv1_t_stride": [2, 2], + }, + (2, 1, 32, 64), + (2, 1, 64, 64), +] + +TEST_CASE_2_A = [ # 2D, batch 2, 1 input channel, shortcut type A + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "shortcut_type": "A", + "conv1_t_size": (7, 7), + "conv1_t_stride": 2, + }, + (2, 1, 32, 64), + (2, 1, 64, 64), +] + +TEST_CASE_3 = [ # 1D, batch 1, 2 input channels + { + "pretrained": False, + "spatial_dims": 1, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": [3], + "conv1_t_stride": 1, + }, + (1, 2, 32), + (1, 2, 32), +] + +TEST_CASES = [] +TEST_CASES = [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3] + + +class TestDetectorUtils(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_detector_utils(self, input_param, input_shape, expected_shape): + size_divisible = 32 * ensure_tuple(input_param["conv1_t_stride"])[0] + input_data = torch.randn(input_shape) + result, _ = preprocess_images(input_data, input_param["spatial_dims"], size_divisible, mode="constant", value=1) + assert_allclose(expected_shape, result.shape, type_test=True, device_test=False, atol=0.1) + + input_data = [torch.randn(input_shape[1:]) for _ in range(random.randint(1, 9))] + result, _ = preprocess_images(input_data, input_param["spatial_dims"], size_divisible, mode="edge") + expected_shape = (len(input_data),) + expected_shape[1:] + assert_allclose(expected_shape, result.shape, type_test=True, device_test=False, atol=0.1) + + +if __name__ == "__main__": + unittest.main() From bbc628d9646366713adb499ea2c2f8dfce78ab71 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Wed, 1 Jun 2022 22:49:53 -0400 Subject: [PATCH 123/183] add box selector (#4437) --- docs/source/apps.rst | 5 + monai/apps/detection/utils/box_coder.py | 29 ++- monai/apps/detection/utils/box_selector.py | 216 +++++++++++++++++++++ monai/data/box_utils.py | 55 +++++- tests/test_detector_boxselector.py | 65 +++++++ 5 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 monai/apps/detection/utils/box_selector.py create mode 100644 tests/test_detector_boxselector.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index e688624151..2afe1864e0 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -164,3 +164,8 @@ Applications ~~~~~~~~~~~~~~~~ .. automodule:: monai.apps.detection.utils.detector_utils :members: + +`Detector box selector` +~~~~~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.utils.box_selector + :members: diff --git a/monai/apps/detection/utils/box_coder.py b/monai/apps/detection/utils/box_coder.py index e8b430af67..036e23f242 100644 --- a/monai/apps/detection/utils/box_coder.py +++ b/monai/apps/detection/utils/box_coder.py @@ -56,15 +56,7 @@ import torch from torch import Tensor -from monai.data.box_utils import ( - COMPUTE_DTYPE, - CenterSizeMode, - CornerCornerModeTypeB, - StandardMode, - convert_box_mode, - convert_box_to_standard_mode, - is_valid_box_values, -) +from monai.data.box_utils import COMPUTE_DTYPE, CenterSizeMode, StandardMode, convert_box_mode, is_valid_box_values from monai.utils.module import look_up_option @@ -207,27 +199,27 @@ def decode_single(self, rel_codes: Tensor, reference_boxes: Tensor) -> Tensor: From a set of original boxes and encoded relative box offsets, Args: - rel_codes: encoded boxes, Nx4 or Nx6 torch tensor. + rel_codes: encoded boxes, Nx(4*num_box_reg) or Nx(6*num_box_reg) torch tensor. reference_boxes: reference boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` Return: - decoded boxes, Nx4 or Nx6 torch tensor. The box mode will to be ``StandardMode`` + decoded boxes, Nx(4*num_box_reg) or Nx(6*num_box_reg) torch tensor. The box mode will to be ``StandardMode`` """ reference_boxes = reference_boxes.to(rel_codes.dtype) + offset = reference_boxes.shape[-1] pred_boxes = [] boxes_cccwhd = convert_box_mode(reference_boxes, src_mode=StandardMode, dst_mode=CenterSizeMode) for axis in range(self.spatial_dims): whd_axis = boxes_cccwhd[:, axis + self.spatial_dims] ctr_xyz_axis = boxes_cccwhd[:, axis] - dxyz_axis = rel_codes[:, axis] / self.weights[axis] - dwhd_axis = rel_codes[:, self.spatial_dims + axis] / self.weights[axis + self.spatial_dims] - + dxyz_axis = rel_codes[:, axis::offset] / self.weights[axis] + dwhd_axis = rel_codes[:, self.spatial_dims + axis :: offset] / self.weights[axis + self.spatial_dims] # Prevent sending too large values into torch.exp() dwhd_axis = torch.clamp(dwhd_axis.to(COMPUTE_DTYPE), max=self.boxes_xform_clip) - pred_ctr_xyx_axis = dxyz_axis * whd_axis + ctr_xyz_axis - pred_whd_axis = torch.exp(dwhd_axis) * whd_axis + pred_ctr_xyx_axis = dxyz_axis * whd_axis[:, None] + ctr_xyz_axis[:, None] + pred_whd_axis = torch.exp(dwhd_axis) * whd_axis[:, None] pred_whd_axis = pred_whd_axis.to(dxyz_axis.dtype) # When convert float32 to float16, Inf or Nan may occur @@ -242,5 +234,6 @@ def decode_single(self, rel_codes: Tensor, reference_boxes: Tensor) -> Tensor: pred_boxes.append(pred_ctr_xyx_axis - c_to_c_whd_axis) pred_boxes.append(pred_ctr_xyx_axis + c_to_c_whd_axis) - pred_boxes_xxyyzz = torch.stack(pred_boxes, dim=1) - return convert_box_to_standard_mode(pred_boxes_xxyyzz, mode=CornerCornerModeTypeB) # type: ignore + pred_boxes = pred_boxes[::2] + pred_boxes[1::2] + pred_boxes_final = torch.stack(pred_boxes, dim=2).flatten(1) + return pred_boxes_final diff --git a/monai/apps/detection/utils/box_selector.py b/monai/apps/detection/utils/box_selector.py new file mode 100644 index 0000000000..85a7f5a166 --- /dev/null +++ b/monai/apps/detection/utils/box_selector.py @@ -0,0 +1,216 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE + +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +""" +Part of this script is adapted from +https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py +""" + +from typing import Callable, List, Tuple, Union + +import torch +from torch import Tensor + +from monai.data.box_utils import batched_nms, box_iou, clip_boxes_to_image +from monai.transforms.utils_pytorch_numpy_unification import floor_divide + + +class BoxSelector: + """ + Box selector which selects the predicted boxes. + The box selection is performed with the following steps: + + #. For each level, discard boxes with scores less than self.score_thresh. + #. For each level, keep boxes with top self.topk_candidates_per_level scores. + #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overapping threshold nms_thresh. + #. For the whole image, keep boxes with top self.detections_per_img scores. + + Args: + apply_sigmoid: whether to apply sigmoid to get scores from classification logits + score_thresh: no box with scores less than score_thresh will be kept + topk_candidates_per_level: max number of boxes to keep for each level + nms_thresh: box overlapping threshold for NMS + detections_per_img: max number of boxes to keep for each image + + Example: + + .. code-block:: python + + input_param = { + "apply_sigmoid": True, + "score_thresh": 0.1, + "topk_candidates_per_level": 2, + "nms_thresh": 0.1, + "detections_per_img": 5, + } + box_selector = BoxSelector(**input_param) + boxes = [torch.randn([3,6]), torch.randn([7,6])] + logits = [torch.randn([3,3]), torch.randn([7,3])] + spatial_size = (8,8,8) + selected_boxes, selected_scores, selected_labels = box_selector.select_boxes_per_image( + boxes, logits, spatial_size + ) + """ + + def __init__( + self, + box_overlap_metric: Callable = box_iou, + apply_sigmoid: bool = True, + score_thresh=0.05, + topk_candidates_per_level=1000, + nms_thresh=0.5, + detections_per_img=300, + ): + self.box_overlap_metric = box_overlap_metric + + self.apply_sigmoid = apply_sigmoid + self.score_thresh = score_thresh + self.topk_candidates_per_level = topk_candidates_per_level + self.nms_thresh = nms_thresh + self.detections_per_img = detections_per_img + + def select_top_score_idx_per_level(self, logits: Tensor) -> Tuple[Tensor, Tensor, Tensor]: + """ + Select indice with highest scores. + + The indice selection is performed with the following steps: + + #. If self.apply_sigmoid, get scores by applying sigmoid to logits. Otherwise, use logits as scores. + #. Discard indice with scores less than self.score_thresh + #. Keep indice with top self.topk_candidates_per_level scores + + Args: + logits: predicted classification logits, Tensor sized (N, num_classes) + + Return: + - topk_idxs: selected M indices, Tensor sized (M, ) + - selected_scores: selected M scores, Tensor sized (M, ) + - selected_labels: selected M labels, Tensor sized (M, ) + """ + num_classes = logits.shape[-1] + + # apply sigmoid to classification logits if asked + if self.apply_sigmoid: + scores = torch.sigmoid(logits.to(torch.float32)).flatten() + else: + scores = logits.flatten() + + # remove low scoring boxes + keep_idxs = scores > self.score_thresh + scores = scores[keep_idxs] + flatten_topk_idxs = torch.where(keep_idxs)[0] + + # keep only topk scoring predictions + num_topk = min(self.topk_candidates_per_level, flatten_topk_idxs.size(0)) + selected_scores, idxs = scores.to(torch.float32).topk( + num_topk + ) # half precision not implemented for cpu float16 + flatten_topk_idxs = flatten_topk_idxs[idxs] + + selected_labels = flatten_topk_idxs % num_classes + + topk_idxs = floor_divide(flatten_topk_idxs, num_classes) + return topk_idxs, selected_scores, selected_labels # type: ignore + + def select_boxes_per_image( + self, boxes_list: List[Tensor], logits_list: List[Tensor], spatial_size: Union[List[int], Tuple[int]] + ) -> Tuple[Tensor, Tensor, Tensor]: + """ + Postprocessing to generate detection result from classification logits and boxes. + + The box selection is performed with the following steps: + + #. For each level, discard boxes with scores less than self.score_thresh. + #. For each level, keep boxes with top self.topk_candidates_per_level scores. + #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overapping threshold nms_thresh. + #. For the whole image, keep boxes with top self.detections_per_img scores. + + Args: + boxes_list: list of predicted boxes from a single image, + each element i is a Tensor sized (N_i, 2*spatial_dims) + logits_list: list of predicted classification logits from a single image, + each element i is a Tensor sized (N_i, num_classes) + spatial_size: spatial size of the image + + Return: + - selected boxes, Tensor sized (P, 2*spatial_dims) + - selected_scores, Tensor sized (P, ) + - selected_labels, Tensor sized (P, ) + """ + + if len(boxes_list) != len(logits_list): + raise ValueError( + "len(boxes_list) should equal to len(logits_list). " + f"Got len(boxes_list)={len(boxes_list)}, len(logits_list)={len(logits_list)}" + ) + + image_boxes = [] + image_scores = [] + image_labels = [] + + boxes_dtype = boxes_list[0].dtype + logits_dtype = logits_list[0].dtype + + for boxes_per_level, logits_per_level in zip(boxes_list, logits_list): + # select topk boxes for each level + topk_idxs: Tensor + topk_idxs, scores_per_level, labels_per_level = self.select_top_score_idx_per_level(logits_per_level) + boxes_per_level = boxes_per_level[topk_idxs] + + keep: Tensor + boxes_per_level, keep = clip_boxes_to_image(boxes_per_level, spatial_size, remove_empty=True) # type: ignore + image_boxes.append(boxes_per_level) + image_scores.append(scores_per_level[keep]) + image_labels.append(labels_per_level[keep]) + + image_boxes_t: Tensor = torch.cat(image_boxes, dim=0) + image_scores_t: Tensor = torch.cat(image_scores, dim=0) + image_labels_t: Tensor = torch.cat(image_labels, dim=0) + + # non-maximum suppression on detected boxes from all levels + keep_t: Tensor = batched_nms( # type: ignore + image_boxes_t, + image_scores_t, + image_labels_t, + self.nms_thresh, + box_overlap_metric=self.box_overlap_metric, + max_proposals=self.detections_per_img, + ) + + selected_boxes = image_boxes_t[keep_t].to(boxes_dtype) + selected_scores = image_scores_t[keep_t].to(logits_dtype) + selected_labels = image_labels_t[keep_t] + + return selected_boxes, selected_scores, selected_labels diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 6bfd89080a..4f728adf6e 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -1023,8 +1023,7 @@ def non_max_suppression( Args: boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` scores: prediction scores of the boxes, sized (N,). This function keeps boxes with higher scores. - nms_thresh: threshold of NMS. For boxes with overlap more than nms_thresh, - we only keep the one with the highest score. + nms_thresh: threshold of NMS. Discards all overlapping boxes with box_overlap > nms_thresh. max_proposals: maximum number of boxes it keeps. If ``max_proposals`` = -1, there is no limit on the number of boxes that are kept. box_overlap_metric: the metric to compute overlap between boxes. @@ -1046,7 +1045,7 @@ def non_max_suppression( f"boxes and scores should have same length, got boxes shape {boxes.shape}, scores shape {scores.shape}" ) - # convert tensor to numpy if needed + # convert numpy to tensor if needed boxes_t, *_ = convert_data_type(boxes, torch.Tensor) scores_t, *_ = convert_to_dst_type(scores, boxes_t) @@ -1077,5 +1076,53 @@ def non_max_suppression( # return only the bounding boxes that were picked using the integer data type pick_idx = sort_idxs[pick] - # convert numpy back to tensor if needed + # convert tensor back to numpy if needed return convert_to_dst_type(src=pick_idx, dst=boxes, dtype=pick_idx.dtype)[0] + + +def batched_nms( + boxes: NdarrayOrTensor, + scores: NdarrayOrTensor, + labels: NdarrayOrTensor, + nms_thresh: float, + max_proposals: int = -1, + box_overlap_metric: Callable = box_iou, +) -> NdarrayOrTensor: + """ + Performs non-maximum suppression in a batched fashion. + Each labels value correspond to a category, and NMS will not be applied between elements of different categories. + + Adapted from https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/core/boxes/nms.py + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + scores: prediction scores of the boxes, sized (N,). This function keeps boxes with higher scores. + labels: indices of the categories for each one of the boxes. sized(N,), value range is (0, num_classes) + nms_thresh: threshold of NMS. Discards all overlapping boxes with box_overlap > nms_thresh. + max_proposals: maximum number of boxes it keeps. + If ``max_proposals`` = -1, there is no limit on the number of boxes that are kept. + box_overlap_metric: the metric to compute overlap between boxes. + + Returns: + Indexes of ``boxes`` that are kept after NMS. + """ + # returns empty array if boxes is empty + if boxes.shape[0] == 0: + return convert_to_dst_type(src=np.array([]), dst=boxes, dtype=torch.long)[0] + + # convert numpy to tensor if needed + boxes_t, *_ = convert_data_type(boxes, torch.Tensor, dtype=torch.float32) + scores_t, *_ = convert_to_dst_type(scores, boxes_t) + labels_t, *_ = convert_to_dst_type(labels, boxes_t, dtype=torch.long) + + # strategy: in order to perform NMS independently per class. + # we add an offset to all the boxes. The offset is dependent + # only on the class idx, and is large enough so that boxes + # from different classes do not overlap + max_coordinate = boxes_t.max() + offsets = labels_t.to(boxes_t) * (max_coordinate + 1) + boxes_for_nms = boxes + offsets[:, None] + keep = non_max_suppression(boxes_for_nms, scores_t, nms_thresh, max_proposals, box_overlap_metric) + + # convert tensor back to numpy if needed + return convert_to_dst_type(src=keep, dst=boxes, dtype=keep.dtype)[0] diff --git a/tests/test_detector_boxselector.py b/tests/test_detector_boxselector.py new file mode 100644 index 0000000000..6e22a7833a --- /dev/null +++ b/tests/test_detector_boxselector.py @@ -0,0 +1,65 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.utils.box_selector import BoxSelector +from tests.utils import assert_allclose + +device = "cuda" if torch.cuda.is_available() else "cpu" +num_anchors = 7 + +TEST_CASE = [] +TEST_CASE.append( + [ # 2D + { + "apply_sigmoid": False, + "score_thresh": 0.1, + "topk_candidates_per_level": 2, + "nms_thresh": 0.1, + "detections_per_img": 5, + }, + [torch.tensor([[1, 2, 3, 2, 3, 4], [5, 6, 7, 8, 9, 10]]).to(torch.float16)], + [torch.tensor([[0.1, 0.6], [0.2, 0.2]])], + (20, 20, 20), + torch.tensor([[1, 2, 3, 2, 3, 4], [5, 6, 7, 8, 9, 10]]), + ] +) +TEST_CASE.append( + [ + { + "apply_sigmoid": False, + "score_thresh": 0.1, + "topk_candidates_per_level": 1, + "nms_thresh": 0.1, + "detections_per_img": 5, + }, + [torch.tensor([[1, 2, 3, 2, 3, 4]]).to(torch.float32), torch.tensor([[5, 6, 7, 8, 9, 10]]).to(torch.float32)], + [torch.tensor([[0.3, 0.6]]), torch.tensor([[0.2, 0.2]])], + (20, 20, 8), + torch.tensor([[1, 2, 3, 2, 3, 4], [5, 6, 7, 8, 9, 8]]), + ] +) + + +class TestBoxSelector(unittest.TestCase): + @parameterized.expand(TEST_CASE) + def test_box_selector(self, input_param, boxes, logits, image_shape, expected_results): + box_selector = BoxSelector(**input_param) + result = box_selector.select_boxes_per_image(boxes, logits, image_shape) + assert_allclose(result[0], expected_results, type_test=True, device_test=False, atol=1e-3) + + +if __name__ == "__main__": + unittest.main() From 187fec8ab82ef13f40054f4d1d172923abd36783 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 2 Jun 2022 09:31:23 +0100 Subject: [PATCH 124/183] test update checkout (#4440) --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/conda.yml | 2 +- .github/workflows/cron-mmar.yml | 6 +++--- .github/workflows/cron.yml | 8 ++++---- .github/workflows/docker.yml | 6 +++--- .github/workflows/integration.yml | 4 ++-- .github/workflows/pythonapp-gpu.yml | 2 +- .github/workflows/pythonapp-min.yml | 18 +++++++++--------- .github/workflows/pythonapp.yml | 24 ++++++++++++------------ .github/workflows/release.yml | 10 +++++----- .github/workflows/setupapp.yml | 16 ++++++++-------- .github/workflows/weekly-preview.yml | 4 ++-- 12 files changed, 51 insertions(+), 51 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3f8bfb345d..1596db26b2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/conda.yml b/.github/workflows/conda.yml index 73098cd63d..98c194f474 100644 --- a/.github/workflows/conda.yml +++ b/.github/workflows/conda.yml @@ -30,7 +30,7 @@ jobs: minimum-size: 8 maximum-size: 16 disk-root: "D:" - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: conda-incubator/setup-miniconda@v2 with: auto-update-conda: true diff --git a/.github/workflows/cron-mmar.yml b/.github/workflows/cron-mmar.yml index 5fc3ad2d22..46bf7ff384 100644 --- a/.github/workflows/cron-mmar.yml +++ b/.github/workflows/cron-mmar.yml @@ -16,16 +16,16 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: cache weekly timestamp id: pip-cache run: echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ~/.cache/pip diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 7a57c5e662..e487fda266 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -17,7 +17,7 @@ jobs: matrix: pytorch-version: [1.7.1, 1.8.1, 1.9.1, 1.10.2, latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install the dependencies run: | which python @@ -68,7 +68,7 @@ jobs: options: "--gpus all" runs-on: [self-hosted, linux, x64, common] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install APT dependencies run: | apt-get update @@ -112,7 +112,7 @@ jobs: options: "--gpus all" runs-on: [self-hosted, linux, x64, common] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Install the dependencies @@ -208,7 +208,7 @@ jobs: options: "--gpus all --ipc=host" runs-on: [self-hosted, linux, x64, common] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install MONAI id: monai-install run: | diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7140cd7dd8..4ca1b18261 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,13 +19,13 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 # full history so that we can git describe with: ref: dev fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - shell: bash @@ -50,7 +50,7 @@ jobs: needs: versioning_dev runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: ref: dev - name: Download version diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 767b58a792..231e4656e1 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -12,7 +12,7 @@ jobs: runs-on: [self-hosted, linux, x64, common] steps: # checkout the pull request branch - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: token: ${{ secrets.PR_MAINTAIN }} repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} @@ -22,7 +22,7 @@ jobs: run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: | diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 2fdfa5a80f..f70996874d 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -60,7 +60,7 @@ jobs: options: --gpus all runs-on: [self-hosted, linux, x64, common] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: apt install run: | # workaround for https://github.com/Project-MONAI/MONAI/issues/4200 diff --git a/.github/workflows/pythonapp-min.yml b/.github/workflows/pythonapp-min.yml index e50f74d816..ef1291420a 100644 --- a/.github/workflows/pythonapp-min.yml +++ b/.github/workflows/pythonapp-min.yml @@ -27,9 +27,9 @@ jobs: os: [windows-latest, macOS-latest, ubuntu-latest] timeout-minutes: 40 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: Prepare pip wheel @@ -43,7 +43,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" shell: bash - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ${{ steps.pip-cache.outputs.dir }} @@ -77,9 +77,9 @@ jobs: python-version: [3.7, 3.8, 3.9] timeout-minutes: 40 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Prepare pip wheel @@ -93,7 +93,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" shell: bash - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ${{ steps.pip-cache.outputs.dir }} @@ -122,9 +122,9 @@ jobs: pytorch-version: [1.7.1, 1.8.1, 1.9.1, 1.10.2, latest] timeout-minutes: 40 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: Prepare pip wheel @@ -138,7 +138,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" shell: bash - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ${{ steps.pip-cache.outputs.dir }} diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 4226ea7d99..d07e6d4494 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -22,9 +22,9 @@ jobs: flake8-py3: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: cache weekly timestamp @@ -32,7 +32,7 @@ jobs: run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ~/.cache/pip @@ -63,9 +63,9 @@ jobs: minimum-size: 8 maximum-size: 16 disk-root: "D:" - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: Prepare pip wheel @@ -79,7 +79,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" shell: bash - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ${{ steps.pip-cache.outputs.dir }} @@ -113,11 +113,11 @@ jobs: QUICKTEST: True shell: bash steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: cache weekly timestamp @@ -125,7 +125,7 @@ jobs: run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: | @@ -196,9 +196,9 @@ jobs: build-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: cache weekly timestamp @@ -206,7 +206,7 @@ jobs: run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cef1ff090..69f68d2119 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,11 +15,11 @@ jobs: matrix: python-version: [3.7, 3.8, 3.9] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Install setuptools @@ -91,12 +91,12 @@ jobs: needs: packaging runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 # full history so that we can git describe with: fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - shell: bash @@ -120,7 +120,7 @@ jobs: needs: versioning runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Download version uses: actions/download-artifact@v2 with: diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 02911ea51f..cad1d44917 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -25,13 +25,13 @@ jobs: options: --gpus all runs-on: [self-hosted, linux, x64, common] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: cache weekly timestamp id: pip-cache run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: | @@ -76,11 +76,11 @@ jobs: matrix: python-version: [3.7, 3.8, 3.9] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: cache weekly timestamp @@ -88,7 +88,7 @@ jobs: run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: | @@ -116,7 +116,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: cache weekly timestamp @@ -124,7 +124,7 @@ jobs: run: | echo "::set-output name=datew::$(date '+%Y-%V')" - name: cache for pip - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: | @@ -146,7 +146,7 @@ jobs: python -c 'import monai; monai.config.print_config()' - name: Get the test cases (dev branch only) if: github.ref == 'refs/heads/dev' - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: dev - name: Quick test installed (dev branch only) diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index 0d899b1f30..6a53c03c28 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -9,12 +9,12 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: ref: dev fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: Install setuptools From cfd156262afdcc5154455a15b2e425ff2ace7caf Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 2 Jun 2022 18:20:54 +0800 Subject: [PATCH 125/183] Enhance ensure_tuple for corner cases (#4439) --- monai/utils/misc.py | 24 ++++++++++++------ tests/test_ensure_tuple.py | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 tests/test_ensure_tuple.py diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 521968a87d..99f645a704 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import collections.abc import inspect import itertools import os @@ -19,6 +18,7 @@ import types import warnings from ast import literal_eval +from collections.abc import Iterable from distutils.util import strtobool from pathlib import Path from typing import Any, Callable, Optional, Sequence, Tuple, Union, cast @@ -88,19 +88,27 @@ def issequenceiterable(obj: Any) -> bool: """ Determine if the object is an iterable sequence and is not a string. """ - if isinstance(obj, torch.Tensor): - return int(obj.dim()) > 0 # a 0-d tensor is not iterable - return isinstance(obj, collections.abc.Iterable) and not isinstance(obj, (str, bytes)) + try: + if hasattr(obj, "ndim") and obj.ndim == 0: + return False # a 0-d tensor is not iterable + except Exception: + return False + return isinstance(obj, Iterable) and not isinstance(obj, (str, bytes)) -def ensure_tuple(vals: Any) -> Tuple[Any, ...]: +def ensure_tuple(vals: Any, wrap_array: bool = False) -> Tuple[Any, ...]: """ Returns a tuple of `vals`. + + Args: + vals: input data to convert to a tuple. + wrap_array: if `True`, treat the input numerical array (ndarray/tensor) as one item of the tuple. + if `False`, try to convert the array with `tuple(vals)`, default to `False`. + """ - if not issequenceiterable(vals): + if wrap_array and isinstance(vals, (np.ndarray, torch.Tensor)): return (vals,) - - return tuple(vals) + return tuple(vals) if issequenceiterable(vals) else (vals,) def ensure_tuple_size(tup: Any, dim: int, pad_val: Any = 0) -> Tuple[Any, ...]: diff --git a/tests/test_ensure_tuple.py b/tests/test_ensure_tuple.py new file mode 100644 index 0000000000..ea580871da --- /dev/null +++ b/tests/test_ensure_tuple.py @@ -0,0 +1,52 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.utils.misc import ensure_tuple +from tests.utils import assert_allclose + +TESTS = [ + ["test", ("test",)], + [["test1", "test2"], ("test1", "test2")], + [123, (123,)], + [(1, [2], 3), (1, [2], 3)], + [(1, 2, 3), (1, 2, 3), True], + [np.array([1, 2]), (np.array([1, 2]),), True], + [np.array([1, 2]), (1, 2), False], + [torch.tensor([1, 2]), (torch.tensor([1, 2]),), True], + [np.array([]), (np.array([]),)], + [torch.tensor([]), (torch.tensor([]),)], + [np.array(123), (np.array(123),), True], + [torch.tensor(123), (torch.tensor(123),)], +] + + +class TestEnsureTuple(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, input, expected_value, wrap_array=False): + result = ensure_tuple(input, wrap_array) + + self.assertTrue(isinstance(result, tuple)) + if isinstance(input, (np.ndarray, torch.Tensor)): + for i, j in zip(result, expected_value): + assert_allclose(i, j) + else: + self.assertTupleEqual(result, expected_value) + + +if __name__ == "__main__": + + unittest.main() From bbb35e2381fa9eaaf64b2a0721be6f0b0a83c306 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 2 Jun 2022 11:11:30 -0400 Subject: [PATCH 126/183] add random foreground crop for boxes (#4388) --- monai/apps/detection/transforms/array.py | 92 +++++-- monai/apps/detection/transforms/box_ops.py | 29 +++ monai/apps/detection/transforms/dictionary.py | 237 +++++++++++++++++- monai/apps/detection/utils/box_selector.py | 4 +- monai/data/box_utils.py | 2 + tests/test_box_transform.py | 23 ++ 6 files changed, 366 insertions(+), 21 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 42aeda71cf..2f1cdbdbe5 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -13,7 +13,6 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ -from copy import deepcopy from typing import Optional, Sequence, Tuple, Type, Union import numpy as np @@ -26,11 +25,12 @@ convert_box_mode, convert_box_to_standard_mode, get_spatial_dims, + spatial_crop_boxes, ) +from monai.transforms import SpatialCrop from monai.transforms.transform import Transform from monai.utils import ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option from monai.utils.enums import TransformBackends -from monai.utils.type_conversion import convert_data_type, convert_to_dst_type from .box_ops import ( apply_affine_to_boxes, @@ -38,6 +38,7 @@ convert_mask_to_box, flip_boxes, resize_boxes, + select_labels, zoom_boxes, ) @@ -51,6 +52,7 @@ "ClipBoxToImage", "BoxToMask", "MaskToBox", + "SpatialCropBox", ] @@ -343,7 +345,7 @@ def __call__( # type: ignore boxes: NdarrayOrTensor, labels: Union[Sequence[NdarrayOrTensor], NdarrayOrTensor], spatial_size: Union[Sequence[int], int], - ) -> Tuple[NdarrayOrTensor, Tuple]: + ) -> Tuple[NdarrayOrTensor, Union[Tuple, NdarrayOrTensor]]: """ Args: boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` @@ -370,18 +372,7 @@ def __call__( # type: ignore spatial_size = ensure_tuple_rep(spatial_size, spatial_dims) # match the spatial image dim boxes_clip, keep = clip_boxes_to_image(boxes, spatial_size, self.remove_empty) - - labels_tuple = ensure_tuple(labels) - labels_clip_list = [] - - keep_t: torch.Tensor = convert_data_type(keep, torch.Tensor)[0] - for i in range(len(labels_tuple)): - labels_t: torch.Tensor = convert_data_type(labels_tuple[i], torch.Tensor)[0] - if boxes.shape[0] != labels_t.shape[0]: - raise ValueError("boxes.shape[0] should be equal to the number of labels or scores.") - labels_t = deepcopy(labels_t[keep_t, ...]) - labels_clip_list.append(convert_to_dst_type(src=labels_t, dst=labels_tuple[i])[0]) - return boxes_clip, tuple(labels_clip_list) + return boxes_clip, select_labels(labels, keep) class BoxToMask(Transform): @@ -452,3 +443,74 @@ def __call__(self, boxes_mask: NdarrayOrTensor) -> Tuple[NdarrayOrTensor, Ndarra - classification foreground(fg) labels, dtype should be int, sized (N,). """ return convert_mask_to_box(boxes_mask, self.bg_label, self.box_dtype, self.label_dtype) + + +class SpatialCropBox(SpatialCrop): + """ + General purpose box cropper when the corresponding image is cropped by SpatialCrop(*) with the same ROI. + The difference is that we do not support negative indexing for roi_slices. + + If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. + So the cropped result may be smaller than the expected ROI, and the cropped results of several images may + not have exactly the same shape. + It can support to crop ND spatial boxes. + + The cropped region can be parameterised in various ways: + - a list of slices for each spatial dimension (do not allow for use of negative indexing) + - a spatial center and size + - the start and end coordinates of the ROI + + Args: + roi_center: voxel coordinates for center of the crop ROI. + roi_size: size of the crop ROI, if a dimension of ROI size is bigger than image size, + will not crop that dimension of the image. + roi_start: voxel coordinates for start of the crop ROI. + roi_end: voxel coordinates for end of the crop ROI, if a coordinate is out of image, + use the end coordinate of image. + roi_slices: list of slices for each of the spatial dimensions. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + roi_center: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_size: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_start: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_end: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_slices: Optional[Sequence[slice]] = None, + ) -> None: + super().__init__(roi_center, roi_size, roi_start, roi_end, roi_slices) + for s in self.slices: + if s.start < 0 or s.stop < 0 or (s.step is not None and s.step < 0): + raise ValueError("Currently negative indexing is not supported for SpatialCropBox.") + + def __call__( # type: ignore + self, boxes: NdarrayOrTensor, labels: Union[Sequence[NdarrayOrTensor], NdarrayOrTensor] + ) -> Tuple[NdarrayOrTensor, Union[Tuple, NdarrayOrTensor]]: + """ + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + labels: Sequence of array. Each element represents classification labels or scores + + Returns: + - cropped boxes, does not share memory with original boxes + - cropped labels, does not share memory with original labels + + Example: + .. code-block:: python + + box_cropper = SpatialCropPadBox(roi_start=[0, 1, 4], roi_end=[21, 15, 8]) + boxes = torch.ones(2, 6) + class_labels = torch.Tensor([0, 1]) + pred_scores = torch.Tensor([[0.4,0.3,0.3], [0.5,0.1,0.4]]) + labels = (class_labels, pred_scores) + boxes_crop, labels_crop_tuple = box_cropper(boxes, labels) + """ + spatial_dims = min(len(self.slices), get_spatial_dims(boxes=boxes)) # spatial dims + boxes_crop, keep = spatial_crop_boxes( + boxes, + [self.slices[axis].start for axis in range(spatial_dims)], + [self.slices[axis].stop for axis in range(spatial_dims)], + ) + return boxes_crop, select_labels(labels, keep) diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index 6b9c2ac87b..65d95d8220 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -318,3 +318,32 @@ def convert_mask_to_box( boxes, *_ = convert_to_dst_type(src=boxes_np, dst=boxes_mask, dtype=box_dtype) labels, *_ = convert_to_dst_type(src=labels_np, dst=boxes_mask, dtype=label_dtype) return boxes, labels + + +def select_labels( + labels: Union[Sequence[NdarrayOrTensor], NdarrayOrTensor], keep: NdarrayOrTensor +) -> Union[Tuple, NdarrayOrTensor]: + """ + For element in labels, select indice keep from it. + + Args: + labels: Sequence of array. Each element represents classification labels or scores + corresponding to ``boxes``, sized (N,). + keep: the indices to keep, same length with each element in labels. + + Return: + selected labels, does not share memory with original labels. + """ + labels_tuple = ensure_tuple(labels, True) # type: ignore + + labels_select_list = [] + keep_t: torch.Tensor = convert_data_type(keep, torch.Tensor)[0] + for i in range(len(labels_tuple)): + labels_t: torch.Tensor = convert_data_type(labels_tuple[i], torch.Tensor)[0] + labels_t = labels_t[keep_t, ...] + labels_select_list.append(convert_to_dst_type(src=labels_t, dst=labels_tuple[i])[0]) + + if isinstance(labels, (torch.Tensor, np.ndarray)): + return labels_select_list[0] # type: ignore + + return tuple(labels_select_list) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index 96c918eda1..b1591c097c 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -14,10 +14,9 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ - from copy import deepcopy from enum import Enum -from typing import Dict, Hashable, List, Mapping, Optional, Sequence, Type, Union +from typing import Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Type, Union import numpy as np import torch @@ -30,15 +29,19 @@ ConvertBoxToStandardMode, FlipBox, MaskToBox, + SpatialCropBox, ZoomBox, ) +from monai.apps.detection.transforms.box_ops import convert_box_to_mask from monai.config import KeysCollection from monai.config.type_definitions import NdarrayOrTensor -from monai.data.box_utils import COMPUTE_DTYPE, BoxMode +from monai.data.box_utils import COMPUTE_DTYPE, BoxMode, clip_boxes_to_image from monai.data.utils import orientation_ras_lps -from monai.transforms import Flip, RandFlip, RandZoom, SpatialPad, Zoom +from monai.transforms import Flip, RandFlip, RandZoom, SpatialCrop, SpatialPad, Zoom from monai.transforms.inverse import InvertibleTransform -from monai.transforms.transform import MapTransform, RandomizableTransform +from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform +from monai.transforms.utils import generate_pos_neg_label_crop_centers, map_binary_to_indices +from monai.utils import ImageMetaKey as Key from monai.utils import InterpolateMode, NumpyPadMode, PytorchPadMode, ensure_tuple, ensure_tuple_rep from monai.utils.enums import PostFix, TraceKeys from monai.utils.type_conversion import convert_data_type @@ -74,6 +77,9 @@ "MaskToBoxd", "MaskToBoxD", "MaskToBoxDict", + "RandCropBoxByPosNegLabeld", + "RandCropBoxByPosNegLabelD", + "RandCropBoxByPosNegLabelDict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -935,6 +941,226 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +class RandCropBoxByPosNegLabeld(Randomizable, MapTransform): + """ + Crop random fixed sized regions that contains foreground boxes. + Suppose all the expected fields specified by `image_keys` have same shape, + and add `patch_index` to the corresponding meta data. + And will return a list of dictionaries for all the cropped images. + If a dimension of the expected spatial size is bigger than the input image size, + will not crop that dimension. So the cropped result may be smaller than the expected size, + and the cropped results of several images may not have exactly the same shape. + + Args: + image_keys: Keys to pick image data for transformation. They need to have the same spatial size. + box_keys: The single key to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + label_keys: Keys that represents the labels corresponding to the ``box_keys``. Multiple keys are allowed. + spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. + if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if its components have non-positive values, the corresponding size of `data[label_key]` will be used. + for example: if the spatial size of input data is [40, 40, 40] and `spatial_size=[32, 64, -1]`, + the spatial size of output data will be [32, 40, 40]. + pos: used with `neg` together to calculate the ratio ``pos / (pos + neg)`` for the probability + to pick a foreground voxel as a center rather than a background voxel. + neg: used with `pos` together to calculate the ratio ``pos / (pos + neg)`` for the probability + to pick a foreground voxel as a center rather than a background voxel. + num_samples: number of samples (crop regions) to take in each list. + whole_box: Bool, default True, whether we prefer to contain at least one whole box in the cropped foreground patch. + Even if True, it is still possible to get partial box if there are multiple boxes in the image. + thresh_image_key: if thresh_image_key is not None, use ``label == 0 & thresh_image > image_threshold`` to select + the negative sample(background) center. so the crop center will only exist on valid image area. + image_threshold: if enabled thresh_image_key, use ``thresh_image > image_threshold`` to determine + the valid image content area. + fg_indices_key: if provided pre-computed foreground indices of `label`, will ignore above `image_key` and + `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` + and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. + a typical usage is to call `FgBgToIndicesd` transform first and cache the results. + bg_indices_key: if provided pre-computed background indices of `label`, will ignore above `image_key` and + `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` + and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. + a typical usage is to call `FgBgToIndicesd` transform first and cache the results. + meta_keys: explicitly indicate the key of the corresponding metadata dictionary. + used to add `patch_index` to the meta dict. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the metadata is a dictionary object which contains: filename, original_shape, etc. + it can be a sequence of string, map to the `keys`. + if None, will try to construct meta_keys by `key_{meta_key_postfix}`. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. + used to add `patch_index` to the meta dict. + allow_smaller: if `False`, an exception will be raised if the image is smaller than + the requested ROI in any dimension. If `True`, any smaller dimensions will be set to + match the cropped size (i.e., no cropping in that dimension). + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__( + self, + image_keys: KeysCollection, + box_keys: str, + label_keys: KeysCollection, + spatial_size: Union[Sequence[int], int], + pos: float = 1.0, + neg: float = 1.0, + num_samples: int = 1, + whole_box: bool = True, + thresh_image_key: Optional[str] = None, + image_threshold: float = 0.0, + fg_indices_key: Optional[str] = None, + bg_indices_key: Optional[str] = None, + meta_keys: Optional[KeysCollection] = None, + meta_key_postfix: str = DEFAULT_POST_FIX, + allow_smaller: bool = False, + allow_missing_keys: bool = False, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + if len(self.image_keys) < 1: + raise ValueError("At least one image_keys should be provided.") + + MapTransform.__init__(self, self.image_keys, allow_missing_keys) + + box_keys_tuple = ensure_tuple(box_keys) + if len(box_keys_tuple) != 1: + raise ValueError( + "Please provide a single key for box_keys.\ + All label_keys are attached to this box_keys." + ) + self.box_keys = box_keys_tuple[0] + self.label_keys = ensure_tuple(label_keys) + + self.spatial_size_: Union[Tuple[int, ...], Sequence[int], int] = spatial_size + + if pos < 0 or neg < 0: + raise ValueError(f"pos and neg must be nonnegative, got pos={pos} neg={neg}.") + if pos + neg == 0: + raise ValueError("Incompatible values: pos=0 and neg=0.") + self.pos_ratio = pos / (pos + neg) + if num_samples < 1: + raise ValueError(f"num_samples needs to be positive int, got num_samples={num_samples}.") + self.num_samples = num_samples + self.whole_box = whole_box + + self.thresh_image_key = thresh_image_key + self.image_threshold = image_threshold + self.fg_indices_key = fg_indices_key + self.bg_indices_key = bg_indices_key + + self.meta_keys = ensure_tuple_rep(None, len(self.image_keys)) if meta_keys is None else ensure_tuple(meta_keys) + if len(self.image_keys) != len(self.meta_keys): + raise ValueError("meta_keys should have the same length as keys.") + self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.image_keys)) + self.centers: Optional[List[List[int]]] = None + self.allow_smaller = allow_smaller + + def generate_fg_center_boxes_np(self, boxes: NdarrayOrTensor, image_size: Sequence[int]) -> np.ndarray: + # We don't require crop center to be whthin the boxes. + # As along as the cropped patch contains a box, it is considered as a foreground patch. + # Positions within extended_boxes are crop centers for foreground patches + spatial_dims = len(image_size) + boxes_np, *_ = convert_data_type(boxes, np.ndarray) + + extended_boxes = np.zeros_like(boxes_np, dtype=int) + boxes_start = np.ceil(boxes_np[:, :spatial_dims]).astype(int) + boxes_stop = np.floor(boxes_np[:, spatial_dims:]).astype(int) + for axis in range(spatial_dims): + if not self.whole_box: + extended_boxes[:, axis] = boxes_start[:, axis] - self.spatial_size[axis] // 2 + 1 + extended_boxes[:, axis + spatial_dims] = boxes_stop[:, axis] + self.spatial_size[axis] // 2 - 1 + else: + # extended box start + extended_boxes[:, axis] = boxes_stop[:, axis] - self.spatial_size[axis] // 2 - 1 + extended_boxes[:, axis] = np.minimum(extended_boxes[:, axis], boxes_start[:, axis]) + # extended box stop + extended_boxes[:, axis + spatial_dims] = extended_boxes[:, axis] + self.spatial_size[axis] // 2 + extended_boxes[:, axis + spatial_dims] = np.maximum( + extended_boxes[:, axis + spatial_dims], boxes_stop[:, axis] + ) + extended_boxes, _ = clip_boxes_to_image(extended_boxes, image_size, remove_empty=True) # type: ignore + return extended_boxes + + def randomize( # type: ignore + self, + boxes: NdarrayOrTensor, + image_size: Sequence[int], + fg_indices: Optional[NdarrayOrTensor] = None, + bg_indices: Optional[NdarrayOrTensor] = None, + thresh_image: Optional[NdarrayOrTensor] = None, + ) -> None: + if fg_indices is None or bg_indices is None: + # We don't require crop center to be whthin the boxes. + # As along as the cropped patch contains a box, it is considered as a foreground patch. + # Positions within extended_boxes are crop centers for foreground patches + extended_boxes_np = self.generate_fg_center_boxes_np(boxes, image_size) + mask_img = convert_box_to_mask( + extended_boxes_np, np.ones(extended_boxes_np.shape[0]), image_size, bg_label=0, ellipse_mask=False + ) + mask_img = np.amax(mask_img, axis=0, keepdims=True)[0:1, ...] + fg_indices_, bg_indices_ = map_binary_to_indices(mask_img, thresh_image, self.image_threshold) + else: + fg_indices_ = fg_indices + bg_indices_ = bg_indices + + self.centers = generate_pos_neg_label_crop_centers( + self.spatial_size, + self.num_samples, + self.pos_ratio, + image_size, + fg_indices_, + bg_indices_, + self.R, + self.allow_smaller, + ) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashable, NdarrayOrTensor]]: + d = dict(data) + spatial_dims = len(d[self.image_keys[0]].shape) - 1 + image_size = d[self.image_keys[0]].shape[1:] + self.spatial_size = ensure_tuple_rep(self.spatial_size_, spatial_dims) + + # randomly sample crop centers + boxes = d[self.box_keys] + labels = [d[label_key] for label_key in self.label_keys] # could be multiple arrays + fg_indices = d.pop(self.fg_indices_key, None) if self.fg_indices_key is not None else None + bg_indices = d.pop(self.bg_indices_key, None) if self.bg_indices_key is not None else None + thresh_image = d[self.thresh_image_key] if self.thresh_image_key else None + self.randomize(boxes, image_size, fg_indices, bg_indices, thresh_image) + + if self.centers is None: + raise ValueError("no available ROI centers to crop.") + + # initialize returned list with shallow copy to preserve key ordering + results: List[Dict[Hashable, NdarrayOrTensor]] = [dict(d) for _ in range(self.num_samples)] + + # crop images and boxes for each center. + for i, center in enumerate(self.centers): + results[i] = deepcopy(d) + # compute crop start and end, always crop, no padding + cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) + crop_start = [max(s.start, 0) for s in cropper.slices] + crop_end = [min(s.stop, image_size_a) for s, image_size_a in zip(cropper.slices, image_size)] + crop_slices = [slice(int(s), int(e)) for s, e in zip(crop_start, crop_end)] + + # crop images + cropper = SpatialCrop(roi_slices=crop_slices) + for image_key in self.image_keys: + results[i][image_key] = cropper(d[image_key]) + + # crop boxes and labels + boxcropper = SpatialCropBox(roi_slices=crop_slices) + results[i][self.box_keys], cropped_labels = boxcropper(boxes, labels) + for label_key, cropped_labels_i in zip(self.label_keys, cropped_labels): + results[i][label_key] = cropped_labels_i + + # add `patch_index` to the meta data + for key, meta_key, meta_key_postfix in zip(self.image_keys, self.meta_keys, self.meta_key_postfix): + meta_key = meta_key or f"{key}_{meta_key_postfix}" + if meta_key not in results[i]: + results[i][meta_key] = {} # type: ignore + results[i][meta_key][Key.PATCH_INDEX] = i # type: ignore + + return results + + ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed ZoomBoxD = ZoomBoxDict = ZoomBoxd @@ -945,3 +1171,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N ClipBoxToImageD = ClipBoxToImageDict = ClipBoxToImaged BoxToMaskD = BoxToMaskDict = BoxToMaskd MaskToBoxD = MaskToBoxDict = MaskToBoxd +RandCropBoxByPosNegLabelD = RandCropBoxByPosNegLabelDict = RandCropBoxByPosNegLabeld diff --git a/monai/apps/detection/utils/box_selector.py b/monai/apps/detection/utils/box_selector.py index 85a7f5a166..72ada2739f 100644 --- a/monai/apps/detection/utils/box_selector.py +++ b/monai/apps/detection/utils/box_selector.py @@ -190,7 +190,9 @@ def select_boxes_per_image( boxes_per_level = boxes_per_level[topk_idxs] keep: Tensor - boxes_per_level, keep = clip_boxes_to_image(boxes_per_level, spatial_size, remove_empty=True) # type: ignore + boxes_per_level, keep = clip_boxes_to_image( # type: ignore + boxes_per_level, spatial_size, remove_empty=True + ) image_boxes.append(boxes_per_level) image_scores.append(scores_per_level[keep]) image_labels.append(labels_per_level[keep]) diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 4f728adf6e..393db74c56 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -983,6 +983,8 @@ def spatial_crop_boxes( for axis in range(1, spatial_dims): keep_t = keep_t & (boxes_t[:, axis + spatial_dims] >= boxes_t[:, axis] + 1 - TO_REMOVE) boxes_t = boxes_t[keep_t] + else: + keep_t = torch.full_like(boxes_t[:, 0], fill_value=True, dtype=torch.bool) # convert tensor back to numpy if needed boxes_keep, *_ = convert_to_dst_type(src=boxes_t, dst=boxes) diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index 0def9e1458..ea999ecb91 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -22,6 +22,7 @@ ConvertBoxModed, FlipBoxd, MaskToBoxd, + RandCropBoxByPosNegLabeld, RandFlipBoxd, RandZoomBoxd, ZoomBoxd, @@ -231,6 +232,28 @@ def test_value_3d( clip_result = transform_clip(data) assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) + # test RandCropBoxByPosNegLabeld + transform_crop = RandCropBoxByPosNegLabeld( + image_keys="image", box_keys="boxes", label_keys=["labels", "scores"], spatial_size=2, num_samples=3 + ) + crop_result = transform_crop(data) + assert len(crop_result) == 3 + for ll in range(3): + assert_allclose( + crop_result[ll]["boxes"].shape[0], + crop_result[ll]["labels"].shape[0], + type_test=True, + device_test=True, + atol=1e-3, + ) + assert_allclose( + crop_result[ll]["boxes"].shape[0], + crop_result[ll]["scores"].shape[0], + type_test=True, + device_test=True, + atol=1e-3, + ) + if __name__ == "__main__": unittest.main() From 5d7eb10b1df55b69714d644d426bf11ae4cf1801 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 2 Jun 2022 13:53:18 -0400 Subject: [PATCH 127/183] Add prediction utils that enable inferer (#4438) --- docs/source/apps.rst | 11 +- monai/apps/detection/utils/box_selector.py | 18 +-- monai/apps/detection/utils/predict_utils.py | 135 ++++++++++++++++++ tests/test_retinanet_predict_utils.py | 149 ++++++++++++++++++++ 4 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 monai/apps/detection/utils/predict_utils.py create mode 100644 tests/test_retinanet_predict_utils.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 2afe1864e0..514a1a05f1 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -160,12 +160,15 @@ Applications .. automodule:: monai.apps.detection.utils.box_coder :members: -`Detector utils` -~~~~~~~~~~~~~~~~ +`Detection Utilities` +~~~~~~~~~~~~~~~~~~~~~ .. automodule:: monai.apps.detection.utils.detector_utils :members: -`Detector box selector` -~~~~~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.utils.predict_utils + :members: + +`Inference box selector` +~~~~~~~~~~~~~~~~~~~~~~~~ .. automodule:: monai.apps.detection.utils.box_selector :members: diff --git a/monai/apps/detection/utils/box_selector.py b/monai/apps/detection/utils/box_selector.py index 72ada2739f..de4de85ea0 100644 --- a/monai/apps/detection/utils/box_selector.py +++ b/monai/apps/detection/utils/box_selector.py @@ -54,7 +54,7 @@ class BoxSelector: #. For each level, discard boxes with scores less than self.score_thresh. #. For each level, keep boxes with top self.topk_candidates_per_level scores. - #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overapping threshold nms_thresh. + #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overlapping threshold nms_thresh. #. For the whole image, keep boxes with top self.detections_per_img scores. Args: @@ -88,10 +88,10 @@ def __init__( self, box_overlap_metric: Callable = box_iou, apply_sigmoid: bool = True, - score_thresh=0.05, - topk_candidates_per_level=1000, - nms_thresh=0.5, - detections_per_img=300, + score_thresh: float = 0.05, + topk_candidates_per_level: int = 1000, + nms_thresh: float = 0.5, + detections_per_img: int = 300, ): self.box_overlap_metric = box_overlap_metric @@ -103,13 +103,13 @@ def __init__( def select_top_score_idx_per_level(self, logits: Tensor) -> Tuple[Tensor, Tensor, Tensor]: """ - Select indice with highest scores. + Select indices with highest scores. The indice selection is performed with the following steps: #. If self.apply_sigmoid, get scores by applying sigmoid to logits. Otherwise, use logits as scores. - #. Discard indice with scores less than self.score_thresh - #. Keep indice with top self.topk_candidates_per_level scores + #. Discard indices with scores less than self.score_thresh + #. Keep indices with top self.topk_candidates_per_level scores Args: logits: predicted classification logits, Tensor sized (N, num_classes) @@ -154,7 +154,7 @@ def select_boxes_per_image( #. For each level, discard boxes with scores less than self.score_thresh. #. For each level, keep boxes with top self.topk_candidates_per_level scores. - #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overapping threshold nms_thresh. + #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overlapping threshold nms_thresh. #. For the whole image, keep boxes with top self.detections_per_img scores. Args: diff --git a/monai/apps/detection/utils/predict_utils.py b/monai/apps/detection/utils/predict_utils.py new file mode 100644 index 0000000000..a11aa97ce7 --- /dev/null +++ b/monai/apps/detection/utils/predict_utils.py @@ -0,0 +1,135 @@ +# 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 typing import Dict, List, Optional + +import torch +from torch import Tensor + +from monai.inferers import SlidingWindowInferer + + +def ensure_dict_value_to_list_(head_outputs: Dict[str, List[Tensor]], keys: Optional[List[str]] = None) -> None: + """ + An in-place function. We expect ``head_outputs`` to be Dict[str, List[Tensor]]. + Yet if it is Dict[str, Tensor], this func converts it to Dict[str, List[Tensor]]. + It will be modified in-place. + + Args: + head_outputs: a Dict[str, List[Tensor]] or Dict[str, Tensor], will be modifier in-place + keys: the keys in head_output that need to have value type List[Tensor]. If not provided, will use head_outputs.keys(). + """ + if keys is None: + keys = list(head_outputs.keys()) + + for k in keys: + value_k = head_outputs[k] # Tensor or List[Tensor] + # convert value_k to List[Tensor] + if isinstance(value_k, Tensor): + head_outputs[k] = [value_k] + elif isinstance(value_k[0], Tensor): + head_outputs[k] = list(value_k) + else: + raise ValueError("The output of network should be Dict[str, List[Tensor]] or Dict[str, Tensor].") + + +def check_dict_values_same_length(head_outputs: Dict[str, List[Tensor]], keys: Optional[List[str]] = None) -> None: + """ + We expect the values in ``head_outputs``: Dict[str, List[Tensor]] to have the same length. + Will raise ValueError if not. + + Args: + head_outputs: a Dict[str, List[Tensor]] or Dict[str, Tensor] + keys: the keys in head_output that need to have values (List) with same length. + If not provided, will use head_outputs.keys(). + """ + if keys is None: + keys = list(head_outputs.keys()) + + num_output_levels_list: List[int] = [len(head_outputs[k]) for k in keys] + num_output_levels = torch.unique(torch.tensor(num_output_levels_list)) + if len(num_output_levels) != 1: + raise ValueError(f"The values in the input dict should have the same length, Got {num_output_levels_list}.") + + +def _network_sequence_output(images: Tensor, network, keys: Optional[List[str]] = None) -> List[Tensor]: + """ + Decompose the output of network (a dict) into a list. + + Args: + images: input of the network + keys: the keys in the network output whose values will be output in this func. + If not provided, will use all keys. + + Return: + network output values concat to a single List[Tensor] + """ + head_outputs = network(images) + ensure_dict_value_to_list_(head_outputs, keys) + if keys is None: + keys = list(head_outputs.keys()) + check_dict_values_same_length(head_outputs, keys) + head_outputs_sequence = [] + for k in keys: + head_outputs_sequence += list(head_outputs[k]) + return head_outputs_sequence + + +def predict_with_inferer( + images: Tensor, network, keys: List[str], inferer: Optional[SlidingWindowInferer] = None +) -> Dict[str, List[Tensor]]: + """ + Predict network dict output with an inferer. Compared with directly output network(images), + it enables a sliding window inferer that can be used to handle large inputs. + + Args: + images: input of the network, Tensor sized (B, C, H, W) or (B, C, H, W, D) + network: a network that takes an image Tensor sized (B, C, H, W) or (B, C, H, W, D) as input + and outputs a dictionary Dict[str, List[Tensor]] or Dict[str, Tensor]. + keys: the keys in the output dict, should be network output keys or a subset of them. + inferer: a SlidingWindowInferer to handle large inputs. + + Return: + The predicted head_output from network, a Dict[str, List[Tensor]] + + Example: + .. code-block:: python + + # define a naive network + import torch + import monai + class NaiveNet(torch.nn.Module): + def __init__(self, ): + super().__init__() + + def forward(self, images: torch.Tensor): + return {"cls": torch.randn(images.shape), "box_reg": [torch.randn(images.shape)]} + + # create a predictor + network = NaiveNet() + inferer = monai.inferers.SlidingWindowInferer( + roi_size = (128, 128, 128), + overlap = 0.25, + cache_roi_weight_map = True, + ) + network_output_keys=["cls", "box_reg"] + images = torch.randn((2, 3, 512, 512, 512)) # a large input + head_outputs = predict_with_inferer(images, network, network_output_keys, inferer) + + """ + if inferer is None: + raise ValueError("Please set inferer as a monai.inferers.inferer.SlidingWindowInferer(*)") + head_outputs_sequence = inferer(images, _network_sequence_output, network, keys=keys) + num_output_levels: int = len(head_outputs_sequence) // len(keys) + head_outputs = {} + for i, k in enumerate(keys): + head_outputs[k] = list(head_outputs_sequence[num_output_levels * i : num_output_levels * (i + 1)]) + return head_outputs diff --git a/tests/test_retinanet_predict_utils.py b/tests/test_retinanet_predict_utils.py new file mode 100644 index 0000000000..5157691696 --- /dev/null +++ b/tests/test_retinanet_predict_utils.py @@ -0,0 +1,149 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.utils.predict_utils import ensure_dict_value_to_list_, predict_with_inferer +from monai.inferers import SlidingWindowInferer + +TEST_CASE_1 = [ # 3D, batch 3, 2 input channel + { + "pretrained": False, + "spatial_dims": 3, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": 7, + "conv1_t_stride": (2, 2, 2), + }, + (3, 2, 32, 64, 48), +] + +TEST_CASE_2 = [ # 2D, batch 2, 1 input channel + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "conv1_t_size": [7, 7], + "conv1_t_stride": [2, 2], + }, + (2, 1, 32, 64), +] + +TEST_CASE_2_A = [ # 2D, batch 2, 1 input channel, shortcut type A + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "shortcut_type": "A", + "conv1_t_size": (7, 7), + "conv1_t_stride": 2, + }, + (2, 1, 32, 64), +] + +TEST_CASE_3 = [ # 1D, batch 1, 2 input channels + { + "pretrained": False, + "spatial_dims": 1, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": [3], + "conv1_t_stride": 1, + }, + (1, 2, 32), +] + +TEST_CASE_3_A = [ # 1D, batch 1, 2 input channels + {"pretrained": False, "spatial_dims": 1, "n_input_channels": 2, "num_classes": 3, "shortcut_type": "A"}, + (1, 2, 32), +] + +TEST_CASE_4 = [ # 2D, batch 2, 1 input channel + {"pretrained": False, "spatial_dims": 2, "n_input_channels": 1, "num_classes": 3, "feed_forward": False}, + (2, 1, 32, 64), +] + +TEST_CASES = [] +TEST_CASES = [TEST_CASE_1, TEST_CASE_2, TEST_CASE_2_A] + +TEST_CASES_TS = [TEST_CASE_1] + + +class NaiveNetwork(torch.nn.Module): + def __init__(self, spatial_dims, num_classes, **kwargs): + super().__init__() + self.spatial_dims = spatial_dims + self.num_classes = num_classes + self.num_anchors = 1 + self.cls_key = "cls" + self.box_reg_key = "box_reg" + self.size_divisible = 1 + + def forward(self, images): + out_cls_shape = (images.shape[0], self.num_classes * self.num_anchors) + images.shape[-self.spatial_dims :] + out_box_reg_shape = (images.shape[0], 2 * self.spatial_dims * self.num_anchors) + images.shape[ + -self.spatial_dims : + ] + return {self.cls_key: torch.randn(out_cls_shape), self.box_reg_key: [torch.randn(out_box_reg_shape)]} + + +class NaiveNetwork2(torch.nn.Module): + def __init__(self, spatial_dims, num_classes, **kwargs): + super().__init__() + self.spatial_dims = spatial_dims + self.num_classes = num_classes + self.num_anchors = 1 + self.cls_key = "cls" + self.box_reg_key = "box_reg" + self.size_divisible = 1 + + def forward(self, images): + out_cls_shape = (images.shape[0], self.num_classes * self.num_anchors) + images.shape[-self.spatial_dims :] + out_box_reg_shape = (images.shape[0], 2 * self.spatial_dims * self.num_anchors) + images.shape[ + -self.spatial_dims : + ] + return {self.cls_key: [torch.randn(out_cls_shape)] * 2, self.box_reg_key: [torch.randn(out_box_reg_shape)] * 2} + + +class TestPredictor(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_naive_predictor(self, input_param, input_shape): + net = NaiveNetwork(**input_param) + net2 = NaiveNetwork2(**input_param) + inferer = SlidingWindowInferer(roi_size=16, overlap=0.25, cache_roi_weight_map=True) + network_output_keys = ["cls", "box_reg"] + + input_data = torch.randn(input_shape) + + result = predict_with_inferer(input_data, net, network_output_keys, inferer=inferer) + self.assertTrue(len(result["cls"]) == 1) + + result = net(input_data) + self.assertTrue(len(result["cls"]) == input_data.shape[0]) + ensure_dict_value_to_list_(result) + self.assertTrue(len(result["cls"]) == 1) + + result = predict_with_inferer(input_data, net2, network_output_keys, inferer=inferer) + self.assertTrue(len(result["cls"]) == 2) + + result = net2(input_data) + self.assertTrue(len(result["cls"]) == 2) + ensure_dict_value_to_list_(result) + self.assertTrue(len(result["cls"]) == 2) + + +if __name__ == "__main__": + unittest.main() From e2f160d15456b82fb867c023c81879f79bea0154 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 2 Jun 2022 17:30:58 -0400 Subject: [PATCH 128/183] Add RetinaNet detector (#4400) --- .../detection/networks/retinanet_detector.py | 624 ++++++++++++++++++ tests/test_retinanet_detector.py | 166 +++++ 2 files changed, 790 insertions(+) create mode 100644 monai/apps/detection/networks/retinanet_detector.py create mode 100644 tests/test_retinanet_detector.py diff --git a/monai/apps/detection/networks/retinanet_detector.py b/monai/apps/detection/networks/retinanet_detector.py new file mode 100644 index 0000000000..9b673b1195 --- /dev/null +++ b/monai/apps/detection/networks/retinanet_detector.py @@ -0,0 +1,624 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py +# which has the following license... +# https://github.com/pytorch/vision/blob/main/LICENSE + +# BSD 3-Clause License + +# Copyright (c) Soumith Chintala 2016, +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +""" +Part of this script is adapted from +https://github.com/pytorch/vision/blob/main/torchvision/models/detection/retinanet.py +""" + +import warnings +from typing import Any, Callable, Dict, List, Sequence, Tuple, Union + +import torch +from torch import Tensor, nn + +from monai.apps.detection.networks.retinanet_network import RetinaNet, resnet_fpn_feature_extractor +from monai.apps.detection.utils.anchor_utils import AnchorGenerator +from monai.apps.detection.utils.box_coder import BoxCoder +from monai.apps.detection.utils.box_selector import BoxSelector +from monai.apps.detection.utils.detector_utils import check_training_targets, preprocess_images +from monai.apps.detection.utils.predict_utils import ensure_dict_value_to_list_, predict_with_inferer +from monai.data.box_utils import box_iou +from monai.inferers import SlidingWindowInferer +from monai.networks.nets import resnet +from monai.utils import BlendMode, PytorchPadMode, ensure_tuple_rep + + +class RetinaNetDetector(nn.Module): + """ + Retinanet detector, expandable to other one stage anchor based box detectors in the future. + An example of construction can found in the source code of + :func:`~monai.apps.detection.networks.retinanet_detector.retinanet_resnet50_fpn_detector` . + + The input to the model is expected to be a list of tensors, each of shape (C, H, W) or (C, H, W, D), + one for each image, and should be in 0-1 range. Different images can have different sizes. + Or it can also be a Tensor sized (B, C, H, W) or (B, C, H, W, D). In this case, all images have same size. + + The behavior of the model changes depending if it is in training or evaluation mode. + + During training, the model expects both the input tensors, as well as a targets (list of dictionary), + containing: + + - boxes (``FloatTensor[N, 4]`` or ``FloatTensor[N, 6]``): the ground-truth boxes in ``StandardMode``, i.e., + ``[xmin, ymin, xmax, ymax]`` or ``[xmin, ymin, zmin, xmax, ymax, zmax]`` format, + with ``0 <= xmin < xmax <= H``, ``0 <= ymin < ymax <= W``, ``0 <= zmin < zmax <= D``. + - labels: the class label for each ground-truth box + + The model returns a Dict[Tensor] during training, containing the classification and regression + losses. + When save the model, only self.network contains trainable parameters and needs to be saved. + + During inference, the model requires only the input tensors, and returns the post-processed + predictions as a List[Dict[Tensor]], one for each input image. The fields of the Dict are as + follows: + - boxes (``FloatTensor[N, 4]`` or ``FloatTensor[N, 6]``): the ground-truth boxes in ``StandardMode``, i.e., + ``[xmin, ymin, xmax, ymax]`` or ``[xmin, ymin, zmin, xmax, ymax, zmax]`` format, + with ``0 <= xmin < xmax <= H``, ``0 <= ymin < ymax <= W``, ``0 <= zmin < zmax <= D``. + - labels (Int64Tensor[N]): the predicted labels for each image + - labels_scores (Tensor[N]): the scores for each prediction + + Args: + network: a network that takes an image Tensor sized (B, C, H, W) or (B, C, H, W, D) as input + and outputs a dictionary Dict[str, List[Tensor]]. + anchor_generator: anchor generator. + box_overlap_metric: func that compute overlap between two sets of boxes, default is Intersection over Union (IoU). + debug: whether to print out internal parameters, used for debugging and parameter tuning. + + Notes: + Input argument ``network`` can be a monai.apps.detection.networks.retinanet_network.RetinaNet(*) object, + but any network that meets the following rules is a valid input ``network``. + + 1. It should have attributes including spatial_dims, num_classes, cls_key, box_reg_key, num_anchors, size_divisible. + + - spatial_dims (int) is the spatial dimension of the network, we support both 2D and 3D. + - num_classes (int) is the number of classes, excluding the background. + - size_divisible (int or Sequene[int]) is the expection on the input image shape. + The network needs the input spatial_size to be divisible by size_divisible + - cls_key (str) is the key to represent classification in the output dict. + - box_reg_key (str) is the key to represent box regression in the output dict. + - num_anchors (int) is the number of anchor shapes at each location. it should equal to + ``self.anchor_generator.num_anchors_per_location()[0]``. + + 2. Its input should be an image Tensor sized (B, C, H, W) or (B, C, H, W, D). + + 3. About its output ``head_outputs``: + + - It should be a dictionary with at least two keys: + ``network.cls_key`` and ``network.box_reg_key``. + - ``head_outputs[network.cls_key]`` should be List[Tensor] or Tensor. Each Tensor represents + classification logits map at one resolution level, + sized (B, num_classes*num_anchors, H_i, W_i) or (B, num_classes*num_anchors, H_i, W_i, D_i). + - ``head_outputs[network.box_reg_key]`` should be List[Tensor] or Tensor. Each Tensor represents + box regression map at one resolution level, + sized (B, 2*spatial_dims*num_anchors, H_i, W_i)or (B, 2*spatial_dims*num_anchors, H_i, W_i, D_i). + - ``len(head_outputs[network.cls_key]) == len(head_outputs[network.box_reg_key])``. + + Example: + .. code-block:: python + + # define a naive network + import torch + class NaiveNet(torch.nn.Module): + def __init__(self, spatial_dims: int, num_classes: int): + super().__init__() + self.spatial_dims = spatial_dims + self.num_classes = num_classes + self.size_divisible = 2 + self.cls_key = "cls" + self.box_reg_key = "box_reg" + self.num_anchors = 1 + def forward(self, images: torch.Tensor): + spatial_size = images.shape[-self.spatial_dims:] + out_spatial_size = tuple(s//self.size_divisible for s in spatial_size) # half size of input + out_cls_shape = (images.shape[0],self.num_classes*self.num_anchors) + out_spatial_size + out_box_reg_shape = (images.shape[0],2*self.spatial_dims*self.num_anchors) + out_spatial_size + return {self.cls_key: [torch.randn(out_cls_shape)], self.box_reg_key: [torch.randn(out_box_reg_shape)]} + + # create a RetinaNetDetector detector + spatial_dims = 3 + num_classes = 5 + anchor_generator = monai.apps.detection.utils.anchor_utils.AnchorGeneratorWithAnchorShape( + feature_map_scales=(1, ), base_anchor_shapes=((8,) * spatial_dims) + ) + net = NaiveNet(spatial_dims, num_classes) + detector = RetinaNetDetector(net, anchor_generator) + + # only detector.network may contain trainable parameters. + optimizer = torch.optim.SGD( + detector.network.parameters(), + 1e-3, + momentum=0.9, + weight_decay=3e-5, + nesterov=True, + ) + torch.save(detector.network.state_dict(), 'model.pt') # save model + detector.network.load_state_dict(torch.load('model.pt')) # load model + """ + + def __init__( + self, network, anchor_generator: AnchorGenerator, box_overlap_metric: Callable = box_iou, debug: bool = False + ): + super().__init__() + + if not all( + hasattr(network, attr) + for attr in ["spatial_dims", "num_classes", "cls_key", "box_reg_key", "num_anchors", "size_divisible"] + ): + raise AttributeError( + "network should have attributes, including: " + "'spatial_dims', 'num_classes', 'cls_key', 'box_reg_key', 'num_anchors', 'size_divisible'." + ) + + self.network = network + self.spatial_dims = self.network.spatial_dims + self.num_classes = self.network.num_classes + self.size_divisible = ensure_tuple_rep(self.network.size_divisible, self.spatial_dims) + # keys for the network output + self.cls_key = self.network.cls_key + self.box_reg_key = self.network.box_reg_key + + # check if anchor_generator matches with network + self.anchor_generator = anchor_generator + + self.num_anchors_per_loc = self.anchor_generator.num_anchors_per_location()[0] + if self.num_anchors_per_loc != self.network.num_anchors: + raise ValueError( + f"Number of feature map channels ({self.network.num_anchors}) " + f"should match with number of anchors at each location ({self.num_anchors_per_loc})." + ) + # if new coming input images has same shape with + # self.previous_image_shape, there is no need to generate new anchors. + self.anchors: Union[List[Tensor], None] = None + self.previous_image_shape: Union[Any, None] = None + + self.box_overlap_metric = box_overlap_metric + self.debug = debug + + # default setting for both training and inference + # can be updated by self.set_box_coder_weights(*) + self.box_coder = BoxCoder(weights=(1.0,) * 2 * self.spatial_dims) + + # default keys in the ground truth targets and predicted boxes, + # can be updated by self.set_target_keys(*) + self.target_box_key = "boxes" + self.target_label_key = "labels" + self.pred_score_key = self.target_label_key + "_scores" # score key for the detected boxes + + # default setting for inference, + # can be updated by self.set_sliding_window_inferer(*) + self.inferer: Union[SlidingWindowInferer, None] = None + # can be updated by self.set_box_selector_parameters(*), + self.box_selector = BoxSelector( + box_overlap_metric=self.box_overlap_metric, + score_thresh=0.05, + topk_candidates_per_level=1000, + nms_thresh=0.5, + detections_per_img=300, + apply_sigmoid=True, + ) + + def set_box_coder_weights(self, weights: Tuple[float]): + """ + Set the weights for box coder. + + Args: + weights: a list/tuple with length of 2*self.spatial_dims + + """ + if len(weights) != 2 * self.spatial_dims: + raise ValueError(f"len(weights) should be {2 * self.spatial_dims}, got weights={weights}.") + self.box_coder = BoxCoder(weights=weights) + + def set_target_keys(self, box_key: str, label_key: str): + """ + Set keys for the training targets and inference outputs. + During training, both box_key and label_key should be keys in the targets + when performing ``self.forward(input_images, targets)``. + During inference, they will be the keys in the output dict of `self.forward(input_images)``. + """ + self.target_box_key = box_key + self.target_label_key = label_key + self.pred_score_key = label_key + "_scores" + + def set_sliding_window_inferer( + self, + roi_size: Union[Sequence[int], int], + sw_batch_size: int = 1, + overlap: float = 0.5, + mode: Union[BlendMode, str] = BlendMode.CONSTANT, + sigma_scale: Union[Sequence[float], float] = 0.125, + padding_mode: Union[PytorchPadMode, str] = PytorchPadMode.CONSTANT, + cval: float = 0.0, + sw_device: Union[torch.device, str, None] = None, + device: Union[torch.device, str, None] = None, + progress: bool = False, + cache_roi_weight_map: bool = False, + ): + """ + Define sliding window inferer and store it to self.inferer. + """ + self.inferer = SlidingWindowInferer( + roi_size, + sw_batch_size, + overlap, + mode, + sigma_scale, + padding_mode, + cval, + sw_device, + device, + progress, + cache_roi_weight_map, + ) + + def set_box_selector_parameters( + self, + score_thresh: float = 0.05, + topk_candidates_per_level: int = 1000, + nms_thresh: float = 0.5, + detections_per_img: int = 300, + apply_sigmoid: bool = True, + ): + """ + Using for inference. Set the parameters that are used for box selection during inference. + The box selection is performed with the following steps: + + #. For each level, discard boxes with scores less than self.score_thresh. + #. For each level, keep boxes with top self.topk_candidates_per_level scores. + #. For the whole image, perform non-maximum suppression (NMS) on boxes, with overapping threshold nms_thresh. + #. For the whole image, keep boxes with top self.detections_per_img scores. + + Args: + score_thresh: no box with scores less than score_thresh will be kept + topk_candidates_per_level: max number of boxes to keep for each level + nms_thresh: box overlapping threshold for NMS + detections_per_img: max number of boxes to keep for each image + """ + + self.box_selector = BoxSelector( + box_overlap_metric=self.box_overlap_metric, + apply_sigmoid=apply_sigmoid, + score_thresh=score_thresh, + topk_candidates_per_level=topk_candidates_per_level, + nms_thresh=nms_thresh, + detections_per_img=detections_per_img, + ) + + def forward( + self, + input_images: Union[List[Tensor], Tensor], + targets: Union[List[Dict[str, Tensor]], None] = None, + use_inferer: bool = False, + ) -> Union[Dict[str, Tensor], List[Dict[str, Tensor]]]: + """ + Returns a dict of losses during training, or a list predicted dict of boxes and labels during inference. + + Args: + input_images: The input to the model is expected to be a list of tensors, each of shape (C, H, W) or (C, H, W, D), + one for each image, and should be in 0-1 range. Different images can have different sizes. + Or it can also be a Tensor sized (B, C, H, W) or (B, C, H, W, D). In this case, all images have same size. + targets: a list of dict. Each dict with two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image (optional). + use_inferer: whether to use self.inferer, a sliding window inferer, to do the inference. + If False, will simply forward the network. + If True, will use self.inferer, and requires + ``self.set_sliding_window_inferer(*args)`` to have been called before. + + Return: + If training mode, will return a dict with at least two keys, + including self.cls_key and self.box_reg_key, representing classification loss and box regression loss. + + If evaluation mode, will return a list of detection results. + Each element corresponds to an images in ``input_images``, is a dict with at least three keys, + including self.target_box_key, self.target_label_key, self.pred_score_key, + representing predicted boxes, classification labels, and classification scores. + + """ + # 1. Check if input arguments are valid + if self.training: + check_training_targets(input_images, targets, self.spatial_dims, self.target_label_key, self.target_box_key) + if not hasattr(self, "proposal_matcher"): + raise AttributeError( + "Matcher is not set. Please refer to self.set_regular_matcher(*) or self.set_atss_matcher(*)." + ) + if self.fg_bg_sampler is None and self.debug: + warnings.warn( + "No balanced sampler is used. Negative samples are likely to " + "be much more than positive samples. Please set balanced samplers with self.set_balanced_sampler(*) " + "or self.set_hard_negative_sampler(*), " + "or set classification loss function as Focal loss with self.set_cls_loss(*)" + ) + + # 2. Pad list of images to a single Tensor `images` with spatial size divisible by self.size_divisible. + # image_sizes stores the original spatial_size of each image before padding. + images, image_sizes = preprocess_images(input_images, self.spatial_dims, self.size_divisible) + + # 3. Generate network outputs. Use inferer only in evaluation mode. + if self.training or (not use_inferer): + head_outputs = self.network(images) + ensure_dict_value_to_list_(head_outputs) # ensure head_outputs is Dict[str, List[Tensor]] + else: + if self.inferer is None: + raise ValueError( + "`self.inferer` is not defined." "Please refer to function self.set_sliding_window_inferer(*)." + ) + head_outputs = predict_with_inferer( + images, self.network, keys=[self.cls_key, self.box_reg_key], inferer=self.inferer + ) + + # 4. Generate anchors and store it in self.anchors: List[Tensor] + self.generate_anchors(images, head_outputs) + # num_anchor_locs_per_level: List[int], list of HW or HWD for each level + num_anchor_locs_per_level = [x.shape[2:].numel() for x in head_outputs[self.cls_key]] + + # 5. Reshape and concatenate head_outputs values from List[Tensor] to Tensor + # head_outputs, originally being Dict[str, List[Tensor]], will be reshaped to Dict[str, Tensor] + for key in [self.cls_key, self.box_reg_key]: + # reshape to Tensor sized(B, sum(HWA), self.num_classes) for self.cls_key + # or (B, sum(HWA), 2* self.spatial_dims) for self.box_reg_key + # A = self.num_anchors_per_loc + head_outputs[key] = self.reshape_maps(head_outputs[key]) + + # 6(1). If during training, return losses + if self.training: + losses = self.compute_loss(head_outputs, targets, self.anchors, num_anchor_locs_per_level) # type: ignore + return losses + + # 6(2). If during inference, return detection results + detections = self.postprocess_detections( + head_outputs, self.anchors, image_sizes, num_anchor_locs_per_level # type: ignore + ) + return detections + + def generate_anchors(self, images: Tensor, head_outputs: Dict[str, List[Tensor]]): + """ + Generate anchors and store it in self.anchors: List[Tensor]. + We generate anchors only when there is no stored anchors, + or the new coming images has different shape with self.previous_image_shape + + Args: + images: input images, a (B, C, H, W) or (B, C, H, W, D) Tensor. + head_outputs: head_outputs. ``head_output_reshape[self.cls_key]`` is a Tensor + sized (B, sum(HW(D)A), self.num_classes). ``head_output_reshape[self.box_reg_key]`` is a Tensor + sized (B, sum(HW(D)A), 2*self.spatial_dims) + """ + if (self.anchors is None) or (self.previous_image_shape != images.shape): + self.anchors = self.anchor_generator(images, head_outputs[self.cls_key]) # List[Tensor], len = batchsize + self.previous_image_shape = images.shape + + def reshape_maps(self, result_maps: List[Tensor]) -> Tensor: + """ + Concat network output map list to a single Tensor. + This function is used in both training and inference. + + Args: + result_maps: a list of Tensor, each Tensor is a (B, num_channel*A, H, W) or (B, num_channel*A, H, W, D) map. + A = self.num_anchors_per_loc + + Return: + reshaped and concatenated result, sized (B, sum(HWA), num_channel) or (B, sum(HWDA), num_channel) + """ + all_reshaped_result_map = [] + + for result_map in result_maps: + batch_size = result_map.shape[0] + num_channel = result_map.shape[1] // self.num_anchors_per_loc + spatial_size = result_map.shape[-self.spatial_dims :] + + # reshaped_result_map will become (B, A, num_channel, H, W) or (B, A, num_channel, H, W, D) + # A = self.num_anchors_per_loc + view_shape = (batch_size, -1, num_channel) + spatial_size + reshaped_result_map = result_map.view(view_shape) + + # permute output to (B, H, W, A, num_channel) or (B, H, W, D, A, num_channel) + if self.spatial_dims == 2: + reshaped_result_map = reshaped_result_map.permute(0, 3, 4, 1, 2) + elif self.spatial_dims == 3: + reshaped_result_map = reshaped_result_map.permute(0, 3, 4, 5, 1, 2) + else: + ValueError("Images can only be 2D or 3D.") + + # reshaped_result_map will become (B, HWA, num_channel) or (B, HWDA, num_channel) + reshaped_result_map = reshaped_result_map.reshape(batch_size, -1, num_channel) + + if torch.isnan(reshaped_result_map).any() or torch.isinf(reshaped_result_map).any(): + raise ValueError("Concatenated result is NaN or Inf.") + + all_reshaped_result_map.append(reshaped_result_map) + + return torch.cat(all_reshaped_result_map, dim=1) + + def postprocess_detections( + self, + head_outputs_reshape: Dict[str, Tensor], + anchors: List[Tensor], + image_sizes: List[List[int]], + num_anchor_locs_per_level: Sequence[int], + need_sigmoid: bool = True, + ) -> List[Dict[str, Tensor]]: + """ + Postprocessing to generate detection result from classification logits and box regression. + Use self.box_selector to select the final outut boxes for each image. + + Args: + head_outputs_reshape: reshaped head_outputs. ``head_output_reshape[self.cls_key]`` is a Tensor + sized (B, sum(HW(D)A), self.num_classes). ``head_output_reshape[self.box_reg_key]`` is a Tensor + sized (B, sum(HW(D)A), 2*self.spatial_dims) + targets: a list of dict. Each dict with two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + anchors: a list of Tensor. Each Tensor represents anchors for each image, + sized (sum(HWA), 2*spatial_dims) or (sum(HWDA), 2*spatial_dims). + A = self.num_anchors_per_loc. + + Return: + a list of dict, each dict scorresponds to detection result on image. + """ + + # recover level sizes, HWA or HWDA for each level + num_anchors_per_level = [ + num_anchor_locs * self.num_anchors_per_loc for num_anchor_locs in num_anchor_locs_per_level + ] + + # split outputs per level + split_head_outputs: Dict[str, List[Tensor]] = {} + for k in head_outputs_reshape: + split_head_outputs[k] = list(head_outputs_reshape[k].split(num_anchors_per_level, dim=1)) + split_anchors = [list(a.split(num_anchors_per_level)) for a in anchors] # List[List[Tensor]] + + class_logits = split_head_outputs[self.cls_key] # List[Tensor], each sized (B, HWA, self.num_classes) + box_regression = split_head_outputs[self.box_reg_key] # List[Tensor], each sized (B, HWA, 2*spatial_dims) + compute_dtype = class_logits[0].dtype + + num_images = len(image_sizes) # B + + detections: List[Dict[str, Tensor]] = [] + + for index in range(num_images): + box_regression_per_image = [ + br[index] for br in box_regression + ] # List[Tensor], each sized (HWA, 2*spatial_dims) + logits_per_image = [cl[index] for cl in class_logits] # List[Tensor], each sized (HWA, self.num_classes) + anchors_per_image, img_spatial_size = split_anchors[index], image_sizes[index] + # decode box regression into boxes + boxes_per_image = [ + self.box_coder.decode_single(b.to(torch.float32), a).to(compute_dtype) + for b, a in zip(box_regression_per_image, anchors_per_image) + ] # List[Tensor], each sized (HWA, 2*spatial_dims) + + selected_boxes, selected_scores, selected_labels = self.box_selector.select_boxes_per_image( + boxes_per_image, logits_per_image, img_spatial_size + ) + + detections.append( + { + self.target_box_key: selected_boxes, # Tensor, sized (N, 2*spatial_dims) + self.pred_score_key: selected_scores, # Tensor, sized (N, ) + self.target_label_key: selected_labels, # Tensor, sized (N, ) + } + ) + + return detections + + def compute_loss( + self, + head_outputs_reshape: Dict[str, Tensor], + targets: List[Dict[str, Tensor]], + anchors: List[Tensor], + num_anchor_locs_per_level: Sequence[int], + ) -> Dict[str, Tensor]: + """ + Compute losses. + + Args: + head_outputs_reshape: reshaped head_outputs. ``head_output_reshape[self.cls_key]`` is a Tensor + sized (B, sum(HW(D)A), self.num_classes). ``head_output_reshape[self.box_reg_key]`` is a Tensor + sized (B, sum(HW(D)A), 2*self.spatial_dims) + targets: a list of dict. Each dict with two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + anchors: a list of Tensor. Each Tensor represents anchors for each image, + sized (sum(HWA), 2*spatial_dims) or (sum(HWDA), 2*spatial_dims). + A = self.num_anchors_per_loc. + + Return: + a dict of several kinds of losses. + """ + return {} + + +def retinanet_resnet50_fpn_detector( + num_classes: int, + anchor_generator: AnchorGenerator, + returned_layers: Sequence[int] = (1, 2, 3), + pretrained: bool = False, + progress: bool = True, + **kwargs: Any, +) -> RetinaNetDetector: + """ + Returns a RetinaNet detector using a ResNet-50 as backbone, which can be pretrained + from `Med3D: Transfer Learning for 3D Medical Image Analysis ` + _. + Args: + num_classes: number of output classes of the model (excluding the background). + anchor_generator: AnchorGenerator, + returned_layers: returned layers to extract feature maps. Each returned layer should be in the range [1,4]. + len(returned_layers)+1 will be the number of extracted feature maps. + There is an extra maxpooling layer LastLevelMaxPool() appended. + pretrained: If True, returns a backbone pre-trained on 23 medical datasets + progress: If True, displays a progress bar of the download to stderr + + Return: + A RetinaNetDetector object with resnet50 as backbone + + Example: + .. code-block:: python + + # define a naive network + resnet_param = { + "pretrained": False, + "spatial_dims": 3, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": 7, + "conv1_t_stride": (2, 2, 2) + } + returned_layers = [1] + anchor_generator = monai.apps.detection.utils.anchor_utils.AnchorGeneratorWithAnchorShape( + feature_map_scales=(1, 2), base_anchor_shapes=((8,) * resnet_param["spatial_dims"]) + ) + detector = retinanet_resnet50_fpn_detector( + **resnet_param, anchor_generator=anchor_generator, returned_layers=returned_layers + ) + """ + + backbone = resnet.resnet50(pretrained, progress, **kwargs) + spatial_dims = len(backbone.conv1.stride) + # number of output feature maps is len(returned_layers)+1 + feature_extractor = resnet_fpn_feature_extractor( + backbone=backbone, + spatial_dims=spatial_dims, + pretrained_backbone=pretrained, + trainable_backbone_layers=None, + returned_layers=returned_layers, + ) + num_anchors = anchor_generator.num_anchors_per_location()[0] + size_divisible = [s * 2 * 2 ** max(returned_layers) for s in feature_extractor.body.conv1.stride] + network = RetinaNet( + spatial_dims=spatial_dims, + num_classes=num_classes, + num_anchors=num_anchors, + feature_extractor=feature_extractor, + size_divisible=size_divisible, + ) + return RetinaNetDetector(network, anchor_generator) diff --git a/tests/test_retinanet_detector.py b/tests/test_retinanet_detector.py new file mode 100644 index 0000000000..f91fb604ca --- /dev/null +++ b/tests/test_retinanet_detector.py @@ -0,0 +1,166 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import unittest + +import torch +from parameterized import parameterized + +from monai.apps.detection.networks.retinanet_detector import RetinaNetDetector, retinanet_resnet50_fpn_detector +from monai.apps.detection.utils.anchor_utils import AnchorGeneratorWithAnchorShape +from monai.networks import eval_mode +from monai.utils import optional_import +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save + +_, has_torchvision = optional_import("torchvision") + + +num_anchors = 7 + +TEST_CASE_1 = [ # 3D, batch 3, 2 input channel + { + "pretrained": False, + "spatial_dims": 3, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": 7, + "conv1_t_stride": (2, 2, 2), + }, + (3, 2, 32, 64, 48), +] + +TEST_CASE_2 = [ # 2D, batch 2, 1 input channel + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "conv1_t_size": [7, 7], + "conv1_t_stride": [2, 2], + }, + (2, 1, 32, 64), +] + +TEST_CASE_2_A = [ # 2D, batch 2, 1 input channel, shortcut type A + { + "pretrained": False, + "spatial_dims": 2, + "n_input_channels": 1, + "num_classes": 3, + "shortcut_type": "A", + "conv1_t_size": (7, 7), + "conv1_t_stride": 2, + }, + (2, 1, 32, 64), +] + +TEST_CASE_3 = [ # 1D, batch 1, 2 input channels + { + "pretrained": False, + "spatial_dims": 1, + "n_input_channels": 2, + "num_classes": 3, + "conv1_t_size": [3], + "conv1_t_stride": 1, + }, + (1, 2, 32), +] + +TEST_CASE_3_A = [ # 1D, batch 1, 2 input channels + {"pretrained": False, "spatial_dims": 1, "n_input_channels": 2, "num_classes": 3, "shortcut_type": "A"}, + (1, 2, 32), +] + +TEST_CASE_4 = [ # 2D, batch 2, 1 input channel + {"pretrained": False, "spatial_dims": 2, "n_input_channels": 1, "num_classes": 3, "feed_forward": False}, + (2, 1, 32, 64), +] + +TEST_CASES = [] +TEST_CASES = [TEST_CASE_1, TEST_CASE_2, TEST_CASE_2_A] + +TEST_CASES_TS = [TEST_CASE_1] + + +class NaiveNetwork(torch.nn.Module): + def __init__(self, spatial_dims, num_classes, **kwargs): + super().__init__() + self.spatial_dims = spatial_dims + self.num_classes = num_classes + self.num_anchors = 1 + self.cls_key = "cls" + self.box_reg_key = "box_reg" + self.size_divisible = 1 + + def forward(self, images): + out_cls_shape = (images.shape[0], self.num_classes * self.num_anchors) + images.shape[-self.spatial_dims :] + out_box_reg_shape = (images.shape[0], 2 * self.spatial_dims * self.num_anchors) + images.shape[ + -self.spatial_dims : + ] + return {self.cls_key: [torch.randn(out_cls_shape)], self.box_reg_key: [torch.randn(out_box_reg_shape)]} + + +@SkipIfBeforePyTorchVersion((1, 11)) +@unittest.skipUnless(has_torchvision, "Requires torchvision") +class TestRetinaNetDetector(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_retina_detector_resnet_backbone_shape(self, input_param, input_shape): + returned_layers = [1] + anchor_generator = AnchorGeneratorWithAnchorShape( + feature_map_scales=(1, 2), base_anchor_shapes=((8,) * input_param["spatial_dims"],) + ) + detector = retinanet_resnet50_fpn_detector( + **input_param, anchor_generator=anchor_generator, returned_layers=returned_layers + ) + + with eval_mode(detector): + input_data = torch.randn(input_shape) + result = detector.forward(input_data) + assert len(result) == len(result) + + input_data = [torch.randn(input_shape[1:]) for _ in range(random.randint(1, 9))] + result = detector.forward(input_data) + assert len(result) == len(result) + + @parameterized.expand(TEST_CASES) + def test_naive_retina_detector_shape(self, input_param, input_shape): + anchor_generator = AnchorGeneratorWithAnchorShape( + feature_map_scales=(1,), base_anchor_shapes=((8,) * input_param["spatial_dims"],) + ) + detector = RetinaNetDetector(network=NaiveNetwork(**input_param), anchor_generator=anchor_generator) + + with eval_mode(detector): + input_data = torch.randn(input_shape) + result = detector.forward(input_data) + assert len(result) == len(result) + + input_data = [torch.randn(input_shape[1:]) for _ in range(random.randint(1, 9))] + result = detector.forward(input_data) + assert len(result) == len(result) + + @parameterized.expand(TEST_CASES_TS) + def test_script(self, input_param, input_shape): + # test whether support torchscript + returned_layers = [1] + anchor_generator = AnchorGeneratorWithAnchorShape( + feature_map_scales=(1, 2), base_anchor_shapes=((8,) * input_param["spatial_dims"],) + ) + detector = retinanet_resnet50_fpn_detector( + **input_param, anchor_generator=anchor_generator, returned_layers=returned_layers + ) + with eval_mode(detector): + input_data = torch.randn(input_shape) + test_script_save(detector.network, input_data) + + +if __name__ == "__main__": + unittest.main() From 07306c03db80cb4fef0bae1b234e70ca9b98f4b6 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Thu, 2 Jun 2022 22:44:41 -0400 Subject: [PATCH 129/183] Masked Patch WSI Dataset (#4410) * Implement MaskedPatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update the min verison for skimage to 0.19.0 Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add channel last for wsireader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Replace all wsi patch key related with WSIPatchKeys Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update ProbMapKeys.NAME Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update GridPatch and iter_patch to support no padding Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add threshold for filtering to grid patch Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/data.rst | 5 + .../pathology/transforms/spatial/array.py | 2 +- monai/data/__init__.py | 2 +- monai/data/utils.py | 37 ++- monai/data/wsi_datasets.py | 262 +++++++++++++----- monai/data/wsi_reader.py | 108 ++++---- monai/handlers/probability_maps.py | 10 +- monai/transforms/intensity/array.py | 1 - monai/transforms/spatial/array.py | 67 +++-- monai/transforms/spatial/dictionary.py | 33 ++- monai/utils/__init__.py | 1 + monai/utils/enums.py | 43 ++- tests/test_grid_patch.py | 2 + tests/test_grid_patchd.py | 3 +- tests/test_handler_prob_map_producer.py | 8 +- tests/test_masked_patch_wsi_dataset.py | 86 ++++++ tests/test_patch_wsi_dataset_new.py | 38 ++- tests/test_rand_grid_patch.py | 2 + tests/test_rand_grid_patchd.py | 2 + tests/test_sliding_patch_wsi_dataset.py | 91 +++--- tests/test_wsireader_new.py | 35 ++- 21 files changed, 576 insertions(+), 262 deletions(-) create mode 100644 tests/test_masked_patch_wsi_dataset.py diff --git a/docs/source/data.rst b/docs/source/data.rst index 7f59e587ec..0de5e0c347 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -312,6 +312,11 @@ PatchWSIDataset .. autoclass:: monai.data.PatchWSIDataset :members: +MaskedPatchWSIDataset +~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.MaskedPatchWSIDataset + :members: + SlidingPatchWSIDataset ~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.SlidingPatchWSIDataset diff --git a/monai/apps/pathology/transforms/spatial/array.py b/monai/apps/pathology/transforms/spatial/array.py index 34cabac50c..ce22b86d49 100644 --- a/monai/apps/pathology/transforms/spatial/array.py +++ b/monai/apps/pathology/transforms/spatial/array.py @@ -211,7 +211,7 @@ def __call__(self, image: NdarrayOrTensor) -> NdarrayOrTensor: constant_values=self.background_val, ) - # extact tiles + # extract tiles x_step, y_step = self.step, self.step h_tile, w_tile = self.tile_size, self.tile_size c_image, h_image, w_image = img_np.shape diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 40ee3cfc29..293f058acf 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -104,7 +104,7 @@ worker_init_fn, zoom_affine, ) -from .wsi_datasets import PatchWSIDataset, SlidingPatchWSIDataset +from .wsi_datasets import MaskedPatchWSIDataset, PatchWSIDataset, SlidingPatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader with contextlib.suppress(BaseException): diff --git a/monai/data/utils.py b/monai/data/utils.py index 93f96feae9..6f18e566e0 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -247,7 +247,7 @@ def iter_patch( start_pos: Sequence[int] = (), overlap: Union[Sequence[float], float] = 0.0, copy_back: bool = True, - mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, + mode: Optional[Union[NumpyPadMode, str]] = NumpyPadMode.WRAP, **pad_opts: Dict, ): """ @@ -262,10 +262,8 @@ def iter_patch( overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. copy_back: if True data from the yielded patches is copied back to `arr` once the generator completes - mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, - ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - One of the listed string values or a user supplied function. Defaults to ``"wrap"``. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + mode: One of the listed string values in ``monai.utils.NumpyPadMode`` or ``monai.utils.PytorchPadMode``, + or a user supplied function. If None, no wrapping is performed. Defaults to ``"wrap"``. pad_opts: padding options, see `numpy.pad` Yields: @@ -285,19 +283,28 @@ def iter_patch( patch_size_ = get_valid_patch_size(arr.shape, patch_size) start_pos = ensure_tuple_size(start_pos, arr.ndim) + # set padded flag to false if pad mode is None + padded = True if mode else False # pad image by maximum values needed to ensure patches are taken from inside an image - arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts) - - # choose a start position in the padded image - start_pos_padded = tuple(s + p for s, p in zip(start_pos, patch_size_)) - - # choose a size to iterate over which is smaller than the actual padded image to prevent producing - # patches which are only in the padded regions - iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) + if padded: + arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts) + # choose a start position in the padded image + start_pos_padded = tuple(s + p for s, p in zip(start_pos, patch_size_)) + + # choose a size to iterate over which is smaller than the actual padded image to prevent producing + # patches which are only in the padded regions + iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) + else: + arrpad = arr + start_pos_padded = start_pos + iter_size = arr.shape - for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap): + for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap, padded=padded): # compensate original image padding - coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + if padded: + coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + else: + coords_no_pad = tuple((coord.start, coord.stop) for coord in slices) yield arrpad[slices], np.asarray(coords_no_pad) # data and coords (in numpy; works with torch loader) # copy back data from the padded image if required diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 5e4cae6d90..689e25d8ca 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -10,6 +10,7 @@ # limitations under the License. import inspect +import os from typing import Callable, Dict, Optional, Sequence, Tuple, Union import numpy as np @@ -17,10 +18,11 @@ from monai.data import Dataset from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader -from monai.transforms import Randomizable, apply_transform -from monai.utils import ensure_tuple_rep +from monai.transforms import ForegroundMask, Randomizable, apply_transform +from monai.utils import CommonKeys, ProbMapKeys, ensure_tuple_rep +from monai.utils.enums import WSIPatchKeys -__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset"] +__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset", "MaskedPatchWSIDataset"] class PatchWSIDataset(Dataset): @@ -33,6 +35,9 @@ class PatchWSIDataset(Dataset): size: the size of patch to be extracted from the whole slide image. level: the level at which the patches to be extracted (default to 0). transform: transforms to be executed on input data. + include_label: whether to load and include labels in the output + center_location: whether the input location information is the position of the center of the patch + additional_meta_keys: the list of keys for items to be copied to the output metadata from the input data reader: the module to be used for loading whole slide imaging. If `reader` is - a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. @@ -47,8 +52,8 @@ class PatchWSIDataset(Dataset): .. code-block:: python [ - {"image": "path/to/image1.tiff", "location": [200, 500], "label": 0}, - {"image": "path/to/image2.tiff", "location": [100, 700], "size": [20, 20], "level": 2, "label": 1} + {"image": "path/to/image1.tiff", "patch_location": [200, 500], "label": 0}, + {"image": "path/to/image2.tiff", "patch_location": [100, 700], "patch_size": [20, 20], "patch_level": 2, "label": 1} ] """ @@ -56,64 +61,73 @@ class PatchWSIDataset(Dataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, transform: Optional[Callable] = None, + include_label: bool = True, + center_location: bool = True, + additional_meta_keys: Optional[Sequence[str]] = None, reader="cuCIM", **kwargs, ): super().__init__(data, transform) # Ensure patch size is a two dimensional tuple - if size is None: - self.size = None + if patch_size is None: + self.patch_size = None else: - self.size = ensure_tuple_rep(size, 2) + self.patch_size = ensure_tuple_rep(patch_size, 2) # Create a default level that override all levels if it is not None - self.level = level + self.patch_level = patch_level # Set the default WSIReader's level to 0 if level is not provided - if level is None: - level = 0 + if patch_level is None: + patch_level = 0 # Setup the WSI reader self.wsi_reader: Union[WSIReader, BaseWSIReader] - self.backend = "" if isinstance(reader, str): - self.backend = reader.lower() - self.wsi_reader = WSIReader(backend=self.backend, level=level, **kwargs) + self.wsi_reader = WSIReader(backend=reader, level=patch_level, **kwargs) elif inspect.isclass(reader) and issubclass(reader, BaseWSIReader): - self.wsi_reader = reader(level=level, **kwargs) + self.wsi_reader = reader(level=patch_level, **kwargs) elif isinstance(reader, BaseWSIReader): self.wsi_reader = reader else: raise ValueError(f"Unsupported reader type: {reader}.") + self.backend = self.wsi_reader.backend + + self.include_label = include_label + self.center_location = center_location + self.additional_meta_keys = additional_meta_keys or [] # Initialized an empty whole slide image object dict self.wsi_object_dict: Dict = {} def _get_wsi_object(self, sample: Dict): - image_path = sample["image"] + image_path = sample[CommonKeys.IMAGE] if image_path not in self.wsi_object_dict: self.wsi_object_dict[image_path] = self.wsi_reader.read(image_path) return self.wsi_object_dict[image_path] def _get_label(self, sample: Dict): - return np.array(sample["label"], dtype=np.float32) + return np.array(sample[CommonKeys.LABEL], dtype=np.float32) def _get_location(self, sample: Dict): - size = self._get_size(sample) - return [sample["location"][i] - size[i] // 2 for i in range(len(size))] + if self.center_location: + size = self._get_size(sample) + return [sample[WSIPatchKeys.LOCATION][i] - size[i] // 2 for i in range(len(size))] + else: + return sample[WSIPatchKeys.LOCATION] def _get_level(self, sample: Dict): - if self.level is None: - return sample.get("level", 0) - return self.level + if self.patch_level is None: + return sample.get(WSIPatchKeys.LEVEL, 0) + return self.patch_level def _get_size(self, sample: Dict): - if self.size is None: - return ensure_tuple_rep(sample.get("size"), 2) - return self.size + if self.patch_size is None: + return ensure_tuple_rep(sample.get(WSIPatchKeys.SIZE), 2) + return self.patch_size def _get_data(self, sample: Dict): # Don't store OpenSlide objects to avoid issues with OpenSlide internal cache @@ -131,12 +145,16 @@ def _transform(self, index: int): # Extract patch image and associated metadata image, metadata = self._get_data(sample) + output = {CommonKeys.IMAGE: image, CommonKeys.METADATA: metadata} + + # Include label in the output + if self.include_label: + output[CommonKeys.LABEL] = self._get_label(sample) - # Get the label - label = self._get_label(sample) + for key in self.additional_meta_keys: + metadata[key] = sample[key] - # Apply transforms and output - output = {"image": image, "label": label, "metadata": metadata} + # Apply transforms and return it return apply_transform(self.transform, output) if self.transform else output @@ -172,7 +190,7 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): [ {"image": "path/to/image1.tiff"}, - {"image": "path/to/image2.tiff", "size": [20, 20], "level": 2} + {"image": "path/to/image2.tiff", "patch_size": [20, 20], "patch_level": 2} ] """ @@ -180,18 +198,32 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, + mask_level: int = 0, overlap: Union[Tuple[float, float], float] = 0.0, offset: Union[Tuple[int, int], int, str] = (0, 0), offset_limits: Optional[Union[Tuple[Tuple[int, int], Tuple[int, int]], Tuple[int, int]]] = None, transform: Optional[Callable] = None, + include_label: bool = False, + center_location: bool = False, + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.SIZE, ProbMapKeys.COUNT), reader="cuCIM", map_level: int = 0, seed: int = 0, **kwargs, ): - super().__init__(data=[], size=size, level=level, transform=transform, reader=reader, **kwargs) + super().__init__( + data=data, + patch_size=patch_size, + patch_level=patch_level, + transform=transform, + include_label=include_label, + center_location=center_location, + additional_meta_keys=additional_meta_keys, + reader=reader, + **kwargs, + ) self.overlap = overlap self.set_random_state(seed) # Set the offset config @@ -221,12 +253,12 @@ def __init__( else: self.offset = ensure_tuple_rep(offset, 2) - self.map_level = map_level + self.mask_level = mask_level # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data for sample in self.image_data: - patch_samples = self._evaluate_patch_coordinates(sample) + patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) def _get_offset(self, sample): @@ -238,49 +270,133 @@ def _get_offset(self, sample): return tuple(self.R.randint(low, high) for low, high in offset_limits) return self.offset - def _evaluate_patch_coordinates(self, sample): - """Define the location for each patch in a sliding-window manner""" + def _evaluate_patch_locations(self, sample): + """Calculate the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) - level = self._get_level(sample) - offset = self._get_offset(sample) - + patch_level = self._get_level(sample) wsi_obj = self._get_wsi_object(sample) + + # calculate the locations wsi_size = self.wsi_reader.get_size(wsi_obj, 0) - ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, level) - patch_size_ = tuple(p * ratio for p in patch_size) # patch size at level 0 - locations = list( - iter_patch_position( - image_size=wsi_size, patch_size=patch_size_, start_pos=offset, overlap=self.overlap, padded=False + mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, patch_level) + patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 + offset = self._get_offset(sample) + patch_locations = np.array( + list( + iter_patch_position( + image_size=wsi_size, patch_size=patch_size_0, start_pos=offset, overlap=self.overlap, padded=False + ) ) ) - n_patches = len(locations) - sample["size"] = np.array(patch_size) - sample["level"] = level - sample["num_patches"] = n_patches - sample["map_level"] = self.map_level - sample["map_size"] = np.array(self.wsi_reader.get_size(wsi_obj, self.map_level)) + # convert locations to mask_location + mask_locations = np.round((patch_locations + patch_size_0 // 2) / float(mask_ratio)) + + # fill out samples with location and metadata + sample[WSIPatchKeys.SIZE.value] = patch_size + sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) + sample[ProbMapKeys.COUNT.value] = len(patch_locations) + sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ - {**sample, "location": np.array(loc), "map_center": self.downsample_center(loc, patch_size, ratio)} - for loc in locations + {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} + for loc, mask_loc in zip(patch_locations, mask_locations) ] - def downsample_center(self, location: Tuple[int, int], patch_size: Tuple[int, int], ratio: float) -> np.ndarray: - """ - For a given location at level=0, evaluate the corresponding center position of patch at level=`level` - """ - center_location = [int((l + p // 2) / ratio) for l, p in zip(location, patch_size)] - return np.array(center_location) - def _get_location(self, sample: Dict): - return sample["location"] +class MaskedPatchWSIDataset(PatchWSIDataset): + """ + This dataset extracts patches from whole slide images at the locations where foreground mask + at a given level is non-zero. - def _transform(self, index: int): - # Get a single entry of data - sample: Dict = self.data[index] - # Extract patch image and associated metadata - image, metadata = self._get_data(sample) - for k in ["num_patches", "map_level", "map_size", "map_center"]: - metadata[k] = sample[k] - # Create put all patch information together and apply transforms - patch = {"image": image, "metadata": metadata} - return apply_transform(self.transform, patch) if self.transform else patch + Args: + data: the list of input samples including image, location, and label (see the note below for more details). + size: the size of patch to be extracted from the whole slide image. + level: the level at which the patches to be extracted (default to 0). + mask_level: the resolution level at which the mask is created. + transform: transforms to be executed on input data. + include_label: whether to load and include labels in the output + center_location: whether the input location information is the position of the center of the patch + additional_meta_keys: the list of keys for items to be copied to the output metadata from the input data + reader: the module to be used for loading whole slide imaging. Defaults to cuCIM. If `reader` is + + - a string, it defines the backend of `monai.data.WSIReader`. + - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader, + - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + + kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class + + Note: + The input data has the following form as an example: + + .. code-block:: python + + [ + {"image": "path/to/image1.tiff"}, + {"image": "path/to/image2.tiff", "patch_size": [20, 20], "patch_level": 2} + ] + + """ + + def __init__( + self, + data: Sequence, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, + mask_level: int = 7, + transform: Optional[Callable] = None, + include_label: bool = False, + center_location: bool = False, + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.NAME), + reader="cuCIM", + **kwargs, + ): + super().__init__( + data=data, + patch_size=patch_size, + patch_level=patch_level, + transform=transform, + include_label=include_label, + center_location=center_location, + additional_meta_keys=additional_meta_keys, + reader=reader, + **kwargs, + ) + + self.mask_level = mask_level + # Create single sample for each patch (in a sliding window manner) + self.data = [] + self.image_data = data + for sample in self.image_data: + patch_samples = self._evaluate_patch_locations(sample) + self.data.extend(patch_samples) + + def _evaluate_patch_locations(self, sample): + """Calculate the location for each patch based on the mask at different resolution level""" + patch_size = self._get_size(sample) + patch_level = self._get_level(sample) + wsi_obj = self._get_wsi_object(sample) + + # load the entire image at level=mask_level + wsi, _ = self.wsi_reader.get_data(wsi_obj, level=self.mask_level) + + # create the foreground tissue mask and get all indices for non-zero pixels + mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) + mask_locations = np.vstack(mask.nonzero()).T + + # convert mask locations to image locations at level=0 + mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, patch_level) + patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 + patch_locations = np.round((mask_locations + 0.5) * float(mask_ratio) - patch_size_0 // 2).astype(int) + + # fill out samples with location and metadata + sample[WSIPatchKeys.SIZE.value] = patch_size + sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) + sample[ProbMapKeys.COUNT.value] = len(patch_locations) + sample[ProbMapKeys.SIZE.value] = mask.shape + return [ + {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} + for loc, mask_loc in zip(patch_locations, mask_locations) + ] diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 434edfd9cc..0bb2de987c 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -18,8 +18,7 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import AsChannelFirst -from monai.utils import ensure_tuple, optional_import, require_pkg +from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") OpenSlide, _ = optional_import("openslide", name="OpenSlide") @@ -56,9 +55,10 @@ class BaseWSIReader(ImageReader): supported_suffixes: List[str] = [] backend = "" - def __init__(self, level: int, **kwargs): + def __init__(self, level: int, channel_dim: int = 0, **kwargs): super().__init__() self.level = level + self.channel_dim = channel_dim self.kwargs = kwargs self.metadata: Dict[Any, Any] = {} @@ -136,14 +136,18 @@ def get_metadata( level: the level number. Defaults to 0 """ + if self.channel_dim >= len(patch.shape) or self.channel_dim < -len(patch.shape): + ValueError(f"The desired channel_dim ({self.channel_dim}) is out of bound for image shape: {patch.shape}") + channel_dim: int = self.channel_dim + (len(patch.shape) if self.channel_dim < 0 else 0) metadata: Dict = { "backend": self.backend, - "original_channel_dim": 0, - "spatial_shape": np.asarray(patch.shape[1:]), - "path": self.get_file_path(wsi), - "patch_location": np.asarray(location), - "patch_size": np.asarray(size), - "patch_level": level, + "original_channel_dim": channel_dim, + "spatial_shape": np.array(patch.shape[:channel_dim] + patch.shape[channel_dim + 1 :]), + "num_patches": 1, + WSIPatchKeys.PATH.value: self.get_file_path(wsi), + WSIPatchKeys.LOCATION.value: np.asarray(location), + WSIPatchKeys.SIZE.value: np.asarray(size), + WSIPatchKeys.LEVEL.value: level, } return metadata @@ -173,7 +177,7 @@ def get_data( and second element is a dictionary of metadata """ patch_list: List = [] - metadata = {} + metadata_list: List = [] # CuImage object is iterable, so ensure_tuple won't work on single object if not isinstance(wsi, List): wsi = [wsi] @@ -195,7 +199,7 @@ def get_data( # Verify size if size is None: if location != (0, 0): - raise ValueError("Patch size should be defined to exctract patches.") + raise ValueError("Patch size should be defined to extract patches.") size = self.get_size(each_wsi, level) else: if size[0] <= 0 or size[1] <= 0: @@ -211,40 +215,30 @@ def get_data( "`WSIReader` is designed to work only with 2D images with color channel." ) # Check if there are four color channels for RGBA - if mode == "RGBA" and patch.shape[0] != 4: - raise ValueError( - f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." - ) + if mode == "RGBA": + if patch.shape[self.channel_dim] != 4: + raise ValueError( + f"The image is expected to have four color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}." + ) # Check if there are three color channels for RGB - elif mode in "RGB" and patch.shape[0] != 3: + elif mode in "RGB" and patch.shape[self.channel_dim] != 3: raise ValueError( - f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " + f"The image is expected to have three color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}. " ) - # Create a list of patches + # Get patch-related metadata + metadata: dict = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) + # Create a list of patches and metadata patch_list.append(patch) - - # Set patch-related metadata - each_meta = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) - - if len(wsi) == 1: - metadata = each_meta - else: - if not metadata: - metadata = { - "backend": each_meta["backend"], - "original_channel_dim": each_meta["original_channel_dim"], - "spatial_shape": each_meta["spatial_shape"], - } - for k in ["path", "patch_size", "patch_level", "patch_location"]: - metadata[k] = [each_meta[k]] - else: - if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: - raise ValueError("original_channel_dim is not consistent across wsi objects.") - if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): - raise ValueError("spatial_shape is not consistent across wsi objects.") - for k in ["path", "patch_size", "patch_level", "patch_location"]: - metadata[k].append(each_meta[k]) - + metadata_list.append(metadata) + if len(wsi) > 1: + if len({m["original_channel_dim"] for m in metadata_list}) > 1: + raise ValueError("original_channel_dim is not consistent across wsi objects.") + if len({tuple(m["spatial_shape"]) for m in metadata_list}) > 1: + raise ValueError("spatial_shape is not consistent across wsi objects.") + for key in WSIPatchKeys: + metadata[key] = [m[key] for m in metadata_list] return _stack_images(patch_list, metadata), metadata def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: @@ -267,18 +261,19 @@ class WSIReader(BaseWSIReader): Args: backend: the name of backend whole slide image reader library, the default is cuCIM. level: the level at which patches are extracted. + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional arguments to be passed to the backend library """ - def __init__(self, backend="cucim", level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, backend="cucim", level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) self.backend = backend.lower() self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader] if self.backend == "cucim": - self.reader = CuCIMWSIReader(level=level, **kwargs) + self.reader = CuCIMWSIReader(level=level, channel_dim=channel_dim, **kwargs) elif self.backend == "openslide": - self.reader = OpenSlideWSIReader(level=level, **kwargs) + self.reader = OpenSlideWSIReader(level=level, channel_dim=channel_dim, **kwargs) else: raise ValueError(f"The supported backends are cucim and openslide, '{self.backend}' was given.") self.supported_suffixes = self.reader.supported_suffixes @@ -360,6 +355,7 @@ class CuCIMWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional args for `cucim.CuImage` module: https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h @@ -368,8 +364,8 @@ class CuCIMWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "cucim" - def __init__(self, level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -457,14 +453,15 @@ def get_patch( # Convert to numpy patch = np.asarray(patch, dtype=dtype) - # Make it channel first - patch = AsChannelFirst()(patch) # type: ignore + # Make the channel to desired dimensions + patch = np.moveaxis(patch, -1, self.channel_dim) # Check if the color channel is 3 (RGB) or 4 (RGBA) if mode in "RGB": - if patch.shape[0] not in [3, 4]: + if patch.shape[self.channel_dim] not in [3, 4]: raise ValueError( - f"The image is expected to have three or four color channels in '{mode}' mode but has {patch.shape[0]}. " + f"The image is expected to have three or four color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}. " ) patch = patch[:3] @@ -479,6 +476,7 @@ class OpenSlideWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional args for `openslide.OpenSlide` module. """ @@ -486,8 +484,8 @@ class OpenSlideWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "openslide" - def __init__(self, level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -577,7 +575,7 @@ def get_patch( # Convert to numpy patch = np.asarray(pil_patch, dtype=dtype) - # Make it channel first - patch = AsChannelFirst()(patch) # type: ignore + # Make the channel to desired dimensions + patch = np.moveaxis(patch, -1, self.channel_dim) return patch diff --git a/monai/handlers/probability_maps.py b/monai/handlers/probability_maps.py index 03588e605b..5c15704d50 100644 --- a/monai/handlers/probability_maps.py +++ b/monai/handlers/probability_maps.py @@ -71,9 +71,9 @@ def attach(self, engine: Engine) -> None: # Initialized probability maps for all the images for sample in image_data: - name = sample[ProbMapKeys.PRE_PATH.value] - self.counter[name] = sample[ProbMapKeys.COUNT.value] - self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE.value], dtype=self.dtype) + name = sample[ProbMapKeys.NAME] + self.counter[name] = sample[ProbMapKeys.COUNT] + self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE], dtype=self.dtype) if self._name is None: self.logger = engine.logger @@ -91,8 +91,8 @@ def __call__(self, engine: Engine) -> None: """ if not isinstance(engine.state.batch, dict) or not isinstance(engine.state.output, dict): raise ValueError("engine.state.batch and engine.state.output must be dictionaries.") - names = engine.state.batch["metadata"][ProbMapKeys.PATH.value] - locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION.value] + names = engine.state.batch["metadata"][ProbMapKeys.NAME] + locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION] probs = engine.state.output[self.prob_key] for name, loc, prob in zip(names, locs, probs): self.prob_map[name][loc] = prob diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 43ed2df62a..9cbd4f4310 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2210,7 +2210,6 @@ def __init__( raise ValueError( f"Threshold for at least one channel of RGB or HSV needs to be set. {self.thresholds} is provided." ) - self.invert = invert def _set_threshold(self, threshold, mode): diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 92cbf8b580..e157959a90 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2637,7 +2637,9 @@ class GridPatch(Transform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. """ @@ -2647,42 +2649,38 @@ class GridPatch(Transform): def __init__( self, patch_size: Sequence[int], - offset: Sequence[int] = (), + offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): self.patch_size = ensure_tuple(patch_size) - self.offset = ensure_tuple(offset) - self.pad_mode: NumpyPadMode = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) + self.offset = ensure_tuple(offset) if offset else (0,) * len(self.patch_size) + self.pad_mode: Optional[NumpyPadMode] = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) if pad_mode else None self.pad_kwargs = pad_kwargs self.overlap = overlap self.num_patches = num_patches self.sort_fn: Optional[Callable] if isinstance(sort_fn, str): - if sort_fn == GridPatchSort.RANDOM.value: - self.sort_fn = np.random.random - elif sort_fn == GridPatchSort.MIN.value: - self.sort_fn = self.get_patch_sum - elif sort_fn == GridPatchSort.MAX.value: - self.sort_fn = self.get_negative_patch_sum - else: - raise ValueError( - f'sort_fn should be one of the following values, "{sort_fn}" was given:', - [enum.value for enum in GridPatchSort], - ) + self.sort_fn = GridPatchSort.get_sort_fn(sort_fn) else: self.sort_fn = sort_fn - @staticmethod - def get_patch_sum(x): - return x[0].sum() + self.threshold = threshold + if threshold: + self.filter_fn = self.threshold_fn + else: + self.filter_fn = self.one_fn @staticmethod - def get_negative_patch_sum(x): - return -x[0].sum() + def one_fn(patch): + return True + + def threshold_fn(self, patch): + return patch.sum() < self.threshold def __call__(self, array: NdarrayOrTensor): # create the patch iterator which sweeps the image row-by-row @@ -2696,18 +2694,25 @@ def __call__(self, array: NdarrayOrTensor): mode=self.pad_mode, **self.pad_kwargs, ) + if self.sort_fn is not None: - output = sorted(patch_iterator, key=self.sort_fn) - else: - output = list(patch_iterator) + patch_iterator = sorted(patch_iterator, key=self.sort_fn) + + output = [ + (convert_to_dst_type(src=patch, dst=array)[0], convert_to_dst_type(src=slices[..., 0], dst=array)[0]) + for patch, slices in patch_iterator + if self.filter_fn(patch) + ] + if self.num_patches: output = output[: self.num_patches] if len(output) < self.num_patches: - patch = np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)) - slices = np.zeros((3, len(self.patch_size))) - output += [(patch, slices)] * (self.num_patches - len(output)) - - output = [convert_to_dst_type(src=patch, dst=array)[0] for patch in output] + patch = convert_to_dst_type( + src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), + dst=array, + )[0] + start_location = convert_to_dst_type(src=np.zeros((len(self.patch_size), 1)), dst=array)[0] + output += [(patch, start_location)] * (self.num_patches - len(output)) return output @@ -2731,7 +2736,9 @@ class RandGridPatch(GridPatch, RandomizableTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. """ @@ -2746,6 +2753,7 @@ def __init__( num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): @@ -2755,6 +2763,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 4a38dbbb59..a1dd60b6a0 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -62,6 +62,7 @@ InterpolateMode, NumpyPadMode, PytorchPadMode, + WSIPatchKeys, ensure_tuple, ensure_tuple_rep, fall_back_tuple, @@ -2170,7 +2171,7 @@ class GridSplitd(MapTransform): keys: keys of the corresponding items to be transformed. grid: a tuple define the shape of the grid upon which the image is split. Defaults to (2, 2) size: a tuple or an integer that defines the output patch sizes, - or a dictionary that define it seperately for each key, like {"image": 3, "mask", (2, 2)}. + or a dictionary that define it separately for each key, like {"image": 3, "mask", (2, 2)}. If it's an integer, the value will be repeated for each dimension. The default is None, where the patch size will be inferred from the grid shape. allow_missing_keys: don't raise exception if key is missing. @@ -2220,7 +2221,9 @@ class GridPatchd(MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2228,7 +2231,7 @@ class GridPatchd(MapTransform): a list of dictionaries, each of which contains the all the original key/value with the values for `keys` replaced by the patches. It also add the following new keys: - "slices": slices from the image that defines the patch, + "patch_location": the starting location of the patch in the image, "patch_size": size of the extracted patch "num_patches": total number of patches in the image "offset": the amount of offset for the patches in the image (starting position of upper left patch) @@ -2240,10 +2243,11 @@ def __init__( self, keys: KeysCollection, patch_size: Sequence[int], - offset: Sequence[int] = (), + offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2255,6 +2259,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) @@ -2272,9 +2277,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["slices"] = patch[0][1] # use the coordinate of the first item - new_dict["patch_size"] = self.patcher.patch_size - new_dict["num_patches"] = num_patches + new_dict[WSIPatchKeys.LOCATION] = patch[0][1] # use the starting coordinate of the first item + new_dict[WSIPatchKeys.SIZE] = self.patcher.patch_size + new_dict[WSIPatchKeys.COUNT] = num_patches new_dict["offset"] = self.patcher.offset output.append(new_dict) return output @@ -2300,7 +2305,9 @@ class RandGridPatchd(RandomizableTransform, MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2308,7 +2315,7 @@ class RandGridPatchd(RandomizableTransform, MapTransform): a list of dictionaries, each of which contains the all the original key/value with the values for `keys` replaced by the patches. It also add the following new keys: - "slices": slices from the image that defines the patch, + "patch_location": the starting location of the patch in the image, "patch_size": size of the extracted patch "num_patches": total number of patches in the image "offset": the amount of offset for the patches in the image (starting position of the first patch) @@ -2326,6 +2333,7 @@ def __init__( num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2338,6 +2346,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) @@ -2368,9 +2377,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["slices"] = patch[0][1] # use the coordinate of the first item - new_dict["patch_size"] = self.patcher.patch_size - new_dict["num_patches"] = num_patches + new_dict[WSIPatchKeys.LOCATION] = patch[0][1] # use the starting coordinate of the first item + new_dict[WSIPatchKeys.SIZE] = self.patcher.patch_size + new_dict[WSIPatchKeys.COUNT] = num_patches new_dict["offset"] = self.patcher.offset output.append(new_dict) return output diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 0d5d8bf92d..33b2a5fa2a 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -40,6 +40,7 @@ TransformBackends, UpsampleMode, Weight, + WSIPatchKeys, ) from .jupyter_utils import StatusMembers, ThreadContainer from .misc import ( diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 9fb4e480f6..51d2772414 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import random from enum import Enum from typing import Optional @@ -273,6 +274,7 @@ class CommonKeys: LABEL = "label" PRED = "pred" LOSS = "loss" + METADATA = "metadata" class PostFix: @@ -332,7 +334,7 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] -class ProbMapKeys(Enum): +class ProbMapKeys(str, Enum): """ The keys to be used for generating the probability maps from patches """ @@ -340,11 +342,10 @@ class ProbMapKeys(Enum): LOCATION = "mask_location" SIZE = "mask_size" COUNT = "num_patches" - PATH = "path" - PRE_PATH = "image" + NAME = "name" -class GridPatchSort(Enum): +class GridPatchSort(str, Enum): """ The sorting method for the generated patches in `GridPatch` """ @@ -352,3 +353,37 @@ class GridPatchSort(Enum): RANDOM = "random" MIN = "min" MAX = "max" + + @staticmethod + def min_fn(x): + return x[0].sum() + + @staticmethod + def max_fn(x): + return -x[0].sum() + + @staticmethod + def get_sort_fn(sort_fn): + if sort_fn == GridPatchSort.RANDOM: + return random.random + elif sort_fn == GridPatchSort.MIN: + return GridPatchSort.min_fn + elif sort_fn == GridPatchSort.MAX: + return GridPatchSort.max_fn + else: + raise ValueError( + f'sort_fn should be one of the following values, "{sort_fn}" was given:', + [e.value for e in GridPatchSort], + ) + + +class WSIPatchKeys(str, Enum): + """ + The keys to be used for metadata of patches extracted from whole slide images + """ + + LOCATION = "patch_location" + LEVEL = "patch_level" + SIZE = "patch_size" + COUNT = "num_patches" + PATH = "path" diff --git a/tests/test_grid_patch.py b/tests/test_grid_patch.py index c1d73f262f..8a105afcd2 100644 --- a/tests/test_grid_patch.py +++ b/tests/test_grid_patch.py @@ -44,6 +44,7 @@ A, [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], ] +TEST_CASE_13 = [{"patch_size": (2, 2), "threshold": 50.0}, A, [A11]] TEST_SINGLE = [] @@ -61,6 +62,7 @@ TEST_SINGLE.append([p, *TEST_CASE_10]) TEST_SINGLE.append([p, *TEST_CASE_11]) TEST_SINGLE.append([p, *TEST_CASE_12]) + TEST_SINGLE.append([p, *TEST_CASE_13]) class TestGridPatch(unittest.TestCase): diff --git a/tests/test_grid_patchd.py b/tests/test_grid_patchd.py index a9eec8a2f6..8f1e238b42 100644 --- a/tests/test_grid_patchd.py +++ b/tests/test_grid_patchd.py @@ -44,7 +44,7 @@ {"image": A}, [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], ] - +TEST_CASE_13 = [{"patch_size": (2, 2), "threshold": 50.0}, {"image": A}, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -61,6 +61,7 @@ TEST_SINGLE.append([p, *TEST_CASE_10]) TEST_SINGLE.append([p, *TEST_CASE_11]) TEST_SINGLE.append([p, *TEST_CASE_12]) + TEST_SINGLE.append([p, *TEST_CASE_13]) class TestGridPatchd(unittest.TestCase): diff --git a/tests/test_handler_prob_map_producer.py b/tests/test_handler_prob_map_producer.py index 38ab7044b2..399657ac60 100644 --- a/tests/test_handler_prob_map_producer.py +++ b/tests/test_handler_prob_map_producer.py @@ -42,7 +42,11 @@ def __init__(self, name, size): ] ) self.image_data = [ - {"image": name, ProbMapKeys.COUNT.value: size, ProbMapKeys.SIZE.value: np.array([size, size])} + { + ProbMapKeys.NAME.value: name, + ProbMapKeys.COUNT.value: size, + ProbMapKeys.SIZE.value: np.array([size, size]), + } ] def __getitem__(self, index): @@ -50,7 +54,7 @@ def __getitem__(self, index): "image": np.zeros((3, 2, 2)), ProbMapKeys.COUNT.value: self.data[index][ProbMapKeys.COUNT.value], "metadata": { - "path": self.data[index]["image"], + ProbMapKeys.NAME.value: self.data[index]["image"], ProbMapKeys.SIZE.value: self.data[index][ProbMapKeys.SIZE.value], ProbMapKeys.LOCATION.value: self.data[index][ProbMapKeys.LOCATION.value], }, diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py new file mode 100644 index 0000000000..c1d202f8f4 --- /dev/null +++ b/tests/test_masked_patch_wsi_dataset.py @@ -0,0 +1,86 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import MaskedPatchWSIDataset +from monai.utils import WSIPatchKeys, optional_import, set_determinism +from tests.utils import testing_data_config + +set_determinism(0) + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (2, 2)}], "mask_level": 8}, + { + "num_patches": 4256, + "wsi_size": [32914, 46000], + "mask_level": 8, + "patch_level": 8, + "mask_size": (128, 179), + "patch_size": (2, 2), + }, +] + + +class MaskedPatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0]) + def test_gen_patches(self, input_parameters, expected): + dataset = MaskedPatchWSIDataset(reader=self.backend, **input_parameters) + self.assertEqual(len(dataset), expected["num_patches"]) + for i, sample in enumerate(dataset): + self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected["patch_level"]) + assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected["patch_size"]) + assert_array_equal(sample["image"].shape[1:], expected["patch_size"]) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][0] >= 0) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][0] < expected["wsi_size"][0]) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][1] >= 0) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][1] < expected["wsi_size"][1]) + if i > 10: + break + + +@skipUnless(has_cucim, "Requires cucim") +class TestSlidingPatchWSIDatasetCuCIM(MaskedPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestSlidingPatchWSIDatasetOpenSlide(MaskedPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py index d128d45262..65e65035c4 100644 --- a/tests/test_patch_wsi_dataset_new.py +++ b/tests/test_patch_wsi_dataset_new.py @@ -35,41 +35,41 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1], "level": 0}], "size": (1, 1)}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "size": 1, "label": [1]}]}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": 1, "level": 0}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "size": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, ] TEST_CASE_4 = [ { "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, ], - "size": 1, + "patch_size": 1, }, [ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, @@ -80,8 +80,20 @@ TEST_CASE_5 = [ { "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]], "size": 1, "level": 1}, - {"image": FILE_PATH, "location": [100, 100], "label": [[[1, 0], [0, 0]]], "size": 1, "level": 1}, + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, ] }, [ @@ -129,9 +141,9 @@ def test_read_patches_class(self, input_parameters, expected): @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_read_patches_object(self, input_parameters, expected): if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("level", 0)) + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("level", 0)) + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) else: raise ValueError("Unsupported backend: {self.backend}") dataset = PatchWSIDataset(reader=reader, **input_parameters) diff --git a/tests/test_rand_grid_patch.py b/tests/test_rand_grid_patch.py index 36da899982..3957dc1ce8 100644 --- a/tests/test_rand_grid_patch.py +++ b/tests/test_rand_grid_patch.py @@ -55,6 +55,7 @@ A, [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], ] +TEST_CASE_10 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "threshold": 50.0}, A, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -68,6 +69,7 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) class TestRandGridPatch(unittest.TestCase): diff --git a/tests/test_rand_grid_patchd.py b/tests/test_rand_grid_patchd.py index 6f89a3d155..656fbd9e36 100644 --- a/tests/test_rand_grid_patchd.py +++ b/tests/test_rand_grid_patchd.py @@ -55,6 +55,7 @@ {"image": A}, [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], ] +TEST_CASE_10 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "threshold": 50.0}, {"image": A}, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -68,6 +69,7 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) class TestRandGridPatchd(unittest.TestCase): diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index ded0a9213c..5f2a2c0d55 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -14,10 +14,11 @@ from unittest import skipUnless import numpy as np +from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import SlidingPatchWSIDataset -from monai.utils import optional_import, set_determinism +from monai.utils import WSIPatchKeys, optional_import, set_determinism from tests.utils import download_url_or_skip_test, testing_data_config set_determinism(0) @@ -41,7 +42,7 @@ ARRAY_SMALL_1 = np.random.randint(low=0, high=255, size=(3, 5, 5), dtype=np.uint8) TEST_CASE_SMALL_0 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2)}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (2, 2)}, [ {"image": ARRAY_SMALL_0[:, :2, :2]}, {"image": ARRAY_SMALL_0[:, :2, 2:]}, @@ -51,7 +52,7 @@ ] TEST_CASE_SMALL_1 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}]}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}]}, [ {"image": ARRAY_SMALL_0[:, :2, :2]}, {"image": ARRAY_SMALL_0[:, :2, 2:]}, @@ -61,7 +62,7 @@ ] TEST_CASE_SMALL_2 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2), "overlap": 0.5}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (2, 2), "overlap": 0.5}, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, {"image": ARRAY_SMALL_0[:, 0:2, 1:3]}, @@ -76,7 +77,7 @@ ] TEST_CASE_SMALL_3 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (3, 3), "overlap": 2.0 / 3.0}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (3, 3), "overlap": 2.0 / 3.0}, [ {"image": ARRAY_SMALL_0[:, :3, :3]}, {"image": ARRAY_SMALL_0[:, :3, 1:]}, @@ -86,7 +87,13 @@ ] TEST_CASE_SMALL_4 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}, {"image": FILE_PATH_SMALL_1, "level": 0}], "size": (2, 2)}, + { + "data": [ + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 0}, + ], + "patch_size": (2, 2), + }, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, @@ -102,8 +109,8 @@ TEST_CASE_SMALL_5 = [ { "data": [ - {"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}, - {"image": FILE_PATH_SMALL_1, "level": 0, "size": (3, 3)}, + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (3, 3)}, ] }, [ @@ -118,11 +125,11 @@ TEST_CASE_SMALL_6 = [ { "data": [ - {"image": FILE_PATH_SMALL_0, "level": 1, "size": (1, 1)}, - {"image": FILE_PATH_SMALL_1, "level": 2, "size": (4, 4)}, + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 1, WSIPatchKeys.SIZE: (1, 1)}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 2, WSIPatchKeys.SIZE: (4, 4)}, ], - "size": (2, 2), - "level": 0, + "patch_size": (2, 2), + "patch_level": 0, }, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, @@ -138,18 +145,22 @@ TEST_CASE_SMALL_7 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": (1, 0)}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], "offset": (1, 0)}, [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], ] TEST_CASE_SMALL_8 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": "random", "offset_limits": (0, 2)}, + { + "data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], + "offset": "random", + "offset_limits": (0, 2), + }, [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], ] TEST_CASE_SMALL_9 = [ { - "data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], + "data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], "offset": "random", "offset_limits": ((0, 3), (0, 2)), }, @@ -157,37 +168,37 @@ ] TEST_CASE_LARGE_0 = [ - {"data": [{"image": FILE_PATH, "level": 8, "size": (64, 50)}]}, + {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (64, 50)}]}, [ - {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, ], ] TEST_CASE_LARGE_1 = [ { "data": [ - {"image": FILE_PATH, "level": 8, "size": (64, 50)}, - {"image": FILE_PATH, "level": 7, "size": (125, 110)}, + {"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (64, 50)}, + {"image": FILE_PATH, WSIPatchKeys.LEVEL: 7, WSIPatchKeys.SIZE: (125, 110)}, ] }, [ - {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (0, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (0, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 1), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 2), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 0), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 1), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 2), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, ], ] @@ -233,11 +244,11 @@ def test_read_patches_large(self, input_parameters, expected): dataset = SlidingPatchWSIDataset(reader=self.backend, **input_parameters) self.assertEqual(len(dataset), len(expected)) for i, sample in enumerate(dataset): - self.assertEqual(sample["metadata"]["patch_level"], expected[i]["level"]) - self.assertTupleEqual(tuple(sample["metadata"]["patch_size"]), expected[i]["size"]) - steps = [round(expected[i]["ratio"] * s) for s in expected[i]["size"]] + self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected[i]["patch_level"]) + assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected[i]["patch_size"]) + steps = [round(expected[i]["ratio"] * s) for s in expected[i]["patch_size"]] expected_location = tuple(expected[i]["step_loc"][j] * steps[j] for j in range(len(steps))) - self.assertTupleEqual(tuple(sample["metadata"]["patch_location"]), expected_location) + assert_array_equal(sample["metadata"][WSIPatchKeys.LOCATION], expected_location) @skipUnless(has_cucim, "Requires cucim") diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 35aaccad26..8330603db0 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -14,7 +14,6 @@ from unittest import skipUnless import numpy as np -import torch from numpy.testing import assert_array_equal from parameterized import parameterized @@ -23,7 +22,7 @@ from monai.transforms import Compose, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -46,17 +45,33 @@ TEST_CASE_1 = [ FILE_PATH, + {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_2 = [ FILE_PATH, + {}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] TEST_CASE_3 = [ + FILE_PATH, + {"channel_dim": -1}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), +] + +TEST_CASE_4 = [ + FILE_PATH, + {"channel_dim": 2}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), +] + +TEST_CASE_MULTI_WSI = [ [FILE_PATH, FILE_PATH], {"location": (0, 0), "size": (2, 1), "level": 2}, np.concatenate( @@ -68,6 +83,7 @@ ), ] + TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW @@ -133,11 +149,10 @@ def test_read_whole_image(self, file_path, level, expected_shape): assert_array_equal(meta["patch_size"], expected_shape[1:]) assert_array_equal(meta["patch_location"], (0, 0)) - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_read_region(self, file_path, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_region(self, file_path, kwargs, patch_info, expected_img): reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path, **kwargs) as img_obj: + with reader.read(file_path) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): reader.get_data(img_obj, **patch_info)[0] @@ -152,10 +167,10 @@ def test_read_region(self, file_path, patch_info, expected_img): self.assertEqual(meta["backend"], self.backend) self.assertEqual(meta["path"], str(os.path.abspath(file_path))) self.assertEqual(meta["patch_level"], patch_info["level"]) - assert_array_equal(meta["patch_size"], expected_img.shape[1:]) + assert_array_equal(meta["patch_size"], patch_info["size"]) assert_array_equal(meta["patch_location"], patch_info["location"]) - @parameterized.expand([TEST_CASE_3]) + @parameterized.expand([TEST_CASE_MULTI_WSI]) def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} reader = WSIReader(self.backend, **kwargs) @@ -222,7 +237,7 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, expected_shape) @parameterized.expand([TEST_CASE_TRANSFORM_0]) @@ -238,7 +253,7 @@ def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, e data_loader = DataLoader(dataset, batch_size=batch_size) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) From 4cddaa830b61b88ec78e089bb5f21e05bb1a78f4 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Fri, 3 Jun 2022 11:30:08 -0400 Subject: [PATCH 130/183] add RetinaNet detector loss computation (#4446) --- docs/source/apps.rst | 9 +- .../detection/networks/retinanet_detector.py | 470 ++++++++++++++++-- .../detection/networks/retinanet_network.py | 4 +- monai/apps/detection/transforms/array.py | 4 +- monai/apps/detection/utils/ATSS_matcher.py | 2 +- monai/apps/detection/utils/anchor_utils.py | 4 +- monai/apps/detection/utils/detector_utils.py | 2 +- .../detection/utils/hard_negative_sampler.py | 24 +- tests/test_retinanet_detector.py | 36 +- 9 files changed, 503 insertions(+), 52 deletions(-) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 514a1a05f1..255db787c2 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -131,11 +131,16 @@ Applications .. automodule:: monai.apps.detection.utils.hard_negative_sampler :members: -`RetinaNet` -~~~~~~~~~~~ +`RetinaNet Network` +~~~~~~~~~~~~~~~~~~~ .. automodule:: monai.apps.detection.networks.retinanet_network :members: +`RetinaNet Detector` +~~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.networks.retinanet_detector + :members: + `Transforms` ~~~~~~~~~~~~ .. automodule:: monai.apps.detection.transforms.box_ops diff --git a/monai/apps/detection/networks/retinanet_detector.py b/monai/apps/detection/networks/retinanet_detector.py index 9b673b1195..f1eb98bddf 100644 --- a/monai/apps/detection/networks/retinanet_detector.py +++ b/monai/apps/detection/networks/retinanet_detector.py @@ -46,21 +46,28 @@ from monai.apps.detection.networks.retinanet_network import RetinaNet, resnet_fpn_feature_extractor from monai.apps.detection.utils.anchor_utils import AnchorGenerator +from monai.apps.detection.utils.ATSS_matcher import ATSSMatcher from monai.apps.detection.utils.box_coder import BoxCoder from monai.apps.detection.utils.box_selector import BoxSelector from monai.apps.detection.utils.detector_utils import check_training_targets, preprocess_images +from monai.apps.detection.utils.hard_negative_sampler import HardNegativeSampler from monai.apps.detection.utils.predict_utils import ensure_dict_value_to_list_, predict_with_inferer from monai.data.box_utils import box_iou from monai.inferers import SlidingWindowInferer from monai.networks.nets import resnet -from monai.utils import BlendMode, PytorchPadMode, ensure_tuple_rep +from monai.utils import BlendMode, PytorchPadMode, ensure_tuple_rep, optional_import + +BalancedPositiveNegativeSampler, _ = optional_import( + "torchvision.models.detection._utils", name="BalancedPositiveNegativeSampler" +) +Matcher, _ = optional_import("torchvision.models.detection._utils", name="Matcher") class RetinaNetDetector(nn.Module): """ Retinanet detector, expandable to other one stage anchor based box detectors in the future. An example of construction can found in the source code of - :func:`~monai.apps.detection.networks.retinanet_detector.retinanet_resnet50_fpn_detector` . + :func:`~monai.apps.detection.networks.retinanet_detector.retinanet_resnet50_fpn_detector` . The input to the model is expected to be a list of tensors, each of shape (C, H, W) or (C, H, W, D), one for each image, and should be in 0-1 range. Different images can have different sizes. @@ -71,32 +78,34 @@ class RetinaNetDetector(nn.Module): During training, the model expects both the input tensors, as well as a targets (list of dictionary), containing: - - boxes (``FloatTensor[N, 4]`` or ``FloatTensor[N, 6]``): the ground-truth boxes in ``StandardMode``, i.e., - ``[xmin, ymin, xmax, ymax]`` or ``[xmin, ymin, zmin, xmax, ymax, zmax]`` format, - with ``0 <= xmin < xmax <= H``, ``0 <= ymin < ymax <= W``, ``0 <= zmin < zmax <= D``. - - labels: the class label for each ground-truth box + - boxes (``FloatTensor[N, 4]`` or ``FloatTensor[N, 6]``): the ground-truth boxes in ``StandardMode``, i.e., + ``[xmin, ymin, xmax, ymax]`` or ``[xmin, ymin, zmin, xmax, ymax, zmax]`` format, + with ``0 <= xmin < xmax <= H``, ``0 <= ymin < ymax <= W``, ``0 <= zmin < zmax <= D``. + - labels: the class label for each ground-truth box - The model returns a Dict[Tensor] during training, containing the classification and regression + The model returns a Dict[str, Tensor] during training, containing the classification and regression losses. - When save the model, only self.network contains trainable parameters and needs to be saved. + When saving the model, only self.network contains trainable parameters and needs to be saved. During inference, the model requires only the input tensors, and returns the post-processed predictions as a List[Dict[Tensor]], one for each input image. The fields of the Dict are as follows: - - boxes (``FloatTensor[N, 4]`` or ``FloatTensor[N, 6]``): the ground-truth boxes in ``StandardMode``, i.e., - ``[xmin, ymin, xmax, ymax]`` or ``[xmin, ymin, zmin, xmax, ymax, zmax]`` format, - with ``0 <= xmin < xmax <= H``, ``0 <= ymin < ymax <= W``, ``0 <= zmin < zmax <= D``. - - labels (Int64Tensor[N]): the predicted labels for each image - - labels_scores (Tensor[N]): the scores for each prediction + + - boxes (``FloatTensor[N, 4]`` or ``FloatTensor[N, 6]``): the predicted boxes in ``StandardMode``, i.e., + ``[xmin, ymin, xmax, ymax]`` or ``[xmin, ymin, zmin, xmax, ymax, zmax]`` format, + with ``0 <= xmin < xmax <= H``, ``0 <= ymin < ymax <= W``, ``0 <= zmin < zmax <= D``. + - labels (Int64Tensor[N]): the predicted labels for each image + - labels_scores (Tensor[N]): the scores for each prediction Args: network: a network that takes an image Tensor sized (B, C, H, W) or (B, C, H, W, D) as input - and outputs a dictionary Dict[str, List[Tensor]]. + and outputs a dictionary Dict[str, List[Tensor]] or Dict[str, Tensor]. anchor_generator: anchor generator. box_overlap_metric: func that compute overlap between two sets of boxes, default is Intersection over Union (IoU). debug: whether to print out internal parameters, used for debugging and parameter tuning. Notes: + Input argument ``network`` can be a monai.apps.detection.networks.retinanet_network.RetinaNet(*) object, but any network that meets the following rules is a valid input ``network``. @@ -105,7 +114,7 @@ class RetinaNetDetector(nn.Module): - spatial_dims (int) is the spatial dimension of the network, we support both 2D and 3D. - num_classes (int) is the number of classes, excluding the background. - size_divisible (int or Sequene[int]) is the expection on the input image shape. - The network needs the input spatial_size to be divisible by size_divisible + The network needs the input spatial_size to be divisible by size_divisible, length should be 2 or 3. - cls_key (str) is the key to represent classification in the output dict. - box_reg_key (str) is the key to represent box regression in the output dict. - num_anchors (int) is the number of anchor shapes at each location. it should equal to @@ -126,6 +135,7 @@ class RetinaNetDetector(nn.Module): - ``len(head_outputs[network.cls_key]) == len(head_outputs[network.box_reg_key])``. Example: + .. code-block:: python # define a naive network @@ -206,6 +216,13 @@ def __init__( self.box_overlap_metric = box_overlap_metric self.debug = debug + # default setting for training + self.fg_bg_sampler: Union[Any, None] = None + self.set_cls_loss(torch.nn.BCEWithLogitsLoss(reduction="mean")) # classification loss + self.set_box_regression_loss( + torch.nn.SmoothL1Loss(beta=1.0 / 9, reduction="mean"), encode_gt=True, decode_pred=False + ) # box regression loss + # default setting for both training and inference # can be updated by self.set_box_coder_weights(*) self.box_coder = BoxCoder(weights=(1.0,) * 2 * self.spatial_dims) @@ -252,6 +269,110 @@ def set_target_keys(self, box_key: str, label_key: str): self.target_label_key = label_key self.pred_score_key = label_key + "_scores" + def set_cls_loss(self, cls_loss: nn.Module) -> None: + """ + Using for training. Set loss for classification that takes logits as inputs, make sure sigmoid/softmax is built in. + + Args: + cls_loss: loss module for classification + + Example: + .. code-block:: python + + detector.set_cls_loss(torch.nn.BCEWithLogitsLoss(reduction="mean")) + detector.set_cls_loss(FocalLoss(reduction="mean", gamma=2.0)) + """ + self.cls_loss_func = cls_loss + + def set_box_regression_loss(self, box_loss: nn.Module, encode_gt: bool, decode_pred: bool) -> None: + """ + Using for training. Set loss for box regression. + + Args: + box_loss: loss module for box regression + encode_gt: if True, will encode ground truth boxes to target box regression + before computing the losses. Should be True for L1 loss and False for GIoU loss. + decode_pred: if True, will decode predicted box regression into predicted boxes + before computing losses. Should be False for L1 loss and True for GIoU loss. + + Example: + .. code-block:: python + + detector.set_box_regression_loss( + torch.nn.SmoothL1Loss(beta=1.0 / 9, reduction="mean"), + encode_gt = True, decode_pred = False + ) + """ + self.box_loss_func = box_loss + self.encode_gt = encode_gt + self.decode_pred = decode_pred + + def set_regular_matcher(self, fg_iou_thresh: float, bg_iou_thresh: float, allow_low_quality_matches=True) -> None: + """ + Using for training. Set torchvision matcher that matches anchors with ground truth boxes. + + Args: + fg_iou_thresh: foreground IoU threshold for Matcher, considered as matched if IoU > fg_iou_thresh + bg_iou_thresh: background IoU threshold for Matcher, considered as not matched if IoU < bg_iou_thresh + """ + if fg_iou_thresh < bg_iou_thresh: + raise ValueError( + "Require fg_iou_thresh >= bg_iou_thresh. " + f"Got fg_iou_thresh={fg_iou_thresh}, bg_iou_thresh={bg_iou_thresh}." + ) + self.proposal_matcher = Matcher(fg_iou_thresh, bg_iou_thresh, allow_low_quality_matches=True) + + def set_atss_matcher(self, num_candidates: int = 4, center_in_gt: bool = False) -> None: + """ + Using for training. Set ATSS matcher that matches anchors with ground truth boxes + + Args: + num_candidates: number of positions to select candidates from. + Smaller value will result in a higher matcher threshold and less matched candidates. + center_in_gt: If False (default), matched anchor center points do not need + to lie withing the ground truth box. Recommend False for small objects. + If True, will result in a strict matcher and less matched candidates. + """ + self.proposal_matcher = ATSSMatcher(num_candidates, self.box_overlap_metric, center_in_gt, debug=self.debug) + + def set_hard_negative_sampler( + self, batch_size_per_image: int, positive_fraction: float, min_neg: int = 1, pool_size: float = 10 + ): + """ + Using for training. Set hard negative sampler that samples part of the anchors for training. + + HardNegativeSampler is used to suppress false positive rate in classification tasks. + During training, it select negative samples with high prediction scores. + + Args: + batch_size_per_image: number of elements to be selected per image + positive_fraction: percentage of positive elements in the selected samples + min_neg: minimum number of negative samples to select if possible. + pool_size: when we need ``num_neg`` hard negative samples, they will be randomly selected from + ``num_neg * pool_size`` negative samples with the highest prediction scores. + Larger ``pool_size`` gives more randomness, yet selects negative samples that are less 'hard', + i.e., negative samples with lower prediction scores. + """ + self.fg_bg_sampler = HardNegativeSampler( + batch_size_per_image=batch_size_per_image, + positive_fraction=positive_fraction, + min_neg=min_neg, + pool_size=pool_size, + ) + + def set_balanced_sampler(self, batch_size_per_image: int, positive_fraction: float): + """ + Using for training. Set torchvision balanced sampler that samples part of the anchors for training. + + Args: + batch_size_per_image: number of elements to be selected per image + positive_fraction: percentage of positive elements per batch + + """ + self.fg_bg_sampler = BalancedPositiveNegativeSampler( + batch_size_per_image=batch_size_per_image, positive_fraction=positive_fraction + ) + def set_sliding_window_inferer( self, roi_size: Union[Sequence[int], int], @@ -349,17 +470,7 @@ def forward( # 1. Check if input arguments are valid if self.training: check_training_targets(input_images, targets, self.spatial_dims, self.target_label_key, self.target_box_key) - if not hasattr(self, "proposal_matcher"): - raise AttributeError( - "Matcher is not set. Please refer to self.set_regular_matcher(*) or self.set_atss_matcher(*)." - ) - if self.fg_bg_sampler is None and self.debug: - warnings.warn( - "No balanced sampler is used. Negative samples are likely to " - "be much more than positive samples. Please set balanced samplers with self.set_balanced_sampler(*) " - "or self.set_hard_negative_sampler(*), " - "or set classification loss function as Focal loss with self.set_cls_loss(*)" - ) + self._check_detector_training_components() # 2. Pad list of images to a single Tensor `images` with spatial size divisible by self.size_divisible. # image_sizes stores the original spatial_size of each image before padding. @@ -389,7 +500,7 @@ def forward( # reshape to Tensor sized(B, sum(HWA), self.num_classes) for self.cls_key # or (B, sum(HWA), 2* self.spatial_dims) for self.box_reg_key # A = self.num_anchors_per_loc - head_outputs[key] = self.reshape_maps(head_outputs[key]) + head_outputs[key] = self._reshape_maps(head_outputs[key]) # 6(1). If during training, return losses if self.training: @@ -402,6 +513,22 @@ def forward( ) return detections + def _check_detector_training_components(self): + """ + Check if self.proposal_matcher and self.fg_bg_sampler have been set for training. + """ + if not hasattr(self, "proposal_matcher"): + raise AttributeError( + "Matcher is not set. Please refer to self.set_regular_matcher(*) or self.set_atss_matcher(*)." + ) + if self.fg_bg_sampler is None and self.debug: + warnings.warn( + "No balanced sampler is used. Negative samples are likely to " + "be much more than positive samples. Please set balanced samplers with self.set_balanced_sampler(*) " + "or self.set_hard_negative_sampler(*), " + "or set classification loss function as Focal loss with self.set_cls_loss(*)" + ) + def generate_anchors(self, images: Tensor, head_outputs: Dict[str, List[Tensor]]): """ Generate anchors and store it in self.anchors: List[Tensor]. @@ -418,7 +545,7 @@ def generate_anchors(self, images: Tensor, head_outputs: Dict[str, List[Tensor]] self.anchors = self.anchor_generator(images, head_outputs[self.cls_key]) # List[Tensor], len = batchsize self.previous_image_shape = images.shape - def reshape_maps(self, result_maps: List[Tensor]) -> Tensor: + def _reshape_maps(self, result_maps: List[Tensor]) -> Tensor: """ Concat network output map list to a single Tensor. This function is used in both training and inference. @@ -554,7 +681,290 @@ def compute_loss( Return: a dict of several kinds of losses. """ - return {} + matched_idxs = self.compute_anchor_matched_idxs(anchors, targets, num_anchor_locs_per_level) + losses_cls = self.compute_cls_loss(head_outputs_reshape[self.cls_key], targets, matched_idxs) + losses_box_regression = self.compute_box_loss( + head_outputs_reshape[self.box_reg_key], targets, anchors, matched_idxs + ) + return {self.cls_key: losses_cls, self.box_reg_key: losses_box_regression} + + def compute_anchor_matched_idxs( + self, anchors: List[Tensor], targets: List[Dict[str, Tensor]], num_anchor_locs_per_level: Sequence[int] + ) -> List[Tensor]: + """ + Compute the matched indices between anchors and ground truth (gt) boxes in targets. + output[k][i] represents the matched gt index for anchor[i] in image k. + Suppose there are M gt boxes for image k. The range of it output[k][i] value is [-2, -1, 0, ..., M-1]. + [0, M - 1] indicates this anchor is matched with a gt box, + while a negative value indicating that it is not matched. + + Args: + anchors: a list of Tensor. Each Tensor represents anchors for each image, + sized (sum(HWA), 2*spatial_dims) or (sum(HWDA), 2*spatial_dims). + A = self.num_anchors_per_loc. + targets: a list of dict. Each dict with two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + num_anchor_locs_per_level: each element represents HW or HWD at this level. + + + Return: + a list of matched index `matched_idxs_per_image` (Tensor[int64]), Tensor sized (sum(HWA),) or (sum(HWDA),). + Suppose there are M gt boxes. `matched_idxs_per_image[i]` is a matched gt index in [0, M - 1] + or a negative value indicating that anchor i could not be matched. + BELOW_LOW_THRESHOLD = -1, BETWEEN_THRESHOLDS = -2 + """ + matched_idxs = [] + for anchors_per_image, targets_per_image in zip(anchors, targets): + # anchors_per_image: Tensor, targets_per_image: Dice[str, Tensor] + if targets_per_image[self.target_box_key].numel() == 0: + # if no GT boxes + matched_idxs.append( + torch.full((anchors_per_image.size(0),), -1, dtype=torch.int64, device=anchors_per_image.device) + ) + continue + + # matched_idxs_per_image (Tensor[int64]): Tensor sized (sum(HWA),) or (sum(HWDA),) + # Suppose there are M gt boxes. matched_idxs_per_image[i] is a matched gt index in [0, M - 1] + # or a negative value indicating that anchor i could not be matched. + # BELOW_LOW_THRESHOLD = -1, BETWEEN_THRESHOLDS = -2 + if isinstance(self.proposal_matcher, Matcher): + # if torcvision matcher + match_quality_matrix = self.box_overlap_metric( + targets_per_image[self.target_box_key].to(anchors_per_image.device), anchors_per_image + ) + matched_idxs_per_image = self.proposal_matcher(match_quality_matrix) + elif isinstance(self.proposal_matcher, ATSSMatcher): + # if monai ATSS matcher + match_quality_matrix, matched_idxs_per_image = self.proposal_matcher( + targets_per_image[self.target_box_key].to(anchors_per_image.device), + anchors_per_image, + num_anchor_locs_per_level, + self.num_anchors_per_loc, + ) + else: + raise NotImplementedError( + "Currently support torchvision Matcher and monai ATSS matcher. Other types of matcher not supported. " + "Please override self.compute_anchor_matched_idxs(*) for your own matcher." + ) + + if self.debug: + print(f"Max box overlap between anchors and gt boxes: {torch.max(match_quality_matrix,dim=1)[0]}.") + + if torch.max(matched_idxs_per_image) < 0: + warnings.warn( + f"No anchor is matched with GT boxes. Please adjust matcher setting, anchor setting," + " or the network setting to change zoom scale between network output and input images." + f"GT boxes are {targets_per_image[self.target_box_key]}." + ) + + matched_idxs.append(matched_idxs_per_image) + return matched_idxs + + def compute_cls_loss( + self, cls_logits: Tensor, targets: List[Dict[str, Tensor]], matched_idxs: List[Tensor] + ) -> Tensor: + """ + Compute classification losses. + + Args: + cls_logits: classification logits, sized (B, sum(HW(D)A), self.num_classes) + targets: a list of dict. Each dict with two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + matched_idxs: a list of matched index. each element is sized (sum(HWA),) or (sum(HWDA),) + + Return: + classification losses. + """ + total_cls_logits_list = [] + total_gt_classes_target_list = [] + for targets_per_image, cls_logits_per_image, matched_idxs_per_image in zip(targets, cls_logits, matched_idxs): + # for each image, get training samples + sampled_cls_logits_per_image, sampled_gt_classes_target = self.get_cls_train_sample_per_image( + cls_logits_per_image, targets_per_image, matched_idxs_per_image + ) + total_cls_logits_list.append(sampled_cls_logits_per_image) + total_gt_classes_target_list.append(sampled_gt_classes_target) + + total_cls_logits = torch.cat(total_cls_logits_list, dim=0) + total_gt_classes_target = torch.cat(total_gt_classes_target_list, dim=0) + losses: Tensor = self.cls_loss_func(total_cls_logits, total_gt_classes_target).to(total_cls_logits.dtype) + return losses + + def compute_box_loss( + self, + box_regression: Tensor, + targets: List[Dict[str, Tensor]], + anchors: List[Tensor], + matched_idxs: List[Tensor], + ) -> Tensor: + """ + Compute box regression losses. + + Args: + box_regression: box regression results, sized (B, sum(HWA), 2*self.spatial_dims) + targets: a list of dict. Each dict with two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + anchors: a list of Tensor. Each Tensor represents anchors for each image, + sized (sum(HWA), 2*spatial_dims) or (sum(HWDA), 2*spatial_dims). + A = self.num_anchors_per_loc. + matched_idxs: a list of matched index. each element is sized (sum(HWA),) or (sum(HWDA),) + + Return: + box regression losses. + """ + total_box_regression_list = [] + total_target_regression_list = [] + + for targets_per_image, box_regression_per_image, anchors_per_image, matched_idxs_per_image in zip( + targets, box_regression, anchors, matched_idxs + ): + # for each image, get training samples + decode_box_regression_per_image, matched_gt_boxes_per_image = self.get_box_train_sample_per_image( + box_regression_per_image, targets_per_image, anchors_per_image, matched_idxs_per_image + ) + total_box_regression_list.append(decode_box_regression_per_image) + total_target_regression_list.append(matched_gt_boxes_per_image) + + total_box_regression = torch.cat(total_box_regression_list, dim=0) + total_target_regression = torch.cat(total_target_regression_list, dim=0) + + if total_box_regression.shape[0] == 0: + # if there is no training sample. + losses = torch.tensor(0.0) + return losses + + losses = self.box_loss_func(total_box_regression, total_target_regression).to(total_box_regression.dtype) + + return losses + + def get_cls_train_sample_per_image( + self, cls_logits_per_image: Tensor, targets_per_image: Dict[str, Tensor], matched_idxs_per_image: Tensor + ) -> Tuple[Tensor, Tensor]: + """ + Get samples from one image for classification losses computation. + + Args: + cls_logits_per_image: classification logits for one image, (sum(HWA), self.num_classes) + targets_per_image: a dict with at least two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + matched_idxs_per_image: matched index, Tensor sized (sum(HWA),) or (sum(HWDA),) + Suppose there are M gt boxes. matched_idxs_per_image[i] is a matched gt index in [0, M - 1] + or a negative value indicating that anchor i could not be matched. + BELOW_LOW_THRESHOLD = -1, BETWEEN_THRESHOLDS = -2 + + Return: + paired predicted and GT samples from one image for classification losses computation + """ + + if torch.isnan(cls_logits_per_image).any() or torch.isinf(cls_logits_per_image).any(): + raise ValueError("NaN or Inf in predicted classification logits.") + + foreground_idxs_per_image = matched_idxs_per_image >= 0 + + num_foreground = foreground_idxs_per_image.sum() + num_gt_box = targets_per_image[self.target_box_key].shape[0] + + if self.debug: + print(f"Number of positive (matched) anchors: {num_foreground}; Number of GT box: {num_gt_box}.") + if num_gt_box > 0 and num_foreground < 2 * num_gt_box: + print( + f"Only {num_foreground} anchors are matched with {num_gt_box} GT boxes. " + "Please consider adjusting matcher setting, anchor setting," + " or the network setting to change zoom scale between network output and input images." + ) + + # create the target classification with one-hot encoding + gt_classes_target = torch.zeros_like(cls_logits_per_image) # (sum(HW(D)A), self.num_classes) + gt_classes_target[ + foreground_idxs_per_image, # fg anchor idx in + targets_per_image[self.target_label_key][ + matched_idxs_per_image[foreground_idxs_per_image] + ], # fg class label + ] = 1.0 + + if self.fg_bg_sampler is None: + # if no balanced sampling + valid_idxs_per_image = matched_idxs_per_image != self.proposal_matcher.BETWEEN_THRESHOLDS + else: + # The input of fg_bg_sampler: list of tensors containing -1, 0 or positive values. + # Each tensor corresponds to a specific image. + # -1 values are ignored, 0 are considered as negatives and > 0 as positives. + + # matched_idxs_per_image (Tensor[int64]): an N tensor where N[i] is a matched gt in + # [0, M - 1] or a negative value indicating that prediction i could not + # be matched. BELOW_LOW_THRESHOLD = -1, BETWEEN_THRESHOLDS = -2 + if isinstance(self.fg_bg_sampler, HardNegativeSampler): + max_cls_logits_per_image = torch.max(cls_logits_per_image.to(torch.float32), dim=1)[0] + sampled_pos_inds_list, sampled_neg_inds_list = self.fg_bg_sampler( + [matched_idxs_per_image + 1], max_cls_logits_per_image + ) + elif isinstance(self.fg_bg_sampler, BalancedPositiveNegativeSampler): + sampled_pos_inds_list, sampled_neg_inds_list = self.fg_bg_sampler([matched_idxs_per_image + 1]) + else: + raise NotImplementedError( + "Currently support torchvision BalancedPositiveNegativeSampler and monai HardNegativeSampler matcher. " + "Other types of sampler not supported. " + "Please override self.get_cls_train_sample_per_image(*) for your own sampler." + ) + + sampled_pos_inds = torch.where(torch.cat(sampled_pos_inds_list, dim=0))[0] + sampled_neg_inds = torch.where(torch.cat(sampled_neg_inds_list, dim=0))[0] + valid_idxs_per_image = torch.cat([sampled_pos_inds, sampled_neg_inds], dim=0) + + return cls_logits_per_image[valid_idxs_per_image, :], gt_classes_target[valid_idxs_per_image, :] + + def get_box_train_sample_per_image( + self, + box_regression_per_image: Tensor, + targets_per_image: Dict[str, Tensor], + anchors_per_image: Tensor, + matched_idxs_per_image: Tensor, + ) -> Tuple[Tensor, Tensor]: + """ + Get samples from one image for box regression losses computation. + + Args: + box_regression_per_image: box regression result for one image, (sum(HWA), 2*self.spatial_dims) + targets_per_image: a dict with at least two keys: self.target_box_key and self.target_label_key, + ground-truth boxes present in the image. + anchors_per_image: anchors of one image, + sized (sum(HWA), 2*spatial_dims) or (sum(HWDA), 2*spatial_dims). + A = self.num_anchors_per_loc. + matched_idxs_per_image: matched index, sized (sum(HWA),) or (sum(HWDA),) + + Return: + paired predicted and GT samples from one image for box regression losses computation + """ + + if torch.isnan(box_regression_per_image).any() or torch.isinf(box_regression_per_image).any(): + raise ValueError("NaN or Inf in predicted box regression.") + + foreground_idxs_per_image = torch.where(matched_idxs_per_image >= 0)[0] + num_gt_box = targets_per_image[self.target_box_key].shape[0] + + # if no GT box, return empty arrays + if num_gt_box == 0: + return box_regression_per_image[0:0, :], box_regression_per_image[0:0, :] + + # select only the foreground boxes + # matched GT boxes for foreground anchors + matched_gt_boxes_per_image = targets_per_image[self.target_box_key][ + matched_idxs_per_image[foreground_idxs_per_image] + ].to(box_regression_per_image.device) + # predicted box regression for foreground anchors + box_regression_per_image = box_regression_per_image[foreground_idxs_per_image, :] + # foreground anchors + anchors_per_image = anchors_per_image[foreground_idxs_per_image, :] + + # encode GT boxes or decode predicted box regression before computing losses + matched_gt_boxes_per_image_ = matched_gt_boxes_per_image + box_regression_per_image_ = box_regression_per_image + if self.encode_gt: + matched_gt_boxes_per_image_ = self.box_coder.encode_single(matched_gt_boxes_per_image_, anchors_per_image) + if self.decode_pred: + box_regression_per_image_ = self.box_coder.decode_single(box_regression_per_image_, anchors_per_image) + + return box_regression_per_image_, matched_gt_boxes_per_image_ def retinanet_resnet50_fpn_detector( @@ -569,6 +979,7 @@ def retinanet_resnet50_fpn_detector( Returns a RetinaNet detector using a ResNet-50 as backbone, which can be pretrained from `Med3D: Transfer Learning for 3D Medical Image Analysis ` _. + Args: num_classes: number of output classes of the model (excluding the background). anchor_generator: AnchorGenerator, @@ -582,6 +993,7 @@ def retinanet_resnet50_fpn_detector( A RetinaNetDetector object with resnet50 as backbone Example: + .. code-block:: python # define a naive network diff --git a/monai/apps/detection/networks/retinanet_network.py b/monai/apps/detection/networks/retinanet_network.py index 63a109e2b5..4539a913ac 100644 --- a/monai/apps/detection/networks/retinanet_network.py +++ b/monai/apps/detection/networks/retinanet_network.py @@ -211,7 +211,7 @@ class RetinaNet(nn.Module): num_anchors: number of anchors at each location. feature_extractor: a network that outputs feature maps from the input images, each feature map corresponds to a different resolution. - Its output can have format of Tensor, Dict[Any, Tensor], or Sequence[Tensor]. + Its output can have a format of Tensor, Dict[Any, Tensor], or Sequence[Tensor]. It can be the output of ``resnet_fpn_feature_extractor(*args, **kwargs)``. size_divisible: the spatial size of the network input should be divisible by size_divisible, decided by the feature_extractor. @@ -242,7 +242,7 @@ class RetinaNet(nn.Module): trainable_backbone_layers = None, returned_layers = returned_layers, ) - # This feature_extractor requires input imgage spatial size + # This feature_extractor requires input image spatial size # to be divisible by (32, 32, 16). size_divisible = tuple(2*s*2**max(returned_layers) for s in conv1_t_stride) model = RetinaNet( diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 2f1cdbdbe5..cb788f8d92 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -328,8 +328,8 @@ def __call__( # type: ignore class ClipBoxToImage(Transform): """ - Clip the bounding boxes and the associated labels/scores to makes sure they are within the image. - There might be multiple arryas of labels/scores associated with one array of boxes. + Clip the bounding boxes and the associated labels/scores to make sure they are within the image. + There might be multiple arrays of labels/scores associated with one array of boxes. Args: remove_empty: whether to remove the boxes and corresponding labels that are actually empty diff --git a/monai/apps/detection/utils/ATSS_matcher.py b/monai/apps/detection/utils/ATSS_matcher.py index 8f92f50f89..f0170422bb 100644 --- a/monai/apps/detection/utils/ATSS_matcher.py +++ b/monai/apps/detection/utils/ATSS_matcher.py @@ -189,7 +189,7 @@ def __init__( self.center_in_gt = center_in_gt self.debug = debug logging.info( - f"Running ATSS Matching with num_candidates={self.num_candidates} " f"and center_in_gt {self.center_in_gt}." + f"Running ATSS Matching with num_candidates={self.num_candidates} and center_in_gt {self.center_in_gt}." ) def compute_matches( diff --git a/monai/apps/detection/utils/anchor_utils.py b/monai/apps/detection/utils/anchor_utils.py index 5821c0c353..c028228d95 100644 --- a/monai/apps/detection/utils/anchor_utils.py +++ b/monai/apps/detection/utils/anchor_utils.py @@ -72,7 +72,7 @@ class AnchorGenerator(nn.Module): sizes: base size of each anchor. len(sizes) is the number of feature maps, i.e., the number of output levels for the feature pyramid network (FPN). - Each elment of ``sizes`` is a Sequence which represents several anchor sizes for each feature map. + Each element of ``sizes`` is a Sequence which represents several anchor sizes for each feature map. aspect_ratios: the aspect ratios of anchors. ``len(aspect_ratios) = len(sizes)``. For 2D images, each element of ``aspect_ratios[i]`` is a Sequence of float. For 3D images, each element of ``aspect_ratios[i]`` is a Sequence of 2 value Sequence. @@ -286,7 +286,7 @@ def forward(self, images: Tensor, feature_maps: List[Tensor]) -> List[Tensor]: Args: images: sized (B, C, W, H) or (B, C, W, H, D) - feature_maps: for FPN level i, feature_maps[i] is sizec (B, C_i, W_i, H_i) or (B, C_i, W_i, H_i, D_i). + feature_maps: for FPN level i, feature_maps[i] is sized (B, C_i, W_i, H_i) or (B, C_i, W_i, H_i, D_i). This input argument does not have to be the actual feature maps. Any list variable with the same (C_i, W_i, H_i) or (C_i, W_i, H_i, D_i) as feature maps works. diff --git a/monai/apps/detection/utils/detector_utils.py b/monai/apps/detection/utils/detector_utils.py index e63a732f25..a3188f53b1 100644 --- a/monai/apps/detection/utils/detector_utils.py +++ b/monai/apps/detection/utils/detector_utils.py @@ -97,7 +97,7 @@ def pad_images( ) -> Tuple[Tensor, List[List[int]]]: """ Pad the input images, so that the output spatial sizes are divisible by `size_divisible`. - It pad them at the end to create a (B, C, H, W) or (B, C, H, W, D) Tensor. + It pads them at the end to create a (B, C, H, W) or (B, C, H, W, D) Tensor. Padded size (H, W) or (H, W, D) is divisible by size_divisible. Default padding uses constant padding with value 0.0 diff --git a/monai/apps/detection/utils/hard_negative_sampler.py b/monai/apps/detection/utils/hard_negative_sampler.py index e572a3bb3a..3067a41a0e 100644 --- a/monai/apps/detection/utils/hard_negative_sampler.py +++ b/monai/apps/detection/utils/hard_negative_sampler.py @@ -100,7 +100,7 @@ def select_negatives(self, negative: Tensor, num_neg: int, fg_probs: Tensor) -> class HardNegativeSampler(HardNegativeSamplerBase): """ HardNegativeSampler is used to suppress false positive rate in classification tasks. - During training, it select negative samples with high prediction scores. + During training, it selects negative samples with high prediction scores. The training workflow is described as the follows: 1) forward network and get prediction scores (classification prob/logits) for all the samples; @@ -109,7 +109,7 @@ class HardNegativeSampler(HardNegativeSamplerBase): 4) do back propagation. Args: - select_sample_size_per_image: number of training samples to be randomly selected per image + batch_size_per_image: number of training samples to be randomly selected per image positive_fraction: percentage of positive elements in the selected samples min_neg: minimum number of negative samples to select if possible. pool_size: when we need ``num_neg`` hard negative samples, they will be randomly selected from @@ -119,11 +119,11 @@ class HardNegativeSampler(HardNegativeSamplerBase): """ def __init__( - self, select_sample_size_per_image: int, positive_fraction: float, min_neg: int = 1, pool_size: float = 10 + self, batch_size_per_image: int, positive_fraction: float, min_neg: int = 1, pool_size: float = 10 ) -> None: super().__init__(pool_size=pool_size) self.min_neg = min_neg - self.select_sample_size_per_image = select_sample_size_per_image + self.batch_size_per_image = batch_size_per_image self.positive_fraction = positive_fraction logging.info("Sampling hard negatives on a per batch basis") @@ -148,7 +148,7 @@ def __call__(self, target_labels: List[Tensor], concat_fg_probs: Tensor) -> Tupl .. code-block:: python sampler = HardNegativeSampler( - select_sample_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 + batch_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 ) # two images with different number of samples target_labels = [ torch.tensor([0,1]), torch.tensor([1,0,2,1])] @@ -183,7 +183,7 @@ def select_samples_img_list( .. code-block:: python sampler = HardNegativeSampler( - select_sample_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 + batch_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 ) # two images with different number of samples target_labels = [ torch.tensor([0,1]), torch.tensor([1,0,2,1])] @@ -224,14 +224,14 @@ def select_samples_per_img(self, labels_per_img: Tensor, fg_probs_per_img: Tenso .. code-block:: python sampler = HardNegativeSampler( - select_sample_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 + batch_size_per_image=6, positive_fraction=0.5, min_neg=1, pool_size=2 ) # two images with different number of samples target_labels = torch.tensor([1,0,2,1]) fg_probs = torch.rand(4) pos_idx, neg_idx = sampler.select_samples_per_img(target_labels, fg_probs) """ - # for each image, find positive sample incides and negative sample indices + # for each image, find positive sample indices and negative sample indices if labels_per_img.numel() != fg_probs_per_img.numel(): raise ValueError("labels_per_img and fg_probs_per_img should have same number of elements.") @@ -254,17 +254,17 @@ def get_num_pos(self, positive: torch.Tensor) -> int: positive: indices of positive samples Returns: - number of postive sample + number of positive sample """ # positive sample sampling - num_pos = int(self.select_sample_size_per_image * self.positive_fraction) + num_pos = int(self.batch_size_per_image * self.positive_fraction) # protect against not enough positive examples num_pos = min(positive.numel(), num_pos) return num_pos def get_num_neg(self, negative: torch.Tensor, num_pos: int) -> int: """ - Sample enough negatives to fill up ``self.select_sample_size_per_image`` + Sample enough negatives to fill up ``self.batch_size_per_image`` Args: negative: indices of positive samples @@ -292,7 +292,7 @@ def select_positives(self, positive: Tensor, num_pos: int, labels: Tensor) -> Te Returns: binary mask of positive samples to choose, sized (A,), - where A is the the number of samples in one image + where A is the number of samples in one image """ if positive.numel() > labels.numel(): raise ValueError("The number of positive samples should not be larger than the number of all samples.") diff --git a/tests/test_retinanet_detector.py b/tests/test_retinanet_detector.py index f91fb604ca..99a70fb5fa 100644 --- a/tests/test_retinanet_detector.py +++ b/tests/test_retinanet_detector.py @@ -17,7 +17,7 @@ from monai.apps.detection.networks.retinanet_detector import RetinaNetDetector, retinanet_resnet50_fpn_detector from monai.apps.detection.utils.anchor_utils import AnchorGeneratorWithAnchorShape -from monai.networks import eval_mode +from monai.networks import eval_mode, train_mode from monai.utils import optional_import from tests.utils import SkipIfBeforePyTorchVersion, test_script_save @@ -131,6 +131,23 @@ def test_retina_detector_resnet_backbone_shape(self, input_param, input_shape): result = detector.forward(input_data) assert len(result) == len(result) + detector.set_atss_matcher() + detector.set_hard_negative_sampler(10, 0.5) + gt_box_start = torch.randint(2, (3, input_param["spatial_dims"])).to(torch.float16) + gt_box_end = gt_box_start + torch.randint(1, 10, (3, input_param["spatial_dims"])) + one_target = { + "boxes": torch.cat((gt_box_start, gt_box_end), dim=1), + "labels": torch.randint(input_param["num_classes"], (3,)), + } + with train_mode(detector): + input_data = torch.randn(input_shape) + targets = [one_target] * len(input_data) + result = detector.forward(input_data, targets) + + input_data = [torch.randn(input_shape[1:]) for _ in range(random.randint(1, 9))] + targets = [one_target] * len(input_data) + result = detector.forward(input_data, targets) + @parameterized.expand(TEST_CASES) def test_naive_retina_detector_shape(self, input_param, input_shape): anchor_generator = AnchorGeneratorWithAnchorShape( @@ -147,6 +164,23 @@ def test_naive_retina_detector_shape(self, input_param, input_shape): result = detector.forward(input_data) assert len(result) == len(result) + detector.set_atss_matcher() + detector.set_hard_negative_sampler(10, 0.5) + gt_box_start = torch.randint(2, (3, input_param["spatial_dims"])).to(torch.float16) + gt_box_end = gt_box_start + torch.randint(1, 10, (3, input_param["spatial_dims"])) + one_target = { + "boxes": torch.cat((gt_box_start, gt_box_end), dim=1), + "labels": torch.randint(input_param["num_classes"], (3,)), + } + with train_mode(detector): + input_data = torch.randn(input_shape) + targets = [one_target] * len(input_data) + result = detector.forward(input_data, targets) + + input_data = [torch.randn(input_shape[1:]) for _ in range(random.randint(1, 9))] + targets = [one_target] * len(input_data) + result = detector.forward(input_data, targets) + @parameterized.expand(TEST_CASES_TS) def test_script(self, input_param, input_shape): # test whether support torchscript From c2347fbf37cd9f321e70da68a5f042a50489b373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Louren=C3=A7o=20Silva?= Date: Fri, 3 Jun 2022 22:07:51 +0100 Subject: [PATCH 131/183] New segmentation loss functions and metrics (#4444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added GeneralizedDiceFocalLoss to losses. Added GeneralizedDiceScore to metrics. Added function to check whether tensors are binarized to metrics.util and refactored metrics to use it. Signed-off-by: João Lourenço Silva * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refactored monai.metrics.utils.is_binary_tensor function to receive name of the tensor as argument and issue warnings directly * Refactored metric classes to use is_binary_tensor function --- docs/source/losses.rst | 5 + docs/source/metrics.rst | 7 + monai/losses/__init__.py | 2 + monai/losses/dice.py | 107 +++++++++++++ monai/metrics/__init__.py | 3 +- monai/metrics/confusion_matrix.py | 12 +- monai/metrics/generalized_dice.py | 177 ++++++++++++++++++++++ monai/metrics/hausdorff_distance.py | 17 ++- monai/metrics/meandice.py | 12 +- monai/metrics/surface_distance.py | 19 +-- monai/metrics/utils.py | 22 ++- tests/test_compute_generalized_dice.py | 150 ++++++++++++++++++ tests/test_generalized_dice_focal_loss.py | 73 +++++++++ 13 files changed, 572 insertions(+), 34 deletions(-) create mode 100644 monai/metrics/generalized_dice.py create mode 100644 tests/test_compute_generalized_dice.py create mode 100644 tests/test_generalized_dice_focal_loss.py diff --git a/docs/source/losses.rst b/docs/source/losses.rst index dfd8ce2ddb..f05e4dc9ff 100644 --- a/docs/source/losses.rst +++ b/docs/source/losses.rst @@ -53,6 +53,11 @@ Segmentation Losses .. autoclass:: DiceFocalLoss :members: +`GeneralizedDiceFocalLoss` +~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: GeneralizedDiceFocalLoss + :members: + `FocalLoss` ~~~~~~~~~~~ .. autoclass:: FocalLoss diff --git a/docs/source/metrics.rst b/docs/source/metrics.rst index 1eb8935141..cb9cbe3c1d 100644 --- a/docs/source/metrics.rst +++ b/docs/source/metrics.rst @@ -39,6 +39,13 @@ Metrics .. autoclass:: DiceMetric :members: +`Generalized Dice Score` +------------------------ +.. autofunction:: compute_generalized_dice + +.. autoclass:: GeneralizedDiceScore + :members: + `Area under the ROC curve` -------------------------- .. autofunction:: compute_roc_auc diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py index 1922996fb6..dffd94f5a5 100644 --- a/monai/losses/__init__.py +++ b/monai/losses/__init__.py @@ -16,12 +16,14 @@ DiceCELoss, DiceFocalLoss, DiceLoss, + GeneralizedDiceFocalLoss, GeneralizedDiceLoss, GeneralizedWassersteinDiceLoss, MaskedDiceLoss, dice_ce, dice_focal, generalized_dice, + generalized_dice_focal, generalized_wasserstein_dice, ) from .focal_loss import FocalLoss diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 67802abc0a..495a1d40f5 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -853,8 +853,115 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: return total_loss +class GeneralizedDiceFocalLoss(torch.nn.modules.loss._Loss): + """Compute both Generalized Dice Loss and Focal Loss, and return their weighted average. The details of Generalized Dice Loss + and Focal Loss are available at ``monai.losses.GeneralizedDiceLoss`` and ``monai.losses.FocalLoss``. + + Args: + include_background (bool, optional): if False channel index 0 (background category) is excluded from the calculation. + Defaults to True. + to_onehot_y (bool, optional): whether to convert `y` into the one-hot format. Defaults to False. + sigmoid (bool, optional): if True, apply a sigmoid function to the prediction. Defaults to False. + softmax (bool, optional): if True, apply a softmax function to the prediction. Defaults to False. + other_act (Optional[Callable], optional): if don't want to use sigmoid or softmax, use other callable + function to execute other activation layers. Defaults to None. + w_type (Union[Weight, str], optional): {``"square"``, ``"simple"``, ``"uniform"``}. Type of function to transform + ground-truth volume to a weight factor. Defaults to ``"square"``. + reduction (Union[LossReduction, str], optional): {``"none"``, ``"mean"``, ``"sum"``}. Specified the reduction to + apply to the output. Defaults to ``"mean"``. + - ``"none"``: no reduction will be applied. + - ``"mean"``: the sum of the output will be divided by the number of elements in the output. + - ``"sum"``: the output will be summed. + smooth_nr (float, optional): a small constant added to the numerator to avoid zero. Defaults to 1e-5. + smooth_dr (float, optional): a small constant added to the denominator to avoid nan. Defaults to 1e-5. + batch (bool, optional): whether to sum the intersection and union areas over the batch dimension before the dividing. + Defaults to False, i.e., the areas are computed for each item in the batch. + gamma (float, optional): value of the exponent gamma in the definition of the Focal loss. Defaults to 2.0. + focal_weight (Optional[Union[Sequence[float], float, int, torch.Tensor]], optional): weights to apply to + the voxels of each class. If None no weights are applied. The input can be a single value + (same weight for all classes), a sequence of values (the length of the sequence hould be the same as + the number of classes). Defaults to None. + lambda_gdl (float, optional): the trade-off weight value for Generalized Dice Loss. The value should be + no less than 0.0. Defaults to 1.0. + lambda_focal (float, optional): the trade-off weight value for Focal Loss. The value should be no less + than 0.0. Defaults to 1.0. + + Raises: + ValueError: if either `lambda_gdl` or `lambda_focal` is less than 0. + """ + + def __init__( + self, + include_background: bool = True, + to_onehot_y: bool = False, + sigmoid: bool = False, + softmax: bool = False, + other_act: Optional[Callable] = None, + w_type: Union[Weight, str] = Weight.SQUARE, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + smooth_nr: float = 1e-5, + smooth_dr: float = 1e-5, + batch: bool = False, + gamma: float = 2.0, + focal_weight: Optional[Union[Sequence[float], float, int, torch.Tensor]] = None, + lambda_gdl: float = 1.0, + lambda_focal: float = 1.0, + ) -> None: + super().__init__() + self.generalized_dice = GeneralizedDiceLoss( + include_background=include_background, + to_onehot_y=to_onehot_y, + sigmoid=sigmoid, + softmax=softmax, + other_act=other_act, + w_type=w_type, + reduction=reduction, + smooth_nr=smooth_nr, + smooth_dr=smooth_dr, + batch=batch, + ) + self.focal = FocalLoss( + include_background=include_background, + to_onehot_y=to_onehot_y, + gamma=gamma, + weight=focal_weight, + reduction=reduction, + ) + if lambda_gdl < 0.0: + raise ValueError("lambda_gdl should be no less than 0.0.") + if lambda_focal < 0.0: + raise ValueError("lambda_focal should be no less than 0.0.") + self.lambda_gdl = lambda_gdl + self.lambda_focal = lambda_focal + + def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """ + Args: + input (torch.Tensor): the shape should be BNH[WD]. The input should be the original logits + due to the restriction of ``monai.losses.FocalLoss``. + target (torch.Tensor): the shape should be BNH[WD] or B1H[WD]. + + Raises: + ValueError: When the input and target tensors have different numbers of dimensions, or the target + channel isn't either one-hot encoded or categorical with the same shape of the input. + + Returns: + torch.Tensor: value of the loss. + """ + if input.dim() != target.dim(): + raise ValueError( + f"Input - {input.shape} - and target - {target.shape} - must have the same number of dimensions." + ) + + gdl_loss = self.generalized_dice(input, target) + focal_loss = self.focal(input, target) + total_loss: torch.Tensor = self.lambda_gdl * gdl_loss + self.lambda_focal * focal_loss + return total_loss + + Dice = DiceLoss dice_ce = DiceCELoss dice_focal = DiceFocalLoss generalized_dice = GeneralizedDiceLoss +generalized_dice_focal = GeneralizedDiceFocalLoss generalized_wasserstein_dice = GeneralizedWassersteinDiceLoss diff --git a/monai/metrics/__init__.py b/monai/metrics/__init__.py index 53d11893ed..750f0f3552 100644 --- a/monai/metrics/__init__.py +++ b/monai/metrics/__init__.py @@ -12,6 +12,7 @@ from .confusion_matrix import ConfusionMatrixMetric, compute_confusion_matrix_metric, get_confusion_matrix from .cumulative_average import CumulativeAverage from .froc import compute_fp_tp_probs, compute_froc_curve_data, compute_froc_score +from .generalized_dice import GeneralizedDiceScore, compute_generalized_dice from .hausdorff_distance import HausdorffDistanceMetric, compute_hausdorff_distance, compute_percent_hausdorff_distance from .meandice import DiceMetric, compute_meandice from .metric import Cumulative, CumulativeIterationMetric, IterationMetric, Metric @@ -19,4 +20,4 @@ from .rocauc import ROCAUCMetric, compute_roc_auc from .surface_dice import SurfaceDiceMetric, compute_surface_dice from .surface_distance import SurfaceDistanceMetric, compute_average_surface_distance -from .utils import do_metric_reduction, get_mask_edges, get_surface_distance, ignore_background +from .utils import do_metric_reduction, get_mask_edges, get_surface_distance, ignore_background, is_binary_tensor diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 38834ee8cf..cfe1a3572c 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -14,7 +14,7 @@ import torch -from monai.metrics.utils import do_metric_reduction, ignore_background +from monai.metrics.utils import do_metric_reduction, ignore_background, is_binary_tensor from monai.utils import MetricReduction, ensure_tuple from .metric import CumulativeIterationMetric @@ -84,13 +84,9 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor ValueError: when `y` is not a binarized tensor. ValueError: when `y_pred` has less than two dimensions. """ - if not isinstance(y_pred, torch.Tensor) or not isinstance(y, torch.Tensor): - raise ValueError("y_pred and y must be PyTorch Tensor.") - # check binarized input - if not torch.all(y_pred.byte() == y_pred): - warnings.warn("y_pred should be a binarized tensor.") - if not torch.all(y.byte() == y): - raise ValueError("y should be a binarized tensor.") + is_binary_tensor(y_pred, "y_pred") + is_binary_tensor(y, "y") + # check dimension dims = y_pred.ndimension() if dims < 2: diff --git a/monai/metrics/generalized_dice.py b/monai/metrics/generalized_dice.py new file mode 100644 index 0000000000..c52fc9890e --- /dev/null +++ b/monai/metrics/generalized_dice.py @@ -0,0 +1,177 @@ +# 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 typing import Union + +import torch + +from monai.metrics.utils import do_metric_reduction, ignore_background +from monai.utils import MetricReduction, Weight, look_up_option + +from .metric import CumulativeIterationMetric +from .utils import is_binary_tensor + + +class GeneralizedDiceScore(CumulativeIterationMetric): + """Compute the Generalized Dice Score metric between tensors, as the complement of the Generalized Dice Loss defined in: + + Sudre, C. et. al. (2017) Generalised Dice overlap as a deep learning + loss function for highly unbalanced segmentations. DLMIA 2017. + + The inputs `y_pred` and `y` are expected to be one-hot, binarized channel-first + or batch-first tensors, i.e., CHW[D] or BCHW[D]. + + Args: + include_background (bool, optional): whether to include the background class (assumed to be in channel 0), in the + score computation. Defaults to True. + reduction (str, optional): define mode of reduction to the metrics. Available reduction modes: + {``"none"``, ``"mean_batch"``, ``"sum_batch"``}. Default to ``"mean_batch"``. If "none", will not do reduction. + weight_type (Union[Weight, str], optional): {``"square"``, ``"simple"``, ``"uniform"``}. Type of function to transform + ground truth volume into a weight factor. Defaults to ``"square"``. + + Raises: + ValueError: when the `weight_type` is not one of {``"none"``, ``"mean"``, ``"sum"``}. + """ + + def __init__( + self, + include_background: bool = True, + reduction: Union[MetricReduction, str] = MetricReduction.MEAN_BATCH, + weight_type: Union[Weight, str] = Weight.SQUARE, + ) -> None: + super().__init__() + self.include_background = include_background + reduction_options = [ + "none", + "mean_batch", + "sum_batch", + MetricReduction.NONE, + MetricReduction.MEAN_BATCH, + MetricReduction.SUM_BATCH, + ] + self.reduction = reduction + if self.reduction not in reduction_options: + raise ValueError(f"reduction must be one of {reduction_options}") + self.weight_type = look_up_option(weight_type, Weight) + + def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignore + """Computes the Generalized Dice Score and returns a tensor with its per image values. + + Args: + y_pred (torch.Tensor): binarized segmentation model output. It must be in one-hot format and in the NCHW[D] format, + where N is the batch dimension, C is the channel dimension, and the remaining are the spatial dimensions. + y (torch.Tensor): binarized ground-truth. It must be in one-hot format and have the same shape as `y_pred`. + + Raises: + ValueError: if `y_pred` or `y` is not a binarized PyTorch tensor, if `y_pred` and `y` have less than + three dimensions, or `y_pred` and `y` don't have the same shape. + """ + return compute_generalized_dice( + y_pred=y_pred, y=y, include_background=self.include_background, weight_type=self.weight_type + ) + + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore + """ + Execute reduction logic for the output of `compute_generalized_dice`. + + Args: + reduction (Union[MetricReduction, str, None], optional): define mode of reduction to the metrics. + Available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``}. + Defaults to ``"mean"``. If "none", will not do reduction. + """ + data = self.get_buffer() + if not isinstance(data, torch.Tensor): + raise ValueError("The data to aggregate must be a PyTorch Tensor.") + + # Validate reduction argument if specified + if reduction is not None: + reduction_options = ["none", "mean", "sum", "mean_batch", "sum_batch"] + if reduction not in reduction_options: + raise ValueError(f"reduction must be one of {reduction_options}") + + # Do metric reduction and return + f, _ = do_metric_reduction(data, reduction or self.reduction) + + return f + + +def compute_generalized_dice( + y_pred: torch.Tensor, + y: torch.Tensor, + include_background: bool = True, + weight_type: Union[Weight, str] = Weight.SQUARE, +) -> torch.Tensor: + """Computes the Generalized Dice Score and returns a tensor with its per image values. + + Args: + y_pred (torch.Tensor): binarized segmentation model output. It should be binarized, in one-hot format + and in the NCHW[D] format, where N is the batch dimension, C is the channel dimension, and the + remaining are the spatial dimensions. + y (torch.Tensor): binarized ground-truth. It should be binarized, in one-hot format and have the same shape as `y_pred`. + include_background (bool, optional): whether to skip score computation on the first channel of the + predicted output. Defaults to True. + weight_type (Union[Weight, str], optional): {``"square"``, ``"simple"``, ``"uniform"``}. Type of function to + transform ground truth volume into a weight factor. Defaults to ``"square"``. + + Returns: + torch.Tensor: per batch and per class Generalized Dice Score, i.e., with the shape [batch_size, num_classes]. + + Raises: + ValueError: if `y_pred` or `y` are not PyTorch tensors, if `y_pred` and `y` have less than three dimensions, + or `y_pred` and `y` don't have the same shape. + """ + # Ensure tensors are binarized + is_binary_tensor(y_pred, "y_pred") + is_binary_tensor(y, "y") + + # Ensure tensors have at least 3 dimensions and have the same shape + dims = y_pred.dim() + if dims < 3: + raise ValueError(f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {dims}.") + if y.shape != y_pred.shape: + raise ValueError(f"y_pred - {y_pred.shape} - and y - {y.shape} - should have the same shapes.") + + # Ignore background, if needed + if not include_background: + y_pred, y = ignore_background(y_pred=y_pred, y=y) + + # Reducing only spatial dimensions (not batch nor channels), compute the intersection and non-weighted denominator + reduce_axis = list(range(2, y_pred.dim())) + intersection = torch.sum(y * y_pred, dim=reduce_axis) + y_o = torch.sum(y, dim=reduce_axis) + y_pred_o = torch.sum(y_pred, dim=reduce_axis) + denominator = y_o + y_pred_o + + # Set the class weights + weight_type = look_up_option(weight_type, Weight) + if weight_type == Weight.SIMPLE: + w = torch.reciprocal(y_o.float()) + elif weight_type == Weight.SQUARE: + w = torch.reciprocal(y_o.float() * y_o.float()) + else: + w = torch.ones_like(y_o.float()) + + # Replace infinite values for non-appearing classes by the maximum weight + for b in w: + infs = torch.isinf(b) + b[infs] = 0 + b[infs] = torch.max(b) + + # Compute the weighted numerator and denominator, summing along the class axis + numer = 2.0 * (intersection * w).sum(dim=1) + denom = (denominator * w).sum(dim=1) + + # Compute the score. Where the denominator (and numerator) is 0, score is 1 + generalized_dice_score = numer / denom + generalized_dice_score[denom == 0] = 1 + + # Compute the final score, replacing nans (where denominator is 0) + return generalized_dice_score diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index ab4ed0f821..1caec2d919 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -15,7 +15,13 @@ import numpy as np import torch -from monai.metrics.utils import do_metric_reduction, get_mask_edges, get_surface_distance, ignore_background +from monai.metrics.utils import ( + do_metric_reduction, + get_mask_edges, + get_surface_distance, + ignore_background, + is_binary_tensor, +) from monai.utils import MetricReduction from .metric import CumulativeIterationMetric @@ -80,12 +86,9 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor ValueError: when `y` is not a binarized tensor. ValueError: when `y_pred` has less than three dimensions. """ - if not isinstance(y_pred, torch.Tensor) or not isinstance(y, torch.Tensor): - raise ValueError("y_pred and y must be PyTorch Tensor.") - if not torch.all(y_pred.byte() == y_pred): - warnings.warn("y_pred should be a binarized tensor.") - if not torch.all(y.byte() == y): - warnings.warn("y should be a binarized tensor.") + is_binary_tensor(y_pred, "y_pred") + is_binary_tensor(y, "y") + dims = y_pred.ndimension() if dims < 3: raise ValueError("y_pred should have at least three dimensions.") diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index c450e17c5f..c86efa3c52 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -9,12 +9,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import warnings from typing import Union import torch -from monai.metrics.utils import do_metric_reduction, ignore_background +from monai.metrics.utils import do_metric_reduction, ignore_background, is_binary_tensor from monai.utils import MetricReduction from .metric import CumulativeIterationMetric @@ -72,12 +71,9 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor ValueError: when `y` is not a binarized tensor. ValueError: when `y_pred` has less than three dimensions. """ - if not isinstance(y_pred, torch.Tensor) or not isinstance(y, torch.Tensor): - raise ValueError("y_pred and y must be PyTorch Tensor.") - if not torch.all(y_pred.byte() == y_pred): - warnings.warn("y_pred should be a binarized tensor.") - if not torch.all(y.byte() == y): - warnings.warn("y should be a binarized tensor.") + is_binary_tensor(y_pred, "y_pred") + is_binary_tensor(y, "y") + dims = y_pred.ndimension() if dims < 3: raise ValueError(f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {dims}.") diff --git a/monai/metrics/surface_distance.py b/monai/metrics/surface_distance.py index 65651e3e51..4b5cdbc2f7 100644 --- a/monai/metrics/surface_distance.py +++ b/monai/metrics/surface_distance.py @@ -15,7 +15,13 @@ import numpy as np import torch -from monai.metrics.utils import do_metric_reduction, get_mask_edges, get_surface_distance, ignore_background +from monai.metrics.utils import ( + do_metric_reduction, + get_mask_edges, + get_surface_distance, + ignore_background, + is_binary_tensor, +) from monai.utils import MetricReduction, convert_data_type from .metric import CumulativeIterationMetric @@ -73,14 +79,9 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor ValueError: when `y` is not a binarized tensor. ValueError: when `y_pred` has less than three dimensions. """ - if not isinstance(y_pred, torch.Tensor) or not isinstance(y, torch.Tensor): - raise ValueError("y_pred and y must be PyTorch Tensor.") - if not torch.all(y_pred.byte() == y_pred): - warnings.warn("y_pred should be a binarized tensor.") - if not torch.all(y.byte() == y): - warnings.warn("y should be a binarized tensor.") - dims = y_pred.ndimension() - if dims < 3: + is_binary_tensor(y_pred, "y_pred") + is_binary_tensor(y, "y") + if y_pred.dim() < 3: raise ValueError("y_pred should have at least three dimensions.") # compute (BxC) for each channel for each batch return compute_average_surface_distance( diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index 2a9c8affc9..faf5093305 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from typing import Tuple, Union import numpy as np @@ -22,7 +23,7 @@ distance_transform_edt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_edt") distance_transform_cdt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_cdt") -__all__ = ["ignore_background", "do_metric_reduction", "get_mask_edges", "get_surface_distance"] +__all__ = ["ignore_background", "do_metric_reduction", "get_mask_edges", "get_surface_distance", "is_binary_tensor"] def ignore_background(y_pred: Union[np.ndarray, torch.Tensor], y: Union[np.ndarray, torch.Tensor]): @@ -203,3 +204,22 @@ def get_surface_distance(seg_pred: np.ndarray, seg_gt: np.ndarray, distance_metr raise ValueError(f"distance_metric {distance_metric} is not implemented.") return np.asarray(dis[seg_pred]) + + +def is_binary_tensor(input: torch.Tensor, name: str): + """Determines whether the input tensor is torch binary tensor or not. + + Args: + input (torch.Tensor): tensor to validate. + name (str): name of the tensor being checked. + + Raises: + ValueError: if `input` is not a PyTorch Tensor. + + Returns: + Union[str, None]: warning message, if the tensor is not binary. Othwerwise, None. + """ + if not isinstance(input, torch.Tensor): + raise ValueError(f"{name} must be of type PyTorch Tensor.") + if not torch.all(input.byte() == input) or input.max() > 1 or input.min() < 0: + warnings.warn(f"{name} should be a binarized tensor.") diff --git a/tests/test_compute_generalized_dice.py b/tests/test_compute_generalized_dice.py new file mode 100644 index 0000000000..0f5941bbfa --- /dev/null +++ b/tests/test_compute_generalized_dice.py @@ -0,0 +1,150 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.metrics import GeneralizedDiceScore, compute_generalized_dice + +# keep background +TEST_CASE_1 = [ # y (1, 1, 2, 2), y_pred (1, 1, 2, 2), expected out (1) + { + "y_pred": torch.tensor([[[[1.0, 0.0], [0.0, 1.0]]]]), + "y": torch.tensor([[[[1.0, 0.0], [1.0, 1.0]]]]), + "include_background": True, + }, + [0.8], +] + +# remove background +TEST_CASE_2 = [ # y (2, 1, 2, 2), y_pred (2, 3, 2, 2), expected out (2) (no background) + { + "y_pred": torch.tensor( + [ + [[[0.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]]], + [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 0.0]]], + ] + ), + "y": torch.tensor( + [ + [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]]], + [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 0.0]]], + ] + ), + "include_background": False, + }, + [0.1667, 0.6667], +] + +# should return 0 for both cases +TEST_CASE_3 = [ + { + "y_pred": torch.tensor( + [ + [[[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]], [[1.0, 1.0], [1.0, 1.0]]], + [[[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]], [[1.0, 1.0], [1.0, 1.0]]], + ] + ), + "y": torch.tensor( + [ + [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]], + [[[0.0, 1.0], [1.0, 0.0]], [[1.0, 0.0], [0.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]]], + ] + ), + "include_background": True, + }, + [0.0, 0.0], +] + +TEST_CASE_4 = [ + {"include_background": True, "reduction": "mean_batch"}, + { + "y_pred": torch.tensor( + [ + [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]], + [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]], + ] + ), + "y": torch.tensor( + [ + [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]], + [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 0.0]]], + ] + ), + }, + [0.5455], +] + +TEST_CASE_5 = [ + {"include_background": True, "reduction": "sum_batch"}, + { + "y_pred": torch.tensor( + [ + [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]], + [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]], + ] + ), + "y": torch.tensor( + [ + [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]], + [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]], + ] + ), + }, + 1.0455, +] + +TEST_CASE_6 = [{"y": torch.ones((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3))}, [1.0000, 1.0000]] + +TEST_CASE_7 = [{"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3))}, [1.0000, 1.0000]] + + +class TestComputeMeanDice(unittest.TestCase): + # Functional part tests + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_6, TEST_CASE_7]) + def test_value(self, input_data, expected_value): + result = compute_generalized_dice(**input_data) + np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) + + # Functional part tests + @parameterized.expand([TEST_CASE_3]) + def test_nans(self, input_data, expected_value): + result = compute_generalized_dice(**input_data) + self.assertTrue(np.allclose(np.isnan(result.cpu().numpy()), expected_value)) + + # Samplewise tests + @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) + def test_value_class(self, input_data, expected_value): + + # same test as for compute_meandice + vals = {} + vals["y_pred"] = input_data.pop("y_pred") + vals["y"] = input_data.pop("y") + generalized_dice_score = GeneralizedDiceScore(**input_data) + generalized_dice_score(**vals) + result = generalized_dice_score.aggregate(reduction="none") + np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) + + # Aggregation tests + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_nans_class(self, params, input_data, expected_value): + + generalized_dice_score = GeneralizedDiceScore(**params) + generalized_dice_score(**input_data) + result = generalized_dice_score.aggregate() + np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generalized_dice_focal_loss.py b/tests/test_generalized_dice_focal_loss.py new file mode 100644 index 0000000000..ef8661c88d --- /dev/null +++ b/tests/test_generalized_dice_focal_loss.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. + +import unittest + +import numpy as np +import torch + +from monai.losses import FocalLoss, GeneralizedDiceFocalLoss, GeneralizedDiceLoss +from tests.utils import test_script_save + + +class TestGeneralizedDiceFocalLoss(unittest.TestCase): + def test_result_onehot_target_include_bg(self): + size = [3, 3, 5, 5] + label = torch.randint(low=0, high=2, size=size) + pred = torch.randn(size) + for reduction in ["sum", "mean", "none"]: + common_params = {"include_background": True, "to_onehot_y": False, "reduction": reduction} + for focal_weight in [None, torch.tensor([1.0, 1.0, 2.0]), (3, 2.0, 1)]: + for lambda_focal in [0.5, 1.0, 1.5]: + generalized_dice_focal = GeneralizedDiceFocalLoss( + focal_weight=focal_weight, gamma=1.0, lambda_focal=lambda_focal, **common_params + ) + generalized_dice = GeneralizedDiceLoss(**common_params) + focal = FocalLoss(weight=focal_weight, gamma=1.0, **common_params) + result = generalized_dice_focal(pred, label) + expected_val = generalized_dice(pred, label) + lambda_focal * focal(pred, label) + np.testing.assert_allclose(result, expected_val) + + def test_result_no_onehot_no_bg(self): + size = [3, 3, 5, 5] + label = torch.randint(low=0, high=2, size=size) + label = torch.argmax(label, dim=1, keepdim=True) + pred = torch.randn(size) + for reduction in ["sum", "mean", "none"]: + common_params = {"include_background": False, "to_onehot_y": True, "reduction": reduction} + for focal_weight in [2.0, torch.tensor([1.0, 2.0]), (2.0, 1)]: + for lambda_focal in [0.5, 1.0, 1.5]: + generalized_dice_focal = GeneralizedDiceFocalLoss( + focal_weight=focal_weight, lambda_focal=lambda_focal, **common_params + ) + generalized_dice = GeneralizedDiceLoss(**common_params) + focal = FocalLoss(weight=focal_weight, **common_params) + result = generalized_dice_focal(pred, label) + expected_val = generalized_dice(pred, label) + lambda_focal * focal(pred, label) + np.testing.assert_allclose(result, expected_val) + + def test_ill_shape(self): + loss = GeneralizedDiceFocalLoss() + with self.assertRaisesRegex(ValueError, ""): + loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_ill_lambda(self): + with self.assertRaisesRegex(ValueError, ""): + GeneralizedDiceFocalLoss(lambda_gdl=-1.0) + + def test_script(self): + loss = GeneralizedDiceFocalLoss() + test_input = torch.ones(2, 1, 8, 8) + test_script_save(loss, test_input, test_input) + + +if __name__ == "__main__": + unittest.main() From db02f0762292d6ae15c4f3d00d37b83f0547db28 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Sat, 4 Jun 2022 07:25:08 +0100 Subject: [PATCH 132/183] 4451 adds testing file downloading (#4452) adds download link Signed-off-by: Wenqi Li --- tests/test_masked_patch_wsi_dataset.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py index c1d202f8f4..bf469c5b40 100644 --- a/tests/test_masked_patch_wsi_dataset.py +++ b/tests/test_masked_patch_wsi_dataset.py @@ -18,14 +18,14 @@ from monai.data import MaskedPatchWSIDataset from monai.utils import WSIPatchKeys, optional_import, set_determinism -from tests.utils import testing_data_config +from tests.utils import download_url_or_skip_test, testing_data_config set_determinism(0) cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_osl = optional_import("openslide") +_, has_tiff = optional_import("tifffile", name="imwrite") _, has_codec = optional_import("imagecodecs") has_tiff = has_tiff and has_codec @@ -48,6 +48,13 @@ ] +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): # noqa: N802 + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + class MaskedPatchWSIDatasetTests: class Tests(unittest.TestCase): backend = None From f02519353d230873bcc12fbb7a61755ffbbfc337 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 6 Jun 2022 08:10:58 +0100 Subject: [PATCH 133/183] matshow3d: supports subplot axes input (#4459) * support of subplots axes input Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/visualize/utils.py | 17 ++++++++++------- tests/test_masked_patch_wsi_dataset.py | 2 +- tests/test_matshow3d.py | 3 +++ tests/test_patch_wsi_dataset_new.py | 2 +- tests/test_sliding_patch_wsi_dataset.py | 2 +- tests/test_wsireader.py | 2 +- tests/test_wsireader_new.py | 2 +- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/monai/visualize/utils.py b/monai/visualize/utils.py index c1111abd82..1ef6d6da57 100644 --- a/monai/visualize/utils.py +++ b/monai/visualize/utils.py @@ -52,7 +52,7 @@ def matshow3d( Higher dimensional arrays will be reshaped into (-1, H, W, [C]), `C` depends on `channel_dim` arg. A list of channel-first (C, H[, W, D]) arrays can also be passed in, in which case they will be displayed as a padded and stacked volume. - fig: matplotlib figure to use. If None, a new figure will be created. + fig: matplotlib figure or Axes to use. If None, a new figure will be created. title: title of the figure. figsize: size of the figure. frames_per_row: number of frames to display in each row. If None, sqrt(firstdim) will be used. @@ -136,17 +136,20 @@ def matshow3d( im = np.moveaxis(im, 0, -1) # figure related configurations - if fig is None: - fig = plt.figure(tight_layout=True) - if not fig.axes: - fig.add_subplot(111) - ax = fig.axes[0] + if isinstance(fig, plt.Axes): + ax = fig + else: + if fig is None: + fig = plt.figure(tight_layout=True) + if not fig.axes: + fig.add_subplot(111) + ax = fig.axes[0] ax.matshow(im, vmin=vmin, vmax=vmax, interpolation=interpolation, **kwargs) ax.axis("off") if title is not None: ax.set_title(title) - if figsize is not None: + if figsize is not None and hasattr(fig, "set_size_inches"): fig.set_size_inches(figsize) if show: plt.show() diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py index bf469c5b40..a79bb5533b 100644 --- a/tests/test_masked_patch_wsi_dataset.py +++ b/tests/test_masked_patch_wsi_dataset.py @@ -49,7 +49,7 @@ @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") hash_val = testing_data_config("images", FILE_KEY, "hash_val") download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) diff --git a/tests/test_matshow3d.py b/tests/test_matshow3d.py index 83984d1556..18ed16dd2c 100644 --- a/tests/test_matshow3d.py +++ b/tests/test_matshow3d.py @@ -42,6 +42,9 @@ def test_3d(self): comp = compare_images(f"{testing_dir}/matshow3d_test.png", tempimg, 5e-2) self.assertIsNone(comp, f"value of comp={comp}") # None indicates test passed + _, axes = pyplot.subplots() + matshow3d(ims[keys], fig=axes, figsize=(2, 2), frames_per_row=5, every_n=2, frame_dim=-1, show=False) + def test_samples(self): testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") keys = "image" diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py index 65e65035c4..fee8a03068 100644 --- a/tests/test_patch_wsi_dataset_new.py +++ b/tests/test_patch_wsi_dataset_new.py @@ -104,7 +104,7 @@ @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") hash_val = testing_data_config("images", FILE_KEY, "hash_val") download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index 5f2a2c0d55..d639d000c5 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -204,7 +204,7 @@ @skipUnless(has_cucim or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): for info in [(ARRAY_SMALL_0, FILE_PATH_SMALL_0), (ARRAY_SMALL_1, FILE_PATH_SMALL_1)]: array = info[0].transpose([1, 2, 0]) imwrite(info[1], array, shape=array.shape, photometric="rgb") diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 5d092c4ce5..ac8477ba84 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -109,7 +109,7 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") hash_val = testing_data_config("images", FILE_KEY, "hash_val") download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 8330603db0..7f0a776aff 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -127,7 +127,7 @@ def save_gray_tiff(array: np.ndarray, filename: str): @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") hash_val = testing_data_config("images", FILE_KEY, "hash_val") download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) From 22924f5709e8c3b7786f3e201bf91be24a44da6e Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Mon, 6 Jun 2022 21:25:18 +0100 Subject: [PATCH 134/183] Adding Thread Worker Option to ThreadDataLoader (#4252) * Adding thread worker option to ThreadDataLoader Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixes Signed-off-by: Eric Kerfoot * Fixes Signed-off-by: Eric Kerfoot * Reworking of implementation with global data fix Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Signed-off-by: Eric Kerfoot * Fix Signed-off-by: Eric Kerfoot * Fix Signed-off-by: Eric Kerfoot * Fix Signed-off-by: Eric Kerfoot * Tweak to cleanup Signed-off-by: Eric Kerfoot * Try to disable thread test to see if global data is causing failure Signed-off-by: Eric Kerfoot * Undoing Signed-off-by: Eric Kerfoot * Update Signed-off-by: Eric Kerfoot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Signed-off-by: Eric Kerfoot Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/data/thread_buffer.py | 85 ++++++++++++++++++++++++++++++++++--- tests/test_thread_buffer.py | 10 +++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index e21af69813..8377e696fa 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -10,9 +10,12 @@ # limitations under the License. +from multiprocessing.context import SpawnContext from queue import Empty, Full, Queue from threading import Thread +import torch + from monai.data import DataLoader, Dataset @@ -77,6 +80,60 @@ def __iter__(self): self.stop() # ensure thread completion +def buffer_iterator(src, buffer_size: int = 1, timeout: float = 0.01, repeats: int = 1): + """ + Create a ThreadBuffer object using the `src`, `buffer_size`, and `timeout` parameters given for the constructor + aguments of the same names, and yield each generated object `repeats` number of times successively. + + Args: + src: Source data iterable + buffer_size: Number of items to buffer from the source + timeout: Time to wait for an item from the buffer, or to wait while the buffer is full when adding items + repeats: Number of repeat generations to perform which is asynchronous from the generation of the next value + + Returns: + Generator yield (repeated) values from `src` asynchronously + """ + buffer = ThreadBuffer(src=src, buffer_size=buffer_size, timeout=timeout) + + for batch in buffer: + for _ in range(repeats): + yield batch + + +class _ProcessThread(Thread): + """Shim class to make a thread look like a process to the DataLoader class.""" + + @property + def pid(self): + return id(self) + + def run(self): + try: + super().run() + finally: + torch.utils.data._utils.worker._worker_info = None # clean up global data used for processes + + +class _ProcessQueue(Queue): + """Shim class to make a thread queue look like a process queue to the DataLoader class.""" + + def close(self): + pass + + def cancel_join_thread(self): + pass + + +class _ProcessThreadContext(SpawnContext): + _name = "processthread" + + # threads will be created which looks like processes + Process = _ProcessThread # type: ignore + # thread queue used in place of process queue to avoid some weird cleanup errors + Queue = _ProcessQueue # type: ignore + + class ThreadDataLoader(DataLoader): """ Subclass of `DataLoader` using a `ThreadBuffer` object to implement `__iter__` method asynchronously. This will @@ -87,7 +144,8 @@ class ThreadDataLoader(DataLoader): value the generated batch is yielded that many times while underlying dataset asynchronously generates the next. Typically not all relevant information is learned from a batch in a single iteration so training multiple times on the same batch will still produce good training with minimal short-term overfitting while allowing a slow batch - generation process more time to produce a result. + generation process more time to produce a result. This duplication is done by simply yielding the same object many + times and not by regenerating the data. Another typical usage is to accelerate light-weight preprocessing (usually cached all the deterministic transforms and no IO operations), because it leverages the separate thread to execute preprocessing to avoid unnecessary IPC @@ -95,6 +153,10 @@ class ThreadDataLoader(DataLoader): `ThreadDataLoader` can be useful for GPU transforms. For more details: https://github.com/Project-MONAI/tutorials/blob/master/acceleration/fast_model_training_guide.md. + The `use_thread_workers` will cause workers to be created as threads rather than processes although everything else + in terms of how the class works is unchanged. This allows multiple workers to be used in Windows for example, or in + any other situation where thread semantics is desired. + See: * Fischetti et al. "Faster SGD training by minibatch persistency." ArXiv (2018) https://arxiv.org/abs/1806.07353 * Dami et al., "Faster Neural Network Training with Data Echoing" ArXiv (2020) https://arxiv.org/abs/1907.05550 @@ -106,21 +168,30 @@ class ThreadDataLoader(DataLoader): buffer_size: number of items to buffer from the data source. buffer_timeout: time to wait for an item from the buffer, or to wait while the buffer is full when adding items. repeats: number of times to yield the same batch. + use_thread_workers: if True and num_workers > 0 the workers are created as threads instead of processes kwargs: other arguments for `DataLoader` except for `dataset`. """ def __init__( - self, dataset: Dataset, buffer_size: int = 1, buffer_timeout: float = 0.01, repeats: int = 1, **kwargs + self, + dataset: Dataset, + buffer_size: int = 1, + buffer_timeout: float = 0.01, + repeats: int = 1, + use_thread_workers: bool = False, + **kwargs, ): + # if workers should be threads, create a new multiprocessing context with the process and queue types + # substituted with the shim types given above + if use_thread_workers and kwargs.get("num_workers", 0) > 0: + kwargs["multiprocessing_context"] = _ProcessThreadContext() + kwargs["persistent_workers"] = False + super().__init__(dataset, **kwargs) self.buffer_size = buffer_size self.buffer_timeout = buffer_timeout self.repeats = repeats def __iter__(self): - buffer = ThreadBuffer(src=super().__iter__(), buffer_size=self.buffer_size, timeout=self.buffer_timeout) - - for batch in buffer: - for _ in range(self.repeats): - yield batch + yield from buffer_iterator(super().__iter__(), self.buffer_size, self.buffer_timeout, self.repeats) diff --git a/tests/test_thread_buffer.py b/tests/test_thread_buffer.py index 04511220f8..013c20f4ce 100644 --- a/tests/test_thread_buffer.py +++ b/tests/test_thread_buffer.py @@ -94,6 +94,16 @@ def test_dataloader_repeats(self): self.assertTrue(previous_batch is d, "Batch object was not repeated") previous_batch = None + def test_thread_workers(self): + dataset = Dataset(data=self.datalist, transform=self.transform) + dataloader = ThreadDataLoader(dataset=dataset, batch_size=2, num_workers=2, use_thread_workers=True) + + for d in dataloader: + self.assertEqual(d["image"][0], "spleen_19.nii.gz") + self.assertEqual(d["image"][1], "spleen_31.nii.gz") + self.assertEqual(d["label"][0], "spleen_label_19.nii.gz") + self.assertEqual(d["label"][1], "spleen_label_31.nii.gz") + if __name__ == "__main__": unittest.main() From f3cbba2f50c56c01dbf09b248d2ab775692c0df8 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 7 Jun 2022 02:30:54 -0400 Subject: [PATCH 135/183] Add RotateBox90 transforms (#4460) * add box rot90 Signed-off-by: Can Zhao * docstring Signed-off-by: Can Zhao * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docstring typos Signed-off-by: Can Zhao * add case k=0 Signed-off-by: Can Zhao * typo Signed-off-by: Can Zhao * fix bug Signed-off-by: Can Zhao Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- monai/apps/detection/transforms/array.py | 33 ++- monai/apps/detection/transforms/box_ops.py | 76 +++++++ monai/apps/detection/transforms/dictionary.py | 190 +++++++++++++++++- tests/test_box_transform.py | 28 +++ 4 files changed, 315 insertions(+), 12 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index cb788f8d92..fb338682ee 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -13,7 +13,7 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ -from typing import Optional, Sequence, Tuple, Type, Union +from typing import Callable, Optional, Sequence, Tuple, Type, Union import numpy as np import torch @@ -27,7 +27,7 @@ get_spatial_dims, spatial_crop_boxes, ) -from monai.transforms import SpatialCrop +from monai.transforms import Rotate90, SpatialCrop from monai.transforms.transform import Transform from monai.utils import ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option from monai.utils.enums import TransformBackends @@ -38,6 +38,7 @@ convert_mask_to_box, flip_boxes, resize_boxes, + rot90_boxes, select_labels, zoom_boxes, ) @@ -53,6 +54,7 @@ "BoxToMask", "MaskToBox", "SpatialCropBox", + "RotateBox90", ] @@ -514,3 +516,30 @@ def __call__( # type: ignore [self.slices[axis].stop for axis in range(spatial_dims)], ) return boxes_crop, select_labels(labels, keep) + + +class RotateBox90(Rotate90): + """ + Rotate a boxes by 90 degrees in the plane specified by `axes`. + See box_ops.rot90_boxes for additional details + + Args: + k: number of times to rotate by 90 degrees. + spatial_axes: 2 int numbers, defines the plane to rotate with 2 spatial axes. + Default: (0, 1), this is the first two axis in spatial dimensions. + If axis is negative it counts from the last to the first axis. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__(self, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: + super().__init__(k, spatial_axes) + + def __call__(self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int]) -> NdarrayOrTensor: # type: ignore + """ + Args: + img: channel first array, must have shape: (num_channels, H[, W, ..., ]), + """ + rot90: Callable = rot90_boxes + out: NdarrayOrTensor = rot90(boxes, spatial_size, self.k, self.spatial_axes) + return out diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index 65d95d8220..4f877967b8 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -347,3 +347,79 @@ def select_labels( return labels_select_list[0] # type: ignore return tuple(labels_select_list) + + +def swapaxes_boxes(boxes: NdarrayOrTensor, axis1: int, axis2: int) -> NdarrayOrTensor: + """ + Interchange two axes of boxes. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + axis1: First axis. + axis2: Second axis. + + Returns: + boxes with two axes interchanged. + + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + boxes_swap: NdarrayOrTensor = deepcopy(boxes) + boxes_swap[:, [axis1, axis2]] = boxes_swap[:, [axis2, axis1]] # type: ignore + boxes_swap[:, [spatial_dims + axis1, spatial_dims + axis2]] = boxes_swap[ # type: ignore + :, [spatial_dims + axis2, spatial_dims + axis1] + ] + return boxes_swap + + +def rot90_boxes( + boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int], k: int = 1, axes: Tuple[int, int] = (0, 1) +) -> NdarrayOrTensor: + """ + Rotate boxes by 90 degrees in the plane specified by axes. + Rotation direction is from the first towards the second axis. + + Args: + boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` + spatial_size: image spatial size. + k : number of times the array is rotated by 90 degrees. + axes: (2,) array_like + The array is rotated in the plane defined by the axes. Axes must be different. + + Returns: + A rotated view of `boxes`. + + Notes: + ``rot90_boxes(boxes, spatial_size, k=1, axes=(1,0))`` is the reverse of + ``rot90_boxes(boxes, spatial_size, k=1, axes=(0,1))`` + ``rot90_boxes(boxes, spatial_size, k=1, axes=(1,0))`` is equivalent to + ``rot90_boxes(boxes, spatial_size, k=-1, axes=(0,1))`` + """ + spatial_dims: int = get_spatial_dims(boxes=boxes) + spatial_size_ = list(ensure_tuple_rep(spatial_size, spatial_dims)) + + axes = ensure_tuple(axes) # type: ignore + + if len(axes) != 2: + raise ValueError("len(axes) must be 2.") + + if axes[0] == axes[1] or abs(axes[0] - axes[1]) == spatial_dims: + raise ValueError("Axes must be different.") + + if axes[0] >= spatial_dims or axes[0] < -spatial_dims or axes[1] >= spatial_dims or axes[1] < -spatial_dims: + raise ValueError(f"Axes={axes} out of range for array of ndim={spatial_dims}.") + + k %= 4 + + if k == 0: + return boxes + if k == 2: + return flip_boxes(flip_boxes(boxes, spatial_size_, axes[0]), spatial_size_, axes[1]) + + if k == 1: + boxes_ = flip_boxes(boxes, spatial_size_, axes[1]) + return swapaxes_boxes(boxes_, axes[0], axes[1]) + else: + # k == 3 + boxes_ = swapaxes_boxes(boxes, axes[0], axes[1]) + spatial_size_[axes[0]], spatial_size_[axes[1]] = spatial_size_[axes[1]], spatial_size_[axes[0]] + return flip_boxes(boxes_, spatial_size_, axes[1]) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index b1591c097c..e47238c222 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -29,6 +29,7 @@ ConvertBoxToStandardMode, FlipBox, MaskToBox, + RotateBox90, SpatialCropBox, ZoomBox, ) @@ -37,7 +38,7 @@ from monai.config.type_definitions import NdarrayOrTensor from monai.data.box_utils import COMPUTE_DTYPE, BoxMode, clip_boxes_to_image from monai.data.utils import orientation_ras_lps -from monai.transforms import Flip, RandFlip, RandZoom, SpatialCrop, SpatialPad, Zoom +from monai.transforms import Flip, RandFlip, RandRotate90d, RandZoom, Rotate90, SpatialCrop, SpatialPad, Zoom from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform from monai.transforms.utils import generate_pos_neg_label_crop_centers, map_binary_to_indices @@ -80,6 +81,12 @@ "RandCropBoxByPosNegLabeld", "RandCropBoxByPosNegLabelD", "RandCropBoxByPosNegLabelDict", + "RotateBox90d", + "RotateBox90D", + "RotateBox90Dict", + "RandRotateBox90d", + "RandRotateBox90D", + "RandRotateBox90Dict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -285,7 +292,7 @@ class ZoomBoxd(MapTransform, InvertibleTransform): Args: image_keys: Keys to pick image data for transformation. box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. zoom: The zoom factor along the spatial axes. If a float, zoom is the same for each spatial axis. If a sequence, zoom should contain one value for each spatial axis. @@ -414,7 +421,7 @@ class RandZoomBoxd(RandomizableTransform, MapTransform, InvertibleTransform): Args: image_keys: Keys to pick image data for transformation. box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. prob: Probability of zooming. min_zoom: Min zoom factor. Can be float or sequence same size as image. If a float, select a random factor from `[min_zoom, max_zoom]` then apply to all spatial dims @@ -577,7 +584,7 @@ class FlipBoxd(MapTransform, InvertibleTransform): Args: image_keys: Keys to pick image data for transformation. box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. spatial_axis: Spatial axes along which to flip over. Default is None. allow_missing_keys: don't raise exception if key is missing. """ @@ -641,7 +648,7 @@ class RandFlipBoxd(RandomizableTransform, MapTransform, InvertibleTransform): Args: image_keys: Keys to pick image data for transformation. box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. prob: Probability of flipping. spatial_axis: Spatial axes along which to flip over. Default is None. allow_missing_keys: don't raise exception if key is missing. @@ -721,7 +728,7 @@ class ClipBoxToImaged(MapTransform): Args: box_keys: The single key to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - label_keys: Keys that represents the labels corresponding to the ``box_keys``. Multiple keys are allowed. + label_keys: Keys that represent the labels corresponding to the ``box_keys``. Multiple keys are allowed. box_ref_image_keys: The single key that represents the reference image to which ``box_keys`` and ``label_keys`` are attached. remove_empty: whether to remove the boxes that are actually empty @@ -791,8 +798,8 @@ class BoxToMaskd(MapTransform): Args: box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. box_mask_keys: Keys to store output box mask results for transformation. Same length with ``box_keys``. - label_keys: Keys that represents the labels corresponding to the ``box_keys``. Same length with ``box_keys``. - box_ref_image_keys: Keys that represents the reference images to which ``box_keys`` are attached. + label_keys: Keys that represent the labels corresponding to the ``box_keys``. Same length with ``box_keys``. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. min_fg_label: min foreground box label. ellipse_mask: bool. @@ -879,7 +886,7 @@ class MaskToBoxd(MapTransform): Args: box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. box_mask_keys: Keys to store output box mask results for transformation. Same length with ``box_keys``. - label_keys: Keys that represents the labels corresponding to the ``box_keys``. Same length with ``box_keys``. + label_keys: Keys that represent the labels corresponding to the ``box_keys``. Same length with ``box_keys``. min_fg_label: min foreground box label. box_dtype: output dtype for box_keys label_dtype: output dtype for label_keys @@ -954,7 +961,7 @@ class RandCropBoxByPosNegLabeld(Randomizable, MapTransform): Args: image_keys: Keys to pick image data for transformation. They need to have the same spatial size. box_keys: The single key to pick box data for transformation. The box mode is assumed to be ``StandardMode``. - label_keys: Keys that represents the labels corresponding to the ``box_keys``. Multiple keys are allowed. + label_keys: Keys that represent the labels corresponding to the ``box_keys``. Multiple keys are allowed. spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. if its components have non-positive values, the corresponding size of `data[label_key]` will be used. @@ -1161,6 +1168,167 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab return results +class RotateBox90d(MapTransform, InvertibleTransform): + """ + Input boxes and images are rotated by 90 degrees + in the plane specified by ``spatial_axes`` for ``k`` times + + Args: + image_keys: Keys to pick image data for transformation. + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. + k: number of times to rotate by 90 degrees. + spatial_axes: 2 int numbers, defines the plane to rotate with 2 spatial axes. + Default (0, 1), this is the first two axis in spatial dimensions. + allow_missing_keys: don't raise exception if key is missing. + """ + + backend = RotateBox90.backend + + def __init__( + self, + image_keys: KeysCollection, + box_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + k: int = 1, + spatial_axes: Tuple[int, int] = (0, 1), + allow_missing_keys: bool = False, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + self.box_keys = ensure_tuple(box_keys) + super().__init__(self.image_keys + self.box_keys, allow_missing_keys) + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + self.img_rotator = Rotate90(k, spatial_axes) + self.box_rotator = RotateBox90(k, spatial_axes) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: + d = dict(data) + for key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): + spatial_size = list(d[box_ref_image_key].shape[1:]) + d[key] = self.box_rotator(d[key], spatial_size) + if self.img_rotator.k % 2 == 1: + # if k = 1 or 3, spatial_size will be transposed + spatial_size[self.img_rotator.spatial_axes[0]], spatial_size[self.img_rotator.spatial_axes[1]] = ( + spatial_size[self.img_rotator.spatial_axes[1]], + spatial_size[self.img_rotator.spatial_axes[0]], + ) + self.push_transform(d, key, extra_info={"spatial_size": spatial_size, "type": "box_key"}) + + for key in self.image_keys: + d[key] = self.img_rotator(d[key]) + self.push_transform(d, key, extra_info={"type": "image_key"}) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + key_type = transform[TraceKeys.EXTRA_INFO]["type"] + num_times_to_rotate = 4 - self.img_rotator.k + + if key_type == "image_key": + inverse_transform = Rotate90(num_times_to_rotate, self.img_rotator.spatial_axes) + d[key] = inverse_transform(d[key]) + if key_type == "box_key": + spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"] + inverse_transform = RotateBox90(num_times_to_rotate, self.box_rotator.spatial_axes) + d[key] = inverse_transform(d[key], spatial_size) + self.pop_transform(d, key) + return d + + +class RandRotateBox90d(RandRotate90d): + """ + With probability `prob`, input boxes and images are rotated by 90 degrees + in the plane specified by `spatial_axes`. + + Args: + image_keys: Keys to pick image data for transformation. + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: Keys that represent the reference images to which ``box_keys`` are attached. + prob: probability of rotating. + (Default 0.1, with 10% probability it returns a rotated array.) + max_k: number of rotations will be sampled from `np.random.randint(max_k) + 1`. + (Default 3) + spatial_axes: 2 int numbers, defines the plane to rotate with 2 spatial axes. + Default: (0, 1), this is the first two axis in spatial dimensions. + allow_missing_keys: don't raise exception if key is missing. + """ + + backend = RotateBox90.backend + + def __init__( + self, + image_keys: KeysCollection, + box_keys: KeysCollection, + box_ref_image_keys: KeysCollection, + prob: float = 0.1, + max_k: int = 3, + spatial_axes: Tuple[int, int] = (0, 1), + allow_missing_keys: bool = False, + ) -> None: + self.image_keys = ensure_tuple(image_keys) + self.box_keys = ensure_tuple(box_keys) + super().__init__(self.image_keys + self.box_keys, prob, max_k, spatial_axes, allow_missing_keys) + self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: + self.randomize() + d = dict(data) + + if self._rand_k % 4 == 0: + return d + + # FIXME: here we didn't use array version `RandRotate90` transform as others, because we need + # to be compatible with the random status of some previous integration tests + box_rotator = RotateBox90(self._rand_k, self.spatial_axes) + img_rotator = Rotate90(self._rand_k, self.spatial_axes) + + for key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): + if self._do_transform: + spatial_size = list(d[box_ref_image_key].shape[1:]) + d[key] = box_rotator(d[key], spatial_size) + if self._rand_k % 2 == 1: + # if k = 1 or 3, spatial_size will be transposed + spatial_size[self.spatial_axes[0]], spatial_size[self.spatial_axes[1]] = ( + spatial_size[self.spatial_axes[1]], + spatial_size[self.spatial_axes[0]], + ) + self.push_transform( + d, key, extra_info={"rand_k": self._rand_k, "spatial_size": spatial_size, "type": "box_key"} + ) + + for key in self.image_keys: + if self._do_transform: + d[key] = img_rotator(d[key]) + self.push_transform(d, key, extra_info={"rand_k": self._rand_k, "type": "image_key"}) + return d + + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = deepcopy(dict(data)) + if self._rand_k % 4 == 0: + return d + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + key_type = transform[TraceKeys.EXTRA_INFO]["type"] + # Check if random transform was actually performed (based on `prob`) + if transform[TraceKeys.DO_TRANSFORM]: + num_times_rotated = transform[TraceKeys.EXTRA_INFO]["rand_k"] + num_times_to_rotate = 4 - num_times_rotated + # flip image, copied from monai.transforms.spatial.dictionary.RandFlipd + if key_type == "image_key": + inverse_transform = Rotate90(num_times_to_rotate, self.spatial_axes) + d[key] = inverse_transform(d[key]) + if key_type == "box_key": + spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"] + inverse_transform = RotateBox90(num_times_to_rotate, self.spatial_axes) + d[key] = inverse_transform(d[key], spatial_size) + self.pop_transform(d, key) + return d + + ConvertBoxModeD = ConvertBoxModeDict = ConvertBoxModed ConvertBoxToStandardModeD = ConvertBoxToStandardModeDict = ConvertBoxToStandardModed ZoomBoxD = ZoomBoxDict = ZoomBoxd @@ -1172,3 +1340,5 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab BoxToMaskD = BoxToMaskDict = BoxToMaskd MaskToBoxD = MaskToBoxDict = MaskToBoxd RandCropBoxByPosNegLabelD = RandCropBoxByPosNegLabelDict = RandCropBoxByPosNegLabeld +RotateBox90D = RotateBox90Dict = RotateBox90d +RandRotateBox90D = RandRotateBox90Dict = RandRotateBox90d diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index ea999ecb91..5d984175aa 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -24,7 +24,9 @@ MaskToBoxd, RandCropBoxByPosNegLabeld, RandFlipBoxd, + RandRotateBox90d, RandZoomBoxd, + RotateBox90d, ZoomBoxd, ) from monai.transforms import CastToTyped, Invertd @@ -47,6 +49,7 @@ p([[1, -6, -1, 1, -6, -1], [1, -3, -1, 2, 3, 3.5], [1, -3, 0.5, 2, 3, 5]]), p([[4, 6, 4, 4, 6, 4], [2, 3, 1, 4, 5, 4], [2, 3, 0, 4, 5, 3]]), p([[0, 1, 0, 2, 3, 3], [0, 1, 1, 2, 3, 4]]), + p([[6, 0, 0, 6, 0, 0], [3, 0, 0, 5, 2, 3], [3, 0, 1, 5, 2, 4]]), ] ) @@ -118,6 +121,7 @@ def test_value_3d( expected_zoom_keepsize_result, expected_flip_result, expected_clip_result, + expected_rotate_result, ): test_dtype = [torch.float32] for dtype in test_dtype: @@ -254,6 +258,30 @@ def test_value_3d( atol=1e-3, ) + # test RotateBox90d + transform_rotate = RotateBox90d( + image_keys="image", box_keys="boxes", box_ref_image_keys="image", k=1, spatial_axes=[0, 1] + ) + rotate_result = transform_rotate(data) + assert_allclose(rotate_result["boxes"], expected_rotate_result, type_test=True, device_test=True, atol=1e-3) + invert_transform_rotate = Invertd( + keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"] + ) + data_back = invert_transform_rotate(rotate_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + + transform_rotate = RandRotateBox90d( + image_keys="image", box_keys="boxes", box_ref_image_keys="image", prob=1.0, max_k=3, spatial_axes=[0, 1] + ) + rotate_result = transform_rotate(data) + invert_transform_rotate = Invertd( + keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"] + ) + data_back = invert_transform_rotate(rotate_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + if __name__ == "__main__": unittest.main() From f00e9be3af75919352e25abf5a419fc58fcf15a1 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 7 Jun 2022 23:24:39 +0800 Subject: [PATCH 136/183] 4463 Enhance doc-string of Normalize and other places (#4464) [DLMED] enhance docs Signed-off-by: Nic Ma --- monai/data/thread_buffer.py | 4 +++- monai/inferers/inferer.py | 9 +++++++-- monai/transforms/intensity/array.py | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index 8377e696fa..b882c2533c 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -155,7 +155,9 @@ class ThreadDataLoader(DataLoader): The `use_thread_workers` will cause workers to be created as threads rather than processes although everything else in terms of how the class works is unchanged. This allows multiple workers to be used in Windows for example, or in - any other situation where thread semantics is desired. + any other situation where thread semantics is desired. Please note that some MONAI components like several datasets + and random transforms are not thread-safe and can't work as expected with `thread workers`, need to check all the + preprocessing components carefully before enabling `use_thread_workers`. See: * Fischetti et al. "Faster SGD training by minibatch persistency." ArXiv (2018) https://arxiv.org/abs/1806.07353 diff --git a/monai/inferers/inferer.py b/monai/inferers/inferer.py index 1b50f7d7e8..e6dd0d27c4 100644 --- a/monai/inferers/inferer.py +++ b/monai/inferers/inferer.py @@ -255,8 +255,13 @@ def __call__(self, inputs: torch.Tensor, network: nn.Module, *args: Any, **kwarg class SliceInferer(SlidingWindowInferer): """ - SliceInferer extends SlidingWindowInferer to provide slice-by-slice (2D) inference - when provided a 3D volume. + SliceInferer extends SlidingWindowInferer to provide slice-by-slice (2D) inference when provided a 3D volume. + A typical use case could be a 2D model (like 2D segmentation UNet) operates on the slices from a 3D volume, + and the output is a 3D volume with 2D slices aggregated. Example:: + + # sliding over the `spatial_dim` + inferer = SliceInferer(roi_size=(64, 256), sw_batch_size=1, spatial_dim=1) + output = inferer(input_volume, net) Args: spatial_dim: Spatial dimension over which the slice-by-slice inference runs on the 3D volume. diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 9cbd4f4310..ebb248387f 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -595,7 +595,8 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen class NormalizeIntensity(Transform): """ - Normalize input based on provided args, using calculated mean and std if not provided. + Normalize input based on the `subtrahend` and `divisor`: `(img - subtrahend) / divisor`. + Use calculated mean or std value of the input image if no `subtrahend` or `divisor` provided. This transform can normalize only non-zero values or entire image, and can also calculate mean and std on each channel separately. When `channel_wise` is True, the first dimension of `subtrahend` and `divisor` should From 7d773229eff7da3aab37bcc2c82fc0ce80731391 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 7 Jun 2022 12:09:06 -0400 Subject: [PATCH 137/183] Add coco detection metrics (#4447) --- docs/source/apps.rst | 7 + monai/apps/detection/metrics/__init__.py | 10 + monai/apps/detection/metrics/coco.py | 547 +++++++++++++++++++++++ monai/apps/detection/metrics/matching.py | 367 +++++++++++++++ tests/test_detection_coco_metrics.py | 66 +++ 5 files changed, 997 insertions(+) create mode 100644 monai/apps/detection/metrics/__init__.py create mode 100644 monai/apps/detection/metrics/coco.py create mode 100644 monai/apps/detection/metrics/matching.py create mode 100644 tests/test_detection_coco_metrics.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 255db787c2..3bb85c9296 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -177,3 +177,10 @@ Applications ~~~~~~~~~~~~~~~~~~~~~~~~ .. automodule:: monai.apps.detection.utils.box_selector :members: + +`Detection metrics` +~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.apps.detection.metrics.coco + :members: +.. automodule:: monai.apps.detection.metrics.matching + :members: diff --git a/monai/apps/detection/metrics/__init__.py b/monai/apps/detection/metrics/__init__.py new file mode 100644 index 0000000000..1e97f89407 --- /dev/null +++ b/monai/apps/detection/metrics/__init__.py @@ -0,0 +1,10 @@ +# 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. diff --git a/monai/apps/detection/metrics/coco.py b/monai/apps/detection/metrics/coco.py new file mode 100644 index 0000000000..7c6b02fe4f --- /dev/null +++ b/monai/apps/detection/metrics/coco.py @@ -0,0 +1,547 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/evaluator/detection/coco.py +# which has the following license... +# https://github.com/MIC-DKFZ/nnDetection/blob/main/LICENSE +# +# Copyright 2020 Division of Medical Image Computing, German Cancer Research Center (DKFZ), Heidelberg, Germany +# 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. + +# ========================================================================= +# Adapted from https://github.com/cocodataset/cocoapi +# which has the following license... +# https://github.com/cocodataset/cocoapi/blob/master/license.txt + +# Copyright (c) 2014, Piotr Dollar and Tsung-Yi Lin +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# The views and conclusions contained in the software and documentation are those +# of the authors and should not be interpreted as representing official policies, +# either expressed or implied, of the FreeBSD Project. + +""" +This script is almost same with https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/evaluator/detection/coco.py +The changes include 1) code reformatting, 2) docstrings. +""" + +import logging as logger +import time +from typing import Dict, List, Sequence, Tuple, Union + +import numpy as np + + +class COCOMetric: + def __init__( + self, + classes: Sequence[str], + iou_list: Sequence[float] = (0.1, 0.5, 0.75), + iou_range: Sequence[float] = (0.1, 0.5, 0.05), + max_detection: Sequence[int] = (1, 5, 100), + per_class: bool = True, + verbose: bool = True, + ): + """ + Class to compute COCO metrics + Metrics computed includes, + + - mAP over the IoU range specified by `iou_range` at last value of `max_detection` + - AP values at IoU thresholds specified by `iou_list` at last value of `max_detection` + - AR over max detections thresholds defined by `max_detection` (over iou range) + + Args: + classes (Sequence[str]): name of each class (index needs to correspond to predicted class indices!) + iou_list (Sequence[float]): specific thresholds where ap is evaluated and saved + iou_range (Sequence[float]): (start, stop, step) for mAP iou thresholds + max_detection (Sequence[int]): maximum number of detections per image + verbose (bool): log time needed for evaluation + + Example: + + .. code-block:: python + + from monai.data.box_utils import box_iou + from monai.apps.detection.metrics.coco import COCOMetric + from monai.apps.detection.metrics.matching import matching_batch + # 3D example outputs of one image from detector + val_outputs_all = [ + {"boxes": torch.tensor([[1,1,1,3,4,5]],dtype=torch.float16), + "labels": torch.randint(3,(1,)), + "scores": torch.randn((1,)).absolute()}, + ] + val_targets_all = [ + {"boxes": torch.tensor([[1,1,1,2,6,4]],dtype=torch.float16), + "labels": torch.randint(3,(1,))}, + ] + + coco_metric = COCOMetric( + classes=['c0','c1','c2'], iou_list=[0.1], max_detection=[10] + ) + results_metric = matching_batch( + iou_fn=box_iou, + iou_thresholds=coco_metric.iou_thresholds, + pred_boxes=[val_data_i["boxes"].numpy() for val_data_i in val_outputs_all], + pred_classes=[val_data_i["labels"].numpy() for val_data_i in val_outputs_all], + pred_scores=[val_data_i["scores"].numpy() for val_data_i in val_outputs_all], + gt_boxes=[val_data_i["boxes"].numpy() for val_data_i in val_targets_all], + gt_classes=[val_data_i["labels"].numpy() for val_data_i in val_targets_all], + ) + val_metric_dict = coco_metric(results_metric) + print(val_metric_dict) + """ + self.verbose = verbose + self.classes = classes + self.per_class = per_class + + iou_list_np = np.array(iou_list) + _iou_range = np.linspace( + iou_range[0], iou_range[1], int(np.round((iou_range[1] - iou_range[0]) / iou_range[2])) + 1, endpoint=True + ) + self.iou_thresholds = np.union1d(iou_list_np, _iou_range) + self.iou_range = iou_range + + # get indices of iou values of ious range and ious list for later evaluation + self.iou_list_idx = np.nonzero(iou_list_np[:, np.newaxis] == self.iou_thresholds[np.newaxis])[1] + self.iou_range_idx = np.nonzero(_iou_range[:, np.newaxis] == self.iou_thresholds[np.newaxis])[1] + + if ( + not (self.iou_thresholds[self.iou_list_idx] == iou_list_np).all() + or not (self.iou_thresholds[self.iou_range_idx] == _iou_range).all() + ): + raise ValueError( + "Require self.iou_thresholds[self.iou_list_idx] == iou_list_np and " + "self.iou_thresholds[self.iou_range_idx] == _iou_range." + ) + + self.recall_thresholds = np.linspace(0.0, 1.00, int(np.round((1.00 - 0.0) / 0.01)) + 1, endpoint=True) + self.max_detections = max_detection + + def __call__(self, *args, **kwargs) -> Tuple[Dict[str, float], Union[Dict[str, np.ndarray], None]]: + """ + Compute metric. See :func:`compute` for more information. + + Args: + *args: positional arguments passed to :func:`compute` + **kwargs: keyword arguments passed to :func:`compute` + + Returns: + Dict[str, float]: dictionary with scalar values for evaluation + Dict[str, np.ndarray]: dictionary with arrays, e.g. for visualization of graphs + """ + return self.compute(*args, **kwargs) + + def check_number_of_iou(self, *args) -> None: + """ + Check if shape of input in first dimension is consistent with expected IoU values + (assumes IoU dimension is the first dimension) + + Args: + args: array like inputs with shape function + """ + num_ious = len(self.get_iou_thresholds()) + for arg in args: + if arg.shape[0] != num_ious: + raise ValueError( + f"Require arg.shape[0] == len(self.get_iou_thresholds()). Got arg.shape[0]={arg.shape[0]}, " + f"self.get_iou_thresholds()={self.get_iou_thresholds()}." + ) + + def get_iou_thresholds(self) -> Sequence[float]: + """ + Return IoU thresholds needed for this metric in an numpy array + + Returns: + Sequence[float]: IoU thresholds [M], M is the number of thresholds + """ + return list(self.iou_thresholds) + + def compute(self, results_list: List[Dict[int, Dict[str, np.ndarray]]]) -> Tuple[Dict[str, float], None]: + """ + Compute COCO metrics + + Args: + results_list (List[Dict[int, Dict[str, np.ndarray]]]): list with results per image (in list) + per category (dict). Inner Dict contains multiple results obtained by :func:`box_matching_batch`. + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + + Returns: + Dict[str, float], dictionary with coco metrics + """ + if self.verbose: + logger.info("Start COCO metric computation...") + tic = time.time() + + dataset_statistics = self._compute_statistics(results_list=results_list) # Dict[str, Union[np.ndarray, List]] + + if self.verbose: + toc = time.time() + logger.info(f"Statistics for COCO metrics finished (t={(toc - tic):0.2f}s).") + + results = {} + results.update(self._compute_ap(dataset_statistics)) + results.update(self._compute_ar(dataset_statistics)) + + if self.verbose: + toc = time.time() + logger.info(f"COCO metrics computed in t={(toc - tic):0.2f}s.") + return results, None + + def _compute_ap(self, dataset_statistics: Dict[str, Union[np.ndarray, List]]) -> Dict[str, float]: + """ + Compute AP metrics + + Args: + dataset_statistics (List[Dict[int, Dict[str, np.ndarray]]]): list with result s per image (in list) + per category (dict). Inner Dict contains multiple results obtained by :func:`box_matching_batch`. + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + """ + results = {} + if self.iou_range: # mAP + key = ( + f"mAP_IoU_{self.iou_range[0]:.2f}_{self.iou_range[1]:.2f}_{self.iou_range[2]:.2f}_" + f"MaxDet_{self.max_detections[-1]}" + ) + results[key] = self._select_ap(dataset_statistics, iou_idx=self.iou_range_idx, max_det_idx=-1) + + if self.per_class: + for cls_idx, cls_str in enumerate(self.classes): # per class results + key = ( + f"{cls_str}_" + f"mAP_IoU_{self.iou_range[0]:.2f}_{self.iou_range[1]:.2f}_{self.iou_range[2]:.2f}_" + f"MaxDet_{self.max_detections[-1]}" + ) + results[key] = self._select_ap( + dataset_statistics, iou_idx=self.iou_range_idx, cls_idx=cls_idx, max_det_idx=-1 + ) + + for idx in self.iou_list_idx: # AP@IoU + key = f"AP_IoU_{self.iou_thresholds[idx]:.2f}_MaxDet_{self.max_detections[-1]}" + results[key] = self._select_ap(dataset_statistics, iou_idx=[idx], max_det_idx=-1) + + if self.per_class: + for cls_idx, cls_str in enumerate(self.classes): # per class results + key = f"{cls_str}_" f"AP_IoU_{self.iou_thresholds[idx]:.2f}_" f"MaxDet_{self.max_detections[-1]}" + results[key] = self._select_ap(dataset_statistics, iou_idx=[idx], cls_idx=cls_idx, max_det_idx=-1) + return results + + def _compute_ar(self, dataset_statistics: Dict[str, Union[np.ndarray, List]]) -> Dict[str, float]: + """ + Compute AR metrics + + Args: + dataset_statistics (List[Dict[int, Dict[str, np.ndarray]]]): list with result s per image (in list) + per category (dict). Inner Dict contains multiple results obtained by :func:`box_matching_batch`. + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + """ + results = {} + for max_det_idx, max_det in enumerate(self.max_detections): # mAR + key = f"mAR_IoU_{self.iou_range[0]:.2f}_{self.iou_range[1]:.2f}_{self.iou_range[2]:.2f}_MaxDet_{max_det}" + results[key] = self._select_ar(dataset_statistics, max_det_idx=max_det_idx) + + if self.per_class: + for cls_idx, cls_str in enumerate(self.classes): # per class results + key = ( + f"{cls_str}_" + f"mAR_IoU_{self.iou_range[0]:.2f}_{self.iou_range[1]:.2f}_{self.iou_range[2]:.2f}_" + f"MaxDet_{max_det}" + ) + results[key] = self._select_ar(dataset_statistics, cls_idx=cls_idx, max_det_idx=max_det_idx) + + for idx in self.iou_list_idx: # AR@IoU + key = f"AR_IoU_{self.iou_thresholds[idx]:.2f}_MaxDet_{self.max_detections[-1]}" + results[key] = self._select_ar(dataset_statistics, iou_idx=idx, max_det_idx=-1) + + if self.per_class: + for cls_idx, cls_str in enumerate(self.classes): # per class results + key = f"{cls_str}_" f"AR_IoU_{self.iou_thresholds[idx]:.2f}_" f"MaxDet_{self.max_detections[-1]}" + results[key] = self._select_ar(dataset_statistics, iou_idx=idx, cls_idx=cls_idx, max_det_idx=-1) + return results + + @staticmethod + def _select_ap( + dataset_statistics: dict, + iou_idx: Union[int, List[int], np.ndarray, None] = None, + cls_idx: Union[int, Sequence[int], None] = None, + max_det_idx: int = -1, + ) -> float: + """ + Compute average precision + + Args: + dataset_statistics (dict): computed statistics over dataset + + - `counts`: Number of thresholds, Number recall thresholds, Number of classes, Number of max + detection thresholds + - `recall`: Computed recall values [num_iou_th, num_classes, num_max_detections] + - `precision`: Precision values at specified recall thresholds + [num_iou_th, num_recall_th, num_classes, num_max_detections] + - `scores`: Scores corresponding to specified recall thresholds + [num_iou_th, num_recall_th, num_classes, num_max_detections] + iou_idx: index of IoU values to select for evaluation(if None, all values are used) + cls_idx: class indices to select, if None all classes will be selected + max_det_idx (int): index to select max detection threshold from data + + Returns: + np.ndarray: AP value + """ + prec = dataset_statistics["precision"] + if iou_idx is not None: + prec = prec[iou_idx] + if cls_idx is not None: + prec = prec[..., cls_idx, :] + prec = prec[..., max_det_idx] + return float(np.mean(prec)) + + @staticmethod + def _select_ar( + dataset_statistics: dict, + iou_idx: Union[int, Sequence[int], None] = None, + cls_idx: Union[int, Sequence[int], None] = None, + max_det_idx: int = -1, + ) -> float: + """ + Compute average recall + + Args: + dataset_statistics (dict): computed statistics over dataset + + - `counts`: Number of thresholds, Number recall thresholds, Number of classes, Number of max + detection thresholds + - `recall`: Computed recall values [num_iou_th, num_classes, num_max_detections] + - `precision`: Precision values at specified recall thresholds + [num_iou_th, num_recall_th, num_classes, num_max_detections] + - `scores`: Scores corresponding to specified recall thresholds + [num_iou_th, num_recall_th, num_classes, num_max_detections] + iou_idx: index of IoU values to select for evaluation(if None, all values are used) + cls_idx: class indices to select, if None all classes will be selected + max_det_idx (int): index to select max detection threshold from data + + Returns: + np.ndarray: recall value + """ + rec = dataset_statistics["recall"] + if iou_idx is not None: + rec = rec[iou_idx] + if cls_idx is not None: + rec = rec[..., cls_idx, :] + rec = rec[..., max_det_idx] + + if len(rec[rec > -1]) == 0: + return -1.0 + + return float(np.mean(rec[rec > -1])) + + def _compute_statistics( + self, results_list: List[Dict[int, Dict[str, np.ndarray]]] + ) -> Dict[str, Union[np.ndarray, List]]: + """ + Compute statistics needed for COCO metrics (mAP, AP of individual classes, mAP@IoU_Thresholds, AR) + Adapted from https://github.com/cocodataset/cocoapi/blob/master/PythonAPI/pycocotools/cocoeval.py + + Args: + results_list (List[Dict[int, Dict[str, np.ndarray]]]): list with result s per image (in list) + per cateory (dict). Inner Dict contains multiple results obtained by :func:`box_matching_batch`. + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + + Returns: + dict: computed statistics over dataset + - `counts`: Number of thresholds, Number recall thresholds, Number of classes, Number of max + detection thresholds + - `recall`: Computed recall values [num_iou_th, num_classes, num_max_detections] + - `precision`: Precision values at specified recall thresholds + [num_iou_th, num_recall_th, num_classes, num_max_detections] + - `scores`: Scores corresponding to specified recall thresholds + [num_iou_th, num_recall_th, num_classes, num_max_detections] + """ + num_iou_th = len(self.iou_thresholds) + num_recall_th = len(self.recall_thresholds) + num_classes = len(self.classes) + num_max_detections = len(self.max_detections) + + # -1 for the precision of absent categories + precision = -np.ones((num_iou_th, num_recall_th, num_classes, num_max_detections)) + recall = -np.ones((num_iou_th, num_classes, num_max_detections)) + scores = -np.ones((num_iou_th, num_recall_th, num_classes, num_max_detections)) + + for cls_idx, cls_i in enumerate(self.classes): # for each class + for max_det_idx, max_det in enumerate(self.max_detections): # for each maximum number of detections + results = [r[cls_idx] for r in results_list if cls_idx in r] # len is num_images + + if len(results) == 0: + logger.warning(f"WARNING, no results found for coco metric for class {cls_i}") + continue + + dt_scores = np.concatenate([r["dtScores"][0:max_det] for r in results]) + # different sorting method generates slightly different results. + # mergesort is used to be consistent as Matlab implementation. + inds = np.argsort(-dt_scores, kind="mergesort") + dt_scores_sorted = dt_scores[inds] + + # r['dtMatches'] [T, R], where R = sum(all detections) + dt_matches = np.concatenate([r["dtMatches"][:, 0:max_det] for r in results], axis=1)[:, inds] + dt_ignores = np.concatenate([r["dtIgnore"][:, 0:max_det] for r in results], axis=1)[:, inds] + self.check_number_of_iou(dt_matches, dt_ignores) + gt_ignore = np.concatenate([r["gtIgnore"] for r in results]) + num_gt = np.count_nonzero(gt_ignore == 0) # number of ground truth boxes (non ignored) + if num_gt == 0: + logger.warning(f"WARNING, no gt found for coco metric for class {cls_i}") + continue + + # ignore cases need to be handled differently for tp and fp + tps = np.logical_and(dt_matches, np.logical_not(dt_ignores)) + fps = np.logical_and(np.logical_not(dt_matches), np.logical_not(dt_ignores)) + + tp_sum = np.cumsum(tps, axis=1).astype(dtype=np.float32) + fp_sum = np.cumsum(fps, axis=1).astype(dtype=np.float32) + + for th_ind, (tp, fp) in enumerate(zip(tp_sum, fp_sum)): # for each threshold th_ind + tp, fp = np.array(tp), np.array(fp) + r, p, s = _compute_stats_single_threshold(tp, fp, dt_scores_sorted, self.recall_thresholds, num_gt) + recall[th_ind, cls_idx, max_det_idx] = r + precision[th_ind, :, cls_idx, max_det_idx] = p + # corresponding score thresholds for recall steps + scores[th_ind, :, cls_idx, max_det_idx] = s + + return { + "counts": [num_iou_th, num_recall_th, num_classes, num_max_detections], # [4] + "recall": recall, # [num_iou_th, num_classes, num_max_detections] + "precision": precision, # [num_iou_th, num_recall_th, num_classes, num_max_detections] + "scores": scores, # [num_iou_th, num_recall_th, num_classes, num_max_detections] + } + + +def _compute_stats_single_threshold( + tp: np.ndarray, + fp: np.ndarray, + dt_scores_sorted: np.ndarray, + recall_thresholds: Union[np.ndarray, Sequence[float]], + num_gt: int, +) -> Tuple[float, np.ndarray, np.ndarray]: + """ + Compute recall value, precision curve and scores thresholds + Adapted from https://github.com/cocodataset/cocoapi/blob/master/PythonAPI/pycocotools/cocoeval.py + + Args: + tp (np.ndarray): cumsum over true positives [R], R is the number of detections + fp (np.ndarray): cumsum over false positives [R], R is the number of detections + dt_scores_sorted (np.ndarray): sorted (descending) scores [R], R is the number of detections + recall_thresholds (Sequence[float]): recall thresholds which should be evaluated + num_gt (int): number of ground truth bounding boxes (excluding boxes which are ignored) + + Returns: + - float, overall recall for given IoU value + - np.ndarray, precision values at defined recall values + [RTH], where RTH is the number of recall thresholds + - np.ndarray, prediction scores corresponding to recall values + [RTH], where RTH is the number of recall thresholds + """ + num_recall_th = len(recall_thresholds) + + rc = tp / num_gt + # np.spacing(1) is the smallest representable epsilon with float + pr = tp / (fp + tp + np.spacing(1)) + + if len(tp): + recall = rc[-1] + else: + # no prediction + recall = 0 + + # array where precision values nearest to given recall th are saved + precision = np.zeros((num_recall_th,)) + # save scores for corresponding recall value in here + th_scores = np.zeros((num_recall_th,)) + # numpy is slow without cython optimization for accessing elements + # use python array gets significant speed improvement + pr = pr.tolist() + precision = precision.tolist() + + # smooth precision curve (create box shape) + for i in range(len(tp) - 1, 0, -1): + if pr[i] > pr[i - 1]: + pr[i - 1] = pr[i] + + # get indices to nearest given recall threshold (nn interpolation!) + inds = np.searchsorted(rc, recall_thresholds, side="left") + try: + for save_idx, array_index in enumerate(inds): + precision[save_idx] = pr[array_index] + th_scores[save_idx] = dt_scores_sorted[array_index] + except BaseException: + pass + + return recall, np.array(precision), np.array(th_scores) diff --git a/monai/apps/detection/metrics/matching.py b/monai/apps/detection/metrics/matching.py new file mode 100644 index 0000000000..6df026bf54 --- /dev/null +++ b/monai/apps/detection/metrics/matching.py @@ -0,0 +1,367 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/evaluator/detection/matching.py +# which has the following license... +# https://github.com/MIC-DKFZ/nnDetection/blob/main/LICENSE +# +# Copyright 2020 Division of Medical Image Computing, German Cancer Research Center (DKFZ), Heidelberg, Germany +# 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. + +# ========================================================================= +# Adapted from https://github.com/cocodataset/cocoapi +# which has the following license... +# https://github.com/cocodataset/cocoapi/blob/master/license.txt + +# Copyright (c) 2014, Piotr Dollar and Tsung-Yi Lin +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# The views and conclusions contained in the software and documentation are those +# of the authors and should not be interpreted as representing official policies, +# either expressed or implied, of the FreeBSD Project. + +""" +This script is almost same with https://github.com/MIC-DKFZ/nnDetection/blob/main/nndet/evaluator/detection/matching.py +The changes include 1) code reformatting, 2) docstrings, +3) allow input args gt_ignore to be optional. (If so, no GT boxes will be ignored.) +""" + +from typing import Callable, Dict, List, Sequence, Union + +import numpy as np + +__all__ = ["matching_batch"] + + +def matching_batch( + iou_fn: Callable[[np.ndarray, np.ndarray], np.ndarray], + iou_thresholds: Sequence[float], + pred_boxes: Sequence[np.ndarray], + pred_classes: Sequence[np.ndarray], + pred_scores: Sequence[np.ndarray], + gt_boxes: Sequence[np.ndarray], + gt_classes: Sequence[np.ndarray], + gt_ignore: Union[Sequence[Sequence[bool]], Sequence[np.ndarray], None] = None, + max_detections: int = 100, +) -> List[Dict[int, Dict[str, np.ndarray]]]: + """ + Match boxes of a batch to corresponding ground truth for each category + independently. + + Args: + iou_fn: compute overlap for each pair + iou_thresholds: defined which IoU thresholds should be evaluated + pred_boxes: predicted boxes from single batch; List[[D, dim * 2]], + D number of predictions + pred_classes: predicted classes from a single batch; List[[D]], + D number of predictions + pred_scores: predicted score for each bounding box; List[[D]], + D number of predictions + gt_boxes: ground truth boxes; List[[G, dim * 2]], G number of ground + truth + gt_classes: ground truth classes; List[[G]], G number of ground truth + gt_ignore: specified if which ground truth boxes are not counted as + true positives. If not given, when use all the gt_boxes. + (detections which match theses boxes are not counted as false + positives either); List[[G]], G number of ground truth + max_detections: maximum number of detections which should be evaluated + + Returns: + List[Dict[int, Dict[str, np.ndarray]]], each Dict[str, np.ndarray] corresponds to an image. + Dict has the following keys. + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + + Example: + + .. code-block:: python + + from monai.data.box_utils import box_iou + from monai.apps.detection.metrics.coco import COCOMetric + from monai.apps.detection.metrics.matching import matching_batch + # 3D example outputs of one image from detector + val_outputs_all = [ + {"boxes": torch.tensor([[1,1,1,3,4,5]],dtype=torch.float16), + "labels": torch.randint(3,(1,)), + "scores": torch.randn((1,)).absolute()}, + ] + val_targets_all = [ + {"boxes": torch.tensor([[1,1,1,2,6,4]],dtype=torch.float16), + "labels": torch.randint(3,(1,))}, + ] + + coco_metric = COCOMetric( + classes=['c0','c1','c2'], iou_list=[0.1], max_detection=[10] + ) + results_metric = matching_batch( + iou_fn=box_iou, + iou_thresholds=coco_metric.iou_thresholds, + pred_boxes=[val_data_i["boxes"].numpy() for val_data_i in val_outputs_all], + pred_classes=[val_data_i["labels"].numpy() for val_data_i in val_outputs_all], + pred_scores=[val_data_i["scores"].numpy() for val_data_i in val_outputs_all], + gt_boxes=[val_data_i["boxes"].numpy() for val_data_i in val_targets_all], + gt_classes=[val_data_i["labels"].numpy() for val_data_i in val_targets_all], + ) + val_metric_dict = coco_metric(results_metric) + print(val_metric_dict) + """ + results = [] + if gt_ignore is None: + gt_ignore = [np.full_like(gt_c, False) for gt_c in gt_classes] + # iterate over images/batches + for pboxes, pclasses, pscores, gboxes, gclasses, gignore in zip( + pred_boxes, pred_classes, pred_scores, gt_boxes, gt_classes, gt_ignore + ): + # for each image + img_classes = np.union1d(pclasses, gclasses) # possible class labels + result = {} # dict contains results for each class in one image + for c in img_classes: + pred_mask = pclasses == c # bool mask predictions with current class + gt_mask = gclasses == c # nool mask ground trtuh with current class + + if not np.any(gt_mask): # no ground truth + result[c] = _matching_no_gt( + iou_thresholds=iou_thresholds, pred_scores=pscores[pred_mask], max_detections=max_detections + ) + elif not np.any(pred_mask): # no predictions + result[c] = _matching_no_pred(iou_thresholds=iou_thresholds, gt_ignore=gignore[gt_mask]) + else: # at least one prediction and one ground truth + result[c] = _matching_single_image_single_class( + iou_fn=iou_fn, + pred_boxes=pboxes[pred_mask], + pred_scores=pscores[pred_mask], + gt_boxes=gboxes[gt_mask], + gt_ignore=gignore[gt_mask], + max_detections=max_detections, + iou_thresholds=iou_thresholds, + ) + results.append(result) + return results + + +def _matching_no_gt( + iou_thresholds: Sequence[float], pred_scores: np.ndarray, max_detections: int +) -> Dict[str, np.ndarray]: + """ + Matching result with not ground truth in image + + Args: + iou_thresholds: defined which IoU thresholds should be evaluated + dt_scores: predicted scores + max_detections: maximum number of allowed detections per image. + This functions uses this parameter to stay consistent with + the actual matching function which needs this limit. + + Returns: + computed matching, a Dict[str, np.ndarray] + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + """ + dt_ind = np.argsort(-pred_scores, kind="mergesort") + dt_ind = dt_ind[:max_detections] + dt_scores = pred_scores[dt_ind] + + num_preds = len(dt_scores) + + gt_match: np.ndarray = np.array([[]] * len(iou_thresholds)) + dt_match: np.ndarray = np.zeros((len(iou_thresholds), num_preds)) + dt_ignore: np.ndarray = np.zeros((len(iou_thresholds), num_preds)) + + return { + "dtMatches": dt_match, # [T, D], where T = number of thresholds, D = number of detections + "gtMatches": gt_match, # [T, G], where T = number of thresholds, G = number of ground truth + "dtScores": dt_scores, # [D] detection scores + "gtIgnore": np.array([]).reshape(-1), # [G] indicate whether ground truth should be ignored + "dtIgnore": dt_ignore, # [T, D], indicate which detections should be ignored + } + + +def _matching_no_pred(iou_thresholds: Sequence[float], gt_ignore: np.ndarray) -> Dict[str, np.ndarray]: + """ + Matching result with no predictions + + Args: + iou_thresholds: defined which IoU thresholds should be evaluated + gt_ignore: specified if which ground truth boxes are not counted as + true positives (detections which match theses boxes are not + counted as false positives either); [G], G number of ground truth + + Returns: + dict: computed matching + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + """ + dt_scores: np.ndarray = np.array([]) + dt_match: np.ndarray = np.array([[]] * len(iou_thresholds)) + dt_ignore: np.ndarray = np.array([[]] * len(iou_thresholds)) + + n_gt = 0 if gt_ignore.size == 0 else gt_ignore.shape[0] + gt_match = np.zeros((len(iou_thresholds), n_gt)) + + return { + "dtMatches": dt_match, # [T, D], where T = number of thresholds, D = number of detections + "gtMatches": gt_match, # [T, G], where T = number of thresholds, G = number of ground truth + "dtScores": dt_scores, # [D] detection scores + "gtIgnore": gt_ignore.reshape(-1), # [G] indicate whether ground truth should be ignored + "dtIgnore": dt_ignore, # [T, D], indicate which detections should be ignored + } + + +def _matching_single_image_single_class( + iou_fn: Callable[[np.ndarray, np.ndarray], np.ndarray], + pred_boxes: np.ndarray, + pred_scores: np.ndarray, + gt_boxes: np.ndarray, + gt_ignore: np.ndarray, + max_detections: int, + iou_thresholds: Sequence[float], +) -> Dict[str, np.ndarray]: + """ + Adapted from https://github.com/cocodataset/cocoapi/blob/master/PythonAPI/pycocotools/cocoeval.py + + Args: + iou_fn: compute overlap for each pair + iou_thresholds: defined which IoU thresholds should be evaluated + pred_boxes: predicted boxes from single batch; [D, dim * 2], D number + of predictions + pred_scores: predicted score for each bounding box; [D], D number of + predictions + gt_boxes: ground truth boxes; [G, dim * 2], G number of ground truth + gt_ignore: specified if which ground truth boxes are not counted as + true positives (detections which match theses boxes are not + counted as false positives either); [G], G number of ground truth + max_detections: maximum number of detections which should be evaluated + + Returns: + dict: computed matching + + - `dtMatches`: matched detections [T, D], where T = number of + thresholds, D = number of detections + - `gtMatches`: matched ground truth boxes [T, G], where T = number + of thresholds, G = number of ground truth + - `dtScores`: prediction scores [D] detection scores + - `gtIgnore`: ground truth boxes which should be ignored + [G] indicate whether ground truth should be ignored + - `dtIgnore`: detections which should be ignored [T, D], + indicate which detections should be ignored + """ + # filter for max_detections highest scoring predictions to speed up computation + dt_ind = np.argsort(-pred_scores, kind="mergesort") + dt_ind = dt_ind[:max_detections] + + pred_boxes = pred_boxes[dt_ind] + pred_scores = pred_scores[dt_ind] + + # sort ignored ground truth to last positions + gt_ind = np.argsort(gt_ignore, kind="mergesort") + gt_boxes = gt_boxes[gt_ind] + gt_ignore = gt_ignore[gt_ind] + + # ious between sorted(!) predictions and ground truth + ious = iou_fn(pred_boxes, gt_boxes) # array sized (num_preds, num_gts) + + num_preds, num_gts = ious.shape[0], ious.shape[1] + gt_match = np.zeros((len(iou_thresholds), num_gts)) + dt_match = np.zeros((len(iou_thresholds), num_preds)) + dt_ignore = np.zeros((len(iou_thresholds), num_preds)) + + for tind, t in enumerate(iou_thresholds): + for dind, _d in enumerate(pred_boxes): # iterate detections starting from highest scoring one + # information about best match so far (m=-1 -> unmatched) + iou = min([t, 1 - 1e-10]) + m = -1 + + for gind, _g in enumerate(gt_boxes): # iterate ground truth + # if this gt already matched, continue + if gt_match[tind, gind] > 0: + continue + + # if dt matched to reg gt, and on ignore gt, stop + if m > -1 and gt_ignore[m] == 0 and gt_ignore[gind] == 1: + break + + # continue to next gt unless better match made + if ious[dind, gind] < iou: + continue + + # if match successful and best so far, store appropriately + iou = ious[dind, gind] + m = gind + + # if match made, store id of match for both dt and gt + if m == -1: + continue + else: + dt_ignore[tind, dind] = int(gt_ignore[m]) + dt_match[tind, dind] = 1 + gt_match[tind, m] = 1 + + # store results for given image and category + return { + "dtMatches": dt_match, # [T, D], where T = number of thresholds, D = number of detections + "gtMatches": gt_match, # [T, G], where T = number of thresholds, G = number of ground truth + "dtScores": pred_scores, # [D] detection scores + "gtIgnore": gt_ignore.reshape(-1), # [G] indicate whether ground truth should be ignored + "dtIgnore": dt_ignore, # [T, D], indicate which detections should be ignored + } diff --git a/tests/test_detection_coco_metrics.py b/tests/test_detection_coco_metrics.py new file mode 100644 index 0000000000..d5c8445f8e --- /dev/null +++ b/tests/test_detection_coco_metrics.py @@ -0,0 +1,66 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import unittest + +import numpy as np +import torch + +from monai.apps.detection.metrics.coco import COCOMetric +from monai.apps.detection.metrics.matching import matching_batch +from monai.data.box_utils import box_iou + + +class TestCOCOMetrics(unittest.TestCase): + def test_coco_run(self): + coco_metric = COCOMetric(classes=["c0", "c1", "c2"], iou_list=[0.1], max_detection=[10]) + + num_images = 10 + + val_outputs_all = [] + val_targets_all = [] + for _ in range(num_images): + # randomly generate gt boxes and pred boxes + num_gt_boxes = random.randint(1, 3) + num_pred_boxes = random.randint(0, 3) + + box_start = torch.randint(3, (num_pred_boxes, 3)) + box_stop = box_start + torch.randint(1, 32, (num_pred_boxes, 3)) + boxes = torch.cat((box_start, box_stop), dim=1).to(torch.float16) + val_outputs_all.append( + { + "boxes": boxes, + "labels": torch.randint(3, (num_pred_boxes,)), + "scores": torch.randn((num_pred_boxes,)).absolute(), + } + ) + + box_start = torch.randint(3, (num_gt_boxes, 3)) + box_stop = box_start + torch.randint(1, 32, (num_gt_boxes, 3)) + boxes = torch.cat((box_start, box_stop), dim=1).to(torch.float16) + val_targets_all.append({"boxes": boxes, "labels": torch.randint(3, (num_gt_boxes,))}) + + results_metric = matching_batch( + iou_fn=box_iou, + iou_thresholds=coco_metric.iou_thresholds, + pred_boxes=[val_data_i["boxes"].numpy() for val_data_i in val_outputs_all], + pred_classes=[val_data_i["labels"].numpy() for val_data_i in val_outputs_all], + pred_scores=[val_data_i["scores"].numpy() for val_data_i in val_outputs_all], + gt_boxes=[val_data_i["boxes"].numpy() for val_data_i in val_targets_all], + gt_classes=[val_data_i["labels"].numpy() for val_data_i in val_targets_all], + ) + val_epoch_metric_dict = coco_metric(results_metric)[0] + np.testing.assert_array_less([-0.01], [sum(val_epoch_metric_dict.values())]) + + +if __name__ == "__main__": + unittest.main() From df4a7d72e1d231b898f88d92cf981721c49ceaeb Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Tue, 7 Jun 2022 14:55:25 -0400 Subject: [PATCH 138/183] add GIoU loss for box detection (#4454) --- .../detection/networks/retinanet_detector.py | 4 ++ monai/losses/__init__.py | 1 + monai/losses/giou_loss.py | 66 +++++++++++++++++++ tests/test_giou_loss.py | 59 +++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 monai/losses/giou_loss.py create mode 100644 tests/test_giou_loss.py diff --git a/monai/apps/detection/networks/retinanet_detector.py b/monai/apps/detection/networks/retinanet_detector.py index f1eb98bddf..fd270ee094 100644 --- a/monai/apps/detection/networks/retinanet_detector.py +++ b/monai/apps/detection/networks/retinanet_detector.py @@ -302,6 +302,10 @@ def set_box_regression_loss(self, box_loss: nn.Module, encode_gt: bool, decode_p torch.nn.SmoothL1Loss(beta=1.0 / 9, reduction="mean"), encode_gt = True, decode_pred = False ) + detector.set_box_regression_loss( + monai.losses.giou_loss.BoxGIoULoss(reduction="mean"), + encode_gt = False, decode_pred = True + ) """ self.box_loss_func = box_loss self.encode_gt = encode_gt diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py index dffd94f5a5..85bbe37fb9 100644 --- a/monai/losses/__init__.py +++ b/monai/losses/__init__.py @@ -27,6 +27,7 @@ generalized_wasserstein_dice, ) from .focal_loss import FocalLoss +from .giou_loss import BoxGIoULoss, giou from .image_dissimilarity import GlobalMutualInformationLoss, LocalNormalizedCrossCorrelationLoss from .multi_scale import MultiScaleLoss from .spatial_mask import MaskedLoss diff --git a/monai/losses/giou_loss.py b/monai/losses/giou_loss.py new file mode 100644 index 0000000000..7f2dfb63ff --- /dev/null +++ b/monai/losses/giou_loss.py @@ -0,0 +1,66 @@ +# 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 typing import Union + +import torch +from torch.nn.modules.loss import _Loss + +from monai.data.box_utils import COMPUTE_DTYPE, box_pair_giou +from monai.utils import LossReduction + + +class BoxGIoULoss(_Loss): + + """ + Compute the generalized intersection over union (GIoU) loss of a pair of boxes. + The two inputs should have the same shape. giou_loss = 1.0 - giou + + The range of GIoU is (-1.0, 1.0]. Thus the range of GIoU loss is [0.0, 2.0). + + Args: + reduction: {``"none"``, ``"mean"``, ``"sum"``} + Specifies the reduction to apply to the output. Defaults to ``"mean"``. + - ``"none"``: no reduction will be applied. + - ``"mean"``: the sum of the output will be divided by the number of elements in the output. + - ``"sum"``: the output will be summed. + """ + + def __init__(self, reduction: Union[LossReduction, str] = LossReduction.MEAN) -> None: + super().__init__(reduction=LossReduction(reduction).value) + + def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """ + Args: + input: predicted bounding boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + target: GT bounding boxes, Nx4 or Nx6 torch tensor. The box mode is assumed to be ``StandardMode`` + + Raises: + ValueError: When the two inputs have different shape. + """ + if target.shape != input.shape: + raise ValueError(f"ground truth has different shape ({target.shape}) from input ({input.shape})") + + box_dtype = input.dtype + giou: torch.Tensor = box_pair_giou(target.to(dtype=COMPUTE_DTYPE), input.to(dtype=COMPUTE_DTYPE)) # type: ignore + loss: torch.Tensor = 1.0 - giou + if self.reduction == LossReduction.MEAN.value: + loss = loss.mean() + elif self.reduction == LossReduction.SUM.value: + loss = loss.sum() + elif self.reduction == LossReduction.NONE.value: + pass + else: + raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].') + return loss.to(box_dtype) + + +giou = BoxGIoULoss diff --git a/tests/test_giou_loss.py b/tests/test_giou_loss.py new file mode 100644 index 0000000000..25cc258054 --- /dev/null +++ b/tests/test_giou_loss.py @@ -0,0 +1,59 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.losses import BoxGIoULoss + +TEST_CASES = [ + [ # shape: (1, 4), (1, 4) + {"input": torch.tensor([[1.0, 1.0, 2.0, 2.0]]), "target": torch.tensor([[1.0, 1.0, 2.0, 2.0]])}, + 0.0, + ], + [ # shape: (1, 6), (1, 6) + { + "input": torch.tensor([[1.0, 1.0, 1.0, 2.0, 2.0, 2.0]]), + "target": torch.tensor([[1.0, 1.0, 1.0, 2.0, 2.0, 2.0]]), + }, + 0.0, + ], +] + + +class TestGIoULoss(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_result(self, input_data, expected_val): + loss = BoxGIoULoss() + result = loss(**input_data) + np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) + + def test_ill_shape(self): + loss = BoxGIoULoss() + with self.assertRaisesRegex(ValueError, ""): + loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_with_cuda(self): + loss = BoxGIoULoss() + i = torch.tensor([[1.0, 1.0, 2.0, 2.0]]) + j = torch.tensor([[1.0, 1.0, 2.0, 2.0]]) + if torch.cuda.is_available(): + i = i.cuda() + j = j.cuda() + output = loss(i, j) + np.testing.assert_allclose(output.detach().cpu().numpy(), 0.0, atol=1e-4, rtol=1e-4) + + +if __name__ == "__main__": + unittest.main() From a20ff4ebc84776b29744fc7a3ba177f724442f05 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 9 Jun 2022 00:23:01 +0800 Subject: [PATCH 139/183] 4386 Update highlights for 0.9 release (#4419) * [DLMED] enhance doc according to user's feedback Signed-off-by: Nic Ma * [DLMED] update fast training tutorial Signed-off-by: Nic Ma * [DLMED] add bundle description Signed-off-by: Nic Ma * [DLMED] add swin unetr Signed-off-by: Nic Ma * [DLMED] add ImageWriter Signed-off-by: Nic Ma * [DLMED] add DeepEdit Signed-off-by: Nic Ma * [DLMED] add Nuclick for Nuclei segmentation Signed-off-by: Nic Ma * [DLMED] update pathology Signed-off-by: Nic Ma * [DLMED] clear files Signed-off-by: Nic Ma * [DLMED] add detection Signed-off-by: Nic Ma * docs update Signed-off-by: Wenqi Li * [DLMED] fix wrong links Signed-off-by: Nic Ma * [DLMED] update detection chart Signed-off-by: Nic Ma * update Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- docs/images/deepedit.png | Bin 0 -> 120022 bytes docs/images/detection.png | Bin 0 -> 419375 bytes docs/images/fast_training.png | Bin 1035766 -> 352618 bytes docs/images/nuclick.png | Bin 0 -> 326274 bytes docs/images/swin_unetr.png | Bin 0 -> 124631 bytes docs/source/highlights.md | 76 ++++++++++++++++++++++++++---- docs/source/networks.rst | 5 ++ monai/data/dataset.py | 5 +- monai/networks/nets/swin_unetr.py | 2 +- 9 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 docs/images/deepedit.png create mode 100644 docs/images/detection.png create mode 100644 docs/images/nuclick.png create mode 100644 docs/images/swin_unetr.png diff --git a/docs/images/deepedit.png b/docs/images/deepedit.png new file mode 100644 index 0000000000000000000000000000000000000000..6da60db34e39651712339ebc974f94a30e5f2627 GIT binary patch literal 120022 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 literal 0 HcmV?d00001 diff --git a/docs/images/detection.png b/docs/images/detection.png new file mode 100644 index 0000000000000000000000000000000000000000..44637ced7fbdea3b94b0ef51b8e2e0e06b219685 GIT binary patch literal 419375 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�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

h_jR4=BG=7h!FSC%%wVAJPL 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 literal 0 HcmV?d00001 diff --git a/docs/images/fast_training.png b/docs/images/fast_training.png index 34e47bcb21b6f6b253c2e838b34e815f8e1b8ec0..8dbd1e5b8d676293b0b52ba83ecd151f5e51d9aa 100644 GIT binary patch literal 352618 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

Ti9P-!m;S&xm(kCv8)5s4+Ta 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 literal 1035766 zcmeFYXHZk?+COYXq)U+w(m|S$CSB=DQ&B;BQHl_HFF{3mFGA=7LR6YasDYrA(7Uuy zqV$$v2t5Se#s2Rz&z$|7c|VDK$$v|bLB=@x;nEcr)_aUUljC$41)$`GZ&NfMb*HSkGV-si9+nSE%&m`R ze8H{b*1uHXRw{fZ%8@<;DnX(9LB5sypI2sAPJIHx?~=d!FJ9km^?sd-qwEHU)Nfo; z`!8M}bPFur{ul2D+{(I2Fi8#35qKnZKu7-Ozj!gC1<3xJ?|$Rbb>|BraI-R)Y8dT* z^Fir!3M_2@@1y?TNB!^Zf&bg7Z~pK2|6fi0uVUYS0;+DoKZ&8oMFhx!%~oV2W|#D0 z-|bN&1cde4tKK3|{3n!vs$Qt>MKIUQO#3@h&<3dPJR}A@VqZAcZ2Ng9$RlQ|J103a z$6-vhs8@fvY!S^vcj4czTgXfH%Mhs$$@lx>53=ru=13Q;sx7cQ>bFGtha3A%AFkkj$GL?p_NARa>Vug5asCs*1TYMX7|pPQcb=;WL& zubV^91{3;cF{dZ{xRPwwbyozo7MB-Ol4mPD7UMP8Y-G7$?eG8R z#}xz-hiE};R3NgD?xfubzfqXs)+&|XjE4h}R0t}HJrQ2!s2*NI@lnJOa{cDyV^5so zzp4yv%ozU%=A>tF!BcgPStaA1MHDw`i}9oW(uAJHD$*b!XjEU$Ps+Y;wM0LAeAa&< zUSBk8-7cqxAXOJ)#bN8~nz?w;%`=p!88{U$chc;%q4W+BWK2{Pppu9hW+Zi?7f{-?>{9t*3ul6HVj|&jE#*M707Z(sUT^3*N zFvwOSkJfR6p(kE`C{_n=pnvG+yDI##`r0t}=|Lw>uH{gl(DbL}kDa;y?{KW2tZC+# z$`Z=i&`{C9s)A~c44h9Ns8iZvHKXPD*W=j6jf&a* zmQ#}b_SKaDha9Q@K$d?EC~m!Cze?JHsbcqL{V*kN2gh^!$cgR_u<#3poX8T#VS3Ka zi2D@P(9cj5jl$}OvlFvXI~TZ7B9-IWBLa?#j`)1B+bM6ZT$-1BPTAhr?Tc|B$Un97ebuKH7=5=qU-f7>G0U=cB`>H} zCatbt(9PS&u(8+PIy&R*X`uc98_xD${(nmZtdiKyC4PtfS-((*7)B8hHQvBk&)#zI ziYuz{3U~0=s}y$EnBWfkwymSUTFp6crT^aJ0uFyVV?uE={@kj<(Mygadk2y3(v!o( zH&?h!KD>=~#(0nF&u&5X5x6U=2lwhGEQ4xVDzR&Nxm|685?tL##8o<9`pzssd7wYE}NL{dsu@?D+bnbn;3d^wS#WZNR(9H@x;E?(?lig_UL+F}WT^a;JMbFTMc(yLLGmYrfrAIyw%C62k}KvQnvXam-+ zAotws{YJ#Ui%~J9OGk4VkOXB@nk8b@x|*j*!_eG)AvR1rUP960x(W?1sIt4>BDR^gCy=8-2@)=g)WoIxxXuDgeu9oR%bRA!! z(l^#1M!8oL22Wydu4saMZW&u>H1IA?=qo>dsoet^*}u8V+tE( zqanX8ayu@Rc=c_S+Uj}GQ|nVTbPOrWyu%)9Y8W!f1 z_)O}NWFW6Nis3?K({>N;Ev1B`N97G8RVN?YS7#gfd6UPuP~-2n<08z9EZWWps<#x{ zYu_C50_;Zz9G!r21gb7`4$*fN+hDaAaGe&)^S9jxw(^(EH-8wNA%5-Gw7su{ZUuqk z>0+0s(MiVtG*$F2U+{F1FD<+sVBn+>mQON&WpEOqkkR?3qA)2IWxGJHDY|_cPU;A1 z+vj24VMK({s?@LXqXgx46zwuo6RN%@RBt=7q2yO`rj>-w$}D1{v12Og?wW9XpmPri@NRS~-hZiZ}IEYC6p4CubW6XGtnBoK9%0bK9CKMd3v&>ItTbLJ}h3zF-LH0m-`^;T!_fosGIC+>%vgLTYgyH7V{By65zQ{28;X?F6qrpoa}4_icOP%m+wZp7{lJ?$Bttup&#KQ zaW-wZr;50GySjeaX?U#)01LI8CJ74bpX}>Bb7WbAu7CV4E6li>&0BYRn1AYoNS=1e z?RLC(-Kwh7#$lr3He-X}aGK3Vimrr{8re1Qqu^^DU!bXnC z=s_DdPGzTnwq_qF_do2-gMgxq0XMFd7vTYJjfuUWlK8Zrk`cBtyJio@Bdl#pd@9%hCb=DcOlA7L@W)wkn6milERQEV7 zJ$A~+TbUS8AMQ9>3|`7I?bbrdiV$>dPd~8!`tXzYkIT%m)bS`8HX{tNne$VJSn6~r zX(H5T=T4Q7EHM2jn#qyj&6C)ty}7xPZ{kkeDD>is2AM4JFahZvUPg0HD0S<$s)=$ zmV3sePT^X@_Ka638$WsaiftqQS5;uY5EFKwd-l7^rJwI9sUbbE#mB{z@oC!B>uO2> zzJ67&J3S%R&2uBkG(!L~zVfNzbY>^y9#^S*&uEJahrqt5Mc1+foVO*rXD$Qy3PlT1 zAj9yLd=U$s;54h=h`Ie4FSpT<<&Tv3P^ZL!k9U&xV`W8D4u33eqO(7}H6SffGE7Tf z=m?8>#KbtpK(}UcY)83W^Qm*wWJ+WbKFk=8vUfW6v?8J>8V7@yrS$pMou|S!W`4Ce zsZn(7P1+^aMvwV3<|X)W7#-F#qV<=ibTI&lyY6%5)BSX~vq1T`)Csup;ZIZ&&w0ph zsnx9Ve#0R4nHjjob$85oYwl>guJ&WrZ_RJu4DJiZIDo8o0 zB*bHX(?JzcvxcFV*>v;*NH`dA9TXJzb54osWxG+7wce|~R~ksW1m#+$0PweNMMPcG zk~mn~Q&F59^EMcyXw#|Js!?pHA+>eW*1NCpfL@N-!uwI@9i3ZztIZo0TkDo5+ZlEK z3|?~pQLv9y#DD9#S?VoX4u8B~=Ac|k+z&n7cMWHHw))J%XKatCFtSrf>A!Y1YnEmW_aET6Nc|sy&%`!{(k`Bb5)Tl+^miL+b7Q?3e?T0Y1edVeMJpEn4NBRa-^Ez0=!5 zdg43Gn!X$5b(W)x?enAgCp$O+$$A{8Fphh}|3|>V;m1E2)31g7V zT$EvJgn%1n%YvIEEMgejZ9b9F|x>Y$1b2U&~FsNX5Oc41T%aw4&_h zZ*kLNz_fO|N3>2xZ_{Mrm2wgD{>1IZ8C|QR5m!KKZp{($_Tg_A!)O#fDOU~QR%=v- zRLg1O(!J2_;4cJ&(|?G9>zo&4AMZ*30oTpe2K75zO692T?sBSDR-xFc$HI@1`MUBB zxjy4-a{z^}Ynl|4_6%j0Cdi0wLL#=2f9#&j88_0YQia&saJOw1dh=3Z83h7wLt7h} zn|@u^H%d#5Wrhf9K0G!(7zuah+%ewu;W>)gbE$1tHwqLOq*k3gr}3Q`=holIKMuKm>so32yOe&V zW4HmITsjKb3bK;ID`A}sH}Z}bh5QUd){PJu&6_XFh1X%KB&X?snEHES?2H`|8qrtK zu9LnJ`_hW`nKcuZ2-bj;U8~cYpm~P;NlSI9433#n>P}c&x^EcxxUQXE*ZXwG@bstb?ZU7r z_jQ26=HKd~D0BJlJs&OP3)m)ZqUO6lwb_F~&!SJyYDeo)b-jg7pLxL2d+?m{{iNtu zfG^`>nss5`G}WHr|H2Y#D>@+>@pCr7KLEoE3C7vg`{B^!x+t^Q1t z7-s&Zk`jNXYu0ZX8;U4ORB2QW?;HKaC_ueTVvs_c7n7e=iTL5QJ7>bq?1UtFx-B^i z2;u|Rwc-p{d4OI8 zo3_teP5^1{6YP*jP48k)f-Z&Y@Dn${h!hGYr=GQz49rok#I^>vlXT028eE`wr ztt-LHRF)GlUYD%0cCX2FfXPU9p#^Yhu3HZ>=av{b)~)uv)S?GWtJP~d1+#^6FxE$Z z^TL+;Adh^ZJCEVsO({oml58&MK6Fx$j`@bb9Vsbat8HZN!8{K5Lbd1*fA~_=!%lwK2q$twifjt)57n^bk+)01w;3Ra(`l5PEV>A zi%2uxE4hBSGm*LXPB@dE3I@2u%xBFCBFh#x*E*Wey{i$nU54fGBRGDx|QQNN7N&hj7WvfqWaMO`r z_PeQbc@0|2)X=|`!Spten%*UF&8B!r`O65@Q%J8TL-JH0h^5@MnmSU5aHDh_R%{ZS*=j@c= zb))=OTvrV7M*!hcnTg zb_uA}YqfThXKK!OpjJI4w~)ikZDQ0lTD%->wxS8WKY4^ z)N`fOybf>vbDyYL(_C_-^^MI_2?-2(9JEv!mM<&xEtATw742L(JIu~f2K~ec7gi~_ zKgptHKwMEh{ft1q`d2SNLrBldOm-K81%(}^qc`TR@C;0i`EGJ>^-@%#%eK+V6NyRH zitTpor8IrYOk%L-CvK^l@K<#e79p%_)d{SdR!alw=)OJ``HBHnmr7*ihVh)Tvzd45 z^XY7h?9``@+KG0bwiy)+=@_)V38zENUpDS&&gN+8`0b>bi93km6nZgGeGlx%p@M;PT{#c7s6=vn~yX5@Ol z>Zc!ZGWo3QoqE_0=nCh3;^=7uL={@s-&bzG6|z>hw33PKUl;$Pbb^U=~;hBSGn4|FozYQQxuo=~M7#P!YdoSB;I^C=?CbL0!)Hw8dzw~ImUIC_3 zihWZ-ds+=?xnWydDcNV(N8{&9qe`VRjH9$VP>XdS+`IS<-*Ca2Jz#gDei(9Q3Y%?C zoh#$c7^B{F&+87=Np!+c2djQ6-&uj7vu9WO0-LbZclZgrrzSzA`<4)Ry&uDIQ zLr&wBgB`Z=A)27<=cM^GBlIa{a3%>!yPh!S+Y$;K|Hh7QC{^cBhp`)+MquN7?N@S` zvsB}Ip$<^xV&}{3|0U3GU*J3*=K#}isp1I{L*O{aE6+uzR#l;n_q2H=Q z_4o$tR&2e7^^>lNP^lsi0u-&IwFP7x<8=>?f*&kjHSgm+()#s+Fi zmal&-Eb>9!0YJXwsC26Zcxto!{@A*$o%3?@+zGy4M-uk<_fD`u6EZZQUrHercq{p# zyoDX^i(aym6OfjbT?fV1Yo9tIbHDmc+Co|4hzZel9vyefp6ZPqhG=!5J32QcyQK+)wZ;k|?)P#uYZU4YudF)V<7Azfj+W4*j8H_x%!G_<{cqIXni8hF zdAIoVHW_I`wM8;s9U_*PtC{iPzIn**YZT}w_lmPNw|NmNCK^BHAFo|8oDU z$wzq%D(~0&9?b|<54lDQq&o_JIx~4@0ef&A2T(Hi(Wx^_qgJ#5|7#*Nnj5qSWG>&7 z1w0}-{agaM>@Z(SE70Q2J0txMWngNEm}(vi9PYXUG@a(DUPz!7AxdI}1utIP!87_e z6iGTMs~&i@tkwb3vtuO89Kh@u z8}B71+|2Ti&01!pP+)VkAJRE)KWCSing0*gH7ec`xxq-edQELUI24 z7Npg??DWM#b-AKgWcKV^bM9TU66i^Y`{P5cqEk0v37})6MQ9{dA_?gJQ*NU9u(d zMR?bMn_1L7M~z9f{Rop}BOvu2YBs_<)9AVhTXgxnI@vX1hX@%E8Es?FNw#w)9mj$HUw34wZ z9yPj;c5~j~o1n{{?*{;<0gqk7e1_$r^T>7lyb`l7T0RKOxpMg!*lI4MpOD>InJ=eM zTf06S?d9w+(!N_u+hCPHn_o+R`c_vuvVOf+ul-3^4tV8xI@indF`>B$w-5d#+`z*a;U8ORq`Z_ukJ&x>k$5I3t0 ziBJ4BzrruF{0?W;u;Q;j8oTsCamyv7cHtZBqexlMnU8Quz;KK3QzaU33~*?Ti->m& zjGH5T4ew1}F$ftNj0!2Hh>%HHDuz#ILRMGuy#WiFmTi!)ma69l^MesvO{Zzpy1xk zUwwSF_QhmNNTsS!Zm{R{o8ExQR~u%S6JH`+>iY6Bshz>lh4&U&%GM9$9#Oecb&ZgW3Sa)em8& z(?=ge2*SP{h=b-ot(4ONb;^(3wa@j4{%;Z;e07LgOB9}JoNmcempU=bxXK}Ysb#;@ zg^bKTIyvs*OV4D&k$Pj(pR>U4=%RA#XzR|K%Kd$_m?YQ3 zbGn7VfCJw)jd^T4EHfYSm%kO{alp15C(p_p4_o0rE6Z0@btRI9^G`ltcDD&Y{62cL zvhV8#iyykPSmo*3NM_L*lpnGWCbdQRs`-s@N3aEiIH|lB9d&l^D9#t1zVoG~Zcqiw zrp@7@F`CRW>&T(UT)#_L>b?oDe*PXdSYC^MRo>;AsxRx5_5Q68yY?`>`zYo_*L=!4 zhRmk!co4Ez4PH5}YsP}X%poN=waTY}7>ut+x=^|CS_zzbLfm42^u^{ zaj3U>oUXwReI(-BVZ@8<`?)NI)X^|V-^e>46Wh-T>e-rgsE(fk!~x+K8;%yVaXf5$ zU9r>nc+`)~Zm)|k(Zx=dJCDPG+WN_Qr;94r@~(qjhd+b@TSg5B9aZ`C1crqNd}CL@ zmW`8bP)K-#ywvU5el#vR7zW+)TkcW#6;x&1nBx;>FSJ)?Y+Foa5PCeCj}DmJo4B}< z!y=+Q7;sdIDQb4?!!?XVSh(qAWKY%li&_jc&v%pM$=yvO$zJ-<`jgjn0?KCuWO2)?v@!N z?-nQKdyb<^N5pnC*_X9dN!jT>2iLUW9+O`!-sHw(CW3KZ*?zIy@foF+luiJ^>aCH_ zYk>+5WI`*RHzpD9=T%BJ-6`gd677tU35+pB7Zb?ro+<4`kpFldL}E&EwB+ibz1 z#OM3TQ1RK?gT{>5^4bWyy=%GAf`u)(x^4b7V}Miaqc~$mWdK^A+`;Qjs zBB~=yF7^%T=ygs59@f{ADSy3!S^I48xU4|66DC0sH`y>T>o(t|EH3+iQi=)y?Bf%q2AxoaalJ#{L2W@48Kc( zz5YJ4@6O-d+}0ddbv8?faF-?h@6`<`Yn&0AGHqdc`%nT&w1>z3&#;4Do7h_YTVQPxeRhN>YcpB1wp{qI)bbU{G8=Dq5 zw^rx&YU}*^w78FdU74oA$1L(=1mJW`{DGHLc$4@;&YYGNm92n5XB2OEXghz0FOcN- zPe1P?>bnIRu}hv;r#EQgX{2L^3XlgM)er_0>okmQ3i6fT^wSK+9L^mM_|>W`@Eq<+ zH$Ql{`?U27wU)?2+9VB*;$8U`IcLQn2mgH2l0Je;N&T^|@)mZg@r9U)( z@K7Aip!&{|H}in!W2Pv9PcT~Wl2PZ5m0m#mJuUd{st_O+1Mi1>MFj%R%$WPJO<2tZ zeYHquy1-i<*SSwm&=bgUUoLvsybWNp|r(88I(W5wQp+OH$wQXO`?Ia6p4AB|d z&NV$PyLP`ngE$rfglr)dSKT!(bjsco)CR8mAZ{Nmw_xfKnJN_olvL-o*|1l5zya@D74QQDav&Y zOY|ApE*}~7W$Wed!4vY{lzM9+x)KeGE|l1Ij%w-(=}V?;0HTCLihF;rf+6nLwc!GS zER7u0F-|&)NB5?mtss9qk0u0{Hr zT!c%_u_el9W7H7HT8G?kWHvSxtAjg@rtD>+wixSAId6sMJCQ}*Q0HH46VZ}s^8^Tv&Qj`AS`gT9vU zb-Z<7fA^dPXJF*vvUuWqqVVGs)!yXA8zkd3HBVvipN&7ahNu>owjW$(jM;@7`W5&H zOFydTmz+0FYS}^(xx|@Mj7AL?3q4sI(q|$D@4BJ}3;%*2$-Oi9`4yT>9Ln@}&Jk)M zCnOHRGnGf5n=y68!cZk+yKOqnTds zQjO+CF^i}@zQGAY`Iea+*Sjl>8@40=QY8|7sU!6~C zsu8t!Lx6QerHrMYRz*sN{AfO|@M{h-VHUR_cd#pnXDcPfSG1;nv?4*v{h^~PzV2%9yw!}||gg)^8-5;JFAkn59?uHi7^;G9Q% zz|jyn>o@gF4-8KB{kNhUYQUg$Z?qwj-` z`q!M%?uLW9P5mNVgul3s$>do`YLlJ3BlI_2zJB2XNlno(|F2;Fl05IbcKyP&tM2ro zw&DD>zFg_n4`ibS+OP7)dhr+2E9TSN8}!vRxY+u(dBLq8j3E9@%o7;<-&|7vNI5~+x*nnq6WVwtx*^O6>_oRGk_M8(=K0EL{u|e=}!jQ>>BdhBRp=O%jCB2 zp?8h2{q=j#O-lnLoDdCu=Qkf2P<=787W-$m#vifwRQ_Re z07PRs_q8f2v3STJq4JJCQrI|cVpxhrfhFh|DV?z%f1Gsn?Ep`uB56m;6rZH@^gNUP zI}zQv71|SY@;=cHkuyoA<2k%LZdS=!65RRWhxuLgB;64P#|z#J0Tp(Pj3V?lCbojs zrbEoc`5q&SHjt;`QN+wauhks#taY>`r^h2#l>6m?Ef>&swP40&$v)SW>ZIaaR)zYC zDp0LqW%N34p@{_Nc!p*r?yQLQA6_ZCw7zNykk3oB6gzw{t5i(idFV{>!K`2BamP70vSa*8L1G6+Z1X zPw#7P1ohMpZo;cxU_qIzYem^CB4OOh>i0fDYZMW%Js|i_+(ASUk zAEcp!mk_m)<^VwtNe60D)bP3@$%L;iMSPKB|sz@9l>;rj!ksS{9axd3Eih2@c-2+e7_|S2&|Nq ze7&41){)m8aLx1asVtx^oW6oNcGco?^7uusN9atiTi1KN)%K${OnYY{ch(AgT%*3^ zm)qbd@hixJg=inV!`x1{Dc@XEKeN||-LdA4&vaDb8CDQYgrO6LR#nN%50B4jf&Uc% z{!id>E8x=d6GxcIJ&*9W?Af2XUM@*{ayA%(M|dWR47)}*1R{U&B{}rM93^%x1bhzG zm>VGSxD6wDxdMdBbu3$rd6^6Eb>Cw${R645<*2Qs>@He*?&-!pn`W$4A(tjey<1A5 z$@rzK87>sLQkmq?9p-rP$WkFTSjV8xO~au|B=!U^Q}4Q_w$vd!`$n)pRr-pi7V7is zNY|ofw6*EI_Yh&W+w+rwL2raa$GwU97NttM@o^~OFkpaVBxO|6E`io29NB+c90VM$bLo6 zILin5i|-4hsMgisw>(O9V)tI0h@NH;5zLO4Z3BUZRu_-kkNei$I$jUPJV)~(=R>l= zD$3JP@s@P_dgUQ~HIDV@?Y#a`$s|JsnMSYPab^ccyS`#jR7e5E9-0~-n~5Q=tR^nk!_~pr zv{f<1wdGQ9n<_PDqrUqLi|Srh&qHgrWW3|?``F{f%UT;kxVDuLe3i#=an2_ z(Ke>A=U7XDhCRVw{tRq4f26rL=GE~}%3$vlQ45O9Um#plry9QZxTj0-{*#0KG?-Fx zW;x4^GY#ak6?%FFZH9O!6mHSSlVVsxX~$1@-?#K2Q9kTY{qa#IxDKqw{APsx%4KsZ zE4>%K1CO2viFEYTa|oTcA0M)M8-Iz3`Be7${6(9jTlP6Dbfd2}N$qB;96};OBlJ#v ziR}|+JiH6hoWU&UxXXrE>e{s+Qkxog=KZK93Lf1i_Q52sN;O40#w%1hmn^(Aqj>DE zv7PMc+_T%Yz!#VZe2Suc`*vQXtV49TXVAoYz+30%P>CUy$jQ@Z#64?xz!154X$5lI z;Szr*eQQ)vJ8dgn$#PeS=CSoWUXc8nR7ZZ0)^Gmd87NTIIynL+nyqQDw-*&bC5Ca< z{&X{aX3=3XC&B+{v?psZaBWCsYTX1Syod=DOB{eg^L%d`n3moyEHeF+7u1B z46b~^Prd6r2=2)xSx(1%{c~GR&$K28|AG;nDHL%tFpRn^ZrXfJ&W_ch&1lLo0A3Ur zOI@Pi)k}4c*}W_`CM1SQu`uzOql1By)}-y5ng*Ht9h$Or3x!LyyL+6o?|eIsTc2ny z*gdfel-k;%$D{gwOxo^yYKBvB!W9;zjEg;@Exb&_gJf?MIt-(Glr#~KbOd}ml2y_m zMvdel$+*_<^T#8*H|=L*nN}vuy}+ZwM3AmsYr2GNC1~Ts;dW8?Ev1n|N39srj=GiZ z1Zz{d{)@nW^#WYE#W1>~(a0YHa31^muK+o}t*hP<_sod{Xt7gA7_{)iWrg7i3S4zms!x)ygNE$r!6$9qMQKH;zOCRS0s9@c~&f#dn~F{mst8v zS)m)XRv>>0BGC#C)L&fzrS)4qqKOloW$d+r9I6evr}`j6D$({)({np$R-6&X(DC*^ z<_t~X~Fh%tf_n63}92%7`UAx!C#jg+7MwLZz+%{ z8k&S0ei>}&_W^rn4!Y3c#N2GaR3$z-xR6u7 z3#E80%+l@QF|VCMNr3o|+=wri=0WlXlom=5#I0mqk@VS`giH7zf1L66GbetiDH2{7 zbma5uD?~XfK8s5ed?N3@^drO~N@bPfP?D6=so6IY;ixYyF$0X0yowvM*E?!Yv=1g7 zGQ(nqzkJgBLCc-63^_NKzlHC%!a7{0B#iOlZm(!Egn(_5+2%jD~aco>3Z`RXh;Ko%QhCILcwPJy?zS3b&(&+3bG_mZ@z@Z2EFW=i zr-1_7es>KCzv)q4iHd)8*vg1`exb$R&Fnw-@e>R4t=kSc1+!{U`Asue_YW^EVpcgLj$2@g(kmJyj zj846jbx(`8<5JOwGAd<_Yic(NqQBq$d9YX4GMe&8QA0jI`n89K?HB*Sfu9;m-GCP3 zd}Oe#3+JJsyGm{^@VZ`NG!}CEl|OOH51s0*dz`bN*qWI8W+&C;o=d7Zy)S>^kz*Y* znc(Gj?=+SxXRDHap zV$M2G9z03!{$&VP8A>vf@_h1!so?d!t8EYVu2Q4Al}g0t>5J&zTwu=kO}A&8{7LHto){hNNm~{k6{wuuSbAL*T`)+A6epGSP$;=JY|tNhJwi=Uv58@q z(Tbj>uXHwqeAtBUIk`Lwy&M!1HIg7?A0MGw#hIUmNXIiinFYauscWB8%l8r|+&9m>T5q<9)C+(58xY3?ZOlkQpl3L~U_~W#t zFQa2}YAB4L`inJGbQHBLVvA+o)L9q6_b@0^#}!&)AFUo8MX@8(wi8<6kB%al028TTcnIO>GzXWDxVcc07_Cfjs20P3 zo`P(LmVj7F3N~6|=vF0yY`e43;RRE|JHNOTw$8uUQPF_E-Y>qzpq0QHOgUbNc#s zL){t^ka`eSRIP^4Kd;!)2caBt0I?sar)s~OovzN8l>vP@0)p%(5??>>&iS>(RYVwG zVrgkFCqC=rh8$c2pUmdtt}KfwaL0EAkH#HsXp}P(2=^1=&uj+8oN|BkkZfn~{Q1rk zmJMacc63RphWpMxdGyIAO~jxo2N|X7p>0NR!^E;HWCj(#1(%D<&3Y5QihLe7VypIro7wXR76kmX2YL)1aEQ(^B+mDqUiJ1QRh>kK z<0XfPb#{X}VIR~74A>tn-e^f$nS|;LtUyo5^gLA$uj;pB zh)#F5-7YhG^Oz9RI$%xGh#`nZ}QgOfJn!e1zq)DAQ0Jp*BKhDx&v1AvNAB177L-NVpsK8ov+VE%b`I>uNNU}|J;os@ zJKm-upkgsgHv3DfHr1+$H?2p3|EFMy?T2E0H5a(T4T^v`Z|k6@J$b|San?DD?;N`% z(@2inX$~1yl!k0DubsBwA+q-72AIqr<+a$VwZylIC!nL8I}BkV7F{_|vko;JCtgzJ z7F|BjNe_eV;@9eB2UF`i<7;_ondPJfIzsb4exilH9;U!LzO8QGtm>>)!W?MDJdbmF zS=3Rsf&Sj792Wi-6!K~QCOV>iQA#w#Mu(YgL~Hiqu^DRKT+fjbK76788Xt502Haw_ z$t169Z@kWR@_^9aAlaYMAmazuYK%FYa&L=An~&OYs2j;;jH_2kO0+{|_O57i&(@E8 zUGc=mOj~UnG?c|lbD`@NfDmizdeK?T7cn)E=r(Aofsu9#LpDPSay?8VR~u}f zn{Tj-r&tzaik+w4#SSd0z)0z25d|{zl+N&jrHeJLqd#-C5lk&O2Yf`dO3ZYOTwKUj zAi6Y?9vIDxFP{bGwRa_YJpkA+$^^7W$@XUP*5CT}>ud5IN-WEhC^oA(2r<1~YF7si zWg@>FcRuBSFnDUm@HOiE9FX-HNKqYk9&N}KQF(-sx1K{Swl;n6cY*`44cD6=@*0=B z3l@{>h2{C9=s2!wq^O5`Q9VQp&E3(u-OzE}Ox7mO>8*~Mqn#2@lv6&w2TJbyj$Kgd zYVo__DvI@{=!_II67S7xA}n@0iBnOme@%?A8)1b7h6VB--~`Ir#j*9Z`&K8O0qhrT z?$5i%B}%~A(E%}zzzDv&VlzXU`yZVk)V0FIw@BaeQ3Gd=S3<((QBNjWB&(u_?gsOc z)4MYM{{2#=Uq(H9@tC>gPO(4_`i9PW%LU~Q2zoTVRo&~?zTd3RKJRnKx}T4AWiA#5 zy%~y2Jg}x~Yn`FL-0P_M5r0XkP{_mdd~U$YIlk)(&s^U7cd}@H;t9;Y*hw!REf$xw zJYs(Bt~)CG;9j2?AKSG-d9+``UOy8)|!V*jAuwC-7;{^{%5jr4-@IUrKPT7(#BC+LvD6WrF%?*dYjE+ zf!{69TQe*|sc$xJeR0%2PeZN!D4HWH!pIQrB{my1?tZl90QVT>XM8^VaDL78=EH36aD5?5B~$d|Pb9&NOTxIsZRo+`E-)(tWmzOS zH~+d^9u*Z$Zq>Qr#$>ieDJv~l!n@2Z$VJ9m21P9IhkriKyY}M!(`QcX>cizwj@9$t zpKjlfk62r7rIdW-^{|nnytN)G)??kS@gulHucXC0_Bkcil^xfJx78rn>Cqeeg2rh* z9@u>!@PP*OU0leU{myFr=ZUnIWZPB45N4|}ZLJ$wX^?2vz;LzykFB?kin49nz7-J| zkQ};W0F~|rr5hEcb3hoRLqbZLp+ibK1XNNwB!`k5P{5%(hwl7NuKT&3?|bj>{X-VB zWG!*#IQL_ZZTs!)7J0tsz8fymgIvDqbL$W{vQuhDlj8A^U;GQ)5G!b;)WK^_d4KK#o5V?(Z|h5kpaz zV(P3wQ|H|_wdO|-4`On^i>W>Xf~bYmPx`!K3LXtu{uv_HoRTY+nuyb)WKXw$)zVw|8BAV~I&H6Bl2x^yy#22b_(R z?iVVz3+Q;c3pkGs{k?rcYp>SmY+$akwu0*CT`p$0}<2-YxMrJO~ z%d`6lL)p>4#11?gi_Y>^HuAk(7)BbG=Otl+s$#W#$g+X7rA>RIm5ICuI=PvbKq4++ zQYtiIR7FXHIaU2Rg>2_y$GeDiP1=l~_hViNK zq`&EJuD5}?(Hd+5cB=`&X9h239(WqEBTDs(ctlP7yP77Cf7XeSOt?Y)^1S{Uc^Ak^ zT<30>=gp5B@GgUzzR%b$S|p#cr298u?~k(GB6G%{cYcZ2`%a3I+ECGY$5&kZNc&-T z4FOfrep>73?RrJb%KJr@BPm}Tn#x??%JPpXWe&7MFF(D@DsdghvJF0&5kVD|8mvvT z&o1h6?$L|SepZMzebFMt_oTK&;Yh{TeXdbvBg9S3SMvDx7;4qy!hUwf(aqyj>)N=j zdEdFwBU-z}_I9+N+qQ&s?EJP%N}J|nUkX(pgSBy+4BR@N7?%bqdf=w&AN%7>?0c>2 zM*oQayYHNA7K%v^{-?EXw$H?9!%VA!NR5K%NdV%UJqIn!XQB62zkPU>na2rFL73zX zJJpQowv6I_AkE>^;j<%t04aaO1Guyo^KsD-OA<$kLEdsA!Zh6@g&&;HEse}zaipp^ zAglz>HRWp700cEg6y&w6W|XB##gZ z+46`QCI4uef|3$GsFc;>WC7MlCopLgv9(IWl9et@DT)zC8JZ?TDYnOANitHw`iu(i zMwE!#=mAAv%NAG~Cu~5h>#s+*7Ns_6_lP{@9Vdj^zeB60=r-XkhC*7vubj}YbFrtk z=AL?&%$WJ=|aP+>?6kynP?2xBjiL%5`S zaYuGs$)0923HxAx7((w+IMr#97qe!|&ikzzZ_DwSTE};?KW-Wry0Dn$9GswG>#Bkt z{(LyIKK)!=I^lBvmr4JC%zZBgx?p0fJ-GW(41ZhiX{r?EJxU4~_l+m_?Gd)$d5cU& z|Na*1ZE_-i5HI87QRkTH#0E>j>mad+Zs^)UkrdiSLwB#oBb4N!%7L8M)65XKg7j1D z_xM=hqxkDYZd6{pnE{&5tSQxUGix_UZl0B=4sRwNSEXba?*E^gbM{ ztCk*BS$}%mD9c z_TPB`x>Cwu*qa_&S37}HIG7rPE|R`$`ZXVGSu@qTs%^zr!fxDoP09U&!UxFGyP<{> z(8XKkRT%-V`wV%_6{ZXSl#K^#sxz#{#?EP47Er|YmO{z_e>uh1t7!Um_i(mFb% zS|d2XiA!V>d?N7s7kx0ELuHmH`S{Tf2tU*Q9PmubKop#NXsrD{TW6@|50yam0#c)+ z_qo02IrjRztmy16US))M;Zb9WQnE`}w-F}xefquqc}nd2F$98*-W zS1N_v9~h9dwLmK}2G~(gQY~73*nIN(X52uB&S_9nrV;=*%tu5fnnF5|*rNePMX=IM=>!V|n6E z=^uvd3QLRCH}LmlWr;S)Hnpd|nP;`yki9a9NM2>L+Q2%}tt@qP6Ep|~h91rKJNUCA zd{1`}b~gEwVhq_&Blrowct>~JRqxi6(p4(#64|=hT;Mno8xPYWRW|Hce%jDLm>40UE_4TH8#@+@cX3T1T{t=?D&AMQy4! zb+lJ|Qjw~Uo>tc%))W)eMWOFSh3k6ak>>WyZMUXvfujLUsgN7}07bClu;}*@&20ng zQaG;v`QTa4wCngX)r9HXMd|I1$c?hrRTUOeCz_1G^{XBwh3oUM8bw2%X#r@;`0DJT zKd9V%W_X9tb&dLq%mt}OjC1SdwdYpcwO-%wT|rV&0q{3xI{3jGO8?VQ;grXOz*)#p z#IDV7#wdNkLp zsWKrE2_7J!#6(QZ1FiXbpwXTV8W0M1!Q@i}$r78taxusYSvL9MsD7voQ&MJnMqM;h zkZSjIfz65-eUxa%g0$2dHHAM0cs{sjT9$?h%`|>q^WuafKY+JlC-M7?P9U~YWlxRP zg--VSRtnK`&-Pz@!5=#bB$`=KnCw8;tstT~objR@KLajseKMfUI)$Ryo#=Q0tK0t3 zFPu+~RHPL^WxPN6tKr$1KS@#y55jx|ed0LBN4!}B zkHC|6SQfduS0kRSP|HJ@#pGh^{D?hqIOHkOV40&~S~&jcBfb#6m~|yH^FqtdZaiXl z#JK2O>*gR)^na1eIFm7#^(>xLC${$eT_>w1TV*}Zm09mUrF@VYw7htnu*Ek>3-n0_ zuiEa-(<3L!vMmwr z2~wZK&~BO{ygCiAl}z`mFNq_T>nzvoC>!0mG=Zq-z5p+arMjP`(J9@pyLeHY(Lt*@ zrXDjwuYZ5ZG<2Y%W&mVg&mG(QysEj_h#Mnr01gNm?cuT_9wVLi0^~iNs+1a=N(_@a zV)v>)=hK=68^h{=kGHnS)m)V&r)i;(QqA86C-pq@s%18c#f>+VtrGEAmZMXLm}Ygr z0Qf!NWEpB|PF%DDP59oa+NqidA>W|19Y7~ZD|~4dD;GTOL264`mE$U4D$D<|GJqKW!42nQ?58K?k_r>U4f!cC2U#cWFIcdY(u zGIu0f> z^l6(Xg-zO6A9po4KWiHnQ(1eSbe5lJ9{sR18u&>f+W{NEM{Z<&C~_(=qdsl87E$Ed z6>>-#z|W6#Pg^Nbs}@HIJ|G)k9hZ6;l3T`7TN=HSXf@uroIuV~!`6^;6kWDuWTp7I z@ls{Zs7EOEax)|K)24sp|2Y6_`_0C9nmPkU!-QO-f2l1eNs1p(cm**Nq9TlL0LY$= z|0NIe0rrQerE{a@8yE0-<+R}f+^9@gk^1Y1<`4w?RSr`M^{>Nnd$DArP(`yxf=@!I zT-Y8m_()gN&-Ga^&m6pJIMu^NN2OVPi9K+(_g(K~aF~7AAS@NoB3~XK)~B{IvEs{n z@(aEpi%Mj_{JC^xLci+M80VuVq4(PG3tr#Xm4gF+8cZaaT}J`K6=g)x8NAve%gELR z7d>v{sur7jIr0~VeMP@Yr-YKVk%hw9n3Fx_)@7kVJim6An{Zv|jbS1P2&@ZLd_*&LMUHB~mb95wlyH+e zRBl?xj*mU9p>9;!kZpd`y`Gi(J;M271SklwERvHA_-yI-QVwHwMa}R*tl@u>eBaxJf%(sytqo07=waT6Zaf(P`i}7*XX8F0lI^3CH zWfam>H~qnu%MhK&_L$&2Z(VA3Q7vQsaV@D&L(j~_j>J-(G_uCVeO%)7vBvM#$^-91 zo0)u94y@W3135aa&PpZ0}wZ%R828+MZRNBBS5FdWA@mC2lJ zemZ_EKuA6J*&8Lg+q8RATlo3G{#>j+O44C`$_rknkHLKV54GUQof}2lEMc_k--!hb z1E|nc$^hxr3;2^UT)BJu8VTL7XPlOvW*rH=D)Zsg>M{wU@S=i5vB2E07mVku9Svoe zuVmGX;(JL1Qwc95gZ z^vUfi%)i#nEUgACHZpm29y7uK&T7f$71oyG4glUs;)8=%#<}z>^I}i4e@NG=}g4n zEu=rEmTEWT-iV!xc_Yuwk3nt2LL7&>BKJsUjD4^+Gdeh8h&CGnUwllW-It|{vZBA zH9R1+G8Ak1i5Ij?uTHe(bk=+p>RYfMJ#n%2;RsTx6iY$~Rdp_xX7HSUj=pi`i9T`J z4F_G7ynN&|@L~~qiS~enYKC&3eyPt%X=E>TTy+I_La>M7WgKM)eEc+(Q8BsGLtlG* zsA`^4y4E&bB<1kE**!Yb;>!vW-o_<@;iq*uc1ul2VUVugHI<@}-g>?LDa%$qGg z6i4=|OM8bJ`%74BXTYmp%KMS-8zYOYs|I{Imnpe5)w<33{n6wq=l_UN{zt~oh*?N} zzhMxF#>@A|iiTlcBg1|-hsm+O{+{En3m<4x$4zT-Z*h4nro~JAs{1}4Ah&wqSi$oGj>C1zbCvOVflAwO+kZX_xM;v=WAviJL}$_6aD zpmA9S0bvD$3t6R?ad|{|GIq;#i7yHKlXCvm0>FwU;e{Pr1(S-c%WE!UpYi4ZUQfCp zlJ_h%O3AKQWu+`aBsz)vw||~7ABE|nlBS)XXCsWADxCLow7}8u_?djZ1^_04P-(b^@_&NHHQSkM)_S`Ctk=CtDeAsIb zLp4*$aH$R$hDCLqZMEKUopF4g}l#u141#Bz5cey)Ai0U||6n)<^Czbo|$M&8$f{nN7b@u^-23GD!PNI&2J zJ-BQ0cqYjCnq4vN5qaR?k~HVa7R?Ff!`Xd_YaoAS6QuiioRu+32mvY5Q9?>{0o8LK z)4X9%a@1dQr35uk0V^zm>%`@lBk1S2@Ck=*;9uXcrL)%MT?F5cG<#-dzG~*f>@(Eg z@0FS=CoyGLzo5uUqJ*a&AmYtN?2ti#@mH z7{f)_bewsJFtcM)m2J}z;X{KMi)pjMT#GZ!_%L^+U+7G&CbMlQG)HNYu@Lv=a{I;5 zYoS+25*DJ;@P-cXh(oh;F=MP1%WcvWe^swN6-FvsbrFy2WuDrZoD5g!c`yi9gtOWv z!^KJRsdeG|$LfMDg#ODcGlqaeWM`Y;Cz76v(U*9*U1>YNY5#CqqCf1zJEn;8D%|6s`R>kNhwm;vPs1LIayS{^HrQw!_tjb+c zdiW3M@lqfK(^@1S4r4jp3|1>%{vdd?=_M#~8hY6)#ZJ(&*TqxY@TK#KyPK9~HeY_) zS}G;gDP{0b5t#CC9OWt#1dI&!7ZfK^hbz>5Xxatj^l%&s6hm^k|VrJJ>Kd5V+ z8L1|=?^l6IxKK34WB}r8*JpDtO85*MY?g><{`T%4_luRW#=e)jej&-COVbZVZ;Ccz z`NE|a68)HD$~9as&S&-V+rfW^gVkz5QrRq-~M$auNP+q7M z+BY%ZTT?fRL|-T?Q9QLTF30<0lJK3HU*t){zTT-)rGMETvQ>;4n>| z_~pA&d8HrJP(FuLIWt<zmLR7k4=Ouqsz4N7e%PithF3g z*19m=)2S?PzonOaYLQ>I>ahX*TMUP>vfU_TJukx6ra3iT3-x&pSuj23>HUM6hYtU_ zqyhLWcC~1)@23_|rTZsEMMo3YL7}F%$@+zNPWANegXuNtI-6f^EA4gou_o7>6OS%e z5Dd|%vx{!d=~R%kQY-c)rqXHU*TnbAMo5i~5x^^P zX<+)!LR{;o>mxtlE$bXSGaeIHcAU7m*eaj9iW=AX%y(078*~~elAtJR(js+g1mrVX z8``L{o3UVY$iY*$`Jj#L)1GO%qSjdclf0v`p&+1nODRWPjPyC~VQd>4|IZlHH zf0TF$Dm(L8bRU=Ei#K&Nox^MVIG5M%Qti~IcT%A@R!^`0g&Bb$yau_M!I7RUX)-61 zODL8})>0DFRSY=&{g3T!rwIbgSowhkB?*-Xb3NaaSsJsM$dZ2Kv=xHBy-Z`QwQp zq#B=<3LC7(FE(}41Nx6auYx5M#j&0RmLsl!YCYr2M>~@X{!uYJxm-E{s+Vfh#3?vP zPcZQX&teb2ic}T&@pl3KcSy2?kT?ZZ?OxPjz^40U~C9>A>Yu>XyH>E4mwH**fWc_>muCIs}7VD7$*ZV8~ytV(q zvHknC5*)_?Sd)warxe}HM|em>P})a&rq;x&Cynvnrx@3M^xurC(;U3KfHxa4CHcI0 z3HLGC*&tj!P(N>Jt||BLeX^1chZZz-oJVg4FsdQGMNAD?(Q-SI<9T0hqSj@qFq5P1W$_m(gJe*IV_0JW zfls=Ly#%T-T`@J3TCPS@TB=w69n^7kAvq{~3$`SFQk zBY|&>yJBQM7d-Y(F@e#ooNghQ|6o*z^pdOQ_baZLK`P(&nhhskpmE^_a{bD)_+6c5 z@hG2RChyqLC&nW;^Bk;Js=SG3YUes=)QQQDQnLd*96O;j4tCC#1GsNEtjKsHSgn#9 z;QDOzl~22m16m3{A~UGMCSCpcI*^}WIXe`m*cNfA1zKXUeTsHZzkLzGpKZ;L_AQxf z%Dz*2(Nw@WY}MzK#~I4sf1<(>uq_gbF^SI3d%16xg>@{se{*Mjno%Q@8#i8i^634r z-f(9B$jS}08mb7Dbj9DwyBztDp>8@wLbt zrlt6QIrsnE!p@k0%!)lFaqMqu@7&gk&w*SBNxApTmVrZWK1)hjps;vU2VMgnSEnJp z*K)j44fErMMPQl3_yI$MAJA5pvcJi#R~Uly6s7GkM-}3U-Tnq73)CMo6mR5u7J72= zdf{a4BO%{lDkf@LIq<&Bl`28db&)MnXxS(R$d$V8pMWJZD^Kh^L_VB zny_*J3?X)-kv38QIC&FUJ0~(;nDcp74yeS(L;tmbd z8m5+)6ZTmBQLX563kdP3tqsEK^zwT~9?n>dlkr(*U6um^Q)y{FwYt{@Hum|^^(k9# zji3h}trF!kv5SxVvp5^0Y90`tN*ee2ZuNZ-R;=q@P+BUuAMq2AE1`N)*ywq`-0pwa ze=;f%4KGBPuFvKlQdW6CZ%!*eKStNQKl!aaRa3)GP;Golb3J23=t7(haAKVey}Ou` zZwM-P8q4htEi}e!FWXRCj)db-nrd61GA`Lz95i6=?hmt(DhxVrRg5yk9t_g^e#`SN z=0DpiBKd*!30%Lb(kuU`8`~yHvKQu?2QP?-Ly%T2X-Wv~D=_O|L|JO)t%zdrhBys8 zO!&heOyrQ78vfQ@GM*ApW3zuv(gU}oisBgKM*QPD1>CEaU?0Wonme&(4H};{%OM;K;BuGm?|MS{1U0JBmF@xTj{>b09C# z0r4aGFcD7+W=9XmS10_23iI%Qgv^un+9sbv7;%*g(RNz$)wU_8O|!>kR`-}nh;S<` zh55=!&VVkA$%TODP{%8nlH{uwD&b_E^5jkZ$EKLLv@;y;wQSo&&v8UBaq_-2Z1-Ry zRlRpunaeIiA0z7D`oWnHIa}qowgNc(4?lZX4hbFs2H93Lr(@4!KAxK`Kgui1WoIp- z;A!n0*2MvjmP7x+oUV^BqbJo{!|(k5cZJHoVc3g>QHueXJ<6zibHH-JXBKrbcKvc_ z6HN=Vyo&D)i$$$hD+eci>nt)wQ@m1unxArc{XiStCw6&(T`KF%uruRaZD zK@%@`bdE;is|xwGJ$Ye8Jhn$P6TRsx6M|gbF?Y2*m-UGw7Tw{q&Q~MB8$iE^-9z`R zcd}hjKARZ(=F@AXi`o)f%Vb+UedCtLm%Ycg|ZD ztV(>-W}NYWH?_WsDz!#A8)h41i^T{zrkZBO-^r&Ps# zswb;9Qp$Je!V3Lm4JqYZ8!FyS{j@Ai+jo<}c&JgzQ|q_5T$L+*&|)G$(>bxp{0m^a zr!IyBy$`wS2J&cz931|Cr0kINHa$G_vakznLrjK3OL@4$&L9Pg%u7h#NN0ERot2A& z@rN^sRzT}{zY?iPrrW3rm&eO#7l214Y^>ZO3h=1bZM?FmH7OG0rifBQg+6l^a6NpA zE3o|i3m%f$Q|&0FRe4r0SysXb7z;Cs#j}g_xq9&}8zlt|_HX~{Ki19Sl6lRT*Bc8k zFcMh~T8{uGaA*hVd8Wq8WxGko+P|w@Jj?Kx6<=ohc&a77dMU0uUa`997(6W1GJm}x zD{W)e4K!yc*9lU4zbkQ6=jT!u1%9a-mM$Vww@EQML9k0#+Q(?l#m~hQ;MrHSr=xcF zNK+O|+?Zy`_n#)$?26xoTZ(VYFDex-^8lxSqBpYrsa4l|GT}GQ#dBVtCt!SrjrTKS zYIzgN#8EYB6pIrF1>%jt7(%{l;8!C`Q$Suk&~iDm>oC_u=+zS=wNqc{t1q%w;4u)% zEvN@8@;#08?$TaDq(m2%khx98&q7Cka&(hUK3Q*E=%!&<`3iKgku+U6#*>KzgA_ar z*EKQ(r!~h4&3Y0g3PZW*Q6{pROt*tR4Z_0gu?!oVajjVeX-@IR790POwH1RFSL3ds zZq=|c8S2WK4FFk6J-%e)GTyp>gw`+Gg1bZGZ**y#(`;!R^sO(A=soA#Yu&d5h8fYb z4L4cO{PR*!Bbs(+GTlGt#c>a>@B<3%0BNoPXUjmtai`%`(on=Xh&^NSY-DzypT`w^ zm_LK}pFS74`yGaS()#iBzZh~A#yhoLh^7By-NIyrKhl z5VN3IIgtD7Z;I=6n!!{@NF44Fj&wp-gI8XreC8z6eO62oiRgI7QJ!y`T3;|zC;5Tf zGOt)ZC4NwJ+X}+htxI!g08FI9z2+$zfI^^5(afdddEy!%n3Pr)0a-#Nt0Ir&8LkKTQH`2aUB z@VOEsf|%?*bFUhln>)+|IAg5>%V!wnc;hK zbK~bdaqhcyIYk31l*Wlowo`%#?L7PNiUdt0hy%uqzX^!?XzU@U%J>v#|BUNuQjNhz z1#T}~@1DtzbC1{>hopck4;K`k!Gsgc(*X)la|@Z5!`*Q7xstgqx_u2vH1LX@BRqkh z=+8N|+{{bXu595*z%x{;ZFTx=iEuEUGvZ(dFcM(~KD&EiwvQ9_88(rifEYuNoq_jj zEuT22%lx@+=dl^iUWLkIutTXU8$fTeuD7inh15#*v>R^rb`BUY)fQj-*Q*h<&!j%o zFA=w6`SFq+^onF0Q36$w*vHQ8@F77vN#Ds_#a2@Z|3D-<81{LPfxz+apvR&mD?;2_yb!t(&_2PrytT~2g^(N6=Am=r$G#!{8JJMhx>6bA~7oSl|J5EP41$*?dMqOf`_ z8D{cA?TT;X73iBwhGJ*p%)nrBXMvv)wi~J_RkcHvYFB)&c#svub7VppCE&#;4b4SS z?9+2$N391UE*B*}uu;Ofrr!BQgGr{~Yk(>W-FjX3#-rWj>}fNFUvB9@%{1KI6Sq0; zq79BlG>c;NIYxs&+#F_`f}ZG%|G2P&95rfHloEqIkr4G=}D${P>F?-J~^)!Lr zp4nRjx-WV}DR_!a7m=-%l!35)@`FXMVpL+yif-7LI>AekNiw~7mFXYUUJL3h8kom+ zz6?F!Zr%&~V;a42_Iznh^u2fYMluZB`^1CLqRg*X#Np?0ZK+d0)s1S(dR_ZjndU`n zB>^{y-F|lMxTd?H-+Y*JWVf;fpGJo5d0OB1$!h=3>c%61U>Lc-N=lMYK^D_hd&+^$XW17x4qCzt%GpTn7>g+K&jD9`#L&4jMkTg$EALFsRJ$JA-c&phjSUfn->bq;mm+Za+`vPgeu_ z^K+W>rRnV59Ud!I1LBlL$aLb1c2#vxkbf;98y8&4nF2vA%A~@jgJx0WlQ>8}nW>BD zc2eF6SmJ#?>T_GnPR@v6%!|J&@{zrd@qo{1DU90mH}q{!`aCWhNvGLUcj<#(mhWvV ziy+nN&Y4NL)}#V65&eChXOIbZ47LmclbE>Wl-A}#7yJ5VbKu#-zv*Py!$e3`Bu1S% z-VC;Wl@4NP$8WDR7d=CDrRK!75`k4I?K^0%9{9x*bwlK=@Oo_KCC(^Kd$b=-g^NLo z7LGp6ki8~ugDusQpwCJ^fUofn|Bi=+{_&V}_9MWXuqCX7APIm#i3r{M_{7I4&;9sg zTFaMGPcb8Ojvj2%S4m154tMVsn*NjXv6K}>EQ!yCA|@YXM-pf?+vm-23BCM1X~*UV z8uApAtoP%4%40pi*Fx0436_Um2n@yX40vqQ{7Dw$`TyQ+cNg6kg^%t~X}J44>p%wh z3#hC!cj=>>hdt&A1eHH9nH%Y-G5I8<+rDX6V?n(tUoLqna9%nSgfe9VNA8wO`)WLR*xeWJ4ZJO0HHd55rD-QfQ3l_E^hzX*X zqm|Cq@F~9G&@KCBbkE1h1}gDB;ESlbq;RjbD0h~?f}mzEx6Po?qDy18ifVP$E4`%< zr~5i8De4dchm1IRs>Y{a<%M2Ibwel?o{}IZQd3=3Nulm7GYu$9lo6z)V^xG*6+t;O z!Wc7{Y(m3>Dc4A0v`nl-`_fD1WQAi%vwDoe>EjiNso0Rb#Es~Hb?aUTm$XE$cGP00 zD8V$xz&`mIyJRe+F+WE9+h*rvT|b__&7(3;H4B9?<_%N4wB|4?8(w70;H1{=ONtDh zbS`&or9>xvDUA*`(=OZ1Dp6G=ET4OOpT;H- zJLgvgw7Nh40>Kx*E55)frJz-ida{X9!Q^A1}zp5{ixY32Dm1_aBT zKw$h*yocL6Ue?Y`}LGGYpDNCDY-?^mU@n^}jY6QzWnH5@9HcUG3A~gq;83Q8u zTuW1jG8x?^A0D>A*?Z(M+mnR@DWPJ+XWUN^F?33Db-Vr0In! zHjZZatn~vfE@5iSO7oP))cnn}>5x<^rQ<%W8{Xh)rHruKm#a(kr}a*4kR{K4Hyk@ryZ{BYkN8YSqP*k4Ve+Iq+c{9mBWT=oA)DME}0rDRUl_=lVq+7ZSfr z)BCUc@utvWX-CEyJJO*)!W}?E*dBYI8NvhCBi4IUznM}J?k{&<$V&|AxPK@9Y%`PU zKQZGmA>B^VJy<$qGw(z7KZo#tKNJEvGbXsZC|xC;#>Z3>b#9ObMzBf(t6Vrfn&GSs znIw{zOT46(oWl-tR#*yYguAneCdA241W~wT215)mn@8;Sd+k7sp0w1&D$*p+oCu(6 zFGYji>TjfUib;KE&NJFB9=Y2-mesp_U;kR*nF%x;KAyJ>}}6nRfR&h$>)M4a?tG}d)n8SB42h7 z^nJ0{zA`LP9NnHf^e4gC16)&#-M^yewG$+27|3TTdnerhsmJTv1udtxCDp>tg#*`< zg*Bfj2h{}romG3s%ShDevFkMbZIFkDwsclPCaZXp2?m2yzNHlWJ<01o`2GJ{C$RRsKIEXo?Az=a&g+uUO{&xfY*B>k-Xv`x_;c!j} zh@Xr**v*QMxiNt5fM|hHbjLv&U^vgX(z0U)g6t;Y_U2vWydQI^%Hk4&rIeKm@JBI_ zXPZEsli}0>`onfe-0^-(blaB%T?;`~2HYsF_e`8U!GNb?KZ&VV%o-eLKZ*waU`tF1 z6Vr9rQ+i1c2#eAG*KxnAD?5qxl=Fpoe?^4IeI3w?2Z0Qn7eGD=kBMW{1(&4IG{n~1?cnE561zL!40Ywdr zXg>O=##H=~<2kW3X!p0gq9_G3NKf^MzY;&giMu`(xPXJADYMt*uXqBY>j$6u-0f7_ zj5@-615C8MHHIK30eJKS=a{7RoV6K13VAC^aoR&}7o8#iBHzcMh2MNuwhaxM?C2A^ z$#r%`lVLm0`3k^&Jd2ZIJtm^j$uRc6#3ws)dwh1g6A|H^ zIhtNsjlTOpz5iX5oQFAvbSjc?if#P;)!k}g4O}osr0WI-;unwzF)V~2Sk6dS#_+;* zov>z9%$gnH?j$VgZWc*Tzt;kPDU|SZ55gsEV~{{KyAuEHNt_Y$cyfUfVNhrMG&DqV zCBo5(5V$Ykm8|bmkHUApY|yKpaONufz|K;cF*K%@p3(bI=qJz|=W3nc>`)G8-up1* znx(DFJfR5JttUE8d=}~mYbF{%xF*;lCUgQ53yL18ye-NU7UMuw1~o3}A#Md_NlMg5 z-!2w+<1IMvaU@VVKNMtg-+vrERv|B8z|@yDXs!MO(F(Cb?I&D4itSbWMH2-W7bE?f z$~U7NI|M3{E-FGmd3%u;SkgnmJ69dPld#Ed(^5eg|6||WQrOycJs5q)DZ_B2e~bY6 zoHiYv{$1_&$+%togmrANgC{3CPXp0VRqT4OxbBo?pTO5Mg=yExOXJ?WkGxW&Ng5uP z_@L0#BXhIA;(rx;`_ewlwbOgy;*3F^TCrlmk;2Hye5E#8VNFL4fcp z{O9L^6wNu@8a$JV(?p7o5Vo99V`H#~A7N6!nzaRiTlz`iyie~Zg zvbp{e=u$HF#65J9eR0O=nXI>Fg3mr@j}ml^*?ss+UdJj_$*4jIN$t%-|8VLT?OL*Y zQ<}U4g2kWpncOntpZMVIkLpU2Oz)3)PL|iU9c`}zrH$?Fe>>~9vE?f(6b|62oN z1~Br{V>*$ub+_9RH7N%(x1C*TYn4`!fWL$fzPf8?`H=y zUlGw}&YiKI*yKf96?MxkFukJ_@5_i$dDO+BdIG_P5OwfLlkDHTAMN-JDtx?Vc+vK#s5T_UR?p3soO^ROb;kOvKKAFnOLW~-g}H?nu;_tf z*9wFGyFQ8DfxXYy%65pEC)3rMsL1@R;%=n2u4a01IgI$tb3YhVex%e0 zEdF6WJLBXKoZ-a!T7dp*ej|z2hQ&q3;hsxHs0{7=g5Qup49WYiVSWg|LI0_uwW-$osT4hQ@~LGpGM3e!WSit;`G#iBJ@azH#D6HjJf0@zlR>mZoN}KNh;4U zIj6J#gb3O~Dyj$ziimPL8%7TMbX&I@pRE#;UIiAGmD6H+S5q3> zO>O@^BX}!p0xP`2|I<3Dn=#8~*5ThVqY5R7KFgqN>iPp5h3`L-pCZe)s<_&}ZB^Ct z*T-yD4c?kruB}B=yb7&dP;P9FYw_B6oAT#|sgJCj85z~OnR1cuw}Lu%ulN4#@k){U<$?SW)|P!i9%~ z*Olmti$~+>-&((FEyX$C^f1UIR)?2&$t{LPM|Ruj9ZdWuKG-NR!rjLl7OMYkFtlM1 zQ3@e7pGERA*7C~5P{m^&2P+5aL#aAw+P}69N_*tZlqNOHaSAePwy7al>yuy0kI32u z@se!get78j%b_58I8dg&_>y=v6|QRb(`DtGstyiqjrR56K``Z2zRX3wI5;uQ3v;9w zGr$t8=$7jyWlkJF7-NLg%+xLYP&{tR82Lh$pD`_55wprk=>t&ll z!a7N);Ix?rCx)6ldG6eRl(o~V(37f~UC`JhIiERsiM8YsRnoR?*p&N@6FwXgdXE6M*+{+~O0^S{(L4Oy-|N`QHlYpHTzQUZlUzcjf~|BOGt8$kWw6 z?-$izLOMWwn!#LbpadhiD;5U?hzOU68ECx(+G}UYGJ5>r{6T)7texOSp;=5dkIpYZG?!IxA%2gS?GUM{!U45h zo99K8pe!jS7A<}wrOfSgbypU1T7W3^nmFX|U$_v&QAH0T+Ib(|pI&_L_IWWta%id) zmpn`xF6lGDG|@q)FUw0z7RxLoS~AOq)q>-RITku`+%xco(X6NitZ`|6DADJI>cx!o?j*DXi1y9h4$tSFnv#yt;aA!*w}x0{1+x`SsW`dP;el!wR$AM&0~e{=IwD(NzhY^ocV zw>5GcN_0m;;GhL2_>ciK4|5uVO4{_0;0M|^XHpDp*Tn9`l*I4%L9~^8p>v1#uk()K z!hTPd0Pwo;Wob)fj}ZOf>Qaz&_V9mSfJb=u_ZNUqN!S7P+5&e!umH~|8nfVTX^9XL zBqGnkOl1S4r9unQmm7Zc8^{UtG6r-zI_4(kp}em+n@xA^o* zaVZ42+`LaMn&XzBwzO3EA$=(jR9b3yGAL{D-P@;1nfs(Nx92?HOmGMilGjatKg4D} zNv|eD1J!fIC%s^fIM^RiCU9Sd=c4t~({Z-ofZ-Do^!%tNlj!w4_L|P4^+1z@gp}WB zDVi$-0QI>YSsG& z90j55Ye3GZd*1OpyUTU4Px0U{0Z;Ifpbm_~M47>ootm#+*reABrPiY;7Z%x;pT_$r z`xm#bTD1TuBiPj9^9`H%Yks|MVb@ z@A?L%V-aZFiulrWx+$L41iG>&k%?Zr+6V?~mRLL{`0Y<@|3xd69a&~N4Ebsg6l-{Q z!40p6TNf+fKlfa!0j8oxHOsp+rffeGxXAM-&LjcD4QpAt;8x7y-exvVr^s@b$WFG9r7TktvhVwDP=sU~!&qh-3WKrF*nZda zd3wJ0eLwg8{q6rbU%0O8JkMi4-p6q`d?r69IhJTrM;aDO(Ot5A0Yl>b#c8#SCiY8_ zBIa6p0AB$^BxS~hX>;#;lBq9@syaR}VkaJfC)p+Vn(m6;SL8oMfDe$RRUhI|Z1Tn6 z&&+W0@Fo7kX+g}BrBQX-$$g${^o;iY9a}vh!>@CVDU=&}bM9Imnp3|UbzPWVS*&w;gV=P(7F9F1T72skP!h*;SYC6(LmAq;fhpd zXMq7JG%!)vT~ZEG0}aZ6ze2xO2D#RJ075@?w3<9JeG-d|p33(9r_i&VP{VH0B=z<9 z+HT8O58Qb6&P8}&O4f0F-*{b zX+*xyP0MkNsT`xh_AqNbbXBNk*G&L1XeFdNl4Z(RC-3Np-+`&M@peDne*c5_i=T#I zfI@?Dv$FwaWP&WFpUJadw%homOylm=2#+3BxzU3|ia9nAKUKKWd%C3a0~oVCk2Vgd zq2@s5^|7Q;l*!_l;C)mrgC)K!uKVy?vGR}yP;YK}ooVYu#3^^PAuD1M=CDy-PAE9N zrznI^sfzXd2Dg$F9WaX@;8<0?9NY)FjT{G&P=ig^(L?f~Es=W!8P4_)v1M`XXFcwg?+ zMwc5KDI0XIEs8}wB<%dEZkd8zx{I2YVh%SBW%M~Xl zPklEA`{Ngpq62+cRH%fquJdaRg%y+saJMyVE~p$&@c^C{GBakSl$NZ7Xx+Q6RLlo- z9%E8*m9X&uo`H!O2MTVK-SLb*lrS;q6uUH{au)v7(yu$zaV6cz!ho^eF%{<9y3yhi z*u$9jkTJ9@BdRnDrqNosY24~}w^Cpv5{!=QIA}jJlD{~|t9-7A_ihwpHjU|+?lB^O zho!n3n9XR($m0~D{kl)tE#E09jp?jL*dn%kg4U8>x&OkFKEP-E`OV3MDlv> zLB)7}8O8XW^chT)XVL0U<-zB9SD3tvnv^#2mm%NUCtct`HhlW~+@k5O@Y}0dZ@D#D zLy|^U)8w*L&UUU$ItXyLIP91_&Bfx&IzB3PkgK$b+`y>?)C@wEWK#6eQ4?1h3!$r( zZX|+Qje-sXTHpGHC+Dr@wrEeBJntyjUf?oefJ#JT6Rv$&@2{~h-2m??aNOM3>^#mq zdwkj(dPs`_=4Axse!bKwm_onsv#oVQk`<3N;OlD?VEO)KsSr<|2-QM?r$fvY_&wfs z#aBG+?9ZKpwzjZc@&8m7*0wc?GF}>(2j`Jh=$@Gk;mrfx3VOZz8s<^->@98B;z5z})of3%_hIg5cJC;pV(mSG9 z(^GgRp^0?t#ci*Oci&21Jp2~(S``wq8N5&$7ZRHclt`8{$bM!&Cx9B3zb6Spj_2*y zu}v@r(OXl9r++dZx$;%kD|4F^rQm(16=PvCaNOa$xn~Dw zr=JMnlIe->I2uow#E*ZZ{<8~3%-THRCgv*Uv=}=N)lEah_W3Rb08>FNzsk4^+(KQ) z$vAWeySJjaA}{?(BR3>jg`pDV^*d!z??VCdywXHL$FPzWG5u>s$z}9eRm{4jV zUd@=i7y)Y|Jr_x_>NDlvp)XIVd2 z1wx27lWdk6S?AA_d~A@_CU3(3tj-v~k4?U?otAFWZwX+yunp)>c8mWAEzVt*M zbOsA0*MD35va^fPKU!sbP&Yt;gEWO47?!q@Yl*++=fTTELd$U_FI=xX=iSQy%7OQ~ zgD^L`>q~6)2H99d_&^WqHoI>c`905FTFVpXa%PAZywKyGZd&XYvSD|fi4Tgryr&1H zmdG~wF}JMIjmCxJ5T?891&Rs_%kk?yn>cSz%)%iDHj=S}Oz1P$Y8-ncYb#yH)~{xS z0#!_N5?XHws?pk7YMj`fptRM+*_+o{)0^j1$xc$JBKGRvK}p8HPAP2@~Bm zAm5dw9M$pB$#S9;r#2s-jk{43J3>!&M#LTb+r2isO-08a6^@@tY9JW4IOlrbI zuMrdGkRPXrc;P-WD#DGVYWxkZF6Hb|84L1E%JbO8_@)PQ-wwFY>Rzge%-%G}>{CY+ zD9(>ib6VV)>pgfaUBRXi?;a87w_XamZd&ukKnNe!VS8g{K1pvv=EMx#qUGQJ0&t=_ z0jBIo(lnHO4Ug&lNFQ0-;gJ(|?d#cU5Zs{WPI^cx!Wqwccb6e0@T#1}% z=Y@VXmYKw1*CESR+wx7%<#m)l2_yyt(ZDUO-OE#l5F=--6ZGWkM5%V#3P=eEySbqFsiP4SawXe ztDGy`6n5!5Z8LAvoy+7K^fBQM8th&X@8G3K2~tqLDhVqtoSWnQ#1&SG>la4tTgEJ& zk%vVVC`o>Sh2ykbBmZxERGBV7 z!-~M-O+I!P)_bZ$zPtkrMow{Vof2v}3FnNPK8!BVDxhoqH9Dx5kCbbdwfk2#tdin; z;gPlZ(y!p*Y285F;?v`$jbupA#oD9$BuB`F6XY#n*_0On9C~9g%BF~lcg{fmm*Xmg z|0W@K0iSDg@wwDF^kx3dt9o;e1N3y?=}hU3=-A*mk|sJ|KFMx_MF`*;Fa}pxv=?5w zyd``JA83wsGb%HFw28^vNQ~+7DyD_xV^d#}>zO^M%I5&mo|ohFZRT4L)APYb>k}Z4 zh@CF~LyNNYD+f5hVyT>G7t)9Y%Km~4AOrZcwtP;@Fd@q&55$5W6z2_-)FDT;K4Qe0 zsTbcV3lFv8Q^sxhcNT3M9lroC%6?Yqn%sEHs=Qiv-G-+|Qi~b-hJFcY6j%{@ic1!j z?I+W|dgo>Y2%R=)NRyaL>iK$F-ThBEK9N3Y%aMo|nOTq<$6(-N{!Q}W4~YwjD$pS&yy(CB3=kgf ziYK#VG%jGUc3m)6k(9Mjj6I(2Dfym@nWndYm`j}Lh$ z>;qUN$Td=#jTjo z!x+0pq+jW@Q>|RcV<35Tt_N9a@R3byDu8F-F(-HDsoFeXY?ZXZtYgw6 zXq1}-d8FGjHd}CFX4oc1<(M7uPp^#M&c}@w%VulQ!7wJOG|c>QgORpPvd{CwWZSFD3 zjlb%)J0Vc-a9t&1#3qmtS|~boPTh~Y!Qa+C4ek3O6l4ajHKZ?E-Fq+v9H62ruN?Xn z{{uz#;aQ1+kmCKoj}OS$+Q;KeVqukoI0UC%43%*nH>?cT{M@ zbAiwveocou@5o#!G8t(1^qamL6I4i+XMX1z#*=O{U<;8wPL_XslMCG9&iUpK@)!g7 z)MDjd@%o)hM~mP}aurrz`jNPu`*`K&*GLjm{-pdXZwuuuK5y zrkA;_C`h}G55dh;<(%bBtd2HN4ioPMaIoHeDFJ_lU#b>3y->+b@%fqR>EQt9Quy14 zHWPsFHjBF}N0URFq^`!Jdg)Q0D@V+sp|^FmDl@ZfD&JhfOt}}ws@1Z&R=^=c{#CCk zm*Knaem|xJt9O905~j9JZa#XL#(f6s#dm`8V@Y7_p=JiJ0_M&e zmsr8AhfREK9A-1*jenUcKJ6{{qLZVb$Qj`=>@}@QpBWPPLht{nu?3~TA*GrTsX2GS zwEc&G*i6pz>BFmPY-+TaMP|UKrs?p(->{$oNqrvbJ`eh!M<0(fk;0UALT2s%%5;tL zctT^LE037N{9tPsj1Y6(V?Lp8R5!jV@z{Kz83*KN&7bsz5+WsNE!~d!bS0-2pTs35 zupYl?UqN9KYDt}8HiDVG2FbJT=-sf_SBN_MkI6^0;(O?TRDZ65w8g!z2~rYTF)ZIm zXA+r%R`sDz(?CD(l&SHLFjdCZx3cFI0~wQwqHE$`UKwl%jNQBOK273U-OBZikGQh zu^21P)m=X%3rX_XI-iQ=FTDqEUC9e}G3}c@6ysphiI+1q)vlNb;KAGyRd(_9)ysKh z9_$55eF$8MZq6k=zpO;&@LC_ZBS1`pZdwoI$l}c0NW2u;fL=dy++w6EZ5J)bI~a=i z!>g~*=ustjJQiZRV#Z}b>!IP(*Dn^b6z|-_*($57(R{3Lg7P|T1Qd%YeqHHcA{{-V zxbw9fyzg6CYStwVjd}yChz9c2V0?wvM}8%FGIzNP*r~HacV;pSn)6U5Nq77GTH<*E zquEO95`S4)5eGr+B*PpE%s-sC!u31lOZp@v_m-Q7QK;Rw?@La&tH=6CB2Fl*w(-iU zXRLVF*-|K;gu|-qGzmYVp~)ckYkw;vq&U4&tO=Av)elUGpS}(%fpCjVG9n&=LbIyVPO)h`nK(Pac1WMBw&2fnKdP zhKCe#!&bbGYWd`b^uaqbbm1^1lBYwBl!&~&G6#btDw5mZt`-Yc(I3k^zV|S>i+-0r zi7qcon%(Pz`@Jq=hfK^DJ&5;}xIpRinI;~jp>BnrWLoKK@=g5@9tjO~H_o_{rfE2k z51ySaoCS+82Xc4GM$m4t&lSAV3q3Nv!O9Lgr4!UAwSFGb3C5FEY9iu zrqJHYT1Q00KZZD#4w+Ks`C@GcdUjkhZ6Q_~`Qg@E>|K)(68fntyS{7f-wp;}PZXDv zKiYGRi~7tOnQc-Ljbt7>=ivLqNaN^hP47}$U_e7Y{o0J|NwQCrJEF~#_k`mLp!ly2*X4)vVmN{vuW0j>2uSco zAPUPpC3@3MI$s6A(nDy+R72u+ox!Ms(6fV5Jx5K`>Xa_Zj0NSjvbT;!dc~A&HUDZu z68RcMzZKJJ;?WmyTD2e3uJw3}+~z@!YW5SmhSnwOV<(*#VM1FgodL)}0AHIAB4L)O z^>K)g?+e&ivaqKYWZ-@pI2Ld51PvkK!@UpEYM6C^8X3^2D#kH)HD(RZw!^;5k>g5X z+3qJMx$3kxX9(3b$La~)z9F6lqYldD$a!+#BX@krtQzBgiJn4f?yl4k_uF;7_ z6m_4E0}C|7zUf;svOF1LJ8FbgQHr%yd6U~f(gf+l9g`)u*b!t8{CK{1hjw>2lW}Zi z+>%cWqNB@!5L8|di5~r0HbGG6c~YT5;`AepHONWXT|XwqBV$4QAe2fSvu2d#qTb=9 z1;$rZM_1WZy7(5Aw_HeF*Avxy_PSERu@wlJrj~rIpgE9iPt57@sFCY3##7InhUAPp zE{*)+nPwYLcS8AL>Csio_AUF6cX3+evWK@iUD`NWXK3^> zKd}K-Xz+4DSsJ9FZVsVXKE6%=Luw;U!0}nVRl}XoNX66;X zbRM=*`w(CpZh80vitzQ=X*B!#r-d#PcD4@EcLX8rxRO|DPgT{RF4@&n?YnnS-=`}2hpM5<-w33$tf6dT zF)lD8cIa*&Fn$@5eXBkgBmJ*t?AOmL+t}n^^03M}NyGEomYo4kGRm5zeafk+ zj{N-Kv664#Mdz{>!RP|1MM={?BhmM+PmO~ z{s)Yfag1bWw=cYhEY%+l&071047(W?@JS;dXDxt=lrm#o*Lz}1Prp20l`9T>2$IO$ zoMasp5bdRP6zVt^%CphWI=1yKGWKJ=R!B5+kMuQ5H5TN(OlF44IHSpvvtwxTbX*uL z|4@2Aypbi7D^@g?hAau;(j&q#VAGgU5XaYzT)ed9f`|fofySkq-&ko8s(8^pp|f3R zoQdq4-I}0jdoMX#=*kfrU^$D^Ld0|eV{@BPX{$oZAGNv{y5AswzT9#@l+;SGaUhh) zP}FU+i%sl4z3CM2*R87l#42WhdV<%JD!cjap?YMiYi}(Hw`C>_Ks`lK2iB(34(9QU zU1ZxPrEhRE3d>t2g!<8t<$|P9_tNpDTDg15@u8U7Ly5WkQ{;2cI)#w>J)bO8WJ?>J znHQ%V?FqcVE<`Q#hg4JNqvjq_!S%0|ucOqv8QCh~Qzbsh!E@-u9?vWihf}^Py5&bjE9_64T zk#Ed9z?<(@{iUwaR=}Gt#4f2#Xf7P&CP49sCjOcEW%S5KXV7<1FOAgI{$rWpb zxS_A{DBM=#16qUr-erxHH;r*tuWlk+T4`;5ZV!o7Y7`Y*Rl9bs*qh#IaPQ?4Rjv}U z{IFLSn$_C)Sgsh~|KY6?Ru)Xn*wh4I@jjoo4tr?Oz|A^#uj&G0K6FFS*pHKhgkt&W zG*2$++&z^a>ZP>@Rdg?eGsW+sm`EPXxF$C7UsQkDonUSj(^NZ3K1@^G#L&ZySd6?E zafdO`*9$upD6u(RZfQAti85bUjz=ce+)A3AXSN;G^MO7Oq0$BXp^qKvBrMyy27kLv`v_nOVc=+URq&cdLm zht=q*I6tupn+xH*Y8O%T5)HQqe3n#zc8Sk=@)K^GT;4`TqZU$O5uuho=`!`iE4Ilr zJN=gdXSQb-lz?o~KY^iBr(u1m=4N~snL8Qup3Bqhb$ikI*B7YdvvDMPZ)v95SG26I z4!Y3eV$TGQuN8SIB8qZJ$(qh_q{rpL2IqU#lwb$zllHUjnCvah9I*;Prk;;MG*)UU-6PrslxwovhmFCw zzO%<`A4KHtDn}=y8RbBjdj$U2M+esI6?ZIeo*Q>nj@SI5XU^py1GQtq=8<#Nmq4Udx90-qcMmEx@kkTuzO|Chi?NK?-R<>SlDk>ih}ArY?+EQdY-xwg^m#R8c*WC!sfuy2i9}E{^i;2^aMV}!B;11zhEv6Q zi`g=6)Zx63HdT7Bv4@^+i!GQ8NcTuUMm+ZJ9MVjE3a)JXZDx`ZdHHWg>^A;_g3>A=o}V3*~IuwG(xVrzj5gS*?o(M1^3CK zIIBPMOHtfrFoH=GUU&`Duf1TV*hjtNz^aehcK?MJNA7cPo2jbPteghx6 z!p!ULP*v(4UfTSXHIZ)leD;bClla3%@YddXUC5+s_%ge zh0GX~sa7hGwF1H2FFd*AP@mUcLh88l4I`-Lp?ePUsQ?S~vFxt(L*QmKrgVbYAx;?H+_Y9$`v#T8q33 zzv=aa4@bJVlIstlZmB;4b>b4=zPd>Hqr^7c0>k_Hm2|i#HoBSU$GkQ-6Z$lqG!b@A zapcyT!lck*K~lw?5T0o!6eD%Ab!FnaMG#DKRqI9+dGwXz6tZ3!Wl}Nhq&vT)DUK3* z=PobTRQW8MgR=XwRL6^ART)Pydg`xqWW##1?Re2_?#Fb{K?nV;#*SLQ+q(r+2dK~y zw8Huq!;1D(0{it(7zmPDv_d_hu|Th>N@~HtVc(&1O6*l$EbEG+$_;bMP90hmv)OS0 z`<8>De}Q5CmH=5G5}QX7wJdr)v4`75Dkmn1wtX3g6kb27*|627@8w&oyIK7G`z2rdg%;1jvVckir{k!@InZu|SIHNt$w1|t~M!_(Aw z$N?P}(LAb4Gly-+UH7U%9vS+ZvCONk4;zXw4M@Zm^-WfFtHgZ~IM;k;Bb&7-{`ym% z4IYSM?+;!$gecowlq*%>ip9p3Y|#vIT`qdQw$TDajh^|mW_$M5Gn)l0*;~*wh-0^D z2L%gUj?V|r1+JKz;*++sX{-1k!+2uNeXgU+t4t|8(`*-)P`=2atl1^yB)y-ugJX9g zACbKc%eACQK6uy6Gp9Y|paYQTS*=|s9j&MY3Zh*+iMm1ZqT_ip_Do=SZLTDn*mM(T zTN30P2vU+;VczU)@8{-b(COuVT;^4i*cdL=1}KE8pWfvW2Qjt#t02UT+>?fhn8Oyx z&SyT;<0o&bzK608tUVowsei<&opd1+loRZZJs`Zo6(uqfZcpc}mZ(Tu80W&ZIFWj1 zLNY$O{(y3r$r>O-G79z!?%S4@2R3_5*oMG7&Z(?49$)T=@Lio3-i-LCFw||jpF%*r zJFb*PT|0Ko@fL$u^x03c=5+T5eE}%y>V_ZL04{egJCKLMmf-+~w82_uOTuhX`9MOs z?G-6koN!YL)A*D2FSfVRfMO`~D3(`T;WYN*^X^aM3RlK9`YG#SYFW=^t6$j$Fdb(E zy_E0Mk$a&!96^X}4q32y9K>Gji*{SQei3HfZHnysp4jL(7q#8z*&D#4n9%f!8kRrk zP+2(U7(yyaEmqLJt_1IQ57q=%Qc2Xo9|fl;qn+p8wtT%3Lp!g@F>EmiUhF1wDSNQl zJLUNz?a;4Eds@B_X=8*7jt|9I%O&(j+ze@d8J9k^i6rYRRB|<_zn1JJ4Rn!Z1|)pI zUl1%o)#9G^@W#lMwA%P4bE<^^p3PrD69&0tvlD%V_!}J&3nJ;G+oPgl1r>+%;O8uW z{KgC&GcCNI9}K@nHN%p(gWOdQc-mgMr;46z9KBWQ+{YiRrCh+yZa3ic>jNRVx?)e~ zz`8ZV@gP!2@jcLbUsU>+j*ZLOgG5IAP`epUeW5m;@lQ?%;>sWKSD-E&5~7KE+ehv* zAuJn%E9b?y}pVFd8E*a7E&1hnR_OFh?hmG#6gO4(!zr33koMuv^rf-Y@Mb_QFOZk$m;@3LxdXY;(NC)*fclWNJzY0i=!oX=AWcjnHgR_ z>Nw!EFnYM7+1L~cU*--_c`tzVp5y-7mLYBkHW#I%+!>s(!J|9W!YNrkUU?M2%D26Ky^z{Is* z+YE#H$^F0#Fr0jZol_>dDU7MNaoW%y2d>TZuBR_dw0#lRa(zI0#CP6C)-HBB{Zrn) zJJrIfB=qPe)Y&%%q`f|0JNiz?6km=UaoTsox;JfSs-BXICWbmTydB=G*PKbzTZ%!< z4L+$rN@R8QOAm(2yOs=p{p;kLdOgQLy{~b0-LO9;&SctvLvz=J#YunKLDqfl$yy~f zvZ2?tp~3&?L(L*0K;G^KOz16mC8*pD>seDZl@u-8n)FgCyUMFEVR?FW!4=gmnO>f| z6%p^k32KmTm7~2|Zs`a1kjde2k?E3lh6Y{J%*DsnkjYLDCrC@RUDtlQ*#H?Mz+CrT z+@R%pRP)0B;L#;<7X_T~O#L$;cy4F=I=ZTwyu zRPwHj>T!wo59)&VYoX>wm1&m+RLlBqisb$3mnh#r9y*KbI#(N@sq0$%epZZgtx&lq zeG)1|)c4v5`;J>I2zRE>CdRijqw%_Pcg~Na*88Ob+%Pvqi;te#&g+^LLl7=T9Sy~s z+IXOHi*OSdu#>oRLd6Kl*IoS*z46f;;3ZUNd$3%mb-AmXYru8W?CA7g0pl(}NJQb1 z7)*Br+do~4Zx5<(GRv-SoGJ5C1LE*Ly+k|oq;y#3Xf7eisUg_c>$@n{9U|)i#crwX z;7s<6{P$csXg@^%a&_C>ABZju5`G|0iHj3u8hn@mIZx*Mh*s;F>82Ei`Lt-n=A;2< z{inU7WmBX^>)HOoscGO;ZJvRV!RrYDEhAKc&(q-9(w5@!1eHX4%dDnQcFMzyAm4!| zLuIIbM?<5RU2qnG%N@&Q>N%Sc<9^^3x>B8s%U5W)0b^c5tiBVwYHE6MAOR60>xOVe z%lLKIFOAjPmTrohwREH%{LWVWkAi`J(N_QD|Ngu{a$4Y_MlUt?R^kx33Goj^u2~o{ zVu8rrK{`4~q2f~c*9t5x!8%(75l2eASYnx6+WyzY2jVFrC{4+m(kXm-`p@SQj zUp7C2T3vA9--xf@I~F$~UyMSO+-1-eZT^w!eT*B(B&vJNM=`Kd;Lhn$CeQ{gIYVkh z?!)onfJ#NFiq>cL2B4UdMUq$s*X7L(v=6}CRyv>}rZ7mu`iSekcbP8#>3x5E&)+Yv zA}1yP1T>}`Ye~)JSEx-~@E#Ju0H6(P{4DU=?UzPv`B_dmRjZ89Lk;AM$$vYx+~nF9Nz5%A_f{u;4T^viju9ruXXKhD2>g7;XH*FQ zR%yeYqvC`NJAM+@Jgx#y)|;ua?)`R`f6V>rO*}8K5xd&}vU}*SAou?&Nsm_p*74zD z%zr!>^1nCy5AXa3VzRO(R|NWq0QvPjyU7v*BGlnp9WDYJz@d# zdgs|G=C!`=HK1QyVDr&N>zcHNelJi%{J*=js$=q-7LJ&~bYHcPKuKKnw(<%z6e7Pj zp+7|Ig}9Ak?rd6&nm^rd%oGd74`Bv{E6cR}Qnx)-{^7~SG(_|eNAXJD-vn31LR_=# zdJ}=||A_|w*Ke3&yZRH^OXXI9hBViJW|WWc%d(v&fDis-J0&@!fWr@NjTt`Lv^-rO zJuPb^Oje3&as@s+IR#p-7Hjz#)@DC=n6A-!flVOu-=t7}v%D1rT@^1f*MHswh$aEU z;-i=|LXQVLUK4@DziB;&!~5Qh|H8>2^z;`rW4diw8sl-g>@f)Ne!KV1uxw@wS5UF; z9RDK^I8fPW{rN`#u(P2sz`)5kzc(iZ+%j-`OcB7EDZ--Sf3~XmhYS599lA*90o<@# zik$yO^UBnl!MMWt{f5T$_JjG>5o2BLdcZBZ6@e$RCJ`Aw$vPtII%k}=&IR5bd+)EC zUlmzQ`3DPteCW7+=RX~F24~o0>Gb6^v0U)}j4$dVvE2xfRO~9-Eewgnr!$CEB^YXt z0QJCw)&+B2dCY5XTpx30@Me1>(DD`F(3#=vOTZ4ksRD${DZVZ9_jZGb+YhoXd?NYx z`tL~4pY8pNsLhfgHLSESujnAGZkKz2OG8kM(5qye&mZP~3Yj+d7a=0u){Y`*m77Ag z)1YN5Sxa+em=CTDSgw$*Zf{_AMLN(6v>?Z;pA~bm85(-U30pnDg;ClJ)WZW0rB9=!_d7U><&PKKvVpJd?7gnpWIP8)9eM zVnY%G9Qw*<_|Z(D-^i@A$w)IP*$3BT#qE1nEa@f8fI2Zue!OT&ehDr3r@DbQ5QZtr zAof2fGi&yKjq=w0PxKVt#qyhQbGa!T_+~bMf|jd>^}#C&3ntoayGyj8O(9E^mc@($ zC%Z)|B8HI&KpNhY?xK9N_zUQ3U0k|s?%90-6K{EJBTxAGL0F6*w*kxs3RH3^Ao2P8 z?C8nwtx8XU0qnwcB3p9CoGyV*-zTXXuHJ0U!2!}b{xA8=zpG@rjEQQ`cuCv(&++{y z0{E>gSg6m{#+&-;lH^XVLI5&*4LV*V^WD2uI`_up*zy#U>b*DFc>_p%x+qOce{j{H zV3&0-~dYz8z?$F%K?2m`*oUo+rs4Z1ZV4S_3Ex$h+}^dDKkDuH~_t?X7z zExu0-^2jJpVI{}sy4bVt0vt(NsTIx3ZwHr*&uuQnfVe(g4B*ict^i7Wf}PbsqWACt z@1a1$zrG*Z9|SiNpO5GK?yJKR{^AQd!T0QV^vn#16qVhgF&P_|d-hS+cB~jZ`^xl( z=bwwKCidg0?s?PmzdaLxT7R>wI2(zEUN#q`sm!voUikmx>44n^mS8)j3-RTkv$>$G{x)Lh8O=K zDfHhzjb%4Xbmd3uWT{bRBD-t`(T1E`3K^s%VNAYVr^CueHt;zlN$;ON;LrDrf!_^D z?U~QM)@Y3@@+$lg{uvHvo`xL%`9RE?;Xj1Qw6p+hkv!g|tEP1DH+0;)>YpuqCk-&`wWFez zM+jC-g4784=*?%x0}A z(#JMjLu1U)b{+Ad|8}EG%HnQ_LViTlji(Hxv1hT+B&B`$c9MVFhc>`#64?w{Q5fQh z;dnd`e6YiRzBm@tdB9?Gls{aI7z!ifGDiRErB!LuwerE%Pv66??e9F*}kys+6lR?dt=c-jJG>S3tG)%{SKL%{POLCOtVk zyUtyS5Ci{ufO#ogan)t2><#}dZeI$L2jRDkW_R}+JHf4p!e)Zs+6M)c^a1B)?TENy z3@0d4$NxFLceNRQB)U(q-b2jeFfE(=tLd8qv!IBTh5(s`(KT(eD;(kK|4q+=(_}4( z!p1g?NL0fYm@d;AqRI=M3fn`sX)$2?jW*<{;#IX|mS+-Hs#k`GbY2YL7EFIU|*Ignz}(wzq?%u7lk$8tns?1%=ov_*c}d5Gv9ql?IMB z=11OO>w}St2f($C)|j^<*hK<*F?A&(U@X$imJOt{xyzx zuj>vf!TfIHX5iMnF-}~|+=1^|!)EM0bw=}tS%tD=>jAR0#&4qvEyu*G1%ns%X9nT( zd$s0LNw!123tki8$AgZ3Zk1_|D<^C>j7w)+UK^e9AT;G=S6o@w!tYf5;qb#Q6xgb= zegC=`1Q+j8@r_smncm<8IFUa6=(IyS2CU%Nb$#O^%QM0>(P#WbQcOhmr6+9PJ}Z=M zciQ=icDuN(6;O}L(|%gpFayqu4EjhTRXdR!-usSv#^?2#_sCC{O~*3(T5AI82i)QE z(LnS`&9r#7Unp(6p2|AZ+_v|0#1>iz>Vp0W%6T1LeI|z)Y*9S&x9sc&;74cv6L?iX)Hyalq zaVH?trAme9z|J`|+zPxM*w&e7ezY9u)dm-hsr1ACYUY!{cIvc3Zf1j97goTXSY*8i z!M)Q>-qbPyTr^r}uCP{%4PDw@_*LWExVb#zY&yGrK=k|4T^KC0Z{oq5!Is20L7Jni ztWt_c(dDq2I-SUHj}SC_ue`5Z(FXkO!m!)G)P~eu(855+xWjk_Xt~)x2s?{y4cy38 zR-i>puedL4936EY#&=#-7$324g$KmikLFTDIJvv5Dy>&W(y?*xn zftx;nMSp$#rp_Ko3||n9WSz33UX|nC>yVm>gE*l)V_gMoT^s@^{u7&?JUBhwJnIl7 zyH5wqwbvi|?U3ZJ$~!gbJO65#v&UU-$tNhRK8IvVJKb-!L9yAZTLkYUbF zP?`V%_X^jue5T$RI>gGiolR^LeNEV~?5d4(X>TatsfuKpd25M%Db_;Ycnk#!E5EAfWWYrY-@Z}?~Ig{`Cn_5=AoC0dh2I!Lp1*td~A`4+&& zuAAg`7c101TtU63)tS;i^JwUE;E;=d!#4ZRz`K?S)S1& zWai~CTvz44uC3Fq7n4MfA11nrG%CkiHWx8K8G(uj^bjUA2o$vdu?_i|*bw=|-`0(^ z3`|%{8EKoWG^k3(`6LcYbVq*QmkZihwx34td*woYa~|JLnS@ey=t1bDLQA58DV{aCsIhY!Bg2KJ`|u`L)U*)?58ZIh zK2CMRDrEi}fe0v+asZ(E3)6L;wybpK4el3Eo@Cg(Gom0=QJ<_#aGYuKvNZwXHh}jb<0478stiCOjdu#y9> z0`2QRklDS_Vn6)WS-rB_I&{6LkTA={UU5HUOl)ToQM2Sd5KF66*J2ie>%-WBj*N-j zWL6<_UIXdGD6>}G+Izi^gYoKJQY9+y${1QUJB|!_7E?62On}MqIYd)fq+;t@dyqXx z_L3INeo3ov=v&}amSk|oV;Q%g04X6RWxK(3Q>bvSsa~c*W1S!x$8@6*XO4ETmuoOc zFQQP9Qp%vARaR=?aE+^$Ka-`&ui0}r17&fn6XOGwfx|9?^TC_u%EwEznen16K;IEl z^>jT23K9S&tqy#7$WHdc4S(Q!b;G;%v*mlG?&#Np3kZSzQ={#)nC3>PJ;&2E#RHnC z!UM~%ggN#^!%ZxLO?+pzHA)|*6Obfp@31CB0!Rv{#z)Rb&g7HMw6lP_s^uyx&lKl7 z%A=jKwX@b6OFc}1jHG>a-jx5VgtxmI3X!ceBJXe8+&jIomWi$>1%)qHP&1nC4kSg$ zJkvZ8mswAyo_ro1naeXX^ALLZs{I-*i7T9ea_YJ3Rmaa$w50YVZHaqR zz$+$e9`wMXsna3tGVFfn_}$*qS;N*}k(rz*nw5H*P1Z(E^UqVMM+-}&ZZ_Y@YhSE89hk4!n`)?AdTOD_S)D?wP z+HMJGHM#%$vEcqdo+}(&5l4Mw+iEyI0zt*8iwBVE}#GOLFLQfZ`iJR-^KUWw?y{a!c=M z0_74$M5wcUDE=&|Blrm0SB_w$bW02X8gBOULBqS={oW;z2^?)_HErzsRH+F+Sc;iF zOAXw>mU6~S2Cp>I&F@TeV;MxbULUC6m7K$ z)%k5cHL%I@s0LS9PrII|y`WBfKOD^iJF-)$PozF_sO@I+4`s~cyUTI+E^yV~9^>1j zxZly*-)Y60wkRY;3B6H)q(ScFNed#?c1jnlLaCvWD#wB+>#9eB9of(0hbtkh$ty8N z3nTWHqmL4yW6)Y1GLi^eA4qUfZ$*3eaS8uQllD3t~b!21kDkJ;Zk{ZC~ zNYk`h;t>F-CTlo4$qryaLQaRn4Y%Lyc`{LV!5`b)@8Sv%W#_f7*&_>^Ay~)Oox&?8 zC*U*v-!WMyAuOywdcgjD+lDqdi#lM?g~qcFAZky%6Kr=Xy((C)gekftynGTmvbH}B zL!DI57E5ChvS?g!G2kL444hPrtGUQ$gZEZk$HQ*>yN-BlK8M%MzMNSv)pN*JeKC3l zTQ>8t-R2wPVIY8 zHQW<5EL|5)FstQJXoyLD&-^1kPW=V-1^hZQyKYY4-I9l{atzvx;KzIpilaYc62<)$ zO;zY(sgJCSnw@9zbWB51Aacx4)9}c5)c;L&l3>AlL^U}e(oPQ>yvq9ELlQ~Dom0&= zPe|W_0lVP3O%I@|G;&ZeydBw%bcH2wHI690!Udm2XDrQVh(9=OLFfRcYSHzpgh zw~qvFaB`L^=e=2(SAObNcdKz>o@x4rO5T`f_K#XgF1;FGCd#}7USw1J1H z#)$&*2(E)=75Ti&jphNAtF#@E=EJ;?;X0gWY-Jg%g4A6@T6&YmFVuNbg*nKw>^ zu2H8xO3QO>^g@4y+L^95@RWA$7L!eP;I?}EKr^nzmOr7#D!{=x%IvO;tWLU=P`8g> zQYPwnOfNA6Rk`rctyru-n)=OYTg`i1t&(LGk7}Iq+j0<)=ll7=t~WZ-Zuo8ep6pny zvn!l-w*G02^@FD}88Y8*o>cMN#8h5UBH|Tx*IZBTzB}1pKI>odi_~GjMx)a_{FF&A*6RQoW{PZku2n>E z{CR=W0YR+vMC{F3M$te|1R>dxtZ<;A-(DiYp|!ioKIm!lPD4SzwC^@loVimZ%d9wd8{k%NQd*vB|H z=Hd4`eLkP>b^ZSAs?K%J^YwZ@#(m$9`|;)k&kST}r{=nMnL9qBp2z2=py=Jqle7@7 zza@2Axz1Lid}J0Dpg2Wkrd`#JfQs6T%q0_Yc}!mbc08x`=4A3fQ~CJW{9PFg+(QTD zG4r+tit*d5E1dQm)gjZ9_o<-DK&RNl9B|myfAWac^aZ*3btmmlBm(9=#j}2LL3yg; z??zbgQ{Uq#qWS;T`7-_%0<|w2Q?762Rp75Rdd+#C=&aH8$8p67NN7Vg%SpH0#_tZ7 zDi@$lMs3uZKWrqaH}ntfEk$B}CFHe-^zLv?a+dQ~<@AR*0(a?aOpU&Xbeagtm3)EN zY})c8u7<`u1B$Rv=KKWgBNda6!3sdr-9w&IB||HTK>7FsV3p+n7;%$xtG^&m5<6+| z01(}EfN`?Yd{1?)Btl5KxL1~U`lX)dzrMeD)ZD%>|4Sptc|mW=2Eyc`xFemaM=ilU zNLzB2-iw-_JhnwXb7;gNz3G`Zkurn&xp!pimKjg*{gq=kQ-SxM{w9o->Xz77aw}7V z09qcW2gwDzK;z2q;EYZnlAEEL$vx6PqkpQys@y)!F4rNq64Jk&TbSfBYto}#&^C}w zBx$LLA*Tg$o!4&PFYiOOoy$0fW7Fupqal!Dw$%!Mk}MZ zJo+BsKJrEh0+LD}*!c;@TxlME3w zO-OLSRDlSYw&|q>K$hcUGC>S*cbP(D5U=i(&?_voDF4vZ%e=_xKPBFwFWU$GFP9T~ zv^u;qiI(GzT~V*3tl)>DFCU9!Su_JrhYOidls*dQA&wlpZ997n8w$C-aBQp9940}h zwC-T;DD6ppj=+7^eg|=)(grT&$v2#%bl*lMslvRHTzdb#c>BGH&(S-#vXuGB_1&?^ zT9jpm5W!ednL*%)T;Tx`$N<@D#D%=3>-feri^GCd$JT26!t)8aX! zN|=FY78Tsww2_LL9o|V@lxUzpo*GIu-b-P677gjJpF^a>8GRz3lwBtQWwfiG=!zrqWpexh58sov#BH zJ?_BUk)Q`~agXlY`m&c`>?{JT>v%oFu&OFv#2f}ao6}%uO-Y~60HS003qLj|X>HeG zX0(?>cgRKo^AAhM$w!4eTeg+imVb_e-(yhD$`Q3?$~6vBcp9IMk;(GgqnQ(b9FuHE z0mwopmC*rS@kVVS9Fbf$4i)AU)sK*khyd(2nL+nnnn_Y1XvyXQO3F?RvJB36y~$0F z`KoF!TgO3*jsAfJ*-mc_NI`c0u>-ciIKb&ai)59{HFWt3PvKSiOD>9co+>Ivy0~mM zhmm~WwaNX~Ojr*@Yy zT2sd^;l}96Mw9(l)RvI{X#u+ZJ401u+jri`3GuNj?+=fgx2b7=vhL822Fl@9z(KSd z$bQBDm;G?;L5#w`U%k{7N0WQ}*co!i{dY_BM*Vsz?wg+fJ7CME>8Cs-SprpxSk`n$ z8K1b_STTCERa9Jh51<7mddMcROtN$DeKE=>mCC|jHl{I~q)#`5-`$z*7RWR~os+f_ zg-dI5rUB87lxvC$=%(-4E%vcQbp>W|;>{PLRirxS)vI=kDwWwvh0NpTS~JZ5 z3Avfd`5ob!D(9TGIqA!MgPJ`vwQfs#CB%)zBYO_0$--2%Lod0D2b$0q2LRdcXeQ## zF;y#pd^3dF=$nn&>Z{$W#s?FB+m4!hPa92V;HM<&F~Okz+YOorb>eBu3z>}k`Tusb zN}fnJPYo+c3E?w|zLF<3%fDTzVe!2SU8i-3NkId%)X6ZH@(-UGlfND$Ef z0!yZ3ob^vim;lw%K^(A|QUE7xe)QxBkYbsU;p*S<`0ru|0F6}BxR(#?x%3X#sq!cT zlp}F~1Al`_0BIi=L@z3s31IX+K!s#5d%7f(2<$9Pm&)E$<2io%mp~yS3bQA(2jerJ zysr`dBDa8&4rvDVc}Ub?SxbrD@U zR_>r?X}=x{TyQ&rc`9R~1d8o%Uqm1YNO37s0JiL-&Bhx-5A1=G+{?ZAWUIM(+8g7K z1IqREPay9wJ;|MMvOvgt3zV54z1fGi=(Irt25!7WJggZsZoWUi_h%=%yTdP*gX5gU zBspuTsaUM{(*t)HkgWe!E$feAU*cq0wm6`}LQ_4Rw=L)_E#aGQ<-9g}H1!sE2Ncg` zoSkfwfJ*uQ?tImdy22n6{-C`wI4_@;2VSmCxh#3#S*K6_EXhmmP!#G2Vc{M=alJNb ziT)$IFzF2;?gu=?CPy4~oDrv2W!?zY04-OChe&&qM>Hs01)G)tbPOQ$&-AiAV%p@w z@4bmtLSRJasP&+;gE+VMa1x31w-2@HbXbe#xDs>Q(A?kasFfM21nzfB#sT>YRR&l@;&ORNcCdA0%76`SpAuuvB~|+P&8aI*TDBq3pN$@8S^;gZY8IL~m60yBa-a5Mb6Cdh7zu|7 z=f_pO9y;buwa`koyV4tpjFIUGTE$UGRXxW^Jx)=>|AzH|Q$Z;Z4+XI9OoGX8)@kqw zkXpXyT;&=UK~6t+Mjb7YWkovu2yCQG{9ozYr`mQ-=f(;|Mgj2ro$PC*(Zl-^m9G)$eW0Z5y>buO+ zNpp64B!VU3?YhzdR3Ps4C%v_U2$|K&fAFM)(h zbKaREILaSE*T>8Me8!)0cZ8UF9`B&0T$BHY#m%dI!}(mjvc&?Mn^8)@CVQs>7PhsjUv$;sM?M3Q94?j-b>Iv8w}LZ zwj+PgNiV=OrQ!g{o5jrnXPOVrFdlM=v!nO3gPDcqn z1JXxrDK?iG%P43T^cjeX{;oT~oVP5+rx~Z$TPrMiXO%2f08ZvT7*G5FsiiV ziKMY&{Ccggd>Khbe3B^2G>eo!Q66=cT1$2p#@<)wR?wC8uYp14rDZ{;@jD1#^AgnXW=OB6Gx#$o1)X>AV*TUB39UzE!6tu zTdvr#z$SUUjS}vjRp%1KX{+K)bIH5u5<~0rYjvu0MU+8e>XjFa1mf zs!jx1YpPs-D*fnCo#x7mkVmxT*7h~d98;$Q;2+M-U%wx`TQ__OHh+{68((%u+v1NYFuLm#Gvrt)(cLRhvc`4NpA7;J0NJ4NKd=Ay;uVDum5Cv zXNp*(>?1h#R2r(j>2tj?MtfU|kD{EC;@TgbsM|Y^F{g^>IVKb?rvGdb&jiWjt?L$(sgk%Q(3e?bf z4$nXhgCBeDldKu5yGFC@(o9LThLS^~9X9?`8dMFl`g)4-(k(mzr)5HOK_bPdbL24^ zGliI_x^$QB_>`ObcuF+$Mti__cxW-HR4`!5=*-2QYs5TvFyU;gBzgNH1#NEm5f6F*G zipx;-4=WG2exCsSYyJ_nbCW2P#^bY^x7_OMEnr!(PT1yqJe_Ur)OsgaiH-|7WKvqe z^-+2_&2JYfTl4$$$uJZ+_|q@2Y$%+`$S=7KI^#9*E>=hSTLFumZDXNCQg|N-j{Gk7 zT~VIM=I{kk=3zr`W~(UW+dpNKViWG|^tG@H+2rpbjxVl}V-MFjz;wrnZ0$L7rXyIO z{FcpfEhZMaSzCj9k2*zsx3oQA&U45A;Y>!? zFm8YaPkz$nnp@Hd(*;=M9yx{913a1j{f|dq@b?^b93hU1bFx(9Cl}i!9CaO^ZKyda zj88ktJmC2pu;<`P-S;Z=u>6boTU9c$v$hZpHgo2ruU)f@y{1W_2`wsr)6bZ=Y*=y| zX=iA~L2Nr}51ai>Bskp*gf_vX_vC0SIm>ADpNN0>BlMMFj)6#CwL-T2cl;z*ll|QLnft9X*C4xvZ*|;riSCOYJc-c)jPDVl;{FFA8^4L(x2R^^Lm? zZfLD$xWaY*XBC|&M&nRy+U`w51p2&99V6Cd_sDLAA9EwnuxG%j*`-InS%g&GEZ(X& zpbOM1h`x&%M4!dcqhqH|juOM04f_@jT>71*-$?}nmD2HcJ;+JhB1sMR#Ur3rPo!8Q zE3P~ziz)5M0|xIpLEhh?Kn3S{3lHqJj0qaydYy_++!;n4gS@9>DCTq_JHOV3)R;?9 z`z1|9Gf;HNUw?SAL}v_G*c|ze-0Z`MTBroiU8FR9Mfy5s3SM^o4l`>nGeqNF7eD&mW=Rxy1N&UdN zAU!(gVKmV}zWhn@CfB8= zG{sIf{8L`q`xRp!n`M8UA4ZwtR?<)~f)$RqktjqyL-clxD)@wvXO>Q0fR2?PTcG zOd@udj#s^D)tl}j_wMOl9AMbov^y5p7wT$=Sc2 zW-xwb_;TkHyV>zoJKNkjpmEX(UL}j`=nQuAQwWp znmFZ9=uYFy5=!?=;i#bF1@#9gCqtaa9%p&$zb?}h7Z}&fj|qvzWDICje&1f)C%Xa7 z=l=uN+K}}@eAH=vnBEL|Y|HC(Pm)H7B|hvRWiUCR0wa-Et5NsXE&NRrd#o@@a)i_mK86#3o0QrmA1Vx#7=_KlI^YgZuZTk&R3M(%D zCVx!R+Lqz5&;EbE}QD)Y@O_;Jhv;8NT?`D0h=g{=C zK2?HhHY_Dq{aV?|2Fze1yAT?K!flV^6F^>{zR7d89lH9S$EnEV1LbZQ<@kv(hn?o2 z{}HZzm7a~u52Vo^a}Q->g+4t~s87Y8nj9Udcb4C@v}dh8JHIIJ<4`RO5_% zgd#0&Ikr6^a8)2fA)(gyQ=H78#am+&W?H0QvM!bi=SAO+S{iS;s-uo_GFeGXx*KVp zwayNEh;c)Ueo@+osQvovE^la^@@{pE%Xj*H%w$>Dgp)*cyTzCVgoUl+@#D{+&didU zsUr84b>Lc~-c^qoXbl1qFA2Fz>Aq{;7ez*=6SlHbqf4$*waQr~j*c!X z9n`(p#PXc`{L}}z(x5XMvrvi$)0OgTUvrCq#^?tj9&)m@l+ZYHh^NUMjgK9WGoTZ^ z{xxZr0>1ALAkmMvsd+BTTY-H5*gCib_h(LJ!^6(SnXqxdh57p9<#`J7NtS7`y20o4 zH%xoHL%Pl4qQQJ!(;RA_)4f8Zy0pO6n5DS!5?<+#Mo6e}KztQ+>be?gulNoI+EZ?b zv1FkG((6cmS;4tqllh21LcnD5VGj{-)*$UkJZA2nxG3=;{YH~Ff2};5ST%@LF?pR< zjhobxvp0cob%V1VM=J4V1dEDB1vRyYewy@@p79*DvV7A?$9X+9VUMgj>dv^EMAmHS zNQ3Gd-`4;SK$Lor&c|cudg>SVH!RnL34qz&oqQJn;$7YTN(ScZNknuksNdvGQ z`;`pj>=3&;luu31R5JO^;I)1JTKX2;b{_y5#9Em+Z>Bz-ET5d`G9U|t^Q08fMs+qB zl-7`pq)xse=eOMb>oN=on)^|5y3rCJ={`yf{e=`{f6^sX+9j`1myWLKDUoO_1sq<{Tv^#|KRHsEq-SF;;hp z<&YIwwoP~0>JVcQ%s8_8A&A75Ajs%kp5ndOE-@G_Nog3T8R~rsRwTTQne5vQXmT}4 z>3PI%FFmp|T|lcu(`V-O$XAA|0oU$FSct0fdBV(br{u9^OTuQ9_%l&Em!-hO(Tv1c z*6q`%#>Ucr5^o}_PNVjGmSC`>mX|5Myx_%bco}3>4)lzME0s@&{T&k9x*pHEx)yHy zdOBttu7M~c=uCOYNx9(@1QSb#~#aGQah>Ivp#z|1@O7GnU-&yrB?BySK-~(Nuv{t zW|gJ<2%TfVm!fBR>v6ckO4qzJY||7gaO4%6eaeroW5B@#)>1xpw1{LhxkjJ92W{b2 zVd=awC=U`w0B=`S6 z=bc0OXWX(gD^G1Dn7_}3LnLXq2_414b1>!2tYTxcUC?e}A*4)9OosCAsWWa@zF`a< z$z-qEAa3_B_z;#M37ZXTY(zOrF4bz*W!)w`4CxqM34IpO$TIkKLO5Kc+W-#SG+p>TcJxA&F}dx#%PY7s(2Un?(aSaCwXes=sGC+#3`Zm zL=Xmn!1 zs7KnTYK5lRl-9F43(2U*F2Dl6A~IDdp@Pg6u`BZBvQ zEgLIllUz1zZGRX%5GYse_cXr@=~MHB$UYV}Jdw-7Ur&8|f=klRa#?~FjF@|D?kyt` zmiHr=1u7ngEni6^j+G&*fvHSX+=jHHh@;$w=o|DKzpI&UEf;C?d>Dxwmi20JRI}*- z7rI*PSW1{t|~Q2bN86>G9ztm^5rUtA;8}AK4S{QneK)}m-u`4E8?pi)?zoe3;U$ej`~iG zJ013Ft@eHtyXp@W9t(k4tOa%@ul{*|697QX&+F??3Bn`47Ao@q2KbQR2LK*=ES;|% zN?!T{B2eAfa0}%SKCg!Psm6SL!RehSdCLdyF`FiMgm zJgM+KFvE-=7t8+C?Tq^#d`>(cXW}rN5TH5%j(+R3jz3C zzP4yvR@-PpE34Q;RBPf&?nOP+L6Ge;R-@VOom~ajy}ba0mcuMdykQyeyBrsI(;2#> zxkw2i9W7oE_8tcU-`W=@bJw83GfkudVNrNAum5~xx54;`t42Q8Y<1-l{2^*>U0Mm} zDM+umS2rfCQ#Ys8LTR3XcEM55p8GX(MiNrIw{IPe-UllVhRe^}iQesGHTM;2b?OmY z`OJ-Ff#fe&$-oMf4z&z_yEqOMg1YA?sToTDm-jt$O;Z$Bg2Tl((@GCPuoz4Ugl(4j_%wtUz9R9}foTo895wZ58I~1_uXvn@C@ykV`T4Rk>d4RK_4&Z*d`!JQI-Yf}(oYAU@jF?3 z0TtfYll2j>Vw1cFW@OA5iH|(U6`{fF$S($GM?i3`J=TJQ_@=p6zw^1RDP&d=eunjh z=ex$QcQ;=uSrkv;70KU7Ef^ve%+#Ep&FktkqVxcxVDcCW{4_!hXw8k*&iej=%GrS`&0u`3H!6(81eD3`jIfwYCkfPdRQ!O1P z-|uy^d544*c094viI8biN5cz0II5v`{7}1j_`OhwRzN!YtY0EKU}Qfpv6#el>1gDH6T(f#Bott+e>ORumn)~pC~0R1XcFY<(W`G1X5&}m0mGqwnN6({|< z`+}_Rn4)C$SwgnR2l@nU05EOzA{$N^_7|4}9MOLInb0WYy5r!4lWH!_e`Ax=QLK0cRjo{fhyHrDKCATZ1rways4l zu$Sc$YpWZ@LJR8^R=-_~cbemTX+`kl8~nH)n#l(8&`3|9Q|SF{phiyVbbM zG7DH2xZ$$?l7@0t*$qH)$tp>xLn^rBt%U-)MJC|Mz$sP*0Cgg|m#z-fIvT-?eqve= zl^Nvk%yfYq44Qj^jX0P7+&p56*7kUkLg?S^*;;kGpl3N_aXtd16mOqUsb3gYQSJ4( zWNzVx)uxdi03KE64EJL6&zZcA7BLj(Qw#X5{`r4efc1)L!>pMXj5mos-^2N2%bA%JxJf_|_%owzb0L67|Lw%53y>Q28i92iq!A-AZ^zNdQO?mQXnz`DC0&bR zNfDq>kv_O8@v0rI43ET=Yg=^TEd^;Q^M1>d%O~-L7OKRo^|EHAKcn0}bAb=sc!T-E z=6@DMbh7kTk}-r_7>Oo7IoqF~0-q5u|Do#H+`4Db2E$@bF=GaD+SeiFk|S5rd#w1g zk2yArtX@rjY*O=ECklR%=h5{EF=vn>JSk-Os~CB>WKeq;mdBECcR=bWPrT6MXB-2eiv7zc$R` zmAWA9tjFM3)G|U#U!y6ln5}tRv#yJB@Z6$k^nBY@SfHOV1F~O7jcJ7ZXvH?wHnDi$ zQ(m~viSw9T^XiRdGAtkK+!0dAz7INz4WEV>kA) zRn4YNS2^bcvt+g&!Mrq&-s1zj>PZPqJgVGl|2hl>DtWdD9p*%MivI?8(SJ_^)6M?l zXFrbfmkpvUke{bND1bMqp;UM6xUHNl^8B;f6)cKa;pfGpj8UF@u+9@5_8hgQLFMy@ z`zFyf{V~RJ<-uT>>0FUk%5lrC^Bn8Ig)riGlCOZ>{tHo`Ti|8pN#~=HR^p!W@o##X z`fjz112x^-YVL8F%U)ReB#T~pAGypizCk<^BE}NQOJZSE#8|TvX)HkAXE=%v#_7JQ8i`UzHQ{nY3iOWRe z3{Mm@L`UqJs}1D7^XjTd+vfXWMT7(c_JdBrV(PYtq0qyYfWInY|5+agt}Yc#MR9! zooJO?!yh!gVm->?OT3u&79IvEZ-T4&_`vw#qcsUDV;_Xsphg zG`m%x_Wr88`rhyy{d9br{?<3;!~1&mg%Gcn+b+#90?M;=++DrsfFS{M->!-C6@4(< z_U&ks5JF2^@mwM>TqZp^Y}OLo?A~e%Q_Y2}hIKePGIF$j;>_r2ZSVLba7f67v()(F zVBl)xRmF>VRn0GN^gua&=nx*C2TI=rrotgUUs(p~7#1J9jK~y`Y~YuU_>Va(W$RlF zkeD@ftt#Z_vicm6V?4<&+_hD+>Z|Y}1-B8Qv@$`uT*dNY zC$SllwqfZA>-fm#!qyXte;8ZQ;lZ}bDGk&4kn!0*X6r?*^yHRC0An(fbPfKOsE^}x zVW-@$&%3d58r#)!t!bl!TIm)eH&%nQW1oP5!u<+?Z{jl;#(Gp^UL$y`(@5=083KaA zg$s-q>Gt0gJgCeheJ7JhUn-*&v0K3SpuNlM&&Fjc3h=yGdX*Ev{2>PAxE!|);JPxc zjGZt1X1JncA#N|O{k!zpFzQKK;%`P~2bXo%8b@n65D1t-UZwYQD6O-(JkqfJlRnlm zZuDov48{G)XRWs_kqAa<+gG)-u5FA$f`ezZEW`Nt;z6#33nu2jJYK^#5FrH`BA32aK=9XdlIAOC2{_K ze#MKF8>Do@qwK60oLEuUW)2Ywgn|%p<{v15qpd`VIj8bQoGkU_DKm6WU?OrijVcq| zylRhi^-*n)OtTBRyltjbBEmX+rSpBa=E3%n7F&UM`-09MXWz0a_v%baz{=15yTd0P zH(9H9PVW?NlG~sYVMCIJd3NY%fBU_1nTj_R4`vW2`0j$Xj9(eM0K34K%7kW2)rC)f zOxL)Q0qjsX!4w+N(JIun)-N*$uvxcJgf^`J)9KpgUr#z@P%%uFZwKXp5Aiw4TSPWdFgKK{&`+FM_1+1lObqJsbtwmCo9U*&cg#2bAoJC zjV2b6eQfN9Zhmn3Y}jGpkd;1Qef2Z#lw7t8nFguBAWqhR7iELsM`m&1{iS|cGaqB! zmSmkh=5dK~bK{?=pGLYk4tV_L&ecNa?dq{sy8o(%V$JR^h88Qt~`I9 zF(Zf_Hh09M?RCt_Dsp5mr}+;i@XhhB54VcioBH-l|{4)l$S z2}K|=I#cycygD#%CbY}`7@;J6armp5^}`T^)`Wo~ZcFijnjD1DhoSo2qhxrkNVKaz_k|s}lsV;2xfGW2@coEGNoJ)c(%?mPz5a#NK z1sT(Gt!ozop~ITw{J*BIHaCxylx-P1pWW({umq4na_&>v#M1uOZeXPI_=g5&%W zA=4I%wLjZWdWyAXeVjP8#TuEpC)b`biJa--k!AAgh5l5Iz~AGMo`-W_NYyjh)|d5J z>@e5du73>nkmT4qZ!*Uax1a-BGSx!uKoOR+9}3 zr9omwx@4>bK_a)+oD_W0k*v6um#pr%j`G$(p3X=yp<+86r`ea;9bb2bvqCR z{PZghZ5*;VZ(5c6Xr@QXHl^6iZ0r)#aU-*tqRsE!jPs_{W{DW4 zc~aW)#ZeY6&pi&?F;fbBfn~Lzbw~{5@sj{byoL*uQ!ixy?WtyFVS`CqTOc{zxOP`{ zc|aGxoezz=YZ?V!-_!8WRR((nCXci6N(&voVUIoO#?=MiAVgOGYa6pQ-JOe(igiJS z+%+e`ln%_RjKLloSNCweP-i9;VwT+PBXRWcdKpGyh)M6y6D~wHS14bU-i8<9t>_D8 zO};=lF^BpOof#UyzW`jWMJe#%_`qzWio!(~T}toEvJhD*w}Q91-QfMNgHzKWX?~!L zRj%Dw+-5Nmm@Dnruj81Ac`(b7iA}gwmkym0@_!VqAI~!EKQ=Ixp&xh9Cxr;+Ga3r4 zK~V_~4Xm!BTm}B&b>w$-OGWz^~&RnH!a{EW3ICJ*IDRRb5b@MUJ@ZM>GO9OW!a<7~Y{M^AgO6O}#Qe zY1mlFUwT@d{#Kuh>hT^ktvKE$bj6&J`uTPe=|fq3MG|92t+wVl057G$b$_1yI`1*U z)4QSWAOTR!V7&xs_5%+rq7NI3l$kXG)Dx!NrMzarj2rWz=K_DK-4(t_NgEFUY*g$9 zguM3e*yk$eII@|9UjvAy$I@aoLPSsvf1oGRYj?Y`o5qgMoXT5ix;OcfqA^Z&G@)ev z!szDz_zOWvhP%7KAG(FhBH#x5*^+^>>_avoEC*?~*bd%=f)au#bs#w<%|Ie9&`Xlf z9#rRZ{beozG)wm>IJLeR$3YEwRf) zGT3~vFoS9hVt-LDL%gS0*mY&jtheQyporZZ8jq>)es@NN2eH#^a2YmJ@>o+&WKlA1 zcExTYQU8QUuL`QpEy7iKYO!N{dj1)zJd;xX)$mS-=6>lc$2cI21&4DwO4g2vfUVeI zE87jJ48dn};Rg>xE`rOnQaF_YH6tx&5efuKaC3a|(dgpv3Nf{fh|n=F6v=5<2rQO= zILz1idH#oE_Ei8ru{e%Iy=R5Z1tpJ)M(3gm8&fu!{JqZXjtK{gv|B0V!Ax84Ir05| z+kNOe@^d)TRYDW1I0LGSQf4lYeLDyWvw+bc0&^vi;?75#J5#P_x-2#Fa>2nj;QP8m zqUj90vcFvHz3OJ2jML|EaQpEagqMaxv`}5ksD?%}@8zZn93^y!sVwWZkEqBZ{Y|;L zCIQ8j3&M$Sx@6{Xu!~?qPH9VqQ|}c*1!0gGGsbB0W#J!;}Lzt~~!hEwsz}qreIS>R$%x zfyainU1E03ryV_oyvo~z241XuQ^@e}?JCCFOaBbBw(Z00Gt9oO%5X}h)i&53#Xvod*)jA*BD5GY5e&CH1t;@UYrmi^3Z zZlYE3c;y!sT;KZiCU@O##4SPCW|rcy&QPf{tiMJ!gCRchI8I;5if#Khi`OHws8DeW zDn#9B-m>>D$a$S89B2@&t`a^BT3NHfFY2))F?RGm&sb~ME>Bgf!eYm6HY~(22d<)7YU<@3cG|r#0K6XE+G~5$G-Si@X~)KB zH7(lngLXJPRP%V)T&! z;a!Qd;3gCN@&&mLdC~EAyQZkuH9Hab$=cQ8th9KzH6GwDz7GI| zF1@xqGUk(*rL?hMHwM;NeLkK6`kdS;=LY;2wfGCFq06&?S4h~++I!3{QTh}taxVY_ zC`=J?dN(yb*jvtRUId=g)GFZG4s3RABpy^HXTLYu?z`Q*7@RrM^U|ZG@g)WRiGv{5 zimOEdAUC?*CQ1Co6;^OKGynEz49fJcwi?Q*uy9T}g>q4=<`F9QXr%0XJ3fQ^;)Dkv z1BraPH;Z`eDK@u)9RE5l&LqN|nga-=VktX58KE@;_^|H#(UiWLlkZj+HVik!I&4+F zPh)nJHk98SNI0K-9tMu-3zPE{5RHogWmfhz&f7x+^G`OEn9SeuiZ9VJOs*F?YB(A# z2y$I$hqi~{h6JG{fLB%NVp}0NQKjLm8!mlopCfvI)qRXhFc6y07two>lApHr=$c)q zSuA6xlVs{Gb-$CUZ*+Xlu^rDeK`((Bv&>?$ezK}!;kr^PWp9GlO;?&!XYZ!2VvN3R zBX3hO>(PkmY`o2#y$H`i$c&%Vclm#Msn~Sm82ShfK3;WS1$xG;8?dtM9J}5r<-B^n zWU@1|AA-y2n{1qguhO_cjtLO!Lf>T^rJh)xrkQSNRV~GY35vj5{+?kb?wVMNc*L>1 z*BG!6u7%f1ETQIGHKs*oj>$av#<|D81%U0Yc2k6w{Xmly@T!HW<%^BmFr+IJ=zNWR)d zS&ihD2aB^u0glS}mu9{a;5jXqg?r$Ub@yDi{@q4S@1dt{>k+GgmF`lF14c2+Iw2jW z=~=he_LUX+t^R3#^F0hc=v@0#XoS*ch|N0ZT*FtV)5yom*BdxZ*Z==CJ95>Mv%qsw z)@=J;#lkC>xE9Wb2RE_0XHkT;+iD>^SRr=tzQ%;Zv3Z9mW|Q3Sdo~&JdDM}!k>W8E z_w`4`VMpVh+S-=7F299z{Jqja9M~D%StEE*R2u4i;&ONNaf`>Q7R)!Uc!$l@a1loW zZ#yWp-j(<<%Ie=Q+AS7Y2Re3#r&KM&q)I`+c{9Rht#Ih?!rO_n!r`ic(?4#dmM8m+VMBh}~0l)J&7^G%}p`jPIh;+n<8GkL!+ zKOJf;Qx~q@6^{*smPLKv`OZA~Y9)EE>u&PJ)*v^Z5~Z0^s`f!(g7% zTj{SweKz$1&OD}!S^MUHx$7E`U3_tKQ`Gb~Utj>!9{r~oWv6bZDG@VSq$Woj!CMNa zy!t=9Vymlw%iWTC&Cc+(+Pq3ACHYjWrm*b%O_)QDUEcar(TQ!>EFttA%zTUp0GoqE z*OoFkEbpX`PIc8=NK@ihYAck4eYd8O9}sV|Ft4LK>N}>SP7%ki^a7>)xWo)3@mJah zeg~YbD|Bcyw!26RkUC40aggPM`2bo~fQ4x9o~;@)Fn&p%bsM1#g=YhfvnQsXqJ0S6 zg_9c3W-9 zDFm!%c?u5z3S;-(R@1@b5ICjWhgqB80~^9Sj@+~L=Ck5@K=rC`b*MeA0GiBBO8Mm& z7Z>O%Z<2-<&%V{ZaOY;UwRoy=opwId#t3UKs@9wz|5q5Ett9w0tE*h`2femt?{<5s zF4K-PjESGNFRY!;PRv^3)pvJx{+TM-HJt=6%3U)QN=)Fj`AQ=2Mlmrhil-Gzy#-rd zFH~@taKFLSC%|OHI-s7FUS`LZ8|nquNKB2|a^_9nt||3Ayx>=m#ip0~R5~vO8Qdys z%-LY6IHgf9Fxu?#M^^(iYpXWCgmvZe{RDQ^%4CS>o|8gvW%aVDPQ2YBsz+C;7SbVH zKOpOxYVsRpi%0`h$~*U$Q>EVo@CMN?1&c6|e3Xm>p+4`l?^wzt#|G3*4PJD5tQ@6e zGLFC}+Us6Cy2)ug5L3|{=`us?>ZGsYq|f*JR54;!NomKm=sWUQ;3b*xs0Q5J1Udx* z-;H}8!1BVG7!;fL$`=Zn$idNy89RXvpYm`^KFA{+J;^n3(@i0JO-@KhM|~rYCHxv(!^+>K)EkKA0gH7t3USB?wMAIUQX4Y zT*^yLEW8ZfPU#g$o1e8wWtr|j=@1mBtNSgU%)Y5%>~4fYs-WxT~RBUASIEWawHIey|Rx0Ie#%JpIsUg2J}hj1^sxoNQf(VTlQaWXkX z7Ic0}w*fSu-^f(m-Xn!T=!PVryJW%|<@Fgurgl7yegmV%iA>>d+bFcM(jibm_h^*{ zz-w3TPpH!VH$6Vin|>qhtmY=bF$R<#C~Gjgn z&GtcDl_o+g5|Pwf{vA9e&{k*0OBC0LS80G&=Lb0j$Yv=gs5GqZ zjHIb|dLy6lEK(7++n5cdQ`0D5)hrgg-S8u>= z#L=L4;*1vWi?5!#s5$qwY~QYo8M3Xi54h;x{zJ@fBa=rhV z$ETybI?Y*td$ue9tCD@F#{gry7a;ep+d@80nf}Db=I^l=teNzc(}&aEDea$;$ID89 z;leS$+1-qHy&_0$>p}8x46xQwFZAaKd%#Y(`Q7xLGg|I{;qncSRo6hC{IK4-`QwWP&xpitDwB9;f(!;-jX&S zw_AD{I&JRW%{K;ktI56fsK49azO+8Q#ajXR*TvPpH4mq$q?r1(<>PLtoXV=_TW& z>$L9BHqL$gDB4Z-_{Cwwin2a?=E(=Gz)>EimDFlIoSk{;nF{!6LC-}}uZgp+jOH*k zd1~`rWxupm1xI1VaqgO7^YP2$l=LHWfYhMpq7{}qT?MSY{xPpa2b`0N$*%RsWFz*l znOa~jr)W9DIF5Mf&d_E-s`C@+(n#s4udkh|$L{gGba#zy8Y+Jy_dSd;-1&yNw!iam zPT-iD@6Ol$RWgR++XPPqMqPe2rn0)QxiZ%D>--DspD4d(f7-@#CMIG~Q2F#iB1?MN z=!2yVpcb*W@OpwFn+(J)U+25*VSIALj6@ zwywA_#!9%`6-)b=H!`}6&aY$U=Xz{@Bz<5Acb0z@#W?FXKcsIZ>+>V|zJ`_ES*jNM ze{OR3lZr+eZ7z%4`OiJvHGo9ckUVugsu3JXoq9F4ZqHs-X5+0zol}IXJe$o4^VHDh z9}RhGEwc6Fs>3b7=TW~JFp7U^e!9RKtN$Yw^#7>(^Kd91_x%GdyD%zc$XH5I*|Icd z?2;rIl`Lh=E@K_WPAHN+m2K=>vS-U~$i5B2*!MA*vG411dw+i4=XjofI*$CMnESr2 z^E%Jh2^XtAvG6Q)bO9Pdw1CvalhF%vR7x#F(9ymJ1~&)VJHXsrUc}UEk;Ikz=kru_ zZ9v04;{SO8Vh74k8_M$|YM>t!t!w84(Yq)o)|8gu#DT~{+NUg!zgyi<7#R=^HF8^X zkl`rD5N(rwAB{Obe6lkZ7-4xxSr-YXwc#iOk+<$}a#XtwuX%X3I~4TqB#&GwRpPCa z1U~eT=*4G-0s3n^c^oux^0uw&@|p5%k`pbfzg=N(f9?0#M$~Zop&?q`nxHo@U`)zA zj}s39cb*u30^~)fd%MhgMEW8yIk>-$9J^tY_px5S*%hGZhBFa1O3&`bOqktQuhpgh}XGK zoYqW%BG)r(fe}`ChMFJiXD8wG_p693#m0HTLZ;Fkn6~4OEETKhh2G#FOxn|YF&CP3QZ7zj+jNyCur4KNtT<(9*wu)_honUcQbJBx^lNKAqLL@iMc>2 zJwpzs1Q7k$!71e>b8ec+(U|HY5jO`S*IwTarUf}w?T)Ve0YaYmn3Xl4qz~R^h8I4u5T+!@L;3f=0cM=L(CppLB2_;}u?q2}>dbm* zrG)fZE5=;7BEl?!00cz}0mpl9rW?Zwg`exOSd!apyP3$1UK-OvY>jK5GC1>Or4UFW zw-=y*c%;agSd!O{yCenK3)!yt92DYl9t6)nH(=ny?W%x}K3@XvoNL4B`1cC%<4KGG zNjMbw;TfTMivN@>RQyZy)G9XE*2>a>nVz^X_8Jf$7*rv|vS1wWLI`1Ic$hsbTEHO# z7Sw%_2#B>ynwf|bx8sfaETUQB&|jNWOWw&@E$5x{EG|SHQ{L3x(0+e1j`RfU+&rG8 zAT2SQi0%Ez_Ut)3UADkU<-M5BNJ!bnt)|MTW@no_N>04>x0;rm9PK)nP|ATQfq?7z zwF!(n-RF@mb@o8R0j^7zvdm&v9x4Ztku1O;-}J&=B-xjfOI&vTdpnPORmic7gc*ln zgV6{IGOU_y`?;^rVY0beItFEc@v-baXq*FCq&T|0IetEi(EI})ZV9BHnEBO{ zwKD{VHZqUISOO34b>@DgMhx5VtdX*vrhklrjR6vhKKajFcsq7H<`w=cw|A*rcl_$e zk4}8U5P3xydfPkkNeUi78^j@Af*A@t0Pf|+TB3YR8_ z7ur8@8ap~bL8~4~+;oiU6ek)V)Q6D73A=?)VZysKoxvakNN-yIc5L@BX<2sj@CEhJ z^%k&ex1W42G31k8RGq5>KAl1>CBuS#8hkM$rvB_ivig|*|=PI zHjuoo&tnyYNpN0jRP4BU8IniZLYWaRDDyAqniniK)HhqoMMDm~M-V(WRqkV5LH%W| z_3eBKd~T3hDrTn_tUTOCZAz|{Rj3bMr5c;gF_o?o$-oQ8%IDkJrSlhi$*!`r4Mq8v zW}#yE&Opz%u`ELm(Txit76XLamsKxcc1OMUIax6>%CFh%7zEPbaU}K1?slCgUEKsT zRE*}ktg+@wDi zq-K=r@~hhehJ3z0pb{fO8;CbNp9&Z}RIGLhQIHJZci8)6Zq)DC@Kt2KC^d?{a%aow zt|TYrl$XRKoWI9N0@W`m1*Mew52f#_w-YJ>c~d)NQW`1rODUp!uvGQAOX#YCKs_Oz za^g^cJ;r7i{=j4WR4smv^V&KvS4&aX9`JmXed5uuHRsg~J^XpXD%G2lofHqcGyzQS zuOcx@BG@b2t1l=}pAKRSxAAik*|j%~YSLZn$hP*m0O7$kgED;x$kvuFwg)blYV2;E z$CS;QekVU(GYMI7#T}E>(qtb7=7l8YRCjqCK!G85^*G)&rxtnzNRqWG^h~eb*2`v; zF79nuHX}qMr0yj;+02by$fK;^-t4^BII8*=F*^oiWOD(TP?CL$eKPTvNmJ7W>49kH z0k(%YqtZ@RPFC-IO~zI7O2=rak3ygwL)h;>RxuEzn|-uOf#R~-Wg$ud5w^gKyVL3x zV^~590Jv)z>}Tem9W`DEa?EOTD4G|$j(ilDI$ZM#qnOoL=jY}c%Gh~m!UKH$HjQFn zZRkrtM85x3a1F#ji?<@&#)Sv8hlso`lsyHyp=4ck)a)uxS~mZKW5C{eAZ@0iLc8!}qwf8PrpD{RuOrefx;$5D%@baoqcXk@u&6AZ+cV&| zP?6}PQO@jnfKnm0;P@K-BCe3DWhoqz5M*s@THuu4PTq3^^MgT4$gbqG<3*N2Oji3+ z@LY(x9KvEKz_v1!3CaQ#zD#8mZaESkF{{9>cZN+_cg;&g_a{UqaXWC?k)I2K_Ndn> z(;7a91M>>@bt-!G3#s`DL4-M?=_lHDa^YAf0bmy?Exh+ma z6hsBFZP$H0FMWBID1G(j)OgF2$%SQy3-Tt2EBih`qUZMF))LKVoG(>|W7on*oA9fQ z6-^-N3`!hJq!M)Uri56db5-q$cvuI0VM<7aMFMc^_51o z8Adf95v4?z_9C$ts`iRIleWDV}6`^%XQl zZ00(gr#)@_&>>)*&eL7Don_y%y0;!jh1r&cad)J(rpV9Xll7IE9y@iOzs%2$hNY(M ze#B?E|C7=;#y=Cte_gxkQkV+_+V(Qt$Ne-?#f#tv{fY1~nKf_307YSFCa5^BL&woF zxaOXY(w<|2$lRWppRSR#{s$0WTX?uquTx57g9Lh_+&c`+>{S-)9k_bu&)2X`U?))9 zeOQMf{Wzi1PPkvs|COOOD6IG9HKdQ^)NyV`jFf)6pB~bpMb_h$yomD$)#R+~0neqT z(dP55`jFAPnp}(M>G&lVV*nLE_J5+6LF#!Nc(d7I}nO3&9z;9UNB=G?QK|sla3?7t?)VP2x&OIZ#n`pS_PdpBOAsTK z6YXgqZn3C|-4>V136PKpVH_J|ebVkQ|N8#PE04-i?8V<5!(deL6Owr-kVGe2DN%Qx zIw|;KqJdxVTn*Wmj(i>mU7EkZ5&MXp##GwUHNzs`9e+=12j7j@rrc)v+7yo+=Ci`X z#k9pkfoaE-;tE3moMLTio%#J#Hc`+ndWlwkUr~y>Up11Hem><(IU^%vcb|siG2d)q z=N(>yz&;d7%4Ptcn*JPu67H`^o;0Cu$)*eWp^0s4j`1q}QMS&PKrFhdgu3mtZkV}D z9gitj^6K?J(;H^{G9w;sVRKh#y|YsoE}1P#_8xU48cT|4TFQ%T>+0#Q1oQ?L%3Y-J zXGQin-S#Axoeqs@+&H|*z+K941b|OqK-Ea3@s;08i6Y!k>U57rs>kiz~lDWIz;;_*R{Qi{~Wa zmDo*w??}oD8IKR%E}+_T)zk?&OB8D8UYebzn`WtLac{rL@$xU=&?XLl<|vrm`+Fd;_Q%%sajIT;+_?1;@LF<_O7sHESI>fO zwSiv{=?eiS03Y0{&vC-}o!5hi81%@u$cUSRD0`CD_mh@lnSMK97|^Zz_U)k4FUx)a zNj-Y96_=fxSznv~)@yQUFhs@M^%DsyV6qkGG-77Coa{|%<`(?PYUE&1w(aWrx--e$ zW9UYjf;6a)FyvahVla3PzMCKKDp#c(3feA26GjS>{BqkL1A2<_5sRN?6Js*Ju!bfbji?9Wqso8@d-mb-`I5?;|_wnzxC$Bn}hI}WMk-^!S|R;`^LMRQtMhky>^^JLb?JwC3U z{pXE*PK+56soZV6rQfrj)H`DXVK zRf;qQ7!gRGm-T%n}9HWxX7!RDckZqCfhp~UdciHOTF8i2l1xW@w4hq&QqA%ikfLe~0 z2IOT4Hi0$GNgC7aLD56$J3*9w(Vy=vv$|ozF&|d`DqvmGFf$F8jTHhd8 za9M$$g@QDqu%0#db);b_8bi<8W;5uP?7zj`YC^X$|4AGK|HaB){ydPEzk(QWQ)w<&?rj0up<%qjOx3o5Pl>_V0}=I3dY-n^oR|N!PZfBQ z@K!)HPt@x@moj6bf#KrTB0}0Hh%qb`Rrde?IBAGC0hS9x?=lbajJ!^Wh*Y)5X+pJj zuh;vNyTBMOizAeH|RSR&W&%}e_VN7e6UgEUi`F~Bf<{K=DifCRI6Ea9w45_ z&oW3((`bBSntbA|s1j}|!hSy?#=Z``Ny8zt_Dj6}SR|rA5en6!_I7N1w-`eczUSlx zoXi^PgOvbp(Ky(DGUA1)h|=M8A2P5u=3MK2eQeQ_P^%aaDWxLKfX zoG=%?m4Bk~cI%$laH$s0U&-C?%PGjyN1L6^cCty&N8_`Fth3F@!`k%W5fzRtf3{~s zn&k#UG+R8%1*G(R*vPNzt#o=BI`5;(FF1rFb{ojS17z4;M>J5v2Dq3i_Cn`Gxf=rV z@J3V}^d{NdqZReQML$|@I)uyN+A*PF^gJnj^=Wb66Amrzv;9_0gMM=7-tX*2@nx}~ zeGjf$O1s_FrCR`)&ew%h+?u7Jh|t?lf8Hf~#j0YZz$iy5vg`Jhg0aOd)X9uo^|4}V~ z`$%WfzFyh<7qwqt5I)@l-5I9cW><0xntbm&6UV@%(YQ^i>RAr^t;U}e?$oZ;@(C|@uY?~rNIGOmtMlPN zC+cpsZd)A!(G?;M{8l~Ie)bu;KuAw&x}W@YmD4l;+E{0$4?91i6miOiG84GZh(&3Z zSI0pbRU;UwVT|MTp-e?WE@7ue$OeLR}+-?V)J8saG zh$_k_4zDIa_OJMyw~F9>17+Xu4wN$Z#SOF9^Z%-jVH{*BeQ13_e0mXzO);u8B&X|n zFF#sC(vIGF1Fo$|cp7*s%&SZTFfCiO4x-B{&OHx>*L`c?*Yzi|mk-+AgjW5@!FCmZ zg2yM@oBEt$g6K*yxFfwV?s$;TJ=zppSn->wkA?WX&SKG+#`;QLHg|%liCuq8W*;x4 zs?D%DaA)ew+vrye69W=@lU8VCv2?NWfRLaAV0Ii2Z8cuMapN!e=EiuSp|%>{T0Wrba& zOrfTlt5$wqF*dCV{{_CJ7L!~@Th}}aG!hyfNSvU@yx&z~CsNjS2c>A-_>TeOQbz(E z@cv>RAE}D}?>HhJ73cgsy!i^c{H^NQvS?h?O(n&scW3)j36Ve3L$5Ix!1*0S z2?#1tS|ZPdYKT0CJa=Fa)=g#8!ryniAHXh)W%OQS2L(RQ`l4L>w2(>W(1h*DGkMH? z{>9ak$ai@nG9fdbtMj6X@qp+!{P};|nABAv^U65`ud8s-NbVB#9 zPitrOgN=JN?uL^Zg(V(k#Dr=-+6UprB`zZ*rxZ30{_O0XVWWB}VBZ!%AP&l``qD~g z{$ks8G>{gKALvyU0j*IOF5B#d47EfRUC9FV;33~_F^-?nC_A)T#hV>Ksn@qF(Mp=M zty|BteWmtmkyYr~t_jrm!SvFuG~8PD(CX5@ClEBgiUdt>;$xTAMm?Bv(I00%Y}RYY zM6I*jZL=deaO^ypfR26tP76xNGZxyl442qe?NN36>qBzx=>^DA)^jf$Zs=_S)E5J6 z`;nU&CsESPEg)%H9l6|FHHwqL&(<|n{af1%zoN%e%v5X5WUP5 zmFqEcMTXL*FAdRP+!n=H#!_lysUf%XI*q!Q;*2Oc9puI3N!s|UQQE|A=%C#DS_C#t ze@exS{U;Z1Yxk=tVJ%nS@L_zrcuN|gdf{x>k!Q4|{Apd1aveF^R3tP^WIksq&@0Sn z-moB^vgNsHHXL0A*w%^C&euIIT4re?;14+qcs{7V7)kQNWHzAk+p$wGqxX5$;_r%R~Pj!F^*q0Iq)cCzrN|A9d+I)lDUH~0c|!u$rHN$Vu9&GwAN!(WN6 z!}_T1?Hy?&XV!1~+XKKqv~K%VT48BtJmuj}sQ=Vq!PuHbbzJXpV+61?CF?1q*0x`? z;WQEvXFVDv=24BZ=W_X$dK}aYLeO_<{8|auX#{$bY9odk3vds zM|jn$tNa!&M_2fi6UrN|YZ@0Lt~) zL#KG6npFx%qdbyhxx>ZLZB|;>?egyB?IKP$nl;(XnVXzy@S2_(YouESS1C5wwtbj! zq<`P2e{cU&5nSU1TG@R=@9EklM?YOxh+GXaX93A%EN_n|`j5~uz7_od#Ph>oW1K1?tw74SjdJecu%;#m$IG%8i~ zR`V3lk%lXv&7LMT1H-qum7Ro0d=u4K(FAP0E%gaVA#JSr=B1%d!i}KuMKJqvGy&!S z*|hfUP<$!ZGyj6-DB6FuL2@|K-N&Wmo!{b6?~JwZ0AkwsbnCjs1J1g6g;$3^cS7J* z!-j7SEe~or<;96_v)Yr29 zX98O1R2{Ledd{MG9OLI)9W(t3ypvwWVny>JfKsdSQZ(LRrOX;OJIIZKf8s8n zzq3({_r&LQ&0sEaJudVeR&5R&U+;8=xuL>za(p)-JWAdg+=5~lnJJp3?w4ZvH87!l zJ{twdZ7G7daB+j$$%E)rz>63_c~gl79R4rq@ZJB$@jkJ<*8a4FaupxdK+2;3o4ntv zQIDNEG{{(?CM>8u@%%uCfZ5qq1=9gfF|e#8^iG4*n3bi#3cMA#%P$}dft{h%=YH%L zq5j}#e= z#RyY|BY{QYc@xri+SZ)!#)-bC2ki%6Sit;tJ$?Szc>v0z-rh&;Gn!N0?s!R^gk3L* zBAk6AexF(v+aN}^n<4@dAz$+}r}T$7wP zK-}k>Oi?y6n`+_y-F9?Mnvx5Y>d7MCv9 zvyWgs1DXo;yytnjRkPhk*<4J1LQ`a6-(M@-d^0DIeamI~o<1%J1S?LD=Jx85S;^Zr zm*F2il1d67;a4{X?Uy>DZ%+QrXS(MLSei1Wx+Wk6>riLUp68=i`ES(>)s=ESD5J$* z8og`zaDTQ?W*^J4nnY}rx4mxPw~Uh|v--~Dkmt-{bj7s?Om$GTo>6`|D_%dF^;njA zkzLq4bMiXvM90s?ZSkI`Wc}(7K}{5cOsw$tzVoaxq7zx7D*QEn^_D1g< zon*NWlR%n|Z^9uk#+yYZaXB;Wypg8g)!D&LL^q8D~C|%W$4EV>z_7F5|?B{O`OIE zPo2%YRt`$xr~7#s$^WwvVqy1)xeD*J*?Fv5n5wz&QBdpKGlVIax&I=(#Jj70`IW2w z=vAri^xbYaa_#(zL%#wvk3C1d!=S@yb{vsGEV4~++lDEY;M>gn31bnG!)Yb@4{75) zTg(gJBRg@dYX3iKg(eWRm{7{10Ww%`4IMy7)JIBqOe4YA0Ksh18@yA#BXJZUvef^s zM|Q17qIVI>U!Nb4Uy@(B#74qQ(>=OW8z{#!F`876QTL&V@L0td{D-vJIq+8B+y8Ls z&d~7Crh_7_F6r63kZB0Q^=05`nn)}!QQHod@`>C~ZCotdr<2z{Vm`l>ig}*G3Bma% zPyajuz?_Kvep_5q`E=Zv*}otksffah&B{h5;(i>Quin(q#7t1xk67TX@T_<(qQ2P+y-p6 zn`?9?wV}e%%xkM5{m%kOz^LFO@RTDy{+91hCi2P^<)P?u0hbc*&9T8Gr^G~=zB@%P zqz5W+EaZR*L1fwd1+Ie>X%fPrcooN;$IXYz0UH3bvLD((v!a+k-}g)MgxTN0c-Kvr zuv7BTWSG{%dw$w&9kJVL-a|4!#u!Ic2d7OrJ;DyKKA%p&TL}&Bj;sIyRqadDgX{tB zF7@pTC@s0xly4+bvY%;qDrJ!#eF4giGKdf7|9@TpPgcDv-eISYrmPx2M3i4fq3xQ! zKyMluI6TNMR>BU;;XPLJW7Z#5^mG23sWkz|SC;FZBLT<$zeqr@rVEy-?;0}O^d9Ry z4t{U(c@KUdS%1(ySbyAE|GLm~V&&J_e#F_+>`vR_X4a+G93gMMR8doyC*G8+XBuKv zr+FG+F8J3L0@T6nW&QSF!h|o1ry~ltRB`H(6B6(n?w58^U&EC%nm>fOtuy{Dad`-+ zG!UyGe!imE8bi(zhkIX^kYztk|A{{f9J!});%AuBMML|A(K4V@T#uH$r4jcCyC(Ld zN3i#ZX=0WqCbhD-t!PR=>5ZFmpm^uh$Ol6+7>9n+h}5IN;<|M~)NI$L7hg(5zxtxW z|GV_?Wf&NPq-aCap#g8m}1&*YJk$RyUoLP~+i!K&~)xrv4#)$oTHgT|cC zEHsup37VJ)4D{f8p;zXA2%k|l+8euroM`;b%(1(3L~Kh3q5aLriJ>Y&|^3J~Ngv@BoW4ju&a8Kc0KqofB_bu?KT=c=3pqqiDAWOD{|6U6-Ey%=@*8w#L-7 zNBJaO!;Pl}iQdFXZZRb^{L}F;t82`5vt6 zxy+yYpN6@dTbM-JI6qMTj*F(mn|UuL)~nTM)6&ATL%)Kh&bu{l^Pbr-teczeQL~%( zwV@*lm&=NSS;hWx?EDGS++}FPjFTuGPG>G>)ut#O_5J8tfh3Z^4s5T$w70@FFcGB> zIjWbnlLL0A>0PlGF-IF3dd+$q`FeJh=9dr-50aS-IO;~_RYX0RX4W%=tV`yPcM5%F z+eZk3`WrBcW1QFS$l~pBlG?O}x75n8CI{{ZSf)@uuq;Geah$Q62 zzTGfp@4X zz3-&VkUFS;yw2*P@c8(mbyU3`J{J1ralAOm0To*?XPJqcdV@E(w5r35^((+m= z$~Av(M38vRnR3M~d>F}ieB4NwmrQJx(z_g)a>q{vsXZDE7aUsa&nHt`7TfEJ1ckLk zhZ|3Hfg1Q)-i<4o58+9SB(ZI7Nxci+&GGS^{#x#quEC8ol7!i z>92l--L9zb8*I2GQj-!yubbgJt1_u_!lz>Df8n8@&i>&>gl~;UtMp{lvHUq)R{LBN zadK)kb?IMRzpB4aM*=hf)2p^5ex#AJ3OdZ5T&9x-MmVb)DPMaYc-BE*WU2yGnQM%A zmd4pWK_d455*`!gTTZhPX!+wi;)m%QpYvbhVqM!r{6(6Q((-30kJ4q<@$tmDr2PnA zlH78(`1;GtrjFq{-f8udq+Js)^Aa=D^u;tC2mjq^@0AM@ zX%Cm5NE_yI^p>i5RC~;&t)+(?n<+Wk{Qjwx<`SopV5!^k7X#m1QeCZk6 zl}Y~J&5xfqb+=A)e#~&hrKtuVSLJ9<7)axe^CjvL_>S%q_)y_ZEF{a=5Nhh;;#kdY zkyI9Jzx@`4bZA-0UiX|cGb*%}@d!nL9(~`YKjo?{xbn|`At{teEzw#1$ah=iFv$BJ zDz)-Q_g%uw2cx8u#S89z;$ISTr0qTb-=Y7;;p@a^{|1VnO}m#(_e=mJcHTLcr!@7v zXFBJw!uP0R8Tcf7@BFB!xg+%Fya*~&IaN89I{lLq=9LL9WQ?L!0qGdW(QJCrh6l>S zAt)r4sJ{oWB#RDv&yIVy^?@UmW|i{jcOAp;tO(YPb2yyTA(H;5b?AGgltk4qFIlG0 zh4*0E_Y3K5MFy`p!fb_``oSt&Swd%kpzk)y8tl>xe1BeF?|oY3IN2FLRe$*+BPa)*F}p5~j^4$T)Kr9N*1J^6$=q z_KVE~PN|`{E9t>}ezyIvte?@fZyRE(rop>f$M1T}-vootQQ6Vr(n+YH83^Grya=}1 zkT`)E(4xvt6&Fs>#iGJZCd;DQp{M7qPQlp}tCI&kuG(urQPeA`nvwOHwwng~B8>CPhJKw}12Z@b zmRT|UPc>d#P|>i}PPhp6h1)y!YbFIe%$D&E(0qkiz2EjCxgXuPA{%sajuL(5BHd>& z0qGtxaIO6}v=J74b-%@aCotcS7c$ze%Q0-%*XYlv-a684Q;m0?MS# zdhgl%MriZ}%9P!6itSdYHnD8K^FY6T(PQF){y0SD{THv+7<$c zusV=NQhQDuDNh5h1Eh$R9t78E>B|G_L6@`(1 zyLTH56)TE-ERMi*t#W30yHpVY_c?yJKvnFZae(uI=R)-tmxl%yWe$*tGoYV5sh`UJ z&Dn42qJtu3It73E1eUR`VE)8!q>D(J)J~iMQF+;-@S+jB^F0mlJLwcj;0zERPRV6J z*G-Z0ueap`|GI_rBMy#>dKU~j71aT9|KMg5nrP`j72%uN0I=@MdCIA_9g-~PHu|V` zId_UG*1P{QZ}HSDl{g-#2Rh5iSSAEFB~I?QNb)6}JC@Wgy5<&Cz25;!!sSOqrs-97 zML@@->)SHUQ5(QvBN2%pA?9hhCExV%Zm<}RUNsz0-H1!-AT;(o?y;+HGNj_Ik@R(A`qP}cHTN?7V=L!Y7p6*51uIQvDkl&*$;6|KK z_+~2m-Y7i&G7p62mH$ZYo1+d|@RIwwV8+%tg-{&==GMRlY%p2I%R)r7K0$ywCLw`H zO|akMom+mG*aSd88RnF znbAxcL`Nwyhh`gQLb7&4Q-#6>9qt~oPO~y2fd9Rk<8s`)YZdElw6*~S9ztlC3m#9~ zYeI(%D9;YF4uFlr+;p7-%oQp3{&#`^m4>umu2+YNwyTN&9*-37AjWpb8m5Zf7Z2+n zoSQ!7_xht)k`}?>QFSsKdpK;kEaBI|r`k&U*Ybj5NCV2dV#vg{79L2|y~I*tmMMPV z%g0+y+MliUZz}=&0gl_!)4Q*e4G7A%TsLTkyaE*kk6<7kL{uuM^J}m#;u0M1i z3Pm~uER=6F7-sdl*8ULAH}qUTU0&ekG?K0Ewg8S1XHyH=wyiOfgicf>CAm$*fsLQ9 zhqrxZ2?&fucl#?bt0US*FVTkMpPNQ6w9Vw|?&vhKzNY2cPc+k6*v7sINc?U}oxsO` zZP#dROkvI{RVW9jB|ft}0PEu4m#K$8g1mz5e8Ad4_&*+XI|d9~=1=sY8g&Fc(g2Wi z+bp~D4(NABExbWh)b_ZBjYJm48-2NUJr9w?hj&!2ZvC8cPf47X^p5R@7;P}r!$YWj zN4op|)-fV{l#R+$?_Ca?yHV5dSIukT?%YS)Y)_}QSF}?2fADX9wwhl#jTCMKaU&jL zJ|bUyjT7_lgcX-83JC1KOyQbzV4a06n7M!q92zKVWk8TG)}?C| zQ>Qt?wikUMt?M7sW5Xn}|K`TXkhO4@NW&&Q_O(%^;`-Ugt`akuP}XoOXf8a?k?XNd z0tLBr3?~pWA+^>=KI2>o{Ox03FIfE&r&c#HnOJDO?Tm_dAJO6MawTvf<`Zn|+(!$u zIM^uX#Q5SPx|lG=cehw;VC4bkr)kj+DcKB z|2<;d-uPBfMoA-gox_75)HQ;uLa!s;$4xh-R^^C;Onpue)!BNp!7hh%%?5);=`e5% zh!}0!+_sb7G-PVhmQ2 z${B4%oSOl6*k4_47m9f&`Kd(qb+`O;6uVdkyUw}U4ph}%#AY*kM+xXo36wLH8Ds{& zDxhSVQ$}l0Mtn$)GR+fd6NdiiI82k>46tr<5dMGX-~t2N9OE%=5botjKQNh-;P8i( zbhHm~J#OUk;M@D%^x-q1CC1oP>$!Urb6y4?S$-F~IQGmSu`fRkd^zKM+fuCjA~u0j zbDAv{@q_h+SyQNzM0FuFj+W6;@TFunxM;5PHYN%=?Z`tB5&+7+mi?f9N!>mkox)kQ z6y(fgG_nG)ODlUVZt;sII8$`aHo(x9PzSWtuD#KxIb1V+b4yZ+#M;;&#-nmeE~|>s z_qmxYl5jQ042pozhC50KMG^s$4U_2$*b*RE*>J8o^!&!s(PGgUlLkIL#A^r|Pr{;q z^IP8g9o_Tp>2$1~FYG-i*x4YTi9+?aMi|Zsl!_zaQngG zy8MVcxSqiLFq0nb(R^d7B3sB9gW^`N588HF$*n*rlA8Yx;`6?hh_apo%A!fc`y)%t z1*wF7my`t!7EVRxx7*TdAC}s4o*CHy$xf&qX~zLGM@$i+C=NZl z-EC5u+wXrQ;KyWdiAknhA+;5iq!X}u~=ZY6DFLS0Gd|gTP7TLzGRe4J2gvl;(jbWEyZz^DE3%#}0@0gcEed|rhk%tfmeQl%X&@zd7l9}K3kgoR235Cq= zZ5uB?(^PS~=V=^8oVqas0>mrJ=>s@>JO*fZ3Yx^Fc7c(10RUe*|0mgx>YqT(gxgO; z?OLFJ*RH6)3o%~1^7>9}nCur@8vCxb%M@-70tlo1{66SPQD*>7iUvVZx; zrUG|8%+qHp-%R?Q(luC6Aw`XsYFNQDX0wWS0%jT?1roo5=!N%@&*m3G#6bdL!%xIe+=xV=j^z26~|YqRCRk2r;2^;ORNTF2-mWS4GOYT z0!xwrrSOf3(JUy3QHp7TNjiMyBg@wlQspA+oyoq;G7d5)qsNm%kwr(4&H!6~x-6oXSXhJ59mUE^-VCAJ_G+l6)&UWK?5e`o)LBZE+}@2UxcKkA{v| zvNTnsk0n<`|NmjClM|IpduYrgQ_ zLA-4VMk5iB{)w$MoFHW2!x(x^pEA{Q>xUh#la^$R{AaVV^bfNBkICm>)G(#Wr7|Z` zI@-KAALxI+#Go}Z_G8`{JF!^nx*dNFpU%@t=bd``2{4%9wb6hM3v)s%*WAV;@H1)%wK=RMFSqsyh+T-6{j}qbm#`B*N7bfvvh$QsZlKGG-X_feA{%i7WxI>R)bUxa& zCkB%_gcZIbexBT2& zjs+fg0em!dP+5z^VdOz{Sie4!OKb>LI-T-Yb^O?}PR#diZ*jbMDSSXC*Go32_8Y;> zAvE9Ynv;oAx<>3AQIzo3+Rn|RBVDqexl5VA;&Q1pB<0dp#NOZx>*c4}(|It& ztb)R@9!xL$4J2q}nf~Oab3A^pqQDPq`96GMTOah6fY=$J?eccm=8AQ`Uo+j+t(veU z+G<&&En*Ox^lDd0+FOOYd(lwrW@lqMD56wd$_)T*tY;j@PeC4t(8r3kFid`iW8Yee zffI2+{IWEwI4RWSr~9v!JFwXemE4$K=~|D~&g8<=;+76AyP!CY`b8-m;_@I+YyGws zsI1oTLlUg2f)1KwYykHQm}kMjsNpJ`r7+ZfK%gz@)bWVZH3P2a5G&HhjozP8)iLH@ z=b8wd{F`;F+as=aAYLyrn$qr{QenSv?VPWRe`~n{f8{*CylU0QazZVDn2@JV^;53z z3m?G%SqL!p)iw`#$m@L_F6-#J7476~v)ZrhWFNkb?~}^Iji%0Szd4G6a4bz+4Xh1z z3TtUf_m!aAnE-B)jMJIND-pG}WsmM1@*2F7XF3Tr#Hkm?1p4fX?x!Yt;g9iK)3^7- z!~BpYU}A2i5bf*Cssg&=vkJN1vm9#TG25cHDxU9D zoQ$t=8b0{7hf{pAj{H_{6cnEDG?o z$l`=qxr!q>uUT3IY}<4i=KiRu%J5kF4$fVS5&UK7weV^AyG~3-O7+X<$*pJ8EbQ?O zH0ps{x`LXw4A`^<@3!H(+q76eYnseq>L6mbB{vxYOrmBmU(7qcN9$+?whaF+N){7X z%u^8T5?W8=W@Yb*gJo$s3ih+xhGr)Y4zcjKf@?b@hk?aG_p67AYg8KdCxIR;Pe&FA?mq9*3X z-vml?(Xa__eM&AK6?3P_>!T0lQVo23{Ne=t*8yOKBwG|~2A}r(j}6$rtbZ-ru3nDd zsZn_T2uYh7*v_vVSZ-$idGzzo%chFXipJE%9J6$dd~&8P$es{mIwVUJ=R70d^uGGa zP(G95r}m3a+4U(m8O<1<4(G*oSL%L`M|aM%owT(_XEU->Q_$UJX@d>}l`>ikDsO|3_&x)R4Si6o-t1PyX=bq?B$8EvI)Y^4}o{+oD zvsdTunzY>(x}~iB-O_}8>h}$)+?XxTr{2u6+mRb!=|M-iWyQM3)yxPI^#8m7eovdd z+g&SlVElVDR1f&lD!E*;W&033!o?>0fN$CwT?fzu@v}B{owED8W1L}sF1)0iFGsSB zR{km5va2dTMYsA4Ys-k+LEjdK2v`D! zvGK}n=cJ`=%HweTi(=8&3!{wAG0%b12s!GPY!o8yto50`sYXF~HPDmfMi~h>gE!3y zhq;v3{^upsUGRz630upWyFr~yt$@fPpHYg4$>OE9-p}L%(20!qode2w_O_1sq|d@X zIVveL44!J`86$dBjevrXVbRBH-=eF2H!+=vFH%j-eTRJA`h9__lGa=Pqk|LT@%H?i z`Si$-2MH_D@?UAE1<)lk@z7o@tuVoP22_sHLieyGN9X=QX@R{y%Il|2Q2YMzt>kK( zhgLqSu4+!c;g4Gdn$tqCi48vZhd+E`ed0ysyoE#f{OGBqgOyLb{ z#9nN|<(n>~f9>;^=DFt8rg^<+8ztwh3YzT$X!QfHN4H ze=9iDv{+dg)aBCtrokar^1`r*zJ?tN2*l~AS<$X4)IIT0HpJ=%ou=`9LVqURU8_VL z1hRoFXlX8TDXf;;asC;2%ZqE2G)B)G095!G!Ip--w&J$Jb?LebLB^7$33gTO{}S1r zCrS-^?cs~(XexfLMuWH`OJ+1TA8g7M(K*S_sv>zRqJyNEJIc)3Uqu)CxfX3P+6GSb zhkuq|bRhutp!uXyUb@5iZfW$YL|tn5zl14&$}0lPpEYzd{CAZM8GMvL%NuUp4~1Dr zy>085eV*6_ueB#zOU|boKb~}V&>kI?NAe>4x zP|CY!d6WJD|0-pX)~dW079W6CVz)}3emQPLxA{{Z#<7>$jQJUHUByXwRcVyAF1@bL zi816)RD?$ScECv3XVHs$k~_WkQw`v~;hx_y!`+^VuBN+Zwu`^We*%v>IgiX;^@V!= z)^?yisoVODiRY{J~UfR9f~E_ zFtG;LB&_v!O6R9J4ZV#uBed^Nr~<6QG_XI~*`iP#8DkucSBK@IS_M*BTbC$ilq8gM zkYJXBKC9XyFikix6KN|{v?IhOs`<@Az*w=xIWZs4t)|$}som&;oUs~Vv|-xnf@3VT z0^a3keBZKYve&JfK)81PsGC@4Eb^fBT!znsDT1pci{2)C?8Q%aC8B1Q@+Xd1ZJZk< zCaW&kM|0!rO@#@ZWNFD{eg9 zrkLz32yIy-JwD_3@@0|-E!7XK6fIDN2RqF9n>MZgHc;hPi(;1|i#vJ+)9&0t2XnrR$7 zEc}$b51p3s5asup)B%V=>KlmNCNnIL;8$>}$Ai!($+K!7P>A!XH#l%EC9H-6QP7+!oUAc9?Te{!tDD)$eG;{>?g zI27FcUEVW=Ra6M4yv;ot9zOoSncSJnS^P*kQ;xoM+G?&_)rrd) zWeir6=b6qbZol;8z%PfZ;c>QD{8#rOZ@#1bM`1mifQ<&zmw_Vw+VQ|q-q0r^kG&id zTndDDQ;zPS{H+-1k=#g`&#i#{tn}N<)|->N0NNDVuMy8`V7?eem|*u_wyLvC(gjrf z&EV~+`^~|tPfA}!eDHjD3?wku4H~Bt2dgLPCyONqB?e_&B7j9E0p>(_9Ni^=P^0)6 z&Y5Tl6vn$fvXr<7thCw*c#!CfZQ9UuiyOz|=PUjzs$p%&`*~t`!GBPSFZguSKXUJ5 z)&WKDp+)C~?;c*@=guV-!Ov<`l5eV}fY<`Rxux{Kv!X+eWuQtEdGQ<>_OsH~hXST~CS zEm`=`15sCkeigKb-rS!29wMNQn={%Xgu{$8CVrS#KPH5mJ&V*={X@$IrX*<_aL?(A z=Zf_h*=E3()J&^}-~k?2D}bBHaciKgb3gk=uo^0q+R`8a6v89H%0hfa z^8OwK(T1VdSluc98?gQGQq`0lT;EhFx8N`)p-3-CA;bofht_@g_X7W4`I#h#WUVhB zSolir0+i2kqN==!$`7W6)vx)*DA&mvQ#0~edHw^rkbmZTQIHTckF2|QfgPz)q&xjT&`n_DO(TQ z=ZQchjzLjqf9`N6u3wmSsHe_8C~8q$E{Q@kIQc5Sv!K#FpX~u>X;5x{VzI8d&X-~T zbFfw*z{*0^IiTviFX|SPX|g{n!8W&1+|F))V(@C)vYyx-$(`j9`B8(&kY1o~m@R9{ z5(m*uC@X2egT1sbmn4w;0po$P4I_{~kki9lVAH2~-#I%gCpKN6>%Dx)_qC-DAxb4iS9z_ZpGC9 z-C`h>>1IoYTh8zn?1r)b0u3ro^#kE2>`G37vLzj*L=Nrbjj9){hO|m~=2A0gbJ($4X~<86ENTYb=+>c!oc~owX!kFHmo7ZycICnW?fC=_se-q zJBPb^?c4j!sy6Npev2t91ay%D9JEH>(p!@z?&=XSZeh{1z>;R}>ckGlr;gM-2ImfL z_9-WW3)-B-1+gPkCB-k`&p4?>u2oG0x*z-MwJSa=!0b(25u5Q?GR%QDRTh(exXFq{ z`W5F-^*mgogsaw2kBxW#7JNfG<_TAgDeYOb1obxF{%YBw9+7I0MVtmdWCnB7pP*9t z^Z*mGuq?3ZuETl| z?J>f8tn|bVJSG7ZWHi)B*!e@s^Aq`^LqMafT+574!=QjczU=A7JxeqSpehKTh9Zoh zFYZW>u4!*FtUiVQ>!B*IX4b1~=MA&xB%39J z@WimEwAae6BeiXRiu9U4`gMDXy;AVVvoj(TTW}px4>klFp%=z0=*Ae{x2vo*M?w19 z>;#&WjurhQjJ#o~h~x+(a7xCQ9OtM3HGtr>%w+y?U?DuwFH zDUVs752(WLrHz@H@K>`outU&oj^ShD73>(JSOECWw*dnK-1wM(9VTT+*^k%^BfOf5 zA1B%JoK{}i4@9>ITMhzOQzj1w?{@7{mZrV0MWDWFThjTmCK9#M&Qg=N|KUOZ;avjh zbP_&M?{OYlOuJpM?K6TR-ySH1kffR1c@u)-gT8DnuO~cXrm-V2qy0+B$nuD~$85+8 zO6&lD`5^A{auAN^{I2ZvNRdRn%{`Y#R)3C1RwX~$7Fw6$EheU-TC^Y(R4r&cy{Odhed(c1QEGyB-zCFU3RHttHiz4epocAODA2NT2m#vLA#p|7#IFkg%0oOw zh!K3l(`<=&4(!n0xXmG!8-1DT^yb_^fqjD|FMC0&_aZ4HP8LQ77^lZL(Uoe2l>PSZ zc&>@Tk7dC;ZD_t|B)Io@!NS~3gLe=TuRDRkEeLH&>CuuwbI(BLwKB(U3D14M)e z>aI+hPLe6?iSB_7Y|heHnk*f07<&ctBTIYKL5<-Vt~e2z5b14T%y(u^=(cb!c{Lh5 zei3gy+ZDSz=!!BZI`{&c{uY*E;PUu#9_(_Qw)w9$`GK-~Xe$Oa4=n7ZhsX;DK=c># zJZ?e={eGEVqordy(om~wH^dQfA5MW2910&A2xOad0a(bJ4E@u;k9!8L_wTwvqS27- zq+&QOv7HbQ8-LWKt)&(0xjehG6y|MI;Edy7r6be!_}R7hW1j%WBd^l>KAuFo&o1IN zX)v8(uYM4dgHuQt@%Cjn=s=ZK5E;oHnGs^z)vi}`WxaCkM>ZlJkCmxp2K;cWwe~O` zg?o)P1=fZ2hH|DoHGap34!Fc1_C~N!3sQK3-^+>SL5rQjFE0&|;=W)9dvsrZQvnOx zEMDSY_6~<{?5$pg2-miGS=c*RSdPAYDsa2yy@MI{gsAy$&H=*kLn&uWr%hj%f?qOw ztMw0$U`9rKzElr{t*HYZJ`7MZDuicilLzpZX8^xUZ7<}hscmv?;35}k55dWH-PBdGS-r6yEe4< zv2Hg9wrZz>|2B-as?<)y(iPmd+(cbB5wp#-o6$~BdcuvfYi}ixqFZrjj0t*;#yQn~ zs6)eSKhJtA55--5ZaSPsW)t~Ax9`;ol52Z-^1K+QIu&X&&cUf$xO$2IH<$nL(c7-X zeeyJ&$*9~%VI1wYRn=15(bkJ5%05lb@=l3=VJp#`U#4pJ=Wp)nWzLnGTJk=}INg0~ zYjx;tbjA}Cx!8`0V+wb*{Ii$B&hmc5$Lv4lKeW@nESHq5kESWSEaI^$S?P<^{$w>! zwr;?vZq>KW_^54N{XyTk1Xx@xQ^bng^LzLYD|Vzd9u_Mp*0&`B2oqx@2hS#(fEWOI zjtHyr<|mQ5J!tNk75fW0zGpXgf#S>l!F9zA?RDk6p0i1v=Aek8FYoxCMkSa{b5zS& zCQ?zTre3{5u|}c-?C_vSD9A2uT7hwztNB3cb(X6MJ+0P+Qh41~YYtBS*n*PDM6&+# zOYF7>$ZBU@y52h#`24f%Pf#+t+8XU)W<<)?{tQ?`OdLVHiG_)jGc;Ag*o*&!eDEtY z7|WjAocISG8X+(PxUmuF?N|05jq!Qv2SK`7xAT_sFgdUQL?iiX&S$3^7*&pFK$iAC z-Ap+^$EI3ysFp0~;H${pvC0BvE$t|5Ed(mnZF zowycIfrh{jVVCDnZk4uCA z1juZF2*IC2>62VjdA3mAJN;>z3LZ+qwbPOD@*RDnW4lLV^`TA)k1BtrC4;15Ybi`|S{72z->R7rIAJW-Hs zjX~pJe^RW;u%|MkX~i@S4~Y}JImHq*<7$6@KikWxZtMq#6Z#*T^x+sR5G(g3=1Qy= z+)gr~5$x7xFYQxJqPk);@vWqzqmOt25kWA5BgTIn^W?Vg*Cirbn@#a_tz^=rBizOk z+pR=kGI?$5_8FiG0#9t&zN{iLbO`X}H*_Ou|D8f7sR~X&If@xT=W^$b>49M1(4awl zN~I8NHc4i=9w^#Kkjx*|Vp;QzOVD{=gY6y(_UC>ecJDt2FYoz8yN4 z;>#Z)r|Dk{w?%zm2^0#X>k6G$YOCNBLLTH3q<(-GJ_74AnwnFI5hbt9wbhYQ-JdqD zfCuf^^-Wr9$n6N6PWVWv(sxK(U!u6nb3&8HTDXSbkCt0x&a~fkHJ@D z5#HN$5ZagG9R&u)vt~kKbB!9FJ;e9OSWllh^xVq+OtiNKd_ zjre}77~!>z`tX2k6U|dEgcK2T(t|iWvb-?0%X*LamQD^1>)weX$2ve6gu_96jXb{u z{PV;(?J4bP=xIu5o%+jnk0a#h2^fTjgy}UFQ+k546xQ`!<66UzL)U-;{y_0|SENfi z{*%`Pmtn&i7ds}A;^DkJn4~~-#0aH$-8O=~aKqibTCDi1E0L5vp1H#E>C|h8Sjo?X zC#f9;_w~GV@$|+O)6Ub|y`{$7Cf`pr#p*R|&y>%=T~qo+Qqck`UCLe9x)`cMyCeyK z9(vpn4Y3PlE_yGO^>*)b8E7ALy}OCwf?*IDvL^CopJ~9*#I~jXXCT*+t+Ux=!!40~ z%3%!gmnr$9ON5shj9>>tveT7E{O*RYsiZ-o`Po!pAoO5;|P+X6P9a#TX%fzGS$F(*ZTS0 z!u7|x_)b6@Dz`w?3b7jsdw-%W%fTR*zvZx~Jk}9;9a6w#*EdsPhEg^9WO!G`s1Cfo zsu71;%5y6@71p?&g@aN(V-L3aaQ3rx?1Ws>MvGR(0T=F!ftuFSu`Q}ExCs^xkC>6%B5ovY)rKtGC<9#mR85&I!eByXosbO7j)eLuqkiiM!~L)t_d?x~ zY+l7R`;6Q{)soYBg$y$$HCEr<*4?2bRzE?toertEgMAh+NIl+AQYGZRq3QCjp;*yE zu7OUGsu!p1^unA00A)cBU0J4K02qwv@CfLkTM8*cEKoCsdvTcE>eq2j^!}{Az9^n- zdH3F)>L9GepR%-UD4wj;+@BfSul-WGG#2J;&J!kRX|`MK25((4@sO_VWXqXi_A8yuutnC#3PMD25pW3PyMt zdqKexhoJMxw;9|We@SiSyUI|NbY-||6$x&y>-t?ijiYH;XVQta6a6!V*htk5wINyG z7}m=BgL_)_lxa%#w&U+g?JqG{?A#Zf+$wJ;;CcLB$|vl8o+Q$Xa6f~?#KutNwj0kh zzBi=gQl#pyo7Am;!_Hz4sVT9g(h&M{LD$+@lOz?=rQL8!VHAlpHF8CU7+!HOJyUT( zBv*ob!YkS(U$^xVKAPN3VfAR@Oy*VK6{oFp)NrmyLQuh$6pw8vf`ycrv`K1uj)QxO zOgs@e9!DGbS6_}R3U{#d-Mf9 zw29L&6ku{5y|swq3QerbTJr>i23QRONI4Ipl0a>tIKz|G`l<$Mxq|k?29rUpJDUps zw0OL`E@y+vy2E-@4e9h`l)*SkuUHRTqDFxC{Qq^oV4d9!3-y02O;Q)g4Wtdg_NyzA z=T-;3xvBJmlp!u6l=E%hks>%){X;)&Suwz_4&m>wrS&)%=UYbFbCq@m*7UL+PUZO~ zUc1B#&73!;6Z@b)z(cFC3n}**-r(gB3gA7!hp-r#=;5m2hvCR0PLika!jL?g@;}rN z2Z+~~jHwY1*0q_W$P%0bNR|<1hxJ-r_qPDl>R4p*9-9j$U(f?#5!;oND`Cu8$__Wt z2U;rjN{NSFf1XIM6evwoxahVNkvHvDk=TOhJ@$SlSs|rUluSj}%bD;yqt;>euOT2_ zEjzhQg(sRJYi~aHPXS?o`g~ECXEJ|Kw3Q38`F%f!5qwuHp!Wt=i7p+BMe&4pGunzN zPdnt2^HK1nff7lI!k-W7zx*nV^lAz`hK>2pNmI$WyS_~0JSRVD61&?DlHAueV4T`j z^~Wp|w2QPKZysYmK5>*TfA8~)dg-NU(X!i}otsr(483-vHlW-9ab! zNUzMhyr)Fj6Zj7(A28aF@x!ec*$?T-R(r!!=%Z8<2r=Ms;IL|~>Qy$zRlQ1B=z`)I zi$($Y3$GsjO=lOMCf4|*iUqJV>lilXJ)PwO1)o#`x!S|Uvi?Q=9``dgwrO5^;x*NR z9-ZFYLNe#g|9JR0ouq#fu58PvzAax(3IG?3pOh<*%YM2}{rBNaw6sV*)ndfLV5QJI!mRb0tM3~(-iE8(RVA*uanrw-OQF$3r5AVO{&yYo{=K7Ul)B~ zD-S1d@{Vq7@69&eqGh#=7HIT0EY;`uK5HgX5n{^H&FLgor%BRYX57T~(XXr#Mm~{_ ziI=5$EE{Cx4)933l&MxA31<^^WaJ8(OS_Eh4Sf-@sWFV~nUk=(GD2B$)RoWe7 zh84RyY}y^}kwt8oWljS;G7bCUWlk%aGQlR5hqDZE94{PEF z@vmzLGDIBekfDb-_MQV49_pN`VG+k9S`S+`5!9TL3rEM&KaKUI7*o0V&7^Y#`P@1? zM_PpW{0vT7Ol%1%50X19=>+*A3Om${tj~Q&kxb@$g!wUvN^22r7-}R{7xxVqiR9_c z4aJ0HysS>ea;#R45#VAu=J%gk!F16q?=8w73a({rRP{bzg6R&V*5wU7sk{O-v@E5e zZB>un{|gsXqHdfUE5YCb&qyy%CHGIvU|}R_Ak2HEEf06FL6e6~E1VmQX6}xmiH|_V zII8cPosnm7dRd$-#&b^7YgOJgSmvS1R@4T!4$gG7jwq(qAEP69Q z3*0mH7gV0w5%bYgj+(9{dCikveGpEgsrJA)1tr(}HBuI*3WOC%ypQ&oj2lbI2Z?j1 z>gg0xru)V`wN44X{gH{E?|jOXjfetbBLKqWhCig#W|8!k0XJyo8b1hB`Qni?;>D7t zC!^uQ8s^-EBlHZO)+ZwoR~ZRH55_8Jr=SJy3JexhaiHD>7PNOcuGt1T=K0`ys+)_R zOKDzCFobjEA<9dx0~Ff%;w1-jv-*=#0HmFEX2V~lah7I}amnV2q=>Xjy~TdODkP`3 z8}?T2!Ncs@>o>W0%`K++zbrt7hg|zN;Hsoov9SRHh1J_=gqQL&?Q}#nss_dtRacF? z-B6%<2IXnFOEYXJa;-?*;)YCmf~-BE!MpSd4Gcp z!GXx%DBQTAjSUlXz1m=nL2n#lR)(>Maqr)~aQKd&8Mik%Kvhpye`&#P;uNHUu zH(XsWF9kj+cUez*-`A_=3(}}*q>h}E_H|L%nQhKvAKrx+47w!2t&q7E@#7ct2OvI*ZKj_TLz`&A@nTo$d?#?}Y4*3E3{e`@5 zD5Y*hW0e)-uk0fWMqr)rmXH0?Y_q*Btd}zzg*A>3V=6-*OaAsfb;Zu!W zvYY4wybqzZ`T>YNXy>d^HjC2)C56Ud>jqHo7`9+Y_W{5&$>p zt8D`!7YY}HJ6%BkLxtR2smcTgpBWZ#bwr?IEW`cn!UgskGK)v}OPlp)xu=fd3iyW* zPs{ee@95HgFQ$6N5s9Eos|k*w+!c3~h(KT;j`)rryvr#z#QBBN5ho#OIcz{j#twBd zItA`6eJVXT?QIQ_3SCHStEz(X6i5h^vcST@$R82xjh(<93P<%wNZu}-Wvre~@)6K2 z%;P;T>9pjK8*5%X304u`O1H^XbUY$A;&AjDTG-!PP$3w0fOxgTgm+7jD<>1)(X`Np}1aq?9_STqG)mhW>RC888cWBH1L zDrZI(?GN7Yj9I(^rCr#_hA=joFb;X*%=6WvKx79@h%|Nl<1Xtqf3+|JHS^QzVk}wf z&la)lC51X=AI+L50iF25yYl^d%tedkjZb1X9HL_PIHJ!l@BF@Rsr;_HGxa6>-zH>JAJBGsv!Bj@+5AE29?_a$vB0L6I<7KspFe=!$^|F_W zXN$lYM3AbRb%Tt2(VAJ_WtNP5v6A_Ubj7?6h4UkOig`UrEY@C5O6SYE?l&=tNmRNc zGa?xjBU&RnQi=!rtl62xkU|Ou4*+Ynzr+ZIV|NgO{_~TGG|VJdd=a4Ya8yhj1;-=QOh^n{tl-%lTIuZ#l!N<;P#F zCM~i9e|4*T-Zd)@I8YSv(HR-m?h=k^cl@EzHEfoiDHt30gtqV%r1_)~`rx7S-aqQf zR#|6f-2Q4Jui_%CE$&rNZH5}kI=Y2v zZy5`Z4ly@GG92kAU~RS&Mw}OBR>Y!NEFl&9&Tv8nutQ)4yu|0UrL@u8_uro?!m8h z^;7$ASB>s}8H2q~v>v+erC+DSQ{NhEugo{ZzMjt|&)$wi!&FOX04!AODsWw60{}Lf;lyQoPf*u=!X5GMM|=dar$Yv_sst1C?Z7Q zfAJ7(1-s~NG#X-yLRkFTbc%0QF3+-&LaeWeAvZCb7~PrqfSo*X%poY5RzB?e_&EsD zLy@`rZ749&B?8nMF~nH_3|OKNNZNF0ec zMgXV@+LKTWz0iTxa|9kngiG8PEUT)?JmGlCLzq1ww^T@2D^kPSV*GE3R`U|4u6O_l z-p#l_I-r{thG(QSeq!z~_)|p7Ytp8<4kxZ3tZ8e3a~YpC>kMkwv6d5#v8X1(@Wr8? z&d;@?T15Hl1%TQl1g_@tw)IvpK>BpFBe>k`oF1A98|qQCIP2X>sw z375;;q0%J&1=H_-&>>u%DJ3!FnuH3h1FHK+oxS@bGoEPdSFR+(iYKc-s~X`>NREan zBadKi;S2k_n#9CpgN1_tJLtvZ*9Q{Hov8FQIUh~jzl^uBy4&8#`-j+O zvo(1bhW`!4{IBA2%bH&j3`Xe)DUOXQPlmKcy5aqyzd#s-%Q%R%wze@T@j3QE-t^JP z7k(>JW-o1cHZ1@P50Y61s848X8w&!HiZ!2|EEd3Pft7j#@W{P3g+x=<9GIRUS}p>?mYL6si!k ziGva%kKH!aNempRJiEU6SmS>rQgqI2tXEliP`d&%bu)pOoSs~m1H4S58Gbw~w@pbk zKQW3hoT-HyLp?QD#Cg|WmqCcm$_$QURD-Y&W)Onfwvm}C%tgJf*NvI_j{HtpB>&)S zMlCeH0fgHM>V|KGvy}L(n>u|~+kVC)NalkD<{h+G&83;U%X}=zx^R>@K_iNQVFgK9 zd%~k3oV?uVu2Bq1U&*QgCuoI~?J!vBSQ-{_>((O2Ou>)&@ltdbJ__k8QmM4@MZrJ7 zY3g-|c_^|bqqG+ZCV>uPlck=Bt5jDotcaJh2|s`iicfW6525Wa0>$lo#<^GilJmX{ zKc_fxjtH0TL!{R|Trj6;*Pr_=|CBpH>N^&&&g)RLYIi!Ddv}OG&5x4J`>0!4a;WHq z=HP>lkR9hb^eAY%yf^z0)n|(IZ)I-Phb_13&0`<7r!J)c->A!6H|~LxcvBgM=0LW< zcij7JpG#h}WuV?$meIL974+E$!VjIlqRy>$Wh;sh2Bz|K?)%+*0n|K1V!lSP9<~Fv z*;{oLZ`hLrSmo@xC%ekOd=Oiw_Qr%^sp08tP3;}$>n`st{s*`-wnJ6#fqX#63g<)7 zAq+Jsu`Xd{d8biL&xqt%_RN%#-fb$Z<~;Y+lKj`s#iyE4gUS+r;=>_IUOta8=|odu zZN%VLS<`p->gQ@Mb$UahB}~!p7c957N{3K4Ds=nK=Xi^$<{OKtRoBX+Z*GQB|3NNF zKM6soeo+J@=acz$e*^?epALx@*Bu1^$TnoMUm7u;aP|$g=-y?BmE~-ri^8fUEw`XA zA{?m4-TI3C`?yCQi#dko&YT{PFZep&-zpgu#_gfT(p`SG%o4pU{vjgZ8?;I?D{vK1 zqXNg(E;4_iV1xdSIX}t#$AJY}c|S%boD$k`kw!+86sxtZP_kUb zb3jI}NTHQUKHkY&mE`wN4CsqmszF(eOX>+$dZnLG#XoNZP`4|%rdIf z=|)|qUPEe@-@Tr85QT-OXgvy`y}S$;8EwABCPMJHpqXZiB?0@D-zmIH$46Z}67`>s zkC|yCs#)OdZ6I}I_j!%_n~D9?8F^&AOG6#uar8g!dKbWhpQoNivNP-bF+O3?zlsji z;97~gGmL*{wZOX0-)u2~*D|YJui)M~alLw`+w3sWdT0BmV=d$c&IUDtlh;^;jds?H zfg$3P>tq71+d_l_b86=4iI2n&;_q72HZ!UJKANnG1?dG@=Iy_guZso9y#jyR zT?J2TymBnBU3DjSGQ9aOZhm>&5ZL!`2@l9?5POVDkI&zv0EzREER$ZC9 zbXtr**$9*VomSn%;T(HHS>$)BYe`-D_>>75F!8?(>wFCBSY}(k-e3H<>4oF}AmrPi zXPQ!H>Hi=eWE#3DO7LJF25UQ$xz71=x>M_VCLBH+tGpWjZ!Ml3J8SznfSAAn8_2QX z6h+v$k~?f9w)OWYu9Af*9+F!pPGz3+blvVue>zVWT4 zuakCF{)xlvjm?iyO%JkywYt?lESlrCBHAw_Vqoo2YT88unlZs%Mc% z^O;e>Z!I->k=%1w5E4%i^tUHN6JmFy{=o6YNi)h(?8Y6iQ3FL+5Ng8YHHqksS2Zcc z6$a9S@tAIs>e0&yzwsRMrTWp{=I)3L# z%F~}3cBuAAPYGEU0$q~thcF;1llt%S_~olxTYS#01^QY0tM75I=(rh8QVM=5*vaJ? zt*`nLn+ON5iG|sU28IWUwO`{c$tL8HkY9RoH-C&_6C`Zao(y8p<>YOT@! zQ2A`bi^7bxQKZZ+NiIMa(Iy$;*{br zAr0Aj*iq1Lzike)y)NLyTlE#mzuvM;f;U2XhS$)GCwhl1hSCN9+AGA}@1J5x+DP{k zQSR^j5#_(LjWwgPO0e*xWOv`L$TsDo+&#J@M!?8o^al;^dR8t1uQi9wj<#NeRt|lM z4fNAVvIe>(nTz2f@&6FtdpZ(aeV|~>5BHL|~_f`g$+N?z8#MhVtF&fp*Wqq<#c_Guez7T2W5rF0=o^lr~z zkF`lE!H%107l8^x740Cx71xbEi`gm1A9Fmmf%2ZR==gJhF!?xoaM*xN;`HSHijYgU z!g(<9m{n_xxYf$c5blF>lj4u(N)tUjI=v2A6fSz}gdnL5Lh7faV}*=nCRPeYZ^ z9863Mm?>>5Z;n_J=8Da9=!vutxs^tGd|9rSt)Pq6CTbI8sz5&HU)5TLHV6-C`Qn!+ ztU)kNS?{u+V*kQ7{=`J2)x|TPUQ3DeiWm!{$ErN(i9`KyZN}8DZ9jTqK^%wbIT{QW zR&|>Di5So4@nog4{9dzf>2}F`%MHk$Va^a*u}YvDhf_h54NdfQy5$Z}6-*$|P7I3R@JM$w@t> z62fvP*3nT%b}YbSiRQY8z=zHEkuY;C(R?6iq||#vBCoBY6Ts^+_h})vqq$ny8qLL&ah4a;l1hnUj6qL6Big)br_S!Js*LY;6oP-qgIL73V?uU8$$-vCF*H}{QUAn7#d>yKK3<(X$kvBi(7r#1()M^bTQ|DVYwx2U(<$eKroX@?58cLj%f8gQ{Ib=Hf%+4i zdML86HJMKzW?vrpcHsXb8j#B3bd_CVz`|q#54ckfj0`k#5{nY|NDcjXD!@?5WwQa7 zu5d1#U6cqWG%%18^iZ$J+g~OD@|hutxt59uOX;ti%Ub%&oBln;NS}{14FkISF{Kv7 zCg~h@kHiuis}&Jo6rR!kfFoTBGg~`@39$&$F2D$226vg&I#L~Zq%`>Le8Tr3KX2OH z+FrU0rtuc*Ej~oB%F{03uFiBzP=xy+uwWKaLW^=!Vn9-E#>WV;XcmAfzqO6&Gl$jr zl-gE~{n~iS)5C&TQtpyhGiJgd>yt(t|DNCN$y_oSgzPN+2HiG2P1 z36$8r)PL~*)WfoyCI3rdE+$%){0z1l6_p?_b_%+gk^8h*o!#bbx0Z6KKSCTPNt^w? z>&Hez+6P);G)>O#=btpZ;P>N1urp_BKqSw5``4ZKe-MmlGJh_aK?1f>lvtVUMRt@{ zXOu>ocC2QZ8tdydtxVUOY#|3FK|o93sUh?hqvN_VAkQQfdYY+oqig6Z+?-B5UgdFW zEPgYy7Ke3GbAMH!?RWdRFQ%98BSy=4=VvqH{|7>D!x%_n?@zG*&0RcRC={Ir0|9DA zL*hj#>EWKQ8erf98(J6tFq1YR)K(_JvtqTlxW z_NMfJxF#pg+!LyBZ!d*i$y{+<=_7((#=K?PUmySe8-m4&Jtuj^EcJNabHuNLxGe6t zE-<9k(KcJDmTZlj8@Or*id5z^jSux9vFEDa-jA^JNO#_2XW(MWdbec@PRK6Q4RbgE0Pj+Zvp8FGtIZ>qF zB=ww_Y)7qYhianWj}YCvB^8asC)^y$t%*9R)S;h(BLbA9&Q;~Ac3hvqk)(b_EGSNF z0A+RpdV?$QPtilEov?6O^l9$km+=~$Hh%xIw?tF4FIb^%Dn4!Vt-HqQxPA5FE^}Zj zC90jo%T?4U%H;{{8w>{OV99dz7_XX;R(r_2SrI0HtX~U~a`<;W!-X z@BP1ZSl_Xi+d@s(GQD9`Ns`O|pDKav5*gdcwX&=KkxK18HQ(f9)8Wa`evg~VdAwl% z#&Y`MmvQ{vOK_}O-#xDw=7OJB)X~4~I1;EXt_{P@X zw|Ro616fPJzczy!l#QxpOT+TMdE$Au!Lj4_#d(|AmvJAa`c3$tPiB)j$6bd~fh`?n zgU%G6YTKYUs=jnobkCNVqS7H}8opS@TQJqa+74(eLvdEvAh8K1A5NfoYnSu#?MXC8 zOx9~V#NHtn9w)U#)x)Bp5T0z{brA{L9LM>50*-DN!x+R787#8#{@_*4_FwO@_YW~U zg{A>>Ic$ViB37q}d6n_OQWfILX{@m@;nr`12EHnS7q|RB=}fGGJ|WMh0km7> zr;xzPY!S2^s^40hsLr_3PZ*~`n=V)6*YBN*nqG|aOLh33$e;X5x~tC4^OhRA_ZJ?5 z7G61ipWBeO-?X zzPkG#CN&9O43t66Z+?Tf?sNhkG&Ayr4R^xnu6;l5WPUoD_#fCqPYK6cZSA}0e;;J# zMZ`rd>{{m7r$V0=HJ_g8txqoST7-_zNK#0G1R4edJ%Bqa3j?^84ecaI60Zvnd8 zGrAG__GYutLOW=sJx?mjyc5aJC_4!;zO{W-BMQZ`C=#S zf4l|B7!BBh-87@Psd_v{fP}NL2e6E|M+QU9V+g6(P5h4|;>wcrwiAi>+pak)?RJU> zFHEEeS65m?>Fxu9!ZAayLwxlMNm}v4%1e|o$ta?^*)3SSW)NILn4*Vck;8g$V9PR} z`rQ&ldvk%Rp})8|HoAkmxrz9910!tBxKEA;!xUk2Io@49uMRnRq%*L8aURd24KAZs zvYh;Yg^HPK@Z($o)@>;BIK~RlG1N`NhKt>`5w^Xyme7cM9NMVPxXQIPvRD{kx^fdHpzjhmoY=_rQ?gdS1%Q zJ->NEk@T(5)NS&5rQLDp$!#=O^0Ky!U9>n0vCrl&`X;IE(asT3={1AL>nW5*L?u{y zy?39igR+ zfT0Qhx_*b1YjT0E75u8oFYkHtJuef=729ev&c6`K>HPGM#w~bQYpT>qD5HHIj^$n8 zAMF3vnY>wHmWdNLkzG2g#Z;TY#lP%lwb_l`{QaML{r~XbwNzm{COrNk|KHRooC*%F z-2x8(_@Gx`w-QEkpHS!#a7?HEky>@Lh}Weusw9QAgDyt99s^#V{A@o7|0=l2T?swQ zP6br#Py5e1_E7rWv>X1@e_LCy*xvB-yFm0azqj;`XRQqc)=Q-~8M37u3k@s8( z;g;R-aTnRo(t@bkhsEXkUt8IfSF$c7)gy+3pUx(aK6<$}1c^2nFm((OC>V8+dE7Q7 zt+Lo#b64k8wb|BS1AtOzo#RjS{I&b}jb1SHVlL7A^uD=v@}mY;o4|iG#`a)6{Lm=I zU*H@Ao_&MS`=VKO`I68kG>&~1&C2f*BaOCS1bxAmdqV``e$ugY(HSDSWoc4viU$RK|O*+V)v7aOd<-@NFW zc!k5V^ZHM4EKm3kmIKGnR1X3?&&+IIy-r-eHaumw3?N=L_m6rjSq?RAWm$Uvx)z50 z;XO9q){Vly@?ha@5Z$i!ob&7Pp@|Mf=2xG1TYXR_f#v z*uytJ7%Vd-TKfn3t)l+l-c`r6k%?E6Ke~hG<}+uBcdsQ=NBnr3<>r{$Nbu&U_oBny z=fPV4DXWuFy)LN&57h3Z{Pe9Xk4$l|m$7B^=@X2@Wxk6O|Tmh(RG~r3a^5q}Z*j`mHR2eU=rrU2}d1S4~XaAQ4$XgWa zJQiV_(rbNu{(D48+TKyIU3ShgzsDPidZTrjz@q55R-~B>6#yhZQkiQ1&mDg&Vl=QA z*C&J(KP0EQ+J4I8j0~Y#go-;R=b(Rw^xo3n|uWrbxy{pa#~YCP~Bl!Q4*9*IqAKoh9NM z_dwO|LSxqlQkYo;Ihg??fW4CHVUE2x#%Oau41-y0dK$0L@Xrz3IH8p7>oZX_5YP$O z=O4^hP_kvyuZo6U@~pm}qTQ0hicvZ&X5*WHi!+)m+A!$kf^u+CDDKE-Rbt1e{)XuZyK#cP zhf5E>plLY0h9M6or@&n@^)o(W2_@Q!?=CNy25RN`!nB?E&o< zrEh8v0&ga^Z$Wtt9s+NY$iYu}KHbgI*!gQNDcz@9tW*L<6@6`dhw^vY*;Lymp3q93 zPWK&HLG$hQ+n&3sBCoM6yiyR{>ZWh+cOw`(R(q zmMEmb$;Qac|ehPl9QN9_(Oc0(`WS@@~E zuW}KJPlYu<+iCAPms#}5PXW0=(=cdQFHM5eV)crzIemjAu7GqR`=D+{pBU4zlpXY- zQzusaHYoaDx8HVhlZ@{yt=*`0HCY_o4oPx?$ve8Obig86S9&aYC za`slu$eRk)iw*fesfzYdtmUusZP@^17!G)}t@pn;;BC&6J>8uIBgq1+Pejj6U!q*g z8xmFLI6A9B8=u;tcRWl}^xGibvKLySnid!!OBMSNItoQyWHK}qk{MM^5rj_+7>^uMPy+G@M+wx-G9u%&vaH;DQZym;n- z$px6}Z8E~xGTDAU+sb6V{`fy+y=7F@(bhdIC0){8(k+sQ1_6=oPDx3DL$`Dz2qGaM z-O_dF?ml#L=2A^Pzz4w}Ht~ux0=n-RSGAYlgwqgnN*RvP~T?JfVO4|hg>GApwq+xA!C!g0%tV^Z!vwNIu-w(K9GeU6M1Pp*0s!nkbC^6SnoljqoUFQ?Ydo59hjUn1{tR;^6zOI$0=)0 zx0awmZgoBwgOlrFR%n;|IVESWc7eL!p6=*}ot&)Hnj877Qsr4D3@2!1q z7*kLTNm^Z17k)=CruH-x&5?H zA_kra@Cx<`Fc>Kjnps$PJI8-wvq66MW{Ze*CGZa-4JtP|BE8cm6qaz`QaSWVjL+ys zxQY^yL8tKZVFV>KO4Q-G_LA{Ie>(4OeG8N_rn9AxU6i!;cF5xc8?@>jvPcp!KbIa< zw5=S9_dV#>*>R`->0!bRlH4)P`zq}UCa~)^#Sx{xrtaKqF>G?>4&n$`jZYE+A6oAl z144%@iO+q-d*Q#SO(g1qpuzpva2fRB>v-h@SGb8ZiIURSx0`WfaHMak!JZ`m3+BNm z?zK>5UO*ERL(JWo6mu_?FM)ylGxVHxBS>0GL>HAj5#Kq-PZM_`(D@W3Cs@OdqTC$> zH8DVjr+8i`$RXYqCm-J5z?tM|&RynZtC7NpNc~O#)#W9J$G>?C0k#pg1>ZV^z_yM7g%MByly=JO5eb~9 z4%%2Um@_kszA3^Jm3oD=BkkM30g)s}>V4(+L3p>h5F|jBBkc5gBLI7EDx8A5^ti)u z(yXslCLM{P|LP0`Mbsf_z=4>ZaS-0Z=bC8qZhUCz7%rx*F>eV}iR)>?`SeD6Uzx2V zosBTuxSgVdyQ78)0Uq?I4xFgxpYJRpamD^pXV@cOr+5Fjwj*MbN{YZf>PQXmDfPPC zEn0MJGX$o3!4XtJQgMCey*({KDQ}P~Kb>PZahpZ8e{Yx|)EUOG3%hQ=@wiqKTge?+ zBk~@1=(xm5+8`n{%i*XobyAR9s^Q+3j+MeVC3^1CzWpu~B#6s%O#e6xE7Py%d(LJ5 z>L7|-os+r5uPNB>(T-TSm_X;&oSQM??J1-BdSm-v0oChz!5X6hX|F|BL^1O{LN&D{ z&H6+JTlc$vvOGW@{H{b)0NDtki_p|o+-vi90_xwz(vUK339On9WRL)h#1)x5um8Ge z9Oub@+qvU&h)df<_&*UKw~47r|L^>4iaacnZxU);_!>0=#nSJxt!ZquRew#p^N|SMX{ImOaQ0eMyTo{pg7i{8|7}z;t|u{O& z#PC8G&K||mIVUV1NmVlvQ6o^pJq#=kQ}NJ>rw>$FUee{2GF8q`Y3Q#{)O}!BJ$#O8 zXHB({Y!AADNEh2bMKwe4dW)=wq&NXWhgYxS-8w>zJZn%vdbrmzWaE2|$f zRuf;2uFu<6w4T9ZLvuSecbSgNtJQHsH{Rg;AJp&CRvAz5ZU!H2Mb7!U zj(^-#HE;O>ydxDvDihi9JFG*0+w&C5lM?bN(^7HUx31C0v4!nL_JsM=f*%QTuMkL? z#X5e0js!U_Z=W;HAG#dTM!W3vVuCgo1o>805F9=zHeRV!8gnAG9hI%s`{F;A#|1q- zn)Q-(NUym$ej1ldc7!=Wnt)RF$IUVQ+b~o>Jy>{} z^wH+meQ+nVW&lk&E9dmG2yLuApIO)?t+P6xHE1L=yFVR}>t~j?M>Dgez59qHrl2x0 z{qg*wwXqssf+^w&P%egCOx0Nj=7cj0ur?gwB=l7Eb}S2cT>tvQ0`cPkxg(}V4`&2D0Aj^>LH>)cZkAHW6f z21{+S0|?$zV5clV;aT+7s!jeYRw}{4r?1$>TR1;*6I%?b)4cajY4T#XnBDL}rPJJp z;qq}*yd_RmZ?yLYHf=y|l6k#K#h0!#>~7aJYOvAbno5BG%^J*^xO_TjXyxe(7gpb+ zI{50N`X5CEdEnOU$x1Qsodzf@*We20TQPQ+}5v7=Ut)nnB7fr?Qkz|YzRzn{!k zBvaS&i#x^SCi;wtD48DCM)OKSrV*CujiDN*nYj0bSZr!X*L3NPlmMe>fQfXY&d>C( zs+NPj5Ap6euFm#ys)swTix4|;;e`eV&=zr8sS(da_Jf^pvPlu?A;Il&f5oYDO5+vv zYP}|JgAOEvf&zMY@1mR5d0=id$r67gHIN7~){)MK0M*-{J}UoClo=+XIrEmUc@1!W zv@3@ieRojJSRqw0Ihpt>CLZTJj%n0RQt6*(01N#U-z}h0+FRl`stq!Yj9k!F{U$MMrK&7@d9&BYU=bIgv}aFJ790^uZ6!_G)Vt?2!Z z=}x^+q?0lPCLJ35sh7`|wna^AUFFP^us8mh07p)&dAdg_O~e(_RJD$oQH&o$S2y{= zzlJ%PxVSf$N~vR48%3oVe2|^7b7+r#z1V!IaoL#8SJbC7`4EqcoBluqPj*#w+~F&u za7N+$8&iUE6H>D06ZfUD!Oe{>No2Jikz@gDjP95lC4gg(+1t#cDDjOfAq`!*QuIj7 zNnVTA$k@KFwfp`ond@cj%TxmBWenvQx?HY>s&X6)p0T6WzP@r@Js9OT8uSL-1!^nK zVqc>FX&q-G#+5AOGem6&Gtj|yj3>VQ{PHcR=_B4R>}X)W5w^eDUi%&yyb{tH(Iq72 zD*%RS0?cS5I^VggTuMI$_P-Fj^E^wxLgf1<5M9L*MHQ$C=yD!}cfO^DFCP$_2V({( ziafcJ)>ozCZ8~uwiEbK%_q4O)^l&R#M4R^QtieYIlAzOtn=jdMGjfqt()7-lP@zEE zjh`R67x((W6BUEvx_Zj9?#UhTHX7o4T)SSd3&SlhrL+I4gI zI$R7G1WL5i79ls_90k|OEM^;|r(7%szoW(Y!_iH$PG5xx1GwvlXt|iwiRUBNc{?AnXVt?)g^ldYdtn@dl#%%RTe%FNa(xen>Jh zcZU*K9S`Z)3V7U%ZMlBkDRn)pskrEwXCez&+XZET*s7wH@`GrhD3)J_kY5jao5(5I z#l8zLp8ywv(Ct)-SZ@)fL|k@CN$Q;)7KCYqN5jUk>`=3rdPU@#bIWv>P{rQpb4g13 zs~0HpTx~&$20MI_IJD*@0VZOMfl)h=SirfnIBE4ssq6C9888*v`Dw~s(3Dx?keWHL zq813klN##F6>FGmOgOxfJo?cB7=>Za&&U}qSN>z0K)RlHZ6pscv0t!P*|vgt4z~`9 z9EirTf<6B;H`K0bTXHwVCjO5mQr9{~Nnwf==ZyODb<6o{k@Ihn!^h75WRT|wVm9ab z-1nC2jJ9IyB)kvL;N{hum&^Wo#Y^cI(AD(sd@pBdo>rk_X1DZ78LAOKbz_cum8T#6 zdAJ9!tp{hF@14iU%SQBzTCLp4&Wr>o)mq+`Cr-Xe-s_}Zu$P><2{;-i7EX+QceaPc zQGC*O|A@r>xpM4x7O@$}E0rwVqUT!ANI~=di|O9@+a_7CM?2Qx6k_6U)*r*Mm4+Jf z1>><+%m}Tl8PW~A-E;S1KXU|GGfNJW4dFLY=`vX@+?xW7^C;ugknZF&1k3(85-32$ z)9b={Sldh?4JZ>6$4BzC1`9PtXSJ*Z`Vi@!C?clFSLkqaf1G8=cPOTfk$k=Om$a9Y zUheu>?k^EM#r(7(92DCJm!T~bMhQ1FDO>gz=`CYo%qKh}+csCJ!pg#_Dv`9aZ9b;4J1tK+!sLnboL9;C0W%7d4Kd3^Sa=)UfkHTscGwcv;5^U5JGH7}7S>BoJ zH7aUjRfHgs|dXmq*?w`9)$T2qO0>&cbpP7SRc^CO?Z6BC}?ysKNk6yZY|QHH=4_S8;!uc1ejJ_u@F>rXr(NY z5>yzPOULyb%rS@B>txl%kl{aAY%0ZtD$L3WYT+_5p2_E?qIr*y z%O@O+LVPI$QnaG?(IJ|osK1ssaq3B=0wQVH!h1ABnUV=)xV!J;>v1b+KiA&mwi3N5 zL9hCa6>nXLXZHTl@gq;Xg-4ub1uibFQgDOx!*4&|`)giWk$&SE_HOM_9UQztxpbO) z@zk!k__fmzf+C4=2?beUoJNT^LDGl`5jUrTpIJxYEaCl>mUOSk67jyhUtmGZC1BM` zn`v!C4kvp{CmQ^o*R&H+3Yz=$hSlrEK^)A@I4jrIbq+r|wJ!5RBp9!3r3ihhen z)g9LAhg4azt_DXYQ14S$iJ@VZH|}3r+K%*e0~b;6RgJ4;g9_1aV7>}h=YwLI;&uNx z7)~m>FWlKS$>2rs%`)ACh&Xu!qcc5((A;HHKe(#QF?*Sve-}!@Px=NQ@gIu;{H{;E z@ggS0U384ER~i5mDL2UvF}>>mZ=t@7a`b01$R(2VPIcX}iTQMpC+3YKycK%OtR6s0 zGC}NAGdPa%F+wpo5E;4}@S;P`ILyspI&+>xr=sQ-y=SX_4`n2<3-8{&XjR;RK*0c9 z?o7*4Z15|;&QwB$z6ilUk-!i0AKcnCaA;}p&7wzc=Q(h?8kwn%zF!1PK|^c~JxHR1 zztFS**_9|r`Mr)EK8Fcx%-jMv#3Y^cr|6?m*zIaexjlYtJBay6vv7Re&KqxeG7DmF z5q!dg#(;!B6S#DA9}KaHRqA_qWSXqkQLifCrOYH~n#B`(;M+CZ$30e$rC-M)Q3e*G zMZ*mTy999uhy{vJ6H0IKyY6yT-{KKtJvXZjqk_@R-M^_%V;VdA`iPUT??^AgRperH z$DUV`k>6rE^X8NlsVnLutnT=9&kkf|8wS1voCRD3h`hmj3}C|Grm_!d*i5;km1`ZA zlrwpIi)tL?9r(0HR4%12?b?554Z4jFNotpE{L#0`ds|2lGWi47d-NCoY7NOT(gKxe z4f3Y(3e2a5Xt3vmiJ!aIss?!hDaz?gT42qqWh!y>bV*oE={vn3?FsvL39qcQyWS(?v>m=QNI&I zR@*nzZ(^<_K2${G+Bja(*W@a4TlU;G|FzQjVBx;{rGauyP-3yb9#?-M7a%^CJT&t@ zb5K>V7&SGWyayCf0#h+UfO)H&BNcOZ?)c*NyyL%0QjQbQ4Ot8n2rt7&xDRd0rNEYa zL&8Ht8dJ!mQn(o+TYGZpP&THYr@QDk9>48-IiEhd&$aF;BC_Qt@@S7I^8A!x%d?N_ zmbs*4JnXCd@wAA3Z5 zKAEvFD>i|qX4-w>`%TUJe%t^kn^~u;e+4azX;tbIAvJ-2v;kbZt8GOmmc8cb{soKAov!DCDErAL<-rHZ zPo7y{IQ~)kzUQi*`l7UsQLg5Y<)sx1%?CP=M02~%m~W5(5a<-#auX5e8p1BcW>JJpb+#cJKoN#y z`^eVB#8ON^3GAmhZ5AJ+{^im@dKN5ufcophT1RicSocp7yU}L0m{eK!q)lH*vB(J> zrI1gr&yYX-TlXnqdArW4{ta|R1Z%MbF^HE{Z?Wy9#e?0`1n^>FF?pT_Bnm<$!nT=y zMs2fr_UkpKvU75>TZ4RbK7NtKI%sJ)+Qp@hrBpq1%FGG-N-#R2x}{QX22581caOTA zlZipuZ|J;Oi69w5KM7kiMDyO5qpVJD@FP0WTa{&K4hSBm%R@=H#f+IT5*R~q$}73- zQ8pqY2S;-8%~XaDT1`Z==4WCumgS$GYiP(Z5hpDXT_loU;|}MQvEi%f5=*_;=14b3 z8ok(Pv~KhmMcXxFiP9g!nG!^D)6%dCakEV6Zz6!sGlsZC%lYe2ZASe^dS#Wn1>s}B zjvtzi8ZUpJr>2}OU+1W5x8nLi7F)};yIsbn2TzuyQ~l#H+!XS(=0k8mSFGk?Q|K*< zB>G!SIm%=T98_vv@udKtz+Gf`>~F;V9P#FW!*F-)1e1&}ZMlm&SDb-F@arF%bV=b3 zYJ2g)HQ{(MYDXadGfrF~CoVu7o)8TV!IYYoZB*(fO~qGA6zT9_>fnIylDj5XU%GXD zWaf#b7K6RSaROW5Y0wM^Itf4lG|?L^@ha3VG_f0LI1Nx#%r}^RIl_!e>0A~Tk}HV@ z)KMx_S__uE_R`?#z$Pv~al{_pzgqDBrv=zT_6t7jB zkw#+1fPb}t`1s+coi1*%D#xHJZv)&DZyO__mYr+{HO{&}nV3(nH(=J>UqkLUslnby z?m&T~%$lfUa$%qef#H09?GqyR-HUeT?d$Gj;z=!POx!p@mz}f=Jbv&(m+~$71PzS3 ztsRCl9FAA=gU+i97S0-heg>ob-I5eHMqQ+eeGZ41*NrA7#69joYxD>ImHoe><05d^o5Zk$dPzocAlLDUQ1(Ps7n6M`qTme0DWPKV^}|%osuL zuGRQW90FROt>@er)7v*$dV$P<2-zT92$z#~RvIe5__f^0;UsIQbrM`iXOR`}K9(uJTV3}A@3;O**DRbf`H=%R`(oV;1<{8JP9#E@wsaIri zdIVBdxkq7cPxqGs6fFVV!sc$hY0-HdSnbNheL>2PFNdvR-!(q0UcWr!I*MqX=wD5% zx6K`HZc@Cu;*%BsfI~olmr5|b=(=jw{*CGNaOCfxR28o5Hy;q`Qwcr>$yw?`e~x%s z-Q3R^L)H z9LsBW^q}l>QHow(XIPtDtLirwhFD2Z+`ejfHQC>(1RX%%@bOP`Zh(#T^ZSr5g}|CT zIE2$->1$1ERURBmnK0tDx58HN4&9rL@J=CJXX)y)eOA#(Yju%PH{nN>a5k4qNCNEz z?fwB2KB=(jekw^WCwj&3m+&}Oq$%k}TFxXgu0U@@yu)@{`BSzbg(^tkreR2y8M_sRsPYP2D8a+xG$tx0JWs0-y%c!FO(fUx1`}la?XRjoi_A zj{R462+hh^4(j?OQj|w+qJX zN4E(x{x@*@#9D{sEx=WtH>$!=MQ?NUNO6mVl&@{z9D=7n8{IQHEo&#PcLRGRb)Mm# zZHNZj;_jagJyJG0Ogq(~b~EVmjslcmp~$({`G|+@;};4UzRI;XcgySdl4wVf-jt>G zvK0Bt!c(eYHnL;n7YcQ`hDMRKwSD%#1iw4tJ#qKGR|P$5PrtF-i^)e)8z|&LKdR&k z)_EP~hUvz_v0A=b_gw5*Vs%jFXgrlVCcB?MpuL+zd#V=7+B zuT{=W+BMHx6oq?ZkU)d!Gp48o6Y~0W-V4^e@(3imB?%RL?~0Ynhv8zqBrIcp#TC~( zI*}_J`kY~X(uEP)dxn`nq2#1s+dxqZ)nqk<)*cW_=IqSMP-_MbT!YWlmoEkp6^pVCO>AbEA&AqZN&Fcz%O)y~Te_r!t@xL_H6y}v7 z7>m=~ZXnX&-P6=;yQ#Cy_b+T}tk!rLf#-d^3{KN3Q^5mW*f0I=o@E~}99?p-T1X_24yLchl&q}8A@`4RTfzhznM29efA6EeQErpvIEv% ztUl0F(+I6(qvp7vtzIj@OGlEn+UvGL8NWtLK6cM(F;LvLuw`RaSNCjuqf|%;osU^w z_9?V1sNeU6w-^Z_m9~SU(J%fza)Jf}hoXlsPX4e!bZqW+V9x4nY^xoIQa6tG*Kv5m zcQ#DNLu=79h=%eWEy##hUoVR-F4W-DcHit zWBCfpl}6}6NjhyxiX}gqDQxHS>J~$IS(BlERoS=m2Cl`FLqeWQi5}KI2L>a=QY%PX z3hknAY6NUzjglo%kn=l{OEi3=LsoiYu!*E6@R?em(*_k8Ka&9CQC3*s(>m9BhMC6? zcj^v_h?+Bna+n`^7>YpQ8Z^)xM~SKQ&oJ6_Y0fZ*QTnDAgVvM8($;s6kD#*+yWm$* z0@|fIdyosZ!yq2)m+wR;(;?=BSlr!F?5$H+bMaK<=9LvhVs|%`bsbtz%z{)Czjq=; zuwvJ-9#QC*q1iHyOx`bZ$KYui#jR?dO@d=a|1CKEj*s6hofH;qLLNp155uc5!R@BV zzrIq!$owv{+;!v>@!Hg@7{-!F(pw&@+mVGUxzFz3p@>4$&!zMRK;J*DAto-P%kn<A$h644o^a4CI8wC_YOzV@ZKaqB&?#c8B2m1u6H~XB4Q4bby{;CoE0;TAAIxP{F(*nb7`kk z1Vbo%kj&kCN1{(iPTrM|Ov5&X?U^=pdvl|$f;U}z2cug_N_a9Y*)ZMPQ~B{=-JT7o z8|z-Z_rk%Ko|Hi@(Y1}eaarp$C~WUTTSvX}}BVy!zke8_sJ!t?CXI+fpY#O6=gN?+Rqyc@3{gVPs6G08Gw zKH{C-x*`n;dU%k%K_7Nz6uZQgbFxZGm3N&7%i$jqoFVN;Tx zFn*6yh4Nt5Y_+TKRN+sm}gGY&9a)@!4KixexM~$$e!m ztUG*&N;u`Ol%6kS0NuvIT|SFyka+BxoW`T$gIaH5I$q0^W4ThL96m6xcA-YkscOIj z;nLz;Oz<3+6%}25w7&XYV@7m-4$B%45?|gh822land}gY$bx-$hhKGh8pdLnaX1J- zYM(U!atm$`plRvZ|KcFQoHXu$J?82ZU^U3z*Ue)NJOuWEXx$D+&h5lD5X*D>ciD!d z=JU+e0rlc6Bn04WioJa9Qpe)CACn_`H4cF*cMge^zdYMQ+dLaz3%djp3dX(+MVZ-g1nPN@3wuPDxHg-+3uvaYc1-eGB^>DZV|gon8e>4CS=rz^#TtZ{%7cqZ0(B<%=>M>Hvs85c zaH4ughh4PsQQB>+@ayAHar$Lb@rtLuCY6HbRl$@dk5!#55r^C{Tc46689xgEKk$O8 z7PBZQs>Lfgz-+IZOU4PA-iH3{XTn<`XwraQpqs)m;5bv8Y|odJH^~ET+i?VJ;We!? zi+6thSPT^{{oxHL0{&d6%7*n_Y~#4-Dqtsp4jXtFF1du$ZTmp*`8EO;qkw4DyURcP zD>$stam3t`|5k21bZ2AJ5IWDB67KF3{$8m(pmYlr3Zf&9m4iS|GhAw>4(r0cPhHI! zpDetDQF`?YV*S;p!cr4pUr(P|34Xr?Bs|`GSaQ|ykAQs}*iE3JWLcwsIKtUikNW>2 zp)u85D5-$F$#=Wmp1)r-qH!-ya10?8UhS4XTh?J??E**6j_RS{L6>@_o*6*($5ysO z&LFmS?^5TK08AR(P8yGKjZxGp+^CsKGGfp#?obFY;B*5P-8E1E*0Pv0o~2|8%fNC6 zIBiE#vJ4bgtB(N=nwUDjFUc*d| zvsENPlU7eFPZG>cbBJd#4jgRTEASG+cMh^(b`|UF4A_rHtMXfVstVMjzxsX)k?cDD zi%oUSPgw)?1K+^QWp8!-7A*ob-mx4?f?kUVRBOy?*eXu2_&y)VdhJBXx;;G1?M0#@ zxL&LG?bh@?=eT4Es%p1^Au=su3S%R_8dT4>u@J+T7yzA>Yz!V1+Z6I;8#c?!Uvs>c z((3JKglD^RpqM8f%!cr5 zJRHBBQSj58*)&#}aZxTd86J@E+G{7`@*DW-a(|_;t zGZyb;@M~T?ezst70*mG%J>CX+$@y+QS^G{!@1Si{h4gaakyuDJs}K@>(uid@k!+47 zOLbS~ausH1=E*=}`h_Ck{Iyd8p)aqZ?OCrhT-bi0W_f+teRbv=D&8d!`i`3Y@9q_b zQTgfo^F6w(nbd={VndN_#!R7geR}I{eBq3%&HEC9vPpxHCx?1B+bPCqjQPs>%5y*@ z)9)hnf~J|SRi6t*k((tK2O`inaB2U`@)-JU= zF-7Z?p1)Hv8XfKxu>R(+FU6QHhZpszN7vjD8M`}#a##P}_Z@f%{e7vOlEW-Jod9=| zP6|R-GdM=@mL9;Wli~4zuUn>)nAI=u4=N6RT4hcYs^%_je0EaGm5l?_<^a?}Lq?G1Y;J&a0dGy-W1Cl7Kt`H2+ZV}R7z zp7Aw%vFBU!`b7$mDy^LbMuS*bMFq;;d24RQmG=#D_O1~EL_(?a5i=)>p1FVm>s;Y< z!NgAXnoWf%o5El+mJ<1B{b%e#7mvj+eY9RH$>zi4j5;$kh@(wPnaIkb*-xKe3fA@Y z8HXvlCY-tJ7bYUIH#@hyT7>hBXJ3BT6SfsMi#6z`w0Xt8hB*E85}T0n%{t6u zHKr&G6TM4e@V)qJ$(~hC#qU=Vb9NOu*R8RvlGiJpt2gaNL30f+C@m%&8ZQ;Eh{&K+ z%0o&UzaSS$rSQj0uAn=1SLanrq4%^zI%dZdS2UL2pCmBwNoWa_YvR8k^sD%1%qQ}N zc?Zw<6KTnjRU&UTK&YH20^(@bB-&q{mB(`5#WVZQ@XTS}6 z^2w_Lz&$f8{#(MKwzmx{y4-NV7i)_%ds0>}U1g5U@Qsuo75YH`lM2*W?G=TW}RA#JD(=3ZmYnELOM<%bLDD z;Iw_T|4FLNroFkJY^!(Coc63!k}oz_WYhJU*M4D3+D-9rr?1?P9+V8^51<{|+NKMr z3V6HE{hf+HlkyEV$bLRk`XhS2c%PraWt-`EwTaI4hn*eqzjbiCkqf#hX$r`Gf!?|A z_ivHSC#l`Kr;ga44sFLdStF4fD1NDI;%t|LtQ8Fe@qppPav9Kha$u104GY>Ecy1>* z-S3yU^-;K6A401n1Ni-)eaiG|<o7Yfb0hO+RQKpjy=WK~C zE_xPNa+;^_(Do5cj z=ZfHrc83eGXW3t|PgM3I_fGDk-@t<1AZ1l`cM_?5h_`*YE%qyDFSusW9Q(oYe>eF4 zoy3DYS0*!%PVf8UhA@bxcWfwy(;Wn%EA(CqM0ULxsXyFFCzj&z3w3PTFD_Z4h0oa5B5WkxUeNbCkY%56jy8mf0%nseIB6X{N%`Jeq9-WG)-+-L8CiOp10 zb#?dBX+5gu|9tc=)q#HT4MY43kAaFoh}qS-F_~FVV1{yrp2^2#7e|2Z&+Zo6Bunug z13gYwR;^t;)}0zF$fUnH!TqgUjXpX1h>}j2c(mamwVECaOA_?&JnFcu<%r65x3;F} zzF)GDnDkkoF9GO6y61tsW_lDS_mderHDHDW7El2|)Gri}M_Q zM+7XbZViLSt)DF7gbUBBlnXcVh&1l*5j9&ybZ4ek)UPh%J}lV6#?(jn(KQ=A66D(q zi*J?1ip^%wW&oNZpQ6W>Bc>#;MS(q;z_zFy?VD}w^sP!R;kU0_t#w*v+Cq{d*`g$* zq;KKzzHq!Bh}g$zsJ11{Zch41t`WWSgHSf{9i*_Z&~+1|mrSN+s~svBnaDR4himi6 zM%_mLBEpp69S~|O2)~?zS&Ourn>JqlgfsDD!ogHbv{})=@+p7+l;W~lSNAbaY}RIP zGEeFKw--|;}{&mYak__ zrs--{KSSW^VoYqVN$h25galk;xG6fBz?q$KHml1dKois1>qmpc@M$2&@OYM7|HmQJ z_B|5eg1z8QTkU#7oAr~k|IChg?D(}&*g`D044yDX%yW!~)8ZoL=UI+DPqv}u15|8z z4891W$WZgi_;qxcoMVbX#A7#jzU)gqU0w?CtEdd{9soz6~Ei-mb z=fq@dE05;W9^Wn<=O*{pZkJpJIdl!lA=DHLGh?%m#-gS26}gSJBHK^%Tq{AZIGPj5 z*YB3mx^}+d{oqYWVOBLL&A;+5w&`&Ms!+r}Y*=Na8DJHix3QPXCfBuD{niCp_FRJG zM+HVBj+T^MpOh7k<@-im5%f?I##A1IAd$wdb@H4fzbCVa3J(`PX^7et4m!3fZND_> z65Om5MXJ(i3SK*F#(1l0I<}PWGoKXR=VEK}Z1T+backG2L)4ZNdy0Y81~V`BQKVYs zDo<&vn$_Skmbo(Zp5B<^6^J3`_O`7I8!W_$A!z99$bf@u=B=eq$G7M!#p*AsKHWQg zL?-FQ(64uJ+n5H>rx0>`AEmbfolLY-NmMo7TO}2rcLiKtm)7nh|M2Na+lx_8 z4@hW)=sy6|nl-xKL76#Xv^I%etp2lk+wNLj8}srn^OCPF;cLsui@Q&?inHacf5ywL zzRH5MNLM`$6BfR@Qj-6U1xZ>+As2x+1n6hW2GU5_b6VL=E@HeF>6SV17V@eiGY&@o zuZm5IQ}w$j_~>OZwPyTvBNR;vac0gsmA!zC&HefKy&GQnR6$}iqQ_<}9g6xqZdS!k0xb z_yd34e|Zx_^_-7am{*a>Db{;w6<158vTpMY)iVzfltM2d>b#d%f%i8xWW0Xs)q0gX z==PD0Di($z`)T`0CNO*unk{gBJ}2TOhPRvKvf?2k@p{Bkjt0<%)2s?w$-1(=GMX0I zH!<{W?fNg!oCaW~(Vp4?vPU`P?2O?0+wa`V>lx6_SktqHG(HEFt_ole-tzj2zxE8L zSjphTO^I1|84H3c|=T`-0KKNm6*rn^JVy%c?1KSS;S4m)E7% zd0L|BHFU+&e8G^s>e9?Y36V`!j?tQFu+qHfMVwLcRIjCi6f}*AYAvUe6u;PJ?i2Zz z-Jd`-0hCy5I$vRH&?p(xR-v^?j!B^6$^l zc&oZlL1XhuE_;!_yQ8YBh3*QyYbrwpSBg@|>LsZ16vp`QT?(I*P>=LNMxPIJ2)VKi z`vSK!@G&Uj296c1;ChwA{|*ffYG`QunJLl!0w{_A^>Zi=5)#5FMV8Nb#KXxiM~JhM zT2D_;v%6wmdOZwfW52B)=GV;UL-R!3{&1l01w+kZEth8eMNO52)j0ZGYZ5)#LkRkJ zyZqNaMHSJ=>+`pWC*?~rR#Sct1lk?nz{u9JalsnX2RH!id)0Nf)^?8`tkse9HVJ3hAd=IHCRGoHtZ zgw!m@UHtjiO(f5VxaU?-6cKZz% znA}ulr&^s@f7mcwW+>$9mM0^TtXaWxb4@}QMRq+}6i+4blFbw%>p!vF$nko zsLQnug^7NNTkNI%?)V+`XrV5{a3Y5Nzr_Z|AVo%oUlDDyw@`64JS_YEwg>W$Qgj<= zsJG!;9;EV>+l~`W$A}XtJ@@ToN)`jgN}Qr4gL6izGfDLtJQwQfF>FHrrv;c{H!2LZ z5G%16vE{YDRgbXOPhwgr-^ljUqN3M?*9Yuf>Umulm1o=M(<8Qk_ zZ`iQ*yx9ETS3bC#u%;%jdKGnHqkU<2zr#bZU;uvEfvYayo3C#bJ?-pU(%2EZ@RV^D z%VRm8+S1bW`sg}3I$7x(7wlud1i8?vIO0^PS*;ZSk*W8yxDP7Y==3#otvk5{nN2^A zl5U+9u2tq~rcstg*+;echI5-FWH37>L4vb(w278QTpsFeoz zbSQzt-Mbmi^olg>f@0gpC>H-y%48#c=_JPyL zV_Hgeo{h%P`#(69!5_NJQL;}ScqyU>9a!U$+T$V7raIKG{+>c5%~K4&{Vho7uF9Ma9Drx?e|H3!qY_eusg~i(L~f&u3ukl@)f$^-0bc|JGypKnmswf zNL+88b5Y7n;ZmaYA+BZoP}3z53-!pq)^U6(#{|pRxtVHt- zgTOf``~RH-wwh1x88r-z%Mu6QJXQIYSw6ipjPntego$|H7~z z&fS+C>*di}fG|GZK?&o0Xc>0;Z22|7L}_omtFR0|&#XU^jZe;Q$aei+ESv`G)AYwK zE(7A)WYW`|r#}=(U7@`){Ns)Xq7P!igaPS$lf)bGI{GEjZ29>kzosiU_-Fi|lJ`aL zRCvD|Y5rq{&)2;*{ra4eja-U0h_2NKbQw#w4%P9ggvImb49ow59M!hz=ro@?b!L?O z6afHG(KOGaadQPf!)cg~vC5&^ivCIS#Xjw{%iZ>N+lseTr@U8ew%BIKk{()%l>nd@ zJ)tUu3`Qs^DXE6GF{fw@KDIK&Vph^&2#tM_X@i(0`V4Oo@%Q0{i{#H=jCFice+BGnD871?!8HQ6VpST|EB8rUw9zY8@O?KS~9;^Lb+t z;TK8Pwg~0S${!o5ZYPSx^dZ4pfQlkpa>kFx67;xV3dk*bQ|6Qbj4JR|o?{f&+giul3d}FLYSaAoJIiSZlpk zhfOt{+9)Q>>Ej#&JOo$g!c(Rjprxr%a`v5XP=FV@V|M+AHevr?v}u|B4{dJqS}c$+ z{xRlz{DTeg)xJ?~RYb@V{pd?|l1H}MARRa6rE>Cg`-AYQQJie!Kyy)NF=hhOc-B`I zA30(_jg&vYV)8p&9>qjuz)3t;BLbYnDc;;fe_-AlbdSC_rzPd(U@VmMYC0jp-^+iF z0k6n!Wr|ulQo_^Ew)XaAC1EXO%^0CcUHLLVx7N74x$pm^TkB#~U>75=Z z08g7P_y7&I>oBg8Q=cH-LP(yIt1v~)$Yp{(B!jK9IQc4dU z0#YJfQX<_jba#tLNOww))X*s)-9rqWGj!+K_&o1f-#Op;*R@zn*n401^~;;H8XsWg zXLwGP&Y+^j_nXgXA_iRJQo36rw^nI<=y|iUbj8UrWpn9lLG|4z&)dX0;~s*Wiw$=m zajgh!sk>A)Lla_ilY0a4&bTz}=kAKJhf*B+)9ovxKc5b(Cy9JEeKUR91n_Kb={V6i zWjKScH~ufG4Qfpb%a$OkIIXj;HuipfW#5l3CnTr*O556PE^{gMhK6_q%@BuR1Fkc7VmU#2D9(9C2FauP1yJmf>goT zhJLDeZWnJyFkTx-bG>;Wv?@yl*de7-L-9Jy7B^I6VY2hMM$;ye+QM;q*Y9KoKN1p$ zNIsE}F`4@zBweD5o(mDI2Ot5w}^h~h;YT8bH4%97dF3jxIO;i;{Qz03Qji4n`=bE8!$ z1{fX5dgwF@@es)lbc407?*tMI%{gegwDCn0>KYq9hxGsSp$8vNf6r?Aas~nWDIu@* zGcO6GbVak|l{LjW*#F$WdWrHY6s|}qbYtMfEk9eMWm=Zm{8ZemXS$VL9rpZntRjjfZV0U0J(O(S3N}4MnfXIIeu|}9 z;-3cr1|_bmltF_+LlMx{0S;s;E3O+a=#MHM9TEwMnXSWMY_UggIXDXDHW#HoxG^zu z{&GaUrg;C!Y-)TJ5+?VfdjjfQ@?4`_wWBsdjn^gLBEPw0TSNcTT3n8r_YjJ?Y4>s0 z5Ll)1ozf?()@L@-uu&HFPcs8kh0E!)YW(YTYB4^_-1p}b=F!Cd>{~#UC|s|4TMyb4 zXEZpX5D!-&yoh`eJW?#6rBKSGy}iAyC-hLlvAe%G%#r$M&o`d&sSeNoZYYCGwOE0} z=*Q9uW>ekA&>auNOV7alz-e$pZDb`DAvIm3^eom zfwhrXAOuDxp>#6xcJMXqoQ#=g+v1zb@$I8v+bp83YsWSMV8%>e9|+hEIdMMyWTr89 zL~HFRI0p0-Y-P@|ZGHj6^H?5fiI7{xSXz9CKTKJfnf?3!lI13NCoC9bYq8B zMnT*NY3Ynztfyjl*1prqmSpSAs(7U;S`<_QN2-so_^>CYc&$bWC){MiXWpp^0S-W6;SXyrK<33MPvdjpXvCm8umOjk z2rB|U64mSPe$227?RXK_7-oI<_uP9erI&7w|LnlC#wm-um+v3KRaWTy6SkfsR4Uj0osLE4khIhw1}112 zp!&pmq^I@q+Yd%>>d*odz(}o8or9=OYTr98uK~9A04jRv##onED(wa=!l+IAOvrku zu|F&vz@sqCR}xl5s7iB^Q*KqpdnyBE$pSVen+AxDx6FUVVGZ5d){(Zu-$hy`$7vQO zfKmOF_7m}18Os_7kplOPrMAz@JTSbQB+4B=a2#+kq?R@-8iV_ zjT5yQV0YoX6jM`lADExo-(mmo)LKPOEss_W!0UPW`Kw3b0dkM(7ToU1OZt(D{q$5$89HoZOx;vix9E@BQ4{RXH>1 z=*RQre`C~O8!E5l#Ha_lz4c0{a+fW|)2J48K4#7P!FSauMI>k4LCcaZ{4Tx#nj(0t zeEAABpR&Mq`wIuv_!2qx=G%W)khh6=m(n~o16CQY?l5Y^JabaX<*u5^I6TjO%km5j zvypu=Gj0ApBl%9(ex{C5*VT!K8`=ePlRPKVAlxd|W+TR#U0IBR6Tj1ZO`v9Sw6o2C7wVcF1@B_QXuK z7ATD}WJC@l5yH+>oyEogxKVeM%l-#;kKP`&6R{Iy1y}%-=OXw8<7ha+*P_?lcq*(< znh?+wy!BG*_k`dKBc(y#EW}83!?V7CGp^+ZxOQ!auF(~QyYxyTs^44rCOfD&jZ=?% zPf>jh*I3OP(W2@;U=p4-rBUe(xQ6eXx*naXUoShVx}O-tBOFSZ-`~oq4TBX3O$%d#zhxq40p@6gY%C3?~%nm=m~HkrzRI(P5=$?oO6OS z8pi$UFg)PO+gapfxvpfU9HRN1a?M_FK^m`V>l!GGX&e`lyT1+2rpC$Bz%t5kA0 zf~Cs2oWV@VqnekPQGQ{zur&5qQB1;G$5wV)Lbyv4%5JKCd&ahRZf8o~ zX&72|YXb}#9AKCyOLYpev+32;)at!GO_vp$5>4|~O8IR8x?}O4F*~l5PsG?)=!w(5 zQ7|nfNN1@SCa|AR_#9%<#^Aq9GFF-}QCo~wsnU-HMP;F#N&u{e17KCLtM;S>g*esrbytzO7Z_(F zG`Y$O7VgWF$Do{}zF%8Wb|z#_B)w9-#kqV8*}K%Tlv&aP^1(5ZX?~lMJdLIGdbq z-xX~34((1&wG53#_ZdxaO%`$*rcG9mTHn1jHp;B2nZ!)t!lC?7TF_%0s^Z`CZ4dA5 zzkRab7(2Gp<;6eO?LsOr$NMZ3* zPYI;k)Z+R=`XwN+9G%jWn>w200n6iDw3{Hy2P{poilv9koLsT$KZqo!>U*9M;6DIzap;5VOfRjqB}w`!Fki|9WI) z>Klo}nyigl8fq~=c>$iO#Ad0!rdWbRD_2+-MXF*4p39yl@> zrM0#1UPY!^URfF!7v`%8l99W@8BMn4Vac-p4o3 zo;y5Z+6#97J+4|(|D_;#@|XI}cUx8PjRYX^eqKAfmCMs0P`5JB?;7Nvz}Yb}r3U6i zLD6xxH#5w=qVLW*f;q!oe!^Bk9W}>qp;;pz2I^9*W9n=oCYj7jps8rL?+=6&C4>#y z#?W>04!SZzBM2bEc^<}^D;!)|X|>T_$(V<>XaK(ZyACyZczOP?c^3GK5_L}Qqn_72 z;{1J?tRr6ucuXBES9YI(O?q)rR>-N7k5~vHplsCt3*eG{J7`D@2YJ=i2xQH-!Dm)l z=SsP}=ZKjp1!R5$_ghxlC?rIgkGSD}bQxHh)?v6Q0H}r8u0e=CZtf-EqG9vW%Je#+ ziaQ|MvVOW&E#`TlSdp`%NlS_-i>tO<4dkf=^c;(#|NT@;fsxO@B#k32=2p#+O!feL z4-a9(*r;gXxv*V_=1hRPaL~zwbOC1Z*{{IVndPZs`HOy~37qobkwB4LvpB3$(X3YV zU_0q~@^(B&*1WJQ`DLKb&zG3O&Y1RrtWf;y+iO}h-yGbwN1-DU+no zxmzewQ3dH={j|j?^FujIe(kPVD?Uazb2~9ZWdLSd3|TiV)^C)-XGtspXh&`hRT%{Z z*;qZ9L%6a>K)lO1h6EK&VTfT{Mb5>x;u$K+Fs)-a^&ndfy>(dW^G8BA_I1j8tr57m zy0q#`(Lgk2Va&abVkPVGo+iV_8)M6?)aM4S`r?O|zUv`LoX#MsuKqzrNO?Ygb6N=OM=Mm%u>o-8FfIzFC7?GKEp%;BoT@8egoFdJ5A>9(%hdt== zlbz$=%syuG8-n4#{k`aJA2&W@Yg0$!tl&!SNXxg{_5MwzJ1d6j%%2WuSM7QC7~bNx4R zs}1(eu6LChyqaO?%|#a{ZgN5y<%l46;UP?=81v@CSx`nm=`;3?f6Lkwi}jC} zd&jH%6e)?D0(=3sX8bi}M(e-`CKv`ZtfImVI$&GlV5*UD(wJMJogFEQ_`!Oqvfwwe zPI-n&n(|AtQhcR5YHYDBV4}sxC+gX|_i_nulub<5nt7+60dT$cf8e?zB6P9mm*?JN z=?2dW$5JmyP$SmS;?ed2{_RwJ^OYwVv(mr+BOq?sX4!R>0h9Q`o4qmc!i==|>6g3u z@mbmpRp2#1ZWMd;$mlF>TY5EFi^k?;e*|CyTY4B~TFtS*UwcJ6B>*U9ey0jbLYXDz ze22O~C#m$hQ?0ZBo-j@RnmF^~WSlhdVs7_7cJ1X6ol*B;tUB z_6fx!kPKc25Ae?|ZcM;>6=6W}9Jm0;+pYe&kA7)G(2?57DP^(ircL~39svBh7p~Ub zDI>=}ho?FFf50OAI8(|Cc-UX5HE}BcK3Z?MdXkrCQBFg3d$e(-e(Iuc0}S%IVk_yBc4I9LQ9PF<;3+5j>}IzvZXS@td>O zrW=C8@__A&Kvj7t;P>yTEH>H(IRQ~n5*Vv+CHmvcuvl>#pJJG^Wy9m}tiRF~KBjvM zUUcn@fcQ1V2FbtD24WaMuHk1t`b`ckVeCcU{+)y{1$jbzJWjf~4}XE?n{_^1_@{34 z1!}`y&MnzPaqei1gFUgqT8OVT5yENeuw}z$vATk$Wi}kFr+Xnh^+TraINw3>$}%H5 zpM2I;_`x7Yl~+xalK8G}kH;-Fxtn-5J0)`8OmEsO0*@&G>v`?z=&G?D(76J#=l_ z6O(nUnAF;P@bQ8q00&qT>o8TeIUkCMtA+*!bn}2 zR;Y9u^-cg+^ob6M+QA>0c!HJ>2S-;8ddTG)CAAsu#Cz&dmHSj#O^K>JIY#8;ynp$4 zh3C&%obuM9vbF#50@OEXvqnYfeLZV%syU?)Pw=P9$ZF-#GeYTV&@5=S(CBvG_j?StEH^(~ z?M()1WAhc$l6gD-rxX(_{?3(`v zw3x7A9mn_!O0KhX+!0#SrS<|<{iQAlFOBbS zjd@SBy=2cJ(3dK<^HzIWsz6774ZkU06?4nT~#D<{sCtB-&3t7C;IPEk6@20H;|pHdlNO!?j4lcNTmYXfWGmkxH{J(BavySaB?TBnsW4XeARhusEb5cW+S z!8#lPs08{r=tEr!K&AEn8!92iVSo_;*b;Xk;xNYvQ+Xx53OTbg6pelfRhC{g4ys-A zmx}(^Z$^f6)$L~G75QOkK?m3jvB_6$H65+d^wn&h^I(eS^SR3{KqI7 zF+4A!zi0pDyoSjrU@OmE)m|Fo~#Bi%24i0cJ^glHA?CGVn7&ZM-{L)=PRaKuoBYV^F_pj7nL=gY6Gw78`ce{e%4yE@j_+sP%I^V-Np`kQfSF^-Y}Li2^pt9VTy=P^NJb~ zCXnC5RX(u!`$Mq;e>sJXePV)J@lB1{2z?K~*A=SP#xM8q@bIIf;wt3<9n}nAvjllp ze;s&o;TzWfA^{6j_7_3GvR`Q?zVNI9%asR?+o zT_l9gF7ld3qlbe>6kCM=_V5fcv_@jaB6tr9=QQDgkornXml$Av*T{+fhb*)P!?G#w z$-;2|VVm{CE%%F(0_QWVTC+drfO|tA5MpBCAP#FMc`;vSmN($OZ=zl+w`d#?0nLAW zBKzoR5sCiK(B_Y(xs*(U#(!uLkAgf{tid^L8TDd2b5Emw;a!Erm#qyDpD&QgjY?sE5fCBFg@kXg4- zQVTDG#8`^gD)NI^o^C#xDY%h`w5^t zi@|3pVSRqX6A2PVizh5anE7-lPc{z4-E9iD$?SH4JC&m|;LsC*%jX`U~nQBHHcYv%o zG=cgd+e7MhfjsFHOL=295at}Z={^WmYq2L|0KTva-ZeZ*R?Q004B#u ziTFLh9E(AWv^`~n7yJ!t@d&+x* zxV!Q>LEPOS1a8k)CSWdm&{UNc?;9%VAU?v8r?4MwYQq|<^D3JG&6di_u_WJ`F4j|` z$&G+Jz-o`?G#NktaU}lfYJT{h3j0ix3THQ1sw}B|y56y1d%GQm{ceRD!hXG7QcgvP z<-ON<6#aEDb&JfiU3ed3X5G7j8`t*+h}@Mm(v}76v_s;UlpwfvZCLGLYyaP`((p58 z^a1e2f~n(zV0iX5T zZUC)}$R{&)sPwdq(P78=&ymzHk3`tm{8z2n4z?&=X#VgnL1{`(U#OIL%w~ji1q<1f zGv8pV3ZU5G1PhV6eG)j4meYGjAp`tUG5X{RTC_z@E_7OLgGZc!L{Kq9=sv@5>JC+` z@0)A6SNTaU`rlHQ<~2Vuu?M1aPkcbWQXiwSR6ASKRTH{N`cO`hJN>MOd2&y+i=8^N zYH6y_0ZB12@G~nvigNsuPD#RhNO}KmG^z5dXGIjBXmG;~pGJ~Zapj*Xv~i5Bn*8~0 z(>r70$6@f#a5kTA^jJ||by=|W%SnvL?PeQN4(f~<5<}5S(POXg3f)n>X zl_N9->UGdXZX-UwH{BU161Vr+N`mTk5qx6_m@ETnI%VBu8SDqru7V;c4YA$UeGa3l zF>a0wJUe%;ysydUM#8Rot838^vb@V6^^2Ejy^Tyk!9@p`T&6oDS7hR5(#>|KGpbde zMQqO%^~=)>f=brkGrrN}mc~^d9MORJU(L52vunXbS!%C~QERiR2-QSZv)M?jkU+r1 zYyre(1U54Rj`iEimqzbVcZZ<&O^fi=M6~`E^!gf&S5jtml8tV(8kr}pHNGHT?;Z{9W1;DKd*t3 z3oW zissxc$negWrtwk*PS6&TA2-KiO3^u|x0G9>ef;8KOKF{oqq1oee zKEqV(w5e`L%a?H*V>OMGl%PZ<;XoCh6IY)p_ zl2|ftmYPy`jD7Y8oS!#1T>rS@z()N-%>MrH?1U#?B{!`PLOJvM7uoLIzcuu7+pE4k zZQCrbY*8HcIfO5|_dp&-?$kG6r1<6uDR|44nTAKw8HkU&jRl+ek!Fo+d(81@{@+{b zwKyz3`CyjG-Tqc{p8I6IDG)OBmnv_M2tE;CQdIxCyyTVh3jKK5FDzhHl}M-UdlWWS z7C7CNR^>}W>yB3~)#=Qv3~_|L5lssy#52M!yXWw!B_5qD0-n}QgAJ8FaIE_#XJBdp zHCd#cu|~S4d{l`>kpLXB1l!&N$^e7Hk;x5Gkcm`cJI62yG`r41#*vH4y;UY``&Pr9VQu~| z@aFLWje?G^gK?}tY3e=^z8a@wvHB;reanx-s1uuaYiLl053QnFjYyiKoA`n9bCNkE z+X)cw1~2skcO#Yn#olV=S6XIpobd7n488zG_9q!XofQDW1%xingMtAk}4|s-0bK(pG;`LszQla^h9Z; z>U-?R%c2!bhfBYb!=W&~uwgVNY;Z3N2fD7u!m*gMaZxKh(6F}6Bx8N>Nw zll!`(zO>&RW&QLwKwa<;wLN3T@X3=?pB`4<(k+DY#vwqZ41Fs?3?Ix*rv*VDn1r21 zoc6|00#*tc$*L=>{k1fz)oDBg60Uh44V+zj@cW{$<9F#)7W1w-K+ir~@|TpJ&)ZXK znQHN<9>HRh`RZ`|aX|+dCb9OcaKJwGZso3PPLeV15|9H$mwtPiPPg`5^0vtzg#-a zI}Qm&XLtaRA5=h)_yj6tifncC+LRY;1Fj%$MYTp%a)8d;W;(FGG4XklP15eo2!_T~ zJ~W9sY^PV+zsyA&3_CvWLRMYOIoaner|dolPub)8Is?Adu=TGyXAr7}j|kZ&{AkO^ zoEjht3@HwKAo44yqPbp5Z#D>(Wy>8VP&Btqe@%!h00waJhSFxM51p^!5ZjOz+i9nkwQFETlPtSZ*Z_z*Y|Pddr#uJ zF9}hgO%{`@Hc=SRkloM~p?b3bE_Piez9(%ey_xQ-UgAFsX_CeW7jD0{E!o`(vbZ)b z1@agBt|oUUW)wpGMY+w~JUl-cG!jtBLz}Hc8?Xj}%Y1GEIlv^wgr4S;&@l|K7QA1g zjWa)6&s0s|$Y@HDEBBIW=w{u=IDsC%>}Zi&*V>~AEz`8T6q^QF+m@Rek~mCRrPSS| zxpaQVe3IQc$Gs-S^S406)m|T1Ae{#JH#X-ShbDr^KW{F#9CTC8v#DZlBt12y7gnQ) zv+Adu-{p%SXNRz0Rl^UgYvgv` zV~2n&Uf?_8v$H(}V&?lf01g(Z6v|p|-Gz>aC3a`{)|RkabBWB44la+s9>tyG(m^dh za9P|8ig2CG_Fhhbr?&h}l_u(ql|of1_Q!GHpziccG?4t=H~Wpv1EsyOM3%3`xmA2A z`!&xK$>GYbplZ&gv)A>j1S~?-^2tZE^^uBg|bsW-R|t*oWL-gTd@WmK~pO0 zg$!GL+#BaUN~u3rR(=W#@0x#jp{lLD4hj9j3*LT)`U{j-=<2-JliUwFipk*HXqMli zje1C4yW=s=Z-}b1U*U4yH<)6av+n^Z{tCs2sf{4NJg@Ugm)WEC;Hs-Mv=FWjc3e=8 zf+17fL8mX+Y;AXqe{G>+lbO0Xb>$W+(foK~5YWoGy)n6Tx6d3l8H{B|e0#leO|T48 z58a!ii&ep?WjpoHpO>gIU4^4RrZU)+tt{l{3r$~{9{%*)Z8q`aI&AUbxsd1WlkHA> zx^wPyrIazWXgZoM`=(LBYeBnI^|YY+f$P6mX6w*B0D5o+rQao_x~#m58JwX0>2 zBg6I^CWxJ1&mo*rXM%z?0V4mEI_8j6=>K6CjC;wT%~eE}89i#7+w$cos%TR7Hve7; zKCY>(o*%n6fC1>ontird$tSt^s$jmjjr|x$2=TB0nqus4yZ!JU2C;b^$P1mffszl7 zLYSFnzt@WYNyRu21P6t0GdvVz66Nsc%xm6 z4`di(=l7Sio!!u*j%}2Poz;1ZZZI=VreH{pW3ShgY3!*d&(yi;m8-wo*)-!3G~^7o zyOOEA_uMT(NkYfIK)3{U+kN`Os}3o0r@ObEqW@qpF+_KUF)fB{s}k^gW+I1!KT4a| z7S)bl=q46z>aaEPXWIDFxsHDIE%k8;+$?-+qEa|)wf-4g%& zH9ZxFfDLJqzyt(sDDv|IDBK-}IR5(EA5mbsiZ%Gr`0WK7QltH8&uL}huc!r~stgAG zCELKncy#c>5KIPBT08s4WyPOX_Da;QW|)dy*Ky((?BkX-@IE1bk)%zRAt!f$?w*$u z19zYBOEg|9H|nvTIAd1oX;N=L+JdEe2?fi4u>;}_nytnZFPiZ?%z!NpC-_()fMVU_ zpX(Y7cl)u*_)Y7?87RFs(%_Y}k_g7LZ;y{t64nCNWa#}x>0lHqEilKrFy!LpNT>$Se~$e zswt`3c_r)Zt2w9D~g^GldV8sA;eK*V(YwA7Q zAcXv)C+M{6l%5k@NwzGrANt|~yAR-pq5u5Lq&O-A>m*Rdo+g^{y6$DN=~@S9${l}P zHamNK!9788nilqkO?o|t-aIzN!{}ig<&Qu>?UG6ib`Rjgdo~;I$?@K;E zD_o%wl$4IS_L+Zs3>uST$j2Mo5b?=eKD3_kuNiQ)Q#3Gr52@_f!eOFTU(R2saUM=k zVcQ~K5v{ggdGA7lu9f;BkzIe6%Fpf*eZ^6ZelHyNm%lOGz>|-Wn8rEvDs-r4MMR@a zdriGKf8o#*{o(@PGUtFRowa~u_^)Vz?N@Y*CO!QZM;ow986Bm@an4bHQPz|Ftt__c zZJv{#rpmm;2Zk(gQFuT>ck~26f*zx45e@UlGVCR0D-XMx8u!i>;hK8G1Hkult$lD^8LEe|Qgw*<0+zi^~*u zk9+)|u96n;F0b)-HW}XkUeU(TleUecw3_7ex+(C>kHXlC@+k`GcK(Ls;89WVU;fKg zXj^W~war{z?;7Up$FJAnz|c)(wUXIi>)Y$=@nHI`FZ0~>(di5Q+=(73H~O1SIo(vI zk60DFTQ6+-WG8k(xYlz-Z*;brReU^W9|ZrP+>Y@{EPxf{Y`BuGr8qnqY&)D9;X^g= zYd-KS8f&wcumBM%S+KrD{eA%8PZe7s5ecvLMvzvO=)L>)U#b_dL7U+Gv6H%2^HYRt{EpC zbv)uT!5SA*7gWa6!?Rjy=avOZwT__{qLRkQ@c$SkY+#9XBi4NgE!ck)xhCYY?1nZ( zc!1-B##B{W_(yNg#fO0)+UcMTzZE058*8eF6z1>oS`BluC#1qNcCI5V3&EBBja9QIc;-$W zatq!%lV;u%Us&=E75P)Zkq|($EHa7#>BQfQOAe-O*6pYc>vxn1>!cpUxr(K+62E=L zt{bDU6js3roEajvltcV&cl&rl0dC+j&C1a{{Pbwk4xKikhev}h9P2*mVQHbb;7h^% zwChLwOm3v#$@*9Pi^Ay0=d>fvv{wyr-erHmP_f*@gCmNz?<}z+*;{t}%_iz0h$TnzwL@B>}p5c-3rTw!? z;8OH9Cu4rPk1|UsnN8^fHm2cBQ>C%$cQhksK5Xi0dO^v=@H{;iz6tbGa0c0a9HZ0w zX9h%8;@}MIFB@9kqQsrU*uKbV9{tLPoL@}XwsQ@W*mq1f_!rkefrSylh7e@(kS4`q zo>7vELvSx{pyA1hMRa`OQqP|1O?L@%%T5O1V#};n8@*@6w#&n@-WRS1oZV*~UWSK{ z>nfKe@Gw>!nh%K!0^YdF_d;C$r9`isc7LXuapHkZd&C#g2s%7t{>xyiymf8iv=5U( z0?5@-y#9YP74v%bU8LKywdFujP26y_tPA5WAYH!r(1!14)AeD$`&M3}?jmpYTW_G^ zYvul~ta;`hzBxgl4EtV>oMTg!{}4x}zxVT-secOe9*&}FfLkO!CbB_R#9xG{prZ?p zUf(lEBUI6#+>H6{q`9w$k%uz0rSio-*9SfH1nN4$mP?l-InuB?&qhAJiU`u>7iA2D zNPjGi6_<-;P=_tmRs{lhgZD-q_L|NbT&1I_wy02=XwNFCvxPfFb7P^^IW*ZKjPv&1 znTVtkj9>N~20cO$9tqXZf#&N&Io^>Lk)gbdh-x$f{7xyE@W}5xisT_c96$7N4kHw| zW`(b47;-Nqy01j>asii{G#&P_lznbFUyf*>3Ec+@)4+yroH2Wz=<9o4De>nb zA_HY9_W?1s{@hb{{N?hB;K|8-0l`pD?AFy@%M`#S&Q%jXqH$Y|6*ylEm?ucSzb&RD z02{|VM3dNa5;-#%7VMtleqQ6(5K@h2l6;QvI@8lox;u#R*#!Pv&o@e9)oFGbr%&Y3W$UJP^tWVcsn2r<%-fy&daO1sW3s~XFi z+?smp>~xWYn65_rSk5Ctf^eDtaFNg|VnCLJrH=t`6;9)GJ;Gn&M*K8d2pXg-(Fni@ zx+I`iHzD6^->>86>1;upzJ%x84k(a_!$@S*|A@$%9|Z@Pj66UugYi7V;)Fj=kH{gt z!1OruXLwf9eiVj!rGJ~|=b9o~Pj5XPdHUT2!&>}88r z-Q9aj9R<~|(Gj#g-LP;T`t#dum2b1=sfAzB;3`Tf zm~HetNRP=9G9hmSwBgxq9bWe>uZz0&+1bVc`X})RjB-=JWOg@cx5z zQ(sGZHuMe6q{rzP)HGiBU;oDofT@Vp?2jM;wu*atgiQbP?iO+92!reS+~7aww)nB) zxk#~Sv&d}c8KkmI3u9xR!@@(KEIw!VATPc4$>>F#^1@T06k8d%ZzF1 zxXW5a=Ds;gGj{I;u~H7oF!lL_IvsO&fJODStzd}5 zv|=`~n4l%9j&gw1F0TDz%XRdbiLW6^Ivr+F)R#OX?DR=G^A0n*D{z?M=*2g8>|mZ( zc+X_4UxWx?E%hz7a#(d}(%pbbJN&=BlW(W3Dk)Yq+W9)+a}reCEnGaBh5X|3-Do4V zc-nV?x#{w)n($HJ_WF>D(X6_dJCut@X5KUYw-d!Dc2pY0=!Zl`Em=I569vjRg1KPB z=Mq8ZPrqnY*nZGSw#ECGDwE7eOPzhr!6dAw-0+#LEfG%OIP>^m7>ohy*f(gxHO~NiYD%Gxc#Oi7D|Q zU5$iU$hR0A*Yu8~Bs0@&b`7ix-Yh-uwA4?aqQ|UI34`8f$bC1Kq8MM<$HV}?Gj>16 zAJ4XVjnp2h@IzcAu?SU8^5&j$O;AOA@S8vvwfB`l#ec7kn{IB2XS4?25cz50hAxw#CQCSi7HQ%(`&lIunhH*MleRn zYLLLYQ#vJ#gV3~O5k@*Y`aTW|E_&z}7emf+NE7|(;luXnVArs_(^*>=BQr_=(3qRS zTt57`U^(`tzhN<9F&@0uaOCm<3tC)4hR;RUZ)fNTw4_rC7UD?BBc3Y#GjkrpBQRny zg6O9b)qpD-EVAlq1Ct!j0Ty8NZh^Ej!tn`;!@e!i&NqkL>*x(!y$_4tyid)6cSKIN zu`|dk1fOO%z3ZLq#*f!~=C>O9^=D+!TFR-=53Gp#TNEmtFbm|Ch0nPlFjYv#=-u}O zOv>kJjntqZayTD<(d^+m>ThSo|5yK2He22BF0J*Ba|JGt!H4#vZMV+xivql%Z5P|g zyc}2|tp-S{T}JR-k0H3T(&8Ui`_3gz2SS2Y(%NaES62vt5QPS?_~{?$679u}KS&g7 z`Bo|D`M7cah`n*qHO8{6zS~~FPBER_P?r$cj{SXc{77+cTBXJ(CsFIEr=7eAoZdx? zrQ*fVMYl!XP~0-|bUbSEbPQpv1)OH|N*Y0qsaC9;y}O$h=V`-`da0zv??6vD9kYot zKiL~2Wx9n};cxnPgR3{abkC*F`go#Zn{jMFJF-nq+8( z+Jn;_mr0sqrP3FBzem90`PgXWe=@HYCZjdxlUB^a&2)bM4EqzYACpN+3klCf!!+D1mEM+=RX)!pfh*M?=(aF$~@RV zMC$lkhG`*LzsA??2x$BE82$}sptox;w2I%c*%lzf;fb?Z9_TiZ@ujg?6~8VyHXd z#i~Iv2;Hrb05q{-!*zV-unpAv+%c?pr{w#pKgujI@6-2rGk^AXf6ys)zf3h>gtCUx zVLbkCg|cAqxESTruTwP_;RBCOFRGk&(@7rVktkSv?P^3xS!f=lSctgmlb+3Ix!$R$ zIV>Yc!5ds%>@ZQ2yJm%nIDL&3c9EjIs<$rh!!-W_K0PBITpfqD;T94%8f^rZ6*fxW z{c+TP3KBi`bIRz*jQz=#soJ@y=rp^?(XdH+n8x zg$8+A-3&i4(mP(fZl|Uqo9>O)LlE(CEd7-PZxAcdMzXN7I@;4NNJRRRJjjms!@z3j zHJ5}4R%cg@@-tUN5JW!?l^`Onr00m+I@<7AW9%mOC#nnaAa+i@FG~cpUA^Z-eHC&~ z;x=gl z66Gip+C2#Gk<**WOI7iYXBSU*(&F^Gg1@n@5#bOL>SkcIOLdJMyXE|ajXEz1-&!w+5RC}gUt&RB@fyI zqWYclb6y)=Mm5>ER>Ii#3#(yXp~PWpt8M@`Q3Om^`fTLODny~jhS=}_2=&F#N@o7D zuK8nc>|y;#%V%Ys`U&tB`*pg~)Sa8bVuczw{b<_n<)u;H?(hlTV| z%GSOy57migtt-}*C?V7u2#Sqzhv!~DpCK!BO!2gRa?W){X8W~>Rj2kVa?pk0I^5uq z;*v-81gv8c$~~a>7Ce8@PdOT5E6CDxab&FGHJTH9=usF12~=ft@$XOa#a>^WO};ZO zUskN3i)(4)lox*g2D63s{BVBvN~nUqE_Bab6Yu)Lf~b{rD%KDw78<7K!}n+`blv{= zt;(GkSixwaEF5nr@GGA#QNF?0ec11>)!Jg9%jj~MBXXH@9L(EDS8jvlNS1ir)-#7C z_fdVXsj{T{wzfA#cGh5E-E}q?eljh?R3{X%A#PSHP ze^?`ZjOFr)Of&o~Q49`7oPSqRW|-#S7fHLPbM+zXImj+2cs@_$+57|p(@Q1SX4&!f+F zaj$~f%lEcR62gcTh*Qk6{qt%mkc0U|E`0`%G5?L)-!LMAvaa&J!9>Ohf4X9g<)`Ft z;+mpfx}F_0hD5dR8s_xmr$IW5d=dss1ZC}^@>BP3}X-G-pWd{3=SliD_+j717YHSpw%i^WH z&zp~zD+{IL;j^lNkfzyEUC@SZSkbw@vJL5uW;0@6)G6;++?V5W9gENWI+aOL zvycRt;R}4<-00OY{Y<{>p^FnR(-Ad5_Bip&Q+moix;7Dh#}E07y|Ui~f8{oQpEA|a zRirJT$41~4w$muV4w^2>dB~`r8godpVv`N6c|h01jj%5XXtiX{)H~8m`?}gP{i0T`;X1 z(HoLq)OAFUCz(7~lMX~s&nO7hKgs;$`HPkV)1Fi^3`Goo(>KPMdjk%x5m|BL|4|85*GWS@DVa@+`9M2z6+AG+OVw4}G0 zKL<3!lvVL8D$O;6qq{84*lNhdaL2GfTMQk+(Ly>>9OiGtNyl)cah!u*^-`K*!$ZZp z@iyw=L4#dc8*(-Diw*DQ!a{##`VBvqJ@!BL71RU{I@ma>XS>_CdaeG=#pOaUw99gb z=E4I03jXFYDHXY)!jI}wG<}A`;|p2pO~qcZu;50LgStPKWz0f5E}Xs5V3vnVF7G$& zloZ`F8#XmBDFuc_8Sriue8MMzQjRXvnx&tZH>WrzoJ(--y?AuN+$(%oNly8|_ zKj;H#S+;9TqQyM*Cbs!zDqds7GDncdV7Lg?FC$nZw`Z&=(Oma$Gaj%(=Re~a4>#W) zK6#c_-<=TcTX-5KPPBpa6>KnZg)^c7E=oS|RFhI+h9{5wnssbR>uFEUPwgU|V_UqH zNCVx<_YC>DNej}K?O;^4Zt2d$`80TYEcsvPN=eZRF2>BXz{3JC;(Ff}Ny_q`AzvA zpg!F4)pwqE5yaw}{v-lq0+DhkGALPR#?nQp%$+Uu37w5fqO1-7O=W6+qI887W=1Ve z`*^WOqTM%%fgpqIMVOg^#3K$0RYA57TF%I8z5rg}VR|7xZ>_;nMum*(N>n5Z0agf9 zpX&yqb-f361t-KflI6)14x@NfxuQ4`?LOaQ3$_3Mu@D}~Kvr9&tjPiB;DBy)54SoqN$K%Lv!y0|I{bHRW%$MxGm+)paQ6`SydaIC61Ek=YlocEyd0| z7Wc|J$-HkEAF<@WigCN$=8_o(eU{nQVv+);=4TE!eR2D!PHiJ3`+=j0_8eSX9Q?Q;Wq{n1(}Fcvyj$V+So#wC7e#P}SoH6{ zEy>mIFQ15*%d>jnRZw-{9-E) zs9MX)-9=6j%8r#e`Rf z`*a29$X`z?x}j=;>(VkTwcDeQ=wVj0%Wfl7G46yRgVLls|1m7|MK|TrmyR1qLGC^N z0%Zo}FQ&Ga`~|8j$K}=c%FNk88h>as`$@ z(R^|FZ7h{?#x@Kjb8GH{N`)0QFfd>#@b&YiM+`kamCHM7+-nY|!GzC0zc+-zPTn4a)e_oM& zaA@p(p*9m?B9*4|FxWIxH!7W%mV?QgLsu07*C}UdaVcZIPQX11eBp~JB?iQ%*?W^( z1{Fb{!sb`(htnJjNKOBAq>z&Tmf{{=prhX4mfn)MvwhSd(d#^KIDo-rv5QpL{J(vr zw$%J(;AmRJV&4XiTPW4{ry~qloNfCWHe7i37q?F-f*%h2t1uiPDJFN<(99GUVB^WQ zoRvIXD6DDKO^x|Hu)UmHFLS61>^pY(-cbHhVKG?9R94?FsnI8bKS;>TvSLFVrxXq zbIEvnQ^VOF{T*EF^TmOTV2+ssCD#HU0*k8y(B;FdopOPSaVmiq^^#t&lc9;bROfxw z1fzoExeGFlLmaWNfRWG?vjvOCwA*A~qpb`UIjY_%tYZn=9!71ufL563_8FX+_~i`1li(P z?^xN!GX+Hh)5{bH-U=i)ULQAsDtTEuS<7s=jsq}|*^eqrso^?9?{tC`e@tmMa65it znCfIbC0qLw6D6zqdS=Gz@Sc>sy0xN%{DSo{8+-c>kMlbnPKBXq=b;#Im_d6h+c!rv zZ}qw|Y=;@LmwKO2qa!h0Rp}Ms$MJyZl(ZvS&gO@k()g48;*+3!*iQfqVau=S1<87h zujx0zx{&h}tv0hy4;()h3)^c+>k_0XgEjKQa{6{rj+ zj+OTrmw*6!(oGcOnwsKo%^L9;Dqmk^R=s!W$5Cr!Fp#oMDPQB8Ut+~_jC4E7zpvJg znJ4#$%HMjNPpg!2S6!MP%@T4fF}1|KOkgET((Eh?oQIL~B1*SEdPkvF%a2}plI<=g zi$MsG1(>6o-*;UVV3kQx4`n8(rWBvP6pemvb;;j6g#SI%7#!&6teqVFhh1IRux|;W zUw+nu-qY_hky36(v{*ers+SfFE_I-Azk0D7cSl{+^lNprVSQU4`dZlEhV1L5rw8TC zr*b#&TfWMB!x%~J1oA2$C+quT9=~#X*Y(;CJ#Jc51tze(y~)=H(x|I1IDntUhS{mF+k&v_yli!*2;D{nmT$=-w43;#O< z%$~kfu3fVgv}`YHr@s0msWfWP`MAX%S~WU(;e|oRCh+tKsa4BB+4B$rJ=&A~592e@ z_O6HAz&WvxO&GdYV_0GME<~^o9Zc%$vTYmwza9D?bBsUsa7uP=21YG(@)DGMm|mp~nHntE4vpQhWN}tiUF`;+^fjMD9iH#U7iUb_s6q z@IOno0hC4obTDnr3oibzP0GzqW5c50x>_ECiTqC1!5z=T;JvO7rr&B_-v>|oJ=~C5 zzK)jZs|PCAC2a+BJdP*_XPEG-*|AJMs~j?FbZWJqu*E#v&~x)p*V@Q)PZb-LOFY4l ztr((+660<})E&Vs5bU4EwThBC{NcYn-TcTQS?pOFbNTbS_P4{o<5`C^f7J9wNB#% z7z>6wQ8aD`nYVVePP6mc@%alBuHvOnBttiC?{0S9<(bL{GZ3HGmJ=ZHiXSTnR*d^3 z@ZO!qTRHdt2ho@uwI`ktIWz^4O0r_1aDFIT6CjV?n_O+~&B(0E*@*HmofQ-Ag9ByW z-rb1&T5DwouN=WPFA^f@Tn0y9cJ(uNjk}E64{Ny-(ZaVBSi(1ZQNfF=I=xpF2|Xul ztiuoU-J_d1m>*3MxR?9{rb8T?3U>}mZ+jp6^+!sdoR{Xsg+88eSjuUnXsOmu6=}bI zua61EpBYkDu)p^NgkySL*zI z8MJZnWZ}eYOj9IEdNnT;HF=~TMA`uH5}jvm7@bK!s0MDC>Iz{n5_P#eWf-g1I!-Qmm zI0I|zPuM4E%oCMVrz z+y6ol`${0F`Vdk#%x69nf=m2}v5rMPs_j)%uIgZr^uO4TSqowwcESnzB`W-MJke|c zgA!G4Y-A^v1X+u`*Lw;hzo7q53-HqYcD>nKA*;nynS9ykeqipsjc$`(0|~fQM~bD9 zx@yT=k@T_vWVUe7d6nHc+#_WE94BjW{Iz4FlgAe+t^g$pD&xBszdtp}gWty%uLBiJ zO@K(4kuvk^Owl{NpH{+x9!l*W9~4e}c2Z479H$(n<;3K&C@9xVnt>=4tH8n}y}uk`6?vY+R_uZ}1; zb~pB&rj}HYw<*1|<(SrKNVYLJmHZ=Z05`mo#`O93I9CzWCbGx zy(YZZfJ6vm6ltLxygL=f<&n|g989OLMYYKpWPUyW1Ws%aqay7)YLC`m>~HZ|Du=SG zOYvMC@FaY!vRm59u?Ry4u5K+JX1OC`AA#=Yb?)LNTX^$9rvu(9ovC~yzZ}N>UXnly z4tta`?EI2ChdS+RAq#fZ>l_Z{Cun;VN5$F4jv@P$-mB)_bvyB#>KmvOJc|E5;kLhF z$ON7BfSk8?tqO?sMV#vN4hrvqbpv)x0@C)Hd8<17e!qr)yr_wB`Kas~&ci`kiTdkz zm&IQRV0w%CW`kme0E((CUDrP0hbEL+*q^j^ZG8iR%l!N#9e-;d#M`}XunXeeb~DU> zrhw2F3fe>5#A65;HfaF6^HRT(EOZg`E{032Q}~Wy~@0QXwb7z7j>Vcd)aTc|sn^ z{}Gl`e}j}7sW-YouX>uy^%GETB0#R|QtTIJCMA~gGs=V zEhtS-iMYII(iPyMd-}KNauN;I-npO-Ha)? zjo5pvl3_BvV_9aJJ-xf8jA5Xfb|6OBGP}1W`p_?#=%9r<~(!)t49UYC&VXcXk zoH@4p^}ELE+pbUB`@nA0@vTP=1he(1TsO*BM-@{?mCpY5#+zhEkL!b(dfG47o->~C zcWT(F{I%W@Ezsx?6leC!c1H6_aDca}yK?&QN8jwzJmq#!XB?z)5#6WIMm2O0E&S1J zB>_^|F8J}VNGUtt?nre>{J`KItE0+D2EK$gr7Na4YFm{iVy7<`P(D&67_JOxK-$+n5!rwfv^cwuFs4;aEK-KGfICMR=ZP0IN?!fqN z)MVU^0Y)=T5(&R4prQq4z2lM#VtY(@dKFCa5=Hvuyll9%h7qo`+l~4;!R~#8f~gb^ zWM^chWQ#3>Nk997#Qw$UPIya|E%!!|Q2tF12@Y-nF`NS673e^r7ozKzYdO+MYbw$x z(*I0s69qkF+EJg{g)=k_*;%6873J_|cbKlae~Jg={~IFNaW(Ed0WS!=gHG<)k1)W@ z*E!4+Gi-oS&Xp~5PE*J;wgj(KlO<13azt~q*_`8J3h(%NEOOk#Lph=7*K)t*3Btu> zKVw_}yKCob`AI@zumQ~=@)d>EhUllUwtN)ZYA&lLZLVt!fiZ|Us`5SiWn|o6ojDaB zw>aYa-NGZr-fXnS8Qo@ISv7d4${i&zq@=1fBsRo}5Jr7Gw4zWkoY9%%*yemJE-&gq znW$9}?pG+quKY(gN=#8>S?E!k-Uy&Ve2}!S7ithtWJKynC}GV|SF%PpxB>Fiz1zP; zV4i)-kK|nuvZREZc-`nvsAufDMZh^DhY(H(t{*WsYYd>@EWIGtr!2oQsPb4v_ceJ4 z#O=PgaspUpb}##u9Xwl-ly^?J%-j=i@9BfVs82HJXZT z%2{6GF2Km9G+yy;F;zq#?QQa);vZ{af`L}6hz%kd#Htx526rEtsm%~THJZP?J@%7a zNxTN^)R`;ds9bB)J35L<+P$qTkq`b8LJ$>+MwQ$tme0fYC?3X#k%YpV+;o*w2|lfZ z1rH*ZwJqa4BTtjXpUn9%`1i{&1BjE+M6x~H?U_nFO59|v9HZ#v+N`n)!iaHcsB{Vj z7rl8!Bh$Q4!8pP$ZANeD?3Dfz@+~YH%+3OLnOLugo#J4!%_?=htW zmdlUWi6idg+Aek!L=qdD5AW0S&QJ)?ZG}96O{~kbQ2^l4mBGUrV z$R3o^wl>{ij|9|UjRf*iVJ} zc)3XRLQ`v?5v55JDP-lhSN~qtn*R(rXVp6uaif(YUKvpio`(rx+v%3l67R@ZjkBml z&iMC41j~n%DwYK^x6y^%gCFC3SDPI3|C@t~+QN{V3g7l_0n*6w$E;*eOLSq~<%H`* z3igazc8cQSfp=V2KY_YP{=E)Z0lT47fOy- zqz>VWM9c)-<|d%vCbGg@Kh!WCM@W6Qe~;BIF4vC%V7R?u$Ej!@==Jna*3f>TVw^T^ z_`awcgTi$V@iy{D&(m3$w0S|pK=5Fx?qXAAKdspPmZP>w{u@22`5c8QF30TfkIV}WXMY#B=lwP&_}|H z@)F`njnb^^0);Y*0_i*IrFyIbW##wtR_IyF19hSkqxt;ec9J$0fEB}(NG?$_5|H3x z>x-Fvmc}e!il$N_@z&=<=`HpXZbUq}oj#E-qa7h`gFqj}6ia_055WZnxR`UGSSJXD zfvf=u)pEP_wSet)uWm`H@s-9uz|j8q3DR;7KRkWPi=2mrrgy~}y-LNhz%%3B2~^8T zYc%7TS&-}X%;UK^9--Sn(r8lRwu~f}Y5!d!+qFWOcjZ;>Z+SZZwt>GclmD?_PO{x! zDlM{PxpUF?p4XjOj$~HNHV$~@Xw(^BP!V;Q^_d&Vzi!WX$7^7%_w%x*{&xO#lLGSp z4q}DRbhdIl(}Ss(m84|l1zj9Xvo1rs;K}(8Prp$8GEjF znXIiZ$jf1f3~#@G>z>sAZvi9qQcnvR#eZgRJwI#H+vA50ft%N^%#R4%j2sSS$;_3F z;2eSodNQsfelaa|g>5#*A0h5K^KV!a$bP<_f8?HQ8^z)s#yauyT3gpB)tZp=jl2!3 zIUCl}(ztJooFDd$-1|h`@)U9bws(tKZ}*E{+sHf73RVl0r zkgjn<@i6qAK4z!TYv39Bw2TJC&S|=#&uwm| zBBfIZxtHI{@rI;|{I0TPKycKBUi{&aYIPKThphSs{E74VD-5J5Q$s)3LJQ3_T>1}K zf#3&MSPm;4MD%tuuiuzUunLz z^C=>be3%T8vhqF9A@X$z3lzh?AAz3=7 z-SOvIbUDjiVpiE;zF~brjeF(*DGlA`x@Z>d0RSSj>~vS&;lMt}nn4A#Fbe%g+H5Wv z?-8Q-wW6hjM?`LD4fFG2ulO3rLqEp^>%>C)o`v3OqZKmq{dBM^bUsO=Wlbb3lUt`^ z%=<^lo6*9{?NWLG3+=$Xp*tKS|Fqe!M>FoI8TK?!iu)Hf8@YED+N0~lbzz0EE4|K? zWQwbBPmQ*jwVLk3OMCYK`LNwKH$~%gzT=jee0#Wi=bP`d?NZ3PH|a+DBPF*ps1;u@ zKb&j=T?QcDEce%`{l_q(9qVj-jdoTDuvSFGf_@{d6LpAqVBR-4jNc+6UZm9L>^MWpj$6`OIYr|tU{ z?*KT=M;xAYa8FZ$=(X}NScPxGey=z*x>_`y%z1c!7V#zPNP^%zz;!jHu^^+w;@TJMQEDBOe?Kx18;7JsgZ1NH(JhoV)v`Z7=3(Z5KATW@93?RI8c&OtWN`5FT+<2T_N{Zx4@W zMkAS1`rUOJ>gAapt#L=jjzW#46f+_Sw3@v?U5uVKJB7F9EcE%;$tEs3Pb5*!hG6nj z)7D-5^>1$7B`zb|d-BKQH!Yxu`*Fu_1~3}rz;g$`DvcIq>1`F)&nqt<#3oIE+72e1 ztgu2W=}!>xRsOb^v73V_KgdfSwMvc9SrRccb81}>R6ng!`S0J7rIY45F7ENMIh9*? z$N8LW214!lnHHT++{dLphpdS?@01ukvnU1NcxDDgJ#zGt`B-+Hsr`)77l09MW@%fh4ME3a zK;3^^VgFJW9BSL+LkgrPNs7ligSbPkIwIfolSoW?UR#|TP7flqa=E(Rh|`a;=@f)o zBMgu=T--}>EHct}6UMsUSJJylOTzx6~5ZMki3i>PSA1owLYn+bQ;0d z68yy!ORQ-=!~JE-HeWzFyQ0`yDq&qZU{=KStg;ynEJSa|%C%;aTD=rhR|h~O(|M@*{uBG;@W zd_4e0*LR2Nkl^r;)7Wm$4pf~G_zCeA{u6v#;6BqT;q>0XseygDW&Ss#-gUjB{Y$jAoZ7{&ne3=*Q+dzPf z@J)^H#ty}~K)rdpD%EIl4-YT8bWvIPHj>#&>d%;#(4%>3%#lCt_dAdW5Dc2C`bDv} zxG5=o#l++n&wH-^(D6G;23i5G;;O=uk{8E*2Ku1Wo7wMt{%n;UXBXgmXs3EM4Uf95 z+GKF@M6KJ6d)Zq5*#{H-WwRSc>8V#Ibg)y4(@j|ePZxOkjY~}@pMLO1fKXU|Sf-a} zoWcG@*d3N>GTz~Hy!vx*K$k;#><2uA1qYZ2g;jx1 zmoOEtQK@culK)rf$`f?9-eY~J_St-jnK0yKc*gX1bI>-$woD(PJe53C$BPa#Eu4S7 z!Sza$Ygc&Uf?*&)V*L{QiLVUO7CvK#A_Q2G+6EVLUsef zIMJ|wKwAS$Dbeo`EWd1uHaqNAmxJC0iN#=9fLvT{B%=ON^i}2S%K`OptKj4U!A4(u zW~FZqllx6^G?ppYlbHVC1`i-?mf2>aucK4Ku?pMEdsVqrMXvFSSaEp#qor}#h~x&| z+W_2x(iNO`r;6oDuEl@|X*}ega)DgW2%dfzQJPZri#iYc9o7|YTG&~3{iQX4gPJXv zt$1ITfR#M+Ji+V}D30n)6#PGhVR*Q8j;k_t6k96!&_4FLfvOImbh)Nj-rqtG_Spk_ zd2*N%lb7iCU+G+?sMxL$Zl8N12!5?iRM?c}_-Q7V9$GzycB+}k^eNx=D?gmcB;-nbhKi## zXwlDxd3#Q@=L50VlyAz?U8UmZeHiu_%lCQ%T-T1*N64Y8`J>Q+z_NkT{3+@=iF;lA zrHT5dukwkn{*|&z+=fJ>)+RHYl4Q5?@)UhGuO@k8_l&e{xJXe)(*B_bsOG77=?pk_e3V=cOwHSc(( zExozA<=Lm(;11?+ZQ&Y$zO+awufAS!#&miW#=@UwbLXF-K9Ad9ez-EER7o+) zftN2hLgzkVw2>6HgdCM|_Jl|)2b$$^@o$F4{=7s;&M(7N?2R1F+0D*bP5#HmoADd* zKjRm9!+i8?M9n@M@5KLgyqLpAm4S#5z|GECL_?aNL0{0~KMRlc-2*yNeU+~&LZZ$i z_j*Az@)DTV3q-d?Q_NZ2loMcPT|6J@#s?PtdFmI|eQ=o|r%1GA^vfh5I=3BWlIN(~_ z%QB+7O-ujld+U@Ub^S+~$x~~q!>QI5Oziz{z-GbyHLT@#Cu-k+>L~AbDf}FwPhxh6 z>M=gOjpm=RPfI(4yT>`wTOK<3epH4+FgCqrk#-MrXLKA#NYL7XQxTnaoZDyrzggg( z$SNgq#yifjlcT>Y08NPE{(2?Y!?UphFsI5@Q%4*uSE|(AdeO`sSokw0jmw5-6(@HD zqQP4FFVrQ&bUNm-X49XBYAvQX0C}6W(0y-NI*-055R7p#C8H$TY6uU-{Ok%qy_Bn2mu$I z#%lx!Z(78j6-=U(B<;YP<$#AOWd4kkeJVntaK5^t_bWGgpI~vrzovSre(y7XY1|dV z=De=S_=N}^qdM8{kn?@yU3bnop9D8}%bhljuqcrbgg=D@oD_%4AIdGG9*1<6~XaI@wigDv%UO4zq-dIz?*UpH=T)e-05M(Eqn zP)cO35^93IZjBuc#(_x!Ue)?JSIGBXqEzH9%*}T80r5%H?KSh>!3&)Gu@RXKwdI6nssHd z#w~_^7!PXccEX<32~+s(!orSC;RJBg7a#AQyu*=T_!h7kp6T6R#&Y*}FjLm$Y+Tfu z9rq3H3MG*-5Yj5>`q-NtC!h!Af4Cm%a};O4ZlCnx>VKnbv+&k}{0owbH7F)7TcjwH z$f}4P?7KxGFVXRpuaW3R`_7=ra@9lz26etC{*}AbKaRyL579B3$L&xKk`tQ=Ak<{w zRt|U>)19aeHj{q~WhB>3R5jlb{>te=+Wr0I_rIh9kuUM52OXAGC5Gf`IcCUA{I6&8 zhYMc0Qp&PHS-=ns+IlNOo27$`QIo&-^v#NuK@Bx?qkWdD`dC*-;<& z)>x#;Nm`T*1P_|DImVkcWtDLlT~(xStt>*Y%g6MB<4`nO|-qyqV zOr>$mHLq5!@Tdt|UsydXh9BXYI5rNKKI^9uwAIL+R;Hg!@@f=9w9S|JNSn~Jr+VFs zU9_#Uc1u)imXZEfji%)d_nNv)w?EzY@3pNxppaRGzU9!KNgyLI__CM!o@Rnqx^qqI za{IyOKDYeHgF*39nNeyBq}dt|Pi28T9C-U?{w(-^S^#YlXdjI}R<;;5FemMlIDljL`2KNGmhO_e3?(H+?gTxozegw=>SZ;vd<}s)5@$r4I;WptC>n zk5C?a)tzWh6$YV^%BQY8aEyU86Pj?-1i2{n@!sb@j<`u51nt|;LB(*-d_T{Lsf)Zz z{it6h-iR{V(x2OM>WqjBOAzbh(NNhe+SlJMZE_>vQosBlb}H0!-nQG(YE)+HXcf6< zT|@=DvhqQ$ewqmh9W2779KUVXN{~`?cXb#eF@{*1D`e1Yjx#!$*eJEUE1bQ3cVqP^ zw>j6j!p2k9XTRpuR>yeS$w8mr7S?9R^d@Bg>?|gN$W3SV6 zcGE!UyW>Js*=McPF?@qowf|@==logUSKYE zx{w(88@RVKDDJpWzowbUInjY=^j+j${(V{I0m;*8@T(eDxa3+kKHjlkEFiCf+$mpW zU46Lf*&RnAvMy8PSU64Ac5Kd9^7PiL{YCrwXrrz<7lO-ED!-T(>2)<(kuL)lfIvXv zOxW>aK9f!`Z~St~vvcLkIEk_P2Ci?AILF%m~1lwtPj~zd4l)V&M1|?_%Jp*K{h78r_{I! zf6O(>sqg>E=lsMV0dg1sZQ9nPsR|yUR|H1|89M@39Gfg}hspw6eop$|pV7q-jSYWQ z;^cWlr=xzcZG1TgI5iq1PjlOl@lzAeJ#@&Qe>3PHu0P8`$DJU`>+8kdgn>o~+)?Ul zM!$^I`>fr#&^YniEiBo45Z4&KfZEcjiLb%qVIYJ$kYU?6m_C1*@`nJ~(<%`-=X6f2 zzy=2Nm_P4!JUbna`y@=hQzXeujvzx8dfALR4?s3G6_iA((3RPKStIDDJ6F(OjYQex-c#%y+z9m>Q2I61te<7-4(*a_6Vvq!=N7JA96&%`i`D zM<-U`Y=fGg_ECOgRTK(iPyhOFke*RA$5Sglqt@3=p8gH|9=~#fJ^H?P=zt;CTsmNm z6V`v_T4{%RPFT6XxpPT~;ex z6>NrVKPe(Q4-Fc>CCi<+)Iv%Eyatut%F(lSu+=0r9*j4}Ws8<%66Nv8Lq@ZSkXcI( zKPTDfGmBSZH^M-JjPB~x)_*XSA{{z>WNvc|-0Bov{@WZ6i3DwAYGi-M@Q}>N)sOyf z48T+~oEljvN)3u81n&`Xsr++nMW4SuW0&6M^2ZUNmZP1ageTlCZ~mH5Zms*C*{V{b zGG{qIO16AuVq9Fvaw-j5@m285btc;`tn!6T8IxJBUrl)K&a)8KZEI!QDf!4#v#-x| zB<79bX?`fl{k#(S(4cq6^kXI=@OH~V5TllmK~DJoDr)9DQcLpmYvZ&q)G)c4L#8%H zL|rHvOVNf)T3#ZI2dXeMP<7-Q=f60q4rc>ri#Y0Y@}J^%o{>u|l8;6GCU`v0@%%+O zz|m?lhfnXZ(q%SeBhN9{-sL29x!1c(6!H?#6zohjCN}ENT64OM(c?G0772H6(mi^@ z2v>B$3L&#l!oq+uPX{jPuL9`VCmbW?(kqMW5KCqf%>*l~-rQ)xKe#3D>`+Z9-=f8yn}E*P92Aqxjs8o6gYo}-LDJFuJ?|iV53QwH zgI3btB`-jgG8pg^zJ%*JD`2F4p%ecy-y)^(-C?8DUTCI_G^a)VbzeDcZ4*SA#skN5)c$hmq^G;VPU~y~5I=)eEd!*xT z21REmWhheHvvjt%8WaXgiuZD}bYL!y%a?GX6^EJGrn^&<%R;?18&Ks@OveGds%cua zTu-{d0Z;_rq*QlWRz#jkVh+(qP8jB$_J80hlZ&}A8re~2Ns~v{E3>-fy5Pbgg)sY6 zOtZ6f55@}Gyim{Bev>HG2IRfokKZ~u6vP2UrT&L_4!~I8lzgnwLkG*hTZ+Z^8gwRj zZ9D>_=^#ZbEc?|v+AnAA@@D29UG5O?_loAFuHRnO+M;$8xIhdeVAw^t>;8}IiFck4 z*Cj%w2dP%OcBA4MkNh6p^9S5s&awPr3cRioJY6nKkj|>rD-aOtR-!RVsbz*;@qh2i z*$_Gkr_1$%N7K2pyG6)c#*>b{^A9HMwh4O?zDmor3=FflrEgW@Qe z3)WnNXWKoM@~YWK$K-(6-A=NJelo!;ZJxMOLLLIdG|I#=i1IRd%zdOIwTnQ$gPtwd zXFEnct;F*JQm;#ZM{Xw8s2fT!DLJVAo# zs?oi>O^f1L#TX z2M!C26-Ngx#$}mK1hIl{gPYwf?|;6;lEoLR`k|zdWc7Dj6j|f6m|^5(%1s1v)Prq^2qP zmgiwmZDtwbtj1t1o!?lYm)z*II7P5R$H{bVh%zG+Ko{c*F@2*s1ch9W!F$(4wi1=6 zE;|-8@Uw{A+ z_tP$2Sh8OX{f3CCW6eF_o=FZShc)5ziX_qzhT>rqG~F#{s|Kg!-8Z6 z5^7fTy&!c-0|Kg~V(i*F`v*vBXs}OuGS?PmWkQ1KyrS`vBoZYPaR}a#;a5;tcjY!K zTv=F9o>J10yRb5l4Q8}tbm4YmzY70SY?ST!IfO<2ClKf&L*zq{9ObuNE|cx%RZedH=7qkIpa zk95!T52gvjils`e9*I(c2$V=E}7c$}Sf^8`l7J1%0$@uo+fgp9E9Au6x;=styk%53_q~7$%nj(om0dh1gAEg95!+R=Pn4=((P_v(a|Os!q9PIMAGGqDvYj*)OX#o!Z;$E2m7RJ9j zk+Z-?6!~yS!xb!;_|ZVvy|)DK#04Up@K~Mqr9Um(7^x<%ZkT_aRCcqe{WnL#GsQ9VOP;gc{umZ`#gtR?31sQ$hka3fX*UB zusa9ff!gnxD;CuPSDT74LjRMCAkW+evA+ByQ6~2MwRZg9bjtg7;k^IYB#*ZPxq6;= z5U(}p?_vISYuRE}G*Hn7vjTS7T7Fcc9$mmhjdhCi_c2z@&7uw7|DozFqoVG<_hCAu zK}x!n?vM@vrBsyeM!JUXMpD|LM7m?78M=FDkS^(=8=epM_y2qGycpMV>2k5gbN1Q$ z+E?KJVR#_iInvF!cD%9^rZgGfhKSqYV_;jA1s8YGWi#QGbBqs$AEF@W?iA?F6JvFw z8?;j4HsImZka2q+{(evxVjTg(_%Pp!^nZ6EY}L6!QFee(Ws_@oFv0s>S$oK@(oNReNDicvrXd zLqD2f&s8X>NR7&t=E&OUi{vnLu^v7FRMG3q%7i1FS48aiQe+{5Mar&$4N~nU@mY?@ z)wv6GIQ&E~G9Tu4T9{+Y?Oy3wy9ix)p#AYc_3ln&Sj6`5unlq9n9nw!=QxFDKtPxM zM=B?2YR(pTr*hu)k-Nj-?tr`Pa$kao-%Y9E);$sN05V-%4H>(_xReYxa+UN0L!1~d zmEZMRI@u|ZmtxNAlvMgwkuRxA@IhU&szSII3(F%^=o6GM+vM~m~jR2epxE!T-=an}f`7ypaq^y?r55^$g zB=!vhvHBejvM<2oRHd@X%af{R*yD@Cb@61CB+Yw86N{S}cRE743t`Q`(lA+Od_MrB9Yw;oT*vHaf8mC~-VC9;bjUL6tfy(8x zP-J#UI3B=KLI>&sCUo5=;Yl1PeX&)9XEz<~(VUKQRuU$1>DlTZAqXwoW1i zB!M(A$R@uHNFxzmQ8E(z917Zdsf;;eMJSP$82Ny4q}-1#%Zo|lkGhT0r|$mDAhw`m zYJu%V_7bl~J+G9fMIQ~RWXZq$(noa(dOwGy>8$YNx|hD^uF{+Fg0xOuzfpW8G_HQ> zrQMw+&}DsRK1i(2Z%-#tUJx=>v+GHZ6@Cp6x-uX~v0zxrd~iLl>msGFGDF2>-}1@f zmgnN6w)d-g0Bi8Nt-t@4h`ML=g#}Ps`L5TwZpmhUNd4<)@OIGH^{1al0R?gbU?%;`2U<*?X<)1_x^ zt0fn?qXYHvu78ELTTk;ug@H6R$oXCOeXF`e=}4n|w*DgJxf&8e}+TU+k2 z6_-|^4;BML|5krw?3pyH5-oHvMaqx-D~FaW(%jAU@irEJ2^ zcCDB5mf7rP^jfkOGK}=gvOYkDqxBJ6$RDC!I_<{3Z>D(S=O453DSxqO6w3v`{O`Bx z+f-b3Mu$8ZeEzU9R#W{*azv3l%~!Dr7Hv-d1RlqYl)r=MWI>J_&PMg*Zye;k7RY~P zO2|KM4`+fSbt3d{@les#1ZFvJYy*)&rnn%nGE^%cWNh?0T=l5ccVR2GO;fZ^||u z1*`Zz>>G|H}bhV&Azeya1Gt2Xd*k|E`FNzL|noUR;?Ce#9iR&lxiaHaj*}a;M zeu;MkZa>+_%#2bEZv7`?g#3im(B$<1lLCt3xDYq^hjsbV<&qWd62S9eW{>LWY6+uD zeFa_JiiKsX)m_2=;Jsy({}rQy;NWM+g3DXiq7aZ{LC?n3;2dG}8^;1r)Ir`n(32mQ ze@q5i1U(-4MF0Oa2?9>eA_DOLoB<51gr16%LP3((uDAVEJB56; zU+*g0dkT|tsWX{xSN`*F$B=r$Gk0peV!z|pWc_!}gwT z#~|w~PaGU~|3ZuRGacURJAgA%vRs^X9@B?lB@}I$Gmrn)ASXALlPSb(d)gdrx>`9$ zvTZ{%+9a#BaMSx0ZKl_aIBhu7S3S=Yl6=epa4QWNNNIl(@ZhuJvyCwdK1=Js{4o0CS@1tW$e(PU7Lq|SS}aSn;@Z2ax+IU9ZMSYDS*>?V$EwF3eWK1_ zAuvC060tx}jB>u(e7ZKlA5Bl%gK2lbYRV~HPtKL?kkl|ES#xL7K(;-fW)ZmWP-(5Y zsy$}J-xA%*vxB901T>$M^!@%SxB{-3@!>_m=OJVk?}V)aq zp?0pDQgoW42s%X)c}BHi-s6|g4Obk~=kiY81EU@I%pIWdurzyh)cf?c^((k9ds^OJ z`9EE?)X@E)BqUtVNZz@geOS4jsdrw`*qQ7bswAHd{-VK@G%)mx?Io@-E2qWd3a==dMa;=N)hSPp@c+wN;SEna=(82%;`NL6MmI}V~?{K zsE;DJp^ZElx=}TKw|oVSZhRxE+$;BKdc>uf6~Z?WkGUQzcG}Ng$MVg&9;6wDP7fgsxckR_f7(%l5^n zki~EsNK5c~=^$AcQ1LuonqcuBB!_Z5E~&T7zg9T0^nMUY zl4)*FHc}4c>&RMYB>koDy{|5C38T?0uikk2Ut<9}cM_RI`_IRT3j)3}Yq~qpg%Pi` z0b7Q}#@*=sIJG|inDL`)dWenegOx&(gkFL+z8?a&6a`*Gjaw{g6kyV%S?;WJ@vH8? zwqj7H>rBjlSMuWbn(TiIhnz!+b6X}W!`ipcCUiP?&r)rh3e};<8&m7uo5Dh1^vJ8& zz*8II^fS~Ecvv~Tz!?)u790VC>ohbLcPYQ)s;K^HhT(?yULq%d6C=8*ZUN&aeMJ|_ zg0Fq_H9W93nlkbjYi2>r=ul>>Bv8f=!2gN=6`uoN>C1{;o$dTjRFH*(ewCv!Nh5wg zzCZqa2d|86V7rl&_I>$+08GLeJMmz`hIe?whjCCV;(mP|(McgIeMcm=@VBE$d zMTkbc!46}fn!|LA*z6AHb_4>TcZlQjLRaZHZ+LTTjPyKmNrR%t{4Q&?g(ENmT?0dm z@H4GKq(VRT>?vxf|L6sj|MuBy9!pIZjl*gZH($I~1JO@ez2vCYzgDz-n04?=d?$T) z>H^RaI6I0tYtEVzH6W9uRzwl}c#`*yS=M0M7r!tOG2Vx3?LGIun|ab?DbpUKD#*gMplCk(Gt0oo&F- zQmB6!Vt5+`G%E9nvabO_*|Qf0#0Unpe$4Ydv)wpkmT?1CmSDCo?&lLic*F&hnTcyR zhQK$j)|RA)llgzegR&$M*lKdgeM51rBGzKFMMgt*p-1DJAlD;dURN|zz);vUgHf_A zv;t{UE3x~X2FeF$Poc;ik&!v8%D_f5)8|Te1a}#GC-AN_W0iN5=&2*4dqaq_S5ARj zk0`&xclE_7DovP5NAT)Y^;ez(K)&4>wE7YjdZ_0*@lM2-j8rce179N#8(KRbwOZ8% z+}{l4C=@nh-9%W&)tc@gC+9ByQpbt$BS0>FNkGFMjo`#sX=QmIbhwt@cB5jtE>?FB z50}hf8RbJmm<(U($e6;cgBk<9$yRIq zBA)lUe@~ltiYC_Rl%GFt@;GiU#A8;qN&o-20Qbw}x+NRR05#OF4Nv|B;0Cue`2WU} zEBD5?QvWqAfSr-^!{GV(Ulv$qTk9L&qrf59yZVew)wZJsc8KV+sYH=HMCcV)t`sQ> zrc^whRxk^-qxn8IRtO%>5&W09MdMyHVUk`A!n*W{vE?i(U|XavSwgj^c~QAw_3Q&- zcSV=5RG?x#bZTzBF{xG{P`{j(;=gVRY*p0WD=qu#?Y+IMf^6oQ!lL|dJak-U%dNYB zzczBV1Gqn=7M4>8fx-yFg;N(Og0hH}AzfMnd5|tT(6B1w%3%1`k}FHJaCiOb+DHHY z6_tlsBH@hHwHExh6s>_e7dJ;F~_7s=M_195MndK6EONe9ns*0U-vqPFMW^`+F;;(}d0{=r1ui{6yXxoWrwV#xPD63L zgDh*=5;ARJaI-&9Kg`1)B0{f-^aqH3;K(HMb>>L188|H{*DOm_%p#jvc6t z84(r?Ztp~W>m=SwhWHf3nJb|PmlBl79*}^(uuKX0nJMJ4AX#6|#Fg&cx;}{N71*}w zN_msB|I&wy1nFKKC91X#wv9knE|SgneX!uaukM1zAQP#;xPz6jbPuQ+=F#I+DyhYQ zC#l9CC@WudHJ>T$f;0IarcMqxOurKUo_aK=AbRQf#Ml|Yb3McI&1$FKmxE%DoT+W%}K#G(Td3Mt+ z#Y#UJdyBRnu)UQdY3~S9u0cjGGEtTD;fZ{Sjk%3<*SK2%c9t;kFt+H8Gg9kztfC>4 zS(V{t32Fzt*G}1G7Gp1FjO|a=FX7F_EqKs^11|0F;?b+Gs*k?A;U*!lDC~_NfVpya&ZU!e)lrA z^6geXIBs0C-yg2Jai>1-vF7mxktBl??^CozzaOOl{vi>0f&sp6z~oaj3ivxFbE1TLuY?fLjc+Rx#10Iji-xCn|j1 zieI_x#+A-kJw9diKAYTZ`*B*L&jA$vLG+(-N;DB&cDjxB@4UW^ZtPyJ{7sWMTX{<2 zY}p-lUHH>yPqd(-G<*M#ZMS&7O=Vv&1+RB)ySX1Qys5&HM*W1UJ%J9z8+;~abcRbX zUvn$w_SZbuyKbfYI#P*EHM_|_&wqFh_9>#-Qh4|VRK06XXUu-oI`R;$y0SG{qmclJ zterO4pqcquqhAihWlok<`oG~2znWsN+JAclXnO7eF`za};a9^)ADyR-@Y;UPN;Auk zCFiJX1qN|G5eMzz3%>$^>5fa+QY&Yz3uoTlr#}@K5!aggw^GV=&jPG{4B@|^Zrp8L zm;BtUwxZ*JS(e`kbbTq2dl*5;_(cr@5+Xn{PQmyWfq(rK{Ib_OF5LsCfyj;^tDOs@ zv8*ZNrE%z+;59|Oql>IOiHP4kRm-ipO~<74ZyAv`F=|OD(uBs<+D+Rt(}M7OhilN_ zBLTMUl%6s_pTSUOMzUy4YEKAYQok-0#5Wg5MvG&Hjl8tZ|1XX)Fg`FuN=hLkFwa;j z!y|57K_rBsf~N{_P{=WEU%{?hPZQYBkKUlAS41ZW@Wn{n?6U1a5$9@w%jmRPj7T-R zd;9o^eUWJL3;!?ECq)5>!ghwX_%BwAMOYX8%(YiRX0l>7)J0R1ha4A zETbf3vSs_jT|-~zWz**nYh(uw2faM+XNcqPQUCokd2GdSqWenYZ(@ZYMM$3YWC2q% zRB`znYvE5_{)%hcnWCYhK)>?oylr9%^vg=XYdM7BFuCZ{N}5EHyQ*qv|F=Nf5qrgt ziek6sNIVbwHkGYecUFR_LJxog201{^!mA^SJ9oiQ1?+_jPH7+h6<*k#;FQM2J*FK` zF$7uUu{4u!i?f}V55~F5-A7;-_+vSzeSLA0tv?+~Vc)<5WG~5BB^uIiTV~z(LJOlz z2fq6hqlZ&_S^WeSIh^3z9O?#5eI^~bBAA*t32kU~`uyDd1mBy3B75PhnR=)|wk6Aj zqBueLPLlA@Of%|~@z`mH)`Y6g-tXJh#hlfd*1LkqieC5NM6?E$zzaDGpK<;2`=Xm@b z9srCncE6p?&9ni#;nPgi(lg)b>=9AAgVC!J2iuhfk55oUotS+xk?eX`)ux?Up~ARj zXTP^cN{RA}%P5xM%dI!NFkQ*`$FutSd`~5dtG-QSXT0I|dj5{N=e^hRX-d7B7=qsc zMa6ut!!mj+#VJq1OR;y9-1+SpMhuSB*T}=@B?(5RUj<4nS7S1du?Iuop z%C~5fBnDz|=7xaZE8K_;IC>~4IEF!XrIk=6qb@jG8rKAc&rqHMcM_4RFkK&3HXXnE z*xF=P<{qohz>)vX9@x3-v+@V@S~{y>rh(M!d4#MEYB@VchP0tkBX8J+iuq^y%Yc|S z!LDty7Z`oRAy;oPIYWljv*8emz3OB-E;_}I4ZRCKh?J;jP1^{*}MG!9$9c|oB7 zccDxWJ#Jmox^LUxFI=T#%}907oVdH9f@wbPC_zoQu(5W+nkPml;su@XqsX-rFk-KS+q(0m93aFnihT>r;!xrex$-}T6O43N(O zQ?I5{Q_g>J`=(+*BfAR)hz-CyE&ibE_h$g8`y~J{RB!%kNwNUTL4q$6s$%7Sc`*qS zkbL~$<#l-sV5cf>=Crvn;puqtT)*3cPPr*ChHwoi5fKo6-BeyX!fho#z6sP3vis)q z^3)JNJV5wfw{Emb%4aSdAm2%l0CRJfKTzZaKH77?6Q%q}qcdRN69pkpdkSAE{PX1L zu5(iCul&qoX!Y9+foxJas3?t!z2SJeUMUn1@?o>{xv#h0kJ@@?RG&CqcqAKKjV7D% zT-*aHucMF8fmK}QTKzZ9{S{JKI*+Bv_M#GB^gVyS(_yTH?r8F{?RiTaA7G=pWH=z>4p7~$e8SlX^3+ztXr0zKLZYXF zi$|$E9W=74qzAYules@VJS9&OvIJ6Bqw!GY25P$!peGSHbs1H@RUr$%CkjE$B?zLq zB5dm~bpB4X+bP0`DTl|mH|XNlYq+v{xX|(&H+0;7GDXFD^oQnue1?jKryAEa|BNgN z_+g^L<|B~Cnp)wzS-M+I>i09H%&S$uVwuhWiE;f43aQD*5Q4lk4J2-YFKsHVcs7N5 zRm86%Iw0Bjy4D+Vh6~Vi;OmIb#JGd#AIj^pe0(=N!TAU45O*drEzg%>B0`-ylOf4j z-}~JHA-YVoAkLK(WIQ#3rdR>|;@3|Pe3$rd=aLKjG9P^>(f24Ic;5H&z^R$v;hlIf zj}@puzNKQYb-4chNuu7(jJ{3}Kqr`wXwMX%YKAz61QT~Oz>k_w3jEAem$0}hE)(1z z>KOZTUo%iFBV>TU%xUfMf=XCDJu_Vc-D5uyA)X0&LZ-x^cm)A$o?zl{uvNYx%y2ig zR5;{O7{emOd5<87&~R~@LB{e@CI!|;2=`Q_{ar1N2!8w-Gxg4I>bTJ&S#Ecb%lNm~ zHZ?RtrR}xhtrE!C|K{@VCp_B|_QbIB%I3%0zoe!6dnWNc_m$97JVb_SfzRa4gm>vq znK5GiE#WEU?pRf#PgSY&O9aX5!}0f$p70dpvhYG4OW(GB~jn3pu=XV?`@l|n=RuMTQEmK11dXtrO>Cw^F(sI{2sPj z7JJGFofp3Yx!P$~Nl_Hx2rDzKzNAeg#}Uvi6{zggyCf8sz_x496%`&{;GcHAH8JRZ zzARksI**G4y#-MNfi&O~kqIVEn)<5hDW+ind3)U22S;?I*VnyGZu@-p^vmHie!{8f z)M#VG;o_&}FnUkTCvj0s`Q(u!@rHR(E}vIWY_71~*FyJ7S*xV6+yV`d$n-f_{pF~M zH}qF%aDBT&o~aC@2YCyHAGL(3yGIg#@^mZn4+fn?PGxd~jm2ygBdh+sSp zgL|Fcf>l|RXj_Xv5_9-s<79HtjY7Q$8kRnI)F_F81O!_h01?hnl8oeUpyXzcnQFyA zwug!lOUvCVA-BS8#O2gGvZRJ-ypt*=TM8k&LE}87;ep3NR9SUW+Y{C96u9WtkG`Vs zpW3E$-4az|L?{93n|27gT9TTlC4)ynlVKedN6ttQjVu$S6nb@QNCohUb)_h9l_&UY zUiX%Wu0(Z}qGVNbTR&1da?Zk8_u{uK0442j7$smU@AYwc2VlnouB5>BLso+VjdSgp z^gQ&x^6Puo@|OqU8Ap()AYkzEz^NaJ0;%l~A40(rcdh=aG)-G&&ol|8$w#{nZ|`o2 zV1FbZzX2$YP~r5csn+CBk+dpT9*Bm*T&mfk$oRf?t&3yc0iY$!eeyL^0Z(3*NK6S1 z&4`c`-F<8u3?FGfAAK|3KWH7W9bj+gMC;q&_Hf%GvlLdC{lSLDi@W@Gdv>x$iBnO3 zGFDMvK}AJr#ruQK;~0%fX@S3Z`Q76ctbjC2NmsLyS{s)v05;>>pPEngUy{ZPi83ho z^p|uDEEPAG2*g(mBx{J{L@%hM^5Yz}_Rxy(B7P2#HeW{vFn}JkrE*&SZ&OA5po2Mf zMQ&l+oP}1j8XQ91i2;NbZVP*XcrjoyYynRvU-w|wcC&U8W@w*8AcW+MA+#cT1Q%sB z@1H4Gm)1B2bC2Z+2jS)h2Kvik=Drf0i0p7j5^IROf3K8) zUVq1nAKuVie2+mHL5Vg%g%bgeA=NNNP-rt_;go(Y?L+%WYe9caO{k&-AQm(an_Qx;^)px*NdE&GM8$n(T9A z!@&ZNN&KztRhuA%uUr{^*f6jEW03OT#LhoajGFFH1mb4lOrr38Ip>Ji3p&B@srFLr z+}^U;2u|)vUHevn`ssdBdV&Pf&p$xAE4dCuG#3;Y{EEUD?{D935mrJG5-{d5$dy)l zQcerXY;)MJmc0$LRKz1hE+7w=_6%kB@7@lw`}yWU=8ys-R?hiQ;uK}O!)=S6Fl7h~ zyJEK9H!cNJnrXP3{Z131EdCCk5C{BWy^q54SJJ+E+Z}yBC*MN|O6SEWrKV${`!qkz z05ajaf}?lTX%FYy$a_GI`)Ji0X+8}KYzjS;+z)2Aum!uCPI3*BBE4#^iu56R?Pbv2 zo3fYI#AHyn&R`sIe$YuD>?!Y&g};#AvBcM2s|b~;((30oo}cOSsHpJID3o$1PZyr)O+-Nu_IuttR)MhM#POV74op-!Oka?>{;V*k3e zGaUPFa}tZeh4G2vpvztCz6Y1$#V{r=;!XluMt7cgh#NPu6T4Oww<#(&qZW#E3OrCq zBuZT(pUz+oh28R~#gm*N+@YOrm3cjx>l#fN$B)n{Qqy2<_lV~tVWhdTX8ysU(jkim zj;DpMhssHPt!1RjEpRR1!vwF&?78$Yf;w$}vLx@@ zsf=~2-ds}~c6~RJ^r18G#imF@7#HviAg*qMAGu%;C+Oec&M?@6^crh+x}|Ixqp#;O z9{l0KG~0L6p>wb5^nkFQ$D3&N&c`GU$CCDpisqwRF+k?`ZCa{ylN|4(;hMXx5qA5Y zj``vQ(q71I7YkP_e+1it`LMM@Y|4F7`5DK@hN{ftE-y^T2x}p47TZWx<3-h$`x84i zu>N^Rbon1;XIqsjkp=XuQHmB%2BhFS&1*iIfRoYX=>Gr@sIS2h8N%}(XpJ4B`^WE{ zm)8-|ZtTi((&NQ{2ySNPXv5<*|6xh-ZtIGdH&An>e959;BV8lDoNLWUA)V-`x^K__ z_}iVUuJtUb?1wwdgRE#K=JLB1Aa{JD62@A6+J2V0w)osQp)WC7Do z2B0p<0zQ)L9C!Vw*3;*uum$u-Q9FkHdvfuO8&2?NFJzofV60@uJbgl-FQE##f zc}#oro-@WO*%iJha9cVqD_2?+`u2$~Cur6X#R@wB`xNWb%>mDkH{C>PgQX+d`H=x} z{d5+L%pduLrL+;z=L)Ms4Y$FKK z`jg99yYUO9-H;dESA5k-&9l<&3si=jZVe@5%`BM>k^e=!>K0#5P`Er>q-~V; zLm~^3?Udo0tqIrn8T21~Q)osT!Z3_9ms`*Tdf(pbF;&zjb<#L zF&f$Bc4f_g!O3Iw$`^$&=K7D#Cir=8y-^&-a-{BeSl0%=tg#qb$l!DicYdw8`E?ni zYYtgDjALu$$uGaB5F_9ymOLfaN`!UAu`m-b> zHPnLT#&JaaI|;{V+tS8Q?}PhQ>aF+!=bFNrA=0PC2Ms~!2D(`lTp%d<%qb3j(y<#IC1iP9hlAa9O%oz%gq0gF}wKY(fWu!X$mygLK$Trb$ z%Ra)6>D4^L2tpSz|6HsbjAj{!M>JD{bpx^?`yKcT1A*t)+P~q(JYeca8XGnvc8^*pW8iVnI*r2xUA~&C@ ztvl4{Id`$R#*PT;+9<#SY-KOd=wewVnC*q}4|=ZZyp454zaLh&`JAZbA!`y!4i5my zu;_w}D(1;7!r0O8nxGH!j6PehY@_V1+r_!bZ?G2|1#M=OMC|AAS3-QZIF|WBKFK#E zpRXF6()olq#a-t!h+ho^d(0bpI1Lr9RJ@VhqIvnxvg3;P%N|ED1x$KYYUED|w6qaHxpJRA z!6V4|q*1;&6Eb4FV2z4GWc+}(Mw_c>p(s8aUwcoCmnsnfl!^fG9HMXl@TV9IzbY9{5F+)L4h0^c1^=0~h=aZAqSFF`h3 zECs6ZE{pXArmUhuDp*?HdQBa<+lxTVH|(U&AFwYHBTpUzJQP^)NHGsJd}u32p|2s$ zLzsu#P!1dSvUs9Gu8Q0B#B_G@Y>w5p{1g$lwsY9bmSh&=bBIfW+}Qn9!*s$pm;=G0xSpMSgRQX&2Y|KRv?! z*k}@`RWE!i*ZIRwzXZ6rxk>XYd}-dBE>!}S-66@^cNhDGK|Hx#Zo9B|@4mV1O-;P^ z_+vAxF+mk19)gWSAvi@k++xN&s1MxrFa5nG6>?23^ymgU%`vH?E!;ay7fHqH7k&{_ z_+?9ecZbeH9Q8%C@x&$sLj;krsqIuOutg)@lRH2!_4YxTN-?QEobf=&lr>c35Yp!VD( zh_UZu?TC3@7?k(_;OF$2mIDNYODk3h zz9q(55V1h4mHJ4MgOmHzzV@P!J7damxbx)ul1L(5RKdeLz1apQQ@4nMV=2d2K4M%o z$UelqF4-Q@@=O^xQaD#A6yTNQSQWA-RNXG2=>Gf^ORhQ{fA!|!(GSx|^CSyjOLI*` zGqNwgmJ$~A;}({rVIF?wActn`l(+fRALgQx@%$`wBVYtt^*zfqSFt!>e6XE!3j z0DOuA@i|T_0{?|Os=}4wYQV2*7$n-8k*LUq-iHF#%PoyiJdej4(sQy|+STA@-qANa zCUaZs^g8Lp3keJqSIoUktO__QMYP)Ilwp=t9v1pmsn*Y_xg8`$4O8Whz2x0zeBYod z%#gDH8^hq*=QBJh(@Yd@HWEwB>p3sH%eS8m+v=)=S(wQ-+Bn)-3*Fk2OStEhEmgyS ze}$OT@UzAGAghG(k*9P(ElnoaZml|?3;%wuV>03=d{sfUl`M-Jy9;&s<}b`1=BFc$ z&E)gbnk(!)lGt8J`{H2g&CS^yk4UXcc*eC~EG0Fs6t2X~r_)8j)H(4O#+kedN}q-p zO7g#Is`v2nj7QVj4Dl+%O6eR+T`v*@SrpWk>m1J00SAiNJj*`YEITrb$=k=jq6A^- zS%z{k(WmI?>&4@O^dGcpEn;3Q^+uC3$cABma+(J?!>{GMW%=b9MNpjJgk}9vuZ@Au z;q+!FyHq$(t>RtTY@r$rv198wOzHM!ue5US24g{MDKqmd;$HqseIyaf-jGE%Uc}x% z_a*s9{OXfjuaotjpM8MAW&dhN@YcnwVXCHq=Q~9jp1)PbUHp5Lxngplhz|Tv&mCEx z9dnMS>d#yzqNfrj-5A7w6G*I2c{BPwB4B{+5Fhb;buLE`|LLaSpvVoAiUH*5q>tnV zAXt7)>pMFsn~Q2WrCJ0W1wTmKY!VS8)9*QQ%?2O+?ytu@q;%f(4Z<-G7q`lQ4W(xu zhNHGmHQyx^2&FAf2yG$;CH0lSf!RFru|Q}H-@5+K*1`` z>6Y=^oAtwL)4r0AKOgbexDT7AFKT$Y<%Wh__3hqxeIf~2c1#=r`ZY|D zjb%qw!6S}HrI)e7qXWV{uXD5+qXhh0jH-uI#w#K$o0J=wgbT35)ZIwjY9c@#xmoE` z<>H)6qo2;~cz@~ddMvg{Xwzmz54!zY+B+QD>dNjN{GNT06ic+AxKDtySt0!=|KT4q zuSpg_oM$BoWI`U;phVGbB@dTlP7oZx8{UMnV23F)(^|E|;Ry_TKAp5Zb#N_&mb6v2 zuyDM6cgH^*V!(l37-LX~eH|OiFTg7T2Li_wOC>6ANudDsS;vmP7)#vY-w8~E)y81m{G(T$p-m?dW5InVgs|C zy2W9ToT+*jw~b@ces3iA@cBLcH?uE|0k1dc9ZkxD;Mh(J3x90B+E$qj5T;A|Lx;ga zY7AKfyWze^e;ZjDH~dH6A?)O$oKPfCB&?9IYdIS42+@mFtohe-8AA0?{aSLims@OO zNqZ9{2V*Konq3N)4Lmpk zIIu~6Qmzz0+&cu|a_9aUg>FdE*U*ra(G!|atCxq1)APVYP`2qZk=t|Y3pAj&!x5LO zKL37`iTIUV5(Omz%Pz$$)y%iW?MDC;{Oj7~!F=tX@f{1^I5D-AS0Jho?_2)yxweP2-}kUrNdUu|BgH1hT0v_e_Y$?y0WkK%r=Dn zTOCY`=y!`aK*Oah`c4QsUe;gDXmZ`n%}@`=rAl%M@()ThLF__AM(ZC=&))O+RaNz| zPb1xn6kmQ{-Li%bQtRYErlhpziDtO*QoUZ2Y&4I`oYUgbqvKby%CDuYSE+3ED1DHN z-nOAp9Zk$W{590NoWyq!tO{<=&vln`rqPUszyIrN7^N1C{WvjrZ0C~Rp9KvZ=sE+U2IP{KEdr=^UNJgS6@(}oS_PvdcU9v|HDB^;z&mQ)>U!e z_#F@9&!zc$ACX#LaE4x!Tx={kXXuisV%gdVVX3CBN%<=!zn$>Saj@^^^$y?GFio2c ziWt#ca$Ad3ujmA?-J1;x458RX5?rhR;xsxLL|KWBDQ;|ZzfE30KWXrbu=I@Q7vjYW z-zp^1-i#?xP?d?;#-(Xr+A&yD4Q(;de(gTIL`jid7uV9l%HWuK<~;77(MW{A?t z;JdBLhH*~q#t)Ki&Gd^T!tT(A-+m2s42zyMcd27YkUtcHw&VS7!u$d@Gs-~vH#^W^ zB>|UV5TF^;)h~g-ehy1bf8-(FS4-{!gnLoMY#-#la2Rs3&VGf2&WQtk!>z_f`ETuB zGD#kJXO}iv!K?2t$5)kf*MtQ(ERh0)zRQSaWP<*}HE%Yy_sv`~ztyx}3jA>Q{W@pd_lg!w@Cd^<74tAd;9!!Swkc-{Vm-Mzp+J)i#2f7ai@Y-J zx3eF5B!2N%Nk#bZ^*DnwW@&9=`T@O5xBoyFK3x zTCo6nMNUW6i@oXQc9H+u!ZGnH#W-s5FY$dh>=OO-e$f=RY)eRxi+RF@`G$H|+s=Eg z!>UyE5U^28M>E1=r!`^~vTPsU*Qr#t3=Isy+4+|XThnE%i#DVu%Km?`$Idn9)d3Q5 z2mSwQ_~QSo;qz&WhQof>h}_j#QXjXIBV4wJex+oA5*aPLHwT(r@aT z;6h`qrX}Y?LpYM`!l!V$!_tJ^#;`A^_v2U64L3n++5Uss3b;e*BW*(iOy4)>&WoL| zV^Z^Te3c@no25yKt2Xc%pCZzg(Kn@(l~77a%?+@M~>d8OfC|CX0ewssZmVE-6}O7{Ebc*RR( zrkn3|(a^p_!4yT4qIhaSAK4drLAHFVjGcQKA}%P1OITbjv~p!#h$Whp2B!dyyBoFP zLE4khvh2?5K?1_l3o31uga&)eQeh@|6pa=;`YWUl;zWs8|IPEV`r#XajsGL-G;d91iJ4BCcd#?6t%0YLW3QiZCjwbHc zJ`tZb9&kcJD_?5Gx(pzl2Lq204LwBHp3CqB=qsmgyD@m+@lEO>!A#3UFO`_bN56*) zP3;E9Up@~Pv%KDyw#1VJ@74gV`Ye^x5&(-8B8k$k+O0tKLrsKrwa69md=HX=2Z58* zqQ%V%BXsBB+B10Mp)**YRV2UMkrI5uSFBY=xnuU;$~|X+sisngxC8Us)rDsN(SBu; zxIpP3bnmN~zrc4ZObSLY2Cfc zZp*m|Zs5nZ@wG_%#Gd8ugMv%-wyd)<+wb;0sV5*rt=t4gza;HiXhXZm#CA2p%TEV0 zm4=mAGKVW|hNPF`-ZRU0r1jqh4Ch2BWJH~RSpBtm6ldaju|dGK(|s*Yaa_`6Rj)V7 z_fWc#PMM`)`0$QhS4MOzy+m^Y1N2U8l6X)Ab(HV_j}YA2C@UQXp_Yk5jiT}l|A!Ie zeO#pR(*asY6I6o?M6GIu*}5(k+<~7|Y-X^U>eJIQ1K3T=_|cI&!Gb_yf+Und@)!~U zY7Eo$@gs}oy*85?EKHC*A$if~5dPbk47Y`F3&yQ%mh4Eyhusybnfup^huz(KaPOO? z>d?Ht=vKFo;lNtp!=}+y?2na#;_g>730e;n`Kt+coBiI%Tiy4K&R+X8&X?^HH)q#Q zQ+|;(=6$fLfUfag0c$djK2bK?yV9O6y3P%kz zE=b@N;=UK)hIg}$WJp9Izn3#j3ssp6eR}6pT#FP=c31z&dl~EySG=F$Bgxm?JYPSR zryg8r#Z_BXep_sxG1RJ>CHD0|B2Sxoje0jBd~$~r!a!xI4t1$?Jx$$nk-HGHHNOy& za;*hAvX9|ilgoRlm=lLVAivICRtthdBz9${M9{^oN8^vWUy@Z3Af`hT1=dQ`b>HW^ z?WJ8+ZMJ)fdDj-^Fh{_i?!O)2sLFmC;=xJ2)9s((at&X@I{W9kQ_pm4AQH>H42%xA zSc?rpE?dL%3r?H{(0|t_-&;~dacNNNWyO{Gxtw-SBzKzj^>mWh>d$Db6I9<)Tp4Lr z{1bE6=qBoDBu1@SZIioE+K~X+e`W0FtofO&j8Fgon+lzc5UTWWHPA{g}(Ch`=5weu7K-7ytv zth!lkBe7_Vn1iZ9MW* z;D;59KVYl$!)~$8`c_XN>obSKX5bmY^bcG?BbFe9u}(fFFHj{_%gXyW%hjLy%^>SX zg3m@uAXs-XVPL3Tv3C2&Va&ejh=Fx>GO~pxlS9s09TD;`f-v_{Ga+`OrujBM=^efP0oxGMM*|Yr3@EiH8;;3Ua`+XoCc=m0EvkNpQ$bgPsm)?8+MXJ zzQwwMfgY^Q{s{N{99{HCfpWI$Z5+lYXn}LpzN~Z6lZ~^_<&Lw(kcEi*LD9YEUS7Wl z8y7JL#|fEC!)H7ZdtG5*@`|r}iCvr;W-JV6NHyBpCWYh=WH<%;YtJft= zQRK{Apbr|%YFX?++I*J5(;!=AKY&^lS*wBt^;5VEIYmJS@%Q(^gSs$~mylyeBPQLe zQ5B%M{s9cQTJq6kbiwF&sttG)g35rUG^@+@@Fy0g3I?^!^0Jtk4l$<{=tGU`F7M61 z-*HoDUEz4C6TcOP^4=$IHXlP1W7-}|ZdlpGA9vCn|H?pw{2zVNc}&BisYErNhtf+% zmF{Sa6I-@LBwPmgxw9>0{Rj&d@Tl)-kdKvWe+A7neE>dyy<>|E!y@XNy4nbkL^Am0 zk6+@DtC#_tcg;X8{;}BiSnNX*arF$aobt8{M+l}Y^~wz#zU=jHpIqCAhM$PAk418< za{p-p&q)xAN9or-139A#L&Ako^y$}LdwBOaEeJvU9z~&8q=UeCl2q^Y^l)X~A5V8W zDti3-2?-rJ4y91Q_ty31a?v6A``X(I7LGn*Y7u9-#u6r_lzt!)lK!`R3?Yny-zvVK zhox;T5>UAo|0T(7`BJ?t@fW$`#pt)?|BtJ;j;cCq|3DQ*6c7X?Bn1SdTN4rmtbjd+lq@+7N(jn5_(s^h&bm!g2d4G4^b^n+(vzE@RHL|~ZKc9RW+&O%%-Bx>K zlFdHf`Aai=4BW{-JqTkqlxDQ_JHPy1#i}96`Tj}iAb1a0!cbFK4iz*u&wdcPgeMKL zQ0IL2`Tc9=6iQdo{U54sil5bq?Iv;j)97f@*ca4&2!Alt{E>^4g@tHFFqhK&;^*@NN za+e=s@Fe%Yuf^7934A@=u|!e;Ph(#ujOxJd8LQz<=R{%Jyb!P<^z4DR9NU>{n6#i^ zX>WxrC8OR$smUQ!Eh@WDG#UHqG^n{JZfL;GD(Ya+H%Qe1cy0{I}~(Zq{!F-{)C}JD~25I-O~mTsoFhW)1K!@AR&0Y-#P-Yw$T+x z*)jq_BnFMb>%GU>ZppH+gblBhgOEF;4crGdH|umn;hXztzjYA&?#Jl&xOlW9Sz`fh2(;0nR} zFSz@hi)P761YMa!wsw)dCKD$bJ^#^2*Y)9u)3IV=b2R~w!dJ8HkqPFP^8{$d52{%ClLgs9k$0dx4aI z_gK-hm8QLMQHD&rUZRT`p`o42@~q9*m3l|NA7IP$CNSr?AFcHWwcxw#&T8oaX+js= zaz_{(Im=O<-bNMQ96#8rXLWvw-kqyw)R59!>eYFeu*{{&h+1#qt!hSiVB__ zw#SGLR&tg`GeKIij_UUry0K?lU9E8KuKbGt(^N@VuS_lIz1K18Xp2u=;pLiHsc87y zH{c>kl%{-mA9E_{IY)OnIM@c$u4{c-?fnsytXwuKL|B;JG%nE7SO{!?DC1c2FV=6+AstzlV2UkYTV%acAgWx{V|Z+Zu|T#&rg4F7%XwMZpc{{y)L@^vtV)CMCSO z>62sM65KeDmVOIkR+=^l(xFYrO5PW1uONmQ{2McRF4c9u;7?7no>Qa_Sk_fXodBQ1 zRYquf&)9%(DPp_^XKCqcpaFI#wS>ob;fGkT#ewzE?(Wpiu^nF{UlZD8%gap}C=ToF zb*Qf85ap_0@VR<7O3GuK@^Tgsp!QMM6-TL_T;@Y>zjIk+8oeDb{^dxT2biKP#<+SI zT%?xpELY+EqlyQL8oVlV&ZF;C@)XHx;ZT9G0(82fsh?)sh06zkBdY$?9*3%Fbgp;!U9`h#KwYW`yKqN8o|4P-S$Ce~9B zZ0F$cI=9z?xmD~;)~Xs+c=b=zI(1(eA2!-(4^w?$f}D)Vv(i_)T&yNCx^|#wNWiZy zx64ey9r`htUbPC<`2MuPdX%(x&fI5e%*xcqYqE|!q4QwV>?q~c(-#VRj7o5mRi#iQmE9wy=rV)esEA>d!5 z&dI&zG3PNL}%WM^1Dd z;&`6gHFNyz&JwEyOM4 zf3i1+62HK2QZe8gpuu1xe=26)8DY1@<02eRGT1&qlrB^__(F0=#hsQF;1C9a-W#We zZy&v%60q*{63t1nw%%u8WmO8&_Hl~V8;BLABZ2oAlxQ`LX+7Ua)PEBmA#n!gv-}@e zd6W;$9#7lWEIq14RL#VoBZ%_gi!jS^h9piiXzA}8FqW7H)&e=Bh!fXeD03R@;56oN zSeDRg@^UxINDX!Tj*5yZ)>aQ*yZkJ?A`fh0F3o2plq<&TSKDRHEGtRz&0&Jlmmr3o zEw#`pif%^Vqm@~w@B$tr=-wG-0Y0_wEbb6RE#425`|^_JokHKLN%@^M_acU{mnbj9 ze}_JG_yNYPOH=%r!?f;x#OU(mNVD#0168tE{Z?Z4?stvHithz%&x@q0se1rW`J?~5 z(VNv9wH|ygP)0=36GZq-o6iSZjJB&c7nCsS9yVjWwQc1oA`(7mw_9lb503-BhV#SJ zxvCA#m*fi@f?BqMPi&}+2fkn+1e;>s>^?1J`pnvJPpm#wz_kYmKdV0ng;o8C8=hWc z2o96jHDshqq`@b_Qo1}HoCvX#ohenMXe$vvQ|FQ#&c5^$tQitW5_GS`Bw~9zh!`t~ z1r()tndI{jnW)Fkq;!#@0gt+mH%DFkfV?YFBv1P#YrfUVfkFZ@n+3GNIyW^yJjb(A zAu_~6z7Mg~EUPk^_QXu&#keP3!0|bigM3X?&8sI46nK7f>XxM7b)u{$0j?>iSApTl zP};~}6*B~xl2L?Ye>K47VGdPU!rDkx&^=@BH0Q7-2Ip`mFRHf{@1tC@^3$BntLiRl z3fOb-eCzO{$U$3PlM%BDPuaXA{+jVXA>o5MX6wDDAQ$&SQ{^l_Kmn^I*0?GNTswwt z<#GwCtifZFTa?}=NhB`ui6+ufU7NlovBs*ja!XE)ukQR3aIMt!v1T0t+IH(cpr`vN zK;Nak%u_Fmw&$E=IpA)FeVPk_?2h+oyAP?p7`G67+c>9 zbCm%Z!QiQHNFhXS>Ku}(_0kXZoAE<1W}`aG8RtB_KyRh&z@8VvbgvT3{dAwTbl1~T zV_K`$@_tCxRo}Rm6s7(o5!wr?Y@9hJnGCYi!-1!j?4^B@edQvImT-eWCtO9z$4jAA zr(papf@5cLs_!cj4J%5pxB0#3q72M;G^BR zU*}H;;*K5qjJTcDMLXTYKePc1&j4Cr3DntufZ`%2CU6*u$FnR(k-PoQ(iuiB_0qVI_<7Z#6j=(fQ=zh z*!$9ajK79sEeJi6|JG#|{c-&$gW=V8I{?Ui!nRY&+WV)gzoO0Y4Ut^lPjn zLP7v`Rl`B8?=r&HhmMI~XCWB8_^q26sNl3VW@q7Ps2kWf1k;qo20>b+Te2dB2(H1^ z(~WpcPpS|bwO%_@iAlqcFVcln2fsvGoN2j_F~Y>ppT|)%oqb8qGuv`^IuYiLHMj$Z ztxk!7saC z+J8?ec}8P4jYb6^4=hpL=t(T;dmE7B zu-B8M~`)Oy5vPSuz>_g};Mi|7@5v$C{3Y$5N{i4mG<1y%^ z!0+|``EcpfkgrVVlM<6Ye^=AF{}Een@utjvP#Ygw7Qq4ujUK&fD1U?58bt|Zu|2`x z$^|snTQ8w(-Tzb{X2Re_uk)`dbFp-JZ?5H`zQW>Ijh788)phb%V~wIl@3v)qX4X{p z)p+>$KYdI2SSK;iZgjYw#3@Nt|G4p0A1bVoXe42T6%e?nCL_H8B+10Vn^Vk*1JF*k zdF3}74MN-P^uvbb01mS z7twRu)8S`p9q}FRdN8IN7VrI1-TEb`92oWYVl_d;QE2F!(5-LVyB3CJ28XloeYK&4Wxt@3sV>jpIaIm*et5XC-Dm zK^X5?z`q`V{jId{A*_Q1Fj2XzI=2PR0{&-#P+h`zDTt8E4z!P@>VWb%g5g~w9EDAi z8UR{uP(6G6ZG=C*>=hFr9JY0hEHuJFc(rk(#e|nNkC{TJ)J^Vph#l7hN=(C$2U>NU zEV1?@>SsUG`zfYpdoyZ!8DwzrH9~t$-buWR;0j`RXg*9FmNVa286W>zq>NBwn=>l} z);iH|lXVuD*v7!Xps^byU;Va$v4QuDA5}M9M#N{H_*jV34zV+jC z!S2VI$$#lyLnPsfX2jl3OiD_SQVoFl3l5Xe@*gSzkDflvmAi(0IgcG=F)reKJ2Fwi53s zeG#yCy_uJOEJ(HNvN0sudAo1Iq%dBfN@ti+1Uz5ghX!7-Ee=YYjJwhLMy4@mq~c8> z3Vf1Q#`6mWQt&VYQE|Elax8EV*UTb0Ia0boB)KA?BiE%cGsX4h{EIh$^&&e)%sT!X zjeub)+(x8(QnGa=os&F1q+@})M4CJ9lek^HBHpeLD@@x)tllv(c#+llfwCSZo1uMR z&Qqy?>c(`}d#ou>F~vnnzE8oB?5~^Ht4RWKjL(#KDEmP43P|S3gKlK8J``W)9XK#a zma3fjDXn;8)ku1GtIfw@`R%#z_&cDhvs8HeS~aLAP}x5=c68gn$0xk4%GbE0&(Jq2 z1Ifuw-tmDnI0KIe-mx)kOlg$JZ&eqrQ&n&e`Q0?1~ zlB50kawB92N|@1Lu!6erw`Atn^fiuw{YYNg=v~5_PYgndwHK^|tpHb84kjcM4C<8r z9^LOxY2YoV%&LL5dawD1?worfM3=ZDtY>ud+i6IO-X06 z+SN9ye$x%^N7=xyyLGJkeF(}$R8~L!2WPh;VEa!HhUwyOKSyxpi_V#8GE&PEhWPkA z6B@htdQQ?8H{Q5$?gsGU<)m+1GIhbwz-oMD=T8>B22E54UMdRSBVqUUb zv!3p&_s>Err2!n!UI*8qG@2QtepG{(^PYR%arCMt*XIXYULTNv{ji7eo%#A(g#)}R z|LOxydg5ZiJX2ZoJgoQQMKXH*g_TE_MJCcEMJBHOj1TEv-#TH*`}ls8s6TujbEqp` zZ?#7fI|IE17L9I%@tvA55=-PT!i@WVyHOHE(60v?3s@YI(7Df#v*-S>K|ktnsyrgy z@v3I#C)hW#I+sGzID2()UI#zb^1*noH0u>zE4=ptvNxdG!Yznr#58^PV@B?9siSdi zLDff4zCLCitF&wBDYp0>^heW=AMa7!uF?MP*vqA~8mWY?lY4AX0@#$qQu$En2#ihJ z4!o4-uc&$}o{fHa!RhSxdOj%njoTj~cH29E&Ew#HoXf=LEa&+jiqto6bZ={UQoxp@ z+rrt&z9ddlfJJ%;E4uJ9g&(>iW~vD#e88{OeIel)2!srLzVGWX^R%d}g-8H^t;X@9 zE?c+Uj~et757*l&-n!Ew$1Blr`SD+fsbjM6UX_8HFW-@NNXODpNlx(t6E#H)1eCDn zuteUZ@6XxlW>kGo0Q`GzBQ?qMCkWbkMq`2|x@}iVm;lbZa;kkb=TCV`>Cx~`rThR= zJOchVkMgU6T2Tz#)^@aLGFk==yIu|bGaxFa@X}+ZH4GFkxuf)MYDLAy5Q=Z*1Mk*7 z?}hHzyp@e%x3Jbg9fB)`=oYn%N5`r^OowPQJJFd5ZWY;Zi~hsIB4iqorpPD3ZpiY=WgH z&kWI6`K+zC%2ERq5X;vnm{D8u;R^p8>oN^Yhz;$}?!gqm-K3V10@$}rI|ZyI|;BTE4vG+SGXNU$R6pUne)k>ss2S9L+E1 zZR&b5zGdBB;c}XjHZ(9g4=+}EwR>1B?7jFIdQo1RPC`UtmpYj_Ilbf`fNLb3G7Maf zKdAUfL$-L#zj9@g70_B@Ha-0`v+5#j79>g}O0|9`aRN5Fpz69as(>Z*{O2BMmVgUFnWB{r&m=)_Gvo z2aXJ~3n!aL175}Ng@IDkxC>fJM)!CP`%?upQVWRRSic7CmcPL4@-lC~F*IE^%2=LK zOQ7A#j|s%E|JEo#bbW)Yo0rNe5y4hSF4)L+lG(mmqj+dQ6e#io?fKt7F%_(E`RaOh zGa}(exKUcbT)%efSKnUaO((0?YUiuK#%s3&?z9>sO#$eWH1oBK~^e#VS=QTj>s`G{V%|1O#?|o8a@W|1j5&7=Ggf@(tAoBnkk{UX;FS@w< zW{Y?Dlvmhhn(M_I>u>0Xi(QKui%Ds2%THFb&P`fq+kEwXZ+;c))~k222*%p9X#VJ; zz)mZuQOc>le(1E`6Kkm+1nJ?_rJsp2yDU0XuvhXsRGYrZE=o3;2!tlr)r;dSBxQ5vtiio-kSQnKE67+|;Ni}EmU+vQlfQqth# zphY*qpbaKpaz3iOyI*=x?>DMgPl;cm3IV41>bq?5?$els6$ri8sVv{+k{e%zTSm&I zO>9ae*;f&zbVV8gh$0`;doEelaHejPG0+yrW|Y8sV3dEd?-i#(lKkn#H!jC=fRAH$4jmO>ZE{G-oBKrCu1G=pcP-hgb+g5`yQ0;80{D}bkF|t zp=Vzj2D}HC%nC~Owl5IjpfF!yoX7MLCPQ8S%XY3#AwxG)I%Bsy^UQOvQJ*q{Y(N&q z&f-C4jH6E7sr3YB2I*SIqgD8dblC5WX3OHasQ-z6vToUfd;XLCQAXdA$q774qzAaf zxh)q|m4s+OSS03u%+TtnlE??MB07)bA^bsby%Lcer10tk2l*b-S)R{Z0caxP7A!h? z>(A5#!-(&IJDAXMihP2=#s8)P^`kg?I}!Z5LYalMG)KqMHwP5-pd$+PY3nWZC}*s6 zON@Y6lY{x2R!|$QzZzO@%75SqKDU_l*e*eG@~c^z_rg7EuT)rg?B<#D zy-(r~<;xmRzx(*?x1b$@hmb*9LQODpO6X!Gmf>0oKXUdQ!gq&?gSq_WA4vc z&O|5BKL1ja$Q>D0%O-}|nC$#HGrcM0c3l2~lyR2mJKxQ&L}PsseJv1+wcXca*vF*n zh|@Y6Y?#2d53d^I+ah;)wmh(vL80DnO^hU&r!hAAjNT8|v$tgJ9#p2tlwq}wbR~&k zlf|eqCiV`8!E^T8=N-$91-k7V^4hi*UZL&TFoi5^QPVYjr^(~FVK6(}#R+sbf89-iI=~;gAQVni;Osh6j=&#QL-BQ1}BO755%Iv zqr7N}+pj&B@>lpKz4_C0Zm``syMp?s<%&o#&$t~ePIuCJj~Vw)PCf_jJxc$@q~$A% zYfr`0N(Z|J-O2+Jtd_=r>1w-XCap>dLP^WcAmsiQI1RlFNyRRx zIp)BCe5^y@Mhu{u&gnXjV1S*k#d;W*RK3!blE~Y!Czq%l2+_#1FV!sGD>1;szIOjHTeaOn z1`witpX@E@gZUtzF>=A7lgtqw$WAb9Npfu}zIT z|Bc=FVKlk&elRu#3z8x#>sJ(7B`?9Mx9Wy=;aja zdE)B^ZtYnqPzF1ouD8>4o5;t(2@ZGw2zj@CZ$YYxkGC6{Le>k_^f& zBBI~>=uZup4nG7rOJr=%U1|B90adA1D$(oNk$qn9bsKK9lMl%*2*B2(kApx@H~O z0gBA8J42s+qk?H2E524>dZ9@4wAFrjt^b$d#xE(}W&zAi3_zw(XiY;{MD!I0u0*l0 z-Bb@L>XeM$ZOQJG&d@+6Y*dB{Qb|b!#0IXdQoFiib7+k3E==yTeW93$3(8Qct}^k0 zeKy?4V*eSZCiuVTU=QvW$V{l_Z|n@R-2Kyv2Ddi9w0N`BQ9Ra8@FtQb0iMZMgU*Ze zVCtfuJjblKcyPJgd6KwksjRx2t)$pgFn>KaRoYy-ey|W8nr{Eu@V0L<%7-eHc?RC| zGOc@%%SqYX^|J2;GX-ydiB7Ewz~j=tY-ZGL>k)4ZSNE+-Yo*9CLkU7SPeFcAjjP({ z1`I%KKBB+7zZruL0OI~qFbyN=AIiho^8+|H@!WTAE0d{8)2+uRzz*{Yf4bHI69er% zM5E**4Zs|uU2eQ4I5c)UlNJ82pH)yOYKF?yDgo!lJ97=F*tT^VR;N02Mr~KVk_k2= z4^qI1n@xEh_%7ko6km_<6hE-zO`8@ssQOxjEOjEhjb(o(50z%W+mTtV0@AsvdmzMi z>CH6x7SX`2g!-SQRKCYfIDssp{Vx8BJX0y-Zn^tMIB{gHJjA@TR%kIx$va*GX&&zO zf_OGUHcfbf!-NmJIo&;NNX|S;q8s-X@1X@w#_+?OH&r4T(C(ZjbR$1lqm@ah@lnYu zr3>p4Iu)vyd@uio*MQY2+gZ=2811SBsx6INflHl{E{OFMP$qHI46k3SVTGDQ?B%n8 zRBnbpQ*jTk6DJ_142N1urcAFAB6i)>;PgbYOgfYH6io%Uu8L2IDAz^zJy{F3lcE-*&8gBt~$T+TtkEPuX~1aj|V zWl9vX0mUroDD}=fA{#>mpHa|D|&6Ocpw;fps=(`f!qJHCDCKiO#+^j8OC}7>4c_+ za|#+|RvL7UwEEoL-b{Clf?|rJrk0C|e<$Chqj6v1&F;xZnx6|T_sE}+e*)j^AsE)X zYaHM!U?&>xc@{`1(i65V%P_44p1XjTjUliIxJgtpQsJ*&tHxDa9IZ2=1rF`n`CcXG zMh-z8yTs(btjZGFZUNhm@-2D`nGW7rAR#aked?9Ep8Gwft*`5eTz6hX0oqLua^xgea=22g>R{Ag#njO4f`vSPPDD%R~Mx5Ld>*jSmO38 z0{_nYY_I(PvAI|vIMq62m9+2Wy7Hy0$!G!gm-JZyw?1T*;lQWLH{#`ghXy;JmyMMmkd+J}rb%mUXMwC8T zMi9S~##&a}9{m8CBxz$8KD3fJ4JCE+=s5@ZlqzNfeQbA;X>SFeqVfzNS8#w3lM zK{I}xkF-gGi^Ho`->wBu8dF2RC2o%t-h3E`Q%~z&YoCIT5E;NcW*3Ey&ee=Kq)icN ztX*!r1T*=f)Wwg6=g$cW_gzNqnk}C5ktQQuzPjFCfUIY56t_OxZ^f2R7xp#*whiK6 zfj)%Nc`n<33trm-pRflDbcUDT8{B97O;h!*>HJ1W(y8fuxi=dJ@w#Xw#M$BNjgX#NHiyBJp}+*#zL(AaN=Ii{gm${jKm_UKH9X69LRw@$Q)8{rNbgn z)~o{2@y$UFDw^(IzQ5#Y^2a-vGj&H|7OyB^Nu_%^2w>H1f2ZIHD2nAb75ecr|FA3m zw4r1NHqR)*rO{?~%YtEn0u)xRw66y*>n?7$PIOfK-Jy*c$WPb!w$kyK|Gym%`h zs;exFR{B^GO*jt>FQ({---&fs0M;|y;jzNRdF#Q0s5@iw0o69kr4k7NF{H=5O3XG-v_@3`W76>=m1B z-DW$nspUrz|7e|XxK%UCzc!B}v3lkRla*`A!xb3x@H0AsSXMp|5=-&*AR?rtFfTFmfixSF zd^UbsUJ)r@yJ$aOfHF3vMbDpd8S}GL(p@m|&r?O~jy}S*n$kq~aE>*O75>WEd`smx z4Q8&)=fiaVl#|NXsx54I7nPpbw)e@B(_A|QJdVHdi~G}eB2B*yL@L;neH`x+B!TXS zc9_ts^XuM`m`D)hU{E$SYI;|1a@c1$ZHS`wLTfDT7YA3^1K$7h0tgRI{UjuNO>{J> z(GsAn!H^~N=eBQ5gKp)u{X|u&3WQjw*vS3ol0jyViXr{Y-_tTk+X6zL&7U)pqO8Hui-Cfw%BtNn+E=x%q2bXS;Lp+F;RZxZclC{lvP@EJ2Ls5G>7lOeHt~{xLcH9`HaHM(@jj^*&^m zf`WwZ75(}c2C5xO26DCRzJk68c}p-0AqurVRHd&cEvB44>gNQJk2|rrmy$M7c>4-} z74^8(|Ec1xINDbPKaloYkoaK!kTGZA?l?_-_^we&N5F;{+5sTs-0*pZN=^fdsW|;Er*;5oSe&MP*2`8Mdc8tp#FYl^vm2x|G8{L zoveTr2xow>>xRU~g0Gwmu;hrLUG8qXi9#9kiQ%fPvU-i+n@o$!KmIXYOQi;AX(|`=6I8np=zet zw;BDf@b$hpegiVOtwBv804bJAXl=0sl^}I;d`5!mY-Ya>k#6zSf@fotJ;}n}b@or8 zimZ*tG0wm*z1wtU>jJY}Xlg7cz97{1qLgURZuIyyADi#N`yMIK4vJfcghp++6szAN z3ra=_nsMNBZt%#%yW54kQ6(Q=z{)Y;UL&d(xEv*E*ZBsZhUaZ8P-A1Jc=S$!;PsvY z!B5KO)gW!zEzmr2e!5-uJS79g_5Wv%jZ=LuLC*<{Ox9c$g8-;jrS5#`F$O~pZF3Mr zx!i<`oNCm|{rS$fqQS-eRiV&GPbd7G!}X$?JL!Y=$)5B=-6RZxatAwz@(+XrlVo6B zJflVrXj5=u&oxW;R=*$)bOsu>iehKjoTkK+3bF`RzNTEm)?fx0ob*iiyT|sq(Ziv7 z$X>!;)0JWnglEn<7X5h9Z!zRqCuPkxn7+!*RYATb&J|gTwE}$lu@3N#%|D8&d5ZCV z4?sV%z2>{`TsUrdB536ku6vpyO``AdFf)@wp6f z{|JLWHOf3sP&M9w&>8qXDl-?i9i}}!*Y)aT|BGt;zr{}Y#2ItafS7!fMIu`ieUg0@ z2RUrPjWAzVt$57zVj_uh%W%YyRqz3)%a14ZvQ$JKbWwiuW4LGID7Gr*M3DP8D0Df4ROmE%nl*+~Zq=J({7 z-VKd3s{yFX?68UgY^i>GT!Y723H=w6AdiVDZeAVn?O!TM9(zp3ymO?tA~h9?YF!)+ z{q@J8w7&!&2g_GEh^@o$NCQ9cyE4X5vy#i&$j$}QMWw9&KT3l{mE%tFZo z3S03qYhQ4F>H>hcjEaB68nA2p8snw9+SFS@hh5afb694~r=Vq5Gsq2Tu=BmD_Qez4 z9xuxCZwC9zq}TeAs0CbiE8BNto{yg2zQKnw%c?+llTa&ZVf7LzLr@urfR!F4a%utO z;m$o%v3e7lmNPhP96DNY7D>}DF9JrC7)S+m`~-4D0T;Ozfwi9M2(2=|5q$o2hSs6h zG9O)s4Czm1VlWv_5X|&qmco)J$wi4VsBwf!fwX?O9Knh%JOX3_T(@NK02Ms8Lt^X` zXl*w*Su4PLn~)~t*#pLKq>)fPgKYCc+N{Mz3iB~nIOO|Z>nUy3PTVx4BjJJYA{3@mqu{&hd1fH;jQ_$(zMn|AXf+5B~F|5u(x9 z^PLCQadb1+Ki_(J|2mx%nW>8d^U91yqq!&@FD-xFCHld*{}iy=ISrv*S~z7? z_LKZ$RpKLSQZ5oiBuk~ zV9VCid;d`;0r#uN@@UDqgyK#z*nj_u=3oVn3GF)(IragG_$9eG_41#9)ZPNm7h?J{ zNclEGtX_krSyw)cyMV6Z(__SUxfazZ@JPJi5l^3&t!sTo6%R+M%?Cs(6o9NTSa;lH z%YK?e{^Brc&jPR^dyr)cjdQ)(2AC{hz)x2;lLT7keh{TnoBnUPr+|VhMkymJc&R z5MW&;pBtB0sSdE)f^8SLcQp)Wm)j$_v9r$r)Y$%`IHBj6>L_9V?(yMb_|{lKR{G-| zmiirz)zrnb+rz}>*Gdng0*SD(B4FV9ptILoX?=6YwCF~=(_@RM@U3#82ilNENjOkg z6?W_uuL^Y;t{&h{QO2kqD)Fd81o6B-H#)J}9{t0frMyk2j4%ITPv97Ot!VCg zH(4(UYN?64;HngHPvoU~g6BkfWkJJ)+Q)0zH^VE@iUZ*A{$|6(R_}I155|0Dqw=j! ztk!pytQPfrH5*|L6c9Ar*1V~#QA?yY>-93l|XpZ#Oq*wMO20u&H@zwdoJ_-=$O z*y}rW;-C~3FJCL0!2`3G?toP3t_0`!#pixo97AMtg}1#^2ARCb1Vpls!2W#Njr~K! z>aF{M6DYh`75=ojGQ>MLQWDQ7CNTY%(=0smqktJ958=8;<360F zNz|d5(D!Ye1#)Q}@#PQE02456|3P0X_CofHHL`9x*k!Ipf9h2);i}UEl;4N!kbBtc z1$Q^ich%PuzBh?dzu`Khb%Uq8}r zo(MtLKE*;bT`uJ7RVNqde!SYCOxg!LA_@a-6Aj5i68p)PmH8g+t zwxe&aHDN$q%IPD#OrKv{=Q%10f4NOV!V&e*sF6 z+wN7x-^U1Yo{UtxjqHT4tc-=ULS-st!xiv1aMi5Na*=om|g%uH;^#0DjxRPs`FcH>b?Z&dX3Fe_2<@! z4&tTG5h1?=`T^YGE9aB(#z2>pykDJ!6$ALd2l;eu5uk~Y@zy9(zD!c?&Vno~PFHe8 zC5ZrWna;um44CfkG^g1Wz_CKny2Tsgo?y>Uf^fAr5VI#bklrGgVZa$t`9nqnjLU?XexiFmdd|} zjYEEgocR#b)f#jRZIEJ}`6h4A&4uRXz7#%Hb-;n6VUgDCTN5f**lFJDOG5kTOJd;O zbw4AuluPet;mUZ;EY&pXW0%}mh%w`|%fuOY`=~Gs@bIR!EZ8t~{#Q-MGTq_rbVS@C z38VD-^qtk!y+Y}Z*#NG0XY*a2p5qb6U6hVk+d@NIlxM$mul`j>-Xpq)QHOxL>;=LM z4S-x9cER42bT!-1Gj$5DeLwU~pNmz(NOb8HfxSn2fo!|T?3bYq*h{VE9KwseYX%Zy zmA-dAl;6oR>j9hD1yba3XMWUTLr6j!3|(K4UKWtAY!XQpa4kJl1nqGEcTYw*NiboK zgGxO7)A15*4MH!Y13EXV%>5P(2j6ohs_hofVF;2ADHb{%Oz2kWsT#tvwb1AWq7_= zX3Mq<+-EFNQp=dbevG)3xxG1)+1ahbbrP%BdyEi{<4k@No+3=9m=(x0vD(6B5=C^e z<4<`q(2=_o#81ff1%$fhM-2D|Q89*cL7+$Il_R)`Svw&RegZbH(F%3m0T(BSwWT+N z*l3>$)<(9h9OVs3)KNMu(AFtQ8C5%)s;Ypwnue&3%c?&u^ z4#H5VX8GV}nveHfQI(gEyLp}59h`HQ3_k#*EQxtPRvhvPY#Nni9>1~l;1zdwZF57a z%*ePUP(uoh-b#2Er!?8jya6rfB9+V)sIT?0oTk^*c&pmzr-rkUgf~lBhw0We?B^Oj z18hcnj%u2wox#L+sHx~dTx1C@mGq3ASbbn_ML_)6|4q`*hzO?yqGBLnJvd<6K<#cD zY^muIC}XEo2gP0|`H`NSA*41m4Znc_dlgW2IDuB@;%6X|MLi{SGN#I5KJ*fQv1R<_ zXQ$}R1}}onMgM1iTka!cU7!ps3ug-nn^)Txtu;+zoNEXpm@2;Et~)gY46K~1lwKOW zzBnd>mlk?vuBX>^MP?g@nylx2^1)D4zJ_V{U7bWFH44SLlYR}`G9bBwL=mK5rwkHC`q=#cSK&*`89`R|1J#;Et?K@NS9v9SHK$kk|7<7?eutBB zXsb=^t`-vYf3ryBqAV26s}&W~_?7}pnZjN&JzGdR7{SZ@{pea$Q=o<`^4{ZtswBB7 zw!XNWfchvx@pYMpo5moE|8`-$>k!_l>V(38JKZ1MY6mu0u~c-p0>|bwSE7GjIw)3o zXP(%NyVyQNTFYVKez&`dETUM)CrQ3ETTsY;7(4|b1vS)U7}+ zFih*-_X5cyk1`N+-{9tb#jf9~70&gnj8FQNg!>oKE(%3OUeyCn5CZs~4h*j!krfet zcqROJ87eZ*K`4MrsSiz7U`mQh-hUpt<%G!Rz^j+>VO))WTHdJ`^p`1ywk_t!Y8JPIo%T%|eJbjW13& zCSq;e(QAt=Hi`qNC!*2hTeL!o>t0RQwFvIa>ptnm zbK*QA%$QQ_z_K9UkJYw~QYD_tVRUh3U>u7K|EtOL1RA>GB#{%~>R1o|smU8A85bIQ z2E6kOXvg3^V+@hxYCsa0z-nd+*d7lo49=FY#@kC0BqLw+GU_PgDG4nSnH&nxDW%D6 zwsc>C9DQJ@D-vpE?w=rLG{BlyDt>Zz#>h^&O(}?q3WnQu(XUR~ezgqn5yJws9R=}H zMw={|HbBudy5IK)g!Mk3&(My4y&+_HBKKb@8LqJa?&tsG;Vz7uFjLQ}oYQCZc1gVR z&=J#LY+niG3Nhg&;tm5N^8M#{3o*x#PnZ&OE71U^b)pO;O+`?~VuCI*KS0Cr1qY!C zB#yda&dK%f`d;;UV+@@spKV|?t|(Y|FN1NpH^w(z!`wCMx!{~Q^j+#Jvo|+DuIG3t zqA6;?!G|U;yh$zJ$|kP$b0*u|PlCuF3?rHMoff&u9gRn=qfLPv>3C1QS1gp-0CaY? z990?5^HrTlZq9HJmfh<-Kgf*acEl+dJ9;ic$RkYSGP8P_^p~7YD%5=!6crHTV0FG! zUy}N93X-&4KsTgDb~S)-PZy}K&QYGUS4UcK6q4V(BMVb6*32;Ck#H&AeWynL&^g`v zH0hZkRt7m#k!RLEK&?pqouns7q5j2hcJLtP{8Wq_6bM*!UW1Ap^@-;~)di3D zwn>XWY8?t+W;3wT6+XacjI7@)eg## zyOtIbOpt zJh_fVu-gAb@#88G#zmyDn*D%vq-~d3G^K{7{nd%pNVa^g#oWna(3eA zH?6up!wqkGFjhbL;yAC)e$0zy62Ju1zz>SAcQ}52{`ebQs)T{(d~Xm$#uA`1)CXfy zllq#dLqrrOU1jCDv6ozf%b%N>X?WW?```8!ii#N!nqcPTblsU$)<9_nN$-_C-@E#M zQ0n@s`L13E{6RYcDs9`Bmte`HZEL=`>ISLh0@>sBg4akoaSDAHSGFdBR!d{x81|bG}Kn{sUuDsE}*qa48 zzeMVfbAaD378qVbBB)4%3E&CAM{5GXSPqMkSCkAC%4gpms8q{udxDpy#%rZVxm>{e zvM;E;_#1piOhz~>)-;2RF_OA+M)^o?N;j)e5_a@r-kOGR0cp>Rk@@!j@p%9+BbR_S zt=>_+n=1Y0{4l1rlG;b>_Yy|waq^<|9_+f(_H>l}E^5d4nF-c_Moo%_z6t6fXFwCWUd$PZWdHPse|syW&&VVUe*L~`g=MG48t{b(3?OJ39{jci3&AD z;-MgcvT30r2|kEyNk4*ATQQxTDF>5`Q0P9>x}l%*q^mLvq_&p2Rwt z;3y!OHrr!E`_1>$&+pJseD7Evk;VEM3HNv2f7E+Dk1HmT^O@G^H^3l#g)6UK!xEpg z$O3_#6RiCkPt4V&CTIbKzgdF1seX4BoAtDHMAz&eYr%2U6Qv>3`HNj!s6TL76ac*} ztsmnvS9N+czE%nbDL+c2uoR(Ye0h18!@$S6i z&-FYGc4~Uz@|c~SQ&QlwXiw;>M-R#ZZ+0QjPGkk%C+`I@<>gu5rP-TQ0-SI6sc&g- z4@LgrS)(Ny%0T;EG$6Cva@*{AG%V3@a9YCNJoL^518!^7P!D`SAYCz6DxPU`&tHF+ z-RC@%Uzm0Q238x7?iT`U!~q!T3iKmSL0UbAo5T7*d@z9sVb*WpInt|}b1$ZWAY;2% z!P3bn&?0Qgd8?PE5Ow6se0>b`rfQW`SxqY3yLi}^}QVJBk`DQP! zPt?qDiKff-ARg;6p3vwkBm#Bv3a9%T8A(;v;v~p;DAt+#FO^EeEDO*N7Z(vmjBU2SBr27cS9iE(6YsbkpHyV{~f6)UVj(b-Vurv2HOv!g$iBv_t zisJjSPPk97m}GUM;d>Hg<_tQkXb?H~F|MJD%0q^a<&jInc2(a>!&aGcbMS_24KYT zz&*m09yhQQVhcP*r6VtO-Typw19nCu5Np+IifD?RPL@mqx1+KwyVGkeohQwI`69rOOxMwa?fUc+cA9qs;EI;V2%DsIS)>C zL|Q;*kXYN~4b}{q3}$FILS958UwtCuz{xVz$oMPk@{Ku;<|8S>>#P%QpqzN}%P{dR&8;AA8k zr}Ot%@IzOj=)_v!r8xfcTjFg51tUljsDE2UcR^9?LokNBNIATd2G)ms_!xzYzk#&B z=}@NJ+R<+U*npp4ah5Q&!(XtH>}Pk9$TYU0}C)#lBMEx6)v_(m^A>$iegZi%1Ee4 z5E^Fp1OWTE1_3D+1JaXvKp(j*Uo(%%0ugRBwZFDvqR{$ew5{;*Q8!o*@o);jr9fU< zzg=V(5Q5-bf;cUYDedV?^9fsjPk}cUzU-UyA%gmbmrC)_AijY*T^D_=Rgbi+g1#~4 zY^E_UM>fl`y|YR2b@fX1`eVtu1=tF{Y^wWzTxt&{lZ_P5ey|(O7OJq{ zh|H-09=XY}Wb@hpXScCXkjSgCjI}5*`}@W`vD?r0WRi74%AoGV{=?>7zB;es3BnV& z1ootMfu-R;^|Y~}wdolMisfX&c?~;VJBhW9-I*|RJ`HbH-l3jRclVmrpl2tycr8HP z`zA`Vf-S~z$BnS3xoTSX@y5D>r=cL`Lk$1Q(T&6=+cq`wMz)b8+#*I$G7hp62;k(Gr;orT0kmD}(IynRjk7(mwY(O0ire@ALgi<$ zt&m&uFR@AL5gHGhy;ZTp&i_D`O^aQe!vwzUhjQ%AoqSKLRCpsv^GO7)M>E0ui;2JZ zp!53X(nO=m2y6ylNLi>{C~2IHUXGewN9Gw1?{Qnt76=kzv(cJ=##<5?MlAU8?gEBx z7`jk5DQP&6!sjmk#_dgYwn(0C{%<^o``EHS^(u!5W@DEp)L(`ac=Z#ENWO60lGwr> zr$Uc31*doqupG9sp7Ux5CD57Rc`Ue8xCk02^Zrd$VkUPGXhTQVWP=COO_yRKe?o?= zRp{z%`cn^ne21<6k#7JwfI$aQ!B1wT3A#~E92p3|c4x!09kF@u{NP}L%hg|%6N}L= zjEY4}0hK>t+ukVSQ!|`4OyxupS^K6&`)&!>qO|O#5O-9R9^|Ta;W>=~-=G2QVtKaoJA8l<;Qz)bvgvx8&*esXRKFgr;`b`{Hq2wi4K@zv$w@C1j=i_gk>zaB6yNV4hz-r9WpA-Yz z`W!Od=P5{1Aa--3=LuwUtEI$DMx$shn|EB+{W~A{W|I?F@;*CDaET!Gws^CP323+b zdg*pIhw^+bYtR%vZ*4hB;U`@m&~s>P>b7TURS#;fXgzAJqnl=Gdz%q6F+1qaJ5is6 z-0W9=c=OrqWu!2%#Jvc@>-$O=QIyNAu*llfCQAe&#x#6DBQpiJZu~)(_v=xEK8(F29HBV ze|-lfP`?wKby`^%5s+Ig$14n|;{_E>Hp;*-W6%r#TUPS%r1rM|j51ZeAJVo5nJ|7Wztzhg#{=>8$ z0Bc5JgFwV-#=n9^RPH{;`pPQggTntm4~h*NffYd=*U>T<>(a8ztV2J?a2jDrW2Is? z@h0sir&vaVTh4o`T37DI=VY!;wutHc_V$`FeYg746FP%;c{)U>A83LxJHq%%uuMdM ze)&iocGp2&O+oQhl(@?HHdBc2%1ap<(+cmw;+@f>)EW2-_zN%CC9YFCpp%6&2l!Oi zQ#|D0?&;HY#l95wN&zdrP5laIlZ>^ElULc2wDlAstlW6?Vk_OZY;9-fy4??DfRVozwX!G-mSbWoQYd^~?k52Yz1O!E z?UP7p)5La}@LdA-x~G7VpI*$UOZ!_WPIA_t{f<>pmB28G@jU;_cpO}s;=O4oA*ZEP zZ-LRfISY+43VlKTAvTdeM#){Ck*UU1Ia_NzMs+<9Z ziGy7Mi-z?|&(GJg4Vfoui5Xn!k?-n_R}Si4Fufc_MOa@>j2%o* zu=m*DLPIO-peax$?~~m`FbnnpozT%*#~OQe!eOmCq2;Sq`o}C{^9qF0cLEqqZ~aiK zJWi?N7XgVtM6txlf@2dZjz-Gq!-*>!DC-9eQRQK0;K8LhJCp}taXE0XC>wX*D`zCV zCkl%++A09&LqViaDMLrEuzo{(%5&YSY8k;pVU|dKi5-JQdZ)J)!{5#<*>Nu3`{`%Y zZu8wauPTRiws_hq#|#}#Bh#AE>yBnG{+n7PB~v+x?gr&Hj@utkAMjhV^%V+0MI0=r z3>X$MEb6Sd$6S3^yD9W4RlMQstqbgTFi1`EF&a$#yqYY`Ma?tn)-cC^?`x%LrxrfL ztKAv3xXZyN598gk)GCJ=(0aL5Z%;O;o@~!DqVw(x4Db)Vo!)f+M8dBWlJjS>_2O%u zqh;ajjh-CEGuk1!`xy;2vGH($&89pp4~_ZUQp;`6cb@kYn)rM2LeYB%66w2da$DBh zAIryypl`#;b+z_?wY8|`x?PtDwkG)`s!zMBxg&}B6TM9H*0-C&!TJx*B$PZJ2SpN^I=_qT&unejwbh3OcHdo6%Lytbq6<2;65{#;7tn%2` zxw^BLNO>^+CqbJiY!RHNSPC;@Wf4ZgDd?|k4>4v!i_o{|8@|ktkj|Y+EG!!=4V4B1 zb<2{7a9j02ti7I}h!?hX{zglkr(ZnQgR zGS>5Awac#p=Uo4fDD3J?4Y#l@dMU+bcIr*WSzk_QKs)*VyT3!gr)IUhcE4I1p)=kUDs zK6kiu=2K8*Lm@$A4^uL)vKj& zM^!L8>9D?s1{I?Vsdn0I(ma_MY#*VO!hvf2u1PH^!9YPYfI9YwkN9grdFeNk@2H4x zYti|<-+BL)n}(}4cZ_CgSQ=xhge301lTA$Sgd-e&ll_tEDOW(bM<`vr2+vq=T79+A zOaZ>q%<1GLu1T=7hr_Kq#Bn~MDmvM@5sxYK$ig^Ml|81^dBz<%XXSz#YXf4I?&4Y?qBP$T*CDtf`0 zTq=agKve1JJmVPZU{uj>xu{JPuUwOj2UDJjsEDe4*0Q}goX<|OY@A`mY;tvO6da@% z(@?i7<{<6vsQMRCi#svf?3&4(MHDNWPVd;z!MoJ5tt}rTG8hxrHbEvC^WNI&xXIPp zQ|bd#dSobFjfl%m+;v|I`eXSa)%$P)4gA8Z6=lF zESz6sgjmGXM&bj^?92T7I85KiTfLrHc#aEf@5Q^Fd7TDWW;{wo=qKS2#7-2YO$+1S z`~CwaCF!@A`>SMI-v z-?E+Z>~IsW=h5*_8`TwlQ^YQyR>XFH=UZPO#-iQ0!4_hoXMl0AubH|fR;11@vg?#(iUz3=Y{Gx;9?)KR@|rB6dL7SLT%9d!ATdZtXi3J$^fNy?c4U~- zkfo!zQxxK?G}Y8(xiR-@{B$Ej-rFXu{%YErp}Q7+Cqd}Y1e->zWoa3*9Dkj$i!fZW z!}N28Y;rqVXG37eI@6oCC_`*-Q44fajed8a8n1(Q`*vjB@Iz5-0P3(yd3h<>2{yRZ zRwQD4h9^o}9L(lH17rmJ7l8bIG}Y)5(kSDGB+7ozJ&wUCp<5HGjR>c;eGit@*Xe5 z0FQiqkpukoS#&I-&E*<}lzzGVQBjzTJAJ3Bb{!~|rvb0K-gL2Bn6@ZUnEkEe(mmt1 zq0z{j`k}A0yyr!R_VZ(H(j!Z1(u3ANo5MTKzRvT!Lu{R8N*p7Njl2e!9mR}#I(!No zCyxRY=t(cKa-Nm2tJ8M(3CD5ZlE$*NeX6W`=AXo)6DV%rWEqfzbB@u=JFC3DT2pxk z-2dND@+HR#yQGhOo0S}T&a2L;e4TB2nscS!oa|VIg>?~qL9KmNp17DMbb-=7!G-q0 z7QZ_Q$I9wkkA5b`*xqAJ5RVpRV4?iBMwB9Lj4xyN75en*JU%C)(n}~>gu3s!1Ycu* zZ#27PnesQ`+<{`Jn3)wPdAlF$%Y^Jp7rUZkZ>NL|zgbe+eI7mfgP*+rdi2?AKYpF* z?rCd;k!;<`#oY=5*zoUh0V5p0c`+6nN98jo>O3J-st+_sw-- zct?Q}7B=ssgBRb+wok_TUSO!qj8f<~ERmz-4(=X&0q7#4k^5t#XvK1Xw;QR7JO>+Y zi2PSQL76K_M@>S_sr}G_g_P^9h}@n>A_;5Zn=?b?Y7S3e0XgO|KytxI`@|zw%_m?X zjN$_-_w7Al0*<*N)#8;dYM;>~U<-;trg{AF1hD{8$rZpg+J_9114=WjjOC`UA4~Sx z;OeSRk%_W^`q5qU*$<#fdviVKb9?RHKvD1pK7^vGs>(`4EVw;g%U+{LbpE}Cf$tn5Q@3x;zr`zbckkx!l)xe+r|d^A>G9YI0hD&jeB zweshei4XCms?cife%C#=MCZ1CM$QKf3_0_pXd#qF2Ll(AK%FA|A%C8`Q0yl|N;Be? zm1XvyC-b#wOYpv=6XpfczgJS~>2{VaO;H$=MFu=V^W3wCf>dM+rQWkwpOkmsi1eTl z6%jI`h^6v{(c3lJu9uV{Kl5&g)*sD`O@XgNe`3I7`UQtiqo;hu7UC9QjGR(ex+A}F z+Hjtbd|p(h^Kyuy;QNN-Mr;k%oykA7GXFdQF`Jn8bv7MELA>pa@Bzg&NyvAt-6Q^+rb^nuGv{?tSfHTf68m^kWA>gBGJo=`$SpQ9V{#QF3PpoD ztcD~~CO42-LC2>$F7Uq_Rb^87Rs9^0N|!sEBn*o-(vbC=%lX@05a~@DDRh!F3&2%Q zAV$tb#Xtb*MjTQX3aA&xufPj+1++3s%F40F97ckZ0Gr*#D)Rsg!pnIi#Q?PDi9lFZ zpSH6xoNe42$H?ijqpblp{;9lw&vZth2CmYWeHOR=3+KrTM8Flfyx7~C|1-b1 zp(4yz4m%)-&Tiva^qwEV3_Vfn^2on+9M0Q)FC^WoNan3#Xmm8H=hA|s=gT(iheq89 z&alXNJB78ddxYG+SW(pZlw`b5A`Vha1SvSgQ+lEz9m~wZgC^`0#rfKT^@rNlh;nw_|s!V;#uxFrIq^R%G z2_Y5z>P=5fsWt0l?sO!L2~|cdz^%$!$LIG}bJ=}H^1Ra=vPuPB$^a)TRTs#kEhg*R zxyh*GJGMH*t2<`Y{`TB;gF4&mR>asQJ$(&FRu2N=mfK)l4qMKn7Q zo{8M4;CAsR-xvVh0!uVd&-L!WJw6aA{yUP}xF=kNHdMJo{~f$H8vY*3 z>eW|W2$5bfT)WZ7zZ-~CA9J1-+fqDxI)tlF8Po>4ys2ed4K2mHsj;9Ln{ay^=P4Y) z@gy)EoY+j|*LCf#t8Be~a$S^9lY{W$b;{+VQJE9Fekd|vqFb{2TPF$ede9y*m1JIH z@eWN}g5Gjn2=OC{Blb<$Z(0@|txFhaaLq(f6FR;SNoI?!oOmcR=^xqjrceRp} zhbzxtz2oCxOMe1BzQy5)*iH|MynCS*OrYnE-00=Aa5aid7sL_FqpIkg?d zz}4##s3TSZ0j9R=fOJ>jfMjB}`jvgfLOQS78Oc&e+~h-e!gR> zITGJT*g zBvg^0UY>cpt01_;3QUV8{Yja}VENG&)Oz#}D3EFmL+IZnNSwu8;cdxO$D-8j>Jf(_ zfAO8vncDJ257jeRywBmgb=8MrSzEfdY+v6nnv^>@SfW=cA12u^iMqlqF5l1nY~Eol zi32#Ga-=hWmYRuDl@)Paz&vLlpz!6}U>B`P`;iS8{9K`0TEm%ggN{k4hz~J#jNQ|^ zK=+|OxZ;r+_fj+23Y<)(s^i-aD_{atofCy=er+*|5PZJ%s{RAGZ$UrEw;S*qEdx#o z&X^6dAxu*u8Tq-k@9Vu{^c$mlz>hXy(dv0&#hIveij*nm5t`)lr$Yx-t(-TGDpCWZo8gRqE8ze z>f$VRW1+dK2VPmr1U^@3ldSbcJu&!vno%mN6UeG>hFO)+c9T`Zm@P%~mc5F|&X;f0 z&-ok^`hQLs=g71!MQYM+M=Wi%w#A=AyRsB>jRvJV2yVxZ#S8op0Sj0Sfp^y*{NkcR zy_`-zXVxtazBT7-nTRlR$*PAa2oj0-)R_Ww0URRImaPU(@0oUf?_zkijr>R?=P2XWEcL7M@8dqN4&yE#i)m7O2ZGwoMHfY3 zODC|s%kdVzjvto~?;toFi?;2*L}-N(|2q3<7p;i7-1np&U`3AGUacd%l8~+ttY2$6k=Y!;Cz2XbO~~f=eh}(QCmBX%9p_ zM6UKekB*L-!-49ScHZkW6*-V#6`?|1kWNbwM!33~sFzUQd?AH2Wuaku14S8DC+d*P zubK&S{ta;ZDmR^UFcudeD+{NC!0*{_e7QmN(WuQkvRR|{#mGWtpS@k!>xcSv%|!3? z(R)sCO3?TjPKvfQhJ`AF!U>?+{0!*RM75{^1?Qd=AKuE`CSaL$O=2F&aO*0&=Y1Eg z90JcnhLRC=N@jNkrw4aB;~%Z@xt5u@ZV`;M8mM3!63F~MdR3o=Yvyu;jh_Jz)H6D5 z*6A?LN=zU|=WOctJWL|SmQI^AR=3B8e!s)k_NcmI<*s(%Q{q>zn4;JhxX_?5S@Qu* z6a*VJQ?dk{PT=O6ed;s_+~ki_D1!aFvdHaMyP=%YYn&`E{9b1B8<5`9?^z;))q%V3 z#hV3<6x3r1aPA6zhaQ5~WNAAHdN%7EK9F%fFaRzgukoRCjBBATQJdNj8AvsHD_ zrT*X#OWh1lBQztP?^!ZGTA@NL?W#CE-HYH7N^Y?+)b{}Wv6Eq)MKn$ZfDv(0pS_Uu zIW<`e4d5Rd;Emp9xC{g;s&257!U$;iv1;3@;b8J(gUp(nn~!?n@0J4}OE1_EItH6X zE+KM-Zh?(?DLyyn2Ox2xkQm12u-1*-90}YlvB}mAl0R*I=OA+^RjA!Tru9WdOvjw) z1-OH5u4%CUDE=OZditI)F*9?qx|#g?_7v=YQH*p&in6sqhjjo_ar(fHXQr)*@(J#} zoVigH$UIVp1ZHAQJW($Q=JG7Vzl!5#nKM}0Z2GIe&?qo)70jGQy&PJlZB^k8Y`@+& z2X*zKB-%7y1-OIz?7ov+txdSbI$*KX1>*lCfsQKZmoW--Yx+Gmq4Xf~Bm47?v>>m= z#*3ZMMEPHRQE#~Mu|zgbz2s(Ubm{DAbjzkfG>P)0ET8GqSk{Gn8gNn_BWCw^oDMp9 zl#Qa1HH~0 zC+5|QxVzZwK}Gq$y2|PX{4CC&GExrhJkXd2P_x$XtM@LuIRFaVo_)I{So$5@ZRbu= zCV6XK$&y`b9+xJZFO_S-1-Biy?*yOj`L;vpbXB%Bu6sXo=?Hg$3c>00$+gwfYK&Kp z(rkj)C+4BF$T~TdJ`D6h#ijxf7vJ%sqGIFA6T}5o^)=l*9O&p+0a#InoFwf}rcC>Q zku^&c1~g3AbK9Mf)a5jdU`-Q~>ke|UBXR&}nnamv`%Gnw5&|^(X^r6CJ3y-3Aq{=( zEw6=Uklyc4r3%KYZ#63P{UF>(N6S2&QH@;3w zNezxF-mdG{svpaM2P(yW+NAK?()u4oqPdoq1wkIShbYiwzPD`2g75ChVfZ=|%kxXT zy_!LS>bRA$3&w#zNf;`v;0>zrjn9my6fOYIJ!#Tv^xbKO8|_^G%Mv(mzKsym=eYIJ z3{uW5F>NSD-B&$bRTVg-*+>So@v@b3g^rn;nmjg>EQfSx#}aiqm61zLn znkG`b>W+$x_rx%zcr509*^u|;wV8opSX~89bZ%cofDEg>vr+eK&zT{`|3VS387373@=V90bVl?7+ z$Mg5vb6f8yTZ&=S^jol+P>^&5zUfS5v>=Ac+N!1~9~&7{0S__3aM>h|S4jJ5THBuX z3AllZs91!8g5v1V)lI8RGg`7eOz`~J2!!abMXq$dKpQ6@ON*&Hpp%crX8SH$*09Nh zHVu9uoM$cy34Gkcqbl*b!!ZYvwpPKBQ%c3j;O+J0c7)7$f#Z9JWEU)fivu!FX5ZcU z+nc%)5bOBU8Qi_&t{V|#TvEtg1IO+>Af5ai#JCVaP3NB$o&s0>*d-i%Yh_fYW#5;u zO52cV9588XI#XQc{5yyqjXM@nCI5vsKnG9A&=(u=tJ?ZjG40@^WNA{eY<|IklK?EC z;)TVW5( zDT!z1kfHOMU!HC(Y$xC<*@M6OUza~(yiNNY39Cun8QQT!o4z0CMD`nDqBEn%gmHVk_$9M-TYzeY#6(pIsoqxS+<#3&Fv5+s8*g zx0;4(+@8uBaITov7#R*u8dAZbQ^sbVk|PAj8PaeVBHUj+RR%FTZMJN>+*NWo=>gJ@ zpQciX_{8pn=chdb9|3CJ3FB=&&mc<3v>91muy1@Bor$7L8U(3{Vq8R?^4b>tD*)qB zXq6O$o-M8Ut7Ut%TtdicF1bM%1_WhY~bwzWXX?3~R2I$EFn zhr@0$e81P;uL-U#Z(M1_^+LyAOJP9UAXuL}jGF}dsJk4a7PdRtg4#`>8vtba z9qo2n#a(HmTB391VJJ5DA`zfnwDOvl?44-4<-NU9=V!~2L`Tm19mK_5-vLytJ?DLC zW5EbbK@E!|l(rrPOY!HSovd*3&%XGMs>U4(kW6 z;3TV7t;|nn*PgamICy&s(zesQFFv9jU9XGWM(0W=Zc@?rdsv1+u23L?AVb`EvclkR zh8?gev6J&ykGgQK5?}mhx@|k6KH=(4bBjO5-+H1)oO{R=Oxry?-n4z`1t)JIl+8z- zZ@Wm5;b$3t+3&T2sdx3pc_Z{>=RU42)>fO0$Xx|8n|xQU)is`%J&A+HyD@wGp%?cq zG9fF!1o6l8R+R(ue?_8p-<`T_;1OwFO>X%6xuaBwtwT#rhpQSS7>?U_L}8w}>QsNe zQn#JB_D}UE51%&)nkV;b#yvDc=SXOqyb&E7g;keq+JWUF{PhyBEJu}2vmNk1D0uHaG_bP2AcQskm=v`?w0G#x{qW{l zfxo)vo?TG*-8xeIr};#>%83RCmykpHLx#g=%TaFe=VLr4J<(PUMw1XTmq(&7!`x|K zXS}pWDt)TFD98=^DdJp#R7?TKF4=wOrCzF*{nfTIg30r#`_J`pA0l7p^ka6<~@=X~X_gMw`6}pg_pZAsZ zz)s;sdP}g2yUM8NA(x3i_i=XGHXLPXk@XH99&)S0c&xjkL@|T0;^3fgaf1jY>CY!PTk6xGito8Zs{FwK zKaCRCfq~T~-Z+EO9D1B%cWO3q)f^)s2LR2NU@V(>8L6p^HD`L#|j({;5xjfYE6z&or*ogk?Og_iI8Z8T&SLj6Wo=nCk$ zRe!*l%Nw)!DNdADx;*B_)bvUR0dut39Cl6Ss6igYe&XOd0RtQ6kzpC1Okv3Qt<&ce zZ|`J$b|?Iw*J_(|D)RBA(x1NH7OS)YOOS&ax7LZKfF<)~$E3a?=|#$yC4Np3%0L%fuPxP2`&`8b{U>_jh49pbD%~@-r@3{7 zz_O zlK^svhV*E=$NmP}s{^b+D?Y3w5wGuBOOq8K_tLdT_T}ifcd;~Ay21qk`>U<3FML#-eIiYrwaX4Fk&MwzF=422=U{3=2PGais8&=Tf=~C+- zf9lWTJaYN4Z!|O#BNNz&ZY6wN^HI89bN0%BG{M{lIqLV$N;BKwas+`6%7?T*?TJs? z)29+=LaP?xaUeP2>U>>~@Oz+I01|_tbeW|E5j>@2&blser_FejNGy^&teQtKGR(YZ zceiZkuJ7?90VO0YgKRtM!JD}HA1Xz|y6O0R{o#PXDxg}3g&!F-B46#o=lIm&mXsI| zaK*Bbu7RdkG9Y~f3AvL#|KLwnm-S*jkcx_q!vq)+zay8bnU{nxpbN@CaN8^5+-V&; zd8n872~k|}mWz=~2dKQy3`qIxb7t-PSlz`Es|pzG_Rz$hZMOHUjS^_il9Yb1Jf8FT zJ(?7c_hF^F;e%zb)e-QgGeCoCZ>ww#nud$ZJ=%nr&gR#b9n%Q;a~wrsVxu0Ef$|p2 z)7T8e%fHy7|OO-(l zU|DrM_uQY!NXQVz2@i~{A{kY1vbZvxZ#~6fv>Lg`?Pw2w14RmDLv@4M9I38!#D!5k zPGu-%Y@cZANTlaao%hyy0wU^z8v){6?$sIGh0kKEae7Fu{2E0_sVO)|i(pe1! zO0J3yy=)Vj=IU&}(?wfdL6IC|;1}v*kGA03R{@b>c^<6PSAoQXS>)J-7m=XLA=<`P z@Y-S7tV6919G)J3b(!j2m7nlw(kp!@nu#Z4qFtbxc zk?yImErAK79&lA!;L;P$bwUobASi$hoT91}`(DP8ay=2Dc>HAsh$+&AMi#n+iI=Tx z9hS-~OTix}*~Hd-_JMD!q#A1N`f;JNy9VT(+atRvC&A5v>>R*mU9^Xy-je}4MH7%# zDjnh0-@1*6MYknnpv*K0ke{u6)fCyp(Af;G!|FwI4U)jvl!9|8<^JlgPVtT)hG7yg zFqMLPLkaM&f_#VIE5V_uGaHLFcZO&K%SqPCbekHQz00KEQPT=j=O5-o(E`E*1w;rN z^0X}9-PHdMYSinKw9bzKU2G2IE!Rvm=%DG84u{u%lJNch$~jC2Jnf3z6;UQZZ?A1` zBfuPx9b|LeX6@41obb4kE=A$`I&G6rdhE5+QC?!7>9c2+zaneSx}^nxTVY zEpID%D6`S^w=8j?u=H_;sxeln;!4M|1hCHf8T6$H5~0{Mt>%;wpN?dOT)l~uGse58dVMDhons!* zKJLy>b_s5tF$`SXpZ3j`OANHzDg&*9#U{V24vs!0N>A0j;FXCVbJcdD9I{^DiZ#9s zF@B0~%+06x)zU7!w*tqh{QdmiWX-9Sp;bS9l%-o$F1xX-q0{}YeZ+{+bi9(1uJdEQ zsAoX_L3S>ROULe_2UD-5To_Gi_fnPUJ@0wpU0AkpJVr3Y=dG zeW0k2a;9@eB5!t>t(9zG_b_46;@QS6&;}dkT+F`6J=v5t?)#i=w0?AIbjyi8Eq?xJ zeb>{N#8fB6`H=TPLT0IKvP6$VJkTCg#DS+i9WQmDYt08nPB4A}K(27T^2%bH?_g0h zucq^7^KDrvJSQ-lgb@zT_hv2%UDj_o0a;*&<}EBJh@y)WRPwWTtwD|J>Y|1%Fb0Zk z)S%Mk4vwNypm*!IE(VxyVD90t-*>h2<<4kgF8lr(>0Ui<^!3|3SnVUa8?aJx{eLq4;c#!*EGkBWP}aGXqv8b7j_TcBsPTaXqN^Zx;Ff`s-%-JrLYE3FcR}UP#W*8*EKv{0FM=o~@}U40Kbnr-a1Xu9A=8bXIRZ=OAG!8o`P zu>M`UH)MQx)A}WOXg9(1!chF2?VOjUk!K*H_=)on7>e|=@=IKruV-0e$ zP`dm3!!rkg%WcM*p7hhB69*cX=c$o6rHGZjH<9))Iy3~nRL`_-e_UK-up1ZNoQm^& z(Szv@zHYVch`L7HgnF9HaW((j@0eaAqZmV6rxEEI=_Ke6iJT#&$~vqDu(bE!>vJjYms49ts>jYJ((a0rL_-7FZ%U1 z{DyRZLc_=UMuNv*LfKJy!w$Ky{kctMf9Ybamd?jEAcu4sFxfG|Mb8Mw4f$Nk@3fiY z&f_ftPNX{tA=PeyRua1b=|d_uW(rfpXH)R$wGG+|-byUnN3*+qqZ5l|J!ga!R&kCE z9*8fOQ2GbiVApZ7M$AL~>c0x~yJSdaPk;2BY7lq!I*hlZ zyv-QJ*@$%u`!i{ng65L<+EWd+wZ7JV)s;o};!|S7`OE6~*h&%I@zH=(!Cm z)(EnQ;g>=V7bM1wFSV$*VYJk4M1^U=8ThQ?*K-6J`&(9i^; zh&~mOKH~6d=Lb~Htg{;?eKw0zo7wmKs{(B^Qti5cFGr9!iZ=tkG<9&+|J;6R#gEf= zNfN-5F+JDDIpCrZ+su{jT^W%6BAZQ`yvJ#HaLUX_v0yy#_7182|IYz`^7u<--=M_;O$FTdZwOYqK>SP!x9M;NN>nF(FjC(L-y={in8feQb{)#q` zQMIf3wE~*SuVKvDmfaPjf6KmN&|Xno3TlZ2Qb0x0Ye3ak-kHWvjr(STY2)X36Z?2Mo+4lILALuT#V`8rioxFaTz0o|Kac;AqLN0GQBz28oS!xZ*K{b-pw zOGzp}n2CD^$^3U6oTAUFb5wMZ(MQ%*_fUAc!(4u{?Gsp-g8gQvFzB0c`hQ#&G6(b!!m^yN;9>_ z=B@c6vURR`FZJ}LuO#~T7V@HROVs%Mf0nN}_}X}FaJ#wKk=&zN+_Su&^!Xaq;vApu z?S?MO&|Ql&@J;DX#-`Y|V)CIN7L@8y5gS`uJeCSHwCJT3YDRj8{VxW)4I_o#`?L|` zGM&%N-{b{BE9&m}V(cztlPVruzw{)KYs{sM;H(ceWLu(7<5JdF(xuCEZs%`M|HS+shE}*T5+OPKa_E^=o~dZLNKQy zP$IaDebgyhQ(e0}*PztL6)GqU#c z%bcfz-Ew^Bp+R^(c{@K(1WV@ zPQrbIjI|G4Xu9jyEfghfzl6QGEd8O06;K_zYQjrz`~z&3vRw?%Go^*x$OZvkMhB`k zH4n6uvVN}v*MyVu5e=6kfB!H@ipxy_AJPbjHBG1Y z8>Ps6`+Begm&h?_C#0TQ2>(z3Y6H7=_FB*8K~)IjQT{Qx(bjq(PBrc<>d*pmCpLS# zgLE0v_TO)( z<=vCh---P{uHHH-%BXGM9za2A0I8t{5ClY832CH3N|2Ni5RmSnL6DH{?vf7a7LZoD zyQLchiQk^*dEf6_>-}Ta8rG6Ec;B<{eeLTy&*Rjrrs^i`_Z%X?C`V^IZ#i*g>7DPLYkYYvQ8i?}x@5-CF6{eh%<2g=d6y}qy z_s_~Az$6;Qc_2baJ3d{Rb|Czax!8?L53eW(THNFk{Z0rP!@VFd8HmEOckk{=(x@KVE;UkSV7L7F;X%*WE>Su!Q<1YnYBg*z%>%(~|0x6Fk^oB2m z4qbC_y{pIHJ7rnAUOk+tL_}TC z#fYg1`#Yq(?^Fu;ju;q-aqOL3$1ZPB2w>13rJc*))avITd;LNoAd34#eVa0gE7QPE z@BMJ~^|63k;2f!yQU1-O)M}>1`LCB=+nvMJ&M^Ix{kd3^W`DS2=#YBE8>hN!LQ`JD z`Qe~$o%#_5Jgb+jv#Eyr6<7XCy*w!%Ga|sbyuKUV{7`aJbkxWBlw>HrN0^MYlL0pp zg-|i}G+jiOEooF=s@0{X`D&j?tJ*LN2sE2|-=D48Z}=!IH)kr}C9VdNe%9|>hH$Na z88nyajZ7is2J$99G$3RriKDecz8y?qnUA8SmP^#oGH$|&p%wYmmmi3WfB4$CS1KY* z#fS^X$9c?TH_b3}LF=u+4c82pb?BZ)+wYu()|Ng)iO&|FxFi}#;%WagQ0x6_N7`!} zB21Qm6ih{E!+{?+j@E(cunV|%z|I|)amEkTzvR)qYa-Z}qjN^%} zA2s)}g4E0~ZzS3?eU2lt>rc>F4JYc(Sb>sMCY2GoGG#zM42r^lCJ`|56kQe>vzh)l ze2PCbatS$eIt9L$c6S&jNPcktR6j`mWY}qc*y*-A?A>RK0I-#92d|`({|>9e5EeyI z%XyDed_?2|RKh!+AlGn$LTyIRX01DH=L-t{!f=-O`I&8~SRoyHE+W^5?NURZRe6{y z{S0z^3pU*Z#&pE%-FsxzMuFfYe>Js@BWwV*e(mD{?vlSJz`CJA@a{yh>B^9y2D{ec6(EC)uPaR>T8jJX1BLTbt5k61!sX)-zm(k+D{t28m(dm7jxIuengk$;)xAvE`%(YvPZXoOlJyboO~lx@`{O1ar7@Z zBlo=%nuU&9%{=zl%55FyY{i#$`|ozSV_;QaWq5@@go|sDB7`|d22KmlQeLVccTGx@ zQ84;3HysWSSejP|$dwF8`upJw@fO4E%K$J(+#=qRylg1-^j8(}GHne0-aP*Bh!pZ{ z-NGaVCv0mti)}o(IqCX|l|PSFh}RbV;qNpt@BZ<+l@}%|!X_%MHEDdaJ!3m%=hDJX zRoHbgFP#su$YgcA%u+nEAoCAtZ&+z=lM$ z_~93J(<30raiViUmNAVUd*W4`#x&}rfqu}^s|UfZiGImPpL%>1|CkP6E$fD( zWo1TKTMj027PXP{9?(u0sxY{{JTsiDwbAezxv0Pi7N6?MQuMiFYdL$Hb_hmmj<422 zUl4r@{TT%&O_G~TP-~)Tvg!ve(7;WCIr%aR*b)QWVHNetoG|_ zk3;i=H}S>-*i7D1pQvQmdgoB7Prc!y7jfmWQEZX!GxFeco7BIUR2#UsAB-x(IE>@o zN_|XO{eTAdNFkxND&ImtYofHJkq}g@N~8m2_Pqd9#exa)kNaTgd^#AR8{SlH zK5EZ*y3I(tMIfygRYmU7T8pQ)R1WeHue9SS=Ojs}=ibFrj!DMD$Fw?8#>vc}5z#jv z_t*qB&4eIwJ!Vu88_6NMot|&oNlu~a)d|ExP>tIcYss8VD}QtHc8ah~#$vZjc5Se0 z@H$OneET^@d4n0}tz#MW^~1$2AV zx8X}ueZw|U!2{2taNBhC`|FD@*X+kDFRR&;m&^itaE%fwdl4q{_K#4MtY=XbMs@Ua z)2oH}UBoC_|0E6>;Tm#PaDJ-th1(@rukQVhrCt;ZY*f{cDL+na_8&n{&&~6k?;=xT z315O%%U3bo7u74TZ`0#LcfyTy5V8Yk=>9%A8DLIeka$fg95a|PUv$p6T zzTa@7a#&wSkD!)6Rpuwr>1A!Ck!t<#4vpMQnRQq%4@EK$+yCU}SK5Jr{?wsF;f|kl z)AziK&I8{R=cOHVO}O>RvwCVE_<#XD=MhI#p%jM5fJ0Tsh0qF)i3 z5PYSb%o{A+WB>XJ3WRux{(=VvYY+=HoE(nc4j9%E=3)|G6qQM>{x)UMK_xKw{LY~d z{0qkXX9yRgqd*nL0Ez$TgiYGp0|{$5(fK!GRjW|ux6V;B5Gjpy&8pX7*q%!AP<7875AumhVh4R#nNsmhhReHPALres zT4HYDJ{Vu(|3gi0#>VVSdAwLP1jaAhAc_rD z=x+B-3V^Du!T!h>GPTN}$uqTDA*DTs&{k zEBWz?#Te)y8Z}TJbQsc->N-Z@z&JAR(3$M_Dtr0~q$#uZ>z|-jqh(6zHarul_ge~O zyf7EUl+Fkp;V_?xu_Kh$jl@2_3GklJH7fm)J{U^0VpD5#(4_)5??Y2Cg9(`tVkpc_ zX$i?l!;Z(}s?}Xq-&$egsA1;p=h9gZOqK$rQ>i^9Qj)AoETeijPdFEp`Nz3$cI4bf z2^0l-<=w{GPUmK`HYUgt#1q`e^)tnU|7d9)g}l6BXzBCeu%NSZ#;}qnBQuTWG|#>Y z<8jJ+V?9b>Ey#%Hl*bw?J!ekm;X~a;rvHc$PToy=A|7Fzp46Xx3rirxU_BA_@C9;ScITh?37*x$@e~WG}OKlZgr-kGX<3oB5Qp3ZB8=8@0VNJ?C@jLV2PCs{Lc8 z{=TLNWA|9%X;p^0W{Sah_i3qD)@ebeLx7ch*a(eo|D(B+KyyN=2EoGT`!B9W!)$6Davh8I<`yIS|#;GR|fAA<3+@hcDcNTX+r@*6)KjnVuK- zV z8>3aV^RVy^wn9fkr$;5<37JrTWmT_q{VyeT>Hg1(wgnLiF?P%fJ5&#Gk(cn}$y24o zEr01b-;zv%INE(Wb`M&mME3QpH~n%b`v)--w>hFskZ))R42UAk48x~KRM5v!-385` z6mqQYg6n$>xmK&zz*i*ikxA3JiAze-k#Zy zC%^RkEI68RMyF`>7lRzbF|Hf3y63nP} zeVkh&K5;;C%>voTJc*26LORjY57f{2Uw4w_STwq*3B6b&wQ=5IEuV_2s)CzMX%&?< zEAuFLDc0Uxg!IFNhj~{xmHx_V+VU=VTT#h+S)V*KGHQZbLWZp7hfO2n`bf2gm7XPV z82833b=irtJ1jxhv4yNhAKvEuwRkk!i+gA$aE|M@2Wy)5z{m+x*O;~TOtC!?qw6Q_ z$g=RrO3U@r0DWF%jC63a-|#*m_#LwpOFiFEP8-T|#gssy?XRkDfpLyG#hs$SybaEi z#rYj$uP}D_A|)~x)?P>tD0k%VON8ON&E8&Se&m@r2(fgnh`ISVH ztwp$GBb5ZKT){wF9oSnrjcfoDYyHuCujTMxaulHoB4Qs=s4F*|;Tjp3Z{4ZZg;|W^rlK($K??p9Z`JCNMP{2S_gm(W)OI^@!K) zK5r68d1(fzPi`Oh0wiJFhN*IV)B_F;!)XS?$UJTCphh6W9Iqfk(-gG?=jUjz%+(w! z^U^Sw0oibw%i*FukbV>&|DpC~QBA|yH@tnFhJrU5FVEH>VcodRP)Y|Uha4GtGy!|? zf=#=4|MKtZ3o%oOH|K1J4;J0KPy-YZfHnJG?J4|wFyVC|@Ye)xJ;zDplcdZELh9ce z%l6M=k6ycXV2uG)W0t$}N4I91B<$0XN|XHcEUj0{NVd(6;+{mWFuVmX=N)!Tf?FnlHZ4{vachtJsIe>k3!3D#ekFA$9hH+wgyj9N^Vt^D z2+L{Z!heELW+Z0eU@5cj2d%S%E!JY&F{KlTXDOZ%fpTVryI-P<;?7#SRG7-$exx?p z{+6--bJ)}lV+jitO*RDklxs}e57F;Cv|@A63ri-E*4r^T`&@ud{_}MERy&d28o#-Q7b}>B&Mb9^_T`JTkQXBI8 z9YJ(qeUZ)qbHDGUA`0S6tQbSVAI@iBe7pu?agW(Qe-h-v?0 zA|2*B_uqJ;KRPPiaJ959?`8HwWR)p_herRdPFRe5^zqa<>`UuJglmw;e9|?8 z$#fcP*hV1Rp&g(KI5DtW(c+qr4_ztfHstJA)Smh?I38K{+)p}(yl@-la8r-3iE`GW zlEp|{R;&p-`a`8i_#00*$7(-Qc)dV)em$(Vb6uX_B5;E?986mKN&lEreX=m#nxA?5 zuYIBFt|Z1-L2@}vQRs)0bq@Udqrartxj6+RC!`M-o6|wR6 z)x?~Q2*Sp`sr53<`a+ZnUOv7Xx9=2OJZ4y` zPNmMMHC5WOSy(bLF*o{Is zE!s)$7*jpg4b!s}Z)(p*oHZ$rV<|SUt>`0ua&wD(*|pxK-}z8QC!E^tRe&I>PU9-2 zI!Z%$Q4IA-*B_^zRns!-j}k~xnrDiqbLa>8)sCzcdK4B$S?T{uP>2YND!OAVJ_z)S zKf*86+i855PH(kmLXjbcSm0yuPD_ye^S~&!a!KmE!@m^Wa;_!j^E;Hz(m$^LC7`7Y zUYZMhmgW46R5?!RyTeZ%%1aF>YbPqdxqTEoOiFLi$uwQ;eUFA9El*kyDB=~MI_;rm z<<`)p*U|G*ab~&Fy)rSfTxu0XPck;;!Vy`~w=L*K4CB3B%Q)*keRC3aAsJ)CK*mK9 z$$r*hQEYkprIA1yzJsDrtl!y)VK;IopRV?=<4Scc17Y?Slq#AcNPbU(DdI+3px4IL zOyp%KJ7}}p%)x4E19wq`yu`fqa~3UqYq$Q&2Lvv;u)luHz^76|TZLf2BnIz7ofPAddV!VBf)P93`BsoW;~&(kR(FpeW7T%;|YW1DkPM;Orq>7zLF|Jnbmdw zV(E$~0g5jp=Qnm&{T%t9_-06KX_&?Jr>cFDp7xV_F~)yDs$3sqE{3qmLpjjFbhOP( zPAFnS-)JZb55uE0>NhK*1LLW%wOu29Iw=R%EilGUBocB=1I$A_*cmbCLSs23gzj%n zAC{x19^B4aln(%Lc+PT5aUkkXVon=`&cGmdZ= z#M?20&-hOMK?UBO4_rB_=_=pRFh1j59dV6a$KiP)(*FWu_BaRjA(7Aiw_>#YQ4Jim_LH*JFTp;vwty z=r;Y51l_uqG;AnBo&UHYA{R!80wd~n{H4ktInzM6B?{9^;@ zLMbw86$UowcY$A|l{cUHxT@QX8D@n5w_=3j_Ne5R;-=S`AbLO^<)+xCsM%1ukPfgb zR#EWTX0}o!9zsY0=b)a8go6o)W+UqY7N|}m(B~7|26_d%U}Na0EES}?$!-0m_WCO( zHgD*L!v13Diy@gd+``m)XwXQISv<$eepNQHFY^AyPsvji#sSh_Mt1RmOT*PXkKy6p zkLG8|Vkv$N*{l!#-GE;9*m|;L)Qqf_WN7m#jgsq^;I6oa{pD!M4BI}(?szf4sFM+fs3B5cNg1FQ=}G|1Wez=+{W!ZHQt2dWNvS&`ibs|M(aT4N6u6}f-Sq< zW>k@EFpEOFM+j&n)ynxAzEIpw(u)X{GYx+Ars}AoweV;K4So7K{SGk~Mc?9G&%Zgk8qQ$oD>FXKpf~gfd-<{RUoRyK3L|BEqg8r(fY*ve@C% z9{L)eh5n$AVW!^r*omeYL#sNEm3rGb2Xl7U&a3Yu41(I!1JVeFtH%eT0w!Vi zcF^@6-*uGzj{^v_St0D<0~|6Q6QB93#V~-65E)5$S)*{qQ~oU=IwO;uOZA5M(ZI36 zCl`xP#RD8yqbzwi;L&_@x5mmuiJ`pAKV0T|!?Gbb*-Y+U=E1jo^6NM2CT_bejCMr< zWqA%sidZD$5Oo7t8|y$TJscq-^$@K97;g1&1CLg1=k0o)@mT?X(5GB~I|lQ=j{;V( zq~wtnub4(o27XY5Z`gSM(`nhlXy#nE7b8TQC{!&d659f9-I4$~7>8l6zl-3e=&aEO z>XR<HR5mvnsukVYdEJEwWK3$&$5wDXx z{FOxefDwD{e&pqsyL}*ezRc|2zWQsGD#H4em3FucZmEG$|GfJzU#2{uq zvohYJvW(k{57?(g?r*-spLE;>I>vUoQ4gbIL@!fW15X@~jTiJDt#lGI^>{rPMu)Eh zFGm)?jr8TNFEyeWt=jj}F7)`D9~m?{uv2_`P8q|;9XT~U;5;gk_%~g!?*7mux8JZ0 z*7_`=7WJqz2JTLI;oESqeNw6vJ(kj3a!B96ss|XY95cy3Y&tdXi5W2xBNAhhwh~3Q zKrB0$m&PDfNn(zWnMB?Z`I(>Z2Mtvs1W$t8){v_7af+>K^D##+26$s{%%zu;LYP}A z+8>;6;9yIkYNPSxA5BY<2alRrKtJQjKMgi^mKSN-K4fI2L3f5-{?+>WEGe3Je0C_&qDFZ>Gt z5i@y377TEZsP{;{jKRS?1^P3CK~8TjbSp}rFs2jv^ogq)FMTjip|4_*#%f&wh_m(c zup*qd(&%cYbf4B4Lvlw1T?K8 zPQq>}A-A((umqtSL^h$J(~RONu9*1b0phT9VW51%LI`Xo8jaTz4dKDkd7LW`0!g~_ zkW`NwkT0h!&iG=ltCx7l4ziIc=^AS8;|i-K=Q{+d+gK1>R!G1qr8sT^5~#7t(7pw7 z5(Oy1idUWRlpe#%*}(iC#sBD0PVteX01Cl}8?4O3??b|>bXv1tvYtydf=PW^{40Qo z6N^>D zj^yBd=09TSX$`2FX_|p!aL}ahK@L>Bp}0S=V_Z!?u&e)%6=;z1qxHEBJjpfYOj-Xj zUj1_@>*f^4Ff`KMNK~NviNh&4Fh4U*wF}s(NNfXsNp9Sw`bs(6blJc~O!k~V&3t^A zR0NF$dM$3xl*tzePWefl4AV;s3spGI0gx{%P>|DQEsiK{7$g&yJKO_b=C?+kO=lok zFT=ZPw%^mVS01h6rkjxl1ry|?@Y;$K=U9WHYl*3r!B&ck;BgHe2{>XLQWcCLUc z;Fg&Z<8B^v_<&;OZe3jR;q1e;ny_Wr=;I={eE#yNhR~+Xe9p{;vZ;bz6wn?kqch1Q z@>*)V)!_I0KOXZAT|PPPSmzCAt63G`3xfYMj>SnC*GGTDr}Eo9%Vv4RIvJq$d0kD4 z@UT^4E1dhe`ZY;%TVcPtN>6aU|9U`;syys)>P^{yapuhtnbv0&SQ!Wxf(@SAgo5>qR^g)9A&3 z-U{%>1vE2dRuf0&CPSSjV61Dba2bx(974zhcnz>?W%THagL9jAWQnSz4{VURljruK zmsKFAa@&xwL>A@6Rh%FclHGp#a@Pa8w`_p3=b+gJ#*X_~Uzd+Ue}<42w?MZ$-60oG z_t=k=!?x%l)-LmQfuEmGCqNQAzY}1DEvp3Ib?Ov>nvXowJ-{}MRwJr*0R<3#;9HOfNDJGSraOHw_A}!D zl1u}0+#c2S$Zn}bi)SNA%yxOfWfVE_`h^u?sQ#2(9HN;-}jDne@W+8Q(PsIvfE5h4Em&lEixy zN3_;8<>z2M)U`>-J5ZD|Nn_nN#fk#F@Lb={L7LiD*CO!|bh+uhqx3HY08s9@q|NE| zIJ`h~hau8|Gs`Y<4B$qb*3KW@W>f&2h!cDt5TTX4xr%C~>@7dr(cjMe2mY79W`q~) zizN2WpzV9ztHEy(vF}etUya^pF!s9+Wt4${4I;D4s+(4%$ZYD!cnLPWNuSb(qI#1# zEdGOHEBNEDl5A6p&uFzYoMca=V`Z5J|I6L(#l5mxY}N{^p`?t)xzb`)>8j+kB_o%P zzLa?72SBM@P|ipRtxni4=Lw2jK#2C|k8r-ty9ijR+Ncaf%Ur+$O6oQJtj;LfZm7o| zbRg0eIVwj&E7#Z}S4iTXav{0iUgM+2y_EfYEIC-;>21M$=)e!dadSZ4bSkCIXFTdl?xNJDzD!3K3sfg9!*SANX0(&q+Ehpp`4;Hi| zY@5ElI7OGLL{$4ADI{g09RSy+rzG3su!JyG@qDl&(v`L-XF!(d~O)qAc#}zRiMxx8< zMCDJ_81*+bxxh0^Oy7j07?ik}YE7)!o-2|Sqo|cDR&B5PR-NWyR(Wq9mTFeyS!tgw z3^aDP@$$q5BVBuReQlOX9jzG4x>S{fo>J0xWFh&(>g6&%d`FQO72&rKce4JKNGNpKp!~C9 zoVcuv{8ettN&tnUjjxLUbX#glTwBy*PXvO6I=}1_iGqgC#AE|xFFS^*wNjU?U!ryY zi_}cz7Lv$hzqEmGQ_49%S_^RzKbzxP@%lN0@^?`CseUGv1EY_rqtG7@$S`MMD=;}1 zv9Cg&ZhDG3A_ShY20lfp+OjQ#Qt>m%;6bRa8697J+(on6^jgHATz(1aj&rH&F(r|{ zA(8tMhri%m=^+HkP?xTXYj-EO&E8H06reCwR%~{vVK%<`V868K?`(%ov7Zlk_=d|& zP+1MFH?<}gLf=jHhw9c9vXqDd(jppcfY#DCecSAo8qcM22Z_?3hQ@wL-cACT6%zE| zZ;U36=#)78w#%X|TF-$eAfj=oH~qVy5%`Dc_U2ACUzu|T`7(hb&r^GAEG9M!*v=L?fz z!~WXSlXIXJ5e*p3Kt?EV8IzPg?MDcQp0K?n-#AiV3imBM>A5~n|^XU&pPL}=(BcJyYm0Y>lZgY$+S=jkjDsYnI{Uj4(0qMaM+Cq8LCI2u$9iYcF#8Gy3HQG@?#KRcj&N=y0$ZTn`SviCX zC!NC!OOX@|9e+l7H0RZOG3ebfp98JQY!Rt|Pu0hYAQiO|bY);ne_asmX2WvV!YR-M zPSk0D zkDT>=7MriPTRR)Zg}6X?k3NJk(lvi-1NQQrtZ+|MQ2|j$0DMeE;`SOND$pKGm1yOd z3?_5SGbpBh`}Z9Br~xn~C$I_I`309%c>908AoK|XHK0n1GnhQCxyd~G*T`4?U(8U$ zzFUxEdUZZNvgAxYqFzuE78N*q4aqG~^45p20|ZBnn8$JQPm}I;6Fs&@hQW!K_HV{4br#$j^wUKq^ZsMN0FF6C&Okm@Xc<# z^CO;Y^+d77(1q8{%@FIs0nd*rJMc%zj#uJ=)&!xuB!*q4?uyBKmaopPLj)wB}ArkO_927qhu@1FYX1n-|3JS7CH5v{VHgO!|FE*+{!ayIql1%4#l13agY)_n zWj2c)WFCw(;M!746@zloU5F3LK`B*%NTDg+$9Qx2`FQLr@$>OWVzg-hanQpx<1G34 zH&~U9Gqa-}D@Q}r1MvmMW10B*Z@yw0GoZs0D)ROOeZBSm`(fX&M?jGo!z^g!xgHae zF2Y^4A>p9-re}-_e#l3h=IC=euBzv+mw55x!dr6-Gef?zOb~|9E1zQG8itBO(Fp;H z;kf}uBvsq*ipiWndrJcd$^>E3A!h2gGo}Bv4hEcvlIUdIFwN%Pn1AC>#`-4M=)P%loyF zWHeE?%L7)=>oXPnhk^DL8U}~$dVcOVe~%l(L7dY0)Y2c2@R4Jri)R+mDbf1^ke!7b z(QMH%K0}&2E#UA|h%=-Dl8Zvsyt#7z8=9WuHH~`e z!a=UYw~yT@Hs5dF2EyHBoFpM9iKZwYPScT@hb__q?G80HHC47N9c~7TBKNPvfFl0$3$p`AzMPbk;{NK? z|MK$xrw77BNtgEa7R7C^$Fe#5@_%U3WmWPz4|~eC7b_Kqh(f18jd(Bu0Oth+a(BO4 z2sXzh{|8>q>Y$_oLGlkU~hO0|Gq(Z93! zWbAACeTAvsct&`fE_IW1Q#S%GO?g@vCZ67!AM7`9Jo*y35I5D^qJN^q7$Uq?21$@O zXinEHlNw@q{_A7~(0ps<;`>VyxZwLem4L;{31P z@j-LX$a!THGT^8|`LN4w{t9S-(r#1ryGSTa6CW;1>nZ8Q%20nGrLd+tzriRx(`M>k!hM! zJSAu!o_OD1dLtu}kv?-I+(bx^5ThP7!eyTHy=~S3?%nbIcNe(cTeZ2i`xlGuZI_ce zZRbP}&K7s$%zkV9OfADiA^huOVJO)PZhI;V%1P$%c`UL>C$e44>{$ZV6f>t1*PwcZY3yVxs)(8IyTO1-LtrF=|l_Ky%&fo>~F)CDWPTwx8BGi zLHBMZb5^2h|JEjs>*?&T=zi{ENhqaEf#_S!o%c?O9U3fupQJ#?%2#upG|ddut}Xmq z#G)S?Sbvgen&*ym6p42a-Vc6HZ*Xm;sa@iiEMBbxQW@OVgu#hvqKP+=N2(bA_n(kR z0cqvC=Ne_P3DV(e;$r|KYe5u@H1h>Jp2s~FABlT@ul9IJ$EGEZ;3>@@0qp@Y9ReVZ zib(B3GZ)0tZ@O*czS%`?>v`Wgg?*d?$2rzZ3~CP=?=tPYF40BmHJwmx&bL1uUPZB7 zUmljDD@PvI-ve`s)m#5ZCxL&Y7c)FBlA_Uqg?+_{yK7*Y3Mm(QHDYnk>pUmR*cG&L zQU>i4M6)HDn5)1FO#MlP_s)n3hs7gJs(PR${N-h^fw#?mUBg?;`6gHBuAz=*Z8aXmLiF&Z>E zb!6=$SJ|46o&bthGNJuL3%t~i6?#7JrM0K8mW|hPB7{fHCXu#YKv0OY2N*sL!hITZ zBoVi<*2l*e4X?k0{DD1{QN94va|>7yO$>v==pUJNc`e#Kv5K5j{J-^5>-0h7k#|AK zUD`OS>HpsnfL@gHu=lg&ywvBuWhfAuCG>Ptln@};x6xf!&h~!%Fg7Spf+9}@DXS@> z;uo_IAd>D9;Ghq5A4zBvaxN*kIaS(s_z@iRL0UvXO)z`i_j1^KE$D0O73ig?&3`p> z&K+psb9!<+k|X{6;{!6hf_KYr2Y>DzG5Uh5<62Jrijj1LO}yH#M76~_49$TnJ1yg@ zS{$>du+_wDF*PTnLUr+kp1@U4P?*_P0o+8Mn{RPe`&*em)~{NyE}Wb!H3Vg7aHKXw zMy$&Og|=uLF2X`KqN57>-sR{@Gzn~^9RgM8>QdgSXqcfNMwC`GYb*o}M)N!c4FaY= z9PKkvaLi2cvjFI;OASd0`5_8T+1)?3_jlSU&ls|95+Ju9OP$U52fg#Y$OS=BOkmhG z>{g$lFC?0_--?6HOV0iMlevFO3?@S&tQ*u~)jCEJO_3#@w>Z1_y4k7%qjbL4j=fF- zqqHamoy*ssonZw~Y}`<}z|GRwD`u4041#_8sqhv0h5;5zOne!MOCG|z&RIC-_=W2pXwJD$xM{B z*)VsL$p6547ULh}OdeLXmi2c0ieqNF24ro`ip_EE&zSxL%f%xup|WmLIB=hL^0BZd zxynFL5zfrlZy)#+H33>AHZE?Wams7G;DKmT(xCt&W!wE7aJhm2j(1M&V&mqM#BAgv zNbkEu^D<)>X7(gO+@sY8_1G3L848_R%aK3RNb0JLbji~owJ!3{g!YTQ8hRsJS{5am zm9Ig}r%HJk`YwYEsl+e8abA$KlvOe$cp6$}#KgGNzylyVPj3nGf@?5TTFD|j;f8Yz zbh8t2wK{kV!3k5Wak{~BsETc8&qE13uB}~E;#%-W4Hu)IkAz=-EEB)Sq9yylrHL!? zijsdMW7K6#e{5;ZavLx(hnFJPXkJl+E~Y(DwhRCOm=lhVv5-kW(bH+MZo0?td-3}2 zO$RXbo7E8SqQjM$j?7Jx#^)yfumnhU6VHf@&=6#6Sce}2MKGtEr!p*c`}+v?xm2c! z9d=MR$MvQvgr`bCGPC)@5aXIvsnaJy%Z8iSTw7IzuV&Is{>F8M1BPUw^%nUpAG2q# z9J)VBH2vMF+CP5DvithG6<4-y=XI?&U9>1HwBJ2@;iTc@Od7AR#)wOz>A7?U-BgQb z;k-yE!G1h!ge|*qg^rAu;OFuUFW;B{)dD=MAm%_h3PkxG?kLml>jH846B%=ezMLoS zT^9Ri!0}IR)NiG0g=?j2mD#Kp^toZPQBcg6(t+F(O>=o?sWG|hgUs0rKH!x)BUK%u za$C*I^Lz%8Np;d^V-d*7ob^R(qCh+)rB=CuQ~1C!as4`bxcwV!!c>bm^MR%*_^Wt- zjz&}(CK!vv>jdu~yr*C8Ers$7{+@NyKF_a^Zw@eVf+$qFXOk!gne1JNPgf@!J3^G%bKt38tU`Z%zFW}! z76dE|yX1S!4{Zn8fgF1EHEkU0V2Qv2JDk$_7i<{;!cosn7)O8JT%DS%cF86)*f#=L z`a5UX&g(bTbaW`Y5o?8*vIP5I1wM$5C{DTvyq|>M@@h@(Ke`Pg_)>1>{OJtTJDSWS zo^ZZG3L9L;bDE2;Am_mT&EHmW#&rjO+WKaV?$~yl;0Ft4BSFsa9p!j>GWR638NGKT zD{8v0d8Dd~^@Yu^0_}>jKV%%xF7u&%m<5lKCm|%PZ(mdF;!g5{hJn#!j1$hO+awkK zZsJzct!uaN4b+xRk#uh)er+f8>mi#Qa|UCo?dO}*jQd#71ndPjoK2e2&Y!>J$?CS}6#s?@k~7TxdZN!vV%)Cl)KE&l|brNhyYLrK{9SLc) z&o#Mzi(GB_;@+`bC7tw>q+igb@}&Q%k9x#H9MtG$H}c7@IXh+TW3-YFpb@G(Q{Re( zHg$i|&KmkWaK%-c2Xdw&9AIDpp#u2n|7%bVRp5a;^`3g*ws5z7*J zbaLXJ#xKr#z9XS{Qi=7T={_$17kIpjjbEK~KEk3?<2juF;S#FL8UJ}OAn*i9CO&9B zRXnE;FF5(4?_Fx%WZF9rh4Ktpn#bZu8w(^*>ve~Ogq#x`NIM^_yBsa^0$YL(j!Q7l zaR)X#^9&yQ7XII(^xum0S{vLThqHFddeM&vV~AW|$R=)^!*ZF+Q)9W>%^F<{;39uG|BsopaY`cJB#| z?T;J4mW^>eXA5l^OtEaM^r{)IBO596y2?GK-nsqZm((B#!zxG_0|?*msVj7eAH~g$ z-FZ^u|LxN$Bsj|pch)L^_4r)Dk#zo429>>Bhq>u-+yk>Up2IM?y_2<*I6T>NZnvY6^TQ>OJPV+(#+R|<^F{^sn_FT;m1mo`idEb}cOnKiXq$A(jv^dY27nnZ~eDW=tjZa5b)_l^A z!9{k4&=51zJ>@bW@A2&+a$UH5Ht0!q3Zpx4{IomO0M>DKxx~9?$wKM8BkGK)^dpMF zl?zaf&_o{W%`R@b*>}Z^HTp1{AO1fAegs*us%gaicUd&@*FJ~SOFQ;* zgJKM2HWh7Vgw|@*F%f>NGMWi>IsE+x-!|N961jX>k6_rk0+w$A&mZp}H0t*2d8qe` z-1P=Ia~6q|i1(I%>?O`YebS3XHF_{iwLk0#^Hnxdd{QEQtu||zx*PHuE$4%JmWhVJ zV*>?4a0-f*6CrtTM4=d+a&vNfaKvZg5Y|X;H24u751v))K|eTEHfh>)0a8>>${qBG z;)tZjA3id#W~BVUN0$d?g8fKmy_B4Wu2UR-+yVGuvJ`o& zW^)-8v)X?m<6-&CKL_y|v_pP+wtLa6AWL3ZH@6Dg`RzPa#mKZt&ME~cd_x-%j^LxLn&9$wiZp>WQdG5zvhs@{*uHIbS z;|<mE)FcoFsQLp=+$v`U^dDx}U+D$UN4HMG5-^T?`r1pAe(Vp3fk>E1#s{^1DSV zzQDx5!mg^IQ^K0oCCO!KU&U;BC#T*z^Rlb@8-u!3X{V#9ahIOHJF#jz7Xig}ONQam zq-fpi)1J79d7>9NskW0x3sqMSeUzr7zp9>p@yK&W>wkWxV5qOkorU#TEKgFXpwn@4 zs&2)FN2F$6p?W}gZ8?7PrzMy3qm&<5J~I@B$E^joJInTMWUa)S{HynT?0OC9ftfF zmo_5^>S%6)vQ1T{?i;FW>*uT0!B_r*VxOh)To&NpIuUqD6jm>au&22Y(eEsfV@qb) ztp3mn5GfBRm=r=fvTFS`_hY!`BH!#+JW-HX zQC2amDDZ@$tt`^LmuFs>JA9GL`;PD)(Lr*ueF_#wB%^$@At6hSQ8unMo*><#5>G9eXY)08 zw8_WiN)nLW8Dd@^eu3{JDrgBFbz%Ac8xxdC(Cx2vOT-3?i?j{cc72QFYdrdurwg~! z!$e4(rs94uCQyy+5_vT%1k6q4x76t&szfXVkVc*tYV7%r4s{+G z+%Qc$T9hO-i`Qk^`jnB63~R6Lr*M*LyuiZdQx6u$=+I6}bL=9KHc^kbB&7PbSI~)g zwIeDz_Ri+omxSg`UTnI6;p;Fl>C}US;95RcW{#1v$u_IYIUkj2#76$FWKpfgVT530N-nj+xO5;OgM@{v6KxBP1dG>>?~!vlZ~7vH-*=OM1H1H5ZL;@~>^{|u zODPv+Fu*Hik9JhH)1;>$B4&C!Y>8-kO$%4YX`Zs}?wsL`9UMi?MG+CJ3t$3H{f<5C z`TKmM#DkglUIqK_4dZP311jIU4TMue>qA+ooDpHn2%p{6v(bE2vN3u!D)3Y8wM5B; z*vaIr{K>Jh`b49EGJPn4^Q=pGdNxvkrT0R>dhqdvY8W=Ymc&j64owP#{J?$pb~uBT z@Cbk1ytxGSn{ja~X(&Ll-}h2zTKEs?^vO5cY42gY`HAR7$&O?}2Xc=?G?fB`*Ec@$ z>jj)Jqqbg>GNIUpQ#xK#4X-9!Dh6u+EG0wsgTSKx6-$T zV3>pM!l?`VzYo2jjV&!QhZs%&;btWhDb{KBq_siC7v|qe{QNKl`W`r`TeK{ScF|ZH zS1R)E6n{BGNCg3`^6hY}H=PR;(RVX#JSml$OV>{+_&Y558`-Q(HqMjFZyZiQH|RfyJ^@gq4Cjngw{<4#{CgVlQ2X3~o>x{DP{W4X(iZPo|8?A(od zQm@#qF`xuB%9YP7hB#^^$DiPdT%io#E11Vj!VL^+?Q4^^X#GKCFM?pCh^M$N`48w`@>hjF~uQ@Z$XD^1$SVGqEt{KnuNF) zrYW;0SZ=XDoXOb|SuBNPGDC6JIv?ACrD0nzDlH0lleW63{BO{p#F4QFzOC;YypHCQ zL=xXbv7eF)${a(5^irVgiKY=qGplto&SO6U$iiH4+E{FKe&`cxjb#OgwL~pyNuUf(p}r-vi${#69h3RcsgzhZ zpeL8|(P@_afv4l94+LH0P8jVhTqHw_KRLpi!q+VR)JfsJbb^@o76RRcOe5}BuH)-1 z2M&pX*o<|84imkgnwlNjLb0p~wwWbDyHGAp(Hu~VkGXD(|B2Qkdtg6`V&T%Og6A-P z)T@5R2kZ5Fcy*i{2v$9WVzTV>?o;y8RWM|8hG34=hrqxPYZ9_25}}oFn(1D_jQq;D z3=<(*Z87T%XA2HbW~VX=xfQ}UO&pIE8dP0966L`okX!#qD4mf3C5p^3SI=?VzZ2%c z0)Cbf*N%gH?2o zN&5gX_WQ$J+xoyViIb7Xjn;$m?tQ)5Yad27Y~f8fHUtSiZ8R(c(dkOvAyL;S_5Dr0 zs=+f}nj@AaR{le|@-Wjw6i`9u@sIc<>oGb~RS2JZ_L|Svb_P9-4m)lRNloD+hVkD; zD&iCgF=m7rMCBu;kaGh(6eDGHOMX9jN;{+26Joa^knilgg?g8jl}Rx6?xD)2C%Lgn zS8*o*(!PXmCIV5fZZq~*H?g;cpz^JDDIG^Z9SMLS0_Gj28-WS5G&6QS171^ z_&$XaA=2HD<1&jYS8OiD z0F5V;9u^zkD$PgJ!LhdYG+XRs`KM1mIPQETrrO3uj>J%jYC)T+ME9DHzPnu?CPSE8 zL@2+lRsJuSf7|L6I~2Xu&mODe2(E^Sb~V>PEHft{%&WY_jJP(rIbB_t?Z0I=T4p5P zU7;?rgw=SlRZ;?D3Aq8Gc|NM#FJ^bt)7$fDze@t~C~7l-`X`Y{8pHI#IT+aOUrO}) zK^Ny|nmRo-QC%8vFR>*>?NI}ECfzFuF78Dzw#I8z?fhfZSjbCiP~5E$V7hsBGIVX4 zr14V>Oqct0pI;_3pscb*M-*VgDR$)v7LsC~RRE2DKfIWb4Hp16Xu4`U5 z<7JnGH{DuWCK_>$B1TJ0q}V1sTP&>7Qoeyf;ycsf+ksteyGp6Z?kxLxoj`2W@unza zT$LWRTDtUFN7x79Lb>DnLbo0K?Y(VCNZu~wq2I~fMt`Hb6@)4R>`Hq_+ixu-zXhFt zmvl8B>=`L~catUG<_#^A4Gi1_JjrNG9Hk5f)JGEzlBl8tg2=q`6u^Z(_(q4WU=w?^ zU~tzvQb24lAG-3DGyQGUuG$0T$5(4UVqh? zGGu7--p7h_8=eImcIyEAoW+bCMz23owk&x9d+5%yRq z!|KOd2NSvX6Qy^pGhot4Q!|Nm%*7r7MmwencgSacW&-X zRm!~|lZgue9iXDHG%LmkqI{>Dv;qW_+ z;DC_JQBVV0SPnRQj}}Eu-gIbKRgG>EtQc{odtX(nyDV-5u(IwB@Nn?c6K!< z;kjV4?>u?O^hYc}!e*fRQz@T8S=#C(#Tzx~)_?~LEk5EC@dP>n=lJZ$S5(860lQw^ z#(xn~Nr^?%vJCXzaKW4AEx1#nP0FHdg-`kY_OGt-uU#GhSwapL$*)}Ri#3Ib>zc)k zy(7XxQ4O^07_ zo!%)|hc=W3_+C4l)#@35#KVpLMJaiIWZ31^2bDQ9$3_0*rSPx5{~>oQb94v zu#rZSr=i5}!I}6&be(Z-3QWN#x2U1FA^3di;YW?@|Fp7?n6++*xvDQzCX-}ZQRQz< zX3}GHLWt1qTF~;iSP`6D%;2N)=T{{J;jiAa@|J=P)!M+?^-*LL#F$fTBLLI0+ybr$ z{~XVDxArCxy4jEwh~a`3tM~zB+zJfCRp#9{yUX2!9QI?Em(7{BT7cJ=0+!y~vD}`8 zs8K|L{j2?gc1>;fNlYj2=X@Uw5QwX3v{v*aQOdT}k)H*X2Vr8-fS2<86f4eD{pE<~ z;1@lrHhriSCI&u-=lLX>5nDU&rl~cqmboANRRGH!E?H!O=c1^F2wisH#}u&ZT_ay1HLPKXs7LlfOHtohDdIkIE5 z8WY$Tvh_ccB{PU$IlIH}QJD{Rw6xSE-9)=Ooae%6IsCDUZ{Q*i)69}b#N?+1Q zc#z`9DS3kJQKbq=ov(5CmP`TKXSrhAyX8h$z6$SW_y~dSg>;{F&&{Q4IRSLSql!&R-g#ufXK$(WXMBKwHsTA= z0==ozk5Tmrug$l_l?Ydz*@MSz^W6aUsCFZ-st9hIJJ!|3>mElEqX`n1hP|? zDPoIFf8Oxli?OCQ&b|{0-iz7O^>MTu>t@Hd{z(T+`Xpo&hXM zEBRf-=-sRMl$OIPVzhY~u5%(mg@VC@7hKji;0ZaKyw~ZNv*ALpo|aZK_C~aa6i9JNpObZcTNc!{KJ0E3UonOS*^ig1*>e?{|8+j&?8aVD_Kx zn(5UPEG+#7=l4UDMv`qr2Ua*Kyl1((S3uL|KWt_&yB6!y3xNw1MPPwj3|Gp_E5}FI zBT3K%WK8|u&V^_n?ZUdMMsS8xXzS_!uD|LD7wirkqelsHGF;aGpQkyq&f3^Ae90?6 zWuC)pqou%P0cDwUElu_hW-jI~7QqTHr;_8MPQv8RU0%+83aOTq=Kp#DD#$B%vIPMa zWaGx{={f;KrV(E@)VR;bQ$be&%f-BRJz`muet4w7L$ZkD7!$tA06SdElUS4!ar?~o z#A9^0mec#W!gZm&6BE)TCX^LbmnCnZH*m}C&i?hvrBb>KQGd-sFo9v$C8Oy(K=^vU|SuH4JC1{Lo!gw@#bAK2^oaE;1KKrT*D-pGg8|j>NDTm9=s-{+n5C z59?8eSWG4ZtAzVTqn+vO4xyaYuNlFTrc|pR3P( z#2gFx3=t1b(;ZWzxVp7k?+@Bl@62eGiafD+tsO+Jqn0ZZQ*!DvzgJ$BbJ03_Th?TL zirNMH+{csCYZu7!wj7UX1$zu83asPr5vg>UBty@f zm=l)G)=Pmpa#vsgk~a+7(%=TxwA=tAQL?{U&(_vdnQsDQnDIf*Z47Mm13fkUiefhW`<-v`Q&Ze zgOeB~oYs|1pf~kiamIAH_*JhC-Q(H=PMejVl~ABSD4S* zx*RZX7Na8zRN6Nu*Q3=^b;0>^i*U9?r~kM7Ez5RonFcF2AR=_Tk}b`CL;b3uC|sWs=@-ztK1}LLEA2#A=I~w@iOxx!HiX zvBi(Q4lqsQSv^|F95Kuy+8%3kJzwA2{`1dpahnQ6pKgdENrv;M%jR;;H~^Y!?(?P}H(UK%j+sb-_UEjW>NEWU6cBIpVHlJDLq2#q9ux zF!>yy0%TeBGkk@bqeg2%WX{4x3cgL1mwgStMx3ork-5tK?Bbj{J@5>a%rI(uc&shz zvpUbbV_axs^Sk%=NytQcH5GP46!H0^nwh%7CqNxJA0gVjy13zfFi@xxKeEw}Zvm-u6_sPIezER`aNB!)!0Fg z>dqgpAQ;Ja((PfovOScE_%S%7HuCNo*GR8W*%R>Z5IoEmA1LHyl73?ksDd%41{w=z z7ZGJ={=to=PQagq(>TF6xinV_TizR|7!eXAR5iIAxbHS!-ko+k`>1vJX2x=0?&_HH z>Z?=L)vm#-KY?oor|lTiM_Wf#E**vLuBw$Zbow90H8Q2`iq)_^R~pw#?DY zlYUNW&sO>4QR3Jr9wweT3&ULZ+=EqZbhG4}?>dS$xV7|}tnat$a8>)2^Yl&RWwDNw zX;a2p18;-e#_2e56>ZnKm?{_2DE5%yxAmzn@vR4{?#iTS{;sK0@N}TwCAXdGn-im} zzK8zcc{M4!bMX2~$4T_x(*c|j!G8ZW)>Q!wXSz1hJE+HqTTZG1h+H3(^7^oGs=-1@ ziV<$D&7Snv0!oBi3G(5v&BPa&ZouxN0RKm#iovQ_UB&X8P{7g3%WMvZP0&qlQyGTY z);B7(th0^m?yThJT^>}SKXF)HVq)ey9+zlIEdUR#<;KDFPwwG{HF5&kr>u?|%2@=G zctSmvBXlb?=y>nfSh~-EG90s2U3&BJb)bM76_?RisI6b{0R+YUB{1moI>mu4HfGV` z8M6&lQnpW0N;YbGrg5!?TgwwQQnhq!VcFRhmxvr|!kY|SZ$!@~=k1hbKyAJ?_;5N;!vSia%_|+j6=SVhRZ;G zdrR=oXmwn2PW^FOi-n>pI8^v#4``pLlHfg|mMHSp+qvdig=2a&Z@aBf1#h~&-j(SU zv~l`0afoDP;OVe;EdWnPU%ec}!+%h&P5ww|v;67*&nuvg zoFn634g1skou+3XYMDvP?JfP?X+7pf)pqRaS*tV`4PLZqvX~>5ErxWf-G`;a2KjfG zZqQ56e0X3|*vYHRK{H@))z5vcrz;!bjYHVqfmGAt^RVxEQ3K@J)E!^fgp`)wz_?Hq zjhcDtX-kkVQH00kd`@ zyalT&T$ryh64gP5)H(m9b$J4wjWMLicSumT94i-njnK^^LjTrf4*1f{3EWon22HhR z;Q}{uS{;FqS*8&=Z@6MgAMUru7cp>}d#^87lVP>}kO??S8^mrT15x`iMR4l|J_G?M z=mrGVfyL_~Ss&FxK(Yhx(l1)njZ@zk+j~zm?As^gpxqRS!Z7--At}|6v2ll_1W>WSQqa-+BtwF z+nvHi7-$PSOK(Xue|h?>ze9~OKXCodwBH$*))vBh5(nY^<~egJ;Q3*oSMtTbuxidczO@1VZfu zQ20PaRrTm&16Z`sgUk%(3`?HUO}Y0WGLJ}T5&@5iWlp+1Qr1EtwRHZysj>|Kh>w_> zvk4y`V)Lwgf{iEx0eQ|E7z1 z(5}N1>Ideia$n5u_E{; zpl?179fk8BD$TzoIK=Syuz1v>2JnhcZn-F!D65Q>{b&8=x^OdiRGSEWoo*BDJ7neeS|O0tG~Of#`wQO5%<)mg0e?9H=~9EEWTNf0_v(#$4tEv?hXJo-;)oI?soW< zPurdo2OPqiIv*fmaB<#3^Z2;wJO3g!cRT}4UI}CvIsVms?|B!zo?Zc;=5c16u)M)7 zLGHbOj-C~@b($_P6L#3tf!wHNSt{suJ^BFuu#4=tY?rUamA2+ z9AQ`u{Ys3p+&-d?^-=t;q4J2?w-;TWKNyp9zwTS(igk&!48Q(?@V-1%UJLiqlDdLT z`{|GYa6)Q1gRe6XM|Y+=Op8hQ`F4fRQn4}-j~q`WHb6q=P%(Enyg0(P3{*~PDKL$`*-!w`N(?D z;|4rH#XnDFNvMLHUpOz9mmI)#Efo5W-YH!JT*QxEWtB&R^>8SpRy41Hd4*v7^rp~0Z)B_3d~6#pZ3mKrv_a-QU<_UT-@X6%##A-(Ap|Aihk&bqK7Tz|Jp zGWX(@;XfJ0Wb#zw<$sMPd;thcdQ=X*q%k28Iyjq^BPv}5XzjR6mdf&M*M4|*bTeR> z%!XHJwrV)2aTLi3`8|r*EWzuLe>X-qxuX=1l}(Uy4KEt&AGy|93OlU&%Z0m1J4u{fhpY@BUuCCc1>VLEgUtX3eD9^OSag#Rg9VCieD* zQ6_=MT?-`PDp*7h5>8LvrTJ|!nF1f|Q(F<`U4GRu(sN$*d}ZSyE{9ooll}X`4+vdy zHQ%|H0KR*aD5!tcd!C)#kQw9wo@JZkJBpSV(YBL2_DM%!G4;Tf82xml0NtwukjgXCN7rka{(%Lj^ZkMU zOaba`bv&DzC4SO1(O&c5S%^<9_8uK>jX2NAI?D{waJpZ6taAunC#{|zvweW{uj&7O z87P$9hjM6Hwx6H@z|DLCdR3~ol*>1qUJGOy1vs(Y{X6Xa51d!Q8F8*S_P+{j z{9&b7)1(z1>|6f`%#VfoBu-TwYNV8d$W$l8;u~H7w;5{13(>(iGT31`R)G^$!eKo$bz5d2Fap1HEZoTT2W1X0@-@sL6X1RoHsYZ+Aem z(gFH#^GZt}y_udf!Q3Zm$0k3{@#T63V3B0YC zp?j3+dLFw>mgytH9A3H*t7$U-)jrDaBbc|HFI|r{cp9@Sg|FC`Oz;3hRs^ z{wca{_dyE93Kx2ONnp_8^HEKBZvCH3+K@mx)RJBEk(t&kNV%we(`R?ai))E#t66h& zc=x0^CPJ2;6qvzeD$*?Np(jU$bR@13&*>B2?OU8Ur5u#IpR*&Rl3Mw*(>h+=9*gO-QTfL#!(lrON{S)(Gc%RRkl7_dR`hUtf`(`8!X<+yynWBb>^Sq*Ghz5 z&IFU(u!Yr!9M6hB1gCwqdsQY}iPFpPrVoge#U9fh12JlLQ@Pe*Ee2F=-lqpY9l^u5 zE{-3?x$*EQ#P;4cbv&5I%NU0I+J{p0-t$iI+tf+sqLMiO=};5pSBH#{$9NCkbUq-J z$YKqsM`JO0{}^U)2{Z**0-!H3p7bNh3-C?k?O7o%BYi@{DY_EiHih-Do!B1T+)fa( zi~ekDwIeQgye6b&8~uZKrXYIHUH4)r{RO6t?(7Y(-V=Sr1lxM&*c1&0dKc* z^b$VpZJ{xJ@GY!r`=fAPV8=bJMz{C9A(*vE6*R$&L=*2YySIOQ(>P7D$0Cu|InMbf zOBM04Boc~o0&3oxOyBzk^RTr7vk(Xjiuz?>eW1pyXwZcxROnyu2Q-uPh2FUJ zz+`l`$|{;dI?;Kbmhg5i>q?c?z$z41az7kS;d?E#2I;aVe;^iHDcwC$L3<)7CZf3$ z_!+H6kn)FpEN^hFf`OUq)uH(hyrto|9;O7sl_x9tkN&_aAyP~e^AA2S;{Z8nh3~W; zvVxDg26#0H0xypGbubw7x_^EVS{EI0pd0hQm=O;0!09E$>(10YRM-(1fuGJZ-cT8s zefaC$8HQuKTI{U@%Q{Dj{*MWZx`o$9?~iT3{0{OfD6zkf_pOmQKRl6}Q+Gs$K#xX2 z!`S!YF?%!J#mA;_AVNf#OUrNxESS+?MkpE7JAYKamAwVTWt|wjQoLW-F}S6OJ(0HS zZ}gkIKQ7xYnh*?_&%dl@UcI>7YvTy-_8?_`lmMdKg<|-lOL>7F>w|x55!~4Ji^2M) z;`eL@!Z&Ky_5$ks%I^~@a_+s1^d3K&FS|e3ci9YtOk$n47s=IrE>~5NTWKw2Dku2l1y^FdXR0A=Oa$){eJ# zrkiwxCGSsVn*ShkQs%5(2Yl#b7RG|k^(elB4O*5)}XrJ>T-=oLbuF_Yi*3#3gK--!^5;8U9-(3P96N%yX7i8<+&Za zNyOo<1-+PDM2(zO^%n22te&x`a{Fe2REjr(VA^v16R-vWbXCfeO|W8Ua)*LEm0-w# z?da+CQnqE#GvHIMSFO-z>C$qNCIPYx?!d#`$5lT|4>e{UgUmx4A(hBu$fJ3FI8gqb z!RN8}G2QXyG0dPotru#u>QRDZ+1clRu0lH|fX$q{e$G1!vY+jO70-SkDq#s+Dk}4s z+7`cP@h@QgFGGmK6B15nFC{@m@5L<4XO<46u7<_e%9?gbcpE^cZ7+y_m-eV6Y|wU_ z@ycg};y4KPxLYb5@J*h+wyt%U;E8p?{F zzL!6{vEd2Ghrj}P`j3~cV0w}h^oLE>s6zhXUXP(A9Z-THbo00^&J58j>d|68r6#qva+K8$q|( z2R=?$%}C#@=xB${h}xTsqEru-Jh#AS_r0dQc(-wby1Sx1v;CDwNA7N;&0C49 z<1v1#{wH1(H>Trky#mgxsTyRfz^j>Ku9k4pp24cUH{VkGXwfWhWi&5PE`lHxlt9V= z=iJ;C&@3|xPUdRAub~xv=Whku9?c`Vb67_R2HAM`&?K0E>S#i6Jih81iI3Q50VrPV z@28bPs-B};4|vvh{i0bw7-O$m z-JQ#5WD~1xePE->eEMSSU8vR5?OUGY8$H256NzLSHf zR}kaV4`lRe7pX>ydXAVf=m*RP-+(*l3hX!Xxl+XQG&SuAYYp|}o8fQT6eQ=|C-iLkZrn3`+oO;$wZE_SJ3oKMlRHB5*3q?jnoc%kn=5c0g4VUb~&Dvesdo+nW(2zk}4$wt9!&n&rM6c)>3u) z@mR8H6=7Z}>t~0(iL(CQ54f)htMh5pG%Bnd+V8^LNYT8Rx-%!PdNXL(=^_OUC#z^h z!fwExIXI8^Zi+gUhZ}4#RDG-D$7>~X?pT8a7KBamI`Z7?mU?6bvo8n0Z$_^VBXk+1D6`{I^Pa|dM zR;bF;@)PYay_B*u|NXR7}g5#y&bG@2?9HZHUfJ&Pc{BW z!f(>@ure4@?m`drQiLpmp}chR2e1^i1|3f3O1hh#uuqHMKpL6+`kuW%?|r9R zVVGPIyNMqYrRtofVW#rpl$>S6*(lwv{J&lR5QYcc?<}s(4y1j<#9!kc{`O^>HKWCH za|RXBTlwOweiJhA_^|dUv<)?_GG#A~XR*2i!?UIw zW3M;7(!ztveHPl>WGn2o_29m!dmhR1J_ayiOzH%%eK9z%oir}7J5tUQ?+pb@vOML# ze;OUtV!~(8z7Py1Js^l*3d13sU=3)#XV+MBJ{*}ywwAxM7zo1O;jJ{r{i-S|UV|2r z#1e)kpByH+gCYnY{(3A~%5W>@!|zj&afAw#I1Y%eFC{UlhQ`mF`7;8J+#iedV66cl z9AE|Te5|oD|9%UI7|RsNl8u>u`AZYiOm~K$B>DZlWwe!1I)QyfLot|Ab`lv?^P0gR zAg}F4q5SG}ATUy}rE}``8#}mdkvB!dU3PhAc?eg%L$~F!XvW(gf8Ux2{wCZ7R#c zvrqFQU=&3-0k>%?F@y1Fk?*3AskEsKs8$6Zk+leq?I4&?FC2x3};eoKTR+`zhXwEtV9lB+Cv|2v)k0~m9D`2R z{?_C|z@FrZ>~D}>V4Fq*1UO;y6CW@BkOZ~|>}Uxgm^dFQNr+E)U9XpW9NoqZ+@5qv zX!O|F5+Jvb`Ptd9`RblA(I{jFkD(McKB6;4 z5{(3QL`a%L=`^Bii3|$Q6ly$394-n0lg(X{HhZ6Dq^$o(@tB#tNXS7~fx1&FhAA;x zAKCHCcfxhs_c(!Zgy22PZ5AO zL$H~jvCRLacZ!i+!A!7dt1XRD9-FbaicLKaec`qd^ zm=$X zxzD=XH4wI6E{GL*w`hBY0#^&XClO$EsR-G-13={7is{MY)D!cS=DB$X4V5cIALrSmwHE z1zxXAJ#F`Z!Bby~be(?}j9bnOjQH>C5o_ynvb~|Qa3F)6;|DU2Ov`=-7O94iaztbz)V10(i!8l_fc)tN@axQ$ax5t5^i z_{9bX)y}48A81YYWay51jDaZ6^7ovzr@kbJWZN$izF^nVA!xL;vlMN%^?-`nRK&t= zN7%U9P^EAo=Z15{*FeJTi5y)_1nsm;;0h@w6T%&ycnM+ymY`+y5TN0b0}_QtvqxZR z=cvT5GSSpbRCwIsdQ*-erclK1H$ZJ^Ak=AuXa{XqQuny@8LO>_NN&GX!YN<$rDy}M zTWU5AU0h>#ZcBjG%qg0}3#ubl!GIPAp3G5GN*Uq>LHnRS)7m8q^V6zrxMf#qc}o<^ zA!nNXs+al7ut(#~O>ZbDy=*-{dZ@FvNp87x19l$}L&_ZW8d(mj%;oFzFrE=^i{mZ@ zP8-gw3P5r;K4sY0NXpe&H(b0UmB2cp9?QyaDXvu2`j_!451m*P$dF{|w2|nQG6P(X zUe8?YxCKzbBy2?!!KyPS-5mhcbfr#Rw*L#Q)|o-SQx#Ywt5R#C%*Y@}wiFYSA!;rTCjNc%&Yq2;1W8fG z8Ng9sjki)wXCx`Hmw67vjTt<>$&#IM_AK+Gr7kb=Z*cPCxZz}z<7m{FEsRt`0*2Ac z+CpzF!Ih6ok7}7n=gL=sHr(ZnvM*=>w}#jd^P8llNBG-s|HgJED_YSDh`;R-3N_oH zm;qrg8`#&-aoe4+&`&o?<5Y9aQY=O#sV|?@@DBLxqLMX?Hee3POQ-oNq!)q10TKm9 zO{?v1%#d?^Rp>Mat1tTzR+BH?v<_;4Q4P-87hlgcsP)nd(ecnC54bWg#BL8ee0w*f zHunCZ&gkEIyp-gUpiHbxh7gfR@6}f_ggsITXaxNkIIUUT2r%iIJxRxR0m?5DM#40I z(Dp4UQ%S@?T~8O34EZi?m+Ezn)9gftyy2lmLjK3LOqEN-f$cA@sWf-`Mk;x)t&wQ~ zj@D8P;RV3`s?GX=UbX61c+q_`Keb(z9urC$J0DC zc5XelK5-46_uD8w^O(=*4J0t>*uNRN0QDMyI(lN{f{YlWK22ALOWW16mZIc3@lb3N z$OBG72!$#;s>O&8xIg`%7X{m1R{vbJ{VB|AvdQ&R7+A@z|N7S(aA8Hd_E4D!3=WHf z;Gd^wKhPk4rTHu!d+g>ghf$k~cpaEDSMf*6s-fx9{*dj}0WjwJeN(0;LYu<^xR5y! zDk+l6lmXQP-9~^tPksqLNdEsE-HIkSl}?}+b_>CVFq28ZtiINEt#c~~#Pt$7GqmnC{W?|+ACZWVzU5Y7EM#&1+33>0;c&yJy#xv>1Z?n1pRzs>g4Q@VuPV5ai_<$BY zIuu}6A1-Q@uBlqSjz6N#XVErn1}30U)ad@v(!qplLHOEu_H#hu>QLGo3Qvq01Iv(y zz4B46+X|P($Jlx=E8YJmF+&D#(j!#)s=3ksiYJE?Ae+Z*63m;-$lK;?Yis@#KUXvt zbCaxP&Bcxxfw@3FqDB(6G~W_X!ji^kjE0>hf-7m_UjUcy>4C{>OD~nvoU*3N87i8(EhUW&0z|8k6>YEK? z*GKGNF#rCAB&SBKZ)9-%aqxE)bf!f=IK2}pmiJIT-7RbmGLDtO2P)n&60wZLo%1@} zSF$B%C%(1|KnD@BNhUYUHr#DA!zNdcXM|vH#$!FO6l7{ZIdMzK!;|mY{C*TA>9X<6_Yu&-zZKJ_c^Fc)Cr{{Ta~a^K(k~P%>ZDj8MvMEN$VWkfEfk>On%1LwUHL5N#grZxwKc@p;RopQ60a zIky5|6Y*hK;Fz#X4gH^_83tMyQTY^W-6+`I@l`RiLXG$n#O(?&2#j0o^>CwK;`&I< zxPa^1ykxa*VXC4??;%lbw%{@};ENdE2YHN&xR;FolfF#3i-dWQxl)4OftO+M4PH*v z5io)bq?QHQ*F}ajn-&@nUR2f+7n4FQfz;{Xm~5=w4-& zIt2@SSVPK6OPQg3`{-EGY_rZ;&Gkz<@IlbyF~Vy)L$v9SjKQe##En9O*>+*2BCu?l zI0p+1{FMrGKX?nvV4nB}JCq(^EE##4`&gyp)Ltl+ZV7%YAc{0flgu6F+xdST0`zS9 z{APH(EIrRUPJKTo6`vdZdp|}#1|9va$s$0`VY-8oIARu6;GzVmMW;`Wh9Jf6p?VTo z_dwpV^ee?ky-_aUlnd5$k6xD6&_yZB#qjdqO7u}q0?3G5=c zKWJzFl|Alu>oMQpxVBzIG|Y?Ys6K_Zw}27!S^Nw+sk|T7WWn(g(I9Od$i65*L6C4n z$~!vHIy01I1rjgzuAWP;?yV_Kf(4az{mc6pEwVtr&b@} z1`n>yHY{H-PUsuT2_38H@Aj*}d}vpR&$-Eqcfpz%qW%|?uDJ|3rf_jwKY*W_vx82j z4*}8D3oX*Ll-{*)Ru-kBFjpD}Dg6*>M8pxaq2}bp5*e0Uf4_{MuVO!;nd`T=GUG2h z*|ZCKK|Sw-P?|L{P&)#OH!PS4;y@Evucoi)EH!BK@GzMER@+i4dErt8s?#>lhH@k+ zFG0CM1nS!_1BY0Sa06kJgjk6T&@!~bjHulH1S@m$F{)=c7KZDYFp7@?mWY9j9@!T5 z^HCVN0sOqS-;#t8SdhAF=i-Ve0>ph;`oV3Ut4~c8p!d}kW&G4>nt>2sx&ADdMV06kBocNpbMf2Fu~-%Z4hFA<$rKJjH@O2xwukQ z__-L}mv@75G{f1hgV@%OD0^gLd?0(E=98>ayN1)Kxohb0or9{VV)=WZjAD~oqo8Gr zS|k1RH|`&&1z*Gq$!v7NEWgc|4&aGr1Eu~`CN&PY(P1A z&3QzRLRVur!h7~scj#5FcQu0*sQO@4svXlh zF`vueYrfEpqvv4T8Nky~p_pe4pE5FdP=@~>SzjF$<<|C1BLYJSQbUJyBQTVLq=1Bg z5=u!o3?-!~h?I1vh;)NUcXxMpcf+^G=Xt+a-}&pTqpY)-+4sG#>zDL2@pAQTVCTx2 z^3!uXnKwD*@s7dOg9QK$*=tk7ZzgL|Uv;iE0bULZN~D>{7hc=9xbVxe-Vj|b0?hnuP%dD?^lw`CqkV$niWytGE> zyKgeK{-8m#nXc30DTcyaG^f9U5+a?R=8gW{09lkqH!hJfPkB5Cl~^->N9Lfr#zez7 z#6&N}n3M3c*JHb@r9BFuT;yM%>&opndeaAO1w4pQtz*?-ex)^`yjEVpC|n_>bIngp zDnj59@etjvb{;q*pGI)rhS?((?Zba!vHvCi25-;o1xWtaRh*pB7xi6VPp;6<+@~9a#qv7`j#ocyH%jOtB_XMC<_@(Fk=8j-) zqGh%&$(Bi*!!{-3mq$_dKgoCoo%$dX(Gv6cX%%|%Fs-DLW(k9Ib*;zh0we&g2;Tmc zChJMyrGKpa!@5$R3zqDv0ar?Xf*>8hdyEqu^O^mWL9Iwou4J@I9}q=JyZmZSY>j!0 z=jSY!5?{`BsyzkvTG7C+a2s@x8TSJa^s%IaD+gPiO<2rN;;aCiztx+7%io4+%HI-? zwQOAzY?S8&BDPq0_7WZY1~=^M@2)H(#x;nq&pOwD&2@6o;0A!3>HyzE(g&#(Tfvk~ zlx`7RCT{>m6uw2%3vPDBf|?@*4%B}=z`;5Gs*hIJ$@RMqZ9-^?+f+dXg!<=Pr57*a zsWzX#)UEfbbr(w!^QZ_S$M%OYnqs2+m~YW7w%z= z)goW^IZ{f4UG!1mzWX1mtr=xO7YllTJ8W^VYJPiBmYZ*`U0+oCjlfYFt z1VRs5Bkb4(Kq_zC<=r)}lm&mv52|wBp0-A@TVOi%+wKj`!B--TfYaHY03KBai%se)U<65tO?v1Q#p72 zYW~eg?(J>>9ZDJET(9``0Y6t3397fDkJ_U%$8z`g{K#5Cmy1=c@xd^1y_ zBw*4tkVc;DD}Oi7@a8H=4Je7S=)M5oipku{8QY92#ou193TYH9kjreepQe!KXdMH` zxdcgO9I9q_qTO%53vN$wS>_1wB;EXwJ_`rOr&@Q%Vmk`(VABhn|}^QOK7Mksh>_a6UCbmUcq~ z1c^N!_PoezBb-trhhqC+`0|U&TKqXf^|{xBB3fPXP6?Qy0>Za=W7T2S&2>r5PY{f9 zv_=d@3T~xVrPjoP)S0O=phQb*Ax8n zUw$%sS}h2W^g}S#{1#~m$B9tI;1^UBx&*!hhW8>bQ~H`1A=?8;6GeVU_m!L6_s%2Q zSWwC3wwAnI{N+zTpyqH5pRBj~wi5?r(HH__tc>Zi*5FKMeeEuAVWJo;;PLgisFxQx z2`gkIOG10;KiJ8e9XVj&`ZB1L9kIuNfP_gJ}u-$7Fe0!J-!4PgPOufs|;g9eBC0l;^+RS=|6RIFqK|0~<%r0?X2qX1Fw) zFSww?44+n?O&2Q>%a6U|R}ethGs=j6`;J)ESW612-bB$1a{eeDr<;1AY8f91dXI3w zgZUM=yZz4E6ERtrgFCp+^BVo7#bqFI$@@kWT8CLsi;@jVwF?>nc{0dZOK8g?;FBPx z|KxKH(mTziMeG=jA26ZW7Xmi6_?L>rt%ghpWi|jH6@2-8xssfQ3S17J3pn*Wy0 z{}c35u0C0Uaebv#IMHQpBUq??o z&mP^?n_#~TZD%XH5{=Jm*5t~*AwgPBp}@Q3N4>Zi)2bNyvi{3{y9h*`Tk%#{GWq@j z3~LPd@Ii9UDwzK?ROU$;Zb62xX87dRXzsS>Dj+%ql()tUQxK)}!69Gqs5yZsNBuJp zD)-{g9-IUR9o*dlf};yWouIMr0q$o&&Vf_`0Roq}9j{r0LmgIyLC++Zl;>Q4G7lR= z87Nqa-Ji5?eA}=24Eq-5KW^XQhbRM1yQ0R*ALy zsHxh5lOFkyp3kf4{%J*bn$uu zYbzc=ja*I2`kDzF_)Z@?J4jat8C9hB`Gcq-TpgelzdDQi99$`$nII2iNl&7mB=8Lv zqp~xsld?H4ub27@Kqo}Wg2RK*O8aR@>sfkl&wuJ*#(M(ahpsXqG@O0JBUKKI0THWP%mX7e>5Dl)i{?M0+R z@yq{wkacH}OpvnO=m$>sT6D!Nr+{Kkk)KL$VK@p;?(6xOo^N4oap%&4s!j*HbDQiL zP1fs}J0=e%zX13x;&fBT=&6H?vvMp6G8y(0j#?T0KUx6jQQT{P`^P;;mL+HGM~)03MRi$>^K3|1xhyp;#b5Y=jSyfRC|RYjZF&U@(O{+J z&#?XCR|mm+mQ-}`!#LE_FK-|Ihg|KE%aNtDDi~k?`9!M@D^!CxuJUwL0>Q+AwfgXN32=!aD@3+k=u zj@LX{x5x6;69c`_WK|G~Pit66FB1n7H!_)o^7D$3XM{t-Kf9&*#9St_QP|aF6-! zptt%A=}`#4L+GY1f6K)Gnp}1xvUY9)3Rpp@l5O&d<;|hFd&J<7Y2!zidZRBI! zyGQ2#G1I31CXSZL_-D0NF1xncOauqJ+;Qa-0dwbQM*r*d*`haKXV;i?v-^zdg@l5< z*9G5I!|(5)bxTcTB572Tn<&$SMn}CS!j6yin~g34HVNeSgNuMrjM^|la%~Fi1!dA_T$f5`LEcjEIj`FHd9U5?vP*8y zC`{J*R66!p_Q91K`+DE$kH@*qC~eAz0J2iTx3RN`+_$7OSevxCdCZ5G)6W~*E?n(A ziyckl&a(gWivMq$MQp~eK(k5%sr&%A>kbv)hamC?qghlnp8d&UI=E4P;k&HNDGq_; zB*ZqB43D67NTHg7^pe?MKqbICLDf4V zW{(Ufg95J{E>qrOp2P_(tJ%M=?f#(s0?cty zHXddIl1R=emapKvbo{PS1{|qW@%jh6l!+8Gdd2UBfiB%lQL5JYJh%E$o&q`)u}fmTqd*(;0`0imYcz(685n zcGV3T*iV@l+zQHG9i|#Ug@DcsCDw%oa#c%_Rou`qW zphM47ckCq{O)v6Kr;HFgB?1kmg)xX(Zs&sxV0wRULA3e=%K&XIUWfCz9y4g480c^! zRUjb73#X7#4wAJhl2Xv}XRfvoxJSZJ+`ewOgs?*h?7;qSvEheI{A0fZ zTV-9kCu0ZE)QbPw7F4qif#m$)KuIUR#ZU=O5Yzc1{0tEZBoO%)BtMaVK;-`q)p>e& znu?|^uTqAtN5<4x-$m568dGFh75vcCr;APt7HAOMyB%zt4WxkoMyci?0H2;BPkk!6dVcXV_b!6 zcM+nX8*C;G7wl?PZ+k-50aj8^b@au|U#j9iQptL?ZySlO_jly}$~OCn!vBBL{85JEl4nF0Ejsac=YB|3u*Y~}vKszKG6}Cn(-!3=L*FqN z?7;9v-E>gtO_qC(LI(X1gg$71Z>E$k8f|*a(0~?RVJc#kd~>vzZ6)&4TYyH4x@@?N zS4aw_?fDd)k&z~paL{I)GkfOmi?jQ)?>l5qW#DJIfo3j^;2sF6D><%WHaRb2Q9A@g zT0GOO*`7rSB%`xW?T`l9i8MK(q{_-O8`Ho;fp_|f&IU?CPP!`pI@*d2gq~O+zAD48 zUO?z@v{QNH5$*+SVPtd2rJCy6Z8LYcNq=hoz3E4!{3?S^&I*6n)!XF&=U7zE@x_Z5 z3IPecCV}qIc6^+YDMXnK(1^tnF4SEtVFRa3Fd~IAIAZ1ktrPNmy6Fq{==Mbzt<+DI z6!GWeu7vHwLMF}^4=+LVGZ*=nkg`srg9Tb%gc!QOLBH=?$WwT|HP> zXmqFks*UJeMyQSz9{6coc;EHk|A6mKb-sA4$8bLHT_66a{=(1~{}ggLp{j)fE(A6r zYON(uxX{%o#e;Eg)o_$#sh_c>Ee)d{{iPf~i9pZC3Dm!HF}x_t2!0;;MOSvZb>Z7Q zxX_Cgj_Hs=K#y;*p_QBJR3r%fJyCBP;9<^ZAdO{mYRV+?^mWoi%~R>@cC<1@!F?00Gf${g z@;UhcXG@z#f)npORntyKGTIUvsIa836rK!$PXc$of{5(U;h4nJhYaQIG72=xsd~@+ zBhv5NjU=mODk8s{sKbcn87E>lnpLkM0zu0g>XrQSOsG>Y=D)r*`S#36!x3|eW9}qd zRBYd1lkbwZ%5cWbVR^(Nb@s-8rC9)@tow^h(4jvTOPjFr9#$#63jld~pn920BTlgj zyj8?D`$IEBTD&%VgNs}cGc6{G$0Q1)f2mgnf>_i<4))b<#!G+xS$~(~3-6b2#Vq~l z(CRQf$QhR^g#+`NLABVdX|CpnflsG*n$cZfs zX@5``b!w3YQK2B5`X)u((|D=nTc14$cpE{U;BHXo!Z{Km0gX6H+8l{V3wJl`65U`< z+M`SuD}1*PM0BR{@vVgbQg+T;=(Xw`@SW*i0idpJ_tR6SjaM-);5s=1 z!I+94dgE6$p{4@I04P!fQIgn0@xQl~Vk-kEfL`1JcyWFB}^eyF(5b7ApIt zZ(^WzCtNqyl0Mz;P_Mz+6cT);6HyRh0(;<89=!ZC}{mm!({%Yc7kZJ{Jt{i*4B z93UCM?maM>QBZsKwz7H_$hQ;4usp)1{71f)dtSp$T@25wOZ!B+cb~UEf4KvfjbDI~ z>W~~ixz`QkpU5e)FCHK~%WnLP*^OY&L07FW;xf|~wYa~#?PK~vFXFcIKn3Lv5LA3_ zrf5}vwp;$ zC+wV@@UIUvk%x)BM;bF}zgYM)a>)c99XZIx90{?sdlSMBQnh}GjJif_eT|$tCdYq4G9AgT1iwpA|6$deO9aj_^@)U8Ar_~-7y{s_! z`@(O_HJjnD{6(jKc!1mqo-Xn-ej|;@lG2j5$!Hggk&2Qqc)U?eRrTIq9nPv1!m8D1 zofUAR2{e~Jtcyq-;KCLG&NtKN3X}==%(nJzdL(cS=|8G)>*u#bFxA;3;5q6 z27z8-73h4Tfgo5&mO&HGwAB_~hI;UInBik2mFSb#qkVEBBnC0k$>1QH4goo%pwnH_0ZzU&*}~2Gx{6w;6#C)WeoY=t^!3AQQXl% zeSaZ~S%dnqU4FvW`&#F+@cxSHGW7le@6!8z|Gu-(>y+(rUmtuxr|x4_X<9`MNm3Fy zdi#@6TljAZVg`JH0cB{)Bjd-vGU}0c(`}a<#VN!vIjUQPeBmdrckA6%sIiavQ}vv7 zC$DTeNk2@ksZc={D`5mG z1b(+JlNdeC(t`-^7~;9?iS-A~oTtF!Dw6OD*okIQ6v2Ju0-I$S;EZr78fvKzhMs^A zbFIN^C*0rD0!*7WX)Fz{7k_`ufk!UvTVE4fE%B=m0<##qyHLA1OUY_Eit^KWyH+Uk zfO5ThQthASZ)tlC1#ouMsV_6hJ(a>5$nCgg>J-Iy%Y$CTW91z_@U)678hZ-c&xG=# z|9NXOIweHGg0MbLp#C-z*l^w+tBP_4)yB(|I(9aV+>JRSc~?P1JplS%(9B`N!|;8H zhvY!YsqOan_it@Lx@-jz4`kK=@1C6a&4JV<(4OnMMP35Hx*OOcDBrWYS-wc6clwp^CGft{F!}EeX`8^V3{pPXbZ!{(Uw7{o?OB?t! zLbqWN9^it4Vjav$+t$bq(jG&=ldyrO4F0X=%IO08F9+;N`@=K&QM}wt!z*K!+Ud%S zB-AM}F5*&&u&yXJ9J9v;d~&~}oGgyxA4Cnbq-*%sNNs*k{MF3#P~hv1eZP`UJP#`d z@!KC9aws)Yj#y?|+S*S55+4Oz$l3_Z(!jq0;jIHr86ib<7vLL3^MUrsy1BVQ9-V_$ zt`4yMqFs1udk3}ME-il1tc1$*X$|aRI5O@ zCBjn&F>6OrKx|Tv9zE)6EaA=05!temW=DDV>J<%zpp{*n9RjhM9z1=!zGxQa+S~?I=&gYHKGuSqvWy7hUhj(@fTk-74S9073j%!UDmt|U$Ss$Oo;ut5uZ=x$%?R4+jv{Q&tW@(0P z{?WaZFEd6@@z@%?D%?Gv3X#Y@rqr^#Xncll8qQS$i$C~(VCJ7FCWVGWo+Ru62zla9jT+|BK|5C+OI35u!vd7R&!IxMVoI+ zKWc_zh*LIP1Q`32(m!QL6=``D2oVmY%y#-ZG4tEk{u>(ipP}=CcX6Pq=|XUGB?RpT zpu+xm)BWFP29w)ajHr)A+n%fV`zIL*?!UQT^g+ap6?gUsgeO&e2`>*|bu5U%k+>~O~`Z=wyDCWQANTCbGLQ2`zxwEq&r`L24Xa%^EVy}3o zINqW3sDek)KPl{Wr7T!25Ca49LbCf73!>brCq?2NkeRdPjr9uQh1nb~wH^UCi!k>b z*kdR=_I}7x*<+g{cQsS}?vq7YDZ&IjWZfZW)|InsK5&H;;#Sb~yI~dOb9r(yEuY%j z=gy9H2AuzWHV;yO%_&n7Pdu%$5^BQd_00c-&$KTO`5c*{Wekgp{i@tyRl&3nEesL7 zj=j*RB7WHt)ktS>@Qe9-xT-At2Da!&G!x=7p;GL)B-a|^+3{d@8Z0~_%9FYo1y&_w zgp31_)V^fStd0=A{xGQNoj%+}_=R2K0;aXLgdwh3Oz&8DJjrd8OLiaI?f&ve75lF= z!T@Zu_N^ATq^Yigf~?LnK5s5qbrPo#7a^7AytA^3x0qs=EKXETYYH~x9o&x|xx9bG z)CfE4MDw|TU}_R(c)r+K@)l*76x72S1Ywg`gw&qZ^OXO&9OFyXv$)iL2`mBwmDvKw zyGVV&tL1O=j6u={fvl8XzwZaf>iY_)vAP#_wJj=obQTXB&RrZNt#k@NXHlo?zL|-@ z352Wh=f$o<3Gty7Tt^tYu#fZv{U{m}2}R#$#q3fM(n5zu^v6OAb3l=?ew1sSb6VcB zVUc&NU(S~%duKl>k6LW64 z>$d+xWPfJl5jztRRtr{{kwJx?o{F<)jCc=Zmjx-lAb>uJ$s2Q#(r%&+Ic%R1UVY zow?d+GM2fI$4ZqB{7x+Upa8&81}9Mi(XUZ;(1g7j$W{>c>@$XN)?;`zs+GSwgu26k z3Jij!7*nqwb*2Yn8;{Hw40#y3YzmM0zzr&bxy^hCX8^9X}s<|5#>H zh7U&g3VLMYAi~b!yw8G@1z-7!Hb8r&21R+eo?IuN2(6+;o=f;hI)FDwyAPZ?HXF)S z8b+)VzXxa~iZ8N>cl@aa3N%7iQOk5Tk$3mD*ji%}8C%7;!oshSrfO-3sXd=W@qTD3P{lH`fF~a_rFEn2(*2AMZK@?)LFX8ZAk}78aRCCWxucN z)KMMj!g|Zwwf{Z(L(oG#b4|(b(MX)=wP4 z3f2V%#yv^%8ZNp1YpQm7k_nU|U~CH&o`(9AAr_-A$dN|K92+Qd9zYef0`6U>ke5FF zNJXlB;3V!1amO6PsFC4D>%vyv6G+9^#Su{DR};)>y>vQAcSpy^(BC%mb)Gga)AbJm z=!yGON50hv$vW@P&F=n^ojiCUR;0rl-M9?~7ULT~ESoegAGd>V!Hz(53t{hgxYFT! z&NxpXLOp8x@jeQA{GrEH%I5DTlkjp7UrYU!@G=wsMIBC`fsT`ut>kKWuRvH_uK+ns z3r@HUR$2Fcz;KKC(Y{2v%X*(pRu#15x$~LCH4aN{MkS?}W6n*Dkq!o7F+=bVq7 z3R)UQY1@OcX5@$dt={*Wp#}FSktQyoO%U2oGvErgLQWIip2)q=*{GkHzHjsUbIAmT z+UHcDmgd2g;40w&hz6Ob=y{`{D7HW@_;^Qcxjpo;l@`+Y0n4)6E&$Y(n>~KGAUm7# zTw+qWHeAN*Igh@!F<`J#VfQNoy^fe))x)3_v>#_>K!^h>29BM$1wxjf(}LAN=_Fjt zY!J~z^~E#Lg~yR~O+E3Vc6X+#yY{bgtEw;U%1Z zP(3R~*;S67)l-SGHg!AJZxCT8ZB}+M@O2IZJ7sHIMEs-u_P6411^1OW@|ap$Fw0=7 zXK;&c$&!ziHn45H0$OzE$&=5TStFSX#2vGraF}8 zC6e`Km9f2_WjWr_WTM)H)AMC@9wtpwF!FV4)T5EwI`a_JS#C66ph<7SG$djUf0>!r zDS>an!9xTDEapq&2H?s*qBKOBIt8^9;J7R&+9Iqr|JrncT_xfU6+_5Bve>9y#Geek zcJ$owE1otEo>2Y*Ke$;aP4ip0E}=vUDeKWxL^!MmHI@{hyhB4qt>A{@~_SV@PU zB-TT37GyCdSuM3=hB>fjfLXR}B3aFyAoHZ`bomD|PBUc)_J<3s7b9r|=wF?8V)M(0 z;(h38f*^5{h6lf>;~kCBp5vuGaO${Lckb{QrPP(ecM4#L>-mQ^OZti!?RMg4tg8gqXTlFjX($NJujZw|G;B~(Loz1HAn7e- zx)E~DGJaKe9*?=+_C=wgQ0mHn@*l{OSyG-WpUOk{K7^cjAf%)yuDF9Z#Q$HBCBU}& zlFzns3;)h;-d&B?D`Y7r{RHtJeRCMEAnO?*vZs2q4!MX!@2b3;5AZUWGN^V4P^1!- zvQKJ)0IW@HU@&$>ssR;foj~Ko3M3?Duln-dlp41N5<|G#6BD5ufVx^fLb7Ro_K@nY z)8^7nDAMHwF2s}>-rFpXB}onIC{Cb+X!@XMQy4u$dcGAfOn4&!h*F1H=W*e;U?+WQ*%T!Q5EPuMc1LPK`F_=fp0c%vZ}9-e){yBnR+J%p%6|Q9tWWV zTwq)^dr4&O>~^yp*(=bb?p-EX9Wl!Z_i>&e?uM6Ps}zD}xcVa-{%Yqx;h{ukG2@fq zziL$HhUwkL#fHJ0O6R>_R-g;bKWD zKcJ(6%p?gN`%Wc(fB!@v@6wrR&^>!#>EQT3S^#Q}Hnf}ai*^IqGX{L>fjzWJ(xA$W z*?kk)Nzg0CJN)_B`F{p%Q_5~UHD%5D zHy{+E1~_~n@7LcAUIGNe9s@_SsO=kuX(VjFX4rQgX~lrQ!LE^Y*u-RY4^xR1gVfAk zyRj3)6bNwIg#Af2-fy@rePh<00H%gSdp};5`$^Lj=RV%Nm%-1Lfcx1F!W&73SY-ul zQ}v43r%tki;?Ijt;+*16$X$|WgfjjAK{0g!kRHAaLWqq9X zSgz?-7I%P?OC?7$|2F`4iZh^+)z|xxpO^K+1BI#OS94w;IU3NKy_LYK#*;_cc$kFT z^E|XhF=CVZ=Tfj02?PEJ?ccI0a}qQP+HVWzFDON<%-eoH7&U!SE{VkrkwGuZs7IAV zjm1E+2D10QG?a&y90j-Ilw$8)$W4&7l-Q6MFziS!agaaS=SHa zupRis8d9_Ytfk5v)YsWO6-f*iLas(GxdwozF_%67y)p0Ssz-jkpoUINmRW$TAD(3KUN%UEJLV?M)6O+eOAc2ZBy?EhAMDpFg@iF$7MrJ3Z zdjl6g3@ysuBFmC6(!=F;6+0SfO8y^YAJcPRLcq94mpNh%__VizlR@7enzbu5qC=IP zLWn&6mJun#M`+nKvX}u_oY2e2n7>dHZYz2%D92JDBijOg|Ioc{*Ak?PjQzdcnK3c{ zDn1__y%~6!rIU`iT-$=H-dU)$%ESwXv_?Po-R2vsPh_<>pdxPp5nK#5`j zxIRd{3{7ja-a+?$oY5J9X^fY!VqE@l?H+_pab4<*^}m%io$^o`eSqriDeXO(X7I zehhC-rW_n60G8N%p$_5z1(<~JLe!bB>Nps7Jl?$A`8hekNn2fNdiTs?*qFRmNX4BzBQR(Hn87i#rIP-N-b0188%ukIzO zciB6K)ya^VaG!VnHSY#9pjC{>?n!4-qiLtDGefj9RD~Fqu@FZUmoA=L$ZaNY1yrX@ z4szNSYM_*P^iB^JV3?gZ=&1?rnPgwMhG-OkWvfdtC)fKW*l{(~`fY@Bq)tpldJ3#E z(tnA(JmY6gutBO8eq~KmmQYc!^7PAHh-=oU<+ur<}-aUJtX|L1Vcb)fl z`}bW4U|$#{L9GIJUbJOFRhuyNB2G)!;>3m>t6>am#{ePladZGoJdmRauQN3`%n-Ff zoROvx^;(EXy;5W{fQNo5Q3!)=^(R0n9e^uXIDXy$oL1RRl-U;ZLY`pp3SMFnPGnoNVcNX z#y@~cN^3HkW;$qguePJ?_q~LW5J@QuxzcKN84{F&063HI*&&U>)ZfQA36_4t4C!c7 z$YYK2pJttfnlUC91609Du_IR|%(3rhizpgd`UJSB(UWmE6+)t#k-{GnH`V)>CU&*13EanZTi39%p}Tx z@2<92!xcGy6zElVf%g_`{bjFhBJMWgE ze@ae9q-~<^7YB?2AWM}z!@9@LaAMl0(@Ya3Kf;e>;w-08t8-rP^PY&fz$m?G` zc1YHhvGDm11NBV3Qxou_A>c&AR==)iYLjc70bSr9%5QpXI>`234ZZT>@YKT^c)UGqFsn;4 zl*sVv+%x_LRw#UERWlMTIrHywn;I%-j*x6-Xow|c9IyeF7YN$dW_;`q2HVYX8(iL( z-+{MAc&Oa+>E=vTsnhmYFY+A1T3JM9*o`m@&MxUp9D+cp?V+Ve zv{+mU+6F;QG)%KIfY$y+uVQ6oo$0IS*)BTG*Dh`nep1XU{9dTRai^f&2JSPG`em1E zauPTxE8jn6i!7aZy#G`zgi?43-iJzhakV`>^KhAc=miz}cH?&W_+9VrhU}!zZP5!u zM`LIE*;#abJ~5<=Vi94sSoOz9E<)-68{|+Y%oo?2S=O+8C3ynoONRMdKe0CtpXz^y>!JIlw$(pqh$Y4cKA~D96-*^Iwae=QHWVR`KJ>@>8&=vA z1w{tPHO;@A5%A75!JUrJ7Sd$#1nj*qiNpOt^JEA~^_W3nqs?liKsNOI?7F_kuE3iT zfmR6YD6%iL7``6x6Za8^&l|bGHjoFJIop*z2acwT z-oEP&s%R73$9*%`i)Qv1pt(M9+NZzK+`xllOfaT5BMGxozD_Sg5ZDF+jK|gxr4p>B zL)2ZmI|OO%cO+F;dMH@yqZ4lnX3!xJ)ul}-2cZ((@Nvp}*^7DagY5E`*wqdv4wN#Y z*8cb!>MiM(Hxh)}32YbZl!n^00Uq%5W}Yv^ZrW)gQE4<_*)B$3ZU^xR#(=8j^*FOa z7_X3aGG4;-jL>a@=psM+qF_=U)dJiaauK6yLJ{`_EOB#^)pTz(YBTiq<-vtyzb>DW zjlg7Ow!}lMPADk%H!P8K>>p*M2)1;xLF)2*rU1&5E-`FiJ-O0FhD+MT42VvCk<9Ra;CS$JmW zq1Bl?qdaOn`$pL}2Ekwww?gT33Q+zm5){VBmg7r!upM^Jay#HOAxYboND1AViF9Fc z4|?m5;LJ-E@-HZ3kZyTcQ=#+RLdQk9lpzW}^-N0fE0munqC(IMi&~NKm%A*cCuWh7 z4Qvq|YG5P#)zTq~954=MZ(Z>&bes#&Y*78W!#-??NQc&>=3?nP2tP8Slnd26EK<2ze>4(v zR7AU^(fV8;?9-80L`))gsf}2Au31({9#}eS$mew{uqKttIws68e4QuNQ^nW|0BGVw zPz}qObu}dW9DTy#>k{=@K6b{f+4Z*bXH7H`NvI&^-Mve!KvN`;F)2&zrZzk5 z@ZuV%WYO}0_ox>g7ZqIt)?>*^V#22AX)!5Gkd4Ite7Y#v?d*-|vVlGPW?Xv$OJ^jS zLucmsUan@oLmAMo?d=FWgAp|dTPg2o`s3ofq7+@AXqPZi-gyZamnd)(29jsNU)w<0PDpOl*=+XF?xGukdr zeUCJuUAqpP3U+MAMGAPIY1?oyLR6u{NREoacKP180qNP4u|GAy z&PR95{C!P9`$XTQf7DmIm9#`a%2%fIH^6XMh31Mx;GtAvey_77E*3t^`M?qUcF_<) zuGdrV)L9fVfpddRvEu3zN{@n*xFIQwno(Jq|97>E$Zl(j_eI-?xnV#y4kRed%j-E+ zPY4o3T{Z6bM81Dv5DdYKhww$}9?NUW`eWjWfH>e(#J)F|(BtSDy7dVeOrM1az;-SC z=836z0L0$#gED!d(cP!PwFYG2zaEtuocH>yzmUqR-vn}I&kaISXD^D?c)Sb1Gpy

51MGRrdcoGn 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ͼ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

x}97 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 z

uogASbH!+I|vnvDZg(-W5qyZPkera55kL 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%0D!-igr0 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�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%0AjkK=HX|P_TE2bbOJIRNTObLAdX4G0gD8M=EK5B- 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 rau4E-bgCDdM=tdSF;&qs>j^W&MALn}fnSG;6^Q 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%0ApW*n$a>kh+DXxb}G)^P3Q9^qUf)`_m4M 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

>tIOV}3 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 BK$HLg diff --git a/docs/images/nuclick.png b/docs/images/nuclick.png new file mode 100644 index 0000000000000000000000000000000000000000..b6768db5a2895e3823122b110a40c3b9a3ad43ff GIT binary patch literal 326274 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

v= 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

  • yrVnlHvT0;>2K2 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�-3op7%u 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О1fWOC$GZ;ry@0#K;r;ZGmWI!v_PCd!8TnY6 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 H&F}vJ!)~2X literal 0 HcmV?d00001 diff --git a/docs/source/highlights.md b/docs/source/highlights.md index cc91cfcd86..b149f04bec 100644 --- a/docs/source/highlights.md +++ b/docs/source/highlights.md @@ -24,6 +24,7 @@ The rest of this page provides more details for each module. * [Visualization](#visualization) * [Result writing](#result-writing) * [Workflows](#workflows) +* [Bundle](#bundle) * [Research](#research) * [Performance optimization and GPU acceleration](#performance-optimization-and-gpu-acceleration) * [Applications](#applications) @@ -343,7 +344,10 @@ The above example is generated by computing [GradCAM/GradCAM++ from a lung CT le ## Result writing Currently, MONAI supports writing the model outputs as NIfTI files or PNG files for segmentation tasks, and as CSV files for classification tasks. And the writers can restore the data spacing, orientation or shape according to the `original_shape` or `original_affine` information from the input image. -A rich set of formats will be supported soon, along with relevant statistics and evaluation metrics automatically computed from the outputs. +MONAI provides `SaveImage` transform to write the data volumes of image or prediction with automatically chose image writers based on the specified suffix, writers like: `NibabelWriter`, `ITKWriter`, `PILWriter`. +The `ImageWriter` API can be easily extended it for customized image writing modules. + +MONAI also supports to save the relevant statistics and evaluation metrics details automatically computed from the evaluation process. ## Workflows To quickly set up training and evaluation experiments, MONAI provides a set of workflows to significantly simplify the modules and allow for fast prototyping. @@ -396,6 +400,34 @@ A typical process of `decollate batch` is illustrated as follows (with a `batch_ ### 6. Easy to integrate into popular workflows Except for the pytorch-ignite based `monai.engines`, most of the MONAI modules could be used independently or combined with other software packages. For example, MONAI can be easily integrated into popular frameworks such as PyTorch-Lightning and Catalyst: [Lightning segmentation](https://github.com/Project-MONAI/tutorials/blob/master/3d_segmentation/spleen_segmentation_3d_lightning.ipynb) and [Lightning + TorchIO](https://github.com/Project-MONAI/tutorials/blob/master/modules/TorchIO_MONAI_PyTorch_Lightning.ipynb) tutorials show the PyTorch Lightning programs with MONAI modules, and [Catalyst segmentation](https://github.com/Project-MONAI/tutorials/blob/master/3d_segmentation/unet_segmentation_3d_catalyst.ipynb) shows the Catalyst program with MONAI modules. +## Bundle +The objective of a MONAI bundle is to define a packaged model which includes the critical information necessary to allow users and programs to understand how the model is used and for what purpose. A bundle includes the stored weights of a single network as a pickled state dictionary plus optionally a Torchscript object and/or an ONNX object. Additional JSON files are included to store metadata about the model, information for constructing training, inference, and post-processing transform sequences, plain-text description, legal information, and other data the model creator wishes to include. More details are available at [bundle specification](https://docs.monai.io/en/latest/mb_specification.html). + +The key benefits of bundle are to define the model package and support building Python-based workflows via structured configurations: +- Self-contained model package include all the necessary information. +- Structured config can be used to easily reconstruct or prototype deep learning workflows. +- Config files can provide good readability and usability by separating parameter settings from the Python code. +- Config files can describe flexible workflow and components, allows for different low-level Python implementations +- Learning paradigms at a higher level such as federated learning and AutoML can be decoupled from the component details. + +A typical bundle example can include: +``` + ModelName + ┣━ configs + ┃ ┗━ metadata.json + ┣━ models + ┃ ┣━ model.pt + ┃ ┣━ *model.ts + ┃ ┗━ *model.onnx + ┗━ docs + ┣━ *README.md + ┗━ *license.txt +``` +Details about the bundle config definition and syntax & examples are at [config syntax](https://docs.monai.io/en/latest/config_syntax.html). +A step-by-step [get started](https://github.com/Project-MONAI/tutorials/blob/master/modules/bundles/get_started.ipynb) tutorial notebook can help users quickly set up a bundle. + +And [bundle examples](https://github.com/Project-MONAI/tutorials/tree/main/modules/bundle) provides more real-world examples and advanced features of bundle, including a bundle example for 3D segmentation of the spleen from CT image, use cases of bringing customized python components, parsing the config files in your own python program, etc. + ## Research There are several research prototypes in MONAI corresponding to the recently published papers that address advanced research problems. We always welcome contributions in forms of comments, suggestions, and code implementations. @@ -430,13 +462,20 @@ The [tutorial](https://github.com/Project-MONAI/tutorials/tree/master/self_super ![self-supervised](../images/ssl_overview.png) +### 6. Swin UNETR model for the task of multi-organ segmentation +For [Swin UNETR: Swin Transformers for Semantic Segmentation of Brain Tumors in MRI Images](https://arxiv.org/abs/2201.01266), MONAI introduces new network modules for multi-organ segmentation task using the BTCV challenge dataset. The architecture of Swin UNETR: + +![swin-unetr](../images/swin_unetr.png) + +The [tutorial](https://github.com/Project-MONAI/tutorials/blob/main/3d_segmentation/swin_unetr_btcv_segmentation_3d.ipynb) shows a typical pipeline of multi-organ segmentation based on Swin UNETR model, DiceCE loss function, Mean Dice, etc. And we used weights from self-supervised pre-training of Swin UNETR encoder (3D Swin Tranformer) on a cohort of 5050 CT scans from publicly available datasets. + ## Performance optimization and GPU acceleration -Typically, model training is a time-consuming step during deep learning development, especially in medical imaging applications. Volumetric medical images are usually large (as multi-dimensional arrays) and the model training process can be complex. Even with powerful hardware (e.g. CPU/GPU with large RAM), it is not easy to fully leverage them to achieve high performance. MONAI provides [a fast training guide to achieve the best performance](https://github.com/Project-MONAI/tutorials/blob/master/acceleration/fast_model_training_guide.md). +Typically, model training is a time-consuming step during deep learning development, especially in medical imaging applications. Volumetric medical images are usually large (as multi-dimensional arrays) and the model training process can be complex. Even with powerful hardware (e.g. CPU/GPU with large RAM), it is not easy to fully leverage them to achieve high performance. MONAI provides a fast training guide to achieve the best performance: https://github.com/Project-MONAI/tutorials/blob/master/acceleration/fast_model_training_guide.md. NVIDIA GPUs have been widely applied in many areas of deep learning training and evaluation, and the CUDA parallel computation shows obvious acceleration when comparing to traditional computation methods. To fully leverage GPU features, many popular mechanisms raised, like automatic mixed precision (AMP), distributed data parallel, etc. MONAI can support these features and provides rich examples. ### 1. Profiling the pipelines -First of all, MONAI provides several methods based on `DLProf`, `Nsight`, `NVTX` and `NVML` for users to analyze their programs to identify the performance bottleneck. The analyses include operation-based GPU activity and overall GPU activity during model training. They will greatly help users manage computing bottlenecks and provide insights for the area to be improved for better computing efficiency. The detailed example is shown in the [performance profiling tutorial](https://github.com/Project-MONAI/tutorials/blob/master/performance_profiling/radiology/profiling_train_base_nvtx.md). +First of all, MONAI provides several methods based on `DLProf`, `Nsight`, `NVTX` and `NVML` for users to analyze their programs to identify the performance bottleneck. The analyses include operation-based GPU activity and overall GPU activity during model training. They will greatly help users manage computing bottlenecks and provide insights for the area to be improved for better computing efficiency. The detailed example is shown in the performance profiling tutorial: https://github.com/Project-MONAI/tutorials/blob/master/performance_profiling/radiology/profiling_train_base_nvtx.md. ### 2. Auto mixed precision(AMP) In 2017, NVIDIA researchers developed a methodology for mixed-precision training, which combined single-precision (FP32) with half-precision (e.g. FP16) format when training a network, and it achieved the same accuracy as FP32 training using the same hyperparameters. @@ -448,7 +487,7 @@ MONAI workflows can easily set `amp=True/False` in `SupervisedTrainer` or `Super We also executed the same test program on NVIDIA A100 GPU with the same software environment, obtained faster results: ![amp a100 results](../images/amp_training_a100.png) More details is available at [AMP training tutorial](https://github.com/Project-MONAI/tutorials/blob/master/acceleration/automatic_mixed_precision.ipynb). -We also tried to combine `AMP` with `CacheDataset`, `GPU cache`, `GPU transforms`, `ThreadDataLoader`, `DiceCE` loss function and `Novograd` optimizer to achieve the fast training in MONAI, able to obtain approximately `200x` speedup compared with a Pytorch native implementation when the training converges at a validation mean dice of `0.95`. Benchmark for reference: +We also tried to combine `AMP` with `CacheDataset`, `GPU cache`, `GPU transforms`, `ThreadDataLoader`, `DiceCE` loss, tuning of network and optimizer, to achieve the fast training in MONAI, with a V100 GPU and the target validation mean dice = 0.94 of the forground channel only, it's more than `100x` speedup compared with the Pytorch regular implementation when achieving the same metric. And every epoch is 20x faster than regular training. Benchmark for reference: ![fast training results](../images/fast_training.png) More details is available at [Fast training tutorial](https://github.com/Project-MONAI/tutorials/blob/master/acceleration/fast_training_tutorial.ipynb). @@ -495,19 +534,40 @@ Sakinis, Tomas, et al. "Interactive segmentation of medical images through fully ![deepgrow scheme](../images/deepgrow.png) -### 2. Lesion detection in digital pathology -[Implementation](https://github.com/Project-MONAI/MONAI/tree/master/monai/apps/pathology) of the pathology detection components, which includes efficient whole slide imaging IO and sampling with NVIDIA cuCIM library and SmartCache mechanism, FROC measurements for lesion and probabilistic post-processing for lesion detection. +### 2. DeepEdit worflow for interactive segmentation +DeepEdit is a method that combines an automatic and a semi-automatic approach for 3D medical images into a single deep learning-based model. The [implementation](https://github.com/Project-MONAI/MONAI/tree/dev/monai/apps/deepedit) of the DeepEdit modules provides essential components for interactive segmentation. More details are available in the training and inference [tutorial](https://github.com/Project-MONAI/tutorials/tree/main/deepedit/ignite). + +The following figure shows the typical workflow of interactive segmentation: + +![deepedit workflow](../images/deepedit.png) + +### 3. NuClick modules for interactive nuclei segmentation +NuClick is a CNN-based approach to speed up collecting annotations for microscopic objects requiring minimum interaction from the annotator. The [implementation](https://github.com/Project-MONAI/MONAI/tree/dev/monai/apps/nuclick) contains essential components for the training and inference workflows of NuClick interactive nuclei segmentation. + +The following figure is example outputs of NuClick (annotator click inside the nucleus and the mask will be generated by CNN): + +![nuclick output](../images/nuclick.png) + +### 4. Lesion detection in digital pathology +[Implementation](https://github.com/Project-MONAI/MONAI/tree/master/monai/apps/pathology) of the pathology detection components, which includes efficient whole slide imaging IO and several patch sampling methods with NVIDIA cuCIM library and SmartCache mechanism, FROC measurements for lesion and probabilistic post-processing for lesion detection. ![digital pathology](../images/pathology.png) -### 3. Learning-based image registration +### 5. Learning-based image registration Starting from v0.5.0, MONAI provides experimental features for building learning-based 2D/3D registration workflows. These include image similarity measures as loss functions, bending energy as model regularization, network architectures, warping modules. The components can be used to build the major unsupervised and weakly-supervised algorithms. The following figure shows the registration of CT images acquired at different time points for a single patient using MONAI: ![3d registration](../images/3d_paired.png) -### 4. Reproducing the state-of-the-art Kaggle competition solutions +### 6. 2D and 3D detection workflow +The [implementation](https://github.com/Project-MONAI/MONAI/tree/dev/monai/apps/detection) contains 2D and 3D bounding box detection components of `RetinaNet`, which includes:bounding box operations, hard negative sampler, and RetinaNet detectors. + +The following figure shows the detection training and inference workfows: + +![detection workflow](../images/detection.png) + +### 7. Reproducing the state-of-the-art Kaggle competition solutions [A reimplementation](https://github.com/Project-MONAI/tutorials/tree/master/kaggle/RANZCR/4th_place_solution) of the 4th place solution of RANZCR CLiP - Catheter and Line Position Challenge in Kaggle: https://www.kaggle.com/c/ranzcr-clip-catheter-line-classification The original solution is produced by Team Watercooled, and the authors are Dieter (https://www.kaggle.com/christofhenkel) and Psi (https://www.kaggle.com/philippsinger). diff --git a/docs/source/networks.rst b/docs/source/networks.rst index dc9f559700..2164fe5d1b 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -496,6 +496,11 @@ Nets .. autoclass:: UNETR :members: +`SwinUNETR` +~~~~~~~~~~~ +.. autoclass:: SwinUNETR + :members: + `BasicUNet` ~~~~~~~~~~~ .. autoclass:: BasicUNet diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 29342742cb..59a33bbf20 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -847,8 +847,9 @@ class SmartCacheDataset(Randomizable, CacheDataset): Note: This replacement will not work for below cases: 1. Set the `multiprocessing_context` of DataLoader to `spawn`. - 2. Run on windows(the default multiprocessing method is `spawn`) with `num_workers` greater than 0. - 3. Set the `persistent_workers` of DataLoader to `True` with `num_workers` greater than 0. + 2. Launch distributed data parallel with `torch.multiprocessing.spawn`. + 3. Run on windows(the default multiprocessing method is `spawn`) with `num_workers` greater than 0. + 4. Set the `persistent_workers` of DataLoader to `True` with `num_workers` greater than 0. If using MONAI workflows, please add `SmartCacheHandler` to the handler list of trainer, otherwise, please make sure to call `start()`, `update_cache()`, `shutdown()` during training. diff --git a/monai/networks/nets/swin_unetr.py b/monai/networks/nets/swin_unetr.py index 79a7936433..4cbc8c0259 100644 --- a/monai/networks/nets/swin_unetr.py +++ b/monai/networks/nets/swin_unetr.py @@ -1,4 +1,4 @@ -# Copyright 2020 - 2022 -> (c) MONAI Consortium +# 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 From 750452652a12b28eb50c92c956323214de358809 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 9 Jun 2022 15:54:29 +0100 Subject: [PATCH 140/183] 4323 update changelog for 0.9.0 (#4470) --- CHANGELOG.md | 54 +++++++++++++++++++++++++++++++- docs/source/highlights.md | 14 ++++----- docs/source/whatsnew.rst | 1 + docs/source/whatsnew_0_8.md | 2 +- docs/source/whatsnew_0_9.md | 61 +++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 docs/source/whatsnew_0_9.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f55ded72f..bb0e8e31f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [0.9.0] - 2022-06-08 +### Added +* `monai.bundle` primary module with a `ConfigParser` and command-line interfaces for configuration-based workflows +* Initial release of MONAI bundle specification +* Initial release of volumetric image detection modules including bounding boxes handling, RetinaNet-based architectures +* API preview `monai.data.MetaTensor` +* Unified `monai.data.image_writer` to support flexible IO backends including an ITK writer +* Various new network blocks and architectures including `SwinUNETR` +* DeepEdit interactive training/validation workflow +* NuClick interactive segmentation transforms +* Patch-based readers and datasets for whole-slide imaging +* New losses and metrics including `SurfaceDiceMetric`, `GeneralizedDiceFocalLoss` +* New pre-processing transforms including `RandIntensityRemap`, `SpatialResample` +* Multi-output and slice-based inference for `SlidingWindowInferer` +* `NrrdReader` for NRRD file support +* Torchscript utilities to save models with meta information +* Gradient-based visualization module `SmoothGrad` +* Automatic regular source code scanning for common vulnerabilities and coding errors + +### Changed +* Simplified `TestTimeAugmentation` using de-collate and invertible transforms APIs +* Refactoring `monai.apps.pathology` modules into `monai.handlers` and `monai.transforms` +* Flexible activation and normalization layers for `TopologySearch` and `DiNTS` +* Anisotropic first layers for 3D resnet +* Flexible ordering of activation, normalization in `UNet` +* Enhanced performance of connected-components analysis using Cupy +* `INSTANCE_NVFUSER` for enhanced performance in 3D instance norm +* Support of string representation of dtype in `convert_data_type` +* Added new options `iteration_log`, `iteration_log` to the logging handlers +* Base Docker image upgraded to `nvcr.io/nvidia/pytorch:22.04-py3` from `nvcr.io/nvidia/pytorch:21.10-py3` +* `collate_fn` generates more data-related debugging info with `dev_collate` + +### Fixed +* Unified the spellings of "meta data", "metadata", "meta-data" to "metadata" +* Various inaccurate error messages when input data are in invalid shapes +* Issue of computing symmetric distances in `compute_average_surface_distance` +* Unnecessary layer `self.conv3` in `UnetResBlock` +* Issue of torchscript compatibility for `ViT` and self-attention blocks +* Issue of hidden layers in `UNETR` +* `allow_smaller` in spatial cropping transforms +* Antialiasing in `Resize` +* Issue of bending energy loss value at different resolutions +* `kwargs_read_csv` in `CSVDataset` +* In-place modification in `Metric` reduction +* `wrap_array` for `ensure_tuple` +* Contribution guide for introducing new third-party dependencies + +### Removed +* Deprecated `nifti_writer`, `png_writer` in favor of `monai.data.image_writer` +* Support for PyTorch 1.6 + ## [0.8.1] - 2022-02-16 ### Added * Support of `matshow3d` with given `channel_dim` @@ -483,7 +534,8 @@ the postprocessing steps should be used before calling the metrics methods [highlights]: https://github.com/Project-MONAI/MONAI/blob/master/docs/source/highlights.md -[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/0.8.1...HEAD +[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/0.9.0...HEAD +[0.9.0]: https://github.com/Project-MONAI/MONAI/compare/0.8.1...0.9.0 [0.8.1]: https://github.com/Project-MONAI/MONAI/compare/0.8.0...0.8.1 [0.8.0]: https://github.com/Project-MONAI/MONAI/compare/0.7.0...0.8.0 [0.7.0]: https://github.com/Project-MONAI/MONAI/compare/0.6.0...0.7.0 diff --git a/docs/source/highlights.md b/docs/source/highlights.md index b149f04bec..31e8eb21ee 100644 --- a/docs/source/highlights.md +++ b/docs/source/highlights.md @@ -329,11 +329,11 @@ To easily visualize a 3D image as frames of 2D images, MONAI provides the utilit ![matshow3d example](../images/matshow3d.png) -MONAI also provides the `blend_images` utility to blend the `image` and `label` to a RGB color image to better visualize the segmentation regions with the specified `cmap` mode and weights, etc. Showing a spleen segmentation `image` and the corresponding `label` as example: +MONAI also provides the `blend_images` utility to blend the `image` and `label` to an RGB color image to better visualize the segmentation regions with the specified `cmap` mode and weights, etc. Showing a spleen segmentation `image` and the corresponding `label` as example: ![blend example](../images/blend.png) -For more details of `TensorBoard utility`, `matshow3d` and `blend_images`, please check the [visualziation tutorial](https://github.com/Project-MONAI/tutorials/blob/master/modules/transform_visualization.ipynb). +For more details of `TensorBoard utility`, `matshow3d` and `blend_images`, please check the [visualization tutorial](https://github.com/Project-MONAI/tutorials/blob/master/modules/transform_visualization.ipynb). And to visualize the class activation mapping for a trained classification model, MONAI provides CAM, GradCAM, GradCAM++ APIs for both 2D and 3D models: @@ -386,7 +386,7 @@ The following figure compares the loss curves and validation scores for (1) trai ![transfer_mmar](../images/transfer_mmar.png) ### 5. Decollate batch data for flexible postprocessings -`decollate batch` is introduced in MONAI v0.6, which simplifies the post processing transforms and provides flexible following operations on a batch of data with various data shapes. It can decollate batched data (e.g. model predictions) into a list of tensors, for the benefits such as: +`decollate batch` is introduced in MONAI v0.6, which simplifies the post-processing transforms and provides flexible following operations on a batch of data with various data shapes. It can decollate batched data (e.g. model predictions) into a list of tensors, for the benefits such as: 1. enabling postprocessing transforms for each item independently -- randomised transforms could be applied differently for each predicted item in a batch. 2. simplifying the transform APIs and reducing the input validation burdens because both the preprocessing and postprocessing transforms now only need to support the "channel-first" input format. 3. enabling the `Invertd` transform for the predictions and the inverted data with different shapes, as the data items are in a list, not stacked in a single tensor. @@ -467,7 +467,7 @@ For [Swin UNETR: Swin Transformers for Semantic Segmentation of Brain Tumors in ![swin-unetr](../images/swin_unetr.png) -The [tutorial](https://github.com/Project-MONAI/tutorials/blob/main/3d_segmentation/swin_unetr_btcv_segmentation_3d.ipynb) shows a typical pipeline of multi-organ segmentation based on Swin UNETR model, DiceCE loss function, Mean Dice, etc. And we used weights from self-supervised pre-training of Swin UNETR encoder (3D Swin Tranformer) on a cohort of 5050 CT scans from publicly available datasets. +The [tutorial](https://github.com/Project-MONAI/tutorials/blob/main/3d_segmentation/swin_unetr_btcv_segmentation_3d.ipynb) shows a typical pipeline of multi-organ segmentation based on Swin UNETR model, DiceCE loss function, Mean Dice, etc. And we used weights from self-supervised pre-training of Swin UNETR encoder (3D Swin Transformer) on a cohort of 5050 CT scans from publicly available datasets. ## Performance optimization and GPU acceleration Typically, model training is a time-consuming step during deep learning development, especially in medical imaging applications. Volumetric medical images are usually large (as multi-dimensional arrays) and the model training process can be complex. Even with powerful hardware (e.g. CPU/GPU with large RAM), it is not easy to fully leverage them to achieve high performance. MONAI provides a fast training guide to achieve the best performance: https://github.com/Project-MONAI/tutorials/blob/master/acceleration/fast_model_training_guide.md. @@ -487,7 +487,7 @@ MONAI workflows can easily set `amp=True/False` in `SupervisedTrainer` or `Super We also executed the same test program on NVIDIA A100 GPU with the same software environment, obtained faster results: ![amp a100 results](../images/amp_training_a100.png) More details is available at [AMP training tutorial](https://github.com/Project-MONAI/tutorials/blob/master/acceleration/automatic_mixed_precision.ipynb). -We also tried to combine `AMP` with `CacheDataset`, `GPU cache`, `GPU transforms`, `ThreadDataLoader`, `DiceCE` loss, tuning of network and optimizer, to achieve the fast training in MONAI, with a V100 GPU and the target validation mean dice = 0.94 of the forground channel only, it's more than `100x` speedup compared with the Pytorch regular implementation when achieving the same metric. And every epoch is 20x faster than regular training. Benchmark for reference: +We also tried to combine `AMP` with `CacheDataset`, `GPU cache`, `GPU transforms`, `ThreadDataLoader`, `DiceCE` loss, tuning of network and optimizer, to achieve the fast training in MONAI, with a V100 GPU and the target validation mean dice = 0.94 of the foreground channel only, it's more than `100x` speedup compared with the Pytorch regular implementation when achieving the same metric. And every epoch is 20x faster than regular training. Benchmark for reference: ![fast training results](../images/fast_training.png) More details is available at [Fast training tutorial](https://github.com/Project-MONAI/tutorials/blob/master/acceleration/fast_training_tutorial.ipynb). @@ -534,7 +534,7 @@ Sakinis, Tomas, et al. "Interactive segmentation of medical images through fully ![deepgrow scheme](../images/deepgrow.png) -### 2. DeepEdit worflow for interactive segmentation +### 2. DeepEdit workflow for interactive segmentation DeepEdit is a method that combines an automatic and a semi-automatic approach for 3D medical images into a single deep learning-based model. The [implementation](https://github.com/Project-MONAI/MONAI/tree/dev/monai/apps/deepedit) of the DeepEdit modules provides essential components for interactive segmentation. More details are available in the training and inference [tutorial](https://github.com/Project-MONAI/tutorials/tree/main/deepedit/ignite). The following figure shows the typical workflow of interactive segmentation: @@ -563,7 +563,7 @@ The following figure shows the registration of CT images acquired at different t ### 6. 2D and 3D detection workflow The [implementation](https://github.com/Project-MONAI/MONAI/tree/dev/monai/apps/detection) contains 2D and 3D bounding box detection components of `RetinaNet`, which includes:bounding box operations, hard negative sampler, and RetinaNet detectors. -The following figure shows the detection training and inference workfows: +The following figure shows the detection training and inference workflows: ![detection workflow](../images/detection.png) diff --git a/docs/source/whatsnew.rst b/docs/source/whatsnew.rst index 1f27d651db..05c853b9cd 100644 --- a/docs/source/whatsnew.rst +++ b/docs/source/whatsnew.rst @@ -6,6 +6,7 @@ What's New .. toctree:: :maxdepth: 1 + whatsnew_0_9.md whatsnew_0_8.md whatsnew_0_7.md whatsnew_0_6.md diff --git a/docs/source/whatsnew_0_8.md b/docs/source/whatsnew_0_8.md index bbaf01f5de..3eb4bea167 100644 --- a/docs/source/whatsnew_0_8.md +++ b/docs/source/whatsnew_0_8.md @@ -1,4 +1,4 @@ -# What's new in 0.8 🎉🎉 +# What's new in 0.8 - Differentiable neural network topology search - Multiple instance learning for digital pathology WSI analysis diff --git a/docs/source/whatsnew_0_9.md b/docs/source/whatsnew_0_9.md new file mode 100644 index 0000000000..fa58630bdc --- /dev/null +++ b/docs/source/whatsnew_0_9.md @@ -0,0 +1,61 @@ +# What's new in 0.9 🎉🎉 + +- MONAI Bundle +- Object detection in medical images +- Swin Transformers for 3D medical image analysis +- New interactive segmentation components +- MetaTensor API preview + +## MONAI Bundle +MONAI Bundle format defines portable described of deep learning models ([docs](https://docs.monai.io/en/latest/bundle_intro.html)). +A bundle includes the critical information necessary during a model development life cycle, +and allows users and programs to understand the purpose and usage of the models. +The key benefits of Bundle and the `monai.bundle` APIs are: +- Standardized packaging format for storing and sharing models, +- Structured configuration files for fast prototyping of deep learning workflows, +- Easy to program APIs to separate deep learning hyperparameter settings from the Python code, +- Flexible config components to allow for different low-level Python implementations, +- Help to decouple the component details from higher level learning paradigms such as federated learning and AutoML. + +More details are [in the tutorials](https://github.com/Project-MONAI/tutorials/tree/main/modules/bundle). + +## Object detection in medical images +This release includes essential components for object localization and categorization workflows. +The initial developments include 2D and 3D bounding box handling, network blocks and architectures of RetinaNet, +and common utility modules such as coordinate-based preprocessing, hard negative sampler. + +The application specific modules are made available at +[monai.apps.detection](https://github.com/Project-MONAI/MONAI/tree/dev/monai/apps/detection). + +![detection workflow](../images/detection.png) + + +## Swin Transformers for 3D medical image analysis +The Swin UNETR model is now implemented in MONAI. +[The tutorial](https://github.com/Project-MONAI/tutorials/blob/main/3d_segmentation/swin_unetr_btcv_segmentation_3d.ipynb) +shows examples of multi-organ segmentation using this state-of-the-art model, +with weights from self-supervised pre-training of +Swin UNETR encoder (3D Swin Transformer) on a cohort of 5050 CT scans from publicly available datasets. +[The research-contribution entry](https://github.com/Project-MONAI/research-contributions/tree/main/SwinUNETR) +includes further technical details. + +![swin-unetr](../images/swin_unetr.png) + +## New interactive segmentation components +New components from deep learning interactive segmentation workflows +such as [DeepEdit](https://github.com/Project-MONAI/tutorials/tree/main/deepedit/ignite) +and NuClick are integrated into the core codebase. They serve as basic building blocks for +[the latest features in MONAILabel](https://github.com/Project-MONAI/MONAILabel). + +![deepedit](../images/deepedit.png) + +![nuclick](../images/nuclick.png) + +## MetaTensor API preview +The metadata associated with the primary imaging modalities is important in many biomedical applications, +especially for the data-driven approaches that MONAI has been focusing. +Starting from this release, we roll out a major refactoring for data representation in MONAI. For the first +step, [the core data structures](https://github.com/Project-MONAI/MONAI/blob/dev/monai/data/meta_tensor.py) +`MetaTensor` and `MetaObj` are implemented as a feature preview. +Further developments [on the feature branch](https://github.com/Project-MONAI/MONAI/tree/feature/MetaTensor) +will be made available in future milestone releases. From 1daf22095d245f50054c72aa514eaf8a48c5c268 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Fri, 10 Jun 2022 07:38:16 -0400 Subject: [PATCH 141/183] add box convert to world coordinate transform (#4476) * add box convert to world coordinate transform Signed-off-by: Can Zhao * add test Signed-off-by: Can Zhao --- monai/apps/detection/transforms/dictionary.py | 56 ++++++++++++++++++- tests/test_box_transform.py | 6 +- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index e47238c222..b7b70d00da 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -248,7 +248,7 @@ def __init__( self.converter_to_image_coordinate = AffineBox() self.affine_lps_to_ras = affine_lps_to_ras - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def extract_affine(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: d = dict(data) meta_key = self.image_meta_key @@ -269,6 +269,12 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N affine_t, *_ = convert_data_type(affine, torch.Tensor) # torch.inverse should not run in half precision inv_affine_t = torch.inverse(affine_t.to(COMPUTE_DTYPE)) + return affine, inv_affine_t + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + affine, inv_affine_t = self.extract_affine(data) for key in self.key_iterator(d): self.push_transform(d, key, extra_info={"affine": affine}) @@ -285,6 +291,54 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd return d +class AffineBoxToWorldCoordinated(AffineBoxToImageCoordinated): + """ + Dictionary-based transform that converts box in image coordinate to world coordinate. + + Args: + box_keys: Keys to pick box data for transformation. The box mode is assumed to be ``StandardMode``. + box_ref_image_keys: The single key that represents the reference image to which ``box_keys`` are attached. + remove_empty: whether to remove the boxes that are actually empty + allow_missing_keys: don't raise exception if key is missing. + image_meta_key: explicitly indicate the key of the corresponding metadata dictionary. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the metadata is a dictionary object which contains: filename, affine, original_shape, etc. + it is a string, map to the `box_ref_image_key`. + if None, will try to construct meta_keys by `box_ref_image_key_{meta_key_postfix}`. + image_meta_key_postfix: if image_meta_keys=None, use `box_ref_image_key_{postfix}` to fetch the metadata according + to the key data, default is `meta_dict`, the metadata is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + affine_lps_to_ras: default ``False``. Yet if 1) the image is read by ITKReader, + and 2) the ITKReader has affine_lps_to_ras=True, and 3) the box is in world coordinate, + then set ``affine_lps_to_ras=True``. + """ + + def __init__( + self, + box_keys: KeysCollection, + box_ref_image_keys: str, + allow_missing_keys: bool = False, + image_meta_key: Union[str, None] = None, + image_meta_key_postfix: Union[str, None] = DEFAULT_POST_FIX, + affine_lps_to_ras=False, + ) -> None: + super().__init__( + box_keys, box_ref_image_keys, allow_missing_keys, image_meta_key, image_meta_key_postfix, affine_lps_to_ras + ) + self.converter_to_world_coordinate = AffineBox() + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + affine, inv_affine_t = self.extract_affine(data) + + for key in self.key_iterator(d): + self.push_transform(d, key, extra_info={"affine": inv_affine_t}) + d[key] = self.converter_to_world_coordinate(d[key], affine=affine) + return d + + class ZoomBoxd(MapTransform, InvertibleTransform): """ Dictionary-based transform that zooms input boxes and images with the given zoom scale. diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index 5d984175aa..222d83526e 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -17,6 +17,7 @@ from monai.apps.detection.transforms.dictionary import ( AffineBoxToImageCoordinated, + AffineBoxToWorldCoordinated, BoxToMaskd, ClipBoxToImaged, ConvertBoxModed, @@ -178,7 +179,7 @@ def test_value_3d( assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) - # test AffineBoxToImageCoordinated + # test AffineBoxToImageCoordinated, AffineBoxToWorldCoordinated transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image") with self.assertRaises(Exception) as context: transform_affine(data) @@ -190,6 +191,9 @@ def test_value_3d( invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"]) data_back = invert_transform_affine(affine_result) assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) + invert_transform_affine = AffineBoxToWorldCoordinated(box_keys="boxes", box_ref_image_keys="image") + data_back = invert_transform_affine(affine_result) + assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) # test FlipBoxd transform_flip = FlipBoxd( From 96c6cc3104dec742bc5933cadfa104c1fdf7e176 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 10 Jun 2022 13:28:41 +0100 Subject: [PATCH 142/183] 4480 skip unused resampling (#4481) skip unused resampling Signed-off-by: Wenqi Li --- monai/transforms/spatial/array.py | 1 + 1 file changed, 1 insertion(+) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index e157959a90..78542107c7 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2054,6 +2054,7 @@ def __call__( do_resampling = self._do_transform or (sp_size != ensure_tuple(img.shape[1:])) if not do_resampling: img, *_ = convert_data_type(img, dtype=torch.float32, device=self.resampler.device) + return img grid = self.get_identity_grid(sp_size) if self._do_transform: grid = self.rand_affine_grid(grid=grid, randomize=randomize) From e3b47fe62c5a63dd9f479499fe971211a70811ec Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 10 Jun 2022 14:25:43 +0100 Subject: [PATCH 143/183] 4477 4478 iterables enhancements (#4479) * iterables enhancements Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update based on comments Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update based on comments Signed-off-by: Wenqi Li * fixes tests Signed-off-by: Wenqi Li Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/data/grid_dataset.py | 20 ++++++++------------ monai/data/iterable_dataset.py | 4 ---- tests/test_grid_dataset.py | 8 ++++---- tests/test_shuffle_buffer.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 tests/test_shuffle_buffer.py diff --git a/monai/data/grid_dataset.py b/monai/data/grid_dataset.py index 2e389d9e0b..ffad8dba88 100644 --- a/monai/data/grid_dataset.py +++ b/monai/data/grid_dataset.py @@ -187,23 +187,19 @@ def __init__( ) -> None: super().__init__(data=data, transform=None) self.patch_iter = patch_iter - self.transform = transform + self.patch_transform = transform self.with_coordinates = with_coordinates def __iter__(self): for image in super().__iter__(): - if not self.with_coordinates: - for patch, *_ in self.patch_iter(image): # patch_iter to yield at least 1 item: patch - out_patch = ( - patch if self.transform is None else apply_transform(self.transform, patch, map_items=False) - ) + for patch, *others in self.patch_iter(image): + out_patch = patch + if self.patch_transform is not None: + out_patch = apply_transform(self.patch_transform, patch, map_items=False) + if self.with_coordinates and len(others) > 0: # patch_iter to yield at least 2 items: patch, coords + yield out_patch, others[0] + else: yield out_patch - else: - for patch, slices, *_ in self.patch_iter(image): # patch_iter to yield at least 2 items: patch, coords - out_patch = ( - patch if self.transform is None else apply_transform(self.transform, patch, map_items=False) - ) - yield out_patch, slices class PatchDataset(Dataset): diff --git a/monai/data/iterable_dataset.py b/monai/data/iterable_dataset.py index f292bf1593..f1906a80fe 100644 --- a/monai/data/iterable_dataset.py +++ b/monai/data/iterable_dataset.py @@ -11,7 +11,6 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union -import numpy as np from torch.utils.data import IterableDataset as _TorchIterableDataset from torch.utils.data import get_worker_info @@ -115,9 +114,6 @@ def _get_item(): def randomize(self, size: int) -> None: self._idx = self.R.randint(size) - def set_random_state(self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None): - raise NotImplementedError(f"`set_random_state` is not available in {self.__class__.__name__}.") - class CSVIterableDataset(IterableDataset): """ diff --git a/tests/test_grid_dataset.py b/tests/test_grid_dataset.py index 529e679142..30680c8e31 100644 --- a/tests/test_grid_dataset.py +++ b/tests/test_grid_dataset.py @@ -60,7 +60,7 @@ def test_loading_array(self): np.testing.assert_equal(tuple(item[0].shape), (2, 1, 2, 2)) np.testing.assert_allclose( item[0], - np.array([[[[7.4965, 8.4965], [11.4965, 12.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), + np.array([[[[8.0577, 9.0577], [12.0577, 13.0577]]], [[[10.5540, 11.5540], [14.5540, 15.5540]]]]), rtol=1e-4, ) np.testing.assert_allclose(item[1], np.array([[[0, 1], [2, 4], [0, 2]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) @@ -69,7 +69,7 @@ def test_loading_array(self): np.testing.assert_equal(tuple(item[0].shape), (2, 1, 2, 2)) np.testing.assert_allclose( item[0], - np.array([[[[7.2548, 8.2548], [11.2548, 12.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), + np.array([[[[7.6533, 8.6533], [11.6533, 12.6533]]], [[[9.8524, 10.8524], [13.8524, 14.8524]]]]), rtol=1e-3, ) np.testing.assert_allclose( @@ -102,7 +102,7 @@ def test_loading_dict(self): self.assertListEqual(item[0]["metadata"], ["test string", "test string"]) np.testing.assert_allclose( item[0]["image"], - np.array([[[[7.4965, 8.4965], [11.4965, 12.4965]]], [[[11.3584, 12.3584], [15.3584, 16.3584]]]]), + np.array([[[[8.0577, 9.0577], [12.0577, 13.0577]]], [[[10.5540, 11.5540], [14.5540, 15.5540]]]]), rtol=1e-4, ) np.testing.assert_allclose(item[1], np.array([[[0, 1], [2, 4], [0, 2]], [[0, 1], [2, 4], [2, 4]]]), rtol=1e-5) @@ -111,7 +111,7 @@ def test_loading_dict(self): np.testing.assert_equal(item[0]["image"].shape, (2, 1, 2, 2)) np.testing.assert_allclose( item[0]["image"], - np.array([[[[7.2548, 8.2548], [11.2548, 12.2548]]], [[[9.1106, 10.1106], [13.1106, 14.1106]]]]), + np.array([[[[7.6533, 8.6533], [11.6533, 12.6533]]], [[[9.8524, 10.8524], [13.8524, 14.8524]]]]), rtol=1e-3, ) np.testing.assert_allclose( diff --git a/tests/test_shuffle_buffer.py b/tests/test_shuffle_buffer.py new file mode 100644 index 0000000000..40012fbf93 --- /dev/null +++ b/tests/test_shuffle_buffer.py @@ -0,0 +1,34 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import unittest + +import numpy as np + +from monai.data import DataLoader, ShuffleBuffer +from monai.utils import convert_data_type + + +class TestShuffleBuffer(unittest.TestCase): + def test_shape(self): + buffer = ShuffleBuffer([1, 2, 3, 4], seed=0) + num_workers = 2 if sys.platform == "linux" else 0 + dataloader = DataLoader(dataset=buffer, batch_size=2, num_workers=num_workers) + output = [convert_data_type(x, np.ndarray)[0] for x in dataloader] + if num_workers == 0: + np.testing.assert_allclose(output, [[2, 1], [3, 4]]) + else: # multiprocess shuffle + np.testing.assert_allclose(output, [[2, 3], [1, 4]]) + + +if __name__ == "__main__": + unittest.main() From 179e4de040d69001b77bba6a027ec88301434048 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Fri, 10 Jun 2022 17:51:03 +0100 Subject: [PATCH 144/183] Fixing SliceInferer Issue (#4482) --- monai/apps/detection/transforms/array.py | 4 +++- monai/inferers/inferer.py | 9 ++++++--- monai/losses/giou_loss.py | 4 +++- tests/test_slice_inferer.py | 3 +++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index fb338682ee..4c3f4f223d 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -535,7 +535,9 @@ class RotateBox90(Rotate90): def __init__(self, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: super().__init__(k, spatial_axes) - def __call__(self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int]) -> NdarrayOrTensor: # type: ignore + def __call__( # type: ignore + self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int] + ) -> NdarrayOrTensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), diff --git a/monai/inferers/inferer.py b/monai/inferers/inferer.py index e6dd0d27c4..af33ecd391 100644 --- a/monai/inferers/inferer.py +++ b/monai/inferers/inferer.py @@ -278,6 +278,7 @@ class SliceInferer(SlidingWindowInferer): def __init__(self, spatial_dim: int = 0, *args, **kwargs) -> None: self.spatial_dim = spatial_dim super().__init__(*args, **kwargs) + self.orig_roi_size = ensure_tuple(self.roi_size) def __call__( self, @@ -298,11 +299,13 @@ def __call__( # Check if ``roi_size`` tuple is 2D and ``inputs`` tensor is 3D self.roi_size = ensure_tuple(self.roi_size) - if len(self.roi_size) == 2 and len(inputs.shape[2:]) == 3: - self.roi_size = list(self.roi_size) + if len(self.orig_roi_size) == 2 and len(inputs.shape[2:]) == 3: + self.roi_size = list(self.orig_roi_size) self.roi_size.insert(self.spatial_dim, 1) else: - raise RuntimeError("Currently, only 2D `roi_size` with 3D `inputs` tensor is supported.") + raise RuntimeError( + f"Currently, only 2D `roi_size` ({self.orig_roi_size}) with 3D `inputs` tensor (shape={inputs.shape}) is supported." + ) return super().__call__(inputs=inputs, network=lambda x: self.network_wrapper(network, x, *args, **kwargs)) diff --git a/monai/losses/giou_loss.py b/monai/losses/giou_loss.py index 7f2dfb63ff..ec7e358f42 100644 --- a/monai/losses/giou_loss.py +++ b/monai/losses/giou_loss.py @@ -50,7 +50,9 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: raise ValueError(f"ground truth has different shape ({target.shape}) from input ({input.shape})") box_dtype = input.dtype - giou: torch.Tensor = box_pair_giou(target.to(dtype=COMPUTE_DTYPE), input.to(dtype=COMPUTE_DTYPE)) # type: ignore + giou: torch.Tensor = box_pair_giou( # type: ignore + target.to(dtype=COMPUTE_DTYPE), input.to(dtype=COMPUTE_DTYPE) + ) loss: torch.Tensor = 1.0 - giou if self.reduction == LossReduction.MEAN.value: loss = loss.mean() diff --git a/tests/test_slice_inferer.py b/tests/test_slice_inferer.py index 3c52082d85..0f33385f42 100644 --- a/tests/test_slice_inferer.py +++ b/tests/test_slice_inferer.py @@ -46,6 +46,9 @@ def test_shape(self, spatial_dim): self.assertTupleEqual(result.shape, input_volume.shape) + # test that the inferer can be run multiple times + result = inferer(input_volume, model) + if __name__ == "__main__": unittest.main() From a23c7f54f899c183052db4b8ecd6a21c190b2c18 Mon Sep 17 00:00:00 2001 From: Ali Hatamizadeh Date: Sun, 12 Jun 2022 02:32:11 -0700 Subject: [PATCH 145/183] enhance swin_unetr relative position (#4486) * enhance swin_unetr relative position Signed-off-by: ahatamizadeh * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/networks/nets/swin_unetr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/networks/nets/swin_unetr.py b/monai/networks/nets/swin_unetr.py index 4cbc8c0259..8e90078873 100644 --- a/monai/networks/nets/swin_unetr.py +++ b/monai/networks/nets/swin_unetr.py @@ -474,7 +474,7 @@ def forward(self, x, mask): q = q * self.scale attn = q @ k.transpose(-2, -1) relative_position_bias = self.relative_position_bias_table[ - self.relative_position_index[:n, :n].reshape(-1) + self.relative_position_index.clone()[:n, :n].reshape(-1) ].reshape(n, n, -1) relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous() attn = attn + relative_position_bias.unsqueeze(0) From 0b97bb6172f967c4b4dd1de92263e26186db54b9 Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Mon, 13 Jun 2022 01:01:56 -0400 Subject: [PATCH 146/183] More Efficient `GridPatch` and `RandGridPatch` V2 (#4473) --- monai/transforms/spatial/array.py | 104 +++++++++++++++---------- monai/transforms/spatial/dictionary.py | 20 ++--- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 78542107c7..c8e43df7e3 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2633,11 +2633,8 @@ class GridPatch(Transform): num_patches: number of patches to return. Defaults to None, which returns all the available patches. overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. - sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it - will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", - which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions - and channels. Also "random" creates a random order of patches. - By default no sorting is being done and patches are returned in a row-major order. + sort_fn: when `num_patches` is provided, it determines if keep patches with highest values (`"max"`), + lowest values (`"min"`), or in their default order (`None`). Default to None. threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. @@ -2653,7 +2650,7 @@ def __init__( offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, - sort_fn: Optional[Union[Callable, str]] = None, + sort_fn: Optional[str] = None, threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, @@ -2664,24 +2661,45 @@ def __init__( self.pad_kwargs = pad_kwargs self.overlap = overlap self.num_patches = num_patches - self.sort_fn: Optional[Callable] - if isinstance(sort_fn, str): - self.sort_fn = GridPatchSort.get_sort_fn(sort_fn) - else: - self.sort_fn = sort_fn - + self.sort_fn = sort_fn.lower() if sort_fn else None self.threshold = threshold - if threshold: - self.filter_fn = self.threshold_fn - else: - self.filter_fn = self.one_fn - @staticmethod - def one_fn(patch): - return True + def filter_threshold(self, image_np: np.ndarray, locations: np.ndarray): + """ + Filter the patches and their locations according to a threshold + Args: + image: a numpy.ndarray representing a stack of patches + location: a numpy.ndarray representing the stack of location of each patch + """ + if self.threshold is not None: + n_dims = len(image_np.shape) + idx = np.argwhere(image_np.sum(axis=tuple(range(1, n_dims))) < self.threshold).reshape(-1) + image_np = image_np[idx] + locations = locations[idx] + return image_np, locations - def threshold_fn(self, patch): - return patch.sum() < self.threshold + def filter_count(self, image_np: np.ndarray, locations: np.ndarray): + """ + Sort the patches based on the sum of their intensity, and just keep `self.num_patches` of them. + Args: + image: a numpy.ndarray representing a stack of patches + location: a numpy.ndarray representing the stack of location of each patch + """ + if self.sort_fn is None: + image_np = image_np[: self.num_patches] + locations = locations[: self.num_patches] + elif self.num_patches is not None: + n_dims = len(image_np.shape) + if self.sort_fn == GridPatchSort.MIN: + idx = np.argsort(image_np.sum(axis=tuple(range(1, n_dims)))) + elif self.sort_fn == GridPatchSort.MAX: + idx = np.argsort(-image_np.sum(axis=tuple(range(1, n_dims)))) + else: + raise ValueError(f'`sort_fn` should be either "min", "max" or None! {self.sort_fn} provided!') + idx = idx[: self.num_patches] + image_np = image_np[idx] + locations = locations[idx] + return image_np, locations def __call__(self, array: NdarrayOrTensor): # create the patch iterator which sweeps the image row-by-row @@ -2695,25 +2713,28 @@ def __call__(self, array: NdarrayOrTensor): mode=self.pad_mode, **self.pad_kwargs, ) + patches = list(zip(*patch_iterator)) + patched_image = np.array(patches[0]) + locations = np.array(patches[1])[:, 1:, 0] # only keep the starting location - if self.sort_fn is not None: - patch_iterator = sorted(patch_iterator, key=self.sort_fn) + # Filter patches + if self.num_patches: + patched_image, locations = self.filter_count(patched_image, locations) + elif self.threshold: + patched_image, locations = self.filter_threshold(patched_image, locations) - output = [ - (convert_to_dst_type(src=patch, dst=array)[0], convert_to_dst_type(src=slices[..., 0], dst=array)[0]) - for patch, slices in patch_iterator - if self.filter_fn(patch) - ] + # Convert to original data type + output = list( + zip(convert_to_dst_type(src=patched_image, dst=array)[0], convert_to_dst_type(src=locations, dst=array)[0]) + ) - if self.num_patches: - output = output[: self.num_patches] - if len(output) < self.num_patches: - patch = convert_to_dst_type( - src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), - dst=array, - )[0] - start_location = convert_to_dst_type(src=np.zeros((len(self.patch_size), 1)), dst=array)[0] - output += [(patch, start_location)] * (self.num_patches - len(output)) + # Pad the patch list to have the requested number of patches + if self.num_patches and len(output) < self.num_patches: + patch = convert_to_dst_type( + src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), dst=array + )[0] + start_location = convert_to_dst_type(src=np.zeros((len(self.patch_size), 1)), dst=array)[0] + output += [(patch, start_location)] * (self.num_patches - len(output)) return output @@ -2732,11 +2753,8 @@ class RandGridPatch(GridPatch, RandomizableTransform): num_patches: number of patches to return. Defaults to None, which returns all the available patches. overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. - sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it - will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", - which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions - and channels. Also "random" creates a random order of patches. - By default no sorting is being done and patches are returned in a row-major order. + sort_fn: when `num_patches` is provided, it determines if keep patches with highest values (`"max"`), + lowest values (`"min"`), or in their default order (`None`). Default to None. threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. @@ -2753,7 +2771,7 @@ def __init__( max_offset: Optional[Union[Sequence[int], int]] = None, num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, - sort_fn: Optional[Union[Callable, str]] = None, + sort_fn: Optional[str] = None, threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index a1dd60b6a0..d0f3dc6705 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -17,7 +17,7 @@ from copy import deepcopy from enum import Enum -from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -2216,11 +2216,8 @@ class GridPatchd(MapTransform): np.random.randint(0, patch_size, 2) creates random start between 0 and `patch_size` for a 2D image. num_patches: number of patches to return. Defaults to None, which returns all the available patches. overlap: amount of overlap between patches in each dimension. Default to 0.0. - sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it - will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", - which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions - and channels. Also "random" creates a random order of patches. - By default no sorting is being done and patches are returned in a row-major order. + sort_fn: when `num_patches` is provided, it determines if keep patches with highest values (`"max"`), + lowest values (`"min"`), or in their default order (`None`). Default to None. threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. @@ -2246,7 +2243,7 @@ def __init__( offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: float = 0.0, - sort_fn: Optional[Union[Callable, str]] = None, + sort_fn: Optional[str] = None, threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, @@ -2300,11 +2297,8 @@ class RandGridPatchd(RandomizableTransform, MapTransform): num_patches: number of patches to return. Defaults to None, which returns all the available patches. overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. - sort_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it - will be passed directly to the `key` argument of `sorted` function. The string can be "min" or "max", - which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions - and channels. Also "random" creates a random order of patches. - By default no sorting is being done and patches are returned in a row-major order. + sort_fn: when `num_patches` is provided, it determines if keep patches with highest values (`"max"`), + lowest values (`"min"`), or in their default order (`None`). Default to None. threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. @@ -2332,7 +2326,7 @@ def __init__( max_offset: Optional[Union[Sequence[int], int]] = None, num_patches: Optional[int] = None, overlap: float = 0.0, - sort_fn: Optional[Union[Callable, str]] = None, + sort_fn: Optional[str] = None, threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, From af0e0e9f757558d144b655c63afcea3a4e0a06f5 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 13 Jun 2022 23:14:10 +0800 Subject: [PATCH 147/183] 4489 Enhance hyperlinks of config syntax doc (#4490) --- docs/source/config_syntax.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/source/config_syntax.md b/docs/source/config_syntax.md index bcd849cd09..6cd4e62ef3 100644 --- a/docs/source/config_syntax.md +++ b/docs/source/config_syntax.md @@ -12,10 +12,10 @@ Content: - [A basic example](#a-basic-example) - [Syntax examples explained](#syntax-examples-explained) - - [`@` to interpolate with Python objects](#1--to-interpolate-with-python-objects) - - [`$` to evaluate as Python expressions](#2--to-evaluate-as-python-expressions) - - [`%` to textually replace configuration elements](#3--to-textually-replace-configuration-elements) - - [`_target_` (`_disabled_` and `_requires_`) to instantiate a Python object](#4-instantiate-a-python-object) + - [`@` to reference Python objects in configurations](#to-reference-python-objects-in-configurations) + - [`$` to evaluate as Python expressions](#to-evaluate-as-python-expressions) + - [`%` to textually replace configuration elements](#to-textually-replace-configuration-elements) + - [`_target_` (`_disabled_` and `_requires_`) to instantiate a Python object](#instantiate-a-python-object) - [The command line interface](#the-command-line-interface) - [Recommendations](#recommendations) @@ -73,22 +73,22 @@ For more details on the `ConfigParser` API, please see https://docs.monai.io/en/ A few characters and keywords are interpreted beyond the plain texts, here are examples of the syntax: -### 1. `@` to interpolate with Python objects +### To reference Python objects in configurations ```json "@preprocessing#transforms#keys" ``` -_Description:_ A reference to another configuration value defined at `preprocessing#transforms#keys`. +_Description:_ `@` character indicates a reference to another configuration value defined at `preprocessing#transforms#keys`. where `#` indicates a sub-structure of this configuration file. ```json "@preprocessing#1" ``` -_Description:_ `1` is interpreted as an integer, which is used to index (zero-based indexing) the `preprocessing` sub-structure. +_Description:_ `1` is referencing as an integer, which is used to index (zero-based indexing) the `preprocessing` sub-structure. -### 2. `$` to evaluate as Python expressions +### To evaluate as Python expressions ```json "$print(42)" @@ -110,16 +110,16 @@ _Description:_ `$` followed by an import statement is handled slightly different Python expressions. The imported module `resnet18` will be available as a global variable to the other configuration sections. This is to simplify the use of external modules in the configuration. -### 3. `%` to textually replace configuration elements +### To textually replace configuration elements ```json "%demo_config.json#demo_net#in_channels" ``` -_Description:_ A macro to replace the current configuration element with the texts at `demo_net#in_channels` in the +_Description:_ `%` character indicates a macro to replace the current configuration element with the texts at `demo_net#in_channels` in the `demo_config.json` file. The replacement is done before instantiating or evaluating the components. -### 4. instantiate a Python object +### Instantiate a Python object ```json { @@ -164,6 +164,7 @@ python -m monai.bundle COMMANDS where `COMMANDS` is one of the following: `run`, `verify_metadata`, `ckpt_export`, ... (please see `python -m monai.bundle --help` for a list of available options). +The CLI supports flexible use cases, such as overriding configs at runtime and predefining arguments in a file. To display a usage page for a command, for example `run`: ```bash python -m monai.bundle run -- --help @@ -182,3 +183,5 @@ Details on the CLI argument parsing is provided in the simple structures with sparse uses of expressions or references are preferred. - For `$import ` in the configuration, please make sure there are instructions for the users to install the `` if it is not a (optional) dependency of MONAI. +- As "#" and "$" might be interpreted differently by the `shell` or `CLI` tools, may need to add escape characters + or quotes for them in the command line, like: `"\$torch.device('cuda:1')"`, `"'train_part#trainer'"`. From 1188b9c9b8ebc3559ea7a032ff6530173fbf41a0 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 15 Jun 2022 09:09:09 +0100 Subject: [PATCH 148/183] 4323 update docs release versions (#4494) update versions Signed-off-by: Wenqi Li --- .github/workflows/weekly-preview.yml | 2 +- CITATION.cff | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index 6a53c03c28..8fa69615c3 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -33,7 +33,7 @@ jobs: export YEAR_WEEK=$(date +'%y%U') echo "Year week for tag is ${YEAR_WEEK}" if ! [[ $YEAR_WEEK =~ ^[0-9]{4}$ ]] ; then echo "Wrong 'year week' format. Should be 4 digits."; exit 1 ; fi - git tag "0.9.dev${YEAR_WEEK}" + git tag "0.10.dev${YEAR_WEEK}" git log -1 git tag --list python setup.py sdist bdist_wheel diff --git a/CITATION.cff b/CITATION.cff index dcce4af377..9fad6a709e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,8 +6,8 @@ title: "MONAI: Medical Open Network for AI" abstract: "AI Toolkit for Healthcare Imaging" authors: - name: "MONAI Consortium" -date-released: 2022-02-16 -version: "0.8.1" +date-released: 2022-06-13 +version: "0.9.0" identifiers: - description: "This DOI represents all versions of MONAI, and will always resolve to the latest one." type: doi From ed233d9b48bd71eb623cf9777ad9b60142c8ad66 Mon Sep 17 00:00:00 2001 From: YanxuanLiu <104543031+YanxuanLiu@users.noreply.github.com> Date: Wed, 15 Jun 2022 18:46:55 +0800 Subject: [PATCH 149/183] changed ngc download url (#4508) changed ngc download url and folder structure Signed-off-by: Yanxuan Liu --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1b022fc92e..85752497f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,11 +38,11 @@ RUN BUILD_MONAI=1 FORCE_CUDA=1 python setup.py develop \ # NGC Client WORKDIR /opt/tools -ARG NGC_CLI_URI="https://ngc.nvidia.com/downloads/ngccli_cat_linux.zip" -RUN wget -q ${NGC_CLI_URI} && \ - unzip ngccli_cat_linux.zip && chmod u+x ngc && \ - md5sum -c ngc.md5 && \ - rm -rf ngccli_cat_linux.zip ngc.md5 +ARG NGC_CLI_URI="https://ngc.nvidia.com/downloads/ngccli_linux.zip" +RUN wget -q ${NGC_CLI_URI} && unzip ngccli_linux.zip && chmod u+x ngc-cli/ngc && \ + find ngc-cli/ -type f -exec md5sum {} + | LC_ALL=C sort | md5sum -c ngc-cli.md5 && \ + cp ngc-cli/ngc ngc && \ + rm -rf ngccli_linux.zip ngc-cli.md5 RUN apt-get update \ && DEBIAN_FRONTEND="noninteractive" apt-get install -y libopenslide0 \ && rm -rf /var/lib/apt/lists/* From 4215599a27f379d06b5a852b132dc88b5abf8086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Louren=C3=A7o=20Silva?= Date: Wed, 15 Jun 2022 19:58:32 +0100 Subject: [PATCH 150/183] Fixed generalized dice score (#4511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed generalized dice score to be 1 if denominator is 0 and prediction is empty, and 0 if denominator is 0 but prediction is not empty Signed-off-by: João Lourenço Silva --- monai/metrics/generalized_dice.py | 12 +++++++++--- tests/test_compute_generalized_dice.py | 8 ++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/monai/metrics/generalized_dice.py b/monai/metrics/generalized_dice.py index c52fc9890e..0e1f61ac68 100644 --- a/monai/metrics/generalized_dice.py +++ b/monai/metrics/generalized_dice.py @@ -169,9 +169,15 @@ def compute_generalized_dice( numer = 2.0 * (intersection * w).sum(dim=1) denom = (denominator * w).sum(dim=1) - # Compute the score. Where the denominator (and numerator) is 0, score is 1 + # Compute the score generalized_dice_score = numer / denom - generalized_dice_score[denom == 0] = 1 - # Compute the final score, replacing nans (where denominator is 0) + # Handle zero deivision. Where denom == 0 and the prediction volume is 0, score is 1. + # Where denom == 0 but the prediction volume is not 0, score is 0 + y_pred_o = y_pred_o.sum(dim=-1) + denom_zeros = denom == 0 + generalized_dice_score[denom_zeros] = torch.where( + (y_pred_o == 0)[denom_zeros], torch.tensor(1.0), torch.tensor(0.0) + ) + return generalized_dice_score diff --git a/tests/test_compute_generalized_dice.py b/tests/test_compute_generalized_dice.py index 0f5941bbfa..38f6e57d32 100644 --- a/tests/test_compute_generalized_dice.py +++ b/tests/test_compute_generalized_dice.py @@ -107,12 +107,16 @@ TEST_CASE_6 = [{"y": torch.ones((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3))}, [1.0000, 1.0000]] -TEST_CASE_7 = [{"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3))}, [1.0000, 1.0000]] +TEST_CASE_7 = [{"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3))}, [0.0000, 0.0000]] + +TEST_CASE_8 = [{"y": torch.ones((2, 2, 3, 3)), "y_pred": torch.zeros((2, 2, 3, 3))}, [0.0000, 0.0000]] + +TEST_CASE_9 = [{"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.zeros((2, 2, 3, 3))}, [1.0000, 1.0000]] class TestComputeMeanDice(unittest.TestCase): # Functional part tests - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_6, TEST_CASE_7]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_6, TEST_CASE_7, TEST_CASE_8, TEST_CASE_9]) def test_value(self, input_data, expected_value): result = compute_generalized_dice(**input_data) np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) From d0a554ec9ed713d5960080e5643b086257578bdf Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 16 Jun 2022 12:33:10 +0800 Subject: [PATCH 151/183] Update bundle specification doc (#4512) [DLMED] update doc Signed-off-by: Nic Ma --- docs/source/mb_specification.rst | 2 +- monai/transforms/spatial/array.py | 4 ++-- monai/transforms/spatial/dictionary.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/mb_specification.rst b/docs/source/mb_specification.rst index 142f250550..4d021d8f60 100644 --- a/docs/source/mb_specification.rst +++ b/docs/source/mb_specification.rst @@ -57,7 +57,7 @@ metadata.json File This file contains the metadata information relating to the model, including what the shape and format of inputs and outputs are, what the meaning of the outputs are, what type of model is present, and other information. The JSON structure is a dictionary containing a defined set of keys with additional user-specified keys. The mandatory keys are as follows: -* **version**: version of the stored model, this allows multiple versions of the same model to be differentiated. +* **version**: version of the stored model, this allows multiple versions of the same model to be differentiated. Versions should follow semantic versioning and contain only characters valid in filenames as we may include the version to construct bundle file name. * **monai_version**: version of MONAI the bundle was generated on, later versions expected to work. * **pytorch_version**: version of Pytorch the bundle was generated on, later versions expected to work. * **numpy_version**: version of Numpy the bundle was generated on, later versions expected to work. diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index c8e43df7e3..f9c5823243 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1052,9 +1052,9 @@ class RandRotate(RandomizableTransform): range_x: Range of rotation angle in radians in the plane defined by the first and second axes. If single number, angle is uniformly sampled from (-range_x, range_x). range_y: Range of rotation angle in radians in the plane defined by the first and third axes. - If single number, angle is uniformly sampled from (-range_y, range_y). + If single number, angle is uniformly sampled from (-range_y, range_y). only work for 3D data. range_z: Range of rotation angle in radians in the plane defined by the second and third axes. - If single number, angle is uniformly sampled from (-range_z, range_z). + If single number, angle is uniformly sampled from (-range_z, range_z). only work for 3D data. prob: Probability of rotation. keep_size: If it is False, the output shape is adapted so that the input array is contained completely in the output. diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d0f3dc6705..3599d645e5 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -1708,9 +1708,9 @@ class RandRotated(RandomizableTransform, MapTransform, InvertibleTransform): range_x: Range of rotation angle in radians in the plane defined by the first and second axes. If single number, angle is uniformly sampled from (-range_x, range_x). range_y: Range of rotation angle in radians in the plane defined by the first and third axes. - If single number, angle is uniformly sampled from (-range_y, range_y). + If single number, angle is uniformly sampled from (-range_y, range_y). only work for 3D data. range_z: Range of rotation angle in radians in the plane defined by the second and third axes. - If single number, angle is uniformly sampled from (-range_z, range_z). + If single number, angle is uniformly sampled from (-range_z, range_z). only work for 3D data. prob: Probability of rotation. keep_size: If it is False, the output shape is adapted so that the input array is contained completely in the output. From 0b6af0b9e2c196f398f01f7edb40ae9d5c2bd894 Mon Sep 17 00:00:00 2001 From: YanxuanLiu <104543031+YanxuanLiu@users.noreply.github.com> Date: Thu, 16 Jun 2022 19:05:34 +0800 Subject: [PATCH 152/183] add ngc to PATH (#4517) * changed ngc download url and folder structure Signed-off-by: Yanxuan Liu * added ngc-cli to PATH Signed-off-by: Yanxuan Liu Co-authored-by: Yanxuan Liu --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 85752497f9..aee64b0edc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,8 +41,8 @@ WORKDIR /opt/tools ARG NGC_CLI_URI="https://ngc.nvidia.com/downloads/ngccli_linux.zip" RUN wget -q ${NGC_CLI_URI} && unzip ngccli_linux.zip && chmod u+x ngc-cli/ngc && \ find ngc-cli/ -type f -exec md5sum {} + | LC_ALL=C sort | md5sum -c ngc-cli.md5 && \ - cp ngc-cli/ngc ngc && \ rm -rf ngccli_linux.zip ngc-cli.md5 +ENV PATH=${PATH}:/opt/tools:/opt/tools/ngc-cli RUN apt-get update \ && DEBIAN_FRONTEND="noninteractive" apt-get install -y libopenslide0 \ && rm -rf /var/lib/apt/lists/* From 78509ae55ef98660907544e92713e44897bb7e48 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 16 Jun 2022 13:41:20 -0400 Subject: [PATCH 153/183] corner case in coco metric test (#4519) corner case in test Signed-off-by: Can Zhao --- tests/test_detection_coco_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_detection_coco_metrics.py b/tests/test_detection_coco_metrics.py index d5c8445f8e..b139377511 100644 --- a/tests/test_detection_coco_metrics.py +++ b/tests/test_detection_coco_metrics.py @@ -59,7 +59,7 @@ def test_coco_run(self): gt_classes=[val_data_i["labels"].numpy() for val_data_i in val_targets_all], ) val_epoch_metric_dict = coco_metric(results_metric)[0] - np.testing.assert_array_less([-0.01], [sum(val_epoch_metric_dict.values())]) + np.testing.assert_array_less([-16.01], [sum(val_epoch_metric_dict.values())]) if __name__ == "__main__": From 29a724d69690c2baf0ae9f396d73f32f4d1f2559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Nicol=C3=A1s=20Acosta?= Date: Mon, 20 Jun 2022 03:05:23 -0400 Subject: [PATCH 154/183] Add youden_index and youden as aliases for bm (#4534) --- monai/metrics/confusion_matrix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index cfe1a3572c..bb195b0cb8 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -318,7 +318,7 @@ def check_confusion_matrix_metric_name(metric_name: str): return "mcc" if metric_name in ["fowlkes_mallows_index", "fm"]: return "fm" - if metric_name in ["informedness", "bookmaker_informedness", "bm"]: + if metric_name in ["informedness", "bookmaker_informedness", "bm", "youden_index", "youden"]: return "bm" if metric_name in ["markedness", "deltap", "mk"]: return "mk" From 8b5ddf5f77b572cce8bbad9cdd856e148e029971 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 20 Jun 2022 22:13:57 +0100 Subject: [PATCH 155/183] MetaTensor non-breaking changes (#4539) * MetaTensor non-breaking changes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- .gitignore | 4 + monai/data/__init__.py | 13 ++- monai/data/dataset_summary.py | 11 +- monai/data/meta_obj.py | 102 ++++++++++------- monai/data/meta_tensor.py | 76 +++++++++---- monai/data/png_writer.py | 13 ++- monai/data/utils.py | 51 ++++++--- monai/inferers/utils.py | 11 +- monai/metrics/utils.py | 14 +-- monai/transforms/utils.py | 45 ++++++-- .../utils_pytorch_numpy_unification.py | 13 ++- monai/utils/__init__.py | 1 + monai/utils/misc.py | 5 +- monai/utils/type_conversion.py | 107 +++++++++++++++++- runtests.sh | 1 - tests/profile_subclass/README.md | 43 +++++++ tests/profile_subclass/cprofile_profiling.py | 28 +++++ tests/profile_subclass/min_classes.py | 29 +++++ tests/profile_subclass/profiling.py | 72 ++++++++++++ tests/profile_subclass/pyspy_profiling.py | 40 +++++++ 20 files changed, 553 insertions(+), 126 deletions(-) create mode 100644 tests/profile_subclass/README.md create mode 100644 tests/profile_subclass/cprofile_profiling.py create mode 100644 tests/profile_subclass/min_classes.py create mode 100644 tests/profile_subclass/profiling.py create mode 100644 tests/profile_subclass/pyspy_profiling.py diff --git a/.gitignore b/.gitignore index 542e08e3b6..9c0554dca9 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ tests/testing_data/MedNIST* tests/testing_data/*Hippocampus* tests/testing_data/*.tiff tests/testing_data/schema.json +*.svg # clang format tool .clang-format-bin/ @@ -138,3 +139,6 @@ tests/testing_data/schema.json # VSCode .vscode/ *.zip + +# profiling results +*.prof diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 293f058acf..7502de5225 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -71,6 +71,7 @@ from .thread_buffer import ThreadBuffer, ThreadDataLoader from .torchscript_utils import load_net_with_metadata, save_net_with_metadata from .utils import ( + affine_to_spacing, compute_importance_map, compute_shape_offset, convert_tables_to_dicts, @@ -111,8 +112,8 @@ from multiprocessing.reduction import ForkingPickler def _rebuild_meta(cls, storage, metadata): - storage_offset, size, stride, meta_obj = metadata - t = cls([], meta=meta_obj, dtype=storage.dtype, device=storage.device) + storage_offset, size, stride, meta_obj, applied_operations = metadata + t = cls([], meta=meta_obj, applied_operations=applied_operations, dtype=storage.dtype, device=storage.device) t.set_(storage._untyped() if hasattr(storage, "_untyped") else storage, storage_offset, size, stride) return t @@ -120,7 +121,13 @@ def reduce_meta_tensor(meta_tensor): storage = meta_tensor.storage() if storage.is_cuda: raise NotImplementedError("sharing CUDA metatensor across processes not implemented") - metadata = (meta_tensor.storage_offset(), meta_tensor.size(), meta_tensor.stride(), meta_tensor.meta) + metadata = ( + meta_tensor.storage_offset(), + meta_tensor.size(), + meta_tensor.stride(), + meta_tensor.meta, + meta_tensor.applied_operations, + ) return _rebuild_meta, (type(meta_tensor), storage, metadata) ForkingPickler.register(MetaTensor, reduce_meta_tensor) diff --git a/monai/data/dataset_summary.py b/monai/data/dataset_summary.py index 14024c0ff9..6af46d11ea 100644 --- a/monai/data/dataset_summary.py +++ b/monai/data/dataset_summary.py @@ -18,9 +18,9 @@ from monai.config import KeysCollection from monai.data.dataloader import DataLoader from monai.data.dataset import Dataset +from monai.data.utils import affine_to_spacing from monai.transforms import concatenate -from monai.utils import convert_data_type -from monai.utils.enums import PostFix +from monai.utils import PostFix, convert_data_type DEFAULT_POST_FIX = PostFix.meta() @@ -84,7 +84,7 @@ def collect_meta_data(self): raise ValueError(f"To collect metadata for the dataset, key `{self.meta_key}` must exist in `data`.") self.all_meta_data.append(data[self.meta_key]) - def get_target_spacing(self, spacing_key: str = "pixdim", anisotropic_threshold: int = 3, percentile: float = 10.0): + def get_target_spacing(self, spacing_key: str = "affine", anisotropic_threshold: int = 3, percentile: float = 10.0): """ Calculate the target spacing according to all spacings. If the target spacing is very anisotropic, @@ -93,7 +93,7 @@ def get_target_spacing(self, spacing_key: str = "pixdim", anisotropic_threshold: After loading with `monai.DataLoader`, "pixdim" is in the form of `torch.Tensor` with size `(batch_size, 8)`. Args: - spacing_key: key of spacing in metadata (default: ``pixdim``). + spacing_key: key of the affine used to compute spacing in metadata (default: ``affine``). anisotropic_threshold: threshold to decide if the target spacing is anisotropic (default: ``3``). percentile: for anisotropic target spacing, use the percentile of all spacings of the anisotropic axis to replace that axis. @@ -103,7 +103,8 @@ def get_target_spacing(self, spacing_key: str = "pixdim", anisotropic_threshold: self.collect_meta_data() if spacing_key not in self.all_meta_data[0]: raise ValueError("The provided spacing_key is not in self.all_meta_data.") - all_spacings = concatenate(to_cat=[data[spacing_key][:, 1:4] for data in self.all_meta_data], axis=0) + spacings = [affine_to_spacing(data[spacing_key][0], 3)[None] for data in self.all_meta_data] + all_spacings = concatenate(to_cat=spacings, axis=0) all_spacings, *_ = convert_data_type(data=all_spacings, output_type=np.ndarray, wrap_sequence=True) target_spacing = np.median(all_spacings, axis=0) diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index 2e5c38938a..0f404dcac7 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -11,8 +11,11 @@ from __future__ import annotations +import itertools from copy import deepcopy -from typing import Any, Callable, Sequence +from typing import Any, Iterable + +from monai.utils.enums import TraceKeys _TRACK_META = True @@ -72,77 +75,79 @@ class MetaObj: """ def __init__(self): - self._meta: dict = self.get_default_meta() + self._meta: dict = MetaObj.get_default_meta() + self._applied_operations: list = MetaObj.get_default_applied_operations() self._is_batch: bool = False @staticmethod - def flatten_meta_objs(args: Sequence[Any]) -> list[MetaObj]: + def flatten_meta_objs(*args: Iterable): """ - Recursively flatten input and return all instances of `MetaObj` as a single - list. This means that for both `torch.add(a, b)`, `torch.stack([a, b])` (and + Recursively flatten input and yield all instances of `MetaObj`. + This means that for both `torch.add(a, b)`, `torch.stack([a, b])` (and their numpy equivalents), we return `[a, b]` if both `a` and `b` are of type `MetaObj`. Args: - args: Sequence of inputs to be flattened. + args: Iterables of inputs to be flattened. Returns: list of nested `MetaObj` from input. """ - out = [] - for a in args: + for a in itertools.chain(*args): if isinstance(a, (list, tuple)): - out += MetaObj.flatten_meta_objs(a) + yield from MetaObj.flatten_meta_objs(a) elif isinstance(a, MetaObj): - out.append(a) - return out + yield a - def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: + def _copy_attr(self, attributes: list[str], input_objs, defaults: list, deep_copy: bool) -> None: """ - Copy an attribute from the first in a list of `MetaObj`. In the case of + Copy attributes from the first in a list of `MetaObj`. In the case of `torch.add(a, b)`, both `a` and `b` could be `MetaObj` or something else, so check them all. Copy the first to `self`. We also perform a deep copy of the data if desired. Args: - attribute: string corresponding to attribute to be copied (e.g., `meta`). - input_objs: List of `MetaObj`. We'll copy the attribute from the first one + attributes: a sequence of strings corresponding to attributes to be copied (e.g., `['meta']`). + input_objs: an iterable of `MetaObj` instances. We'll copy the attribute from the first one that contains that particular attribute. - default_fn: If none of `input_objs` have the attribute that we're - interested in, then use this default function (e.g., `lambda: {}`.) - deep_copy: Should the attribute be deep copied? See `_copy_meta`. + defaults: If none of `input_objs` have the attribute that we're + interested in, then use this default value/function (e.g., `lambda: {}`.) + the defaults must be the same length as `attributes`. + deep_copy: whether to deep copy the corresponding attribute. Returns: Returns `None`, but `self` should be updated to have the copied attribute. """ - attributes = [getattr(i, attribute) for i in input_objs if hasattr(i, attribute)] - if len(attributes) > 0: - val = attributes[0] - if deep_copy: - val = deepcopy(val) - setattr(self, attribute, val) - else: - setattr(self, attribute, default_fn()) - - def _copy_meta(self, input_objs: list[MetaObj]) -> None: + found = [False] * len(attributes) + for i, (idx, a) in itertools.product(input_objs, enumerate(attributes)): + if not found[idx] and hasattr(i, a): + setattr(self, a, deepcopy(getattr(i, a)) if deep_copy else getattr(i, a)) + found[idx] = True + if all(found): + return + for a, f, d in zip(attributes, found, defaults): + if not f: + setattr(self, a, d() if callable(defaults) else d) + return + + def _copy_meta(self, input_objs, deep_copy=False) -> None: """ - Copy metadata from a list of `MetaObj`. For a given attribute, we copy the + Copy metadata from an iterable of `MetaObj` instances. For a given attribute, we copy the adjunct data from the first element in the list containing that attribute. - If there has been a change in `id` (e.g., `a=b+c`), then deepcopy. Else (e.g., - `a+=1`), then don't. - Args: input_objs: list of `MetaObj` to copy data from. """ - id_in = id(input_objs[0]) if len(input_objs) > 0 else None - deep_copy = id(self) != id_in - self._copy_attr("meta", input_objs, self.get_default_meta, deep_copy) - self._copy_attr("applied_operations", input_objs, self.get_default_applied_operations, deep_copy) - self.is_batch = input_objs[0].is_batch if len(input_objs) > 0 else False + self._copy_attr( + ["meta", "applied_operations"], + input_objs, + [MetaObj.get_default_meta(), MetaObj.get_default_applied_operations()], + deep_copy, + ) - def get_default_meta(self) -> dict: + @staticmethod + def get_default_meta() -> dict: """Get the default meta. Returns: @@ -150,7 +155,8 @@ def get_default_meta(self) -> dict: """ return {} - def get_default_applied_operations(self) -> list: + @staticmethod + def get_default_applied_operations() -> list: """Get the default applied operations. Returns: @@ -180,21 +186,29 @@ def __repr__(self) -> str: @property def meta(self) -> dict: """Get the meta.""" - return self._meta + return self._meta if hasattr(self, "_meta") else MetaObj.get_default_meta() @meta.setter - def meta(self, d: dict) -> None: + def meta(self, d) -> None: """Set the meta.""" + if d == TraceKeys.NONE: + self._meta = MetaObj.get_default_meta() self._meta = d @property def applied_operations(self) -> list: """Get the applied operations.""" - return self._applied_operations + if hasattr(self, "_applied_operations"): + return self._applied_operations + return MetaObj.get_default_applied_operations() @applied_operations.setter - def applied_operations(self, t: list) -> None: + def applied_operations(self, t) -> None: """Set the applied operations.""" + if t == TraceKeys.NONE: + # received no operations when decollating a batch + self._applied_operations = MetaObj.get_default_applied_operations() + return self._applied_operations = t def push_applied_operation(self, t: Any) -> None: @@ -206,7 +220,7 @@ def pop_applied_operation(self) -> Any: @property def is_batch(self) -> bool: """Return whether object is part of batch or not.""" - return self._is_batch + return self._is_batch if hasattr(self, "_is_batch") else False @is_batch.setter def is_batch(self, val: bool) -> None: diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index 9ba86300a7..a993a5e464 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -13,14 +13,15 @@ import warnings from copy import deepcopy -from typing import Any, Callable, Sequence +from typing import Any, Sequence import torch from monai.config.type_definitions import NdarrayTensor from monai.data.meta_obj import MetaObj, get_track_meta -from monai.data.utils import decollate_batch, list_data_collate, remove_extra_metadata +from monai.data.utils import affine_to_spacing, decollate_batch, list_data_collate, remove_extra_metadata from monai.utils.enums import PostFix +from monai.utils.type_conversion import convert_to_tensor __all__ = ["MetaTensor"] @@ -125,7 +126,7 @@ def __init__( elif isinstance(x, MetaTensor): self.applied_operations = x.applied_operations else: - self.applied_operations = self.get_default_applied_operations() + self.applied_operations = MetaObj.get_default_applied_operations() # if we are creating a new MetaTensor, then deep copy attributes if isinstance(x, torch.Tensor) and not isinstance(x, MetaTensor): @@ -133,11 +134,12 @@ def __init__( self.applied_operations = deepcopy(self.applied_operations) self.affine = self.affine.to(self.device) - def _copy_attr(self, attribute: str, input_objs: list[MetaObj], default_fn: Callable, deep_copy: bool) -> None: - super()._copy_attr(attribute, input_objs, default_fn, deep_copy) - val = getattr(self, attribute) - if isinstance(val, torch.Tensor): - setattr(self, attribute, val.to(self.device)) + def _copy_attr(self, attributes: list[str], input_objs, defaults: list, deep_copy: bool) -> None: + super()._copy_attr(attributes, input_objs, defaults, deep_copy) + for a in attributes: + val = getattr(self, a) + if isinstance(val, torch.Tensor): + setattr(self, a, val.to(self.device)) @staticmethod def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: @@ -172,6 +174,7 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: """ out = [] metas = None + is_batch = any(x.is_batch for x in MetaObj.flatten_meta_objs(args, kwargs.values()) if hasattr(x, "is_batch")) for idx, ret in enumerate(rets): # if not `MetaTensor`, nothing to do. if not isinstance(ret, MetaTensor): @@ -181,30 +184,34 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: ret = ret.as_tensor() # else, handle the `MetaTensor` metadata. else: - meta_args = MetaObj.flatten_meta_objs(list(args) + list(kwargs.values())) - ret._copy_meta(meta_args) + meta_args = MetaObj.flatten_meta_objs(args, kwargs.values()) # type: ignore + ret._copy_meta(meta_args, deep_copy=not is_batch) + ret.is_batch = is_batch + # the following is not implemented but the network arch may run into this case: + # if func == torch.cat and any(m.is_batch if hasattr(m, "is_batch") else False for m in meta_args): + # raise NotImplementedError("torch.cat is not implemented for batch of MetaTensors.") # If we have a batch of data, then we need to be careful if a slice of # the data is returned. Depending on how the data are indexed, we return # some or all of the metadata, and the return object may or may not be a # batch of data (e.g., `batch[:,-1]` versus `batch[0]`). - if ret.is_batch: - # only decollate metadata once - if metas is None: - metas = decollate_batch(ret.meta) + if is_batch: # if indexing e.g., `batch[0]` if func == torch.Tensor.__getitem__: - idx = args[1] - if isinstance(idx, Sequence): - idx = idx[0] + batch_idx = args[1] + if isinstance(batch_idx, Sequence): + batch_idx = batch_idx[0] # if using e.g., `batch[:, -1]` or `batch[..., -1]`, then the # first element will be `slice(None, None, None)` and `Ellipsis`, # respectively. Don't need to do anything with the metadata. - if idx not in (slice(None, None, None), Ellipsis): - meta = metas[idx] + if batch_idx not in (slice(None, None, None), Ellipsis): + # only decollate metadata once + if metas is None: + metas = decollate_batch(ret.meta) + meta = metas[batch_idx] # if using e.g., `batch[0:2]`, then `is_batch` should still be # `True`. Also re-collate the remaining elements. - if isinstance(meta, list) and len(meta) > 1: + if isinstance(meta, list): ret.meta = list_data_collate(meta) # if using e.g., `batch[0]` or `batch[0, 1]`, then return single # element from batch, and set `is_batch` to `False`. @@ -222,6 +229,8 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: else: dim = 0 if dim == 0: + if metas is None: + metas = decollate_batch(ret.meta) ret.meta = metas[idx] ret.is_batch = False @@ -242,6 +251,19 @@ def __torch_function__(cls, func, types, args=(), kwargs=None) -> Any: # we might have 1 or multiple outputs. Might be MetaTensor, might be something # else (e.g., `__repr__` returns a string). # Convert to list (if necessary), process, and at end remove list if one was added. + if ( + hasattr(torch, "return_types") + and hasattr(func, "__name__") + and hasattr(torch.return_types, func.__name__) + and isinstance(getattr(torch.return_types, func.__name__), type) + and isinstance(ret, getattr(torch.return_types, func.__name__)) + ): + # for torch.max(torch.tensor(1.0), dim=0), the return type is named-tuple like + out_items = MetaTensor.update_meta(ret, func, args, kwargs) + for idx in range(ret.n_fields): + ret[idx].meta = out_items[idx].meta + ret[idx].applied_operations = out_items[idx].applied_operations + return ret if isinstance(ret, (str, bytes)) or not isinstance(ret, Sequence): ret = [ret] unpack = True @@ -263,6 +285,7 @@ def as_tensor(self) -> torch.Tensor: def as_dict(self, key: str) -> dict: """ Get the object as a dictionary for backwards compatibility. + This method makes a copy of the objects. Args: key: Base key to store main data. The key for the metadata will be @@ -273,7 +296,7 @@ def as_dict(self, key: str) -> dict: the metadata. """ return { - key: self.as_tensor(), + key: self.as_tensor().clone().detach(), PostFix.meta(key): deepcopy(self.meta), PostFix.transforms(key): deepcopy(self.applied_operations), } @@ -281,13 +304,18 @@ def as_dict(self, key: str) -> dict: @property def affine(self) -> torch.Tensor: """Get the affine.""" - return self.meta["affine"] # type: ignore + return self.meta.get("affine", self.get_default_affine()) # type: ignore @affine.setter def affine(self, d: NdarrayTensor) -> None: """Set the affine.""" self.meta["affine"] = torch.as_tensor(d, device=self.device) + @property + def pixdim(self): + """Get the spacing""" + return affine_to_spacing(self.affine) + def new_empty(self, size, dtype=None, device=None, requires_grad=False): """ must be defined for deepcopy to work @@ -313,7 +341,7 @@ def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict): By default, a `MetaTensor` is returned. However, if `get_track_meta()` is `False`, a `torch.Tensor` is returned. """ - img = torch.as_tensor(im) + img = convert_to_tensor(im) # potentially ascontiguousarray # if not tracking metadata, return `torch.Tensor` if not get_track_meta() or meta is None: @@ -321,7 +349,7 @@ def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict): # ensure affine is of type `torch.Tensor` if "affine" in meta: - meta["affine"] = torch.as_tensor(meta["affine"]) + meta["affine"] = convert_to_tensor(meta["affine"]) # remove any superfluous metadata. remove_extra_metadata(meta) diff --git a/monai/data/png_writer.py b/monai/data/png_writer.py index b1fe7eb327..9fb463e9b9 100644 --- a/monai/data/png_writer.py +++ b/monai/data/png_writer.py @@ -14,7 +14,14 @@ import numpy as np from monai.transforms.spatial.array import Resize -from monai.utils import InterpolateMode, deprecated, ensure_tuple_rep, look_up_option, optional_import +from monai.utils import ( + InterpolateMode, + convert_data_type, + deprecated, + ensure_tuple_rep, + look_up_option, + optional_import, +) Image, _ = optional_import("PIL", name="Image") @@ -74,9 +81,9 @@ def write_png( if scale is not None: data = np.clip(data, 0.0, 1.0) # png writer only can scale data in range [0, 1] if scale == np.iinfo(np.uint8).max: - data = (scale * data).astype(np.uint8, copy=False) + data = convert_data_type((scale * data), np.ndarray, dtype=np.uint8, drop_meta=True)[0] elif scale == np.iinfo(np.uint16).max: - data = (scale * data).astype(np.uint16, copy=False) + data = convert_data_type((scale * data), np.ndarray, dtype=np.uint16, drop_meta=True)[0] else: raise ValueError(f"Unsupported scale: {scale}, available options are [255, 65535]") diff --git a/monai/data/utils.py b/monai/data/utils.py index 6f18e566e0..dd863c4898 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -36,6 +36,7 @@ Method, NumpyPadMode, PytorchPadMode, + TraceKeys, convert_data_type, convert_to_dst_type, ensure_tuple, @@ -391,6 +392,24 @@ def dev_collate(batch, level: int = 1, logger_name: str = "dev_collate"): return +def collate_meta_tensor(batch): + """collate a sequence of meta tensor sequences/dictionaries into + a single batched metatensor or a dictionary of batched metatensor""" + if not isinstance(batch, Sequence): + raise NotImplementedError() + elem_0 = first(batch) + if isinstance(elem_0, MetaObj): + collated = default_collate(batch) + collated.meta = default_collate([i.meta or TraceKeys.NONE for i in batch]) + collated.applied_operations = [i.applied_operations or TraceKeys.NONE for i in batch] + collated.is_batch = True + return collated + if isinstance(elem_0, Mapping): + return {k: collate_meta_tensor([d[k] for d in batch]) for k in elem_0} + # no more recursive search for MetaTensor + return default_collate(batch) + + def list_data_collate(batch: Sequence): """ Enhancement for PyTorch DataLoader default collate. @@ -410,15 +429,9 @@ def list_data_collate(batch: Sequence): for k in elem: key = k data_for_batch = [d[key] for d in data] - ret[key] = default_collate(data_for_batch) - if isinstance(ret[key], MetaObj) and all(isinstance(d, MetaObj) for d in data_for_batch): - ret[key].meta = list_data_collate([i.meta for i in data_for_batch]) - ret[key].is_batch = True + ret[key] = collate_meta_tensor(data_for_batch) else: - ret = default_collate(data) - if isinstance(ret, MetaObj) and all(isinstance(d, MetaObj) for d in data): - ret.meta = list_data_collate([i.meta for i in data]) - ret.is_batch = True + ret = collate_meta_tensor(data) return ret except RuntimeError as re: re_str = str(re) @@ -529,7 +542,9 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None): """ if batch is None: return batch - if isinstance(batch, (float, int, str, bytes)): + if isinstance(batch, (float, int, str, bytes)) or ( + type(batch).__module__ == "numpy" and not isinstance(batch, Iterable) + ): return batch if isinstance(batch, torch.Tensor): if detach: @@ -538,11 +553,15 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None): return batch.item() if detach else batch out_list = torch.unbind(batch, dim=0) # if of type MetaObj, decollate the metadata - if isinstance(batch, MetaObj) and all(isinstance(i, MetaObj) for i in out_list): - metas = decollate_batch(batch.meta) - for i in range(len(out_list)): - out_list[i].meta = metas[i] # type: ignore - out_list[i].is_batch = False # type: ignore + if isinstance(batch, MetaObj): + for t, m in zip(out_list, decollate_batch(batch.meta)): + if isinstance(t, MetaObj): + t.meta = m + t.is_batch = False + for t, m in zip(out_list, batch.applied_operations): + if isinstance(t, MetaObj): + t.applied_operations = m + t.is_batch = False if out_list[0].ndim == 0 and detach: return [t.item() for t in out_list] return list(out_list) @@ -643,6 +662,8 @@ def affine_to_spacing(affine: NdarrayTensor, r: int = 3, dtype=float, suppress_z Returns: an `r` dimensional vector of spacing. """ + if len(affine.shape) != 2 or affine.shape[0] != affine.shape[1]: + raise ValueError(f"affine must be a square matrix, got {affine.shape}.") _affine, *_ = convert_to_dst_type(affine[:r, :r], dst=affine, dtype=dtype) if isinstance(_affine, torch.Tensor): spacing = torch.sqrt(torch.sum(_affine * _affine, dim=0)) @@ -835,7 +856,7 @@ def to_affine_nd(r: Union[np.ndarray, int], affine: NdarrayTensor, dtype=np.floa an (r+1) x (r+1) matrix (tensor or ndarray depends on the input ``affine`` data type) """ - affine_np = convert_data_type(affine, output_type=np.ndarray, dtype=dtype, wrap_sequence=True)[0] + affine_np = convert_data_type(affine, output_type=np.ndarray, dtype=dtype, wrap_sequence=True, drop_meta=True)[0] affine_np = affine_np.copy() if affine_np.ndim != 2: raise ValueError(f"affine must have 2 dimensions, got {affine_np.ndim}.") diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index c4c4bd891c..b7e13323ec 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -15,12 +15,14 @@ import torch import torch.nn.functional as F +from monai.data.meta_tensor import MetaTensor from monai.data.utils import compute_importance_map, dense_patch_slices, get_valid_patch_size from monai.transforms import Resize from monai.utils import ( BlendMode, PytorchPadMode, convert_data_type, + convert_to_dst_type, ensure_tuple, fall_back_tuple, look_up_option, @@ -172,7 +174,9 @@ def sliding_window_inference( [slice(int(idx / num_win), int(idx / num_win) + 1), slice(None)] + list(slices[idx % num_win]) for idx in slice_range ] - window_data = torch.cat([inputs[win_slice] for win_slice in unravel_slice]).to(sw_device) + window_data = torch.cat( + [convert_data_type(inputs[win_slice], torch.Tensor, drop_meta=True)[0] for win_slice in unravel_slice] + ).to(sw_device) seg_prob_out = predictor(window_data, *args, **kwargs) # batched patch segmentation # convert seg_prob_out to tuple seg_prob_tuple, this does not allocate new memory. @@ -272,7 +276,10 @@ def sliding_window_inference( final_output = dict(zip(dict_key, output_image_list)) else: final_output = tuple(output_image_list) # type: ignore - return final_output[0] if is_tensor_output else final_output # type: ignore + final_output = final_output[0] if is_tensor_output else final_output # type: ignore + if isinstance(inputs, MetaTensor): + final_output = convert_to_dst_type(final_output, inputs)[0] # type: ignore + return final_output def _get_scan_interval( diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index faf5093305..4722f0f040 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -17,7 +17,7 @@ from monai.transforms.croppad.array import SpatialCrop from monai.transforms.utils import generate_spatial_bounding_box -from monai.utils import MetricReduction, look_up_option, optional_import +from monai.utils import MetricReduction, convert_data_type, look_up_option, optional_import binary_erosion, _ = optional_import("scipy.ndimage.morphology", name="binary_erosion") distance_transform_edt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_edt") @@ -103,12 +103,7 @@ def do_metric_reduction(f: torch.Tensor, reduction: Union[MetricReduction, str] return f, not_nans -def get_mask_edges( - seg_pred: Union[np.ndarray, torch.Tensor], - seg_gt: Union[np.ndarray, torch.Tensor], - label_idx: int = 1, - crop: bool = True, -) -> Tuple[np.ndarray, np.ndarray]: +def get_mask_edges(seg_pred, seg_gt, label_idx: int = 1, crop: bool = True) -> Tuple[np.ndarray, np.ndarray]: """ Do binary erosion and use XOR for input to get the edges. This function is helpful to further calculate metrics such as Average Surface @@ -160,9 +155,8 @@ def get_mask_edges( seg_pred, seg_gt = np.expand_dims(seg_pred, axis=channel_dim), np.expand_dims(seg_gt, axis=channel_dim) box_start, box_end = generate_spatial_bounding_box(np.asarray(seg_pred | seg_gt)) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) - seg_pred, seg_gt = np.squeeze(cropper(seg_pred), axis=channel_dim), np.squeeze( - cropper(seg_gt), axis=channel_dim - ) + seg_pred = convert_data_type(np.squeeze(cropper(seg_pred), axis=channel_dim), np.ndarray, drop_meta=True)[0] + seg_gt = convert_data_type(np.squeeze(cropper(seg_gt), axis=channel_dim), np.ndarray, drop_meta=True)[0] # Do binary erosion and use XOR to get edges edges_pred = binary_erosion(seg_pred) ^ seg_pred diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 847614adfe..24db2a871c 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -105,6 +105,7 @@ "convert_pad_mode", "convert_to_contiguous", "get_unique_labels", + "scale_affine", ] @@ -1185,16 +1186,13 @@ def map_spatial_axes( """ if spatial_axes is None: - spatial_axes_ = list(range(1, img_ndim) if channel_first else range(img_ndim - 1)) - - else: - spatial_axes_ = [] - for a in ensure_tuple(spatial_axes): - if channel_first: - spatial_axes_.append(a if a < 0 else a + 1) - else: - spatial_axes_.append(a - 1 if a < 0 else a) - + return list(range(1, img_ndim) if channel_first else range(img_ndim - 1)) + spatial_axes_ = [] + for a in ensure_tuple(spatial_axes): + if channel_first: + spatial_axes_.append(a % img_ndim if a < 0 else a + 1) + else: + spatial_axes_.append((a - 1) % (img_ndim - 1) if a < 0 else a) return spatial_axes_ @@ -1529,7 +1527,7 @@ def print_table_column(name, torch, numpy, color=Colors.none): print_color(f"Number of uncategorised: {n_uncategorized}", Colors.red) -def convert_pad_mode(dst: NdarrayOrTensor, mode: Union[NumpyPadMode, PytorchPadMode, str]): +def convert_pad_mode(dst: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]]): """ Utility to convert padding mode between numpy array and PyTorch Tensor. @@ -1573,5 +1571,30 @@ def convert_to_contiguous(data, **kwargs): return data +def scale_affine(affine, spatial_size, new_spatial_size, centered: bool = True): + """ + Scale the affine matrix according to the new spatial size. + + Args: + affine: affine matrix to scale. + spatial_size: original spatial size. + new_spatial_size: new spatial size. + centered: whether the scaling is with respect to + the image center (True, default) or corner (False). + + Returns: + Scaled affine matrix. + + """ + if spatial_size == new_spatial_size: + return affine + r = len(affine) - 1 + s = np.array([float(o) / float(max(n, 1)) for o, n in zip(spatial_size, new_spatial_size)]) + scale = create_scale(r, s.tolist()) + if centered: + scale[:r, -1] = (np.diag(scale)[:r] - 1) / 2 # type: ignore + return affine @ convert_to_dst_type(scale, affine)[0] + + if __name__ == "__main__": print_transform_backends() diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 2aedc77dd7..5e84efafe7 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -376,7 +376,7 @@ def mode(x: NdarrayTensor, dim: int = -1, to_long: bool = True) -> NdarrayTensor to_long: convert input to long before performing mode. """ dtype = torch.int64 if to_long else None - x_t, *_ = convert_data_type(x, torch.Tensor, dtype=dtype) + x_t, *_ = convert_data_type(x, torch.Tensor, dtype=dtype, drop_meta=True) o_t = torch.mode(x_t, dim).values o, *_ = convert_to_dst_type(o_t, x) return o @@ -389,3 +389,14 @@ def unique(x: NdarrayTensor) -> NdarrayTensor: x: array/tensor """ return torch.unique(x) if isinstance(x, torch.Tensor) else np.unique(x) # type: ignore + + +def linalg_inv(x: NdarrayTensor) -> NdarrayTensor: + """`torch.linalg.inv` with equivalent implementation for numpy. + + Args: + x: array/tensor + """ + if isinstance(x, torch.Tensor) and hasattr(torch, "inverse"): # pytorch 1.7.0 + return torch.inverse(x) # type: ignore + return torch.linalg.inv(x) if isinstance(x, torch.Tensor) else np.linalg.inv(x) # type: ignore diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 33b2a5fa2a..f53cfdaef0 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -93,6 +93,7 @@ convert_to_cupy, convert_to_dst_type, convert_to_list, + convert_to_meta_tensor, convert_to_numpy, convert_to_tensor, dtype_numpy_to_torch, diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 99f645a704..fc38dc5056 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -26,7 +26,7 @@ import numpy as np import torch -from monai.config.type_definitions import NdarrayOrTensor, PathLike +from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor, PathLike from monai.utils.module import version_leq __all__ = [ @@ -155,7 +155,7 @@ def ensure_tuple_rep(tup: Any, dim: int) -> Tuple[Any, ...]: def fall_back_tuple( - user_provided: Any, default: Union[Sequence, np.ndarray], func: Callable = lambda x: x and x > 0 + user_provided: Any, default: Union[Sequence, NdarrayTensor], func: Callable = lambda x: x and x > 0 ) -> Tuple[Any, ...]: """ Refine `user_provided` according to the `default`, and returns as a validated tuple. @@ -367,6 +367,7 @@ class ImageMetaKey: FILENAME_OR_OBJ = "filename_or_obj" PATCH_INDEX = "patch_index" + SPATIAL_SHAPE = "spatial_shape" def has_option(obj, keywords: Union[str, Sequence[str]]) -> bool: diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index a6cd2522d7..2d88a269fe 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -10,11 +10,13 @@ # limitations under the License. import re +from copy import deepcopy from typing import Any, Optional, Sequence, Tuple, Type, Union import numpy as np import torch +import monai from monai.config.type_definitions import DtypeLike, NdarrayTensor from monai.utils import optional_import @@ -32,6 +34,7 @@ "convert_to_cupy", "convert_to_numpy", "convert_to_tensor", + "convert_to_meta_tensor", "convert_to_dst_type", ] @@ -70,7 +73,7 @@ def get_equivalent_dtype(dtype, data_type): """ if dtype is None: return None - if data_type is torch.Tensor: + if data_type is torch.Tensor or data_type.__name__ == "MetaTensor": if isinstance(dtype, torch.dtype): # already a torch dtype and target `data_type` is torch.Tensor return dtype @@ -111,8 +114,11 @@ def convert_to_tensor( wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[tensor(1), tensor(2)]`. If `True`, then `[1, 2]` -> `tensor([1, 2])`. + """ if isinstance(data, torch.Tensor): + if isinstance(data, monai.data.MetaTensor): + data = data.as_tensor() return data.to(dtype=dtype, device=device, memory_format=torch.contiguous_format) # type: ignore if isinstance(data, np.ndarray): # skip array of string classes and object, refer to: @@ -137,6 +143,59 @@ def convert_to_tensor( return data +def convert_to_meta_tensor( + data, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, wrap_sequence: bool = False +): + """ + Utility to convert the input data to a MetaTensor. If passing a dictionary, list or tuple, + recursively check every item and convert it to MetaTensor. + + Args: + data: input data can be MetaTensor, PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. + will convert Tensor, Numpy array, float, int, bool to Tensor, strings and objects keep the original. + for dictionary, list or tuple, convert every item to a Tensor if applicable. + dtype: target data type to when converting to Tensor. + device: target device to put the converted Tensor data. + wrap_sequence: if `False`, then lists will recursively call this function. + E.g., `[1, 2]` -> `[tensor(1), tensor(2)]`. If `True`, then `[1, 2]` -> `tensor([1, 2])`. + + """ + if isinstance(data, torch.Tensor): + out = data.to(dtype=dtype, device=device, memory_format=torch.contiguous_format) # type: ignore + if not isinstance(out, monai.data.MetaTensor): + out = monai.data.MetaTensor(out) + return out + if isinstance(data, np.ndarray): + # skip array of string classes and object, refer to: + # https://github.com/pytorch/pytorch/blob/v1.9.0/torch/utils/data/_utils/collate.py#L13 + if re.search(r"[SaUO]", data.dtype.str) is None: + # numpy array with 0 dims is also sequence iterable, + # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims + if data.ndim > 0: + data = np.ascontiguousarray(data) + return monai.data.MetaTensor(torch.as_tensor(data, dtype=dtype, device=device)) # type: ignore + elif (has_cp and isinstance(data, cp_ndarray)) or isinstance(data, (float, int, bool)): + return monai.data.MetaTensor(torch.as_tensor(data, dtype=dtype, device=device)) # type: ignore + elif isinstance(data, list): + list_ret = [convert_to_meta_tensor(i, dtype=dtype, device=device) for i in data] + return ( + monai.data.MetaTensor(torch.as_tensor(list_ret, dtype=dtype, device=device)) # type: ignore + if wrap_sequence + else list_ret + ) + elif isinstance(data, tuple): + tuple_ret = tuple(convert_to_meta_tensor(i, dtype=dtype, device=device) for i in data) + return ( + monai.data.MetaTensor(torch.as_tensor(tuple_ret, dtype=dtype, device=device)) # type: ignore + if wrap_sequence + else tuple_ret + ) + elif isinstance(data, dict): + return {k: convert_to_meta_tensor(v, dtype=dtype, device=device) for k, v in data.items()} + + return data + + def convert_to_numpy(data, dtype: DtypeLike = None, wrap_sequence: bool = False): """ Utility to convert the input data to a numpy array. If passing a dictionary, list or tuple, @@ -212,6 +271,7 @@ def convert_data_type( device: Optional[torch.device] = None, dtype: Union[DtypeLike, torch.dtype] = None, wrap_sequence: bool = False, + drop_meta: bool = True, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: """ Convert to `torch.Tensor`/`np.ndarray` from `torch.Tensor`/`np.ndarray`/`float`/`int` etc. @@ -225,6 +285,10 @@ def convert_data_type( If left blank, it remains unchanged. wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[array(1), array(2)]`. If `True`, then `[1, 2]` -> `array([1, 2])`. + drop_meta: whether to drop the meta information of the input data, default to `True`. + If `True`, then the meta information will be dropped quietly, unless the output type is MetaTensor. + If `False`, converting a MetaTensor into a non-tensor instance will raise an error. + Returns: modified data, orig_type, orig_device @@ -238,7 +302,9 @@ def convert_data_type( """ orig_type: type - if isinstance(data, torch.Tensor): + if isinstance(data, monai.data.MetaTensor): + orig_type = monai.data.MetaTensor + elif isinstance(data, torch.Tensor): orig_type = torch.Tensor elif isinstance(data, np.ndarray): orig_type = np.ndarray @@ -253,7 +319,19 @@ def convert_data_type( dtype_ = get_equivalent_dtype(dtype, output_type) + if not drop_meta and not issubclass(output_type, monai.data.MetaObj) and isinstance(data, monai.data.MetaObj): + # input has a MetaObj, user chose keep the metadata, but the output type cannot take a MetaObj. + if issubclass(output_type, torch.Tensor): + # user-specified MetaTensor to torch tensor keep the MetaTensor type, for backward compatibility + output_type = type(data) # type: ignore + else: + raise RuntimeError(f"the specified output_type {output_type} cannot have the metaobj, but drop_meta=False.") + data_: NdarrayTensor + + if issubclass(output_type, monai.data.MetaTensor): + data_ = convert_to_meta_tensor(data, dtype=dtype_, device=device, wrap_sequence=wrap_sequence) + return data_, orig_type, orig_device if issubclass(output_type, torch.Tensor): data_ = convert_to_tensor(data, dtype=dtype_, device=device, wrap_sequence=wrap_sequence) return data_, orig_type, orig_device @@ -267,7 +345,11 @@ def convert_data_type( def convert_to_dst_type( - src: Any, dst: NdarrayTensor, dtype: Union[DtypeLike, torch.dtype, None] = None, wrap_sequence: bool = False + src: Any, + dst: NdarrayTensor, + dtype: Union[DtypeLike, torch.dtype, None] = None, + wrap_sequence: bool = False, + drop_meta: bool = True, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: """ Convert source data to the same data type and device as the destination data. @@ -281,22 +363,37 @@ def convert_to_dst_type( dtype: an optional argument if the target `dtype` is different from the original `dst`'s data type. wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[array(1), array(2)]`. If `True`, then `[1, 2]` -> `array([1, 2])`. + drop_meta: whether to drop the meta information of the input data, default to `True`. + If `True`, then the meta information will be dropped quietly, unless the output type is MetaTensor. + If `False`, converting a MetaTensor into a non-tensor instance will raise an error. See Also: :func:`convert_data_type` """ + device = dst.device if isinstance(dst, torch.Tensor) else None if dtype is None: dtype = dst.dtype + copy_meta = False output_type: Any - if isinstance(dst, torch.Tensor): + if isinstance(dst, monai.data.MetaTensor): + output_type = monai.data.MetaTensor + if not isinstance(src, monai.data.MetaTensor): + copy_meta = True # converting a non-meta tensor to a meta tensor, probably take the metadata as well. + elif isinstance(dst, torch.Tensor): output_type = torch.Tensor elif isinstance(dst, np.ndarray): output_type = np.ndarray else: output_type = type(dst) - return convert_data_type(data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence) + output: NdarrayTensor + output, _type, _device = convert_data_type( + data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence, drop_meta=drop_meta + ) + if copy_meta and isinstance(output, monai.data.MetaTensor): # type: ignore + output.meta, output.applied_operations = deepcopy(dst.meta), deepcopy(dst.applied_operations) # type: ignore + return output, _type, _device def convert_to_list(data: Union[Sequence, torch.Tensor, np.ndarray]) -> list: diff --git a/runtests.sh b/runtests.sh index a69c408e3c..a632e2664f 100755 --- a/runtests.sh +++ b/runtests.sh @@ -373,7 +373,6 @@ then clang_format echo "${green}done!${noColor}" - exit fi # unconditionally report on the state of monai diff --git a/tests/profile_subclass/README.md b/tests/profile_subclass/README.md new file mode 100644 index 0000000000..de16ef2d91 --- /dev/null +++ b/tests/profile_subclass/README.md @@ -0,0 +1,43 @@ +# Profiling the performance of subclassing/`__torch_function__` in MONAI + +## Requirements +```bash +pip install py-spy +pip install snakeviz # for viewing the cProfile results +``` + +## Commands + +### Install MONAI +``` +./runtests.sh --build # from monai's root directory +``` +or follow the installation guide (https://docs.monai.io/en/latest/installation.html) + +### Profiling the task of adding two MetaTensors +```bash +python profiling.py +``` + +### Profiling using `py-spy` +```bash +py-spy record -o Tensor.svg -- python pyspy_profiling.py Tensor +py-spy record -o SubTensor.svg -- python pyspy_profiling.py SubTensor +py-spy record -o SubWithTorchFunc.svg -- python pyspy_profiling.py SubWithTorchFunc +py-spy record -o MetaTensor.svg -- python pyspy_profiling.py MetaTensor +``` + +### Profiling using `cProfile` and `SNAKEVIZ` + +```bash +python cprofile_profiling.py +snakeviz out_200.prof +``` + +--- +These tests are based on the following code: +https://github.com/pytorch/pytorch/tree/v1.11.0/benchmarks/overrides_benchmark + +- Overhead for torch functions when run on `torch.Tensor` objects is on the order of 2 microseconds. +- `__torch_function__` should add zero overhead for `torch.Tensor` inputs, a small overhead for subclasses of `torch.Tensor`, and an order of microseconds for `MeatTensor`. +- Changing the dispatching mechanism may result in changes that are on the order of 100 ns, which are hard to detect due to noise, but important. diff --git a/tests/profile_subclass/cprofile_profiling.py b/tests/profile_subclass/cprofile_profiling.py new file mode 100644 index 0000000000..a6c940c9c0 --- /dev/null +++ b/tests/profile_subclass/cprofile_profiling.py @@ -0,0 +1,28 @@ +# 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. + +""" +Profiling MetaTensor +""" + +import cProfile + +import torch + +from monai.data.meta_tensor import MetaTensor + +if __name__ == "__main__": + n_chan = 3 + for hwd in (10, 200): + shape = (n_chan, hwd, hwd, hwd) + a = MetaTensor(torch.rand(shape), meta={"affine": torch.eye(4) * 2, "fname": "something1"}) + b = MetaTensor(torch.rand(shape), meta={"affine": torch.eye(4) * 3, "fname": "something2"}) + cProfile.run("c = a + b", filename=f"out_{hwd}.prof") diff --git a/tests/profile_subclass/min_classes.py b/tests/profile_subclass/min_classes.py new file mode 100644 index 0000000000..87c0ce671d --- /dev/null +++ b/tests/profile_subclass/min_classes.py @@ -0,0 +1,29 @@ +# 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. + + +""" +Minimal subclassing as baselines +Adapted from https://github.com/pytorch/pytorch/tree/v1.11.0/benchmarks/overrides_benchmark +""" + +import torch + +__all__ = ["SubTensor", "SubWithTorchFunc"] + + +class SubTensor(torch.Tensor): + pass + + +class SubWithTorchFunc(torch.Tensor): + def __torch_function__(self, func, types, args=(), kwargs=None): + return super().__torch_function__(func, types, args, {} if kwargs is None else kwargs) diff --git a/tests/profile_subclass/profiling.py b/tests/profile_subclass/profiling.py new file mode 100644 index 0000000000..28740e82e1 --- /dev/null +++ b/tests/profile_subclass/profiling.py @@ -0,0 +1,72 @@ +# 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. + +""" +Comparing torch.Tensor, SubTensor, SubWithTorchFunc, MetaTensor +Adapted from https://github.com/pytorch/pytorch/tree/v1.11.0/benchmarks/overrides_benchmark +""" +import argparse + +import torch +from min_classes import SubTensor, SubWithTorchFunc + +from monai.data import MetaTensor +from monai.utils.profiling import PerfContext + +NUM_REPEATS = 1000 +NUM_REPEAT_OF_REPEATS = 1000 + + +def bench(t1, t2): + bench_times = [] + for _ in range(NUM_REPEAT_OF_REPEATS): + with PerfContext() as pc: + for _ in range(NUM_REPEATS): + torch.add(t1, t2) + bench_times.append(pc.total_time) + + bench_time_min = float(torch.min(torch.Tensor(bench_times))) / NUM_REPEATS + bench_time_avg = float(torch.sum(torch.Tensor(bench_times))) / (NUM_REPEATS * NUM_REPEAT_OF_REPEATS) + bench_time_med = float(torch.median(torch.Tensor(bench_times))) / NUM_REPEATS + bench_std = float(torch.std(torch.Tensor(bench_times))) / NUM_REPEATS + return bench_time_min, bench_time_avg, bench_time_med, bench_std + + +def main(): + global NUM_REPEATS + global NUM_REPEAT_OF_REPEATS + + parser = argparse.ArgumentParser(description="Run the __torch_function__ benchmarks.") + parser.add_argument( + "--nreps", "-n", type=int, default=NUM_REPEATS, help="The number of repeats for one measurement." + ) + parser.add_argument("--nrepreps", "-m", type=int, default=NUM_REPEAT_OF_REPEATS, help="The number of measurements.") + args = parser.parse_args() + + NUM_REPEATS = args.nreps + NUM_REPEAT_OF_REPEATS = args.nrepreps + + types = torch.Tensor, SubTensor, SubWithTorchFunc, MetaTensor + + for t in types: + tensor_1 = t(1) + tensor_2 = t(2) + + b_min, b_avg, b_med, b_std = bench(tensor_1, tensor_2) + print( + "Type {} time (microseconds): min: {}, avg: {}, median: {}, and std {}.".format( + t.__name__, (10**6 * b_min), (10**6) * b_avg, (10**6) * b_med, (10**6) * b_std + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/profile_subclass/pyspy_profiling.py b/tests/profile_subclass/pyspy_profiling.py new file mode 100644 index 0000000000..302bfd39c3 --- /dev/null +++ b/tests/profile_subclass/pyspy_profiling.py @@ -0,0 +1,40 @@ +# 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. + +""" +To be used with py-spy, comparing torch.Tensor, SubTensor, SubWithTorchFunc, MetaTensor +Adapted from https://github.com/pytorch/pytorch/tree/v1.11.0/benchmarks/overrides_benchmark +""" +import argparse + +import torch +from min_classes import SubTensor, SubWithTorchFunc # noqa: F401 + +from monai.data import MetaTensor # noqa: F401 + +Tensor = torch.Tensor + +NUM_REPEATS = 1000000 + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run the torch.add for a given class a given number of times.") + parser.add_argument("tensor_class", metavar="TensorClass", type=str, help="The class to benchmark.") + parser.add_argument("--nreps", "-n", type=int, default=NUM_REPEATS, help="The number of repeats.") + args = parser.parse_args() + + TensorClass = globals()[args.tensor_class] + NUM_REPEATS = args.nreps + + t1 = TensorClass(1) + t2 = TensorClass(2) + + for _ in range(NUM_REPEATS): + torch.add(t1, t2) From 1f0791398da6c0101187115ebe6255b6a58b3bec Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 21 Jun 2022 03:16:23 +0100 Subject: [PATCH 156/183] 4530 optional SummaryWriter (#4546) fixes #4530 Signed-off-by: Wenqi Li --- tests/test_handler_tb_stats.py | 4 +++- tests/test_integration_segmentation_3d.py | 5 +++-- tests/test_integration_workflows.py | 5 +++-- tests/test_plot_2d_or_3d_image.py | 3 ++- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_handler_tb_stats.py b/tests/test_handler_tb_stats.py index 4d582d151b..9335e48080 100644 --- a/tests/test_handler_tb_stats.py +++ b/tests/test_handler_tb_stats.py @@ -14,9 +14,11 @@ import unittest from ignite.engine import Engine, Events -from torch.utils.tensorboard import SummaryWriter from monai.handlers import TensorBoardStatsHandler +from monai.utils import optional_import + +SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") class TestHandlerTBStats(unittest.TestCase): diff --git a/tests/test_integration_segmentation_3d.py b/tests/test_integration_segmentation_3d.py index 5c273d0a46..b29a514488 100644 --- a/tests/test_integration_segmentation_3d.py +++ b/tests/test_integration_segmentation_3d.py @@ -18,7 +18,6 @@ import nibabel as nib import numpy as np import torch -from torch.utils.tensorboard import SummaryWriter import monai from monai.data import create_test_image_3d, decollate_batch @@ -40,12 +39,14 @@ ToTensor, ToTensord, ) -from monai.utils import set_determinism +from monai.utils import optional_import, set_determinism from monai.utils.enums import PostFix from monai.visualize import plot_2d_or_3d_image from tests.testing_data.integration_answers import test_integration_value from tests.utils import DistTestCase, TimedCall, skip_if_quick +SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") + TASK = "integration_segmentation_3d" diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index 852228efc4..2a95b23d4f 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -21,7 +21,6 @@ import torch from ignite.engine import Events from ignite.metrics import Accuracy -from torch.utils.tensorboard import SummaryWriter import monai from monai.data import create_test_image_3d, decollate_batch @@ -52,11 +51,13 @@ ScaleIntensityd, ToTensord, ) -from monai.utils import set_determinism +from monai.utils import optional_import, set_determinism from monai.utils.enums import PostFix from tests.testing_data.integration_answers import test_integration_value from tests.utils import DistTestCase, TimedCall, pytorch_after, skip_if_quick +SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") + TASK = "integration_workflows" diff --git a/tests/test_plot_2d_or_3d_image.py b/tests/test_plot_2d_or_3d_image.py index 2e4adb93e3..24fb6f0dde 100644 --- a/tests/test_plot_2d_or_3d_image.py +++ b/tests/test_plot_2d_or_3d_image.py @@ -15,12 +15,13 @@ import torch from parameterized import parameterized -from torch.utils.tensorboard import SummaryWriter from monai.utils import optional_import from monai.visualize import plot_2d_or_3d_image from tests.utils import SkipIfNoModule +SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") + SummaryWriterX, _ = optional_import("tensorboardX", name="SummaryWriter") TEST_CASE_1 = [(1, 1, 10, 10)] From d34fa14eed3191126fc1bc1a9751e69e0396f78b Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 22 Jun 2022 02:01:22 +0800 Subject: [PATCH 157/183] Enhance type conversion of MetaTensor (#4549) * [DLMED] update type conversion Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/data/png_writer.py | 4 +- monai/data/utils.py | 2 +- monai/inferers/utils.py | 2 +- monai/metrics/utils.py | 4 +- .../utils_pytorch_numpy_unification.py | 2 +- monai/utils/__init__.py | 1 - monai/utils/type_conversion.py | 128 +++++------------- tests/test_convert_data_type.py | 20 +-- tests/test_utils_pytorch_numpy_unification.py | 2 +- tests/utils.py | 14 ++ 10 files changed, 68 insertions(+), 111 deletions(-) diff --git a/monai/data/png_writer.py b/monai/data/png_writer.py index 9fb463e9b9..dc042971cb 100644 --- a/monai/data/png_writer.py +++ b/monai/data/png_writer.py @@ -81,9 +81,9 @@ def write_png( if scale is not None: data = np.clip(data, 0.0, 1.0) # png writer only can scale data in range [0, 1] if scale == np.iinfo(np.uint8).max: - data = convert_data_type((scale * data), np.ndarray, dtype=np.uint8, drop_meta=True)[0] + data = convert_data_type((scale * data), np.ndarray, dtype=np.uint8)[0] elif scale == np.iinfo(np.uint16).max: - data = convert_data_type((scale * data), np.ndarray, dtype=np.uint16, drop_meta=True)[0] + data = convert_data_type((scale * data), np.ndarray, dtype=np.uint16)[0] else: raise ValueError(f"Unsupported scale: {scale}, available options are [255, 65535]") diff --git a/monai/data/utils.py b/monai/data/utils.py index dd863c4898..88e3dbbcc2 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -856,7 +856,7 @@ def to_affine_nd(r: Union[np.ndarray, int], affine: NdarrayTensor, dtype=np.floa an (r+1) x (r+1) matrix (tensor or ndarray depends on the input ``affine`` data type) """ - affine_np = convert_data_type(affine, output_type=np.ndarray, dtype=dtype, wrap_sequence=True, drop_meta=True)[0] + affine_np = convert_data_type(affine, output_type=np.ndarray, dtype=dtype, wrap_sequence=True)[0] affine_np = affine_np.copy() if affine_np.ndim != 2: raise ValueError(f"affine must have 2 dimensions, got {affine_np.ndim}.") diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index b7e13323ec..2fa0b79476 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -175,7 +175,7 @@ def sliding_window_inference( for idx in slice_range ] window_data = torch.cat( - [convert_data_type(inputs[win_slice], torch.Tensor, drop_meta=True)[0] for win_slice in unravel_slice] + [convert_data_type(inputs[win_slice], torch.Tensor)[0] for win_slice in unravel_slice] ).to(sw_device) seg_prob_out = predictor(window_data, *args, **kwargs) # batched patch segmentation diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index 4722f0f040..c17df7a54a 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -155,8 +155,8 @@ def get_mask_edges(seg_pred, seg_gt, label_idx: int = 1, crop: bool = True) -> T seg_pred, seg_gt = np.expand_dims(seg_pred, axis=channel_dim), np.expand_dims(seg_gt, axis=channel_dim) box_start, box_end = generate_spatial_bounding_box(np.asarray(seg_pred | seg_gt)) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) - seg_pred = convert_data_type(np.squeeze(cropper(seg_pred), axis=channel_dim), np.ndarray, drop_meta=True)[0] - seg_gt = convert_data_type(np.squeeze(cropper(seg_gt), axis=channel_dim), np.ndarray, drop_meta=True)[0] + seg_pred = convert_data_type(np.squeeze(cropper(seg_pred), axis=channel_dim), np.ndarray)[0] + seg_gt = convert_data_type(np.squeeze(cropper(seg_gt), axis=channel_dim), np.ndarray)[0] # Do binary erosion and use XOR to get edges edges_pred = binary_erosion(seg_pred) ^ seg_pred diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 5e84efafe7..2718018a10 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -376,7 +376,7 @@ def mode(x: NdarrayTensor, dim: int = -1, to_long: bool = True) -> NdarrayTensor to_long: convert input to long before performing mode. """ dtype = torch.int64 if to_long else None - x_t, *_ = convert_data_type(x, torch.Tensor, dtype=dtype, drop_meta=True) + x_t, *_ = convert_data_type(x, torch.Tensor, dtype=dtype) o_t = torch.mode(x_t, dim).values o, *_ = convert_to_dst_type(o_t, x) return o diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index f53cfdaef0..33b2a5fa2a 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -93,7 +93,6 @@ convert_to_cupy, convert_to_dst_type, convert_to_list, - convert_to_meta_tensor, convert_to_numpy, convert_to_tensor, dtype_numpy_to_torch, diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index 2d88a269fe..e77909fd4a 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -34,14 +34,13 @@ "convert_to_cupy", "convert_to_numpy", "convert_to_tensor", - "convert_to_meta_tensor", "convert_to_dst_type", ] def get_numpy_dtype_from_string(dtype: str) -> np.dtype: """Get a numpy dtype (e.g., `np.float32`) from its string (e.g., `"float32"`).""" - return np.zeros([], dtype=dtype).dtype # type: ignore + return np.empty([], dtype=dtype).dtype # type: ignore def get_torch_dtype_from_string(dtype: str) -> torch.dtype: @@ -51,12 +50,12 @@ def get_torch_dtype_from_string(dtype: str) -> torch.dtype: def dtype_torch_to_numpy(dtype: torch.dtype) -> np.dtype: """Convert a torch dtype to its numpy equivalent.""" - return torch.zeros([], dtype=dtype).numpy().dtype # type: ignore + return torch.empty([], dtype=dtype).numpy().dtype # type: ignore def dtype_numpy_to_torch(dtype: np.dtype) -> torch.dtype: """Convert a numpy dtype to its torch equivalent.""" - return torch.from_numpy(np.zeros([], dtype=dtype)).dtype + return torch.from_numpy(np.empty([], dtype=dtype)).dtype def get_equivalent_dtype(dtype, data_type): @@ -99,11 +98,16 @@ def get_dtype(data: Any): def convert_to_tensor( - data, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, wrap_sequence: bool = False + data, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, + wrap_sequence: bool = False, + track_meta: bool = False, ): """ - Utility to convert the input data to a PyTorch Tensor. If passing a dictionary, list or tuple, - recursively check every item and convert it to PyTorch Tensor. + Utility to convert the input data to a PyTorch Tensor, if `track_meta` is True, the output will be a `MetaTensor`, + otherwise, the output will be a regular torch Tensor. + If passing a dictionary, list or tuple, recursively check every item and convert it to PyTorch Tensor. Args: data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. @@ -113,58 +117,23 @@ def convert_to_tensor( device: target device to put the converted Tensor data. wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[tensor(1), tensor(2)]`. If `True`, then `[1, 2]` -> `tensor([1, 2])`. - + track_meta: whether to track the meta information, if `True`, will convert to `MetaTensor`. + default to `False`. """ - if isinstance(data, torch.Tensor): - if isinstance(data, monai.data.MetaTensor): - data = data.as_tensor() - return data.to(dtype=dtype, device=device, memory_format=torch.contiguous_format) # type: ignore - if isinstance(data, np.ndarray): - # skip array of string classes and object, refer to: - # https://github.com/pytorch/pytorch/blob/v1.9.0/torch/utils/data/_utils/collate.py#L13 - if re.search(r"[SaUO]", data.dtype.str) is None: - # numpy array with 0 dims is also sequence iterable, - # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims - if data.ndim > 0: - data = np.ascontiguousarray(data) - return torch.as_tensor(data, dtype=dtype, device=device) # type: ignore - elif (has_cp and isinstance(data, cp_ndarray)) or isinstance(data, (float, int, bool)): - return torch.as_tensor(data, dtype=dtype, device=device) # type: ignore - elif isinstance(data, list): - list_ret = [convert_to_tensor(i, dtype=dtype, device=device) for i in data] - return torch.as_tensor(list_ret, dtype=dtype, device=device) if wrap_sequence else list_ret # type: ignore - elif isinstance(data, tuple): - tuple_ret = tuple(convert_to_tensor(i, dtype=dtype, device=device) for i in data) - return torch.as_tensor(tuple_ret, dtype=dtype, device=device) if wrap_sequence else tuple_ret # type: ignore - elif isinstance(data, dict): - return {k: convert_to_tensor(v, dtype=dtype, device=device) for k, v in data.items()} - return data + def _convert_tensor(tensor, **kwargs): + if not isinstance(tensor, torch.Tensor): + # if input data is not Tensor, convert it to Tensor first + tensor = torch.as_tensor(tensor, **kwargs) + if track_meta and not isinstance(tensor, monai.data.MetaTensor): + return monai.data.MetaTensor(tensor) + if not track_meta and isinstance(tensor, monai.data.MetaTensor): + return tensor.as_tensor() + return tensor - -def convert_to_meta_tensor( - data, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, wrap_sequence: bool = False -): - """ - Utility to convert the input data to a MetaTensor. If passing a dictionary, list or tuple, - recursively check every item and convert it to MetaTensor. - - Args: - data: input data can be MetaTensor, PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. - will convert Tensor, Numpy array, float, int, bool to Tensor, strings and objects keep the original. - for dictionary, list or tuple, convert every item to a Tensor if applicable. - dtype: target data type to when converting to Tensor. - device: target device to put the converted Tensor data. - wrap_sequence: if `False`, then lists will recursively call this function. - E.g., `[1, 2]` -> `[tensor(1), tensor(2)]`. If `True`, then `[1, 2]` -> `tensor([1, 2])`. - - """ if isinstance(data, torch.Tensor): - out = data.to(dtype=dtype, device=device, memory_format=torch.contiguous_format) # type: ignore - if not isinstance(out, monai.data.MetaTensor): - out = monai.data.MetaTensor(out) - return out + return _convert_tensor(data).to(dtype=dtype, device=device, memory_format=torch.contiguous_format) if isinstance(data, np.ndarray): # skip array of string classes and object, refer to: # https://github.com/pytorch/pytorch/blob/v1.9.0/torch/utils/data/_utils/collate.py#L13 @@ -173,25 +142,17 @@ def convert_to_meta_tensor( # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims if data.ndim > 0: data = np.ascontiguousarray(data) - return monai.data.MetaTensor(torch.as_tensor(data, dtype=dtype, device=device)) # type: ignore + return _convert_tensor(data, dtype=dtype, device=device) elif (has_cp and isinstance(data, cp_ndarray)) or isinstance(data, (float, int, bool)): - return monai.data.MetaTensor(torch.as_tensor(data, dtype=dtype, device=device)) # type: ignore + return _convert_tensor(data, dtype=dtype, device=device) elif isinstance(data, list): - list_ret = [convert_to_meta_tensor(i, dtype=dtype, device=device) for i in data] - return ( - monai.data.MetaTensor(torch.as_tensor(list_ret, dtype=dtype, device=device)) # type: ignore - if wrap_sequence - else list_ret - ) + list_ret = [convert_to_tensor(i, dtype=dtype, device=device, track_meta=track_meta) for i in data] + return _convert_tensor(list_ret, dtype=dtype, device=device) if wrap_sequence else list_ret elif isinstance(data, tuple): - tuple_ret = tuple(convert_to_meta_tensor(i, dtype=dtype, device=device) for i in data) - return ( - monai.data.MetaTensor(torch.as_tensor(tuple_ret, dtype=dtype, device=device)) # type: ignore - if wrap_sequence - else tuple_ret - ) + tuple_ret = tuple(convert_to_tensor(i, dtype=dtype, device=device, track_meta=track_meta) for i in data) + return _convert_tensor(tuple_ret, dtype=dtype, device=device) if wrap_sequence else tuple_ret elif isinstance(data, dict): - return {k: convert_to_meta_tensor(v, dtype=dtype, device=device) for k, v in data.items()} + return {k: convert_to_tensor(v, dtype=dtype, device=device, track_meta=track_meta) for k, v in data.items()} return data @@ -271,7 +232,6 @@ def convert_data_type( device: Optional[torch.device] = None, dtype: Union[DtypeLike, torch.dtype] = None, wrap_sequence: bool = False, - drop_meta: bool = True, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: """ Convert to `torch.Tensor`/`np.ndarray` from `torch.Tensor`/`np.ndarray`/`float`/`int` etc. @@ -285,9 +245,6 @@ def convert_data_type( If left blank, it remains unchanged. wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[array(1), array(2)]`. If `True`, then `[1, 2]` -> `array([1, 2])`. - drop_meta: whether to drop the meta information of the input data, default to `True`. - If `True`, then the meta information will be dropped quietly, unless the output type is MetaTensor. - If `False`, converting a MetaTensor into a non-tensor instance will raise an error. Returns: modified data, orig_type, orig_device @@ -319,21 +276,11 @@ def convert_data_type( dtype_ = get_equivalent_dtype(dtype, output_type) - if not drop_meta and not issubclass(output_type, monai.data.MetaObj) and isinstance(data, monai.data.MetaObj): - # input has a MetaObj, user chose keep the metadata, but the output type cannot take a MetaObj. - if issubclass(output_type, torch.Tensor): - # user-specified MetaTensor to torch tensor keep the MetaTensor type, for backward compatibility - output_type = type(data) # type: ignore - else: - raise RuntimeError(f"the specified output_type {output_type} cannot have the metaobj, but drop_meta=False.") - data_: NdarrayTensor - if issubclass(output_type, monai.data.MetaTensor): - data_ = convert_to_meta_tensor(data, dtype=dtype_, device=device, wrap_sequence=wrap_sequence) - return data_, orig_type, orig_device if issubclass(output_type, torch.Tensor): - data_ = convert_to_tensor(data, dtype=dtype_, device=device, wrap_sequence=wrap_sequence) + track_meta = issubclass(output_type, monai.data.MetaTensor) + data_ = convert_to_tensor(data, dtype=dtype_, device=device, wrap_sequence=wrap_sequence, track_meta=track_meta) return data_, orig_type, orig_device if issubclass(output_type, np.ndarray): data_ = convert_to_numpy(data, dtype=dtype_, wrap_sequence=wrap_sequence) @@ -345,11 +292,7 @@ def convert_data_type( def convert_to_dst_type( - src: Any, - dst: NdarrayTensor, - dtype: Union[DtypeLike, torch.dtype, None] = None, - wrap_sequence: bool = False, - drop_meta: bool = True, + src: Any, dst: NdarrayTensor, dtype: Union[DtypeLike, torch.dtype, None] = None, wrap_sequence: bool = False ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: """ Convert source data to the same data type and device as the destination data. @@ -363,9 +306,6 @@ def convert_to_dst_type( dtype: an optional argument if the target `dtype` is different from the original `dst`'s data type. wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[array(1), array(2)]`. If `True`, then `[1, 2]` -> `array([1, 2])`. - drop_meta: whether to drop the meta information of the input data, default to `True`. - If `True`, then the meta information will be dropped quietly, unless the output type is MetaTensor. - If `False`, converting a MetaTensor into a non-tensor instance will raise an error. See Also: :func:`convert_data_type` @@ -389,7 +329,7 @@ def convert_to_dst_type( output_type = type(dst) output: NdarrayTensor output, _type, _device = convert_data_type( - data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence, drop_meta=drop_meta + data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence ) if copy_meta and isinstance(output, monai.data.MetaTensor): # type: ignore output.meta, output.applied_operations = deepcopy(dst.meta), deepcopy(dst.applied_operations) # type: ignore diff --git a/tests/test_convert_data_type.py b/tests/test_convert_data_type.py index 1818f500f9..796e607884 100644 --- a/tests/test_convert_data_type.py +++ b/tests/test_convert_data_type.py @@ -16,24 +16,25 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor from monai.utils.type_conversion import convert_data_type, convert_to_dst_type -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL TESTS: List[Tuple] = [] -for in_type in TEST_NDARRAYS + (int, float): - for out_type in TEST_NDARRAYS: +for in_type in TEST_NDARRAYS_ALL + (int, float): + for out_type in TEST_NDARRAYS_ALL: TESTS.append((in_type(np.array(1.0)), out_type(np.array(1.0)))) # type: ignore TESTS_LIST: List[Tuple] = [] -for in_type in TEST_NDARRAYS + (int, float): - for out_type in TEST_NDARRAYS: +for in_type in TEST_NDARRAYS_ALL + (int, float): + for out_type in TEST_NDARRAYS_ALL: TESTS_LIST.append( ([in_type(np.array(1.0)), in_type(np.array(1.0))], out_type(np.array([1.0, 1.0])), True) # type: ignore ) TESTS_LIST.append( ( [in_type(np.array(1.0)), in_type(np.array(1.0))], # type: ignore - [out_type(np.array(1.0)), out_type(np.array(1.0))], + [out_type(np.array(1.0)), out_type(np.array(1.0))], # type: ignore False, ) ) @@ -83,14 +84,17 @@ def test_convert_data_type(self, in_image, im_out): self.assertEqual(type(in_image), orig_type) if isinstance(in_image, torch.Tensor): self.assertEqual(in_image.device, orig_device) + # check output is desired type - if isinstance(im_out, torch.Tensor): + if isinstance(im_out, MetaTensor): + output_type = MetaTensor + elif isinstance(im_out, torch.Tensor): output_type = torch.Tensor else: output_type = np.ndarray self.assertEqual(type(converted_im), output_type) # check dtype is unchanged - if isinstance(in_type, (np.ndarray, torch.Tensor)): + if isinstance(in_type, (np.ndarray, torch.Tensor, MetaTensor)): self.assertEqual(converted_im.dtype, im_out.dtype) diff --git a/tests/test_utils_pytorch_numpy_unification.py b/tests/test_utils_pytorch_numpy_unification.py index 1b08a5bd2f..df4db8a27c 100644 --- a/tests/test_utils_pytorch_numpy_unification.py +++ b/tests/test_utils_pytorch_numpy_unification.py @@ -36,7 +36,7 @@ def test_percentile(self): for p in TEST_NDARRAYS: arr = p(np.arange(100 * 101).reshape(1, 100, 101).astype(np.float32)) results.append(percentile(arr, q)) - assert_allclose(results[0], results[-1], type_test=False, atol=1e-4) + assert_allclose(results[0], results[-1], type_test=False, atol=1e-4, rtol=1e-4) def test_fails(self): for p in TEST_NDARRAYS: diff --git a/tests/utils.py b/tests/utils.py index 1a547fc2d2..b8d18916b7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -38,6 +38,7 @@ from monai.config.deviceconfig import USE_COMPILED from monai.config.type_definitions import NdarrayOrTensor from monai.data import create_test_image_2d, create_test_image_3d +from monai.data.meta_tensor import MetaTensor from monai.networks import convert_to_torchscript from monai.utils import optional_import from monai.utils.module import pytorch_after, version_leq @@ -712,6 +713,19 @@ def query_memory(n=2): gpu_tensor: Callable = partial(torch.as_tensor, device="cuda") TEST_NDARRAYS = TEST_NDARRAYS + (gpu_tensor,) # type: ignore +TEST_TORCH_TENSORS: Tuple[Callable] = (torch.as_tensor,) # type: ignore +if torch.cuda.is_available(): + gpu_tensor: Callable = partial(torch.as_tensor, device="cuda") # type: ignore + TEST_NDARRAYS = TEST_TORCH_TENSORS + (gpu_tensor,) # type: ignore + +DEFAULT_TEST_AFFINE = torch.tensor( + [[2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0], [0.0, 0.0, 0.0, 1.0]] +) +_metatensor_creator = partial(MetaTensor, meta={"a": "b", "affine": DEFAULT_TEST_AFFINE}) +TEST_NDARRAYS_NO_META_TENSOR: Tuple[Callable] = (np.array,) + TEST_TORCH_TENSORS # type: ignore +TEST_TORCH_AND_META_TENSORS: Tuple[Callable] = TEST_TORCH_TENSORS + (_metatensor_creator,) # type: ignore +TEST_NDARRAYS_ALL: Tuple[Callable] = TEST_NDARRAYS_NO_META_TENSOR + (_metatensor_creator,) # type: ignore + TEST_DEVICES = [[torch.device("cpu")]] if torch.cuda.is_available(): From 6c095a80b280dbb64c8c77c54a73dc1490c40bab Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 22 Jun 2022 06:17:13 +0100 Subject: [PATCH 158/183] 4351 - adds StrEnum (#4556) * adds strenum Signed-off-by: Wenqi Li * fixes tests Signed-off-by: Wenqi Li * fixes tests Signed-off-by: Wenqi Li * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/losses/dice.py | 4 +- monai/utils/__init__.py | 1 + monai/utils/enums.py | 73 ++++++++++++++++++++++++------------ tests/test_look_up_option.py | 15 +++++++- tests/test_rand_zoom.py | 8 +--- 5 files changed, 67 insertions(+), 34 deletions(-) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 495a1d40f5..892d71d06a 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -291,9 +291,9 @@ def __init__( self.batch = batch def w_func(self, grnd): - if self.w_type == Weight.SIMPLE: + if self.w_type == str(Weight.SIMPLE): return torch.reciprocal(grnd) - if self.w_type == Weight.SQUARE: + if self.w_type == str(Weight.SQUARE): return torch.reciprocal(grnd * grnd) return torch.ones_like(grnd) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 33b2a5fa2a..cb376b7280 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -36,6 +36,7 @@ ProbMapKeys, PytorchPadMode, SkipMode, + StrEnum, TraceKeys, TransformBackends, UpsampleMode, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 51d2772414..c4c33596a7 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -16,6 +16,7 @@ from monai.utils.deprecate_utils import deprecated __all__ = [ + "StrEnum", "NumpyPadMode", "GridSampleMode", "InterpolateMode", @@ -42,7 +43,29 @@ ] -class NumpyPadMode(Enum): +class StrEnum(str, Enum): + """ + Enum subclass that converts its value to a string. + + .. code-block:: python + + from monai.utils import StrEnum + + class Example(StrEnum): + MODE_A = "A" + MODE_B = "B" + + assert (list(Example) == ["A", "B"]) + assert Example.MODE_A == "A" + assert str(Example.MODE_A) == "A" + assert monai.utils.look_up_option("A", Example) == "A" + """ + + def __str__(self): + return self.value + + +class NumpyPadMode(StrEnum): """ See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html """ @@ -60,7 +83,7 @@ class NumpyPadMode(Enum): EMPTY = "empty" -class GridSampleMode(Enum): +class GridSampleMode(StrEnum): """ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html @@ -78,7 +101,7 @@ class GridSampleMode(Enum): BICUBIC = "bicubic" -class InterpolateMode(Enum): +class InterpolateMode(StrEnum): """ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html """ @@ -92,7 +115,7 @@ class InterpolateMode(Enum): AREA = "area" -class UpsampleMode(Enum): +class UpsampleMode(StrEnum): """ See also: :py:class:`monai.networks.blocks.UpSample` """ @@ -102,7 +125,7 @@ class UpsampleMode(Enum): PIXELSHUFFLE = "pixelshuffle" -class BlendMode(Enum): +class BlendMode(StrEnum): """ See also: :py:class:`monai.data.utils.compute_importance_map` """ @@ -111,7 +134,7 @@ class BlendMode(Enum): GAUSSIAN = "gaussian" -class PytorchPadMode(Enum): +class PytorchPadMode(StrEnum): """ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html """ @@ -122,7 +145,7 @@ class PytorchPadMode(Enum): CIRCULAR = "circular" -class GridSamplePadMode(Enum): +class GridSamplePadMode(StrEnum): """ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html """ @@ -132,7 +155,7 @@ class GridSamplePadMode(Enum): REFLECTION = "reflection" -class Average(Enum): +class Average(StrEnum): """ See also: :py:class:`monai.metrics.rocauc.compute_roc_auc` """ @@ -143,7 +166,7 @@ class Average(Enum): NONE = "none" -class MetricReduction(Enum): +class MetricReduction(StrEnum): """ See also: :py:func:`monai.metrics.utils.do_metric_reduction` """ @@ -157,7 +180,7 @@ class MetricReduction(Enum): SUM_CHANNEL = "sum_channel" -class LossReduction(Enum): +class LossReduction(StrEnum): """ See also: - :py:class:`monai.losses.dice.DiceLoss` @@ -171,7 +194,7 @@ class LossReduction(Enum): SUM = "sum" -class DiceCEReduction(Enum): +class DiceCEReduction(StrEnum): """ See also: - :py:class:`monai.losses.dice.DiceCELoss` @@ -181,7 +204,7 @@ class DiceCEReduction(Enum): SUM = "sum" -class Weight(Enum): +class Weight(StrEnum): """ See also: :py:class:`monai.losses.dice.GeneralizedDiceLoss` """ @@ -191,7 +214,7 @@ class Weight(Enum): UNIFORM = "uniform" -class ChannelMatching(Enum): +class ChannelMatching(StrEnum): """ See also: :py:class:`monai.networks.nets.HighResBlock` """ @@ -200,7 +223,7 @@ class ChannelMatching(Enum): PROJECT = "project" -class SkipMode(Enum): +class SkipMode(StrEnum): """ See also: :py:class:`monai.networks.layers.SkipConnection` """ @@ -210,7 +233,7 @@ class SkipMode(Enum): MUL = "mul" -class Method(Enum): +class Method(StrEnum): """ See also: :py:class:`monai.transforms.croppad.array.SpatialPad` """ @@ -219,7 +242,7 @@ class Method(Enum): END = "end" -class ForwardMode(Enum): +class ForwardMode(StrEnum): """ See also: :py:class:`monai.transforms.engines.evaluator.Evaluator` """ @@ -228,7 +251,7 @@ class ForwardMode(Enum): EVAL = "eval" -class TraceKeys: +class TraceKeys(StrEnum): """Extra metadata keys used for traceable transforms.""" CLASS_NAME: str = "class" @@ -259,7 +282,7 @@ class InverseKeys: NONE = "none" -class CommonKeys: +class CommonKeys(StrEnum): """ A set of common keys for dictionary based supervised training process. `IMAGE` is the input image data. @@ -277,7 +300,7 @@ class CommonKeys: METADATA = "metadata" -class PostFix: +class PostFix(StrEnum): """Post-fixes.""" @staticmethod @@ -297,7 +320,7 @@ def transforms(key: Optional[str] = None): return PostFix._get_str(key, TraceKeys.KEY_SUFFIX[1:]) -class TransformBackends(Enum): +class TransformBackends(StrEnum): """ Transform backends. """ @@ -306,7 +329,7 @@ class TransformBackends(Enum): NUMPY = "numpy" -class JITMetadataKeys(Enum): +class JITMetadataKeys(StrEnum): """ Keys stored in the metadata file for saved Torchscript models. Some of these are generated by the routines and others are optionally provided by users. @@ -318,7 +341,7 @@ class JITMetadataKeys(Enum): DESCRIPTION = "description" -class BoxModeName(Enum): +class BoxModeName(StrEnum): """ Box mode names. """ @@ -334,7 +357,7 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] -class ProbMapKeys(str, Enum): +class ProbMapKeys(StrEnum): """ The keys to be used for generating the probability maps from patches """ @@ -345,7 +368,7 @@ class ProbMapKeys(str, Enum): NAME = "name" -class GridPatchSort(str, Enum): +class GridPatchSort(StrEnum): """ The sorting method for the generated patches in `GridPatch` """ @@ -377,7 +400,7 @@ def get_sort_fn(sort_fn): ) -class WSIPatchKeys(str, Enum): +class WSIPatchKeys(StrEnum): """ The keys to be used for metadata of patches extracted from whole slide images """ diff --git a/tests/test_look_up_option.py b/tests/test_look_up_option.py index 89fec1b575..700f7b9691 100644 --- a/tests/test_look_up_option.py +++ b/tests/test_look_up_option.py @@ -14,7 +14,7 @@ from parameterized import parameterized -from monai.utils import look_up_option +from monai.utils import StrEnum, look_up_option class _CaseEnum(Enum): @@ -27,6 +27,11 @@ class _CaseEnum1(Enum): EMPTY = "empty" +class _CaseStrEnum(StrEnum): + MODE_A = "A" + MODE_B = "B" + + TEST_CASES = ( ("test", ("test", "test1"), "test"), ("test1", {"test1", "test"}, "test1"), @@ -46,6 +51,14 @@ def test_default(self): output = look_up_option("not here", {"a", "b"}, default=None) self.assertEqual(output, None) + def test_str_enum(self): + output = look_up_option("C", {"A", "B"}, default=None) + self.assertEqual(output, None) + self.assertEqual(list(_CaseStrEnum), ["A", "B"]) + self.assertEqual(_CaseStrEnum.MODE_A, "A") + self.assertEqual(str(_CaseStrEnum.MODE_A), "A") + self.assertEqual(look_up_option("A", _CaseStrEnum), "A") + def test_no_found(self): with self.assertRaisesRegex(ValueError, "Unsupported"): look_up_option("not here", {"a", "b"}) diff --git a/tests/test_rand_zoom.py b/tests/test_rand_zoom.py index 35472024ef..55b167d272 100644 --- a/tests/test_rand_zoom.py +++ b/tests/test_rand_zoom.py @@ -16,7 +16,7 @@ from scipy.ndimage import zoom as zoom_scipy from monai.transforms import RandZoom -from monai.utils import GridSampleMode, InterpolateMode +from monai.utils import InterpolateMode from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose VALID_CASES = [(0.8, 1.2, "nearest", False), (0.8, 1.2, InterpolateMode.NEAREST, False)] @@ -49,11 +49,7 @@ def test_keep_size(self): self.assertTrue(np.array_equal(zoomed.shape, self.imt.shape[1:])) @parameterized.expand( - [ - ("no_min_zoom", None, 1.1, "bilinear", TypeError), - ("invalid_mode", 0.9, 1.1, "s", ValueError), - ("invalid_mode", 0.9, 1.1, GridSampleMode.NEAREST, ValueError), - ] + [("no_min_zoom", None, 1.1, "bilinear", TypeError), ("invalid_mode", 0.9, 1.1, "s", ValueError)] ) def test_invalid_inputs(self, _, min_zoom, max_zoom, mode, raises): for p in TEST_NDARRAYS: From 669bddf581201f994d1bcc0cb780854901605d9b Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 22 Jun 2022 15:06:40 +0800 Subject: [PATCH 159/183] Move metatensor support into dev (update inverse, runtests.sh) (#4557) * [DLMED] update inverse for MetaTensor Signed-off-by: Nic Ma --- monai/transforms/inverse.py | 218 +++++++++++++++++++++++++--------- monai/transforms/transform.py | 4 +- runtests.sh | 47 ++++---- 3 files changed, 186 insertions(+), 83 deletions(-) diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index bdaa2f9b40..41a02989db 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -8,8 +8,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + import os -from typing import Hashable, Mapping, Optional, Tuple +import warnings +from contextlib import contextmanager +from typing import Any, Hashable, Mapping, Optional, Tuple import torch @@ -22,18 +25,31 @@ class TraceableTransform(Transform): """ - Maintains a stack of applied transforms. The stack is inserted as pairs of - `trace_key: list of transforms` to each data dictionary. + Maintains a stack of applied transforms to data. + + Data can be one of two types: + 1. A `MetaTensor` (this is the preferred data type). + 2. A dictionary of data containing arrays/tensors and auxiliary metadata. In + this case, a key must be supplied (this dictionary-based approach is deprecated). + + If `data` is of type `MetaTensor`, then the applied transform will be added to ``data.applied_operations``. + + If `data` is a dictionary, then one of two things can happen: + 1. If data[key] is a `MetaTensor`, the applied transform will be added to ``data[key].applied_operations``. + 2. Else, the applied transform will be appended to an adjacent list using + `trace_key`. If, for example, the key is `image`, then the transform + will be appended to `image_transforms` (this dictionary-based approach is deprecated). + + Hopefully it is clear that there are three total possibilities: + 1. data is `MetaTensor` + 2. data is dictionary, data[key] is `MetaTensor` + 3. data is dictionary, data[key] is not `MetaTensor` (this is a deprecated approach). The ``__call__`` method of this transform class must be implemented so - that the transformation information for each key is stored when - ``__call__`` is called. If the transforms were applied to keys "image" and - "label", there will be two extra keys in the dictionary: "image_transforms" - and "label_transforms" (based on `TraceKeys.KEY_SUFFIX`). Each list - contains a list of the transforms applied to that key. + that the transformation information is stored during the data transformation. - The information in ``data[key_transform]`` will be compatible with the - default collate since it only stores strings, numbers and arrays. + The information in the stack of applied transforms must be compatible with the + default collate, by only storing strings, numbers and arrays. `tracing` could be enabled by `self.set_tracing` or setting `MONAI_TRACE_TRANSFORM` when initializing the class. @@ -49,44 +65,152 @@ def set_tracing(self, tracing: bool) -> None: def trace_key(key: Hashable = None): """The key to store the stack of applied transforms.""" if key is None: - return TraceKeys.KEY_SUFFIX - return str(key) + TraceKeys.KEY_SUFFIX + return f"{TraceKeys.KEY_SUFFIX}" + return f"{key}{TraceKeys.KEY_SUFFIX}" - def push_transform( - self, data: Mapping, key: Hashable = None, extra_info: Optional[dict] = None, orig_size: Optional[Tuple] = None - ) -> None: - """Push to a stack of applied transforms for that key.""" + def get_transform_info( + self, data, key: Hashable = None, extra_info: Optional[dict] = None, orig_size: Optional[Tuple] = None + ) -> dict: + """ + Return a dictionary with the relevant information pertaining to an applied transform. - if not self.tracing: - return + Args: + data: input data. Can be dictionary or MetaTensor. We can use `shape` to + determine the original size of the object (unless that has been given + explicitly, see `orig_size`). + key: if data is a dictionary, data[key] will be modified. + extra_info: if desired, any extra information pertaining to the applied + transform can be stored in this dictionary. These are often needed for + computing the inverse transformation. + orig_size: sometimes during the inverse it is useful to know what the size + of the original image was, in which case it can be supplied here. + + Returns: + Dictionary of data pertaining to the applied transformation. + """ info = {TraceKeys.CLASS_NAME: self.__class__.__name__, TraceKeys.ID: id(self)} if orig_size is not None: info[TraceKeys.ORIG_SIZE] = orig_size - elif key in data and hasattr(data[key], "shape"): + elif isinstance(data, Mapping) and key in data and hasattr(data[key], "shape"): info[TraceKeys.ORIG_SIZE] = data[key].shape[1:] + elif hasattr(data, "shape"): + info[TraceKeys.ORIG_SIZE] = data.shape[1:] if extra_info is not None: info[TraceKeys.EXTRA_INFO] = extra_info # If class is randomizable transform, store whether the transform was actually performed (based on `prob`) if hasattr(self, "_do_transform"): # RandomizableTransform info[TraceKeys.DO_TRANSFORM] = self._do_transform # type: ignore + return info - if key in data and isinstance(data[key], MetaTensor): - data[key].push_applied_operation(info) - else: - # If this is the first, create list - if self.trace_key(key) not in data: - if not isinstance(data, dict): - data = dict(data) - data[self.trace_key(key)] = [] - data[self.trace_key(key)].append(info) - - def pop_transform(self, data: Mapping, key: Hashable = None): - """Remove the most recent applied transform.""" + def push_transform( + self, data, key: Hashable = None, extra_info: Optional[dict] = None, orig_size: Optional[Tuple] = None + ) -> None: + """ + Push to a stack of applied transforms. + + Args: + data: dictionary of data or `MetaTensor`. + key: if data is a dictionary, data[key] will be modified. + extra_info: if desired, any extra information pertaining to the applied + transform can be stored in this dictionary. These are often needed for + computing the inverse transformation. + orig_size: sometimes during the inverse it is useful to know what the size + of the original image was, in which case it can be supplied here. + + Returns: + None, but data has been updated to store the applied transformation. + """ if not self.tracing: return - if key in data and isinstance(data[key], MetaTensor): - return data[key].pop_applied_operation() - return data.get(self.trace_key(key), []).pop() + info = self.get_transform_info(data, key, extra_info, orig_size) + + if isinstance(data, MetaTensor): + data.push_applied_operation(info) + elif isinstance(data, Mapping): + if key in data and isinstance(data[key], MetaTensor): + data[key].push_applied_operation(info) + else: + # If this is the first, create list + if self.trace_key(key) not in data: + if not isinstance(data, dict): + data = dict(data) + data[self.trace_key(key)] = [] + data[self.trace_key(key)].append(info) + else: + warnings.warn(f"`data` should be either `MetaTensor` or dictionary, got {type(data)}. {info} not tracked.") + + def check_transforms_match(self, transform: Mapping) -> None: + """Check transforms are of same instance.""" + xform_id = transform.get(TraceKeys.ID, "") + if xform_id == id(self): + return + # TraceKeys.NONE to skip the id check + if xform_id == TraceKeys.NONE: + return + xform_name = transform.get(TraceKeys.CLASS_NAME, "") + # basic check if multiprocessing uses 'spawn' (objects get recreated so don't have same ID) + if torch.multiprocessing.get_start_method() in ("spawn", None) and xform_name == self.__class__.__name__: + return + raise RuntimeError( + f"Error {self.__class__.__name__} getting the most recently " + f"applied invertible transform {xform_name} {xform_id} != {id(self)}." + ) + + def get_most_recent_transform(self, data, key: Hashable = None, check: bool = True, pop: bool = False): + """ + Get most recent transform for the stack. + + Args: + data: dictionary of data or `MetaTensor`. + key: if data is a dictionary, data[key] will be modified. + check: if true, check that `self` is the same type as the most recently-applied transform. + pop: if true, remove the transform as it is returned. + + Returns: + Dictionary of most recently applied transform + + Raises: + - RuntimeError: data is neither `MetaTensor` nor dictionary + """ + if not self.tracing: + raise RuntimeError("Transform Tracing must be enabled to get the most recent transform.") + if isinstance(data, MetaTensor): + all_transforms = data.applied_operations + elif isinstance(data, Mapping): + if key in data and isinstance(data[key], MetaTensor): + all_transforms = data[key].applied_operations + else: + all_transforms = data.get(self.trace_key(key), MetaTensor.get_default_applied_operations()) + else: + raise ValueError(f"`data` should be either `MetaTensor` or dictionary, got {type(data)}.") + if check: + self.check_transforms_match(all_transforms[-1]) + return all_transforms.pop() if pop else all_transforms[-1] + + def pop_transform(self, data, key: Hashable = None, check: bool = True): + """ + Return and pop the most recent transform. + + Args: + data: dictionary of data or `MetaTensor` + key: if data is a dictionary, data[key] will be modified + check: if true, check that `self` is the same type as the most recently-applied transform. + + Returns: + Dictionary of most recently applied transform + + Raises: + - RuntimeError: data is neither `MetaTensor` nor dictionary + """ + return self.get_most_recent_transform(data, key, check, pop=True) + + @contextmanager + def trace_transform(self, to_trace: bool): + """Temporarily set the tracing status of a transform with a context manager.""" + prev = self.tracing + self.tracing = to_trace + yield + self.tracing = prev class InvertibleTransform(TraceableTransform): @@ -103,7 +227,7 @@ class InvertibleTransform(TraceableTransform): different parameters being passed to each label (e.g., different interpolation for image and label). - - the inverse transforms are applied in a last- in-first-out order. As + - the inverse transforms are applied in a last-in-first-out order. As the inverse is applied, its entry is removed from the list detailing the applied transformations. That is to say that during the forward pass, the list of applied transforms grows, and then during the @@ -126,29 +250,7 @@ class InvertibleTransform(TraceableTransform): """ - def check_transforms_match(self, transform: Mapping) -> None: - """Check transforms are of same instance.""" - xform_name = transform.get(TraceKeys.CLASS_NAME, "") - xform_id = transform.get(TraceKeys.ID, "") - if xform_id == id(self): - return - # basic check if multiprocessing uses 'spawn' (objects get recreated so don't have same ID) - if torch.multiprocessing.get_start_method() in ("spawn", None) and xform_name == self.__class__.__name__: - return - raise RuntimeError(f"Error inverting the most recently applied invertible transform {xform_name} {xform_id}.") - - def get_most_recent_transform(self, data: Mapping, key: Hashable = None): - """Get most recent transform.""" - if not self.tracing: - raise RuntimeError("Transform Tracing must be enabled to get the most recent transform.") - if isinstance(data[key], MetaTensor): - transform = data[key].applied_operations[-1] - else: - transform = data[self.trace_key(key)][-1] - self.check_transforms_match(transform) - return transform - - def inverse(self, data: dict) -> dict: + def inverse(self, data: Any) -> Any: """ Inverse of ``__call__``. diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 65bb13e6b8..5819d2971d 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -14,7 +14,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, Generator, Hashable, Iterable, List, Optional, Tuple, TypeVar, Union +from typing import Any, Callable, Dict, Generator, Hashable, Iterable, List, Mapping, Optional, Tuple, TypeVar, Union import numpy as np import torch @@ -348,7 +348,7 @@ def __call__(self, data): """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") - def key_iterator(self, data: Dict[Hashable, Any], *extra_iterables: Optional[Iterable]) -> Generator: + def key_iterator(self, data: Mapping[Hashable, Any], *extra_iterables: Optional[Iterable]) -> Generator: """ Iterate across keys and optionally extra iterables. If key is missing, exception is raised if `allow_missing_keys==False` (default). If `allow_missing_keys==True`, key is skipped. diff --git a/runtests.sh b/runtests.sh index a632e2664f..9e6ef3d0e1 100755 --- a/runtests.sh +++ b/runtests.sh @@ -403,6 +403,30 @@ then fi +if [ $doPrecommit = true ] +then + set +e # disable exit on failure so that diagnostics can be given on failure + echo "${separator}${blue}pre-commit${noColor}" + + # ensure that the necessary packages for code format testing are installed + if ! is_pip_installed pre_commit + then + install_deps + fi + ${cmdPrefix}${PY_EXE} -m pre_commit run --all-files + + pre_commit_status=$? + if [ ${pre_commit_status} -ne 0 ] + then + print_style_fail_msg + exit ${pre_commit_status} + else + echo "${green}passed!${noColor}" + fi + set -e # enable exit on failure +fi + + if [ $doIsortFormat = true ] then set +e # disable exit on failure so that diagnostics can be given on failure @@ -500,29 +524,6 @@ then set -e # enable exit on failure fi -if [ $doPrecommit = true ] -then - set +e # disable exit on failure so that diagnostics can be given on failure - echo "${separator}${blue}pre-commit${noColor}" - - # ensure that the necessary packages for code format testing are installed - if ! is_pip_installed pre_commit - then - install_deps - fi - ${cmdPrefix}${PY_EXE} -m pre_commit run --all-files - - pre_commit_status=$? - if [ ${pre_commit_status} -ne 0 ] - then - print_style_fail_msg - exit ${pre_commit_status} - else - echo "${green}passed!${noColor}" - fi - set -e # enable exit on failure -fi - if [ $doPylintFormat = true ] then set +e # disable exit on failure so that diagnostics can be given on failure From d6b45933cbcfdaba54c8f985b059bc1d20765ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BD=97=E5=B4=9A=E9=AA=81=28LUO=20Lingxiao=29?= Date: Thu, 23 Jun 2022 18:50:54 +0800 Subject: [PATCH 160/183] Wrap `Type` to the typing of `writer` parameter of `SaveImage` (#4561) * wrap `Type` to the typing of `writer` parameter of `SaveImage` Signed-off-by: function2 --- monai/transforms/io/array.py | 4 ++-- monai/transforms/io/dictionary.py | 4 ++-- monai/transforms/utils_pytorch_numpy_unification.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 87f9c6676d..2678520ce1 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -20,7 +20,7 @@ import warnings from pathlib import Path from pydoc import locate -from typing import Dict, List, Optional, Sequence, Union +from typing import Dict, List, Optional, Sequence, Type, Union import numpy as np import torch @@ -330,7 +330,7 @@ def __init__( separate_folder: bool = True, print_log: bool = True, output_format: str = "", - writer: Union[image_writer.ImageWriter, str, None] = None, + writer: Union[Type[image_writer.ImageWriter], str, None] = None, channel_dim: Optional[int] = 0, ) -> None: self.folder_layout = FolderLayout( diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 46dcda469e..d0e2726df0 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -16,7 +16,7 @@ """ from pathlib import Path -from typing import Optional, Union +from typing import Optional, Type, Union import numpy as np @@ -237,7 +237,7 @@ def __init__( separate_folder: bool = True, print_log: bool = True, output_format: str = "", - writer: Union[image_writer.ImageWriter, str, None] = None, + writer: Union[Type[image_writer.ImageWriter], str, None] = None, ) -> None: super().__init__(keys, allow_missing_keys) self.meta_keys = ensure_tuple_rep(meta_keys, len(self.keys)) diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 2718018a10..2dd224b023 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -276,7 +276,7 @@ def cumsum(a: NdarrayOrTensor, axis=None, **kwargs) -> NdarrayOrTensor: """ if isinstance(a, np.ndarray): - return np.cumsum(a, axis) + return np.cumsum(a, axis) # type: ignore if axis is None: return torch.cumsum(a[:], 0, **kwargs) return torch.cumsum(a, dim=axis, **kwargs) From ca165d8ae3dcc029d211ba73bc70e528e665eb1f Mon Sep 17 00:00:00 2001 From: Bryn Lloyd Date: Thu, 23 Jun 2022 15:49:20 +0200 Subject: [PATCH 161/183] add TORCH backend for RandHistogramShift (#4567) * add TORCH backend for RandHistogramShift Signed-off-by: Bryn Lloyd --- monai/transforms/intensity/array.py | 38 ++++++++++++++++++++--------- tests/test_rand_histogram_shift.py | 19 +++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index ebb248387f..e659c7ebc0 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -1353,7 +1353,7 @@ class RandHistogramShift(RandomizableTransform): prob: probability of histogram shift. """ - backend = [TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__(self, num_control_points: Union[Tuple[int, int], int] = 10, prob: float = 0.1) -> None: RandomizableTransform.__init__(self, prob) @@ -1368,8 +1368,25 @@ def __init__(self, num_control_points: Union[Tuple[int, int], int] = 10, prob: f if min(num_control_points) <= 2: raise ValueError("num_control_points should be greater than or equal to 3") self.num_control_points = (min(num_control_points), max(num_control_points)) - self.reference_control_points: np.ndarray - self.floating_control_points: np.ndarray + self.reference_control_points: NdarrayOrTensor + self.floating_control_points: NdarrayOrTensor + + def interp(self, x: NdarrayOrTensor, xp: NdarrayOrTensor, fp: NdarrayOrTensor) -> NdarrayOrTensor: + ns = torch if isinstance(x, torch.Tensor) else np + if isinstance(x, np.ndarray): + # approx 2x faster than code below for ndarray + return np.interp(x, xp, fp) + + m = (fp[1:] - fp[:-1]) / (xp[1:] - xp[:-1]) + b = fp[:-1] - (m * xp[:-1]) + + indices = ns.searchsorted(xp.reshape(-1), x.reshape(-1)) - 1 + indices = ns.clip(indices, 0, len(m) - 1) + + f = (m[indices] * x.reshape(-1) + b[indices]).reshape(x.shape) + f[x < xp[0]] = fp[0] # type: ignore + f[x > xp[-1]] = fp[-1] # type: ignore + return f def randomize(self, data: Optional[Any] = None) -> None: super().randomize(None) @@ -1392,14 +1409,13 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen if self.reference_control_points is None or self.floating_control_points is None: raise RuntimeError("please call the `randomize()` function first.") - img_np, *_ = convert_data_type(img, np.ndarray) - img_min, img_max = img_np.min(), img_np.max() - reference_control_points_scaled = self.reference_control_points * (img_max - img_min) + img_min - floating_control_points_scaled = self.floating_control_points * (img_max - img_min) + img_min - img_np = np.asarray( # type: ignore - np.interp(img_np, reference_control_points_scaled, floating_control_points_scaled), dtype=img_np.dtype - ) - img, *_ = convert_to_dst_type(img_np, dst=img) + + xp, *_ = convert_to_dst_type(self.reference_control_points, dst=img) + yp, *_ = convert_to_dst_type(self.floating_control_points, dst=img) + img_min, img_max = img.min(), img.max() + reference_control_points_scaled = xp * (img_max - img_min) + img_min + floating_control_points_scaled = yp * (img_max - img_min) + img_min + img = self.interp(img, reference_control_points_scaled, floating_control_points_scaled) return img diff --git a/tests/test_rand_histogram_shift.py b/tests/test_rand_histogram_shift.py index c66f7859c6..0682306bb6 100644 --- a/tests/test_rand_histogram_shift.py +++ b/tests/test_rand_histogram_shift.py @@ -12,6 +12,7 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandHistogramShift @@ -50,6 +51,24 @@ def test_rand_histogram_shift(self, input_param, input_data, expected_val): result = g(**input_data) assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4, type_test=False) + def test_interp(self): + tr = RandHistogramShift() + for array_type in (torch.tensor, np.array): + x = array_type([0.0, 4.0, 6.0, 10.0]) + y = array_type([1.0, -1.0, 3.0, 5.0]) + + yi = tr.interp(array_type([0, 2, 4, 8, 10]), x, y) + assert yi.shape == (5,) + assert_allclose(yi, array_type([1.0, 0.0, -1.0, 4.0, 5.0])) + + yi = tr.interp(array_type([-1, 11, 10.001, -0.001]), x, y) + assert yi.shape == (4,) + assert_allclose(yi, array_type([1.0, 5.0, 5.0, 1.0])) + + yi = tr.interp(array_type([[-2, 11], [1, 3], [8, 10]]), x, y) + assert yi.shape == (3, 2) + assert_allclose(yi, array_type([[1.0, 5.0], [0.5, -0.5], [4.0, 5.0]])) + if __name__ == "__main__": unittest.main() From 9d23698750f6cc5def10ce66939b4865909009c0 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 23 Jun 2022 14:08:34 -0400 Subject: [PATCH 162/183] correct code examples in box_utils docstring (#4572) * clean up code examle in box_utils docstring Signed-off-by: Can Zhao * clean up code examle in box_utils docstring Signed-off-by: Can Zhao --- monai/data/box_utils.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 393db74c56..f0f95dbc3e 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -110,7 +110,8 @@ def boxes_to_corners(self, boxes: torch.Tensor) -> Tuple: .. code-block:: python boxes = torch.ones(10,6) - boxmode.boxes_to_corners(boxes) will return a 6-element tuple, each element is a 10x1 tensor + boxmode = BoxMode() + boxmode.boxes_to_corners(boxes) # will return a 6-element tuple, each element is a 10x1 tensor """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -130,7 +131,8 @@ def corners_to_boxes(self, corners: Sequence) -> torch.Tensor: .. code-block:: python corners = (torch.ones(10,1), torch.ones(10,1), torch.ones(10,1), torch.ones(10,1)) - boxmode.corners_to_boxes(corners) will return a 10x4 tensor + boxmode = BoxMode() + boxmode.corners_to_boxes(corners) # will return a 10x4 tensor """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -516,9 +518,9 @@ def convert_box_mode( boxes = torch.ones(10,4) # The following three lines are equivalent # They convert boxes with format [xmin, ymin, xmax, ymax] to [xcenter, ycenter, xsize, ysize]. - box_convert_mode(boxes=boxes, src_mode="xyxy", dst_mode="ccwh") - box_convert_mode(boxes=boxes, src_mode="xyxy", dst_mode=monai.data.box_utils.CenterSizeMode) - box_convert_mode(boxes=boxes, src_mode="xyxy", dst_mode=monai.data.box_utils.CenterSizeMode()) + convert_box_mode(boxes=boxes, src_mode="xyxy", dst_mode="ccwh") + convert_box_mode(boxes=boxes, src_mode="xyxy", dst_mode=monai.data.box_utils.CenterSizeMode) + convert_box_mode(boxes=boxes, src_mode="xyxy", dst_mode=monai.data.box_utils.CenterSizeMode()) """ src_boxmode = get_boxmode(src_mode) dst_boxmode = get_boxmode(dst_mode) @@ -570,8 +572,8 @@ def convert_box_to_standard_mode( boxes = torch.ones(10,6) # The following two lines are equivalent # They convert boxes with format [xmin, xmax, ymin, ymax, zmin, zmax] to [xmin, ymin, zmin, xmax, ymax, zmax] - box_convert_standard_mode(boxes=boxes, mode="xxyyzz") - box_convert_mode(boxes=boxes, src_mode="xxyyzz", dst_mode="xyzxyz") + convert_box_to_standard_mode(boxes=boxes, mode="xxyyzz") + convert_box_mode(boxes=boxes, src_mode="xxyyzz", dst_mode="xyzxyz") """ return convert_box_mode(boxes=boxes, src_mode=mode, dst_mode=StandardMode()) @@ -1034,8 +1036,12 @@ def non_max_suppression( Indexes of ``boxes`` that are kept after NMS. Example: - keep = non_max_suppression(boxes, scores, num_thresh=0.1) - boxes_after_nms = boxes[keep] + .. code-block:: python + + boxes = torch.ones(10,6) + scores = torch.ones(10) + keep = non_max_suppression(boxes, scores, num_thresh=0.1) + boxes_after_nms = boxes[keep] """ # returns empty array if boxes is empty From 9c4710199b80178ad11f7dd74925eee3ae921863 Mon Sep 17 00:00:00 2001 From: mueller-franzes <56117447+mueller-franzes@users.noreply.github.com> Date: Fri, 24 Jun 2022 07:44:36 +0200 Subject: [PATCH 163/183] Fix incorrect documentation for ResampleToMatch (#4576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix documentation for ResampleToMatch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: Gustav Müller-Franzes Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/transforms/spatial/array.py | 39 +++++++++++++++++++++++++- monai/transforms/spatial/dictionary.py | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index f9c5823243..05ee474590 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -297,7 +297,44 @@ def __call__( # type: ignore padding_mode: Union[GridSamplePadMode, str, None] = None, align_corners: Optional[bool] = False, dtype: DtypeLike = None, - ): + ) -> Tuple[NdarrayOrTensor, Dict]: + """ + Args: + img: input image to be resampled to match ``dst_meta``. It currently supports channel-first arrays with + at most three spatial dimensions. + src_meta: Dictionary containing the source affine matrix in the form ``{'affine':src_affine}``. + If ``affine`` is not specified, an identity matrix is assumed. Defaults to ``None``. + See also: https://docs.monai.io/en/stable/transforms.html#spatialresample + dst_meta: Dictionary containing the target affine matrix and target spatial shape in the form + ``{'affine':src_affine, 'spatial_shape':spatial_size}``. If ``affine`` is not + specified, ``src_affine`` is assumed. If ``spatial_shape`` is not specified, spatial size is + automatically computed, containing the previous field of view. Defaults to ``None``. + See also: https://docs.monai.io/en/stable/transforms.html#spatialresample + mode: {``"bilinear"``, ``"nearest"``} + Interpolation mode to calculate output values. Defaults to ``"bilinear"``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html + When `USE_COMPILED` is `True`, this argument uses + ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations. + See also: https://docs.monai.io/en/stable/networks.html#grid-pull + padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``} + Padding mode for outside grid values. Defaults to ``"border"``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html + align_corners: Geometrically, we consider the pixels of the input as squares rather than points. + Defaults to ``False``. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html + dtype: data type for resampling computation. Defaults to ``self.dtype`` or + ``np.float64`` (for best precision). If ``None``, use the data type of input data. + To be compatible with other modules, the output data type is always `float32`. + + Raises: + RuntimeError: When ``src_meta`` is missing. + RuntimeError: When ``dst_meta`` is missing. + ValueError: When the affine matrix of the source image is not invertible. + + Returns: + Resampled input image, Metadata + + """ if src_meta is None: raise RuntimeError("`in_meta` is missing") if dst_meta is None: diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 3599d645e5..6b7843349d 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -412,7 +412,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd dtype=dtype, ) d[key] = img - d[src_meta_key] = new_meta + d[src_meta_key] = new_meta # type: ignore # Remove the applied transform self.pop_transform(d, key) From f9b038a89cdc4e9ef257a40767c3a376f89d370a Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Fri, 24 Jun 2022 08:15:14 -0400 Subject: [PATCH 164/183] correct a bug in convert_mask_to_box (#4583) --- monai/apps/detection/transforms/box_ops.py | 6 +++--- tests/test_box_transform.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index 4f877967b8..1cdcab0a44 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -305,11 +305,11 @@ def convert_mask_to_box( boxes_b.append(min(fd_i)) # top left corner for fd_i in fg_indices: boxes_b.append(max(fd_i) + 1 - TO_REMOVE) # bottom right corner + boxes_list.append(boxes_b) if spatial_dims == 2: - labels_list.append(boxes_mask_np[b, boxes_b[0], boxes_b[1]]) + labels_list.append(boxes_mask_np[b, fg_indices[0][0], fg_indices[1][0]]) if spatial_dims == 3: - labels_list.append(boxes_mask_np[b, boxes_b[0], boxes_b[1], boxes_b[2]]) - boxes_list.append(boxes_b) + labels_list.append(boxes_mask_np[b, fg_indices[0][0], fg_indices[1][0], fg_indices[2][0]]) if len(boxes_list) == 0: boxes_np, labels_np = np.zeros([0, 2 * spatial_dims]), np.zeros([0]) diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index 222d83526e..be2b8a84b9 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -15,6 +15,7 @@ import torch from parameterized import parameterized +from monai.apps.detection.transforms.box_ops import convert_mask_to_box from monai.apps.detection.transforms.dictionary import ( AffineBoxToImageCoordinated, AffineBoxToWorldCoordinated, @@ -64,8 +65,22 @@ [{"boxes": p(boxes), "image": p(image), "labels": p(labels)}, p([[[0, 2], [0, 2]], [[1, 0], [0, 0]]])] ) +TESTS_2D_mask = [] +boxes_mask = [[[-1, 0], [0, -1]]] +for p in TEST_NDARRAYS: + TESTS_2D_mask.append([p(boxes_mask), (p([[0.0, 0.0, 2.0, 2.0]]), p([0]))]) +boxes_mask = [[[-1, 0], [0, -1]], [[-1, 1], [1, -1]]] +for p in TEST_NDARRAYS: + TESTS_2D_mask.append([p(boxes_mask), (p([[0.0, 0.0, 2.0, 2.0], [0.0, 0.0, 2.0, 2.0]]), p([0, 1]))]) + class TestBoxTransform(unittest.TestCase): + @parameterized.expand(TESTS_2D_mask) + def test_value_2d_mask(self, mask, expected_box_label): + box_label = convert_mask_to_box(mask) + assert_allclose(box_label[0], expected_box_label[0], type_test=True, device_test=True, atol=1e-3) + assert_allclose(box_label[1], expected_box_label[1], type_test=True, device_test=True, atol=1e-3) + @parameterized.expand(TESTS_2D) def test_value_2d(self, data, expected_mask): test_dtype = [torch.float32, torch.float16] From 4c5c388e529ee45b417ee5f206041b8d0a864ab6 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 24 Jun 2022 21:24:18 +0800 Subject: [PATCH 165/183] Fix error in the bundle script (#4584) [DLMED] fix error in script Signed-off-by: Nic Ma --- monai/bundle/scripts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 1b5118e0bb..930bd51921 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -595,7 +595,8 @@ def ckpt_export( # here we use ignite Checkpoint to support nested weights and be compatible with MONAI CheckpointSaver Checkpoint.load_objects(to_load={key_in_ckpt_: net}, checkpoint=ckpt_file_) else: - copy_model_state(dst=net, src=ckpt_file_ if key_in_ckpt_ == "" else ckpt_file_[key_in_ckpt_]) + ckpt = torch.load(ckpt_file_) + copy_model_state(dst=net, src=ckpt if key_in_ckpt_ == "" else ckpt[key_in_ckpt_]) # convert to TorchScript model and save with metadata, config content net = convert_to_torchscript(model=net) From 78f8e74fe3fe739a56729622f9f945b4e875ca61 Mon Sep 17 00:00:00 2001 From: Mason Ma <42313377+JohnMasoner@users.noreply.github.com> Date: Sun, 26 Jun 2022 20:42:15 +0800 Subject: [PATCH 166/183] Add Unified Focal Loss (#4488) * Add Unified Focal Loss Signed-off-by: JohnMasoner --- monai/losses/__init__.py | 1 + monai/losses/unified_focal_loss.py | 239 +++++++++++++++++++++++++++++ tests/test_unified_focal_loss.py | 63 ++++++++ 3 files changed, 303 insertions(+) create mode 100644 monai/losses/unified_focal_loss.py create mode 100644 tests/test_unified_focal_loss.py diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py index 85bbe37fb9..c3ae941519 100644 --- a/monai/losses/__init__.py +++ b/monai/losses/__init__.py @@ -32,3 +32,4 @@ from .multi_scale import MultiScaleLoss from .spatial_mask import MaskedLoss from .tversky import TverskyLoss +from .unified_focal_loss import AsymmetricUnifiedFocalLoss diff --git a/monai/losses/unified_focal_loss.py b/monai/losses/unified_focal_loss.py new file mode 100644 index 0000000000..be5d27a276 --- /dev/null +++ b/monai/losses/unified_focal_loss.py @@ -0,0 +1,239 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import warnings +from typing import Union + +import torch +from torch.nn.modules.loss import _Loss + +from monai.networks import one_hot +from monai.utils import LossReduction + + +class AsymmetricFocalTverskyLoss(_Loss): + """ + AsymmetricFocalTverskyLoss is a variant of FocalTverskyLoss, which attentions to the foreground class. + + Actually, it's only supported for binary image segmentation now. + + Reimplementation of the Asymmetric Focal Tversky Loss described in: + + - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", + Michael Yeung, Computerized Medical Imaging and Graphics + """ + + def __init__( + self, + to_onehot_y: bool = False, + delta: float = 0.7, + gamma: float = 0.75, + epsilon: float = 1e-7, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + ) -> None: + """ + Args: + to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. + delta : weight of the background. Defaults to 0.7. + gamma : value of the exponent gamma in the definition of the Focal loss . Defaults to 0.75. + epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. + """ + super().__init__(reduction=LossReduction(reduction).value) + self.to_onehot_y = to_onehot_y + self.delta = delta + self.gamma = gamma + self.epsilon = epsilon + + def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: + n_pred_ch = y_pred.shape[1] + + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + y_true = one_hot(y_true, num_classes=n_pred_ch) + + if y_true.shape != y_pred.shape: + raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") + + # clip the prediction to avoid NaN + y_pred = torch.clamp(y_pred, self.epsilon, 1.0 - self.epsilon) + axis = list(range(2, len(y_pred.shape))) + + # Calculate true positives (tp), false negatives (fn) and false positives (fp) + tp = torch.sum(y_true * y_pred, dim=axis) + fn = torch.sum(y_true * (1 - y_pred), dim=axis) + fp = torch.sum((1 - y_true) * y_pred, dim=axis) + dice_class = (tp + self.epsilon) / (tp + self.delta * fn + (1 - self.delta) * fp + self.epsilon) + + # Calculate losses separately for each class, enhancing both classes + back_dice = 1 - dice_class[:, 0] + fore_dice = (1 - dice_class[:, 1]) * torch.pow(1 - dice_class[:, 1], -self.gamma) + + # Average class scores + loss = torch.mean(torch.stack([back_dice, fore_dice], dim=-1)) + return loss + + +class AsymmetricFocalLoss(_Loss): + """ + AsymmetricFocalLoss is a variant of FocalTverskyLoss, which attentions to the foreground class. + + Actually, it's only supported for binary image segmentation now. + + Reimplementation of the Asymmetric Focal Loss described in: + + - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", + Michael Yeung, Computerized Medical Imaging and Graphics + """ + + def __init__( + self, + to_onehot_y: bool = False, + delta: float = 0.7, + gamma: float = 2, + epsilon: float = 1e-7, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + ): + """ + Args: + to_onehot_y : whether to convert `y` into the one-hot format. Defaults to False. + delta : weight of the background. Defaults to 0.7. + gamma : value of the exponent gamma in the definition of the Focal loss . Defaults to 0.75. + epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. + """ + super().__init__(reduction=LossReduction(reduction).value) + self.to_onehot_y = to_onehot_y + self.delta = delta + self.gamma = gamma + self.epsilon = epsilon + + def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: + n_pred_ch = y_pred.shape[1] + + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + y_true = one_hot(y_true, num_classes=n_pred_ch) + + if y_true.shape != y_pred.shape: + raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") + + y_pred = torch.clamp(y_pred, self.epsilon, 1.0 - self.epsilon) + cross_entropy = -y_true * torch.log(y_pred) + + back_ce = torch.pow(1 - y_pred[:, 0], self.gamma) * cross_entropy[:, 0] + back_ce = (1 - self.delta) * back_ce + + fore_ce = cross_entropy[:, 1] + fore_ce = self.delta * fore_ce + + loss = torch.mean(torch.sum(torch.stack([back_ce, fore_ce], dim=1), dim=1)) + return loss + + +class AsymmetricUnifiedFocalLoss(_Loss): + """ + AsymmetricUnifiedFocalLoss is a variant of Focal Loss. + + Actually, it's only supported for binary image segmentation now + + Reimplementation of the Asymmetric Unified Focal Tversky Loss described in: + + - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", + Michael Yeung, Computerized Medical Imaging and Graphics + """ + + def __init__( + self, + to_onehot_y: bool = False, + num_classes: int = 2, + weight: float = 0.5, + gamma: float = 0.5, + delta: float = 0.7, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + ): + """ + Args: + to_onehot_y : whether to convert `y` into the one-hot format. Defaults to False. + num_classes : number of classes, it only supports 2 now. Defaults to 2. + delta : weight of the background. Defaults to 0.7. + gamma : value of the exponent gamma in the definition of the Focal loss. Defaults to 0.75. + epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. + weight : weight for each loss function, if it's none it's 0.5. Defaults to None. + + Example: + >>> import torch + >>> from monai.losses import AsymmetricUnifiedFocalLoss + >>> pred = torch.ones((1,1,32,32), dtype=torch.float32) + >>> grnd = torch.ones((1,1,32,32), dtype=torch.int64) + >>> fl = AsymmetricUnifiedFocalLoss(to_onehot_y=True) + >>> fl(pred, grnd) + """ + super().__init__(reduction=LossReduction(reduction).value) + self.to_onehot_y = to_onehot_y + self.num_classes = num_classes + self.gamma = gamma + self.delta = delta + self.weight: float = weight + self.asy_focal_loss = AsymmetricFocalLoss(gamma=self.gamma, delta=self.delta) + self.asy_focal_tversky_loss = AsymmetricFocalTverskyLoss(gamma=self.gamma, delta=self.delta) + + # TODO: Implement this function to support multiple classes segmentation + def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: + """ + Args: + y_pred : the shape should be BNH[WD], where N is the number of classes. + It only supports binary segmentation. + The input should be the original logits since it will be transformed by + a sigmoid in the forward function. + y_true : the shape should be BNH[WD], where N is the number of classes. + It only supports binary segmentation. + + Raises: + ValueError: When input and target are different shape + ValueError: When len(y_pred.shape) != 4 and len(y_pred.shape) != 5 + ValueError: When num_classes + ValueError: When the number of classes entered does not match the expected number + """ + if y_pred.shape != y_true.shape: + raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") + + if len(y_pred.shape) != 4 and len(y_pred.shape) != 5: + raise ValueError(f"input shape must be 4 or 5, but got {y_pred.shape}") + + if y_pred.shape[1] == 1: + y_pred = one_hot(y_pred, num_classes=self.num_classes) + y_true = one_hot(y_true, num_classes=self.num_classes) + + if torch.max(y_true) != self.num_classes - 1: + raise ValueError(f"Pelase make sure the number of classes is {self.num_classes-1}") + + n_pred_ch = y_pred.shape[1] + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + y_true = one_hot(y_true, num_classes=n_pred_ch) + + asy_focal_loss = self.asy_focal_loss(y_pred, y_true) + asy_focal_tversky_loss = self.asy_focal_tversky_loss(y_pred, y_true) + + loss: torch.Tensor = self.weight * asy_focal_loss + (1 - self.weight) * asy_focal_tversky_loss + + if self.reduction == LossReduction.SUM.value: + return torch.sum(loss) # sum over the batch and channel dims + if self.reduction == LossReduction.NONE.value: + return loss # returns [N, num_classes] losses + if self.reduction == LossReduction.MEAN.value: + return torch.mean(loss) + raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].') diff --git a/tests/test_unified_focal_loss.py b/tests/test_unified_focal_loss.py new file mode 100644 index 0000000000..5819edea85 --- /dev/null +++ b/tests/test_unified_focal_loss.py @@ -0,0 +1,63 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.losses import AsymmetricUnifiedFocalLoss + +TEST_CASES = [ + [ # shape: (2, 1, 2, 2), (2, 1, 2, 2) + { + "y_pred": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + "y_true": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + }, + 0.0, + ], + [ # shape: (2, 1, 2, 2), (2, 1, 2, 2) + { + "y_pred": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + "y_true": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + }, + 0.0, + ], +] + + +class TestAsymmetricUnifiedFocalLoss(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_result(self, input_data, expected_val): + loss = AsymmetricUnifiedFocalLoss() + result = loss(**input_data) + np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) + + def test_ill_shape(self): + loss = AsymmetricUnifiedFocalLoss() + with self.assertRaisesRegex(ValueError, ""): + loss(torch.ones((2, 2, 2)), torch.ones((2, 2, 2, 2))) + + def test_with_cuda(self): + loss = AsymmetricUnifiedFocalLoss() + i = torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]) + j = torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]) + if torch.cuda.is_available(): + i = i.cuda() + j = j.cuda() + output = loss(i, j) + print(output) + np.testing.assert_allclose(output.detach().cpu().numpy(), 0.0, atol=1e-4, rtol=1e-4) + + +if __name__ == "__main__": + unittest.main() From ff9ff55dd9198dabcc31eebe3109332ea708b356 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Mon, 27 Jun 2022 05:03:50 +0800 Subject: [PATCH 167/183] 4502-implement-pydicomreader (#4550) * implement pydicomreader Signed-off-by: Yiheng Wang * [MONAI] code formatting Signed-off-by: monai-bot * modify read function to return obj Signed-off-by: Yiheng Wang * enhance docstrings and fix errors Signed-off-by: Yiheng Wang * add unittest Signed-off-by: Yiheng Wang * adds deps Signed-off-by: Wenqi Li * optional meta Signed-off-by: Wenqi Li * adds consistency Signed-off-by: Wenqi Li Co-authored-by: monai-bot Co-authored-by: Wenqi Li Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- docs/requirements.txt | 1 + docs/source/installation.md | 4 +- environment-dev.yml | 1 + monai/data/__init__.py | 2 +- monai/data/image_reader.py | 335 ++++++++++++++++++++++++++++++++++- monai/transforms/io/array.py | 13 +- requirements-dev.txt | 1 + setup.cfg | 3 + tests/test_init_reader.py | 8 +- tests/test_load_image.py | 41 ++++- 10 files changed, 397 insertions(+), 12 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index b7edff27fa..50c3f07b5d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -29,3 +29,4 @@ pyyaml fire jsonschema pynrrd +pydicom diff --git a/docs/source/installation.md b/docs/source/installation.md index 393205cd1b..8623faee21 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -190,9 +190,9 @@ Since MONAI v0.2.0, the extras syntax such as `pip install 'monai[nibabel]'` is - The options are ``` -[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema, pynrrd] +[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema, pynrrd, pydicom] ``` which correspond to `nibabel`, `scikit-image`, `pillow`, `tensorboard`, -`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, `pynrrd`, respectively. +`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, `pynrrd`, `pydicom`, respectively. - `pip install 'monai[all]'` installs all the optional dependencies. diff --git a/environment-dev.yml b/environment-dev.yml index dc9a0970cd..e71f434dc4 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -46,6 +46,7 @@ dependencies: - fire - jsonschema - pynrrd + - pydicom - pip - pip: # pip for itk as conda-forge version only up to v5.1 diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 7502de5225..b7a160b3de 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -47,7 +47,7 @@ from .folder_layout import FolderLayout from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter, PatchIterd from .image_dataset import ImageDataset -from .image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader +from .image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader from .image_writer import ( SUPPORTED_WRITERS, ImageWriter, diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 13872d5c61..bccd689a37 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -9,6 +9,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import glob +import os import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -19,7 +21,12 @@ from torch.utils.data._utils.collate import np_str_obj_array_pattern from monai.config import DtypeLike, KeysCollection, PathLike -from monai.data.utils import correct_nifti_header_if_necessary, is_supported_format, orientation_ras_lps +from monai.data.utils import ( + affine_to_spacing, + correct_nifti_header_if_necessary, + is_supported_format, + orientation_ras_lps, +) from monai.transforms.utility.array import EnsureChannelFirst from monai.utils import ensure_tuple, ensure_tuple_rep, optional_import, require_pkg @@ -27,22 +34,33 @@ import itk import nibabel as nib import nrrd + import pydicom from nibabel.nifti1 import Nifti1Image from PIL import Image as PILImage - has_nrrd = has_itk = has_nib = has_pil = True + has_nrrd = has_itk = has_nib = has_pil = has_pydicom = True else: itk, has_itk = optional_import("itk", allow_namespace_pkg=True) nib, has_nib = optional_import("nibabel") Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image") PILImage, has_pil = optional_import("PIL.Image") + pydicom, has_pydicom = optional_import("pydicom") nrrd, has_nrrd = optional_import("nrrd", allow_namespace_pkg=True) OpenSlide, _ = optional_import("openslide", name="OpenSlide") CuImage, _ = optional_import("cucim", name="CuImage") TiffFile, _ = optional_import("tifffile", name="TiffFile") -__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader", "NrrdReader"] +__all__ = [ + "ImageReader", + "ITKReader", + "NibabelReader", + "NumpyReader", + "PILReader", + "PydicomReader", + "WSIReader", + "NrrdReader", +] class ImageReader(ABC): @@ -354,6 +372,317 @@ def _get_array_data(self, img): return np_img if self.reverse_indexing else np.moveaxis(np_img.T, 0, -1) +@require_pkg(pkg_name="pydicom") +class PydicomReader(ImageReader): + """ + Load medical images based on Pydicom library. + All the supported image formats can be found at: + https://dicom.nema.org/medical/dicom/current/output/chtml/part10/chapter_7.html + + This class refers to: + https://nipy.org/nibabel/dicom/dicom_orientation.html#dicom-affine-formula + https://github.com/pydicom/contrib-pydicom/blob/master/input-output/pydicom_series.py + + Args: + channel_dim: the channel dimension of the input image, default is None. + This is used to set original_channel_dim in the metadata, EnsureChannelFirstD reads this field. + If None, `original_channel_dim` will be either `no_channel` or `-1`. + affine_lps_to_ras: whether to convert the affine matrix from "LPS" to "RAS". Defaults to ``True``. + Set to ``True`` to be consistent with ``NibabelReader``, + otherwise the affine matrix remains in the Dicom convention. + swap_ij: whether to swap the first two spatial axes. Default to ``True``, so that the outputs + are consistent with the other readers. + kwargs: additional args for `pydicom.dcmread` API. more details about available args: + https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.filereader.dcmread.html#pydicom.filereader.dcmread + If the `get_data` function will be called + (for example, when using this reader with `monai.transforms.LoadImage`), please ensure that the argument + `stop_before_pixels` is `True`, and `specific_tags` covers all necessary tags, such as `PixelSpacing`, + `ImagePositionPatient`, `ImageOrientationPatient` and all `pixel_array` related tags. + + Note:: + + the current + + """ + + def __init__( + self, channel_dim: Optional[int] = None, affine_lps_to_ras: bool = True, swap_ij: bool = True, **kwargs + ): + super().__init__() + self.kwargs = kwargs + self.channel_dim = channel_dim + self.affine_lps_to_ras = affine_lps_to_ras + self.swap_ij = swap_ij + + def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: + """ + Verify whether the specified file or files format is supported by Pydicom reader. + + Args: + filename: file name or a list of file names to read. + if a list of files, verify all the suffixes. + + """ + return has_pydicom + + def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs): + """ + Read image data from specified file or files, it can read a list of images + and stack them together as multi-channel data in `get_data()`. + If passing directory path instead of file path, will treat it as DICOM images series and read. + + Args: + data: file name or a list of file names to read, + kwargs: additional args for `pydicom.dcmread` API, will override `self.kwargs` for existing keys. + + Returns: + If `data` represents a filename: return a pydicom dataset object. + If `data` represents a list of filenames or a directory: return a list of pydicom dataset object. + If `data` represents a list of directories: return a list of list of pydicom dataset object. + + """ + img_ = [] + + filenames: Sequence[PathLike] = ensure_tuple(data) + kwargs_ = self.kwargs.copy() + kwargs_.update(kwargs) + + self.has_series = False + + for name in filenames: + name = f"{name}" + if Path(name).is_dir(): + # read DICOM series + slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in glob.glob(os.path.join(name, "**"))] + img_.append(slices if len(slices) > 1 else slices[0]) + self.has_series = True + else: + ds = pydicom.dcmread(fp=name, **kwargs_) + img_.append(ds) + return img_ if len(filenames) > 1 else img_[0] + + def _combine_dicom_series(self, data): + """ + Combine dicom series (a list of pydicom dataset objects). Their data arrays will be stacked together at a new + dimension as the last dimension. + + The stack order depends on Instance Number. The metadata will be based on the + first slice's metadata, and some new items will be added: + + "spacing": the new spacing of the stacked slices. + "lastImagePositionPatient": `ImagePositionPatient` for the last slice, it will be used to achieve the affine + matrix. + "spatial_shape": the spatial shape of the stacked slices. + + Args: + data: a list of pydicom dataset objects. + Returns: + a tuple that consisted with data array and metadata. + """ + slices = [] + # for a dicom series + for slc_ds in data: + if hasattr(slc_ds, "InstanceNumber"): + slices.append(slc_ds) + else: + warnings.warn(f"slice: {slc_ds.filename} does not have InstanceNumber tag, skip it.") + slices = sorted(slices, key=lambda s: s.InstanceNumber) + + if len(slices) == 0: + raise ValueError("the input does not have valid slices.") + + first_slice = slices[0] + average_distance = 0.0 + first_array = self._get_array_data(first_slice) + shape = first_array.shape + spacing = getattr(first_slice, "PixelSpacing", (1.0, 1.0, 1.0)) + pos = getattr(first_slice, "ImagePositionPatient", (0.0, 0.0, 0.0))[2] + stack_array = [first_array] + for idx in range(1, len(slices)): + slc_array = self._get_array_data(slices[idx]) + slc_shape = slc_array.shape + slc_spacing = getattr(first_slice, "PixelSpacing", (1.0, 1.0, 1.0)) + slc_pos = getattr(first_slice, "ImagePositionPatient", (0.0, 0.0, float(idx)))[2] + if spacing != slc_spacing: + warnings.warn(f"the list contains slices that have different spacings {spacing} and {slc_spacing}.") + if shape != slc_shape: + warnings.warn(f"the list contains slices that have different shapes {shape} and {slc_shape}.") + average_distance += abs(pos - slc_pos) + pos = slc_pos + stack_array.append(slc_array) + + if len(slices) > 1: + average_distance /= len(slices) - 1 + spacing.append(average_distance) + stack_array = np.stack(stack_array, axis=-1) + stack_metadata = self._get_meta_dict(first_slice) + stack_metadata["spacing"] = np.asarray(spacing) + if hasattr(slices[-1], "ImagePositionPatient"): + stack_metadata["lastImagePositionPatient"] = np.asarray(slices[-1].ImagePositionPatient) + stack_metadata["spatial_shape"] = shape + (len(slices),) + else: + stack_array = stack_array[0] + stack_metadata = self._get_meta_dict(first_slice) + stack_metadata["spacing"] = np.asarray(spacing) + stack_metadata["spatial_shape"] = shape + + return stack_array, stack_metadata + + def get_data(self, data): + """ + Extract data array and metadata from loaded image and return them. + This function returns two objects, first is numpy array of image data, second is dict of metadata. + It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. + For dicom series within the input, all slices will be stacked first, + When loading a list of files (dicom file, or stacked dicom series), they are stacked together at a new + dimension as the first dimension, and the metadata of the first image is used to represent the output metadata. + + To use this function, all pydicom dataset objects should contain: `pixel_array`, `PixelSpacing`, + `ImagePositionPatient` and `ImageOrientationPatient`. + + Args: + data: a pydicom dataset object, or a list of pydicom dataset objects, or a list of list of + pydicom dataset objects. + + """ + + dicom_data = [] + # combine dicom series if exists + if self.has_series is True: + # a list, all objects within a list belong to one dicom series + if not isinstance(data[0], List): + dicom_data.append(self._combine_dicom_series(data)) + # a list of list, each inner list represents a dicom series + else: + for series in data: + dicom_data.append(self._combine_dicom_series(series)) + else: + # a single pydicom dataset object + if not isinstance(data, List): + data = [data] + for d in data: + data_array = self._get_array_data(d) + metadata = self._get_meta_dict(d) + metadata["spatial_shape"] = data_array.shape + metadata["spacing"] = np.asarray(metadata.get("00280030", {}).get("Value", (1.0, 1.0, 1.0))) + dicom_data.append((data_array, metadata)) + + img_array: List[np.ndarray] = [] + compatible_meta: Dict = {} + + for (data_array, metadata) in ensure_tuple(dicom_data): + img_array.append(np.ascontiguousarray(np.swapaxes(data_array, 0, 1) if self.swap_ij else data_array)) + affine = self._get_affine(metadata, self.affine_lps_to_ras) + if self.swap_ij: + affine = affine @ np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + sp_size = list(metadata["spatial_shape"]) + sp_size[0], sp_size[1] = sp_size[1], sp_size[0] + metadata["spatial_shape"] = ensure_tuple(sp_size) + metadata["original_affine"] = affine + metadata["affine"] = affine.copy() + if self.channel_dim is None: # default to "no_channel" or -1 + metadata["original_channel_dim"] = ( + "no_channel" if len(data_array.shape) == len(metadata["spatial_shape"]) else -1 + ) + else: + metadata["original_channel_dim"] = self.channel_dim + metadata["spacing"] = affine_to_spacing(metadata["original_affine"], r=len(metadata["spatial_shape"])) + _copy_compatible_dict(metadata, compatible_meta) + + return _stack_images(img_array, compatible_meta), compatible_meta + + def _get_meta_dict(self, img) -> Dict: + """ + Get all the metadata of the image and convert to dict type. + + Args: + img: a Pydicom dataset object. + + """ + if not hasattr(img, "ImagePositionPatient"): + raise ValueError(f"dicom data: {img.filename} does not have ImagePositionPatient.") + if not hasattr(img, "ImageOrientationPatient"): + raise ValueError(f"dicom data: {img.filename} does not have ImageOrientationPatient.") + + meta_dict = img.to_json_dict() + # remove Pixel Data "7FE00008" or "7FE00009" or "7FE00010" + # remove Data Set Trailing Padding "FFFCFFFC" + for key in ["7FE00008", "7FE00009", "7FE00010", "FFFCFFFC"]: + if key in meta_dict.keys(): + meta_dict.pop(key) + + return meta_dict # type: ignore + + def _get_affine(self, metadata: Dict, lps_to_ras: bool = True): + """ + Get or construct the affine matrix of the image, it can be used to correct + spacing, orientation or execute spatial transforms. + + Args: + metadata: metadata with dict type. + lps_to_ras: whether to convert the affine matrix from "LPS" to "RAS". Defaults to True. + + """ + affine: np.ndarray = np.eye(4) + if not ("00200037" in metadata and "00200032" in metadata): + return affine + # "00200037" is the tag of `ImageOrientationPatient` + rx, ry, rz, cx, cy, cz = metadata["00200037"]["Value"] + # "00200032" is the tag of `ImagePositionPatient` + sx, sy, sz = metadata["00200032"]["Value"] + dr, dc = metadata.get("spacing", (1.0, 1.0))[:2] + affine[0, 0] = cx * dr + affine[0, 1] = rx * dc + affine[0, 3] = sx + affine[1, 0] = cy * dr + affine[1, 1] = ry * dc + affine[1, 3] = sy + affine[2, 0] = cz * dr + affine[2, 1] = rz * dc + affine[2, 2] = 0 + affine[2, 3] = sz + + # 3d + if "lastImagePositionPatient" in metadata: + t1n, t2n, t3n = metadata["lastImagePositionPatient"] + n = metadata["spatial_shape"][-1] + k1, k2, k3 = (t1n - sx) / (n - 1), (t2n - sy) / (n - 1), (t3n - sz) / (n - 1) + affine[0, 2] = k1 + affine[1, 2] = k2 + affine[2, 2] = k3 + + if lps_to_ras: + affine = orientation_ras_lps(affine) + return affine + + def _get_array_data(self, img): + """ + Get the array data of the image. If `RescaleSlope` and `RescaleIntercept` are available, the raw array data + will be rescaled. The output data has the dtype np.float32 if the rescaling is applied. + + Args: + img: a Pydicom dataset object. + + """ + # process Dicom series + if not hasattr(img, "pixel_array"): + raise ValueError(f"dicom data: {img.filename} does not have pixel_array.") + data = img.pixel_array + + slope, offset = 1.0, 0.0 + rescale_flag = False + if hasattr(img, "RescaleSlope"): + slope = img.RescaleSlope + rescale_flag = True + if hasattr(img, "RescaleIntercept"): + offset = img.RescaleIntercept + rescale_flag = True + if rescale_flag: + data = data.astype(np.float32) * slope + offset + + return data + + @require_pkg(pkg_name="nibabel") class NibabelReader(ImageReader): """ diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 2678520ce1..31359cd89a 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -28,7 +28,15 @@ from monai.config import DtypeLike, NdarrayOrTensor, PathLike from monai.data import image_writer from monai.data.folder_layout import FolderLayout -from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader +from monai.data.image_reader import ( + ImageReader, + ITKReader, + NibabelReader, + NrrdReader, + NumpyReader, + PILReader, + PydicomReader, +) from monai.transforms.transform import Transform from monai.transforms.utility.array import EnsureChannelFirst from monai.utils import GridSampleMode, GridSamplePadMode @@ -42,6 +50,7 @@ __all__ = ["LoadImage", "SaveImage", "SUPPORTED_READERS"] SUPPORTED_READERS = { + "pydicomreader": PydicomReader, "itkreader": ITKReader, "nrrdreader": NrrdReader, "numpyreader": NumpyReader, @@ -110,7 +119,7 @@ def __init__( - if `reader` is None, a default set of `SUPPORTED_READERS` will be used. - if `reader` is a string, it's treated as a class name or dotted path (such as ``"monai.data.ITKReader"``), the supported built-in reader classes are - ``"ITKReader"``, ``"NibabelReader"``, ``"NumpyReader"``. + ``"ITKReader"``, ``"NibabelReader"``, ``"NumpyReader"``, ``"PydicomReader"``. a reader instance will be constructed with the `*args` and `**kwargs` parameters. - if `reader` is a reader class/instance, it will be registered to this loader accordingly. image_only: if True return only the image volume, otherwise return image data array and header dict. diff --git a/requirements-dev.txt b/requirements-dev.txt index df1fe67f5e..0c043ec420 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -49,3 +49,4 @@ fire jsonschema pynrrd pre-commit +pydicom diff --git a/setup.cfg b/setup.cfg index a25a64797c..d10ca8ff49 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,6 +54,7 @@ all = fire jsonschema pynrrd + pydicom nibabel = nibabel skimage = @@ -104,6 +105,8 @@ jsonschema = jsonschema pynrrd = pynrrd +pydicom = + pydicom [flake8] select = B,C,E,F,N,P,T4,W,B9 diff --git a/tests/test_init_reader.py b/tests/test_init_reader.py index df055e571c..e9a8ec96f7 100644 --- a/tests/test_init_reader.py +++ b/tests/test_init_reader.py @@ -11,7 +11,7 @@ import unittest -from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader +from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader from monai.transforms import LoadImage, LoadImaged from tests.utils import SkipIfNoModule @@ -23,7 +23,7 @@ def test_load_image(self): self.assertIsInstance(instance1, LoadImage) self.assertIsInstance(instance2, LoadImage) - for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", None]: + for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", None]: inst = LoadImaged("image", reader=r) self.assertIsInstance(inst, LoadImaged) @@ -31,6 +31,7 @@ def test_load_image(self): @SkipIfNoModule("nibabel") @SkipIfNoModule("PIL") @SkipIfNoModule("nrrd") + @SkipIfNoModule("Pydicom") def test_readers(self): inst = ITKReader() self.assertIsInstance(inst, ITKReader) @@ -40,6 +41,9 @@ def test_readers(self): inst = NibabelReader(as_closest_canonical=True) self.assertIsInstance(inst, NibabelReader) + inst = PydicomReader() + self.assertIsInstance(inst, PydicomReader) + inst = NumpyReader() self.assertIsInstance(inst, NumpyReader) inst = NumpyReader(npz_keys="test") diff --git a/tests/test_load_image.py b/tests/test_load_image.py index 201fe2fd5b..7b5572f5fc 100644 --- a/tests/test_load_image.py +++ b/tests/test_load_image.py @@ -20,7 +20,7 @@ from parameterized import parameterized from PIL import Image -from monai.data import ITKReader, NibabelReader +from monai.data import ITKReader, NibabelReader, PydicomReader from monai.transforms import LoadImage @@ -134,6 +134,31 @@ def get_data(self, _obj): (128, 128, 3, 128), ] +# test same dicom data with PydicomReader +TEST_CASE_19 = [ + {"image_only": False, "reader": PydicomReader()}, + "tests/testing_data/CT_DICOM", + (16, 16, 4), + (16, 16, 4), +] + +TEST_CASE_20 = [ + {"image_only": False, "reader": "PydicomReader", "ensure_channel_first": True}, + "tests/testing_data/CT_DICOM", + (16, 16, 4), + (1, 16, 16, 4), +] + +TEST_CASE_21 = [ + {"image_only": False, "reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"}, + "tests/testing_data/CT_DICOM", + (16, 16, 4), + (16, 16, 4), +] + +# test reader consistency between PydicomReader and ITKReader on dicom data +TEST_CASE_22 = ["tests/testing_data/CT_DICOM"] + class TestLoadImage(unittest.TestCase): @parameterized.expand( @@ -174,7 +199,7 @@ def test_itk_reader(self, input_param, filenames, expected_shape): np.testing.assert_allclose(header["original_affine"], np_diag) self.assertTupleEqual(result.shape, expected_shape) - @parameterized.expand([TEST_CASE_10, TEST_CASE_11, TEST_CASE_12]) + @parameterized.expand([TEST_CASE_10, TEST_CASE_11, TEST_CASE_12, TEST_CASE_19, TEST_CASE_20, TEST_CASE_21]) def test_itk_dicom_series_reader(self, input_param, filenames, expected_shape, expected_np_shape): result, header = LoadImage(**input_param)(filenames) self.assertTrue("affine" in header) @@ -208,6 +233,18 @@ def test_itk_reader_multichannel(self): np.testing.assert_allclose(result[:, :, 1], test_image[:, :, 1]) np.testing.assert_allclose(result[:, :, 2], test_image[:, :, 2]) + @parameterized.expand([TEST_CASE_22]) + def test_dicom_reader_consistency(self, filenames): + itk_param = {"reader": "ITKReader"} + pydicom_param = {"reader": "PydicomReader"} + for affine_flag in [True, False]: + itk_param["affine_lps_to_ras"] = affine_flag + pydicom_param["affine_lps_to_ras"] = affine_flag + itk_result, itk_header = LoadImage(**itk_param)(filenames) + pydicom_result, pydicom_header = LoadImage(**pydicom_param)(filenames) + np.testing.assert_allclose(pydicom_result, itk_result) + np.testing.assert_allclose(itk_header["affine"], pydicom_header["affine"]) + def test_load_nifti_multichannel(self): test_image = np.random.randint(0, 256, size=(31, 64, 16, 2)).astype(np.float32) with tempfile.TemporaryDirectory() as tempdir: From d4c65215c00ea518fcf2d4c424beb7bf33a28fea Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 27 Jun 2022 08:18:24 +0100 Subject: [PATCH 168/183] 4589 enable dropout in dynunet (#4590) * 4589 enable dropout Signed-off-by: Wenqi Li * fixes docstring typo Signed-off-by: Wenqi Li --- monai/data/image_reader.py | 5 --- monai/networks/blocks/convolutions.py | 29 +++++++------ monai/networks/blocks/dynunet_block.py | 57 ++++++++++++++++++++++---- tests/test_dynunet.py | 4 +- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index bccd689a37..743896c99a 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -398,11 +398,6 @@ class PydicomReader(ImageReader): (for example, when using this reader with `monai.transforms.LoadImage`), please ensure that the argument `stop_before_pixels` is `True`, and `specific_tags` covers all necessary tags, such as `PixelSpacing`, `ImagePositionPatient`, `ImageOrientationPatient` and all `pixel_array` related tags. - - Note:: - - the current - """ def __init__( diff --git a/monai/networks/blocks/convolutions.py b/monai/networks/blocks/convolutions.py index 37530668a3..17d3c9d084 100644 --- a/monai/networks/blocks/convolutions.py +++ b/monai/networks/blocks/convolutions.py @@ -159,19 +159,22 @@ def __init__( self.add_module("conv", conv) - if not conv_only: - self.add_module( - "adn", - ADN( - ordering=adn_ordering, - in_channels=out_channels, - act=act, - norm=norm, - norm_dim=self.dimensions, - dropout=dropout, - dropout_dim=dropout_dim, - ), - ) + if conv_only: + return + if act is None and norm is None and dropout is None: + return + self.add_module( + "adn", + ADN( + ordering=adn_ordering, + in_channels=out_channels, + act=act, + norm=norm, + norm_dim=self.dimensions, + dropout=dropout, + dropout_dim=dropout_dim, + ), + ) class ResidualUnit(nn.Module): diff --git a/monai/networks/blocks/dynunet_block.py b/monai/networks/blocks/dynunet_block.py index 0df4980dcd..894686b50a 100644 --- a/monai/networks/blocks/dynunet_block.py +++ b/monai/networks/blocks/dynunet_block.py @@ -57,10 +57,20 @@ def __init__( kernel_size=kernel_size, stride=stride, dropout=dropout, - conv_only=True, + act=None, + norm=None, + conv_only=False, ) self.conv2 = get_conv_layer( - spatial_dims, out_channels, out_channels, kernel_size=kernel_size, stride=1, dropout=dropout, conv_only=True + spatial_dims, + out_channels, + out_channels, + kernel_size=kernel_size, + stride=1, + dropout=dropout, + act=None, + norm=None, + conv_only=False, ) self.lrelu = get_act_layer(name=act_name) self.norm1 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) @@ -71,7 +81,15 @@ def __init__( self.downsample = True if self.downsample: self.conv3 = get_conv_layer( - spatial_dims, in_channels, out_channels, kernel_size=1, stride=stride, dropout=dropout, conv_only=True + spatial_dims, + in_channels, + out_channels, + kernel_size=1, + stride=stride, + dropout=dropout, + act=None, + norm=None, + conv_only=False, ) self.norm3 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) @@ -93,7 +111,7 @@ def forward(self, inp): class UnetBasicBlock(nn.Module): """ - A CNN module module that can be used for DynUNet, based on: + A CNN module that can be used for DynUNet, based on: `Automated Design of Deep Learning Methods for Biomedical Image Segmentation `_. `nnU-Net: Self-adapting Framework for U-Net-Based Medical Image Segmentation `_. @@ -128,10 +146,20 @@ def __init__( kernel_size=kernel_size, stride=stride, dropout=dropout, - conv_only=True, + act=None, + norm=None, + conv_only=False, ) self.conv2 = get_conv_layer( - spatial_dims, out_channels, out_channels, kernel_size=kernel_size, stride=1, dropout=dropout, conv_only=True + spatial_dims, + out_channels, + out_channels, + kernel_size=kernel_size, + stride=1, + dropout=dropout, + act=None, + norm=None, + conv_only=False, ) self.lrelu = get_act_layer(name=act_name) self.norm1 = get_norm_layer(name=norm_name, spatial_dims=spatial_dims, channels=out_channels) @@ -190,7 +218,9 @@ def __init__( stride=upsample_stride, dropout=dropout, bias=trans_bias, - conv_only=True, + act=None, + norm=None, + conv_only=False, is_transposed=True, ) self.conv_block = UnetBasicBlock( @@ -218,7 +248,16 @@ def __init__( ): super().__init__() self.conv = get_conv_layer( - spatial_dims, in_channels, out_channels, kernel_size=1, stride=1, dropout=dropout, bias=True, conv_only=True + spatial_dims, + in_channels, + out_channels, + kernel_size=1, + stride=1, + dropout=dropout, + bias=True, + act=None, + norm=None, + conv_only=False, ) def forward(self, inp): @@ -232,7 +271,7 @@ def get_conv_layer( kernel_size: Union[Sequence[int], int] = 3, stride: Union[Sequence[int], int] = 1, act: Optional[Union[Tuple, str]] = Act.PRELU, - norm: Union[Tuple, str] = Norm.INSTANCE, + norm: Optional[Union[Tuple, str]] = Norm.INSTANCE, dropout: Optional[Union[Tuple, str, float]] = None, bias: bool = False, conv_only: bool = True, diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index ff5d5efbef..01c58ea788 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -105,9 +105,11 @@ class TestDynUNet(unittest.TestCase): - @parameterized.expand(TEST_CASE_DYNUNET_2D + TEST_CASE_DYNUNET_3D) + @parameterized.expand(TEST_CASE_DYNUNET_3D) def test_shape(self, input_param, input_shape, expected_shape): net = DynUNet(**input_param).to(device) + if "alphadropout" in input_param.get("dropout"): + self.assertTrue(any(isinstance(x, torch.nn.AlphaDropout) for x in net.modules())) with eval_mode(net): result = net(torch.randn(input_shape).to(device)) self.assertEqual(result.shape, expected_shape) From 23d9b5217f0b367e3bedf24d6e508131e66a0cd1 Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Mon, 27 Jun 2022 08:48:22 +0000 Subject: [PATCH 169/183] Move dictionary inserts to its definition (#4594) * Move dictionary inserts to its definition * [MONAI] code formatting Signed-off-by: monai-bot Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: monai-bot --- monai/losses/unified_focal_loss.py | 478 +++++++++--------- tests/test_identityd.py | 3 +- ...test_scale_intensity_range_percentilesd.py | 3 +- tests/test_unified_focal_loss.py | 126 ++--- 4 files changed, 304 insertions(+), 306 deletions(-) diff --git a/monai/losses/unified_focal_loss.py b/monai/losses/unified_focal_loss.py index be5d27a276..1f6d51bead 100644 --- a/monai/losses/unified_focal_loss.py +++ b/monai/losses/unified_focal_loss.py @@ -1,239 +1,239 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import warnings -from typing import Union - -import torch -from torch.nn.modules.loss import _Loss - -from monai.networks import one_hot -from monai.utils import LossReduction - - -class AsymmetricFocalTverskyLoss(_Loss): - """ - AsymmetricFocalTverskyLoss is a variant of FocalTverskyLoss, which attentions to the foreground class. - - Actually, it's only supported for binary image segmentation now. - - Reimplementation of the Asymmetric Focal Tversky Loss described in: - - - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", - Michael Yeung, Computerized Medical Imaging and Graphics - """ - - def __init__( - self, - to_onehot_y: bool = False, - delta: float = 0.7, - gamma: float = 0.75, - epsilon: float = 1e-7, - reduction: Union[LossReduction, str] = LossReduction.MEAN, - ) -> None: - """ - Args: - to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. - delta : weight of the background. Defaults to 0.7. - gamma : value of the exponent gamma in the definition of the Focal loss . Defaults to 0.75. - epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. - """ - super().__init__(reduction=LossReduction(reduction).value) - self.to_onehot_y = to_onehot_y - self.delta = delta - self.gamma = gamma - self.epsilon = epsilon - - def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: - n_pred_ch = y_pred.shape[1] - - if self.to_onehot_y: - if n_pred_ch == 1: - warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") - else: - y_true = one_hot(y_true, num_classes=n_pred_ch) - - if y_true.shape != y_pred.shape: - raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") - - # clip the prediction to avoid NaN - y_pred = torch.clamp(y_pred, self.epsilon, 1.0 - self.epsilon) - axis = list(range(2, len(y_pred.shape))) - - # Calculate true positives (tp), false negatives (fn) and false positives (fp) - tp = torch.sum(y_true * y_pred, dim=axis) - fn = torch.sum(y_true * (1 - y_pred), dim=axis) - fp = torch.sum((1 - y_true) * y_pred, dim=axis) - dice_class = (tp + self.epsilon) / (tp + self.delta * fn + (1 - self.delta) * fp + self.epsilon) - - # Calculate losses separately for each class, enhancing both classes - back_dice = 1 - dice_class[:, 0] - fore_dice = (1 - dice_class[:, 1]) * torch.pow(1 - dice_class[:, 1], -self.gamma) - - # Average class scores - loss = torch.mean(torch.stack([back_dice, fore_dice], dim=-1)) - return loss - - -class AsymmetricFocalLoss(_Loss): - """ - AsymmetricFocalLoss is a variant of FocalTverskyLoss, which attentions to the foreground class. - - Actually, it's only supported for binary image segmentation now. - - Reimplementation of the Asymmetric Focal Loss described in: - - - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", - Michael Yeung, Computerized Medical Imaging and Graphics - """ - - def __init__( - self, - to_onehot_y: bool = False, - delta: float = 0.7, - gamma: float = 2, - epsilon: float = 1e-7, - reduction: Union[LossReduction, str] = LossReduction.MEAN, - ): - """ - Args: - to_onehot_y : whether to convert `y` into the one-hot format. Defaults to False. - delta : weight of the background. Defaults to 0.7. - gamma : value of the exponent gamma in the definition of the Focal loss . Defaults to 0.75. - epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. - """ - super().__init__(reduction=LossReduction(reduction).value) - self.to_onehot_y = to_onehot_y - self.delta = delta - self.gamma = gamma - self.epsilon = epsilon - - def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: - n_pred_ch = y_pred.shape[1] - - if self.to_onehot_y: - if n_pred_ch == 1: - warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") - else: - y_true = one_hot(y_true, num_classes=n_pred_ch) - - if y_true.shape != y_pred.shape: - raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") - - y_pred = torch.clamp(y_pred, self.epsilon, 1.0 - self.epsilon) - cross_entropy = -y_true * torch.log(y_pred) - - back_ce = torch.pow(1 - y_pred[:, 0], self.gamma) * cross_entropy[:, 0] - back_ce = (1 - self.delta) * back_ce - - fore_ce = cross_entropy[:, 1] - fore_ce = self.delta * fore_ce - - loss = torch.mean(torch.sum(torch.stack([back_ce, fore_ce], dim=1), dim=1)) - return loss - - -class AsymmetricUnifiedFocalLoss(_Loss): - """ - AsymmetricUnifiedFocalLoss is a variant of Focal Loss. - - Actually, it's only supported for binary image segmentation now - - Reimplementation of the Asymmetric Unified Focal Tversky Loss described in: - - - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", - Michael Yeung, Computerized Medical Imaging and Graphics - """ - - def __init__( - self, - to_onehot_y: bool = False, - num_classes: int = 2, - weight: float = 0.5, - gamma: float = 0.5, - delta: float = 0.7, - reduction: Union[LossReduction, str] = LossReduction.MEAN, - ): - """ - Args: - to_onehot_y : whether to convert `y` into the one-hot format. Defaults to False. - num_classes : number of classes, it only supports 2 now. Defaults to 2. - delta : weight of the background. Defaults to 0.7. - gamma : value of the exponent gamma in the definition of the Focal loss. Defaults to 0.75. - epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. - weight : weight for each loss function, if it's none it's 0.5. Defaults to None. - - Example: - >>> import torch - >>> from monai.losses import AsymmetricUnifiedFocalLoss - >>> pred = torch.ones((1,1,32,32), dtype=torch.float32) - >>> grnd = torch.ones((1,1,32,32), dtype=torch.int64) - >>> fl = AsymmetricUnifiedFocalLoss(to_onehot_y=True) - >>> fl(pred, grnd) - """ - super().__init__(reduction=LossReduction(reduction).value) - self.to_onehot_y = to_onehot_y - self.num_classes = num_classes - self.gamma = gamma - self.delta = delta - self.weight: float = weight - self.asy_focal_loss = AsymmetricFocalLoss(gamma=self.gamma, delta=self.delta) - self.asy_focal_tversky_loss = AsymmetricFocalTverskyLoss(gamma=self.gamma, delta=self.delta) - - # TODO: Implement this function to support multiple classes segmentation - def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: - """ - Args: - y_pred : the shape should be BNH[WD], where N is the number of classes. - It only supports binary segmentation. - The input should be the original logits since it will be transformed by - a sigmoid in the forward function. - y_true : the shape should be BNH[WD], where N is the number of classes. - It only supports binary segmentation. - - Raises: - ValueError: When input and target are different shape - ValueError: When len(y_pred.shape) != 4 and len(y_pred.shape) != 5 - ValueError: When num_classes - ValueError: When the number of classes entered does not match the expected number - """ - if y_pred.shape != y_true.shape: - raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") - - if len(y_pred.shape) != 4 and len(y_pred.shape) != 5: - raise ValueError(f"input shape must be 4 or 5, but got {y_pred.shape}") - - if y_pred.shape[1] == 1: - y_pred = one_hot(y_pred, num_classes=self.num_classes) - y_true = one_hot(y_true, num_classes=self.num_classes) - - if torch.max(y_true) != self.num_classes - 1: - raise ValueError(f"Pelase make sure the number of classes is {self.num_classes-1}") - - n_pred_ch = y_pred.shape[1] - if self.to_onehot_y: - if n_pred_ch == 1: - warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") - else: - y_true = one_hot(y_true, num_classes=n_pred_ch) - - asy_focal_loss = self.asy_focal_loss(y_pred, y_true) - asy_focal_tversky_loss = self.asy_focal_tversky_loss(y_pred, y_true) - - loss: torch.Tensor = self.weight * asy_focal_loss + (1 - self.weight) * asy_focal_tversky_loss - - if self.reduction == LossReduction.SUM.value: - return torch.sum(loss) # sum over the batch and channel dims - if self.reduction == LossReduction.NONE.value: - return loss # returns [N, num_classes] losses - if self.reduction == LossReduction.MEAN.value: - return torch.mean(loss) - raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].') +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import warnings +from typing import Union + +import torch +from torch.nn.modules.loss import _Loss + +from monai.networks import one_hot +from monai.utils import LossReduction + + +class AsymmetricFocalTverskyLoss(_Loss): + """ + AsymmetricFocalTverskyLoss is a variant of FocalTverskyLoss, which attentions to the foreground class. + + Actually, it's only supported for binary image segmentation now. + + Reimplementation of the Asymmetric Focal Tversky Loss described in: + + - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", + Michael Yeung, Computerized Medical Imaging and Graphics + """ + + def __init__( + self, + to_onehot_y: bool = False, + delta: float = 0.7, + gamma: float = 0.75, + epsilon: float = 1e-7, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + ) -> None: + """ + Args: + to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. + delta : weight of the background. Defaults to 0.7. + gamma : value of the exponent gamma in the definition of the Focal loss . Defaults to 0.75. + epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. + """ + super().__init__(reduction=LossReduction(reduction).value) + self.to_onehot_y = to_onehot_y + self.delta = delta + self.gamma = gamma + self.epsilon = epsilon + + def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: + n_pred_ch = y_pred.shape[1] + + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + y_true = one_hot(y_true, num_classes=n_pred_ch) + + if y_true.shape != y_pred.shape: + raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") + + # clip the prediction to avoid NaN + y_pred = torch.clamp(y_pred, self.epsilon, 1.0 - self.epsilon) + axis = list(range(2, len(y_pred.shape))) + + # Calculate true positives (tp), false negatives (fn) and false positives (fp) + tp = torch.sum(y_true * y_pred, dim=axis) + fn = torch.sum(y_true * (1 - y_pred), dim=axis) + fp = torch.sum((1 - y_true) * y_pred, dim=axis) + dice_class = (tp + self.epsilon) / (tp + self.delta * fn + (1 - self.delta) * fp + self.epsilon) + + # Calculate losses separately for each class, enhancing both classes + back_dice = 1 - dice_class[:, 0] + fore_dice = (1 - dice_class[:, 1]) * torch.pow(1 - dice_class[:, 1], -self.gamma) + + # Average class scores + loss = torch.mean(torch.stack([back_dice, fore_dice], dim=-1)) + return loss + + +class AsymmetricFocalLoss(_Loss): + """ + AsymmetricFocalLoss is a variant of FocalTverskyLoss, which attentions to the foreground class. + + Actually, it's only supported for binary image segmentation now. + + Reimplementation of the Asymmetric Focal Loss described in: + + - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", + Michael Yeung, Computerized Medical Imaging and Graphics + """ + + def __init__( + self, + to_onehot_y: bool = False, + delta: float = 0.7, + gamma: float = 2, + epsilon: float = 1e-7, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + ): + """ + Args: + to_onehot_y : whether to convert `y` into the one-hot format. Defaults to False. + delta : weight of the background. Defaults to 0.7. + gamma : value of the exponent gamma in the definition of the Focal loss . Defaults to 0.75. + epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. + """ + super().__init__(reduction=LossReduction(reduction).value) + self.to_onehot_y = to_onehot_y + self.delta = delta + self.gamma = gamma + self.epsilon = epsilon + + def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: + n_pred_ch = y_pred.shape[1] + + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + y_true = one_hot(y_true, num_classes=n_pred_ch) + + if y_true.shape != y_pred.shape: + raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") + + y_pred = torch.clamp(y_pred, self.epsilon, 1.0 - self.epsilon) + cross_entropy = -y_true * torch.log(y_pred) + + back_ce = torch.pow(1 - y_pred[:, 0], self.gamma) * cross_entropy[:, 0] + back_ce = (1 - self.delta) * back_ce + + fore_ce = cross_entropy[:, 1] + fore_ce = self.delta * fore_ce + + loss = torch.mean(torch.sum(torch.stack([back_ce, fore_ce], dim=1), dim=1)) + return loss + + +class AsymmetricUnifiedFocalLoss(_Loss): + """ + AsymmetricUnifiedFocalLoss is a variant of Focal Loss. + + Actually, it's only supported for binary image segmentation now + + Reimplementation of the Asymmetric Unified Focal Tversky Loss described in: + + - "Unified Focal Loss: Generalising Dice and Cross Entropy-based Losses to Handle Class Imbalanced Medical Image Segmentation", + Michael Yeung, Computerized Medical Imaging and Graphics + """ + + def __init__( + self, + to_onehot_y: bool = False, + num_classes: int = 2, + weight: float = 0.5, + gamma: float = 0.5, + delta: float = 0.7, + reduction: Union[LossReduction, str] = LossReduction.MEAN, + ): + """ + Args: + to_onehot_y : whether to convert `y` into the one-hot format. Defaults to False. + num_classes : number of classes, it only supports 2 now. Defaults to 2. + delta : weight of the background. Defaults to 0.7. + gamma : value of the exponent gamma in the definition of the Focal loss. Defaults to 0.75. + epsilon : it defines a very small number each time. simmily smooth value. Defaults to 1e-7. + weight : weight for each loss function, if it's none it's 0.5. Defaults to None. + + Example: + >>> import torch + >>> from monai.losses import AsymmetricUnifiedFocalLoss + >>> pred = torch.ones((1,1,32,32), dtype=torch.float32) + >>> grnd = torch.ones((1,1,32,32), dtype=torch.int64) + >>> fl = AsymmetricUnifiedFocalLoss(to_onehot_y=True) + >>> fl(pred, grnd) + """ + super().__init__(reduction=LossReduction(reduction).value) + self.to_onehot_y = to_onehot_y + self.num_classes = num_classes + self.gamma = gamma + self.delta = delta + self.weight: float = weight + self.asy_focal_loss = AsymmetricFocalLoss(gamma=self.gamma, delta=self.delta) + self.asy_focal_tversky_loss = AsymmetricFocalTverskyLoss(gamma=self.gamma, delta=self.delta) + + # TODO: Implement this function to support multiple classes segmentation + def forward(self, y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor: + """ + Args: + y_pred : the shape should be BNH[WD], where N is the number of classes. + It only supports binary segmentation. + The input should be the original logits since it will be transformed by + a sigmoid in the forward function. + y_true : the shape should be BNH[WD], where N is the number of classes. + It only supports binary segmentation. + + Raises: + ValueError: When input and target are different shape + ValueError: When len(y_pred.shape) != 4 and len(y_pred.shape) != 5 + ValueError: When num_classes + ValueError: When the number of classes entered does not match the expected number + """ + if y_pred.shape != y_true.shape: + raise ValueError(f"ground truth has different shape ({y_true.shape}) from input ({y_pred.shape})") + + if len(y_pred.shape) != 4 and len(y_pred.shape) != 5: + raise ValueError(f"input shape must be 4 or 5, but got {y_pred.shape}") + + if y_pred.shape[1] == 1: + y_pred = one_hot(y_pred, num_classes=self.num_classes) + y_true = one_hot(y_true, num_classes=self.num_classes) + + if torch.max(y_true) != self.num_classes - 1: + raise ValueError(f"Pelase make sure the number of classes is {self.num_classes-1}") + + n_pred_ch = y_pred.shape[1] + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + y_true = one_hot(y_true, num_classes=n_pred_ch) + + asy_focal_loss = self.asy_focal_loss(y_pred, y_true) + asy_focal_tversky_loss = self.asy_focal_tversky_loss(y_pred, y_true) + + loss: torch.Tensor = self.weight * asy_focal_loss + (1 - self.weight) * asy_focal_tversky_loss + + if self.reduction == LossReduction.SUM.value: + return torch.sum(loss) # sum over the batch and channel dims + if self.reduction == LossReduction.NONE.value: + return loss # returns [N, num_classes] losses + if self.reduction == LossReduction.MEAN.value: + return torch.mean(loss) + raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].') diff --git a/tests/test_identityd.py b/tests/test_identityd.py index 2df74ba2c6..f1d27d61d4 100644 --- a/tests/test_identityd.py +++ b/tests/test_identityd.py @@ -19,8 +19,7 @@ class TestIdentityd(NumpyImageTestCase2D): def test_identityd(self): for p in TEST_NDARRAYS: img = p(self.imt) - data = {} - data["img"] = img + data = {"img": img} identity = Identityd(keys=data.keys()) assert_allclose(img, identity(data)["img"]) diff --git a/tests/test_scale_intensity_range_percentilesd.py b/tests/test_scale_intensity_range_percentilesd.py index 5441832a77..edb421cec3 100644 --- a/tests/test_scale_intensity_range_percentilesd.py +++ b/tests/test_scale_intensity_range_percentilesd.py @@ -38,8 +38,7 @@ def test_scaling(self): def test_relative_scaling(self): img = self.imt - data = {} - data["img"] = img + data = {"img": img} lower = 10 upper = 99 b_min = 100 diff --git a/tests/test_unified_focal_loss.py b/tests/test_unified_focal_loss.py index 5819edea85..1a7bb91059 100644 --- a/tests/test_unified_focal_loss.py +++ b/tests/test_unified_focal_loss.py @@ -1,63 +1,63 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import unittest - -import numpy as np -import torch -from parameterized import parameterized - -from monai.losses import AsymmetricUnifiedFocalLoss - -TEST_CASES = [ - [ # shape: (2, 1, 2, 2), (2, 1, 2, 2) - { - "y_pred": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), - "y_true": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), - }, - 0.0, - ], - [ # shape: (2, 1, 2, 2), (2, 1, 2, 2) - { - "y_pred": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), - "y_true": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), - }, - 0.0, - ], -] - - -class TestAsymmetricUnifiedFocalLoss(unittest.TestCase): - @parameterized.expand(TEST_CASES) - def test_result(self, input_data, expected_val): - loss = AsymmetricUnifiedFocalLoss() - result = loss(**input_data) - np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) - - def test_ill_shape(self): - loss = AsymmetricUnifiedFocalLoss() - with self.assertRaisesRegex(ValueError, ""): - loss(torch.ones((2, 2, 2)), torch.ones((2, 2, 2, 2))) - - def test_with_cuda(self): - loss = AsymmetricUnifiedFocalLoss() - i = torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]) - j = torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]) - if torch.cuda.is_available(): - i = i.cuda() - j = j.cuda() - output = loss(i, j) - print(output) - np.testing.assert_allclose(output.detach().cpu().numpy(), 0.0, atol=1e-4, rtol=1e-4) - - -if __name__ == "__main__": - unittest.main() +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.losses import AsymmetricUnifiedFocalLoss + +TEST_CASES = [ + [ # shape: (2, 1, 2, 2), (2, 1, 2, 2) + { + "y_pred": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + "y_true": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + }, + 0.0, + ], + [ # shape: (2, 1, 2, 2), (2, 1, 2, 2) + { + "y_pred": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + "y_true": torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]), + }, + 0.0, + ], +] + + +class TestAsymmetricUnifiedFocalLoss(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_result(self, input_data, expected_val): + loss = AsymmetricUnifiedFocalLoss() + result = loss(**input_data) + np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) + + def test_ill_shape(self): + loss = AsymmetricUnifiedFocalLoss() + with self.assertRaisesRegex(ValueError, ""): + loss(torch.ones((2, 2, 2)), torch.ones((2, 2, 2, 2))) + + def test_with_cuda(self): + loss = AsymmetricUnifiedFocalLoss() + i = torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]) + j = torch.tensor([[[[1.0, 0], [0, 1.0]]], [[[1.0, 0], [0, 1.0]]]]) + if torch.cuda.is_available(): + i = i.cuda() + j = j.cuda() + output = loss(i, j) + print(output) + np.testing.assert_allclose(output.detach().cpu().numpy(), 0.0, atol=1e-4, rtol=1e-4) + + +if __name__ == "__main__": + unittest.main() From 040a4ced51c06c70e27b29ab3c817d30e3b1e620 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 28 Jun 2022 23:35:05 +0800 Subject: [PATCH 170/183] 4578 add default return for ConfigParser (#4601) * [DLMED] add default value Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/bundle/config_parser.py | 3 ++- monai/bundle/reference_resolver.py | 14 +++++++++----- tests/test_config_parser.py | 3 +++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py index edbe4d1923..791759fd65 100644 --- a/monai/bundle/config_parser.py +++ b/monai/bundle/config_parser.py @@ -226,7 +226,8 @@ def get_parsed_content(self, id: str = "", **kwargs): kwargs: additional keyword arguments to be passed to ``_resolve_one_item``. Currently support ``lazy`` (whether to retain the current config cache, default to `True`), ``instantiate`` (whether to instantiate the `ConfigComponent`, default to `True`) and - ``eval_expr`` (whether to evaluate the `ConfigExpression`, default to `True`). + ``eval_expr`` (whether to evaluate the `ConfigExpression`, default to `True`), ``default`` + (the default config item if the `id` is not in the config content). """ if not self.ref_resolver.is_resolved(): diff --git a/monai/bundle/reference_resolver.py b/monai/bundle/reference_resolver.py index 02c6902151..7ed5ae3522 100644 --- a/monai/bundle/reference_resolver.py +++ b/monai/bundle/reference_resolver.py @@ -111,14 +111,16 @@ def _resolve_one_item(self, id: str, waiting_list: Optional[Set[str]] = None, ** waiting_list: set of ids pending to be resolved. It's used to detect circular references such as: `{"name": "A", "dep": "@B"}` and `{"name": "B", "dep": "@A"}`. - kwargs: keyword arguments to pass to ``_resolve_one_item()``. - Currently support ``instantiate`` and ``eval_expr``. Both are defaulting to True. + kwargs: keyword arguments to pass to ``_resolve_one_item()``. + Currently support ``instantiate``, ``eval_expr`` and ``default``. + `instantiate` and `eval_expr` are defaulting to True, `default` is the target config item + if the `id` is not in the config content, must be a `ConfigItem` object. """ if id in self.resolved_content: return self.resolved_content[id] try: - item = look_up_option(id, self.items, print_all_options=False) + item = look_up_option(id, self.items, print_all_options=False, default=kwargs.get("default", "no_default")) except ValueError as err: raise KeyError(f"id='{id}' is not found in the config resolver.") from err item_config = item.get_config() @@ -175,8 +177,10 @@ def get_resolved_content(self, id: str, **kwargs): Args: id: id name of the expected item. - kwargs: additional keyword arguments to be passed to ``_resolve_one_item``. - Currently support ``instantiate`` and ``eval_expr``. Both are defaulting to True. + kwargs: keyword arguments to pass to ``_resolve_one_item()``. + Currently support ``instantiate``, ``eval_expr`` and ``default``. + `instantiate` and `eval_expr` are defaulting to True, `default` is the target config item + if the `id` is not in the config content, must be a `ConfigItem` object. """ return self._resolve_one_item(id=id, **kwargs) diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index 14c1579686..81e96d4095 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -18,6 +18,7 @@ from parameterized import parameterized from monai.bundle import ConfigParser, ReferenceResolver +from monai.bundle.config_item import ConfigItem from monai.data import DataLoader, Dataset from monai.transforms import Compose, LoadImaged, RandTorchVisiond from monai.utils import min_version, optional_import @@ -129,6 +130,8 @@ def test_parse(self, config, expected_ids, output_types): root = parser.get_parsed_content(id="") for v, cls in zip(root.values(), [Compose, Dataset, DataLoader]): self.assertTrue(isinstance(v, cls)) + # test default value + self.assertEqual(parser.get_parsed_content(id="abc", default=ConfigItem(12345, "abc")), 12345) @parameterized.expand([TEST_CASE_2]) def test_function(self, config): From 25d62086696085802c2b2497ded3461b36ef5ebb Mon Sep 17 00:00:00 2001 From: Mohammad Zalbagi Darestani <47939388+mersad95zd@users.noreply.github.com> Date: Wed, 29 Jun 2022 03:29:07 -0500 Subject: [PATCH 171/183] mri utils added (#4552) * mri utils added Signed-off-by: mersad95zd * fft_utils with its unit test added Signed-off-by: mersad95zd * fft_utils updated with monai data converter Signed-off-by: mersad95zd * updated fft_util's docstring Signed-off-by: mersad95zd * apps.rst updated with fft_utils docstrings under the reconstruction module Signed-off-by: mersad95zd * fft_utils docstring updated by adding dimension hins Signed-off-by: mersad95zd * fft_utils docstring updated by removing redundant output type Signed-off-by: mersad95zd * test_fft_utils.py moved to the tests folder Signed-off-by: mersad95zd * created fft_utils_t, the torch-only version of fft_utils Signed-off-by: mersad95zd * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fft_utils_t updated with type ignore for mypy Signed-off-by: mersad95zd * docs/source/networks.rst updated with fft_utils_t Signed-off-by: mersad95zd * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * manual fix for fft_utils_t output data types Signed-off-by: mersad95zd * added support for older pytorch versions Signed-off-by: mersad95zd * fixes mypy Signed-off-by: Wenqi Li * update to remove assert Signed-off-by: Wenqi Li Co-authored-by: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/data.rst | 6 + docs/source/networks.rst | 11 + monai/data/box_utils.py | 2 +- monai/data/fft_utils.py | 94 +++++++ .../blocks/feature_pyramid_network.py | 2 +- monai/networks/blocks/fft_utils_t.py | 232 ++++++++++++++++++ monai/transforms/utility/array.py | 4 +- tests/test_fft_utils.py | 63 +++++ 8 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 monai/data/fft_utils.py create mode 100644 monai/networks/blocks/fft_utils_t.py create mode 100644 tests/test_fft_utils.py diff --git a/docs/source/data.rst b/docs/source/data.rst index 0de5e0c347..eab4f867af 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -268,6 +268,12 @@ TestTimeAugmentation ~~~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.TestTimeAugmentation +N-Dim Fourier Transform +~~~~~~~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.data.fft_utils +.. autofunction:: monai.data.fft_utils.fftn_centered +.. autofunction:: monai.data.fft_utils.ifftn_centered + Meta Object ----------- diff --git a/docs/source/networks.rst b/docs/source/networks.rst index 2164fe5d1b..a7b6d8cdc0 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -233,6 +233,17 @@ Blocks .. autoclass:: DVF2DDF :members: + +N-Dim Fourier Transform +~~~~~~~~~~~~~~~~~~~~~~~~ +.. automodule:: monai.networks.blocks.fft_utils_t +.. autofunction:: monai.networks.blocks.fft_utils_t.fftn_centered_t +.. autofunction:: monai.networks.blocks.fft_utils_t.ifftn_centered_t +.. autofunction:: monai.networks.blocks.fft_utils_t.roll +.. autofunction:: monai.networks.blocks.fft_utils_t.roll_1d +.. autofunction:: monai.networks.blocks.fft_utils_t.fftshift +.. autofunction:: monai.networks.blocks.fft_utils_t.ifftshift + Layers ------ diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index f0f95dbc3e..47e7b6911d 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -621,7 +621,7 @@ def centers_in_boxes(centers: NdarrayOrTensor, boxes: NdarrayOrTensor, eps: floa min_center_to_border: np.ndarray = np.stack(center_to_border, axis=1).min(axis=1) return min_center_to_border > eps # array[bool] - return torch.stack(center_to_border, dim=1).to(COMPUTE_DTYPE).min(dim=1)[0] > eps # Tensor[bool] + return torch.stack(center_to_border, dim=1).to(COMPUTE_DTYPE).min(dim=1)[0] > eps # type: ignore def boxes_center_distance( diff --git a/monai/data/fft_utils.py b/monai/data/fft_utils.py new file mode 100644 index 0000000000..2d38beed5e --- /dev/null +++ b/monai/data/fft_utils.py @@ -0,0 +1,94 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import torch + +from monai.config.type_definitions import NdarrayOrTensor +from monai.networks.blocks.fft_utils_t import fftn_centered_t, ifftn_centered_t +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type + + +def ifftn_centered(ksp: NdarrayOrTensor, spatial_dims: int, is_complex: bool = True) -> NdarrayOrTensor: + """ + Pytorch-based ifft for spatial_dims-dim signals. "centered" means this function automatically takes care + of the required ifft and fft shifts. This function calls monai.metworks.blocks.fft_utils_t.ifftn_centered_t. + This is equivalent to do fft in numpy based on numpy.fft.ifftn, numpy.fft.fftshift, and numpy.fft.ifftshift + + Args: + ksp: k-space data that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + spatial_dims: number of spatial dimensions (e.g., is 2 for an image, and is 3 for a volume) + is_complex: if True, then the last dimension of the input ksp is expected to be 2 (representing real and imaginary channels) + + Returns: + "out" which is the output image (inverse fourier of ksp) + + Example: + + .. code-block:: python + + import torch + ksp = torch.ones(1,3,3,2) # the last dim belongs to real/imaginary parts + # output1 and output2 will be identical + output1 = torch.fft.ifftn(torch.view_as_complex(torch.fft.ifftshift(ksp,dim=(-3,-2))), dim=(-2,-1), norm="ortho") + output1 = torch.fft.fftshift( torch.view_as_real(output1), dim=(-3,-2) ) + + output2 = ifftn_centered(ksp, spatial_dims=2, is_complex=True) + """ + # handle numpy format + ksp_t, *_ = convert_data_type(ksp, torch.Tensor) + + # compute ifftn + out_t = ifftn_centered_t(ksp_t, spatial_dims=spatial_dims, is_complex=is_complex) + + # handle numpy format + out, *_ = convert_to_dst_type(src=out_t, dst=ksp) + return out + + +def fftn_centered(im: NdarrayOrTensor, spatial_dims: int, is_complex: bool = True) -> NdarrayOrTensor: + """ + Pytorch-based fft for spatial_dims-dim signals. "centered" means this function automatically takes care + of the required ifft and fft shifts. This function calls monai.metworks.blocks.fft_utils_t.fftn_centered_t. + This is equivalent to do ifft in numpy based on numpy.fft.fftn, numpy.fft.fftshift, and numpy.fft.ifftshift + + Args: + im: image that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + spatial_dims: number of spatial dimensions (e.g., is 2 for an image, and is 3 for a volume) + is_complex: if True, then the last dimension of the input im is expected to be 2 (representing real and imaginary channels) + + Returns: + "out" which is the output kspace (fourier of im) + + Example: + + .. code-block:: python + + import torch + im = torch.ones(1,3,3,2) # the last dim belongs to real/imaginary parts + # output1 and output2 will be identical + output1 = torch.fft.fftn(torch.view_as_complex(torch.fft.ifftshift(im,dim=(-3,-2))), dim=(-2,-1), norm="ortho") + output1 = torch.fft.fftshift( torch.view_as_real(output1), dim=(-3,-2) ) + + output2 = fftn_centered(im, spatial_dims=2, is_complex=True) + """ + # handle numpy format + im_t, *_ = convert_data_type(im, torch.Tensor) + + # compute ifftn + out_t = fftn_centered_t(im_t, spatial_dims=spatial_dims, is_complex=is_complex) + + # handle numpy format + out, *_ = convert_to_dst_type(src=out_t, dst=im) + return out diff --git a/monai/networks/blocks/feature_pyramid_network.py b/monai/networks/blocks/feature_pyramid_network.py index 2f7b903a19..2373cfc099 100644 --- a/monai/networks/blocks/feature_pyramid_network.py +++ b/monai/networks/blocks/feature_pyramid_network.py @@ -193,7 +193,7 @@ def __init__( conv_type_: Type[nn.Module] = Conv[Conv.CONV, spatial_dims] for m in self.modules(): if isinstance(m, conv_type_): - nn.init.kaiming_uniform_(m.weight, a=1) + nn.init.kaiming_uniform_(m.weight, a=1) # type: ignore nn.init.constant_(m.bias, 0.0) # type: ignore if extra_blocks is not None: diff --git a/monai/networks/blocks/fft_utils_t.py b/monai/networks/blocks/fft_utils_t.py new file mode 100644 index 0000000000..0d6b99d7e1 --- /dev/null +++ b/monai/networks/blocks/fft_utils_t.py @@ -0,0 +1,232 @@ +# 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 typing import Optional, Sequence + +import torch +from torch import Tensor + +from monai.utils.type_conversion import convert_data_type + + +def roll_1d(x: Tensor, shift: int, shift_dim: int) -> Tensor: + """ + Similar to roll but for only one dim. + + Args: + x: input data (k-space or image) that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + shift: the amount of shift along each of shift_dims dimension + shift_dim: the dimension over which the shift is applied + + Returns: + 1d-shifted version of x + + Note: + This function is called when fftshift and ifftshift are not available in the running pytorch version + """ + shift = shift % x.size(shift_dim) + if shift == 0: + return x + + left = x.narrow(shift_dim, 0, x.size(shift_dim) - shift) + right = x.narrow(shift_dim, x.size(shift_dim) - shift, shift) + + return torch.cat((right, left), dim=shift_dim) + + +def roll(x: Tensor, shift: Sequence[int], shift_dims: Sequence[int]) -> Tensor: + """ + Similar to np.roll but applies to PyTorch Tensors + + Args: + x: input data (k-space or image) that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + shift: the amount of shift along each of shift_dims dimensions + shift_dims: dimensions over which the shift is applied + + Returns: + shifted version of x + + Note: + This function is called when fftshift and ifftshift are not available in the running pytorch version + """ + if len(shift) != len(shift_dims): + raise ValueError(f"len(shift) != len(shift_dims), got f{len(shift)} and f{len(shift_dims)}.") + for s, d in zip(shift, shift_dims): + x = roll_1d(x, s, d) + return x + + +def fftshift(x: Tensor, shift_dims: Optional[Sequence[int]] = None) -> Tensor: + """ + Similar to np.fft.fftshift but applies to PyTorch Tensors + + Args: + x: input data (k-space or image) that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + shift_dims: dimensions over which the shift is applied + + Returns: + fft-shifted version of x + + Note: + This function is called when fftshift is not available in the running pytorch version + """ + if shift_dims is None: + # for torch.jit.script based on the fastmri repository + shift_dims = [0] * (x.dim()) + for i in range(1, x.dim()): + shift_dims[i] = i + shift = [0] * len(shift_dims) + for i, dim_num in enumerate(shift_dims): + shift[i] = x.shape[dim_num] // 2 + return roll(x, shift, shift_dims) + + +def ifftshift(x: Tensor, shift_dims: Optional[Sequence[int]] = None) -> Tensor: + """ + Similar to np.fft.ifftshift but applies to PyTorch Tensors + + Args: + x: input data (k-space or image) that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + shift_dims: dimensions over which the shift is applied + + Returns: + ifft-shifted version of x + + Note: + This function is called when ifftshift is not available in the running pytorch version + """ + if shift_dims is None: + # for torch.jit.script based on the fastmri repository + shift_dims = [0] * (x.dim()) + for i in range(1, x.dim()): + shift_dims[i] = i + shift = [0] * len(shift_dims) + for i, dim_num in enumerate(shift_dims): + shift[i] = (x.shape[dim_num] + 1) // 2 + return roll(x, shift, shift_dims) + + +def ifftn_centered_t(ksp: Tensor, spatial_dims: int, is_complex: bool = True) -> Tensor: + """ + Pytorch-based ifft for spatial_dims-dim signals. "centered" means this function automatically takes care + of the required ifft and fft shifts. + This is equivalent to do fft in numpy based on numpy.fft.ifftn, numpy.fft.fftshift, and numpy.fft.ifftshift + + Args: + ksp: k-space data that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + spatial_dims: number of spatial dimensions (e.g., is 2 for an image, and is 3 for a volume) + is_complex: if True, then the last dimension of the input ksp is expected to be 2 (representing real and imaginary channels) + + Returns: + "out" which is the output image (inverse fourier of ksp) + + Example: + + .. code-block:: python + + import torch + ksp = torch.ones(1,3,3,2) # the last dim belongs to real/imaginary parts + # output1 and output2 will be identical + output1 = torch.fft.ifftn(torch.view_as_complex(torch.fft.ifftshift(ksp,dim=(-3,-2))), dim=(-2,-1), norm="ortho") + output1 = torch.fft.fftshift( torch.view_as_real(output1), dim=(-3,-2) ) + + output2 = ifftn_centered(ksp, spatial_dims=2, is_complex=True) + """ + # define spatial dims to perform ifftshift, fftshift, and ifft + shift = tuple(range(-spatial_dims, 0)) + if is_complex: + if ksp.shape[-1] != 2: + raise ValueError(f"ksp.shape[-1] is not 2 ({ksp.shape[-1]}).") + shift = tuple(range(-spatial_dims - 1, -1)) + dims = tuple(range(-spatial_dims, 0)) + + # apply ifft + if hasattr(torch.fft, "ifftshift"): # ifftshift was added in pytorch 1.8 + x = torch.fft.ifftshift(ksp, dim=shift) + else: + x = ifftshift(ksp, shift) + + if is_complex: + x = torch.view_as_real(torch.fft.ifftn(torch.view_as_complex(x), dim=dims, norm="ortho")) + else: + x = torch.view_as_real(torch.fft.ifftn(x, dim=dims, norm="ortho")) + + if hasattr(torch.fft, "fftshift"): + out = convert_data_type(torch.fft.fftshift(x, dim=shift), torch.Tensor)[0] + else: + out = convert_data_type(fftshift(x, shift), torch.Tensor)[0] + + return out + + +def fftn_centered_t(im: Tensor, spatial_dims: int, is_complex: bool = True) -> Tensor: + """ + Pytorch-based fft for spatial_dims-dim signals. "centered" means this function automatically takes care + of the required ifft and fft shifts. + This is equivalent to do ifft in numpy based on numpy.fft.fftn, numpy.fft.fftshift, and numpy.fft.ifftshift + + Args: + im: image that can be + 1) real-valued: the shape is (C,H,W) for 2D spatial inputs and (C,H,W,D) for 3D, or + 2) complex-valued: the shape is (C,H,W,2) for 2D spatial data and (C,H,W,D,2) for 3D. C is the number of channels. + spatial_dims: number of spatial dimensions (e.g., is 2 for an image, and is 3 for a volume) + is_complex: if True, then the last dimension of the input im is expected to be 2 (representing real and imaginary channels) + + Returns: + "out" which is the output kspace (fourier of im) + + Example: + + .. code-block:: python + + import torch + im = torch.ones(1,3,3,2) # the last dim belongs to real/imaginary parts + # output1 and output2 will be identical + output1 = torch.fft.fftn(torch.view_as_complex(torch.fft.ifftshift(im,dim=(-3,-2))), dim=(-2,-1), norm="ortho") + output1 = torch.fft.fftshift( torch.view_as_real(output1), dim=(-3,-2) ) + + output2 = fftn_centered(im, spatial_dims=2, is_complex=True) + """ + # define spatial dims to perform ifftshift, fftshift, and fft + shift = tuple(range(-spatial_dims, 0)) + if is_complex: + if im.shape[-1] != 2: + raise ValueError(f"img.shape[-1] is not 2 ({im.shape[-1]}).") + shift = tuple(range(-spatial_dims - 1, -1)) + dims = tuple(range(-spatial_dims, 0)) + + # apply fft + if hasattr(torch.fft, "ifftshift"): # ifftshift was added in pytorch 1.8 + x = torch.fft.ifftshift(im, dim=shift) + else: + x = ifftshift(im, shift) + + if is_complex: + x = torch.view_as_real(torch.fft.fftn(torch.view_as_complex(x), dim=dims, norm="ortho")) + else: + x = torch.view_as_real(torch.fft.fftn(x, dim=dims, norm="ortho")) + + if hasattr(torch.fft, "fftshift"): + out = convert_data_type(torch.fft.fftshift(x, dim=shift), torch.Tensor)[0] + else: + out = convert_data_type(fftshift(x, shift), torch.Tensor)[0] + + return out diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 0d5bafb026..d50505fe84 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -313,10 +313,10 @@ def __call__(self, img: NdarrayOrTensor) -> List[NdarrayOrTensor]: if isinstance(img, torch.Tensor): outputs = list(torch.split(img, 1, self.dim)) else: - outputs = np.split(img, n_out, self.dim) + outputs = np.split(img, n_out, self.dim) # type: ignore if not self.keepdim: outputs = [o.squeeze(self.dim) for o in outputs] - return outputs + return outputs # type: ignore @deprecated(since="0.8", msg_suffix="please use `SplitDim` instead.") diff --git a/tests/test_fft_utils.py b/tests/test_fft_utils.py new file mode 100644 index 0000000000..d5e3a22eaa --- /dev/null +++ b/tests/test_fft_utils.py @@ -0,0 +1,63 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from parameterized import parameterized + +from monai.data.fft_utils import fftn_centered, ifftn_centered +from tests.utils import TEST_NDARRAYS, assert_allclose + +# +im = [[[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]] +res = [ + [[[0.0, 0.0], [0.0, 3.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]] +] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append((p(im), p(res))) + +# +TESTS_CONSISTENCY = [] +for p in TEST_NDARRAYS: + TESTS_CONSISTENCY.append(p(im)) + +# +im_complex = [ + [[[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]]] +] +TESTS_CONSISTENCY_COMPLEX = [] +for p in TEST_NDARRAYS: + TESTS_CONSISTENCY_COMPLEX.append(p(im_complex)) + + +class TestFFT(unittest.TestCase): + @parameterized.expand(TESTS) + def test(self, test_data, res_data): + result = fftn_centered(test_data, spatial_dims=2, is_complex=False) + assert_allclose(result, res_data, type_test=True) + + @parameterized.expand(TESTS_CONSISTENCY) + def test_consistency(self, test_data): + result = fftn_centered(test_data, spatial_dims=2, is_complex=False) + result = ifftn_centered(result, spatial_dims=2, is_complex=True) + result = (result[..., 0] ** 2 + result[..., 1] ** 2) ** 0.5 + assert_allclose(result, test_data, type_test=False) + + @parameterized.expand(TESTS_CONSISTENCY_COMPLEX) + def test_consistency_complex(self, test_data): + result = fftn_centered(test_data, spatial_dims=2) + result = ifftn_centered(result, spatial_dims=2) + assert_allclose(result, test_data, type_test=False) + + +if __name__ == "__main__": + unittest.main() From 91ec2b52049fb1ce138b950dc39d7a5c31d42a30 Mon Sep 17 00:00:00 2001 From: JHancox <48477639+JHancox@users.noreply.github.com> Date: Wed, 29 Jun 2022 10:51:28 +0100 Subject: [PATCH 172/183] Hovernet and associated test script added (#4602) * Hovernet and associated test script added Signed-off-by: JHancox --- monai/networks/nets/__init__.py | 1 + monai/networks/nets/hovernet.py | 532 ++++++++++++++++++++++++++++++++ tests/test_hovernet.py | 99 ++++++ 3 files changed, 632 insertions(+) create mode 100644 monai/networks/nets/hovernet.py create mode 100644 tests/test_hovernet.py diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index a85d55769c..6bf1868122 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -43,6 +43,7 @@ from .fullyconnectednet import FullyConnectedNet, VarFullyConnectedNet from .generator import Generator from .highresnet import HighResBlock, HighResNet +from .hovernet import Hovernet, HoVernet, HoVerNet, HoverNet from .milmodel import MILModel from .netadapter import NetAdapter from .regressor import Regressor diff --git a/monai/networks/nets/hovernet.py b/monai/networks/nets/hovernet.py new file mode 100644 index 0000000000..568d6658c5 --- /dev/null +++ b/monai/networks/nets/hovernet.py @@ -0,0 +1,532 @@ +# 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. + +# ========================================================================= +# Adapted from https://github.com/vqdang/hover_net +# which has the following license: +# https://github.com/vqdang/hover_net/blob/master/LICENSE +# MIT License + +# Origial publication: +# @article{graham2019hover, +# title={Hover-net: Simultaneous segmentation and classification of nuclei in multi-tissue histology images}, +# author={Graham, Simon and Vu, Quoc Dang and Raza, Shan E Ahmed and Azam, Ayesha and Tsang, Yee Wah and Kwak, +# Jin Tae and Rajpoot, Nasir}, +# journal={Medical Image Analysis}, +# pages={101563}, +# year={2019}, +# publisher={Elsevier} +# } + +# ========================================================================= + +from collections import OrderedDict +from enum import Enum +from typing import Callable, Dict, List, Sequence, Type, Union + +import torch +import torch.nn as nn + +from monai.networks.blocks import UpSample +from monai.networks.layers.factories import Conv, Dropout +from monai.networks.layers.utils import get_act_layer, get_norm_layer +from monai.utils import InterpolateMode, UpsampleMode, export + +__all__ = ["HoverNet", "Hovernet", "HoVernet", "HoVerNet"] + + +class _DenseLayerDecoder(nn.Module): + def __init__( + self, + num_features: int, + in_channels: int, + out_channels: int, + dropout_prob: float = 0.0, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + kernel_size: int = 3, + ) -> None: + """ + Args: + spatial_dims: number of spatial dimensions of the input image. + num_features: number of internal channels used for the layer + in_channels: number of the input channels. + out_channels: number of the output channels. + dropout_prob: dropout rate after each dense layer. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + kernel_size: size of the kernel for >1 convolutions (dependent on mode) + """ + super().__init__() + + conv_type: Callable = Conv[Conv.CONV, 2] + dropout_type: Callable = Dropout[Dropout.DROPOUT, 2] + + self.layers = nn.Sequential() + + self.layers.add_module("preact_bna/bn", get_norm_layer(name=norm, spatial_dims=2, channels=in_channels)) + self.layers.add_module("preact_bna/relu", get_act_layer(name=act)) + self.layers.add_module("conv1", conv_type(in_channels, num_features, kernel_size=1, bias=False)) + self.layers.add_module("conv1/norm", get_norm_layer(name=norm, spatial_dims=2, channels=num_features)) + self.layers.add_module("conv1/relu2", get_act_layer(name=act)) + self.layers.add_module( + "conv2", conv_type(num_features, out_channels, kernel_size=kernel_size, padding=0, groups=4, bias=False) + ) + + if dropout_prob > 0: + self.layers.add_module("dropout", dropout_type(dropout_prob)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + + x1 = self.layers(x) + if x1.shape != x.shape: + trim = (x.shape[-1] - x1.shape[-1]) // 2 + x = x[:, :, trim:-trim, trim:-trim] + + x = torch.cat([x, x1], 1) + + return x + + +class _DecoderBlock(nn.Sequential): + def __init__( + self, + layers: int, + num_features: int, + in_channels: int, + out_channels: int, + dropout_prob: float = 0.0, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + kernel_size: int = 3, + ) -> None: + """ + Args: + spatial_dims: number of spatial dimensions of the input image. + layers: number of layers in the block. + num_features: number of internal features used. + in_channels: number of the input channel. + out_channels: number of the output channel. + dropout_prob: dropout rate after each dense layer. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + kernel_size: size of the kernel for >1 convolutions (dependent on mode) + """ + super().__init__() + + conv_type: Callable = Conv[Conv.CONV, 2] + + self.add_module("conva", conv_type(in_channels, in_channels // 4, kernel_size=kernel_size, bias=False)) + + _in_channels = in_channels // 4 + for i in range(layers): + layer = _DenseLayerDecoder( + num_features, _in_channels, out_channels, dropout_prob, act=act, norm=norm, kernel_size=kernel_size + ) + _in_channels += out_channels + self.add_module("denselayerdecoder%d" % (i + 1), layer) + + trans = _Transition(_in_channels, act=act, norm=norm) + self.add_module("bna_block", trans) + self.add_module("convf", conv_type(_in_channels, _in_channels, kernel_size=1, bias=False)) + + +class _DenseLayer(nn.Sequential): + def __init__( + self, + num_features: int, + in_channels: int, + out_channels: int, + dropout_prob: float = 0.0, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + drop_first_norm_relu: int = 0, + kernel_size: int = 3, + ) -> None: + """Dense Convolutional Block. + + References: + Huang, Gao, et al. "Densely connected convolutional networks." + Proceedings of the IEEE conference on computer vision and + pattern recognition. 2017. + + Args: + num_features: number of internal channels used for the layer + in_channels: number of the input channels. + out_channels: number of the output channels. + dropout_prob: dropout rate after each dense layer. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + drop_first_norm_relu - omits the first norm/relu for the first layer + kernel_size: size of the kernel for >1 convolutions (dependent on mode) + """ + super().__init__() + + self.layers = nn.Sequential() + conv_type: Callable = Conv[Conv.CONV, 2] + dropout_type: Callable = Dropout[Dropout.DROPOUT, 2] + + if not drop_first_norm_relu: + self.layers.add_module("preact_norm", get_norm_layer(name=norm, spatial_dims=2, channels=in_channels)) + self.layers.add_module("preact_relu", get_act_layer(name=act)) + + self.layers.add_module("conv1", conv_type(in_channels, num_features, kernel_size=1, padding=0, bias=False)) + self.layers.add_module("norm2", get_norm_layer(name=norm, spatial_dims=2, channels=num_features)) + self.layers.add_module("relu2", get_act_layer(name=act)) + + if in_channels != 64 and drop_first_norm_relu: + self.layers.add_module( + "conv2", conv_type(num_features, num_features, kernel_size=kernel_size, stride=2, padding=2, bias=False) + ) + else: + self.layers.add_module("conv2", conv_type(num_features, num_features, kernel_size=1, padding=0, bias=False)) + + self.layers.add_module("norm3", get_norm_layer(name=norm, spatial_dims=2, channels=num_features)) + self.layers.add_module("relu3", get_act_layer(name=act)) + self.layers.add_module("conv3", conv_type(num_features, out_channels, kernel_size=1, padding=0, bias=False)) + + if dropout_prob > 0: + self.layers.add_module("dropout", dropout_type(dropout_prob)) + + +class _Transition(nn.Sequential): + def __init__( + self, in_channels: int, act: Union[str, tuple] = ("relu", {"inplace": True}), norm: Union[str, tuple] = "batch" + ) -> None: + """ + Args: + in_channels: number of the input channel. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + """ + super().__init__() + + self.add_module("norm", get_norm_layer(name=norm, spatial_dims=2, channels=in_channels)) + self.add_module("relu", get_act_layer(name=act)) + + +class _ResidualBlock(nn.Module): + def __init__( + self, + layers: int, + num_features: int, + in_channels: int, + out_channels: int, + dropout_prob: float = 0.0, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + ) -> None: + """Residual block. + + References: + He, Kaiming, et al. "Deep residual learning for image + recognition." Proceedings of the IEEE conference on computer + vision and pattern recognition. 2016. + + Args: + layers: number of layers in the block. + num_features: number of internal features used. + in_channels: number of the input channel. + out_channels: number of the output channel. + dropout_prob: dropout rate after each dense layer. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + """ + super().__init__() + + self.layers = nn.Sequential() + conv_type: Callable = Conv[Conv.CONV, 2] + + if in_channels == 64: + self.shortcut = conv_type(in_channels, out_channels, kernel_size=1, bias=False) + else: + self.shortcut = conv_type(in_channels, out_channels, kernel_size=1, stride=2, padding=1, bias=False) + + layer = _DenseLayer( + num_features, in_channels, out_channels, dropout_prob, act=act, norm=norm, drop_first_norm_relu=True + ) + self.layers.add_module("prim_denselayer%d" % (1), layer) + + for i in range(1, layers): + layer = _DenseLayer(num_features, out_channels, out_channels, dropout_prob, act=act, norm=norm) + self.layers.add_module("main_denselayer%d" % (i + 1), layer) + + self.bna_block = _Transition(out_channels, act=act, norm=norm) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + + sc = self.shortcut(x) + + if self.shortcut.stride == (2, 2): + sc = sc[:, :, :-1, :-1] + + for layer in self.layers: + x = layer.forward(x) + if x.shape[-2:] != sc.shape[-2:]: + x = x[:, :, :-1, :-1] + + x = x + sc + sc = x + + x = self.bna_block(x) + + return x + + +class _DecoderBranch(nn.ModuleList): + def __init__( + self, + decode_config: Sequence[int] = (8, 4), + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + dropout_prob: float = 0.0, + out_channels: int = 2, + kernel_size: int = 3, + ) -> None: + """ + Args: + decode_config: number of layers for each block. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + dropout_prob: dropout rate after each dense layer. + num_features: number of internal features used. + out_channels: number of the output channel. + kernel_size: size of the kernel for >1 convolutions (dependent on mode) + """ + super().__init__() + conv_type: Callable = Conv[Conv.CONV, 2] + + # decode branches + _in_channels = 1024 + _num_features = 128 + _out_channels = 32 + + self.decoder_blocks = nn.Sequential() + for i, num_layers in enumerate(decode_config): + block = _DecoderBlock( + layers=num_layers, + num_features=_num_features, + in_channels=_in_channels, + out_channels=_out_channels, + dropout_prob=dropout_prob, + act=act, + norm=norm, + kernel_size=kernel_size, + ) + self.decoder_blocks.add_module(f"decoderblock{i + 1}", block) + _in_channels = 512 + + # output layers + self.output_features = nn.Sequential() + _i = len(decode_config) + _pad_size = (kernel_size - 1) // 2 + _seq_block = nn.Sequential( + OrderedDict( + [("conva", conv_type(256, 64, kernel_size=kernel_size, stride=1, bias=False, padding=_pad_size))] + ) + ) + + self.output_features.add_module(f"decoderblock{_i + 1}", _seq_block) + + _seq_block = nn.Sequential( + OrderedDict( + [ + ("norm", get_norm_layer(name=norm, spatial_dims=2, channels=64)), + ("relu", get_act_layer(name=act)), + ("conv", conv_type(64, out_channels, kernel_size=1, stride=1)), + ] + ) + ) + + self.output_features.add_module(f"decoderblock{_i + 2}", _seq_block) + + self.upsample = UpSample( + 2, scale_factor=2, mode=UpsampleMode.NONTRAINABLE, interp_mode=InterpolateMode.BILINEAR, bias=False + ) + + def forward(self, xin: torch.Tensor, short_cuts: List[torch.Tensor]) -> torch.Tensor: + + block_number = len(short_cuts) - 1 + x = xin + short_cuts[block_number] + + for block in self.decoder_blocks: + x = block(x) + x = self.upsample(x) + block_number -= 1 + trim = (short_cuts[block_number].shape[-1] - x.shape[-1]) // 2 + x += short_cuts[block_number][:, :, trim:-trim, trim:-trim] + + for block in self.output_features: + x = block(x) + + return x + + +@export("monai.networks.nets") +class HoverNet(nn.Module): + """HoVerNet + + References: + Graham, Simon et al. Hover-net: Simultaneous segmentation + and classification of nuclei in multi-tissue histology images, + Medical Image Analysis 2019 + + Args: + in_channels: number of the input channel. + out_classes: number of the nuclear type classes. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. + dropout_prob: dropout rate after each dense layer. + """ + + class Mode(Enum): + FAST: int = 0 + ORIGINAL: int = 1 + + def _mode_to_int(self, mode) -> int: + + if mode == self.Mode.FAST: + return 0 + else: + return 1 + + def __init__( + self, + mode: Mode = Mode.FAST, + in_channels: int = 3, + out_classes: int = 0, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + dropout_prob: float = 0.0, + ) -> None: + + super().__init__() + + self.mode: int = self._mode_to_int(mode) + + if mode not in [self.Mode.ORIGINAL, self.Mode.FAST]: + raise ValueError("Input size should be 270 x 270 when using Mode.ORIGINAL") + + if out_classes > 128: + raise ValueError("Number of nuclear types classes exceeds maximum (128)") + elif out_classes == 1: + raise ValueError("Number of nuclear type classes should either be None or >1") + + if dropout_prob > 1 or dropout_prob < 0: + raise ValueError("Dropout can only be in the range 0.0 to 1.0") + + # number of filters in the first convolution layer. + _init_features: int = 64 + # number of layers in each pooling block. + _block_config: Sequence[int] = (3, 4, 6, 3) + + if mode == self.Mode.FAST: + _ksize = 3 + _pad = 3 + else: + _ksize = 5 + _pad = 0 + + conv_type: Type[nn.Conv2d] = Conv[Conv.CONV, 2] + + self.input_features = nn.Sequential( + OrderedDict( + [ + ( + "conv0", + conv_type(in_channels, _init_features, kernel_size=7, stride=1, padding=_pad, bias=False), + ), + ("norm0", get_norm_layer(name=norm, spatial_dims=2, channels=_init_features)), + ("relu0", get_act_layer(name=act)), + ] + ) + ) + + _in_channels = _init_features + _out_channels = 256 + _num_features = _init_features + + self.res_blocks = nn.Sequential() + + for i, num_layers in enumerate(_block_config): + block = _ResidualBlock( + layers=num_layers, + num_features=_num_features, + in_channels=_in_channels, + out_channels=_out_channels, + dropout_prob=dropout_prob, + act=act, + norm=norm, + ) + self.res_blocks.add_module(f"residualblock{i + 1}", block) + + _in_channels = _out_channels + _out_channels *= 2 + _num_features *= 2 + + # bottleneck convolution + self.bottleneck = nn.Sequential() + self.bottleneck.add_module( + "conv_bottleneck", conv_type(_in_channels, _num_features, kernel_size=1, stride=1, padding=0, bias=False) + ) + self.upsample = UpSample( + 2, scale_factor=2, mode=UpsampleMode.NONTRAINABLE, interp_mode=InterpolateMode.BILINEAR, bias=False + ) + + # decode branches + self.nucleus_prediction = _DecoderBranch(kernel_size=_ksize) + self.horizontal_vertical = _DecoderBranch(kernel_size=_ksize) + self.type_prediction: _DecoderBranch = None # type: ignore + + if out_classes > 0: + self.type_prediction = _DecoderBranch(out_channels=out_classes, kernel_size=_ksize) + + for m in self.modules(): + if isinstance(m, conv_type): + nn.init.kaiming_normal_(torch.as_tensor(m.weight)) + elif isinstance(m, nn.BatchNorm2d): + nn.init.constant_(torch.as_tensor(m.weight), 1) + nn.init.constant_(torch.as_tensor(m.bias), 0) + + def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: + + if self.mode == 1: + if x.shape[-1] != 270 or x.shape[-2] != 270: + raise ValueError("Input size should be 270 x 270 when using Mode.ORIGINAL") + else: + if x.shape[-1] != 256 or x.shape[-2] != 256: + raise ValueError("Input size should be 256 x 256 when using Mode.FAST") + + x = x / 255.0 # to 0-1 range to match XY + x = self.input_features(x) + short_cuts = [] + + for i, block in enumerate(self.res_blocks): + x = block.forward(x) + + if i <= 2: + short_cuts.append(x) + + x = self.bottleneck(x) + x = self.upsample(x) + + x_np = self.nucleus_prediction(x, short_cuts) + x_hv = self.horizontal_vertical(x, short_cuts) + tp = self.type_prediction + + if tp is not None: + x_tp = self.type_prediction(x, short_cuts) + return {"nucleus_prediction": x_np, "horizonal_vertical": x_hv, "type_prediction": x_tp} + + return {"nucleus_prediction": x_np, "horizonal_vertical": x_hv} + + +Hovernet = HoVernet = HoVerNet = HoverNet diff --git a/tests/test_hovernet.py b/tests/test_hovernet.py new file mode 100644 index 0000000000..568aeb04dc --- /dev/null +++ b/tests/test_hovernet.py @@ -0,0 +1,99 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.networks import eval_mode +from monai.networks.nets import HoverNet +from tests.utils import test_script_save + +device = "cuda" if torch.cuda.is_available() else "cpu" + +TEST_CASE_0 = [ # fast mode, batch 16 + {"out_classes": 5, "mode": HoverNet.Mode.FAST}, + (1, 3, 256, 256), + { + "nucleus_prediction": (1, 2, 164, 164), + "type_prediction": (1, 5, 164, 164), + "horizonal_vertical": (1, 2, 164, 164), + }, +] + +TEST_CASE_1 = [ # single channel 2D, batch 16 + {"mode": HoverNet.Mode.FAST}, + (1, 3, 256, 256), + {"nucleus_prediction": (1, 2, 164, 164), "horizonal_vertical": (1, 2, 164, 164)}, +] + +TEST_CASE_2 = [ # single channel 3D, batch 16 + {"mode": HoverNet.Mode.ORIGINAL}, + (1, 3, 270, 270), + {"nucleus_prediction": (1, 2, 80, 80), "horizonal_vertical": (1, 2, 80, 80)}, +] + +TEST_CASE_3 = [ # 4-channel 3D, batch 16 + {"out_classes": 6, "mode": HoverNet.Mode.ORIGINAL}, + (1, 3, 270, 270), + {"nucleus_prediction": (1, 2, 80, 80), "type_prediction": (1, 6, 80, 80), "horizonal_vertical": (1, 2, 80, 80)}, +] + +TEST_CASE_4 = [ # 4-channel 3D, batch 16, batch normalization + {"mode": HoverNet.Mode.FAST, "dropout_prob": 0.5}, + (1, 3, 256, 256), + {"nucleus_prediction": (1, 2, 164, 164), "horizonal_vertical": (1, 2, 164, 164)}, +] + +CASES = [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4] + +ILL_CASES = [ + [{"out_classes": 6, "mode": 3}], + [{"out_classes": 1000, "mode": HoverNet.Mode.ORIGINAL}], + [{"out_classes": 1, "mode": HoverNet.Mode.ORIGINAL}], + [{"out_classes": 6, "mode": HoverNet.Mode.ORIGINAL, "dropout_prob": 100}], +] + + +class TestHoverNet(unittest.TestCase): + @parameterized.expand(CASES) + def test_shape(self, input_param, input_shape, expected_shapes): + net = HoverNet(**input_param).to(device) + with eval_mode(net): + result = net.forward(torch.randn(input_shape).to(device)) + for item in result: + self.assertEqual(result[item].shape, expected_shapes[item]) + + def test_script(self): + net = HoverNet(mode=HoverNet.Mode.FAST) + test_data = torch.randn(1, 3, 256, 256) + test_script_save(net, test_data) + + def test_script_without_running_stats(self): + net = HoverNet(mode=HoverNet.Mode.FAST) + test_data = torch.randn(1, 3, 256, 256) + test_script_save(net, test_data) + + def test_ill_input_shape(self): + net = HoverNet(mode=HoverNet.Mode.FAST) + with eval_mode(net): + with self.assertRaises(ValueError): + net.forward(torch.randn(1, 3, 270, 260)) + + @parameterized.expand(ILL_CASES) + def test_ill_input_hyper_params(self, input_param): + with self.assertRaises(ValueError): + _ = HoverNet(**input_param) + + +if __name__ == "__main__": + unittest.main(argv=["first-arg-is-ignored"], exit=False) From 112d36c659b0cae510f44f78edfddea93ba314d3 Mon Sep 17 00:00:00 2001 From: vale-salvatelli Date: Wed, 29 Jun 2022 12:42:36 +0100 Subject: [PATCH 173/183] Fix the location type returned in GridPatch when padding (#4598) * Fix the location type returned in GridPatch when padding This PR fixes this issue https://github.com/Project-MONAI/MONAI/issues/4597 * signing and fixing docstring Signed-off-by: Valentina Salvatelli --- monai/transforms/spatial/array.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 05ee474590..83792d49a7 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2668,6 +2668,7 @@ class GridPatch(Transform): patch_size: size of patches to generate slices for, 0 or None selects whole dimension offset: offset of starting position in the array, default is 0 for each dimension. num_patches: number of patches to return. Defaults to None, which returns all the available patches. + If the required patches are more than the available patches, padding will be applied. overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. sort_fn: when `num_patches` is provided, it determines if keep patches with highest values (`"max"`), @@ -2770,7 +2771,7 @@ def __call__(self, array: NdarrayOrTensor): patch = convert_to_dst_type( src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), dst=array )[0] - start_location = convert_to_dst_type(src=np.zeros((len(self.patch_size), 1)), dst=array)[0] + start_location = convert_to_dst_type(src=np.zeros(len(self.patch_size)), dst=array)[0] output += [(patch, start_location)] * (self.num_patches - len(output)) return output From 7f80288381575121e670a4e184d45b01813a51be Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Thu, 30 Jun 2022 02:57:50 -0400 Subject: [PATCH 174/183] debug device conflict (#4614) * debug device conflict Signed-off-by: Can Zhao * debug device conflict Signed-off-by: Can Zhao --- monai/data/box_utils.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 47e7b6911d..65e5a06c74 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -959,25 +959,23 @@ def spatial_crop_boxes( - ``keep``, it indicates whether each box in ``boxes`` are kept when ``remove_empty=True``. """ - roi_start_torch, *_ = convert_data_type( - data=roi_start, output_type=torch.Tensor, dtype=torch.int16, wrap_sequence=True - ) - roi_end_torch, *_ = convert_to_dst_type(src=roi_end, dst=roi_start_torch, wrap_sequence=True) - roi_end_torch = torch.maximum(roi_end_torch, roi_start_torch) - # convert numpy to tensor if needed boxes_t, *_ = convert_data_type(deepcopy(boxes), torch.Tensor) # convert to float32 since torch.clamp_ does not support float16 boxes_t = boxes_t.to(dtype=COMPUTE_DTYPE) + roi_start_t = convert_to_dst_type(src=roi_start, dst=boxes_t, wrap_sequence=True)[0].to(torch.int16) + roi_end_t = convert_to_dst_type(src=roi_end, dst=boxes_t, wrap_sequence=True)[0].to(torch.int16) + roi_end_t = torch.maximum(roi_end_t, roi_start_t) + # makes sure the bounding boxes are within the patch spatial_dims = get_spatial_dims(boxes=boxes, spatial_size=roi_end) for axis in range(0, spatial_dims): - boxes_t[:, axis].clamp_(min=roi_start_torch[axis], max=roi_end_torch[axis] - TO_REMOVE) - boxes_t[:, axis + spatial_dims].clamp_(min=roi_start_torch[axis], max=roi_end_torch[axis] - TO_REMOVE) - boxes_t[:, axis] -= roi_start_torch[axis] - boxes_t[:, axis + spatial_dims] -= roi_start_torch[axis] + boxes_t[:, axis].clamp_(min=roi_start_t[axis], max=roi_end_t[axis] - TO_REMOVE) + boxes_t[:, axis + spatial_dims].clamp_(min=roi_start_t[axis], max=roi_end_t[axis] - TO_REMOVE) + boxes_t[:, axis] -= roi_start_t[axis] + boxes_t[:, axis + spatial_dims] -= roi_start_t[axis] # remove the boxes that are actually empty if remove_empty: From edf3b742a4ae85d1f30462ed0c7511c520fae888 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 30 Jun 2022 19:07:13 +0100 Subject: [PATCH 175/183] 4531 test/update deps versions (#4605) * ignite 0.4.9 Signed-off-by: Wenqi Li * pytorch docker 22.05 Signed-off-by: Wenqi Li * pytorch 1.8.2, 1.12.0 Signed-off-by: Wenqi Li * temp tests Signed-off-by: Wenqi Li * update install command 1.8.2 Signed-off-by: Wenqi Li * fixes mypy Signed-off-by: Wenqi Li * Revert "temp tests" This reverts commit 5167d65e44eaea4cbd0c3e51d2d69b5621b49ce7. Signed-off-by: Wenqi Li * test 22.06 Signed-off-by: Wenqi Li * fixes unit tests Signed-off-by: Wenqi Li --- .github/workflows/cron.yml | 6 +++--- .github/workflows/integration.yml | 4 ++-- .github/workflows/pythonapp-gpu.yml | 19 ++++++++++--------- .github/workflows/pythonapp-min.yml | 14 ++++++++------ .github/workflows/pythonapp.yml | 4 ++-- .github/workflows/setupapp.yml | 4 ++-- Dockerfile | 2 +- docs/requirements.txt | 2 +- requirements-dev.txt | 2 +- setup.cfg | 4 ++-- tests/test_handler_tb_image.py | 4 ++++ tests/test_handler_tb_stats.py | 3 ++- tests/test_plot_2d_or_3d_image.py | 3 ++- 13 files changed, 40 insertions(+), 31 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index e487fda266..dca7f39617 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -62,7 +62,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.04"] # 21.02, 21.10 for backward comp. + container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.06"] # 21.02, 21.10 for backward comp. container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -106,7 +106,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.04"] # 21.02, 21.10 for backward comp. + container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.06"] # 21.02, 21.10 for backward comp. container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -204,7 +204,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' needs: cron-gpu # so that monai itself is verified first container: - image: nvcr.io/nvidia/pytorch:22.04-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:22.06-py3 # testing with the latest pytorch base image options: "--gpus all --ipc=host" runs-on: [self-hosted, linux, x64, common] steps: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 231e4656e1..951b09d082 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -7,7 +7,7 @@ on: jobs: integration-py3: container: - image: nvcr.io/nvidia/pytorch:21.12-py3 # CUDA 11.5 + image: nvcr.io/nvidia/pytorch:22.04-py3 # CUDA 11.6 options: --gpus all # shm-size 4g works fine runs-on: [self-hosted, linux, x64, common] steps: @@ -34,7 +34,7 @@ jobs: which python python -m pip install --upgrade pip wheel python -m pip uninstall -y torch torchvision - python -m pip install torch==1.11.0+cu115 torchvision==0.12.0+cu115 -f https://download.pytorch.org/whl/torch_stable.html + python -m pip install torch==1.12.0+cu116 torchvision==0.13.0+cu116 -f https://download.pytorch.org/whl/torch_stable.html python -m pip install -r requirements-dev.txt rm -rf /github/home/.cache/torch/hub/mmars/ - name: Run integration tests diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index f70996874d..52e588a555 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -23,16 +23,17 @@ jobs: - "PT17+CUDA102" - "PT18+CUDA102" - "PT18+CUDA112" - - "PT111+CUDA116" + - "PT112+CUDA116" - "PT110+CUDA102" - - "PT111+CUDA102" + - "PT112+CUDA102" include: # https://docs.nvidia.com/deeplearning/frameworks/pytorch-release-notes - environment: PT17+CUDA102 pytorch: "torch==1.7.1 torchvision==0.8.2" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - environment: PT18+CUDA102 - pytorch: "torch==1.8.1 torchvision==0.9.1" + # pytorch 1.8.2 LTS + pytorch: "torch==1.8.2 torchvision==0.9.2 --extra-index-url https://download.pytorch.org/whl/lts/1.8/cu102" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - environment: PT18+CUDA112 # we explicitly set pytorch to -h to avoid pip install error @@ -44,16 +45,16 @@ jobs: # 21.10: 1.10.0a0+0aef44c pytorch: "-h" base: "nvcr.io/nvidia/pytorch:21.10-py3" - - environment: PT111+CUDA116 + - environment: PT112+CUDA116 # we explicitly set pytorch to -h to avoid pip install error - # 22.04: 1.12.0a0+bd13bc6 + # 22.06: 1.13.0a0+340c412 pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:22.04-py3" + base: "nvcr.io/nvidia/pytorch:22.06-py3" - environment: PT110+CUDA102 pytorch: "torch==1.10.2 torchvision==0.11.3" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - - environment: PT111+CUDA102 - pytorch: "torch==1.11.0 torchvision==0.12.0" + - environment: PT112+CUDA102 + pytorch: "torch==1.12.0 torchvision==0.13.0" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" container: image: ${{ matrix.base }} @@ -73,7 +74,7 @@ jobs: if [ ${{ matrix.environment }} = "PT17+CUDA102" ] || \ [ ${{ matrix.environment }} = "PT18+CUDA102" ] || \ [ ${{ matrix.environment }} = "PT110+CUDA102" ] || \ - [ ${{ matrix.environment }} = "PT111+CUDA102" ] + [ ${{ matrix.environment }} = "PT112+CUDA102" ] then PYVER=3.7 PYSFX=3 DISTUTILS=python3-distutils && \ apt-get update && apt-get install -y --no-install-recommends \ diff --git a/.github/workflows/pythonapp-min.yml b/.github/workflows/pythonapp-min.yml index ef1291420a..8a9f4dbe67 100644 --- a/.github/workflows/pythonapp-min.yml +++ b/.github/workflows/pythonapp-min.yml @@ -51,11 +51,11 @@ jobs: - if: runner.os == 'windows' name: Install torch cpu from pytorch.org (Windows only) run: | - python -m pip install torch==1.11.0+cpu -f https://download.pytorch.org/whl/torch_stable.html + python -m pip install torch==1.12.0+cpu -f https://download.pytorch.org/whl/torch_stable.html - name: Install the dependencies run: | # min. requirements - python -m pip install torch==1.11.0 + python -m pip install torch==1.12.0 python -m pip install -r requirements-min.txt python -m pip list BUILD_MONAI=0 python setup.py develop # no compile of extensions @@ -101,7 +101,7 @@ jobs: - name: Install the dependencies run: | # min. requirements - python -m pip install torch==1.11.0 + python -m pip install torch==1.12.0 python -m pip install -r requirements-min.txt python -m pip list BUILD_MONAI=0 python setup.py develop # no compile of extensions @@ -119,7 +119,7 @@ jobs: strategy: fail-fast: false matrix: - pytorch-version: [1.7.1, 1.8.1, 1.9.1, 1.10.2, latest] + pytorch-version: [1.7.1, 1.8.2, 1.9.1, 1.10.2, 1.11.0, latest] timeout-minutes: 40 steps: - uses: actions/checkout@v3 @@ -150,12 +150,14 @@ jobs: python -m pip install torch elif [ ${{ matrix.pytorch-version }} == "1.7.1" ]; then python -m pip install torch==1.7.1 - elif [ ${{ matrix.pytorch-version }} == "1.8.1" ]; then - python -m pip install torch==1.8.1 + elif [ ${{ matrix.pytorch-version }} == "1.8.2" ]; then + python -m pip install torch==1.8.2 --extra-index-url https://download.pytorch.org/whl/lts/1.8/cpu elif [ ${{ matrix.pytorch-version }} == "1.9.1" ]; then python -m pip install torch==1.9.1 elif [ ${{ matrix.pytorch-version }} == "1.10.2" ]; then python -m pip install torch==1.10.2 + elif [ ${{ matrix.pytorch-version }} == "1.11.0" ]; then + python -m pip install torch==1.11.0 fi python -m pip install -r requirements-min.txt python -m pip list diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index d07e6d4494..a0b1070bd4 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -87,10 +87,10 @@ jobs: - if: runner.os == 'windows' name: Install torch cpu from pytorch.org (Windows only) run: | - python -m pip install torch==1.11.0+cpu torchvision==0.12.0+cpu -f https://download.pytorch.org/whl/torch_stable.html + python -m pip install torch==1.12.0+cpu torchvision==0.13.0+cpu -f https://download.pytorch.org/whl/torch_stable.html - name: Install the dependencies run: | - python -m pip install torch==1.11.0 torchvision==0.12.0 + python -m pip install torch==1.12.0 torchvision==0.13.0 cat "requirements-dev.txt" python -m pip install -r requirements-dev.txt python -m pip list diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index cad1d44917..4a2a46cfc1 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -44,7 +44,7 @@ jobs: python -m pip install --upgrade pip wheel python -m pip uninstall -y torch torchvision rm -rf $(python -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")/ruamel* - python -m pip install torch==1.11.0+cu113 torchvision==0.12.0+cu113 -f https://download.pytorch.org/whl/torch_stable.html + python -m pip install torch==1.12.0+cu113 torchvision==0.13.0+cu113 -f https://download.pytorch.org/whl/torch_stable.html python -m pip install -r requirements-dev.txt - name: Run unit tests report coverage run: | @@ -98,7 +98,7 @@ jobs: - name: Install the dependencies run: | python -m pip install --upgrade pip wheel - python -m pip install torch==1.11.0 torchvision==0.12.0 + python -m pip install torch==1.12.0 torchvision==0.13.0 python -m pip install -r requirements-dev.txt - name: Run quick tests CPU ubuntu run: | diff --git a/Dockerfile b/Dockerfile index aee64b0edc..26a1fc242f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # To build with a different base image # please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.04-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.06-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" diff --git a/docs/requirements.txt b/docs/requirements.txt index 50c3f07b5d..09fff56b8b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -f https://download.pytorch.org/whl/cpu/torch-1.6.0%2Bcpu-cp37-cp37m-linux_x86_64.whl torch>=1.6 -pytorch-ignite==0.4.8 +pytorch-ignite==0.4.9 numpy>=1.17 itk>=5.2 nibabel diff --git a/requirements-dev.txt b/requirements-dev.txt index 0c043ec420..8318a1795c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ # Full requirements for developments -r requirements-min.txt -pytorch-ignite==0.4.8 +pytorch-ignite==0.4.9 gdown>=4.4.0 scipy itk>=5.2 diff --git a/setup.cfg b/setup.cfg index d10ca8ff49..d929b2c986 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,7 +34,7 @@ all = pillow tensorboard gdown>=4.4.0 - pytorch-ignite==0.4.8 + pytorch-ignite==0.4.9 torchvision itk>=5.2 tqdm>=4.47.0 @@ -66,7 +66,7 @@ tensorboard = gdown = gdown>=4.4.0 ignite = - pytorch-ignite==0.4.8 + pytorch-ignite==0.4.9 torchvision = torchvision itk = diff --git a/tests/test_handler_tb_image.py b/tests/test_handler_tb_image.py index d11bbfec59..749480e279 100644 --- a/tests/test_handler_tb_image.py +++ b/tests/test_handler_tb_image.py @@ -20,10 +20,14 @@ from monai.data import decollate_batch from monai.handlers import TensorBoardImageHandler +from monai.utils import optional_import + +_, has_tb = optional_import("torch.utils.tensorboard", name="SummaryWriter") TEST_CASES = [[[20, 20]], [[2, 20, 20]], [[3, 20, 20]], [[20, 20, 20]], [[2, 20, 20, 20]], [[2, 2, 20, 20, 20]]] +@unittest.skipUnless(has_tb, "Requires SummaryWriter installation") class TestHandlerTBImage(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_tb_image_shape(self, shape): diff --git a/tests/test_handler_tb_stats.py b/tests/test_handler_tb_stats.py index 9335e48080..eef77e5e2b 100644 --- a/tests/test_handler_tb_stats.py +++ b/tests/test_handler_tb_stats.py @@ -18,9 +18,10 @@ from monai.handlers import TensorBoardStatsHandler from monai.utils import optional_import -SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") +SummaryWriter, has_tb = optional_import("torch.utils.tensorboard", name="SummaryWriter") +@unittest.skipUnless(has_tb, "Requires SummaryWriter installation") class TestHandlerTBStats(unittest.TestCase): def test_metrics_print(self): with tempfile.TemporaryDirectory() as tempdir: diff --git a/tests/test_plot_2d_or_3d_image.py b/tests/test_plot_2d_or_3d_image.py index 24fb6f0dde..5325c6a294 100644 --- a/tests/test_plot_2d_or_3d_image.py +++ b/tests/test_plot_2d_or_3d_image.py @@ -20,7 +20,7 @@ from monai.visualize import plot_2d_or_3d_image from tests.utils import SkipIfNoModule -SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") +SummaryWriter, has_tb = optional_import("torch.utils.tensorboard", name="SummaryWriter") SummaryWriterX, _ = optional_import("tensorboardX", name="SummaryWriter") @@ -35,6 +35,7 @@ TEST_CASE_5 = [(1, 3, 10, 10, 10)] +@unittest.skipUnless(has_tb, "Requires SummaryWriter installation") class TestPlot2dOr3dImage(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) def test_tb_image(self, shape): From b45794ab42b29fd01634396f99821d322d6e065b Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Sat, 2 Jul 2022 16:20:26 +0800 Subject: [PATCH 176/183] fix: avoid duplicate log when multiple DataStats are used (#4623) * fix: avoid duplicate log when multiple DataStats are used Signed-off-by: upupming --- monai/transforms/utility/array.py | 15 +++++++++++---- setup.cfg | 1 + tests/test_data_stats.py | 10 ++++++++++ tests/test_senet.py | 10 +++++++++- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index d50505fe84..eaa2ae3c07 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -621,10 +621,17 @@ def __init__( _logger = logging.getLogger(self._logger_name) _logger.setLevel(logging.INFO) if logging.root.getEffectiveLevel() > logging.INFO: - # if the root log level is higher than INFO, set a separate stream handler to record - console = logging.StreamHandler(sys.stdout) - console.setLevel(logging.INFO) - _logger.addHandler(console) + # Avoid duplicate stream handlers to be added when multiple DataStats are used in a chain. + has_console_handler = any( + hasattr(h, "is_data_stats_handler") and h.is_data_stats_handler # type:ignore[attr-defined] + for h in _logger.handlers + ) + if not has_console_handler: + # if the root log level is higher than INFO, set a separate stream handler to record + console = logging.StreamHandler(sys.stdout) + console.setLevel(logging.INFO) + console.is_data_stats_handler = True # type:ignore[attr-defined] + _logger.addHandler(console) def __call__( self, diff --git a/setup.cfg b/setup.cfg index d929b2c986..9a5efba82d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -121,6 +121,7 @@ ignore = W504 C408 N812 # lowercase 'torch.nn.functional' imported as non lowercase 'F' + B023 # https://github.com/Project-MONAI/MONAI/issues/4627 per_file_ignores = __init__.py: F401, __main__.py: F401 exclude = *.pyi,.git,.eggs,monai/_version.py,versioneer.py,venv,.venv,_version.py diff --git a/tests/test_data_stats.py b/tests/test_data_stats.py index 6c11b9bb0f..2b652f8f62 100644 --- a/tests/test_data_stats.py +++ b/tests/test_data_stats.py @@ -14,6 +14,8 @@ import sys import tempfile import unittest +from io import StringIO +from unittest.mock import patch import numpy as np import torch @@ -166,6 +168,14 @@ def test_file(self, input_data, expected_print): if sys.platform != "win32": self.assertEqual(content, expected_print) + def test_multiple_data_stats(self): + with patch("sys.stdout", new=StringIO()) as out: + input_data = np.array([[0, 1], [1, 2]]) + transform = DataStats() + _ = DataStats() + _ = transform(input_data) + print(out.getvalue().strip()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_senet.py b/tests/test_senet.py index 57ca49237d..80d2b071c3 100644 --- a/tests/test_senet.py +++ b/tests/test_senet.py @@ -65,7 +65,15 @@ def test_script(self, net, net_args): class TestPretrainedSENET(unittest.TestCase): def setUp(self): self.original_urls = se_mod.SE_NET_MODELS.copy() - if test_is_quick(): + replace_url = test_is_quick() + if not replace_url: + try: + SEResNet50(pretrained=True, spatial_dims=2, in_channels=3, num_classes=2) + except OSError as rt_e: + print(rt_e) + if "certificate" in str(rt_e): # [SSL: CERTIFICATE_VERIFY_FAILED] + replace_url = True + if replace_url: testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") testing_data_urls = { "senet154": { From 564c6be8b4aa2edffb54b094c5e623a0256612e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 Jul 2022 08:02:21 +0000 Subject: [PATCH 177/183] [pre-commit.ci] pre-commit suggestions (#4631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.3.0) - [github.com/asottile/pyupgrade: v2.31.1 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.31.1...v2.34.0) - [github.com/hadialqattan/pycln: v1.3.3 → v1.3.5](https://github.com/hadialqattan/pycln/compare/v1.3.3...v1.3.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 153617b3dd..d2a6990830 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.3.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace @@ -28,7 +28,7 @@ repos: - id: mixed-line-ending - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py37-plus] @@ -58,7 +58,7 @@ repos: )$ - repo: https://github.com/hadialqattan/pycln - rev: v1.3.3 + rev: v1.3.5 hooks: - id: pycln args: [--config=pyproject.toml] From a2d63468cf7e92f3051a5fc1f2b297a2847c9073 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 29 Jun 2022 14:54:49 +0800 Subject: [PATCH 178/183] Move metatensor support into dev branch (crop/pad) (#4548) * [DLMED] adapt Pad transform for MetaTensor Signed-off-by: Nic Ma * [DLMED] format code Signed-off-by: Nic Ma * [DLMED] update inverse and spatial_pad Signed-off-by: Nic Ma * [DLMED] update border pad Signed-off-by: Nic Ma * [DLMED] update divisible pad Signed-off-by: Nic Ma * [DLMED] update spatial crop Signed-off-by: Nic Ma * [DLMED] make thread safe Signed-off-by: Nic Ma * [DLMED] update CenterSpatialCrop Signed-off-by: Nic Ma * [DLMED] update scale crop Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma * [DLMED] update random spatial crop Signed-off-by: Nic Ma * [DLMED] update random scale crop Signed-off-by: Nic Ma * [DLMED] update random spatial crop samples Signed-off-by: Nic Ma * [DLMED] adjust Pad design Signed-off-by: Nic Ma * [DLMED] update CropForeground Signed-off-by: Nic Ma * [DLMED] update random weighted crop Signed-off-by: Nic Ma * [DLMED] update RandCropPosNeg Signed-off-by: Nic Ma * [DLMED] update rand crop by label classes Signed-off-by: Nic Ma * [DLMED] update ResizeCropOrPad Signed-off-by: Nic Ma * [DLMED] restore numpy pad Signed-off-by: Nic Ma * [DLMED] update dict spatial pad Signed-off-by: Nic Ma * [DLMED] update border pad and divisible pad Signed-off-by: Nic Ma * [DLMED] update spatial crop dict Signed-off-by: Nic Ma * [DLMED] update center spatial crop Signed-off-by: Nic Ma * [DLMED] update rand scale crop dict Signed-off-by: Nic Ma * [DLMED] update rand spatial crop samples dict Signed-off-by: Nic Ma * [DLMED] update crop foreground dict Signed-off-by: Nic Ma * [DLMED] update rand weighted crop dict Signed-off-by: Nic Ma * [DLMED] update pos neg crop dict Signed-off-by: Nic Ma * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [DLMED] update crop by labels dict Signed-off-by: Nic Ma * [DLMED] update resize with pad or crop dict Signed-off-by: Nic Ma * [DLMED] update format Signed-off-by: Nic Ma * [DLMED] fix all the mypy errors Signed-off-by: Nic Ma * [DLMED] add crop / pad base tests Signed-off-by: Nic Ma * [DLMED] update border pad test Signed-off-by: Nic Ma * [DLMED] update spatial crop Signed-off-by: Nic Ma * [DLMED] update pad transforms Signed-off-by: Nic Ma * [DLMED] update samples crop Signed-off-by: Nic Ma * [DLMED] update crop tests Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] add test for deepcopy Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] update docs Signed-off-by: Nic Ma Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/source/transforms.rst | 24 + monai/transforms/__init__.py | 14 +- monai/transforms/croppad/array.py | 647 +++++++------ monai/transforms/croppad/dictionary.py | 1018 ++++++--------------- tests/croppers.py | 103 +++ tests/padders.py | 108 +++ tests/test_border_pad.py | 47 +- tests/test_border_padd.py | 51 +- tests/test_center_scale_crop.py | 45 +- tests/test_center_scale_cropd.py | 47 +- tests/test_center_spatial_crop.py | 44 +- tests/test_center_spatial_cropd.py | 65 +- tests/test_crop_foreground.py | 17 +- tests/test_crop_foregroundd.py | 20 +- tests/test_divisible_pad.py | 39 +- tests/test_divisible_padd.py | 29 +- tests/test_module_list.py | 2 +- tests/test_rand_crop_by_label_classes.py | 4 +- tests/test_rand_crop_by_label_classesd.py | 4 +- tests/test_rand_crop_by_pos_neg_label.py | 4 +- tests/test_rand_crop_by_pos_neg_labeld.py | 32 +- tests/test_rand_scale_crop.py | 87 +- tests/test_rand_scale_cropd.py | 117 +-- tests/test_rand_spatial_crop.py | 81 +- tests/test_rand_spatial_crop_samples.py | 28 +- tests/test_rand_spatial_crop_samplesd.py | 41 +- tests/test_rand_spatial_cropd.py | 93 +- tests/test_rand_weighted_crop.py | 20 +- tests/test_rand_weighted_cropd.py | 308 +++---- tests/test_resize_with_pad_or_crop.py | 29 +- tests/test_resize_with_pad_or_cropd.py | 23 +- tests/test_spatial_crop.py | 37 +- tests/test_spatial_cropd.py | 87 +- tests/test_spatial_pad.py | 83 +- tests/test_spatial_padd.py | 40 +- 35 files changed, 1599 insertions(+), 1839 deletions(-) create mode 100644 tests/croppers.py create mode 100644 tests/padders.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 2eb2537b49..7eaa17ea43 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -105,6 +105,12 @@ Crop and Pad :members: :special-members: __call__ +`Crop` +"""""" +.. autoclass:: Crop + :members: + :special-members: __call__ + `SpatialCrop` """"""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/SpatialCrop.png @@ -995,6 +1001,12 @@ Dictionary Transforms Crop and Pad (Dict) ^^^^^^^^^^^^^^^^^^^ +`Padd` +"""""" +.. autoclass:: Padd + :members: + :special-members: __call__ + `SpatialPadd` """"""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/SpatialPadd.png @@ -1019,6 +1031,18 @@ Crop and Pad (Dict) :members: :special-members: __call__ +`Cropd` +""""""" +.. autoclass:: Cropd + :members: + :special-members: __call__ + +`RandCropd` +""""""""""" +.. autoclass:: RandCropd + :members: + :special-members: __call__ + `SpatialCropd` """""""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/SpatialCropd.png diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index d4f09474de..a5c9ed05eb 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -16,6 +16,7 @@ BoundingRect, CenterScaleCrop, CenterSpatialCrop, + Crop, CropForeground, DivisiblePad, Pad, @@ -43,19 +44,30 @@ CenterSpatialCropd, CenterSpatialCropD, CenterSpatialCropDict, + Cropd, + CropD, + CropDict, CropForegroundd, CropForegroundD, CropForegroundDict, DivisiblePadd, DivisiblePadD, DivisiblePadDict, - PadModeSequence, + Padd, + PadD, + PadDict, + RandCropd, + RandCropD, + RandCropDict, RandCropByLabelClassesd, RandCropByLabelClassesD, RandCropByLabelClassesDict, RandCropByPosNegLabeld, RandCropByPosNegLabelD, RandCropByPosNegLabelDict, + RandCropd, + RandCropD, + RandCropDict, RandScaleCropd, RandScaleCropD, RandScaleCropDict, diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 6537cf3e21..0483839759 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -23,11 +23,15 @@ from monai.config import IndexSelection from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta +from monai.data.meta_tensor import MetaTensor from monai.data.utils import get_random_patch, get_valid_patch_size +from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import Randomizable, Transform from monai.transforms.utils import ( compute_divisible_spatial_size, convert_pad_mode, + create_translate, generate_label_classes_crop_centers, generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, @@ -36,24 +40,17 @@ map_classes_to_indices, weighted_patch_samples, ) -from monai.transforms.utils_pytorch_numpy_unification import floor_divide, maximum -from monai.utils import ( - Method, - NumpyPadMode, - PytorchPadMode, - ensure_tuple, - ensure_tuple_rep, - fall_back_tuple, - look_up_option, -) -from monai.utils.enums import TransformBackends -from monai.utils.type_conversion import convert_data_type, convert_to_dst_type +from monai.utils import ImageMetaKey as Key +from monai.utils import Method, PytorchPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option +from monai.utils.enums import TraceKeys, TransformBackends +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor __all__ = [ "Pad", "SpatialPad", "BorderPad", "DivisiblePad", + "Crop", "SpatialCrop", "CenterSpatialCrop", "CenterScaleCrop", @@ -69,13 +66,16 @@ ] -class Pad(Transform): +class Pad(InvertibleTransform): """ Perform padding for a given an amount of padding in each dimension. - If input is `torch.Tensor`, `torch.nn.functional.pad` will be used, otherwise, `np.pad` will be used. + + `torch.nn.functional.pad` is used unless the mode or kwargs are not available in torch, + in which case `np.pad` will be used. Args: to_pad: the amount to be padded in each dimension [(low_H, high_H), (low_W, high_W), ...]. + if None, must provide in the `__call__` at runtime. mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. @@ -84,63 +84,113 @@ class Pad(Transform): https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html kwargs: other arguments for the `np.pad` or `torch.pad` function. note that `np.pad` treats channel dimension as the first dimension. + """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__( - self, - to_pad: List[Tuple[int, int]], - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, - **kwargs, + self, to_pad: Optional[List[Tuple[int, int]]] = None, mode: str = PytorchPadMode.CONSTANT, **kwargs ) -> None: self.to_pad = to_pad self.mode = mode self.kwargs = kwargs + def compute_pad_width(self, spatial_shape: Sequence[int]) -> List[Tuple[int, int]]: + """ + dynamically compute the pad width according to the spatial shape. + + Args: + spatial_shape: spatial shape of the original image. + + """ + raise NotImplementedError(f"subclass {self.__class__.__name__} must implement this method.") + @staticmethod - def _np_pad(img: np.ndarray, all_pad_width, mode, **kwargs) -> np.ndarray: - return np.pad(img, all_pad_width, mode=mode, **kwargs) # type: ignore + def _np_pad(img: torch.Tensor, pad_width, mode, **kwargs) -> torch.Tensor: + img_np = img.detach().cpu().numpy() if isinstance(img, torch.Tensor) else img + mode = convert_pad_mode(dst=img_np, mode=mode).value + out = torch.as_tensor(np.pad(img, pad_width, mode=mode, **kwargs)) + if isinstance(img, MetaTensor): + out = MetaTensor(out, meta=img.meta, applied_operations=img.applied_operations) + return out @staticmethod - def _pt_pad(img: torch.Tensor, all_pad_width, mode, **kwargs) -> torch.Tensor: - pt_pad_width = [val for sublist in all_pad_width[1:] for val in sublist[::-1]][::-1] + def _pt_pad(img: torch.Tensor, pad_width, mode, **kwargs) -> torch.Tensor: + pt_pad_width = [val for sublist in pad_width[1:] for val in sublist[::-1]][::-1] # torch.pad expects `[B, C, H, W, [D]]` shape return pad_pt(img.unsqueeze(0), pt_pad_width, mode=mode, **kwargs).squeeze(0) - def __call__( - self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None - ) -> NdarrayOrTensor: + def __call__( # type: ignore + self, img: torch.Tensor, to_pad: Optional[List[Tuple[int, int]]] = None, mode: Optional[str] = None, **kwargs + ) -> torch.Tensor: """ Args: - img: data to be transformed, assuming `img` is channel-first and - padding doesn't apply to the channel dim. - mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, - ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"`` or ``"circular"``}. - One of the listed string values or a user supplied function. Defaults to `self.mode`. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html - https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + img: data to be transformed, assuming `img` is channel-first and padding doesn't apply to the channel dim. + to_pad: the amount to be padded in each dimension [(low_H, high_H), (low_W, high_W), ...]. + default to `self.to_pad`. + mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. """ - if not np.asarray(self.to_pad).any(): - # all zeros, skip padding - return img - mode = convert_pad_mode(dst=img, mode=mode or self.mode).value - pad = self._pt_pad if isinstance(img, torch.Tensor) else self._np_pad - return pad(img, self.to_pad, mode, **self.kwargs) # type: ignore + to_pad_ = self.to_pad if to_pad is None else to_pad + if to_pad_ is None: + to_pad_ = self.compute_pad_width(img.shape[1:]) + mode_ = self.mode if mode is None else mode + kwargs_ = dict(self.kwargs) + kwargs_.update(kwargs) + + img_t = convert_to_tensor(data=img, track_meta=get_track_meta()) + + # all zeros, skip padding + if np.asarray(to_pad_).any(): + if mode in ["linear_ramp", "maximum", "mean", "median", "minimum", "symmetric", "empty"]: + out = self._np_pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_) + else: + try: + mode_ = convert_pad_mode(dst=img_t, mode=mode_).value + out = self._pt_pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_) + # but if mode or args don't exist in pytorch, use numpy instead + except (ValueError, TypeError) as err: + if "Unsupported option" in str(err) or "unexpected keyword" in str(err): + out = self._np_pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_) + else: + out = img_t + if get_track_meta(): + self.update_meta(tensor=out, to_pad=to_pad_) # type: ignore + self.push_transform(out, extra_info={"padded": to_pad_}) + return out + + def update_meta(self, tensor: MetaTensor, to_pad: List[Tuple[int, int]]): + spatial_rank = max(len(tensor.affine) - 1, 1) + to_shift = [-s[0] for s in to_pad[1:]] # skipping the channel pad + mat = create_translate(spatial_rank, to_shift) + tensor.meta["affine"] = tensor.affine @ convert_to_dst_type(mat, tensor.affine)[0] + + def inverse(self, data: MetaTensor) -> MetaTensor: + transform = self.pop_transform(data) + padded = transform[TraceKeys.EXTRA_INFO]["padded"] + if padded[0][0] != 0 or padded[0][1] != 0: + raise NotImplementedError( + "Inverse uses SpatialCrop, which hasn't yet been extended to crop channels. Trivial change." + ) + roi_start = [i[0] for i in padded[1:]] + roi_end = [i - j[1] for i, j in zip(data.shape[1:], padded[1:])] + cropper = SpatialCrop(roi_start=roi_start, roi_end=roi_end) + with cropper.trace_transform(False): + return cropper(data) # type: ignore -class SpatialPad(Transform): +class SpatialPad(Pad): """ Performs padding to the data, symmetric for all sides or all on one side for each dimension. - If input is `torch.Tensor` and mode is `constant`, `torch.nn.functional.pad` will be used. - Otherwise, `np.pad` will be used (input converted to `np.ndarray` if necessary). - - Uses np.pad so in practice, a mode needs to be provided. See numpy.lib.arraypad.pad - for additional details. - Args: spatial_size: the spatial size of output data after padding, if a dimension of the input data size is bigger than the pad size, will not pad that dimension. @@ -160,56 +210,37 @@ class SpatialPad(Transform): """ - backend = Pad.backend - def __init__( self, spatial_size: Union[Sequence[int], int], - method: Union[Method, str] = Method.SYMMETRIC, - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + method: str = Method.SYMMETRIC, + mode: str = PytorchPadMode.CONSTANT, **kwargs, ) -> None: self.spatial_size = spatial_size self.method: Method = look_up_option(method, Method) - self.mode = mode - self.kwargs = kwargs - - def _determine_data_pad_width(self, data_shape: Sequence[int]) -> List[Tuple[int, int]]: - spatial_size = fall_back_tuple(self.spatial_size, data_shape) - if self.method == Method.SYMMETRIC: - pad_width = [] - for i, sp_i in enumerate(spatial_size): - width = max(sp_i - data_shape[i], 0) - pad_width.append((width // 2, width - (width // 2))) - return pad_width - return [(0, max(sp_i - data_shape[i], 0)) for i, sp_i in enumerate(spatial_size)] + super().__init__(mode=mode, **kwargs) - def __call__( - self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None - ) -> NdarrayOrTensor: + def compute_pad_width(self, spatial_shape: Sequence[int]) -> List[Tuple[int, int]]: """ + dynamically compute the pad width according to the spatial shape. + Args: - img: data to be transformed, assuming `img` is channel-first and - padding doesn't apply to the channel dim. - mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, - ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. - One of the listed string values or a user supplied function. Defaults to `self.mode`. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html - https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + spatial_shape: spatial shape of the original image. """ - data_pad_width = self._determine_data_pad_width(img.shape[1:]) - all_pad_width = [(0, 0)] + data_pad_width - if not np.asarray(all_pad_width).any(): - # all zeros, skip padding - return img - - padder = Pad(to_pad=all_pad_width, mode=mode or self.mode, **self.kwargs) - return padder(img) + spatial_size = fall_back_tuple(self.spatial_size, spatial_shape) + if self.method == Method.SYMMETRIC: + pad_width = [] + for i, sp_i in enumerate(spatial_size): + width = max(sp_i - spatial_shape[i], 0) + pad_width.append((width // 2, width - (width // 2))) + else: + pad_width = [(0, max(sp_i - spatial_shape[i], 0)) for i, sp_i in enumerate(spatial_size)] + return [(0, 0)] + pad_width -class BorderPad(Transform): +class BorderPad(Pad): """ Pad the input data by adding specified borders to every dimension. @@ -235,39 +266,13 @@ class BorderPad(Transform): """ - backend = Pad.backend - def __init__( - self, - spatial_border: Union[Sequence[int], int], - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, - **kwargs, + self, spatial_border: Union[Sequence[int], int], mode: str = PytorchPadMode.CONSTANT, **kwargs ) -> None: self.spatial_border = spatial_border - self.mode = mode - self.kwargs = kwargs - - def __call__( - self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None - ) -> NdarrayOrTensor: - """ - Args: - img: data to be transformed, assuming `img` is channel-first and - padding doesn't apply to the channel dim. - mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, - ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. - One of the listed string values or a user supplied function. Defaults to `self.mode`. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html - https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + super().__init__(mode=mode, **kwargs) - Raises: - ValueError: When ``self.spatial_border`` does not contain ints. - ValueError: When ``self.spatial_border`` length is not one of - [1, len(spatial_shape), 2*len(spatial_shape)]. - - """ - spatial_shape = img.shape[1:] + def compute_pad_width(self, spatial_shape: Sequence[int]) -> List[Tuple[int, int]]: spatial_border = ensure_tuple(self.spatial_border) if not all(isinstance(b, int) for b in spatial_border): raise ValueError(f"self.spatial_border must contain only ints, got {spatial_border}.") @@ -284,13 +289,10 @@ def __call__( f"Unsupported spatial_border length: {len(spatial_border)}, available options are " f"[1, len(spatial_shape)={len(spatial_shape)}, 2*len(spatial_shape)={2*len(spatial_shape)}]." ) + return [(0, 0)] + data_pad_width - all_pad_width = [(0, 0)] + data_pad_width - padder = Pad(all_pad_width, mode or self.mode, **self.kwargs) - return padder(img) - -class DivisiblePad(Transform): +class DivisiblePad(Pad): """ Pad the input data, so that the spatial sizes are divisible by `k`. """ @@ -300,8 +302,8 @@ class DivisiblePad(Transform): def __init__( self, k: Union[Sequence[int], int], - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, - method: Union[Method, str] = Method.SYMMETRIC, + mode: str = PytorchPadMode.CONSTANT, + method: str = Method.SYMMETRIC, **kwargs, ) -> None: """ @@ -323,32 +325,111 @@ def __init__( See also :py:class:`monai.transforms.SpatialPad` """ self.k = k - self.mode: NumpyPadMode = NumpyPadMode(mode) self.method: Method = Method(method) - self.kwargs = kwargs + super().__init__(mode=mode, **kwargs) - def __call__( - self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None - ) -> NdarrayOrTensor: + def compute_pad_width(self, spatial_shape: Sequence[int]) -> List[Tuple[int, int]]: + new_size = compute_divisible_spatial_size(spatial_shape=spatial_shape, k=self.k) + spatial_pad = SpatialPad(spatial_size=new_size, method=self.method) + return spatial_pad.compute_pad_width(spatial_shape) + + +class Crop(InvertibleTransform): + """ + Perform crop operation on the input image. + + """ + + backend = [TransformBackends.TORCH] + + @staticmethod + def compute_slices( + roi_center: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_size: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_start: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_end: Union[Sequence[int], NdarrayOrTensor, None] = None, + roi_slices: Optional[Sequence[slice]] = None, + ): """ + Compute the crop slices based on specified `center & size` or `start & end`. + Args: - img: data to be transformed, assuming `img` is channel-first - and padding doesn't apply to the channel dim. - mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, - ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. - One of the listed string values or a user supplied function. Defaults to `self.mode`. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html - https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + roi_center: voxel coordinates for center of the crop ROI. + roi_size: size of the crop ROI, if a dimension of ROI size is bigger than image size, + will not crop that dimension of the image. + roi_start: voxel coordinates for start of the crop ROI. + roi_end: voxel coordinates for end of the crop ROI, if a coordinate is out of image, + use the end coordinate of image. + roi_slices: list of slices for each of the spatial dimensions. """ - new_size = compute_divisible_spatial_size(spatial_shape=img.shape[1:], k=self.k) - spatial_pad = SpatialPad(spatial_size=new_size, method=self.method, mode=mode or self.mode, **self.kwargs) + roi_start_t: torch.Tensor - return spatial_pad(img) + if roi_slices: + if not all(s.step is None or s.step == 1 for s in roi_slices): + raise ValueError("only slice steps of 1/None are currently supported") + return list(roi_slices) + else: + if roi_center is not None and roi_size is not None: + roi_center_t = convert_to_tensor(data=roi_center, dtype=torch.int16, wrap_sequence=True) + roi_size_t = convert_to_tensor(data=roi_size, dtype=torch.int16, wrap_sequence=True) + _zeros = torch.zeros_like(roi_center_t) + roi_start_t = torch.maximum(roi_center_t - torch.div(roi_size_t, 2, rounding_mode="floor"), _zeros) + roi_end_t = torch.maximum(roi_start_t + roi_size_t, roi_start_t) + else: + if roi_start is None or roi_end is None: + raise ValueError("please specify either roi_center, roi_size or roi_start, roi_end.") + roi_start_t = convert_to_tensor(data=roi_start, dtype=torch.int16, wrap_sequence=True) + roi_start_t = torch.maximum(roi_start_t, torch.zeros_like(roi_start_t)) + roi_end_t = convert_to_tensor(data=roi_end, dtype=torch.int16, wrap_sequence=True) + roi_end_t = torch.maximum(roi_end_t, roi_start_t) + # convert to slices (accounting for 1d) + if roi_start_t.numel() == 1: + return [slice(int(roi_start_t.item()), int(roi_end_t.item()))] + else: + return [slice(int(s), int(e)) for s, e in zip(roi_start_t.tolist(), roi_end_t.tolist())] + def __call__(self, img: torch.Tensor, slices: Tuple[slice, ...]) -> torch.Tensor: # type: ignore + """ + Apply the transform to `img`, assuming `img` is channel-first and + slicing doesn't apply to the channel dim. -class SpatialCrop(Transform): + """ + orig_size = img.shape[1:] + slices_ = list(slices) + sd = len(img.shape[1:]) # spatial dims + if len(slices_) < sd: + slices_ += [slice(None)] * (sd - len(slices_)) + # Add in the channel (no cropping) + slices = tuple([slice(None)] + slices_[:sd]) + + img_t: MetaTensor = convert_to_tensor(data=img, track_meta=get_track_meta()) + img_t = img_t[slices] # type: ignore + if get_track_meta(): + self.update_meta(tensor=img_t, slices=slices) + cropped_from_start = np.asarray([s.indices(o)[0] for s, o in zip(slices[1:], orig_size)]) + cropped_from_end = np.asarray(orig_size) - img_t.shape[1:] - cropped_from_start + cropped = list(chain(*zip(cropped_from_start.tolist(), cropped_from_end.tolist()))) + self.push_transform(img_t, extra_info={"cropped": cropped}) + return img_t + + def update_meta(self, tensor: MetaTensor, slices: Tuple[slice, ...]): + spatial_rank = max(len(tensor.affine) - 1, 1) + to_shift = [s.start if s.start is not None else 0 for s in ensure_tuple(slices)[1:]] + mat = create_translate(spatial_rank, to_shift) + tensor.meta["affine"] = tensor.affine @ convert_to_dst_type(mat, tensor.affine)[0] + + def inverse(self, img: MetaTensor) -> MetaTensor: + transform = self.pop_transform(img) + cropped = transform[TraceKeys.EXTRA_INFO]["cropped"] + # the amount we pad is equal to the amount we cropped in each direction + inverse_transform = BorderPad(cropped) + # Apply inverse transform + with inverse_transform.trace_transform(False): + return inverse_transform(img) # type: ignore + + +class SpatialCrop(Crop): """ General purpose cropper to produce sub-volume region of interest (ROI). If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. @@ -362,8 +443,6 @@ class SpatialCrop(Transform): - the start and end coordinates of the ROI """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__( self, roi_center: Union[Sequence[int], NdarrayOrTensor, None] = None, @@ -382,47 +461,20 @@ def __init__( use the end coordinate of image. roi_slices: list of slices for each of the spatial dimensions. """ - roi_start_torch: torch.Tensor - - if roi_slices: - if not all(s.step is None or s.step == 1 for s in roi_slices): - raise ValueError("Only slice steps of 1/None are currently supported") - self.slices = list(roi_slices) - else: - if roi_center is not None and roi_size is not None: - roi_center, *_ = convert_data_type( - data=roi_center, output_type=torch.Tensor, dtype=torch.int16, wrap_sequence=True - ) - roi_size, *_ = convert_to_dst_type(src=roi_size, dst=roi_center, wrap_sequence=True) - _zeros = torch.zeros_like(roi_center) - roi_start_torch = maximum(roi_center - floor_divide(roi_size, 2), _zeros) # type: ignore - roi_end_torch = maximum(roi_start_torch + roi_size, roi_start_torch) - else: - if roi_start is None or roi_end is None: - raise ValueError("Please specify either roi_center, roi_size or roi_start, roi_end.") - roi_start_torch, *_ = convert_data_type( - data=roi_start, output_type=torch.Tensor, dtype=torch.int16, wrap_sequence=True - ) - roi_start_torch = maximum(roi_start_torch, torch.zeros_like(roi_start_torch)) # type: ignore - roi_end_torch, *_ = convert_to_dst_type(src=roi_end, dst=roi_start_torch, wrap_sequence=True) - roi_end_torch = maximum(roi_end_torch, roi_start_torch) - # convert to slices (accounting for 1d) - if roi_start_torch.numel() == 1: - self.slices = [slice(int(roi_start_torch.item()), int(roi_end_torch.item()))] - else: - self.slices = [slice(int(s), int(e)) for s, e in zip(roi_start_torch.tolist(), roi_end_torch.tolist())] + self.slices = self.compute_slices( + roi_center=roi_center, roi_size=roi_size, roi_start=roi_start, roi_end=roi_end, roi_slices=roi_slices + ) - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor) -> torch.Tensor: # type: ignore """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. + """ - sd = min(len(self.slices), len(img.shape[1:])) # spatial dims - slices = [slice(None)] + self.slices[:sd] - return img[tuple(slices)] + return super().__call__(img=img, slices=self.slices) -class CenterSpatialCrop(Transform): +class CenterSpatialCrop(Crop): """ Crop at the center of image with specified ROI size. If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. @@ -437,23 +489,24 @@ class CenterSpatialCrop(Transform): the spatial size of output data will be [32, 40, 40]. """ - backend = SpatialCrop.backend - def __init__(self, roi_size: Union[Sequence[int], int]) -> None: self.roi_size = roi_size - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + def compute_slices(self, spatial_size: Sequence[int]): # type: ignore + roi_size = fall_back_tuple(self.roi_size, spatial_size) + roi_center = [i // 2 for i in spatial_size] + return super().compute_slices(roi_center=roi_center, roi_size=roi_size) + + def __call__(self, img: torch.Tensor) -> torch.Tensor: # type: ignore """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. + """ - roi_size = fall_back_tuple(self.roi_size, img.shape[1:]) - center = [i // 2 for i in img.shape[1:]] - cropper = SpatialCrop(roi_center=center, roi_size=roi_size) - return cropper(img) + return super().__call__(img=img, slices=self.compute_slices(img.shape[1:])) -class CenterScaleCrop(Transform): +class CenterScaleCrop(Crop): """ Crop at the center of image with specified scale of ROI size. @@ -463,20 +516,18 @@ class CenterScaleCrop(Transform): """ - backend = CenterSpatialCrop.backend - def __init__(self, roi_scale: Union[Sequence[float], float]): self.roi_scale = roi_scale - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor) -> torch.Tensor: # type: ignore img_size = img.shape[1:] ndim = len(img_size) roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)] - sp_crop = CenterSpatialCrop(roi_size=roi_size) - return sp_crop(img=img) + cropper = CenterSpatialCrop(roi_size=roi_size) + return super().__call__(img=img, slices=cropper.compute_slices(img.shape[1:])) -class RandSpatialCrop(Randomizable, Transform): +class RandSpatialCrop(Randomizable, Crop): """ Crop image with random size or specific size ROI. It can crop at a random position as center or at the image center. And allows to set the minimum and maximum size to limit the randomly generated ROI. @@ -500,8 +551,6 @@ class RandSpatialCrop(Randomizable, Transform): if True, the actual size is sampled from `randint(roi_size, max_roi_size + 1)`. """ - backend = CenterSpatialCrop.backend - def __init__( self, roi_size: Union[Sequence[int], int], @@ -514,7 +563,7 @@ def __init__( self.random_center = random_center self.random_size = random_size self._size: Optional[Sequence[int]] = None - self._slices: Optional[Tuple[slice, ...]] = None + self._slices: Tuple[slice, ...] def randomize(self, img_size: Sequence[int]) -> None: self._size = fall_back_tuple(self.roi_size, img_size) @@ -525,20 +574,22 @@ def randomize(self, img_size: Sequence[int]) -> None: self._size = tuple(self.R.randint(low=self._size[i], high=max_size[i] + 1) for i in range(len(img_size))) if self.random_center: valid_size = get_valid_patch_size(img_size, self._size) - self._slices = (slice(None),) + get_random_patch(img_size, valid_size, self.R) + self._slices = get_random_patch(img_size, valid_size, self.R) - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: # type: ignore """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. + """ - self.randomize(img.shape[1:]) + if randomize: + self.randomize(img.shape[1:]) if self._size is None: raise RuntimeError("self._size not specified.") if self.random_center: - return img[self._slices] + return super().__call__(img=img, slices=self._slices) cropper = CenterSpatialCrop(self._size) - return cropper(img) + return super().__call__(img=img, slices=cropper.compute_slices(img.shape[1:])) class RandScaleCrop(RandSpatialCrop): @@ -573,19 +624,26 @@ def __init__( self.roi_scale = roi_scale self.max_roi_scale = max_roi_scale - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: - """ - Apply the transform to `img`, assuming `img` is channel-first and - slicing doesn't apply to the channel dim. - """ - img_size = img.shape[1:] + def get_max_roi_size(self, img_size): ndim = len(img_size) self.roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)] if self.max_roi_scale is not None: self.max_roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.max_roi_scale, ndim), img_size)] else: self.max_roi_size = None - return super().__call__(img=img) + + def randomize(self, img_size: Sequence[int]) -> None: + self.get_max_roi_size(img_size) + super().randomize(img_size) + + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: # type: ignore + """ + Apply the transform to `img`, assuming `img` is channel-first and + slicing doesn't apply to the channel dim. + + """ + self.get_max_roi_size(img.shape[1:]) + return super().__call__(img=img, randomize=randomize) class RandSpatialCropSamples(Randomizable, Transform): @@ -644,15 +702,21 @@ def set_random_state( def randomize(self, data: Optional[Any] = None) -> None: pass - def __call__(self, img: NdarrayOrTensor) -> List[NdarrayOrTensor]: + def __call__(self, img: torch.Tensor) -> List[torch.Tensor]: """ Apply the transform to `img`, assuming `img` is channel-first and cropping doesn't change the channel dim. """ - return [self.cropper(img) for _ in range(self.num_samples)] + ret = [] + for i in range(self.num_samples): + cropped = self.cropper(img) + if get_track_meta(): + cropped.meta[Key.PATCH_INDEX] = i # type: ignore + ret.append(cropped) + return ret -class CropForeground(Transform): +class CropForeground(Crop): """ Crop an image using a bounding box. The bounding box is generated by selecting foreground using select_fn at channels channel_indices. margin is added in each spatial dimension of the bounding box. @@ -684,8 +748,6 @@ def threshold_at_one(x): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__( self, select_fn: Callable = is_positive, @@ -694,7 +756,7 @@ def __init__( allow_smaller: bool = True, return_coords: bool = False, k_divisible: Union[Sequence[int], int] = 1, - mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = NumpyPadMode.CONSTANT, + mode: str = PytorchPadMode.CONSTANT, **pad_kwargs, ) -> None: """ @@ -725,10 +787,9 @@ def __init__( self.allow_smaller = allow_smaller self.return_coords = return_coords self.k_divisible = k_divisible - self.mode: NumpyPadMode = look_up_option(mode, NumpyPadMode) - self.pad_kwargs = pad_kwargs + self.padder = Pad(mode=mode, **pad_kwargs) - def compute_bounding_box(self, img: NdarrayOrTensor): + def compute_bounding_box(self, img: torch.Tensor): """ Compute the start points and end points of bounding box to crop. And adjust bounding box coords to be divisible by `k`. @@ -748,34 +809,49 @@ def compute_bounding_box(self, img: NdarrayOrTensor): return box_start_, box_end_ def crop_pad( - self, - img: NdarrayOrTensor, - box_start: np.ndarray, - box_end: np.ndarray, - mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None, + self, img: torch.Tensor, box_start: np.ndarray, box_end: np.ndarray, mode: Optional[str] = None, **pad_kwargs ): """ Crop and pad based on the bounding box. """ - cropped = SpatialCrop(roi_start=box_start, roi_end=box_end)(img) + slices = self.compute_slices(roi_start=box_start, roi_end=box_end) + cropped = super().__call__(img=img, slices=slices) pad_to_start = np.maximum(-box_start, 0) pad_to_end = np.maximum(box_end - np.asarray(img.shape[1:]), 0) pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - return BorderPad(spatial_border=pad, mode=mode or self.mode, **self.pad_kwargs)(cropped) - - def __call__(self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None): + pad_width = BorderPad(spatial_border=pad).compute_pad_width(cropped.shape[1:]) + ret = self.padder.__call__(img=cropped, to_pad=pad_width, mode=mode, **pad_kwargs) + # combine the traced cropping and padding into one transformation + # by taking the padded info and placing it in a key inside the crop info. + if get_track_meta(): + ret_: MetaTensor = ret # type: ignore + app_op = ret_.applied_operations.pop(-1) + ret_.applied_operations[-1][TraceKeys.EXTRA_INFO]["pad_info"] = app_op + return ret + + def __call__(self, img: torch.Tensor, mode: Optional[str] = None, **pad_kwargs): # type: ignore """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't change the channel dim. """ box_start, box_end = self.compute_bounding_box(img) - cropped = self.crop_pad(img, box_start, box_end, mode) + cropped = self.crop_pad(img, box_start, box_end, mode, **pad_kwargs) if self.return_coords: return cropped, box_start, box_end return cropped + def inverse(self, img: MetaTensor) -> MetaTensor: + transform = self.get_most_recent_transform(img) + # we moved the padding info in the forward, so put it back for the inverse + pad_info = transform[TraceKeys.EXTRA_INFO].pop("pad_info") + img.applied_operations.append(pad_info) + # first inverse the padder + inv = self.padder.inverse(img) + # and then inverse the cropper (self) + return super().inverse(inv) + class RandWeightedCrop(Randomizable, Transform): """ @@ -808,13 +884,16 @@ def randomize(self, weight_map: NdarrayOrTensor) -> None: spatial_size=self.spatial_size, w=weight_map[0], n_samples=self.num_samples, r_state=self.R ) # using only the first channel as weight map - def __call__(self, img: NdarrayOrTensor, weight_map: Optional[NdarrayOrTensor] = None) -> List[NdarrayOrTensor]: + def __call__( + self, img: torch.Tensor, weight_map: Optional[NdarrayOrTensor] = None, randomize: bool = True + ) -> List[torch.Tensor]: """ Args: img: input image to sample patches from. assuming `img` is a channel-first array. weight_map: weight map used to generate patch samples. The weights must be non-negative. Each element denotes a sampling weight of the spatial location. 0 indicates no sampling. It should be a single-channel array in shape, for example, `(1, spatial_dim_0, spatial_dim_1, ...)` + randomize: whether to execute random operations, defautl to `True`. Returns: A list of image patches @@ -826,12 +905,17 @@ def __call__(self, img: NdarrayOrTensor, weight_map: Optional[NdarrayOrTensor] = if img.shape[1:] != weight_map.shape[1:]: raise ValueError(f"image and weight map spatial shape mismatch: {img.shape[1:]} vs {weight_map.shape[1:]}.") - self.randomize(weight_map) + if randomize: + self.randomize(weight_map) _spatial_size = fall_back_tuple(self.spatial_size, weight_map.shape[1:]) - results: List[NdarrayOrTensor] = [] - for center in self.centers: - cropper = SpatialCrop(roi_center=center, roi_size=_spatial_size) - results.append(cropper(img)) + results: List[torch.Tensor] = [] + for i, center in enumerate(self.centers): + cropped = SpatialCrop(roi_center=center, roi_size=_spatial_size)(img) + if get_track_meta(): + ret_: MetaTensor = cropped # type: ignore + ret_.meta[Key.PATCH_INDEX] = i + ret_.meta["crop_center"] = center + results.append(cropped) return results @@ -890,16 +974,16 @@ class RandCropByPosNegLabel(Randomizable, Transform): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = SpatialCrop.backend def __init__( self, spatial_size: Union[Sequence[int], int], - label: Optional[NdarrayOrTensor] = None, + label: Optional[torch.Tensor] = None, pos: float = 1.0, neg: float = 1.0, num_samples: int = 1, - image: Optional[NdarrayOrTensor] = None, + image: Optional[torch.Tensor] = None, image_threshold: float = 0.0, fg_indices: Optional[NdarrayOrTensor] = None, bg_indices: Optional[NdarrayOrTensor] = None, @@ -922,10 +1006,10 @@ def __init__( def randomize( self, - label: NdarrayOrTensor, + label: torch.Tensor, fg_indices: Optional[NdarrayOrTensor] = None, bg_indices: Optional[NdarrayOrTensor] = None, - image: Optional[NdarrayOrTensor] = None, + image: Optional[torch.Tensor] = None, ) -> None: if fg_indices is None or bg_indices is None: if self.fg_indices is not None and self.bg_indices is not None: @@ -949,12 +1033,13 @@ def randomize( def __call__( self, - img: NdarrayOrTensor, - label: Optional[NdarrayOrTensor] = None, - image: Optional[NdarrayOrTensor] = None, + img: torch.Tensor, + label: Optional[torch.Tensor] = None, + image: Optional[torch.Tensor] = None, fg_indices: Optional[NdarrayOrTensor] = None, bg_indices: Optional[NdarrayOrTensor] = None, - ) -> List[NdarrayOrTensor]: + randomize: bool = True, + ) -> List[torch.Tensor]: """ Args: img: input data to crop samples from based on the pos/neg ratio of `label` and `image`. @@ -967,6 +1052,7 @@ def __call__( need to provide `fg_indices` and `bg_indices` together. bg_indices: background indices to randomly select crop centers, need to provide `fg_indices` and `bg_indices` together. + randomize: whether to execute the random operations, default to `True`. """ if label is None: @@ -976,14 +1062,18 @@ def __call__( if image is None: image = self.image - self.randomize(label, fg_indices, bg_indices, image) - results: List[NdarrayOrTensor] = [] + if randomize: + self.randomize(label, fg_indices, bg_indices, image) + results: List[torch.Tensor] = [] if self.centers is not None: - for center in self.centers: + for i, center in enumerate(self.centers): roi_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) - cropper = SpatialCrop(roi_center=center, roi_size=roi_size) - results.append(cropper(img)) - + cropped = SpatialCrop(roi_center=center, roi_size=roi_size)(img) + if get_track_meta(): + ret_: MetaTensor = cropped # type: ignore + ret_.meta[Key.PATCH_INDEX] = i + ret_.meta["crop_center"] = center + results.append(cropped) return results @@ -1051,16 +1141,16 @@ class RandCropByLabelClasses(Randomizable, Transform): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = SpatialCrop.backend def __init__( self, spatial_size: Union[Sequence[int], int], ratios: Optional[List[Union[float, int]]] = None, - label: Optional[NdarrayOrTensor] = None, + label: Optional[torch.Tensor] = None, num_classes: Optional[int] = None, num_samples: int = 1, - image: Optional[NdarrayOrTensor] = None, + image: Optional[torch.Tensor] = None, image_threshold: float = 0.0, indices: Optional[List[NdarrayOrTensor]] = None, allow_smaller: bool = False, @@ -1077,10 +1167,7 @@ def __init__( self.allow_smaller = allow_smaller def randomize( - self, - label: NdarrayOrTensor, - indices: Optional[List[NdarrayOrTensor]] = None, - image: Optional[NdarrayOrTensor] = None, + self, label: torch.Tensor, indices: Optional[List[NdarrayOrTensor]] = None, image: Optional[torch.Tensor] = None ) -> None: indices_: Sequence[NdarrayOrTensor] if indices is None: @@ -1096,11 +1183,12 @@ def randomize( def __call__( self, - img: NdarrayOrTensor, - label: Optional[NdarrayOrTensor] = None, - image: Optional[NdarrayOrTensor] = None, + img: torch.Tensor, + label: Optional[torch.Tensor] = None, + image: Optional[torch.Tensor] = None, indices: Optional[List[NdarrayOrTensor]] = None, - ) -> List[NdarrayOrTensor]: + randomize: bool = True, + ) -> List[torch.Tensor]: """ Args: img: input data to crop samples from based on the ratios of every class, assumes `img` is a @@ -1109,6 +1197,7 @@ def __call__( image: optional image data to help select valid area, can be same as `img` or another image array. use ``image > image_threshold`` to select the centers only in valid region. if None, use `self.image`. indices: list of indices for every class in the image, used to randomly select crop centers. + randomize: whether to execute the random operations, default to `True`. """ if label is None: @@ -1118,18 +1207,23 @@ def __call__( if image is None: image = self.image - self.randomize(label, indices, image) - results: List[NdarrayOrTensor] = [] + if randomize: + self.randomize(label, indices, image) + results: List[torch.Tensor] = [] if self.centers is not None: - for center in self.centers: + for i, center in enumerate(self.centers): roi_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) - cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) - results.append(cropper(img)) + cropped = SpatialCrop(roi_center=tuple(center), roi_size=roi_size)(img) + if get_track_meta(): + ret_: MetaTensor = cropped # type: ignore + ret_.meta[Key.PATCH_INDEX] = i + ret_.meta["crop_center"] = center + results.append(cropped) return results -class ResizeWithPadOrCrop(Transform): +class ResizeWithPadOrCrop(InvertibleTransform): """ Resize an image to a target spatial size by either centrally cropping the image or padding it evenly with a user-specified mode. @@ -1139,14 +1233,14 @@ class ResizeWithPadOrCrop(Transform): Args: spatial_size: the spatial size of output data after padding or crop. If has non-positive values, the corresponding size of input image will be used (no padding). + method: {``"symmetric"``, ``"end"``} + Pad image symmetrically on every side or only pad at the end sides. Defaults to ``"symmetric"``. mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html - method: {``"symmetric"``, ``"end"``} - Pad image symmetrically on every side or only pad at the end sides. Defaults to ``"symmetric"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. note that `np.pad` treats channel dimension as the first dimension. @@ -1157,16 +1251,14 @@ class ResizeWithPadOrCrop(Transform): def __init__( self, spatial_size: Union[Sequence[int], int], - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, - method: Union[Method, str] = Method.SYMMETRIC, + method: str = Method.SYMMETRIC, + mode: str = PytorchPadMode.CONSTANT, **pad_kwargs, ): self.padder = SpatialPad(spatial_size=spatial_size, method=method, mode=mode, **pad_kwargs) self.cropper = CenterSpatialCrop(roi_size=spatial_size) - def __call__( - self, img: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None - ) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor, mode: Optional[str] = None, **pad_kwargs) -> torch.Tensor: # type: ignore """ Args: img: data to pad or crop, assuming `img` is channel-first and @@ -1177,8 +1269,33 @@ def __call__( One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + kwargs: other arguments for the `np.pad` or `torch.pad` function. + note that `np.pad` treats channel dimension as the first dimension. + """ - return self.padder(self.cropper(img), mode=mode) + ret = self.padder(self.cropper(img), mode=mode, **pad_kwargs) + # remove the individual info and combine + if get_track_meta(): + ret_: MetaTensor = ret # type: ignore + pad_info = ret_.applied_operations.pop(-1) + crop_info = ret_.applied_operations.pop(-1) + self.push_transform(ret_, extra_info={"pad_info": pad_info, "crop_info": crop_info}) + return ret + + def inverse(self, img: MetaTensor) -> MetaTensor: + transform = self.pop_transform(img) + return self.inverse_transform(img, transform) + + def inverse_transform(self, img: MetaTensor, transform) -> MetaTensor: + # we joined the cropping and padding, so put them back before calling the inverse + crop_info = transform[TraceKeys.EXTRA_INFO].pop("crop_info") + pad_info = transform[TraceKeys.EXTRA_INFO].pop("pad_info") + img.applied_operations.append(crop_info) + img.applied_operations.append(pad_info) + # first inverse the padder + inv = self.padder.inverse(img) + # and then inverse the cropper (self) + return self.cropper.inverse(inv) class BoundingRect(Transform): diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 50cc767cab..ad739c0fcd 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -15,50 +15,47 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ -import contextlib from copy import deepcopy -from enum import Enum -from itertools import chain -from math import ceil, floor -from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Union import numpy as np +import torch from monai.config import IndexSelection, KeysCollection from monai.config.type_definitions import NdarrayOrTensor -from monai.data.utils import get_random_patch, get_valid_patch_size +from monai.data.meta_tensor import MetaTensor from monai.transforms.croppad.array import ( BorderPad, BoundingRect, + CenterScaleCrop, CenterSpatialCrop, + Crop, CropForeground, DivisiblePad, + Pad, RandCropByLabelClasses, RandCropByPosNegLabel, + RandScaleCrop, + RandSpatialCrop, + RandSpatialCropSamples, + RandWeightedCrop, ResizeWithPadOrCrop, SpatialCrop, SpatialPad, ) from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import MapTransform, Randomizable -from monai.transforms.utils import ( - allow_missing_keys_mode, - generate_label_classes_crop_centers, - generate_pos_neg_label_crop_centers, - is_positive, - map_binary_to_indices, - map_classes_to_indices, - weighted_patch_samples, -) -from monai.utils import ImageMetaKey as Key -from monai.utils import Method, NumpyPadMode, PytorchPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple -from monai.utils.enums import PostFix, TraceKeys +from monai.transforms.utils import is_positive +from monai.utils import MAX_SEED, Method, PytorchPadMode, ensure_tuple_rep +from monai.utils.deprecate_utils import deprecated_arg __all__ = [ - "PadModeSequence", + "Padd", "SpatialPadd", "BorderPadd", "DivisiblePadd", + "Cropd", + "RandCropd", "SpatialCropd", "CenterSpatialCropd", "CenterScaleCropd", @@ -71,12 +68,18 @@ "ResizeWithPadOrCropd", "BoundingRectd", "RandCropByLabelClassesd", + "PadD", + "PadDict", "SpatialPadD", "SpatialPadDict", "BorderPadD", "BorderPadDict", "DivisiblePadD", "DivisiblePadDict", + "CropD", + "CropDict", + "RandCropD", + "RandCropDict", "SpatialCropD", "SpatialCropDict", "CenterSpatialCropD", @@ -103,24 +106,67 @@ "RandCropByLabelClassesDict", ] -PadModeSequence = Union[Sequence[Union[NumpyPadMode, PytorchPadMode, str]], NumpyPadMode, PytorchPadMode, str] -DEFAULT_POST_FIX = PostFix.meta() +class Padd(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.Pad`. -class SpatialPadd(MapTransform, InvertibleTransform): + """ + + backend = Pad.backend + + def __init__( + self, + keys: KeysCollection, + padder: Pad, + mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + padder: pad transform for the input image. + mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, + ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}. + One of the listed string values or a user supplied function. Defaults to ``"constant"``. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html + It also can be a sequence of string, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. + + """ + super().__init__(keys, allow_missing_keys) + self.padder = padder + self.mode = ensure_tuple_rep(mode, len(self.keys)) + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = dict(data) + for key, m in self.key_iterator(d, self.mode): + d[key] = self.padder(d[key], mode=m) + return d + + def inverse(self, data: Mapping[Hashable, MetaTensor]) -> Dict[Hashable, MetaTensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.padder.inverse(d[key]) + return d + + +class SpatialPadd(Padd): """ Dictionary-based wrapper of :py:class:`monai.transforms.SpatialPad`. Performs padding to the data, symmetric for all sides or all on one side for each dimension. - """ - backend = SpatialPad.backend + """ def __init__( self, keys: KeysCollection, spatial_size: Union[Sequence[int], int], - method: Union[Method, str] = Method.SYMMETRIC, - mode: PadModeSequence = NumpyPadMode.CONSTANT, + method: str = Method.SYMMETRIC, + mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, **kwargs, ) -> None: @@ -147,39 +193,11 @@ def __init__( note that `np.pad` treats channel dimension as the first dimension. """ - super().__init__(keys, allow_missing_keys) - self.mode = ensure_tuple_rep(mode, len(self.keys)) - self.padder = SpatialPad(spatial_size, method, **kwargs) + padder = SpatialPad(spatial_size, method, **kwargs) + super().__init__(keys, padder=padder, mode=mode, allow_missing_keys=allow_missing_keys) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) - d[key] = self.padder(d[key], mode=m) - return d - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = transform[TraceKeys.ORIG_SIZE] - if self.padder.method == Method.SYMMETRIC: - current_size = d[key].shape[1:] - roi_center = [floor(i / 2) if r % 2 == 0 else (i - 1) // 2 for r, i in zip(orig_size, current_size)] - else: - roi_center = [floor(r / 2) if r % 2 == 0 else (r - 1) // 2 for r in orig_size] - - inverse_transform = SpatialCrop(roi_center, orig_size) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - return d - - -class BorderPadd(MapTransform, InvertibleTransform): +class BorderPadd(Padd): """ Pad the input data by adding specified borders to every dimension. Dictionary-based wrapper of :py:class:`monai.transforms.BorderPad`. @@ -191,7 +209,7 @@ def __init__( self, keys: KeysCollection, spatial_border: Union[Sequence[int], int], - mode: PadModeSequence = NumpyPadMode.CONSTANT, + mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, **kwargs, ) -> None: @@ -222,43 +240,11 @@ def __init__( note that `np.pad` treats channel dimension as the first dimension. """ - super().__init__(keys, allow_missing_keys) - self.mode = ensure_tuple_rep(mode, len(self.keys)) - self.padder = BorderPad(spatial_border=spatial_border, **kwargs) - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) - d[key] = self.padder(d[key], mode=m) - return d + padder = BorderPad(spatial_border=spatial_border, **kwargs) + super().__init__(keys, padder=padder, mode=mode, allow_missing_keys=allow_missing_keys) - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.array(transform[TraceKeys.ORIG_SIZE]) - roi_start = np.array(self.padder.spatial_border) - # Need to convert single value to [min1,min2,...] - if roi_start.size == 1: - roi_start = np.full((len(orig_size)), roi_start) - # need to convert [min1,max1,min2,...] to [min1,min2,...] - elif roi_start.size == 2 * orig_size.size: - roi_start = roi_start[::2] - roi_end = np.array(transform[TraceKeys.ORIG_SIZE]) + roi_start - - inverse_transform = SpatialCrop(roi_start=roi_start, roi_end=roi_end) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d - - -class DivisiblePadd(MapTransform, InvertibleTransform): +class DivisiblePadd(Padd): """ Pad the input data, so that the spatial sizes are divisible by `k`. Dictionary-based wrapper of :py:class:`monai.transforms.DivisiblePad`. @@ -270,8 +256,8 @@ def __init__( self, keys: KeysCollection, k: Union[Sequence[int], int], - mode: PadModeSequence = NumpyPadMode.CONSTANT, - method: Union[Method, str] = Method.SYMMETRIC, + mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + method: str = Method.SYMMETRIC, allow_missing_keys: bool = False, **kwargs, ) -> None: @@ -298,37 +284,81 @@ def __init__( See also :py:class:`monai.transforms.SpatialPad` """ + padder = DivisiblePad(k=k, method=method, **kwargs) + super().__init__(keys, padder=padder, mode=mode, allow_missing_keys=allow_missing_keys) + + +class Cropd(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of abstract class :py:class:`monai.transforms.Crop`. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + cropper: crop transform for the input image. + allow_missing_keys: don't raise exception if key is missing. + + """ + + backend = Crop.backend + + def __init__(self, keys: KeysCollection, cropper: Crop, allow_missing_keys: bool = False): super().__init__(keys, allow_missing_keys) - self.mode = ensure_tuple_rep(mode, len(self.keys)) - self.padder = DivisiblePad(k=k, method=method, **kwargs) + self.cropper = cropper - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) - d[key] = self.padder(d[key], mode=m) + for key in self.key_iterator(d): + d[key] = self.cropper(d[key]) # type: ignore return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - + def inverse(self, data: Mapping[Hashable, MetaTensor]) -> Dict[Hashable, MetaTensor]: + d = dict(data) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.array(transform[TraceKeys.ORIG_SIZE]) - current_size = np.array(d[key].shape[1:]) - roi_start = np.floor((current_size - orig_size) / 2) - roi_end = orig_size + roi_start - inverse_transform = SpatialCrop(roi_start=roi_start, roi_end=roi_end) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) + d[key] = self.cropper.inverse(d[key]) + return d + +class RandCropd(Cropd, Randomizable): + """ + Base class for random crop transform. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + cropper: random crop transform for the input image. + allow_missing_keys: don't raise exception if key is missing. + + """ + + backend = Crop.backend + + def __init__(self, keys: KeysCollection, cropper: Crop, allow_missing_keys: bool = False): + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandCropd": + super().set_random_state(seed, state) + if isinstance(self.cropper, Randomizable): + self.cropper.set_random_state(seed, state) + return self + + def randomize(self, img_size: Sequence[int]) -> None: + if isinstance(self.cropper, Randomizable): + self.cropper.randomize(img_size) + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = dict(data) + # the first key must exist to execute random operations + self.randomize(d[self.first_key(d)].shape[1:]) + for key in self.key_iterator(d): + kwargs = {"randomize": False} if isinstance(self.cropper, Randomizable) else {} + d[key] = self.cropper(d[key], **kwargs) # type: ignore return d -class SpatialCropd(MapTransform, InvertibleTransform): +class SpatialCropd(Cropd): """ Dictionary-based wrapper of :py:class:`monai.transforms.SpatialCrop`. General purpose cropper to produce sub-volume region of interest (ROI). @@ -343,8 +373,6 @@ class SpatialCropd(MapTransform, InvertibleTransform): - the start and end coordinates of the ROI """ - backend = SpatialCrop.backend - def __init__( self, keys: KeysCollection, @@ -367,40 +395,13 @@ def __init__( use the end coordinate of image. roi_slices: list of slices for each of the spatial dimensions. allow_missing_keys: don't raise exception if key is missing. - """ - super().__init__(keys, allow_missing_keys) - self.cropper = SpatialCrop(roi_center, roi_size, roi_start, roi_end, roi_slices) - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - for key in self.key_iterator(d): - self.push_transform(d, key) - d[key] = self.cropper(d[key]) - return d - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.array(transform[TraceKeys.ORIG_SIZE]) - current_size = np.array(d[key].shape[1:]) - # get required pad to start and end - pad_to_start = np.array([s.indices(o)[0] for s, o in zip(self.cropper.slices, orig_size)]) - pad_to_end = orig_size - current_size - pad_to_start - # interleave mins and maxes - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d + """ + cropper = SpatialCrop(roi_center, roi_size, roi_start, roi_end, roi_slices) + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) -class CenterSpatialCropd(MapTransform, InvertibleTransform): +class CenterSpatialCropd(Cropd): """ Dictionary-based wrapper of :py:class:`monai.transforms.CenterSpatialCrop`. If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. @@ -418,45 +419,14 @@ class CenterSpatialCropd(MapTransform, InvertibleTransform): allow_missing_keys: don't raise exception if key is missing. """ - backend = CenterSpatialCrop.backend - def __init__( self, keys: KeysCollection, roi_size: Union[Sequence[int], int], allow_missing_keys: bool = False ) -> None: - super().__init__(keys, allow_missing_keys) - self.cropper = CenterSpatialCrop(roi_size) - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - for key in self.key_iterator(d): - orig_size = d[key].shape[1:] - d[key] = self.cropper(d[key]) - self.push_transform(d, key, orig_size=orig_size) - return d - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.array(transform[TraceKeys.ORIG_SIZE]) - current_size = np.array(d[key].shape[1:]) - pad_to_start = np.floor((orig_size - current_size) / 2).astype(int) - # in each direction, if original size is even and current size is odd, += 1 - pad_to_start[np.logical_and(orig_size % 2 == 0, current_size % 2 == 1)] += 1 - pad_to_end = orig_size - current_size - pad_to_start - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d + cropper = CenterSpatialCrop(roi_size) + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) -class CenterScaleCropd(MapTransform, InvertibleTransform): +class CenterScaleCropd(Cropd): """ Dictionary-based wrapper of :py:class:`monai.transforms.CenterScaleCrop`. Note: as using the same scaled ROI to crop, all the input data specified by `keys` should have @@ -470,54 +440,14 @@ class CenterScaleCropd(MapTransform, InvertibleTransform): allow_missing_keys: don't raise exception if key is missing. """ - backend = CenterSpatialCrop.backend - def __init__( self, keys: KeysCollection, roi_scale: Union[Sequence[float], float], allow_missing_keys: bool = False ) -> None: - super().__init__(keys, allow_missing_keys=allow_missing_keys) - self.roi_scale = roi_scale + cropper = CenterScaleCrop(roi_scale) + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - first_key: Union[Hashable, List] = self.first_key(d) - if first_key == []: - return d - - # use the spatial size of first image to scale, expect all images have the same spatial size - img_size = d[first_key].shape[1:] # type: ignore - ndim = len(img_size) - roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)] - cropper = CenterSpatialCrop(roi_size) - for key in self.key_iterator(d): - self.push_transform(d, key, orig_size=img_size) - d[key] = cropper(d[key]) - return d - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.array(transform[TraceKeys.ORIG_SIZE]) - current_size = np.array(d[key].shape[1:]) - pad_to_start = np.floor((orig_size - current_size) / 2).astype(int) - # in each direction, if original size is even and current size is odd, += 1 - pad_to_start[np.logical_and(orig_size % 2 == 0, current_size % 2 == 1)] += 1 - pad_to_end = orig_size - current_size - pad_to_start - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d - - -class RandSpatialCropd(Randomizable, MapTransform, InvertibleTransform): +class RandSpatialCropd(RandCropd): """ Dictionary-based version :py:class:`monai.transforms.RandSpatialCrop`. Crop image with random size or specific size ROI. It can crop at a random position as @@ -547,8 +477,6 @@ class RandSpatialCropd(Randomizable, MapTransform, InvertibleTransform): allow_missing_keys: don't raise exception if key is missing. """ - backend = CenterSpatialCrop.backend - def __init__( self, keys: KeysCollection, @@ -558,78 +486,11 @@ def __init__( random_size: bool = True, allow_missing_keys: bool = False, ) -> None: - MapTransform.__init__(self, keys, allow_missing_keys) - self.roi_size = roi_size - self.max_roi_size = max_roi_size - self.random_center = random_center - self.random_size = random_size - self._slices: Optional[Tuple[slice, ...]] = None - self._size: Optional[Sequence[int]] = None + cropper = RandSpatialCrop(roi_size, max_roi_size, random_center, random_size) + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) - def randomize(self, img_size: Sequence[int]) -> None: - self._size = fall_back_tuple(self.roi_size, img_size) - if self.random_size: - max_size = img_size if self.max_roi_size is None else fall_back_tuple(self.max_roi_size, img_size) - if any(i > j for i, j in zip(self._size, max_size)): - raise ValueError(f"min ROI size: {self._size} is bigger than max ROI size: {max_size}.") - self._size = [self.R.randint(low=self._size[i], high=max_size[i] + 1) for i in range(len(img_size))] - if self.random_center: - valid_size = get_valid_patch_size(img_size, self._size) - self._slices = (slice(None),) + get_random_patch(img_size, valid_size, self.R) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - first_key: Union[Hashable, List] = self.first_key(d) - if first_key == []: - return d - - self.randomize(d[first_key].shape[1:]) # type: ignore - if self._size is None: - raise RuntimeError("self._size not specified.") - for key in self.key_iterator(d): - if self.random_center: - self.push_transform(d, key, {"slices": [(i.start, i.stop) for i in self._slices[1:]]}) # type: ignore - d[key] = d[key][self._slices] - else: - self.push_transform(d, key) - cropper = CenterSpatialCrop(self._size) - d[key] = cropper(d[key]) - return d - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = transform[TraceKeys.ORIG_SIZE] - random_center = self.random_center - pad_to_start = np.empty((len(orig_size)), dtype=np.int32) - pad_to_end = np.empty((len(orig_size)), dtype=np.int32) - if random_center: - for i, _slice in enumerate(transform[TraceKeys.EXTRA_INFO]["slices"]): - pad_to_start[i] = _slice[0] - pad_to_end[i] = orig_size[i] - _slice[1] - else: - current_size = d[key].shape[1:] - for i, (o_s, c_s) in enumerate(zip(orig_size, current_size)): - pad_to_start[i] = pad_to_end[i] = (o_s - c_s) / 2 - if o_s % 2 == 0 and c_s % 2 == 1: - pad_to_start[i] += 1 - elif o_s % 2 == 1 and c_s % 2 == 0: - pad_to_end[i] += 1 - # interleave mins and maxes - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d - - -class RandScaleCropd(RandSpatialCropd): +class RandScaleCropd(RandCropd): """ Dictionary-based version :py:class:`monai.transforms.RandScaleCrop`. Crop image with random size or specific size ROI. @@ -654,8 +515,6 @@ class RandScaleCropd(RandSpatialCropd): allow_missing_keys: don't raise exception if key is missing. """ - backend = RandSpatialCropd.backend - def __init__( self, keys: KeysCollection, @@ -665,41 +524,11 @@ def __init__( random_size: bool = True, allow_missing_keys: bool = False, ) -> None: - super().__init__( - keys=keys, - roi_size=-1, - max_roi_size=None, - random_center=random_center, - random_size=random_size, - allow_missing_keys=allow_missing_keys, - ) - self.roi_scale = roi_scale - self.max_roi_scale = max_roi_scale + cropper = RandScaleCrop(roi_scale, max_roi_scale, random_center, random_size) + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - first_key: Union[Hashable, List] = self.first_key(data) # type: ignore - if first_key == []: - return data # type: ignore - - img_size = data[first_key].shape[1:] # type: ignore - ndim = len(img_size) - self.roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)] - if self.max_roi_scale is not None: - self.max_roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.max_roi_scale, ndim), img_size)] - else: - self.max_roi_size = None - return super().__call__(data=data) - - -@contextlib.contextmanager -def _nullcontext(x): - """ - This is just like contextlib.nullcontext but also works in Python 3.6. - """ - yield x - -class RandSpatialCropSamplesd(Randomizable, MapTransform, InvertibleTransform): +class RandSpatialCropSamplesd(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandSpatialCropSamples`. Crop image with random size or specific size ROI to generate a list of N samples. @@ -728,15 +557,6 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform, InvertibleTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - used to add `patch_index` to the meta dict. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - used to add `patch_index` to the meta dict. allow_missing_keys: don't raise exception if key is missing. Raises: @@ -744,8 +564,10 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform, InvertibleTransform): """ - backend = RandSpatialCropd.backend + backend = RandSpatialCropSamples.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") def __init__( self, keys: KeysCollection, @@ -755,63 +577,33 @@ def __init__( random_center: bool = True, random_size: bool = True, meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) - if num_samples < 1: - raise ValueError(f"num_samples must be positive, got {num_samples}.") - self.num_samples = num_samples - self.cropper = RandSpatialCropd(keys, roi_size, max_roi_size, random_center, random_size, allow_missing_keys) - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - - def set_random_state( - self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None - ) -> "RandSpatialCropSamplesd": - super().set_random_state(seed, state) - self.cropper.set_random_state(seed, state) - return self + self.cropper = RandSpatialCropSamples(roi_size, num_samples, max_roi_size, random_center, random_size) def randomize(self, data: Optional[Any] = None) -> None: - pass - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashable, NdarrayOrTensor]]: - ret = [] - for i in range(self.num_samples): - d = dict(data) - # deep copy all the unmodified data - for key in set(data.keys()).difference(set(self.keys)): - d[key] = deepcopy(data[key]) - cropped = self.cropper(d) - # self.cropper will have added RandSpatialCropd to the list. Change to RandSpatialCropSamplesd - for key in self.key_iterator(cropped): - cropped[self.trace_key(key)][-1][TraceKeys.CLASS_NAME] = self.__class__.__name__ # type: ignore - cropped[self.trace_key(key)][-1][TraceKeys.ID] = id(self) # type: ignore - # add `patch_index` to the metadata - for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): - meta_key = meta_key or f"{key}_{meta_key_postfix}" - if meta_key not in cropped: - cropped[meta_key] = {} # type: ignore - cropped[meta_key][Key.PATCH_INDEX] = i # type: ignore - ret.append(cropped) + self.sub_seed = self.R.randint(MAX_SEED, dtype="uint32") + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]: + # output starts as empty list of dictionaries + ret: List[Dict[Hashable, torch.Tensor]] = [{} for _ in range(self.cropper.num_samples)] + # deep copy all the unmodified data + for key in set(data.keys()).difference(set(self.keys)): + for r in ret: + r[key] = deepcopy(data[key]) + + # for each key we reset the random state to ensure crops are the same + self.randomize() + for key in self.key_iterator(dict(data)): + self.cropper.set_random_state(seed=self.sub_seed) + for i, im in enumerate(self.cropper(data[key])): + ret[i][key] = im return ret - def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: - d = deepcopy(dict(data)) - # We changed the transform name from RandSpatialCropd to RandSpatialCropSamplesd - # Need to revert that since we're calling RandSpatialCropd's inverse - for key in self.key_iterator(d): - d[self.trace_key(key)][-1][TraceKeys.CLASS_NAME] = self.cropper.__class__.__name__ - d[self.trace_key(key)][-1][TraceKeys.ID] = id(self.cropper) - context_manager = allow_missing_keys_mode if self.allow_missing_keys else _nullcontext - with context_manager(self.cropper): - return self.cropper.inverse(d) - -class CropForegroundd(MapTransform, InvertibleTransform): +class CropForegroundd(Cropd): """ Dictionary-based version :py:class:`monai.transforms.CropForeground`. Crop only the foreground object of the expected images. @@ -824,8 +616,6 @@ class CropForegroundd(MapTransform, InvertibleTransform): channels. And it can also add margin to every dim of the bounding box of foreground object. """ - backend = CropForeground.backend - def __init__( self, keys: KeysCollection, @@ -835,7 +625,7 @@ def __init__( margin: Union[Sequence[int], int] = 0, allow_smaller: bool = True, k_divisible: Union[Sequence[int], int] = 1, - mode: PadModeSequence = NumpyPadMode.CONSTANT, + mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, start_coord_key: str = "foreground_start_coord", end_coord_key: str = "foreground_end_coord", allow_missing_keys: bool = False, @@ -869,11 +659,10 @@ def __init__( note that `np.pad` treats channel dimension as the first dimension. """ - super().__init__(keys, allow_missing_keys) self.source_key = source_key self.start_coord_key = start_coord_key self.end_coord_key = end_coord_key - self.cropper = CropForeground( + cropper = CropForeground( select_fn=select_fn, channel_indices=channel_indices, margin=margin, @@ -881,48 +670,21 @@ def __init__( k_divisible=k_divisible, **pad_kwargs, ) + super().__init__(keys, cropper=cropper, allow_missing_keys=allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) + self.cropper: CropForeground box_start, box_end = self.cropper.compute_bounding_box(img=d[self.source_key]) d[self.start_coord_key] = box_start d[self.end_coord_key] = box_end for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key, extra_info={"box_start": box_start, "box_end": box_end}) d[key] = self.cropper.crop_pad(img=d[key], box_start=box_start, box_end=box_end, mode=m) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.asarray(transform[TraceKeys.ORIG_SIZE]) - cur_size = np.asarray(d[key].shape[1:]) - extra_info = transform[TraceKeys.EXTRA_INFO] - box_start = np.asarray(extra_info["box_start"]) - box_end = np.asarray(extra_info["box_end"]) - # first crop the padding part - roi_start = np.maximum(-box_start, 0) - roi_end = cur_size - np.maximum(box_end - orig_size, 0) - - d[key] = SpatialCrop(roi_start=roi_start, roi_end=roi_end)(d[key]) - - # update bounding box to pad - pad_to_start = np.maximum(box_start, 0) - pad_to_end = orig_size - np.minimum(box_end, orig_size) - # interleave mins and maxes - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - # second pad back the original size - d[key] = BorderPad(pad)(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d - -class RandWeightedCropd(Randomizable, MapTransform, InvertibleTransform): +class RandWeightedCropd(Randomizable, MapTransform): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -934,16 +696,6 @@ class RandWeightedCropd(Randomizable, MapTransform, InvertibleTransform): spatial_size: the spatial size of the image patch e.g. [224, 224, 128]. If its components have non-positive values, the corresponding size of `img` will be used. num_samples: number of samples (image patches) to take in the returned list. - center_coord_key: if specified, the actual sampling location will be stored with the corresponding key. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - used to add `patch_index` to the meta dict. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - used to add `patch_index` to the meta dict. allow_missing_keys: don't raise exception if key is missing. See Also: @@ -952,6 +704,9 @@ class RandWeightedCropd(Randomizable, MapTransform, InvertibleTransform): backend = SpatialCrop.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") + @deprecated_arg(name="center_coord_key", since="0.9", msg_suffix="coords stored in img.meta['crop_center']") def __init__( self, keys: KeysCollection, @@ -960,85 +715,41 @@ def __init__( num_samples: int = 1, center_coord_key: Optional[str] = None, meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ): MapTransform.__init__(self, keys, allow_missing_keys) - self.spatial_size = ensure_tuple(spatial_size) self.w_key = w_key - self.num_samples = int(num_samples) - self.center_coord_key = center_coord_key - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - self.centers: List[np.ndarray] = [] + self.cropper = RandWeightedCrop(spatial_size, num_samples) - def randomize(self, weight_map: NdarrayOrTensor) -> None: - self.centers = weighted_patch_samples( - spatial_size=self.spatial_size, w=weight_map[0], n_samples=self.num_samples, r_state=self.R - ) - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashable, NdarrayOrTensor]]: - d = dict(data) - self.randomize(d[self.w_key]) - _spatial_size = fall_back_tuple(self.spatial_size, d[self.w_key].shape[1:]) - - # initialize returned list with shallow copy to preserve key ordering - results: List[Dict[Hashable, NdarrayOrTensor]] = [dict(data) for _ in range(self.num_samples)] - # fill in the extra keys with unmodified data - for i in range(self.num_samples): - for key in set(data.keys()).difference(set(self.keys)): - results[i][key] = deepcopy(data[key]) - for key in self.key_iterator(d): - img = d[key] - if img.shape[1:] != d[self.w_key].shape[1:]: - raise ValueError( - f"data {key} and weight map {self.w_key} spatial shape mismatch: " - f"{img.shape[1:]} vs {d[self.w_key].shape[1:]}." - ) - for i, center in enumerate(self.centers): - cropper = SpatialCrop(roi_center=center, roi_size=_spatial_size) - orig_size = img.shape[1:] - results[i][key] = cropper(img) - self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) - if self.center_coord_key: - results[i][self.center_coord_key] = center - # fill in the extra keys with unmodified data - for i in range(self.num_samples): - # add `patch_index` to the metadata - for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): - meta_key = meta_key or f"{key}_{meta_key_postfix}" - if meta_key not in results[i]: - results[i][meta_key] = {} # type: ignore - results[i][meta_key][Key.PATCH_INDEX] = i # type: ignore - - return results - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.asarray(transform[TraceKeys.ORIG_SIZE]) - current_size = np.asarray(d[key].shape[1:]) - center = transform[TraceKeys.EXTRA_INFO]["center"] - cropper = SpatialCrop(roi_center=center, roi_size=self.spatial_size) - # get required pad to start and end - pad_to_start = np.array([s.indices(o)[0] for s, o in zip(cropper.slices, orig_size)]) - pad_to_end = orig_size - current_size - pad_to_start - # interleave mins and maxes - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandWeightedCropd": + super().set_random_state(seed, state) + self.cropper.set_random_state(seed, state) + return self - return d + def randomize(self, weight_map: NdarrayOrTensor) -> None: + self.cropper.randomize(weight_map) + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]: + # output starts as empty list of dictionaries + ret: List = [{} for _ in range(self.cropper.num_samples)] + # deep copy all the unmodified data + for key in set(data.keys()).difference(set(self.keys)): + for r in ret: + r[key] = deepcopy(data[key]) + + self.randomize(weight_map=data[self.w_key]) + for key in self.key_iterator(data): + for i, im in enumerate(self.cropper(data[key], weight_map=data[self.w_key], randomize=False)): + ret[i][key] = im + return ret -class RandCropByPosNegLabeld(Randomizable, MapTransform, InvertibleTransform): +@deprecated_arg(name="meta_keys", since="0.9") +@deprecated_arg(name="meta_key_postfix", since="0.9") +class RandCropByPosNegLabeld(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandCropByPosNegLabel`. Crop random fixed sized regions with the center being a foreground or background voxel @@ -1079,15 +790,6 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform, InvertibleTransform): `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `FgBgToIndicesd` transform first and cache the results. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - used to add `patch_index` to the meta dict. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - used to add `patch_index` to the meta dict. allow_smaller: if `False`, an exception will be raised if the image is smaller than the requested ROI in any dimension. If `True`, any smaller dimensions will be set to match the cropped size (i.e., no cropping in that dimension). @@ -1114,54 +816,41 @@ def __init__( fg_indices_key: Optional[str] = None, bg_indices_key: Optional[str] = None, meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", allow_smaller: bool = False, allow_missing_keys: bool = False, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) self.label_key = label_key - self.spatial_size: Union[Tuple[int, ...], Sequence[int], int] = spatial_size - if pos < 0 or neg < 0: - raise ValueError(f"pos and neg must be nonnegative, got pos={pos} neg={neg}.") - if pos + neg == 0: - raise ValueError("Incompatible values: pos=0 and neg=0.") - self.pos_ratio = pos / (pos + neg) - self.num_samples = num_samples self.image_key = image_key - self.image_threshold = image_threshold self.fg_indices_key = fg_indices_key self.bg_indices_key = bg_indices_key - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - self.centers: Optional[List[List[int]]] = None - self.allow_smaller = allow_smaller + self.cropper = RandCropByPosNegLabel( + spatial_size=spatial_size, + pos=pos, + neg=neg, + num_samples=num_samples, + image_threshold=image_threshold, + allow_smaller=allow_smaller, + ) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandCropByPosNegLabeld": + super().set_random_state(seed, state) + self.cropper.set_random_state(seed, state) + return self def randomize( self, - label: NdarrayOrTensor, + label: torch.Tensor, fg_indices: Optional[NdarrayOrTensor] = None, bg_indices: Optional[NdarrayOrTensor] = None, - image: Optional[NdarrayOrTensor] = None, + image: Optional[torch.Tensor] = None, ) -> None: - if fg_indices is None or bg_indices is None: - fg_indices_, bg_indices_ = map_binary_to_indices(label, image, self.image_threshold) - else: - fg_indices_ = fg_indices - bg_indices_ = bg_indices - self.centers = generate_pos_neg_label_crop_centers( - self.spatial_size, - self.num_samples, - self.pos_ratio, - label.shape[1:], - fg_indices_, - bg_indices_, - self.R, - self.allow_smaller, - ) + self.cropper.randomize(label=label, fg_indices=fg_indices, bg_indices=bg_indices, image=image) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashable, NdarrayOrTensor]]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]: d = dict(data) label = d[self.label_key] image = d[self.image_key] if self.image_key else None @@ -1169,57 +858,23 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab bg_indices = d.pop(self.bg_indices_key, None) if self.bg_indices_key is not None else None self.randomize(label, fg_indices, bg_indices, image) - if self.centers is None: - raise ValueError("no available ROI centers to crop.") # initialize returned list with shallow copy to preserve key ordering - results: List[Dict[Hashable, NdarrayOrTensor]] = [dict(d) for _ in range(self.num_samples)] - - for i, center in enumerate(self.centers): - # fill in the extra keys with unmodified data - for key in set(d.keys()).difference(set(self.keys)): - results[i][key] = deepcopy(d[key]) - for key in self.key_iterator(d): - img = d[key] - orig_size = img.shape[1:] - roi_size = fall_back_tuple(self.spatial_size, default=orig_size) - cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) - results[i][key] = cropper(img) - self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) - # add `patch_index` to the metadata - for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): - meta_key = meta_key or f"{key}_{meta_key_postfix}" - if meta_key not in results[i]: - results[i][meta_key] = {} # type: ignore - results[i][meta_key][Key.PATCH_INDEX] = i # type: ignore - - return results - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.asarray(transform[TraceKeys.ORIG_SIZE]) - current_size = np.asarray(d[key].shape[1:]) - center = transform[TraceKeys.EXTRA_INFO]["center"] - roi_size = fall_back_tuple(self.spatial_size, default=orig_size) - cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) # type: ignore - # get required pad to start and end - pad_to_start = np.array([s.indices(o)[0] for s, o in zip(cropper.slices, orig_size)]) - pad_to_end = orig_size - current_size - pad_to_start - # interleave mins and maxes - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d + ret: List = [{} for _ in range(self.cropper.num_samples)] + # deep copy all the unmodified data + for key in set(data.keys()).difference(set(self.keys)): + for r in ret: + r[key] = deepcopy(data[key]) + + for key in self.key_iterator(data): + for i, im in enumerate(self.cropper(data[key], label=label, randomize=False)): + ret[i][key] = im + return ret -class RandCropByLabelClassesd(Randomizable, MapTransform, InvertibleTransform): +@deprecated_arg(name="meta_keys", since="0.9") +@deprecated_arg(name="meta_key_postfix", since="0.9") +class RandCropByLabelClassesd(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandCropByLabelClasses`. Crop random fixed sized regions with the center being a class based on the specified ratios of every class. @@ -1284,15 +939,6 @@ class RandCropByLabelClassesd(Randomizable, MapTransform, InvertibleTransform): `image_threshold`, and randomly select crop centers based on them, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `ClassesToIndices` transform first and cache the results for better performance. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - used to add `patch_index` to the meta dict. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - used to add `patch_index` to the meta dict. allow_smaller: if `False`, an exception will be raised if the image is smaller than the requested ROI in any dimension. If `True`, any smaller dimensions will remain unchanged. @@ -1314,98 +960,57 @@ def __init__( image_threshold: float = 0.0, indices_key: Optional[str] = None, meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", allow_smaller: bool = False, allow_missing_keys: bool = False, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) self.label_key = label_key - self.spatial_size = spatial_size - self.ratios = ratios - self.num_classes = num_classes - self.num_samples = num_samples self.image_key = image_key - self.image_threshold = image_threshold self.indices_key = indices_key - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - self.centers: Optional[List[List[int]]] = None - self.allow_smaller = allow_smaller + self.cropper = RandCropByLabelClasses( + spatial_size=spatial_size, + ratios=ratios, + num_classes=num_classes, + num_samples=num_samples, + image_threshold=image_threshold, + allow_smaller=allow_smaller, + ) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandCropByLabelClassesd": + super().set_random_state(seed, state) + self.cropper.set_random_state(seed, state) + return self def randomize( - self, - label: NdarrayOrTensor, - indices: Optional[List[NdarrayOrTensor]] = None, - image: Optional[NdarrayOrTensor] = None, + self, label: torch.Tensor, indices: Optional[List[NdarrayOrTensor]] = None, image: Optional[torch.Tensor] = None ) -> None: - if indices is None: - indices_ = map_classes_to_indices(label, self.num_classes, image, self.image_threshold) - else: - indices_ = indices - self.centers = generate_label_classes_crop_centers( - self.spatial_size, self.num_samples, label.shape[1:], indices_, self.ratios, self.R, self.allow_smaller - ) + self.cropper.randomize(label=label, indices=indices, image=image) - def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, NdarrayOrTensor]]: + def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, torch.Tensor]]: d = dict(data) label = d[self.label_key] image = d[self.image_key] if self.image_key else None indices = d.pop(self.indices_key, None) if self.indices_key is not None else None self.randomize(label, indices, image) - if self.centers is None: - raise ValueError("no available ROI centers to crop.") # initialize returned list with shallow copy to preserve key ordering - results: List[Dict[Hashable, NdarrayOrTensor]] = [dict(d) for _ in range(self.num_samples)] - - for i, center in enumerate(self.centers): - # fill in the extra keys with unmodified data - for key in set(d.keys()).difference(set(self.keys)): - results[i][key] = deepcopy(d[key]) - for key in self.key_iterator(d): - img = d[key] - orig_size = img.shape[1:] - roi_size = fall_back_tuple(self.spatial_size, default=orig_size) - cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) - results[i][key] = cropper(img) - self.push_transform(results[i], key, extra_info={"center": center}, orig_size=orig_size) - # add `patch_index` to the metadata - for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): - meta_key = meta_key or f"{key}_{meta_key_postfix}" - if meta_key not in results[i]: - results[i][meta_key] = {} # type: ignore - results[i][meta_key][Key.PATCH_INDEX] = i # type: ignore - - return results - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.asarray(transform[TraceKeys.ORIG_SIZE]) - current_size = np.asarray(d[key].shape[1:]) - center = transform[TraceKeys.EXTRA_INFO]["center"] - roi_size = fall_back_tuple(self.spatial_size, default=orig_size) - cropper = SpatialCrop(roi_center=tuple(center), roi_size=roi_size) # type: ignore - # get required pad to start and end - pad_to_start = np.array([s.indices(o)[0] for s, o in zip(cropper.slices, orig_size)]) - pad_to_end = orig_size - current_size - pad_to_start - # interleave mins and maxes - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - inverse_transform = BorderPad(pad) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - - return d + ret: List = [{} for _ in range(self.cropper.num_samples)] + # deep copy all the unmodified data + for key in set(data.keys()).difference(set(self.keys)): + for r in ret: + r[key] = deepcopy(data[key]) + + for key in self.key_iterator(data): + for i, im in enumerate(self.cropper(data[key], label=label, randomize=False)): + ret[i][key] = im + return ret -class ResizeWithPadOrCropd(MapTransform, InvertibleTransform): +class ResizeWithPadOrCropd(Padd): """ Dictionary-based wrapper of :py:class:`monai.transforms.ResizeWithPadOrCrop`. @@ -1429,63 +1034,17 @@ class ResizeWithPadOrCropd(MapTransform, InvertibleTransform): """ - backend = ResizeWithPadOrCrop.backend - def __init__( self, keys: KeysCollection, spatial_size: Union[Sequence[int], int], - mode: PadModeSequence = NumpyPadMode.CONSTANT, + mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, - method: Union[Method, str] = Method.SYMMETRIC, + method: str = Method.SYMMETRIC, **pad_kwargs, ) -> None: - super().__init__(keys, allow_missing_keys) - self.mode = ensure_tuple_rep(mode, len(self.keys)) - self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, method=method, **pad_kwargs) - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = dict(data) - for key, m in self.key_iterator(d, self.mode): - orig_size = d[key].shape[1:] - d[key] = self.padcropper(d[key], mode=m) - self.push_transform(d, key, orig_size=orig_size, extra_info={"mode": m.value if isinstance(m, Enum) else m}) - return d - - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_size = np.array(transform[TraceKeys.ORIG_SIZE]) - current_size = np.array(d[key].shape[1:]) - # Unfortunately, we can't just use ResizeWithPadOrCrop with original size because of odd/even rounding. - # Instead, we first pad any smaller dimensions, and then we crop any larger dimensions. - - # First, do pad - if np.any((orig_size - current_size) > 0): - pad_to_start = np.floor((orig_size - current_size) / 2).astype(int) - # in each direction, if original size is even and current size is odd, += 1 - pad_to_start[np.logical_and(orig_size % 2 == 0, current_size % 2 == 1)] += 1 - pad_to_start[pad_to_start < 0] = 0 - pad_to_end = orig_size - current_size - pad_to_start - pad_to_end[pad_to_end < 0] = 0 - pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - d[key] = BorderPad(pad)(d[key]) - - # Next crop - if np.any((orig_size - current_size) < 0): - if self.padcropper.padder.method == Method.SYMMETRIC: - roi_center = [floor(i / 2) if r % 2 == 0 else (i - 1) // 2 for r, i in zip(orig_size, current_size)] - else: - roi_center = [floor(r / 2) if r % 2 == 0 else (r - 1) // 2 for r in orig_size] - - d[key] = SpatialCrop(roi_center, orig_size)(d[key]) - - # Remove the applied transform - self.pop_transform(d, key) - - return d + padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, method=method, **pad_kwargs) + super().__init__(keys, padder=padcropper, mode=mode, allow_missing_keys=allow_missing_keys) # type: ignore class BoundingRectd(MapTransform): @@ -1528,9 +1087,12 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +PadD = PadDict = Padd SpatialPadD = SpatialPadDict = SpatialPadd BorderPadD = BorderPadDict = BorderPadd DivisiblePadD = DivisiblePadDict = DivisiblePadd +CropD = CropDict = Cropd +RandCropD = RandCropDict = RandCropd SpatialCropD = SpatialCropDict = SpatialCropd CenterSpatialCropD = CenterSpatialCropDict = CenterSpatialCropd CenterScaleCropD = CenterScaleCropDict = CenterScaleCropd diff --git a/tests/croppers.py b/tests/croppers.py new file mode 100644 index 0000000000..8f78249d90 --- /dev/null +++ b/tests/croppers.py @@ -0,0 +1,103 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from copy import deepcopy + +import numpy as np + +from monai.data.meta_tensor import MetaTensor +from monai.transforms.transform import MapTransform +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose + + +class CropTest(unittest.TestCase): + @staticmethod + def get_arr(shape): + return np.random.randint(100, size=shape).astype(float) + + def crop_test(self, input_param, input_shape, expected_shape, same_area=None): + base_comparison = None + input_image = self.get_arr(input_shape) + + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + # input parameters, such as roi_start can be numpy, torch, list etc. + for param_type in TEST_NDARRAYS_ALL + (None,): + with self.subTest(param_type=param_type): + input_param_mod = deepcopy(input_param) + if param_type is not None: + for k in ("roi_start", "roi_end", "roi_center", "roi_size", "roi_scale"): + if k in input_param: + input_param_mod[k] = param_type(input_param[k]) + im = im_type(input_image) + cropper = self.Cropper(**input_param_mod) + is_map = isinstance(cropper, MapTransform) + input_data = {"img": im} if is_map else im + result = cropper(input_data) + out_im = result["img"] if is_map else result + self.assertIsInstance(out_im, MetaTensor) + self.assertTupleEqual(out_im.shape, expected_shape) + if same_area is not None: + assert_allclose(out_im, im[same_area], type_test=False) + # check result is the same regardless of input type + if base_comparison is None: + base_comparison = out_im + else: + assert_allclose(out_im, base_comparison) + + # test inverse + inv = cropper.inverse(result) + inv_im = inv["img"] if is_map else inv + self.assertIsInstance(inv_im, MetaTensor) + if same_area is not None: + assert_allclose(inv_im[same_area], im[same_area], type_test=False) + self.assertEqual(inv_im.applied_operations, []) + + def crop_test_value(self, input_param, input_arr, expected_array): + cropper = self.Cropper(**input_param) + is_map = isinstance(cropper, MapTransform) + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + im = im_type(input_arr) + input_data = {"img": im} if is_map else im + result = self.Cropper(**input_param)(input_data) + out_im = result["img"] if is_map else result + self.assertIsInstance(out_im, MetaTensor) + assert_allclose(out_im, expected_array, type_test=False) + + def multi_inverse(self, input_shape, init_params): + input_data = np.arange(np.prod(input_shape)).reshape(*input_shape) + 1 + xform = self.Cropper(**init_params) + xform.set_random_state(1234) + out = xform(input_data) + if "num_samples" in init_params: + self.assertEqual(len(out), init_params["num_samples"]) + inv = xform.inverse(out) + self.assertIsInstance(inv, MetaTensor) + self.assertEqual(inv.applied_operations, []) + self.assertTrue("patch_index" not in inv.meta) + self.assertTupleEqual(inv.shape, input_shape) + inv_np = inv.numpy() + + # get list of all numbers that exist inside the crops + uniques = set() + for o in out: + uniques.update(set(o.flatten().tolist())) + + # make sure that + for i in uniques: + a = np.where(input_data == i) + b = np.where(inv_np == i) + self.assertTupleEqual(a, b) + # there should be as many zeros as elements missing from uniques + missing = input_data.size - len(uniques) + self.assertEqual((inv_np == 0).sum(), missing) diff --git a/tests/padders.py b/tests/padders.py new file mode 100644 index 0000000000..3fa3280cb5 --- /dev/null +++ b/tests/padders.py @@ -0,0 +1,108 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from typing import List + +import numpy as np +import torch + +from monai.data.meta_tensor import MetaTensor +from monai.transforms.transform import MapTransform +from monai.utils.enums import NumpyPadMode, PytorchPadMode +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose + +MODES = [] +# Test modes +NP_MODES: List = [ + "constant", + "edge", + # `reflect` mode is not supported in some PyTorch versions, skip the test + # "reflect", + "wrap", + "median", + "mean", +] +MODES += NP_MODES +MODES += [NumpyPadMode(i) for i in NP_MODES] + +PT_MODES: list = [ + "constant", + "replicate", + "circular", + # `reflect` mode is not supported in some PyTorch versions, skip the test + # "reflect", +] +MODES += PT_MODES +MODES += [PytorchPadMode(i) for i in PT_MODES] + + +class PadTest(unittest.TestCase): + @staticmethod + def get_arr(shape): + return np.random.randint(100, size=shape).astype(float) + + def pad_test(self, input_param, input_shape, expected_shape, modes=None): + # loop over each mode + for mode in modes or MODES: + with self.subTest(mode=mode): + base_comparison = None + im = self.get_arr(input_shape) + padder = self.Padder(mode=mode, **input_param) + is_map = isinstance(padder, MapTransform) + # check result is the same regardless of input type + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + input_image = im_type(im) + input_data = {"img": im_type(im)} if is_map else im_type(im) + # our array transforms can also take `mode` as an argument to `__call__` + # Check this gives equivalent results + for call_extra_args in [{}] if is_map else [{}, {"mode": mode}]: + with self.subTest(call_extra_args=call_extra_args): + r_out = padder(input_data, **call_extra_args) + r_im = r_out["img"] if is_map else r_out + # check shape, type, etc. + np.testing.assert_allclose(r_im.shape, expected_shape) + self.assertIsInstance(r_im, MetaTensor) + self.assertEqual(len(r_im.applied_operations), 1) + # check results are same regardless of input type + if base_comparison is None: + base_comparison = r_im + else: + assert_allclose(r_im, base_comparison) + # test inverse + if isinstance(r_im, MetaTensor): + r_out = padder.inverse(r_out) + r_im = r_out["img"] if is_map else r_out + self.assertIsInstance(r_im, MetaTensor) + assert_allclose(r_im, input_image, type_test=False) + self.assertEqual(r_im.applied_operations, []) + + def pad_test_kwargs(self, unchanged_slices, **input_param): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + for kwargs in ({"value": 2}, {"constant_values": ((0, 0), (1, 1), (2, 2))}): + with self.subTest(kwargs=kwargs): + im = im_type(np.random.randint(-100, -10, size=(3, 8, 4))) + padder = self.Padder(**input_param, **kwargs) + result = padder(im) + if isinstance(result, torch.Tensor): + result = result.cpu() + assert_allclose(result[unchanged_slices], im, type_test=False) + # we should have the same as the input plus some 2s (if value) or 1s and 2s (if constant_values) + expected_vals = np.unique(im).tolist() + expected_vals += [2] if "value" in kwargs else [1, 2] + assert_allclose(np.unique(result), expected_vals, type_test=False) + # check inverse + if isinstance(result, MetaTensor): + inv = padder.inverse(result) + assert_allclose(im, inv, type_test=False) + self.assertEqual(inv.applied_operations, []) diff --git a/tests/test_border_pad.py b/tests/test_border_pad.py index b632ff831f..1194ae49a6 100644 --- a/tests/test_border_pad.py +++ b/tests/test_border_pad.py @@ -11,45 +11,32 @@ import unittest -import numpy as np from parameterized import parameterized from monai.transforms import BorderPad -from monai.utils import NumpyPadMode -from tests.utils import TEST_NDARRAYS - -TEST_CASE_1 = [{"spatial_border": 2, "mode": "constant"}, np.zeros((3, 8, 8, 4)), np.zeros((3, 12, 12, 8))] - -TEST_CASE_2 = [{"spatial_border": [1, 2, 3], "mode": "constant"}, np.zeros((3, 8, 8, 4)), np.zeros((3, 10, 12, 10))] - -TEST_CASE_3 = [ - {"spatial_border": [1, 2, 3, 4, 5, 6], "mode": "constant"}, - np.zeros((3, 8, 8, 4)), - np.zeros((3, 11, 15, 15)), +from monai.utils.enums import NumpyPadMode, PytorchPadMode +from tests.padders import PadTest + +TESTS = [ + [{"spatial_border": 2}, (3, 8, 8, 4), (3, 12, 12, 8)], + [{"spatial_border": [1, 2, 3]}, (3, 8, 8, 4), (3, 10, 12, 10)], + [{"spatial_border": [1, 2, 3, 4, 5, 6]}, (3, 8, 8, 4), (3, 11, 15, 15)], + [{"spatial_border": [1, 2, 3, 4, 5, 6]}, (3, 8, 8, 4), (3, 11, 15, 15)], ] -TEST_CASE_4 = [ - {"spatial_border": [1, 2, 3, 4, 5, 6], "mode": NumpyPadMode.CONSTANT}, - np.zeros((3, 8, 8, 4)), - np.zeros((3, 11, 15, 15)), -] +class TestBorderPad(PadTest): + Padder = BorderPad -class TestBorderPad(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) - def test_pad_shape(self, input_param, input_data, expected_val): - for p in TEST_NDARRAYS: - padder = BorderPad(**input_param) - r1 = padder(p(input_data)) - r2 = padder(input_data, mode=input_param["mode"]) - self.assertAlmostEqual(r1.shape, expected_val.shape) - self.assertAlmostEqual(r2.shape, expected_val.shape) + @parameterized.expand(TESTS) + def test_pad(self, input_param, input_shape, expected_shape): + modes = ["constant", NumpyPadMode.CONSTANT, PytorchPadMode.CONSTANT] + self.pad_test(input_param, input_shape, expected_shape, modes) def test_pad_kwargs(self): - padder = BorderPad(spatial_border=2, mode="constant", constant_values=((0, 0), (1, 1), (2, 2))) - result = padder(np.zeros((3, 8, 4))) - np.testing.assert_allclose(result[:, :2, 2:6], np.ones((3, 2, 4))) - np.testing.assert_allclose(result[:, :, :2], np.ones((3, 12, 2)) + 1) + kwargs = {"spatial_border": 2, "mode": "constant"} + unchanged_slices = [slice(None), slice(2, -2), slice(2, -2)] + self.pad_test_kwargs(unchanged_slices, **kwargs) if __name__ == "__main__": diff --git a/tests/test_border_padd.py b/tests/test_border_padd.py index e4b8dd20ea..ca55c8b09d 100644 --- a/tests/test_border_padd.py +++ b/tests/test_border_padd.py @@ -11,49 +11,28 @@ import unittest -import numpy as np from parameterized import parameterized from monai.transforms import BorderPadd -from monai.utils import NumpyPadMode - -TEST_CASE_1 = [ - {"keys": ["img", "seg"], "spatial_border": 2, "mode": ["constant", "edge"]}, - {"img": np.zeros((3, 8, 8, 4)), "seg": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 12, 12, 8)), -] - -TEST_CASE_2 = [ - {"keys": "img", "spatial_border": [1, 2, 3], "mode": "constant"}, - {"img": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 10, 12, 10)), -] - -TEST_CASE_3 = [ - {"keys": "img", "spatial_border": [1, 2, 3, 4, 5, 6], "mode": "constant"}, - {"img": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 11, 15, 15)), -] - -TEST_CASE_4 = [ - {"keys": ["img", "seg"], "spatial_border": 2, "mode": ["constant", NumpyPadMode.EDGE]}, - {"img": np.zeros((3, 8, 8, 4)), "seg": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 12, 12, 8)), +from monai.utils.enums import NumpyPadMode, PytorchPadMode +from tests.padders import PadTest + +TESTS = [ + [{"keys": "img", "spatial_border": 2}, (3, 8, 8, 4), (3, 12, 12, 8)], + [{"keys": "img", "spatial_border": [1, 2, 3]}, (3, 8, 8, 4), (3, 10, 12, 10)], + [{"keys": "img", "spatial_border": [1, 2, 3, 4, 5, 6]}, (3, 8, 8, 4), (3, 11, 15, 15)], + [{"keys": "img", "spatial_border": 2}, (3, 8, 8, 4), (3, 12, 12, 8)], + [{"keys": "img", "spatial_border": 2}, (3, 8, 8, 4), (3, 12, 12, 8)], ] -TEST_CASE_5 = [ - {"keys": ["img", "seg"], "spatial_border": 2, "mode": [NumpyPadMode.CONSTANT, NumpyPadMode.EDGE]}, - {"img": np.zeros((3, 8, 8, 4)), "seg": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 12, 12, 8)), -] +class TestBorderPadd(PadTest): + Padder = BorderPadd -class TestBorderPadd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) - def test_pad_shape(self, input_param, input_data, expected_val): - padder = BorderPadd(**input_param) - result = padder(input_data) - self.assertAlmostEqual(result["img"].shape, expected_val.shape) + @parameterized.expand(TESTS) + def test_pad(self, input_param, input_shape, expected_shape): + modes = ["constant", NumpyPadMode.CONSTANT, PytorchPadMode.CONSTANT, "edge", NumpyPadMode.EDGE] + self.pad_test(input_param, input_shape, expected_shape, modes) if __name__ == "__main__": diff --git a/tests/test_center_scale_crop.py b/tests/test_center_scale_crop.py index f22651e3e0..ab07a44eb5 100644 --- a/tests/test_center_scale_crop.py +++ b/tests/test_center_scale_crop.py @@ -9,43 +9,40 @@ # See the License for the specific language governing permissions and # limitations under the License. + import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import CenterScaleCrop +from tests.croppers import CropTest -TEST_CASE_0 = [{"roi_scale": [0.6, 0.3, -1]}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 1, 3)] - -TEST_CASE_1 = [{"roi_scale": 0.6}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 2, 2)] - -TEST_CASE_2 = [ - {"roi_scale": [0.4, 0.4]}, - np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), - np.array([[[1, 2], [2, 3]]]), +TEST_SHAPES = [ + [{"roi_scale": [0.6, 0.3, -1]}, (3, 3, 3, 3), (3, 2, 1, 3)], + [{"roi_scale": 0.6}, (3, 3, 3, 3), (3, 2, 2, 2)], + [{"roi_scale": 0.5}, (3, 3, 3, 3), (3, 2, 2, 2)], ] -TEST_CASE_3 = [ - {"roi_scale": 0.5}, - torch.randint(0, 2, size=[3, 3, 3, 3], device="cuda" if torch.cuda.is_available() else "cpu"), - (3, 2, 2, 2), +TEST_VALUES = [ + [ + {"roi_scale": [0.4, 0.4]}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + np.array([[[1, 2], [2, 3]]]), + ] ] -class TestCenterScaleCrop(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_3]) - def test_shape(self, input_param, input_data, expected_shape): - result = CenterScaleCrop(**input_param)(input_data) - self.assertEqual(isinstance(result, torch.Tensor), isinstance(input_data, torch.Tensor)) - np.testing.assert_allclose(result.shape, expected_shape) +class TestCenterSpatialCrop(CropTest): + Cropper = CenterScaleCrop + + @parameterized.expand(TEST_SHAPES) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_2]) - def test_value(self, input_param, input_data, expected_value): - result = CenterScaleCrop(**input_param)(input_data) - self.assertEqual(isinstance(result, torch.Tensor), isinstance(input_data, torch.Tensor)) - np.testing.assert_allclose(result, expected_value) + @parameterized.expand(TEST_VALUES) + def test_value(self, input_param, input_arr, expected_arr): + self.crop_test_value(input_param, input_arr, expected_arr) if __name__ == "__main__": diff --git a/tests/test_center_scale_cropd.py b/tests/test_center_scale_cropd.py index 8aef2dbe5b..894692530d 100644 --- a/tests/test_center_scale_cropd.py +++ b/tests/test_center_scale_cropd.py @@ -12,44 +12,37 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import CenterScaleCropd +from tests.croppers import CropTest -TEST_CASE_0 = [{"keys": "img", "roi_scale": [0.6, 0.3, -1]}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 1, 3)] - -TEST_CASE_1 = [{"keys": "img", "roi_scale": 0.6}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 2, 2)] - -TEST_CASE_2 = [ - {"keys": "img", "roi_scale": [0.4, 0.4]}, - np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), - np.array([[[1, 2], [2, 3]]]), +TESTS = [ + [{"keys": "img", "roi_scale": [0.6, 0.3, -1]}, (3, 3, 3, 3), (3, 2, 1, 3)], + [{"keys": "img", "roi_scale": 0.6}, (3, 3, 3, 3), (3, 2, 2, 2)], + [{"keys": "img", "roi_scale": 0.5}, (3, 3, 3, 3), (3, 2, 2, 2)], ] -TEST_CASE_3 = [ - {"keys": "img", "roi_scale": 0.5}, - torch.randint(0, 2, size=[3, 3, 3, 3], device="cuda" if torch.cuda.is_available() else "cpu"), - (3, 2, 2, 2), -] -TEST_CASE_4 = [ - {"keys": "test", "roi_scale": 0.6, "allow_missing_keys": True}, - np.random.randint(0, 2, size=[3, 3, 3, 3]), - (3, 3, 3, 3), +TEST_VALUES = [ + [ + {"keys": "img", "roi_scale": [0.4, 0.4]}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + np.array([[[1, 2], [2, 3]]]), + ] ] -class TestCenterScaleCropd(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_3, TEST_CASE_4]) - def test_shape(self, input_param, input_data, expected_shape): - result = CenterScaleCropd(**input_param)({"img": input_data}) - np.testing.assert_allclose(result["img"].shape, expected_shape) +class TestCenterScaleCropd(CropTest): + Cropper = CenterScaleCropd + + @parameterized.expand(TESTS) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_2]) - def test_value(self, input_param, input_data, expected_value): - result = CenterScaleCropd(**input_param)({"img": input_data}) - np.testing.assert_allclose(result["img"], expected_value) + @parameterized.expand(TEST_VALUES) + def test_value(self, input_param, input_arr, expected_arr): + self.crop_test_value(input_param, input_arr, expected_arr) if __name__ == "__main__": diff --git a/tests/test_center_spatial_crop.py b/tests/test_center_spatial_crop.py index 09f61be2f1..7b5b19107d 100644 --- a/tests/test_center_spatial_crop.py +++ b/tests/test_center_spatial_crop.py @@ -12,40 +12,36 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import CenterSpatialCrop +from tests.croppers import CropTest -TEST_CASE_0 = [{"roi_size": [2, 2, -1]}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 2, 3)] - -TEST_CASE_1 = [{"roi_size": [2, 2, 2]}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 2, 2)] - -TEST_CASE_2 = [ - {"roi_size": [2, 2]}, - np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), - np.array([[[1, 2], [2, 3]]]), +TEST_SHAPES = [ + [{"roi_size": [2, 2, -1]}, (3, 3, 3, 3), (3, 2, 2, 3)], + [{"roi_size": [2, 2, 2]}, (3, 3, 3, 3), (3, 2, 2, 2)], + [{"roi_size": [2, 2, 2]}, (3, 3, 3, 3), (3, 2, 2, 2)], ] -TEST_CASE_3 = [ - {"roi_size": [2, 2, 2]}, - torch.randint(0, 2, size=[3, 3, 3, 3], device="cuda" if torch.cuda.is_available() else "cpu"), - (3, 2, 2, 2), +TEST_VALUES = [ + [ + {"roi_size": [2, 2]}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + np.array([[[1, 2], [2, 3]]]), + ] ] -class TestCenterSpatialCrop(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_3]) - def test_shape(self, input_param, input_data, expected_shape): - result = CenterSpatialCrop(**input_param)(input_data) - self.assertEqual(isinstance(result, torch.Tensor), isinstance(input_data, torch.Tensor)) - np.testing.assert_allclose(result.shape, expected_shape) +class TestCenterSpatialCrop(CropTest): + Cropper = CenterSpatialCrop + + @parameterized.expand(TEST_SHAPES) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_2]) - def test_value(self, input_param, input_data, expected_value): - result = CenterSpatialCrop(**input_param)(input_data) - self.assertEqual(isinstance(result, torch.Tensor), isinstance(input_data, torch.Tensor)) - np.testing.assert_allclose(result, expected_value) + @parameterized.expand(TEST_VALUES) + def test_value(self, input_param, input_arr, expected_arr): + self.crop_test_value(input_param, input_arr, expected_arr) if __name__ == "__main__": diff --git a/tests/test_center_spatial_cropd.py b/tests/test_center_spatial_cropd.py index bdbc1a5031..fa7bc8c8fa 100644 --- a/tests/test_center_spatial_cropd.py +++ b/tests/test_center_spatial_cropd.py @@ -15,43 +15,42 @@ from parameterized import parameterized from monai.transforms import CenterSpatialCropd -from tests.utils import TEST_NDARRAYS, assert_allclose - -TEST_SHAPES = [] -for p in TEST_NDARRAYS: - TEST_SHAPES.append( - [{"keys": "img", "roi_size": [2, -1, -1]}, {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, (3, 2, 3, 3)] - ) - - TEST_SHAPES.append( - [{"keys": "img", "roi_size": [2, 2, 2]}, {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, (3, 2, 2, 2)] - ) - -TEST_CASES = [] -for p in TEST_NDARRAYS: - TEST_CASES.append( - [ - {"keys": "img", "roi_size": [2, 2]}, - { - "img": p( - np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]) - ) - }, - p(np.array([[[1, 2], [2, 3]]])), - ] - ) - - -class TestCenterSpatialCropd(unittest.TestCase): +from tests.croppers import CropTest + +TEST_SHAPES = [ + [ + {"keys": "img", "roi_size": [2, -1, -1]}, + (3, 3, 3, 3), + (3, 2, 3, 3), + (slice(None), slice(None, -1), slice(None), slice(None)), + ], + [ + {"keys": "img", "roi_size": [2, 2, 2]}, + (3, 3, 3, 3), + (3, 2, 2, 2), + (slice(None), slice(None, -1), slice(None, -1), slice(None, -1)), + ], +] + +TEST_CASES = [ + [ + {"keys": "img", "roi_size": [2, 2]}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + np.array([[[1, 2], [2, 3]]]), + ] +] + + +class TestCenterSpatialCropd(CropTest): + Cropper = CenterSpatialCropd + @parameterized.expand(TEST_SHAPES) - def test_shape(self, input_param, input_data, expected_shape): - result = CenterSpatialCropd(**input_param)(input_data) - self.assertTupleEqual(result["img"].shape, expected_shape) + def test_shape(self, input_param, input_shape, expected_shape, same_area): + self.crop_test(input_param, input_shape, expected_shape, same_area) @parameterized.expand(TEST_CASES) def test_value(self, input_param, input_data, expected_value): - result = CenterSpatialCropd(**input_param)(input_data) - assert_allclose(result["img"], expected_value, type_test=False) + self.crop_test_value(input_param, input_data, expected_value) if __name__ == "__main__": diff --git a/tests/test_crop_foreground.py b/tests/test_crop_foreground.py index af945673fe..e400406e4d 100644 --- a/tests/test_crop_foreground.py +++ b/tests/test_crop_foreground.py @@ -12,15 +12,15 @@ import unittest import numpy as np -import torch from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms import CropForeground -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TEST_COORDS, TESTS = [], [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_COORDS.append( [ {"select_fn": lambda x: x > 0, "channel_indices": None, "margin": 0}, @@ -89,8 +89,15 @@ class TestCropForeground(unittest.TestCase): @parameterized.expand(TEST_COORDS + TESTS) def test_value(self, argments, image, expected_data): - result = CropForeground(**argments)(image) - torch.testing.assert_allclose(result, expected_data, rtol=1e-7, atol=0) + cropper = CropForeground(**argments) + result = cropper(image) + assert_allclose(result, expected_data, type_test=False) + self.assertIsInstance(result, MetaTensor) + self.assertEqual(len(result.applied_operations), 1) + inv = cropper.inverse(result) + self.assertIsInstance(inv, MetaTensor) + self.assertEqual(inv.applied_operations, []) + self.assertTupleEqual(inv.shape, image.shape) @parameterized.expand(TEST_COORDS) def test_return_coords(self, argments, image, _): diff --git a/tests/test_crop_foregroundd.py b/tests/test_crop_foregroundd.py index fa69143827..d641c5a376 100644 --- a/tests/test_crop_foregroundd.py +++ b/tests/test_crop_foregroundd.py @@ -12,14 +12,13 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import CropForegroundd -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TEST_POSITION, TESTS = [], [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_POSITION.append( [ @@ -151,12 +150,15 @@ class TestCropForegroundd(unittest.TestCase): @parameterized.expand(TEST_POSITION + TESTS) def test_value(self, argments, input_data, expected_data): - result = CropForegroundd(**argments)(input_data) - r, i = result["img"], input_data["img"] - self.assertEqual(type(r), type(i)) - if isinstance(r, torch.Tensor): - self.assertEqual(r.device, i.device) - assert_allclose(r, expected_data) + cropper = CropForegroundd(**argments) + result = cropper(input_data) + assert_allclose(result["img"], expected_data, type_test=False) + if "label" in input_data and "img" in input_data: + self.assertTupleEqual(result["img"].shape, result["label"].shape) + inv = cropper.inverse(result) + self.assertTupleEqual(inv["img"].shape, input_data["img"].shape) + if "label" in input_data: + self.assertTupleEqual(inv["label"].shape, input_data["label"].shape) @parameterized.expand(TEST_POSITION) def test_foreground_position(self, argments, input_data, _): diff --git a/tests/test_divisible_pad.py b/tests/test_divisible_pad.py index f940636fa8..df610c4939 100644 --- a/tests/test_divisible_pad.py +++ b/tests/test_divisible_pad.py @@ -11,43 +11,32 @@ import unittest -import numpy as np -import torch from parameterized import parameterized from monai.transforms import DivisiblePad -from tests.utils import TEST_NDARRAYS +from monai.utils.enums import NumpyPadMode, PytorchPadMode +from tests.padders import PadTest TESTS = [] -for p in TEST_NDARRAYS: - # pad first dim to be divisible by 7, the second unchanged. - TESTS.append([{"k": (7, -1), "mode": "constant"}, p(np.zeros((3, 8, 7))), p(np.zeros((3, 14, 7)))]) +# pad first dim to be divisible by 7, the second unchanged. +TESTS.append([{"k": (7, -1)}, (3, 8, 7), (3, 14, 7)]) +# pad all dimensions to be divisible by 5 +TESTS.append([{"k": 5, "method": "end"}, (3, 10, 5, 17), (3, 10, 5, 20)]) - # pad all dimensions to be divisible by 5 - TESTS.append( - [{"k": 5, "mode": "constant", "method": "end"}, p(np.zeros((3, 10, 5, 17))), p(np.zeros((3, 10, 5, 20)))] - ) +class TestDivisiblePad(PadTest): + Padder = DivisiblePad -class TestDivisiblePad(unittest.TestCase): @parameterized.expand(TESTS) - def test_pad_shape(self, input_param, input_data, expected_val): - padder = DivisiblePad(**input_param) - result = padder(input_data) - self.assertAlmostEqual(result.shape, expected_val.shape) - result = padder(input_data, mode=input_param["mode"]) - self.assertAlmostEqual(result.shape, expected_val.shape) + def test_pad(self, input_param, input_shape, expected_shape): + modes = ["constant", NumpyPadMode.CONSTANT, PytorchPadMode.CONSTANT] + self.pad_test(input_param, input_shape, expected_shape, modes) def test_pad_kwargs(self): - for p in TEST_NDARRAYS: - input_data = p(np.zeros((3, 8, 4))) - if isinstance(input_data, np.ndarray): - result = DivisiblePad(k=5, mode="constant", constant_values=((0, 0), (1, 1), (2, 2)))(input_data) - np.testing.assert_allclose(result[:, :1, :4], np.ones((3, 1, 4)), rtol=1e-7, atol=0) - else: - result = DivisiblePad(k=5, mode="constant", value=2)(input_data).cpu() - torch.testing.assert_allclose(result[:, :, 4:5], np.ones((3, 10, 1)) + 1, rtol=1e-7, atol=0) + kwargs = {"k": 5, "method": "end"} + unchanged_slices = [slice(None), slice(None, 8), slice(None, 4)] + self.pad_test_kwargs(unchanged_slices, **kwargs) if __name__ == "__main__": diff --git a/tests/test_divisible_padd.py b/tests/test_divisible_padd.py index 61fe917421..93e5a879f0 100644 --- a/tests/test_divisible_padd.py +++ b/tests/test_divisible_padd.py @@ -11,32 +11,25 @@ import unittest -import numpy as np from parameterized import parameterized from monai.transforms import DivisiblePadd +from monai.utils.enums import NumpyPadMode, PytorchPadMode +from tests.padders import PadTest -TEST_CASE_1 = [ - {"keys": ["img"], "k": [4, 3, 2], "mode": "constant"}, - {"img": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 8, 9, 4)), +TESTS = [ + [{"keys": "img", "k": [4, 3, 2]}, (3, 8, 8, 4), (3, 8, 9, 4)], + [{"keys": "img", "k": 7, "method": "end"}, (3, 8, 7), (3, 14, 7)], ] -TEST_CASE_2 = [ - {"keys": ["img"], "k": 7, "mode": "constant", "method": "end"}, - {"img": np.zeros((3, 8, 7))}, - np.zeros((3, 14, 7)), -] - -TEST_CASE_3 = [{"keys": ["img"], "k": 0, "mode": {"constant"}}, {"img": np.zeros((3, 8))}, np.zeros((3, 8))] +class TestDivisiblePadd(PadTest): + Padder = DivisiblePadd -class TestDivisiblePadd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_pad_shape(self, input_param, input_data, expected_val): - padder = DivisiblePadd(**input_param) - result = padder(input_data) - np.testing.assert_allclose(result["img"], expected_val) + @parameterized.expand(TESTS) + def test_pad(self, input_param, input_shape, expected_shape): + modes = ["constant", NumpyPadMode.CONSTANT, PytorchPadMode.CONSTANT, "edge", NumpyPadMode.EDGE] + self.pad_test(input_param, input_shape, expected_shape, modes) if __name__ == "__main__": diff --git a/tests/test_module_list.py b/tests/test_module_list.py index d81d067c58..d0b5aaf26b 100644 --- a/tests/test_module_list.py +++ b/tests/test_module_list.py @@ -38,7 +38,7 @@ def test_public_api(self): def test_transform_api(self): """monai subclasses of MapTransforms must have alias names ending with 'd', 'D', 'Dict'""" to_exclude = {"MapTransform"} # except for these transforms - to_exclude_docs = {"Decollate", "Ensemble", "Invert", "SaveClassification", "RandTorchVision"} + to_exclude_docs = {"Decollate", "Ensemble", "Invert", "SaveClassification", "RandTorchVision", "RandCrop"} to_exclude_docs.update({"DeleteItems", "SelectItems", "CopyItems", "ConcatItems"}) to_exclude_docs.update({"ToMetaTensor", "FromMetaTensor"}) xforms = { diff --git a/tests/test_rand_crop_by_label_classes.py b/tests/test_rand_crop_by_label_classes.py index 11d73df74e..b1165e8986 100644 --- a/tests/test_rand_crop_by_label_classes.py +++ b/tests/test_rand_crop_by_label_classes.py @@ -15,10 +15,10 @@ from parameterized import parameterized from monai.transforms import ClassesToIndices, RandCropByLabelClasses -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL TESTS_INDICES, TESTS_SHAPE = [], [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: # One-Hot label TESTS_INDICES.append( [ diff --git a/tests/test_rand_crop_by_label_classesd.py b/tests/test_rand_crop_by_label_classesd.py index 92780458e0..9a99ebab29 100644 --- a/tests/test_rand_crop_by_label_classesd.py +++ b/tests/test_rand_crop_by_label_classesd.py @@ -15,10 +15,10 @@ from parameterized import parameterized from monai.transforms import ClassesToIndicesd, RandCropByLabelClassesd -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TESTS.append( [ # One-Hot label diff --git a/tests/test_rand_crop_by_pos_neg_label.py b/tests/test_rand_crop_by_pos_neg_label.py index 1d9e2612c7..f6da393ab9 100644 --- a/tests/test_rand_crop_by_pos_neg_label.py +++ b/tests/test_rand_crop_by_pos_neg_label.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.transforms import RandCropByPosNegLabel -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL TESTS = [ [ @@ -103,7 +103,7 @@ def convert_data_type(im_type, d, keys=("img", "image", "label")): @parameterized.expand(TESTS) def test_type_shape(self, input_param, input_data, expected_shape): results = [] - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: input_param_mod = self.convert_data_type(p, input_param) input_data_mod = self.convert_data_type(p, input_data) cropper = RandCropByPosNegLabel(**input_param_mod) diff --git a/tests/test_rand_crop_by_pos_neg_labeld.py b/tests/test_rand_crop_by_pos_neg_labeld.py index a2808bd65d..64673bf4bf 100644 --- a/tests/test_rand_crop_by_pos_neg_labeld.py +++ b/tests/test_rand_crop_by_pos_neg_labeld.py @@ -16,8 +16,7 @@ from parameterized import parameterized from monai.transforms import RandCropByPosNegLabeld -from monai.utils.enums import PostFix -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL TESTS = [ [ @@ -35,7 +34,6 @@ "image": np.random.randint(0, 2, size=[3, 3, 3, 3]), "extra": np.random.randint(0, 2, size=[3, 3, 3, 3]), "label": np.random.randint(0, 2, size=[3, 3, 3, 3]), - PostFix.meta("image"): {"affine": np.eye(3), "shape": "CHWD"}, }, (3, 3, 2, 2), ], @@ -54,7 +52,6 @@ "image": np.random.randint(0, 2, size=[3, 3, 3, 3]), "extra": np.random.randint(0, 2, size=[3, 3, 3, 3]), "label": np.random.randint(0, 2, size=[3, 3, 3, 3]), - PostFix.meta("label"): {"affine": np.eye(3), "shape": "CHWD"}, }, (3, 2, 2, 2), ], @@ -69,12 +66,7 @@ "image_key": None, "image_threshold": 0, }, - { - "image": np.zeros([3, 3, 3, 3]) - 1, - "extra": np.zeros([3, 3, 3, 3]), - "label": np.ones([3, 3, 3, 3]), - PostFix.meta("extra"): {"affine": np.eye(3), "shape": "CHWD"}, - }, + {"image": np.zeros([3, 3, 3, 3]) - 1, "extra": np.zeros([3, 3, 3, 3]), "label": np.ones([3, 3, 3, 3])}, (3, 2, 2, 2), ], [ @@ -89,12 +81,7 @@ "image_threshold": 0, "allow_smaller": True, }, - { - "image": np.zeros([3, 3, 3, 3]) - 1, - "extra": np.zeros([3, 3, 3, 3]), - "label": np.ones([3, 3, 3, 3]), - PostFix.meta("extra"): {"affine": np.eye(3), "shape": "CHWD"}, - }, + {"image": np.zeros([3, 3, 3, 3]) - 1, "extra": np.zeros([3, 3, 3, 3]), "label": np.ones([3, 3, 3, 3])}, (3, 3, 3, 2), ], [ @@ -109,12 +96,7 @@ "image_threshold": 0, "allow_smaller": True, }, - { - "image": np.zeros([3, 3, 3, 3]) - 1, - "extra": np.zeros([3, 3, 3, 3]), - "label": np.ones([3, 3, 3, 3]), - PostFix.meta("extra"): {"affine": np.eye(3), "shape": "CHWD"}, - }, + {"image": np.zeros([3, 3, 3, 3]) - 1, "extra": np.zeros([3, 3, 3, 3]), "label": np.ones([3, 3, 3, 3])}, (3, 3, 3, 3), ], ] @@ -131,13 +113,13 @@ def convert_data_type(im_type, d, keys=("img", "image", "label")): @parameterized.expand(TESTS) def test_type_shape(self, input_param, input_data, expected_shape): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: input_param_mod = self.convert_data_type(p, input_param) input_data_mod = self.convert_data_type(p, input_data) cropper = RandCropByPosNegLabeld(**input_param_mod) cropper.set_random_state(0) result = cropper(input_data_mod) - self.assertListEqual(cropper.spatial_size, input_param["spatial_size"]) + self.assertListEqual(cropper.cropper.spatial_size, input_param["spatial_size"]) self.assertIsInstance(result, list) @@ -146,7 +128,7 @@ def test_type_shape(self, input_param, input_data, expected_shape): for k in ("image", "extra", "label"): self.assertTupleEqual(result[0][k].shape, expected_shape) for i, item in enumerate(result): - self.assertEqual(item[PostFix.meta(k)]["patch_index"], i) + self.assertEqual(item[k].meta["patch_index"], i) def test_correct_center(self): cropper = RandCropByPosNegLabeld(keys="label", label_key="label", spatial_size=[3, 3]) diff --git a/tests/test_rand_scale_crop.py b/tests/test_rand_scale_crop.py index 5d6312002f..aea26d62bb 100644 --- a/tests/test_rand_scale_crop.py +++ b/tests/test_rand_scale_crop.py @@ -15,66 +15,57 @@ from parameterized import parameterized from monai.transforms import RandScaleCrop -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.croppers import CropTest +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose -TEST_CASE_1 = [ - {"roi_scale": [1.0, 1.0, -1.0], "random_center": True}, - np.random.randint(0, 2, size=[3, 3, 3, 4]), - (3, 3, 3, 4), +TEST_SHAPES = [ + [{"roi_scale": [1.0, 1.0, -1.0], "random_center": True}, (3, 3, 3, 4), (3, 3, 3, 4)], + [{"roi_scale": [1.0, 1.0, 1.0], "random_center": False}, (3, 3, 3, 3), (3, 3, 3, 3)], ] -TEST_CASE_2 = [ - {"roi_scale": [1.0, 1.0, 1.0], "random_center": False}, - np.random.randint(0, 2, size=[3, 3, 3, 3]), - (3, 3, 3, 3), +TEST_VALUES = [ + [ + {"roi_scale": [0.6, 0.6], "random_center": False}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + ] ] -TEST_CASE_3 = [ - {"roi_scale": [0.6, 0.6], "random_center": False}, - np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), +TEST_RANDOM_SHAPES = [ + [ + {"roi_scale": [0.75, 0.6, 0.5], "max_roi_scale": [1.0, -1.0, 0.6], "random_center": True, "random_size": True}, + (1, 4, 5, 6), + (1, 3, 4, 3), + ], + [{"roi_scale": 0.6, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, (1, 4, 5, 6), (1, 3, 4, 4)], + [{"roi_scale": 0.2, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, (1, 4, 5, 6), (1, 3, 2, 4)], ] -TEST_CASE_4 = [ - {"roi_scale": [0.75, 0.6, 0.5], "max_roi_scale": [1.0, -1.0, 0.6], "random_center": True, "random_size": True}, - np.random.randint(0, 2, size=[1, 4, 5, 6]), - (1, 3, 4, 3), -] - -TEST_CASE_5 = [ - {"roi_scale": 0.6, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, - np.random.randint(0, 2, size=[1, 4, 5, 6]), - (1, 3, 4, 4), -] - -TEST_CASE_6 = [ - {"roi_scale": 0.2, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, - np.random.randint(0, 2, size=[1, 4, 5, 6]), - (1, 3, 2, 4), -] +class TestRandScaleCrop(CropTest): + Cropper = RandScaleCrop -class TestRandScaleCrop(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_shape(self, input_param, input_data, expected_shape): - for p in TEST_NDARRAYS: - result = RandScaleCrop(**input_param)(p(input_data)) - self.assertTupleEqual(result.shape, expected_shape) + @parameterized.expand(TEST_SHAPES) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_3]) + @parameterized.expand(TEST_VALUES) def test_value(self, input_param, input_data): - for p in TEST_NDARRAYS: - cropper = RandScaleCrop(**input_param) - result = cropper(p(input_data)) - roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] - assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = RandScaleCrop(**input_param) + result = cropper(im_type(input_data)) + roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] + assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) - @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) - def test_random_shape(self, input_param, input_data, expected_shape): - for p in TEST_NDARRAYS: - cropper = RandScaleCrop(**input_param) - cropper.set_random_state(seed=123) - result = cropper(p(input_data)) - self.assertTupleEqual(result.shape, expected_shape) + @parameterized.expand(TEST_RANDOM_SHAPES) + def test_random_shape(self, input_param, input_shape, expected_shape): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = RandScaleCrop(**input_param) + cropper.set_random_state(seed=123) + input_data = im_type(np.random.randint(0, 2, input_shape)) + result = cropper(input_data) + self.assertTupleEqual(result.shape, expected_shape) if __name__ == "__main__": diff --git a/tests/test_rand_scale_cropd.py b/tests/test_rand_scale_cropd.py index 5e833fef98..645c058dfb 100644 --- a/tests/test_rand_scale_cropd.py +++ b/tests/test_rand_scale_cropd.py @@ -15,74 +15,77 @@ from parameterized import parameterized from monai.transforms import RandScaleCropd -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.croppers import CropTest +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose -TEST_CASE_1 = [ - {"keys": "img", "roi_scale": [1.0, 1.0, -1.0], "random_center": True}, - {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, - (3, 3, 3, 4), +TEST_SHAPES = [ + [{"keys": "img", "roi_scale": [1.0, 1.0, -1.0], "random_center": True}, (3, 3, 3, 4), (3, 3, 3, 4)], + [ + # test `allow_missing_keys` with key "label" + {"keys": ["label", "img"], "roi_scale": [1.0, 1.0, 1.0], "random_center": False, "allow_missing_keys": True}, + (3, 3, 3, 3), + (3, 3, 3, 3), + ], ] -TEST_CASE_2 = [ - # test `allow_missing_keys` with key "label" - {"keys": ["label", "img"], "roi_scale": [1.0, 1.0, 1.0], "random_center": False, "allow_missing_keys": True}, - {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, - (3, 3, 3, 3), +TEST_VALUES = [ + [ + {"keys": "img", "roi_scale": [0.6, 0.6], "random_center": False}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + ] ] -TEST_CASE_3 = [ - {"keys": "img", "roi_scale": [0.6, 0.6], "random_center": False}, - {"img": np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]])}, +TEST_RANDOM_SHAPES = [ + [ + { + "keys": "img", + "roi_scale": [0.75, 0.6, 0.5], + "max_roi_scale": [1.0, -1.0, 0.6], + "random_center": True, + "random_size": True, + }, + (1, 4, 5, 6), + (1, 3, 4, 3), + ], + [ + {"keys": "img", "roi_scale": 0.6, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, + (1, 4, 5, 6), + (1, 3, 4, 4), + ], + [ + {"keys": "img", "roi_scale": 0.2, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, + (1, 4, 5, 6), + (1, 3, 2, 4), + ], ] -TEST_CASE_4 = [ - { - "keys": "img", - "roi_scale": [0.75, 0.6, 0.5], - "max_roi_scale": [1.0, -1.0, 0.6], - "random_center": True, - "random_size": True, - }, - {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])}, - (1, 3, 4, 3), -] - -TEST_CASE_5 = [ - {"keys": "img", "roi_scale": 0.6, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, - {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])}, - (1, 3, 4, 4), -] - -TEST_CASE_6 = [ - {"keys": "img", "roi_scale": 0.2, "max_roi_scale": 0.8, "random_center": True, "random_size": True}, - {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])}, - (1, 3, 2, 4), -] +class TestRandScaleCropd(CropTest): + Cropper = RandScaleCropd -class TestRandScaleCropd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_shape(self, input_param, input_data, expected_shape): - result = RandScaleCropd(**input_param)(input_data) - self.assertTupleEqual(result["img"].shape, expected_shape) + @parameterized.expand(TEST_SHAPES) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_3]) - def test_value(self, input_param, input_data): - for p in TEST_NDARRAYS: - cropper = RandScaleCropd(**input_param) - input_data["img"] = p(input_data["img"]) - result = cropper(input_data) - roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] - assert_allclose( - result["img"], input_data["img"][:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False - ) + @parameterized.expand(TEST_VALUES) + def test_value(self, input_param, input_im): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = self.Cropper(**input_param) + input_data = {"img": im_type(input_im)} + result = cropper(input_data)["img"] + roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper.cropper._size] + assert_allclose(result, input_im[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) - @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) - def test_random_shape(self, input_param, input_data, expected_shape): - cropper = RandScaleCropd(**input_param) - cropper.set_random_state(seed=123) - result = cropper(input_data) - self.assertTupleEqual(result["img"].shape, expected_shape) + @parameterized.expand(TEST_RANDOM_SHAPES) + def test_random_shape(self, input_param, input_shape, expected_shape): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = self.Cropper(**input_param) + cropper.set_random_state(seed=123) + input_data = {"img": im_type(np.random.randint(0, 2, input_shape))} + result = cropper(input_data)["img"] + self.assertTupleEqual(result.shape, expected_shape) if __name__ == "__main__": diff --git a/tests/test_rand_spatial_crop.py b/tests/test_rand_spatial_crop.py index 8f4bb0fffa..0c8d4ab132 100644 --- a/tests/test_rand_spatial_crop.py +++ b/tests/test_rand_spatial_crop.py @@ -15,60 +15,57 @@ from parameterized import parameterized from monai.transforms import RandSpatialCrop -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.croppers import CropTest +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose -TEST_CASE_0 = [ - {"roi_size": [3, 3, -1], "random_center": True}, - np.random.randint(0, 2, size=[3, 3, 3, 4]), - (3, 3, 3, 4), +TEST_SHAPES = [ + [{"roi_size": [3, 3, -1], "random_center": True}, (3, 3, 3, 4), (3, 3, 3, 4)], + [{"roi_size": [3, 3, 3], "random_center": True}, (3, 3, 3, 3), (3, 3, 3, 3)], + [{"roi_size": [3, 3, 3], "random_center": False}, (3, 3, 3, 3), (3, 3, 3, 3)], ] -TEST_CASE_1 = [{"roi_size": [3, 3, 3], "random_center": True}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 3, 3, 3)] - -TEST_CASE_2 = [ - {"roi_size": [3, 3, 3], "random_center": False}, - np.random.randint(0, 2, size=[3, 3, 3, 3]), - (3, 3, 3, 3), -] - -TEST_CASE_3 = [ - {"roi_size": [3, 3], "random_center": False}, - np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), +TEST_VALUES = [ + [ + {"roi_size": [3, 3], "random_center": False}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + ] ] -TEST_CASE_4 = [ - {"roi_size": [3, 3, 3], "max_roi_size": [5, -1, 4], "random_center": True, "random_size": True}, - np.random.randint(0, 2, size=[1, 4, 5, 6]), - (1, 4, 4, 3), +TEST_RANDOM_SHAPES = [ + [ + {"roi_size": [3, 3, 3], "max_roi_size": [5, -1, 4], "random_center": True, "random_size": True}, + (1, 4, 5, 6), + (1, 4, 4, 3), + ], + [{"roi_size": 3, "max_roi_size": 4, "random_center": True, "random_size": True}, (1, 4, 5, 6), (1, 3, 4, 3)], ] -TEST_CASE_5 = [ - {"roi_size": 3, "max_roi_size": 4, "random_center": True, "random_size": True}, - np.random.randint(0, 2, size=[1, 4, 5, 6]), - (1, 3, 4, 3), -] +class TestRandSpatialCrop(CropTest): + Cropper = RandSpatialCrop -class TestRandSpatialCrop(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2]) - def test_shape(self, input_param, input_data, expected_shape): - result = RandSpatialCrop(**input_param)(input_data) - self.assertTupleEqual(result.shape, expected_shape) + @parameterized.expand(TEST_SHAPES) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_3]) + @parameterized.expand(TEST_VALUES) def test_value(self, input_param, input_data): - for p in TEST_NDARRAYS: - cropper = RandSpatialCrop(**input_param) - result = cropper(p(input_data)) - roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] - assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = RandSpatialCrop(**input_param) + result = cropper(im_type(input_data)) + roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] + assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) - def test_random_shape(self, input_param, input_data, expected_shape): - cropper = RandSpatialCrop(**input_param) - cropper.set_random_state(seed=123) - result = cropper(input_data) - self.assertTupleEqual(result.shape, expected_shape) + @parameterized.expand(TEST_RANDOM_SHAPES) + def test_random_shape(self, input_param, input_shape, expected_shape): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = RandSpatialCrop(**input_param) + cropper.set_random_state(seed=123) + input_data = im_type(np.random.randint(0, 2, input_shape)) + result = cropper(input_data) + self.assertTupleEqual(result.shape, expected_shape) if __name__ == "__main__": diff --git a/tests/test_rand_spatial_crop_samples.py b/tests/test_rand_spatial_crop_samples.py index 18fdf38773..50571b5955 100644 --- a/tests/test_rand_spatial_crop_samples.py +++ b/tests/test_rand_spatial_crop_samples.py @@ -15,11 +15,12 @@ from parameterized import parameterized from monai.transforms import RandSpatialCropSamples -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.croppers import CropTest +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TEST_CASE_1 = [ {"roi_size": [3, 3, 3], "num_samples": 4, "random_center": True, "random_size": False}, - np.arange(192).reshape(3, 4, 4, 4), + (3, 4, 4, 4), [(3, 3, 3, 3), (3, 3, 3, 3), (3, 3, 3, 3), (3, 3, 3, 3)], np.array( [ @@ -44,7 +45,7 @@ TEST_CASE_2 = [ {"roi_size": [3, 3, 3], "num_samples": 8, "random_center": False, "random_size": True}, - np.arange(192).reshape(3, 4, 4, 4), + (3, 4, 4, 4), [(3, 4, 4, 3), (3, 4, 3, 3), (3, 3, 4, 4), (3, 4, 4, 4), (3, 3, 3, 4), (3, 3, 3, 3), (3, 3, 3, 3), (3, 3, 3, 3)], np.array( [ @@ -67,18 +68,31 @@ ), ] +TEST_INVERSE_LIST = [ + [(1, 2, 2), {"roi_size": (1, 1), "num_samples": 4, "random_size": False}], + [(1, 3, 2), {"roi_size": (1, 1), "num_samples": 100, "random_size": False}], + [(3, 10, 11, 12), {"roi_size": (3, 5, 4), "num_samples": 7, "random_size": False}], + [(3, 10, 11, 12), {"roi_size": (10, 11, 12), "num_samples": 3, "random_size": False}], + [(3, 10, 11, 12), {"roi_size": (3, 4, 5), "num_samples": 100, "random_size": False}], +] + + +class TestRandSpatialCropSamples(CropTest): + Cropper = RandSpatialCropSamples -class TestRandSpatialCropSamples(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_shape(self, input_param, input_data, expected_shape, expected_last_item): - for p in TEST_NDARRAYS: + def test_shape(self, input_param, input_shape, expected_shape, expected_last_item): + input_data = np.arange(192).reshape(*input_shape) + + for p in TEST_NDARRAYS_ALL: xform = RandSpatialCropSamples(**input_param) xform.set_random_state(1234) result = xform(p(input_data)) np.testing.assert_equal(len(result), input_param["num_samples"]) - for item, expected in zip(result, expected_shape): + for i, (item, expected) in enumerate(zip(result, expected_shape)): self.assertTupleEqual(item.shape, expected) + self.assertEqual(item.meta["patch_index"], i) assert_allclose(result[-1], expected_last_item, type_test=False) diff --git a/tests/test_rand_spatial_crop_samplesd.py b/tests/test_rand_spatial_crop_samplesd.py index 0891068488..4da438d2a0 100644 --- a/tests/test_rand_spatial_crop_samplesd.py +++ b/tests/test_rand_spatial_crop_samplesd.py @@ -14,46 +14,45 @@ import numpy as np from parameterized import parameterized -from monai.transforms import Compose, RandSpatialCropSamplesd, ToTensord -from monai.utils.enums import PostFix -from tests.utils import TEST_NDARRAYS, assert_allclose +from monai.transforms import Compose, DivisiblePadd, RandSpatialCropSamplesd +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TEST_CASE_1 = [ {"keys": ["img", "seg"], "num_samples": 4, "roi_size": [2, 2, 2], "random_center": True}, {"img": np.arange(81).reshape(3, 3, 3, 3), "seg": np.arange(81, 0, -1).reshape(3, 3, 3, 3)}, - [(3, 3, 3, 2), (3, 2, 2, 2), (3, 3, 3, 2), (3, 3, 2, 2)], + [(3, 2, 2, 2), (3, 2, 3, 3), (3, 2, 3, 2), (3, 2, 3, 2)], { "img": np.array( [ - [[[0, 1], [3, 4]], [[9, 10], [12, 13]], [[18, 19], [21, 22]]], - [[[27, 28], [30, 31]], [[36, 37], [39, 40]], [[45, 46], [48, 49]]], - [[[54, 55], [57, 58]], [[63, 64], [66, 67]], [[72, 73], [75, 76]]], + [[[1, 2], [4, 5], [7, 8]], [[10, 11], [13, 14], [16, 17]]], + [[[28, 29], [31, 32], [34, 35]], [[37, 38], [40, 41], [43, 44]]], + [[[55, 56], [58, 59], [61, 62]], [[64, 65], [67, 68], [70, 71]]], ] ), "seg": np.array( [ - [[[81, 80], [78, 77]], [[72, 71], [69, 68]], [[63, 62], [60, 59]]], - [[[54, 53], [51, 50]], [[45, 44], [42, 41]], [[36, 35], [33, 32]]], - [[[27, 26], [24, 23]], [[18, 17], [15, 14]], [[9, 8], [6, 5]]], + [[[80, 79], [77, 76], [74, 73]], [[71, 70], [68, 67], [65, 64]]], + [[[53, 52], [50, 49], [47, 46]], [[44, 43], [41, 40], [38, 37]]], + [[[26, 25], [23, 22], [20, 19]], [[17, 16], [14, 13], [11, 10]]], ] ), }, ] TEST_CASE_2 = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASE_2.append( [ {"keys": ["img", "seg"], "num_samples": 8, "roi_size": [2, 2, 3], "random_center": False}, {"img": p(np.arange(81).reshape(3, 3, 3, 3)), "seg": p(np.arange(81, 0, -1).reshape(3, 3, 3, 3))}, [ - (3, 3, 3, 3), - (3, 2, 3, 3), (3, 2, 2, 3), - (3, 2, 3, 3), + (3, 2, 2, 3), (3, 3, 3, 3), + (3, 2, 3, 3), (3, 3, 3, 3), - (3, 2, 2, 3), + (3, 2, 3, 3), + (3, 2, 3, 3), (3, 3, 2, 3), ], { @@ -90,10 +89,10 @@ def test_shape(self, input_param, input_data, expected_shape, expected_last): self.assertTupleEqual(item["img"].shape, expected) self.assertTupleEqual(item["seg"].shape, expected) for i, item in enumerate(result): - self.assertEqual(item[PostFix.meta("img")]["patch_index"], i) - self.assertEqual(item[PostFix.meta("seg")]["patch_index"], i) - assert_allclose(item["img"], expected_last["img"], type_test=True) - assert_allclose(item["seg"], expected_last["seg"], type_test=True) + self.assertEqual(item["img"].meta["patch_index"], i) + self.assertEqual(item["seg"].meta["patch_index"], i) + assert_allclose(item["img"], expected_last["img"], type_test=False) + assert_allclose(item["seg"], expected_last["seg"], type_test=False) def test_deep_copy(self): data = {"img": np.ones((1, 10, 11, 12))} @@ -101,11 +100,11 @@ def test_deep_copy(self): sampler = RandSpatialCropSamplesd( keys=["img"], roi_size=(3, 3, 3), num_samples=num_samples, random_center=True, random_size=False ) - transform = Compose([ToTensord(keys="img"), sampler]) + transform = Compose([DivisiblePadd(keys="img", k=5), sampler]) samples = transform(data) self.assertEqual(len(samples), num_samples) for sample in samples: - self.assertEqual(len(sample["img_transforms"]), len(transform)) + self.assertEqual(len(sample["img"].applied_operations), len(transform)) if __name__ == "__main__": diff --git a/tests/test_rand_spatial_cropd.py b/tests/test_rand_spatial_cropd.py index 9e6e86eea2..c6a0fbe5e7 100644 --- a/tests/test_rand_spatial_cropd.py +++ b/tests/test_rand_spatial_cropd.py @@ -15,65 +15,62 @@ from parameterized import parameterized from monai.transforms import RandSpatialCropd -from tests.utils import TEST_NDARRAYS +from tests.croppers import CropTest +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose -TEST_CASE_0 = [ - {"keys": "img", "roi_size": [3, 3, -1], "random_center": True}, - {"img": np.random.randint(0, 2, size=[3, 3, 3, 5])}, - (3, 3, 3, 5), +TEST_SHAPES = [ + [{"keys": "img", "roi_size": [3, 3, -1], "random_center": True}, (3, 3, 3, 5), (3, 3, 3, 5)], + [{"keys": "img", "roi_size": [3, 3, 3], "random_center": True}, (3, 3, 3, 3), (3, 3, 3, 3)], + [{"keys": "img", "roi_size": [3, 3, 3], "random_center": False}, (3, 3, 3, 3), (3, 3, 3, 3)], ] -TEST_CASE_1 = [ - {"keys": "img", "roi_size": [3, 3, 3], "random_center": True}, - {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, - (3, 3, 3, 3), +TEST_VALUES = [ + [ + {"keys": "img", "roi_size": [3, 3], "random_center": False}, + np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]), + ] ] -TEST_CASE_2 = [ - {"keys": "img", "roi_size": [3, 3, 3], "random_center": False}, - {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])}, - (3, 3, 3, 3), +TEST_RANDOM_SHAPES = [ + [ + {"keys": "img", "roi_size": [3, 3, 3], "max_roi_size": [5, -1, 4], "random_center": True, "random_size": True}, + (1, 4, 5, 6), + (1, 4, 4, 3), + ], + [ + {"keys": "img", "roi_size": 3, "max_roi_size": 4, "random_center": True, "random_size": True}, + (1, 4, 5, 6), + (1, 3, 4, 3), + ], ] -TEST_CASE_3 = [ - {"keys": "img", "roi_size": [3, 3], "random_center": False}, - {"img": np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]])}, -] - -TEST_CASE_4 = [ - {"keys": "img", "roi_size": [3, 3, 3], "max_roi_size": [5, -1, 4], "random_center": True, "random_size": True}, - {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])}, - (1, 4, 4, 3), -] - -TEST_CASE_5 = [ - {"keys": "img", "roi_size": 3, "max_roi_size": 4, "random_center": True, "random_size": True}, - {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])}, - (1, 3, 4, 3), -] +class TestRandSpatialCropd(CropTest): + Cropper = RandSpatialCropd -class TestRandSpatialCropd(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2]) - def test_shape(self, input_param, input_data, expected_shape): - result = RandSpatialCropd(**input_param)(input_data) - self.assertTupleEqual(result["img"].shape, expected_shape) + @parameterized.expand(TEST_SHAPES) + def test_shape(self, input_param, input_shape, expected_shape): + self.crop_test(input_param, input_shape, expected_shape) - @parameterized.expand([TEST_CASE_3]) - def test_value(self, input_param, input_data): - cropper = RandSpatialCropd(**input_param) - result = cropper(input_data) - roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] - np.testing.assert_allclose(result["img"], input_data["img"][:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]]) + @parameterized.expand(TEST_VALUES) + def test_value(self, input_param, input_im): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = self.Cropper(**input_param) + input_data = {"img": im_type(input_im)} + result = cropper(input_data)["img"] + roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper.cropper._size] + assert_allclose(result, input_im[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) - def test_random_shape(self, input_param, input_data, expected_shape): - for p in TEST_NDARRAYS: - cropper = RandSpatialCropd(**input_param) - cropper.set_random_state(seed=123) - input_data["img"] = p(input_data["img"]) - result = cropper(input_data) - self.assertTupleEqual(result["img"].shape, expected_shape) + @parameterized.expand(TEST_RANDOM_SHAPES) + def test_random_shape(self, input_param, input_shape, expected_shape): + for im_type in TEST_NDARRAYS_ALL: + with self.subTest(im_type=im_type): + cropper = self.Cropper(**input_param) + cropper.set_random_state(seed=123) + input_data = {"img": im_type(np.random.randint(0, 2, input_shape))} + result = cropper(input_data)["img"] + self.assertTupleEqual(result.shape, expected_shape) if __name__ == "__main__": diff --git a/tests/test_rand_weighted_crop.py b/tests/test_rand_weighted_crop.py index dae7f05016..696de9c05e 100644 --- a/tests/test_rand_weighted_crop.py +++ b/tests/test_rand_weighted_crop.py @@ -12,11 +12,12 @@ import unittest import numpy as np -import torch from parameterized.parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms.croppad.array import RandWeightedCrop -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose +from tests.croppers import CropTest +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose def get_data(ndim): @@ -30,8 +31,8 @@ def get_data(ndim): TESTS = [] -for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: + for q in TEST_NDARRAYS_ALL: im = SEG1_2D weight = np.zeros_like(im) weight[0, 30, 17] = 1.1 @@ -148,7 +149,9 @@ def get_data(ndim): ) -class TestRandWeightedCrop(unittest.TestCase): +class TestRandWeightedCrop(CropTest): + Cropper = RandWeightedCrop + @parameterized.expand(TESTS) def test_rand_weighted_crop(self, _, input_params, img, weight, expected_shape, expected_vals): crop = RandWeightedCrop(**input_params) @@ -161,10 +164,9 @@ def test_rand_weighted_crop(self, _, input_params, img, weight, expected_shape, # if desired ROI is larger than image, check image is unchanged if all(s >= i for i, s in zip(img.shape[1:], input_params["spatial_size"])): for res in result: - self.assertEqual(type(img), type(res)) - if isinstance(img, torch.Tensor): - self.assertEqual(res.device, img.device) - assert_allclose(res, img) + self.assertIsInstance(res, MetaTensor) + assert_allclose(res, img, type_test=False) + self.assertEqual(len(res.applied_operations), 1) if __name__ == "__main__": diff --git a/tests/test_rand_weighted_cropd.py b/tests/test_rand_weighted_cropd.py index a357398f1c..b3fc92b445 100644 --- a/tests/test_rand_weighted_cropd.py +++ b/tests/test_rand_weighted_cropd.py @@ -12,180 +12,144 @@ import unittest import numpy as np +from parameterized import parameterized from monai.transforms.croppad.dictionary import RandWeightedCropd -from monai.utils.enums import PostFix -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose - - -class TestRandWeightedCrop(NumpyImageTestCase2D): - def test_rand_weighted_crop_small_roi(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.seg1[0] - n_samples = 3 - crop = RandWeightedCropd("img", "w", (10, 12), n_samples) - weight = np.zeros_like(img) - weight[0, 30, 17] = 1.1 - weight[0, 40, 31] = 1 - weight[0, 80, 21] = 1 - crop.set_random_state(10) - d = {"img": p(img), "w": q(weight)} - result = crop(d) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 10, 12)) - for c, e in zip(crop.centers, [[80, 21], [30, 17], [40, 31]]): - assert_allclose(c, e, type_test=False) - - def test_rand_weighted_crop_default_roi(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.imt[0] - n_samples = 3 - crop = RandWeightedCropd("im", "weight", (10, -1), n_samples, "coords") - weight = np.zeros_like(img) - weight[0, 30, 17] = 1.1 - weight[0, 40, 31] = 1 - weight[0, 80, 21] = 1 - crop.set_random_state(10) - data = {"im": p(img), "weight": q(weight), "others": np.nan} - result = crop(data) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["im"].shape, (1, 10, 64)) - for c, e in zip(crop.centers, [[14, 32], [105, 32], [20, 32]]): - assert_allclose(c, e, type_test=False) - assert_allclose(result[1]["coords"], [105, 32], type_test=False) - - def test_rand_weighted_crop_large_roi(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.segn[0] - n_samples = 3 - crop = RandWeightedCropd(("img", "seg"), "weight", (10000, 400), n_samples, "location") - weight = np.zeros_like(img) - weight[0, 30, 17] = 1.1 - weight[0, 10, 1] = 1 - crop.set_random_state(10) - data = {"img": p(img), "seg": p(self.imt[0]), "weight": q(weight)} - result = crop(data) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 128, 64)) - np.testing.assert_allclose(result[0]["seg"].shape, (1, 128, 64)) - for c, e in zip(crop.centers, [[64, 32], [64, 32], [64, 32]]): - assert_allclose(c, e, type_test=False) - assert_allclose(result[1]["location"], [64, 32], type_test=False) - - def test_rand_weighted_crop_bad_w(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.imt[0] - n_samples = 3 - crop = RandWeightedCropd(("img", "seg"), "w", (20, 40), n_samples) - weight = np.zeros_like(img) - weight[0, 30, 17] = np.inf - weight[0, 10, 1] = -np.inf - weight[0, 10, 20] = -np.nan - crop.set_random_state(10) - result = crop({"img": p(img), "seg": p(self.segn[0]), "w": q(weight)}) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 20, 40)) - np.testing.assert_allclose(result[0]["seg"].shape, (1, 20, 40)) - for c, e in zip(crop.centers, [[63, 37], [31, 43], [66, 20]]): - assert_allclose(c, e, type_test=False) - - -class TestRandWeightedCrop3D(NumpyImageTestCase3D): - def test_rand_weighted_crop_small_roi(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.seg1[0] - n_samples = 3 - crop = RandWeightedCropd("img", "w", (8, 10, 12), n_samples) - weight = np.zeros_like(img) - weight[0, 5, 30, 17] = 1.1 - weight[0, 8, 40, 31] = 1 - weight[0, 11, 23, 21] = 1 - crop.set_random_state(10) - result = crop({"img": p(img), "w": q(weight)}) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 8, 10, 12)) - for c, e in zip(crop.centers, [[11, 23, 21], [5, 30, 17], [8, 40, 31]]): - assert_allclose(c, e, type_test=False) - - def test_rand_weighted_crop_default_roi(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.imt[0] - n_samples = 3 - crop = RandWeightedCropd(("img", "seg"), "w", (10, -1, -1), n_samples) - weight = np.zeros_like(img) - weight[0, 7, 17] = 1.1 - weight[0, 13, 31] = 1.1 - weight[0, 24, 21] = 1 - crop.set_random_state(10) - result = crop({"img": p(img), "seg": p(self.segn[0]), "w": q(weight)}) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 10, 64, 80)) - np.testing.assert_allclose(result[0]["seg"].shape, (1, 10, 64, 80)) - for c, e in zip(crop.centers, [[14, 32, 40], [41, 32, 40], [20, 32, 40]]): - assert_allclose(c, e, type_test=False) - - def test_rand_weighted_crop_large_roi(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.segn[0] - n_samples = 3 - crop = RandWeightedCropd("img", "w", (10000, 400, 80), n_samples) - weight = np.zeros_like(img) - weight[0, 30, 17, 20] = 1.1 - weight[0, 10, 1, 17] = 1 - crop.set_random_state(10) - result = crop({"img": p(img), "w": q(weight)}) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 48, 64, 80)) - for c, e in zip(crop.centers, [[24, 32, 40], [24, 32, 40], [24, 32, 40]]): - assert_allclose(c, e, type_test=False) - - def test_rand_weighted_crop_bad_w(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.imt[0] - n_samples = 3 - crop = RandWeightedCropd(("img", "seg"), "w", (48, 64, 80), n_samples) - weight = np.zeros_like(img) - weight[0, 30, 17] = np.inf - weight[0, 10, 1] = -np.inf - weight[0, 10, 20] = -np.nan - crop.set_random_state(10) - result = crop({"img": p(img), "seg": p(self.segn[0]), "w": q(weight)}) - self.assertTrue(len(result) == n_samples) - np.testing.assert_allclose(result[0]["img"].shape, (1, 48, 64, 80)) - np.testing.assert_allclose(result[0]["seg"].shape, (1, 48, 64, 80)) - for c, e in zip(crop.centers, [[24, 32, 40], [24, 32, 40], [24, 32, 40]]): - assert_allclose(c, e, type_test=False) - - def test_rand_weighted_crop_patch_index(self): - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: - img = self.imt[0] - n_samples = 3 - crop = RandWeightedCropd(("img", "seg"), "w", (10, -1, -1), n_samples) - weight = np.zeros_like(img) - weight[0, 7, 17] = 1.1 - weight[0, 13, 31] = 1.1 - weight[0, 24, 21] = 1 - crop.set_random_state(10) - result = crop( - {"img": p(img), "seg": p(self.segn[0]), "w": q(weight), PostFix.meta("img"): {"affine": None}} - ) - self.assertTrue(len(result) == n_samples) - for c, e in zip(crop.centers, [[14, 32, 40], [41, 32, 40], [20, 32, 40]]): - assert_allclose(c, e, type_test=False) - for i in range(n_samples): - np.testing.assert_allclose(result[i]["img"].shape, (1, 10, 64, 80)) - np.testing.assert_allclose(result[i]["seg"].shape, (1, 10, 64, 80)) - np.testing.assert_allclose(result[i][PostFix.meta("img")]["patch_index"], i) - np.testing.assert_allclose(result[i][PostFix.meta("seg")]["patch_index"], i) +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D + + +def get_data(ndim): + im_gen = NumpyImageTestCase2D() if ndim == 2 else NumpyImageTestCase3D() + im_gen.setUp() + return im_gen.imt[0], im_gen.seg1[0], im_gen.segn[0] + + +IMT_2D, SEG1_2D, SEGN_2D = get_data(ndim=2) +IMT_3D, SEG1_3D, SEGN_3D = get_data(ndim=3) + +TESTS = [] +for p in TEST_NDARRAYS_ALL: + for q in TEST_NDARRAYS_ALL: + im = IMT_2D + weight = np.zeros_like(im) + weight[0, 30, 17] = 1.1 + weight[0, 40, 31] = 1 + weight[0, 80, 21] = 1 + TESTS.append( + [ + "small roi 2d", + dict(keys="img", w_key="w", spatial_size=(10, 12), num_samples=3), + {"img": p(im), "w": q(weight)}, + (1, 10, 12), + [[80, 21], [30, 17], [40, 31]], + ] + ) + + weight = np.zeros_like(im) + weight[0, 30, 17] = 1.1 + weight[0, 40, 31] = 1 + weight[0, 80, 21] = 1 + TESTS.append( + [ + "default roi 2d", + dict(keys="img", w_key="w", spatial_size=(10, -1), num_samples=3), + {"img": p(im), "w": q(weight), "others": np.nan}, + (1, 10, 64), + [[14, 32], [105, 32], [20, 32]], + ] + ) + + weight = np.zeros_like(im) + weight[0, 30, 17] = 1.1 + weight[0, 10, 1] = 1 + TESTS.append( + [ + "large roi 2d", + dict(keys=("img", "seg"), w_key="weight", spatial_size=(10000, 400), num_samples=3), + {"img": p(im), "seg": p(SEGN_2D), "weight": q(weight)}, + (1, 128, 64), + [[64, 32], [64, 32], [64, 32]], + ] + ) + + weight = np.zeros_like(im) + weight[0, 30, 17] = np.inf + weight[0, 10, 1] = -np.inf + weight[0, 10, 20] = -np.nan + TESTS.append( + [ + "bad w roi 2d", + dict(keys=("img", "seg"), w_key="w", spatial_size=(20, 40), num_samples=3), + {"img": p(im), "seg": p(SEGN_2D), "w": q(weight)}, + (1, 20, 40), + [[63, 37], [31, 43], [66, 20]], + ] + ) + + im = IMT_3D + weight = np.zeros_like(im) + weight[0, 5, 30, 17] = 1.1 + weight[0, 8, 40, 31] = 1 + weight[0, 11, 23, 21] = 1 + TESTS.append( + [ + "small roi 3d", + dict(keys="img", w_key="w", spatial_size=(8, 10, 12), num_samples=3), + {"img": p(im), "w": q(weight)}, + (1, 8, 10, 12), + [[11, 23, 21], [5, 30, 17], [8, 40, 31]], + ] + ) + + weight = np.zeros_like(im) + weight[0, 5, 30, 17] = 1.1 + weight[0, 8, 40, 31] = 1 + weight[0, 11, 23, 21] = 1 + TESTS.append( + [ + "default roi 3d", + dict(keys=("img", "seg"), w_key="w", spatial_size=(10, -1, -1), num_samples=3), + {"img": p(im), "seg": p(SEGN_3D), "w": q(weight)}, + (1, 10, 64, 80), + [[14, 32, 40], [41, 32, 40], [20, 32, 40]], + ] + ) + + weight = np.zeros_like(im) + weight[0, 30, 17, 20] = 1.1 + weight[0, 10, 1, 17] = 1 + TESTS.append( + [ + "large roi 3d", + dict(keys="img", w_key="w", spatial_size=(10000, 400, 80), num_samples=3), + {"img": p(im), "w": q(weight)}, + (1, 48, 64, 80), + [[24, 32, 40], [24, 32, 40], [24, 32, 40]], + ] + ) + + weight = np.zeros_like(im) + weight[0, 30, 17] = np.inf + weight[0, 10, 1] = -np.inf + weight[0, 10, 20] = -np.nan + TESTS.append( + [ + "bad w roi 3d", + dict(keys=("img", "seg"), w_key="w", spatial_size=(48, 64, 80), num_samples=3), + {"img": p(im), "seg": p(SEGN_3D), "w": q(weight)}, + (1, 48, 64, 80), + [[24, 32, 40], [24, 32, 40], [24, 32, 40]], + ] + ) + + +class TestRandWeightedCrop(unittest.TestCase): + @parameterized.expand(TESTS) + def test_rand_weighted_cropd(self, _, init_params, input_data, expected_shape, expected_centers): + crop = RandWeightedCropd(**init_params) + crop.set_random_state(10) + result = crop(input_data) + self.assertTrue(len(result) == init_params["num_samples"]) if __name__ == "__main__": diff --git a/tests/test_resize_with_pad_or_crop.py b/tests/test_resize_with_pad_or_crop.py index f81e1d4b08..4e097cd3d4 100644 --- a/tests/test_resize_with_pad_or_crop.py +++ b/tests/test_resize_with_pad_or_crop.py @@ -15,8 +15,9 @@ import torch from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms import ResizeWithPadOrCrop -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL, pytorch_after TEST_CASES = [ [{"spatial_size": [15, 8, 8], "mode": "constant"}, (3, 8, 8, 4), (3, 15, 8, 8)], @@ -26,24 +27,38 @@ (3, 15, 4, 8), ], [{"spatial_size": [15, 4, -1], "mode": "constant"}, (3, 8, 8, 4), (3, 15, 4, 4)], - [{"spatial_size": [15, 4, -1], "mode": "reflect"}, (3, 8, 8, 4), (3, 15, 4, 4)], - [{"spatial_size": [-1, -1, -1], "mode": "reflect"}, (3, 8, 8, 4), (3, 8, 8, 4)], + [ + {"spatial_size": [15, 4, -1], "mode": "reflect" if pytorch_after(1, 11) else "constant"}, + (3, 8, 8, 4), + (3, 15, 4, 4), + ], + [ + {"spatial_size": [-1, -1, -1], "mode": "reflect" if pytorch_after(1, 11) else "constant"}, + (3, 8, 8, 4), + (3, 8, 8, 4), + ], ] class TestResizeWithPadOrCrop(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_pad_shape(self, input_param, input_shape, expected_shape): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: if isinstance(p(0), torch.Tensor) and ( "constant_values" in input_param or input_param["mode"] == "reflect" ): continue - paddcroper = ResizeWithPadOrCrop(**input_param) - result = paddcroper(p(np.zeros(input_shape))) + padcropper = ResizeWithPadOrCrop(**input_param) + result = padcropper(p(np.zeros(input_shape))) np.testing.assert_allclose(result.shape, expected_shape) - result = paddcroper(p(np.zeros(input_shape)), mode="constant") + result = padcropper(p(np.zeros(input_shape)), mode="constant") np.testing.assert_allclose(result.shape, expected_shape) + self.assertIsInstance(result, MetaTensor) + self.assertEqual(len(result.applied_operations), 1) + inv = padcropper.inverse(result) + self.assertTupleEqual(inv.shape, input_shape) + self.assertIsInstance(inv, MetaTensor) + self.assertEqual(inv.applied_operations, []) if __name__ == "__main__": diff --git a/tests/test_resize_with_pad_or_cropd.py b/tests/test_resize_with_pad_or_cropd.py index 28993a2bf4..eb4e5f09cc 100644 --- a/tests/test_resize_with_pad_or_cropd.py +++ b/tests/test_resize_with_pad_or_cropd.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.transforms import ResizeWithPadOrCropd -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS_ALL, pytorch_after TEST_CASES = [ [{"keys": "img", "spatial_size": [15, 8, 8], "mode": "constant"}, {"img": np.zeros((3, 8, 8, 4))}, (3, 15, 8, 8)], @@ -26,23 +26,34 @@ (3, 15, 4, 8), ], [{"keys": "img", "spatial_size": [15, 4, -1], "mode": "constant"}, {"img": np.zeros((3, 8, 8, 4))}, (3, 15, 4, 4)], - [{"keys": "img", "spatial_size": [15, 4, -1], "mode": "reflect"}, {"img": np.zeros((3, 8, 8, 4))}, (3, 15, 4, 4)], - [{"keys": "img", "spatial_size": [-1, -1, -1], "mode": "reflect"}, {"img": np.zeros((3, 8, 8, 4))}, (3, 8, 8, 4)], + [ + {"keys": "img", "spatial_size": [15, 4, -1], "mode": "reflect" if pytorch_after(1, 11) else "constant"}, + {"img": np.zeros((3, 8, 8, 4))}, + (3, 15, 4, 4), + ], + [ + {"keys": "img", "spatial_size": [-1, -1, -1], "mode": "reflect" if pytorch_after(1, 11) else "constant"}, + {"img": np.zeros((3, 8, 8, 4))}, + (3, 8, 8, 4), + ], ] class TestResizeWithPadOrCropd(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_pad_shape(self, input_param, input_data, expected_val): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: if isinstance(p(0), torch.Tensor) and ( "constant_values" in input_param or input_param["mode"] == "reflect" ): continue - paddcroper = ResizeWithPadOrCropd(**input_param) + padcropper = ResizeWithPadOrCropd(**input_param) input_data["img"] = p(input_data["img"]) - result = paddcroper(input_data) + result = padcropper(input_data) np.testing.assert_allclose(result["img"].shape, expected_val) + inv = padcropper.inverse(result) + for k in input_data: + self.assertTupleEqual(inv[k].shape, input_data[k].shape) if __name__ == "__main__": diff --git a/tests/test_spatial_crop.py b/tests/test_spatial_crop.py index bf1eb11491..6fdfbd3f70 100644 --- a/tests/test_spatial_crop.py +++ b/tests/test_spatial_crop.py @@ -11,12 +11,10 @@ import unittest -import numpy as np -import torch from parameterized import parameterized from monai.transforms import SpatialCrop -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.croppers import CropTest TESTS = [ [{"roi_center": [1, 1, 1], "roi_size": [2, 2, 2]}, (3, 3, 3, 3), (3, 2, 2, 2)], @@ -26,31 +24,28 @@ [{"roi_start": [0, 0, 0, 0, 0], "roi_end": [2, 2, 2, 2, 2]}, (3, 3, 3, 3), (3, 2, 2, 2)], [{"roi_start": [0, 0, 0, 0, 0], "roi_end": [8, 8, 8, 2, 2]}, (3, 3, 3, 3), (3, 3, 3, 3)], [{"roi_start": [1, 0, 0], "roi_end": [1, 8, 8]}, (3, 3, 3, 3), (3, 0, 3, 3)], - [{"roi_slices": [slice(s, e) for s, e in zip([-1, -2, 0], [None, None, 2])]}, (3, 3, 3, 3), (3, 1, 2, 2)], + [ + {"roi_slices": [slice(s, e) for s, e in zip([None, None, None], [None, None, None])]}, + (3, 11, 12, 15), + (3, 11, 12, 15), + ], + [{"roi_slices": [slice(s, e) for s, e in zip([1, None, 0], [None, None, None])]}, (3, 7, 9, 11), (3, 6, 9, 11)], + [{"roi_slices": [slice(s, e) for s, e in zip([0, None, None], [-1, None, None])]}, (3, 7, 9, 11), (3, 6, 9, 11)], + [{"roi_slices": [slice(s, e) for s, e in zip([1, None, None], [None, None, None])]}, (3, 10, 8, 6), (3, 9, 8, 6)], + [{"roi_slices": [slice(s, e) for s, e in zip([-1, -2, 0], [None, None, 2])]}, (3, 15, 17, 8), (3, 1, 2, 2)], + [{"roi_slices": [slice(s, e) for s, e in zip([None, None, None], [-2, -1, 2])]}, (3, 13, 8, 6), (3, 11, 7, 2)], + [{"roi_start": [-1, 0], "roi_end": [5, 5]}, (1, 5, 5), (1, 5, 5)], ] TEST_ERRORS = [[{"roi_slices": [slice(s, e, 2) for s, e in zip([-1, -2, 0], [None, None, 2])]}]] -class TestSpatialCrop(unittest.TestCase): +class TestSpatialCrop(CropTest): + Cropper = SpatialCrop + @parameterized.expand(TESTS) def test_shape(self, input_param, input_shape, expected_shape): - input_data = np.random.randint(0, 2, size=input_shape) - results = [] - for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS + (None,): - input_param_mod = { - k: q(v) if k != "roi_slices" and q is not None else v for k, v in input_param.items() - } - im = p(input_data) - result = SpatialCrop(**input_param_mod)(im) - self.assertEqual(type(im), type(result)) - if isinstance(result, torch.Tensor): - self.assertEqual(result.device, im.device) - self.assertTupleEqual(result.shape, expected_shape) - results.append(result) - if len(results) > 1: - assert_allclose(results[0], results[-1], type_test=False) + self.crop_test(input_param, input_shape, expected_shape) @parameterized.expand(TEST_ERRORS) def test_error(self, input_param): diff --git a/tests/test_spatial_cropd.py b/tests/test_spatial_cropd.py index 5b16f460fd..11f6da0811 100644 --- a/tests/test_spatial_cropd.py +++ b/tests/test_spatial_cropd.py @@ -11,56 +11,57 @@ import unittest -import numpy as np from parameterized import parameterized from monai.transforms import SpatialCropd -from tests.utils import TEST_NDARRAYS +from tests.croppers import CropTest -TESTS = [] -for p in TEST_NDARRAYS: - TESTS.append( - [ - {"keys": ["img"], "roi_center": [1, 1, 1], "roi_size": [2, 2, 2]}, - {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, - (3, 2, 2, 2), - ] - ) - TESTS.append( - [ - {"keys": ["img"], "roi_start": [0, 0, 0], "roi_end": [2, 2, 2]}, - {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, - (3, 2, 2, 2), - ] - ) - TESTS.append( - [ - {"keys": ["img"], "roi_start": [0, 0], "roi_end": [2, 2]}, - {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, - (3, 2, 2, 3), - ] - ) - TESTS.append( - [ - {"keys": ["img"], "roi_start": [0, 0, 0, 0, 0], "roi_end": [2, 2, 2, 2, 2]}, - {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, - (3, 2, 2, 2), - ] - ) - TESTS.append( - [ - {"keys": ["img"], "roi_slices": [slice(s, e) for s, e in zip([-1, -2, 0], [None, None, 2])]}, - {"img": p(np.random.randint(0, 2, size=[3, 3, 3, 3]))}, - (3, 1, 2, 2), - ] - ) +TESTS = [ + [ + {"keys": ["img"], "roi_center": [1, 1], "roi_size": [2, 2]}, + (1, 3, 3), + (1, 2, 2), + (slice(None), slice(None, 2), slice(None, 2)), + ], + [ + {"keys": ["img"], "roi_center": [1, 1, 1], "roi_size": [2, 2, 2]}, + (3, 3, 3, 3), + (3, 2, 2, 2), + (slice(None), slice(None, 2), slice(None, 2), slice(None, 2)), + ], + [ + {"keys": ["img"], "roi_start": [0, 0, 0], "roi_end": [2, 2, 2]}, + (3, 3, 3, 3), + (3, 2, 2, 2), + (slice(None), slice(None, 2), slice(None, 2), slice(None, 2)), + ], + [ + {"keys": ["img"], "roi_start": [0, 0], "roi_end": [2, 2]}, + (3, 3, 3, 3), + (3, 2, 2, 3), + (slice(None), slice(None, 2), slice(None, 2), slice(None)), + ], + [ + {"keys": ["img"], "roi_start": [0, 0, 0, 0, 0], "roi_end": [2, 2, 2, 2, 2]}, + (3, 3, 3, 3), + (3, 2, 2, 2), + (slice(None), slice(None, 2), slice(None, 2), slice(None, 2)), + ], + [ + {"keys": ["img"], "roi_slices": [slice(s, e) for s, e in zip([-1, -2, 0], [None, None, 2])]}, + (3, 3, 3, 3), + (3, 1, 2, 2), + (slice(None), slice(-1, None), slice(-2, None), slice(0, 2)), + ], +] -class TestSpatialCropd(unittest.TestCase): +class TestSpatialCropd(CropTest): + Cropper = SpatialCropd + @parameterized.expand(TESTS) - def test_shape(self, input_param, input_data, expected_shape): - result = SpatialCropd(**input_param)(input_data) - self.assertTupleEqual(result["img"].shape, expected_shape) + def test_shape(self, input_param, input_shape, expected_shape, same_area): + self.crop_test(input_param, input_shape, expected_shape, same_area) if __name__ == "__main__": diff --git a/tests/test_spatial_pad.py b/tests/test_spatial_pad.py index 4cdeb6d64e..5a70c10686 100644 --- a/tests/test_spatial_pad.py +++ b/tests/test_spatial_pad.py @@ -10,91 +10,28 @@ # limitations under the License. import unittest -from typing import List -import numpy as np -import torch from parameterized import parameterized from monai.transforms import SpatialPad -from monai.utils.enums import NumpyPadMode, PytorchPadMode -from monai.utils.misc import set_determinism -from tests.utils import TEST_NDARRAYS +from tests.padders import PadTest TESTS = [] +TESTS.append([{"spatial_size": [3, 4], "method": "end"}, (1, 2, 3), (1, 3, 4)]) +TESTS.append([{"spatial_size": [15, 4, -1], "method": "symmetric"}, (3, 8, 8, 4), (3, 15, 8, 4)]) -MODES = [] -# Test modes -NP_MODES: List = [ - "constant", - "edge", - # `reflect` mode is not supported in some PyTorch versions, skip the test - # "reflect", - "wrap", -] -MODES += NP_MODES -MODES += [NumpyPadMode(i) for i in NP_MODES] - -PT_MODES: list = [ - "constant", - "replicate", - "circular", - # `reflect` mode is not supported in some PyTorch versions, skip the test - # "reflect", -] -MODES += PT_MODES -MODES += [PytorchPadMode(i) for i in PT_MODES] - -for mode in MODES: - TESTS.append([{"spatial_size": [3, 4], "method": "end", "mode": mode}, (1, 2, 3), (1, 3, 4)]) - - TESTS.append([{"spatial_size": [15, 4, -1], "method": "symmetric", "mode": mode}, (3, 8, 8, 4), (3, 15, 8, 4)]) - - -class TestSpatialPad(unittest.TestCase): - def setUp(self) -> None: - set_determinism(seed=0) - - def tearDown(self) -> None: - set_determinism(None) - - @staticmethod - def get_arr(shape): - return np.random.randint(100, size=shape).astype(float) +class TestSpatialPad(PadTest): + Padder = SpatialPad @parameterized.expand(TESTS) - def test_pad_shape(self, input_param, input_shape, expected_shape): - results_1 = [] - results_2 = [] - input_data = self.get_arr(input_shape) - # check result is the same regardless of input type - for p in TEST_NDARRAYS: - padder = SpatialPad(**input_param) - r1 = padder(p(input_data)) - r2 = padder(p(input_data), mode=input_param["mode"]) - results_1.append(r1.cpu() if isinstance(r1, torch.Tensor) else r1) - results_2.append(r2.cpu() if isinstance(r2, torch.Tensor) else r2) - for results in (results_1, results_2): - np.testing.assert_allclose(results[-1].shape, expected_shape) - if input_param["mode"] not in ("empty", NumpyPadMode.EMPTY): - torch.testing.assert_allclose(results[0], results[-1], atol=0, rtol=1e-5) + def test_pad(self, input_param, input_shape, expected_shape): + self.pad_test(input_param, input_shape, expected_shape) def test_pad_kwargs(self): - for p in TEST_NDARRAYS: - input_data = p(np.zeros((3, 8, 4))) - if isinstance(input_data, torch.Tensor): - result = ( - SpatialPad(spatial_size=[15, 8], method="end", mode="constant", value=2)(img=input_data) - .cpu() - .numpy() - ) - else: - result = SpatialPad( - spatial_size=[15, 8], method="end", mode="constant", constant_values=((0, 0), (1, 1), (2, 2)) - )(img=input_data) - torch.testing.assert_allclose(result[:, 8:, :4], np.ones((3, 7, 4)), rtol=1e-7, atol=0) - torch.testing.assert_allclose(result[:, :, 4:], np.ones((3, 15, 4)) + 1, rtol=1e-7, atol=0) + kwargs = {"spatial_size": [15, 8], "method": "end", "mode": "constant"} + unchanged_slices = [slice(None), slice(None, 8), slice(None, 4)] + self.pad_test_kwargs(unchanged_slices, **kwargs) if __name__ == "__main__": diff --git a/tests/test_spatial_padd.py b/tests/test_spatial_padd.py index 762a1145f5..656a731de0 100644 --- a/tests/test_spatial_padd.py +++ b/tests/test_spatial_padd.py @@ -11,42 +11,26 @@ import unittest -import numpy as np from parameterized import parameterized from monai.transforms import SpatialPadd +from tests.padders import PadTest -TEST_CASE_1 = [ - {"keys": ["img"], "spatial_size": [15, 8, 8], "method": "symmetric", "mode": "constant"}, - {"img": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 15, 8, 8)), +TESTS = [ + [{"keys": ["img"], "spatial_size": [15, 8, 8], "method": "symmetric"}, (3, 8, 8, 4), (3, 15, 8, 8)], + [{"keys": ["img"], "spatial_size": [15, 8, 8], "method": "end"}, (3, 8, 8, 4), (3, 15, 8, 8)], + [{"keys": ["img"], "spatial_size": [15, 8, 8], "method": "end"}, (3, 8, 8, 4), (3, 15, 8, 8)], + [{"keys": ["img"], "spatial_size": [15, 8, -1], "method": "end"}, (3, 8, 4, 4), (3, 15, 8, 4)], ] -TEST_CASE_2 = [ - {"keys": ["img"], "spatial_size": [15, 8, 8], "method": "end", "mode": "constant"}, - {"img": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 15, 8, 8)), -] - -TEST_CASE_3 = [ - {"keys": ["img"], "spatial_size": [15, 8, 8], "method": "end", "mode": {"constant"}}, - {"img": np.zeros((3, 8, 8, 4))}, - np.zeros((3, 15, 8, 8)), -] - -TEST_CASE_4 = [ - {"keys": ["img"], "spatial_size": [15, 8, -1], "method": "end", "mode": {"constant"}}, - {"img": np.zeros((3, 8, 4, 4))}, - np.zeros((3, 15, 8, 4)), -] +class TestSpatialPadd(PadTest): + Padder = SpatialPadd -class TestSpatialPadd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) - def test_pad_shape(self, input_param, input_data, expected_val): - padder = SpatialPadd(**input_param) - result = padder(input_data) - np.testing.assert_allclose(result["img"].shape, expected_val.shape) + @parameterized.expand(TESTS) + def test_pad(self, input_param, input_shape, expected_shape): + modes = ["constant", {"constant"}] + self.pad_test(input_param, input_shape, expected_shape, modes) if __name__ == "__main__": From 24cf76159e4184f2dd69dbfb6a530d52dc140b21 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 5 Jul 2022 11:20:42 +0100 Subject: [PATCH 179/183] Move metatensor support into dev branch (#4562) --- monai/apps/deepgrow/dataset.py | 5 +- monai/apps/detection/transforms/array.py | 18 +- monai/apps/detection/transforms/box_ops.py | 8 +- monai/apps/detection/transforms/dictionary.py | 48 +- monai/apps/detection/utils/detector_utils.py | 4 +- monai/apps/nuclick/transforms.py | 23 +- monai/bundle/scripts.py | 4 +- monai/config/__init__.py | 1 + monai/config/deviceconfig.py | 3 +- monai/config/type_definitions.py | 5 + monai/data/grid_dataset.py | 8 +- monai/data/image_writer.py | 32 +- monai/data/meta_obj.py | 16 +- monai/data/meta_tensor.py | 18 +- monai/data/nifti_saver.py | 8 +- monai/data/nifti_writer.py | 4 +- monai/data/png_saver.py | 2 +- monai/data/png_writer.py | 4 +- monai/data/utils.py | 10 +- monai/data/wsi_datasets.py | 4 +- monai/inferers/inferer.py | 2 +- monai/networks/blocks/patchembedding.py | 2 +- monai/networks/blocks/upsample.py | 2 +- monai/networks/layers/spatial_transforms.py | 30 +- monai/networks/nets/swin_unetr.py | 14 +- monai/transforms/__init__.py | 5 +- monai/transforms/croppad/array.py | 94 +- monai/transforms/croppad/batch.py | 14 +- monai/transforms/croppad/dictionary.py | 72 +- monai/transforms/intensity/array.py | 111 +- monai/transforms/intensity/dictionary.py | 39 +- monai/transforms/io/array.py | 34 +- monai/transforms/io/dictionary.py | 12 +- monai/transforms/post/array.py | 60 +- monai/transforms/post/dictionary.py | 60 +- monai/transforms/smooth_field/array.py | 37 +- monai/transforms/smooth_field/dictionary.py | 41 +- monai/transforms/spatial/array.py | 1218 +++++++++++------ monai/transforms/spatial/dictionary.py | 824 +++-------- monai/transforms/utility/array.py | 124 +- monai/transforms/utility/dictionary.py | 84 +- monai/transforms/utils.py | 68 +- .../transforms/utils_create_transform_ims.py | 5 +- .../utils_pytorch_numpy_unification.py | 2 +- monai/utils/enums.py | 3 + monai/utils/type_conversion.py | 5 +- tests/test_activations.py | 4 +- tests/test_activationsd.py | 4 +- tests/test_adjust_contrast.py | 6 +- tests/test_adjust_contrastd.py | 2 +- tests/test_affine.py | 18 +- tests/test_affine_grid.py | 9 +- tests/test_affined.py | 11 +- tests/test_arraydataset.py | 28 +- tests/test_as_channel_first.py | 8 +- tests/test_as_discrete.py | 2 +- tests/test_as_discreted.py | 4 +- tests/test_box_transform.py | 320 +++-- tests/test_box_utils.py | 2 +- tests/test_cachedataset_persistent_workers.py | 2 +- tests/test_convert_data_type.py | 2 +- tests/test_crop_foregroundd.py | 2 +- tests/test_cross_validation.py | 13 +- tests/test_dataset_summary.py | 17 +- tests/test_decathlondataset.py | 17 +- tests/test_decollate.py | 13 +- tests/test_detect_envelope.py | 7 +- tests/test_ensure_channel_first.py | 50 +- tests/test_ensure_channel_firstd.py | 25 +- tests/test_fill_holes.py | 15 +- tests/test_fill_holesd.py | 16 +- tests/test_flip.py | 30 +- tests/test_flipd.py | 33 +- tests/test_foreground_mask.py | 2 +- tests/test_gaussian_sharpen.py | 2 +- tests/test_gaussian_sharpend.py | 2 +- tests/test_gaussian_smooth.py | 2 +- tests/test_gaussian_smoothd.py | 2 +- tests/test_gibbs_noise.py | 13 +- tests/test_gibbs_noised.py | 20 +- tests/test_grid_distortion.py | 6 +- tests/test_grid_distortiond.py | 8 +- tests/test_histogram_normalize.py | 2 +- tests/test_histogram_normalized.py | 2 +- tests/test_image_dataset.py | 14 +- tests/test_image_rw.py | 39 +- tests/test_integration_bundle_run.py | 7 +- tests/test_integration_classification_2d.py | 14 +- tests/test_integration_determinism.py | 4 +- tests/test_integration_fast_train.py | 9 +- tests/test_integration_segmentation_3d.py | 27 +- tests/test_integration_sliding_window.py | 9 +- tests/test_integration_workflows.py | 21 +- tests/test_integration_workflows_gan.py | 3 +- tests/test_inverse.py | 160 ++- tests/test_inverse_array.py | 67 + tests/test_inverse_collation.py | 41 +- tests/test_invertd.py | 57 +- tests/test_k_space_spike_noise.py | 4 - tests/test_k_space_spike_noised.py | 11 - .../test_keep_largest_connected_component.py | 6 +- .../test_keep_largest_connected_componentd.py | 2 +- tests/test_label_to_contour.py | 4 +- tests/test_label_to_contourd.py | 4 +- tests/test_label_to_mask.py | 6 +- tests/test_label_to_maskd.py | 8 +- tests/test_lambda.py | 10 +- tests/test_lambdad.py | 16 +- tests/test_load_image.py | 176 +-- tests/test_load_imaged.py | 106 +- tests/test_load_spacing_orientation.py | 86 +- tests/test_mask_intensity.py | 8 +- tests/test_mednistdataset.py | 17 +- tests/test_meta_affine.py | 179 +++ tests/test_meta_tensor.py | 17 +- tests/test_metatensor_integration.py | 101 ++ tests/test_nifti_rw.py | 38 +- tests/test_nifti_saver.py | 5 +- tests/test_normalize_intensity.py | 9 +- tests/test_normalize_intensityd.py | 16 +- tests/test_numpy_reader.py | 3 - tests/test_orientation.py | 193 ++- tests/test_orientationd.py | 153 ++- tests/test_pad_collation.py | 5 +- tests/test_rand_adjust_contrast.py | 5 +- tests/test_rand_adjust_contrastd.py | 2 +- tests/test_rand_affine.py | 8 +- tests/test_rand_affine_grid.py | 4 +- tests/test_rand_affined.py | 375 ++--- tests/test_rand_axis_flip.py | 18 +- tests/test_rand_axis_flipd.py | 19 +- tests/test_rand_bias_field.py | 5 +- tests/test_rand_bias_fieldd.py | 1 - tests/test_rand_coarse_dropout.py | 12 +- tests/test_rand_deform_grid.py | 4 +- tests/test_rand_elastic_2d.py | 12 +- tests/test_rand_elastic_3d.py | 13 +- tests/test_rand_elasticd_2d.py | 6 +- tests/test_rand_elasticd_3d.py | 6 +- tests/test_rand_flip.py | 14 +- tests/test_rand_flipd.py | 17 +- tests/test_rand_gaussian_noise.py | 1 - tests/test_rand_gaussian_noised.py | 1 - tests/test_rand_gaussian_sharpen.py | 2 +- tests/test_rand_gaussian_smooth.py | 2 +- tests/test_rand_gibbs_noise.py | 12 +- tests/test_rand_gibbs_noised.py | 17 +- tests/test_rand_grid_distortion.py | 6 +- tests/test_rand_grid_distortiond.py | 8 +- tests/test_rand_histogram_shift.py | 8 +- tests/test_rand_histogram_shiftd.py | 4 +- tests/test_rand_k_space_spike_noise.py | 22 +- tests/test_rand_k_space_spike_noised.py | 23 +- tests/test_rand_lambda.py | 50 +- tests/test_rand_lambdad.py | 42 +- tests/test_rand_rician_noise.py | 3 +- tests/test_rand_rotate.py | 17 +- tests/test_rand_rotate90.py | 49 +- tests/test_rand_rotate90d.py | 44 +- tests/test_rand_rotated.py | 10 +- tests/test_rand_scale_crop.py | 2 +- tests/test_rand_scale_cropd.py | 2 +- tests/test_rand_scale_intensity.py | 22 +- tests/test_rand_scale_intensityd.py | 2 +- tests/test_rand_shift_intensity.py | 11 +- tests/test_rand_shift_intensityd.py | 2 +- tests/test_rand_spatial_crop.py | 2 +- tests/test_rand_spatial_crop_samples.py | 2 +- tests/test_rand_spatial_cropd.py | 2 +- tests/test_rand_std_shift_intensity.py | 28 +- tests/test_rand_std_shift_intensityd.py | 7 +- tests/test_rand_weighted_crop.py | 4 +- tests/test_rand_zoom.py | 22 +- tests/test_rand_zoomd.py | 20 +- tests/test_remove_repeated_channel.py | 7 +- tests/test_resample_to_match.py | 72 +- tests/test_resample_to_matchd.py | 70 +- tests/test_resampler.py | 8 +- tests/test_resize.py | 42 +- tests/test_resized.py | 16 +- tests/test_rotate.py | 20 +- tests/test_rotate90.py | 89 +- tests/test_rotate90d.py | 40 +- tests/test_rotated.py | 17 +- tests/test_save_image.py | 9 +- tests/test_save_imaged.py | 13 +- tests/test_savitzky_golay_smooth.py | 5 +- tests/test_scale_intensity.py | 28 +- tests/test_scale_intensity_range.py | 4 +- .../test_scale_intensity_range_percentiles.py | 10 +- ...test_scale_intensity_range_percentilesd.py | 4 +- tests/test_scale_intensity_ranged.py | 4 +- tests/test_scale_intensityd.py | 6 +- tests/test_shift_intensityd.py | 2 +- tests/test_sliding_window_inference.py | 15 +- tests/test_smartcachedataset.py | 6 +- tests/test_smooth_field.py | 16 +- tests/test_spacing.py | 210 ++- tests/test_spacingd.py | 84 +- tests/test_spatial_resample.py | 261 ++-- tests/test_spatial_resampled.py | 146 +- tests/test_split_channel.py | 1 + tests/test_splitdim.py | 1 + tests/test_splitdimd.py | 20 +- tests/test_std_shift_intensity.py | 3 +- tests/test_std_shift_intensityd.py | 3 +- tests/test_testtimeaugmentation.py | 8 +- tests/test_threshold_intensity.py | 2 +- tests/test_threshold_intensityd.py | 6 +- tests/test_transchex.py | 2 +- tests/test_varautoencoder.py | 2 +- tests/test_warp.py | 3 +- tests/test_wsireader.py | 3 +- tests/test_wsireader_new.py | 4 +- tests/test_zoom.py | 35 +- tests/test_zoomd.py | 14 +- tests/testing_data/data_config.json | 20 + tests/testing_data/inference.json | 5 - tests/testing_data/inference.yaml | 3 - tests/testing_data/matshow3d_patch_test.png | Bin 5050 -> 5005 bytes .../transform_metatensor_cases.yaml | 194 +++ tests/utils.py | 44 +- 222 files changed, 4660 insertions(+), 3450 deletions(-) create mode 100644 tests/test_inverse_array.py create mode 100644 tests/test_meta_affine.py create mode 100644 tests/test_metatensor_integration.py create mode 100644 tests/testing_data/transform_metatensor_cases.yaml diff --git a/monai/apps/deepgrow/dataset.py b/monai/apps/deepgrow/dataset.py index 721781196b..d51fd4e238 100644 --- a/monai/apps/deepgrow/dataset.py +++ b/monai/apps/deepgrow/dataset.py @@ -15,8 +15,9 @@ import numpy as np -from monai.transforms import AsChannelFirstd, Compose, LoadImaged, Orientationd, Spacingd +from monai.transforms import AsChannelFirstd, Compose, FromMetaTensord, LoadImaged, Orientationd, Spacingd, ToNumpyd from monai.utils import GridSampleMode +from monai.utils.enums import PostFix def create_dataset( @@ -128,6 +129,8 @@ def _default_transforms(image_key, label_key, pixdim): AsChannelFirstd(keys=keys), Orientationd(keys=keys, axcodes="RAS"), Spacingd(keys=keys, pixdim=pixdim, mode=mode), + FromMetaTensord(keys=keys), + ToNumpyd(keys=keys + [PostFix.meta(k) for k in keys]), ] ) diff --git a/monai/apps/detection/transforms/array.py b/monai/apps/detection/transforms/array.py index 4c3f4f223d..d5d61f6e43 100644 --- a/monai/apps/detection/transforms/array.py +++ b/monai/apps/detection/transforms/array.py @@ -205,9 +205,7 @@ def __init__(self, zoom: Union[Sequence[float], float], keep_size: bool = False, self.keep_size = keep_size self.kwargs = kwargs - def __call__( - self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int, None] = None - ) -> NdarrayOrTensor: # type: ignore + def __call__(self, boxes: torch.Tensor, src_spatial_size: Union[Sequence[int], int, None] = None): """ Args: boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` @@ -266,9 +264,7 @@ def __init__(self, spatial_size: Union[Sequence[int], int], size_mode: str = "al self.size_mode = look_up_option(size_mode, ["all", "longest"]) self.spatial_size = spatial_size - def __call__( # type: ignore - self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int] - ) -> NdarrayOrTensor: + def __call__(self, boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int]): # type: ignore """ Args: boxes: source bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` @@ -316,9 +312,7 @@ class FlipBox(Transform): def __init__(self, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: self.spatial_axis = spatial_axis - def __call__( # type: ignore - self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int] - ) -> NdarrayOrTensor: + def __call__(self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int]): # type: ignore """ Args: boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` @@ -489,7 +483,7 @@ def __init__( def __call__( # type: ignore self, boxes: NdarrayOrTensor, labels: Union[Sequence[NdarrayOrTensor], NdarrayOrTensor] - ) -> Tuple[NdarrayOrTensor, Union[Tuple, NdarrayOrTensor]]: + ): """ Args: boxes: bounding boxes, Nx4 or Nx6 torch tensor or ndarray. The box mode is assumed to be ``StandardMode`` @@ -535,9 +529,7 @@ class RotateBox90(Rotate90): def __init__(self, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: super().__init__(k, spatial_axes) - def __call__( # type: ignore - self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int] - ) -> NdarrayOrTensor: + def __call__(self, boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int]): # type: ignore """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py index 1cdcab0a44..1562f28b29 100644 --- a/monai/apps/detection/transforms/box_ops.py +++ b/monai/apps/detection/transforms/box_ops.py @@ -99,7 +99,7 @@ def apply_affine_to_boxes(boxes: NdarrayOrTensor, affine: NdarrayOrTensor) -> Nd return boxes_affine -def zoom_boxes(boxes: NdarrayOrTensor, zoom: Union[Sequence[float], float]) -> NdarrayOrTensor: +def zoom_boxes(boxes: NdarrayOrTensor, zoom: Union[Sequence[float], float]): """ Zoom boxes @@ -128,7 +128,7 @@ def zoom_boxes(boxes: NdarrayOrTensor, zoom: Union[Sequence[float], float]) -> N def resize_boxes( boxes: NdarrayOrTensor, src_spatial_size: Union[Sequence[int], int], dst_spatial_size: Union[Sequence[int], int] -) -> NdarrayOrTensor: +): """ Resize boxes when the corresponding image is resized @@ -262,7 +262,7 @@ def convert_box_to_mask( boxes_only_mask = resizer(boxes_only_mask[None])[0] # type: ignore else: # generate a rect mask - boxes_only_mask = np.ones(box_size, dtype=np.int16) * np.int16(labels_np[b]) # type: ignore + boxes_only_mask = np.ones(box_size, dtype=np.int16) * np.int16(labels_np[b]) # apply to global mask slicing = [b] slicing.extend(slice(boxes_np[b, d], boxes_np[b, d + spatial_dims]) for d in range(spatial_dims)) # type:ignore @@ -334,7 +334,7 @@ def select_labels( Return: selected labels, does not share memory with original labels. """ - labels_tuple = ensure_tuple(labels, True) # type: ignore + labels_tuple = ensure_tuple(labels, True) labels_select_list = [] keep_t: torch.Tensor = convert_data_type(keep, torch.Tensor)[0] diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index b7b70d00da..cb08d0ed69 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -34,7 +34,7 @@ ZoomBox, ) from monai.apps.detection.transforms.box_ops import convert_box_to_mask -from monai.config import KeysCollection +from monai.config import KeysCollection, SequenceStr from monai.config.type_definitions import NdarrayOrTensor from monai.data.box_utils import COMPUTE_DTYPE, BoxMode, clip_boxes_to_image from monai.data.utils import orientation_ras_lps @@ -43,7 +43,7 @@ from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform from monai.transforms.utils import generate_pos_neg_label_crop_centers, map_binary_to_indices from monai.utils import ImageMetaKey as Key -from monai.utils import InterpolateMode, NumpyPadMode, PytorchPadMode, ensure_tuple, ensure_tuple_rep +from monai.utils import InterpolateMode, NumpyPadMode, ensure_tuple, ensure_tuple_rep from monai.utils.enums import PostFix, TraceKeys from monai.utils.type_conversion import convert_data_type @@ -90,8 +90,6 @@ ] DEFAULT_POST_FIX = PostFix.meta() -InterpolateModeSequence = Union[Sequence[Union[InterpolateMode, str]], InterpolateMode, str] -PadModeSequence = Union[Sequence[Union[NumpyPadMode, PytorchPadMode, str]], NumpyPadMode, PytorchPadMode, str] class ConvertBoxModed(MapTransform, InvertibleTransform): @@ -377,8 +375,8 @@ def __init__( box_keys: KeysCollection, box_ref_image_keys: KeysCollection, zoom: Union[Sequence[float], float], - mode: InterpolateModeSequence = InterpolateMode.AREA, - padding_mode: PadModeSequence = NumpyPadMode.EDGE, + mode: SequenceStr = InterpolateMode.AREA, + padding_mode: SequenceStr = NumpyPadMode.EDGE, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, allow_missing_keys: bool = False, @@ -395,7 +393,7 @@ def __init__( self.zoomer = Zoom(zoom=zoom, keep_size=keep_size, **kwargs) self.keep_size = keep_size - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) # zoom box @@ -431,7 +429,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): @@ -453,7 +451,8 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd align_corners=None if align_corners == TraceKeys.NONE else align_corners, ) # Size might be out by 1 voxel so pad - d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key]) + orig_shape = transform[TraceKeys.EXTRA_INFO]["original_shape"] + d[key] = SpatialPad(orig_shape, mode="edge")(d[key]) # zoom boxes if key_type == "box_key": @@ -518,8 +517,8 @@ def __init__( prob: float = 0.1, min_zoom: Union[Sequence[float], float] = 0.9, max_zoom: Union[Sequence[float], float] = 1.1, - mode: InterpolateModeSequence = InterpolateMode.AREA, - padding_mode: PadModeSequence = NumpyPadMode.EDGE, + mode: SequenceStr = InterpolateMode.AREA, + padding_mode: SequenceStr = NumpyPadMode.EDGE, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, allow_missing_keys: bool = False, @@ -544,7 +543,7 @@ def set_random_state( self.rand_zoom.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: @@ -594,7 +593,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): @@ -616,7 +615,8 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd align_corners=None if align_corners == TraceKeys.NONE else align_corners, ) # Size might be out by 1 voxel so pad - d[key] = SpatialPad(transform[TraceKeys.EXTRA_INFO]["original_shape"], mode="edge")(d[key]) + orig_shape = transform[TraceKeys.EXTRA_INFO]["original_shape"] + d[key] = SpatialPad(orig_shape, mode="edge")(d[key]) # zoom boxes if key_type == "box_key": @@ -661,7 +661,7 @@ def __init__( self.flipper = Flip(spatial_axis=spatial_axis) self.box_flipper = FlipBox(spatial_axis=self.flipper.spatial_axis) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key in self.image_keys: @@ -674,7 +674,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N self.push_transform(d, box_key, extra_info={"spatial_size": spatial_size, "type": "box_key"}) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): @@ -735,7 +735,7 @@ def set_random_state( self.flipper.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) self.randomize(None) @@ -751,7 +751,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N self.push_transform(d, box_key, extra_info={"spatial_size": spatial_size, "type": "box_key"}) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): @@ -1172,7 +1172,7 @@ def randomize( # type: ignore self.allow_smaller, ) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashable, NdarrayOrTensor]]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]: d = dict(data) spatial_dims = len(d[self.image_keys[0]].shape) - 1 image_size = d[self.image_keys[0]].shape[1:] @@ -1190,7 +1190,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict[Hashab raise ValueError("no available ROI centers to crop.") # initialize returned list with shallow copy to preserve key ordering - results: List[Dict[Hashable, NdarrayOrTensor]] = [dict(d) for _ in range(self.num_samples)] + results: List[Dict[Hashable, torch.Tensor]] = [dict(d) for _ in range(self.num_samples)] # crop images and boxes for each center. for i, center in enumerate(self.centers): @@ -1255,7 +1255,7 @@ def __init__( self.img_rotator = Rotate90(k, spatial_axes) self.box_rotator = RotateBox90(k, spatial_axes) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Mapping[Hashable, torch.Tensor]: d = dict(data) for key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): spatial_size = list(d[box_ref_image_key].shape[1:]) @@ -1273,7 +1273,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable self.push_transform(d, key, extra_info={"type": "image_key"}) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): @@ -1327,7 +1327,7 @@ def __init__( super().__init__(self.image_keys + self.box_keys, prob, max_k, spatial_axes, allow_missing_keys) self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys)) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Mapping[Hashable, torch.Tensor]: self.randomize() d = dict(data) @@ -1359,7 +1359,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable self.push_transform(d, key, extra_info={"rand_k": self._rand_k, "type": "image_key"}) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) if self._rand_k % 4 == 0: return d diff --git a/monai/apps/detection/utils/detector_utils.py b/monai/apps/detection/utils/detector_utils.py index a3188f53b1..d7693da62c 100644 --- a/monai/apps/detection/utils/detector_utils.py +++ b/monai/apps/detection/utils/detector_utils.py @@ -127,7 +127,7 @@ def pad_images( if max(pt_pad_width) == 0: # if there is no need to pad return input_images, [orig_size] * input_images.shape[0] - mode_: str = convert_pad_mode(dst=input_images, mode=mode).value + mode_: str = convert_pad_mode(dst=input_images, mode=mode) return F.pad(input_images, pt_pad_width, mode=mode_, **kwargs), [orig_size] * input_images.shape[0] # If input_images: List[Tensor]) @@ -151,7 +151,7 @@ def pad_images( # Use `SpatialPad` to match sizes, padding in the end will not affect boxes padder = SpatialPad(spatial_size=max_spatial_size, method="end", mode=mode, **kwargs) for idx, img in enumerate(input_images): - images[idx, ...] = padder(img) # type: ignore + images[idx, ...] = padder(img) return images, [list(ss) for ss in image_sizes] diff --git a/monai/apps/nuclick/transforms.py b/monai/apps/nuclick/transforms.py index d6be1a84fa..28c6417a42 100644 --- a/monai/apps/nuclick/transforms.py +++ b/monai/apps/nuclick/transforms.py @@ -11,20 +11,19 @@ import math import random -from enum import Enum from typing import Any, Tuple, Union import numpy as np from monai.config import KeysCollection from monai.transforms import MapTransform, Randomizable, SpatialPad -from monai.utils import optional_import +from monai.utils import StrEnum, optional_import measure, _ = optional_import("skimage.measure") morphology, _ = optional_import("skimage.morphology") -class NuclickKeys(Enum): +class NuclickKeys(StrEnum): """ Keys for nuclick transforms. """ @@ -83,7 +82,7 @@ class ExtractPatchd(MapTransform): def __init__( self, keys: KeysCollection, - centroid_key: str = NuclickKeys.CENTROID.value, + centroid_key: str = NuclickKeys.CENTROID, patch_size: Union[Tuple[int, int], int] = 128, allow_missing_keys: bool = False, **kwargs: Any, @@ -138,9 +137,9 @@ class SplitLabeld(MapTransform): def __init__( self, keys: KeysCollection, - # label: str = NuclickKeys.LABEL.value, - others: str = NuclickKeys.OTHERS.value, - mask_value: str = NuclickKeys.MASK_VALUE.value, + # label: str = NuclickKeys.LABEL, + others: str = NuclickKeys.OTHERS, + mask_value: str = NuclickKeys.MASK_VALUE, min_area: int = 5, ): @@ -268,9 +267,9 @@ class AddPointGuidanceSignald(Randomizable, MapTransform): def __init__( self, - image: str = NuclickKeys.IMAGE.value, - label: str = NuclickKeys.LABEL.value, - others: str = NuclickKeys.OTHERS.value, + image: str = NuclickKeys.IMAGE, + label: str = NuclickKeys.LABEL, + others: str = NuclickKeys.OTHERS, drop_rate: float = 0.5, jitter_range: int = 3, ): @@ -338,9 +337,7 @@ class AddClickSignalsd(MapTransform): bb_size: single integer size, defines a bounding box like (bb_size, bb_size) """ - def __init__( - self, image: str = NuclickKeys.IMAGE.value, foreground: str = NuclickKeys.FOREGROUND.value, bb_size: int = 128 - ): + def __init__(self, image: str = NuclickKeys.IMAGE, foreground: str = NuclickKeys.FOREGROUND, bb_size: int = 128): self.image = image self.foreground = foreground self.bb_size = bb_size diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 930bd51921..ff8144397f 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -53,7 +53,7 @@ def _update_args(args: Optional[Union[str, Dict]] = None, ignore_none: bool = Tr kwargs: destination args to update. """ - args_: Dict = args if isinstance(args, dict) else {} # type: ignore + args_: Dict = args if isinstance(args, dict) else {} if isinstance(args, str): # args are defined in a structured file args_ = ConfigParser.load_config_file(args) @@ -519,7 +519,7 @@ def verify_net_in_out( net.eval() with torch.no_grad(): - spatial_shape = _get_fake_spatial_shape(input_spatial_shape, p=p_, n=n_, any=any_) # type: ignore + spatial_shape = _get_fake_spatial_shape(input_spatial_shape, p=p_, n=n_, any=any_) test_data = torch.rand(*(1, input_channels, *spatial_shape), dtype=input_dtype, device=device_) output = net(test_data) if output.shape[1] != output_channels: diff --git a/monai/config/__init__.py b/monai/config/__init__.py index bf1b66fe92..5f67ea6584 100644 --- a/monai/config/__init__.py +++ b/monai/config/__init__.py @@ -28,5 +28,6 @@ NdarrayOrTensor, NdarrayTensor, PathLike, + SequenceStr, TensorOrList, ) diff --git a/monai/config/deviceconfig.py b/monai/config/deviceconfig.py index 8d6383ed97..ad633a133d 100644 --- a/monai/config/deviceconfig.py +++ b/monai/config/deviceconfig.py @@ -121,7 +121,8 @@ def get_system_info() -> OrderedDict: if output["System"] == "Windows": _dict_append(output, "Win32 version", platform.win32_ver) if hasattr(platform, "win32_edition"): - _dict_append(output, "Win32 edition", platform.win32_edition) # type:ignore[attr-defined] + _dict_append(output, "Win32 edition", platform.win32_edition) + elif output["System"] == "Darwin": _dict_append(output, "Mac version", lambda: platform.mac_ver()[0]) else: diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index 16919c2ec4..bb6f87e97a 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -38,6 +38,7 @@ "NdarrayOrTensor", "TensorOrList", "PathLike", + "SequenceStr", ] @@ -77,3 +78,7 @@ #: PathLike: The PathLike type is used for defining a file path. PathLike = Union[str, os.PathLike] + +#: SequenceStr +# string or a sequence of strings for `mode` types. +SequenceStr = Union[Sequence[str], str] diff --git a/monai/data/grid_dataset.py b/monai/data/grid_dataset.py index ffad8dba88..2b28949419 100644 --- a/monai/data/grid_dataset.py +++ b/monai/data/grid_dataset.py @@ -32,11 +32,7 @@ class PatchIter: """ def __init__( - self, - patch_size: Sequence[int], - start_pos: Sequence[int] = (), - mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, - **pad_opts: Dict, + self, patch_size: Sequence[int], start_pos: Sequence[int] = (), mode: str = NumpyPadMode.WRAP, **pad_opts: Dict ): """ @@ -109,7 +105,7 @@ def __init__( keys: KeysCollection, patch_size: Sequence[int], start_pos: Sequence[int] = (), - mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, + mode: str = NumpyPadMode.WRAP, **pad_opts, ): self.keys = ensure_tuple(keys) diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py index cf9ef90e8c..27cfef6db1 100644 --- a/monai/data/image_writer.py +++ b/monai/data/image_writer.py @@ -15,6 +15,7 @@ from monai.apps.utils import get_logger from monai.config import DtypeLike, NdarrayOrTensor, PathLike +from monai.data.meta_tensor import MetaTensor from monai.data.utils import affine_to_spacing, ensure_tuple, ensure_tuple_rep, orientation_ras_lps, to_affine_nd from monai.transforms.spatial.array import Resize, SpatialResample from monai.transforms.utils_pytorch_numpy_unification import ascontiguousarray, moveaxis @@ -24,6 +25,7 @@ InterpolateMode, OptionalImportError, convert_data_type, + convert_to_tensor, look_up_option, optional_import, require_pkg, @@ -204,8 +206,8 @@ def resample_if_needed( affine: Optional[NdarrayOrTensor] = None, target_affine: Optional[NdarrayOrTensor] = None, output_spatial_shape: Union[Sequence[int], int, None] = None, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: DtypeLike = np.float64, ): @@ -258,11 +260,18 @@ def resample_if_needed( ``np.float64`` for best precision. If ``None``, use the data type of input data. The output data type of this method is always ``np.float32``. """ + orig_type = type(data_array) + data_array = convert_to_tensor(data_array, track_meta=True) + if affine is not None: + data_array.affine = convert_to_tensor(affine, track_meta=False) # type: ignore resampler = SpatialResample(mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype) - output_array, target_affine = resampler( - data_array[None], src_affine=affine, dst_affine=target_affine, spatial_size=output_spatial_shape - ) - return output_array[0], target_affine + output_array = resampler(data_array[None], dst_affine=target_affine, spatial_size=output_spatial_shape) + # convert back at the end + if isinstance(output_array, MetaTensor): + output_array.applied_operations = [] + data_array, *_ = convert_data_type(output_array, output_type=orig_type) # type: ignore + affine, *_ = convert_data_type(output_array.affine, output_type=orig_type) # type: ignore + return data_array[0], affine @classmethod def convert_to_channel_last( @@ -613,6 +622,8 @@ def create_backend_obj( if dtype is not None: data_array = data_array.astype(dtype, copy=False) affine = convert_data_type(affine, np.ndarray)[0] + if affine is None: + affine = np.eye(4) affine = to_affine_nd(r=3, affine=affine) return nib.nifti1.Nifti1Image( data_array, @@ -736,7 +747,7 @@ def resample_and_clip( cls, data_array: NdarrayOrTensor, output_spatial_shape: Optional[Sequence[int]] = None, - mode: Union[InterpolateMode, str] = InterpolateMode.BICUBIC, + mode: str = InterpolateMode.BICUBIC, ): """ Resample ``data_array`` to ``output_spatial_shape`` if needed. @@ -755,11 +766,11 @@ def resample_and_clip( _min, _max = np.min(data), np.max(data) if len(data.shape) == 3: data = np.moveaxis(data, -1, 0) # to channel first - data = xform(data) # type: ignore + data = convert_data_type(xform(data), np.ndarray)[0] # type: ignore data = np.moveaxis(data, 0, -1) else: # (H, W) data = np.expand_dims(data, 0) # make a channel - data = xform(data)[0] # type: ignore + data = convert_data_type(xform(data), np.ndarray)[0][0] # type: ignore if mode != InterpolateMode.NEAREST: data = np.clip(data, _min, _max) return data @@ -792,7 +803,8 @@ def create_backend_obj( data: np.ndarray = super().create_backend_obj(data_array) if scale: # scale the data to be in an integer range - data = np.clip(data, 0.0, 1.0) # type: ignore # png writer only can scale data in range [0, 1] + data = np.clip(data, 0.0, 1.0) # png writer only can scale data in range [0, 1] + if scale == np.iinfo(np.uint8).max: data = (scale * data).astype(np.uint8, copy=False) elif scale == np.iinfo(np.uint16).max: diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index 0f404dcac7..7d2e99ff79 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -12,6 +12,7 @@ from __future__ import annotations import itertools +import pprint from copy import deepcopy from typing import Any, Iterable @@ -29,7 +30,7 @@ def set_track_meta(val: bool) -> None: with empty metadata. If `set_track_meta` is `False`, then standard data objects will be returned (e.g., - `torch.Tensor` and `np.ndarray`) as opposed to our enhanced objects. + `torch.Tensor` and `np.ndarray`) as opposed to MONAI's enhanced objects. By default, this is `True`, and most users will want to leave it this way. However, if you are experiencing any problems regarding metadata, and aren't interested in @@ -46,7 +47,7 @@ def get_track_meta() -> bool: returned with empty metadata. If `set_track_meta` is `False`, then standard data objects will be returned (e.g., - `torch.Tensor` and `np.ndarray`) as opposed to our enhanced objects. + `torch.Tensor` and `np.ndarray`) as opposed to MONAI's enhanced objects. By default, this is `True`, and most users will want to leave it this way. However, if you are experiencing any problems regarding metadata, and aren't interested in @@ -59,8 +60,7 @@ class MetaObj: """ Abstract base class that stores data as well as any extra metadata. - This allows for subclassing `torch.Tensor` and `np.ndarray` through multiple - inheritance. + This allows for subclassing `torch.Tensor` and `np.ndarray` through multiple inheritance. Metadata is stored in the form of a dictionary. @@ -70,7 +70,8 @@ class MetaObj: Copying of information: * For `c = a + b`, then auxiliary data (e.g., metadata) will be copied from the - first instance of `MetaObj`. + first instance of `MetaObj` if `a.is_batch` is False + (For batched data, the metdata will be shallow copied for efficiency purposes). """ @@ -174,8 +175,7 @@ def __repr__(self) -> str: out += "\nApplied operations\n" if self.applied_operations is not None: - for i in self.applied_operations: - out += f"\t{str(i)}\n" + out += pprint.pformat(self.applied_operations, indent=2, compact=True, width=120) else: out += "None" @@ -196,7 +196,7 @@ def meta(self, d) -> None: self._meta = d @property - def applied_operations(self) -> list: + def applied_operations(self) -> list[dict]: """Get the applied operations.""" if hasattr(self, "_applied_operations"): return self._applied_operations diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index a993a5e464..1582652f53 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -39,7 +39,8 @@ class MetaTensor(MetaObj, torch.Tensor): Copying of information: * For `c = a + b`, then auxiliary data (e.g., metadata) will be copied from the - first instance of `MetaTensor`. + first instance of `MetaTensor` if `a.is_batch` is False + (For batched data, the metdata will be shallow copied for efficiency purposes). Example: .. code-block:: python @@ -48,13 +49,16 @@ class MetaTensor(MetaObj, torch.Tensor): from monai.data import MetaTensor t = torch.tensor([1,2,3]) - affine = torch.eye(4) * 100 + affine = torch.as_tensor([[2,0,0,0], + [0,2,0,0], + [0,0,2,0], + [0,0,0,1]], dtype=torch.float64) meta = {"some": "info"} m = MetaTensor(t, affine=affine, meta=meta) - m2 = m+m + m2 = m + m assert isinstance(m2, MetaTensor) assert m2.meta["some"] == "info" - assert m2.affine == affine + assert torch.all(m2.affine == affine) Notes: - Requires pytorch 1.9 or newer for full compatibility. @@ -184,7 +188,7 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: ret = ret.as_tensor() # else, handle the `MetaTensor` metadata. else: - meta_args = MetaObj.flatten_meta_objs(args, kwargs.values()) # type: ignore + meta_args = MetaObj.flatten_meta_objs(args, kwargs.values()) ret._copy_meta(meta_args, deep_copy=not is_batch) ret.is_batch = is_batch # the following is not implemented but the network arch may run into this case: @@ -204,7 +208,7 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence: # if using e.g., `batch[:, -1]` or `batch[..., -1]`, then the # first element will be `slice(None, None, None)` and `Ellipsis`, # respectively. Don't need to do anything with the metadata. - if batch_idx not in (slice(None, None, None), Ellipsis): + if batch_idx not in (slice(None, None, None), Ellipsis, None): # only decollate metadata once if metas is None: metas = decollate_batch(ret.meta) @@ -304,7 +308,7 @@ def as_dict(self, key: str) -> dict: @property def affine(self) -> torch.Tensor: """Get the affine.""" - return self.meta.get("affine", self.get_default_affine()) # type: ignore + return self.meta.get("affine", self.get_default_affine()) @affine.setter def affine(self, d: NdarrayTensor) -> None: diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index 89f58aa23b..ddc5e10f63 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -45,8 +45,8 @@ def __init__( output_postfix: str = "seg", output_ext: str = ".nii.gz", resample: bool = True, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, @@ -99,8 +99,8 @@ def __init__( self.output_postfix = output_postfix self.output_ext = output_ext self.resample = resample - self.mode: GridSampleMode = GridSampleMode(mode) - self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) + self.mode: str = GridSampleMode(mode) + self.padding_mode: str = GridSamplePadMode(padding_mode) self.align_corners = align_corners self.dtype = dtype self.output_dtype = output_dtype diff --git a/monai/data/nifti_writer.py b/monai/data/nifti_writer.py index 8a6172955f..234f5b0a22 100644 --- a/monai/data/nifti_writer.py +++ b/monai/data/nifti_writer.py @@ -33,8 +33,8 @@ def write_nifti( target_affine: Optional[np.ndarray] = None, resample: bool = True, output_spatial_shape: Union[Sequence[int], np.ndarray, None] = None, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py index efe46603fb..5b6e3b5a30 100644 --- a/monai/data/png_saver.py +++ b/monai/data/png_saver.py @@ -42,7 +42,7 @@ def __init__( output_postfix: str = "seg", output_ext: str = ".png", resample: bool = True, - mode: Union[InterpolateMode, str] = InterpolateMode.NEAREST, + mode: str = InterpolateMode.NEAREST, scale: Optional[int] = None, data_root_dir: PathLike = "", separate_folder: bool = True, diff --git a/monai/data/png_writer.py b/monai/data/png_writer.py index dc042971cb..8c49944843 100644 --- a/monai/data/png_writer.py +++ b/monai/data/png_writer.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional, Sequence, Union +from typing import Optional, Sequence import numpy as np @@ -31,7 +31,7 @@ def write_png( data: np.ndarray, file_name: str, output_spatial_shape: Optional[Sequence[int]] = None, - mode: Union[InterpolateMode, str] = InterpolateMode.BICUBIC, + mode: str = InterpolateMode.BICUBIC, scale: Optional[int] = None, ) -> None: """ diff --git a/monai/data/utils.py b/monai/data/utils.py index 88e3dbbcc2..45294cc66e 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -35,7 +35,6 @@ BlendMode, Method, NumpyPadMode, - PytorchPadMode, TraceKeys, convert_data_type, convert_to_dst_type, @@ -248,7 +247,7 @@ def iter_patch( start_pos: Sequence[int] = (), overlap: Union[Sequence[float], float] = 0.0, copy_back: bool = True, - mode: Optional[Union[NumpyPadMode, str]] = NumpyPadMode.WRAP, + mode: Optional[str] = NumpyPadMode.WRAP, **pad_opts: Dict, ): """ @@ -581,12 +580,7 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None): raise NotImplementedError(f"Unable to de-collate: {batch}, type: {type(batch)}.") -def pad_list_data_collate( - batch: Sequence, - method: Union[Method, str] = Method.SYMMETRIC, - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, - **kwargs, -): +def pad_list_data_collate(batch: Sequence, method: str = Method.SYMMETRIC, mode: str = NumpyPadMode.CONSTANT, **kwargs): """ Function version of :py:class:`monai.transforms.croppad.batch.PadListDataCollate`. diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 689e25d8ca..37439c6e59 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -19,7 +19,7 @@ from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader from monai.transforms import ForegroundMask, Randomizable, apply_transform -from monai.utils import CommonKeys, ProbMapKeys, ensure_tuple_rep +from monai.utils import CommonKeys, ProbMapKeys, convert_to_dst_type, ensure_tuple_rep from monai.utils.enums import WSIPatchKeys __all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset", "MaskedPatchWSIDataset"] @@ -381,7 +381,7 @@ def _evaluate_patch_locations(self, sample): wsi, _ = self.wsi_reader.get_data(wsi_obj, level=self.mask_level) # create the foreground tissue mask and get all indices for non-zero pixels - mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) + mask = np.squeeze(convert_to_dst_type(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi), dst=wsi)[0]) mask_locations = np.vstack(mask.nonzero()).T # convert mask locations to image locations at level=0 diff --git a/monai/inferers/inferer.py b/monai/inferers/inferer.py index af33ecd391..084b1021c2 100644 --- a/monai/inferers/inferer.py +++ b/monai/inferers/inferer.py @@ -189,7 +189,7 @@ def __call__( kwargs: optional keyword args to be passed to ``network``. """ - return sliding_window_inference( # type: ignore + return sliding_window_inference( inputs, self.roi_size, self.sw_batch_size, diff --git a/monai/networks/blocks/patchembedding.py b/monai/networks/blocks/patchembedding.py index f02f6342e8..7dc84a9837 100644 --- a/monai/networks/blocks/patchembedding.py +++ b/monai/networks/blocks/patchembedding.py @@ -140,7 +140,7 @@ def __init__( patch_size: Union[Sequence[int], int] = 2, in_chans: int = 1, embed_dim: int = 48, - norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + norm_layer: Type[LayerNorm] = nn.LayerNorm, spatial_dims: int = 3, ) -> None: """ diff --git a/monai/networks/blocks/upsample.py b/monai/networks/blocks/upsample.py index fa3929df20..364db0e236 100644 --- a/monai/networks/blocks/upsample.py +++ b/monai/networks/blocks/upsample.py @@ -46,7 +46,7 @@ def __init__( size: Optional[Union[Tuple[int], int]] = None, mode: Union[UpsampleMode, str] = UpsampleMode.DECONV, pre_conv: Optional[Union[nn.Module, str]] = "default", - interp_mode: Union[InterpolateMode, str] = InterpolateMode.LINEAR, + interp_mode: str = InterpolateMode.LINEAR, align_corners: Optional[bool] = True, bias: bool = True, apply_pad_pool: bool = True, diff --git a/monai/networks/layers/spatial_transforms.py b/monai/networks/layers/spatial_transforms.py index 07ddb3ce9d..7a41d79291 100644 --- a/monai/networks/layers/spatial_transforms.py +++ b/monai/networks/layers/spatial_transforms.py @@ -14,6 +14,7 @@ import torch import torch.nn as nn +import monai from monai.networks import to_norm_affine from monai.utils import GridSampleMode, GridSamplePadMode, ensure_tuple, look_up_option, optional_import @@ -116,6 +117,8 @@ def grid_pull( ] out: torch.Tensor out = _GridPull.apply(input, grid, interpolation, bound, extrapolate) + if isinstance(input, monai.data.MetaTensor): + out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations) return out @@ -217,7 +220,10 @@ def grid_push( if shape is None: shape = tuple(input.shape[2:]) - return _GridPush.apply(input, grid, shape, interpolation, bound, extrapolate) + out: torch.Tensor = _GridPush.apply(input, grid, shape, interpolation, bound, extrapolate) + if isinstance(input, monai.data.MetaTensor): + out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations) + return out class _GridCount(torch.autograd.Function): @@ -313,7 +319,10 @@ def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="ze if shape is None: shape = tuple(grid.shape[2:]) - return _GridCount.apply(grid, shape, interpolation, bound, extrapolate) + out: torch.Tensor = _GridCount.apply(grid, shape, interpolation, bound, extrapolate) + if isinstance(input, monai.data.MetaTensor): + out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations) + return out class _GridGrad(torch.autograd.Function): @@ -408,7 +417,10 @@ def grid_grad(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b for i in ensure_tuple(interpolation) ] - return _GridGrad.apply(input, grid, interpolation, bound, extrapolate) + out: torch.Tensor = _GridGrad.apply(input, grid, interpolation, bound, extrapolate) + if isinstance(input, monai.data.MetaTensor): + out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations) + return out class AffineTransform(nn.Module): @@ -416,8 +428,8 @@ def __init__( self, spatial_size: Optional[Union[Sequence[int], int]] = None, normalized: bool = False, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.ZEROS, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.ZEROS, align_corners: bool = False, reverse_indexing: bool = True, zero_centered: Optional[bool] = None, @@ -465,8 +477,8 @@ def __init__( super().__init__() self.spatial_size = ensure_tuple(spatial_size) if spatial_size is not None else None self.normalized = normalized - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) self.align_corners = align_corners self.reverse_indexing = reverse_indexing if zero_centered is not None and self.normalized: @@ -557,8 +569,8 @@ def forward( dst = nn.functional.grid_sample( input=src.contiguous(), grid=grid, - mode=self.mode.value, - padding_mode=self.padding_mode.value, + mode=self.mode, + padding_mode=self.padding_mode, align_corners=self.align_corners, ) return dst diff --git a/monai/networks/nets/swin_unetr.py b/monai/networks/nets/swin_unetr.py index 8e90078873..994fb50171 100644 --- a/monai/networks/nets/swin_unetr.py +++ b/monai/networks/nets/swin_unetr.py @@ -222,9 +222,7 @@ def __init__( res_block=True, ) - self.out = UnetOutBlock( - spatial_dims=spatial_dims, in_channels=feature_size, out_channels=out_channels - ) # type: ignore + self.out = UnetOutBlock(spatial_dims=spatial_dims, in_channels=feature_size, out_channels=out_channels) def load_from(self, weights): @@ -513,7 +511,7 @@ def __init__( attn_drop: float = 0.0, drop_path: float = 0.0, act_layer: str = "GELU", - norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + norm_layer: Type[LayerNorm] = nn.LayerNorm, use_checkpoint: bool = False, ) -> None: """ @@ -667,9 +665,7 @@ class PatchMerging(nn.Module): https://github.com/microsoft/Swin-Transformer """ - def __init__( - self, dim: int, norm_layer: Type[LayerNorm] = nn.LayerNorm, spatial_dims: int = 3 - ) -> None: # type: ignore + def __init__(self, dim: int, norm_layer: Type[LayerNorm] = nn.LayerNorm, spatial_dims: int = 3) -> None: """ Args: dim: number of feature channels. @@ -779,7 +775,7 @@ def __init__( qkv_bias: bool = False, drop: float = 0.0, attn_drop: float = 0.0, - norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + norm_layer: Type[LayerNorm] = nn.LayerNorm, downsample: isinstance = None, # type: ignore use_checkpoint: bool = False, ) -> None: @@ -881,7 +877,7 @@ def __init__( drop_rate: float = 0.0, attn_drop_rate: float = 0.0, drop_path_rate: float = 0.0, - norm_layer: Type[LayerNorm] = nn.LayerNorm, # type: ignore + norm_layer: Type[LayerNorm] = nn.LayerNorm, patch_norm: bool = False, use_checkpoint: bool = False, spatial_dims: int = 3, diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index a5c9ed05eb..0cc7fb3e67 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -56,9 +56,6 @@ Padd, PadD, PadDict, - RandCropd, - RandCropD, - RandCropDict, RandCropByLabelClassesd, RandCropByLabelClassesD, RandCropByLabelClassesDict, @@ -578,7 +575,7 @@ Fourier, allow_missing_keys_mode, compute_divisible_spatial_size, - convert_inverse_interp_mode, + convert_applied_interp_mode, convert_pad_mode, convert_to_contiguous, copypaste_arrays, diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 0483839759..662eb62eb6 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -26,7 +26,7 @@ from monai.data.meta_obj import get_track_meta from monai.data.meta_tensor import MetaTensor from monai.data.utils import get_random_patch, get_valid_patch_size -from monai.transforms.inverse import InvertibleTransform +from monai.transforms.inverse import InvertibleTransform, TraceableTransform from monai.transforms.transform import Randomizable, Transform from monai.transforms.utils import ( compute_divisible_spatial_size, @@ -41,9 +41,20 @@ weighted_patch_samples, ) from monai.utils import ImageMetaKey as Key -from monai.utils import Method, PytorchPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option -from monai.utils.enums import TraceKeys, TransformBackends -from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor +from monai.utils import ( + Method, + PytorchPadMode, + TraceKeys, + TransformBackends, + convert_data_type, + convert_to_dst_type, + convert_to_tensor, + ensure_tuple, + ensure_tuple_rep, + fall_back_tuple, + look_up_option, + pytorch_after, +) __all__ = [ "Pad", @@ -99,6 +110,7 @@ def __init__( def compute_pad_width(self, spatial_shape: Sequence[int]) -> List[Tuple[int, int]]: """ dynamically compute the pad width according to the spatial shape. + the output is the amount of padding for all dimensions including the channel. Args: spatial_shape: spatial shape of the original image. @@ -147,6 +159,7 @@ def __call__( # type: ignore kwargs_.update(kwargs) img_t = convert_to_tensor(data=img, track_meta=get_track_meta()) + _orig_size = img_t.shape[1:] # all zeros, skip padding if np.asarray(to_pad_).any(): @@ -164,7 +177,7 @@ def __call__( # type: ignore out = img_t if get_track_meta(): self.update_meta(tensor=out, to_pad=to_pad_) # type: ignore - self.push_transform(out, extra_info={"padded": to_pad_}) + self.push_transform(out, orig_size=_orig_size, extra_info={"padded": to_pad_}) return out def update_meta(self, tensor: MetaTensor, to_pad: List[Tuple[int, int]]): @@ -176,10 +189,10 @@ def update_meta(self, tensor: MetaTensor, to_pad: List[Tuple[int, int]]): def inverse(self, data: MetaTensor) -> MetaTensor: transform = self.pop_transform(data) padded = transform[TraceKeys.EXTRA_INFO]["padded"] - if padded[0][0] != 0 or padded[0][1] != 0: - raise NotImplementedError( - "Inverse uses SpatialCrop, which hasn't yet been extended to crop channels. Trivial change." - ) + if padded[0][0] > 0 or padded[0][1] > 0: # slicing the channel dimension + s = padded[0][0] + e = min(max(padded[0][1], s + 1), len(data)) + data = data[s : len(data) - e] # type: ignore roi_start = [i[0] for i in padded[1:]] roi_end = [i - j[1] for i, j in zip(data.shape[1:], padded[1:])] cropper = SpatialCrop(roi_start=roi_start, roi_end=roi_end) @@ -193,7 +206,7 @@ class SpatialPad(Pad): Args: spatial_size: the spatial size of output data after padding, if a dimension of the input - data size is bigger than the pad size, will not pad that dimension. + data size is larger than the pad size, will not pad that dimension. If its components have non-positive values, the corresponding size of input image will be used (no padding). for example: if the spatial size of input data is [30, 30, 30] and `spatial_size=[32, 25, -1]`, the spatial size of output data will be [32, 30, 30]. @@ -355,7 +368,7 @@ def compute_slices( Args: roi_center: voxel coordinates for center of the crop ROI. - roi_size: size of the crop ROI, if a dimension of ROI size is bigger than image size, + roi_size: size of the crop ROI, if a dimension of ROI size is larger than image size, will not crop that dimension of the image. roi_start: voxel coordinates for start of the crop ROI. roi_end: voxel coordinates for end of the crop ROI, if a coordinate is out of image, @@ -374,7 +387,12 @@ def compute_slices( roi_center_t = convert_to_tensor(data=roi_center, dtype=torch.int16, wrap_sequence=True) roi_size_t = convert_to_tensor(data=roi_size, dtype=torch.int16, wrap_sequence=True) _zeros = torch.zeros_like(roi_center_t) - roi_start_t = torch.maximum(roi_center_t - torch.div(roi_size_t, 2, rounding_mode="floor"), _zeros) + half = ( + torch.divide(roi_size_t, 2, rounding_mode="floor") + if pytorch_after(1, 8) + else torch.floor_divide(roi_size_t, 2) + ) + roi_start_t = torch.maximum(roi_center_t - half, _zeros) roi_end_t = torch.maximum(roi_start_t + roi_size_t, roi_start_t) else: if roi_start is None or roi_end is None: @@ -404,13 +422,14 @@ def __call__(self, img: torch.Tensor, slices: Tuple[slice, ...]) -> torch.Tensor slices = tuple([slice(None)] + slices_[:sd]) img_t: MetaTensor = convert_to_tensor(data=img, track_meta=get_track_meta()) + _orig_size = img_t.shape[1:] img_t = img_t[slices] # type: ignore if get_track_meta(): self.update_meta(tensor=img_t, slices=slices) cropped_from_start = np.asarray([s.indices(o)[0] for s, o in zip(slices[1:], orig_size)]) cropped_from_end = np.asarray(orig_size) - img_t.shape[1:] - cropped_from_start cropped = list(chain(*zip(cropped_from_start.tolist(), cropped_from_end.tolist()))) - self.push_transform(img_t, extra_info={"cropped": cropped}) + self.push_transform(img_t, orig_size=_orig_size, extra_info={"cropped": cropped}) return img_t def update_meta(self, tensor: MetaTensor, slices: Tuple[slice, ...]): @@ -432,7 +451,7 @@ def inverse(self, img: MetaTensor) -> MetaTensor: class SpatialCrop(Crop): """ General purpose cropper to produce sub-volume region of interest (ROI). - If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. + If a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. It can support to crop ND spatial (channel-first) data. @@ -454,7 +473,7 @@ def __init__( """ Args: roi_center: voxel coordinates for center of the crop ROI. - roi_size: size of the crop ROI, if a dimension of ROI size is bigger than image size, + roi_size: size of the crop ROI, if a dimension of ROI size is larger than image size, will not crop that dimension of the image. roi_start: voxel coordinates for start of the crop ROI. roi_end: voxel coordinates for end of the crop ROI, if a coordinate is out of image, @@ -477,13 +496,13 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: # type: ignore class CenterSpatialCrop(Crop): """ Crop at the center of image with specified ROI size. - If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. + If a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. Args: roi_size: the spatial size of the crop region e.g. [224,224,128] - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [40, 40, 40] and `roi_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -532,14 +551,14 @@ class RandSpatialCrop(Randomizable, Crop): Crop image with random size or specific size ROI. It can crop at a random position as center or at the image center. And allows to set the minimum and maximum size to limit the randomly generated ROI. - Note: even `random_size=False`, if a dimension of the expected ROI size is bigger than the input image size, + Note: even `random_size=False`, if a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. Args: roi_size: if `random_size` is True, it specifies the minimum crop region. if `random_size` is False, it specifies the expected ROI size to crop. e.g. [224, 224, 128] - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [40, 40, 40] and `roi_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -570,7 +589,7 @@ def randomize(self, img_size: Sequence[int]) -> None: if self.random_size: max_size = img_size if self.max_roi_size is None else fall_back_tuple(self.max_roi_size, img_size) if any(i > j for i, j in zip(self._size, max_size)): - raise ValueError(f"min ROI size: {self._size} is bigger than max ROI size: {max_size}.") + raise ValueError(f"min ROI size: {self._size} is larger than max ROI size: {max_size}.") self._size = tuple(self.R.randint(low=self._size[i], high=max_size[i] + 1) for i in range(len(img_size))) if self.random_center: valid_size = get_valid_patch_size(img_size, self._size) @@ -646,21 +665,21 @@ def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: return super().__call__(img=img, randomize=randomize) -class RandSpatialCropSamples(Randomizable, Transform): +class RandSpatialCropSamples(Randomizable, TraceableTransform): """ Crop image with random size or specific size ROI to generate a list of N samples. It can crop at a random position as center or at the image center. And allows to set the minimum size to limit the randomly generated ROI. It will return a list of cropped images. - Note: even `random_size=False`, if a dimension of the expected ROI size is bigger than the input image size, + Note: even `random_size=False`, if a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. Args: roi_size: if `random_size` is True, it specifies the minimum crop region. if `random_size` is False, it specifies the expected ROI size to crop. e.g. [224, 224, 128] - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [40, 40, 40] and `roi_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -708,10 +727,12 @@ def __call__(self, img: torch.Tensor) -> List[torch.Tensor]: cropping doesn't change the channel dim. """ ret = [] + orig_size = img.shape[1:] for i in range(self.num_samples): cropped = self.cropper(img) if get_track_meta(): cropped.meta[Key.PATCH_INDEX] = i # type: ignore + self.push_transform(cropped, orig_size=orig_size, extra_info=self.pop_transform(cropped, check=False)) ret.append(cropped) return ret @@ -766,7 +787,7 @@ def __init__( of image. if None, select foreground on the whole image. margin: add margin value to spatial dims of the bounding box, if only 1 value provided, use it for all dims. allow_smaller: when computing box size with `margin`, whether allow the image size to be smaller - than box size, default to `True`. if the margined size is bigger than image size, will pad with + than box size, default to `True`. if the margined size is larger than image size, will pad with specified `mode`. return_coords: whether return the coordinates of spatial bounding box for foreground. k_divisible: make each spatial dimension to be divisible by k, default to 1. @@ -853,7 +874,7 @@ def inverse(self, img: MetaTensor) -> MetaTensor: return super().inverse(inv) -class RandWeightedCrop(Randomizable, Transform): +class RandWeightedCrop(Randomizable, TraceableTransform): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -893,7 +914,7 @@ def __call__( weight_map: weight map used to generate patch samples. The weights must be non-negative. Each element denotes a sampling weight of the spatial location. 0 indicates no sampling. It should be a single-channel array in shape, for example, `(1, spatial_dim_0, spatial_dim_1, ...)` - randomize: whether to execute random operations, defautl to `True`. + randomize: whether to execute random operations, default to `True`. Returns: A list of image patches @@ -909,17 +930,19 @@ def __call__( self.randomize(weight_map) _spatial_size = fall_back_tuple(self.spatial_size, weight_map.shape[1:]) results: List[torch.Tensor] = [] + orig_size = img.shape[1:] for i, center in enumerate(self.centers): cropped = SpatialCrop(roi_center=center, roi_size=_spatial_size)(img) if get_track_meta(): ret_: MetaTensor = cropped # type: ignore ret_.meta[Key.PATCH_INDEX] = i ret_.meta["crop_center"] = center + self.push_transform(ret_, orig_size=orig_size, extra_info=self.pop_transform(ret_, check=False)) results.append(cropped) return results -class RandCropByPosNegLabel(Randomizable, Transform): +class RandCropByPosNegLabel(Randomizable, TraceableTransform): """ Crop random fixed sized regions with the center being a foreground or background voxel based on the Pos Neg Ratio. @@ -932,7 +955,7 @@ class RandCropByPosNegLabel(Randomizable, Transform): [0, 0, 0, 0, 0], [0, 0, 0]] [0, 0, 0]] [0, 0, 0, 0, 0]]] - If a dimension of the expected spatial size is bigger than the input image size, + If a dimension of the expected spatial size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than expected size, and the cropped results of several images may not have exactly same shape. And if the crop ROI is partly out of the image, will automatically adjust the crop center to ensure the @@ -940,7 +963,7 @@ class RandCropByPosNegLabel(Randomizable, Transform): Args: spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. if its components have non-positive values, the corresponding size of `label` will be used. for example: if the spatial size of input data is [40, 40, 40] and `spatial_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -1065,6 +1088,7 @@ def __call__( if randomize: self.randomize(label, fg_indices, bg_indices, image) results: List[torch.Tensor] = [] + orig_size = img.shape[1:] if self.centers is not None: for i, center in enumerate(self.centers): roi_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) @@ -1073,11 +1097,12 @@ def __call__( ret_: MetaTensor = cropped # type: ignore ret_.meta[Key.PATCH_INDEX] = i ret_.meta["crop_center"] = center + self.push_transform(ret_, orig_size=orig_size, extra_info=self.pop_transform(ret_, check=False)) results.append(cropped) return results -class RandCropByLabelClasses(Randomizable, Transform): +class RandCropByLabelClasses(Randomizable, TraceableTransform): """ Crop random fixed sized regions with the center being a class based on the specified ratios of every class. The label data can be One-Hot format array or Argmax data. And will return a list of arrays for all the @@ -1110,7 +1135,7 @@ class RandCropByLabelClasses(Randomizable, Transform): [0, 1, 3], [1, 2, 1], [0, 0, 0]] [1, 3, 0]] - If a dimension of the expected spatial size is bigger than the input image size, + If a dimension of the expected spatial size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than expected size, and the cropped results of several images may not have exactly same shape. And if the crop ROI is partly out of the image, will automatically adjust the crop center to ensure the @@ -1118,7 +1143,7 @@ class RandCropByLabelClasses(Randomizable, Transform): Args: spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. if its components have non-positive values, the corresponding size of `label` will be used. for example: if the spatial size of input data is [40, 40, 40] and `spatial_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -1210,6 +1235,7 @@ def __call__( if randomize: self.randomize(label, indices, image) results: List[torch.Tensor] = [] + orig_size = img.shape[1:] if self.centers is not None: for i, center in enumerate(self.centers): roi_size = fall_back_tuple(self.spatial_size, default=label.shape[1:]) @@ -1218,6 +1244,7 @@ def __call__( ret_: MetaTensor = cropped # type: ignore ret_.meta[Key.PATCH_INDEX] = i ret_.meta["crop_center"] = center + self.push_transform(ret_, orig_size=orig_size, extra_info=self.pop_transform(ret_, check=False)) results.append(cropped) return results @@ -1273,13 +1300,14 @@ def __call__(self, img: torch.Tensor, mode: Optional[str] = None, **pad_kwargs) note that `np.pad` treats channel dimension as the first dimension. """ + orig_size = img.shape[1:] ret = self.padder(self.cropper(img), mode=mode, **pad_kwargs) # remove the individual info and combine if get_track_meta(): ret_: MetaTensor = ret # type: ignore pad_info = ret_.applied_operations.pop(-1) crop_info = ret_.applied_operations.pop(-1) - self.push_transform(ret_, extra_info={"pad_info": pad_info, "crop_info": crop_info}) + self.push_transform(ret_, orig_size=orig_size, extra_info={"pad_info": pad_info, "crop_info": crop_info}) return ret def inverse(self, img: MetaTensor) -> MetaTensor: diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py index ab16633fde..a4fc952745 100644 --- a/monai/transforms/croppad/batch.py +++ b/monai/transforms/croppad/batch.py @@ -14,7 +14,7 @@ """ from copy import deepcopy -from typing import Any, Dict, Hashable, Union +from typing import Any, Dict, Hashable import numpy as np import torch @@ -22,7 +22,7 @@ from monai.data.utils import list_data_collate from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad from monai.transforms.inverse import InvertibleTransform -from monai.utils.enums import Method, NumpyPadMode, PytorchPadMode, TraceKeys +from monai.utils.enums import Method, PytorchPadMode, TraceKeys __all__ = ["PadListDataCollate"] @@ -62,12 +62,7 @@ class PadListDataCollate(InvertibleTransform): """ - def __init__( - self, - method: Union[Method, str] = Method.SYMMETRIC, - mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, - **kwargs, - ) -> None: + def __init__(self, method: str = Method.SYMMETRIC, mode: str = PytorchPadMode.CONSTANT, **kwargs) -> None: self.method = method self.mode = mode self.kwargs = kwargs @@ -80,7 +75,8 @@ def __call__(self, batch: Any): # data is either list of dicts or list of lists is_list_of_dicts = isinstance(batch[0], dict) # loop over items inside of each element in a batch - for key_or_idx in batch[0].keys() if is_list_of_dicts else range(len(batch[0])): + batch_item = tuple(batch[0].keys()) if is_list_of_dicts else range(len(batch[0])) + for key_or_idx in batch_item: # calculate max size of each dimension max_shapes = [] for elem in batch: diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index ad739c0fcd..a3310a10d1 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -21,7 +21,7 @@ import numpy as np import torch -from monai.config import IndexSelection, KeysCollection +from monai.config import IndexSelection, KeysCollection, SequenceStr from monai.config.type_definitions import NdarrayOrTensor from monai.data.meta_tensor import MetaTensor from monai.transforms.croppad.array import ( @@ -119,7 +119,7 @@ def __init__( self, keys: KeysCollection, padder: Pad, - mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + mode: SequenceStr = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, ) -> None: """ @@ -166,7 +166,7 @@ def __init__( keys: KeysCollection, spatial_size: Union[Sequence[int], int], method: str = Method.SYMMETRIC, - mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + mode: SequenceStr = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, **kwargs, ) -> None: @@ -175,7 +175,7 @@ def __init__( keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` spatial_size: the spatial size of output data after padding, if a dimension of the input - data size is bigger than the pad size, will not pad that dimension. + data size is larger than the pad size, will not pad that dimension. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [30, 30, 30] and `spatial_size=[32, 25, -1]`, the spatial size of output data will be [32, 30, 30]. @@ -209,7 +209,7 @@ def __init__( self, keys: KeysCollection, spatial_border: Union[Sequence[int], int], - mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + mode: SequenceStr = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, **kwargs, ) -> None: @@ -256,7 +256,7 @@ def __init__( self, keys: KeysCollection, k: Union[Sequence[int], int], - mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + mode: SequenceStr = PytorchPadMode.CONSTANT, method: str = Method.SYMMETRIC, allow_missing_keys: bool = False, **kwargs, @@ -362,7 +362,7 @@ class SpatialCropd(Cropd): """ Dictionary-based wrapper of :py:class:`monai.transforms.SpatialCrop`. General purpose cropper to produce sub-volume region of interest (ROI). - If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. + If a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. It can support to crop ND spatial (channel-first) data. @@ -388,7 +388,7 @@ def __init__( keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` roi_center: voxel coordinates for center of the crop ROI. - roi_size: size of the crop ROI, if a dimension of ROI size is bigger than image size, + roi_size: size of the crop ROI, if a dimension of ROI size is larger than image size, will not crop that dimension of the image. roi_start: voxel coordinates for start of the crop ROI. roi_end: voxel coordinates for end of the crop ROI, if a coordinate is out of image, @@ -404,7 +404,7 @@ def __init__( class CenterSpatialCropd(Cropd): """ Dictionary-based wrapper of :py:class:`monai.transforms.CenterSpatialCrop`. - If a dimension of the expected ROI size is bigger than the input image size, will not crop that dimension. + If a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. @@ -412,7 +412,7 @@ class CenterSpatialCropd(Cropd): keys: keys of the corresponding items to be transformed. See also: monai.transforms.MapTransform roi_size: the size of the crop region e.g. [224,224,128] - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [40, 40, 40] and `roi_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -454,7 +454,7 @@ class RandSpatialCropd(RandCropd): center or at the image center. And allows to set the minimum and maximum size to limit the randomly generated ROI. Suppose all the expected fields specified by `keys` have same shape. - Note: even `random_size=False`, if a dimension of the expected ROI size is bigger than the input image size, + Note: even `random_size=False`, if a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. @@ -463,7 +463,7 @@ class RandSpatialCropd(RandCropd): See also: monai.transforms.MapTransform roi_size: if `random_size` is True, it specifies the minimum crop region. if `random_size` is False, it specifies the expected ROI size to crop. e.g. [224, 224, 128] - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [40, 40, 40] and `roi_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -537,7 +537,7 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform): specified by `keys` have same shape, and add `patch_index` to the corresponding metadata. It will return a list of dictionaries for all the cropped images. - Note: even `random_size=False`, if a dimension of the expected ROI size is bigger than the input image size, + Note: even `random_size=False`, if a dimension of the expected ROI size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected ROI, and the cropped results of several images may not have exactly the same shape. @@ -546,7 +546,7 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform): See also: monai.transforms.MapTransform roi_size: if `random_size` is True, it specifies the minimum crop region. if `random_size` is False, it specifies the expected ROI size to crop. e.g. [224, 224, 128] - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. If its components have non-positive values, the corresponding size of input image will be used. for example: if the spatial size of input data is [40, 40, 40] and `roi_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -625,7 +625,7 @@ def __init__( margin: Union[Sequence[int], int] = 0, allow_smaller: bool = True, k_divisible: Union[Sequence[int], int] = 1, - mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + mode: SequenceStr = PytorchPadMode.CONSTANT, start_coord_key: str = "foreground_start_coord", end_coord_key: str = "foreground_end_coord", allow_missing_keys: bool = False, @@ -641,7 +641,7 @@ def __init__( of image. if None, select foreground on the whole image. margin: add margin value to spatial dims of the bounding box, if only 1 value provided, use it for all dims. allow_smaller: when computing box size with `margin`, whether allow the image size to be smaller - than box size, default to `True`. if the margined size is bigger than image size, will pad with + than box size, default to `True`. if the margined size is larger than image size, will pad with specified `mode`. k_divisible: make each spatial dimension to be divisible by k, default to 1. if `k_divisible` is an int, the same `k` be applied to all the input spatial dimensions. @@ -677,8 +677,10 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc d = dict(data) self.cropper: CropForeground box_start, box_end = self.cropper.compute_bounding_box(img=d[self.source_key]) - d[self.start_coord_key] = box_start - d[self.end_coord_key] = box_end + if self.start_coord_key is not None: + d[self.start_coord_key] = box_start + if self.end_coord_key is not None: + d[self.end_coord_key] = box_end for key, m in self.key_iterator(d, self.mode): d[key] = self.cropper.crop_pad(img=d[key], box_start=box_start, box_end=box_end, mode=m) return d @@ -747,8 +749,6 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, return ret -@deprecated_arg(name="meta_keys", since="0.9") -@deprecated_arg(name="meta_key_postfix", since="0.9") class RandCropByPosNegLabeld(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandCropByPosNegLabel`. @@ -758,7 +758,7 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform): and add `patch_index` to the corresponding metadata. And will return a list of dictionaries for all the cropped images. - If a dimension of the expected spatial size is bigger than the input image size, + If a dimension of the expected spatial size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than the expected size, and the cropped results of several images may not have exactly the same shape. And if the crop ROI is partly out of the image, will automatically adjust the crop center @@ -769,7 +769,7 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform): See also: :py:class:`monai.transforms.compose.MapTransform` label_key: name of key for label image, this will be used for finding foreground/background. spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. if its components have non-positive values, the corresponding size of `data[label_key]` will be used. for example: if the spatial size of input data is [40, 40, 40] and `spatial_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -803,6 +803,8 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform): backend = RandCropByPosNegLabel.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") def __init__( self, keys: KeysCollection, @@ -862,18 +864,16 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, # initialize returned list with shallow copy to preserve key ordering ret: List = [{} for _ in range(self.cropper.num_samples)] # deep copy all the unmodified data - for key in set(data.keys()).difference(set(self.keys)): + for key in set(d.keys()).difference(set(self.keys)): for r in ret: - r[key] = deepcopy(data[key]) + r[key] = deepcopy(d[key]) - for key in self.key_iterator(data): - for i, im in enumerate(self.cropper(data[key], label=label, randomize=False)): + for key in self.key_iterator(d): + for i, im in enumerate(self.cropper(d[key], label=label, randomize=False)): ret[i][key] = im return ret -@deprecated_arg(name="meta_keys", since="0.9") -@deprecated_arg(name="meta_key_postfix", since="0.9") class RandCropByLabelClassesd(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandCropByLabelClasses`. @@ -912,7 +912,7 @@ class RandCropByLabelClassesd(Randomizable, MapTransform): [0, 1, 3], [1, 2, 1], [0, 0, 0]] [1, 3, 0]] - If a dimension of the expected spatial size is bigger than the input image size, + If a dimension of the expected spatial size is larger than the input image size, will not crop that dimension. So the cropped result may be smaller than expected size, and the cropped results of several images may not have exactly same shape. And if the crop ROI is partly out of the image, will automatically adjust the crop center to ensure the @@ -923,7 +923,7 @@ class RandCropByLabelClassesd(Randomizable, MapTransform): See also: :py:class:`monai.transforms.compose.MapTransform` label_key: name of key for label image, this will be used for finding indices of every class. spatial_size: the spatial size of the crop region e.g. [224, 224, 128]. - if a dimension of ROI size is bigger than image size, will not crop that dimension of the image. + if a dimension of ROI size is larger than image size, will not crop that dimension of the image. if its components have non-positive values, the corresponding size of `label` will be used. for example: if the spatial size of input data is [40, 40, 40] and `spatial_size=[32, 64, -1]`, the spatial size of output data will be [32, 40, 40]. @@ -948,6 +948,8 @@ class RandCropByLabelClassesd(Randomizable, MapTransform): backend = RandCropByLabelClasses.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") def __init__( self, keys: KeysCollection, @@ -1000,12 +1002,12 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, torch.Te # initialize returned list with shallow copy to preserve key ordering ret: List = [{} for _ in range(self.cropper.num_samples)] # deep copy all the unmodified data - for key in set(data.keys()).difference(set(self.keys)): + for key in set(d.keys()).difference(set(self.keys)): for r in ret: - r[key] = deepcopy(data[key]) + r[key] = deepcopy(d[key]) - for key in self.key_iterator(data): - for i, im in enumerate(self.cropper(data[key], label=label, randomize=False)): + for key in self.key_iterator(d): + for i, im in enumerate(self.cropper(d[key], label=label, randomize=False)): ret[i][key] = im return ret @@ -1038,7 +1040,7 @@ def __init__( self, keys: KeysCollection, spatial_size: Union[Sequence[int], int], - mode: Union[Sequence[str], str] = PytorchPadMode.CONSTANT, + mode: SequenceStr = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, method: str = Method.SYMMETRIC, **pad_kwargs, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index e659c7ebc0..3fa3aa63fb 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -24,6 +24,7 @@ from monai.config import DtypeLike from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor +from monai.data.meta_obj import get_track_meta from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter from monai.transforms.transform import RandomizableTransform, Transform @@ -71,6 +72,7 @@ "HistogramNormalize", "IntensityRemap", "RandIntensityRemap", + "ForegroundMask", ] @@ -108,6 +110,7 @@ def __call__(self, img: NdarrayOrTensor, mean: Optional[float] = None, randomize """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize(img=img, mean=self.mean if mean is None else mean) @@ -186,6 +189,7 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: super().randomize(None) @@ -228,6 +232,7 @@ def __call__(self, img: NdarrayOrTensor, offset: Optional[float] = None) -> Ndar Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) offset = self.offset if offset is None else offset out = img + offset out, *_ = convert_data_type(data=out, dtype=img.dtype) @@ -257,7 +262,7 @@ def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1 else: self.offsets = (min(offsets), max(offsets)) self._offset = self.offsets[0] - self._shfiter = ShiftIntensity(self._offset) + self._shifter = ShiftIntensity(self._offset) def randomize(self, data: Optional[Any] = None) -> None: super().randomize(None) @@ -275,13 +280,14 @@ def __call__(self, img: NdarrayOrTensor, factor: Optional[float] = None, randomi can be some image specific value at runtime, like: max(img), etc. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() if not self._do_transform: return img - return self._shfiter(img, self._offset if factor is None else self._offset * factor) + return self._shifter(img, self._offset if factor is None else self._offset * factor) class StdShiftIntensity(Transform): @@ -329,6 +335,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if self.dtype is not None: img, *_ = convert_data_type(img, dtype=self.dtype) if self.channel_wise: @@ -387,6 +394,7 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -439,17 +447,18 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: ValueError: When ``self.minv=None`` or ``self.maxv=None`` and ``self.factor=None``. Incompatible values. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = convert_to_tensor(img, track_meta=False) ret: NdarrayOrTensor if self.minv is not None or self.maxv is not None: if self.channel_wise: - out = [rescale_array(d, self.minv, self.maxv, dtype=self.dtype) for d in img] - ret = torch.stack(out) if isinstance(img, torch.Tensor) else np.stack(out) # type: ignore + out = [rescale_array(d, self.minv, self.maxv, dtype=self.dtype) for d in img_t] + ret = torch.stack(out) # type: ignore else: - ret = rescale_array(img, self.minv, self.maxv, dtype=self.dtype) + ret = rescale_array(img_t, self.minv, self.maxv, dtype=self.dtype) else: - ret = (img * (1 + self.factor)) if self.factor is not None else img - - ret, *_ = convert_data_type(ret, dtype=self.dtype or img.dtype) + ret = (img_t * (1 + self.factor)) if self.factor is not None else img_t + ret = convert_to_dst_type(ret, dst=img, dtype=self.dtype or img_t.dtype)[0] return ret @@ -492,6 +501,7 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -573,6 +583,7 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize(img_size=img.shape[1:]) @@ -675,6 +686,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`, assuming `img` is a channel-first array if `self.channel_wise` is True, """ + img = convert_to_tensor(img, track_meta=get_track_meta()) dtype = self.dtype or img.dtype if self.channel_wise: if self.subtrahend is not None and len(self.subtrahend) != len(img): @@ -719,6 +731,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) mask = img > self.threshold if self.above else img < self.threshold res = where(mask, img, self.cval) res, *_ = convert_data_type(res, dtype=img.dtype) @@ -730,7 +743,7 @@ class ScaleIntensityRange(Transform): Apply specific intensity scaling to the whole numpy array. Scaling from [a_min, a_max] to [b_min, b_max] with clip option. - When `b_min` or `b_max` are `None`, `scacled_array * (b_max - b_min) + b_min` will be skipped. + When `b_min` or `b_max` are `None`, `scaled_array * (b_max - b_min) + b_min` will be skipped. If `clip=True`, when `b_min`/`b_max` is None, the clipping is not performed on the corresponding edge. Args: @@ -764,6 +777,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) dtype = self.dtype or img.dtype if self.a_max - self.a_min == 0.0: warn("Divide by zero (a_min == a_max)", Warning) @@ -802,6 +816,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) epsilon = 1e-7 img_min = img.min() img_range = img.max() - img_min @@ -849,6 +864,7 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -964,19 +980,21 @@ def _normalize(self, img: NdarrayOrTensor) -> NdarrayOrTensor: a_min=a_min, a_max=a_max, b_min=b_min, b_max=b_max, clip=self.clip, dtype=self.dtype ) img = scalar(img) + img = convert_to_tensor(img, track_meta=False) return img def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = convert_to_tensor(img, track_meta=False) if self.channel_wise: - out = [self._normalize(img=d) for d in img] - img = torch.stack(out) if isinstance(img, torch.Tensor) else np.stack(out) # type: ignore + img_t = torch.stack([self._normalize(img=d) for d in img_t]) # type: ignore else: - img = self._normalize(img=img) + img_t = self._normalize(img=img_t) - return img + return convert_to_dst_type(img_t, dst=img)[0] class MaskIntensity(Transform): @@ -1016,6 +1034,7 @@ def __call__(self, img: NdarrayOrTensor, mask_data: Optional[NdarrayOrTensor] = - ValueError: When ``mask_data`` and ``img`` channels differ and ``mask_data`` is not single channel. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) mask_data = self.mask_data if mask_data is None else mask_data if mask_data is None: raise ValueError("must provide the mask_data when initializing the transform or at runtime.") @@ -1029,7 +1048,7 @@ def __call__(self, img: NdarrayOrTensor, mask_data: Optional[NdarrayOrTensor] = f"got img channels={img.shape[0]} mask_data channels={mask_data_.shape[0]}." ) - return img * mask_data_ + return convert_to_dst_type(img * mask_data_, dst=img)[0] class SavitzkyGolaySmooth(Transform): @@ -1066,7 +1085,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: array containing smoothed result. """ - self.img_t = convert_to_tensor(img) + img = convert_to_tensor(img, track_meta=get_track_meta()) + self.img_t = convert_to_tensor(img, track_meta=False) # add one to transform axis because a batch axis will be added at dimension 0 savgol_filter = SavitzkyGolayFilter(self.window_length, self.order, self.axis + 1, self.mode) @@ -1108,6 +1128,7 @@ def __call__(self, img: NdarrayOrTensor): np.ndarray containing envelope of data in img along the specified axis. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) img_t, *_ = convert_data_type(img, torch.Tensor) # add one to transform axis because a batch axis will be added at dimension 0 hilbert_transform = HilbertTransform(self.axis + 1, self.n) @@ -1139,6 +1160,7 @@ def __init__(self, sigma: Union[Sequence[float], float] = 1.0, approx: str = "er self.approx = approx def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) img_t, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float) sigma: Union[Sequence[torch.Tensor], torch.Tensor] if isinstance(self.sigma, Sequence): @@ -1147,7 +1169,7 @@ def __call__(self, img: NdarrayTensor) -> NdarrayTensor: sigma = torch.as_tensor(self.sigma, device=img_t.device) gaussian_filter = GaussianFilter(img_t.ndim - 1, sigma, approx=self.approx) out_t: torch.Tensor = gaussian_filter(img_t.unsqueeze(0)).squeeze(0) - out, *_ = convert_data_type(out_t, type(img), device=img.device if isinstance(img, torch.Tensor) else None) + out, *_ = convert_to_dst_type(out_t, dst=img, dtype=out_t.dtype) return out @@ -1195,6 +1217,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.z = self.R.uniform(low=self.sigma_z[0], high=self.sigma_z[1]) def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -1247,6 +1270,7 @@ def __init__( self.approx = approx def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) img_t, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float32) gf1, gf2 = ( @@ -1256,7 +1280,7 @@ def __call__(self, img: NdarrayTensor) -> NdarrayTensor: blurred_f = gf1(img_t.unsqueeze(0)) filter_blurred_f = gf2(blurred_f) out_t: torch.Tensor = (blurred_f + self.alpha * (blurred_f - filter_blurred_f)).squeeze(0) - out, *_ = convert_data_type(out_t, type(img), device=img.device if isinstance(img, torch.Tensor) else None) + out, *_ = convert_to_dst_type(out_t, dst=img, dtype=out_t.dtype) return out @@ -1329,6 +1353,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.a = self.R.uniform(low=self.alpha[0], high=self.alpha[1]) def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -1384,8 +1409,8 @@ def interp(self, x: NdarrayOrTensor, xp: NdarrayOrTensor, fp: NdarrayOrTensor) - indices = ns.clip(indices, 0, len(m) - 1) f = (m[indices] * x.reshape(-1) + b[indices]).reshape(x.shape) - f[x < xp[0]] = fp[0] # type: ignore - f[x > xp[-1]] = fp[-1] # type: ignore + f[x < xp[0]] = fp[0] + f[x > xp[-1]] = fp[-1] return f def randomize(self, data: Optional[Any] = None) -> None: @@ -1401,6 +1426,7 @@ def randomize(self, data: Optional[Any] = None) -> None: ) def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -1409,14 +1435,14 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen if self.reference_control_points is None or self.floating_control_points is None: raise RuntimeError("please call the `randomize()` function first.") - - xp, *_ = convert_to_dst_type(self.reference_control_points, dst=img) - yp, *_ = convert_to_dst_type(self.floating_control_points, dst=img) - img_min, img_max = img.min(), img.max() + img_t = convert_to_tensor(img, track_meta=False) + xp, *_ = convert_to_dst_type(self.reference_control_points, dst=img_t) + yp, *_ = convert_to_dst_type(self.floating_control_points, dst=img_t) + img_min, img_max = img_t.min(), img_t.max() reference_control_points_scaled = xp * (img_max - img_min) + img_min floating_control_points_scaled = yp * (img_max - img_min) + img_min - img = self.interp(img, reference_control_points_scaled, floating_control_points_scaled) - return img + img_t = self.interp(img_t, reference_control_points_scaled, floating_control_points_scaled) + return convert_to_dst_type(img_t, dst=img)[0] class GibbsNoise(Transform, Fourier): @@ -1449,14 +1475,17 @@ def __init__(self, alpha: float = 0.1, as_tensor_output: bool = True) -> None: self.alpha = alpha def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: - n_dims = len(img.shape[1:]) + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = convert_to_tensor(img, track_meta=False) + n_dims = len(img_t.shape[1:]) # FT - k = self.shift_fourier(img, n_dims) + k = self.shift_fourier(img_t, n_dims) # build and apply mask k = self._apply_mask(k) # map back - img = self.inv_shift_fourier(k, n_dims) + out = self.inv_shift_fourier(k, n_dims) + img, *_ = convert_to_dst_type(out, dst=img, dtype=out.dtype) return img @@ -1542,6 +1571,7 @@ def randomize(self, data: Any) -> None: self.sampled_alpha = self.R.uniform(self.alpha[0], self.alpha[1]) def __call__(self, img: NdarrayOrTensor, randomize: bool = True): + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: # randomize application and possibly alpha self.randomize(None) @@ -1616,6 +1646,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: Args: img: image with dimensions (C, H, W) or (C, H, W, D) """ + img = convert_to_tensor(img, track_meta=get_track_meta()) # checking that tuples in loc are consistent with img size self._check_indices(img) @@ -1758,7 +1789,7 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True): raise RuntimeError( "If intensity_range is a sequence of sequences, then there must be one (low, high) tuple for each channel." ) - + img = convert_to_tensor(img, track_meta=get_track_meta()) self.sampled_k_intensity = [] self.sampled_locs = [] @@ -1893,6 +1924,7 @@ def _transform_holes(self, img: np.ndarray) -> np.ndarray: raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize(img.shape[1:]) @@ -2054,6 +2086,7 @@ def __init__( self.dtype = dtype def __call__(self, img: NdarrayOrTensor, mask: Optional[NdarrayOrTensor] = None) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) img_np, *_ = convert_data_type(img, np.ndarray) mask = mask if mask is not None else self.mask mask_np: Optional[np.ndarray] = None @@ -2088,10 +2121,6 @@ class IntensityRemap(RandomizableTransform): curve. slope: slope of the linear component. Easiest to leave default value and tune the kernel_size parameter instead. - return_map: set to True for the transform to return a dictionary version - of the lookup table used in the intensity remapping. The keys - correspond to the old intensities, and the values are the new - values. """ def __init__(self, kernel_size: int = 30, slope: float = 0.7): @@ -2106,10 +2135,10 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: Args: img: image to remap. """ - - img = img.clone() + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_ = convert_to_tensor(img, track_meta=False) # sample noise - vals_to_sample = torch.unique(img).tolist() + vals_to_sample = torch.unique(img_).tolist() noise = torch.from_numpy(self.R.choice(vals_to_sample, len(vals_to_sample) - 1 + self.kernel_size)) # smooth noise = torch.nn.AvgPool1d(self.kernel_size, stride=1)(noise.unsqueeze(0)).squeeze() @@ -2117,11 +2146,11 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: grid = torch.arange(len(noise)) / len(noise) noise += self.slope * grid # rescale - noise = (noise - noise.min()) / (noise.max() - noise.min()) * img.max() + img.min() + noise = (noise - noise.min()) / (noise.max() - noise.min()) * img_.max() + img_.min() # intensity remapping function - index_img = torch.bucketize(img, torch.tensor(vals_to_sample)) - img = noise[index_img] + index_img = torch.bucketize(img_, torch.tensor(vals_to_sample)) + img, *_ = convert_to_dst_type(noise[index_img], dst=img) return img @@ -2154,7 +2183,7 @@ def __init__(self, prob: float = 0.1, kernel_size: int = 30, slope: float = 0.7, RandomizableTransform.__init__(self, prob=prob) self.kernel_size = kernel_size self.slope = slope - self.channel_wise = True + self.channel_wise = channel_wise def __call__(self, img: torch.Tensor) -> torch.Tensor: """ @@ -2162,6 +2191,7 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: img: image to remap. """ super().randomize(None) + img = convert_to_tensor(img, track_meta=get_track_meta()) if self._do_transform: if self.channel_wise: img = torch.stack( @@ -2248,6 +2278,7 @@ def _get_threshold(self, image, mode): return threshold def __call__(self, image: NdarrayOrTensor): + image = convert_to_tensor(image, track_meta=get_track_meta()) img_rgb, *_ = convert_data_type(image, np.ndarray) if self.invert: img_rgb = skimage.util.invert(img_rgb) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 25cf261fe1..b9308255cf 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -21,6 +21,7 @@ from monai.config import DtypeLike, KeysCollection from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta from monai.transforms.intensity.array import ( AdjustContrast, ForegroundMask, @@ -55,7 +56,7 @@ ) from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import is_positive -from monai.utils import ensure_tuple, ensure_tuple_rep +from monai.utils import convert_to_tensor, ensure_tuple, ensure_tuple_rep from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import PostFix @@ -197,11 +198,15 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random noise first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d self.rand_gaussian_noise.randomize(d[first_key]) # type: ignore @@ -272,6 +277,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d for key in self.key_iterator(d): @@ -398,6 +405,8 @@ def __call__(self, data) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random shift factor @@ -494,6 +503,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random shift factor @@ -588,6 +599,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random scale factor @@ -641,11 +654,15 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random bias factor first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d self.rand_bias_field.randomize(img_size=d[first_key].shape[1:]) # type: ignore @@ -833,6 +850,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random gamma value @@ -1046,6 +1065,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random sigma @@ -1161,6 +1182,8 @@ def __call__(self, data: Dict[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Ndar d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random sigma1, sigma2, etc. @@ -1209,6 +1232,8 @@ def __call__(self, data: Dict[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Ndar d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random shift params @@ -1270,6 +1295,8 @@ def __call__(self, data: Dict[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Ndar d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # all the keys share the same random noise params @@ -1454,6 +1481,8 @@ def __call__(self, data: Dict[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Ndar d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d for key in self.key_iterator(d): @@ -1530,11 +1559,15 @@ def __call__(self, data): d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # expect all the specified keys have same spatial shape and share same random holes first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d self.dropper.randomize(d[first_key].shape[1:]) @@ -1599,11 +1632,15 @@ def __call__(self, data): d = dict(data) self.randomize(None) if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d # expect all the specified keys have same spatial shape and share same random holes first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) return d self.shuffle.randomize(d[first_key].shape[1:]) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 31359cd89a..52bfebcd76 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -37,11 +37,12 @@ PILReader, PydicomReader, ) +from monai.data.meta_tensor import MetaTensor from monai.transforms.transform import Transform from monai.transforms.utility.array import EnsureChannelFirst -from monai.utils import GridSampleMode, GridSamplePadMode +from monai.utils import GridSamplePadMode from monai.utils import ImageMetaKey as Key -from monai.utils import InterpolateMode, OptionalImportError, ensure_tuple, look_up_option, optional_import +from monai.utils import OptionalImportError, convert_to_dst_type, ensure_tuple, look_up_option, optional_import nib, _ = optional_import("nibabel") Image, _ = optional_import("PIL.Image") @@ -107,7 +108,7 @@ class LoadImage(Transform): def __init__( self, reader=None, - image_only: bool = False, + image_only: bool = True, dtype: DtypeLike = np.float32, ensure_channel_first: bool = False, *args, @@ -122,7 +123,7 @@ def __init__( ``"ITKReader"``, ``"NibabelReader"``, ``"NumpyReader"``, ``"PydicomReader"``. a reader instance will be constructed with the `*args` and `**kwargs` parameters. - if `reader` is a reader class/instance, it will be registered to this loader accordingly. - image_only: if True return only the image volume, otherwise return image data array and header dict. + image_only: if True return only the image MetaTensor, otherwise return image and header dict. dtype: if not None convert the loaded image to this data type. ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert the image array shape to `channel first`. default to `False`. @@ -131,8 +132,8 @@ def __init__( Note: - - The transform returns an image data array if `image_only` is True, - or a tuple of two elements containing the data array, and the metadata in a dictionary format otherwise. + - The transform returns a MetaTensor, unless `set_track_meta(False)` has been used, in which case, a + `torch.Tensor` will be returned. - If `reader` is specified, the loader will attempt to use the specified readers and the default supported readers. This might introduce overheads when handling the exceptions of trying the incompatible loaders. In this case, it is therefore recommended setting the most appropriate reader as @@ -247,19 +248,19 @@ def __call__(self, filename: Union[Sequence[PathLike], PathLike], reader: Option img_array: NdarrayOrTensor img_array, meta_data = reader.get_data(img) - img_array = img_array.astype(self.dtype, copy=False) + img_array = convert_to_dst_type(img_array, dst=img_array, dtype=self.dtype)[0] if not isinstance(meta_data, dict): raise ValueError("`meta_data` must be a dict.") # make sure all elements in metadata are little endian meta_data = switch_endianness(meta_data, "<") - if self.ensure_channel_first: - img_array = EnsureChannelFirst()(img_array, meta_data) - if self.image_only: - return img_array meta_data[Key.FILENAME_OR_OBJ] = f"{ensure_tuple(filename)[0]}" # Path obj should be strings for data loader - - return img_array, meta_data + img = MetaTensor.ensure_torch_and_prune_meta(img_array, meta_data) + if self.ensure_channel_first: + img = EnsureChannelFirst()(img) + if self.image_only: + return img + return img, img.meta # for compatibility purpose class SaveImage(Transform): @@ -330,8 +331,8 @@ def __init__( output_ext: str = ".nii.gz", output_dtype: DtypeLike = np.float32, resample: bool = True, - mode: Union[GridSampleMode, InterpolateMode, str] = "nearest", - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = "nearest", + padding_mode: str = GridSamplePadMode.BORDER, scale: Optional[int] = None, dtype: DtypeLike = np.float64, squeeze_end_dims: bool = True, @@ -358,7 +359,7 @@ def __init__( writer_ = locate(f"{writer}") # search dotted path if writer_ is None: raise ValueError(f"writer {writer} not found") - writer = writer_ # type: ignore + writer = writer_ self.writers = image_writer.resolve_writer(self.output_ext) if writer is None else (writer,) self.writer_obj = None @@ -400,6 +401,7 @@ def __call__(self, img: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dic img: target data content that save into file. The image should be channel-first, shape: `[C,H,W,[D]]`. meta_data: key-value pairs of metadata corresponding to the data. """ + meta_data = img.meta if isinstance(img, MetaTensor) else meta_data subject = meta_data[Key.FILENAME_OR_OBJ] if meta_data else str(self._data_index) patch_index = meta_data.get(Key.PATCH_INDEX, None) if meta_data else None filename = self.folder_layout.filename(subject=f"{subject}", idx=patch_index) diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index d0e2726df0..c166b44956 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -25,7 +25,7 @@ from monai.data.image_reader import ImageReader from monai.transforms.io.array import LoadImage, SaveImage from monai.transforms.transform import MapTransform -from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, ensure_tuple, ensure_tuple_rep +from monai.utils import GridSamplePadMode, ensure_tuple, ensure_tuple_rep from monai.utils.enums import PostFix __all__ = ["LoadImaged", "LoadImageD", "LoadImageDict", "SaveImaged", "SaveImageD", "SaveImageDict"] @@ -72,7 +72,7 @@ def __init__( meta_keys: Optional[KeysCollection] = None, meta_key_postfix: str = DEFAULT_POST_FIX, overwriting: bool = False, - image_only: bool = False, + image_only: bool = True, ensure_channel_first: bool = False, allow_missing_keys: bool = False, *args, @@ -130,8 +130,6 @@ def __call__(self, data, reader: Optional[ImageReader] = None): for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): data = self._loader(d[key], reader) if self._loader.image_only: - if not isinstance(data, np.ndarray): - raise ValueError("loader must return a numpy array (because image_only=True was used).") d[key] = data else: if not isinstance(data, (tuple, list)): @@ -226,8 +224,8 @@ def __init__( output_postfix: str = "trans", output_ext: str = ".nii.gz", resample: bool = True, - mode: Union[GridSampleMode, InterpolateMode, str] = "nearest", - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = "nearest", + padding_mode: str = GridSamplePadMode.BORDER, scale: Optional[int] = None, dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, @@ -268,7 +266,7 @@ def __call__(self, data): for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): if meta_key is None and meta_key_postfix is not None: meta_key = f"{key}_{meta_key_postfix}" - meta_data = d[meta_key] if meta_key is not None else None + meta_data = d.get(meta_key) if meta_key is not None else None self.saver(img=d[key], meta_data=meta_data) return d diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 6396435aa7..29aa39d7ac 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -20,12 +20,20 @@ import torch from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta from monai.networks import one_hot from monai.networks.layers import GaussianFilter, apply_filter from monai.transforms.transform import Transform from monai.transforms.utils import fill_holes, get_largest_connected_component_mask, get_unique_labels from monai.transforms.utils_pytorch_numpy_unification import unravel_index -from monai.utils import TransformBackends, convert_data_type, deprecated_arg, ensure_tuple, look_up_option +from monai.utils import ( + TransformBackends, + convert_data_type, + convert_to_tensor, + deprecated_arg, + ensure_tuple, + look_up_option, +) from monai.utils.type_conversion import convert_to_dst_type __all__ = [ @@ -95,6 +103,7 @@ def __call__( raise TypeError(f"other must be None or callable but is {type(other).__name__}.") # convert to float as activation must operate on float tensor + img = convert_to_tensor(img, track_meta=get_track_meta()) img_t, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float) if sigmoid or self.sigmoid: img_t = torch.sigmoid(img_t) @@ -230,7 +239,7 @@ def __call__( if isinstance(threshold, bool): warnings.warn("`threshold_values=True/False` is deprecated, please use `threshold=value` instead.") threshold = logit_thresh if threshold else None - + img = convert_to_tensor(img, track_meta=get_track_meta()) img_t, *_ = convert_data_type(img, torch.Tensor) if argmax or self.argmax: img_t = torch.argmax(img_t, dim=0, keepdim=True) @@ -344,28 +353,29 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: applied_labels = self.applied_labels else: applied_labels = tuple(get_unique_labels(img, is_onehot, discard=0)) - + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_: torch.Tensor = convert_to_tensor(img, track_meta=False) if self.independent: for i in applied_labels: - foreground = img[i] > 0 if is_onehot else img[0] == i + foreground = img_[i] > 0 if is_onehot else img_[0] == i mask = get_largest_connected_component_mask(foreground, self.connectivity) if is_onehot: - img[i][foreground != mask] = 0 + img_[i][foreground != mask] = 0 else: - img[0][foreground != mask] = 0 - return img + img_[0][foreground != mask] = 0 + return convert_to_dst_type(img_, dst=img)[0] if not is_onehot: # not one-hot, union of labels - labels, *_ = convert_to_dst_type(applied_labels, dst=img, wrap_sequence=True) - foreground = (img[..., None] == labels).any(-1)[0] + labels, *_ = convert_to_dst_type(applied_labels, dst=img_, wrap_sequence=True) + foreground = (img_[..., None] == labels).any(-1)[0] mask = get_largest_connected_component_mask(foreground, self.connectivity) - img[0][foreground != mask] = 0 - return img + img_[0][foreground != mask] = 0 + return convert_to_dst_type(img_, dst=img)[0] # one-hot, union of labels - foreground = (img[applied_labels, ...] == 1).any(0) + foreground = (img_[applied_labels, ...] == 1).any(0) mask = get_largest_connected_component_mask(foreground, self.connectivity) for i in applied_labels: - img[i][foreground != mask] = 0 - return img + img_[i][foreground != mask] = 0 + return convert_to_dst_type(img_, dst=img)[0] class LabelFilter: @@ -414,13 +424,15 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") if isinstance(img, torch.Tensor): + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_ = convert_to_tensor(img, track_meta=False) if hasattr(torch, "isin"): # `isin` is new in torch 1.10.0 - appl_lbls = torch.as_tensor(self.applied_labels, device=img.device) - return torch.where(torch.isin(img, appl_lbls), img, torch.tensor(0.0).to(img)) - else: - out = self(img.detach().cpu().numpy()) - out, *_ = convert_to_dst_type(out, img) - return out + appl_lbls = torch.as_tensor(self.applied_labels, device=img_.device) + out = torch.where(torch.isin(img_, appl_lbls), img_, torch.tensor(0.0).to(img_)) + return convert_to_dst_type(out, dst=img)[0] + out: NdarrayOrTensor = self(img_.detach().cpu().numpy()) # type: ignore + out = convert_to_dst_type(out, img)[0] # type: ignore + return out return np.asarray(np.where(np.isin(img, self.applied_labels), img, 0)) @@ -497,8 +509,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: Returns: Pytorch Tensor or numpy array of shape [C, spatial_dim1[, spatial_dim2, ...]]. """ - if not isinstance(img, (np.ndarray, torch.Tensor)): - raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") + img = convert_to_tensor(img, track_meta=get_track_meta()) img_np, *_ = convert_data_type(img, np.ndarray) out_np: np.ndarray = fill_holes(img_np, self.applied_labels, self.connectivity) out, *_ = convert_to_dst_type(out_np, img) @@ -541,7 +552,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: ideally the edge should be thin enough, but now it has a thickness. """ - img_: torch.Tensor = convert_data_type(img, torch.Tensor)[0] + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_: torch.Tensor = convert_to_tensor(img, track_meta=False) spatial_dims = len(img_.shape) - 1 img_ = img_.unsqueeze(0) # adds a batch dim if spatial_dims == 2: @@ -733,7 +745,7 @@ def __call__(self, prob_map: NdarrayOrTensor): if self.sigma != 0: if not isinstance(prob_map, torch.Tensor): prob_map = torch.as_tensor(prob_map, dtype=torch.float) - self.filter.to(prob_map) + self.filter.to(prob_map.device) prob_map = self.filter(prob_map) prob_map_shape = prob_map.shape diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 6625a9d791..3704d92ec3 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -23,6 +23,7 @@ from monai.config.type_definitions import KeysCollection, NdarrayOrTensor, PathLike from monai.data.csv_saver import CSVSaver +from monai.data.meta_tensor import MetaTensor from monai.transforms.inverse import InvertibleTransform from monai.transforms.post.array import ( Activations, @@ -37,8 +38,8 @@ ) from monai.transforms.transform import MapTransform from monai.transforms.utility.array import ToTensor -from monai.transforms.utils import allow_missing_keys_mode, convert_inverse_interp_mode -from monai.utils import deprecated_arg, ensure_tuple, ensure_tuple_rep +from monai.transforms.utils import allow_missing_keys_mode, convert_applied_interp_mode +from monai.utils import convert_to_tensor, deprecated_arg, ensure_tuple, ensure_tuple_rep from monai.utils.enums import PostFix __all__ = [ @@ -160,7 +161,7 @@ def __init__( it also can be a sequence of bool, each element corresponds to a key in ``keys``. to_onehot: if not None, convert input data into the one-hot format with specified number of classes. defaults to ``None``. it also can be a sequence, each element corresponds to a key in ``keys``. - threshold: if not None, threshold the float values to int number 0 or 1 with specified theashold value. + threshold: if not None, threshold the float values to int number 0 or 1 with specified threshold value. defaults to ``None``. it also can be a sequence, each element corresponds to a key in ``keys``. rounding: if not None, round the data according to the specified option, available options: ["torchrounding"]. it also can be a sequence of str or None, @@ -543,7 +544,7 @@ def __init__( self, keys: KeysCollection, transform: InvertibleTransform, - orig_keys: KeysCollection, + orig_keys: Optional[KeysCollection] = None, meta_keys: Optional[KeysCollection] = None, orig_meta_keys: Optional[KeysCollection] = None, meta_key_postfix: str = DEFAULT_POST_FIX, @@ -558,7 +559,7 @@ def __init__( keys: the key of expected data in the dict, the inverse of ``transforms`` will be applied on it in-place. It also can be a list of keys, will apply the inverse transform respectively. transform: the transform applied to ``orig_key``, its inverse will be applied on ``key``. - orig_keys: the key of the original input data in the dict. + orig_keys: the key of the original input data in the dict. These keys default to `self.keys` if not set. the transform trace information of ``transforms`` should be stored at ``{orig_keys}_transforms``. It can also be a list of keys, each matches the ``keys``. meta_keys: The key to output the inverted metadata dictionary. @@ -588,7 +589,7 @@ def __init__( if not isinstance(transform, InvertibleTransform): raise ValueError("transform is not invertible, can't invert transform for the data.") self.transform = transform - self.orig_keys = ensure_tuple_rep(orig_keys, len(self.keys)) + self.orig_keys = ensure_tuple_rep(orig_keys, len(self.keys)) if orig_keys is not None else self.keys self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) if len(self.keys) != len(self.meta_keys): raise ValueError("meta_keys should have the same length as keys.") @@ -623,38 +624,53 @@ def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: self.device, self.post_func, ): - transform_key = InvertibleTransform.trace_key(orig_key) - if transform_key not in d: - warnings.warn(f"transform info of `{orig_key}` is not available or no InvertibleTransform applied.") - continue - - transform_info = d[transform_key] + if isinstance(d[key], MetaTensor): + if orig_key not in d: + warnings.warn(f"transform info of `{orig_key}` is not available in MetaTensor {key}.") + continue + else: + transform_key = InvertibleTransform.trace_key(orig_key) + if transform_key not in d: + warnings.warn(f"transform info of `{orig_key}` is not available or no InvertibleTransform applied.") + continue + + if orig_key in d and isinstance(d[orig_key], MetaTensor): + transform_info = d[orig_key].applied_operations + meta_info = d[orig_key].meta + else: + transform_info = d[InvertibleTransform.trace_key(orig_key)] + meta_info = d.get(orig_meta_key or f"{orig_key}_{meta_key_postfix}", {}) if nearest_interp: - transform_info = convert_inverse_interp_mode( + transform_info = convert_applied_interp_mode( trans_info=deepcopy(transform_info), mode="nearest", align_corners=None ) - input = d[key] - if isinstance(input, torch.Tensor): - input = input.detach() + inputs = d[key] + if isinstance(inputs, torch.Tensor): + inputs = inputs.detach() + + if not isinstance(inputs, MetaTensor): + inputs = convert_to_tensor(inputs, track_meta=True) + inputs.applied_operations = transform_info + inputs.meta = meta_info # construct the input dict data - input_dict = {orig_key: input, transform_key: transform_info} - orig_meta_key = orig_meta_key or f"{orig_key}_{meta_key_postfix}" - if orig_meta_key in d: - input_dict[orig_meta_key] = d[orig_meta_key] + input_dict = {orig_key: inputs} with allow_missing_keys_mode(self.transform): # type: ignore inverted = self.transform.inverse(input_dict) # save the inverted data - d[key] = post_func(self._totensor(inverted[orig_key]).to(device) if to_tensor else inverted[orig_key]) + if to_tensor and not isinstance(inverted[orig_key], MetaTensor): + inverted_data = self._totensor(inverted[orig_key]) + else: + inverted_data = inverted[orig_key] + d[key] = post_func(inverted_data.to(device)) # save the inverted meta dict if orig_meta_key in d: meta_key = meta_key or f"{key}_{meta_key_postfix}" d[meta_key] = inverted.get(orig_meta_key) - return d diff --git a/monai/transforms/smooth_field/array.py b/monai/transforms/smooth_field/array.py index f581687ea5..953c589288 100644 --- a/monai/transforms/smooth_field/array.py +++ b/monai/transforms/smooth_field/array.py @@ -17,8 +17,8 @@ import torch from torch.nn.functional import grid_sample, interpolate -import monai from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta from monai.networks.utils import meshgrid_ij from monai.transforms.transform import Randomizable, RandomizableTransform from monai.transforms.utils_pytorch_numpy_unification import moveaxis @@ -61,7 +61,7 @@ def __init__( high: float = 1.0, channels: int = 1, spatial_size: Optional[Sequence[int]] = None, - mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + mode: str = InterpolateMode.AREA, align_corners: Optional[bool] = None, device: Optional[torch.device] = None, ): @@ -109,7 +109,7 @@ def set_spatial_size(self, spatial_size: Optional[Sequence[int]]) -> None: self.spatial_size = tuple(spatial_size) self.spatial_zoom = tuple(s / f for s, f in zip(self.spatial_size, self.total_rand_size)) - def set_mode(self, mode: Union[monai.utils.InterpolateMode, str]) -> None: + def set_mode(self, mode: str) -> None: self.mode = mode def __call__(self, randomize=False) -> torch.Tensor: @@ -119,10 +119,10 @@ def __call__(self, randomize=False) -> torch.Tensor: field = self.field.clone() if self.spatial_zoom is not None: - resized_field = interpolate( # type: ignore + resized_field = interpolate( input=field, scale_factor=self.spatial_zoom, - mode=look_up_option(self.mode, InterpolateMode).value, + mode=look_up_option(self.mode, InterpolateMode), align_corners=self.align_corners, recompute_scale_factor=False, ) @@ -147,7 +147,7 @@ class RandSmoothFieldAdjustContrast(RandomizableTransform): edges of the input volume of that width will be mostly unchanged. Contrast is changed by raising input values by the power of the smooth field so the range of values given by `gamma` should be chosen with this in mind. For example, a minimum value of 0 in `gamma` will produce white areas so this should be avoided. - Afte the contrast is adjusted the values of the result are rescaled to the range of the original input. + After the contrast is adjusted the values of the result are rescaled to the range of the original input. Args: spatial_size: size of input array's spatial dimensions @@ -167,7 +167,7 @@ def __init__( spatial_size: Sequence[int], rand_size: Sequence[int], pad: int = 0, - mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + mode: str = InterpolateMode.AREA, align_corners: Optional[bool] = None, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0.5, 4.5), @@ -209,13 +209,14 @@ def randomize(self, data: Optional[Any] = None) -> None: if self._do_transform: self.sfield.randomize() - def set_mode(self, mode: Union[monai.utils.InterpolateMode, str]) -> None: + def set_mode(self, mode: str) -> None: self.sfield.set_mode(mode) def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: """ Apply the transform to `img`, if `randomize` randomizing the smooth field otherwise reusing the previous. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -267,7 +268,7 @@ def __init__( spatial_size: Sequence[int], rand_size: Sequence[int], pad: int = 0, - mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + mode: str = InterpolateMode.AREA, align_corners: Optional[bool] = None, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0.1, 1.0), @@ -309,13 +310,14 @@ def randomize(self, data: Optional[Any] = None) -> None: if self._do_transform: self.sfield.randomize() - def set_mode(self, mode: Union[InterpolateMode, str]) -> None: + def set_mode(self, mode: str) -> None: self.sfield.set_mode(mode) def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: """ Apply the transform to `img`, if `randomize` randomizing the smooth field otherwise reusing the previous. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -363,13 +365,13 @@ def __init__( spatial_size: Sequence[int], rand_size: Sequence[int], pad: int = 0, - field_mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + field_mode: str = InterpolateMode.AREA, align_corners: Optional[bool] = None, prob: float = 0.1, def_range: Union[Sequence[float], float] = 1.0, grid_dtype=torch.float32, - grid_mode: Union[GridSampleMode, str] = GridSampleMode.NEAREST, - grid_padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + grid_mode: str = GridSampleMode.NEAREST, + grid_padding_mode: str = GridSamplePadMode.BORDER, grid_align_corners: Optional[bool] = False, device: Optional[torch.device] = None, ): @@ -422,15 +424,16 @@ def randomize(self, data: Optional[Any] = None) -> None: if self._do_transform: self.sfield.randomize() - def set_field_mode(self, mode: Union[monai.utils.InterpolateMode, str]) -> None: + def set_field_mode(self, mode: str) -> None: self.sfield.set_mode(mode) - def set_grid_mode(self, mode: Union[monai.utils.GridSampleMode, str]) -> None: + def set_grid_mode(self, mode: str) -> None: self.grid_mode = mode def __call__( self, img: NdarrayOrTensor, randomize: bool = True, device: Optional[torch.device] = None ) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) if randomize: self.randomize() @@ -449,9 +452,9 @@ def __call__( out = grid_sample( input=img_t, grid=dgrid, - mode=look_up_option(self.grid_mode, GridSampleMode).value, + mode=look_up_option(self.grid_mode, GridSampleMode), align_corners=self.grid_align_corners, - padding_mode=look_up_option(self.grid_padding_mode, GridSamplePadMode).value, + padding_mode=look_up_option(self.grid_padding_mode, GridSamplePadMode), ) out_t, *_ = convert_to_dst_type(out.squeeze(0), img) diff --git a/monai/transforms/smooth_field/dictionary.py b/monai/transforms/smooth_field/dictionary.py index 24890140cc..48e00b9e4a 100644 --- a/monai/transforms/smooth_field/dictionary.py +++ b/monai/transforms/smooth_field/dictionary.py @@ -15,15 +15,16 @@ import numpy as np import torch -from monai.config import KeysCollection +from monai.config import KeysCollection, SequenceStr from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta from monai.transforms.smooth_field.array import ( RandSmoothDeform, RandSmoothFieldAdjustContrast, RandSmoothFieldAdjustIntensity, ) from monai.transforms.transform import MapTransform, RandomizableTransform -from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, ensure_tuple_rep +from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, convert_to_tensor, ensure_tuple_rep from monai.utils.enums import TransformBackends __all__ = [ @@ -39,10 +40,6 @@ ] -InterpolateModeType = Union[InterpolateMode, str] -GridSampleModeType = Union[GridSampleMode, str] - - class RandSmoothFieldAdjustContrastd(RandomizableTransform, MapTransform): """ Dictionary version of RandSmoothFieldAdjustContrast. @@ -71,7 +68,7 @@ def __init__( spatial_size: Sequence[int], rand_size: Sequence[int], pad: int = 0, - mode: Union[InterpolateModeType, Sequence[InterpolateModeType]] = InterpolateMode.AREA, + mode: SequenceStr = InterpolateMode.AREA, align_corners: Optional[bool] = None, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0.5, 4.5), @@ -108,11 +105,11 @@ def randomize(self, data: Optional[Any] = None) -> None: def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: self.randomize() - - if not self._do_transform: - return data - d = dict(data) + if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + return d for idx, key in enumerate(self.key_iterator(d)): self.trans.set_mode(self.mode[idx % len(self.mode)]) @@ -149,7 +146,7 @@ def __init__( spatial_size: Sequence[int], rand_size: Sequence[int], pad: int = 0, - mode: Union[InterpolateModeType, Sequence[InterpolateModeType]] = InterpolateMode.AREA, + mode: SequenceStr = InterpolateMode.AREA, align_corners: Optional[bool] = None, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0.1, 1.0), @@ -185,10 +182,11 @@ def randomize(self, data: Optional[Any] = None) -> None: def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: self.randomize() - if not self._do_transform: - return data - d = dict(data) + if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + return d for idx, key in enumerate(self.key_iterator(d)): self.trans.set_mode(self.mode[idx % len(self.mode)]) @@ -229,13 +227,13 @@ def __init__( spatial_size: Sequence[int], rand_size: Sequence[int], pad: int = 0, - field_mode: Union[InterpolateModeType, Sequence[InterpolateModeType]] = InterpolateMode.AREA, + field_mode: SequenceStr = InterpolateMode.AREA, align_corners: Optional[bool] = None, prob: float = 0.1, def_range: Union[Sequence[float], float] = 1.0, grid_dtype=torch.float32, - grid_mode: Union[GridSampleModeType, Sequence[GridSampleModeType]] = GridSampleMode.NEAREST, - grid_padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + grid_mode: SequenceStr = GridSampleMode.NEAREST, + grid_padding_mode: str = GridSamplePadMode.BORDER, grid_align_corners: Optional[bool] = False, device: Optional[torch.device] = None, ): @@ -274,10 +272,11 @@ def randomize(self, data: Optional[Any] = None) -> None: def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: self.randomize() - if not self._do_transform: - return data - d = dict(data) + if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + return d for idx, key in enumerate(self.key_iterator(d)): self.trans.set_field_mode(self.field_mode[idx % len(self.field_mode)]) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 83792d49a7..0a3b7779ef 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -14,7 +14,8 @@ """ import warnings from copy import deepcopy -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from enum import Enum +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -22,19 +23,15 @@ from monai.config import USE_COMPILED, DtypeLike from monai.config.type_definitions import NdarrayOrTensor -from monai.data.utils import ( - AFFINE_TOL, - compute_shape_offset, - iter_patch, - reorient_spatial_axes, - to_affine_nd, - zoom_affine, -) +from monai.data.meta_obj import get_track_meta +from monai.data.meta_tensor import MetaTensor +from monai.data.utils import AFFINE_TOL, compute_shape_offset, iter_patch, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.networks.utils import meshgrid_ij, normalize_transform -from monai.transforms.croppad.array import CenterSpatialCrop, Pad +from monai.transforms.croppad.array import CenterSpatialCrop, ResizeWithPadOrCrop from monai.transforms.intensity.array import GaussianSmooth -from monai.transforms.transform import Randomizable, RandomizableTransform, ThreadUnsafe, Transform +from monai.transforms.inverse import InvertibleTransform +from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( convert_pad_mode, create_control_grid, @@ -44,15 +41,16 @@ create_shear, create_translate, map_spatial_axes, + scale_affine, ) -from monai.transforms.utils_pytorch_numpy_unification import allclose, moveaxis +from monai.transforms.utils_pytorch_numpy_unification import allclose, linalg_inv, moveaxis from monai.utils import ( GridSampleMode, GridSamplePadMode, InterpolateMode, NumpyPadMode, - PytorchPadMode, convert_to_dst_type, + convert_to_tensor, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, @@ -62,10 +60,10 @@ pytorch_after, ) from monai.utils.deprecate_utils import deprecated_arg -from monai.utils.enums import GridPatchSort, TransformBackends +from monai.utils.enums import GridPatchSort, PytorchPadMode, TraceKeys, TransformBackends from monai.utils.misc import ImageMetaKey as Key from monai.utils.module import look_up_option -from monai.utils.type_conversion import convert_data_type +from monai.utils.type_conversion import convert_data_type, get_equivalent_dtype, get_torch_dtype_from_string nib, has_nib = optional_import("nibabel") @@ -102,7 +100,7 @@ RandRange = Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] -class SpatialResample(Transform): +class SpatialResample(InvertibleTransform): """ Resample input image from the orientation/spacing defined by ``src_affine`` affine matrix into the ones specified by ``dst_affine`` affine matrix. @@ -115,8 +113,8 @@ class SpatialResample(Transform): def __init__( self, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: DtypeLike = np.float64, ): @@ -142,24 +140,62 @@ def __init__( self.align_corners = align_corners self.dtype = dtype + def _post_process( + self, + img: torch.Tensor, + src_affine: torch.Tensor, + dst_affine: torch.Tensor, + mode, + padding_mode, + align_corners, + original_spatial_shape, + ) -> torch.Tensor: + """ + Small fn to simplify returning data. If `MetaTensor`, update affine. Elif + tracking metadata is desired, create `MetaTensor` with affine. Else, return + image as `torch.Tensor`. Output type is always `torch.float32`. + + Also append the transform to the stack. + """ + dtype = img.dtype + img = convert_to_tensor(img, track_meta=get_track_meta(), dtype=torch.float32) + if get_track_meta(): + self.update_meta(img, dst_affine) + self.push_transform( + img, + extra_info={ + "dtype": str(dtype)[6:], # dtype as string; remove "torch": torch.float32 -> float32 + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, + "src_affine": src_affine, + }, + orig_size=original_spatial_shape, + ) + return img + + def update_meta(self, img, dst_affine): + img.affine = dst_affine + + @deprecated_arg( + name="src_affine", since="0.9", msg_suffix="img should be `MetaTensor`, so affine can be extracted directly." + ) def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, src_affine: Optional[NdarrayOrTensor] = None, - dst_affine: Optional[NdarrayOrTensor] = None, - spatial_size: Optional[Union[Sequence[int], np.ndarray, int]] = None, - mode: Union[GridSampleMode, str, None] = None, - padding_mode: Union[GridSamplePadMode, str, None] = None, + dst_affine: Optional[torch.Tensor] = None, + spatial_size: Optional[Union[Sequence[int], torch.Tensor, int]] = None, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = False, dtype: DtypeLike = None, - ) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: + ) -> torch.Tensor: """ Args: img: input image to be resampled. It currently supports channel-first arrays with at most three spatial dimensions. - src_affine: source affine matrix. Defaults to ``None``, which means the identity matrix. - the shape should be `(r+1, r+1)` where `r` is the spatial rank of ``img``. - dst_affine: destination affine matrix. Defaults to ``None``, which means the same as `src_affine`. + dst_affine: destination affine matrix. Defaults to ``None``, which means the same as `img.affine`. the shape should be `(r+1, r+1)` where `r` is the spatial rank of ``img``. when `dst_affine` and `spatial_size` are None, the input will be returned without resampling, but the data type will be `float32`. @@ -188,85 +224,77 @@ def __call__( MONAI's resampling implementation will be used. Set `dst_affine` and `spatial_size` to `None` to turn off the resampling step. """ - if src_affine is None: - src_affine = np.eye(4, dtype=np.float64) - spatial_rank = min(len(img.shape) - 1, src_affine.shape[0] - 1, 3) + # get dtype as torch (e.g., torch.float64) + _dtype = get_equivalent_dtype(dtype or self.dtype or img.dtype, torch.Tensor) + align_corners = self.align_corners if align_corners is None else align_corners + mode = mode or self.mode + padding_mode = padding_mode or self.padding_mode + original_spatial_shape = img.shape[1:] + + src_affine_: torch.Tensor = img.affine if isinstance(img, MetaTensor) else torch.eye(4) + img = convert_to_tensor(data=img, track_meta=get_track_meta(), dtype=_dtype) + spatial_rank = min(len(img.shape) - 1, src_affine_.shape[0] - 1, 3) if (not isinstance(spatial_size, int) or spatial_size != -1) and spatial_size is not None: spatial_rank = min(len(ensure_tuple(spatial_size)), 3) # infer spatial rank based on spatial_size - src_affine = to_affine_nd(spatial_rank, src_affine) - dst_affine = to_affine_nd(spatial_rank, dst_affine) if dst_affine is not None else src_affine - dst_affine, *_ = convert_to_dst_type(dst_affine, dst_affine, dtype=torch.float32) + src_affine_ = to_affine_nd(spatial_rank, src_affine_).to(_dtype) + dst_affine = to_affine_nd(spatial_rank, dst_affine) if dst_affine is not None else src_affine_ + dst_affine = convert_to_dst_type(dst_affine, src_affine_)[0] + if not isinstance(dst_affine, torch.Tensor): + raise ValueError(f"dst_affine should be a torch.Tensor, got {type(dst_affine)}") - in_spatial_size = np.asarray(img.shape[1 : spatial_rank + 1]) + in_spatial_size = torch.tensor(img.shape[1 : spatial_rank + 1]) if isinstance(spatial_size, int) and (spatial_size == -1): # using the input spatial size spatial_size = in_spatial_size elif spatial_size is None and spatial_rank > 1: # auto spatial size - spatial_size, _ = compute_shape_offset(in_spatial_size, src_affine, dst_affine) # type: ignore - spatial_size = np.asarray(fall_back_tuple(ensure_tuple(spatial_size)[:spatial_rank], in_spatial_size)) + spatial_size, _ = compute_shape_offset(in_spatial_size, src_affine_, dst_affine) # type: ignore + spatial_size = torch.tensor(fall_back_tuple(ensure_tuple(spatial_size)[:spatial_rank], in_spatial_size)) if ( - allclose(src_affine, dst_affine, atol=AFFINE_TOL) + allclose(src_affine_, dst_affine, atol=AFFINE_TOL) and allclose(spatial_size, in_spatial_size) or spatial_rank == 1 ): # no significant change, return original image - output_data, *_ = convert_to_dst_type(img, img, dtype=torch.float32) - return output_data, dst_affine - - if has_nib and isinstance(img, np.ndarray): - spatial_ornt, dst_r = reorient_spatial_axes(img.shape[1 : spatial_rank + 1], src_affine, dst_affine) - if allclose(dst_r, dst_affine, atol=AFFINE_TOL) and allclose(spatial_size, in_spatial_size): - # simple reorientation achieves the desired affine - spatial_ornt[:, 0] += 1 - spatial_ornt = np.concatenate([np.array([[0, 1]]), spatial_ornt]) - img_ = nib.orientations.apply_orientation(img, spatial_ornt) - output_data, *_ = convert_to_dst_type(img_, img, dtype=torch.float32) - return output_data, dst_affine + return self._post_process( + img, src_affine_, src_affine_, mode, padding_mode, align_corners, original_spatial_shape + ) try: - src_affine, *_ = convert_to_dst_type(src_affine, dst_affine) - if isinstance(src_affine, np.ndarray): - xform = np.linalg.solve(src_affine, dst_affine) - else: - xform = ( - torch.linalg.solve(src_affine, dst_affine) - if pytorch_after(1, 8, 0) - else torch.solve(dst_affine, src_affine).solution # type: ignore - ) + _s = convert_to_tensor(src_affine_, track_meta=False, device=torch.device("cpu")) + _d = convert_to_tensor(dst_affine, track_meta=False, device=torch.device("cpu")) + xform = ( + torch.linalg.solve(_s, _d) if pytorch_after(1, 8, 0) else torch.solve(_d, _s).solution # type: ignore + ) except (np.linalg.LinAlgError, RuntimeError) as e: raise ValueError("src affine is not invertible.") from e - xform = to_affine_nd(spatial_rank, xform) + xform = to_affine_nd(spatial_rank, xform).to(device=img.device, dtype=_dtype) # no resampling if it's identity transform - if allclose(xform, np.diag(np.ones(len(xform))), atol=AFFINE_TOL) and allclose(spatial_size, in_spatial_size): - output_data, *_ = convert_to_dst_type(img, img, dtype=torch.float32) - return output_data, dst_affine + if allclose(xform, torch.eye(len(xform)), atol=AFFINE_TOL) and allclose(spatial_size, in_spatial_size): + return self._post_process( + img, src_affine_, src_affine_, mode, padding_mode, align_corners, original_spatial_shape + ) - _dtype = dtype or self.dtype or img.dtype - in_spatial_size = in_spatial_size.tolist() + in_spatial_size = in_spatial_size.tolist() # type: ignore chns, additional_dims = img.shape[0], img.shape[spatial_rank + 1 :] # beyond three spatial dims - # resample - img_ = convert_data_type(img, torch.Tensor, dtype=_dtype)[0] - xform = convert_to_dst_type(xform, img_)[0] - align_corners = self.align_corners if align_corners is None else align_corners - mode = mode or self.mode - padding_mode = padding_mode or self.padding_mode + if additional_dims: xform_shape = [-1] + in_spatial_size - img_ = img_.reshape(xform_shape) + img = img.reshape(xform_shape) # type: ignore if align_corners: - _t_r = torch.diag(torch.ones(len(xform), dtype=xform.dtype, device=xform.device)) # type: ignore + _t_r = torch.eye(len(xform), dtype=xform.dtype, device=xform.device) for idx, d_dst in enumerate(spatial_size[:spatial_rank]): _t_r[idx, -1] = (max(d_dst, 2) - 1.0) / 2.0 xform = xform @ _t_r if not USE_COMPILED: _t_l = normalize_transform( in_spatial_size, xform.device, xform.dtype, align_corners=True # type: ignore - ) - xform = _t_l @ xform # type: ignore + )[0] + xform = _t_l @ xform affine_xform = Affine( affine=xform, spatial_size=spatial_size, normalized=True, image_only=True, dtype=_dtype ) - output_data = affine_xform(img_, mode=mode, padding_mode=padding_mode) + with affine_xform.trace_transform(False): + img = affine_xform(img, mode=mode, padding_mode=padding_mode) else: affine_xform = AffineTransform( normalized=False, @@ -275,29 +303,61 @@ def __call__( align_corners=align_corners, reverse_indexing=True, ) - output_data = affine_xform(img_.unsqueeze(0), theta=xform, spatial_size=spatial_size).squeeze(0) + img = affine_xform(img.unsqueeze(0), theta=xform, spatial_size=spatial_size).squeeze(0) if additional_dims: full_shape = (chns, *spatial_size, *additional_dims) - output_data = output_data.reshape(full_shape) - # output dtype float - output_data, *_ = convert_to_dst_type(output_data, img, dtype=torch.float32) - return output_data, dst_affine + img = img.reshape(full_shape) + + return self._post_process( + img, src_affine_, dst_affine, mode, padding_mode, align_corners, original_spatial_shape + ) + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + # Create inverse transform + kw_args = transform[TraceKeys.EXTRA_INFO] + # need to convert dtype from string back to torch.dtype + kw_args["dtype"] = get_torch_dtype_from_string(kw_args["dtype"]) + # source becomes destination + kw_args["dst_affine"] = kw_args.pop("src_affine") + kw_args["spatial_size"] = transform[TraceKeys.ORIG_SIZE] + if kw_args.get("align_corners") == TraceKeys.NONE: + kw_args["align_corners"] = False + with self.trace_transform(False): + # we can't use `self.__call__` in case a child class calls this inverse. + out: torch.Tensor = SpatialResample.__call__(self, data, **kw_args) + return out class ResampleToMatch(SpatialResample): """Resample an image to match given metadata. The affine matrix will be aligned, and the size of the output image will match.""" - def __call__( # type: ignore + def update_meta(self, img: torch.Tensor, dst_affine=None, img_dst=None): + if dst_affine is not None: + super().update_meta(img, dst_affine) + if isinstance(img_dst, MetaTensor) and isinstance(img, MetaTensor): + original_fname = img.meta[Key.FILENAME_OR_OBJ] + img.meta = deepcopy(img_dst.meta) + img.meta[Key.FILENAME_OR_OBJ] = original_fname # keep the original name, the others are overwritten + + @deprecated_arg( + name="src_meta", since="0.9", msg_suffix="img should be `MetaTensor`, so affine can be extracted directly." + ) + @deprecated_arg( + name="dst_meta", since="0.9", msg_suffix="img_dst should be `MetaTensor`, so affine can be extracted directly." + ) + def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, + img_dst: torch.Tensor, src_meta: Optional[Dict] = None, dst_meta: Optional[Dict] = None, - mode: Union[GridSampleMode, str, None] = None, - padding_mode: Union[GridSamplePadMode, str, None] = None, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = False, dtype: DtypeLike = None, - ) -> Tuple[NdarrayOrTensor, Dict]: + ) -> torch.Tensor: """ Args: img: input image to be resampled to match ``dst_meta``. It currently supports channel-first arrays with @@ -325,55 +385,43 @@ def __call__( # type: ignore dtype: data type for resampling computation. Defaults to ``self.dtype`` or ``np.float64`` (for best precision). If ``None``, use the data type of input data. To be compatible with other modules, the output data type is always `float32`. - Raises: RuntimeError: When ``src_meta`` is missing. RuntimeError: When ``dst_meta`` is missing. ValueError: When the affine matrix of the source image is not invertible. - Returns: Resampled input image, Metadata - """ - if src_meta is None: - raise RuntimeError("`in_meta` is missing") - if dst_meta is None: - raise RuntimeError("`out_meta` is missing") - mode = mode or self.mode - padding_mode = padding_mode or self.padding_mode - align_corners = self.align_corners if align_corners is None else align_corners - dtype = dtype or self.dtype - src_affine = src_meta.get("affine") - dst_affine = dst_meta.get("affine") - img, updated_affine = super().__call__( + if img_dst is None: + raise RuntimeError("`img_dst` is missing.") + dst_affine = img_dst.affine if isinstance(img_dst, MetaTensor) else torch.eye(4) + img = super().__call__( img=img, - src_affine=src_affine, dst_affine=dst_affine, - spatial_size=dst_meta.get("spatial_shape"), + spatial_size=img_dst.shape[1:], # skip channel mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype, ) - dst_meta = deepcopy(dst_meta) - dst_meta["affine"] = updated_affine - dst_meta[Key.FILENAME_OR_OBJ] = src_meta.get(Key.FILENAME_OR_OBJ) - return img, dst_meta + self.update_meta(img, dst_affine=dst_affine, img_dst=img_dst) + return img -class Spacing(Transform): +class Spacing(InvertibleTransform): """ Resample input image into the specified `pixdim`. """ backend = SpatialResample.backend + @deprecated_arg(name="image_only", since="0.9") def __init__( self, pixdim: Union[Sequence[float], float, np.ndarray], diagonal: bool = False, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: DtypeLike = np.float64, image_only: bool = False, @@ -412,12 +460,10 @@ def __init__( dtype: data type for resampling computation. Defaults to ``np.float64`` for best precision. If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. - image_only: return just the image or the image, the old affine and new affine. Default is `False`. """ self.pixdim = np.array(ensure_tuple(pixdim), dtype=np.float64) self.diagonal = diagonal - self.image_only = image_only self.sp_resample = SpatialResample( mode=look_up_option(mode, GridSampleMode), @@ -426,20 +472,20 @@ def __init__( dtype=dtype, ) + @deprecated_arg(name="affine", since="0.9", msg_suffix="Not needed, input should be `MetaTensor`.") def __call__( self, - data_array: NdarrayOrTensor, + data_array: torch.Tensor, affine: Optional[NdarrayOrTensor] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = None, dtype: DtypeLike = None, output_spatial_shape: Optional[Union[Sequence[int], np.ndarray, int]] = None, - ) -> Union[NdarrayOrTensor, Tuple[NdarrayOrTensor, NdarrayOrTensor, NdarrayOrTensor]]: + ) -> torch.Tensor: """ Args: data_array: in shape (num_channels, H[, W, ...]). - affine (matrix): (N+1)x(N+1) original affine matrix for spatially ND `data_array`. Defaults to identity. mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``self.mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html @@ -466,16 +512,22 @@ def __call__( data_array (resampled into `self.pixdim`), original affine, current affine. """ - sr = int(data_array.ndim - 1) + # if the input isn't MetaTensor, create MetaTensor with the default info. + data_array = convert_to_tensor(data_array, track_meta=get_track_meta()) + + original_spatial_shape = data_array.shape[1:] + sr = len(original_spatial_shape) if sr <= 0: raise ValueError("data_array must have at least one spatial dimension.") - if affine is None: + affine_: np.ndarray + affine_np: np.ndarray + if isinstance(data_array, MetaTensor): + affine_np, *_ = convert_data_type(data_array.affine, np.ndarray) + affine_ = to_affine_nd(sr, affine_np) + else: + warnings.warn("`data_array` is not of type MetaTensor, assuming affine to be identity.") # default to identity - affine_np = affine = np.eye(sr + 1, dtype=np.float64) affine_ = np.eye(sr + 1, dtype=np.float64) - else: - affine_np, *_ = convert_data_type(affine, np.ndarray) - affine_ = to_affine_nd(sr, affine_np) out_d = self.pixdim[:sr] if out_d.size < sr: @@ -485,31 +537,35 @@ def __call__( new_affine = zoom_affine(affine_, out_d, diagonal=self.diagonal) output_shape, offset = compute_shape_offset(data_array.shape[1:], affine_, new_affine) new_affine[:sr, -1] = offset[:sr] - output_data, new_affine = self.sp_resample( + # convert to MetaTensor if necessary + data_array = convert_to_tensor(data_array, track_meta=get_track_meta()) + data_array.affine = torch.as_tensor(affine_) # type: ignore + + # we don't want to track the nested transform otherwise two will be appended + data_array = self.sp_resample( data_array, - src_affine=affine, - dst_affine=new_affine, + dst_affine=torch.as_tensor(new_affine), spatial_size=list(output_shape) if output_spatial_shape is None else output_spatial_shape, mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype, ) - new_affine = to_affine_nd(affine_np, new_affine) - new_affine, *_ = convert_to_dst_type(src=new_affine, dst=affine, dtype=torch.float32) - if self.image_only: - return output_data - return output_data, affine, new_affine + return data_array + def inverse(self, data: torch.Tensor) -> torch.Tensor: + return self.sp_resample.inverse(data) -class Orientation(Transform): + +class Orientation(InvertibleTransform): """ Change the input image's orientation into the specified based on `axcodes`. """ backend = [TransformBackends.NUMPY, TransformBackends.TORCH] + @deprecated_arg(name="image_only", since="0.9") def __init__( self, axcodes: Optional[str] = None, @@ -528,7 +584,6 @@ def __init__( labels: optional, None or sequence of (2,) sequences (2,) sequences are labels for (beginning, end) of output axis. Defaults to ``(('L', 'R'), ('P', 'A'), ('I', 'S'))``. - image_only: if True return only the image volume, otherwise return (image, affine, new_affine). Raises: ValueError: When ``axcodes=None`` and ``as_closest_canonical=True``. Incompatible values. @@ -543,39 +598,41 @@ def __init__( self.axcodes = axcodes self.as_closest_canonical = as_closest_canonical self.labels = labels - self.image_only = image_only - def __call__( - self, data_array: NdarrayOrTensor, affine: Optional[NdarrayOrTensor] = None - ) -> Union[NdarrayOrTensor, Tuple[NdarrayOrTensor, NdarrayOrTensor, NdarrayOrTensor]]: + def __call__(self, data_array: torch.Tensor) -> torch.Tensor: """ - original orientation of `data_array` is defined by `affine`. + If input type is `MetaTensor`, original affine is extracted with `data_array.affine`. + If input type is `torch.Tensor`, original affine is assumed to be identity. Args: data_array: in shape (num_channels, H[, W, ...]). - affine (matrix): (N+1)x(N+1) original affine matrix for spatially ND `data_array`. Defaults to identity. Raises: ValueError: When ``data_array`` has no spatial dimensions. ValueError: When ``axcodes`` spatiality differs from ``data_array``. Returns: - data_array [reoriented in `self.axcodes`] if `self.image_only`, else - (data_array [reoriented in `self.axcodes`], original axcodes, current axcodes). + data_array [reoriented in `self.axcodes`]. Output type will be `MetaTensor` + unless `get_track_meta() == False`, in which case it will be + `torch.Tensor`. """ + data_array = convert_to_tensor(data_array, track_meta=get_track_meta()) + spatial_shape = data_array.shape[1:] sr = len(spatial_shape) if sr <= 0: raise ValueError("data_array must have at least one spatial dimension.") affine_: np.ndarray - if affine is None: + affine_np: np.ndarray + if isinstance(data_array, MetaTensor): + affine_np, *_ = convert_data_type(data_array.affine, np.ndarray) + affine_ = to_affine_nd(sr, affine_np) + else: + warnings.warn("`data_array` is not of type `MetaTensor, assuming affine to be identity.") # default to identity - affine_np = affine = np.eye(sr + 1, dtype=np.float64) + affine_np = np.eye(sr + 1, dtype=np.float64) affine_ = np.eye(sr + 1, dtype=np.float64) - else: - affine_np, *_ = convert_data_type(affine, np.ndarray) - affine_ = to_affine_nd(sr, affine_np) src = nib.io_orientation(affine_) if self.as_closest_canonical: @@ -596,35 +653,47 @@ def __call__( ) spatial_ornt = nib.orientations.ornt_transform(src, dst) new_affine = affine_ @ nib.orientations.inv_ornt_aff(spatial_ornt, spatial_shape) - _is_tensor = isinstance(data_array, torch.Tensor) + spatial_ornt[:, 0] += 1 # skip channel dim spatial_ornt = np.concatenate([np.array([[0, 1]]), spatial_ornt]) axes = [ax for ax, flip in enumerate(spatial_ornt[:, 1]) if flip == -1] if axes: - data_array = ( - torch.flip(data_array, dims=axes) if _is_tensor else np.flip(data_array, axis=axes) # type: ignore - ) + data_array = torch.flip(data_array, dims=axes) full_transpose = np.arange(len(data_array.shape)) full_transpose[: len(spatial_ornt)] = np.argsort(spatial_ornt[:, 0]) if not np.all(full_transpose == np.arange(len(data_array.shape))): - if _is_tensor: - data_array = data_array.permute(full_transpose.tolist()) # type: ignore - else: - data_array = data_array.transpose(full_transpose) # type: ignore - out, *_ = convert_to_dst_type(src=data_array, dst=data_array) + data_array = data_array.permute(full_transpose.tolist()) + new_affine = to_affine_nd(affine_np, new_affine) - new_affine, *_ = convert_to_dst_type(src=new_affine, dst=affine, dtype=torch.float32) + new_affine, *_ = convert_data_type(new_affine, torch.Tensor, dtype=torch.float32, device=data_array.device) + + data_array = convert_to_tensor(data_array, track_meta=get_track_meta()) + if get_track_meta(): + self.update_meta(data_array, new_affine) + self.push_transform(data_array, extra_info={"original_affine": affine_np}) + return data_array - if self.image_only: - return out - return out, affine, new_affine + def update_meta(self, img, new_affine): + img.affine = new_affine + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + # Create inverse transform + orig_affine = transform[TraceKeys.EXTRA_INFO]["original_affine"] + orig_axcodes = nib.orientations.aff2axcodes(orig_affine) + inverse_transform = Orientation(axcodes=orig_axcodes, as_closest_canonical=False, labels=self.labels) + # Apply inverse + with inverse_transform.trace_transform(False): + data = inverse_transform(data) -class Flip(Transform): + return data + + +class Flip(InvertibleTransform): """ Reverses the order of elements along the given spatial axis. Preserves shape. - Uses ``np.flip`` in practice. See numpy.flip for additional details: - https://docs.scipy.org/doc/numpy/reference/generated/numpy.flip.html. + See `torch.flip` documentation for additional details: + https://pytorch.org/docs/stable/generated/torch.flip.html Args: spatial_axis: spatial axes along which to flip over. Default is None. @@ -635,22 +704,44 @@ class Flip(Transform): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH] def __init__(self, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: self.spatial_axis = spatial_axis - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + def update_meta(self, img, shape, axes): + # shape and axes include the channel dim + affine = img.affine + mat = convert_to_dst_type(torch.eye(len(affine)), affine)[0] + for axis in axes: + sp = axis - 1 + mat[sp, sp], mat[sp, -1] = mat[sp, sp] * -1, shape[axis] - 1 + img.affine = affine @ mat + + def forward_image(self, img, axes) -> torch.Tensor: + return torch.flip(img, axes) + + def __call__(self, img: torch.Tensor) -> torch.Tensor: """ Args: - img: channel first array, must have shape: (num_channels, H[, W, ..., ]), - """ - if isinstance(img, np.ndarray): - return np.ascontiguousarray(np.flip(img, map_spatial_axes(img.ndim, self.spatial_axis))) - return torch.flip(img, map_spatial_axes(img.ndim, self.spatial_axis)) + img: channel first array, must have shape: (num_channels, H[, W, ..., ]) + """ + img = convert_to_tensor(img, track_meta=get_track_meta()) + axes = map_spatial_axes(img.ndim, self.spatial_axis) + out = self.forward_image(img, axes) + if get_track_meta(): + self.update_meta(out, out.shape, axes) + self.push_transform(out) + return out + def inverse(self, data: torch.Tensor) -> torch.Tensor: + self.pop_transform(data) + flipper = Flip(spatial_axis=self.spatial_axis) + with flipper.trace_transform(False): + return flipper(data) -class Resize(Transform): + +class Resize(InvertibleTransform): """ Resize the input image to given spatial size (with scaling, not cropping/padding). Implemented using :py:class:`torch.nn.functional.interpolate`. @@ -688,7 +779,7 @@ def __init__( self, spatial_size: Union[Sequence[int], int], size_mode: str = "all", - mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + mode: str = InterpolateMode.AREA, align_corners: Optional[bool] = None, anti_aliasing: bool = False, anti_aliasing_sigma: Union[Sequence[float], float, None] = None, @@ -702,12 +793,12 @@ def __init__( def __call__( self, - img: NdarrayOrTensor, - mode: Optional[Union[InterpolateMode, str]] = None, + img: torch.Tensor, + mode: Optional[str] = None, align_corners: Optional[bool] = None, anti_aliasing: Optional[bool] = None, anti_aliasing_sigma: Union[Sequence[float], float, None] = None, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]). @@ -735,8 +826,8 @@ def __call__( anti_aliasing = self.anti_aliasing if anti_aliasing is None else anti_aliasing anti_aliasing_sigma = self.anti_aliasing_sigma if anti_aliasing_sigma is None else anti_aliasing_sigma + input_ndim = img.ndim - 1 # spatial ndim if self.size_mode == "all": - input_ndim = img.ndim - 1 # spatial ndim output_ndim = len(ensure_tuple(self.spatial_size)) if output_ndim > input_ndim: input_shape = ensure_tuple_size(img.shape, output_ndim + 1, 1) @@ -755,8 +846,11 @@ def __call__( spatial_size_ = tuple(int(round(s * scale)) for s in img_size) if tuple(img.shape[1:]) == spatial_size_: # spatial shape is already the desired - return img - img_, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float) + return convert_to_tensor(img, track_meta=get_track_meta()) # type: ignore + + original_sp_size = img.shape[1:] + img_ = convert_to_tensor(img, dtype=torch.float, track_meta=False) + if anti_aliasing and any(x < y for x, y in zip(spatial_size_, img_.shape[1:])): factors = torch.div(torch.Tensor(list(img_.shape[1:])), torch.Tensor(spatial_size_)) if anti_aliasing_sigma is None: @@ -768,19 +862,52 @@ def __call__( for axis in range(len(spatial_size_)): anti_aliasing_sigma[axis] = anti_aliasing_sigma[axis] * int(factors[axis] > 1) anti_aliasing_filter = GaussianSmooth(sigma=anti_aliasing_sigma) - img_ = anti_aliasing_filter(img_) + img_ = convert_to_tensor(anti_aliasing_filter(img_), track_meta=False) + + img = convert_to_tensor(img, track_meta=get_track_meta()) + _mode = look_up_option(self.mode if mode is None else mode, InterpolateMode) + _align_corners = self.align_corners if align_corners is None else align_corners resized = torch.nn.functional.interpolate( - input=img_.unsqueeze(0), - size=spatial_size_, - mode=look_up_option(self.mode if mode is None else mode, InterpolateMode).value, - align_corners=self.align_corners if align_corners is None else align_corners, + input=img_.unsqueeze(0), size=spatial_size_, mode=_mode, align_corners=_align_corners ) out, *_ = convert_to_dst_type(resized.squeeze(0), img) + if get_track_meta(): + self.update_meta(out, original_sp_size, spatial_size_) + self.push_transform( + out, + orig_size=original_sp_size, + extra_info={ + "mode": _mode, + "align_corners": _align_corners if _align_corners is not None else TraceKeys.NONE, + "new_dim": len(original_sp_size) - input_ndim, # additional dims appended + }, + ) return out + def update_meta(self, img, spatial_size, new_spatial_size): + affine = convert_to_tensor(img.affine, track_meta=False) + img.affine = scale_affine(affine, spatial_size, new_spatial_size) + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + return self.inverse_transform(data, transform) + + def inverse_transform(self, data: torch.Tensor, transform) -> torch.Tensor: + orig_size = transform[TraceKeys.ORIG_SIZE] + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] + xform = Resize( + spatial_size=orig_size, mode=mode, align_corners=None if align_corners == TraceKeys.NONE else align_corners + ) + with xform.trace_transform(False): + data = xform(data) + for _ in range(transform[TraceKeys.EXTRA_INFO]["new_dim"]): + data = data.squeeze(-1) # remove the additional dims + return data + -class Rotate(Transform, ThreadUnsafe): +class Rotate(InvertibleTransform): """ Rotates an input image by given angle using :py:class:`monai.networks.layers.AffineTransform`. @@ -808,27 +935,26 @@ def __init__( self, angle: Union[Sequence[float], float], keep_size: bool = True, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: Union[DtypeLike, torch.dtype] = np.float32, ) -> None: self.angle = angle self.keep_size = keep_size - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) self.align_corners = align_corners self.dtype = dtype - self._rotation_matrix: Optional[NdarrayOrTensor] = None def __call__( self, - img: NdarrayOrTensor, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + img: torch.Tensor, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = None, dtype: Union[DtypeLike, torch.dtype] = None, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: channel first array, must have shape: [chns, H, W] or [chns, H, W, D]. @@ -850,14 +976,13 @@ def __call__( ValueError: When ``img`` spatially is not one of [2D, 3D]. """ - _dtype = dtype or self.dtype or img.dtype - - img_t, *_ = convert_data_type(img, torch.Tensor, dtype=_dtype) + img = convert_to_tensor(img, track_meta=get_track_meta()) + _dtype = get_equivalent_dtype(dtype or self.dtype or img.dtype, torch.Tensor) - im_shape = np.asarray(img_t.shape[1:]) # spatial dimensions + im_shape = np.asarray(img.shape[1:]) # spatial dimensions input_ndim = len(im_shape) if input_ndim not in (2, 3): - raise ValueError(f"Unsupported img dimension: {input_ndim}, available options are [2, 3].") + raise ValueError(f"Unsupported image dimension: {input_ndim}, available options are [2, 3].") _angle = ensure_tuple_rep(self.angle, 1 if input_ndim == 2 else 3) transform = create_rotate(input_ndim, _angle) shift = create_translate(input_ndim, ((im_shape - 1) / 2).tolist()) @@ -872,30 +997,70 @@ def __call__( shift_1 = create_translate(input_ndim, (-(output_shape - 1) / 2).tolist()) transform = shift @ transform @ shift_1 + img_t = img.to(_dtype) transform_t, *_ = convert_to_dst_type(transform, img_t) - + _mode = look_up_option(mode or self.mode, GridSampleMode) + _padding_mode = look_up_option(padding_mode or self.padding_mode, GridSamplePadMode) + _align_corners = self.align_corners if align_corners is None else align_corners xform = AffineTransform( normalized=False, - mode=look_up_option(mode or self.mode, GridSampleMode), - padding_mode=look_up_option(padding_mode or self.padding_mode, GridSamplePadMode), - align_corners=self.align_corners if align_corners is None else align_corners, + mode=_mode, + padding_mode=_padding_mode, + align_corners=_align_corners, reverse_indexing=True, ) output: torch.Tensor = xform(img_t.unsqueeze(0), transform_t, spatial_size=output_shape).float().squeeze(0) - self._rotation_matrix = transform - out: NdarrayOrTensor out, *_ = convert_to_dst_type(output, dst=img, dtype=output.dtype) + if get_track_meta(): + self.update_meta(out, transform_t) + self.push_transform( + out, + orig_size=img_t.shape[1:], + extra_info={ + "rot_mat": transform, + "mode": _mode, + "padding_mode": _padding_mode, + "align_corners": _align_corners if _align_corners is not None else TraceKeys.NONE, + "dtype": str(_dtype)[6:], # dtype as string; remove "torch": torch.float32 -> float32 + }, + ) return out - def get_rotation_matrix(self) -> Optional[NdarrayOrTensor]: - """ - Get the most recently applied rotation matrix - This is not thread-safe. - """ - return self._rotation_matrix + def update_meta(self, img, rotate_mat): + affine = convert_to_tensor(img.affine, track_meta=False) + mat = to_affine_nd(len(affine) - 1, rotate_mat) + img.affine = affine @ convert_to_dst_type(mat, affine)[0] + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + return self.inverse_transform(data, transform) + def inverse_transform(self, data: torch.Tensor, transform) -> torch.Tensor: + fwd_rot_mat = transform[TraceKeys.EXTRA_INFO]["rot_mat"] + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] + dtype = transform[TraceKeys.EXTRA_INFO]["dtype"] + inv_rot_mat = linalg_inv(fwd_rot_mat) -class Zoom(Transform): + xform = AffineTransform( + normalized=False, + mode=mode, + padding_mode=padding_mode, + align_corners=False if align_corners == TraceKeys.NONE else align_corners, + reverse_indexing=True, + ) + img_t: torch.Tensor = convert_data_type(data, MetaTensor, dtype=dtype)[0] + transform_t, *_ = convert_to_dst_type(inv_rot_mat, img_t) + sp_size = transform[TraceKeys.ORIG_SIZE] + out: torch.Tensor = xform(img_t.unsqueeze(0), transform_t, spatial_size=sp_size).float().squeeze(0) + out = convert_to_dst_type(out, dst=data, dtype=out.dtype)[0] + if isinstance(data, MetaTensor): + self.update_meta(out, transform_t) + return out + + +class Zoom(InvertibleTransform): """ Zooms an ND image using :py:class:`torch.nn.functional.interpolate`. For details, please see https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html. @@ -931,8 +1096,8 @@ class Zoom(Transform): def __init__( self, zoom: Union[Sequence[float], float], - mode: Union[InterpolateMode, str] = InterpolateMode.AREA, - padding_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.EDGE, + mode: str = InterpolateMode.AREA, + padding_mode: str = NumpyPadMode.EDGE, align_corners: Optional[bool] = None, keep_size: bool = True, **kwargs, @@ -946,11 +1111,11 @@ def __init__( def __call__( self, - img: NdarrayOrTensor, - mode: Optional[Union[InterpolateMode, str]] = None, - padding_mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None, + img: torch.Tensor, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = None, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]). @@ -970,47 +1135,83 @@ def __call__( See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html """ - img_t, *_ = convert_data_type(img, torch.Tensor, dtype=torch.float32) + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = img.to(torch.float32) _zoom = ensure_tuple_rep(self.zoom, img.ndim - 1) # match the spatial image dim - zoomed: NdarrayOrTensor = torch.nn.functional.interpolate( # type: ignore + _mode = look_up_option(self.mode if mode is None else mode, InterpolateMode).value + _align_corners = self.align_corners if align_corners is None else align_corners + _padding_mode = padding_mode or self.padding_mode + + zoomed: NdarrayOrTensor = torch.nn.functional.interpolate( recompute_scale_factor=True, input=img_t.unsqueeze(0), scale_factor=list(_zoom), - mode=look_up_option(self.mode if mode is None else mode, InterpolateMode).value, - align_corners=self.align_corners if align_corners is None else align_corners, + mode=_mode, + align_corners=_align_corners, ) zoomed = zoomed.squeeze(0) - - if self.keep_size and not np.allclose(img_t.shape, zoomed.shape): - - pad_vec = [(0, 0)] * len(img_t.shape) - slice_vec = [slice(None)] * len(img_t.shape) - for idx, (od, zd) in enumerate(zip(img_t.shape, zoomed.shape)): - diff = od - zd - half = abs(diff) // 2 - if diff > 0: # need padding - pad_vec[idx] = (half, diff - half) - elif diff < 0: # need slicing - slice_vec[idx] = slice(half, half + od) - - padder = Pad(pad_vec, padding_mode or self.padding_mode) - zoomed = padder(zoomed) - zoomed = zoomed[tuple(slice_vec)] + orig_size, z_size = img_t.shape, zoomed.shape out, *_ = convert_to_dst_type(zoomed, dst=img) + if get_track_meta(): + self.update_meta(out, orig_size[1:], z_size[1:]) + do_pad_crop = self.keep_size and not np.allclose(orig_size, z_size) + if do_pad_crop: + _pad_crop = ResizeWithPadOrCrop(spatial_size=img_t.shape[1:], mode=_padding_mode) + out = _pad_crop(out) + if get_track_meta(): + padcrop_xform = self.pop_transform(out, check=False) if do_pad_crop else {} + self.push_transform( + out, + orig_size=orig_size[1:], + extra_info={ + "mode": _mode, + "align_corners": _align_corners if _align_corners is not None else TraceKeys.NONE, + "do_padcrop": do_pad_crop, + "padcrop": padcrop_xform, + }, + ) + return out + + def update_meta(self, img, spatial_size, new_spatial_size): + affine = convert_to_tensor(img.affine, track_meta=False) + img.affine = scale_affine(affine, spatial_size, new_spatial_size) + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + return self.inverse_transform(data, transform) + + def inverse_transform(self, data: torch.Tensor, transform) -> torch.Tensor: + if transform[TraceKeys.EXTRA_INFO]["do_padcrop"]: + orig_size = transform[TraceKeys.ORIG_SIZE] + pad_or_crop = ResizeWithPadOrCrop(spatial_size=orig_size, mode="edge") + padcrop_xform = transform[TraceKeys.EXTRA_INFO]["padcrop"] + padcrop_xform[TraceKeys.EXTRA_INFO]["pad_info"][TraceKeys.ID] = TraceKeys.NONE + padcrop_xform[TraceKeys.EXTRA_INFO]["crop_info"][TraceKeys.ID] = TraceKeys.NONE + # this uses inverse because spatial_size // 2 in the forward pass of center crop may cause issues + data = pad_or_crop.inverse_transform(data, padcrop_xform) # type: ignore + # Create inverse transform + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] + inverse_transform = Resize(spatial_size=transform[TraceKeys.ORIG_SIZE]) + # Apply inverse + with inverse_transform.trace_transform(False): + out = inverse_transform( + data, mode=mode, align_corners=None if align_corners == TraceKeys.NONE else align_corners + ) return out -class Rotate90(Transform): +class Rotate90(InvertibleTransform): """ Rotate an array by 90 degrees in the plane specified by `axes`. - See np.rot90 for additional details: - https://numpy.org/doc/stable/reference/generated/numpy.rot90.html. + See `torch.rot90` for additional details: + https://pytorch.org/docs/stable/generated/torch.rot90.html#torch-rot90. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH] def __init__(self, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: """ @@ -1026,18 +1227,52 @@ def __init__(self, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: raise ValueError("spatial_axes must be 2 int numbers to indicate the axes to rotate 90 degrees.") self.spatial_axes = spatial_axes_ - def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor) -> torch.Tensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), """ - rot90: Callable = torch.rot90 if isinstance(img, torch.Tensor) else np.rot90 # type: ignore - out: NdarrayOrTensor = rot90(img, self.k, map_spatial_axes(img.ndim, self.spatial_axes)) - out, *_ = convert_data_type(out, dtype=img.dtype) + img = convert_to_tensor(img, track_meta=get_track_meta()) + axes = map_spatial_axes(img.ndim, self.spatial_axes) + ori_shape = img.shape[1:] + out: NdarrayOrTensor = torch.rot90(img, self.k, axes) + out = convert_to_dst_type(out, img)[0] + if get_track_meta(): + self.update_meta(out, ori_shape, out.shape[1:], axes, self.k) + self.push_transform(out, extra_info={"axes": [d - 1 for d in axes], "k": self.k}) # compensate spatial dim return out - -class RandRotate90(RandomizableTransform): + def update_meta(self, img, spatial_size, new_spatial_size, axes, k): + affine = convert_data_type(img.affine, torch.Tensor)[0] + r, sp_r = len(affine) - 1, len(spatial_size) + mat = to_affine_nd(r, create_translate(sp_r, [-float(d - 1) / 2 for d in new_spatial_size])) + s = -1.0 if int(axes[0]) - int(axes[1]) in (-1, 2) else 1.0 + if sp_r == 2: + rot90 = to_affine_nd(r, create_rotate(sp_r, [s * np.pi / 2])) + else: + idx = {1, 2, 3} - set(axes) + angle = [0, 0, 0] + angle[idx.pop() - 1] = s * np.pi / 2 + rot90 = to_affine_nd(r, create_rotate(sp_r, angle)) + for _ in range(k): + mat = rot90 @ mat + mat = to_affine_nd(r, create_translate(sp_r, [float(d - 1) / 2 for d in spatial_size])) @ mat + img.affine = affine @ convert_to_dst_type(mat, affine)[0] + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + return self.inverse_transform(data, transform) + + def inverse_transform(self, data: torch.Tensor, transform) -> torch.Tensor: + axes = transform[TraceKeys.EXTRA_INFO]["axes"] + k = transform[TraceKeys.EXTRA_INFO]["k"] + inv_k = 4 - k % 4 + xform = Rotate90(k=inv_k, spatial_axes=axes) + with xform.trace_transform(False): + return xform(data) + + +class RandRotate90(RandomizableTransform, InvertibleTransform): """ With probability `prob`, input arrays are rotated by 90 degrees in the plane specified by `spatial_axes`. @@ -1066,7 +1301,7 @@ def randomize(self, data: Optional[Any] = None) -> None: return None self._rand_k = self.R.randint(self.max_k) + 1 - def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), @@ -1075,13 +1310,25 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen if randomize: self.randomize() - if not self._do_transform: - return img + if self._do_transform: + out = Rotate90(self._rand_k, self.spatial_axes)(img) + else: + out = convert_to_tensor(img, track_meta=get_track_meta()) + + if get_track_meta(): + maybe_rot90_info = self.pop_transform(out, check=False) if self._do_transform else {} + self.push_transform(out, extra_info=maybe_rot90_info) + return out - return Rotate90(self._rand_k, self.spatial_axes)(img) + def inverse(self, data: torch.Tensor) -> torch.Tensor: + xform_info = self.pop_transform(data) + if not xform_info[TraceKeys.DO_TRANSFORM]: + return data + rotate_xform = xform_info[TraceKeys.EXTRA_INFO] + return Rotate90().inverse_transform(data, rotate_xform) -class RandRotate(RandomizableTransform): +class RandRotate(RandomizableTransform, InvertibleTransform): """ Randomly rotate the input arrays. @@ -1118,8 +1365,8 @@ def __init__( range_z: Union[Tuple[float, float], float] = 0.0, prob: float = 0.1, keep_size: bool = True, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, align_corners: bool = False, dtype: Union[DtypeLike, torch.dtype] = np.float32, ) -> None: @@ -1135,8 +1382,8 @@ def __init__( self.range_z = tuple(sorted([-self.range_z[0], self.range_z[0]])) self.keep_size = keep_size - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) self.align_corners = align_corners self.dtype = dtype @@ -1152,11 +1399,12 @@ def randomize(self, data: Optional[Any] = None) -> None: self.y = self.R.uniform(low=self.range_y[0], high=self.range_y[1]) self.z = self.R.uniform(low=self.range_z[0], high=self.range_z[1]) + @deprecated_arg(name="get_matrix", since="0.9", msg_suffix="please use `img.meta` instead.") def __call__( self, - img: NdarrayOrTensor, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + img: torch.Tensor, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = None, dtype: Union[DtypeLike, torch.dtype] = None, randomize: bool = True, @@ -1177,27 +1425,35 @@ def __call__( If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. randomize: whether to execute `randomize()` function first, default to True. - get_matrix: whether to return the rotated image and rotate matrix together, default to False. """ if randomize: self.randomize() - if not self._do_transform: - return img - - rotator = Rotate( - angle=self.x if img.ndim == 3 else (self.x, self.y, self.z), - keep_size=self.keep_size, - mode=look_up_option(mode or self.mode, GridSampleMode), - padding_mode=look_up_option(padding_mode or self.padding_mode, GridSamplePadMode), - align_corners=self.align_corners if align_corners is None else align_corners, - dtype=dtype or self.dtype or img.dtype, - ) - img = rotator(img) - return (img, rotator.get_rotation_matrix()) if get_matrix else img + if self._do_transform: + rotator = Rotate( + angle=self.x if img.ndim == 3 else (self.x, self.y, self.z), + keep_size=self.keep_size, + mode=look_up_option(mode or self.mode, GridSampleMode), + padding_mode=look_up_option(padding_mode or self.padding_mode, GridSamplePadMode), + align_corners=self.align_corners if align_corners is None else align_corners, + dtype=dtype or self.dtype or img.dtype, + ) + out = rotator(img) + else: + out = convert_to_tensor(img, track_meta=get_track_meta()) + if get_track_meta(): + rot_info = self.pop_transform(out, check=False) if self._do_transform else {} + self.push_transform(out, extra_info=rot_info) + return out + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + xform_info = self.pop_transform(data) + if not xform_info[TraceKeys.DO_TRANSFORM]: + return data + return Rotate(0).inverse_transform(data, xform_info[TraceKeys.EXTRA_INFO]) -class RandFlip(RandomizableTransform): +class RandFlip(RandomizableTransform, InvertibleTransform): """ Randomly flips the image along axes. Preserves shape. See numpy.flip for additional details. @@ -1214,7 +1470,7 @@ def __init__(self, prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int] RandomizableTransform.__init__(self, prob) self.flipper = Flip(spatial_axis=spatial_axis) - def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), @@ -1222,14 +1478,22 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen """ if randomize: self.randomize(None) + out = self.flipper(img) if self._do_transform else img + out = convert_to_tensor(out, track_meta=get_track_meta()) + if get_track_meta(): + xform_info = self.pop_transform(out, check=False) if self._do_transform else {} + self.push_transform(out, extra_info=xform_info) + return out - if not self._do_transform: - return img - - return self.flipper(img) + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + if not transform[TraceKeys.DO_TRANSFORM]: + return data + data.applied_operations.append(transform[TraceKeys.EXTRA_INFO]) # type: ignore + return self.flipper.inverse(data) -class RandAxisFlip(RandomizableTransform): +class RandAxisFlip(RandomizableTransform, InvertibleTransform): """ Randomly select a spatial axis and flip along it. See numpy.flip for additional details. @@ -1245,6 +1509,7 @@ class RandAxisFlip(RandomizableTransform): def __init__(self, prob: float = 0.1) -> None: RandomizableTransform.__init__(self, prob) self._axis: Optional[int] = None + self.flipper = Flip(spatial_axis=self._axis) def randomize(self, data: NdarrayOrTensor) -> None: super().randomize(None) @@ -1252,22 +1517,36 @@ def randomize(self, data: NdarrayOrTensor) -> None: return None self._axis = self.R.randint(data.ndim - 1) - def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: """ Args: - img: channel first array, must have shape: (num_channels, H[, W, ..., ]), + img: channel first array, must have shape: (num_channels, H[, W, ..., ]) randomize: whether to execute `randomize()` function first, default to True. """ if randomize: self.randomize(data=img) - if not self._do_transform: - return img + if self._do_transform: + self.flipper.spatial_axis = self._axis + out = self.flipper(img) + else: + out = convert_to_tensor(img, track_meta=get_track_meta()) + if get_track_meta(): + xform = self.pop_transform(out, check=False) if self._do_transform else {} + xform["axes"] = self._axis + self.push_transform(out, extra_info=xform) + return out - return Flip(spatial_axis=self._axis)(img) + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + if not transform[TraceKeys.DO_TRANSFORM]: + return data + flipper = Flip(spatial_axis=transform[TraceKeys.EXTRA_INFO]["axes"]) + with flipper.trace_transform(False): + return flipper(data) -class RandZoom(RandomizableTransform): +class RandZoom(RandomizableTransform, InvertibleTransform): """ Randomly zooms input arrays with given probability within given zoom range. @@ -1309,8 +1588,8 @@ def __init__( prob: float = 0.1, min_zoom: Union[Sequence[float], float] = 0.9, max_zoom: Union[Sequence[float], float] = 1.1, - mode: Union[InterpolateMode, str] = InterpolateMode.AREA, - padding_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.EDGE, + mode: str = InterpolateMode.AREA, + padding_mode: str = NumpyPadMode.EDGE, align_corners: Optional[bool] = None, keep_size: bool = True, **kwargs, @@ -1342,17 +1621,17 @@ def randomize(self, img: NdarrayOrTensor) -> None: def __call__( self, - img: NdarrayOrTensor, - mode: Optional[Union[InterpolateMode, str]] = None, - padding_mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]] = None, + img: torch.Tensor, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, align_corners: Optional[bool] = None, randomize: bool = True, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: channel first array, must have shape 2D: (nchannels, H, W), or 3D: (nchannels, H, W, D). - mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} - The interpolation mode. Defaults to ``self.mode``. + mode: {``"nearest"``, ``"nearest-exact"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, + ``"area"``}, the interpolation mode. Defaults to ``self.mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} @@ -1372,16 +1651,26 @@ def __call__( self.randomize(img=img) if not self._do_transform: - return img - - return Zoom( - self._zoom, - keep_size=self.keep_size, - mode=look_up_option(mode or self.mode, InterpolateMode), - padding_mode=padding_mode or self.padding_mode, - align_corners=self.align_corners if align_corners is None else align_corners, - **self.kwargs, - )(img) + out = convert_to_tensor(img, track_meta=get_track_meta()) + else: + out = Zoom( + self._zoom, + keep_size=self.keep_size, + mode=look_up_option(mode or self.mode, InterpolateMode), + padding_mode=padding_mode or self.padding_mode, + align_corners=self.align_corners if align_corners is None else align_corners, + **self.kwargs, + )(img) + if get_track_meta(): + z_info = self.pop_transform(out, check=False) if self._do_transform else {} + self.push_transform(out, extra_info=z_info) + return out # type: ignore + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + xform_info = self.pop_transform(data) + if not xform_info[TraceKeys.DO_TRANSFORM]: + return data + return Zoom(self._zoom).inverse_transform(data, xform_info[TraceKeys.EXTRA_INFO]) class AffineGrid(Transform): @@ -1417,7 +1706,7 @@ class AffineGrid(Transform): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH] @deprecated_arg(name="as_tensor_output", since="0.6") def __init__( @@ -1440,8 +1729,8 @@ def __init__( self.affine = affine def __call__( - self, spatial_size: Optional[Sequence[int]] = None, grid: Optional[NdarrayOrTensor] = None - ) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]: + self, spatial_size: Optional[Sequence[int]] = None, grid: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: """ The grid can be initialized with a `spatial_size` parameter, or provided directly as `grid`. Therefore, either `spatial_size` or `grid` must be provided. @@ -1458,17 +1747,17 @@ def __call__( if grid is None: # create grid from spatial_size if spatial_size is None: raise ValueError("Incompatible values: grid=None and spatial_size=None.") - grid = create_grid(spatial_size, device=self.device, backend="torch", dtype=self.dtype) - _b = TransformBackends.TORCH if isinstance(grid, torch.Tensor) else TransformBackends.NUMPY - _device = grid.device if isinstance(grid, torch.Tensor) else self.device + grid_ = create_grid(spatial_size, device=self.device, backend="torch", dtype=self.dtype) + else: + grid_ = grid + _dtype = self.dtype or grid_.dtype + grid_: torch.Tensor = convert_to_tensor(grid_, dtype=_dtype, track_meta=get_track_meta()) # type: ignore + _b = TransformBackends.TORCH + _device = grid_.device # type: ignore affine: NdarrayOrTensor if self.affine is None: - spatial_dims = len(grid.shape) - 1 - affine = ( - torch.eye(spatial_dims + 1, device=_device) - if _b == TransformBackends.TORCH - else np.eye(spatial_dims + 1) - ) + spatial_dims = len(grid_.shape) - 1 + affine = torch.eye(spatial_dims + 1, device=_device) if self.rotate_params: affine = affine @ create_rotate(spatial_dims, self.rotate_params, device=_device, backend=_b) if self.shear_params: @@ -1480,11 +1769,10 @@ def __call__( else: affine = self.affine - grid, *_ = convert_data_type(grid, torch.Tensor, device=_device, dtype=self.dtype or grid.dtype) - affine, *_ = convert_to_dst_type(affine, grid) - - grid = (affine @ grid.reshape((grid.shape[0], -1))).reshape([-1] + list(grid.shape[1:])) - return grid, affine + affine = to_affine_nd(len(grid_) - 1, affine) + affine = convert_to_tensor(affine, device=grid_.device, dtype=grid_.dtype, track_meta=False) # type: ignore + grid_ = (affine @ grid_.reshape((grid_.shape[0], -1))).reshape([-1] + list(grid_.shape[1:])) + return grid_, affine # type: ignore class RandAffineGrid(Randomizable, Transform): @@ -1552,7 +1840,7 @@ def __init__( self.scale_params: Optional[List[float]] = None self.device = device - self.affine: Optional[NdarrayOrTensor] = None + self.affine: Optional[torch.Tensor] = torch.eye(4, dtype=torch.float64) def _get_rand_param(self, param_range, add_scalar: float = 0.0): out_param = [] @@ -1576,13 +1864,12 @@ def __call__( spatial_size: Optional[Sequence[int]] = None, grid: Optional[NdarrayOrTensor] = None, randomize: bool = True, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ 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. - randomize: boolean as to whether the grid parameters governing the grid - should be randomized. + randomize: boolean as to whether the grid parameters governing the grid should be randomized. Returns: a 2D (3xHxW) or 3D (4xHxWxD) grid. @@ -1596,11 +1883,11 @@ def __call__( scale_params=self.scale_params, device=self.device, ) - _grid: NdarrayOrTensor + _grid: torch.Tensor _grid, self.affine = affine_grid(spatial_size, grid) return _grid - def get_transformation_matrix(self) -> Optional[NdarrayOrTensor]: + def get_transformation_matrix(self) -> Optional[torch.Tensor]: """Get the most recently applied transformation matrix""" return self.affine @@ -1612,6 +1899,7 @@ class RandDeformGrid(Randomizable, Transform): backend = [TransformBackends.TORCH] + @deprecated_arg(name="as_tensor_output", since="0.8") def __init__( self, spacing: Union[Sequence[float], float], @@ -1627,15 +1915,12 @@ def __init__( spacing=(2, 2) indicates deformation field defined on every other pixel in 2D. magnitude_range: the random offsets will be generated from `uniform[magnitude[0], magnitude[1])`. - as_tensor_output: whether to output tensor instead of numpy array. - defaults to True. device: device to store the output grid data. """ self.spacing = spacing self.magnitude = magnitude_range self.rand_mag = 1.0 - self.as_tensor_output = as_tensor_output self.random_offset: np.ndarray self.device = device @@ -1643,7 +1928,7 @@ def randomize(self, grid_size: Sequence[int]) -> None: self.random_offset = self.R.normal(size=([len(grid_size)] + list(grid_size))).astype(np.float32, copy=False) self.rand_mag = self.R.uniform(self.magnitude[0], self.magnitude[1]) - def __call__(self, spatial_size: Sequence[int]): + def __call__(self, spatial_size: Sequence[int]) -> torch.Tensor: """ Args: spatial_size: spatial size of the grid. @@ -1653,9 +1938,7 @@ def __call__(self, spatial_size: Sequence[int]): self.randomize(control_grid.shape[1:]) _offset, *_ = convert_to_dst_type(self.rand_mag * self.random_offset, control_grid) control_grid[: len(spatial_size)] += _offset - if not self.as_tensor_output: - control_grid, *_ = convert_data_type(control_grid, output_type=np.ndarray, dtype=np.float32) - return control_grid + return control_grid # type: ignore class Resample(Transform): @@ -1665,8 +1948,8 @@ class Resample(Transform): @deprecated_arg(name="as_tensor_output", since="0.6") def __init__( self, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, as_tensor_output: bool = True, norm_coords: bool = True, device: Optional[torch.device] = None, @@ -1699,20 +1982,20 @@ def __init__( ``as_tensor_output`` is deprecated. """ - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) self.norm_coords = norm_coords self.device = device self.dtype = dtype - def __call__( + def __call__( # type: ignore self, - img: NdarrayOrTensor, - grid: Optional[NdarrayOrTensor] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + img: torch.Tensor, + grid: torch.Tensor, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, dtype: DtypeLike = None, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W[, D]). @@ -1735,12 +2018,11 @@ def __call__( See also: :py:const:`monai.config.USE_COMPILED` """ - if grid is None: - raise ValueError("Unknown grid.") _device = img.device if isinstance(img, torch.Tensor) else self.device _dtype = dtype or self.dtype or img.dtype - img_t, *_ = convert_data_type(img, torch.Tensor, device=_device, dtype=_dtype) - grid_t = convert_to_dst_type(grid, img_t)[0] + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t, *_ = convert_data_type(img, torch.Tensor, dtype=_dtype, device=_device) + grid_t, *_ = convert_to_dst_type(grid, img_t) if grid_t is grid: # copy if needed (convert_data_type converts to contiguous) grid_t = grid_t.clone(memory_format=torch.contiguous_format) sr = min(len(img_t.shape[1:]), 3) @@ -1751,10 +2033,8 @@ def __call__( grid_t[i] = (max(dim, 2) / 2.0 - 0.5 + grid_t[i]) / grid_t[-1:] grid_t = moveaxis(grid_t[:sr], 0, -1) # type: ignore _padding_mode = self.padding_mode if padding_mode is None else padding_mode - _padding_mode = _padding_mode.value if isinstance(_padding_mode, GridSamplePadMode) else _padding_mode bound = 1 if _padding_mode == "reflection" else _padding_mode _interp_mode = self.mode if mode is None else mode - _interp_mode = _interp_mode.value if isinstance(_interp_mode, GridSampleMode) else _interp_mode if _interp_mode == "bicubic": interp = 3 elif _interp_mode == "bilinear": @@ -1773,15 +2053,15 @@ def __call__( out = torch.nn.functional.grid_sample( img_t.unsqueeze(0), grid_t.unsqueeze(0), - mode=self.mode.value if mode is None else GridSampleMode(mode).value, - padding_mode=self.padding_mode.value if padding_mode is None else GridSamplePadMode(padding_mode).value, + mode=self.mode if mode is None else GridSampleMode(mode), + padding_mode=self.padding_mode if padding_mode is None else GridSamplePadMode(padding_mode), align_corners=True, )[0] out_val, *_ = convert_to_dst_type(out, dst=img, dtype=np.float32) return out_val -class Affine(Transform): +class Affine(InvertibleTransform): """ Transform ``img`` given the affine parameters. A tutorial is available: https://github.com/Project-MONAI/tutorials/blob/0.6.0/modules/transforms_demo_2d.ipynb. @@ -1800,8 +2080,8 @@ def __init__( scale_params: Optional[Union[Sequence[float], float]] = None, affine: Optional[NdarrayOrTensor] = None, spatial_size: Optional[Union[Sequence[int], int]] = None, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.REFLECTION, normalized: bool = False, norm_coords: bool = True, as_tensor_output: bool = True, @@ -1875,18 +2155,19 @@ def __init__( device=device, ) self.image_only = image_only - self.resampler = Resample(norm_coords=not normalized, device=device, dtype=dtype) + self.norm_coord = not normalized + self.resampler = Resample(norm_coords=self.norm_coord, device=device, dtype=dtype) self.spatial_size = spatial_size - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, spatial_size: Optional[Union[Sequence[int], int]] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, - ) -> Union[NdarrayOrTensor, Tuple[NdarrayOrTensor, NdarrayOrTensor]]: + mode: Optional[str] = None, + padding_mode: Optional[str] = None, + ) -> Union[torch.Tensor, Tuple[torch.Tensor, NdarrayOrTensor]]: """ Args: img: shape must be (num_channels, H, W[, D]), @@ -1905,14 +2186,60 @@ def __call__( Padding mode for outside grid values. Defaults to ``self.padding_mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html """ - sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img.shape[1:]) + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_size = img.shape[1:] + sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img_size) + _mode = mode or self.mode + _padding_mode = padding_mode or self.padding_mode grid, affine = self.affine_grid(spatial_size=sp_size) - ret = self.resampler(img, grid=grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode) - - return ret if self.image_only else (ret, affine) - - -class RandAffine(RandomizableTransform): + out = self.resampler(img, grid=grid, mode=_mode, padding_mode=_padding_mode) + if not isinstance(out, MetaTensor): + return out if self.image_only else (out, affine) + if not self.norm_coord: + warnings.warn("customized transform may not work with the metadata operation.") + if get_track_meta(): + out.meta = img.meta # type: ignore + self.update_meta(out, affine, img_size, sp_size) + self.push_transform( + out, orig_size=img_size, extra_info={"affine": affine, "mode": _mode, "padding_mode": _padding_mode} + ) + return out if self.image_only else (out, affine) + + @classmethod + def compute_w_affine(cls, affine, mat, img_size, sp_size): + r = len(affine) - 1 + mat = to_affine_nd(r, mat) + shift_1 = create_translate(r, [float(d - 1) / 2 for d in img_size[:r]]) + shift_2 = create_translate(r, [-float(d - 1) / 2 for d in sp_size[:r]]) + mat = shift_1 @ convert_data_type(mat, np.ndarray)[0] @ shift_2 + return affine @ convert_to_dst_type(mat, affine)[0] + + def update_meta(self, img, mat, img_size, sp_size): + affine = convert_data_type(img.affine, torch.Tensor)[0] + img.affine = Affine.compute_w_affine(affine, mat, img_size, sp_size) + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + orig_size = transform[TraceKeys.ORIG_SIZE] + # Create inverse transform + fwd_affine = transform[TraceKeys.EXTRA_INFO]["affine"] + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] + inv_affine = linalg_inv(fwd_affine) + inv_affine = convert_to_dst_type(inv_affine, data, dtype=inv_affine.dtype)[0] + + affine_grid = AffineGrid(affine=inv_affine) + grid, _ = affine_grid(orig_size) + # Apply inverse transform + out = self.resampler(data, grid, mode, padding_mode) + if not isinstance(out, MetaTensor): + out = MetaTensor(out) + out.meta = data.meta # type: ignore + self.update_meta(out, inv_affine, data.shape[1:], orig_size) + return out # type: ignore + + +class RandAffine(RandomizableTransform, InvertibleTransform): """ Random affine transform. A tutorial is available: https://github.com/Project-MONAI/tutorials/blob/0.6.0/modules/transforms_demo_2d.ipynb. @@ -1930,8 +2257,8 @@ def __init__( translate_range: RandRange = None, scale_range: RandRange = None, spatial_size: Optional[Union[Sequence[int], int]] = None, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.REFLECTION, cache_grid: bool = False, as_tensor_output: bool = True, device: Optional[torch.device] = None, @@ -2001,8 +2328,8 @@ def __init__( self.spatial_size = spatial_size self.cache_grid = cache_grid self._cached_grid = self._init_identity_cache() - self.mode: GridSampleMode = GridSampleMode(mode) - self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) + self.mode: str = GridSampleMode(mode) + self.padding_mode: str = GridSamplePadMode(padding_mode) def _init_identity_cache(self): """ @@ -2059,12 +2386,13 @@ def randomize(self, data: Optional[Any] = None) -> None: def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, spatial_size: Optional[Union[Sequence[int], int]] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, randomize: bool = True, - ) -> NdarrayOrTensor: + grid=None, + ) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W[, D]), @@ -2080,26 +2408,70 @@ def __call__( Padding mode for outside grid values. Defaults to ``self.padding_mode``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html randomize: whether to execute `randomize()` function first, default to True. + grid: precomputed grid to be used (mainly to accelerate `RandAffined`). """ if randomize: self.randomize() - # if not doing transform and spatial size doesn't change, nothing to do # except convert to float and device sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img.shape[1:]) do_resampling = self._do_transform or (sp_size != ensure_tuple(img.shape[1:])) + _mode = mode or self.mode + _padding_mode = padding_mode or self.padding_mode + img = convert_to_tensor(img, track_meta=get_track_meta()) if not do_resampling: - img, *_ = convert_data_type(img, dtype=torch.float32, device=self.resampler.device) - return img - grid = self.get_identity_grid(sp_size) - if self._do_transform: - grid = self.rand_affine_grid(grid=grid, randomize=randomize) - out: NdarrayOrTensor = self.resampler( - img=img, grid=grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode - ) + out: torch.Tensor = convert_data_type(img, dtype=torch.float32, device=self.resampler.device)[0] + else: + if grid is None: + grid = self.get_identity_grid(sp_size) + if self._do_transform: + grid = self.rand_affine_grid(grid=grid, randomize=randomize) + out = self.resampler(img=img, grid=grid, mode=_mode, padding_mode=_padding_mode) + mat = self.rand_affine_grid.get_transformation_matrix() + out = convert_to_tensor(out, track_meta=get_track_meta()) + if get_track_meta(): + self.push_transform( + out, + orig_size=img.shape[1:], + extra_info={ + "affine": mat, + "mode": _mode, + "padding_mode": _padding_mode, + "do_resampling": do_resampling, + }, + ) + self.update_meta(out, mat, img.shape[1:], sp_size) return out + def update_meta(self, img, mat, img_size, sp_size): + affine = convert_data_type(img.affine, torch.Tensor)[0] + img.affine = Affine.compute_w_affine(affine, mat, img_size, sp_size) + + def inverse(self, data: torch.Tensor) -> torch.Tensor: + transform = self.pop_transform(data) + # if transform was not performed nothing to do. + if not transform[TraceKeys.EXTRA_INFO]["do_resampling"]: + return data + orig_size = transform[TraceKeys.ORIG_SIZE] + orig_size = fall_back_tuple(orig_size, data.shape[1:]) + # Create inverse transform + fwd_affine = transform[TraceKeys.EXTRA_INFO]["affine"] + mode = transform[TraceKeys.EXTRA_INFO]["mode"] + padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] + inv_affine = linalg_inv(fwd_affine) + inv_affine = convert_to_dst_type(inv_affine, data, dtype=inv_affine.dtype)[0] + affine_grid = AffineGrid(affine=inv_affine) + grid, _ = affine_grid(orig_size) + + # Apply inverse transform + out = self.resampler(data, grid, mode, padding_mode) + if not isinstance(out, MetaTensor): + out = MetaTensor(out) + out.meta = data.meta # type: ignore + self.update_meta(out, inv_affine, data.shape[1:], orig_size) + return out # type: ignore + class Rand2DElastic(RandomizableTransform): """ @@ -2121,8 +2493,8 @@ def __init__( translate_range: RandRange = None, scale_range: RandRange = None, spatial_size: Optional[Union[Tuple[int, int], int]] = None, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, ) -> None: @@ -2176,9 +2548,7 @@ def __init__( """ RandomizableTransform.__init__(self, prob) - self.deform_grid = RandDeformGrid( - spacing=spacing, magnitude_range=magnitude_range, as_tensor_output=True, device=device - ) + self.deform_grid = RandDeformGrid(spacing=spacing, magnitude_range=magnitude_range, device=device) self.rand_affine_grid = RandAffineGrid( rotate_range=rotate_range, shear_range=shear_range, @@ -2190,8 +2560,8 @@ def __init__( self.device = device self.spatial_size = spatial_size - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) def set_random_state( self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None @@ -2210,12 +2580,12 @@ def randomize(self, spatial_size: Sequence[int]) -> None: def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, spatial_size: Optional[Union[Tuple[int, int], int]] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, randomize: bool = True, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W), @@ -2237,7 +2607,7 @@ def __call__( if self._do_transform: grid = self.deform_grid(spatial_size=sp_size) grid = self.rand_affine_grid(grid=grid) - grid = torch.nn.functional.interpolate( # type: ignore + grid = torch.nn.functional.interpolate( recompute_scale_factor=True, input=grid.unsqueeze(0), scale_factor=list(ensure_tuple(self.deform_grid.spacing)), @@ -2248,7 +2618,7 @@ def __call__( else: _device = img.device if isinstance(img, torch.Tensor) else self.device grid = create_grid(spatial_size=sp_size, device=_device, backend="torch") - out: NdarrayOrTensor = self.resampler( + out: torch.Tensor = self.resampler( img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode ) return out @@ -2274,8 +2644,8 @@ def __init__( translate_range: RandRange = None, scale_range: RandRange = None, spatial_size: Optional[Union[Tuple[int, int, int], int]] = None, - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, ) -> None: @@ -2344,8 +2714,8 @@ def __init__( self.sigma_range = sigma_range self.magnitude_range = magnitude_range self.spatial_size = spatial_size - self.mode: GridSampleMode = look_up_option(mode, GridSampleMode) - self.padding_mode: GridSamplePadMode = look_up_option(padding_mode, GridSamplePadMode) + self.mode: str = look_up_option(mode, GridSampleMode) + self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode) self.device = device self.rand_offset: np.ndarray @@ -2370,12 +2740,12 @@ def randomize(self, grid_size: Sequence[int]) -> None: def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, spatial_size: Optional[Union[Tuple[int, int, int], int]] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + mode: Optional[str] = None, + padding_mode: Optional[str] = None, randomize: bool = True, - ) -> NdarrayOrTensor: + ) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W, D), @@ -2403,7 +2773,7 @@ def __call__( offset = torch.as_tensor(self.rand_offset, device=_device).unsqueeze(0) grid[:3] += gaussian(offset)[0] * self.magnitude grid = self.rand_affine_grid(grid=grid) - out: NdarrayOrTensor = self.resampler( + out: torch.Tensor = self.resampler( img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode ) return out @@ -2417,8 +2787,8 @@ def __init__( self, num_cells: Union[Tuple[int], int], distort_steps: Sequence[Sequence[float]], - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, device: Optional[torch.device] = None, ) -> None: """ @@ -2446,11 +2816,11 @@ def __init__( def __call__( self, - img: NdarrayOrTensor, + img: torch.Tensor, distort_steps: Optional[Sequence[Sequence]] = None, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, - ) -> NdarrayOrTensor: + mode: Optional[str] = None, + padding_mode: Optional[str] = None, + ) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W[, D]). @@ -2504,8 +2874,8 @@ def __init__( num_cells: Union[Tuple[int], int] = 5, prob: float = 0.1, distort_limit: Union[Tuple[float, float], float] = (-0.03, 0.03), - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, device: Optional[torch.device] = None, ) -> None: """ @@ -2548,12 +2918,8 @@ def randomize(self, spatial_shape: Sequence[int]) -> None: ) def __call__( - self, - img: NdarrayOrTensor, - mode: Optional[Union[GridSampleMode, str]] = None, - padding_mode: Optional[Union[GridSamplePadMode, str]] = None, - randomize: bool = True, - ) -> NdarrayOrTensor: + self, img: torch.Tensor, mode: Optional[str] = None, padding_mode: Optional[str] = None, randomize: bool = True + ) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W[, D]). @@ -2568,7 +2934,7 @@ def __call__( if randomize: self.randomize(img.shape[1:]) if not self._do_transform: - return img + return convert_to_tensor(img, track_meta=get_track_meta()) # type: ignore return self.grid_distortion(img, distort_steps=self.distort_steps, mode=mode, padding_mode=padding_mode) @@ -2690,7 +3056,7 @@ def __init__( overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[str] = None, threshold: Optional[float] = None, - pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + pad_mode: str = PytorchPadMode.CONSTANT, **pad_kwargs, ): self.patch_size = ensure_tuple(patch_size) @@ -2706,8 +3072,8 @@ def filter_threshold(self, image_np: np.ndarray, locations: np.ndarray): """ Filter the patches and their locations according to a threshold Args: - image: a numpy.ndarray representing a stack of patches - location: a numpy.ndarray representing the stack of location of each patch + image_np: a numpy.ndarray representing a stack of patches + locations: a numpy.ndarray representing the stack of location of each patch """ if self.threshold is not None: n_dims = len(image_np.shape) @@ -2720,8 +3086,8 @@ def filter_count(self, image_np: np.ndarray, locations: np.ndarray): """ Sort the patches based on the sum of their intensity, and just keep `self.num_patches` of them. Args: - image: a numpy.ndarray representing a stack of patches - location: a numpy.ndarray representing the stack of location of each patch + image_np: a numpy.ndarray representing a stack of patches + locations: a numpy.ndarray representing the stack of location of each patch """ if self.sort_fn is None: image_np = image_np[: self.num_patches] @@ -2811,7 +3177,7 @@ def __init__( overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[str] = None, threshold: Optional[float] = None, - pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + pad_mode: str = PytorchPadMode.CONSTANT, **pad_kwargs, ): super().__init__( diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 6b7843349d..c809d38ba0 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -16,22 +16,20 @@ """ from copy import deepcopy -from enum import Enum from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch -from monai.config import DtypeLike, KeysCollection +from monai.config import DtypeLike, KeysCollection, SequenceStr from monai.config.type_definitions import NdarrayOrTensor -from monai.data.utils import affine_to_spacing -from monai.networks.layers import AffineTransform +from monai.data.meta_obj import get_track_meta +from monai.data.meta_tensor import MetaTensor from monai.networks.layers.simplelayers import GaussianFilter -from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad +from monai.transforms.croppad.array import CenterSpatialCrop from monai.transforms.inverse import InvertibleTransform from monai.transforms.spatial.array import ( Affine, - AffineGrid, Flip, GridDistortion, GridPatch, @@ -41,7 +39,6 @@ Rand3DElastic, RandAffine, RandAxisFlip, - RandFlip, RandGridDistortion, RandGridPatch, RandRotate, @@ -61,17 +58,16 @@ GridSamplePadMode, InterpolateMode, NumpyPadMode, - PytorchPadMode, WSIPatchKeys, + convert_to_tensor, ensure_tuple, ensure_tuple_rep, fall_back_tuple, first, ) from monai.utils.deprecate_utils import deprecated_arg -from monai.utils.enums import PostFix, TraceKeys +from monai.utils.enums import PytorchPadMode, TraceKeys from monai.utils.module import optional_import -from monai.utils.type_conversion import convert_data_type, convert_to_dst_type nib, _ = optional_import("nibabel") @@ -145,12 +141,6 @@ "RandGridPatchDict", ] -GridSampleModeSequence = Union[Sequence[Union[GridSampleMode, str]], GridSampleMode, str] -GridSamplePadModeSequence = Union[Sequence[Union[GridSamplePadMode, str]], GridSamplePadMode, str] -InterpolateModeSequence = Union[Sequence[Union[InterpolateMode, str]], InterpolateMode, str] -PadModeSequence = Union[Sequence[Union[NumpyPadMode, PytorchPadMode, str]], NumpyPadMode, PytorchPadMode, str] -DEFAULT_POST_FIX = PostFix.meta() - class SpatialResampled(MapTransform, InvertibleTransform): """ @@ -169,17 +159,20 @@ class SpatialResampled(MapTransform, InvertibleTransform): backend = SpatialResample.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") + @deprecated_arg(name="meta_src_keys", since="0.9") def __init__( self, keys: KeysCollection, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", meta_src_keys: Optional[KeysCollection] = "src_affine", - meta_dst_keys: Optional[KeysCollection] = "dst_affine", + dst_keys: Optional[KeysCollection] = "dst_affine", allow_missing_keys: bool = False, ) -> None: """ @@ -200,17 +193,7 @@ def __init__( If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. It also can be a sequence of dtypes, each element corresponds to a key in ``keys``. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, affine, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys=None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - For example, to handle key `image`, read/write affine matrices from the - metadata `image_meta_dict` dictionary's `affine` field. - meta_src_keys: the key of the corresponding ``src_affine`` in the metadata dictionary. - meta_dst_keys: the key of the corresponding ``dst_affine`` in the metadata dictionary. + dst_keys: the key of the corresponding ``dst_affine`` in the metadata dictionary. allow_missing_keys: don't raise exception if key is missing. """ super().__init__(keys, allow_missing_keys) @@ -219,90 +202,28 @@ def __init__( self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.dtype = ensure_tuple_rep(dtype, len(self.keys)) - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - self.meta_src_keys = ensure_tuple_rep(meta_src_keys, len(self.keys)) - self.meta_dst_keys = ensure_tuple_rep(meta_dst_keys, len(self.keys)) - - def __call__( - self, data: Mapping[Union[Hashable, str], Dict[str, NdarrayOrTensor]] - ) -> Dict[Hashable, NdarrayOrTensor]: + self.dst_keys = ensure_tuple_rep(dst_keys, len(self.keys)) + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d: Dict = dict(data) - for (key, mode, padding_mode, align_corners, dtype, *metakeyinfo) in self.key_iterator( - d, - self.mode, - self.padding_mode, - self.align_corners, - self.dtype, - self.meta_keys, - self.meta_key_postfix, - self.meta_src_keys, - self.meta_dst_keys, + for (key, mode, padding_mode, align_corners, dtype, dst_key) in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype, self.dst_keys ): - meta_key, meta_key_postfix, meta_src_key, meta_dst_key = metakeyinfo - meta_key = meta_key or f"{key}_{meta_key_postfix}" - # create metadata if necessary - if meta_key not in d: - d[meta_key] = {meta_src_key: None, meta_dst_key: None} - meta_data = d[meta_key] - original_spatial_shape = d[key].shape[1:] - d[key], meta_data[meta_dst_key] = self.sp_transform( # write dst affine because the dtype might change + d[key] = self.sp_transform( img=d[key], - src_affine=meta_data[meta_src_key], - dst_affine=meta_data[meta_dst_key], + dst_affine=d[dst_key], spatial_size=None, # None means shape auto inferred mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype, ) - meta_data[meta_dst_key], meta_data[meta_src_key] = meta_data[meta_src_key], meta_data[meta_dst_key] - self.push_transform( - d, - key, - extra_info={ - "meta_key": meta_key, - "meta_src_key": meta_src_key, - "meta_dst_key": meta_dst_key, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - orig_size=original_spatial_shape, - ) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) - for key, dtype in self.key_iterator(d, self.dtype): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - meta_data = d[transform[TraceKeys.EXTRA_INFO]["meta_key"]] - src_key = transform[TraceKeys.EXTRA_INFO]["meta_src_key"] - dst_key = transform[TraceKeys.EXTRA_INFO]["meta_dst_key"] - src_affine = meta_data[src_key] - dst_affine = meta_data[dst_key] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - orig_size = transform[TraceKeys.ORIG_SIZE] - inverse_transform = SpatialResample() - # Apply inverse - d[key], dst_affine = inverse_transform( - img=d[key], - src_affine=src_affine, - dst_affine=dst_affine, - mode=mode, - padding_mode=padding_mode, - align_corners=False if align_corners == TraceKeys.NONE else align_corners, - dtype=dtype, - spatial_size=orig_size, - ) - meta_data[src_key], meta_data[dst_key] = dst_affine, meta_data[src_key] # type: ignore - # Remove the applied transform - self.pop_transform(d, key) + for key in self.key_iterator(d): + d[key] = self.sp_transform.inverse(d[key]) return d @@ -311,12 +232,14 @@ class ResampleToMatchd(MapTransform, InvertibleTransform): backend = ResampleToMatch.backend + @deprecated_arg(name="template_key", since="0.9") def __init__( self, keys: KeysCollection, - template_key: str, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, + key_dst: str, + template_key: Optional[str] = None, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, allow_missing_keys: bool = False, @@ -324,7 +247,7 @@ def __init__( """ Args: keys: keys of the corresponding items to be transformed. - template_key: key to metadata that output should be resampled to match. + key_dst: key of image to resample to match. mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -343,79 +266,32 @@ def __init__( allow_missing_keys: don't raise exception if key is missing. """ super().__init__(keys, allow_missing_keys) - self.template_key = template_key + self.key_dst = key_dst self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.dtype = ensure_tuple_rep(dtype, len(self.keys)) self.resampler = ResampleToMatch() - def __call__(self, data): + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) - dst_meta = d[self.template_key] for (key, mode, padding_mode, align_corners, dtype) in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners, self.dtype ): - src_meta_key = PostFix.meta(key) - src_meta = d[src_meta_key] - - orig_spatial_shape = d[key].shape[1:] - orig_meta = deepcopy(src_meta) - - img, new_meta = self.resampler( + d[key] = self.resampler( img=d[key], - src_meta=src_meta, - dst_meta=dst_meta, + img_dst=d[self.key_dst], mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype, ) - d[key] = img - d[src_meta_key] = new_meta - - # track the transform for the inverse - self.push_transform( - d, - key, - extra_info={ - "orig_meta": orig_meta, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - orig_size=orig_spatial_shape, - ) - return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) - for key, dtype in self.key_iterator(d, self.dtype): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - orig_meta = transform[TraceKeys.EXTRA_INFO]["orig_meta"] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - - src_meta_key = PostFix.meta(key) - src_meta = d[src_meta_key] - - img, new_meta = self.resampler( - img=d[key], - src_meta=src_meta, # type: ignore - dst_meta=orig_meta, - mode=mode, - padding_mode=padding_mode, - align_corners=align_corners, - dtype=dtype, - ) - d[key] = img - d[src_meta_key] = new_meta # type: ignore - - # Remove the applied transform - self.pop_transform(d, key) + for key in self.key_iterator(d): + d[key] = self.resampler.inverse(d[key]) return d @@ -435,17 +311,19 @@ class Spacingd(MapTransform, InvertibleTransform): backend = Spacing.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") def __init__( self, keys: KeysCollection, pixdim: Union[Sequence[float], float], diagonal: bool = False, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: """ @@ -484,20 +362,8 @@ def __init__( If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. It also can be a sequence of dtypes, each element corresponds to a key in ``keys``. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, affine, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys=None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - For example, to handle key `image`, read/write affine matrices from the - metadata `image_meta_dict` dictionary's `affine` field. allow_missing_keys: don't raise exception if key is missing. - Raises: - TypeError: When ``meta_key_postfix`` is not a ``str``. - """ super().__init__(keys, allow_missing_keys) self.spacing_transform = Spacing(pixdim, diagonal=diagonal) @@ -505,82 +371,22 @@ def __init__( self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.dtype = ensure_tuple_rep(dtype, len(self.keys)) - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - - def __call__( - self, data: Mapping[Union[Hashable, str], Dict[str, NdarrayOrTensor]] - ) -> Dict[Hashable, NdarrayOrTensor]: + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d: Dict = dict(data) - for key, mode, padding_mode, align_corners, dtype, meta_key, meta_key_postfix in self.key_iterator( - d, self.mode, self.padding_mode, self.align_corners, self.dtype, self.meta_keys, self.meta_key_postfix + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype ): - meta_key = meta_key or f"{key}_{meta_key_postfix}" - # create metadata if necessary - if meta_key not in d: - d[meta_key] = {"affine": None} - meta_data = d[meta_key] # resample array of each corresponding key - # using affine fetched from d[affine_key] - original_spatial_shape = d[key].shape[1:] - d[key], old_affine, new_affine = self.spacing_transform( - data_array=d[key], - affine=meta_data["affine"], - mode=mode, - padding_mode=padding_mode, - align_corners=align_corners, - dtype=dtype, + d[key] = self.spacing_transform( + data_array=d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype ) - self.push_transform( - d, - key, - extra_info={ - "meta_key": meta_key, - "old_affine": old_affine, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - orig_size=original_spatial_shape, - ) - # set the 'affine' key - meta_data["affine"] = new_affine return d def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) - for key, dtype in self.key_iterator(d, self.dtype): - transform = self.get_most_recent_transform(d, key) - if self.spacing_transform.diagonal: - raise RuntimeError( - "Spacingd:inverse not yet implemented for diagonal=True. " - + "Please raise a github issue if you need this feature" - ) - # Create inverse transform - meta_data = d[transform[TraceKeys.EXTRA_INFO]["meta_key"]] - old_affine = np.array(transform[TraceKeys.EXTRA_INFO]["old_affine"]) - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - orig_size = transform[TraceKeys.ORIG_SIZE] - orig_pixdim = affine_to_spacing(old_affine, -1) - inverse_transform = Spacing(orig_pixdim, diagonal=self.spacing_transform.diagonal) - # Apply inverse - d[key], _, new_affine = inverse_transform( - data_array=d[key], - affine=meta_data["affine"], # type: ignore - mode=mode, - padding_mode=padding_mode, - align_corners=False if align_corners == TraceKeys.NONE else align_corners, - dtype=dtype, - output_spatial_shape=orig_size, - ) - meta_data["affine"] = new_affine # type: ignore - # Remove the applied transform - self.pop_transform(d, key) - + for key in self.key_iterator(d): + d[key] = self.spacing_transform.inverse(d[key]) return d @@ -588,12 +394,6 @@ class Orientationd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Orientation`. - This transform assumes the ``data`` dictionary has a key for the input - data's metadata and contains `affine` field. The key is formed by ``key_{meta_key_postfix}``. - - After reorienting the input array, this transform will write the new affine - to the `affine` field of metadata which is formed by ``key_{meta_key_postfix}``. - This transform assumes the channel-first input format. In the case of using this transform for normalizing the orientations of images, it should be used before any anisotropic spatial transforms. @@ -601,6 +401,8 @@ class Orientationd(MapTransform, InvertibleTransform): backend = Orientation.backend + @deprecated_arg(name="meta_keys", since="0.9") + @deprecated_arg(name="meta_key_postfix", since="0.9") def __init__( self, keys: KeysCollection, @@ -608,7 +410,7 @@ def __init__( as_closest_canonical: bool = False, labels: Optional[Sequence[Tuple[str, str]]] = (("L", "R"), ("P", "A"), ("I", "S")), meta_keys: Optional[KeysCollection] = None, - meta_key_postfix: str = DEFAULT_POST_FIX, + meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: """ @@ -622,65 +424,25 @@ def __init__( labels: optional, None or sequence of (2,) sequences (2,) sequences are labels for (beginning, end) of output axis. Defaults to ``(('L', 'R'), ('P', 'A'), ('I', 'S'))``. - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, affine, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None, use `key_{postfix}` to fetch the metadata according - to the key data, default is `meta_dict`, the metadata is a dictionary object. - For example, to handle key `image`, read/write affine matrices from the - metadata `image_meta_dict` dictionary's `affine` field. allow_missing_keys: don't raise exception if key is missing. - Raises: - TypeError: When ``meta_key_postfix`` is not a ``str``. - See Also: `nibabel.orientations.ornt2axcodes`. """ super().__init__(keys, allow_missing_keys) self.ornt_transform = Orientation(axcodes=axcodes, as_closest_canonical=as_closest_canonical, labels=labels) - if not isinstance(meta_key_postfix, str): - raise TypeError(f"meta_key_postfix must be a str but is {type(meta_key_postfix).__name__}.") - self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) - if len(self.keys) != len(self.meta_keys): - raise ValueError("meta_keys should have the same length as keys.") - self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - - def __call__( - self, data: Mapping[Union[Hashable, str], Dict[str, NdarrayOrTensor]] - ) -> Dict[Hashable, NdarrayOrTensor]: + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d: Dict = dict(data) - for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix): - meta_key = meta_key or f"{key}_{meta_key_postfix}" - # create metadata if necessary - if meta_key not in d: - d[meta_key] = {"affine": None} - meta_data = d[meta_key] - d[key], old_affine, new_affine = self.ornt_transform(d[key], affine=meta_data["affine"]) - self.push_transform(d, key, extra_info={"meta_key": meta_key, "old_affine": old_affine}) - d[meta_key]["affine"] = new_affine + for key in self.key_iterator(d): + d[key] = self.ornt_transform(d[key]) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - meta_data: Dict = d[transform[TraceKeys.EXTRA_INFO]["meta_key"]] # type: ignore - orig_affine = transform[TraceKeys.EXTRA_INFO]["old_affine"] - orig_axcodes = nib.orientations.aff2axcodes(orig_affine) - inverse_transform = Orientation( - axcodes=orig_axcodes, as_closest_canonical=False, labels=self.ornt_transform.labels - ) - # Apply inverse - d[key], _, new_affine = inverse_transform(d[key], affine=meta_data["affine"]) - meta_data["affine"] = new_affine - # Remove the applied transform - self.pop_transform(d, key) - + d[key] = self.ornt_transform.inverse(d[key]) return d @@ -704,27 +466,16 @@ def __init__( super().__init__(keys, allow_missing_keys) self.rotator = Rotate90(k, spatial_axes) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key in self.key_iterator(d): - self.push_transform(d, key) d[key] = self.rotator(d[key]) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - _ = self.get_most_recent_transform(d, key) - # Create inverse transform - spatial_axes = self.rotator.spatial_axes - num_times_rotated = self.rotator.k - num_times_to_rotate = 4 - num_times_rotated - inverse_transform = Rotate90(num_times_to_rotate, spatial_axes) - # Apply inverse - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - + d[key] = self.rotator.inverse(d[key]) return d @@ -769,7 +520,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self._rand_k = self.R.randint(self.max_k) + 1 super().randomize(None) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Mapping[Hashable, torch.Tensor]: self.randomize() d = dict(data) @@ -777,26 +528,20 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Mapping[Hashable # to be compatible with the random status of some previous integration tests rotator = Rotate90(self._rand_k, self.spatial_axes) for key in self.key_iterator(d): - if self._do_transform: - d[key] = rotator(d[key]) - self.push_transform(d, key, extra_info={"rand_k": self._rand_k}) + d[key] = rotator(d[key]) if self._do_transform else convert_to_tensor(d[key], track_meta=get_track_meta()) + if get_track_meta(): + xform = self.pop_transform(d[key], check=False) if self._do_transform else {} + self.push_transform(d[key], extra_info=xform) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = dict(data) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Check if random transform was actually performed (based on `prob`) - if transform[TraceKeys.DO_TRANSFORM]: - # Create inverse transform - num_times_rotated = transform[TraceKeys.EXTRA_INFO]["rand_k"] - num_times_to_rotate = 4 - num_times_rotated - inverse_transform = Rotate90(num_times_to_rotate, self.spatial_axes) - # Apply inverse - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - + if not isinstance(d[key], MetaTensor): + continue + xform = self.pop_transform(d[key]) + if xform[TraceKeys.DO_TRANSFORM]: + d[key] = Rotate90().inverse_transform(d[key], xform[TraceKeys.EXTRA_INFO]) return d @@ -834,7 +579,7 @@ def __init__( keys: KeysCollection, spatial_size: Union[Sequence[int], int], size_mode: str = "all", - mode: InterpolateModeSequence = InterpolateMode.AREA, + mode: SequenceStr = InterpolateMode.AREA, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, allow_missing_keys: bool = False, ) -> None: @@ -843,38 +588,16 @@ def __init__( self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.resizer = Resize(spatial_size=spatial_size, size_mode=size_mode) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): - self.push_transform( - d, - key, - extra_info={ - "mode": mode.value if isinstance(mode, Enum) else mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - ) d[key] = self.resizer(d[key], mode=mode, align_corners=align_corners) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - orig_size = transform[TraceKeys.ORIG_SIZE] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - # Create inverse transform - inverse_transform = Resize( - spatial_size=orig_size, - mode=mode, - align_corners=None if align_corners == TraceKeys.NONE else align_corners, - ) - # Apply inverse transform - d[key] = inverse_transform(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - + d[key] = self.resizer.inverse(d[key]) return d @@ -895,8 +618,8 @@ def __init__( scale_params: Optional[Union[Sequence[float], float]] = None, affine: Optional[NdarrayOrTensor] = None, spatial_size: Optional[Union[Sequence[int], int]] = None, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, as_tensor_output: bool = True, device: Optional[torch.device] = None, dtype: Union[DtypeLike, torch.dtype] = np.float32, @@ -966,44 +689,16 @@ def __init__( self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): - orig_size = d[key].shape[1:] - d[key], affine = self.affine(d[key], mode=mode, padding_mode=padding_mode) - self.push_transform( - d, - key, - orig_size=orig_size, - extra_info={ - "affine": affine, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - }, - ) + d[key], _ = self.affine(d[key], mode=mode, padding_mode=padding_mode) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) - for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - orig_size = transform[TraceKeys.ORIG_SIZE] - # Create inverse transform - fwd_affine = transform[TraceKeys.EXTRA_INFO]["affine"] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - inv_affine = np.linalg.inv(fwd_affine) - - affine_grid = AffineGrid(affine=inv_affine) - grid, _ = affine_grid(orig_size) - - # Apply inverse transform - d[key] = self.affine.resampler(d[key], grid, mode, padding_mode) - - # Remove the applied transform - self.pop_transform(d, key) - + d[key] = self.affine.inverse(d[key]) return d @@ -1024,8 +719,8 @@ def __init__( shear_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, translate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, scale_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, cache_grid: bool = False, as_tensor_output: bool = True, device: Optional[torch.device] = None, @@ -1112,64 +807,41 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: - return d + out: Dict[Hashable, NdarrayOrTensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out self.randomize(None) # all the keys share the same random Affine factor self.rand_affine.randomize() - device = self.rand_affine.resampler.device spatial_size = d[first_key].shape[1:] # type: ignore sp_size = fall_back_tuple(self.rand_affine.spatial_size, spatial_size) # change image size or do random transform do_resampling = self._do_transform or (sp_size != ensure_tuple(spatial_size)) - affine: torch.Tensor = torch.eye(len(sp_size) + 1, dtype=torch.float64, device=device) # converting affine to tensor because the resampler currently only support torch backend grid = None if do_resampling: # need to prepare grid grid = self.rand_affine.get_identity_grid(sp_size) if self._do_transform: # add some random factors grid = self.rand_affine.rand_affine_grid(grid=grid) - affine = self.rand_affine.rand_affine_grid.get_transformation_matrix() for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): - self.push_transform( - d, - key, - extra_info={ - "affine": affine, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - }, - ) # do the transform if do_resampling: - d[key] = self.rand_affine.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) - + d[key] = self.rand_affine(d[key], mode=mode, padding_mode=padding_mode, grid=grid) + if get_track_meta(): + xform = self.pop_transform(d[key], check=False) if do_resampling else {} + self.push_transform(d[key], extra_info={"do_resampling": do_resampling, "rand_affine_info": xform}) return d def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) - + d = dict(data) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # if transform was not performed and spatial size is None, nothing to do. - if transform[TraceKeys.DO_TRANSFORM] or self.rand_affine.spatial_size is not None: - orig_size = transform[TraceKeys.ORIG_SIZE] - # Create inverse transform - fwd_affine = transform[TraceKeys.EXTRA_INFO]["affine"] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - inv_affine = np.linalg.inv(fwd_affine) - - affine_grid = AffineGrid(affine=inv_affine) - grid, _ = affine_grid(orig_size) - - # Apply inverse transform - d[key] = self.rand_affine.resampler(d[key], grid, mode, padding_mode) - - # Remove the applied transform - self.pop_transform(d, key) + tr = self.pop_transform(d[key]) + do_resampling = tr[TraceKeys.EXTRA_INFO]["do_resampling"] + if do_resampling: + d[key].applied_operations.append(tr[TraceKeys.EXTRA_INFO]["rand_affine_info"]) # type: ignore + d[key] = self.rand_affine.inverse(d[key]) return d @@ -1193,8 +865,8 @@ def __init__( shear_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, translate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, scale_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, allow_missing_keys: bool = False, @@ -1280,7 +952,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d = dict(data) first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: - return d + out: Dict[Hashable, NdarrayOrTensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out self.randomize(None) @@ -1291,7 +964,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N if self._do_transform: grid = self.rand_2d_elastic.deform_grid(spatial_size=sp_size) grid = self.rand_2d_elastic.rand_affine_grid(grid=grid) - grid = torch.nn.functional.interpolate( # type: ignore + grid = torch.nn.functional.interpolate( recompute_scale_factor=True, input=grid.unsqueeze(0), scale_factor=ensure_tuple_rep(self.rand_2d_elastic.deform_grid.spacing, 2), @@ -1327,8 +1000,8 @@ def __init__( shear_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, translate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, scale_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, allow_missing_keys: bool = False, @@ -1412,11 +1085,12 @@ def set_random_state( super().set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: - return d + out: Dict[Hashable, torch.Tensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out self.randomize(None) @@ -1462,22 +1136,16 @@ def __init__( super().__init__(keys, allow_missing_keys) self.flipper = Flip(spatial_axis=spatial_axis) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key in self.key_iterator(d): - self.push_transform(d, key) d[key] = self.flipper(d[key]) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - _ = self.get_most_recent_transform(d, key) - # Inverse is same as forward - d[key] = self.flipper(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - + d[key] = self.flipper.inverse(d[key]) return d @@ -1495,7 +1163,7 @@ class RandFlipd(RandomizableTransform, MapTransform, InvertibleTransform): allow_missing_keys: don't raise exception if key is missing. """ - backend = RandFlip.backend + backend = Flip.backend def __init__( self, @@ -1506,35 +1174,36 @@ def __init__( ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) - self.flipper = RandFlip(prob=1.0, spatial_axis=spatial_axis) + self.flipper = Flip(spatial_axis=spatial_axis) def set_random_state( self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None ) -> "RandFlipd": super().set_random_state(seed, state) - self.flipper.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) self.randomize(None) for key in self.key_iterator(d): if self._do_transform: - d[key] = self.flipper(d[key], randomize=False) - self.push_transform(d, key) + d[key] = self.flipper(d[key]) + else: + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + if get_track_meta(): + xform_info = self.pop_transform(d[key], check=False) if self._do_transform else {} + self.push_transform(d[key], extra_info=xform_info) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Check if random transform was actually performed (based on `prob`) - if transform[TraceKeys.DO_TRANSFORM]: - # Inverse is same as forward - d[key] = self.flipper(d[key], randomize=False) - # Remove the applied transform - self.pop_transform(d, key) + xform = self.pop_transform(d[key]) + if not xform[TraceKeys.DO_TRANSFORM]: + continue + with self.flipper.trace_transform(False): + d[key] = self.flipper(d[key]) return d @@ -1566,7 +1235,7 @@ def set_random_state( self.flipper.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: @@ -1579,20 +1248,20 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N for key in self.key_iterator(d): if self._do_transform: d[key] = self.flipper(d[key], randomize=False) - self.push_transform(d, key, extra_info={"axis": self.flipper._axis}) + else: + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + if get_track_meta(): + xform = self.pop_transform(d[key], check=False) if self._do_transform else {} + self.push_transform(d[key], extra_info=xform) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: - d = deepcopy(dict(data)) + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = dict(data) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Check if random transform was actually performed (based on `prob`) - if transform[TraceKeys.DO_TRANSFORM]: - flipper = Flip(spatial_axis=transform[TraceKeys.EXTRA_INFO]["axis"]) - # Inverse is same as forward - d[key] = flipper(d[key]) - # Remove the applied transform - self.pop_transform(d, key) + xform = self.pop_transform(d[key]) + if xform[TraceKeys.DO_TRANSFORM]: + d[key].applied_operations.append(xform[TraceKeys.EXTRA_INFO]) # type: ignore + d[key] = self.flipper.inverse(d[key]) return d @@ -1631,8 +1300,8 @@ def __init__( keys: KeysCollection, angle: Union[Sequence[float], float], keep_size: bool = True, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[Union[DtypeLike, torch.dtype]], DtypeLike, torch.dtype] = np.float32, allow_missing_keys: bool = False, @@ -1645,56 +1314,20 @@ def __init__( self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.dtype = ensure_tuple_rep(dtype, len(self.keys)) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, mode, padding_mode, align_corners, dtype in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners, self.dtype ): - orig_size = d[key].shape[1:] d[key] = self.rotator( d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype ) - rot_mat = self.rotator.get_rotation_matrix() - self.push_transform( - d, - key, - orig_size=orig_size, - extra_info={ - "rot_mat": rot_mat, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - ) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) - for key, dtype in self.key_iterator(d, self.dtype): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - fwd_rot_mat = transform[TraceKeys.EXTRA_INFO]["rot_mat"] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - inv_rot_mat = np.linalg.inv(fwd_rot_mat) - - xform = AffineTransform( - normalized=False, - mode=mode, - padding_mode=padding_mode, - align_corners=False if align_corners == TraceKeys.NONE else align_corners, - reverse_indexing=True, - ) - img_t, *_ = convert_data_type(d[key], torch.Tensor, dtype=dtype) - transform_t, *_ = convert_to_dst_type(inv_rot_mat, img_t) - - out = xform(img_t.unsqueeze(0), transform_t, spatial_size=transform[TraceKeys.ORIG_SIZE]).squeeze(0) - out, *_ = convert_to_dst_type(out, dst=d[key], dtype=out.dtype) - d[key] = out - # Remove the applied transform - self.pop_transform(d, key) - + for key in self.key_iterator(d): + d[key] = self.rotator.inverse(d[key]) return d @@ -1743,8 +1376,8 @@ def __init__( range_z: Union[Tuple[float, float], float] = 0.0, prob: float = 0.1, keep_size: bool = True, - mode: GridSampleModeSequence = GridSampleMode.BILINEAR, - padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, + mode: SequenceStr = GridSampleMode.BILINEAR, + padding_mode: SequenceStr = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[Union[DtypeLike, torch.dtype]], DtypeLike, torch.dtype] = np.float32, allow_missing_keys: bool = False, @@ -1764,7 +1397,7 @@ def set_random_state( self.rand_rotate.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) self.randomize(None) @@ -1774,59 +1407,28 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d, self.mode, self.padding_mode, self.align_corners, self.dtype ): if self._do_transform: - d[key], rot_mat = self.rand_rotate( + d[key] = self.rand_rotate( d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype, randomize=False, - get_matrix=True, ) else: - rot_mat = np.eye(d[key].ndim) - self.push_transform( - d, - key, - orig_size=d[key].shape[1:], - extra_info={ - "rot_mat": rot_mat, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - ) + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + if get_track_meta(): + rot_info = self.pop_transform(d[key], check=False) if self._do_transform else {} + self.push_transform(d[key], extra_info=rot_info) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) - for key, dtype in self.key_iterator(d, self.dtype): - transform = self.get_most_recent_transform(d, key) - # Check if random transform was actually performed (based on `prob`) - if transform[TraceKeys.DO_TRANSFORM]: - # Create inverse transform - fwd_rot_mat = transform[TraceKeys.EXTRA_INFO]["rot_mat"] - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - inv_rot_mat = np.linalg.inv(fwd_rot_mat) - - xform = AffineTransform( - normalized=False, - mode=mode, - padding_mode=padding_mode, - align_corners=False if align_corners == TraceKeys.NONE else align_corners, - reverse_indexing=True, - ) - img_t, *_ = convert_data_type(d[key], torch.Tensor, dtype=dtype) - transform_t, *_ = convert_to_dst_type(inv_rot_mat, img_t) - output: torch.Tensor - out = xform(img_t.unsqueeze(0), transform_t, spatial_size=transform[TraceKeys.ORIG_SIZE]).squeeze(0) - out, *_ = convert_to_dst_type(out, dst=d[key], dtype=out.dtype) - d[key] = out - # Remove the applied transform - self.pop_transform(d, key) - + for key in self.key_iterator(d): + xform = self.pop_transform(d[key]) + if xform[TraceKeys.DO_TRANSFORM]: + d[key].applied_operations.append(xform[TraceKeys.EXTRA_INFO]) # type: ignore + d[key] = self.rand_rotate.inverse(d[key]) return d @@ -1867,8 +1469,8 @@ def __init__( self, keys: KeysCollection, zoom: Union[Sequence[float], float], - mode: InterpolateModeSequence = InterpolateMode.AREA, - padding_mode: PadModeSequence = NumpyPadMode.EDGE, + mode: SequenceStr = InterpolateMode.AREA, + padding_mode: SequenceStr = NumpyPadMode.EDGE, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, allow_missing_keys: bool = False, @@ -1880,45 +1482,18 @@ def __init__( self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.zoomer = Zoom(zoom=zoom, keep_size=keep_size, **kwargs) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): - self.push_transform( - d, - key, - extra_info={ - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - ) d[key] = self.zoomer(d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Create inverse transform - zoom = np.array(self.zoomer.zoom) - inverse_transform = Zoom(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size) - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - # Apply inverse - d[key] = inverse_transform( - d[key], - mode=mode, - padding_mode=padding_mode, - align_corners=None if align_corners == TraceKeys.NONE else align_corners, - ) - # Size might be out by 1 voxel so pad - d[key] = SpatialPad(transform[TraceKeys.ORIG_SIZE], mode="edge")(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - + d[key] = self.zoomer.inverse(d[key]) return d @@ -1969,8 +1544,8 @@ def __init__( prob: float = 0.1, min_zoom: Union[Sequence[float], float] = 0.9, max_zoom: Union[Sequence[float], float] = 1.1, - mode: InterpolateModeSequence = InterpolateMode.AREA, - padding_mode: PadModeSequence = NumpyPadMode.EDGE, + mode: SequenceStr = InterpolateMode.AREA, + padding_mode: SequenceStr = NumpyPadMode.EDGE, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, allow_missing_keys: bool = False, @@ -1990,11 +1565,12 @@ def set_random_state( self.rand_zoom.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: - return d + out: Dict[Hashable, torch.Tensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out self.randomize(None) @@ -2007,42 +1583,20 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N d[key] = self.rand_zoom( d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, randomize=False ) - self.push_transform( - d, - key, - extra_info={ - "zoom": self.rand_zoom._zoom, - "mode": mode.value if isinstance(mode, Enum) else mode, - "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, - "align_corners": align_corners if align_corners is not None else TraceKeys.NONE, - }, - ) + else: + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + if get_track_meta(): + xform = self.pop_transform(d[key], check=False) if self._do_transform else {} + self.push_transform(d[key], extra_info=xform) return d - def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) - # Check if random transform was actually performed (based on `prob`) - if transform[TraceKeys.DO_TRANSFORM]: - # Create inverse transform - zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"]) - mode = transform[TraceKeys.EXTRA_INFO]["mode"] - padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"] - align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"] - inverse_transform = Zoom(zoom=(1 / zoom).tolist(), keep_size=self.rand_zoom.keep_size) - # Apply inverse - d[key] = inverse_transform( - d[key], - mode=mode, - padding_mode=padding_mode, - align_corners=None if align_corners == TraceKeys.NONE else align_corners, - ) - # Size might be out by 1 voxel so pad - d[key] = SpatialPad(transform[TraceKeys.ORIG_SIZE], mode="edge")(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - + xform = self.pop_transform(d[key]) + if xform[TraceKeys.DO_TRANSFORM]: + d[key].applied_operations.append(xform[TraceKeys.EXTRA_INFO]) # type: ignore + d[key] = self.rand_zoom.inverse(d[key]) return d @@ -2058,8 +1612,8 @@ def __init__( keys: KeysCollection, num_cells: Union[Tuple[int], int], distort_steps: List[Tuple], - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, device: Optional[torch.device] = None, allow_missing_keys: bool = False, ) -> None: @@ -2087,7 +1641,7 @@ def __init__( self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): d[key] = self.grid_distortion(d[key], mode=mode, padding_mode=padding_mode) @@ -2107,8 +1661,8 @@ def __init__( num_cells: Union[Tuple[int], int] = 5, prob: float = 0.1, distort_limit: Union[Tuple[float, float], float] = (-0.03, 0.03), - mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, - padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, + mode: str = GridSampleMode.BILINEAR, + padding_mode: str = GridSamplePadMode.BORDER, device: Optional[torch.device] = None, allow_missing_keys: bool = False, ) -> None: @@ -2147,15 +1701,17 @@ def set_random_state( self.rand_grid_distortion.set_random_state(seed, state) return self - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) self.randomize(None) if not self._do_transform: - return d + out: Dict[Hashable, torch.Tensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out first_key: Union[Hashable, List] = self.first_key(d) if first_key == []: - return d + out = convert_to_tensor(d, track_meta=get_track_meta()) + return out self.rand_grid_distortion.randomize(d[first_key].shape[1:]) # type: ignore for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): @@ -2245,7 +1801,7 @@ def __init__( overlap: float = 0.0, sort_fn: Optional[str] = None, threshold: Optional[float] = None, - pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + pad_mode: str = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, ): @@ -2328,7 +1884,7 @@ def __init__( overlap: float = 0.0, sort_fn: Optional[str] = None, threshold: Optional[float] = None, - pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, + pad_mode: str = PytorchPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, ): diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index eaa2ae3c07..8f84eb2531 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -17,6 +17,7 @@ import sys import time import warnings +from copy import deepcopy from typing import Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np @@ -24,6 +25,10 @@ from monai.config import DtypeLike from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_obj import get_track_meta +from monai.data.meta_tensor import MetaTensor +from monai.data.utils import no_collation +from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( extreme_points_to_image, @@ -33,6 +38,7 @@ ) from monai.transforms.utils_pytorch_numpy_unification import concatenate, in1d, moveaxis, unravel_indices from monai.utils import ( + TraceKeys, convert_data_type, convert_to_cupy, convert_to_numpy, @@ -133,7 +139,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - return moveaxis(img, self.channel_dim, 0) + out: NdarrayOrTensor = convert_to_tensor(moveaxis(img, self.channel_dim, 0), track_meta=get_track_meta()) + return out class AsChannelLast(Transform): @@ -162,7 +169,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - return moveaxis(img, self.channel_dim, -1) + out: NdarrayOrTensor = convert_to_tensor(moveaxis(img, self.channel_dim, -1), track_meta=get_track_meta()) + return out class AddChannel(Transform): @@ -185,7 +193,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - return img[None] + out: NdarrayOrTensor = convert_to_tensor(img[None], track_meta=get_track_meta()) + return out class EnsureChannelFirst(Transform): @@ -206,18 +215,19 @@ def __init__(self, strict_check: bool = True): self.strict_check = strict_check self.add_channel = AddChannel() - def __call__(self, img: NdarrayOrTensor, meta_dict: Optional[Mapping] = None) -> NdarrayOrTensor: + def __call__(self, img: torch.Tensor, meta_dict: Optional[Mapping] = None) -> torch.Tensor: """ Apply the transform to `img`. """ - if not isinstance(meta_dict, Mapping): - msg = "meta_dict not available, EnsureChannelFirst is not in use." + if not isinstance(img, MetaTensor) and not isinstance(meta_dict, Mapping): + msg = "metadata not available, EnsureChannelFirst is not in use." if self.strict_check: raise ValueError(msg) warnings.warn(msg) return img - - channel_dim = meta_dict.get("original_channel_dim") + if isinstance(img, MetaTensor): + meta_dict = img.meta + channel_dim = meta_dict.get("original_channel_dim") # type: ignore if channel_dim is None: msg = "Unknown original_channel_dim in the meta_dict, EnsureChannelFirst is not in use." @@ -226,8 +236,8 @@ def __call__(self, img: NdarrayOrTensor, meta_dict: Optional[Mapping] = None) -> warnings.warn(msg) return img if channel_dim == "no_channel": - return self.add_channel(img) - return AsChannelFirst(channel_dim=channel_dim)(img) + return self.add_channel(img) # type: ignore + return AsChannelFirst(channel_dim=channel_dim)(img) # type: ignore class RepeatChannel(Transform): @@ -252,7 +262,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: Apply the transform to `img`, assuming `img` is a "channel-first" array. """ repeat_fn = torch.repeat_interleave if isinstance(img, torch.Tensor) else np.repeat - return repeat_fn(img, self.repeats, 0) # type: ignore + return convert_to_tensor(repeat_fn(img, self.repeats, 0), track_meta=get_track_meta()) # type: ignore class RemoveRepeatedChannel(Transform): @@ -280,7 +290,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: if img.shape[0] < 2: raise AssertionError("Image must have more than one channel") - return img[:: self.repeats, :] + out: NdarrayOrTensor = convert_to_tensor(img[:: self.repeats, :], track_meta=get_track_meta()) + return out class SplitDim(Transform): @@ -295,28 +306,40 @@ class SplitDim(Transform): dim: dimension on which to split keepdim: if `True`, output will have singleton in the split dimension. If `False`, this dimension will be squeezed. + update_meta: whether to update the MetaObj in each split result. """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, dim: int = -1, keepdim: bool = True) -> None: + def __init__(self, dim: int = -1, keepdim: bool = True, update_meta=True) -> None: self.dim = dim self.keepdim = keepdim + self.update_meta = update_meta - def __call__(self, img: NdarrayOrTensor) -> List[NdarrayOrTensor]: + def __call__(self, img: torch.Tensor) -> List[torch.Tensor]: """ Apply the transform to `img`. """ n_out = img.shape[self.dim] if n_out <= 1: - raise RuntimeError("Input image is singleton along dimension to be split.") + raise RuntimeError(f"Input image is singleton along dimension to be split, got shape {img.shape}.") if isinstance(img, torch.Tensor): outputs = list(torch.split(img, 1, self.dim)) else: - outputs = np.split(img, n_out, self.dim) # type: ignore - if not self.keepdim: - outputs = [o.squeeze(self.dim) for o in outputs] - return outputs # type: ignore + outputs = np.split(img, n_out, self.dim) + for idx, item in enumerate(outputs): + if not self.keepdim: + outputs[idx] = item.squeeze(self.dim) + if self.update_meta and isinstance(img, MetaTensor): + if not isinstance(item, MetaTensor): + item = MetaTensor(item, meta=deepcopy(img.meta)) + if self.dim == 0: # don't update affine if channel dim + continue + ndim = len(item.affine) + shift = torch.eye(ndim, device=item.affine.device, dtype=item.affine.dtype) + shift[self.dim - 1, -1] = idx + item.affine = item.affine @ shift + return outputs @deprecated(since="0.8", msg_suffix="please use `SplitDim` instead.") @@ -394,6 +417,8 @@ def __call__(self, img: NdarrayOrTensor): """ Apply the transform to `img` and make it contiguous. """ + if isinstance(img, MetaTensor): + img.applied_operations = [] # drops tracking info return convert_to_tensor(img, dtype=self.dtype, device=self.device, wrap_sequence=self.wrap_sequence) @@ -528,9 +553,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - if isinstance(img, torch.Tensor): - return img.permute(self.indices or tuple(range(img.ndim)[::-1])) - return img.transpose(self.indices) # type: ignore + img = convert_to_tensor(img, track_meta=get_track_meta()) + return img.permute(self.indices or tuple(range(img.ndim)[::-1])) # type: ignore class SqueezeDim(Transform): @@ -559,11 +583,12 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: Args: img: numpy arrays with required dimension `dim` removed """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if self.dim is None: return img.squeeze() # for pytorch/numpy unification if img.shape[self.dim] != 1: - raise ValueError("Can only squeeze singleton dimension") + raise ValueError(f"Can only squeeze singleton dimension, got shape {img.shape}.") return img.squeeze(self.dim) @@ -704,7 +729,7 @@ def __call__(self, img: NdarrayOrTensor, delay_time: Optional[float] = None) -> return img -class Lambda(Transform): +class Lambda(InvertibleTransform): """ Apply a user-defined lambda as a transform. @@ -720,6 +745,7 @@ class Lambda(Transform): Args: func: Lambda/function to be applied. + inv_func: Lambda/function of inverse operation, default to `lambda x: x`. Raises: TypeError: When ``func`` is not an ``Optional[Callable]``. @@ -728,10 +754,11 @@ class Lambda(Transform): backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, func: Optional[Callable] = None) -> None: + def __init__(self, func: Optional[Callable] = None, inv_func: Callable = no_collation) -> None: if func is not None and not callable(func): raise TypeError(f"func must be None or callable but is {type(func).__name__}.") self.func = func + self.inv_func = inv_func def __call__(self, img: NdarrayOrTensor, func: Optional[Callable] = None): """ @@ -742,16 +769,23 @@ def __call__(self, img: NdarrayOrTensor, func: Optional[Callable] = None): Raises: TypeError: When ``func`` is not an ``Optional[Callable]``. - ValueError: When ``func=None`` and ``self.func=None``. Incompatible values. """ - if func is not None: - if not callable(func): - raise TypeError(f"func must be None or callable but is {type(func).__name__}.") - return func(img) - if self.func is not None: - return self.func(img) - raise ValueError("Incompatible values: func=None and self.func=None.") + fn = func if func is not None else self.func + if not callable(fn): + raise TypeError(f"func must be None or callable but is {type(fn).__name__}.") + out = fn(img) + # convert to MetaTensor if necessary + if isinstance(out, (np.ndarray, torch.Tensor)) and not isinstance(out, MetaTensor) and get_track_meta(): + out = MetaTensor(out) + if isinstance(out, MetaTensor): + self.push_transform(out) + return out + + def inverse(self, data: torch.Tensor): + if isinstance(data, MetaTensor): + self.pop_transform(data) + return self.inv_func(data) class RandLambda(Lambda, RandomizableTransform): @@ -762,19 +796,35 @@ class RandLambda(Lambda, RandomizableTransform): Args: func: Lambda/function to be applied. prob: probability of executing the random function, default to 1.0, with 100% probability to execute. + inv_func: Lambda/function of inverse operation, default to `lambda x: x`. For more details, please check :py:class:`monai.transforms.Lambda`. """ backend = Lambda.backend - def __init__(self, func: Optional[Callable] = None, prob: float = 1.0) -> None: - Lambda.__init__(self=self, func=func) + def __init__(self, func: Optional[Callable] = None, prob: float = 1.0, inv_func: Callable = no_collation) -> None: + Lambda.__init__(self=self, func=func, inv_func=inv_func) RandomizableTransform.__init__(self=self, prob=prob) def __call__(self, img: NdarrayOrTensor, func: Optional[Callable] = None): self.randomize(img) - return super().__call__(img=img, func=func) if self._do_transform else img + out = deepcopy(super().__call__(img, func) if self._do_transform else img) + # convert to MetaTensor if necessary + if not isinstance(out, MetaTensor) and get_track_meta(): + out = MetaTensor(out) + if isinstance(out, MetaTensor): + lambda_info = self.pop_transform(out) if self._do_transform else {} + self.push_transform(out, extra_info=lambda_info) + return out + + def inverse(self, data: torch.Tensor): + do_transform = self.get_most_recent_transform(data).pop(TraceKeys.DO_TRANSFORM) + if do_transform: + data = super().inverse(data) + else: + self.pop_transform(data) + return data class LabelToMask(Transform): @@ -818,6 +868,7 @@ def __call__( merge_channels: whether to use `np.any()` to merge the result on channel dim. if yes, will return a single channel mask with binary data. """ + img = convert_to_tensor(img, track_meta=get_track_meta()) if select_labels is None: select_labels = self.select_labels else: @@ -1058,6 +1109,7 @@ def __call__(self, img: NdarrayOrTensor): """ img_t, *_ = convert_data_type(img, torch.Tensor) + out = self.trans(img_t) out, *_ = convert_to_dst_type(src=out, dst=img) return out diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 87d1becaa4..e5f4b2f058 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -24,6 +24,7 @@ from monai.config import DtypeLike, KeysCollection from monai.config.type_definitions import NdarrayOrTensor +from monai.data.meta_tensor import MetaTensor from monai.data.utils import no_collation from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform @@ -291,6 +292,8 @@ class EnsureChannelFirstd(MapTransform): backend = EnsureChannelFirst.backend + @deprecated_arg(name="meta_keys", since="0.9", msg_suffix="not needed if image is type `MetaTensor`.") + @deprecated_arg(name="meta_key_postfix", since="0.9", msg_suffix="not needed if image is type `MetaTensor`.") def __init__( self, keys: KeysCollection, @@ -302,14 +305,6 @@ def __init__( Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` - meta_keys: explicitly indicate the key of the corresponding metadata dictionary. - for example, for data with key `image`, the metadata by default is in `image_meta_dict`. - the metadata is a dictionary object which contains: filename, original_shape, etc. - it can be a sequence of string, map to the `keys`. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. - meta_key_postfix: if meta_keys is None and `key_{postfix}` was used to store the metadata in `LoadImaged`. - So need the key to extract metadata for channel dim information, default is `meta_dict`. - For example, for data with key `image`, metadata by default is in `image_meta_dict`. strict_check: whether to raise an error when the meta information is insufficient. """ @@ -318,10 +313,10 @@ def __init__( self.meta_keys = ensure_tuple_rep(meta_keys, len(self.keys)) self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - def __call__(self, data) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, meta_key, meta_key_postfix in zip(self.keys, self.meta_keys, self.meta_key_postfix): - d[key] = self.adjuster(d[key], d[meta_key or f"{key}_{meta_key_postfix}"]) + d[key] = self.adjuster(d[key], d.get(meta_key or f"{key}_{meta_key_postfix}")) # type: ignore return d @@ -402,33 +397,20 @@ def __init__( """ super().__init__(keys, allow_missing_keys) self.output_postfixes = output_postfixes - self.splitter = SplitDim(dim, keepdim) - self.update_meta = update_meta + self.splitter = SplitDim(dim, keepdim, update_meta) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key in self.key_iterator(d): rets = self.splitter(d[key]) postfixes: Sequence = list(range(len(rets))) if self.output_postfixes is None else self.output_postfixes if len(postfixes) != len(rets): - raise AssertionError("count of split results must match output_postfixes.") + raise ValueError(f"count of splits must match output_postfixes, {len(postfixes)} != {len(rets)}.") for i, r in enumerate(rets): split_key = f"{key}_{postfixes[i]}" if split_key in d: raise RuntimeError(f"input data already contains key {split_key}.") d[split_key] = r - - if self.update_meta: - orig_meta = d.get(PostFix.meta(key), None) - if orig_meta is not None: - split_meta_key = PostFix.meta(split_key) - d[split_meta_key] = deepcopy(orig_meta) - dim = self.splitter.dim - if dim > 0: # don't update affine if channel dim - shift = np.eye(len(d[split_meta_key]["affine"])) # type: ignore - shift[dim - 1, -1] = i # type: ignore - d[split_meta_key]["affine"] = d[split_meta_key]["affine"] @ shift # type: ignore - return d @@ -1012,6 +994,7 @@ class Lambdad(MapTransform, InvertibleTransform): print(lambd(input_data)['label'].shape) (4, 2, 2) + Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` @@ -1044,29 +1027,20 @@ def __init__( self.overwrite = ensure_tuple_rep(overwrite, len(self.keys)) self._lambd = Lambda() - def _transform(self, data: Any, func: Callable): - return self._lambd(data, func=func) - - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) for key, func, overwrite in self.key_iterator(d, self.func, self.overwrite): - ret = self._transform(data=d[key], func=func) + ret = self._lambd(img=d[key], func=func) if overwrite: d[key] = ret - self.push_transform(d, key) return d - def _inverse_transform(self, transform_info: Dict, data: Any, func: Callable): - return self._lambd(data, func=func) - def inverse(self, data): d = deepcopy(dict(data)) - for key, inv_func, overwrite in self.key_iterator(d, self.inv_func, self.overwrite): - transform = self.get_most_recent_transform(d, key) - ret = self._inverse_transform(transform_info=transform, data=d[key], func=inv_func) + for key, overwrite in self.key_iterator(d, self.overwrite): + ret = self._lambd.inverse(data=d[key]) if overwrite: d[key] = ret - self.pop_transform(d, key) return d @@ -1115,15 +1089,33 @@ def __init__( ) RandomizableTransform.__init__(self=self, prob=prob, do_transform=True) - def _transform(self, data: Any, func: Callable): - return self._lambd(data, func=func) if self._do_transform else data - def __call__(self, data): self.randomize(data) - return super().__call__(data) + d = dict(data) + for key, func, overwrite in self.key_iterator(d, self.func, self.overwrite): + ret = d[key] + if not isinstance(ret, MetaTensor): + ret = MetaTensor(ret) + if self._do_transform: + ret = self._lambd(ret, func=func) + self.push_transform(ret, extra_info={"lambda_info": self._lambd.pop_transform(ret)}) + else: + self.push_transform(ret) + if overwrite: + d[key] = ret + return d - def _inverse_transform(self, transform_info: Dict, data: Any, func: Callable): - return self._lambd(data, func=func) if transform_info[TraceKeys.DO_TRANSFORM] else data + def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = deepcopy(dict(data)) + for key, overwrite in self.key_iterator(d, self.overwrite): + if isinstance(d[key], MetaTensor): + tr = self.pop_transform(d[key]) + if tr[TraceKeys.DO_TRANSFORM]: + d[key].applied_operations.append(tr[TraceKeys.EXTRA_INFO]["lambda_info"]) # type: ignore + ret = self._lambd.inverse(d[key]) + if overwrite: + d[key] = ret + return d class LabelToMaskd(MapTransform): @@ -1259,7 +1251,7 @@ class ConvertToMultiChannelBasedOnBratsClassesd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ConvertToMultiChannelBasedOnBratsClasses`. Convert labels to multi channels based on brats18 classes: label 1 is the necrotic and non-enhancing tumor core - label 2 is the the peritumoral edema + label 2 is the peritumoral edema label 4 is the GD-enhancing tumor The possible classes are TC (Tumor core), WT (Whole tumor) and ET (Enhancing tumor). diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 24db2a871c..ccc467bda4 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -68,7 +68,7 @@ __all__ = [ "allow_missing_keys_mode", "compute_divisible_spatial_size", - "convert_inverse_interp_mode", + "convert_applied_interp_mode", "copypaste_arrays", "create_control_grid", "create_grid", @@ -577,7 +577,7 @@ def create_grid( dtype: Union[DtypeLike, torch.dtype] = float, device: Optional[torch.device] = None, backend=TransformBackends.NUMPY, -): +) -> NdarrayOrTensor: """ compute a `spatial_size` mesh. @@ -593,9 +593,9 @@ def create_grid( _backend = look_up_option(backend, TransformBackends) _dtype = dtype or float if _backend == TransformBackends.NUMPY: - return _create_grid_numpy(spatial_size, spacing, homogeneous, _dtype) + return _create_grid_numpy(spatial_size, spacing, homogeneous, _dtype) # type: ignore if _backend == TransformBackends.TORCH: - return _create_grid_torch(spatial_size, spacing, homogeneous, _dtype, device) + return _create_grid_torch(spatial_size, spacing, homogeneous, _dtype, device) # type: ignore raise ValueError(f"backend {backend} is not supported") @@ -672,7 +672,7 @@ def create_rotate( spatial_dims: int, radians: Union[Sequence[float], float], device: Optional[torch.device] = None, - backend=TransformBackends.NUMPY, + backend: str = TransformBackends.NUMPY, ) -> NdarrayOrTensor: """ create a 2D or 3D rotation matrix @@ -935,8 +935,8 @@ def generate_spatial_bounding_box( min_d = max(min_d, 0) max_d = min(max_d, spatial_size[di]) - box_start[di] = min_d.detach().cpu().item() if isinstance(min_d, torch.Tensor) else min_d # type: ignore - box_end[di] = max_d.detach().cpu().item() if isinstance(max_d, torch.Tensor) else max_d # type: ignore + box_start[di] = min_d.detach().cpu().item() if isinstance(min_d, torch.Tensor) else min_d + box_end[di] = max_d.detach().cpu().item() if isinstance(max_d, torch.Tensor) else max_d return box_start, box_end @@ -1243,37 +1243,42 @@ def allow_missing_keys_mode(transform: Union[MapTransform, Compose, Tuple[MapTra t.allow_missing_keys = o_s -def convert_inverse_interp_mode(trans_info: List, mode: str = "nearest", align_corners: Optional[bool] = None): +_interp_modes = list(InterpolateMode) + list(GridSampleMode) + + +def convert_applied_interp_mode(trans_info, mode: str = "nearest", align_corners: Optional[bool] = None): """ - Change the interpolation mode when inverting spatial transforms, default to "nearest". - This function modifies trans_info's `TraceKeys.EXTRA_INFO`. + Recursively change the interpolation mode in the applied operation stacks, default to "nearest". See also: :py:class:`monai.transform.inverse.InvertibleTransform` Args: - trans_info: transforms inverse information list, contains context of every invertible transform. + trans_info: applied operation stack, tracking the previously applied invertible transform. mode: target interpolation mode to convert, default to "nearest" as it's usually used to save the mode output. align_corners: target align corner value in PyTorch interpolation API, need to align with the `mode`. """ - interp_modes = [i.value for i in InterpolateMode] + [i.value for i in GridSampleMode] - - # set to string for DataLoader collation - align_corners_ = TraceKeys.NONE if align_corners is None else align_corners - - for item in ensure_tuple(trans_info): - if TraceKeys.EXTRA_INFO in item: - orig_mode = item[TraceKeys.EXTRA_INFO].get("mode", None) - if orig_mode is not None: - if orig_mode[0] in interp_modes: - item[TraceKeys.EXTRA_INFO]["mode"] = [mode for _ in range(len(mode))] - elif orig_mode in interp_modes: - item[TraceKeys.EXTRA_INFO]["mode"] = mode - if "align_corners" in item[TraceKeys.EXTRA_INFO]: - if issequenceiterable(item[TraceKeys.EXTRA_INFO]["align_corners"]): - item[TraceKeys.EXTRA_INFO]["align_corners"] = [align_corners_ for _ in range(len(mode))] - else: - item[TraceKeys.EXTRA_INFO]["align_corners"] = align_corners_ + if isinstance(trans_info, (list, tuple)): + return [convert_applied_interp_mode(x, mode=mode, align_corners=align_corners) for x in trans_info] + if not isinstance(trans_info, Mapping): + return trans_info + trans_info = dict(trans_info) + if "mode" in trans_info: + current_mode = trans_info["mode"] + if current_mode[0] in _interp_modes: + trans_info["mode"] = [mode for _ in range(len(mode))] + elif current_mode in _interp_modes: + trans_info["mode"] = mode + if "align_corners" in trans_info: + _align_corners = TraceKeys.NONE if align_corners is None else align_corners + current_value = trans_info["align_corners"] + trans_info["align_corners"] = ( + [_align_corners for _ in mode] if issequenceiterable(current_value) else _align_corners + ) + if ("mode" not in trans_info) and ("align_corners" not in trans_info): + return { + k: convert_applied_interp_mode(trans_info[k], mode=mode, align_corners=align_corners) for k in trans_info + } return trans_info @@ -1527,7 +1532,7 @@ def print_table_column(name, torch, numpy, color=Colors.none): print_color(f"Number of uncategorised: {n_uncategorized}", Colors.red) -def convert_pad_mode(dst: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, PytorchPadMode, str]]): +def convert_pad_mode(dst: NdarrayOrTensor, mode: Optional[str]): """ Utility to convert padding mode between numpy array and PyTorch Tensor. @@ -1536,7 +1541,6 @@ def convert_pad_mode(dst: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, Py mode: current padding mode. """ - mode = mode.value if isinstance(mode, (NumpyPadMode, PytorchPadMode)) else mode if isinstance(dst, torch.Tensor): if mode == "wrap": mode = "circular" @@ -1554,7 +1558,7 @@ def convert_pad_mode(dst: NdarrayOrTensor, mode: Optional[Union[NumpyPadMode, Py def convert_to_contiguous(data, **kwargs): """ - Check and ensure the numpy array or PyTorch Tensor in data to be contuguous in memory. + Check and ensure the numpy array or PyTorch Tensor in data to be contiguous in memory. Args: data: input data to convert, will recursively convert the numpy array or PyTorch Tensor in dict and sequence. diff --git a/monai/transforms/utils_create_transform_ims.py b/monai/transforms/utils_create_transform_ims.py index 6165496599..8f2ae82639 100644 --- a/monai/transforms/utils_create_transform_ims.py +++ b/monai/transforms/utils_create_transform_ims.py @@ -460,7 +460,6 @@ def create_transform_im( create_transform_im(RandFlipd, dict(keys=keys, prob=1, spatial_axis=2), data) create_transform_im(Flip, dict(spatial_axis=1), data) create_transform_im(Flipd, dict(keys=keys, spatial_axis=2), data) - create_transform_im(Flipd, dict(keys=keys, spatial_axis=2), data) create_transform_im(Orientation, dict(axcodes="RPI", image_only=True), data) create_transform_im(Orientationd, dict(keys=keys, axcodes="RPI"), data) create_transform_im( @@ -722,7 +721,7 @@ def create_transform_im( create_transform_im( RandSmoothDeform, - dict(spatial_size=(217, 217, 217), rand_size=(10, 10, 10), prob=1.0, def_range=0.05, grid_mode="blinear"), + dict(spatial_size=(217, 217, 217), rand_size=(10, 10, 10), prob=1.0, def_range=0.05, grid_mode="bilinear"), data, ) create_transform_im( @@ -733,7 +732,7 @@ def create_transform_im( rand_size=(10, 10, 10), prob=1.0, def_range=0.05, - grid_mode="blinear", + grid_mode="bilinear", ), data, ) diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 2dd224b023..441ea23b2f 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -314,7 +314,7 @@ def repeat(a: NdarrayOrTensor, repeats: int, axis: Optional[int] = None, **kwarg Args: a: input data to repeat. - repeats: number of repetitions for each element, repeats is broadcasted to fit the shape of the given axis. + repeats: number of repetitions for each element, repeats is broadcast to fit the shape of the given axis. axis: axis along which to repeat values. kwargs: if `a` is PyTorch Tensor, additional args for `torch.repeat_interleave`, more details: https://pytorch.org/docs/stable/generated/torch.repeat_interleave.html. diff --git a/monai/utils/enums.py b/monai/utils/enums.py index c4c33596a7..88faf88432 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -64,6 +64,9 @@ class Example(StrEnum): def __str__(self): return self.value + def __repr__(self): + return self.value + class NumpyPadMode(StrEnum): """ diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index e77909fd4a..93112fd572 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -40,7 +40,7 @@ def get_numpy_dtype_from_string(dtype: str) -> np.dtype: """Get a numpy dtype (e.g., `np.float32`) from its string (e.g., `"float32"`).""" - return np.empty([], dtype=dtype).dtype # type: ignore + return np.empty([], dtype=dtype).dtype def get_torch_dtype_from_string(dtype: str) -> torch.dtype: @@ -132,6 +132,7 @@ def _convert_tensor(tensor, **kwargs): return tensor.as_tensor() return tensor + dtype = get_equivalent_dtype(dtype, torch.Tensor) if isinstance(data, torch.Tensor): return _convert_tensor(data).to(dtype=dtype, device=device, memory_format=torch.contiguous_format) if isinstance(data, np.ndarray): @@ -331,7 +332,7 @@ def convert_to_dst_type( output, _type, _device = convert_data_type( data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence ) - if copy_meta and isinstance(output, monai.data.MetaTensor): # type: ignore + if copy_meta and isinstance(output, monai.data.MetaTensor): output.meta, output.applied_operations = deepcopy(dst.meta), deepcopy(dst.applied_operations) # type: ignore return output, _type, _device diff --git a/tests/test_activations.py b/tests/test_activations.py index a67e6f8cb6..a06316b253 100644 --- a/tests/test_activations.py +++ b/tests/test_activations.py @@ -76,12 +76,12 @@ class TestActivations(unittest.TestCase): - @parameterized.expand(TEST_CASES[:3]) + @parameterized.expand(TEST_CASES) def test_value_shape(self, input_param, img, out, expected_shape): result = Activations(**input_param)(img) def _compare(ret, out, shape): - assert_allclose(ret, out, rtol=1e-3) + assert_allclose(ret, out, rtol=1e-3, type_test=False) self.assertTupleEqual(ret.shape, shape) if isinstance(result, (list, tuple)): diff --git a/tests/test_activationsd.py b/tests/test_activationsd.py index 557d68de90..e38f36e49d 100644 --- a/tests/test_activationsd.py +++ b/tests/test_activationsd.py @@ -51,10 +51,10 @@ class TestActivationsd(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_value_shape(self, input_param, test_input, output, expected_shape): result = Activationsd(**input_param)(test_input) - assert_allclose(result["pred"], output["pred"], rtol=1e-3) + assert_allclose(result["pred"], output["pred"], rtol=1e-3, type_test="tensor") self.assertTupleEqual(result["pred"].shape, expected_shape) if "label" in result: - assert_allclose(result["label"], output["label"], rtol=1e-3) + assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") self.assertTupleEqual(result["label"].shape, expected_shape) diff --git a/tests/test_adjust_contrast.py b/tests/test_adjust_contrast.py index 2f6c4e2259..1c38d0edf3 100644 --- a/tests/test_adjust_contrast.py +++ b/tests/test_adjust_contrast.py @@ -29,7 +29,9 @@ class TestAdjustContrast(NumpyImageTestCase2D): def test_correct_results(self, gamma): adjuster = AdjustContrast(gamma=gamma) for p in TEST_NDARRAYS: - result = adjuster(p(self.imt)) + im = p(self.imt) + result = adjuster(im) + self.assertTrue(type(im), type(result)) if gamma == 1.0: expected = self.imt else: @@ -37,7 +39,7 @@ def test_correct_results(self, gamma): img_min = self.imt.min() img_range = self.imt.max() - img_min expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min - assert_allclose(expected, result, rtol=1e-05, type_test=False) + assert_allclose(result, expected, rtol=1e-05, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_adjust_contrastd.py b/tests/test_adjust_contrastd.py index a7224b643b..2d674c6003 100644 --- a/tests/test_adjust_contrastd.py +++ b/tests/test_adjust_contrastd.py @@ -37,7 +37,7 @@ def test_correct_results(self, gamma): img_min = self.imt.min() img_range = self.imt.max() - img_min expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min - assert_allclose(expected, result["img"], rtol=1e-05, type_test=False) + assert_allclose(result["img"], expected, rtol=1e-05, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_affine.py b/tests/test_affine.py index d681d2941b..019a8f59a4 100644 --- a/tests/test_affine.py +++ b/tests/test_affine.py @@ -10,16 +10,18 @@ # limitations under the License. import unittest +from copy import deepcopy import numpy as np import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import Affine -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, test_local_inversion TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -155,11 +157,21 @@ class TestAffine(unittest.TestCase): @parameterized.expand(TESTS) def test_affine(self, input_param, input_data, expected_val): + input_copy = deepcopy(input_data["img"]) g = Affine(**input_param) result = g(**input_data) if isinstance(result, tuple): result = result[0] - assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + test_local_inversion(g, result, input_copy) + assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4, type_test=False) + + set_track_meta(False) + result = g(**input_data) + if isinstance(result, tuple): + result = result[0] + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) if __name__ == "__main__": diff --git a/tests/test_affine_grid.py b/tests/test_affine_grid.py index 6f6364feda..b481601df5 100644 --- a/tests/test_affine_grid.py +++ b/tests/test_affine_grid.py @@ -15,11 +15,12 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import AffineGrid -from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, is_tf32_env TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -136,7 +137,11 @@ class TestAffineGrid(unittest.TestCase): @parameterized.expand(TESTS) def test_affine_grid(self, input_param, input_data, expected_val): g = AffineGrid(**input_param) + set_track_meta(False) result, _ = g(**input_data) + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) if "device" in input_data: self.assertEqual(result.device, input_data[device]) assert_allclose(result, expected_val, type_test=False, rtol=_rtol) diff --git a/tests/test_affined.py b/tests/test_affined.py index 665c93d23f..b922d80fb5 100644 --- a/tests/test_affined.py +++ b/tests/test_affined.py @@ -10,16 +10,17 @@ # limitations under the License. import unittest +from copy import deepcopy import numpy as np import torch from parameterized import parameterized from monai.transforms import Affined -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, test_local_inversion TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -159,9 +160,11 @@ class TestAffined(unittest.TestCase): @parameterized.expand(TESTS) def test_affine(self, input_param, input_data, expected_val): + input_copy = deepcopy(input_data) g = Affined(**input_param) - result = g(input_data)["img"] - assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + result = g(input_data) + test_local_inversion(g, result, input_copy, dict_key="img") + assert_allclose(result["img"], expected_val, rtol=1e-4, atol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_arraydataset.py b/tests/test_arraydataset.py index ee1a92cf97..eb1a767f6a 100644 --- a/tests/test_arraydataset.py +++ b/tests/test_arraydataset.py @@ -23,15 +23,15 @@ from monai.transforms import AddChannel, Compose, LoadImage, RandAdjustContrast, RandGaussianNoise, Spacing TEST_CASE_1 = [ - Compose([LoadImage(image_only=True), AddChannel(), RandGaussianNoise(prob=1.0)]), - Compose([LoadImage(image_only=True), AddChannel(), RandGaussianNoise(prob=1.0)]), + Compose([LoadImage(), AddChannel(), RandGaussianNoise(prob=1.0)]), + Compose([LoadImage(), AddChannel(), RandGaussianNoise(prob=1.0)]), (0, 1), (1, 128, 128, 128), ] TEST_CASE_2 = [ - Compose([LoadImage(image_only=True), AddChannel(), RandAdjustContrast(prob=1.0)]), - Compose([LoadImage(image_only=True), AddChannel(), RandAdjustContrast(prob=1.0)]), + Compose([LoadImage(), AddChannel(), RandAdjustContrast(prob=1.0)]), + Compose([LoadImage(), AddChannel(), RandAdjustContrast(prob=1.0)]), (0, 1), (1, 128, 128, 128), ] @@ -39,26 +39,28 @@ class TestCompose(Compose): def __call__(self, input_): - img, metadata = self.transforms[0](input_) + img = self.transforms[0](input_) + metadata = img.meta img = self.transforms[1](img) - img, _, _ = self.transforms[2](img, metadata["affine"]) + img = self.transforms[2](img, metadata["affine"]) + metadata = img.meta return self.transforms[3](img), metadata TEST_CASE_3 = [ - TestCompose([LoadImage(image_only=False), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), - TestCompose([LoadImage(image_only=False), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), + TestCompose([LoadImage(), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), + TestCompose([LoadImage(), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), (0, 2), (1, 64, 64, 33), ] -TEST_CASE_4 = [Compose([LoadImage(image_only=True), AddChannel(), RandGaussianNoise(prob=1.0)]), (1, 128, 128, 128)] +TEST_CASE_4 = [Compose([LoadImage(), AddChannel(), RandGaussianNoise(prob=1.0)]), (1, 128, 128, 128)] class TestArrayDataset(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_shape(self, img_transform, label_transform, indices, expected_shape): - test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)), np.eye(4)) + test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)).astype(float), np.eye(4)) with tempfile.TemporaryDirectory() as tempdir: test_image1 = os.path.join(tempdir, "test_image1.nii.gz") test_seg1 = os.path.join(tempdir, "test_seg1.nii.gz") @@ -92,7 +94,7 @@ def test_shape(self, img_transform, label_transform, indices, expected_shape): @parameterized.expand([TEST_CASE_4]) def test_default_none(self, img_transform, expected_shape): - test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)), np.eye(4)) + test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)).astype(float), np.eye(4)) with tempfile.TemporaryDirectory() as tempdir: test_image1 = os.path.join(tempdir, "test_image1.nii.gz") test_image2 = os.path.join(tempdir, "test_image2.nii.gz") @@ -115,7 +117,7 @@ def test_default_none(self, img_transform, expected_shape): @parameterized.expand([TEST_CASE_4]) def test_dataloading_img(self, img_transform, expected_shape): - test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)), np.eye(4)) + test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)).astype(float), np.eye(4)) with tempfile.TemporaryDirectory() as tempdir: test_image1 = os.path.join(tempdir, "test_image1.nii.gz") test_image2 = os.path.join(tempdir, "test_image2.nii.gz") @@ -136,7 +138,7 @@ def test_dataloading_img(self, img_transform, expected_shape): @parameterized.expand([TEST_CASE_4]) def test_dataloading_img_label(self, img_transform, expected_shape): - test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)), np.eye(4)) + test_image = nib.Nifti1Image(np.random.randint(0, 2, size=(128, 128, 128)).astype(float), np.eye(4)) with tempfile.TemporaryDirectory() as tempdir: test_image1 = os.path.join(tempdir, "test_image1.nii.gz") test_image2 = os.path.join(tempdir, "test_image2.nii.gz") diff --git a/tests/test_as_channel_first.py b/tests/test_as_channel_first.py index a2d56295b8..732c559a1a 100644 --- a/tests/test_as_channel_first.py +++ b/tests/test_as_channel_first.py @@ -12,10 +12,10 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import AsChannelFirst +from monai.transforms.utils_pytorch_numpy_unification import moveaxis from tests.utils import TEST_NDARRAYS, assert_allclose TESTS = [] @@ -31,10 +31,8 @@ def test_value(self, in_type, input_param, expected_shape): test_data = in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])) result = AsChannelFirst(**input_param)(test_data) self.assertTupleEqual(result.shape, expected_shape) - if isinstance(test_data, torch.Tensor): - test_data = test_data.cpu().numpy() - expected = np.moveaxis(test_data, input_param["channel_dim"], 0) - assert_allclose(result, expected, type_test=False) + expected = moveaxis(test_data, input_param["channel_dim"], 0) + assert_allclose(result, expected, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_as_discrete.py b/tests/test_as_discrete.py index a68e6431ec..867ef84062 100644 --- a/tests/test_as_discrete.py +++ b/tests/test_as_discrete.py @@ -66,7 +66,7 @@ class TestAsDiscrete(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_value_shape(self, input_param, img, out, expected_shape): result = AsDiscrete(**input_param)(img) - assert_allclose(result, out, rtol=1e-3) + assert_allclose(result, out, rtol=1e-3, type_test="tensor") self.assertTupleEqual(result.shape, expected_shape) diff --git a/tests/test_as_discreted.py b/tests/test_as_discreted.py index 21825c2d6c..17527c0fd4 100644 --- a/tests/test_as_discreted.py +++ b/tests/test_as_discreted.py @@ -85,10 +85,10 @@ class TestAsDiscreted(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_value_shape(self, input_param, test_input, output, expected_shape): result = AsDiscreted(**input_param)(test_input) - assert_allclose(result["pred"], output["pred"], rtol=1e-3) + assert_allclose(result["pred"], output["pred"], rtol=1e-3, type_test="tensor") self.assertTupleEqual(result["pred"].shape, expected_shape) if "label" in result: - assert_allclose(result["label"], output["label"], rtol=1e-3) + assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") self.assertTupleEqual(result["label"].shape, expected_shape) diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py index be2b8a84b9..6b0a4a2b19 100644 --- a/tests/test_box_transform.py +++ b/tests/test_box_transform.py @@ -16,22 +16,8 @@ from parameterized import parameterized from monai.apps.detection.transforms.box_ops import convert_mask_to_box -from monai.apps.detection.transforms.dictionary import ( - AffineBoxToImageCoordinated, - AffineBoxToWorldCoordinated, - BoxToMaskd, - ClipBoxToImaged, - ConvertBoxModed, - FlipBoxd, - MaskToBoxd, - RandCropBoxByPosNegLabeld, - RandFlipBoxd, - RandRotateBox90d, - RandZoomBoxd, - RotateBox90d, - ZoomBoxd, -) -from monai.transforms import CastToTyped, Invertd +from monai.apps.detection.transforms.dictionary import BoxToMaskd, ConvertBoxModed, MaskToBoxd +from monai.transforms import CastToTyped from tests.utils import TEST_NDARRAYS, assert_allclose TESTS_3D = [] @@ -149,157 +135,157 @@ def test_value_3d( convert_result["boxes"], expected_convert_result, type_test=True, device_test=True, atol=1e-3 ) - invert_transform_convert_mode = Invertd( - keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"] - ) - data_back = invert_transform_convert_mode(convert_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) - - # test ZoomBoxd - transform_zoom = ZoomBoxd( - image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=False - ) - zoom_result = transform_zoom(data) - assert_allclose(zoom_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=1e-3) - invert_transform_zoom = Invertd( - keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"] - ) - data_back = invert_transform_zoom(zoom_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) - assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) - - transform_zoom = ZoomBoxd( - image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=True - ) - zoom_result = transform_zoom(data) - assert_allclose( - zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3 - ) - - # test RandZoomBoxd - transform_zoom = RandZoomBoxd( - image_keys="image", - box_keys="boxes", - box_ref_image_keys="image", - prob=1.0, - min_zoom=(0.3,) * 3, - max_zoom=(3.0,) * 3, - keep_size=False, - ) - zoom_result = transform_zoom(data) - invert_transform_zoom = Invertd( - keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"] - ) - data_back = invert_transform_zoom(zoom_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) - assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) - - # test AffineBoxToImageCoordinated, AffineBoxToWorldCoordinated - transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image") - with self.assertRaises(Exception) as context: - transform_affine(data) - self.assertTrue("Please check whether it is the correct the image meta key." in str(context.exception)) - - data["image_meta_dict"] = {"affine": torch.diag(1.0 / torch.Tensor([0.5, 3, 1.5, 1]))} - affine_result = transform_affine(data) - assert_allclose(affine_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=0.01) - invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"]) - data_back = invert_transform_affine(affine_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) - invert_transform_affine = AffineBoxToWorldCoordinated(box_keys="boxes", box_ref_image_keys="image") - data_back = invert_transform_affine(affine_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) - - # test FlipBoxd - transform_flip = FlipBoxd( - image_keys="image", box_keys="boxes", box_ref_image_keys="image", spatial_axis=[0, 1, 2] - ) - flip_result = transform_flip(data) - assert_allclose(flip_result["boxes"], expected_flip_result, type_test=True, device_test=True, atol=1e-3) - invert_transform_flip = Invertd( - keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"] - ) - data_back = invert_transform_flip(flip_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) - assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) - - # test RandFlipBoxd - for spatial_axis in [(0,), (1,), (2,), (0, 1), (1, 2)]: - transform_flip = RandFlipBoxd( - image_keys="image", - box_keys="boxes", - box_ref_image_keys="image", - prob=1.0, - spatial_axis=spatial_axis, - ) - flip_result = transform_flip(data) - invert_transform_flip = Invertd( - keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"] - ) - data_back = invert_transform_flip(flip_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) - assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) - - # test ClipBoxToImaged - transform_clip = ClipBoxToImaged( - box_keys="boxes", box_ref_image_keys="image", label_keys=["labels", "scores"], remove_empty=True - ) - clip_result = transform_clip(data) - assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) - assert_allclose(clip_result["labels"], data["labels"][1:], type_test=True, device_test=True, atol=1e-3) - assert_allclose(clip_result["scores"], data["scores"][1:], type_test=True, device_test=True, atol=1e-3) - - transform_clip = ClipBoxToImaged( - box_keys="boxes", box_ref_image_keys="image", label_keys=[], remove_empty=True - ) # corner case when label_keys is empty - clip_result = transform_clip(data) - assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) - - # test RandCropBoxByPosNegLabeld - transform_crop = RandCropBoxByPosNegLabeld( - image_keys="image", box_keys="boxes", label_keys=["labels", "scores"], spatial_size=2, num_samples=3 - ) - crop_result = transform_crop(data) - assert len(crop_result) == 3 - for ll in range(3): - assert_allclose( - crop_result[ll]["boxes"].shape[0], - crop_result[ll]["labels"].shape[0], - type_test=True, - device_test=True, - atol=1e-3, - ) - assert_allclose( - crop_result[ll]["boxes"].shape[0], - crop_result[ll]["scores"].shape[0], - type_test=True, - device_test=True, - atol=1e-3, - ) - - # test RotateBox90d - transform_rotate = RotateBox90d( - image_keys="image", box_keys="boxes", box_ref_image_keys="image", k=1, spatial_axes=[0, 1] - ) - rotate_result = transform_rotate(data) - assert_allclose(rotate_result["boxes"], expected_rotate_result, type_test=True, device_test=True, atol=1e-3) - invert_transform_rotate = Invertd( - keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"] - ) - data_back = invert_transform_rotate(rotate_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) - assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) - - transform_rotate = RandRotateBox90d( - image_keys="image", box_keys="boxes", box_ref_image_keys="image", prob=1.0, max_k=3, spatial_axes=[0, 1] - ) - rotate_result = transform_rotate(data) - invert_transform_rotate = Invertd( - keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"] - ) - data_back = invert_transform_rotate(rotate_result) - assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) - assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # invert_transform_convert_mode = Invertd( + # keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"] + # ) + # data_back = invert_transform_convert_mode(convert_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + # + # # test ZoomBoxd + # transform_zoom = ZoomBoxd( + # image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=False + # ) + # zoom_result = transform_zoom(data) + # assert_allclose(zoom_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=1e-3) + # invert_transform_zoom = Invertd( + # keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"] + # ) + # data_back = invert_transform_zoom(zoom_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # + # transform_zoom = ZoomBoxd( + # image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=True + # ) + # zoom_result = transform_zoom(data) + # assert_allclose( + # zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3 + # ) + # + # # test RandZoomBoxd + # transform_zoom = RandZoomBoxd( + # image_keys="image", + # box_keys="boxes", + # box_ref_image_keys="image", + # prob=1.0, + # min_zoom=(0.3,) * 3, + # max_zoom=(3.0,) * 3, + # keep_size=False, + # ) + # zoom_result = transform_zoom(data) + # invert_transform_zoom = Invertd( + # keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"] + # ) + # data_back = invert_transform_zoom(zoom_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) + # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # + # # test AffineBoxToImageCoordinated, AffineBoxToWorldCoordinated + # transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image") + # with self.assertRaises(Exception) as context: + # transform_affine(data) + # self.assertTrue("Please check whether it is the correct the image meta key." in str(context.exception)) + # + # data["image_meta_dict"] = {"affine": torch.diag(1.0 / torch.Tensor([0.5, 3, 1.5, 1]))} + # affine_result = transform_affine(data) + # assert_allclose(affine_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=0.01) + # invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"]) + # data_back = invert_transform_affine(affine_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) + # invert_transform_affine = AffineBoxToWorldCoordinated(box_keys="boxes", box_ref_image_keys="image") + # data_back = invert_transform_affine(affine_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01) + # + # # test FlipBoxd + # transform_flip = FlipBoxd( + # image_keys="image", box_keys="boxes", box_ref_image_keys="image", spatial_axis=[0, 1, 2] + # ) + # flip_result = transform_flip(data) + # assert_allclose(flip_result["boxes"], expected_flip_result, type_test=True, device_test=True, atol=1e-3) + # invert_transform_flip = Invertd( + # keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"] + # ) + # data_back = invert_transform_flip(flip_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # + # # test RandFlipBoxd + # for spatial_axis in [(0,), (1,), (2,), (0, 1), (1, 2)]: + # transform_flip = RandFlipBoxd( + # image_keys="image", + # box_keys="boxes", + # box_ref_image_keys="image", + # prob=1.0, + # spatial_axis=spatial_axis, + # ) + # flip_result = transform_flip(data) + # invert_transform_flip = Invertd( + # keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"] + # ) + # data_back = invert_transform_flip(flip_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # + # # test ClipBoxToImaged + # transform_clip = ClipBoxToImaged( + # box_keys="boxes", box_ref_image_keys="image", label_keys=["labels", "scores"], remove_empty=True + # ) + # clip_result = transform_clip(data) + # assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) + # assert_allclose(clip_result["labels"], data["labels"][1:], type_test=True, device_test=True, atol=1e-3) + # assert_allclose(clip_result["scores"], data["scores"][1:], type_test=True, device_test=True, atol=1e-3) + # + # transform_clip = ClipBoxToImaged( + # box_keys="boxes", box_ref_image_keys="image", label_keys=[], remove_empty=True + # ) # corner case when label_keys is empty + # clip_result = transform_clip(data) + # assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3) + # + # # test RandCropBoxByPosNegLabeld + # transform_crop = RandCropBoxByPosNegLabeld( + # image_keys="image", box_keys="boxes", label_keys=["labels", "scores"], spatial_size=2, num_samples=3 + # ) + # crop_result = transform_crop(data) + # assert len(crop_result) == 3 + # for ll in range(3): + # assert_allclose( + # crop_result[ll]["boxes"].shape[0], + # crop_result[ll]["labels"].shape[0], + # type_test=True, + # device_test=True, + # atol=1e-3, + # ) + # assert_allclose( + # crop_result[ll]["boxes"].shape[0], + # crop_result[ll]["scores"].shape[0], + # type_test=True, + # device_test=True, + # atol=1e-3, + # ) + # + # # test RotateBox90d + # transform_rotate = RotateBox90d( + # image_keys="image", box_keys="boxes", box_ref_image_keys="image", k=1, spatial_axes=[0, 1] + # ) + # rotate_result = transform_rotate(data) + # assert_allclose(rotate_result["boxes"], expected_rotate_result, type_test=True, device_test=True, atol=1e-3) + # invert_transform_rotate = Invertd( + # keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"] + # ) + # data_back = invert_transform_rotate(rotate_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) + # + # transform_rotate = RandRotateBox90d( + # image_keys="image", box_keys="boxes", box_ref_image_keys="image", prob=1.0, max_k=3, spatial_axes=[0, 1] + # ) + # rotate_result = transform_rotate(data) + # invert_transform_rotate = Invertd( + # keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"] + # ) + # data_back = invert_transform_rotate(rotate_result) + # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3) + # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3) if __name__ == "__main__": diff --git a/tests/test_box_utils.py b/tests/test_box_utils.py index 2a71cd7d5b..8c56783c3b 100644 --- a/tests/test_box_utils.py +++ b/tests/test_box_utils.py @@ -164,7 +164,7 @@ def test_value(self, input_data, mode2, expected_box, expected_area): # test box_area, box_iou, box_giou, box_pair_giou assert_allclose(box_area(result_standard), expected_area, type_test=True, device_test=True, atol=0.0) - iou_metrics = (box_iou, box_giou) # type: ignore + iou_metrics = (box_iou, box_giou) for p in iou_metrics: self_iou = p(boxes1=result_standard[1:2, :], boxes2=result_standard[1:1, :]) assert_allclose(self_iou, np.array([[]]), type_test=False) diff --git a/tests/test_cachedataset_persistent_workers.py b/tests/test_cachedataset_persistent_workers.py index 8cef298be7..7f241899eb 100644 --- a/tests/test_cachedataset_persistent_workers.py +++ b/tests/test_cachedataset_persistent_workers.py @@ -31,7 +31,7 @@ def test_duplicate_transforms(self): b1 = next(iter(train_loader)) b2 = next(iter(train_loader)) - self.assertEqual(len(b1["img_transforms"]), len(b2["img_transforms"])) + self.assertEqual(len(b1["img"].applied_operations), len(b2["img"].applied_operations)) if __name__ == "__main__": diff --git a/tests/test_convert_data_type.py b/tests/test_convert_data_type.py index 796e607884..ab4bd3e3e6 100644 --- a/tests/test_convert_data_type.py +++ b/tests/test_convert_data_type.py @@ -34,7 +34,7 @@ TESTS_LIST.append( ( [in_type(np.array(1.0)), in_type(np.array(1.0))], # type: ignore - [out_type(np.array(1.0)), out_type(np.array(1.0))], # type: ignore + [out_type(np.array(1.0)), out_type(np.array(1.0))], False, ) ) diff --git a/tests/test_crop_foregroundd.py b/tests/test_crop_foregroundd.py index d641c5a376..ab42d6694d 100644 --- a/tests/test_crop_foregroundd.py +++ b/tests/test_crop_foregroundd.py @@ -152,7 +152,7 @@ class TestCropForegroundd(unittest.TestCase): def test_value(self, argments, input_data, expected_data): cropper = CropForegroundd(**argments) result = cropper(input_data) - assert_allclose(result["img"], expected_data, type_test=False) + assert_allclose(result["img"], expected_data, type_test="tensor") if "label" in input_data and "img" in input_data: self.assertTupleEqual(result["img"].shape, result["label"].shape) inv = cropper.inverse(result) diff --git a/tests/test_cross_validation.py b/tests/test_cross_validation.py index c378a52f78..811dcea026 100644 --- a/tests/test_cross_validation.py +++ b/tests/test_cross_validation.py @@ -13,8 +13,8 @@ import unittest from monai.apps import CrossValidation, DecathlonDataset -from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd, ToTensord -from monai.utils.enums import PostFix +from monai.data import MetaTensor +from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd from tests.utils import skip_if_downloading_fails, skip_if_quick @@ -23,12 +23,7 @@ class TestCrossValidation(unittest.TestCase): def test_values(self): testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") train_transform = Compose( - [ - LoadImaged(keys=["image", "label"]), - AddChanneld(keys=["image", "label"]), - ScaleIntensityd(keys="image"), - ToTensord(keys=["image", "label"]), - ] + [LoadImaged(keys=["image", "label"]), AddChanneld(keys=["image", "label"]), ScaleIntensityd(keys="image")] ) val_transform = LoadImaged(keys=["image", "label"]) @@ -36,7 +31,7 @@ def _test_dataset(dataset): self.assertEqual(len(dataset), 52) self.assertTrue("image" in dataset[0]) self.assertTrue("label" in dataset[0]) - self.assertTrue(PostFix.meta("image") in dataset[0]) + self.assertTrue(isinstance(dataset[0]["image"], MetaTensor)) self.assertTupleEqual(dataset[0]["image"].shape, (1, 34, 49, 41)) cvdataset = CrossValidation( diff --git a/tests/test_dataset_summary.py b/tests/test_dataset_summary.py index 51840f77ea..d0531b28a0 100644 --- a/tests/test_dataset_summary.py +++ b/tests/test_dataset_summary.py @@ -19,6 +19,9 @@ from monai.data import Dataset, DatasetSummary, create_test_image_3d from monai.transforms import LoadImaged +from monai.transforms.compose import Compose +from monai.transforms.meta_utility.dictionary import FromMetaTensord +from monai.transforms.utility.dictionary import ToNumpyd from monai.utils import set_determinism from monai.utils.enums import PostFix @@ -50,12 +53,17 @@ def test_spacing_intensity(self): {"image": image_name, "label": label_name} for image_name, label_name in zip(train_images, train_labels) ] - dataset = Dataset( - data=data_dicts, transform=LoadImaged(keys=["image", "label"], meta_keys=["test1", "test2"]) + t = Compose( + [ + LoadImaged(keys=["image", "label"]), + FromMetaTensord(keys=["image", "label"]), + ToNumpyd(keys=["image", "label", "image_meta_dict", "label_meta_dict"]), + ] ) + dataset = Dataset(data=data_dicts, transform=t) # test **kwargs of `DatasetSummary` for `DataLoader` - calculator = DatasetSummary(dataset, num_workers=4, meta_key="test1", collate_fn=test_collate) + calculator = DatasetSummary(dataset, num_workers=4, meta_key="image_meta_dict", collate_fn=test_collate) target_spacing = calculator.get_target_spacing() self.assertEqual(target_spacing, (1.0, 1.0, 1.0)) @@ -85,7 +93,8 @@ def test_anisotropic_spacing(self): {"image": image_name, "label": label_name} for image_name, label_name in zip(train_images, train_labels) ] - dataset = Dataset(data=data_dicts, transform=LoadImaged(keys=["image", "label"])) + t = Compose([LoadImaged(keys=["image", "label"]), FromMetaTensord(keys=["image", "label"])]) + dataset = Dataset(data=data_dicts, transform=t) calculator = DatasetSummary(dataset, num_workers=4, meta_key_postfix=PostFix.meta()) diff --git a/tests/test_decathlondataset.py b/tests/test_decathlondataset.py index 744dccefaa..49280f6fa6 100644 --- a/tests/test_decathlondataset.py +++ b/tests/test_decathlondataset.py @@ -15,8 +15,8 @@ from pathlib import Path from monai.apps import DecathlonDataset -from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd, ToTensord -from monai.utils.enums import PostFix +from monai.data import MetaTensor +from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd from tests.utils import skip_if_downloading_fails, skip_if_quick @@ -25,19 +25,14 @@ class TestDecathlonDataset(unittest.TestCase): def test_values(self): testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") transform = Compose( - [ - LoadImaged(keys=["image", "label"]), - AddChanneld(keys=["image", "label"]), - ScaleIntensityd(keys="image"), - ToTensord(keys=["image", "label"]), - ] + [LoadImaged(keys=["image", "label"]), AddChanneld(keys=["image", "label"]), ScaleIntensityd(keys="image")] ) def _test_dataset(dataset): self.assertEqual(len(dataset), 52) self.assertTrue("image" in dataset[0]) self.assertTrue("label" in dataset[0]) - self.assertTrue(PostFix.meta("image") in dataset[0]) + self.assertTrue(isinstance(dataset[0]["image"], MetaTensor)) self.assertTupleEqual(dataset[0]["image"].shape, (1, 36, 47, 44)) with skip_if_downloading_fails(): @@ -55,8 +50,8 @@ def _test_dataset(dataset): root_dir=testing_dir, task="Task04_Hippocampus", transform=transform, section="validation", download=False ) _test_dataset(data) - self.assertTrue(data[0][PostFix.meta("image")]["filename_or_obj"].endswith("hippocampus_163.nii.gz")) - self.assertTrue(data[0][PostFix.meta("label")]["filename_or_obj"].endswith("hippocampus_163.nii.gz")) + self.assertTrue(data[0]["image"].meta["filename_or_obj"].endswith("hippocampus_163.nii.gz")) + self.assertTrue(data[0]["label"].meta["filename_or_obj"].endswith("hippocampus_163.nii.gz")) # test validation without transforms data = DecathlonDataset(root_dir=testing_dir, task="Task04_Hippocampus", section="validation", download=False) self.assertTupleEqual(data[0]["image"].shape, (36, 47, 44)) diff --git a/tests/test_decollate.py b/tests/test_decollate.py index adeaa73337..ac9220d538 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -74,19 +74,12 @@ ], [[None, None], [None, None]], [["test"], ["test"]], + [np.array([64, 64]), [64, 64]], [[], []], [[("ch1", "ch2"), ("ch3",)], [["ch1", "ch3"], ["ch2", None]]], # default pad None ] -class _ListCompose(Compose): - def __call__(self, input_): - img, metadata = self.transforms[0](input_) - for t in self.transforms[1:]: - img = t(img) - return img, metadata - - class TestDeCollate(unittest.TestCase): def setUp(self) -> None: set_determinism(seed=0) @@ -148,7 +141,7 @@ def test_decollation_tensor(self, *transforms): t_compose = Compose([AddChannel(), Compose(transforms), ToTensor()]) # If nibabel present, read from disk if has_nib: - t_compose = Compose([LoadImage(image_only=True), t_compose]) + t_compose = Compose([LoadImage(), t_compose]) dataset = Dataset(self.data_list, t_compose) self.check_decollate(dataset=dataset) @@ -158,7 +151,7 @@ def test_decollation_list(self, *transforms): t_compose = Compose([AddChannel(), Compose(transforms), ToTensor()]) # If nibabel present, read from disk if has_nib: - t_compose = _ListCompose([LoadImage(image_only=False), t_compose]) + t_compose = Compose([LoadImage(), t_compose]) dataset = Dataset(self.data_list, t_compose) self.check_decollate(dataset=dataset) diff --git a/tests/test_detect_envelope.py b/tests/test_detect_envelope.py index 5ea82a463d..5eea0c8653 100644 --- a/tests/test_detect_envelope.py +++ b/tests/test_detect_envelope.py @@ -17,7 +17,7 @@ from monai.transforms import DetectEnvelope from monai.utils import OptionalImportError -from tests.utils import SkipIfModule, SkipIfNoModule +from tests.utils import TEST_NDARRAYS, SkipIfModule, SkipIfNoModule, assert_allclose n_samples = 500 hann_windowed_sine = np.sin(2 * np.pi * 10 * np.linspace(0, 1, n_samples)) * np.hanning(n_samples) @@ -125,8 +125,9 @@ class TestDetectEnvelope(unittest.TestCase): ] ) def test_value(self, arguments, image, expected_data, atol): - result = DetectEnvelope(**arguments)(image) - np.testing.assert_allclose(result, expected_data, atol=atol) + for p in TEST_NDARRAYS: + result = DetectEnvelope(**arguments)(p(image)) + assert_allclose(result, p(expected_data), atol=atol, type_test="tensor") @parameterized.expand( [ diff --git a/tests/test_ensure_channel_first.py b/tests/test_ensure_channel_first.py index dd6168ec75..b97578ba1d 100644 --- a/tests/test_ensure_channel_first.py +++ b/tests/test_ensure_channel_first.py @@ -16,30 +16,27 @@ import itk import nibabel as nib import numpy as np +import torch from parameterized import parameterized from PIL import Image from monai.data import ITKReader +from monai.data.meta_tensor import MetaTensor from monai.transforms import EnsureChannelFirst, LoadImage -from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"image_only": False}, ["test_image.nii.gz"], None] +TEST_CASE_1 = [{}, ["test_image.nii.gz"], None] -TEST_CASE_2 = [{"image_only": False}, ["test_image.nii.gz"], -1] +TEST_CASE_2 = [{}, ["test_image.nii.gz"], -1] -TEST_CASE_3 = [{"image_only": False}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], None] +TEST_CASE_3 = [{}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], None] -TEST_CASE_4 = [{"reader": ITKReader(), "image_only": False}, ["test_image.nii.gz"], None] +TEST_CASE_4 = [{"reader": ITKReader()}, ["test_image.nii.gz"], None] -TEST_CASE_5 = [{"reader": ITKReader(), "image_only": False}, ["test_image.nii.gz"], -1] +TEST_CASE_5 = [{"reader": ITKReader()}, ["test_image.nii.gz"], -1] -TEST_CASE_6 = [ - {"reader": ITKReader(), "image_only": False}, - ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], - None, -] +TEST_CASE_6 = [{"reader": ITKReader()}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], None] -TEST_CASE_7 = [{"image_only": False, "reader": ITKReader(pixel_type=itk.UC)}, "tests/testing_data/CT_DICOM", None] +TEST_CASE_7 = [{"reader": ITKReader(pixel_type=itk.UC)}, "tests/testing_data/CT_DICOM", None] class TestEnsureChannelFirst(unittest.TestCase): @@ -54,15 +51,15 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim): for i, name in enumerate(filenames): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) - for p in TEST_NDARRAYS: - result, header = LoadImage(**input_param)(filenames) - result = EnsureChannelFirst()(p(result), header) - self.assertEqual(result.shape[0], len(filenames)) + + result = LoadImage(**input_param)(filenames) + result = EnsureChannelFirst()(result) + self.assertEqual(result.shape[0], len(filenames)) @parameterized.expand([TEST_CASE_7]) - def test_itk_dicom_series_reader(self, input_param, filenames, original_channel_dim): - result, header = LoadImage(**input_param)(filenames) - result = EnsureChannelFirst()(result, header) + def test_itk_dicom_series_reader(self, input_param, filenames, _): + result = LoadImage(**input_param)(filenames) + result = EnsureChannelFirst()(result) self.assertEqual(result.shape[0], 1) def test_load_png(self): @@ -71,17 +68,20 @@ def test_load_png(self): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_image.png") Image.fromarray(test_image.astype("uint8")).save(filename) - result, header = LoadImage(image_only=False)(filename) - result = EnsureChannelFirst()(result, header) + result = LoadImage()(filename) + result = EnsureChannelFirst()(result) self.assertEqual(result.shape[0], 3) def test_check(self): + im = torch.zeros(1, 2, 3) + with self.assertRaises(ValueError): # not MetaTensor + EnsureChannelFirst()(im) with self.assertRaises(ValueError): # no meta - EnsureChannelFirst()(np.zeros((1, 2, 3)), None) + EnsureChannelFirst()(MetaTensor(im)) with self.assertRaises(ValueError): # no meta channel - EnsureChannelFirst()(np.zeros((1, 2, 3)), {"original_channel_dim": None}) - EnsureChannelFirst(strict_check=False)(np.zeros((1, 2, 3)), None) - EnsureChannelFirst(strict_check=False)(np.zeros((1, 2, 3)), {"original_channel_dim": None}) + EnsureChannelFirst()(MetaTensor(im, meta={"original_channel_dim": None})) + EnsureChannelFirst(strict_check=False)(im) + EnsureChannelFirst(strict_check=False)(MetaTensor(im, meta={"original_channel_dim": None})) if __name__ == "__main__": diff --git a/tests/test_ensure_channel_firstd.py b/tests/test_ensure_channel_firstd.py index 7f1a57a207..8525939f59 100644 --- a/tests/test_ensure_channel_firstd.py +++ b/tests/test_ensure_channel_firstd.py @@ -15,12 +15,12 @@ import nibabel as nib import numpy as np +import torch from parameterized import parameterized from PIL import Image +from monai.data.meta_tensor import MetaTensor from monai.transforms import EnsureChannelFirstd, LoadImaged -from monai.utils.enums import PostFix -from tests.utils import TEST_NDARRAYS TEST_CASE_1 = [{"keys": "img"}, ["test_image.nii.gz"], None] @@ -41,11 +41,9 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim): for i, name in enumerate(filenames): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) - for p in TEST_NDARRAYS: - result = LoadImaged(**input_param)({"img": filenames}) - result["img"] = p(result["img"]) - result = EnsureChannelFirstd(**input_param)(result) - self.assertEqual(result["img"].shape[0], len(filenames)) + result = LoadImaged(**input_param)({"img": filenames}) + result = EnsureChannelFirstd(**input_param)(result) + self.assertEqual(result["img"].shape[0], len(filenames)) def test_load_png(self): spatial_size = (256, 256, 3) @@ -58,16 +56,13 @@ def test_load_png(self): self.assertEqual(result["img"].shape[0], 3) def test_exceptions(self): + im = torch.zeros((1, 2, 3)) with self.assertRaises(ValueError): # no meta - EnsureChannelFirstd("img")({"img": np.zeros((1, 2, 3)), PostFix.meta("img"): None}) + EnsureChannelFirstd("img")({"img": im}) with self.assertRaises(ValueError): # no meta channel - EnsureChannelFirstd("img")( - {"img": np.zeros((1, 2, 3)), PostFix.meta("img"): {"original_channel_dim": None}} - ) - EnsureChannelFirstd("img", strict_check=False)({"img": np.zeros((1, 2, 3)), PostFix.meta("img"): None}) - EnsureChannelFirstd("img", strict_check=False)( - {"img": np.zeros((1, 2, 3)), PostFix.meta("img"): {"original_channel_dim": None}} - ) + EnsureChannelFirstd("img")({"img": MetaTensor(im, meta={"original_channel_dim": None})}) + EnsureChannelFirstd("img", strict_check=False)({"img": im}) + EnsureChannelFirstd("img", strict_check=False)({"img": MetaTensor(im, meta={"original_channel_dim": None})}) if __name__ == "__main__": diff --git a/tests/test_fill_holes.py b/tests/test_fill_holes.py index 9f9dc1fc2e..4292ff3a22 100644 --- a/tests/test_fill_holes.py +++ b/tests/test_fill_holes.py @@ -192,10 +192,6 @@ TEST_CASE_22, ] -ITEST_CASE_1 = ["invalid_image_data_type", {}, [[[[1, 1, 1]]]], NotImplementedError] - -INVALID_CASES = [ITEST_CASE_1] - class TestFillHoles(unittest.TestCase): @parameterized.expand(VALID_CASES) @@ -203,16 +199,7 @@ def test_correct_results(self, _, args, input_image, expected): converter = FillHoles(**args) for p in TEST_NDARRAYS: result = converter(p(clone(input_image))) - assert_allclose(result, p(expected)) - - @parameterized.expand(INVALID_CASES) - def test_raise_exception(self, _, args, input_image, expected_error): - with self.assertRaises(expected_error): - converter = FillHoles(**args) - if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): - _ = converter(clone(input_image).cuda()) - else: - _ = converter(clone(input_image)) + assert_allclose(result, p(expected), type_test=False) if __name__ == "__main__": diff --git a/tests/test_fill_holesd.py b/tests/test_fill_holesd.py index f7aa9f6108..fce90fd86a 100644 --- a/tests/test_fill_holesd.py +++ b/tests/test_fill_holesd.py @@ -193,10 +193,6 @@ TEST_CASE_22, ] -ITEST_CASE_1 = ["invalid_image_data_type", {}, [[[[1, 1, 1]]]], NotImplementedError] - -INVALID_CASES = [ITEST_CASE_1] - class TestFillHoles(unittest.TestCase): @parameterized.expand(VALID_CASES) @@ -205,17 +201,7 @@ def test_correct_results(self, _, args, input_image, expected): converter = FillHolesd(keys=key, **args) for p in TEST_NDARRAYS: result = converter({key: p(clone(input_image))})[key] - assert_allclose(result, p(expected)) - - @parameterized.expand(INVALID_CASES) - def test_raise_exception(self, _, args, input_image, expected_error): - key = CommonKeys.IMAGE - with self.assertRaises(expected_error): - converter = FillHolesd(keys=key, **args) - if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): - _ = converter({key: clone(input_image).cuda()})[key] - else: - _ = converter({key: clone(input_image)})[key] + assert_allclose(result, p(expected), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_flip.py b/tests/test_flip.py index 17cf0d2c39..c5a281b127 100644 --- a/tests/test_flip.py +++ b/tests/test_flip.py @@ -12,15 +12,23 @@ import unittest import numpy as np +import torch from parameterized import parameterized +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor from monai.transforms import Flip -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] VALID_CASES = [("no_axis", None), ("one_axis", 1), ("many_axis", [0, 1]), ("negative_axis", [0, -1])] +TORCH_CASES = [] +for track_meta in (False, True): + for device in TEST_DEVICES: + TORCH_CASES.append([[0, 1], torch.zeros((1, 3, 2)), track_meta, *device]) + class TestFlip(NumpyImageTestCase2D): @parameterized.expand(INVALID_CASES) @@ -31,13 +39,29 @@ def test_invalid_inputs(self, _, spatial_axis, raises): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: im = p(self.imt[0]) flip = Flip(spatial_axis=spatial_axis) expected = [np.flip(channel, spatial_axis) for channel in self.imt[0]] expected = np.stack(expected) result = flip(im) - assert_allclose(result, p(expected)) + assert_allclose(result, p(expected), type_test="tensor") + test_local_inversion(flip, result, im) + + @parameterized.expand(TORCH_CASES) + def test_torch(self, init_param, img: torch.Tensor, track_meta: bool, device): + set_track_meta(track_meta) + img = img.to(device) + xform = Flip(init_param) + res = xform(img) + self.assertEqual(img.shape, res.shape) + if track_meta: + self.assertIsInstance(res, MetaTensor) + else: + self.assertNotIsInstance(res, MetaTensor) + self.assertIsInstance(res, torch.Tensor) + with self.assertRaisesRegex(ValueError, "MetaTensor"): + xform.inverse(res) if __name__ == "__main__": diff --git a/tests/test_flipd.py b/tests/test_flipd.py index 900779f4e0..c97674b83b 100644 --- a/tests/test_flipd.py +++ b/tests/test_flipd.py @@ -12,15 +12,23 @@ import unittest import numpy as np +import torch from parameterized import parameterized +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor from monai.transforms import Flipd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] VALID_CASES = [("no_axis", None), ("one_axis", 1), ("many_axis", [0, 1])] +TORCH_CASES = [] +for track_meta in (False, True): + for device in TEST_DEVICES: + TORCH_CASES.append([[0, 1], torch.zeros((1, 3, 2)), track_meta, *device]) + class TestFlipd(NumpyImageTestCase2D): @parameterized.expand(INVALID_CASES) @@ -31,12 +39,29 @@ def test_invalid_cases(self, _, spatial_axis, raises): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: flip = Flipd(keys="img", spatial_axis=spatial_axis) expected = [np.flip(channel, spatial_axis) for channel in self.imt[0]] expected = np.stack(expected) - result = flip({"img": p(self.imt[0])})["img"] - assert_allclose(result, p(expected)) + im = p(self.imt[0]) + result = flip({"img": im})["img"] + assert_allclose(result, p(expected), type_test="tensor") + test_local_inversion(flip, {"img": result}, {"img": im}, "img") + + @parameterized.expand(TORCH_CASES) + def test_torch(self, init_param, img: torch.Tensor, track_meta: bool, device): + set_track_meta(track_meta) + img = img.to(device) + xform = Flipd("image", init_param) + res = xform({"image": img}) + self.assertEqual(img.shape, res["image"].shape) + if track_meta: + self.assertIsInstance(res["image"], MetaTensor) + else: + self.assertNotIsInstance(res["image"], MetaTensor) + self.assertIsInstance(res["image"], torch.Tensor) + with self.assertRaisesRegex(ValueError, "MetaTensor"): + xform.inverse(res) if __name__ == "__main__": diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index c18e87fe53..160db5bae3 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -83,7 +83,7 @@ class TestForegroundMask(unittest.TestCase): def test_foreground_mask(self, in_type, arguments, image, mask): input_image = in_type(image) result = ForegroundMask(**arguments)(input_image) - assert_allclose(result, mask, type_test=False) + assert_allclose(result, mask, type_test="tensor") @parameterized.expand(TESTS_ERROR) def test_foreground_mask_error(self, in_type, arguments, image): diff --git a/tests/test_gaussian_sharpen.py b/tests/test_gaussian_sharpen.py index 547febdfaf..af36e7c03d 100644 --- a/tests/test_gaussian_sharpen.py +++ b/tests/test_gaussian_sharpen.py @@ -83,7 +83,7 @@ class TestGaussianSharpen(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = GaussianSharpen(**argments)(image) - assert_allclose(result, expected_data, atol=0, rtol=1e-4, type_test=False) + assert_allclose(result, expected_data, atol=0, rtol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_gaussian_sharpend.py b/tests/test_gaussian_sharpend.py index d9ef503532..14339fff26 100644 --- a/tests/test_gaussian_sharpend.py +++ b/tests/test_gaussian_sharpend.py @@ -83,7 +83,7 @@ class TestGaussianSharpend(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = GaussianSharpend(**argments)(image) - assert_allclose(result["img"], expected_data, rtol=1e-4, type_test=False) + assert_allclose(result["img"], expected_data, rtol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_gaussian_smooth.py b/tests/test_gaussian_smooth.py index 53f2fc396b..032a60caad 100644 --- a/tests/test_gaussian_smooth.py +++ b/tests/test_gaussian_smooth.py @@ -87,7 +87,7 @@ class TestGaussianSmooth(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = GaussianSmooth(**argments)(image) - assert_allclose(result, expected_data, atol=0, rtol=1e-4, type_test=False) + assert_allclose(result, expected_data, atol=1e-4, rtol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_gaussian_smoothd.py b/tests/test_gaussian_smoothd.py index 839bac81fe..8f5465c848 100644 --- a/tests/test_gaussian_smoothd.py +++ b/tests/test_gaussian_smoothd.py @@ -87,7 +87,7 @@ class TestGaussianSmoothd(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = GaussianSmoothd(**argments)(image) - assert_allclose(result["img"], expected_data, rtol=1e-4, type_test=False) + assert_allclose(result["img"], expected_data, rtol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_gibbs_noise.py b/tests/test_gibbs_noise.py index 3fbe047944..e40eda38db 100644 --- a/tests/test_gibbs_noise.py +++ b/tests/test_gibbs_noise.py @@ -13,14 +13,13 @@ from copy import deepcopy import numpy as np -import torch from parameterized import parameterized from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import GibbsNoise from monai.utils.misc import set_determinism from monai.utils.module import optional_import -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose _, has_torch_fft = optional_import("torch.fft", name="fftshift") @@ -51,11 +50,7 @@ def test_same_result(self, im_shape, input_type): t = GibbsNoise(alpha) out1 = t(deepcopy(im)) out2 = t(deepcopy(im)) - self.assertEqual(type(out1), type(im)) - if isinstance(out1, torch.Tensor): - self.assertEqual(out1.device, im.device) - torch.testing.assert_allclose(out1, out2, rtol=1e-7, atol=0) - self.assertIsInstance(out1, type(im)) + assert_allclose(out1, out2, rtol=1e-7, atol=0, type_test="tensor") @parameterized.expand(TEST_CASES) def test_identity(self, im_shape, input_type): @@ -63,7 +58,7 @@ def test_identity(self, im_shape, input_type): alpha = 0.0 t = GibbsNoise(alpha) out = t(deepcopy(im)) - torch.testing.assert_allclose(im, out, atol=1e-2, rtol=1e-7) + assert_allclose(out, im, atol=1e-2, rtol=1e-7, type_test="tensor") @parameterized.expand(TEST_CASES) def test_alpha_1(self, im_shape, input_type): @@ -71,7 +66,7 @@ def test_alpha_1(self, im_shape, input_type): alpha = 1.0 t = GibbsNoise(alpha) out = t(deepcopy(im)) - torch.testing.assert_allclose(0 * im, out, rtol=1e-7, atol=0) + assert_allclose(out, 0 * im, rtol=1e-7, atol=0, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_gibbs_noised.py b/tests/test_gibbs_noised.py index 4905300703..6662e9e17c 100644 --- a/tests/test_gibbs_noised.py +++ b/tests/test_gibbs_noised.py @@ -13,14 +13,13 @@ from copy import deepcopy import numpy as np -import torch from parameterized import parameterized from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import GibbsNoised from monai.utils.misc import set_determinism from monai.utils.module import optional_import -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose _, has_torch_fft = optional_import("torch.fft", name="fftshift") @@ -53,8 +52,7 @@ def test_same_result(self, im_shape, input_type): out1 = t(deepcopy(data)) out2 = t(deepcopy(data)) for k in KEYS: - torch.testing.assert_allclose(out1[k], out2[k], rtol=1e-7, atol=0) - self.assertIsInstance(out1[k], type(data[k])) + assert_allclose(out1[k], out2[k], rtol=1e-7, atol=0, type_test="tensor") @parameterized.expand(TEST_CASES) def test_identity(self, im_shape, input_type): @@ -63,11 +61,7 @@ def test_identity(self, im_shape, input_type): t = GibbsNoised(KEYS, alpha) out = t(deepcopy(data)) for k in KEYS: - self.assertEqual(type(out[k]), type(data[k])) - if isinstance(out[k], torch.Tensor): - self.assertEqual(out[k].device, data[k].device) - out[k], data[k] = out[k].cpu(), data[k].cpu() - np.testing.assert_allclose(data[k], out[k], atol=1e-2) + assert_allclose(out[k], data[k], atol=1e-2, type_test="tensor") @parameterized.expand(TEST_CASES) def test_alpha_1(self, im_shape, input_type): @@ -76,11 +70,7 @@ def test_alpha_1(self, im_shape, input_type): t = GibbsNoised(KEYS, alpha) out = t(deepcopy(data)) for k in KEYS: - self.assertEqual(type(out[k]), type(data[k])) - if isinstance(out[k], torch.Tensor): - self.assertEqual(out[k].device, data[k].device) - out[k], data[k] = out[k].cpu(), data[k].cpu() - np.testing.assert_allclose(0.0 * data[k], out[k], atol=1e-2) + assert_allclose(out[k], 0.0 * data[k], atol=1e-2, type_test="tensor") @parameterized.expand(TEST_CASES) def test_dict_matches(self, im_shape, input_type): @@ -89,7 +79,7 @@ def test_dict_matches(self, im_shape, input_type): alpha = 1.0 t = GibbsNoised(KEYS, alpha) out = t(deepcopy(data)) - torch.testing.assert_allclose(out[KEYS[0]], out[KEYS[1]], rtol=1e-7, atol=0) + assert_allclose(out[KEYS[0]], out[KEYS[1]], rtol=1e-7, atol=0, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_grid_distortion.py b/tests/test_grid_distortion.py index 5e7ccd7c32..d71642aae8 100644 --- a/tests/test_grid_distortion.py +++ b/tests/test_grid_distortion.py @@ -15,10 +15,10 @@ from parameterized import parameterized from monai.transforms import GridDistortion -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TESTS.append( [ dict(num_cells=3, distort_steps=[(1.5,) * 4] * 2, mode="nearest", padding_mode="zeros"), @@ -101,7 +101,7 @@ class TestGridDistortion(unittest.TestCase): def test_grid_distortion(self, input_param, input_data, expected_val): g = GridDistortion(**input_param) result = g(input_data) - assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + assert_allclose(result, expected_val, type_test=False, rtol=1e-4, atol=1e-4) if __name__ == "__main__": diff --git a/tests/test_grid_distortiond.py b/tests/test_grid_distortiond.py index 662596f935..2cf8bc7ff9 100644 --- a/tests/test_grid_distortiond.py +++ b/tests/test_grid_distortiond.py @@ -15,12 +15,12 @@ from parameterized import parameterized from monai.transforms import GridDistortiond -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] num_cells = (2, 2) distort_steps = [(1.5,) * (1 + n_c) for n_c in num_cells] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: img = np.indices([6, 6]).astype(np.float32) TESTS.append( [ @@ -77,8 +77,8 @@ class TestGridDistortiond(unittest.TestCase): def test_grid_distortiond(self, input_param, input_data, expected_val_img, expected_val_mask): g = GridDistortiond(**input_param) result = g(input_data) - assert_allclose(result["img"], expected_val_img, rtol=1e-4, atol=1e-4) - assert_allclose(result["mask"], expected_val_mask, rtol=1e-4, atol=1e-4) + assert_allclose(result["img"], expected_val_img, type_test=False, rtol=1e-4, atol=1e-4) + assert_allclose(result["mask"], expected_val_mask, type_test=False, rtol=1e-4, atol=1e-4) if __name__ == "__main__": diff --git a/tests/test_histogram_normalize.py b/tests/test_histogram_normalize.py index 95aa37f26e..9218d247b1 100644 --- a/tests/test_histogram_normalize.py +++ b/tests/test_histogram_normalize.py @@ -49,7 +49,7 @@ class TestHistogramNormalize(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = HistogramNormalize(**argments)(image) - assert_allclose(result, expected_data) + assert_allclose(result, expected_data, type_test="tensor") self.assertEqual(get_equivalent_dtype(result.dtype, data_type=np.ndarray), argments.get("dtype", np.float32)) diff --git a/tests/test_histogram_normalized.py b/tests/test_histogram_normalized.py index 7b86a9685f..a56b063847 100644 --- a/tests/test_histogram_normalized.py +++ b/tests/test_histogram_normalized.py @@ -49,7 +49,7 @@ class TestHistogramNormalized(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = HistogramNormalized(**argments)(image)["img"] - assert_allclose(result, expected_data) + assert_allclose(result, expected_data, type_test="tensor") self.assertEqual(get_equivalent_dtype(result.dtype, data_type=np.ndarray), argments.get("dtype", np.float32)) diff --git a/tests/test_image_dataset.py b/tests/test_image_dataset.py index 41eda803dc..a89759323d 100644 --- a/tests/test_image_dataset.py +++ b/tests/test_image_dataset.py @@ -15,6 +15,7 @@ import nibabel as nib import numpy as np +import torch from monai.data import ImageDataset from monai.transforms import ( @@ -45,8 +46,9 @@ def __call__(self, data): class _TestCompose(Compose): def __call__(self, data, meta): - data = self.transforms[0](data, meta) # ensure channel first - data, _, meta["affine"] = self.transforms[1](data, meta["affine"]) # spacing + data = self.transforms[0](data) # ensure channel first + data = self.transforms[1](data, data.meta["affine"]) # spacing + meta = data.meta if len(self.transforms) == 3: return self.transforms[2](data), meta # image contrast return data, meta @@ -55,8 +57,8 @@ def __call__(self, data, meta): class TestImageDataset(unittest.TestCase): def test_use_case(self): with tempfile.TemporaryDirectory() as tempdir: - img_ = nib.Nifti1Image(np.random.randint(0, 2, size=(20, 20, 20)), np.eye(4)) - seg_ = nib.Nifti1Image(np.random.randint(0, 2, size=(20, 20, 20)), np.eye(4)) + img_ = nib.Nifti1Image(np.random.randint(0, 2, size=(20, 20, 20)).astype(float), np.eye(4)) + seg_ = nib.Nifti1Image(np.random.randint(0, 2, size=(20, 20, 20)).astype(float), np.eye(4)) img_name, seg_name = os.path.join(tempdir, "img.nii.gz"), os.path.join(tempdir, "seg.nii.gz") nib.save(img_, img_name) nib.save(seg_, seg_name) @@ -79,7 +81,7 @@ def test_dataset(self): with tempfile.TemporaryDirectory() as tempdir: full_names, ref_data = [], [] for filename in FILENAMES: - test_image = np.random.randint(0, 2, size=(4, 4, 4)) + test_image = np.random.randint(0, 2, size=(4, 4, 4)).astype(float) ref_data.append(test_image) save_path = os.path.join(tempdir, filename) full_names.append(save_path) @@ -93,7 +95,7 @@ def test_dataset(self): # loading no meta, int dataset = ImageDataset(full_names, dtype=np.float16) for d, _ in zip(dataset, ref_data): - self.assertEqual(d.dtype, np.float16) + self.assertEqual(d.dtype, torch.float16) # loading with meta, no transform dataset = ImageDataset(full_names, image_only=False) diff --git a/tests/test_image_rw.py b/tests/test_image_rw.py index 7975349109..159a99c27e 100644 --- a/tests/test_image_rw.py +++ b/tests/test_image_rw.py @@ -16,10 +16,12 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.data.image_reader import ITKReader, NibabelReader, NrrdReader, PILReader from monai.data.image_writer import ITKWriter, NibabelWriter, PILWriter, register_writer, resolve_writer +from monai.data.meta_tensor import MetaTensor from monai.transforms import LoadImage, SaveImage, moveaxis from monai.utils import OptionalImportError from tests.utils import TEST_NDARRAYS, assert_allclose @@ -41,25 +43,25 @@ def nifti_rw(self, test_data, reader, writer, dtype, resample=True): saver = SaveImage( output_dir=self.test_dir, output_ext=output_ext, resample=resample, separate_folder=False, writer=writer ) - saver( - p(test_data), - { - "filename_or_obj": f"{filepath}.png", - "affine": np.eye(4), - "original_affine": np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]), - }, - ) + meta_dict = { + "filename_or_obj": f"{filepath}.png", + "affine": np.eye(4), + "original_affine": np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]), + } + test_data = MetaTensor(p(test_data), meta=meta_dict) + saver(test_data) saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) self.assertTrue(os.path.exists(saved_path)) loader = LoadImage(reader=reader, squeeze_non_spatial_dims=True) - data, meta = loader(saved_path) + data = loader(saved_path) + meta = data.meta if meta["original_channel_dim"] == -1: _test_data = moveaxis(test_data, 0, -1) else: _test_data = test_data[0] if resample: _test_data = moveaxis(_test_data, 0, 1) - assert_allclose(data, _test_data) + assert_allclose(data, torch.as_tensor(_test_data)) @parameterized.expand(itertools.product([NibabelReader, ITKReader], [NibabelWriter, "ITKWriter"])) def test_2d(self, reader, writer): @@ -95,16 +97,18 @@ def png_rw(self, test_data, reader, writer, dtype, resample=True): saver = SaveImage( output_dir=self.test_dir, output_ext=output_ext, resample=resample, separate_folder=False, writer=writer ) - saver(p(test_data), {"filename_or_obj": f"{filepath}.png", "spatial_shape": (6, 8)}) + test_data = MetaTensor(p(test_data), meta={"filename_or_obj": f"{filepath}.png", "spatial_shape": (6, 8)}) + saver(test_data) saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) self.assertTrue(os.path.exists(saved_path)) loader = LoadImage(reader=reader) - data, meta = loader(saved_path) + data = loader(saved_path) + meta = data.meta if meta["original_channel_dim"] == -1: _test_data = moveaxis(test_data, 0, -1) else: _test_data = test_data[0] - assert_allclose(data, _test_data) + assert_allclose(data, torch.as_tensor(_test_data)) @parameterized.expand(itertools.product([PILReader, ITKReader], [PILWriter, ITKWriter])) def test_2d(self, reader, writer): @@ -148,11 +152,14 @@ def nrrd_rw(self, test_data, reader, writer, dtype, resample=True): saver = SaveImage( output_dir=self.test_dir, output_ext=output_ext, resample=resample, separate_folder=False, writer=writer ) - saver(p(test_data), {"filename_or_obj": f"{filepath}{output_ext}", "spatial_shape": test_data.shape}) + test_data = MetaTensor( + p(test_data), meta={"filename_or_obj": f"{filepath}{output_ext}", "spatial_shape": test_data.shape} + ) + saver(test_data) saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) loader = LoadImage(reader=reader) - data, meta = loader(saved_path) - assert_allclose(data, test_data) + data = loader(saved_path) + assert_allclose(data, torch.as_tensor(test_data)) @parameterized.expand(itertools.product([NrrdReader, ITKReader], [ITKWriter, ITKWriter])) def test_2d(self, reader, writer): diff --git a/tests/test_integration_bundle_run.py b/tests/test_integration_bundle_run.py index 3813e63d7f..c81836099d 100644 --- a/tests/test_integration_bundle_run.py +++ b/tests/test_integration_bundle_run.py @@ -87,8 +87,6 @@ def test_shape(self, config_file, expected_shape): # test override with the whole overriding file json.dump("Dataset", f) - saver = LoadImage(image_only=True) - if sys.platform == "win32": override = "--network $@network_def.to(@device) --dataset#_target_ Dataset" else: @@ -99,14 +97,15 @@ def test_shape(self, config_file, expected_shape): test_env = os.environ.copy() print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES")) subprocess.check_call(la + ["--args_file", def_args_file], env=test_env) - self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape) + loader = LoadImage() + self.assertTupleEqual(loader(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape) # here test the script with `google fire` tool as CLI cmd = "-m fire monai.bundle.scripts run --runner_id evaluating" cmd += f" --evaluator#amp False {override}" la = ["coverage", "run"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file] subprocess.check_call(la, env=test_env) - self.assertTupleEqual(saver(os.path.join(tempdir, "image", "image_trans.nii.gz")).shape, expected_shape) + self.assertTupleEqual(loader(os.path.join(tempdir, "image", "image_trans.nii.gz")).shape, expected_shape) if __name__ == "__main__": diff --git a/tests/test_integration_classification_2d.py b/tests/test_integration_classification_2d.py index 5a742ce4f9..9bfe7648d0 100644 --- a/tests/test_integration_classification_2d.py +++ b/tests/test_integration_classification_2d.py @@ -33,7 +33,6 @@ RandRotate, RandZoom, ScaleIntensity, - ToTensor, Transpose, ) from monai.utils import set_determinism @@ -69,15 +68,12 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", RandRotate(range_x=np.pi / 12, prob=0.5, keep_size=True, dtype=np.float64), RandFlip(spatial_axis=0, prob=0.5), RandZoom(min_zoom=0.9, max_zoom=1.1, prob=0.5), - ToTensor(), ] ) train_transforms.set_random_state(1234) - val_transforms = Compose( - [LoadImage(image_only=True), AddChannel(), Transpose(indices=[0, 2, 1]), ScaleIntensity(), ToTensor()] - ) - y_pred_trans = Compose([ToTensor(), Activations(softmax=True)]) - y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=len(np.unique(train_y)))]) + val_transforms = Compose([LoadImage(image_only=True), AddChannel(), Transpose(indices=[0, 2, 1]), ScaleIntensity()]) + y_pred_trans = Compose([Activations(softmax=True)]) + y_trans = AsDiscrete(to_onehot=len(np.unique(train_y))) auc_metric = ROCAUCMetric() # create train, val data loaders @@ -132,7 +128,7 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", acc_metric = acc_value.sum().item() / len(acc_value) # decollate prediction and label and execute post processing y_pred = [y_pred_trans(i) for i in decollate_batch(y_pred)] - y = [y_trans(i) for i in decollate_batch(y)] + y = [y_trans(i) for i in decollate_batch(y, detach=False)] # compute AUC auc_metric(y_pred, y) auc_value = auc_metric.aggregate() @@ -153,7 +149,7 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", def run_inference_test(root_dir, test_x, test_y, device="cuda:0", num_workers=10): # define transforms for image and classification - val_transforms = Compose([LoadImage(image_only=True), AddChannel(), ScaleIntensity(), ToTensor()]) + val_transforms = Compose([LoadImage(image_only=True), AddChannel(), ScaleIntensity()]) val_ds = MedNISTDataset(test_x, test_y, val_transforms) val_loader = DataLoader(val_ds, batch_size=300, num_workers=num_workers) diff --git a/tests/test_integration_determinism.py b/tests/test_integration_determinism.py index 64c018b4f5..94d2325514 100644 --- a/tests/test_integration_determinism.py +++ b/tests/test_integration_determinism.py @@ -18,7 +18,7 @@ from monai.data import create_test_image_2d from monai.losses import DiceLoss from monai.networks.nets import UNet -from monai.transforms import AddChannel, Compose, RandRotate90, RandSpatialCrop, ScaleIntensity, ToTensor +from monai.transforms import AddChannel, Compose, RandRotate90, RandSpatialCrop, ScaleIntensity from monai.utils import set_determinism from tests.utils import DistTestCase, TimedCall @@ -47,7 +47,7 @@ def __len__(self): loss = DiceLoss(sigmoid=True) opt = torch.optim.Adam(net.parameters(), 1e-2) train_transforms = Compose( - [AddChannel(), ScaleIntensity(), RandSpatialCrop((96, 96), random_size=False), RandRotate90(), ToTensor()] + [AddChannel(), ScaleIntensity(), RandSpatialCrop((96, 96), random_size=False), RandRotate90()] ) src = DataLoader(_TestBatch(train_transforms), batch_size=batch_size, shuffle=True) diff --git a/tests/test_integration_fast_train.py b/tests/test_integration_fast_train.py index 4dbb70b102..13f918d201 100644 --- a/tests/test_integration_fast_train.py +++ b/tests/test_integration_fast_train.py @@ -34,8 +34,6 @@ Compose, CropForegroundd, EnsureChannelFirstd, - EnsureType, - EnsureTyped, FgBgToIndicesd, LoadImaged, RandAffined, @@ -94,8 +92,6 @@ def test_train_timing(self): # pre-compute foreground and background indexes # and cache them to accelerate training FgBgToIndicesd(keys="label", fg_postfix="_fg", bg_postfix="_bg"), - # change to execute transforms with Tensor data - EnsureTyped(keys=["image", "label"]), # move the data to GPU and cache to avoid CPU -> GPU sync in every epoch ToDeviced(keys=["image", "label"], device=device), # randomly crop out patch samples from big @@ -137,7 +133,6 @@ def test_train_timing(self): Spacingd(keys=["image", "label"], pixdim=(1.0, 1.0, 1.0), mode=("bilinear", "nearest")), ScaleIntensityd(keys="image"), CropForegroundd(keys=["image", "label"], source_key="image"), - EnsureTyped(keys=["image", "label"]), # move the data to GPU and cache to avoid CPU -> GPU sync in every epoch ToDeviced(keys=["image", "label"], device=device), ] @@ -170,8 +165,8 @@ def test_train_timing(self): optimizer = Novograd(model.parameters(), learning_rate * 10) scaler = torch.cuda.amp.GradScaler() - post_pred = Compose([EnsureType(), AsDiscrete(argmax=True, to_onehot=2)]) - post_label = Compose([EnsureType(), AsDiscrete(to_onehot=2)]) + post_pred = AsDiscrete(argmax=True, to_onehot=2) + post_label = AsDiscrete(to_onehot=2) dice_metric = DiceMetric(include_background=True, reduction="mean", get_not_nans=False) diff --git a/tests/test_integration_segmentation_3d.py b/tests/test_integration_segmentation_3d.py index b29a514488..b5b1d69565 100644 --- a/tests/test_integration_segmentation_3d.py +++ b/tests/test_integration_segmentation_3d.py @@ -36,11 +36,8 @@ SaveImage, ScaleIntensityd, Spacingd, - ToTensor, - ToTensord, ) from monai.utils import optional_import, set_determinism -from monai.utils.enums import PostFix from monai.visualize import plot_2d_or_3d_image from tests.testing_data.integration_answers import test_integration_value from tests.utils import DistTestCase, TimedCall, skip_if_quick @@ -70,7 +67,6 @@ def run_training_test(root_dir, device="cuda:0", cachedataset=0, readers=(None, keys=["img", "seg"], label_key="seg", spatial_size=[96, 96, 96], pos=1, neg=1, num_samples=4 ), RandRotate90d(keys=["img", "seg"], prob=0.8, spatial_axes=[0, 2]), - ToTensord(keys=["img", "seg"]), ] ) train_transforms.set_random_state(1234) @@ -82,7 +78,6 @@ def run_training_test(root_dir, device="cuda:0", cachedataset=0, readers=(None, # slight different results between PyTorch 1.5 an 1.6 Spacingd(keys=["img", "seg"], pixdim=[1.2, 0.8, 0.7], mode=["bilinear", "nearest"], dtype=np.float32), ScaleIntensityd(keys="img"), - ToTensord(keys=["img", "seg"]), ] ) @@ -98,7 +93,7 @@ def run_training_test(root_dir, device="cuda:0", cachedataset=0, readers=(None, # create a validation data loader val_ds = monai.data.Dataset(data=val_files, transform=val_transforms) val_loader = monai.data.DataLoader(val_ds, batch_size=1, num_workers=4) - val_post_tran = Compose([ToTensor(), Activations(sigmoid=True), AsDiscrete(threshold=0.5)]) + val_post_tran = Compose([Activations(sigmoid=True), AsDiscrete(threshold=0.5)]) dice_metric = DiceMetric(include_background=True, reduction="mean", get_not_nans=False) # create UNet, DiceLoss and Adam optimizer @@ -183,6 +178,13 @@ def run_inference_test(root_dir, device="cuda:0"): segs = sorted(glob(os.path.join(root_dir, "seg*.nii.gz"))) val_files = [{"img": img, "seg": seg} for img, seg in zip(images, segs)] + saver = SaveImage( + output_dir=os.path.join(root_dir, "output"), + dtype=np.float32, + output_ext=".nii.gz", + output_postfix="seg", + mode="bilinear", + ) # define transforms for image and segmentation val_transforms = Compose( [ @@ -192,13 +194,12 @@ def run_inference_test(root_dir, device="cuda:0"): # slight different results between PyTorch 1.5 an 1.6 Spacingd(keys=["img", "seg"], pixdim=[1.2, 0.8, 0.7], mode=["bilinear", "nearest"], dtype=np.float32), ScaleIntensityd(keys="img"), - ToTensord(keys=["img", "seg"]), ] ) val_ds = monai.data.Dataset(data=val_files, transform=val_transforms) # sliding window inference need to input 1 image in every iteration val_loader = monai.data.DataLoader(val_ds, batch_size=1, num_workers=4) - val_post_tran = Compose([ToTensor(), Activations(sigmoid=True), AsDiscrete(threshold=0.5)]) + val_post_tran = Compose([Activations(sigmoid=True), AsDiscrete(threshold=0.5), saver]) dice_metric = DiceMetric(include_background=True, reduction="mean", get_not_nans=False) model = UNet( @@ -215,13 +216,6 @@ def run_inference_test(root_dir, device="cuda:0"): with eval_mode(model): # resampling with align_corners=True or dtype=float64 will generate # slight different results between PyTorch 1.5 an 1.6 - saver = SaveImage( - output_dir=os.path.join(root_dir, "output"), - dtype=np.float32, - output_ext=".nii.gz", - output_postfix="seg", - mode="bilinear", - ) for val_data in val_loader: val_images, val_labels = val_data["img"].to(device), val_data["seg"].to(device) # define sliding window size and batch size for windows inference @@ -229,11 +223,8 @@ def run_inference_test(root_dir, device="cuda:0"): val_outputs = sliding_window_inference(val_images, roi_size, sw_batch_size, model) # decollate prediction into a list val_outputs = [val_post_tran(i) for i in decollate_batch(val_outputs)] - val_meta = decollate_batch(val_data[PostFix.meta("img")]) # compute metrics dice_metric(y_pred=val_outputs, y=val_labels) - for img, meta in zip(val_outputs, val_meta): # save a decollated batch of files - saver(img, meta) return dice_metric.aggregate().item() diff --git a/tests/test_integration_sliding_window.py b/tests/test_integration_sliding_window.py index 3c21edd9c3..ba1f96c1bc 100644 --- a/tests/test_integration_sliding_window.py +++ b/tests/test_integration_sliding_window.py @@ -19,7 +19,7 @@ from ignite.engine import Engine, Events from torch.utils.data import DataLoader -from monai.data import ImageDataset, create_test_image_3d, decollate_batch +from monai.data import ImageDataset, create_test_image_3d from monai.inferers import sliding_window_inference from monai.networks import eval_mode, predict_segmentation from monai.networks.nets import UNet @@ -29,7 +29,7 @@ def run_test(batch_size, img_name, seg_name, output_dir, device="cuda:0"): - ds = ImageDataset([img_name], [seg_name], transform=AddChannel(), seg_transform=AddChannel(), image_only=False) + ds = ImageDataset([img_name], [seg_name], transform=AddChannel(), seg_transform=AddChannel(), image_only=True) loader = DataLoader(ds, batch_size=1, pin_memory=torch.cuda.is_available()) net = UNet( @@ -47,9 +47,8 @@ def _sliding_window_processor(_engine, batch): return predict_segmentation(seg_probs) def save_func(engine): - meta_data = decollate_batch(engine.state.batch[2]) - for m, o in zip(meta_data, engine.state.output): - saver(o, m) + for m in engine.state.output: + saver(m) infer_engine = Engine(_sliding_window_processor) infer_engine.add_event_handler(Events.ITERATION_COMPLETED, save_func) diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index 2a95b23d4f..0ef95d4005 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -23,7 +23,7 @@ from ignite.metrics import Accuracy import monai -from monai.data import create_test_image_3d, decollate_batch +from monai.data import create_test_image_3d from monai.engines import IterationEvents, SupervisedEvaluator, SupervisedTrainer from monai.handlers import ( CheckpointLoader, @@ -49,10 +49,8 @@ SaveImage, SaveImaged, ScaleIntensityd, - ToTensord, ) from monai.utils import optional_import, set_determinism -from monai.utils.enums import PostFix from tests.testing_data.integration_answers import test_integration_value from tests.utils import DistTestCase, TimedCall, pytorch_after, skip_if_quick @@ -77,7 +75,6 @@ def run_training_test(root_dir, device="cuda:0", amp=False, num_workers=4): keys=["image", "label"], label_key="label", spatial_size=[96, 96, 96], pos=1, neg=1, num_samples=4 ), RandRotate90d(keys=["image", "label"], prob=0.5, spatial_axes=[0, 2]), - ToTensord(keys=["image", "label"]), ] ) val_transforms = Compose( @@ -85,7 +82,6 @@ def run_training_test(root_dir, device="cuda:0", amp=False, num_workers=4): LoadImaged(keys=["image", "label"]), AsChannelFirstd(keys=["image", "label"], channel_dim=-1), ScaleIntensityd(keys=["image", "label"]), - ToTensord(keys=["image", "label"]), ] ) @@ -113,7 +109,6 @@ def run_training_test(root_dir, device="cuda:0", amp=False, num_workers=4): val_postprocessing = Compose( [ - ToTensord(keys=["pred", "label"]), Activationsd(keys="pred", sigmoid=True), AsDiscreted(keys="pred", threshold=0.5), KeepLargestConnectedComponentd(keys="pred", applied_labels=[1]), @@ -156,7 +151,6 @@ def _forward_completed(self, engine): train_postprocessing = Compose( [ - ToTensord(keys=["pred", "label"]), Activationsd(keys="pred", sigmoid=True), AsDiscreted(keys="pred", threshold=0.5), KeepLargestConnectedComponentd(keys="pred", applied_labels=[1]), @@ -225,7 +219,6 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor LoadImaged(keys=["image", "label"]), AsChannelFirstd(keys=["image", "label"], channel_dim=-1), ScaleIntensityd(keys=["image", "label"]), - ToTensord(keys=["image", "label"]), ] ) @@ -245,14 +238,11 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor val_postprocessing = Compose( [ - ToTensord(keys=["pred", "label"]), Activationsd(keys="pred", sigmoid=True), AsDiscreted(keys="pred", threshold=0.5), KeepLargestConnectedComponentd(keys="pred", applied_labels=[1]), # test the case that `pred` in `engine.state.output`, while `image_meta_dict` in `engine.state.batch` - SaveImaged( - keys="pred", meta_keys=PostFix.meta("image"), output_dir=root_dir, output_postfix="seg_transform" - ), + SaveImaged(keys="pred", output_dir=root_dir, output_postfix="seg_transform"), ] ) val_handlers = [ @@ -263,11 +253,8 @@ def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_wor saver = SaveImage(output_dir=root_dir, output_postfix="seg_handler") def save_func(engine): - meta_data = from_engine(PostFix.meta("image"))(engine.state.batch) - if isinstance(meta_data, dict): - meta_data = decollate_batch(meta_data) - for m, o in zip(meta_data, from_engine("pred")(engine.state.output)): - saver(o, m) + for o in from_engine("pred")(engine.state.output): + saver(o) evaluator = SupervisedEvaluator( device=device, diff --git a/tests/test_integration_workflows_gan.py b/tests/test_integration_workflows_gan.py index f65a30450a..ff53851ce0 100644 --- a/tests/test_integration_workflows_gan.py +++ b/tests/test_integration_workflows_gan.py @@ -26,7 +26,7 @@ from monai.handlers import CheckpointSaver, StatsHandler, TensorBoardStatsHandler from monai.networks import normal_init from monai.networks.nets import Discriminator, Generator -from monai.transforms import AsChannelFirstd, Compose, LoadImaged, RandFlipd, ScaleIntensityd, ToTensord +from monai.transforms import AsChannelFirstd, Compose, LoadImaged, RandFlipd, ScaleIntensityd from monai.utils import set_determinism from tests.utils import DistTestCase, TimedCall, skip_if_quick @@ -42,7 +42,6 @@ def run_training_test(root_dir, device="cuda:0"): AsChannelFirstd(keys=["reals"]), ScaleIntensityd(keys=["reals"]), RandFlipd(keys=["reals"], prob=0.5), - ToTensord(keys=["reals"]), ] ) train_ds = monai.data.CacheDataset(data=train_files, transform=train_transforms, cache_rate=0.5) diff --git a/tests/test_inverse.py b/tests/test_inverse.py index c04e9b0cd7..82902a09eb 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -20,13 +20,11 @@ import torch from parameterized import parameterized -from monai.data import CacheDataset, DataLoader, create_test_image_2d, create_test_image_3d -from monai.data.utils import decollate_batch +from monai.data import CacheDataset, DataLoader, MetaTensor, create_test_image_2d, create_test_image_3d, decollate_batch from monai.networks.nets import UNet from monai.transforms import ( AddChanneld, Affined, - BatchInverseTransform, BorderPadd, CenterScaleCropd, CenterSpatialCropd, @@ -34,6 +32,7 @@ CropForegroundd, DivisiblePadd, Flipd, + FromMetaTensord, InvertibleTransform, Lambdad, LoadImaged, @@ -59,11 +58,11 @@ Spacingd, SpatialCropd, SpatialPadd, - TraceableTransform, + ToMetaTensord, Transposed, Zoomd, allow_missing_keys_mode, - convert_inverse_interp_mode, + convert_applied_interp_mode, ) from monai.utils import first, get_seed, optional_import, set_determinism from tests.utils import make_nifti_image, make_rand_affine @@ -97,7 +96,7 @@ partial(RandSpatialCropd, roi_size=12 + val), partial(ResizeWithPadOrCropd, spatial_size=21 - val), ): - TESTS.append((t.func.__name__ + name, name, 0, t(KEYS))) # type: ignore + TESTS.append((t.func.__name__ + name, name, 0, True, t(KEYS))) # type: ignore # non-sensical tests: crop bigger or pad smaller or -ve values for t in ( @@ -110,112 +109,118 @@ partial(SpatialCropd, roi_center=10, roi_size=100), partial(SpatialCropd, roi_start=3, roi_end=100), ): - TESTS.append((t.func.__name__ + "bad 1D even", "1D even", 0, t(KEYS))) # type: ignore + TESTS.append((t.func.__name__ + "bad 1D even", "1D even", 0, True, t(KEYS))) # type: ignore TESTS.append( ( "SpatialPadd (x2) 2d", "2D", 0, + True, SpatialPadd(KEYS, spatial_size=[111, 113], method="end"), SpatialPadd(KEYS, spatial_size=[118, 117]), ) ) -TESTS.append(("SpatialPadd 3d", "3D", 0, SpatialPadd(KEYS, spatial_size=[112, 113, 116]))) +TESTS.append(("SpatialPadd 3d", "3D", 0, True, SpatialPadd(KEYS, spatial_size=[112, 113, 116]))) -TESTS.append(("SpatialCropd 2d", "2D", 0, SpatialCropd(KEYS, [49, 51], [90, 89]))) +TESTS.append(("SpatialCropd 2d", "2D", 0, True, SpatialCropd(KEYS, [49, 51], [90, 89]))) TESTS.append( ( "SpatialCropd 3d", "3D", 0, + True, SpatialCropd(KEYS, roi_slices=[slice(s, e) for s, e in zip([None, None, -99], [None, -2, None])]), ) ) -TESTS.append(("SpatialCropd 2d", "2D", 0, SpatialCropd(KEYS, [49, 51], [390, 89]))) +TESTS.append(("SpatialCropd 2d", "2D", 0, True, SpatialCropd(KEYS, [49, 51], [390, 89]))) -TESTS.append(("SpatialCropd 3d", "3D", 0, SpatialCropd(KEYS, [49, 51, 44], [90, 89, 93]))) +TESTS.append(("SpatialCropd 3d", "3D", 0, True, SpatialCropd(KEYS, [49, 51, 44], [90, 89, 93]))) -TESTS.append(("RandSpatialCropd 2d", "2D", 0, RandSpatialCropd(KEYS, [96, 93], None, True, False))) +TESTS.append(("RandSpatialCropd 2d", "2D", 0, True, RandSpatialCropd(KEYS, [96, 93], None, True, False))) -TESTS.append(("RandSpatialCropd 3d", "3D", 0, RandSpatialCropd(KEYS, [96, 93, 92], None, False, False))) +TESTS.append(("RandSpatialCropd 3d", "3D", 0, True, RandSpatialCropd(KEYS, [96, 93, 92], None, False, False))) -TESTS.append(("BorderPadd 2d", "2D", 0, BorderPadd(KEYS, [3, 7, 2, 5]))) +TESTS.append(("BorderPadd 2d", "2D", 0, True, BorderPadd(KEYS, [3, 7, 2, 5]))) -TESTS.append(("BorderPadd 2d", "2D", 0, BorderPadd(KEYS, [3, 7]))) +TESTS.append(("BorderPadd 2d", "2D", 0, True, BorderPadd(KEYS, [3, 7]))) -TESTS.append(("BorderPadd 3d", "3D", 0, BorderPadd(KEYS, [4]))) +TESTS.append(("BorderPadd 3d", "3D", 0, True, BorderPadd(KEYS, [4]))) -TESTS.append(("DivisiblePadd 2d", "2D", 0, DivisiblePadd(KEYS, k=4))) +TESTS.append(("DivisiblePadd 2d", "2D", 0, True, DivisiblePadd(KEYS, k=4))) -TESTS.append(("DivisiblePadd 3d", "3D", 0, DivisiblePadd(KEYS, k=[4, 8, 11]))) +TESTS.append(("DivisiblePadd 3d", "3D", 0, True, DivisiblePadd(KEYS, k=[4, 8, 11]))) +TESTS.append(("CenterSpatialCropd 2d", "2D", 0, True, CenterSpatialCropd(KEYS, roi_size=95))) -TESTS.append(("CenterSpatialCropd 2d", "2D", 0, CenterSpatialCropd(KEYS, roi_size=95))) +TESTS.append(("CenterSpatialCropd 3d", "3D", 0, True, CenterSpatialCropd(KEYS, roi_size=[95, 97, 98]))) -TESTS.append(("CenterSpatialCropd 3d", "3D", 0, CenterSpatialCropd(KEYS, roi_size=[95, 97, 98]))) +TESTS.append(("CropForegroundd 2d", "2D", 0, True, CropForegroundd(KEYS, source_key="label", margin=2))) -TESTS.append(("CropForegroundd 2d", "2D", 0, CropForegroundd(KEYS, source_key="label", margin=2))) +TESTS.append(("CropForegroundd 3d", "3D", 0, True, CropForegroundd(KEYS, source_key="label", k_divisible=[5, 101, 2]))) -TESTS.append(("CropForegroundd 3d", "3D", 0, CropForegroundd(KEYS, source_key="label", k_divisible=[5, 101, 2]))) +TESTS.append(("ResizeWithPadOrCropd 3d", "3D", 0, True, ResizeWithPadOrCropd(KEYS, [201, 150, 105]))) +TESTS.append(("Flipd 3d", "3D", 0, True, Flipd(KEYS, [1, 2]))) +TESTS.append(("Flipd 3d", "3D", 0, True, Flipd(KEYS, [1, 2]))) -TESTS.append(("ResizeWithPadOrCropd 3d", "3D", 0, ResizeWithPadOrCropd(KEYS, [201, 150, 105]))) +TESTS.append(("RandFlipd 3d", "3D", 0, True, RandFlipd(KEYS, 1, [1, 2]))) -TESTS.append(("Flipd 3d", "3D", 0, Flipd(KEYS, [1, 2]))) - -TESTS.append(("RandFlipd 3d", "3D", 0, RandFlipd(KEYS, 1, [1, 2]))) - -TESTS.append(("RandAxisFlipd 3d", "3D", 0, RandAxisFlipd(KEYS, 1))) +TESTS.append(("RandAxisFlipd 3d", "3D", 0, True, RandAxisFlipd(KEYS, 1))) +TESTS.append(("RandAxisFlipd 3d", "3D", 0, True, RandAxisFlipd(KEYS, 1))) for acc in [True, False]: - TESTS.append(("Orientationd 3d", "3D", 0, Orientationd(KEYS, "RAS", as_closest_canonical=acc))) + TESTS.append(("Orientationd 3d", "3D", 0, True, Orientationd(KEYS, "RAS", as_closest_canonical=acc))) -TESTS.append(("Rotate90d 2d", "2D", 0, Rotate90d(KEYS))) +TESTS.append(("Rotate90d 2d", "2D", 0, True, Rotate90d(KEYS))) -TESTS.append(("Rotate90d 3d", "3D", 0, Rotate90d(KEYS, k=2, spatial_axes=(1, 2)))) +TESTS.append(("Rotate90d 3d", "3D", 0, True, Rotate90d(KEYS, k=2, spatial_axes=(1, 2)))) -TESTS.append(("RandRotate90d 3d", "3D", 0, RandRotate90d(KEYS, prob=1, spatial_axes=(1, 2)))) +TESTS.append(("RandRotate90d 3d", "3D", 0, True, RandRotate90d(KEYS, prob=1, spatial_axes=(1, 2)))) -TESTS.append(("Spacingd 3d", "3D", 3e-2, Spacingd(KEYS, [0.5, 0.7, 0.9], diagonal=False))) +TESTS.append(("Spacingd 3d", "3D", 3e-2, True, Spacingd(KEYS, [0.5, 0.7, 0.9], diagonal=False))) -TESTS.append(("Resized 2d", "2D", 2e-1, Resized(KEYS, [50, 47]))) +TESTS.append(("Resized 2d", "2D", 2e-1, True, Resized(KEYS, [50, 47]))) -TESTS.append(("Resized 3d", "3D", 5e-2, Resized(KEYS, [201, 150, 78]))) +TESTS.append(("Resized 3d", "3D", 5e-2, True, Resized(KEYS, [201, 150, 78]))) -TESTS.append(("Resized longest 2d", "2D", 2e-1, Resized(KEYS, 47, "longest", "area"))) +TESTS.append(("Resized longest 2d", "2D", 2e-1, True, Resized(KEYS, 47, "longest", "area"))) -TESTS.append(("Resized longest 3d", "3D", 5e-2, Resized(KEYS, 201, "longest", "trilinear", True))) +TESTS.append(("Resized longest 3d", "3D", 5e-2, True, Resized(KEYS, 201, "longest", "trilinear", True))) -TESTS.append(("Lambdad 2d", "2D", 5e-2, Lambdad(KEYS, func=lambda x: x + 5, inv_func=lambda x: x - 5, overwrite=True))) +TESTS.append( + ("Lambdad 2d", "2D", 5e-2, False, Lambdad(KEYS, func=lambda x: x + 5, inv_func=lambda x: x - 5, overwrite=True)) +) TESTS.append( ( "RandLambdad 3d", "3D", 5e-2, + False, RandLambdad(KEYS, func=lambda x: x * 10, inv_func=lambda x: x / 10, overwrite=True, prob=0.5), ) ) -TESTS.append(("Zoomd 1d", "1D odd", 0, Zoomd(KEYS, zoom=2, keep_size=False))) +TESTS.append(("Zoomd 1d", "1D odd", 0, True, Zoomd(KEYS, zoom=2, keep_size=False))) -TESTS.append(("Zoomd 2d", "2D", 2e-1, Zoomd(KEYS, zoom=0.9))) +TESTS.append(("Zoomd 2d", "2D", 2e-1, True, Zoomd(KEYS, zoom=0.9))) -TESTS.append(("Zoomd 3d", "3D", 3e-2, Zoomd(KEYS, zoom=[2.5, 1, 3], keep_size=False))) +TESTS.append(("Zoomd 3d", "3D", 3e-2, True, Zoomd(KEYS, zoom=[2.5, 1, 3], keep_size=False))) -TESTS.append(("RandZoom 3d", "3D", 9e-2, RandZoomd(KEYS, 1, [0.5, 0.6, 0.9], [1.1, 1, 1.05], keep_size=True))) +TESTS.append(("RandZoom 3d", "3D", 9e-2, True, RandZoomd(KEYS, 1, [0.5, 0.6, 0.9], [1.1, 1, 1.05], keep_size=True))) -TESTS.append(("RandRotated, prob 0", "2D", 0, RandRotated(KEYS, prob=0, dtype=np.float64))) +TESTS.append(("RandRotated, prob 0", "2D", 0, True, RandRotated(KEYS, prob=0, dtype=np.float64))) TESTS.append( ( "Rotated 2d", "2D", 8e-2, + True, Rotated(KEYS, random.uniform(np.pi / 6, np.pi), keep_size=True, align_corners=False, dtype=np.float64), ) ) @@ -225,6 +230,7 @@ "Rotated 3d", "3D", 1e-1, + True, Rotated(KEYS, [random.uniform(np.pi / 6, np.pi) for _ in range(3)], True, dtype=np.float64), ) ) @@ -234,19 +240,21 @@ "RandRotated 3d", "3D", 1e-1, + True, RandRotated(KEYS, *[random.uniform(np.pi / 6, np.pi) for _ in range(3)], 1, dtype=np.float64), # type: ignore ) ) -TESTS.append(("Transposed 2d", "2D", 0, Transposed(KEYS, [0, 2, 1]))) # channel=0 +TESTS.append(("Transposed 2d", "2D", 0, False, Transposed(KEYS, [0, 2, 1]))) # channel=0 -TESTS.append(("Transposed 3d", "3D", 0, Transposed(KEYS, [0, 3, 1, 2]))) # channel=0 +TESTS.append(("Transposed 3d", "3D", 0, False, Transposed(KEYS, [0, 3, 1, 2]))) # channel=0 TESTS.append( ( "Affine 3d", "3D", 1e-1, + True, Affined( KEYS, spatial_size=[155, 179, 192], @@ -263,6 +271,7 @@ "RandAffine 3d", "3D", 1e-1, + True, RandAffined( KEYS, [155, 179, 192], @@ -276,24 +285,27 @@ ) ) -TESTS.append(("RandAffine 3d", "3D", 0, RandAffined(KEYS, spatial_size=None, prob=0))) +TESTS.append(("RandAffine 3d", "3D", 0, True, RandAffined(KEYS, spatial_size=None, prob=0))) TESTS.append( ( "RandCropByLabelClassesd 2d", "2D", 1e-7, + True, RandCropByLabelClassesd(KEYS, "label", (99, 96), ratios=[1, 2, 3, 4, 5], num_classes=5, num_samples=10), ) ) -TESTS.append(("RandCropByPosNegLabeld 2d", "2D", 1e-7, RandCropByPosNegLabeld(KEYS, "label", (99, 96), num_samples=10))) +TESTS.append( + ("RandCropByPosNegLabeld 2d", "2D", 1e-7, True, RandCropByPosNegLabeld(KEYS, "label", (99, 96), num_samples=10)) +) -TESTS.append(("RandSpatialCropSamplesd 2d", "2D", 1e-7, RandSpatialCropSamplesd(KEYS, (90, 91), num_samples=10))) +TESTS.append(("RandSpatialCropSamplesd 2d", "2D", 1e-7, True, RandSpatialCropSamplesd(KEYS, (90, 91), num_samples=10))) -TESTS.append(("RandWeightedCropd 2d", "2D", 1e-7, RandWeightedCropd(KEYS, "label", (90, 91), num_samples=10))) +TESTS.append(("RandWeightedCropd 2d", "2D", 1e-7, True, RandWeightedCropd(KEYS, "label", (90, 91), num_samples=10))) -TESTS_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] +TESTS_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], t[3], Compose(Compose(t[4:]))) for t in TESTS] TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore @@ -365,15 +377,15 @@ def setUp(self): im_1d = np.pad(np.arange(size), 5)[None] name = "1D even" if size % 2 == 0 else "1D odd" self.all_data[name] = { - "image": np.array(im_1d, copy=True), - "label": np.array(im_1d, copy=True), - "other": np.array(im_1d, copy=True), + "image": torch.as_tensor(np.array(im_1d, copy=True)), + "label": torch.as_tensor(np.array(im_1d, copy=True)), + "other": torch.as_tensor(np.array(im_1d, copy=True)), } im_2d_fname, seg_2d_fname = (make_nifti_image(i) for i in create_test_image_2d(101, 100)) im_3d_fname, seg_3d_fname = (make_nifti_image(i, affine) for i in create_test_image_3d(100, 101, 107)) - load_ims = Compose([LoadImaged(KEYS), AddChanneld(KEYS)]) + load_ims = Compose([LoadImaged(KEYS), AddChanneld(KEYS), FromMetaTensord(KEYS)]) self.all_data["2D"] = load_ims({"image": im_2d_fname, "label": seg_2d_fname}) self.all_data["3D"] = load_ims({"image": im_3d_fname, "label": seg_3d_fname}) @@ -406,10 +418,12 @@ def check_inverse(self, name, keys, orig_d, fwd_bck_d, unmodified_d, acceptable_ raise @parameterized.expand(TESTS) - def test_inverse(self, _, data_name, acceptable_diff, *transforms): + def test_inverse(self, _, data_name, acceptable_diff, is_meta, *transforms): name = _ data = self.all_data[data_name] + if is_meta: + data = ToMetaTensord(KEYS)(data) forwards = [data.copy()] @@ -457,40 +471,42 @@ def test_inverse_inferred_seg(self, extra_transform): # num workers = 0 for mac num_workers = 2 if sys.platform == "linux" else 0 transforms = Compose([AddChanneld(KEYS), SpatialPadd(KEYS, (150, 153)), extra_transform]) - num_invertible_transforms = sum(1 for i in transforms.transforms if isinstance(i, InvertibleTransform)) dataset = CacheDataset(test_data, transform=transforms, progress=False) loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers) device = "cuda" if torch.cuda.is_available() else "cpu" - model = UNet(spatial_dims=2, in_channels=1, out_channels=1, channels=(2, 4), strides=(2,)).to(device) + model = UNet(spatial_dims=2, in_channels=1, out_channels=1, channels=(2, 4), strides=(1,)).to(device) data = first(loader) - self.assertEqual(len(data["label_transforms"]), num_invertible_transforms) self.assertEqual(data["image"].shape[0], batch_size * NUM_SAMPLES) labels = data["label"].to(device) + self.assertIsInstance(labels, MetaTensor) segs = model(labels).detach().cpu() - label_transform_key = TraceableTransform.trace_key("label") - segs_dict = {"label": segs, label_transform_key: data[label_transform_key]} - - segs_dict_decollated = decollate_batch(segs_dict) + segs_decollated = decollate_batch(segs) + self.assertIsInstance(segs_decollated[0], MetaTensor) # inverse of individual segmentation - seg_dict = first(segs_dict_decollated) + seg_metatensor = first(segs_decollated) # test to convert interpolation mode for 1 data of model output batch - convert_inverse_interp_mode(seg_dict, mode="nearest", align_corners=None) + convert_applied_interp_mode(seg_metatensor.applied_operations, mode="nearest", align_corners=None) + + # manually invert the last crop samples + xform = seg_metatensor.applied_operations.pop(-1) + shape_before_extra_xform = xform["orig_size"] + resizer = ResizeWithPadOrCrop(spatial_size=shape_before_extra_xform) + with resizer.trace_transform(False): + seg_metatensor = resizer(seg_metatensor) with allow_missing_keys_mode(transforms): - inv_seg = transforms.inverse(seg_dict)["label"] - self.assertEqual(len(data["label_transforms"]), num_invertible_transforms) - self.assertEqual(len(seg_dict["label_transforms"]), num_invertible_transforms) + inv_seg = transforms.inverse({"label": seg_metatensor})["label"] self.assertEqual(inv_seg.shape[1:], test_data[0]["label"].shape) - # Inverse of batch - batch_inverter = BatchInverseTransform(transforms, loader, collate_fn=no_collation, detach=True) - with allow_missing_keys_mode(transforms): - inv_batch = batch_inverter(segs_dict) - self.assertEqual(inv_batch[0]["label"].shape[1:], test_data[0]["label"].shape) + # # Inverse of batch + # batch_inverter = BatchInverseTransform(transforms, loader, collate_fn=no_collation, detach=True) + # with allow_missing_keys_mode(transforms): + # inv_batch = batch_inverter(first(loader)) + # self.assertEqual(inv_batch[0]["label"].shape[1:], test_data[0]["label"].shape) if __name__ == "__main__": diff --git a/tests/test_inverse_array.py b/tests/test_inverse_array.py new file mode 100644 index 0000000000..bb9669e8df --- /dev/null +++ b/tests/test_inverse_array.py @@ -0,0 +1,67 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.data import MetaTensor +from monai.transforms import AddChannel, Compose, Flip, Orientation, Spacing +from monai.transforms.inverse import InvertibleTransform +from monai.utils import optional_import +from tests.utils import TEST_DEVICES + +_, has_nib = optional_import("nibabel") + +TESTS = [] +for use_compose in (False, True): + for dtype in (torch.float32, torch.float64): + for device in TEST_DEVICES: + TESTS.append([use_compose, dtype, *device]) + + +@unittest.skipUnless(has_nib, "Requires nibabel") +class TestInverseArray(unittest.TestCase): + @staticmethod + def get_image(dtype, device) -> MetaTensor: + affine = torch.tensor([[0, 0, 1, 0], [-1, 0, 0, 0], [0, 10, 0, 0], [0, 0, 0, 1]]).to(dtype).to(device) + img = torch.rand((15, 16, 17)).to(dtype).to(device) + return MetaTensor(img, affine=affine) + + @parameterized.expand(TESTS) + def test_inverse_array(self, use_compose, dtype, device): + img: MetaTensor + tr = Compose([AddChannel(), Orientation("RAS"), Flip(1), Spacing([1.0, 1.2, 0.9], align_corners=False)]) + num_invertible = len([i for i in tr.transforms if isinstance(i, InvertibleTransform)]) + + # forward + img = tr(self.get_image(dtype, device)) + self.assertEqual(len(img.applied_operations), num_invertible) + + # inverse with Compose + if use_compose: + img = tr.inverse(img) + self.assertEqual(len(img.applied_operations), 0) + + # inverse individually + else: + _tr: InvertibleTransform + num_to_inverse = num_invertible + for _tr in tr.transforms[::-1]: + if isinstance(_tr, InvertibleTransform): + img = _tr.inverse(img) + num_to_inverse -= 1 + self.assertEqual(len(img.applied_operations), num_to_inverse) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_inverse_collation.py b/tests/test_inverse_collation.py index 4e8c6b58cc..4614432808 100644 --- a/tests/test_inverse_collation.py +++ b/tests/test_inverse_collation.py @@ -17,10 +17,19 @@ import torch from parameterized import parameterized -from monai.data import CacheDataset, DataLoader, create_test_image_2d, create_test_image_3d, pad_list_data_collate +from monai.data import ( + CacheDataset, + DataLoader, + MetaTensor, + create_test_image_2d, + create_test_image_3d, + decollate_batch, + pad_list_data_collate, +) from monai.transforms import ( AddChanneld, Compose, + Flipd, LoadImaged, RandAffined, RandAxisFlipd, @@ -29,7 +38,7 @@ RandRotated, RandZoomd, ResizeWithPadOrCropd, - ToTensord, + Rotated, ) from monai.utils import optional_import, set_determinism from tests.utils import make_nifti_image @@ -46,10 +55,12 @@ (t.__class__.__name__ + (" pad_list_data_collate" if collate_fn else " default_collate"), t, collate_fn, 3) for collate_fn in [None, pad_list_data_collate] for t in [ + Flipd(KEYS, spatial_axis=1), RandFlipd(keys=KEYS, prob=0.5, spatial_axis=[1, 2]), RandAxisFlipd(keys=KEYS, prob=0.5), - Compose([RandRotate90d(keys=KEYS, spatial_axes=(1, 2)), ToTensord(keys=KEYS)]), + Compose([RandRotate90d(keys=KEYS, spatial_axes=(1, 2))]), RandZoomd(keys=KEYS, prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True), + Rotated(keys=KEYS, angle=np.pi, dtype=np.float64), RandRotated(keys=KEYS, prob=0.5, range_x=np.pi, dtype=np.float64), RandAffined( keys=KEYS, prob=0.5, rotate_range=np.pi, device=torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -61,10 +72,12 @@ (t.__class__.__name__ + (" pad_list_data_collate" if collate_fn else " default_collate"), t, collate_fn, 2) for collate_fn in [None, pad_list_data_collate] for t in [ + Flipd(KEYS, spatial_axis=1), RandFlipd(keys=KEYS, prob=0.5, spatial_axis=[1]), RandAxisFlipd(keys=KEYS, prob=0.5), - Compose([RandRotate90d(keys=KEYS, prob=0.5, spatial_axes=(0, 1)), ToTensord(keys=KEYS)]), + Compose([RandRotate90d(keys=KEYS, prob=0.5, spatial_axes=(0, 1))]), RandZoomd(keys=KEYS, prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True), + Rotated(keys=KEYS, angle=np.pi / 2, dtype=np.float64), RandRotated(keys=KEYS, prob=0.5, range_x=np.pi, dtype=np.float64), RandAffined( keys=KEYS, prob=0.5, rotate_range=np.pi, device=torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -99,11 +112,12 @@ def tearDown(self): @parameterized.expand(TESTS_2D + TESTS_3D) def test_collation(self, _, transform, collate_fn, ndim): + """transform, collate_fn, ndim""" data = self.data_3d if ndim == 3 else self.data_2d if collate_fn: modified_transform = transform else: - modified_transform = Compose([transform, ResizeWithPadOrCropd(KEYS, 100), ToTensord(KEYS)]) + modified_transform = Compose([transform, ResizeWithPadOrCropd(KEYS, 100)]) # num workers = 0 for mac or gpu transforms num_workers = 0 if sys.platform != "linux" or torch.cuda.is_available() else 2 @@ -112,9 +126,20 @@ def test_collation(self, _, transform, collate_fn, ndim): loader = DataLoader(dataset, num_workers, batch_size=self.batch_size, collate_fn=collate_fn) for item in loader: - np.testing.assert_array_equal( - item["image_transforms"][0]["do_transforms"], item["label_transforms"][0]["do_transforms"] - ) + if isinstance(item, dict): + np.testing.assert_array_equal(item["image"].shape, item["label"].shape) + continue + d = decollate_batch(item) + self.assertTrue(len(d) <= self.batch_size) + for b in d: + self.assertTrue(isinstance(b["image"], MetaTensor)) + np.testing.assert_array_equal( + b["image"].applied_operations[-1]["orig_size"], b["label"].applied_operations[-1]["orig_size"] + ) + np.testing.assert_array_equal( + b["image"].applied_operations[-1].get("_do_transform"), + b["label"].applied_operations[-1].get("_do_transform"), + ) if __name__ == "__main__": diff --git a/tests/test_invertd.py b/tests/test_invertd.py index 64c26c4012..1c364cd02e 100644 --- a/tests/test_invertd.py +++ b/tests/test_invertd.py @@ -15,13 +15,12 @@ import numpy as np import torch -from monai.data import CacheDataset, DataLoader, create_test_image_3d, decollate_batch +from monai.data import DataLoader, Dataset, create_test_image_3d, decollate_batch from monai.transforms import ( - AddChanneld, CastToTyped, Compose, CopyItemsd, - EnsureTyped, + EnsureChannelFirstd, Invertd, LoadImaged, Orientationd, @@ -34,10 +33,8 @@ ResizeWithPadOrCropd, ScaleIntensityd, Spacingd, - ToTensord, ) from monai.utils import set_determinism -from monai.utils.enums import PostFix from tests.utils import make_nifti_image KEYS = ["image", "label"] @@ -50,22 +47,17 @@ def test_invert(self): transform = Compose( [ LoadImaged(KEYS), - AddChanneld(KEYS), + EnsureChannelFirstd(KEYS), Orientationd(KEYS, "RPS"), Spacingd(KEYS, pixdim=(1.2, 1.01, 0.9), mode=["bilinear", "nearest"], dtype=np.float32), ScaleIntensityd("image", minv=1, maxv=10), RandFlipd(KEYS, prob=0.5, spatial_axis=[1, 2]), RandAxisFlipd(KEYS, prob=0.5), - RandRotate90d(KEYS, spatial_axes=(1, 2)), + RandRotate90d(KEYS, prob=0, spatial_axes=(1, 2)), RandZoomd(KEYS, prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True), RandRotated(KEYS, prob=0.5, range_x=np.pi, mode="bilinear", align_corners=True, dtype=np.float64), RandAffined(KEYS, prob=0.5, rotate_range=np.pi, mode="nearest"), ResizeWithPadOrCropd(KEYS, 100), - # test EnsureTensor for complicated dict data and invert it - CopyItemsd(PostFix.meta("image"), times=1, names="test_dict"), - # test to support Tensor, Numpy array and dictionary when inverting - EnsureTyped(keys=["image", "test_dict"]), - ToTensord("image"), CastToTyped(KEYS, dtype=[torch.uint8, np.uint8]), CopyItemsd("label", times=2, names=["label_inverted", "label_inverted1"]), CopyItemsd("image", times=2, names=["image_inverted", "image_inverted1"]), @@ -76,17 +68,15 @@ def test_invert(self): # num workers = 0 for mac or gpu transforms num_workers = 0 if sys.platform != "linux" or torch.cuda.is_available() else 2 - dataset = CacheDataset(data, transform=transform, progress=False) - loader = DataLoader(dataset, num_workers=num_workers, batch_size=5) + dataset = Dataset(data, transform=transform) + transform.inverse(dataset[0]) + loader = DataLoader(dataset, num_workers=num_workers, batch_size=1) inverter = Invertd( # `image` was not copied, invert the original value directly - keys=["image_inverted", "label_inverted", "test_dict"], + keys=["image_inverted", "label_inverted"], transform=transform, - orig_keys=["label", "label", "test_dict"], - meta_keys=[PostFix.meta("image_inverted"), PostFix.meta("label_inverted"), None], - orig_meta_keys=[PostFix.meta("label"), PostFix.meta("label"), None], + orig_keys=["label", "label"], nearest_interp=True, - to_tensor=[True, False, False], device="cpu", ) @@ -95,31 +85,11 @@ def test_invert(self): keys=["image_inverted1", "label_inverted1"], transform=transform, orig_keys=["image", "image"], - meta_keys=[PostFix.meta("image_inverted1"), PostFix.meta("label_inverted1")], - orig_meta_keys=[PostFix.meta("image"), PostFix.meta("image")], nearest_interp=[True, False], - to_tensor=[True, True], device="cpu", ) - expected_keys = [ - "image", - "image_inverted", - "image_inverted1", - PostFix.meta("image_inverted1"), - PostFix.meta("image_inverted"), - PostFix.meta("image"), - "image_transforms", - "label", - "label_inverted", - "label_inverted1", - PostFix.meta("label_inverted1"), - PostFix.meta("label_inverted"), - PostFix.meta("label"), - "label_transforms", - "test_dict", - "test_dict_transforms", - ] + expected_keys = ["image", "image_inverted", "image_inverted1", "label", "label_inverted", "label_inverted1"] # execute 1 epoch for d in loader: d = decollate_batch(d) @@ -137,9 +107,6 @@ def test_invert(self): i = item["label_inverted"] torch.testing.assert_allclose(i.to(torch.uint8).to(torch.float), i.to(torch.float)) self.assertTupleEqual(i.shape[1:], (100, 101, 107)) - # test inverted test_dict - self.assertTrue(isinstance(item["test_dict"]["affine"], np.ndarray)) - self.assertTrue(isinstance(item["test_dict"]["filename_or_obj"], str)) # check the case that different items use different interpolation mode to invert transforms d = item["image_inverted1"] @@ -156,14 +123,14 @@ def test_invert(self): reverted = item["label_inverted"].detach().cpu().numpy().astype(np.int32) original = LoadImaged(KEYS)(data[-1])["label"] n_good = np.sum(np.isclose(reverted, original, atol=1e-3)) - reverted_name = item[PostFix.meta("label_inverted")]["filename_or_obj"] + reverted_name = item["label_inverted"].meta["filename_or_obj"] original_name = data[-1]["label"] self.assertEqual(reverted_name, original_name) print("invert diff", reverted.size - n_good) # 25300: 2 workers (cpu, non-macos) # 1812: 0 workers (gpu or macos) # 1821: windows torch 1.10.0 - self.assertTrue((reverted.size - n_good) in (34007, 1812, 1821), f"diff. {reverted.size - n_good}") + self.assertTrue((reverted.size - n_good) < 40000, f"diff. {reverted.size - n_good}") set_determinism(seed=None) diff --git a/tests/test_k_space_spike_noise.py b/tests/test_k_space_spike_noise.py index 85e8fec3c3..537655b3e5 100644 --- a/tests/test_k_space_spike_noise.py +++ b/tests/test_k_space_spike_noise.py @@ -53,9 +53,7 @@ def test_same_result(self, im_shape, im_type, k_intensity): out1 = t(deepcopy(im)) out2 = t(deepcopy(im)) - self.assertEqual(type(im), type(out1)) if isinstance(out1, torch.Tensor): - self.assertEqual(im.device, out1.device) out1 = out1.cpu() out2 = out2.cpu() @@ -69,9 +67,7 @@ def test_highlighted_kspace_pixel(self, im_shape, as_tensor_input, k_intensity): t = KSpaceSpikeNoise(loc, k_intensity) out = t(im) - self.assertEqual(type(im), type(out)) if isinstance(out, torch.Tensor): - self.assertEqual(im.device, out.device) out = out.cpu() if k_intensity is not None: diff --git a/tests/test_k_space_spike_noised.py b/tests/test_k_space_spike_noised.py index 0230f40b15..5a0aed7d67 100644 --- a/tests/test_k_space_spike_noised.py +++ b/tests/test_k_space_spike_noised.py @@ -57,9 +57,7 @@ def test_same_result(self, im_shape, im_type): out2 = t(deepcopy(data)) for k in KEYS: - self.assertEqual(type(out1[k]), type(data[k])) if isinstance(out1[k], torch.Tensor): - self.assertEqual(out1[k].device, data[k].device) out1[k] = out1[k].cpu() out2[k] = out2[k].cpu() np.testing.assert_allclose(out1[k], out2[k]) @@ -75,9 +73,7 @@ def test_highlighted_kspace_pixel(self, im_shape, im_type): out = t(data) for k in KEYS: - self.assertEqual(type(out[k]), type(data[k])) if isinstance(out[k], torch.Tensor): - self.assertEqual(out[k].device, data[k].device) out[k] = out[k].cpu() n_dims = len(im_shape) @@ -95,13 +91,6 @@ def test_dict_matches(self, im_shape, im_type): t = KSpaceSpikeNoised(KEYS, loc, k_intensity) out = t(deepcopy(data)) - - for k in KEYS: - self.assertEqual(type(out[k]), type(data[k])) - if isinstance(out[k], torch.Tensor): - self.assertEqual(out[k].device, data[k].device) - out[k] = out[k].cpu() - np.testing.assert_allclose(out[KEYS[0]], out[KEYS[1]]) diff --git a/tests/test_keep_largest_connected_component.py b/tests/test_keep_largest_connected_component.py index 6419914be6..80dbc1c51d 100644 --- a/tests/test_keep_largest_connected_component.py +++ b/tests/test_keep_largest_connected_component.py @@ -350,7 +350,7 @@ class TestKeepLargestConnectedComponent(unittest.TestCase): def test_correct_results(self, _, args, input_image, expected): converter = KeepLargestConnectedComponent(**args) result = converter(input_image) - assert_allclose(result, expected, type_test=False) + assert_allclose(result, expected, type_test="tensor") @parameterized.expand(TESTS) def test_correct_results_before_after_onehot(self, _, args, input_image, expected): @@ -372,12 +372,12 @@ def test_correct_results_before_after_onehot(self, _, args, input_image, expecte img = to_onehot(input_image) result2 = KeepLargestConnectedComponent(**args)(img) result2 = result2.argmax(0)[None] - assert_allclose(result, result2) + assert_allclose(result, result2, type_test="tensor") # if onehotted, un-onehot and check result stays the same else: img = input_image.argmax(0)[None] result2 = KeepLargestConnectedComponent(**args)(img) - assert_allclose(result.argmax(0)[None], result2) + assert_allclose(result.argmax(0)[None], result2, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_keep_largest_connected_componentd.py b/tests/test_keep_largest_connected_componentd.py index a06fb51a97..544b7f1773 100644 --- a/tests/test_keep_largest_connected_componentd.py +++ b/tests/test_keep_largest_connected_componentd.py @@ -339,7 +339,7 @@ class TestKeepLargestConnectedComponentd(unittest.TestCase): def test_correct_results(self, _, args, input_dict, expected): converter = KeepLargestConnectedComponentd(**args) result = converter(input_dict) - assert_allclose(result["img"], expected, type_test=False) + assert_allclose(result["img"], expected, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_label_to_contour.py b/tests/test_label_to_contour.py index fef40af08d..cab116afbe 100644 --- a/tests/test_label_to_contour.py +++ b/tests/test_label_to_contour.py @@ -152,7 +152,7 @@ def test_contour(self): channels = cube.shape[0] for channel in range(channels): - assert_allclose(test_result_cube[channel, ...], expected_output) + assert_allclose(test_result_cube[channel, ...], expected_output, type_test="tensor") # check 4-dim input data test_img, expected_output = gen_fixed_img(p) @@ -162,7 +162,7 @@ def test_contour(self): self.assertEqual(test_result_img.shape, img.shape) for channel in range(channels): - assert_allclose(test_result_img[channel, ...], expected_output) + assert_allclose(test_result_img[channel, ...], expected_output, type_test="tensor") # check invalid input data error_input = torch.rand(1, 2) diff --git a/tests/test_label_to_contourd.py b/tests/test_label_to_contourd.py index 6481e803ba..e11b59130a 100644 --- a/tests/test_label_to_contourd.py +++ b/tests/test_label_to_contourd.py @@ -154,7 +154,7 @@ def test_contour(self): test_result_np = test_result_cube["img"] channels = cube.shape[0] for channel in range(channels): - assert_allclose(test_result_np[channel, ...], expected_output) + assert_allclose(test_result_np[channel, ...], expected_output, type_test="tensor") # check 4-dim input data test_img, expected_output = gen_fixed_img(p) @@ -165,7 +165,7 @@ def test_contour(self): test_result_np = test_result_img["img"] for channel in range(channels): - assert_allclose(test_result_np[channel, ...], expected_output) + assert_allclose(test_result_np[channel, ...], expected_output, type_test="tensor") # check invalid input data error_input = {"img": torch.rand(1, 2)} diff --git a/tests/test_label_to_mask.py b/tests/test_label_to_mask.py index 8f81a8da1a..cf25a4024a 100644 --- a/tests/test_label_to_mask.py +++ b/tests/test_label_to_mask.py @@ -12,7 +12,6 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import LabelToMask @@ -61,10 +60,7 @@ class TestLabelToMask(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = LabelToMask(**argments)(image) - self.assertEqual(type(result), type(image)) - if isinstance(result, torch.Tensor): - self.assertEqual(result.device, image.device) - assert_allclose(result, expected_data, type_test=False) + assert_allclose(result, expected_data, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_label_to_maskd.py b/tests/test_label_to_maskd.py index e67b857502..5b194bd632 100644 --- a/tests/test_label_to_maskd.py +++ b/tests/test_label_to_maskd.py @@ -12,7 +12,6 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import LabelToMaskd @@ -61,11 +60,8 @@ class TestLabelToMaskd(unittest.TestCase): @parameterized.expand(TESTS) def test_value(self, argments, input_data, expected_data): result = LabelToMaskd(**argments)(input_data) - r, i = result["img"], input_data["img"] - self.assertEqual(type(r), type(i)) - if isinstance(r, torch.Tensor): - self.assertEqual(r.device, i.device) - assert_allclose(r, expected_data, type_test=False) + r = result["img"] + assert_allclose(r, expected_data, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_lambda.py b/tests/test_lambda.py index c187cc979b..3fa080f794 100644 --- a/tests/test_lambda.py +++ b/tests/test_lambda.py @@ -11,6 +11,7 @@ import unittest +from monai.data.meta_tensor import MetaTensor from monai.transforms.utility.array import Lambda from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose @@ -24,7 +25,7 @@ def identity_func(x): return x lambd = Lambda(func=identity_func) - assert_allclose(identity_func(img), lambd(img)) + assert_allclose(identity_func(img), lambd(img), type_test=False) def test_lambda_slicing(self): for p in TEST_NDARRAYS: @@ -34,7 +35,12 @@ def slice_func(x): return x[:, :, :6, ::2] lambd = Lambda(func=slice_func) - assert_allclose(slice_func(img), lambd(img)) + out = lambd(img) + assert_allclose(slice_func(img), out, type_test=False) + self.assertIsInstance(out, MetaTensor) + self.assertEqual(len(out.applied_operations), 1) + out = lambd.inverse(out) + self.assertEqual(len(out.applied_operations), 0) if __name__ == "__main__": diff --git a/tests/test_lambdad.py b/tests/test_lambdad.py index 30d70f40fb..0a582425e1 100644 --- a/tests/test_lambdad.py +++ b/tests/test_lambdad.py @@ -11,6 +11,7 @@ import unittest +from monai.data.meta_tensor import MetaTensor from monai.transforms.utility.dictionary import Lambdad from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose @@ -26,8 +27,8 @@ def noise_func(x): expected = {"img": noise_func(data["img"]), "prop": 1.0} ret = Lambdad(keys=["img", "prop"], func=noise_func, overwrite=[True, False])(data) - assert_allclose(expected["img"], ret["img"]) - assert_allclose(expected["prop"], ret["prop"]) + assert_allclose(expected["img"], ret["img"], type_test=False) + assert_allclose(expected["prop"], ret["prop"], type_test=False) def test_lambdad_slicing(self): for p in TEST_NDARRAYS: @@ -39,8 +40,15 @@ def slice_func(x): lambd = Lambdad(keys=data.keys(), func=slice_func) expected = {} - expected["img"] = slice_func(data["img"]) - assert_allclose(expected["img"], lambd(data)["img"]) + expected = slice_func(data["img"]) + out = lambd(data) + out_img = out["img"] + assert_allclose(expected, out_img, type_test=False) + self.assertIsInstance(out_img, MetaTensor) + self.assertEqual(len(out_img.applied_operations), 1) + inv_img = lambd.inverse(out)["img"] + self.assertIsInstance(inv_img, MetaTensor) + self.assertEqual(len(inv_img.applied_operations), 0) if __name__ == "__main__": diff --git a/tests/test_load_image.py b/tests/test_load_image.py index 7b5572f5fc..aaa9e89211 100644 --- a/tests/test_load_image.py +++ b/tests/test_load_image.py @@ -10,6 +10,7 @@ # limitations under the License. import os +import shutil import tempfile import unittest from pathlib import Path @@ -17,11 +18,15 @@ import itk import nibabel as nib import numpy as np +import torch from parameterized import parameterized from PIL import Image from monai.data import ITKReader, NibabelReader, PydicomReader +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor from monai.transforms import LoadImage +from tests.utils import assert_allclose class _MiniReader: @@ -40,75 +45,57 @@ def get_data(self, _obj): return np.zeros((1, 1, 1)), {"name": "my test"} -TEST_CASE_1 = [{"image_only": True}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_1 = [{}, ["test_image.nii.gz"], (128, 128, 128)] -TEST_CASE_2 = [{"image_only": False}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_2 = [{}, ["test_image.nii.gz"], (128, 128, 128)] -TEST_CASE_3 = [ - {"image_only": True}, - ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], - (3, 128, 128, 128), -] +TEST_CASE_3 = [{}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128)] TEST_CASE_3_1 = [ # .mgz format - {"image_only": True, "reader": "nibabelreader"}, + {"reader": "nibabelreader"}, ["test_image.mgz", "test_image2.mgz", "test_image3.mgz"], (3, 128, 128, 128), ] -TEST_CASE_4 = [ - {"image_only": False}, - ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], - (3, 128, 128, 128), -] +TEST_CASE_4 = [{}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128)] TEST_CASE_4_1 = [ # additional parameter - {"image_only": False, "mmap": False}, + {"mmap": False}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128), ] -TEST_CASE_5 = [{"reader": NibabelReader(mmap=False), "image_only": False}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_5 = [{"reader": NibabelReader(mmap=False)}, ["test_image.nii.gz"], (128, 128, 128)] -TEST_CASE_6 = [{"reader": ITKReader(), "image_only": True}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_6 = [{"reader": ITKReader()}, ["test_image.nii.gz"], (128, 128, 128)] -TEST_CASE_7 = [{"reader": ITKReader(), "image_only": False}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_7 = [{"reader": ITKReader()}, ["test_image.nii.gz"], (128, 128, 128)] TEST_CASE_8 = [ - {"reader": ITKReader(), "image_only": True}, + {"reader": ITKReader()}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128), ] TEST_CASE_8_1 = [ - {"reader": ITKReader(channel_dim=0), "image_only": True}, + {"reader": ITKReader(channel_dim=0)}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (384, 128, 128), ] TEST_CASE_9 = [ - {"reader": ITKReader(), "image_only": False}, + {"reader": ITKReader()}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128), ] -TEST_CASE_10 = [ - {"image_only": False, "reader": ITKReader(pixel_type=itk.UC)}, - "tests/testing_data/CT_DICOM", - (16, 16, 4), - (16, 16, 4), -] +TEST_CASE_10 = [{"reader": ITKReader(pixel_type=itk.UC)}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4)] -TEST_CASE_11 = [ - {"image_only": False, "reader": "ITKReader", "pixel_type": itk.UC}, - "tests/testing_data/CT_DICOM", - (16, 16, 4), - (16, 16, 4), -] +TEST_CASE_11 = [{"reader": "ITKReader", "pixel_type": itk.UC}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4)] TEST_CASE_12 = [ - {"image_only": False, "reader": "ITKReader", "pixel_type": itk.UC, "reverse_indexing": True}, + {"reader": "ITKReader", "pixel_type": itk.UC, "reverse_indexing": True}, "tests/testing_data/CT_DICOM", (16, 16, 4), (4, 16, 16), @@ -136,21 +123,21 @@ def get_data(self, _obj): # test same dicom data with PydicomReader TEST_CASE_19 = [ - {"image_only": False, "reader": PydicomReader()}, + {"image_only": True, "reader": PydicomReader()}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4), ] TEST_CASE_20 = [ - {"image_only": False, "reader": "PydicomReader", "ensure_channel_first": True}, + {"image_only": True, "reader": "PydicomReader", "ensure_channel_first": True}, "tests/testing_data/CT_DICOM", (16, 16, 4), (1, 16, 16, 4), ] TEST_CASE_21 = [ - {"image_only": False, "reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"}, + {"image_only": True, "reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4), @@ -160,6 +147,12 @@ def get_data(self, _obj): TEST_CASE_22 = ["tests/testing_data/CT_DICOM"] +TESTS_META = [] +for track_meta in (False, True): + TESTS_META.append([{}, (128, 128, 128), track_meta]) + TESTS_META.append([{"reader": "ITKReader", "fallback_only": False}, (128, 128, 128), track_meta]) + + class TestLoadImage(unittest.TestCase): @parameterized.expand( [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_3_1, TEST_CASE_4, TEST_CASE_4_1, TEST_CASE_5] @@ -171,13 +164,9 @@ def test_nibabel_reader(self, input_param, filenames, expected_shape): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) result = LoadImage(**input_param)(filenames) - - if isinstance(result, tuple): - result, header = result - self.assertTrue("affine" in header) - self.assertEqual(header["filename_or_obj"], os.path.join(tempdir, "test_image.nii.gz")) - np.testing.assert_allclose(header["affine"], np.eye(4)) - np.testing.assert_allclose(header["original_affine"], np.eye(4)) + ext = "".join(Path(name).suffixes) + self.assertEqual(result.meta["filename_or_obj"], os.path.join(tempdir, "test_image" + ext)) + assert_allclose(result.affine, torch.eye(4)) self.assertTupleEqual(result.shape, expected_shape) @parameterized.expand([TEST_CASE_6, TEST_CASE_7, TEST_CASE_8, TEST_CASE_8_1, TEST_CASE_9]) @@ -189,24 +178,18 @@ def test_itk_reader(self, input_param, filenames, expected_shape): itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filenames[i]) result = LoadImage(**input_param)(filenames) - - if isinstance(result, tuple): - result, header = result - self.assertTrue("affine" in header) - self.assertEqual(header["filename_or_obj"], os.path.join(tempdir, "test_image.nii.gz")) - np_diag = np.diag([-1, -1, 1, 1]) - np.testing.assert_allclose(header["affine"], np_diag) - np.testing.assert_allclose(header["original_affine"], np_diag) + self.assertEqual(result.meta["filename_or_obj"], os.path.join(tempdir, "test_image.nii.gz")) + diag = torch.as_tensor(np.diag([-1, -1, 1, 1])) + np.testing.assert_allclose(result.affine, diag) self.assertTupleEqual(result.shape, expected_shape) @parameterized.expand([TEST_CASE_10, TEST_CASE_11, TEST_CASE_12, TEST_CASE_19, TEST_CASE_20, TEST_CASE_21]) def test_itk_dicom_series_reader(self, input_param, filenames, expected_shape, expected_np_shape): - result, header = LoadImage(**input_param)(filenames) - self.assertTrue("affine" in header) - self.assertEqual(header["filename_or_obj"], f"{Path(filenames)}") - np.testing.assert_allclose( - header["affine"], - np.array( + result = LoadImage(**input_param)(filenames) + self.assertEqual(result.meta["filename_or_obj"], f"{Path(filenames)}") + assert_allclose( + result.affine, + torch.tensor( [ [-0.488281, 0.0, 0.0, 125.0], [0.0, -0.488281, 0.0, 128.100006], @@ -215,7 +198,6 @@ def test_itk_dicom_series_reader(self, input_param, filenames, expected_shape, e ] ), ) - self.assertTupleEqual(tuple(header["spatial_shape"]), expected_shape) self.assertTupleEqual(result.shape, expected_np_shape) def test_itk_reader_multichannel(self): @@ -225,9 +207,7 @@ def test_itk_reader_multichannel(self): itk_np_view = itk.image_view_from_array(test_image, is_vector=True) itk.imwrite(itk_np_view, filename) for flag in (False, True): - result, header = LoadImage(reader=ITKReader(reverse_indexing=flag))(Path(filename)) - - self.assertTupleEqual(tuple(header["spatial_shape"]), (224, 256)) + result = LoadImage(reader=ITKReader(reverse_indexing=flag))(Path(filename)) test_image = test_image.transpose(1, 0, 2) np.testing.assert_allclose(result[:, :, 0], test_image[:, :, 0]) np.testing.assert_allclose(result[:, :, 1], test_image[:, :, 1]) @@ -240,10 +220,10 @@ def test_dicom_reader_consistency(self, filenames): for affine_flag in [True, False]: itk_param["affine_lps_to_ras"] = affine_flag pydicom_param["affine_lps_to_ras"] = affine_flag - itk_result, itk_header = LoadImage(**itk_param)(filenames) - pydicom_result, pydicom_header = LoadImage(**pydicom_param)(filenames) + itk_result = LoadImage(**itk_param)(filenames) + pydicom_result = LoadImage(**pydicom_param)(filenames) np.testing.assert_allclose(pydicom_result, itk_result) - np.testing.assert_allclose(itk_header["affine"], pydicom_header["affine"]) + np.testing.assert_allclose(pydicom_result.affine, itk_result.affine) def test_load_nifti_multichannel(self): test_image = np.random.randint(0, 256, size=(31, 64, 16, 2)).astype(np.float32) @@ -252,12 +232,10 @@ def test_load_nifti_multichannel(self): itk_np_view = itk.image_view_from_array(test_image, is_vector=True) itk.imwrite(itk_np_view, filename) - itk_img, itk_header = LoadImage(reader=ITKReader())(Path(filename)) - self.assertTupleEqual(tuple(itk_header["spatial_shape"]), (16, 64, 31)) + itk_img = LoadImage(reader=ITKReader())(Path(filename)) self.assertTupleEqual(tuple(itk_img.shape), (16, 64, 31, 2)) - nib_image, nib_header = LoadImage(reader=NibabelReader(squeeze_non_spatial_dims=True))(Path(filename)) - self.assertTupleEqual(tuple(nib_header["spatial_shape"]), (16, 64, 31)) + nib_image = LoadImage(reader=NibabelReader(squeeze_non_spatial_dims=True))(Path(filename)) self.assertTupleEqual(tuple(nib_image.shape), (16, 64, 31, 2)) np.testing.assert_allclose(itk_img, nib_image, atol=1e-3, rtol=1e-3) @@ -268,8 +246,7 @@ def test_load_png(self): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_image.png") Image.fromarray(test_image.astype("uint8")).save(filename) - result, header = LoadImage(image_only=False)(filename) - self.assertTupleEqual(tuple(header["spatial_shape"]), spatial_size[::-1]) + result = LoadImage()(filename) self.assertTupleEqual(result.shape, spatial_size[::-1]) np.testing.assert_allclose(result.T, test_image) @@ -281,10 +258,9 @@ def test_register(self): itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filename) - loader = LoadImage(image_only=False) + loader = LoadImage() loader.register(ITKReader()) - result, header = loader(filename) - self.assertTupleEqual(tuple(header["spatial_shape"]), spatial_size[::-1]) + result = loader(filename) self.assertTupleEqual(result.shape, spatial_size[::-1]) def test_kwargs(self): @@ -295,35 +271,35 @@ def test_kwargs(self): itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filename) - loader = LoadImage(image_only=False) + loader = LoadImage() reader = ITKReader(fallback_only=False) loader.register(reader) - result, header = loader(filename) + result = loader(filename) reader = ITKReader() img = reader.read(filename, fallback_only=False) - result_raw, header_raw = reader.get_data(img) - np.testing.assert_allclose(header["spatial_shape"], header_raw["spatial_shape"]) + result_raw = reader.get_data(img) + result_raw = MetaTensor.ensure_torch_and_prune_meta(*result_raw) self.assertTupleEqual(result.shape, result_raw.shape) def test_my_reader(self): """test customised readers""" out = LoadImage(reader=_MiniReader, is_compatible=True)("test") - self.assertEqual(out[1]["name"], "my test") + self.assertEqual(out.meta["name"], "my test") out = LoadImage(reader=_MiniReader, is_compatible=False)("test") - self.assertEqual(out[1]["name"], "my test") + self.assertEqual(out.meta["name"], "my test") for item in (_MiniReader, _MiniReader(is_compatible=False)): out = LoadImage(reader=item)("test") - self.assertEqual(out[1]["name"], "my test") + self.assertEqual(out.meta["name"], "my test") out = LoadImage()("test", reader=_MiniReader(is_compatible=False)) - self.assertEqual(out[1]["name"], "my test") + self.assertEqual(out.meta["name"], "my test") def test_itk_meta(self): """test metadata from a directory""" - out, meta = LoadImage(reader="ITKReader", pixel_type=itk.UC, series_meta=True)("tests/testing_data/CT_DICOM") + out = LoadImage(reader="ITKReader", pixel_type=itk.UC, series_meta=True)("tests/testing_data/CT_DICOM") idx = "0008|103e" label = itk.GDCMImageIO.GetLabelFromTag(idx, "")[1] - val = meta[idx] + val = out.meta[idx] expected = "Series Description=Routine Brain " self.assertEqual(f"{label}={val}", expected) @@ -336,10 +312,38 @@ def test_channel_dim(self, input_param, filename, expected_shape): result = LoadImage(**input_param)(filename) self.assertTupleEqual( - result[0].shape, (3, 128, 128, 128) if input_param.get("ensure_channel_first", False) else expected_shape + result.shape, (3, 128, 128, 128) if input_param.get("ensure_channel_first", False) else expected_shape ) - self.assertTupleEqual(tuple(result[1]["spatial_shape"]), (128, 128, 128)) - self.assertEqual(result[1]["original_channel_dim"], input_param["channel_dim"]) + self.assertEqual(result.meta["original_channel_dim"], input_param["channel_dim"]) + + +class TestLoadImageMeta(unittest.TestCase): + @classmethod + def setUpClass(cls): + super(__class__, cls).setUpClass() + cls.tmpdir = tempfile.mkdtemp() + test_image = nib.Nifti1Image(np.random.rand(128, 128, 128), np.eye(4)) + nib.save(test_image, os.path.join(cls.tmpdir, "im.nii.gz")) + cls.test_data = os.path.join(cls.tmpdir, "im.nii.gz") + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdir) + super(__class__, cls).tearDownClass() + + @parameterized.expand(TESTS_META) + def test_correct(self, input_param, expected_shape, track_meta): + set_track_meta(track_meta) + r = LoadImage(**input_param)(self.test_data) + self.assertTupleEqual(r.shape, expected_shape) + if track_meta: + self.assertIsInstance(r, MetaTensor) + self.assertTrue(hasattr(r, "affine")) + self.assertIsInstance(r.affine, torch.Tensor) + else: + self.assertIsInstance(r, torch.Tensor) + self.assertNotIsInstance(r, MetaTensor) + self.assertFalse(hasattr(r, "affine")) if __name__ == "__main__": diff --git a/tests/test_load_imaged.py b/tests/test_load_imaged.py index bc001cf2fd..5dfb266f6c 100644 --- a/tests/test_load_imaged.py +++ b/tests/test_load_imaged.py @@ -10,6 +10,7 @@ # limitations under the License. import os +import shutil import tempfile import unittest from pathlib import Path @@ -17,11 +18,15 @@ import itk import nibabel as nib import numpy as np +import torch from parameterized import parameterized from monai.data import ITKReader -from monai.transforms import Compose, EnsureChannelFirstD, LoadImaged, SaveImageD -from monai.utils.enums import PostFix +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor +from monai.transforms import Compose, EnsureChannelFirstD, FromMetaTensord, LoadImaged, SaveImageD +from monai.transforms.meta_utility.dictionary import ToMetaTensord +from tests.utils import assert_allclose KEYS = ["image", "label", "extra"] @@ -29,6 +34,11 @@ TEST_CASE_2 = [{"keys": KEYS, "reader": "ITKReader", "fallback_only": False}, (128, 128, 128)] +TESTS_META = [] +for track_meta in (False, True): + TESTS_META.append([{"keys": KEYS}, (128, 128, 128), track_meta]) + TESTS_META.append([{"keys": KEYS, "reader": "ITKReader", "fallback_only": False}, (128, 128, 128), track_meta]) + class TestLoadImaged(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) @@ -55,7 +65,6 @@ def test_register(self): loader = LoadImaged(keys="img") loader.register(ITKReader()) result = loader({"img": Path(filename)}) - self.assertTupleEqual(tuple(result[PostFix.meta("img")]["spatial_shape"]), spatial_size[::-1]) self.assertTupleEqual(result["img"].shape, spatial_size[::-1]) def test_channel_dim(self): @@ -67,8 +76,8 @@ def test_channel_dim(self): loader = LoadImaged(keys="img") loader.register(ITKReader(channel_dim=2)) - result = EnsureChannelFirstD("img")(loader({"img": filename})) - self.assertTupleEqual(tuple(result[PostFix.meta("img")]["spatial_shape"]), (32, 64, 128)) + t = Compose([EnsureChannelFirstD("img"), FromMetaTensord("img")]) + result = t(loader({"img": filename})) self.assertTupleEqual(result["img"].shape, (3, 32, 64, 128)) def test_no_file(self): @@ -79,49 +88,50 @@ def test_no_file(self): class TestConsistency(unittest.TestCase): - def _cmp(self, filename, shape, ch_shape, reader_1, reader_2, outname, ext): + def _cmp(self, filename, ch_shape, reader_1, reader_2, outname, ext): data_dict = {"img": filename} keys = data_dict.keys() xforms = Compose([LoadImaged(keys, reader=reader_1, ensure_channel_first=True)]) img_dict = xforms(data_dict) # load dicom with itk self.assertTupleEqual(img_dict["img"].shape, ch_shape) - self.assertTupleEqual(tuple(img_dict[PostFix.meta("img")]["spatial_shape"]), shape) with tempfile.TemporaryDirectory() as tempdir: - save_xform = SaveImageD( - keys, meta_keys=PostFix.meta("img"), output_dir=tempdir, squeeze_end_dims=False, output_ext=ext - ) + save_xform = SaveImageD(keys, output_dir=tempdir, squeeze_end_dims=False, output_ext=ext) save_xform(img_dict) # save to nifti - new_xforms = Compose([LoadImaged(keys, reader=reader_2), EnsureChannelFirstD(keys)]) + new_xforms = Compose( + [ + LoadImaged(keys, reader=reader_2), + EnsureChannelFirstD(keys), + FromMetaTensord(keys), + ToMetaTensord(keys), + ] + ) out = new_xforms({"img": os.path.join(tempdir, outname)}) # load nifti with itk self.assertTupleEqual(out["img"].shape, ch_shape) - self.assertTupleEqual(tuple(out[PostFix.meta("img")]["spatial_shape"]), shape) - if "affine" in img_dict[PostFix.meta("img")] and "affine" in out[PostFix.meta("img")]: - np.testing.assert_allclose( - img_dict[PostFix.meta("img")]["affine"], out[PostFix.meta("img")]["affine"], rtol=1e-3 - ) - np.testing.assert_allclose(out["img"], img_dict["img"], rtol=1e-3) + + def is_identity(x): + return (x == torch.eye(x.shape[0])).all() + + if not is_identity(img_dict["img"].affine) and not is_identity(out["img"].affine): + assert_allclose(img_dict["img"].affine, out["img"].affine, rtol=1e-3) + assert_allclose(out["img"], img_dict["img"], rtol=1e-3) def test_dicom(self): img_dir = "tests/testing_data/CT_DICOM" - self._cmp( - img_dir, (16, 16, 4), (1, 16, 16, 4), "itkreader", "itkreader", "CT_DICOM/CT_DICOM_trans.nii.gz", ".nii.gz" - ) + self._cmp(img_dir, (1, 16, 16, 4), "itkreader", "itkreader", "CT_DICOM/CT_DICOM_trans.nii.gz", ".nii.gz") output_name = "CT_DICOM/CT_DICOM_trans.nii.gz" - self._cmp(img_dir, (16, 16, 4), (1, 16, 16, 4), "nibabelreader", "itkreader", output_name, ".nii.gz") - self._cmp(img_dir, (16, 16, 4), (1, 16, 16, 4), "itkreader", "nibabelreader", output_name, ".nii.gz") + self._cmp(img_dir, (1, 16, 16, 4), "nibabelreader", "itkreader", output_name, ".nii.gz") + self._cmp(img_dir, (1, 16, 16, 4), "itkreader", "nibabelreader", output_name, ".nii.gz") def test_multi_dicom(self): """multichannel dicom reading, saving to nifti, then load with itk or nibabel""" img_dir = ["tests/testing_data/CT_DICOM", "tests/testing_data/CT_DICOM"] - self._cmp( - img_dir, (16, 16, 4), (2, 16, 16, 4), "itkreader", "itkreader", "CT_DICOM/CT_DICOM_trans.nii.gz", ".nii.gz" - ) + self._cmp(img_dir, (2, 16, 16, 4), "itkreader", "itkreader", "CT_DICOM/CT_DICOM_trans.nii.gz", ".nii.gz") output_name = "CT_DICOM/CT_DICOM_trans.nii.gz" - self._cmp(img_dir, (16, 16, 4), (2, 16, 16, 4), "nibabelreader", "itkreader", output_name, ".nii.gz") - self._cmp(img_dir, (16, 16, 4), (2, 16, 16, 4), "itkreader", "nibabelreader", output_name, ".nii.gz") + self._cmp(img_dir, (2, 16, 16, 4), "nibabelreader", "itkreader", output_name, ".nii.gz") + self._cmp(img_dir, (2, 16, 16, 4), "itkreader", "nibabelreader", output_name, ".nii.gz") def test_png(self): """png reading with itk, saving to nifti, then load with itk or nibabel or PIL""" @@ -132,9 +142,45 @@ def test_png(self): itk_np_view = itk.image_view_from_array(test_image, is_vector=True) itk.imwrite(itk_np_view, filename) output_name = "test_image/test_image_trans.png" - self._cmp(filename, (224, 256), (3, 224, 256), "itkreader", "itkreader", output_name, ".png") - self._cmp(filename, (224, 256), (3, 224, 256), "itkreader", "PILReader", output_name, ".png") - self._cmp(filename, (224, 256), (3, 224, 256), "itkreader", "nibabelreader", output_name, ".png") + self._cmp(filename, (3, 224, 256), "itkreader", "itkreader", output_name, ".png") + self._cmp(filename, (3, 224, 256), "itkreader", "PILReader", output_name, ".png") + self._cmp(filename, (3, 224, 256), "itkreader", "nibabelreader", output_name, ".png") + + +class TestLoadImagedMeta(unittest.TestCase): + @classmethod + def setUpClass(cls): + super(__class__, cls).setUpClass() + cls.tmpdir = tempfile.mkdtemp() + test_image = nib.Nifti1Image(np.random.rand(128, 128, 128), np.eye(4)) + cls.test_data = {} + for key in KEYS: + nib.save(test_image, os.path.join(cls.tmpdir, key + ".nii.gz")) + cls.test_data.update({key: os.path.join(cls.tmpdir, key + ".nii.gz")}) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdir) + super(__class__, cls).tearDownClass() + + @parameterized.expand(TESTS_META) + def test_correct(self, input_param, expected_shape, track_meta): + set_track_meta(track_meta) + result = LoadImaged(**input_param)(self.test_data) + + # shouldn't have any extra meta data keys + self.assertEqual(len(result), len(KEYS)) + for key in KEYS: + r = result[key] + self.assertTupleEqual(r.shape, expected_shape) + if track_meta: + self.assertIsInstance(r, MetaTensor) + self.assertTrue(hasattr(r, "affine")) + self.assertIsInstance(r.affine, torch.Tensor) + else: + self.assertIsInstance(r, torch.Tensor) + self.assertNotIsInstance(r, MetaTensor) + self.assertFalse(hasattr(r, "affine")) if __name__ == "__main__": diff --git a/tests/test_load_spacing_orientation.py b/tests/test_load_spacing_orientation.py index 2792822c3d..8257b9965f 100644 --- a/tests/test_load_spacing_orientation.py +++ b/tests/test_load_spacing_orientation.py @@ -15,11 +15,11 @@ import nibabel import numpy as np +import torch from nibabel.processing import resample_to_output from parameterized import parameterized -from monai.transforms import AddChanneld, LoadImaged, Orientationd, Spacingd -from monai.utils.enums import PostFix +from monai.transforms import AddChanneld, Compose, LoadImaged, Orientationd, Spacingd FILES = tuple( os.path.join(os.path.dirname(__file__), "testing_data", filename) @@ -28,43 +28,45 @@ class TestLoadSpacingOrientation(unittest.TestCase): + @staticmethod + def load_image(filename): + data = {"image": filename} + t = Compose([LoadImaged(keys="image"), AddChanneld(keys="image")]) + return t(data) + @parameterized.expand(FILES) def test_load_spacingd(self, filename): - data = {"image": filename} - data_dict = LoadImaged(keys="image")(data) - data_dict = AddChanneld(keys="image")(data_dict) + data_dict = self.load_image(filename) t = time.time() res_dict = Spacingd(keys="image", pixdim=(1, 0.2, 1), diagonal=True, padding_mode="zeros")(data_dict) t1 = time.time() print(f"time monai: {t1 - t}") - anat = nibabel.Nifti1Image(data_dict["image"][0], data_dict[PostFix.meta("image")]["original_affine"]) + anat = nibabel.Nifti1Image(np.asarray(data_dict["image"][0]), data_dict["image"].meta["original_affine"]) ref = resample_to_output(anat, (1, 0.2, 1), order=1) t2 = time.time() print(f"time scipy: {t2 - t1}") self.assertTrue(t2 >= t1) - np.testing.assert_allclose(res_dict[PostFix.meta("image")]["affine"], ref.affine) + np.testing.assert_allclose(res_dict["image"].affine, ref.affine) np.testing.assert_allclose(res_dict["image"].shape[1:], ref.shape) np.testing.assert_allclose(ref.get_fdata(), res_dict["image"][0], atol=0.05) @parameterized.expand(FILES) def test_load_spacingd_rotate(self, filename): - data = {"image": filename} - data_dict = LoadImaged(keys="image")(data) - data_dict = AddChanneld(keys="image")(data_dict) - affine = data_dict[PostFix.meta("image")]["affine"] - data_dict[PostFix.meta("image")]["original_affine"] = data_dict[PostFix.meta("image")]["affine"] = ( - np.array([[0, 0, 1, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 0, 1]]) @ affine + data_dict = self.load_image(filename) + affine = data_dict["image"].affine + data_dict["image"].meta["original_affine"] = data_dict["image"].affine = ( + torch.tensor([[0, 0, 1, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 0, 1]], dtype=torch.float64) @ affine ) t = time.time() res_dict = Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=True, padding_mode="zeros")(data_dict) t1 = time.time() print(f"time monai: {t1 - t}") - anat = nibabel.Nifti1Image(data_dict["image"][0], data_dict[PostFix.meta("image")]["original_affine"]) + anat = nibabel.Nifti1Image(np.asarray(data_dict["image"][0]), data_dict["image"].meta["original_affine"]) ref = resample_to_output(anat, (1, 2, 3), order=1) t2 = time.time() print(f"time scipy: {t2 - t1}") self.assertTrue(t2 >= t1) - np.testing.assert_allclose(res_dict[PostFix.meta("image")]["affine"], ref.affine) + np.testing.assert_allclose(res_dict["image"].affine, ref.affine) if "anatomical" not in filename: np.testing.assert_allclose(res_dict["image"].shape[1:], ref.shape) np.testing.assert_allclose(ref.get_fdata(), res_dict["image"][0], atol=0.05) @@ -74,16 +76,14 @@ def test_load_spacingd_rotate(self, filename): np.testing.assert_allclose(ref.get_fdata()[..., :-1], res_dict["image"][0], atol=0.05) def test_load_spacingd_non_diag(self): - data = {"image": FILES[1]} - data_dict = LoadImaged(keys="image")(data) - data_dict = AddChanneld(keys="image")(data_dict) - affine = data_dict[PostFix.meta("image")]["affine"] - data_dict[PostFix.meta("image")]["original_affine"] = data_dict[PostFix.meta("image")]["affine"] = ( - np.array([[0, 0, 1, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 0, 1]]) @ affine + data_dict = self.load_image(FILES[1]) + affine = data_dict["image"].affine + data_dict["image"].meta["original_affine"] = data_dict["image"].affine = ( + torch.tensor([[0, 0, 1, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 0, 1]], dtype=torch.float64) @ affine ) res_dict = Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=False, padding_mode="zeros")(data_dict) np.testing.assert_allclose( - res_dict[PostFix.meta("image")]["affine"], + res_dict["image"].affine, np.array( [ [0.0, 0.0, 3.0, -27.599409], @@ -95,38 +95,42 @@ def test_load_spacingd_non_diag(self): ) def test_load_spacingd_rotate_non_diag(self): - data = {"image": FILES[0]} - data_dict = LoadImaged(keys="image")(data) - data_dict = AddChanneld(keys="image")(data_dict) + data_dict = self.load_image(FILES[0]) res_dict = Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=False, padding_mode="border")(data_dict) np.testing.assert_allclose( - res_dict[PostFix.meta("image")]["affine"], + res_dict["image"].affine, np.array([[-1.0, 0.0, 0.0, 32.0], [0.0, 2.0, 0.0, -40.0], [0.0, 0.0, 3.0, -16.0], [0.0, 0.0, 0.0, 1.0]]), ) def test_load_spacingd_rotate_non_diag_ornt(self): - data = {"image": FILES[0]} - data_dict = LoadImaged(keys="image")(data) - data_dict = AddChanneld(keys="image")(data_dict) - res_dict = Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=False, padding_mode="border")(data_dict) - res_dict = Orientationd(keys="image", axcodes="LPI")(res_dict) + data_dict = self.load_image(FILES[0]) + t = Compose( + [ + Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=False, padding_mode="border"), + Orientationd(keys="image", axcodes="LPI"), + ] + ) + res_dict = t(data_dict) np.testing.assert_allclose( - res_dict[PostFix.meta("image")]["affine"], + res_dict["image"].affine, np.array([[-1.0, 0.0, 0.0, 32.0], [0.0, -2.0, 0.0, 40.0], [0.0, 0.0, -3.0, 32.0], [0.0, 0.0, 0.0, 1.0]]), ) def test_load_spacingd_non_diag_ornt(self): - data = {"image": FILES[1]} - data_dict = LoadImaged(keys="image")(data) - data_dict = AddChanneld(keys="image")(data_dict) - affine = data_dict[PostFix.meta("image")]["affine"] - data_dict[PostFix.meta("image")]["original_affine"] = data_dict[PostFix.meta("image")]["affine"] = ( - np.array([[0, 0, 1, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 0, 1]]) @ affine + data_dict = self.load_image(FILES[1]) + affine = data_dict["image"].affine + data_dict["image"].meta["original_affine"] = data_dict["image"].affine = ( + torch.tensor([[0, 0, 1, 0], [0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 0, 1]], dtype=torch.float64) @ affine ) - res_dict = Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=False, padding_mode="border")(data_dict) - res_dict = Orientationd(keys="image", axcodes="LPI")(res_dict) + t = Compose( + [ + Spacingd(keys="image", pixdim=(1, 2, 3), diagonal=False, padding_mode="border"), + Orientationd(keys="image", axcodes="LPI"), + ] + ) + res_dict = t(data_dict) np.testing.assert_allclose( - res_dict[PostFix.meta("image")]["affine"], + res_dict["image"].affine, np.array( [ [-3.0, 0.0, 0.0, 56.4005909], diff --git a/tests/test_mask_intensity.py b/tests/test_mask_intensity.py index b6cfe0e10c..65182b6678 100644 --- a/tests/test_mask_intensity.py +++ b/tests/test_mask_intensity.py @@ -16,6 +16,7 @@ from parameterized import parameterized from monai.transforms import MaskIntensity +from tests.utils import TEST_NDARRAYS, assert_allclose TEST_CASE_1 = [ {"mask_data": np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]]])}, @@ -54,8 +55,9 @@ class TestMaskIntensity(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) def test_value(self, argments, image, expected_data): - result = MaskIntensity(**argments)(image) - np.testing.assert_allclose(result, expected_data) + for p in TEST_NDARRAYS: + result = MaskIntensity(**argments)(p(image)) + assert_allclose(result, p(expected_data), type_test="tensor") def test_runtime_mask(self): mask_data = np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]]]) @@ -63,7 +65,7 @@ def test_runtime_mask(self): expected = np.array([[[0, 0, 0], [0, 2, 0], [0, 0, 0]], [[0, 0, 0], [0, 5, 0], [0, 0, 0]]]) result = MaskIntensity()(img=img, mask_data=mask_data) - np.testing.assert_allclose(result, expected) + assert_allclose(result, expected, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_mednistdataset.py b/tests/test_mednistdataset.py index e7cc1a60ff..87a0d6a2ec 100644 --- a/tests/test_mednistdataset.py +++ b/tests/test_mednistdataset.py @@ -15,8 +15,8 @@ from pathlib import Path from monai.apps import MedNISTDataset -from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd, ToTensord -from monai.utils.enums import PostFix +from monai.data import MetaTensor +from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd from tests.utils import skip_if_downloading_fails, skip_if_quick MEDNIST_FULL_DATASET_LENGTH = 58954 @@ -26,20 +26,13 @@ class TestMedNISTDataset(unittest.TestCase): @skip_if_quick def test_values(self): testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") - transform = Compose( - [ - LoadImaged(keys="image"), - AddChanneld(keys="image"), - ScaleIntensityd(keys="image"), - ToTensord(keys=["image", "label"]), - ] - ) + transform = Compose([LoadImaged(keys="image"), AddChanneld(keys="image"), ScaleIntensityd(keys="image")]) def _test_dataset(dataset): self.assertEqual(len(dataset), int(MEDNIST_FULL_DATASET_LENGTH * dataset.test_frac)) self.assertTrue("image" in dataset[0]) self.assertTrue("label" in dataset[0]) - self.assertTrue(PostFix.meta("image") in dataset[0]) + self.assertTrue(isinstance(dataset[0]["image"], MetaTensor)) self.assertTupleEqual(dataset[0]["image"].shape, (1, 64, 64)) with skip_if_downloading_fails(): @@ -59,7 +52,7 @@ def _test_dataset(dataset): data = MedNISTDataset(root_dir=testing_dir, transform=transform, section="test", download=False, seed=42) _test_dataset(data) self.assertEqual(data[0]["class_name"], "AbdomenCT") - self.assertEqual(data[0]["label"].cpu().item(), 0) + self.assertEqual(data[0]["label"], 0) shutil.rmtree(os.path.join(testing_dir, "MedNIST")) try: MedNISTDataset(root_dir=testing_dir, transform=transform, section="test", download=False) diff --git a/tests/test_meta_affine.py b/tests/test_meta_affine.py new file mode 100644 index 0000000000..437fee112d --- /dev/null +++ b/tests/test_meta_affine.py @@ -0,0 +1,179 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from copy import deepcopy + +import numpy as np +from parameterized import parameterized + +from monai.data.image_writer import ITKWriter +from monai.transforms import ( + Compose, + EnsureChannelFirst, + EnsureChannelFirstd, + LoadImage, + LoadImaged, + MapTransform, + Orientation, + Orientationd, + Randomizable, + Spacing, + Spacingd, + Transform, +) +from monai.utils import convert_data_type, optional_import +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config + +itk, has_itk = optional_import("itk") +TINY_DIFF = 1e-4 + +keys = ("img1", "img2") +key, key_1 = "ref_avg152T1_LR", "ref_avg152T1_RL" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"{key}.nii.gz") +FILE_PATH_1 = os.path.join(os.path.dirname(__file__), "testing_data", f"{key_1}.nii.gz") + +TEST_CASES_ARRAY = [ + [Compose([Spacing(pixdim=(1.0, 1.1, 1.2)), Orientation(axcodes="RAS")]), {}, TINY_DIFF], + [Compose([Orientation(axcodes="RAS"), Spacing(pixdim=(1.0, 1.1, 1.2))]), {}, TINY_DIFF], + ["CropForeground", {"k_divisible": 3}, TINY_DIFF], + ["BorderPad", {"spatial_border": (2, 3, 4)}, TINY_DIFF], + ["CenterScaleCrop", {"roi_scale": (0.6, 0.7, 0.8)}, TINY_DIFF], + ["CenterSpatialCrop", {"roi_size": (30, 200, 52)}, TINY_DIFF], + ["DivisiblePad", {"k": 16}, TINY_DIFF], + ["RandScaleCrop", {"roi_scale": (0.3, 0.4, 0.5)}, TINY_DIFF], + ["RandSpatialCrop", {"roi_size": (31, 32, 33)}, TINY_DIFF], + ["ResizeWithPadOrCrop", {"spatial_size": (50, 80, 200)}, TINY_DIFF], + ["Spacing", {"pixdim": (1.0, 1.1, 1.2)}, TINY_DIFF], + ["Orientation", {"axcodes": "RAS"}, TINY_DIFF], + ["Flip", {"spatial_axis": (0, 1)}, TINY_DIFF], + ["Resize", {"spatial_size": (100, 201, 1)}, 30.0], + ["Rotate", {"angle": (np.pi / 3, np.pi / 2, np.pi / 4), "mode": "bilinear"}, 20.0], + ["Zoom", {"zoom": (0.8, 0.91, 1.2)}, 20.0], + ["Rotate90", {"k": 3}, TINY_DIFF], + ["RandRotate90", {"prob": 1.0, "max_k": 3}, TINY_DIFF], + ["RandRotate", {"prob": 1.0, "range_x": np.pi / 3}, 20.0], + ["RandFlip", {"prob": 1.0}, TINY_DIFF], + ["RandAxisFlip", {"prob": 1.0}, TINY_DIFF], + ["RandZoom", {"prob": 1.0, "mode": "trilinear"}, TINY_DIFF], + [ + "RandAffine", + { + "prob": 1.0, + "rotate_range": (np.pi / 4, np.pi / 3, np.pi / 2), + "translate_range": (3, 4, 5), + "scale_range": (-0.1, 0.2), + "spatial_size": (30, 40, 50), + "cache_grid": True, + "mode": "bilinear", + }, + 20.0, + ], + [ + "Affine", + { + "rotate_params": (np.pi / 4, 0.0, 0.0), + "translate_params": (3, 4, 5), + "spatial_size": (30, 40, 50), + "mode": "bilinear", + "image_only": True, + }, + 20.0, + ], +] + +TEST_CASES_DICT = [ + [Compose([Spacingd(keys, pixdim=(1.0, 1.1, 1.2)), Orientationd(keys, axcodes="LAS")]), {}, TINY_DIFF], + [Compose([Orientationd(keys, axcodes="LAS"), Spacingd(keys, pixdim=(1.0, 1.1, 1.2))]), {}, TINY_DIFF], + ["CropForegroundd", {"k_divisible": 3, "source_key": "img1"}, TINY_DIFF], +] +for c in TEST_CASES_ARRAY[3:-1]: # exclude CropForegroundd and Affined + TEST_CASES_DICT.append(deepcopy(c)) + TEST_CASES_DICT[-1][0] = TEST_CASES_DICT[-1][0] + "d" # type: ignore + + +def _create_itk_obj(array, affine): + itk_img = deepcopy(array) + itk_img = convert_data_type(itk_img, np.ndarray)[0] + itk_obj = ITKWriter.create_backend_obj(itk_img, channel_dim=None, affine=affine, affine_lps=True) + return itk_obj + + +def _resample_to_affine(itk_obj, ref_obj): + """linear resample""" + dim = itk_obj.GetImageDimension() + transform = itk.IdentityTransform[itk.D, dim].New() + interpolator = itk.LinearInterpolateImageFunction[type(itk_obj), itk.D].New() + resampled = itk.resample_image_filter( + Input=itk_obj, interpolator=interpolator, transform=transform, UseReferenceImage=True, ReferenceImage=ref_obj + ) + return resampled + + +@unittest.skipUnless(has_itk, "Requires itk package.") +class TestAffineConsistencyITK(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + for k, n in ((key, FILE_PATH), (key_1, FILE_PATH_1)): + config = testing_data_config("images", f"{k}") + download_url_or_skip_test(filepath=n, **config) + + def run_transform(self, img, xform_cls, args_dict): + if isinstance(xform_cls, Transform): + xform = xform_cls + output = xform(img, **args_dict) + else: + if isinstance(xform_cls, str): + xform_cls, _ = optional_import("monai.transforms", name=xform_cls) + if issubclass(xform_cls, MapTransform): + args_dict.update({"keys": keys}) + xform = xform_cls(**args_dict) + if isinstance(xform, Randomizable): + xform.set_random_state(5) + output = xform(img) + return output + + @parameterized.expand(TEST_CASES_ARRAY) + def test_linear_consistent(self, xform_cls, input_dict, atol): + """xform cls testing itk consistency""" + img = LoadImage()(FILE_PATH) + img = EnsureChannelFirst()(img) + ref_1 = _create_itk_obj(img[0], img.affine) + output = self.run_transform(img, xform_cls, input_dict) + ref_2 = _create_itk_obj(output[0], output.affine) + assert_allclose(output.pixdim, np.asarray(ref_2.GetSpacing()), type_test=False) + expected = _resample_to_affine(ref_1, ref_2) + # compare ref_2 and expected results from itk + diff = np.abs(itk.GetArrayFromImage(ref_2) - itk.GetArrayFromImage(expected)) + avg_diff = np.mean(diff) + + self.assertTrue(avg_diff < atol, f"{xform_cls} avg_diff: {avg_diff}, tol: {atol}") + + @parameterized.expand(TEST_CASES_DICT) + def test_linear_consistent_dict(self, xform_cls, input_dict, atol): + """xform cls testing itk consistency""" + img = LoadImaged(keys)({keys[0]: FILE_PATH, keys[1]: FILE_PATH_1}) + img = EnsureChannelFirstd(keys)(img) + ref_1 = {k: _create_itk_obj(img[k][0], img[k].affine) for k in keys} + output = self.run_transform(img, xform_cls, input_dict) + ref_2 = {k: _create_itk_obj(output[k][0], output[k].affine) for k in keys} + expected = {k: _resample_to_affine(ref_1[k], ref_2[k]) for k in keys} + # compare ref_2 and expected results from itk + diff = {k: np.abs(itk.GetArrayFromImage(ref_2[k]) - itk.GetArrayFromImage(expected[k])) for k in keys} + avg_diff = {k: np.mean(diff[k]) for k in keys} + for k in keys: + self.assertTrue(avg_diff[k] < atol, f"{xform_cls} avg_diff: {avg_diff}, tol: {atol}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 217c3479a4..8fafdd7976 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -284,6 +284,7 @@ def test_out(self): def test_collate(self, device, dtype): numel = 3 ims = [self.get_im(device=device, dtype=dtype)[0] for _ in range(numel)] + ims = [MetaTensor(im, applied_operations=[f"t{i}"]) for i, im in enumerate(ims)] collated = list_data_collate(ims) # tensor self.assertIsInstance(collated, MetaTensor) @@ -295,6 +296,7 @@ def test_collate(self, device, dtype): self.assertIsInstance(collated.affine, torch.Tensor) expected_shape = (numel,) + tuple(ims[0].affine.shape) self.assertTupleEqual(tuple(collated.affine.shape), expected_shape) + self.assertEqual(len(collated.applied_operations), numel) @parameterized.expand(TESTS) def test_dataset(self, device, dtype): @@ -308,6 +310,7 @@ def test_dataset(self, device, dtype): def test_dataloader(self, dtype): batch_size = 5 ims = [self.get_im(dtype=dtype)[0] for _ in range(batch_size * 2)] + ims = [MetaTensor(im, applied_operations=[f"t{i}"]) for i, im in enumerate(ims)] ds = Dataset(ims) im_shape = tuple(ims[0].shape) affine_shape = tuple(ims[0].affine.shape) @@ -318,6 +321,7 @@ def test_dataloader(self, dtype): self.assertIsInstance(batch, MetaTensor) self.assertTupleEqual(tuple(batch.shape), expected_im_shape) self.assertTupleEqual(tuple(batch.affine.shape), expected_affine_shape) + self.assertEqual(len(batch.applied_operations), batch_size) @SkipIfBeforePyTorchVersion((1, 9)) def test_indexing(self): @@ -334,6 +338,9 @@ def test_indexing(self): self.check_meta(im[0], im) self.check_meta(next(iter(im)), im) + self.assertEqual(im[None].shape, (1, 1, 10, 8)) + self.assertEqual(data[None].shape, (1, 5, 1, 10, 8)) + # index d = data[0] self.check(d, ims[0], ids=False) @@ -420,7 +427,7 @@ def test_str(self): + "\taffine: 1\n" + "\n" + "Applied operations\n" - + "\n" + + "[]\n" + "Is batch?: False" ) for s in (s1, s2): @@ -429,7 +436,7 @@ def test_str(self): def test_transforms(self): key = "im" _, im = self.get_im() - tr = Compose([BorderPadd(key, 1), DivisiblePadd(key, 16), ToMetaTensord(key), FromMetaTensord(key)]) + tr = Compose([ToMetaTensord(key), BorderPadd(key, 1), DivisiblePadd(key, 16), FromMetaTensord(key)]) num_tr = len(tr.transforms) data = {key: im, PostFix.meta(key): {"affine": torch.eye(4)}} @@ -437,7 +444,7 @@ def test_transforms(self): is_meta = isinstance(im, MetaTensor) for i, _tr in enumerate(tr.transforms): data = _tr(data) - is_meta = isinstance(_tr, ToMetaTensord) + is_meta = isinstance(_tr, (ToMetaTensord, BorderPadd, DivisiblePadd)) if is_meta: self.assertEqual(len(data), 1) # im self.assertIsInstance(data[key], MetaTensor) @@ -454,7 +461,7 @@ def test_transforms(self): is_meta = isinstance(im, MetaTensor) for i, _tr in enumerate(tr.transforms[::-1]): data = _tr.inverse(data) - is_meta = isinstance(_tr, FromMetaTensord) + is_meta = isinstance(_tr, (FromMetaTensord, BorderPadd, DivisiblePadd)) if is_meta: self.assertEqual(len(data), 1) # im self.assertIsInstance(data[key], MetaTensor) @@ -488,7 +495,7 @@ def test_construct_with_pre_applied_transforms(self): _, im = self.get_im() tr = Compose([BorderPadd(key, 1), DivisiblePadd(key, 16)]) data = tr({key: im}) - m = MetaTensor(im, applied_operations=data[PostFix.transforms(key)]) + m = MetaTensor(im, applied_operations=data["im"].applied_operations) self.assertEqual(len(m.applied_operations), len(tr.transforms)) @parameterized.expand(TESTS) diff --git a/tests/test_metatensor_integration.py b/tests/test_metatensor_integration.py new file mode 100644 index 0000000000..d6908815ee --- /dev/null +++ b/tests/test_metatensor_integration.py @@ -0,0 +1,101 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tempfile +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.bundle import ConfigParser +from monai.data import CacheDataset, DataLoader, MetaTensor, decollate_batch +from monai.data.utils import TraceKeys +from monai.transforms import InvertD, SaveImageD +from monai.utils import optional_import, set_determinism +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config + +nib, has_nib = optional_import("nibabel") +TINY_DIFF = 0.1 + +keys = ("img", "seg") +key, key_1 = "MNI152_T1_2mm", "MNI152_T1_2mm_strucseg" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", f"{key}.nii.gz") +FILE_PATH_1 = os.path.join(os.path.dirname(__file__), "testing_data", f"{key_1}.nii.gz") +TEST_CASES = os.path.join(os.path.dirname(__file__), "testing_data", "transform_metatensor_cases.yaml") + + +@unittest.skipUnless(has_nib, "Requires nibabel package.") +class TestMetaTensorIntegration(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + for k, n in ((key, FILE_PATH), (key_1, FILE_PATH_1)): + config = testing_data_config("images", f"{k}") + download_url_or_skip_test(filepath=n, **config) + cls.files = [{keys[0]: x, keys[1]: y} for (x, y) in [[FILE_PATH, FILE_PATH_1]] * 4] + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + set_determinism(None) + + @parameterized.expand(["TEST_CASE_1", "TEST_CASE_2", "TEST_CASE_3"]) + def test_transforms(self, case_id): + set_determinism(2022) + config = ConfigParser() + config.read_config(TEST_CASES) + config["input_keys"] = keys + test_case = config.get_parsed_content(id=case_id, instantiate=True) # transform instance + + dataset = CacheDataset(self.files, transform=test_case) + loader = DataLoader(dataset, batch_size=3, shuffle=True) + for x in loader: + self.assertIsInstance(x[keys[0]], MetaTensor) + self.assertIsInstance(x[keys[1]], MetaTensor) + out = decollate_batch(x) # decollate every batch should work + + # test forward patches + loaded = out[0] + self.assertEqual(len(loaded), len(keys)) + img, seg = loaded[keys[0]], loaded[keys[1]] + expected = config.get_parsed_content(id=f"{case_id}_answer", instantiate=True) # expected results + self.assertEqual(expected["load_shape"], list(x[keys[0]].shape)) + assert_allclose(expected["affine"], img.affine, type_test=False, atol=TINY_DIFF, rtol=TINY_DIFF) + assert_allclose(expected["affine"], seg.affine, type_test=False, atol=TINY_DIFF, rtol=TINY_DIFF) + test_cls = [type(x).__name__ for x in test_case.transforms] + tracked_cls = [x[TraceKeys.CLASS_NAME] for x in img.applied_operations] + self.assertTrue(len(tracked_cls) <= len(test_cls)) # tracked items should be no more than the compose items. + with tempfile.TemporaryDirectory() as tempdir: # test writer + SaveImageD(keys, resample=False, output_dir=tempdir, output_postfix=case_id)(loaded) + + # test inverse + inv = InvertD(keys, orig_keys=keys, transform=test_case, nearest_interp=True) + out = inv(loaded) + img, seg = out[keys[0]], out[keys[1]] + assert_allclose(expected["inv_affine"], img.affine, type_test=False, atol=TINY_DIFF, rtol=TINY_DIFF) + assert_allclose(expected["inv_affine"], seg.affine, type_test=False, atol=TINY_DIFF, rtol=TINY_DIFF) + self.assertFalse(img.applied_operations) + self.assertFalse(seg.applied_operations) + assert_allclose(expected["inv_shape"], img.shape, type_test=False, atol=TINY_DIFF, rtol=TINY_DIFF) + assert_allclose(expected["inv_shape"], seg.shape, type_test=False, atol=TINY_DIFF, rtol=TINY_DIFF) + with tempfile.TemporaryDirectory() as tempdir: # test writer + SaveImageD(keys, resample=False, output_dir=tempdir, output_postfix=case_id)(out) + seg_file = os.path.join(tempdir, key_1, f"{key_1}_{case_id}.nii.gz") + segout = nib.load(seg_file).get_fdata() + segin = nib.load(FILE_PATH_1).get_fdata() + ndiff = np.sum(np.abs(segout - segin) > 0) + total = np.prod(segout.shape) + self.assertTrue(ndiff / total < 0.4, f"{ndiff / total}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nifti_rw.py b/tests/test_nifti_rw.py index 2c0a8dc9a3..27bd5e0ce1 100644 --- a/tests/test_nifti_rw.py +++ b/tests/test_nifti_rw.py @@ -30,14 +30,14 @@ [[-5.3, 0.0, 0.0, 102.01], [0.0, 0.52, 2.17, -7.50], [-0.0, 1.98, -0.26, -23.12], [0.0, 0.0, 0.0, 1.0]] ) ) - TESTS.append( - [ - TEST_IMAGE, - TEST_AFFINE, - dict(reader="NibabelReader", image_only=False, as_closest_canonical=True), - np.arange(24).reshape((2, 4, 3)), - ] - ) + # TESTS.append( + # [ + # TEST_IMAGE, + # TEST_AFFINE, + # dict(reader="NibabelReader", image_only=False, as_closest_canonical=True), + # np.arange(24).reshape((2, 4, 3)), + # ] + # ) TESTS.append( [ TEST_IMAGE, @@ -63,7 +63,7 @@ [ TEST_IMAGE, TEST_AFFINE, - dict(reader="NibabelReader", image_only=False, as_closest_canonical=False), + dict(reader="NibabelReader", image_only=True, as_closest_canonical=False), np.arange(24).reshape((2, 4, 3)), ] ) @@ -71,7 +71,7 @@ [ TEST_IMAGE, None, - dict(reader="NibabelReader", image_only=False, as_closest_canonical=False), + dict(reader="NibabelReader", image_only=True, as_closest_canonical=False), np.arange(24).reshape((2, 4, 3)), ] ) @@ -85,11 +85,12 @@ def test_orientation(self, array, affine, reader_param, expected): # read test cases loader = LoadImage(**reader_param) load_result = loader(test_image) - if isinstance(load_result, tuple): - data_array, header = load_result - else: - data_array = load_result + data_array = load_result.numpy() + if reader_param.get("image_only", False): header = None + else: + header = load_result.meta + header["affine"] = header["affine"].numpy() if os.path.exists(test_image): os.remove(test_image) @@ -114,9 +115,12 @@ def test_orientation(self, array, affine, reader_param, expected): def test_consistency(self): np.set_printoptions(suppress=True, precision=3) test_image = make_nifti_image(np.arange(64).reshape(1, 8, 8), np.diag([1.5, 1.5, 1.5, 1])) - data, header = LoadImage(reader="NibabelReader", as_closest_canonical=False)(test_image) - data, original_affine, new_affine = Spacing([0.8, 0.8, 0.8])(data[None], header["affine"], mode="nearest") - data, _, new_affine = Orientation("ILP")(data, new_affine) + data = LoadImage(reader="NibabelReader", as_closest_canonical=False)(test_image) + header = data.meta + data = Spacing([0.8, 0.8, 0.8])(data[None], header["affine"], mode="nearest") + original_affine = data.meta["original_affine"] + data = Orientation("ILP")(data) + new_affine = data.affine if os.path.exists(test_image): os.remove(test_image) writer_obj = NibabelWriter() diff --git a/tests/test_nifti_saver.py b/tests/test_nifti_saver.py index 6855a59041..bd1bf86207 100644 --- a/tests/test_nifti_saver.py +++ b/tests/test_nifti_saver.py @@ -80,7 +80,7 @@ def test_saved_3d_no_resize_content(self): saver.save_batch(torch.randint(0, 255, (8, 8, 1, 2, 2)), meta_data) for i in range(8): filepath = os.path.join(tempdir, "testfile" + str(i), "testfile" + str(i) + "_seg.nii.gz") - img, _ = LoadImage("nibabelreader")(filepath) + img = LoadImage("nibabelreader")(filepath) self.assertEqual(img.shape, (1, 2, 2, 8)) def test_squeeze_end_dims(self): @@ -102,9 +102,8 @@ def test_squeeze_end_dims(self): # 2d image w channel saver.save(torch.randint(0, 255, (1, 2, 2)), meta_data) - im, meta = LoadImage()(os.path.join(tempdir, fname, fname + ".nii.gz")) + im = LoadImage()(os.path.join(tempdir, fname, fname + ".nii.gz")) self.assertTrue(im.ndim == 2 if squeeze_end_dims else 4) - self.assertTrue(meta["dim"][0] == im.ndim) if __name__ == "__main__": diff --git a/tests/test_normalize_intensity.py b/tests/test_normalize_intensity.py index 5bcee1263b..4d06a80c1d 100644 --- a/tests/test_normalize_intensity.py +++ b/tests/test_normalize_intensity.py @@ -86,19 +86,16 @@ def test_default(self, im_type): im = im_type(self.imt.copy()) normalizer = NormalizeIntensity() normalized = normalizer(im) - self.assertEqual(type(im), type(normalized)) - if isinstance(normalized, torch.Tensor): - self.assertEqual(im.device, normalized.device) self.assertTrue(normalized.dtype in (np.float32, torch.float32)) expected = (self.imt - np.mean(self.imt)) / np.std(self.imt) - assert_allclose(normalized, expected, type_test=False, rtol=1e-3) + assert_allclose(normalized, expected, type_test="tensor", rtol=1e-3) @parameterized.expand(TESTS) def test_nonzero(self, in_type, input_param, input_data, expected_data): normalizer = NormalizeIntensity(**input_param) im = in_type(input_data) normalized = normalizer(im) - assert_allclose(normalized, in_type(expected_data)) + assert_allclose(normalized, in_type(expected_data), type_test="tensor") @parameterized.expand([[p] for p in TEST_NDARRAYS]) def test_channel_wise(self, im_type): @@ -106,7 +103,7 @@ def test_channel_wise(self, im_type): input_data = im_type(np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]])) expected = np.array([[0.0, -1.0, 0.0, 1.0], [0.0, -1.0, 0.0, 1.0]]) normalized = normalizer(input_data) - assert_allclose(normalized, im_type(expected)) + assert_allclose(normalized, im_type(expected), type_test="tensor") @parameterized.expand([[p] for p in TEST_NDARRAYS]) def test_value_errors(self, im_type): diff --git a/tests/test_normalize_intensityd.py b/tests/test_normalize_intensityd.py index 12a39b1b5b..a8167a1e93 100644 --- a/tests/test_normalize_intensityd.py +++ b/tests/test_normalize_intensityd.py @@ -12,7 +12,6 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import NormalizeIntensityd @@ -57,20 +56,14 @@ def test_image_normalize_intensityd(self, im_type): normalizer = NormalizeIntensityd(keys=[key]) normalized = normalizer({key: im})[key] expected = (self.imt - np.mean(self.imt)) / np.std(self.imt) - self.assertEqual(type(im), type(normalized)) - if isinstance(normalized, torch.Tensor): - self.assertEqual(im.device, normalized.device) - assert_allclose(normalized, im_type(expected), rtol=1e-3) + assert_allclose(normalized, im_type(expected), rtol=1e-3, type_test="tensor") @parameterized.expand(TESTS) def test_nonzero(self, input_param, input_data, expected_data): key = "img" normalizer = NormalizeIntensityd(**input_param) normalized = normalizer(input_data)[key] - self.assertEqual(type(input_data[key]), type(normalized)) - if isinstance(normalized, torch.Tensor): - self.assertEqual(input_data[key].device, normalized.device) - assert_allclose(normalized, expected_data) + assert_allclose(normalized, expected_data, type_test="tensor") @parameterized.expand([[p] for p in TEST_NDARRAYS]) def test_channel_wise(self, im_type): @@ -78,11 +71,8 @@ def test_channel_wise(self, im_type): normalizer = NormalizeIntensityd(keys=key, nonzero=True, channel_wise=True) input_data = {key: im_type(np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]]))} normalized = normalizer(input_data)[key] - self.assertEqual(type(input_data[key]), type(normalized)) - if isinstance(normalized, torch.Tensor): - self.assertEqual(input_data[key].device, normalized.device) expected = np.array([[0.0, -1.0, 0.0, 1.0], [0.0, -1.0, 0.0, 1.0]]) - assert_allclose(normalized, im_type(expected)) + assert_allclose(normalized, im_type(expected), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_numpy_reader.py b/tests/test_numpy_reader.py index c2f3679e33..bb7686f67d 100644 --- a/tests/test_numpy_reader.py +++ b/tests/test_numpy_reader.py @@ -19,7 +19,6 @@ from monai.data import DataLoader, Dataset, NumpyReader from monai.transforms import LoadImaged -from monai.utils.enums import PostFix class TestNumpyReader(unittest.TestCase): @@ -110,8 +109,6 @@ def test_dataloader(self): num_workers=num_workers, ) for d in loader: - for s in d[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, torch.as_tensor([3, 4, 5])) for c in d["image"]: torch.testing.assert_allclose(c, test_data) diff --git a/tests/test_orientation.py b/tests/test_orientation.py index 2b749dabad..3026305d6a 100644 --- a/tests/test_orientation.py +++ b/tests/test_orientation.py @@ -13,180 +13,227 @@ import nibabel as nib import numpy as np +import torch from parameterized import parameterized +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor from monai.transforms import Orientation, create_rotate, create_translate -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_DEVICES, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: +for device in TEST_DEVICES: TESTS.append( [ - p, {"axcodes": "RAS"}, - np.arange(12).reshape((2, 1, 2, 3)), - {"affine": np.eye(4)}, - np.arange(12).reshape((2, 1, 2, 3)), + torch.arange(12).reshape((2, 1, 2, 3)), + torch.eye(4), + torch.arange(12).reshape((2, 1, 2, 3)), "RAS", + *device, ] ) TESTS.append( [ - p, {"axcodes": "ALS"}, - np.arange(12).reshape((2, 1, 2, 3)), - {"affine": np.diag([-1, -1, 1, 1])}, - np.array([[[[3, 4, 5]], [[0, 1, 2]]], [[[9, 10, 11]], [[6, 7, 8]]]]), + torch.arange(12).reshape((2, 1, 2, 3)), + torch.as_tensor(np.diag([-1, -1, 1, 1])), + torch.tensor([[[[3, 4, 5]], [[0, 1, 2]]], [[[9, 10, 11]], [[6, 7, 8]]]]), "ALS", + *device, ] ) TESTS.append( [ - p, {"axcodes": "RAS"}, - np.arange(12).reshape((2, 1, 2, 3)), - {"affine": np.diag([-1, -1, 1, 1])}, - np.array([[[[3, 4, 5], [0, 1, 2]]], [[[9, 10, 11], [6, 7, 8]]]]), + torch.arange(12).reshape((2, 1, 2, 3)), + torch.as_tensor(np.diag([-1, -1, 1, 1])), + torch.tensor([[[[3, 4, 5], [0, 1, 2]]], [[[9, 10, 11], [6, 7, 8]]]]), "RAS", + *device, ] ) TESTS.append( [ - p, {"axcodes": "AL"}, - np.arange(6).reshape((2, 1, 3)), - {"affine": np.eye(3)}, - np.array([[[0], [1], [2]], [[3], [4], [5]]]), + torch.arange(6).reshape((2, 1, 3)), + torch.eye(3), + torch.tensor([[[0], [1], [2]], [[3], [4], [5]]]), "AL", + *device, ] ) TESTS.append( [ - p, {"axcodes": "L"}, - np.arange(6).reshape((2, 3)), - {"affine": np.eye(2)}, - np.array([[2, 1, 0], [5, 4, 3]]), + torch.arange(6).reshape((2, 3)), + torch.eye(2), + torch.tensor([[2, 1, 0], [5, 4, 3]]), "L", + *device, ] ) TESTS.append( [ - p, {"axcodes": "L"}, - np.arange(6).reshape((2, 3)), - {"affine": np.eye(2)}, - np.array([[2, 1, 0], [5, 4, 3]]), + torch.arange(6).reshape((2, 3)), + torch.eye(2), + torch.tensor([[2, 1, 0], [5, 4, 3]]), "L", + *device, ] ) TESTS.append( [ - p, {"axcodes": "L"}, - np.arange(6).reshape((2, 3)), - {"affine": np.diag([-1, 1])}, - np.arange(6).reshape((2, 3)), + torch.arange(6).reshape((2, 3)), + torch.as_tensor(np.diag([-1, 1])), + torch.arange(6).reshape((2, 3)), "L", + *device, ] ) TESTS.append( [ - p, {"axcodes": "LPS"}, - np.arange(12).reshape((2, 1, 2, 3)), - { - "affine": create_translate(3, (10, 20, 30)) + torch.arange(12).reshape((2, 1, 2, 3)), + torch.as_tensor( + create_translate(3, (10, 20, 30)) @ create_rotate(3, (np.pi / 2, np.pi / 2, np.pi / 4)) @ np.diag([-1, 1, 1, 1]) - }, - np.array([[[[2, 5]], [[1, 4]], [[0, 3]]], [[[8, 11]], [[7, 10]], [[6, 9]]]]), + ), + torch.tensor([[[[2, 5]], [[1, 4]], [[0, 3]]], [[[8, 11]], [[7, 10]], [[6, 9]]]]), "LPS", + *device, ] ) TESTS.append( [ - p, {"as_closest_canonical": True}, - np.arange(12).reshape((2, 1, 2, 3)), - { - "affine": create_translate(3, (10, 20, 30)) + torch.arange(12).reshape((2, 1, 2, 3)), + torch.as_tensor( + create_translate(3, (10, 20, 30)) @ create_rotate(3, (np.pi / 2, np.pi / 2, np.pi / 4)) @ np.diag([-1, 1, 1, 1]) - }, - np.array([[[[0, 3]], [[1, 4]], [[2, 5]]], [[[6, 9]], [[7, 10]], [[8, 11]]]]), + ), + torch.tensor([[[[0, 3]], [[1, 4]], [[2, 5]]], [[[6, 9]], [[7, 10]], [[8, 11]]]]), "RAS", + *device, ] ) TESTS.append( [ - p, {"as_closest_canonical": True}, - np.arange(6).reshape((1, 2, 3)), - {"affine": create_translate(2, (10, 20)) @ create_rotate(2, (np.pi / 3)) @ np.diag([-1, -0.2, 1])}, - np.array([[[3, 0], [4, 1], [5, 2]]]), + torch.arange(6).reshape((1, 2, 3)), + torch.as_tensor(create_translate(2, (10, 20)) @ create_rotate(2, (np.pi / 3)) @ np.diag([-1, -0.2, 1])), + torch.tensor([[[3, 0], [4, 1], [5, 2]]]), "RA", + *device, ] ) TESTS.append( [ - p, {"axcodes": "LP"}, - np.arange(6).reshape((1, 2, 3)), - {"affine": create_translate(2, (10, 20)) @ create_rotate(2, (np.pi / 3)) @ np.diag([-1, -0.2, 1])}, - np.array([[[2, 5], [1, 4], [0, 3]]]), + torch.arange(6).reshape((1, 2, 3)), + torch.as_tensor(create_translate(2, (10, 20)) @ create_rotate(2, (np.pi / 3)) @ np.diag([-1, -0.2, 1])), + torch.tensor([[[2, 5], [1, 4], [0, 3]]]), "LP", + *device, ] ) TESTS.append( [ - p, {"axcodes": "LPID", "labels": tuple(zip("LPIC", "RASD"))}, - np.zeros((1, 2, 3, 4, 5)), - {"affine": np.diag([-1, -0.2, -1, 1, 1])}, - np.zeros((1, 2, 3, 4, 5)), + torch.zeros((1, 2, 3, 4, 5)), + torch.as_tensor(np.diag([-1, -0.2, -1, 1, 1])), + torch.zeros((1, 2, 3, 4, 5)), "LPID", + *device, ] ) TESTS.append( [ - p, {"as_closest_canonical": True, "labels": tuple(zip("LPIC", "RASD"))}, - np.zeros((1, 2, 3, 4, 5)), - {"affine": np.diag([-1, -0.2, -1, 1, 1])}, - np.zeros((1, 2, 3, 4, 5)), + torch.zeros((1, 2, 3, 4, 5)), + torch.as_tensor(np.diag([-1, -0.2, -1, 1, 1])), + torch.zeros((1, 2, 3, 4, 5)), "RASD", + *device, ] ) +TESTS_TORCH = [] +for track_meta in (False, True): + for device in TEST_DEVICES: + TESTS_TORCH.append([{"axcodes": "LPS"}, torch.zeros((1, 3, 4, 5)), track_meta, *device]) + ILL_CASES = [ - # no axcodes or as_cloest_canonical - [{}, np.arange(6).reshape((2, 3)), "L"], # too short axcodes - [{"axcodes": "RA"}, np.arange(12).reshape((2, 1, 2, 3)), {"affine": np.eye(4)}], + [{"axcodes": "RA"}, torch.arange(12).reshape((2, 1, 2, 3)), torch.eye(4)] ] class TestOrientationCase(unittest.TestCase): @parameterized.expand(TESTS) - def test_ornt(self, in_type, init_param, img, data_param, expected_data, expected_code): - img = in_type(img) + def test_ornt_meta( + self, + init_param, + img: torch.Tensor, + affine: torch.Tensor, + expected_data: torch.Tensor, + expected_code: str, + device, + ): + img = MetaTensor(img, affine=affine).to(device) ornt = Orientation(**init_param) - res = ornt(img, **data_param) - if not isinstance(res, tuple): - assert_allclose(res, in_type(expected_data)) - return - assert_allclose(res[0], in_type(expected_data)) - original_affine = data_param["affine"] - np.testing.assert_allclose(original_affine, res[1]) - new_code = nib.orientations.aff2axcodes(res[2], labels=ornt.labels) + res: MetaTensor = ornt(img) + assert_allclose(res, expected_data.to(device)) + new_code = nib.orientations.aff2axcodes(res.affine.cpu(), labels=ornt.labels) self.assertEqual("".join(new_code), expected_code) + @parameterized.expand(TESTS_TORCH) + def test_ornt_torch(self, init_param, img: torch.Tensor, track_meta: bool, device): + set_track_meta(track_meta) + ornt = Orientation(**init_param) + + img = img.to(device) + expected_data = img.clone() + expected_code = ornt.axcodes + + res = ornt(img) + assert_allclose(res, expected_data) + if track_meta: + self.assertIsInstance(res, MetaTensor) + new_code = nib.orientations.aff2axcodes(res.affine.cpu(), labels=ornt.labels) + self.assertEqual("".join(new_code), expected_code) + else: + self.assertIsInstance(res, torch.Tensor) + self.assertNotIsInstance(res, MetaTensor) + @parameterized.expand(ILL_CASES) - def test_bad_params(self, init_param, img, data_param): + def test_bad_params(self, init_param, img: torch.Tensor, affine: torch.Tensor): + img = MetaTensor(img, affine=affine) with self.assertRaises(ValueError): - Orientation(**init_param)(img, **data_param) + Orientation(**init_param)(img) + + @parameterized.expand(TEST_DEVICES) + def test_inverse(self, device): + img_t = torch.rand((1, 10, 9, 8), dtype=torch.float32, device=device) + affine = torch.tensor( + [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device=device + ) + meta = {"fname": "somewhere"} + img = MetaTensor(img_t, affine=affine, meta=meta) + tr = Orientation("LPS") + # check that image and affine have changed + img = tr(img) + self.assertNotEqual(img.shape, img_t.shape) + self.assertGreater((affine - img.affine).max(), 0.5) + # check that with inverse, image affine are back to how they were + img = tr.inverse(img) + self.assertEqual(img.shape, img_t.shape) + self.assertLess((affine - img.affine).max(), 1e-2) if __name__ == "__main__": diff --git a/tests/test_orientationd.py b/tests/test_orientationd.py index a4b953c8b5..1b4660a60a 100644 --- a/tests/test_orientationd.py +++ b/tests/test_orientationd.py @@ -10,94 +10,95 @@ # limitations under the License. import unittest +from typing import Optional import nibabel as nib import numpy as np +import torch +from parameterized import parameterized +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor from monai.transforms import Orientationd -from monai.utils.enums import PostFix -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_DEVICES +TESTS = [] +for device in TEST_DEVICES: + TESTS.append( + [{"keys": "seg", "axcodes": "RAS"}, torch.ones((2, 1, 2, 3)), torch.eye(4), (2, 1, 2, 3), "RAS", *device] + ) + # 3d + TESTS.append( + [ + {"keys": ["img", "seg"], "axcodes": "PLI"}, + torch.ones((2, 1, 2, 3)), + torch.eye(4), + (2, 2, 1, 3), + "PLI", + *device, + ] + ) + # 2d + TESTS.append( + [{"keys": ["img", "seg"], "axcodes": "PLI"}, torch.ones((2, 1, 3)), torch.eye(4), (2, 3, 1), "PLS", *device] + ) + # 1d + TESTS.append([{"keys": ["img", "seg"], "axcodes": "L"}, torch.ones((2, 3)), torch.eye(4), (2, 3), "LAS", *device]) + # canonical + TESTS.append( + [ + {"keys": ["img", "seg"], "as_closest_canonical": True}, + torch.ones((2, 1, 2, 3)), + torch.eye(4), + (2, 1, 2, 3), + "RAS", + *device, + ] + ) -class TestOrientationdCase(unittest.TestCase): - def test_orntd(self): - data = {"seg": np.ones((2, 1, 2, 3)), PostFix.meta("seg"): {"affine": np.eye(4)}} - ornt = Orientationd(keys="seg", axcodes="RAS") - res = ornt(data) - np.testing.assert_allclose(res["seg"].shape, (2, 1, 2, 3)) - code = nib.aff2axcodes(res[PostFix.meta("seg")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("R", "A", "S")) +TESTS_TORCH = [] +for track_meta in (False, True): + for device in TEST_DEVICES: + TESTS_TORCH.append([{"keys": "seg", "axcodes": "RAS"}, torch.ones(2, 1, 2, 3), track_meta, *device]) - def test_orntd_3d(self): - for p in TEST_NDARRAYS: - data = { - "seg": p(np.ones((2, 1, 2, 3))), - "img": p(np.ones((2, 1, 2, 3))), - PostFix.meta("seg"): {"affine": np.eye(4)}, - PostFix.meta("img"): {"affine": np.eye(4)}, - } - ornt = Orientationd(keys=("img", "seg"), axcodes="PLI") - res = ornt(data) - np.testing.assert_allclose(res["img"].shape, (2, 2, 1, 3)) - np.testing.assert_allclose(res["seg"].shape, (2, 2, 1, 3)) - code = nib.aff2axcodes(res[PostFix.meta("seg")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("P", "L", "I")) - code = nib.aff2axcodes(res[PostFix.meta("img")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("P", "L", "I")) - - def test_orntd_2d(self): - data = { - "seg": np.ones((2, 1, 3)), - "img": np.ones((2, 1, 3)), - PostFix.meta("seg"): {"affine": np.eye(4)}, - PostFix.meta("img"): {"affine": np.eye(4)}, - } - ornt = Orientationd(keys=("img", "seg"), axcodes="PLI") - res = ornt(data) - np.testing.assert_allclose(res["img"].shape, (2, 3, 1)) - code = nib.aff2axcodes(res[PostFix.meta("seg")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("P", "L", "S")) - code = nib.aff2axcodes(res[PostFix.meta("img")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("P", "L", "S")) - def test_orntd_1d(self): - data = { - "seg": np.ones((2, 3)), - "img": np.ones((2, 3)), - PostFix.meta("seg"): {"affine": np.eye(4)}, - PostFix.meta("img"): {"affine": np.eye(4)}, - } - ornt = Orientationd(keys=("img", "seg"), axcodes="L") - res = ornt(data) - np.testing.assert_allclose(res["img"].shape, (2, 3)) - code = nib.aff2axcodes(res[PostFix.meta("seg")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("L", "A", "S")) - code = nib.aff2axcodes(res[PostFix.meta("img")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("L", "A", "S")) - - def test_orntd_canonical(self): - data = { - "seg": np.ones((2, 1, 2, 3)), - "img": np.ones((2, 1, 2, 3)), - PostFix.meta("seg"): {"affine": np.eye(4)}, - PostFix.meta("img"): {"affine": np.eye(4)}, - } - ornt = Orientationd(keys=("img", "seg"), as_closest_canonical=True) +class TestOrientationdCase(unittest.TestCase): + @parameterized.expand(TESTS) + def test_orntd( + self, init_param, img: torch.Tensor, affine: Optional[torch.Tensor], expected_shape, expected_code, device + ): + ornt = Orientationd(**init_param) + if affine is not None: + img = MetaTensor(img, affine=affine) + img = img.to(device) + data = {k: img.clone() for k in ornt.keys} res = ornt(data) - np.testing.assert_allclose(res["img"].shape, (2, 1, 2, 3)) - np.testing.assert_allclose(res["seg"].shape, (2, 1, 2, 3)) - code = nib.aff2axcodes(res[PostFix.meta("seg")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("R", "A", "S")) - code = nib.aff2axcodes(res[PostFix.meta("img")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("R", "A", "S")) + for k in ornt.keys: + _im = res[k] + self.assertIsInstance(_im, MetaTensor) + np.testing.assert_allclose(_im.shape, expected_shape) + code = nib.aff2axcodes(_im.affine.cpu(), ornt.ornt_transform.labels) + self.assertEqual("".join(code), expected_code) - def test_orntd_no_metadata(self): - data = {"seg": np.ones((2, 1, 2, 3))} - ornt = Orientationd(keys="seg", axcodes="RAS") + @parameterized.expand(TESTS_TORCH) + def test_orntd_torch(self, init_param, img: torch.Tensor, track_meta: bool, device): + set_track_meta(track_meta) + ornt = Orientationd(**init_param) + img = img.to(device) + expected_shape = img.shape + expected_code = ornt.ornt_transform.axcodes + data = {k: img.clone() for k in ornt.keys} res = ornt(data) - np.testing.assert_allclose(res["seg"].shape, (2, 1, 2, 3)) - code = nib.aff2axcodes(res[PostFix.meta("seg")]["affine"], ornt.ornt_transform.labels) - self.assertEqual(code, ("R", "A", "S")) + for k in ornt.keys: + _im = res[k] + np.testing.assert_allclose(_im.shape, expected_shape) + if track_meta: + self.assertIsInstance(_im, MetaTensor) + code = nib.aff2axcodes(_im.affine.cpu(), ornt.ornt_transform.labels) + self.assertEqual("".join(code), expected_code) + else: + self.assertIsInstance(_im, torch.Tensor) + self.assertNotIsInstance(_im, MetaTensor) if __name__ == "__main__": diff --git a/tests/test_pad_collation.py b/tests/test_pad_collation.py index 530e5f86a3..9ea3a7bc73 100644 --- a/tests/test_pad_collation.py +++ b/tests/test_pad_collation.py @@ -31,7 +31,6 @@ RandZoom, RandZoomd, ToTensor, - ToTensord, ) from monai.utils import set_determinism @@ -44,7 +43,9 @@ TESTS.append((dict, pad_collate, RandSpatialCropd("image", roi_size=[8, 7], random_size=True))) TESTS.append((dict, pad_collate, RandRotated("image", prob=1, range_x=np.pi, keep_size=False, dtype=np.float64))) TESTS.append((dict, pad_collate, RandZoomd("image", prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) - TESTS.append((dict, pad_collate, Compose([RandRotate90d("image", prob=1, max_k=2), ToTensord("image")]))) + TESTS.append( + (dict, pad_collate, Compose([RandRotate90d("image", prob=1, max_k=3), RandRotate90d("image", prob=1, max_k=4)])) + ) TESTS.append((list, pad_collate, RandSpatialCrop(roi_size=[8, 7], random_size=True))) TESTS.append((list, pad_collate, RandRotate(prob=1, range_x=np.pi, keep_size=False, dtype=np.float64))) diff --git a/tests/test_rand_adjust_contrast.py b/tests/test_rand_adjust_contrast.py index eaeff70d51..5dc800793e 100644 --- a/tests/test_rand_adjust_contrast.py +++ b/tests/test_rand_adjust_contrast.py @@ -27,7 +27,8 @@ class TestRandAdjustContrast(NumpyImageTestCase2D): def test_correct_results(self, gamma): adjuster = RandAdjustContrast(prob=1.0, gamma=gamma) for p in TEST_NDARRAYS: - result = adjuster(p(self.imt)) + im = p(self.imt) + result = adjuster(im) epsilon = 1e-7 img_min = self.imt.min() img_range = self.imt.max() - img_min @@ -35,7 +36,7 @@ def test_correct_results(self, gamma): np.power(((self.imt - img_min) / float(img_range + epsilon)), adjuster.gamma_value) * img_range + img_min ) - assert_allclose(expected, result, rtol=1e-05, type_test=False) + assert_allclose(result, expected, rtol=1e-05, type_test=False) if __name__ == "__main__": diff --git a/tests/test_rand_adjust_contrastd.py b/tests/test_rand_adjust_contrastd.py index e5f1f6099a..b355ac3e4f 100644 --- a/tests/test_rand_adjust_contrastd.py +++ b/tests/test_rand_adjust_contrastd.py @@ -35,7 +35,7 @@ def test_correct_results(self, gamma): np.power(((self.imt - img_min) / float(img_range + epsilon)), adjuster.adjuster.gamma_value) * img_range + img_min ) - assert_allclose(expected, result["img"], rtol=1e-05, type_test=False) + assert_allclose(result["img"], expected, rtol=1e-05, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index dcfe193213..b5bc67ffb1 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -17,12 +17,12 @@ from monai.transforms import RandAffine from monai.utils.type_conversion import convert_data_type -from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, is_tf32_env _rtol = 1e-3 if is_tf32_env() else 1e-4 TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [dict(device=device), {"img": p(torch.arange(27).reshape((3, 3, 3)))}, p(np.arange(27).reshape((3, 3, 3)))] @@ -126,7 +126,7 @@ ) TEST_CASES_SKIPPED_CONSISTENCY = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for in_dtype in (np.int32, np.float32): TEST_CASES_SKIPPED_CONSISTENCY.append((p(np.arange(9 * 10).reshape(1, 9, 10)), in_dtype)) @@ -144,7 +144,7 @@ def test_rand_affine(self, input_param, input_data, expected_val): result = g(**input_data) 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) + assert_allclose(result, expected_val, rtol=_rtol, atol=1e-4, type_test="tensor") def test_ill_cache(self): with self.assertWarns(UserWarning): diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index 722bafb0e5..6a40d39e4e 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -16,12 +16,12 @@ from parameterized import parameterized from monai.transforms import RandAffineGrid -from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, is_tf32_env _rtol = 1e-1 if is_tf32_env() else 1e-4 TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append([{"device": device}, {"grid": p(torch.ones((3, 3, 3)))}, p(np.ones((3, 3, 3)))]) TESTS.append( diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index 882b5554e6..a33496895c 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -9,214 +9,243 @@ # See the License for the specific language governing permissions and # limitations under the License. +import itertools import unittest import numpy as np import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandAffined from monai.utils import GridSampleMode -from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env +from tests.utils import assert_allclose, is_tf32_env _rtol = 1e-3 if is_tf32_env() else 1e-4 TESTS = [] -for p in TEST_NDARRAYS: - for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: - TESTS.append( - [ - dict(device=device, spatial_size=None, keys=("img", "seg")), - {"img": p(torch.arange(27).reshape((3, 3, 3))), "seg": p(torch.arange(27).reshape((3, 3, 3)))}, - p(np.arange(27).reshape((3, 3, 3))), - ] - ) - TESTS.append( - [ - dict(device=device, spatial_size=(2, 2), keys=("img", "seg")), - {"img": p(torch.ones((3, 3, 3))), "seg": p(torch.ones((3, 3, 3)))}, - p(np.ones((3, 2, 2))), - ] - ) - TESTS.append( - [ - dict(device=device, spatial_size=(2, 2), cache_grid=True, keys=("img", "seg")), - {"img": p(torch.ones((3, 3, 3))), "seg": p(torch.ones((3, 3, 3)))}, - p(np.ones((3, 2, 2))), - ] - ) - TESTS.append( - [ - dict(device=device, spatial_size=(2, 2, 2), keys=("img", "seg")), - {"img": p(torch.ones((1, 3, 3, 3))), "seg": p(torch.ones((1, 3, 3, 3)))}, - p(torch.ones((1, 2, 2, 2))), - ] - ) - TESTS.append( - [ - dict( - prob=0.9, - rotate_range=(np.pi / 2,), - shear_range=[1, 2], - translate_range=[2, 1], - spatial_size=(2, 2, 2), - padding_mode="zeros", - device=device, - keys=("img", "seg"), - mode="bilinear", - ), - {"img": p(torch.ones((1, 3, 3, 3))), "seg": p(torch.ones((1, 3, 3, 3)))}, - p(torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]])), - ] - ) - TESTS.append( - [ - dict( - prob=0.9, - rotate_range=(np.pi / 2,), - shear_range=[1, 2], - translate_range=[2, 1], - scale_range=[0.1, 0.2], - spatial_size=(3, 3), - keys=("img", "seg"), - device=device, - ), - {"img": p(torch.arange(64).reshape((1, 8, 8))), "seg": p(torch.arange(64).reshape((1, 8, 8)))}, - p( + +for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: + TESTS.append( + [ + dict(device=device, spatial_size=None, keys=("img", "seg")), + { + "img": MetaTensor(torch.arange(27).reshape((3, 3, 3))), + "seg": MetaTensor(torch.arange(27).reshape((3, 3, 3))), + }, + torch.arange(27).reshape((3, 3, 3)), + ] + ) + TESTS.append( + [ + dict(device=device, spatial_size=(2, 2), keys=("img", "seg")), + {"img": MetaTensor(torch.ones((3, 3, 3))), "seg": MetaTensor(torch.ones((3, 3, 3)))}, + torch.ones((3, 2, 2)), + ] + ) + TESTS.append( + [ + dict(device=device, spatial_size=(2, 2), cache_grid=True, keys=("img", "seg")), + {"img": MetaTensor(torch.ones((3, 3, 3))), "seg": MetaTensor(torch.ones((3, 3, 3)))}, + torch.ones((3, 2, 2)), + ] + ) + TESTS.append( + [ + dict(device=device, spatial_size=(2, 2, 2), keys=("img", "seg")), + {"img": MetaTensor(torch.ones((1, 3, 3, 3))), "seg": MetaTensor(torch.ones((1, 3, 3, 3)))}, + torch.ones((1, 2, 2, 2)), + ] + ) + TESTS.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + shear_range=[1, 2], + translate_range=[2, 1], + spatial_size=(2, 2, 2), + padding_mode="zeros", + device=device, + keys=("img", "seg"), + 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]]]]), + ] + ) + TESTS.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + shear_range=[1, 2], + translate_range=[2, 1], + scale_range=[0.1, 0.2], + spatial_size=(3, 3), + keys=("img", "seg"), + device=device, + ), + { + "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]]]), + ] + ) + TESTS.append( + [ + dict( + prob=0.9, + mode=("bilinear", "nearest"), + rotate_range=(np.pi / 2,), + shear_range=[1, 2], + translate_range=[2, 1], + scale_range=[0.1, 0.2], + spatial_size=(3, 3), + keys=("img", "seg"), + device=device, + ), + { + "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + "seg": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + }, + { + "img": MetaTensor( torch.tensor( - [[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]] - ) - ), - ] - ) - TESTS.append( - [ - dict( - prob=0.9, - mode=("bilinear", "nearest"), - rotate_range=(np.pi / 2,), - shear_range=[1, 2], - translate_range=[2, 1], - scale_range=[0.1, 0.2], - spatial_size=(3, 3), - keys=("img", "seg"), - device=device, - ), - {"img": p(torch.arange(64).reshape((1, 8, 8))), "seg": p(torch.arange(64).reshape((1, 8, 8)))}, - { - "img": p( - np.array( + [ [ - [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], - ] + [18.736153, 15.581954, 12.4277525], + [27.398798, 24.244598, 21.090399], + [36.061443, 32.90724, 29.753046], ] - ) - ), - "seg": p(np.array([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), - }, - ] - ) - TESTS.append( - [ - dict( - prob=0.9, - rotate_range=(np.pi / 2,), - shear_range=[1, 2], - translate_range=[2, 1], - spatial_size=(2, 2, 2), - padding_mode="zeros", - device=device, - keys=("img", "seg"), - mode=GridSampleMode.BILINEAR, - ), - {"img": p(torch.ones((1, 3, 3, 3))), "seg": p(torch.ones((1, 3, 3, 3)))}, - p(torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]])), - ] - ) - 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), - keys=("img", "seg"), - device=device, + ] + ) ), - {"img": p(torch.arange(64).reshape((1, 8, 8))), "seg": p(torch.arange(64).reshape((1, 8, 8)))}, - { - "img": p( - np.array( + "seg": MetaTensor(torch.tensor([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + }, + ] + ) + TESTS.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + shear_range=[1, 2], + translate_range=[2, 1], + spatial_size=(2, 2, 2), + padding_mode="zeros", + device=device, + keys=("img", "seg"), + 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]]]]), + ] + ) + 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), + keys=("img", "seg"), + device=device, + ), + { + "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + "seg": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + }, + { + "img": MetaTensor( + np.array( + [ [ - [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], - ] + [18.736153, 15.581954, 12.4277525], + [27.398798, 24.244598, 21.090399], + [36.061443, 32.90724, 29.753046], ] - ) - ), - "seg": p(np.array([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), - }, - ] - ) - 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), - cache_grid=True, - keys=("img", "seg"), - device=device, + ] + ) ), - {"img": p(torch.arange(64).reshape((1, 8, 8))), "seg": p(torch.arange(64).reshape((1, 8, 8)))}, - { - "img": p( - np.array( + "seg": MetaTensor(np.array([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + }, + ] + ) + 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), + cache_grid=True, + keys=("img", "seg"), + device=device, + ), + { + "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + "seg": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + }, + { + "img": MetaTensor( + torch.tensor( + [ [ - [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], - ] + [18.736153, 15.581954, 12.4277525], + [27.398798, 24.244598, 21.090399], + [36.061443, 32.90724, 29.753046], ] - ) - ), - "seg": p(np.array([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), - }, - ] - ) + ] + ) + ), + "seg": MetaTensor(torch.tensor([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + }, + ] + ) class TestRandAffined(unittest.TestCase): - @parameterized.expand(TESTS) - def test_rand_affined(self, input_param, input_data, expected_val): + @parameterized.expand(x + [y] for x, y in itertools.product(TESTS, (False, True))) + def test_rand_affined(self, input_param, input_data, expected_val, track_meta): + set_track_meta(track_meta) g = RandAffined(**input_param).set_random_state(123) res = g(input_data) if input_param.get("cache_grid", False): self.assertTrue(g.rand_affine._cached_grid is not None) for key in res: result = res[key] - if "_transforms" in key: - continue + if track_meta: + self.assertIsInstance(result, MetaTensor) + self.assertEqual(len(result.applied_operations), 1) expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - assert_allclose(result, expected, rtol=_rtol, atol=1e-3) + assert_allclose(result, expected, rtol=_rtol, atol=1e-3, type_test=False) g.set_random_state(4) res = g(input_data) + if not track_meta: + return + # affine should be tensor because the resampler only supports pytorch backend - self.assertTrue(isinstance(res["img_transforms"][0]["extra_info"]["affine"], torch.Tensor)) + if isinstance(res["img"], MetaTensor) and "extra_info" in res["img"].applied_operations[0]: + if not res["img"].applied_operations[-1]["extra_info"]["do_resampling"]: + return + affine_img = res["img"].applied_operations[0]["extra_info"]["rand_affine_info"]["extra_info"]["affine"] + affine_seg = res["seg"].applied_operations[0]["extra_info"]["rand_affine_info"]["extra_info"]["affine"] + assert_allclose(affine_img, affine_seg, rtol=_rtol, atol=1e-3) + + res_inv = g.inverse(res) + for k, v in res_inv.items(): + self.assertIsInstance(v, MetaTensor) + self.assertEqual(len(v.applied_operations), 0) + self.assertTupleEqual(v.shape, input_data[k].shape) def test_ill_cache(self): with self.assertWarns(UserWarning): diff --git a/tests/test_rand_axis_flip.py b/tests/test_rand_axis_flip.py index b7c504557f..7458b9d6dd 100644 --- a/tests/test_rand_axis_flip.py +++ b/tests/test_rand_axis_flip.py @@ -12,18 +12,28 @@ import unittest import numpy as np +import torch +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandAxisFlip -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion class TestRandAxisFlip(NumpyImageTestCase2D): def test_correct_results(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: flip = RandAxisFlip(prob=1.0) - result = flip(p(self.imt[0])) + im = p(self.imt[0]) + result = flip(im) expected = [np.flip(channel, flip._axis) for channel in self.imt[0]] - assert_allclose(result, p(np.stack(expected))) + assert_allclose(result, p(np.stack(expected)), type_test="tensor") + test_local_inversion(flip, result, im) + + set_track_meta(False) + result = flip(im) + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) if __name__ == "__main__": diff --git a/tests/test_rand_axis_flipd.py b/tests/test_rand_axis_flipd.py index ff97d5dc1e..a62da88af3 100644 --- a/tests/test_rand_axis_flipd.py +++ b/tests/test_rand_axis_flipd.py @@ -12,19 +12,28 @@ import unittest import numpy as np +import torch +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandAxisFlipd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase3D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase3D, assert_allclose, test_local_inversion class TestRandAxisFlip(NumpyImageTestCase3D): def test_correct_results(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: flip = RandAxisFlipd(keys="img", prob=1.0) - result = flip({"img": p(self.imt[0])})["img"] - + im = p(self.imt[0]) + result = flip({"img": im}) + test_local_inversion(flip, result, {"img": im}, "img") expected = [np.flip(channel, flip.flipper._axis) for channel in self.imt[0]] - assert_allclose(result, p(np.stack(expected))) + assert_allclose(result["img"], p(np.stack(expected)), type_test="tensor") + + set_track_meta(False) + result = flip({"img": im})["img"] + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) if __name__ == "__main__": diff --git a/tests/test_rand_bias_field.py b/tests/test_rand_bias_field.py index b3aa8e9174..690c4022eb 100644 --- a/tests/test_rand_bias_field.py +++ b/tests/test_rand_bias_field.py @@ -16,6 +16,7 @@ from parameterized import parameterized from monai.transforms import RandBiasField +from tests.utils import TEST_NDARRAYS TEST_CASES_2D = [{"prob": 1.0}, (3, 32, 32)] TEST_CASES_3D = [{"prob": 1.0}, (3, 32, 32, 32)] @@ -29,10 +30,10 @@ class TestRandBiasField(unittest.TestCase): @parameterized.expand([TEST_CASES_2D, TEST_CASES_3D]) def test_output_shape(self, class_args, img_shape): - for fn in (np.random, torch): + for p in TEST_NDARRAYS: for degree in [1, 2, 3]: bias_field = RandBiasField(degree=degree, **class_args) - img = fn.rand(*img_shape) + img = p(np.random.rand(*img_shape)) output = bias_field(img) np.testing.assert_equal(output.shape, img_shape) self.assertTrue(output.dtype in (np.float32, torch.float32)) diff --git a/tests/test_rand_bias_fieldd.py b/tests/test_rand_bias_fieldd.py index da08cfe053..05a5a1b636 100644 --- a/tests/test_rand_bias_fieldd.py +++ b/tests/test_rand_bias_fieldd.py @@ -33,7 +33,6 @@ def test_output_shape(self, class_args, img_shape): img = np.random.rand(*img_shape) output = bias_field({key: img}) np.testing.assert_equal(output[key].shape, img_shape) - np.testing.assert_equal(output[key].dtype, bias_field.rand_bias_field.dtype) @parameterized.expand([TEST_CASES_2D_ZERO_RANGE]) def test_zero_range(self, class_args, img_shape): diff --git a/tests/test_rand_coarse_dropout.py b/tests/test_rand_coarse_dropout.py index a05d323277..cc05edbf02 100644 --- a/tests/test_rand_coarse_dropout.py +++ b/tests/test_rand_coarse_dropout.py @@ -17,6 +17,7 @@ from monai.transforms import RandCoarseDropout from monai.utils import fall_back_tuple +from tests.utils import TEST_NDARRAYS, assert_allclose TEST_CASE_0 = [ {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "prob": 1.0}, @@ -64,9 +65,10 @@ class TestRandCoarseDropout(unittest.TestCase): [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7] ) def test_value(self, input_param, input_data): - dropout = RandCoarseDropout(**input_param) - result = dropout(input_data) - self.assertEqual(type(result), type(input_data)) + for p in TEST_NDARRAYS: + dropout = RandCoarseDropout(**input_param) + im = p(input_data) + result = dropout(im) holes = input_param.get("holes") max_holes = input_param.get("max_holes") spatial_size = fall_back_tuple(input_param.get("spatial_size"), input_data.shape[1:]) @@ -84,7 +86,7 @@ def test_value(self, input_param, input_data): if input_param.get("dropout_holes", True): fill_value = input_param.get("fill_value", None) if isinstance(fill_value, (int, float)): - np.testing.assert_allclose(data, fill_value) + assert_allclose(data, fill_value, type_test=False) elif fill_value is not None: min_value = data.min() max_value = data.max() @@ -92,7 +94,7 @@ def test_value(self, input_param, input_data): self.assertGreaterEqual(min_value, fill_value[0]) self.assertLess(max_value, fill_value[1]) else: - np.testing.assert_allclose(data, input_data[h]) + assert_allclose(data, input_data[h], type_test=False) if max_spatial_size is None: self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) diff --git a/tests/test_rand_deform_grid.py b/tests/test_rand_deform_grid.py index 8a2c8bf6eb..3e59e3207b 100644 --- a/tests/test_rand_deform_grid.py +++ b/tests/test_rand_deform_grid.py @@ -19,7 +19,7 @@ TEST_CASES = [ [ - dict(spacing=(1, 2), magnitude_range=(1.0, 2.0), as_tensor_output=False, device=None), + dict(spacing=(1, 2), magnitude_range=(1.0, 2.0), device=None), {"spatial_size": (3, 3)}, np.array( [ @@ -48,7 +48,7 @@ ), ], [ - dict(spacing=(1, 2, 2), magnitude_range=(1.0, 3.0), as_tensor_output=False, device=None), + dict(spacing=(1, 2, 2), magnitude_range=(1.0, 3.0), device=None), {"spatial_size": (1, 2, 2)}, np.array( [ diff --git a/tests/test_rand_elastic_2d.py b/tests/test_rand_elastic_2d.py index bc23a6c5cb..125da74528 100644 --- a/tests/test_rand_elastic_2d.py +++ b/tests/test_rand_elastic_2d.py @@ -15,13 +15,14 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import Rand2DElastic -from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, is_tf32_env _rtol = 5e-3 if is_tf32_env() else 1e-4 TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -110,9 +111,14 @@ class TestRand2DElastic(unittest.TestCase): @parameterized.expand(TESTS) def test_rand_2d_elastic(self, input_param, input_data, expected_val): g = Rand2DElastic(**input_param) + set_track_meta(False) + result = g(**input_data) + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) g.set_random_state(123) result = g(**input_data) - assert_allclose(result, expected_val, rtol=_rtol, atol=1e-4) + assert_allclose(result, expected_val, type_test=False, rtol=_rtol, atol=1e-4) if __name__ == "__main__": diff --git a/tests/test_rand_elastic_3d.py b/tests/test_rand_elastic_3d.py index 39ce779cb0..76c9e9024d 100644 --- a/tests/test_rand_elastic_3d.py +++ b/tests/test_rand_elastic_3d.py @@ -15,11 +15,12 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import Rand3DElastic -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -86,9 +87,15 @@ class TestRand3DElastic(unittest.TestCase): @parameterized.expand(TESTS) def test_rand_3d_elastic(self, input_param, input_data, expected_val): g = Rand3DElastic(**input_param) + set_track_meta(False) g.set_random_state(123) result = g(**input_data) - assert_allclose(result, expected_val, rtol=1e-1, atol=1e-1) + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) + g.set_random_state(123) + result = g(**input_data) + assert_allclose(result, expected_val, type_test=False, rtol=1e-1, atol=1e-1) if __name__ == "__main__": diff --git a/tests/test_rand_elasticd_2d.py b/tests/test_rand_elasticd_2d.py index ead39e5731..759ba2c4da 100644 --- a/tests/test_rand_elasticd_2d.py +++ b/tests/test_rand_elasticd_2d.py @@ -16,12 +16,12 @@ from parameterized import parameterized from monai.transforms import Rand2DElasticd -from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose, is_tf32_env _rtol = 5e-3 if is_tf32_env() else 1e-4 TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -166,7 +166,7 @@ def test_rand_2d_elasticd(self, input_param, input_data, expected_val): for key in res: result = res[key] expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - assert_allclose(result, expected, rtol=_rtol, atol=5e-3) + assert_allclose(result, expected, rtol=_rtol, atol=5e-3, type_test=False) if __name__ == "__main__": diff --git a/tests/test_rand_elasticd_3d.py b/tests/test_rand_elasticd_3d.py index c78ed1f42e..eaba06c953 100644 --- a/tests/test_rand_elasticd_3d.py +++ b/tests/test_rand_elasticd_3d.py @@ -16,10 +16,10 @@ from parameterized import parameterized from monai.transforms import Rand3DElasticd -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -145,7 +145,7 @@ def test_rand_3d_elasticd(self, input_param, input_data, expected_val): for key in res: result = res[key] expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - assert_allclose(result, expected, rtol=1e-2, atol=1e-2) + assert_allclose(result, expected, type_test=False, rtol=1e-2, atol=1e-2) if __name__ == "__main__": diff --git a/tests/test_rand_flip.py b/tests/test_rand_flip.py index b9e9a8c4d6..cdd51dd77e 100644 --- a/tests/test_rand_flip.py +++ b/tests/test_rand_flip.py @@ -12,10 +12,12 @@ import unittest import numpy as np +import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandFlip -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -31,13 +33,19 @@ def test_invalid_inputs(self, _, spatial_axis, raises): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: im = p(self.imt[0]) flip = RandFlip(prob=1.0, spatial_axis=spatial_axis) + set_track_meta(False) + result = flip(im) + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) expected = [np.flip(channel, spatial_axis) for channel in self.imt[0]] expected = np.stack(expected) result = flip(im) - assert_allclose(result, p(expected)) + assert_allclose(result, p(expected), type_test="tensor") + test_local_inversion(flip, result, im) if __name__ == "__main__": diff --git a/tests/test_rand_flipd.py b/tests/test_rand_flipd.py index 9a92661c59..92b070fd0a 100644 --- a/tests/test_rand_flipd.py +++ b/tests/test_rand_flipd.py @@ -12,10 +12,12 @@ import unittest import numpy as np +import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandFlipd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion VALID_CASES = [("no_axis", None), ("one_axis", 1), ("many_axis", [0, 1])] @@ -23,12 +25,19 @@ class TestRandFlipd(NumpyImageTestCase2D): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: flip = RandFlipd(keys="img", prob=1.0, spatial_axis=spatial_axis) - result = flip({"img": p(self.imt[0])})["img"] + im = p(self.imt[0]) + result = flip({"img": im})["img"] expected = [np.flip(channel, spatial_axis) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(result, p(expected)) + assert_allclose(result, p(expected), type_test="tensor") + test_local_inversion(flip, {"img": result}, {"img": im}, "img") + set_track_meta(False) + result = flip({"img": im})["img"] + self.assertNotIsInstance(result, MetaTensor) + self.assertIsInstance(result, torch.Tensor) + set_track_meta(True) if __name__ == "__main__": diff --git a/tests/test_rand_gaussian_noise.py b/tests/test_rand_gaussian_noise.py index 1f2adfb9e7..faa2b143f5 100644 --- a/tests/test_rand_gaussian_noise.py +++ b/tests/test_rand_gaussian_noise.py @@ -35,7 +35,6 @@ def test_correct_results(self, _, im_type, mean, std): np.random.seed(seed) np.random.random() expected = self.imt + np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) - self.assertEqual(type(im), type(noised)) if isinstance(noised, torch.Tensor): noised = noised.cpu() np.testing.assert_allclose(expected, noised, atol=1e-5) diff --git a/tests/test_rand_gaussian_noised.py b/tests/test_rand_gaussian_noised.py index be1df0f2e6..a927761186 100644 --- a/tests/test_rand_gaussian_noised.py +++ b/tests/test_rand_gaussian_noised.py @@ -39,7 +39,6 @@ def test_correct_results(self, _, im_type, keys, mean, std): noise = np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) for k in keys: expected = self.imt + noise - self.assertEqual(type(im), type(noised[k])) if isinstance(noised[k], torch.Tensor): noised[k] = noised[k].cpu() np.testing.assert_allclose(expected, noised[k], atol=1e-5, rtol=1e-5) diff --git a/tests/test_rand_gaussian_sharpen.py b/tests/test_rand_gaussian_sharpen.py index 06563a35b6..3f7f276cd3 100644 --- a/tests/test_rand_gaussian_sharpen.py +++ b/tests/test_rand_gaussian_sharpen.py @@ -131,7 +131,7 @@ def test_value(self, argments, image, expected_data): converter = RandGaussianSharpen(**argments) converter.set_random_state(seed=0) result = converter(image) - assert_allclose(result, expected_data, atol=0, rtol=1e-4, type_test=False) + assert_allclose(result, expected_data, atol=0, rtol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_gaussian_smooth.py b/tests/test_rand_gaussian_smooth.py index d51618be95..e395d4f395 100644 --- a/tests/test_rand_gaussian_smooth.py +++ b/tests/test_rand_gaussian_smooth.py @@ -89,7 +89,7 @@ def test_value(self, argments, image, expected_data): converter = RandGaussianSmooth(**argments) converter.set_random_state(seed=0) result = converter(image) - assert_allclose(result, expected_data, rtol=1e-4, type_test=False) + assert_allclose(result, expected_data, rtol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_gibbs_noise.py b/tests/test_rand_gibbs_noise.py index fe928038da..b87b839eb9 100644 --- a/tests/test_rand_gibbs_noise.py +++ b/tests/test_rand_gibbs_noise.py @@ -13,14 +13,13 @@ from copy import deepcopy import numpy as np -import torch from parameterized import parameterized from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import RandGibbsNoise from monai.utils.misc import set_determinism from monai.utils.module import optional_import -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose _, has_torch_fft = optional_import("torch.fft", name="fftshift") @@ -50,7 +49,7 @@ def test_0_prob(self, im_shape, input_type): alpha = [0.5, 1.0] t = RandGibbsNoise(0.0, alpha) out = t(im) - torch.testing.assert_allclose(im, out, rtol=1e-7, atol=0) + assert_allclose(out, im, rtol=1e-7, atol=0, type_test="tensor") @parameterized.expand(TEST_CASES) def test_same_result(self, im_shape, input_type): @@ -61,8 +60,7 @@ def test_same_result(self, im_shape, input_type): out1 = t(deepcopy(im)) t.set_random_state(42) out2 = t(deepcopy(im)) - torch.testing.assert_allclose(out1, out2, rtol=1e-7, atol=0) - self.assertIsInstance(out1, type(im)) + assert_allclose(out1, out2, rtol=1e-7, atol=1e-2, type_test="tensor") @parameterized.expand(TEST_CASES) def test_identity(self, im_shape, input_type): @@ -70,7 +68,7 @@ def test_identity(self, im_shape, input_type): alpha = [0.0, 0.0] t = RandGibbsNoise(1.0, alpha) out = t(deepcopy(im)) - torch.testing.assert_allclose(im, out, atol=1e-2, rtol=1e-7) + assert_allclose(out, im, atol=1e-2, rtol=1e-7, type_test="tensor") @parameterized.expand(TEST_CASES) def test_alpha_1(self, im_shape, input_type): @@ -78,7 +76,7 @@ def test_alpha_1(self, im_shape, input_type): alpha = [1.0, 1.0] t = RandGibbsNoise(1.0, alpha) out = t(deepcopy(im)) - torch.testing.assert_allclose(0 * im, out, rtol=1e-7, atol=0) + assert_allclose(out, 0 * im, rtol=1e-7, atol=1e-2, type_test="tensor") @parameterized.expand(TEST_CASES) def test_alpha(self, im_shape, input_type): diff --git a/tests/test_rand_gibbs_noised.py b/tests/test_rand_gibbs_noised.py index 8c5e045b90..8b15fcc267 100644 --- a/tests/test_rand_gibbs_noised.py +++ b/tests/test_rand_gibbs_noised.py @@ -20,7 +20,7 @@ from monai.transforms import RandGibbsNoised from monai.utils.misc import set_determinism from monai.utils.module import optional_import -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose _, has_torch_fft = optional_import("torch.fft", name="fftshift") @@ -65,8 +65,7 @@ def test_same_result(self, im_shape, input_type): t.set_random_state(42) out2 = t(deepcopy(data)) for k in KEYS: - torch.testing.assert_allclose(out1[k], out2[k], rtol=1e-7, atol=0) - self.assertIsInstance(out1[k], type(data[k])) + assert_allclose(out1[k], out2[k], rtol=1e-7, atol=0, type_test="tensor") @parameterized.expand(TEST_CASES) def test_identity(self, im_shape, input_type): @@ -75,11 +74,7 @@ def test_identity(self, im_shape, input_type): t = RandGibbsNoised(KEYS, 1.0, alpha) out = t(deepcopy(data)) for k in KEYS: - self.assertEqual(type(out[k]), type(data[k])) - if isinstance(out[k], torch.Tensor): - self.assertEqual(out[k].device, data[k].device) - out[k], data[k] = out[k].cpu(), data[k].cpu() - np.testing.assert_allclose(data[k], out[k], atol=1e-2) + assert_allclose(out[k], data[k], atol=1e-2, type_test="tensor") @parameterized.expand(TEST_CASES) def test_alpha_1(self, im_shape, input_type): @@ -88,11 +83,7 @@ def test_alpha_1(self, im_shape, input_type): t = RandGibbsNoised(KEYS, 1.0, alpha) out = t(deepcopy(data)) for k in KEYS: - self.assertEqual(type(out[k]), type(data[k])) - if isinstance(out[k], torch.Tensor): - self.assertEqual(out[k].device, data[k].device) - out[k], data[k] = out[k].cpu(), data[k].cpu() - np.testing.assert_allclose(0.0 * data[k], out[k], atol=1e-2) + assert_allclose(out[k], 0.0 * data[k], atol=1e-2, type_test="tensor") @parameterized.expand(TEST_CASES) def test_dict_matches(self, im_shape, input_type): diff --git a/tests/test_rand_grid_distortion.py b/tests/test_rand_grid_distortion.py index 80f19df0db..88b4989cd5 100644 --- a/tests/test_rand_grid_distortion.py +++ b/tests/test_rand_grid_distortion.py @@ -15,10 +15,10 @@ from parameterized import parameterized from monai.transforms import RandGridDistortion -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: seed = 0 TESTS.append( [ @@ -87,7 +87,7 @@ def test_rand_grid_distortion(self, input_param, seed, input_data, expected_val) g = RandGridDistortion(**input_param) g.set_random_state(seed=seed) result = g(input_data) - assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + assert_allclose(result, expected_val, type_test="tensor", rtol=1e-4, atol=1e-4) if __name__ == "__main__": diff --git a/tests/test_rand_grid_distortiond.py b/tests/test_rand_grid_distortiond.py index 323848dc0b..a7b64e5980 100644 --- a/tests/test_rand_grid_distortiond.py +++ b/tests/test_rand_grid_distortiond.py @@ -15,12 +15,12 @@ from parameterized import parameterized from monai.transforms import RandGridDistortiond -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] num_cells = 2 seed = 0 -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: img = np.indices([6, 6]).astype(np.float32) TESTS.append( [ @@ -80,8 +80,8 @@ def test_rand_grid_distortiond(self, input_param, seed, input_data, expected_val g = RandGridDistortiond(**input_param) g.set_random_state(seed=seed) result = g(input_data) - assert_allclose(result["img"], expected_val_img, rtol=1e-4, atol=1e-4) - assert_allclose(result["mask"], expected_val_mask, rtol=1e-4, atol=1e-4) + assert_allclose(result["img"], expected_val_img, type_test=False, rtol=1e-4, atol=1e-4) + assert_allclose(result["mask"], expected_val_mask, type_test=False, rtol=1e-4, atol=1e-4) if __name__ == "__main__": diff --git a/tests/test_rand_histogram_shift.py b/tests/test_rand_histogram_shift.py index 0682306bb6..89198549cd 100644 --- a/tests/test_rand_histogram_shift.py +++ b/tests/test_rand_histogram_shift.py @@ -49,7 +49,7 @@ def test_rand_histogram_shift(self, input_param, input_data, expected_val): g = RandHistogramShift(**input_param) g.set_random_state(123) result = g(**input_data) - assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4, type_test=False) + assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4, type_test="tensor") def test_interp(self): tr = RandHistogramShift() @@ -58,15 +58,15 @@ def test_interp(self): y = array_type([1.0, -1.0, 3.0, 5.0]) yi = tr.interp(array_type([0, 2, 4, 8, 10]), x, y) - assert yi.shape == (5,) + self.assertEqual(yi.shape, (5,)) assert_allclose(yi, array_type([1.0, 0.0, -1.0, 4.0, 5.0])) yi = tr.interp(array_type([-1, 11, 10.001, -0.001]), x, y) - assert yi.shape == (4,) + self.assertEqual(yi.shape, (4,)) assert_allclose(yi, array_type([1.0, 5.0, 5.0, 1.0])) yi = tr.interp(array_type([[-2, 11], [1, 3], [8, 10]]), x, y) - assert yi.shape == (3, 2) + self.assertEqual(yi.shape, (3, 2)) assert_allclose(yi, array_type([[1.0, 5.0], [0.5, -0.5], [4.0, 5.0]])) diff --git a/tests/test_rand_histogram_shiftd.py b/tests/test_rand_histogram_shiftd.py index fe8ddf9ffd..7c94379e0e 100644 --- a/tests/test_rand_histogram_shiftd.py +++ b/tests/test_rand_histogram_shiftd.py @@ -64,10 +64,10 @@ def test_rand_histogram_shiftd(self, input_param, input_data, expected_val): g = RandHistogramShiftd(**input_param) g.set_random_state(123) res = g(input_data) - for key in res: + for key in ("img",): result = res[key] expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - assert_allclose(result, expected, rtol=1e-4, atol=1e-4, type_test=False) + assert_allclose(result, expected, rtol=1e-4, atol=1e-4, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_k_space_spike_noise.py b/tests/test_rand_k_space_spike_noise.py index 8027194555..176699ddd1 100644 --- a/tests/test_rand_k_space_spike_noise.py +++ b/tests/test_rand_k_space_spike_noise.py @@ -12,14 +12,12 @@ import unittest from copy import deepcopy -import numpy as np -import torch from parameterized import parameterized from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import KSpaceSpikeNoise, RandKSpaceSpikeNoise from monai.utils.misc import set_determinism -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose TESTS = [] for shape in ((128, 64), (64, 48, 80)): @@ -48,11 +46,7 @@ def test_0_prob(self, im_shape, im_type, channel_wise): intensity_range = [14, 15] t = RandKSpaceSpikeNoise(0.0, intensity_range, channel_wise) out = t(im) - self.assertEqual(type(im), type(out)) - if isinstance(out, torch.Tensor): - self.assertEqual(out.device, im.device) - im, out = im.cpu(), out.cpu() - np.testing.assert_allclose(im, out) + assert_allclose(out, im, type_test="tensor") @parameterized.expand(TESTS) def test_1_prob(self, im_shape, im_type, channel_wise): @@ -62,11 +56,7 @@ def test_1_prob(self, im_shape, im_type, channel_wise): out = t(im) base_t = KSpaceSpikeNoise(t.sampled_locs, [14]) out = out - base_t(im) - self.assertEqual(type(im), type(out)) - if isinstance(out, torch.Tensor): - self.assertEqual(out.device, im.device) - im, out = im.cpu(), out.cpu() - np.testing.assert_allclose(out, im * 0) + assert_allclose(out, im * 0, type_test="tensor") @parameterized.expand(TESTS) def test_same_result(self, im_shape, im_type, channel_wise): @@ -77,11 +67,7 @@ def test_same_result(self, im_shape, im_type, channel_wise): out1 = t(deepcopy(im)) t.set_random_state(42) out2 = t(deepcopy(im)) - self.assertEqual(type(im), type(out1)) - if isinstance(out1, torch.Tensor): - self.assertEqual(out1.device, im.device) - out1, out2 = out1.cpu(), out2.cpu() - np.testing.assert_allclose(out1, out2) + assert_allclose(out1, out2, type_test="tensor") @parameterized.expand(TESTS) def test_intensity(self, im_shape, im_type, channel_wise): diff --git a/tests/test_rand_k_space_spike_noised.py b/tests/test_rand_k_space_spike_noised.py index 7a6a73b215..156c95822f 100644 --- a/tests/test_rand_k_space_spike_noised.py +++ b/tests/test_rand_k_space_spike_noised.py @@ -12,14 +12,12 @@ import unittest from copy import deepcopy -import numpy as np -import torch from parameterized import parameterized from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import RandKSpaceSpikeNoised from monai.utils.misc import set_determinism -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose TESTS = [] for shape in ((128, 64), (64, 48, 80)): @@ -57,33 +55,20 @@ def test_same_result(self, im_shape, im_type): out2 = t(deepcopy(data)) for k in KEYS: - self.assertEqual(type(out1[k]), type(data[k])) - if isinstance(out1[k], torch.Tensor): - self.assertEqual(out1[k].device, data[k].device) - out1[k] = out1[k].cpu() - out2[k] = out2[k].cpu() - np.testing.assert_allclose(out1[k], out2[k], atol=1e-10) + assert_allclose(out1[k], out2[k], atol=1e-10, type_test="tensor") @parameterized.expand(TESTS) def test_0_prob(self, im_shape, im_type): data = self.get_data(im_shape, im_type) t1 = RandKSpaceSpikeNoised(KEYS, prob=0.0, intensity_range=(13, 15), channel_wise=True) - t2 = RandKSpaceSpikeNoised(KEYS, prob=0.0, intensity_range=(13, 15), channel_wise=True) out1 = t1(data) out2 = t2(data) for k in KEYS: - self.assertEqual(type(out1[k]), type(data[k])) - if isinstance(out1[k], torch.Tensor): - self.assertEqual(out1[k].device, data[k].device) - out1[k] = out1[k].cpu() - out2[k] = out2[k].cpu() - data[k] = data[k].cpu() - - np.testing.assert_allclose(data[k], out1[k]) - np.testing.assert_allclose(data[k], out2[k]) + assert_allclose(out1[k], data[k], type_test="tensor") + assert_allclose(out2[k], data[k], type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_lambda.py b/tests/test_rand_lambda.py index 043f44aec4..c356406f61 100644 --- a/tests/test_rand_lambda.py +++ b/tests/test_rand_lambda.py @@ -10,11 +10,15 @@ # limitations under the License. import unittest +from copy import deepcopy import numpy as np +from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms.transform import Randomizable from monai.transforms.utility.array import RandLambda +from tests.utils import TEST_NDARRAYS, assert_allclose class RandTest(Randomizable): @@ -27,26 +31,56 @@ def randomize(self, data=None): def __call__(self, data): self.randomize() - return data + self._a + return deepcopy(data) + self._a class TestRandLambda(unittest.TestCase): - def test_rand_lambdad_identity(self): - img = np.zeros((10, 10)) + def check(self, tr: RandLambda, img, img_orig_type, out, expected=None): + # input shouldn't change + self.assertIsInstance(img, img_orig_type) + if isinstance(img, MetaTensor): + self.assertEqual(len(img.applied_operations), 0) + # output data matches expected + assert_allclose(expected, out, type_test=False) + # output type is MetaTensor with 1 appended operation + self.assertIsInstance(out, MetaTensor) + self.assertEqual(len(out.applied_operations), 1) + + # inverse + inv = tr.inverse(out) + # after inverse, input image remains unchanged + self.assertIsInstance(img, img_orig_type) + if isinstance(img, MetaTensor): + self.assertEqual(len(img.applied_operations), 0) + # after inverse, output is MetaTensor with 0 applied operations + self.assertIsInstance(inv, MetaTensor) + self.assertEqual(len(inv.applied_operations), 0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_rand_lambdad_identity(self, t): + img = t(np.zeros((10, 10))) + img_t = type(img) test_func = RandTest() test_func.set_random_state(seed=134) expected = test_func(img) test_func.set_random_state(seed=134) - ret = RandLambda(func=test_func)(img) - np.testing.assert_allclose(expected, ret) - ret = RandLambda(func=test_func, prob=0.0)(img) - np.testing.assert_allclose(img, ret) + # default prob + tr = RandLambda(func=test_func) + ret = tr(img) + self.check(tr, img, img_t, ret, expected) + + # prob = 0 + tr = RandLambda(func=test_func, prob=0.0) + ret = tr(img) + self.check(tr, img, img_t, ret, expected=img) + + # prob = 0.5 trans = RandLambda(func=test_func, prob=0.5) trans.set_random_state(seed=123) ret = trans(img) - np.testing.assert_allclose(img, ret) + self.check(trans, img, img_t, ret, expected=img) if __name__ == "__main__": diff --git a/tests/test_rand_lambdad.py b/tests/test_rand_lambdad.py index 854fef8879..b181db5035 100644 --- a/tests/test_rand_lambdad.py +++ b/tests/test_rand_lambdad.py @@ -10,11 +10,15 @@ # limitations under the License. import unittest +from copy import deepcopy import numpy as np +from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms.transform import Randomizable from monai.transforms.utility.dictionary import RandLambdad +from tests.utils import TEST_NDARRAYS, assert_allclose class RandTest(Randomizable): @@ -31,26 +35,42 @@ def __call__(self, data): class TestRandLambdad(unittest.TestCase): - def test_rand_lambdad_identity(self): - img = np.zeros((10, 10)) + def check(self, tr: RandLambdad, input: dict, out: dict, expected: dict): + if isinstance(input["img"], MetaTensor): + self.assertEqual(len(input["img"].applied_operations), 0) + self.assertIsInstance(out["img"], MetaTensor) + self.assertEqual(len(out["img"].applied_operations), 1) + assert_allclose(expected["img"], out["img"], type_test=False) + assert_allclose(expected["prop"], out["prop"], type_test=False) + inv = tr.inverse(out) + self.assertIsInstance(inv["img"], MetaTensor) + self.assertEqual(len(inv["img"].applied_operations), 0) # type: ignore + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_rand_lambdad_identity(self, t): + img = t(np.zeros((10, 10))) data = {"img": img, "prop": 1.0} test_func = RandTest() test_func.set_random_state(seed=134) expected = {"img": test_func(data["img"]), "prop": 1.0} test_func.set_random_state(seed=134) - ret = RandLambdad(keys=["img", "prop"], func=test_func, overwrite=[True, False])(data) - np.testing.assert_allclose(expected["img"], ret["img"]) - np.testing.assert_allclose(expected["prop"], ret["prop"]) - ret = RandLambdad(keys=["img", "prop"], func=test_func, prob=0.0)(data) - np.testing.assert_allclose(data["img"], ret["img"]) - np.testing.assert_allclose(data["prop"], ret["prop"]) + # default prob + tr = RandLambdad(keys=["img", "prop"], func=test_func, overwrite=[True, False]) + ret = tr(deepcopy(data)) + self.check(tr, data, ret, expected) + + # prob = 0 + tr = RandLambdad(keys=["img", "prop"], func=test_func, prob=0.0) + ret = tr(deepcopy(data)) + self.check(tr, data, ret, expected=data) + + # prob = 0.5 trans = RandLambdad(keys=["img", "prop"], func=test_func, prob=0.5) trans.set_random_state(seed=123) - ret = trans(data) - np.testing.assert_allclose(data["img"], ret["img"]) - np.testing.assert_allclose(data["prop"], ret["prop"]) + ret = trans(deepcopy(data)) + self.check(trans, data, ret, expected=data) if __name__ == "__main__": diff --git a/tests/test_rand_rician_noise.py b/tests/test_rand_rician_noise.py index 8e2ea1ee3a..896ae8b2e0 100644 --- a/tests/test_rand_rician_noise.py +++ b/tests/test_rand_rician_noise.py @@ -30,7 +30,8 @@ def test_correct_results(self, _, in_type, mean, std): seed = 0 rician_fn = RandRicianNoise(prob=1.0, mean=mean, std=std) rician_fn.set_random_state(seed) - noised = rician_fn(in_type(self.imt)) + im = in_type(self.imt) + noised = rician_fn(im) np.random.seed(seed) np.random.random() _std = np.random.uniform(0, std) diff --git a/tests/test_rand_rotate.py b/tests/test_rand_rotate.py index 7a85fce23b..bdee0474d0 100644 --- a/tests/test_rand_rotate.py +++ b/tests/test_rand_rotate.py @@ -17,18 +17,19 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandRotate -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D, test_local_inversion TEST_CASES_2D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_2D.append((p, np.pi / 2, True, "bilinear", "border", False)) TEST_CASES_2D.append((p, np.pi / 4, True, "nearest", "border", False)) TEST_CASES_2D.append((p, np.pi, False, "nearest", "zeros", True)) TEST_CASES_2D.append((p, (-np.pi / 4, 0), False, "nearest", "zeros", True)) TEST_CASES_3D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_3D.append( (p, np.pi / 2, -np.pi / 6, (0.0, np.pi), False, "bilinear", "border", False, (1, 87, 104, 109)) ) @@ -108,8 +109,16 @@ def test_correct_results(self, im_type, x, y, z, keep_size, mode, padding_mode, dtype=np.float64, ) rotate_fn.set_random_state(243) - rotated = rotate_fn(im_type(self.imt[0])) + im = im_type(self.imt[0]) + rotated = rotate_fn(im) torch.testing.assert_allclose(rotated.shape, expected, rtol=1e-7, atol=0) + test_local_inversion(rotate_fn, rotated, im) + + set_track_meta(False) + rotated = rotate_fn(im) + self.assertNotIsInstance(rotated, MetaTensor) + self.assertIsInstance(rotated, torch.Tensor) + set_track_meta(True) if __name__ == "__main__": diff --git a/tests/test_rand_rotate90.py b/tests/test_rand_rotate90.py index b845944062..30ad906ac2 100644 --- a/tests/test_rand_rotate90.py +++ b/tests/test_rand_rotate90.py @@ -12,47 +12,64 @@ import unittest import numpy as np +import torch +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandRotate90 -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion class TestRandRotate90(NumpyImageTestCase2D): def test_default(self): rotate = RandRotate90() - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: rotate.set_random_state(123) - rotated = rotate(p(self.imt[0])) + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 0, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") def test_k(self): rotate = RandRotate90(max_k=2) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + set_track_meta(False) + rotated = rotate(im) + self.assertNotIsInstance(rotated, MetaTensor) + self.assertIsInstance(rotated, torch.Tensor) + + set_track_meta(True) rotate.set_random_state(123) - rotated = rotate(p(self.imt[0])) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 0, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") def test_spatial_axes(self): - rotate = RandRotate90(spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: - rotate.set_random_state(123) - rotated = rotate(p(self.imt[0])) - expected = [np.rot90(channel, 0, (0, 1)) for channel in self.imt[0]] + rotate = RandRotate90(spatial_axes=(0, 1), prob=1.0) + for p in TEST_NDARRAYS_ALL: + rotate.set_random_state(1234) + im = p(self.imt[0]) + rotated = rotate(im) + self.assertEqual(len(rotated.applied_operations), 1) + expected = [np.rot90(channel, rotate._rand_k, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") + test_local_inversion(rotate, rotated, im) def test_prob_k_spatial_axes(self): rotate = RandRotate90(prob=1.0, max_k=2, spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: rotate.set_random_state(234) - rotated = rotate(p(self.imt[0])) + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 1, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_rotate90d.py b/tests/test_rand_rotate90d.py index ded18e430a..ec0e5ac92e 100644 --- a/tests/test_rand_rotate90d.py +++ b/tests/test_rand_rotate90d.py @@ -12,51 +12,67 @@ import unittest import numpy as np +import torch +from monai.data import MetaTensor, set_track_meta from monai.transforms import RandRotate90d -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion class TestRandRotate90d(NumpyImageTestCase2D): def test_default(self): key = None rotate = RandRotate90d(keys=key) - for p in TEST_NDARRAYS: - rotate.set_random_state(123) - rotated = rotate({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + rotate.set_random_state(1323) + im = {key: p(self.imt[0])} + rotated = rotate(im) + test_local_inversion(rotate, rotated, im, key) expected = [np.rot90(channel, 0, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") + + set_track_meta(False) + rotated = rotate(im)[key] + self.assertNotIsInstance(rotated, MetaTensor) + self.assertIsInstance(rotated, torch.Tensor) + set_track_meta(True) def test_k(self): key = "test" rotate = RandRotate90d(keys=key, max_k=2) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: rotate.set_random_state(234) - rotated = rotate({key: p(self.imt[0])}) + im = {key: p(self.imt[0])} + rotated = rotate(im) + test_local_inversion(rotate, rotated, im, key) expected = [np.rot90(channel, 0, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") def test_spatial_axes(self): key = "test" rotate = RandRotate90d(keys=key, spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: rotate.set_random_state(234) - rotated = rotate({key: p(self.imt[0])}) + im = {key: p(self.imt[0])} + rotated = rotate(im) + test_local_inversion(rotate, rotated, im, key) expected = [np.rot90(channel, 0, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") def test_prob_k_spatial_axes(self): key = "test" rotate = RandRotate90d(keys=key, prob=1.0, max_k=2, spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: rotate.set_random_state(234) - rotated = rotate({key: p(self.imt[0])}) + im = {key: p(self.imt[0])} + rotated = rotate(im) expected = [np.rot90(channel, 1, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") + test_local_inversion(rotate, rotated, im, key) def test_no_key(self): key = "unknown" diff --git a/tests/test_rand_rotated.py b/tests/test_rand_rotated.py index 464b37d925..906977f3fa 100644 --- a/tests/test_rand_rotated.py +++ b/tests/test_rand_rotated.py @@ -19,10 +19,10 @@ from monai.transforms import RandRotated from monai.utils import GridSampleMode, GridSamplePadMode -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D, test_local_inversion TEST_CASES_2D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_2D.append((p, np.pi / 2, True, "bilinear", "border", False)) TEST_CASES_2D.append((p, np.pi / 4, True, "nearest", "border", False)) TEST_CASES_2D.append((p, np.pi, False, "nearest", "zeros", True)) @@ -30,7 +30,7 @@ TEST_CASES_3D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_3D.append( (p, np.pi / 2, -np.pi / 6, (0.0, np.pi), False, "bilinear", "border", False, (1, 87, 104, 109)) ) @@ -118,8 +118,9 @@ def test_correct_results(self, im_type, degrees, keep_size, mode, padding_mode, align_corners=align_corners, dtype=np.float64, ) + im = im_type(self.imt[0]) rotate_fn.set_random_state(243) - rotated = rotate_fn({"img": im_type(self.imt[0]), "seg": im_type(self.segn[0])}) + rotated = rotate_fn({"img": im, "seg": im_type(self.segn[0])}) _order = 0 if mode == "nearest" else 1 if padding_mode == "border": @@ -132,6 +133,7 @@ def test_correct_results(self, im_type, degrees, keep_size, mode, padding_mode, expected = scipy.ndimage.rotate( self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False ) + test_local_inversion(rotate_fn, rotated, {"img": im}, "img") for k, v in rotated.items(): rotated[k] = v.cpu() if isinstance(v, torch.Tensor) else v expected = np.stack(expected).astype(np.float32) diff --git a/tests/test_rand_scale_crop.py b/tests/test_rand_scale_crop.py index aea26d62bb..a97a77a8e6 100644 --- a/tests/test_rand_scale_crop.py +++ b/tests/test_rand_scale_crop.py @@ -55,7 +55,7 @@ def test_value(self, input_param, input_data): cropper = RandScaleCrop(**input_param) result = cropper(im_type(input_data)) roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] - assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) + assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test="tensor") @parameterized.expand(TEST_RANDOM_SHAPES) def test_random_shape(self, input_param, input_shape, expected_shape): diff --git a/tests/test_rand_scale_cropd.py b/tests/test_rand_scale_cropd.py index 645c058dfb..dd92783766 100644 --- a/tests/test_rand_scale_cropd.py +++ b/tests/test_rand_scale_cropd.py @@ -75,7 +75,7 @@ def test_value(self, input_param, input_im): input_data = {"img": im_type(input_im)} result = cropper(input_data)["img"] roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper.cropper._size] - assert_allclose(result, input_im[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) + assert_allclose(result, input_im[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test="tensor") @parameterized.expand(TEST_RANDOM_SHAPES) def test_random_shape(self, input_param, input_shape, expected_shape): diff --git a/tests/test_rand_scale_intensity.py b/tests/test_rand_scale_intensity.py index 5aa5c7b964..b0999a82a5 100644 --- a/tests/test_rand_scale_intensity.py +++ b/tests/test_rand_scale_intensity.py @@ -12,22 +12,24 @@ import unittest import numpy as np +from parameterized import parameterized from monai.transforms import RandScaleIntensity from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandScaleIntensity(NumpyImageTestCase2D): - def test_value(self): - for p in TEST_NDARRAYS: - scaler = RandScaleIntensity(factors=0.5, prob=1.0) - scaler.set_random_state(seed=0) - result = scaler(p(self.imt)) - np.random.seed(0) - # simulate the randomize() of transform - np.random.random() - expected = p((self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32)) - assert_allclose(result, p(expected), rtol=1e-7, atol=0) + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_value(self, p): + scaler = RandScaleIntensity(factors=0.5, prob=1.0) + scaler.set_random_state(seed=0) + im = p(self.imt) + result = scaler(im) + np.random.seed(0) + # simulate the randomize() of transform + np.random.random() + expected = p((self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32)) + assert_allclose(result, p(expected), rtol=1e-7, atol=0, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_scale_intensityd.py b/tests/test_rand_scale_intensityd.py index 655bd88ee0..d548ee34d6 100644 --- a/tests/test_rand_scale_intensityd.py +++ b/tests/test_rand_scale_intensityd.py @@ -28,7 +28,7 @@ def test_value(self): # simulate the randomize function of transform np.random.random() expected = (self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32) - assert_allclose(result[key], p(expected)) + assert_allclose(result[key], p(expected), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_shift_intensity.py b/tests/test_rand_shift_intensity.py index b4f32a385a..d5ad083d33 100644 --- a/tests/test_rand_shift_intensity.py +++ b/tests/test_rand_shift_intensity.py @@ -12,21 +12,24 @@ import unittest import numpy as np +from parameterized import parameterized from monai.transforms import RandShiftIntensity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandShiftIntensity(NumpyImageTestCase2D): - def test_value(self): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_value(self, p): shifter = RandShiftIntensity(offsets=1.0, prob=1.0) shifter.set_random_state(seed=0) - result = shifter(self.imt, factor=1.0) + im = p(self.imt) + result = shifter(im, factor=1.0) np.random.seed(0) # simulate the randomize() of transform np.random.random() expected = self.imt + np.random.uniform(low=-1.0, high=1.0) - np.testing.assert_allclose(result, expected) + assert_allclose(result, expected, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_shift_intensityd.py b/tests/test_rand_shift_intensityd.py index 4d05149e3c..1a8356c2c9 100644 --- a/tests/test_rand_shift_intensityd.py +++ b/tests/test_rand_shift_intensityd.py @@ -29,7 +29,7 @@ def test_value(self): # simulate the randomize() of transform np.random.random() expected = self.imt + np.random.uniform(low=-1.0, high=1.0) - assert_allclose(result[key], p(expected)) + assert_allclose(result[key], p(expected), type_test="tensor") def test_factor(self): key = "img" diff --git a/tests/test_rand_spatial_crop.py b/tests/test_rand_spatial_crop.py index 0c8d4ab132..383ea8a1cb 100644 --- a/tests/test_rand_spatial_crop.py +++ b/tests/test_rand_spatial_crop.py @@ -55,7 +55,7 @@ def test_value(self, input_param, input_data): cropper = RandSpatialCrop(**input_param) result = cropper(im_type(input_data)) roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size] - assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) + assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test="tensor") @parameterized.expand(TEST_RANDOM_SHAPES) def test_random_shape(self, input_param, input_shape, expected_shape): diff --git a/tests/test_rand_spatial_crop_samples.py b/tests/test_rand_spatial_crop_samples.py index 50571b5955..fd905a6dae 100644 --- a/tests/test_rand_spatial_crop_samples.py +++ b/tests/test_rand_spatial_crop_samples.py @@ -93,7 +93,7 @@ def test_shape(self, input_param, input_shape, expected_shape, expected_last_ite for i, (item, expected) in enumerate(zip(result, expected_shape)): self.assertTupleEqual(item.shape, expected) self.assertEqual(item.meta["patch_index"], i) - assert_allclose(result[-1], expected_last_item, type_test=False) + assert_allclose(result[-1], expected_last_item, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_spatial_cropd.py b/tests/test_rand_spatial_cropd.py index c6a0fbe5e7..1b256959c6 100644 --- a/tests/test_rand_spatial_cropd.py +++ b/tests/test_rand_spatial_cropd.py @@ -60,7 +60,7 @@ def test_value(self, input_param, input_im): input_data = {"img": im_type(input_im)} result = cropper(input_data)["img"] roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper.cropper._size] - assert_allclose(result, input_im[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test=False) + assert_allclose(result, input_im[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]], type_test="tensor") @parameterized.expand(TEST_RANDOM_SHAPES) def test_random_shape(self, input_param, input_shape, expected_shape): diff --git a/tests/test_rand_std_shift_intensity.py b/tests/test_rand_std_shift_intensity.py index fdf386fee4..b26f5ef096 100644 --- a/tests/test_rand_std_shift_intensity.py +++ b/tests/test_rand_std_shift_intensity.py @@ -12,25 +12,25 @@ import unittest import numpy as np -import torch +from parameterized import parameterized from monai.transforms import RandStdShiftIntensity -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandStdShiftIntensity(NumpyImageTestCase2D): - def test_value(self): - for p in TEST_NDARRAYS: - np.random.seed(0) - # simulate the randomize() of transform - np.random.random() - factor = np.random.uniform(low=-1.0, high=1.0) - offset = factor * np.std(self.imt) - expected = p(self.imt + offset) - shifter = RandStdShiftIntensity(factors=1.0, prob=1.0) - shifter.set_random_state(seed=0) - result = shifter(p(self.imt)) - torch.testing.assert_allclose(result, expected, atol=0, rtol=1e-5) + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_value(self, p): + np.random.seed(0) + # simulate the randomize() of transform + np.random.random() + factor = np.random.uniform(low=-1.0, high=1.0) + offset = factor * np.std(self.imt) + expected = p(self.imt + offset) + shifter = RandStdShiftIntensity(factors=1.0, prob=1.0) + shifter.set_random_state(seed=0) + result = shifter(p(self.imt)) + assert_allclose(result, expected, atol=0, rtol=1e-5, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_std_shift_intensityd.py b/tests/test_rand_std_shift_intensityd.py index e98d1e3ad3..bbbed053ad 100644 --- a/tests/test_rand_std_shift_intensityd.py +++ b/tests/test_rand_std_shift_intensityd.py @@ -12,10 +12,9 @@ import unittest import numpy as np -import torch from monai.transforms import RandStdShiftIntensityd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandStdShiftIntensityd(NumpyImageTestCase2D): @@ -30,9 +29,7 @@ def test_value(self): shifter = RandStdShiftIntensityd(keys=[key], factors=1.0, prob=1.0) shifter.set_random_state(seed=0) result = shifter({key: p(self.imt)})[key] - if isinstance(result, torch.Tensor): - result = result.cpu() - np.testing.assert_allclose(result, expected, rtol=1e-5) + assert_allclose(result, expected, rtol=1e-5, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_weighted_crop.py b/tests/test_rand_weighted_crop.py index 696de9c05e..53913ce987 100644 --- a/tests/test_rand_weighted_crop.py +++ b/tests/test_rand_weighted_crop.py @@ -14,7 +14,6 @@ import numpy as np from parameterized.parameterized import parameterized -from monai.data.meta_tensor import MetaTensor from monai.transforms.croppad.array import RandWeightedCrop from tests.croppers import CropTest from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D, assert_allclose @@ -164,8 +163,7 @@ def test_rand_weighted_crop(self, _, input_params, img, weight, expected_shape, # if desired ROI is larger than image, check image is unchanged if all(s >= i for i, s in zip(img.shape[1:], input_params["spatial_size"])): for res in result: - self.assertIsInstance(res, MetaTensor) - assert_allclose(res, img, type_test=False) + assert_allclose(res, img, type_test="tensor") self.assertEqual(len(res.applied_operations), 1) diff --git a/tests/test_rand_zoom.py b/tests/test_rand_zoom.py index 55b167d272..fc8280490f 100644 --- a/tests/test_rand_zoom.py +++ b/tests/test_rand_zoom.py @@ -17,7 +17,7 @@ from monai.transforms import RandZoom from monai.utils import InterpolateMode -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion VALID_CASES = [(0.8, 1.2, "nearest", False), (0.8, 1.2, InterpolateMode.NEAREST, False)] @@ -25,23 +25,27 @@ class TestRandZoom(NumpyImageTestCase2D): @parameterized.expand(VALID_CASES) def test_correct_results(self, min_zoom, max_zoom, mode, keep_size): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: random_zoom = RandZoom(prob=1.0, min_zoom=min_zoom, max_zoom=max_zoom, mode=mode, keep_size=keep_size) random_zoom.set_random_state(1234) - zoomed = random_zoom(p(self.imt[0])) + im = p(self.imt[0]) + zoomed = random_zoom(im) + test_local_inversion(random_zoom, zoomed, im) expected = [ zoom_scipy(channel, zoom=random_zoom._zoom, mode="nearest", order=0, prefilter=False) for channel in self.imt[0] ] expected = np.stack(expected).astype(np.float32) - assert_allclose(zoomed, p(expected), atol=1.0) + assert_allclose(zoomed, p(expected), atol=1.0, type_test=False) def test_keep_size(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: im = p(self.imt[0]) random_zoom = RandZoom(prob=1.0, min_zoom=0.6, max_zoom=0.7, keep_size=True) + random_zoom.set_random_state(12) zoomed = random_zoom(im) + test_local_inversion(random_zoom, zoomed, im) self.assertTrue(np.array_equal(zoomed.shape, self.imt.shape[1:])) zoomed = random_zoom(im) self.assertTrue(np.array_equal(zoomed.shape, self.imt.shape[1:])) @@ -52,19 +56,19 @@ def test_keep_size(self): [("no_min_zoom", None, 1.1, "bilinear", TypeError), ("invalid_mode", 0.9, 1.1, "s", ValueError)] ) def test_invalid_inputs(self, _, min_zoom, max_zoom, mode, raises): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: with self.assertRaises(raises): random_zoom = RandZoom(prob=1.0, min_zoom=min_zoom, max_zoom=max_zoom, mode=mode) random_zoom(p(self.imt[0])) def test_auto_expand_3d(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: random_zoom = RandZoom(prob=1.0, min_zoom=[0.8, 0.7], max_zoom=[1.2, 1.3], mode="nearest", keep_size=False) random_zoom.set_random_state(1234) test_data = p(np.random.randint(0, 2, size=[2, 2, 3, 4])) zoomed = random_zoom(test_data) - assert_allclose(random_zoom._zoom, (1.048844, 1.048844, 0.962637), atol=1e-2) - assert_allclose(zoomed.shape, (2, 2, 3, 3)) + assert_allclose(random_zoom._zoom, (1.048844, 1.048844, 0.962637), atol=1e-2, type_test=False) + assert_allclose(zoomed.shape, (2, 2, 3, 3), type_test=False) if __name__ == "__main__": diff --git a/tests/test_rand_zoomd.py b/tests/test_rand_zoomd.py index a22f2f36f1..b2ae40530a 100644 --- a/tests/test_rand_zoomd.py +++ b/tests/test_rand_zoomd.py @@ -16,7 +16,7 @@ from scipy.ndimage import zoom as zoom_scipy from monai.transforms import RandZoomd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion VALID_CASES = [(0.8, 1.2, "nearest", None, False)] @@ -34,25 +34,29 @@ def test_correct_results(self, min_zoom, max_zoom, mode, align_corners, keep_siz align_corners=align_corners, keep_size=keep_size, ) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: random_zoom.set_random_state(1234) - zoomed = random_zoom({key: p(self.imt[0])}) + im = p(self.imt[0]) + zoomed = random_zoom({key: im}) + test_local_inversion(random_zoom, zoomed, {key: im}, key) expected = [ zoom_scipy(channel, zoom=random_zoom.rand_zoom._zoom, mode="nearest", order=0, prefilter=False) for channel in self.imt[0] ] expected = np.stack(expected).astype(np.float32) - assert_allclose(zoomed[key], p(expected), atol=1.0) + assert_allclose(zoomed[key], p(expected), atol=1.0, type_test=False) def test_keep_size(self): key = "img" random_zoom = RandZoomd( keys=key, prob=1.0, min_zoom=0.6, max_zoom=0.7, keep_size=True, padding_mode="constant", constant_values=2 ) - for p in TEST_NDARRAYS: - zoomed = random_zoom({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + zoomed = random_zoom({key: im}) + test_local_inversion(random_zoom, zoomed, {key: im}, key) np.testing.assert_array_equal(zoomed[key].shape, self.imt.shape[1:]) @parameterized.expand( @@ -60,7 +64,7 @@ def test_keep_size(self): ) def test_invalid_inputs(self, _, min_zoom, max_zoom, mode, raises): key = "img" - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: with self.assertRaises(raises): random_zoom = RandZoomd(key, prob=1.0, min_zoom=min_zoom, max_zoom=max_zoom, mode=mode) random_zoom({key: p(self.imt[0])}) @@ -69,7 +73,7 @@ def test_auto_expand_3d(self): random_zoom = RandZoomd( keys="img", prob=1.0, min_zoom=[0.8, 0.7], max_zoom=[1.2, 1.3], mode="nearest", keep_size=False ) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: random_zoom.set_random_state(1234) test_data = {"img": p(np.random.randint(0, 2, size=[2, 2, 3, 4]))} zoomed = random_zoom(test_data) diff --git a/tests/test_remove_repeated_channel.py b/tests/test_remove_repeated_channel.py index 39b42cc4b0..e4b707ce42 100644 --- a/tests/test_remove_repeated_channel.py +++ b/tests/test_remove_repeated_channel.py @@ -11,15 +11,14 @@ import unittest -import numpy as np -import torch from parameterized import parameterized from monai.transforms import RemoveRepeatedChannel +from tests.utils import TEST_NDARRAYS TEST_CASES = [] -for q in (torch.Tensor, np.array): - TEST_CASES.append([{"repeats": 2}, q([[1, 2], [1, 2], [3, 4], [3, 4]]), (2, 2)]) # type: ignore +for q in TEST_NDARRAYS: + TEST_CASES.append([{"repeats": 2}, q([[1, 2], [1, 2], [3, 4], [3, 4]]), (2, 2)]) class TestRemoveRepeatedChannel(unittest.TestCase): diff --git a/tests/test_resample_to_match.py b/tests/test_resample_to_match.py index b65a1ea319..f1d58e6379 100644 --- a/tests/test_resample_to_match.py +++ b/tests/test_resample_to_match.py @@ -9,9 +9,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import copy import itertools import os +import random +import shutil +import string import tempfile import unittest @@ -27,36 +29,62 @@ TEST_CASES = ["itkreader", "nibabelreader"] +def get_rand_fname(len=10, suffix=".nii.gz"): + letters = string.ascii_letters + out = "".join(random.choice(letters) for _ in range(len)) + out += suffix + return out + + class TestResampleToMatch(unittest.TestCase): - def setUp(self): - self.fnames = [] + @classmethod + def setUpClass(cls): + super(__class__, cls).setUpClass() + cls.fnames = [] + cls.tmpdir = tempfile.mkdtemp() for key in ("0000_t2_tse_tra_4", "0000_ep2d_diff_tra_7"): - fname = os.path.join(os.path.dirname(__file__), "testing_data", f"test_{key}.nii.gz") + fname = os.path.join(cls.tmpdir, f"test_{key}.nii.gz") url = testing_data_config("images", key, "url") hash_type = testing_data_config("images", key, "hash_type") hash_val = testing_data_config("images", key, "hash_val") download_url_or_skip_test(url=url, filepath=fname, hash_type=hash_type, hash_val=hash_val) - self.fnames.append(fname) + cls.fnames.append(fname) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdir) + super(__class__, cls).tearDownClass() @parameterized.expand(itertools.product([NibabelReader, ITKReader], ["monai.data.NibabelWriter", ITKWriter])) def test_correct(self, reader, writer): - with tempfile.TemporaryDirectory() as temp_dir: - loader = Compose([LoadImaged(("im1", "im2"), reader=reader), EnsureChannelFirstd(("im1", "im2"))]) - data = loader({"im1": self.fnames[0], "im2": self.fnames[1]}) - - with self.assertRaises(ValueError): - ResampleToMatch(mode=None)(data["im2"], data["im2_meta_dict"], data["im1_meta_dict"]) - im_mod, meta = ResampleToMatch()(data["im2"], data["im2_meta_dict"], data["im1_meta_dict"]) - current_dims = copy.deepcopy(meta.get("dim")) - saver = SaveImaged("im3", output_dir=temp_dir, output_postfix="", separate_folder=False, writer=writer) - meta["filename_or_obj"] = "file3.nii.gz" - saver({"im3": im_mod, "im3_meta_dict": meta}) - - saved = nib.load(os.path.join(temp_dir, meta["filename_or_obj"])) - assert_allclose(data["im1"].shape[1:], saved.shape) - assert_allclose(saved.header["dim"][:4], np.array([3, 384, 384, 19])) - if current_dims is not None: - assert_allclose(saved.header["dim"], current_dims) + loader = Compose([LoadImaged(("im1", "im2"), reader=reader), EnsureChannelFirstd(("im1", "im2"))]) + data = loader({"im1": self.fnames[0], "im2": self.fnames[1]}) + + with self.assertRaises(ValueError): + ResampleToMatch(mode=None)(img=data["im2"], img_dst=data["im1"]) + im_mod = ResampleToMatch()(data["im2"], data["im1"]) + saver = SaveImaged( + "im3", output_dir=self.tmpdir, output_postfix="", separate_folder=False, writer=writer, resample=False + ) + im_mod.meta["filename_or_obj"] = get_rand_fname() + saver({"im3": im_mod}) + + saved = nib.load(os.path.join(self.tmpdir, im_mod.meta["filename_or_obj"])) + assert_allclose(data["im1"].shape[1:], saved.shape) + assert_allclose(saved.header["dim"][:4], np.array([3, 384, 384, 19])) + + def test_inverse(self): + loader = Compose([LoadImaged(("im1", "im2")), EnsureChannelFirstd(("im1", "im2"))]) + data = loader({"im1": self.fnames[0], "im2": self.fnames[1]}) + tr = ResampleToMatch() + im_mod = tr(data["im2"], data["im1"]) + self.assertNotEqual(im_mod.shape, data["im2"].shape) + self.assertGreater(((im_mod.affine - data["im2"].affine) ** 2).sum() ** 0.5, 1e-2) + # inverse + im_mod2 = tr.inverse(im_mod) + self.assertEqual(im_mod2.shape, data["im2"].shape) + self.assertLess(((im_mod2.affine - data["im2"].affine) ** 2).sum() ** 0.5, 1e-2) + self.assertEqual(im_mod2.applied_operations, []) if __name__ == "__main__": diff --git a/tests/test_resample_to_matchd.py b/tests/test_resample_to_matchd.py index d9dbeee133..566ef4ada9 100644 --- a/tests/test_resample_to_matchd.py +++ b/tests/test_resample_to_matchd.py @@ -10,6 +10,7 @@ # limitations under the License. import os +import shutil import tempfile import unittest @@ -27,52 +28,51 @@ def update_fname(d): - d["im3_meta_dict"]["filename_or_obj"] = "file3.nii.gz" + d["im3"].meta["filename_or_obj"] = "file3.nii.gz" return d class TestResampleToMatchd(unittest.TestCase): - def setUp(self): - self.fnames = [] + @classmethod + def setUpClass(cls): + super(__class__, cls).setUpClass() + cls.fnames = [] + cls.tmpdir = tempfile.mkdtemp() for key in ("0000_t2_tse_tra_4", "0000_ep2d_diff_tra_7"): - fname = os.path.join(os.path.dirname(__file__), "testing_data", f"test_{key}.nii.gz") + fname = os.path.join(cls.tmpdir, f"test_{key}.nii.gz") url = testing_data_config("images", key, "url") hash_type = testing_data_config("images", key, "hash_type") hash_val = testing_data_config("images", key, "hash_val") download_url_or_skip_test(url=url, filepath=fname, hash_type=hash_type, hash_val=hash_val) - self.fnames.append(fname) + cls.fnames.append(fname) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdir) + super(__class__, cls).tearDownClass() def test_correct(self): - with tempfile.TemporaryDirectory() as temp_dir: - transforms = Compose( - [ - LoadImaged(("im1", "im2")), - EnsureChannelFirstd(("im1", "im2")), - CopyItemsd(("im2", "im2_meta_dict"), names=("im3", "im3_meta_dict")), - ResampleToMatchd("im3", "im1_meta_dict"), - Lambda(update_fname), - SaveImaged("im3", output_dir=temp_dir, output_postfix="", separate_folder=False), - ] - ) - data = transforms({"im1": self.fnames[0], "im2": self.fnames[1]}) - # check that output sizes match - assert_allclose(data["im1"].shape, data["im3"].shape) - # and that the meta data has been updated accordingly - assert_allclose(data["im3"].shape[1:], data["im3_meta_dict"]["spatial_shape"], type_test=False) - assert_allclose(data["im3_meta_dict"]["affine"], data["im1_meta_dict"]["affine"]) - # check we're different from the original - self.assertTrue(any(i != j for i, j in zip(data["im3"].shape, data["im2"].shape))) - self.assertTrue( - any( - i != j - for i, j in zip( - data["im3_meta_dict"]["affine"].flatten(), data["im2_meta_dict"]["affine"].flatten() - ) - ) - ) - # test the inverse - data = Invertd("im3", transforms, "im3")(data) - assert_allclose(data["im2"].shape, data["im3"].shape) + transforms = Compose( + [ + LoadImaged(("im1", "im2")), + EnsureChannelFirstd(("im1", "im2")), + CopyItemsd(("im2"), names=("im3")), + ResampleToMatchd("im3", "im1"), + Lambda(update_fname), + SaveImaged("im3", output_dir=self.tmpdir, output_postfix="", separate_folder=False, resample=False), + ] + ) + data = transforms({"im1": self.fnames[0], "im2": self.fnames[1]}) + # check that output sizes match + assert_allclose(data["im1"].shape, data["im3"].shape) + # and that the meta data has been updated accordingly + assert_allclose(data["im3"].affine, data["im1"].affine) + # check we're different from the original + self.assertTrue(any(i != j for i, j in zip(data["im3"].shape, data["im2"].shape))) + self.assertTrue(any(i != j for i, j in zip(data["im3"].affine.flatten(), data["im2"].affine.flatten()))) + # test the inverse + data = Invertd("im3", transforms)(data) + assert_allclose(data["im2"].shape, data["im3"].shape) if __name__ == "__main__": diff --git a/tests/test_resampler.py b/tests/test_resampler.py index 7dfb86a7a9..5c8ef24c0e 100644 --- a/tests/test_resampler.py +++ b/tests/test_resampler.py @@ -17,11 +17,11 @@ from monai.transforms import Resample from monai.transforms.utils import create_grid -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: - for q in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: + for q in TEST_NDARRAYS_ALL: for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]: TESTS.append( [ @@ -156,7 +156,7 @@ def test_resample(self, input_param, input_data, expected_val): result = g(**input_data) if "device" in input_data: self.assertEqual(result.device, input_data["device"]) - assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4, type_test=False) if __name__ == "__main__": diff --git a/tests/test_resize.py b/tests/test_resize.py index 5f946a13e3..8927b5dba5 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -16,8 +16,9 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import Resize -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose, is_tf32_env, pytorch_after +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, is_tf32_env, pytorch_after TEST_CASE_0 = [{"spatial_size": 15}, (6, 10, 15)] @@ -60,6 +61,7 @@ def test_correct_results(self, spatial_size, mode, anti_aliasing): _order = 1 if spatial_size == (32, -1): spatial_size = (32, 64) + expected = [ skimage.transform.resize( channel, spatial_size, order=_order, clip=False, preserve_range=False, anti_aliasing=anti_aliasing @@ -68,20 +70,28 @@ def test_correct_results(self, spatial_size, mode, anti_aliasing): ] expected = np.stack(expected).astype(np.float32) - for p in TEST_NDARRAYS: - out = resize(p(self.imt[0])) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + out = resize(im) + if isinstance(im, MetaTensor): + if not out.applied_operations: + return # skipped because good shape + im_inv = resize.inverse(out) + self.assertTrue(not im_inv.applied_operations) + assert_allclose(im_inv.shape, im.shape) + assert_allclose(im_inv.affine, im.affine, atol=1e-3, rtol=1e-3) if not anti_aliasing: assert_allclose(out, expected, type_test=False, atol=0.9) - else: - # skimage uses reflect padding for anti-aliasing filter. - # Our implementation reuses GaussianSmooth() as anti-aliasing filter, which uses zero padding instead. - # Thus their results near the image boundary will be different. - if isinstance(out, torch.Tensor): - out = out.cpu().detach().numpy() - good = np.sum(np.isclose(expected, out, atol=0.9)) - self.assertLessEqual( - np.abs(good - expected.size) / float(expected.size), diff_t, f"at most {diff_t} percent mismatch " - ) + return + # skimage uses reflect padding for anti-aliasing filter. + # Our implementation reuses GaussianSmooth() as anti-aliasing filter, which uses zero padding instead. + # Thus their results near the image boundary will be different. + if isinstance(out, torch.Tensor): + out = out.cpu().detach().numpy() + good = np.sum(np.isclose(expected, out, atol=0.9)) + self.assertLessEqual( + np.abs(good - expected.size) / float(expected.size), diff_t, f"at most {diff_t} percent mismatch " + ) @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_longest_shape(self, input_param, expected_shape): @@ -90,6 +100,12 @@ def test_longest_shape(self, input_param, expected_shape): result = Resize(**input_param)(input_data) np.testing.assert_allclose(result.shape[1:], expected_shape) + set_track_meta(False) + result = Resize(**input_param)(input_data) + self.assertNotIsInstance(result, MetaTensor) + np.testing.assert_allclose(result.shape[1:], expected_shape) + set_track_meta(True) + def test_longest_infinite_decimals(self): resize = Resize(spatial_size=1008, size_mode="longest", mode="bilinear", align_corners=False) ret = resize(np.random.randint(0, 2, size=[1, 2544, 3032])) diff --git a/tests/test_resized.py b/tests/test_resized.py index d7374ea930..b8db666357 100644 --- a/tests/test_resized.py +++ b/tests/test_resized.py @@ -15,8 +15,9 @@ import skimage.transform from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import Resized -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion TEST_CASE_0 = [{"keys": "img", "spatial_size": 15}, (6, 10, 15)] @@ -56,9 +57,11 @@ def test_correct_results(self, spatial_size, mode): ] expected = np.stack(expected).astype(np.float32) - for p in TEST_NDARRAYS: - out = resize({"img": p(self.imt[0])})["img"] - assert_allclose(out, expected, type_test=False, atol=0.9) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + out = resize({"img": im}) + test_local_inversion(resize, out, {"img": im}, "img") + assert_allclose(out["img"], expected, type_test=False, atol=0.9) @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_longest_shape(self, input_param, expected_shape): @@ -71,6 +74,11 @@ def test_longest_shape(self, input_param, expected_shape): result = rescaler(input_data) for k in rescaler.keys: np.testing.assert_allclose(result[k].shape[1:], expected_shape) + set_track_meta(False) + result = Resized(**input_param)(input_data) + self.assertNotIsInstance(result["img"], MetaTensor) + np.testing.assert_allclose(result["img"].shape[1:], expected_shape) + set_track_meta(True) if __name__ == "__main__": diff --git a/tests/test_rotate.py b/tests/test_rotate.py index 01842f6d73..d039738b21 100644 --- a/tests/test_rotate.py +++ b/tests/test_rotate.py @@ -17,11 +17,12 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor, set_track_meta from monai.transforms import Rotate -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D, test_local_inversion TEST_CASES_2D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_2D.append((p, np.pi / 6, False, "bilinear", "border", False)) TEST_CASES_2D.append((p, np.pi / 4, True, "bilinear", "border", False)) TEST_CASES_2D.append((p, -np.pi / 4.5, True, "nearest", "reflection", False)) @@ -29,7 +30,7 @@ TEST_CASES_2D.append((p, -np.pi / 2, False, "bilinear", "zeros", True)) TEST_CASES_3D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_3D.append((p, -np.pi / 2, True, "nearest", "border", False)) TEST_CASES_3D.append((p, np.pi / 4, True, "bilinear", "border", False)) TEST_CASES_3D.append((p, -np.pi / 4.5, True, "nearest", "reflection", False)) @@ -37,7 +38,7 @@ TEST_CASES_3D.append((p, -np.pi / 2, False, "bilinear", "zeros", False)) TEST_CASES_SHAPE_3D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_SHAPE_3D.append((p, [-np.pi / 2, 1.0, 2.0], "nearest", "border", False)) TEST_CASES_SHAPE_3D.append((p, [np.pi / 4, 0, 0], "bilinear", "border", False)) TEST_CASES_SHAPE_3D.append((p, [-np.pi / 4.5, -20, 20], "nearest", "reflection", False)) @@ -101,11 +102,18 @@ def test_correct_results(self, im_type, angle, keep_size, mode, padding_mode, al @parameterized.expand(TEST_CASES_SHAPE_3D) def test_correct_shape(self, im_type, angle, mode, padding_mode, align_corners): rotate_fn = Rotate(angle, True, align_corners=align_corners, dtype=np.float64) - rotated = rotate_fn(im_type(self.imt[0]), mode=mode, padding_mode=padding_mode) + im = im_type(self.imt[0]) + set_track_meta(False) + rotated = rotate_fn(im, mode=mode, padding_mode=padding_mode) + self.assertNotIsInstance(rotated, MetaTensor) np.testing.assert_allclose(self.imt[0].shape, rotated.shape) + set_track_meta(True) + rotated = rotate_fn(im, mode=mode, padding_mode=padding_mode) + np.testing.assert_allclose(self.imt[0].shape, rotated.shape) + test_local_inversion(rotate_fn, rotated, im) def test_ill_case(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: rotate_fn = Rotate(10, True) with self.assertRaises(ValueError): # wrong shape rotate_fn(p(self.imt)) diff --git a/tests/test_rotate90.py b/tests/test_rotate90.py index 9865120688..69414430c2 100644 --- a/tests/test_rotate90.py +++ b/tests/test_rotate90.py @@ -13,42 +13,105 @@ import numpy as np +from monai.data import MetaTensor, set_track_meta from monai.transforms import Rotate90 -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import ( + TEST_NDARRAYS_ALL, + NumpyImageTestCase2D, + NumpyImageTestCase3D, + assert_allclose, + test_local_inversion, +) class TestRotate90(NumpyImageTestCase2D): def test_rotate90_default(self): rotate = Rotate90() - for p in TEST_NDARRAYS: - rotated = rotate(p(self.imt[0])) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + set_track_meta(True) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 1, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") + set_track_meta(False) + rotated = rotate(im) + self.assertNotIsInstance(rotated, MetaTensor) + set_track_meta(True) def test_k(self): rotate = Rotate90(k=2) - for p in TEST_NDARRAYS: - rotated = rotate(p(self.imt[0])) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 2, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") def test_spatial_axes(self): rotate = Rotate90(spatial_axes=(0, -1)) - for p in TEST_NDARRAYS: - rotated = rotate(p(self.imt[0])) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 1, (0, -1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") def test_prob_k_spatial_axes(self): rotate = Rotate90(k=2, spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: - rotated = rotate(p(self.imt[0])) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) + expected = [np.rot90(channel, 2, (0, 1)) for channel in self.imt[0]] + expected = np.stack(expected) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") + + +class TestRotate903d(NumpyImageTestCase3D): + def test_rotate90_default(self): + rotate = Rotate90() + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) + expected = [np.rot90(channel, 1, (0, 1)) for channel in self.imt[0]] + expected = np.stack(expected) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") + + def test_k(self): + rotate = Rotate90(k=2) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) + expected = [np.rot90(channel, 2, (0, 1)) for channel in self.imt[0]] + expected = np.stack(expected) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") + + def test_spatial_axes(self): + rotate = Rotate90(spatial_axes=(0, -1)) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) + expected = [np.rot90(channel, 1, (0, -1)) for channel in self.imt[0]] + expected = np.stack(expected) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") + + def test_prob_k_spatial_axes(self): + rotate = Rotate90(k=2, spatial_axes=(0, 1)) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate(im) + test_local_inversion(rotate, rotated, im) expected = [np.rot90(channel, 2, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8) + assert_allclose(rotated, p(expected), rtol=1.0e-5, atol=1.0e-8, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rotate90d.py b/tests/test_rotate90d.py index ef4bad9419..f88e8937e8 100644 --- a/tests/test_rotate90d.py +++ b/tests/test_rotate90d.py @@ -13,46 +13,60 @@ import numpy as np +from monai.data import MetaTensor, set_track_meta from monai.transforms import Rotate90d -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion class TestRotate90d(NumpyImageTestCase2D): def test_rotate90_default(self): key = "test" rotate = Rotate90d(keys=key) - for p in TEST_NDARRAYS: - rotated = rotate({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + set_track_meta(True) + rotated = rotate({key: im}) + test_local_inversion(rotate, rotated, {key: im}, key) expected = [np.rot90(channel, 1, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") + set_track_meta(False) + rotated = rotate({key: im}) + self.assertNotIsInstance(rotated[key], MetaTensor) + set_track_meta(True) def test_k(self): key = None rotate = Rotate90d(keys=key, k=2) - for p in TEST_NDARRAYS: - rotated = rotate({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate({key: im}) + test_local_inversion(rotate, rotated, {key: im}, key) expected = [np.rot90(channel, 2, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") def test_spatial_axes(self): key = "test" rotate = Rotate90d(keys=key, spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: - rotated = rotate({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate({key: im}) + test_local_inversion(rotate, rotated, {key: im}, key) expected = [np.rot90(channel, 1, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") def test_prob_k_spatial_axes(self): key = "test" rotate = Rotate90d(keys=key, k=2, spatial_axes=(0, 1)) - for p in TEST_NDARRAYS: - rotated = rotate({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + rotated = rotate({key: im}) + test_local_inversion(rotate, rotated, {key: im}, key) expected = [np.rot90(channel, 2, (0, 1)) for channel in self.imt[0]] expected = np.stack(expected) - assert_allclose(rotated[key], p(expected)) + assert_allclose(rotated[key], p(expected), type_test="tensor") def test_no_key(self): key = "unknown" diff --git a/tests/test_rotated.py b/tests/test_rotated.py index 43b5a68f61..48b2e8a3c7 100644 --- a/tests/test_rotated.py +++ b/tests/test_rotated.py @@ -17,11 +17,12 @@ import torch from parameterized import parameterized +from monai.data import MetaTensor from monai.transforms import Rotated -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, NumpyImageTestCase3D +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, NumpyImageTestCase3D, test_local_inversion TEST_CASES_2D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_2D.append((p, -np.pi / 6, False, "bilinear", "border", False)) TEST_CASES_2D.append((p, -np.pi / 4, True, "bilinear", "border", False)) TEST_CASES_2D.append((p, np.pi / 4.5, True, "nearest", "reflection", False)) @@ -29,7 +30,7 @@ TEST_CASES_2D.append((p, np.pi / 2, False, "bilinear", "zeros", True)) TEST_CASES_3D: List[Tuple] = [] -for p in TEST_NDARRAYS: +for p in TEST_NDARRAYS_ALL: TEST_CASES_3D.append((p, -np.pi / 6, False, "bilinear", "border", False)) TEST_CASES_3D.append((p, -np.pi / 4, True, "bilinear", "border", False)) TEST_CASES_3D.append((p, np.pi / 4.5, True, "nearest", "reflection", False)) @@ -43,7 +44,8 @@ def test_correct_results(self, im_type, angle, keep_size, mode, padding_mode, al rotate_fn = Rotated( ("img", "seg"), angle, keep_size, (mode, "nearest"), padding_mode, align_corners, dtype=np.float64 ) - rotated = rotate_fn({"img": im_type(self.imt[0]), "seg": im_type(self.segn[0])}) + im = im_type(self.imt[0]) + rotated = rotate_fn({"img": im, "seg": im_type(self.segn[0])}) if keep_size: np.testing.assert_allclose(self.imt[0].shape, rotated["img"].shape) _order = 0 if mode == "nearest" else 1 @@ -60,11 +62,14 @@ def test_correct_results(self, im_type, angle, keep_size, mode, padding_mode, al rotated[k] = v.cpu() if isinstance(v, torch.Tensor) else v good = np.sum(np.isclose(expected, rotated["img"][0], atol=1e-3)) self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 pixels") + test_local_inversion(rotate_fn, rotated, {"img": im}, "img") expected = scipy.ndimage.rotate( self.segn[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=0, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(int) + if isinstance(rotated["seg"], MetaTensor): + rotated["seg"] = rotated["seg"].as_tensor() # pytorch 1.7 compatible self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 30) @@ -96,6 +101,8 @@ def test_correct_results(self, im_type, angle, keep_size, mode, padding_mode, al self.segn[0, 0], np.rad2deg(angle), (0, 2), not keep_size, order=0, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(int) + if isinstance(rotated["seg"], MetaTensor): + rotated["seg"] = rotated["seg"].as_tensor() # pytorch 1.7 compatible self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 160) @@ -127,6 +134,8 @@ def test_correct_results(self, im_type, angle, keep_size, mode, padding_mode, al self.segn[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=0, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(int) + if isinstance(rotated["seg"], MetaTensor): + rotated["seg"] = rotated["seg"].as_tensor() # pytorch 1.7 compatible self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 160) diff --git a/tests/test_save_image.py b/tests/test_save_image.py index a1297c1e61..6591283c22 100644 --- a/tests/test_save_image.py +++ b/tests/test_save_image.py @@ -13,10 +13,10 @@ import tempfile import unittest -import numpy as np import torch from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms import SaveImage TEST_CASE_1 = [torch.randint(0, 255, (1, 2, 3, 4)), {"filename_or_obj": "testfile0.nii.gz"}, ".nii.gz", False] @@ -26,7 +26,7 @@ TEST_CASE_3 = [torch.randint(0, 255, (1, 2, 3, 4)), {"filename_or_obj": "testfile0.nrrd"}, ".nrrd", False] TEST_CASE_4 = [ - np.random.randint(0, 255, (3, 2, 4, 5), dtype=np.uint8), + torch.randint(0, 255, (3, 2, 4, 5), dtype=torch.uint8), {"filename_or_obj": "testfile0.dcm"}, ".dcm", False, @@ -36,6 +36,9 @@ class TestSaveImage(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_saved_content(self, test_data, meta_data, output_ext, resample): + if meta_data is not None: + test_data = MetaTensor(test_data, meta=meta_data) + with tempfile.TemporaryDirectory() as tempdir: trans = SaveImage( output_dir=tempdir, @@ -43,7 +46,7 @@ def test_saved_content(self, test_data, meta_data, output_ext, resample): resample=resample, separate_folder=False, # test saving into the same folder ) - trans(test_data, meta_data) + trans(test_data) filepath = "testfile0" if meta_data is not None else "0" self.assertTrue(os.path.exists(os.path.join(tempdir, filepath + "_trans" + output_ext))) diff --git a/tests/test_save_imaged.py b/tests/test_save_imaged.py index a6988683e5..96b6fb1626 100644 --- a/tests/test_save_imaged.py +++ b/tests/test_save_imaged.py @@ -16,19 +16,18 @@ import torch from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms import SaveImaged -from monai.utils.enums import PostFix TEST_CASE_1 = [ - {"img": torch.randint(0, 255, (1, 2, 3, 4)), PostFix.meta("img"): {"filename_or_obj": "testfile0.nii.gz"}}, + {"img": MetaTensor(torch.randint(0, 255, (1, 2, 3, 4)), meta={"filename_or_obj": "testfile0.nii.gz"})}, ".nii.gz", False, ] TEST_CASE_2 = [ { - "img": torch.randint(0, 255, (1, 2, 3, 4)), - PostFix.meta("img"): {"filename_or_obj": "testfile0.nii.gz"}, + "img": MetaTensor(torch.randint(0, 255, (1, 2, 3, 4)), meta={"filename_or_obj": "testfile0.nii.gz"}), "patch_index": 6, }, ".nii.gz", @@ -37,8 +36,7 @@ TEST_CASE_3 = [ { - "img": torch.randint(0, 255, (1, 2, 3, 4)), - PostFix.meta("img"): {"filename_or_obj": "testfile0.nrrd"}, + "img": MetaTensor(torch.randint(0, 255, (1, 2, 3, 4)), meta={"filename_or_obj": "testfile0.nrrd"}), "patch_index": 6, }, ".nrrd", @@ -52,7 +50,6 @@ def test_saved_content(self, test_data, output_ext, resample): with tempfile.TemporaryDirectory() as tempdir: trans = SaveImaged( keys=["img", "pred"], - meta_keys=PostFix.meta("img"), output_dir=tempdir, output_ext=output_ext, resample=resample, @@ -60,7 +57,7 @@ def test_saved_content(self, test_data, output_ext, resample): ) trans(test_data) - patch_index = test_data[PostFix.meta("img")].get("patch_index", None) + patch_index = test_data["img"].meta.get("patch_index", None) patch_index = f"_{patch_index}" if patch_index is not None else "" filepath = os.path.join("testfile0", "testfile0" + "_trans" + patch_index + output_ext) self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) diff --git a/tests/test_savitzky_golay_smooth.py b/tests/test_savitzky_golay_smooth.py index ac42cf806e..b296372986 100644 --- a/tests/test_savitzky_golay_smooth.py +++ b/tests/test_savitzky_golay_smooth.py @@ -12,11 +12,10 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import SavitzkyGolaySmooth -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_NDARRAYS, assert_allclose # Zero-padding trivial tests @@ -65,7 +64,7 @@ class TestSavitzkyGolaySmooth(unittest.TestCase): def test_value(self, arguments, image, expected_data, atol): for p in TEST_NDARRAYS: result = SavitzkyGolaySmooth(**arguments)(p(image.astype(np.float32))) - torch.testing.assert_allclose(result, p(expected_data.astype(np.float32)), rtol=1e-4, atol=atol) + assert_allclose(result, p(expected_data.astype(np.float32)), rtol=1e-4, atol=atol, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_scale_intensity.py b/tests/test_scale_intensity.py index bd1adac4f4..9941172b0e 100644 --- a/tests/test_scale_intensity.py +++ b/tests/test_scale_intensity.py @@ -12,6 +12,7 @@ import unittest import numpy as np +from parameterized import parameterized from monai.transforms import ScaleIntensity from monai.transforms.utils import rescale_array @@ -19,29 +20,30 @@ class TestScaleIntensity(NumpyImageTestCase2D): - def test_range_scale(self): - for p in TEST_NDARRAYS: - scaler = ScaleIntensity(minv=1.0, maxv=2.0) - result = scaler(p(self.imt)) - mina = self.imt.min() - maxa = self.imt.max() - norm = (self.imt - mina) / (maxa - mina) - expected = p((norm * (2.0 - 1.0)) + 1.0) - assert_allclose(result, expected, type_test=False, rtol=1e-7, atol=0) + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_range_scale(self, p): + scaler = ScaleIntensity(minv=1.0, maxv=2.0) + im = p(self.imt) + result = scaler(im) + mina = self.imt.min() + maxa = self.imt.max() + norm = (self.imt - mina) / (maxa - mina) + expected = p((norm * (2.0 - 1.0)) + 1.0) + assert_allclose(result, expected, type_test="tensor", rtol=1e-7, atol=0) def test_factor_scale(self): for p in TEST_NDARRAYS: scaler = ScaleIntensity(minv=None, maxv=None, factor=0.1) result = scaler(p(self.imt)) expected = p((self.imt * (1 + 0.1)).astype(np.float32)) - assert_allclose(result, p(expected), rtol=1e-7, atol=0) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) def test_max_none(self): for p in TEST_NDARRAYS: scaler = ScaleIntensity(minv=0.0, maxv=None, factor=0.1) result = scaler(p(self.imt)) expected = rescale_array(p(self.imt), minv=0.0, maxv=None) - assert_allclose(result, expected, rtol=1e-3, atol=1e-3) + assert_allclose(result, expected, type_test="tensor", rtol=1e-3, atol=1e-3) def test_int(self): """integers should be handled by converting them to floats first.""" @@ -53,7 +55,7 @@ def test_int(self): maxa = _imt.max() norm = (_imt - mina) / (maxa - mina) expected = p((norm * (2.0 - 1.0)) + 1.0) - assert_allclose(result, expected, type_test=False, rtol=1e-7, atol=0) + assert_allclose(result, expected, type_test="tensor", rtol=1e-7, atol=0) def test_channel_wise(self): for p in TEST_NDARRAYS: @@ -65,7 +67,7 @@ def test_channel_wise(self): for i, c in enumerate(data): norm = (c - mina) / (maxa - mina) expected = p((norm * (2.0 - 1.0)) + 1.0) - assert_allclose(result[i], expected, type_test=False, rtol=1e-7, atol=0) + assert_allclose(result[i], expected, type_test="tensor", rtol=1e-7, atol=0) if __name__ == "__main__": diff --git a/tests/test_scale_intensity_range.py b/tests/test_scale_intensity_range.py index faddf9001b..958881f790 100644 --- a/tests/test_scale_intensity_range.py +++ b/tests/test_scale_intensity_range.py @@ -24,7 +24,7 @@ def test_image_scale_intensity_range(self): scaled = scaler(p(self.imt)) self.assertTrue(scaled.dtype, np.uint8) expected = (((self.imt - 20) / 88) * 30 + 50).astype(np.uint8) - assert_allclose(scaled, p(expected)) + assert_allclose(scaled, p(expected), type_test="tensor") def test_image_scale_intensity_range_none_clip(self): scaler = ScaleIntensityRange(a_min=20, a_max=108, b_min=None, b_max=80, clip=True, dtype=np.uint8) @@ -32,7 +32,7 @@ def test_image_scale_intensity_range_none_clip(self): scaled = scaler(p(self.imt)) self.assertTrue(scaled.dtype, np.uint8) expected = (np.clip((self.imt - 20) / 88, None, 80)).astype(np.uint8) - assert_allclose(scaled, p(expected)) + assert_allclose(scaled, p(expected), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_scale_intensity_range_percentiles.py b/tests/test_scale_intensity_range_percentiles.py index f8656dd929..184e1dff0c 100644 --- a/tests/test_scale_intensity_range_percentiles.py +++ b/tests/test_scale_intensity_range_percentiles.py @@ -31,7 +31,7 @@ def test_scaling(self): scaler = ScaleIntensityRangePercentiles(lower=lower, upper=upper, b_min=b_min, b_max=b_max, dtype=np.uint8) for p in TEST_NDARRAYS: result = scaler(p(img)) - assert_allclose(result, p(expected), rtol=1e-4) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-4) def test_relative_scaling(self): img = self.imt[0] @@ -50,14 +50,16 @@ def test_relative_scaling(self): for p in TEST_NDARRAYS: result = scaler(p(img)) - assert_allclose(result, p(expected_img), rtol=1e-3) + assert_allclose(result, p(expected_img), type_test="tensor", rtol=1e-3) scaler = ScaleIntensityRangePercentiles( lower=lower, upper=upper, b_min=b_min, b_max=b_max, relative=True, clip=True ) for p in TEST_NDARRAYS: result = scaler(p(img)) - assert_allclose(result, p(np.clip(expected_img, expected_b_min, expected_b_max)), rtol=1e-4) + assert_allclose( + result, p(np.clip(expected_img, expected_b_min, expected_b_max)), type_test="tensor", rtol=1e-4 + ) def test_invalid_instantiation(self): self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=-10, upper=99, b_min=0, b_max=255) @@ -83,7 +85,7 @@ def test_channel_wise(self): for p in TEST_NDARRAYS: result = scaler(p(img)) - assert_allclose(result, p(expected), rtol=1e-4) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-4) if __name__ == "__main__": diff --git a/tests/test_scale_intensity_range_percentilesd.py b/tests/test_scale_intensity_range_percentilesd.py index edb421cec3..50438532d1 100644 --- a/tests/test_scale_intensity_range_percentilesd.py +++ b/tests/test_scale_intensity_range_percentilesd.py @@ -34,7 +34,7 @@ def test_scaling(self): scaler = ScaleIntensityRangePercentilesd( keys=data.keys(), lower=lower, upper=upper, b_min=b_min, b_max=b_max, dtype=np.uint8 ) - assert_allclose(p(expected), scaler(data)["img"], rtol=1e-4) + assert_allclose(scaler(data)["img"], p(expected), type_test="tensor", rtol=1e-4) def test_relative_scaling(self): img = self.imt @@ -91,7 +91,7 @@ def test_channel_wise(self): for p in TEST_NDARRAYS: data = {"img": p(img)} - assert_allclose(scaler(data)["img"], p(expected), rtol=1e-4) + assert_allclose(scaler(data)["img"], p(expected), type_test="tensor", rtol=1e-4) if __name__ == "__main__": diff --git a/tests/test_scale_intensity_ranged.py b/tests/test_scale_intensity_ranged.py index ffbd3e44c4..4ac4910e37 100644 --- a/tests/test_scale_intensity_ranged.py +++ b/tests/test_scale_intensity_ranged.py @@ -23,7 +23,7 @@ def test_image_scale_intensity_ranged(self): scaled = scaler({key: p(self.imt)}) expected = (self.imt - 20) / 88 expected = expected * 30 + 50 - assert_allclose(scaled[key], p(expected)) + assert_allclose(scaled[key], p(expected), type_test="tensor") def test_image_scale_intensity_ranged_none(self): key = "img" @@ -31,7 +31,7 @@ def test_image_scale_intensity_ranged_none(self): for p in TEST_NDARRAYS: scaled = scaler({key: p(self.imt)}) expected = (self.imt - 20) / 88 - assert_allclose(scaled[key], p(expected)) + assert_allclose(scaled[key], p(expected), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_scale_intensityd.py b/tests/test_scale_intensityd.py index 42f1527490..d560523214 100644 --- a/tests/test_scale_intensityd.py +++ b/tests/test_scale_intensityd.py @@ -27,7 +27,7 @@ def test_range_scale(self): maxa = np.max(self.imt) norm = (self.imt - mina) / (maxa - mina) expected = (norm * (2.0 - 1.0)) + 1.0 - assert_allclose(result[key], p(expected)) + assert_allclose(result[key], p(expected), type_test="tensor") def test_factor_scale(self): key = "img" @@ -35,7 +35,7 @@ def test_factor_scale(self): scaler = ScaleIntensityd(keys=[key], minv=None, maxv=None, factor=0.1) result = scaler({key: p(self.imt)}) expected = (self.imt * (1 + 0.1)).astype(np.float32) - assert_allclose(result[key], p(expected)) + assert_allclose(result[key], p(expected), type_test="tensor") def test_channel_wise(self): key = "img" @@ -48,7 +48,7 @@ def test_channel_wise(self): for i, c in enumerate(data): norm = (c - mina) / (maxa - mina) expected = p((norm * (2.0 - 1.0)) + 1.0) - assert_allclose(result[key][i], expected, type_test=False, rtol=1e-7, atol=0) + assert_allclose(result[key][i], expected, type_test="tensor", rtol=1e-7, atol=0) if __name__ == "__main__": diff --git a/tests/test_shift_intensityd.py b/tests/test_shift_intensityd.py index e28b7f54e4..b5a2a3218d 100644 --- a/tests/test_shift_intensityd.py +++ b/tests/test_shift_intensityd.py @@ -25,7 +25,7 @@ def test_value(self): shifter = ShiftIntensityd(keys=[key], offset=1.0) result = shifter({key: p(self.imt)}) expected = self.imt + 1.0 - assert_allclose(result[key], p(expected)) + assert_allclose(result[key], p(expected), type_test="tensor") def test_factor(self): key = "img" diff --git a/tests/test_sliding_window_inference.py b/tests/test_sliding_window_inference.py index e1fa7d600e..8b8ec47d32 100644 --- a/tests/test_sliding_window_inference.py +++ b/tests/test_sliding_window_inference.py @@ -15,9 +15,10 @@ import torch from parameterized import parameterized +from monai.data.utils import list_data_collate from monai.inferers import SlidingWindowInferer, sliding_window_inference from monai.utils import optional_import -from tests.utils import skip_if_no_cuda +from tests.utils import TEST_TORCH_AND_META_TENSORS, skip_if_no_cuda _, has_tqdm = optional_import("tqdm") @@ -68,9 +69,11 @@ def compute(data): np.testing.assert_string_equal(device.type, result.device.type) np.testing.assert_allclose(result.cpu().numpy(), expected_val) - def test_default_device(self): + @parameterized.expand([[x] for x in TEST_TORCH_AND_META_TENSORS]) + def test_default_device(self, data_type): device = "cuda" if torch.cuda.is_available() else "cpu:0" - inputs = torch.ones((1, 3, 16, 15, 7)).to(device=device) + inputs = data_type(torch.ones((3, 16, 15, 7))).to(device=device) + inputs = list_data_collate([inputs]) # make a proper batch roi_shape = (4, 10, 7) sw_batch_size = 10 @@ -82,9 +85,11 @@ def compute(data): expected_val = np.ones((1, 3, 16, 15, 7), dtype=np.float32) + 1 np.testing.assert_allclose(result.cpu().numpy(), expected_val) + @parameterized.expand([[x] for x in TEST_TORCH_AND_META_TENSORS]) @skip_if_no_cuda - def test_sw_device(self): - inputs = torch.ones((1, 3, 16, 15, 7)).to(device="cpu") + def test_sw_device(self, data_type): + inputs = data_type(torch.ones((3, 16, 15, 7))).to(device="cpu") + inputs = list_data_collate([inputs]) # make a proper batch roi_shape = (4, 10, 7) sw_batch_size = 10 diff --git a/tests/test_smartcachedataset.py b/tests/test_smartcachedataset.py index 6eca6113f0..9f9043d19e 100644 --- a/tests/test_smartcachedataset.py +++ b/tests/test_smartcachedataset.py @@ -17,10 +17,12 @@ import nibabel as nib import numpy as np +import torch from parameterized import parameterized from monai.data import DataLoader, SmartCacheDataset from monai.transforms import Compose, Lambda, LoadImaged +from tests.utils import assert_allclose TEST_CASE_1 = [0.1, 0, Compose([LoadImaged(keys=["image", "label", "extra"])])] @@ -77,8 +79,8 @@ def test_shape(self, replace_rate, num_replace_workers, transform): for _ in range(3): dataset.update_cache() self.assertIsNotNone(dataset[15]) - if isinstance(dataset[15]["image"], np.ndarray): - np.testing.assert_allclose(dataset[15]["image"], dataset[15]["label"]) + if isinstance(dataset[15]["image"], (np.ndarray, torch.Tensor)): + assert_allclose(dataset[15]["image"], dataset[15]["label"]) else: self.assertIsInstance(dataset[15]["image"], str) dataset.shutdown() diff --git a/tests/test_smooth_field.py b/tests/test_smooth_field.py index 5849b96167..c67865ba39 100644 --- a/tests/test_smooth_field.py +++ b/tests/test_smooth_field.py @@ -87,7 +87,7 @@ def test_rand_smooth_field_adjust_contrastd(self, input_param, input_data, expec res = g(input_data) for key, result in res.items(): expected = expected_val[key] - assert_allclose(result, expected, rtol=_rtol, atol=1e-1) + assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor") def test_rand_smooth_field_adjust_contrastd_pad(self): input_param, input_data, expected_val = TESTS_CONTRAST[0] @@ -98,7 +98,7 @@ def test_rand_smooth_field_adjust_contrastd_pad(self): res = g(input_data) for key, result in res.items(): expected = expected_val[key] - assert_allclose(result, expected, rtol=_rtol, atol=1e-1) + assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor") @parameterized.expand(TESTS_INTENSITY) def test_rand_smooth_field_adjust_intensityd(self, input_param, input_data, expected_val): @@ -108,7 +108,7 @@ def test_rand_smooth_field_adjust_intensityd(self, input_param, input_data, expe res = g(input_data) for key, result in res.items(): expected = expected_val[key] - assert_allclose(result, expected, rtol=_rtol, atol=1e-1) + assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor") def test_rand_smooth_field_adjust_intensityd_pad(self): input_param, input_data, expected_val = TESTS_INTENSITY[0] @@ -119,7 +119,7 @@ def test_rand_smooth_field_adjust_intensityd_pad(self): res = g(input_data) for key, result in res.items(): expected = expected_val[key] - assert_allclose(result, expected, rtol=_rtol, atol=1e-1) + assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor") @parameterized.expand(TESTS_DEFORM) def test_rand_smooth_deformd(self, input_param, input_data, expected_val): @@ -129,7 +129,7 @@ def test_rand_smooth_deformd(self, input_param, input_data, expected_val): res = g(input_data) for key, result in res.items(): expected = expected_val[key] - assert_allclose(result, expected, rtol=_rtol, atol=1e-1) + assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor") def test_rand_smooth_deformd_pad(self): input_param, input_data, expected_val = TESTS_DEFORM[0] @@ -140,4 +140,8 @@ def test_rand_smooth_deformd_pad(self): res = g(input_data) for key, result in res.items(): expected = expected_val[key] - assert_allclose(result, expected, rtol=_rtol, atol=1e-1) + assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_spacing.py b/tests/test_spacing.py index 80df981b73..6e56a21b63 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -15,127 +15,140 @@ import torch from parameterized import parameterized +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor from monai.data.utils import affine_to_spacing from monai.transforms import Spacing from monai.utils import ensure_tuple, fall_back_tuple -from tests.utils import TEST_NDARRAYS +from tests.utils import TEST_DEVICES, assert_allclose TESTS = [] -for p in TEST_NDARRAYS: +for device in TEST_DEVICES: TESTS.append( [ - p, {"pixdim": (1.0, 1.5), "padding_mode": "zeros", "dtype": float}, - np.arange(4).reshape((1, 2, 2)) + 1.0, # data - {"affine": np.eye(4)}, - np.array([[[1.0, 1.0], [3.0, 2.0]]]), + torch.arange(4).reshape((1, 2, 2)) + 1.0, # data + torch.eye(4), + {}, + torch.tensor([[[1.0, 1.0], [3.0, 2.0]]]), + *device, ] ) TESTS.append( [ - p, {"pixdim": 1.0, "padding_mode": "zeros", "dtype": float}, - np.ones((1, 2, 1, 2)), # data - {"affine": np.eye(4)}, - np.array([[[[1.0, 1.0]], [[1.0, 1.0]]]]), + torch.ones((1, 2, 1, 2)), # data + torch.eye(4), + {}, + torch.tensor([[[[1.0, 1.0]], [[1.0, 1.0]]]]), + *device, ] ) TESTS.append( [ - p, {"pixdim": (1.0, 1.0, 1.0), "padding_mode": "zeros", "dtype": float}, - np.ones((1, 2, 1, 2)), # data - {"affine": np.eye(4)}, - np.array([[[[1.0, 1.0]], [[1.0, 1.0]]]]), + torch.ones((1, 2, 1, 2)), # data + torch.eye(4), + {}, + torch.tensor([[[[1.0, 1.0]], [[1.0, 1.0]]]]), + *device, ] ) TESTS.append( [ - p, {"pixdim": (1.0, 0.2, 1.5), "diagonal": False, "padding_mode": "zeros", "align_corners": True}, - np.ones((1, 2, 1, 2)), # data - {"affine": np.array([[2, 1, 0, 4], [-1, -3, 0, 5], [0, 0, 2.0, 5], [0, 0, 0, 1]])}, - np.array([[[[0.95527864, 0.95527864]], [[1.0, 1.0]], [[1.0, 1.0]]]]), + torch.ones((1, 2, 1, 2)), # data + torch.tensor([[2, 1, 0, 4], [-1, -3, 0, 5], [0, 0, 2.0, 5], [0, 0, 0, 1]]), + {}, + torch.tensor([[[[0.95527864, 0.95527864]], [[1.0, 1.0]], [[1.0, 1.0]]]]), + *device, ] ) TESTS.append( [ - p, {"pixdim": (3.0, 1.0), "padding_mode": "zeros"}, - np.arange(24).reshape((2, 3, 4)), # data - {"affine": np.diag([-3.0, 0.2, 1.5, 1])}, - np.array([[[0, 0], [4, 0], [8, 0]], [[12, 0], [16, 0], [20, 0]]]), + torch.arange(24).reshape((2, 3, 4)), # data + torch.as_tensor(np.diag([-3.0, 0.2, 1.5, 1])), + {}, + torch.tensor([[[0, 0], [4, 0], [8, 0]], [[12, 0], [16, 0], [20, 0]]]), + *device, ] ) TESTS.append( [ - p, {"pixdim": (3.0, 1.0), "padding_mode": "zeros"}, - np.arange(24).reshape((2, 3, 4)), # data + torch.arange(24).reshape((2, 3, 4)), # data + torch.eye(4), {}, - np.array([[[0, 1, 2, 3], [0, 0, 0, 0]], [[12, 13, 14, 15], [0, 0, 0, 0]]]), + torch.tensor([[[0, 1, 2, 3], [0, 0, 0, 0]], [[12, 13, 14, 15], [0, 0, 0, 0]]]), + *device, ] ) TESTS.append( [ - p, {"pixdim": (1.0, 1.0), "align_corners": True}, - np.arange(24).reshape((2, 3, 4)), # data + torch.arange(24).reshape((2, 3, 4)), # data + torch.eye(4), {}, - np.array( + torch.tensor( [[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]], [[12, 13, 14, 15], [16, 17, 18, 19], [20, 21, 22, 23]]] ), + *device, ] ) TESTS.append( [ - p, {"pixdim": (4.0, 5.0, 6.0)}, - np.arange(24).reshape((1, 2, 3, 4)), # data - {"affine": np.array([[-4, 0, 0, 4], [0, 5, 0, -5], [0, 0, 6, -6], [0, 0, 0, 1]])}, - np.arange(24).reshape((1, 2, 3, 4)), # data + torch.arange(24).reshape((1, 2, 3, 4)), # data + torch.tensor([[-4, 0, 0, 4], [0, 5, 0, -5], [0, 0, 6, -6], [0, 0, 0, 1]]), + {}, + torch.arange(24).reshape((1, 2, 3, 4)), # data + *device, ] ) TESTS.append( [ - p, {"pixdim": (4.0, 5.0, 6.0), "diagonal": True}, - np.arange(24).reshape((1, 2, 3, 4)), # data - {"affine": np.array([[-4, 0, 0, 4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]])}, - np.array( + torch.arange(24).reshape((1, 2, 3, 4)), # data + torch.tensor([[-4, 0, 0, 4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), + {}, + torch.tensor( [[[[12, 13, 14, 15], [16, 17, 18, 19], [20, 21, 22, 23]], [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]]] ), + *device, ] ) TESTS.append( [ - p, {"pixdim": (4.0, 5.0, 6.0), "padding_mode": "border", "diagonal": True}, - np.arange(24).reshape((1, 2, 3, 4)), # data - {"affine": np.array([[-4, 0, 0, -4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]])}, - np.array( + torch.arange(24).reshape((1, 2, 3, 4)), # data + torch.tensor([[-4, 0, 0, -4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), + {}, + torch.tensor( [[[[12, 13, 14, 15], [16, 17, 18, 19], [20, 21, 22, 23]], [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]]] ), + *device, ] ) TESTS.append( [ - p, {"pixdim": (4.0, 5.0, 6.0), "padding_mode": "border", "diagonal": True}, - np.arange(24).reshape((1, 2, 3, 4)), # data - {"affine": np.array([[-4, 0, 0, -4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), "mode": "nearest"}, - np.array( + torch.arange(24).reshape((1, 2, 3, 4)), # data + torch.tensor([[-4, 0, 0, -4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), + {"mode": "nearest"}, + torch.tensor( [[[[12, 13, 14, 15], [16, 17, 18, 19], [20, 21, 22, 23]], [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]]] ), + *device, ] ) TESTS.append( [ - p, {"pixdim": (1.9, 4.0), "padding_mode": "zeros", "diagonal": True}, - np.arange(24).reshape((1, 4, 6)), # data - {"affine": np.array([[-4, 0, 0, -4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), "mode": "nearest"}, - np.array( + torch.arange(24).reshape((1, 4, 6)), # data + torch.tensor([[-4, 0, 0, -4], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), + {"mode": "nearest"}, + torch.tensor( [ [ [18.0, 19.0, 20.0, 20.0, 21.0, 22.0, 23.0], @@ -148,15 +161,16 @@ ] ] ), + *device, ] ) TESTS.append( [ - p, - {"pixdim": (5.0, 3.0), "padding_mode": "border", "diagonal": True, "dtype": np.float32}, - np.arange(24).reshape((1, 4, 6)), # data - {"affine": np.array([[-4, 0, 0, 0], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), "mode": "bilinear"}, - np.array( + {"pixdim": (5.0, 3.0), "padding_mode": "border", "diagonal": True, "dtype": torch.float32}, + torch.arange(24).reshape((1, 4, 6)), # data + torch.tensor([[-4, 0, 0, 0], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), + {"mode": "bilinear"}, + torch.tensor( [ [ [18.0, 18.6, 19.2, 19.8, 20.400002, 21.0, 21.6, 22.2, 22.8], @@ -165,15 +179,16 @@ ] ] ), + *device, ] ) TESTS.append( [ - p, - {"pixdim": (5.0, 3.0), "padding_mode": "zeros", "diagonal": True, "dtype": np.float32}, - np.arange(24).reshape((1, 4, 6)), # data - {"affine": np.array([[-4, 0, 0, 0], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), "mode": "bilinear"}, - np.array( + {"pixdim": (5.0, 3.0), "padding_mode": "zeros", "diagonal": True, "dtype": torch.float32}, + torch.arange(24).reshape((1, 4, 6)), # data + torch.tensor([[-4, 0, 0, 0], [0, 5, 0, 0], [0, 0, 6, 0], [0, 0, 0, 1]]), + {"mode": "bilinear"}, + torch.tensor( [ [ [18.0000, 18.6000, 19.2000, 19.8000, 20.4000, 21.0000, 21.6000, 22.2000, 22.8000], @@ -182,45 +197,88 @@ ] ] ), + *device, ] ) TESTS.append( [ - p, {"pixdim": [-1, -1, 0.5], "padding_mode": "zeros", "dtype": float}, - np.ones((1, 2, 1, 2)), # data - {"affine": np.eye(4)}, - np.array([[[[1.0, 1.0, 1.0]], [[1.0, 1.0, 1.0]]]]), + torch.ones((1, 2, 1, 2)), # data + torch.eye(4), + {}, + torch.tensor([[[[1.0, 1.0, 1.0]], [[1.0, 1.0, 1.0]]]]), + *device, ] ) TESTS.append( # 5D input [ - p, {"pixdim": [-1, -1, 0.5], "padding_mode": "zeros", "dtype": float, "align_corners": True}, - np.ones((1, 2, 2, 2, 1)), # data - {"affine": np.eye(4)}, - np.ones((1, 2, 2, 3, 1)), + torch.ones((1, 2, 2, 2, 1)), # data + torch.eye(4), + {}, + torch.ones((1, 2, 2, 3, 1)), + *device, ] ) +TESTS_TORCH = [] +for track_meta in (False, True): + for device in TEST_DEVICES: + TESTS_TORCH.append([[1.2, 1.3, 0.9], torch.zeros((1, 3, 4, 5)), track_meta, *device]) + class TestSpacingCase(unittest.TestCase): @parameterized.expand(TESTS) - def test_spacing(self, in_type, init_param, img, data_param, expected_output): - _img = in_type(img) - output_data, _, new_affine = Spacing(**init_param)(_img, **data_param) - if isinstance(_img, torch.Tensor): - self.assertEqual(_img.device, output_data.device) - output_data = output_data.cpu() + def test_spacing(self, init_param, img, affine, data_param, expected_output, device): + img = MetaTensor(img, affine=affine).to(device) + res: MetaTensor = Spacing(**init_param)(img, **data_param) + self.assertEqual(img.device, res.device) - np.testing.assert_allclose(output_data, expected_output, atol=1e-1, rtol=1e-1) - sr = min(len(output_data.shape) - 1, 3) + assert_allclose(res, expected_output, atol=1e-1, rtol=1e-1) + sr = min(len(res.shape) - 1, 3) if isinstance(init_param["pixdim"], float): init_param["pixdim"] = [init_param["pixdim"]] * sr init_pixdim = ensure_tuple(init_param["pixdim"]) init_pixdim = init_param["pixdim"][:sr] - norm = affine_to_spacing(new_affine, sr) - np.testing.assert_allclose(fall_back_tuple(init_pixdim, norm), norm) + norm = affine_to_spacing(res.affine, sr).cpu().numpy() + assert_allclose(fall_back_tuple(init_pixdim, norm), norm, type_test=False) + + @parameterized.expand(TESTS_TORCH) + def test_spacing_torch(self, pixdim, img: torch.Tensor, track_meta: bool, device): + set_track_meta(track_meta) + tr = Spacing(pixdim=pixdim) + img = img.to(device) + res = tr(img) + if track_meta: + self.assertIsInstance(res, MetaTensor) + new_spacing = affine_to_spacing(res.affine, 3) + assert_allclose(new_spacing, pixdim, type_test=False) + self.assertNotEqual(img.shape, res.shape) + else: + self.assertIsInstance(res, torch.Tensor) + self.assertNotIsInstance(res, MetaTensor) + self.assertNotEqual(img.shape, res.shape) + + @parameterized.expand(TEST_DEVICES) + def test_inverse(self, device): + img_t = torch.rand((1, 10, 9, 8), dtype=torch.float32, device=device) + affine = torch.tensor( + [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device=device + ) + meta = {"fname": "somewhere"} + img = MetaTensor(img_t, affine=affine, meta=meta) + tr = Spacing(pixdim=[1.1, 1.2, 0.9]) + # check that image and affine have changed + img = tr(img) + self.assertNotEqual(img.shape, img_t.shape) + l2_norm_affine = ((affine - img.affine) ** 2).sum() ** 0.5 + self.assertGreater(l2_norm_affine, 5e-2) + # check that with inverse, image affine are back to how they were + img = tr.inverse(img) + self.assertEqual(img.applied_operations, []) + self.assertEqual(img.shape, img_t.shape) + l2_norm_affine = ((affine - img.affine) ** 2).sum() ** 0.5 + self.assertLess(l2_norm_affine, 5e-2) if __name__ == "__main__": diff --git a/tests/test_spacingd.py b/tests/test_spacingd.py index 060d908699..d3c7bbc629 100644 --- a/tests/test_spacingd.py +++ b/tests/test_spacingd.py @@ -16,83 +16,107 @@ import torch from parameterized import parameterized +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor +from monai.data.utils import affine_to_spacing from monai.transforms import Spacingd -from monai.utils.enums import PostFix -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_DEVICES, assert_allclose TESTS: List[Tuple] = [] -for p in TEST_NDARRAYS: +for device in TEST_DEVICES: TESTS.append( ( "spacing 3d", - {"image": p(np.ones((2, 10, 15, 20))), PostFix.meta("image"): {"affine": p(np.eye(4))}}, + {"image": MetaTensor(torch.ones((2, 10, 15, 20)), affine=torch.eye(4))}, dict(keys="image", pixdim=(1, 2, 1.4)), - ("image", PostFix.meta("image"), "image_transforms"), (2, 10, 8, 15), - p(np.diag([1, 2, 1.4, 1.0])), + torch.as_tensor(np.diag([1, 2, 1.4, 1.0])), + *device, ) ) TESTS.append( ( "spacing 2d", - {"image": np.ones((2, 10, 20)), PostFix.meta("image"): {"affine": np.eye(3)}}, + {"image": MetaTensor(torch.ones((2, 10, 20)), affine=torch.eye(3))}, dict(keys="image", pixdim=(1, 2)), - ("image", PostFix.meta("image"), "image_transforms"), (2, 10, 10), - np.diag((1, 2, 1)), + torch.as_tensor(np.diag((1, 2, 1))), + *device, ) ) TESTS.append( ( "spacing 2d no metadata", - {"image": np.ones((2, 10, 20))}, + {"image": MetaTensor(torch.ones((2, 10, 20)))}, dict(keys="image", pixdim=(1, 2)), - ("image", PostFix.meta("image"), "image_transforms"), (2, 10, 10), - np.diag((1, 2, 1)), + torch.as_tensor(np.diag((1, 2, 1))), + *device, ) ) TESTS.append( ( "interp all", { - "image": np.arange(20).reshape((2, 1, 10)), - "seg": np.ones((2, 1, 10)), - PostFix.meta("image"): {"affine": np.eye(4)}, - PostFix.meta("seg"): {"affine": np.eye(4)}, + "image": MetaTensor(np.arange(20).reshape((2, 1, 10)), affine=torch.eye(4)), + "seg": MetaTensor(torch.ones((2, 1, 10)), affine=torch.eye(4)), }, dict(keys=("image", "seg"), mode="nearest", pixdim=(1, 0.2)), - ("image", PostFix.meta("image"), "image_transforms", "seg", PostFix.meta("seg"), "seg_transforms"), (2, 1, 46), - np.diag((1, 0.2, 1, 1)), + torch.as_tensor(np.diag((1, 0.2, 1))), + *device, ) ) TESTS.append( ( "interp sep", { - "image": np.ones((2, 1, 10)), - "seg": np.ones((2, 1, 10)), - PostFix.meta("image"): {"affine": np.eye(4)}, - PostFix.meta("seg"): {"affine": np.eye(4)}, + "image": MetaTensor(torch.ones((2, 1, 10)), affine=torch.eye(4)), + "seg": MetaTensor(torch.ones((2, 1, 10)), affine=torch.eye(4)), }, dict(keys=("image", "seg"), mode=("bilinear", "nearest"), pixdim=(1, 0.2)), - ("image", PostFix.meta("image"), "image_transforms", "seg", PostFix.meta("seg"), "seg_transforms"), (2, 1, 46), - np.diag((1, 0.2, 1, 1)), + torch.as_tensor(np.diag((1, 0.2, 1))), + *device, ) ) +TESTS_TORCH = [] +for track_meta in (False, True): + for device in TEST_DEVICES: + TESTS_TORCH.append([{"keys": "seg", "pixdim": [0.2, 0.3, 1]}, torch.ones(2, 1, 2, 3), track_meta, *device]) + + class TestSpacingDCase(unittest.TestCase): @parameterized.expand(TESTS) - def test_spacingd(self, _, data, kw_args, expected_keys, expected_shape, expected_affine): + def test_spacingd(self, _, data, kw_args, expected_shape, expected_affine, device): + data = {k: v.to(device) for k, v in data.items()} res = Spacingd(**kw_args)(data) - if isinstance(data["image"], torch.Tensor): - self.assertEqual(data["image"].device, res["image"].device) - self.assertEqual(expected_keys, tuple(sorted(res))) - np.testing.assert_allclose(res["image"].shape, expected_shape) - assert_allclose(res[PostFix.meta("image")]["affine"], expected_affine) + in_img = data["image"] + out_img = res["image"] + self.assertEqual(in_img.device, out_img.device) + # no change in number of keys + self.assertEqual(tuple(sorted(data)), tuple(sorted(res))) + np.testing.assert_allclose(out_img.shape, expected_shape) + assert_allclose(out_img.affine, expected_affine) + + @parameterized.expand(TESTS_TORCH) + def test_orntd_torch(self, init_param, img: torch.Tensor, track_meta: bool, device): + set_track_meta(track_meta) + tr = Spacingd(**init_param) + data = {"seg": img.to(device)} + res = tr(data)["seg"] + + if track_meta: + self.assertIsInstance(res, MetaTensor) + new_spacing = affine_to_spacing(res.affine, 3) + assert_allclose(new_spacing, init_param["pixdim"], type_test=False) + self.assertNotEqual(img.shape, res.shape) + else: + self.assertIsInstance(res, torch.Tensor) + self.assertNotIsInstance(res, MetaTensor) + self.assertNotEqual(img.shape, res.shape) if __name__ == "__main__": diff --git a/tests/test_spatial_resample.py b/tests/test_spatial_resample.py index a288ab9c0d..63260373d0 100644 --- a/tests/test_spatial_resample.py +++ b/tests/test_spatial_resample.py @@ -9,139 +9,196 @@ # See the License for the specific language governing permissions and # limitations under the License. -import itertools import unittest import numpy as np +import torch from parameterized import parameterized from monai.config import USE_COMPILED +from monai.data.meta_obj import set_track_meta +from monai.data.meta_tensor import MetaTensor +from monai.data.utils import to_affine_nd from monai.transforms import SpatialResample -from tests.utils import TEST_NDARRAYS, assert_allclose +from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, assert_allclose TESTS = [] -for ind, dst in enumerate( - [ - np.asarray([[1.0, 0.0, 0.0], [0.0, -1.0, 1.0], [0.0, 0.0, 1.0]]), # flip the second - np.asarray([[-1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), # flip the first - ] -): - for p in TEST_NDARRAYS: - for p_src in TEST_NDARRAYS: - for align in (False, True): - for interp_mode in ("nearest", "bilinear"): - TESTS.append( - [ - {}, # default no params - np.arange(4).reshape((1, 2, 2)) + 1.0, # data - { - "src_affine": p_src(np.eye(3)), - "dst_affine": p(dst), - "dtype": np.float32, - "align_corners": align, - "mode": interp_mode, - "padding_mode": "zeros", - }, - np.array([[[2.0, 1.0], [4.0, 3.0]]]) if ind == 0 else np.array([[[3.0, 4.0], [1.0, 2.0]]]), - ] - ) -for ind, dst in enumerate( - [ - np.asarray([[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), - np.asarray([[-1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), - ] -): - for p_src in TEST_NDARRAYS: - for align in (True, False): - if align and USE_COMPILED: - interp = ("nearest", "bilinear", 0, 1) - else: - interp = ("nearest", "bilinear") # type: ignore - for interp_mode in interp: # type: ignore +destinations_3d = [ + torch.tensor([[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), + torch.tensor([[-1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), +] +expected_3d = [ + torch.tensor([[[[4.0, 5.0, 6.0], [1.0, 2.0, 3.0]], [[10.0, 11.0, 12.0], [7.0, 8.0, 9.0]]]]), + torch.tensor([[[[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]]), +] + +for dst, expct in zip(destinations_3d, expected_3d): + for device in TEST_DEVICES: + for align in (False, True): + interp = ("nearest", "bilinear", 0, 1) if align and USE_COMPILED else ("nearest", "bilinear") + for interp_mode in interp: for padding_mode in ("zeros", "border", "reflection"): TESTS.append( [ - {}, # default no params - np.arange(12).reshape((1, 2, 2, 3)) + 1.0, # data + torch.arange(12).reshape((1, 2, 2, 3)) + 1.0, # data + *device, { - "src_affine": p_src(np.eye(4)), - "dst_affine": p_src(dst), - "dtype": np.float64, + "dst_affine": dst, + "dtype": torch.float64, "align_corners": align, "mode": interp_mode, "padding_mode": padding_mode, }, - np.array([[[[4.0, 5.0, 6.0], [1.0, 2.0, 3.0]], [[10.0, 11.0, 12.0], [7.0, 8.0, 9.0]]]]) - if ind == 0 - else np.array( - [[[[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]] - ), + expct, ] ) -class TestSpatialResample(unittest.TestCase): - @parameterized.expand(itertools.product(TEST_NDARRAYS, TESTS)) - def test_flips(self, p_type, args): - init_param, img, data_param, expected_output = args - _img = p_type(img) - _expected_output = p_type(expected_output) - output_data, output_dst = SpatialResample(**init_param)(img=_img, **data_param) - assert_allclose(output_data, _expected_output, rtol=1e-2, atol=1e-2) - expected_dst = ( - data_param.get("dst_affine") if data_param.get("dst_affine") is not None else data_param.get("src_affine") - ) - assert_allclose(output_dst, expected_dst, type_test=False, rtol=1e-2, atol=1e-2) +destinations_2d = [ + torch.tensor([[1.0, 0.0, 0.0], [0.0, -1.0, 1.0], [0.0, 0.0, 1.0]]), # flip the second + torch.tensor([[-1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), # flip the first +] +expected_2d = [torch.tensor([[[2.0, 1.0], [4.0, 3.0]]]), torch.tensor([[[3.0, 4.0], [1.0, 2.0]]])] - @parameterized.expand(itertools.product([True, False], TEST_NDARRAYS)) - def test_4d_5d(self, is_5d, p_type): - new_shape = (1, 2, 2, 3, 1, 1) if is_5d else (1, 2, 2, 3, 1) - img = np.arange(12).reshape(new_shape) - img = np.tile(img, (1, 1, 1, 1, 2, 2) if is_5d else (1, 1, 1, 1, 2)) - _img = p_type(img) - dst = np.asarray([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 1.5], [0.0, 0.0, 0.0, 1.0]]) - output_data, output_dst = SpatialResample(dtype=np.float32)( - img=_img, src_affine=p_type(np.eye(4)), dst_affine=dst - ) - expected_data = ( - np.asarray( - [ +for dst, expct in zip(destinations_2d, expected_2d): + for device in TEST_DEVICES: + for align in (False, True): + for interp_mode in ("nearest", "bilinear"): + TESTS.append( [ - [[[0.0, 0.0], [0.0, 1.0]], [[0.5, 0.0], [1.5, 1.0]], [[1.0, 2.0], [2.0, 2.0]]], - [[[3.0, 3.0], [3.0, 4.0]], [[3.5, 3.0], [4.5, 4.0]], [[4.0, 5.0], [5.0, 5.0]]], - ], + torch.arange(4).reshape((1, 2, 2)) + 1.0, + *device, + { + "dst_affine": dst, + "dtype": torch.float32, + "align_corners": align, + "mode": interp_mode, + "padding_mode": "zeros", + }, + expct, + ] + ) + +TEST_4_5_D = [] +for device in TEST_DEVICES: + for dtype in (torch.float32, torch.float64): + # 4D + TEST_4_5_D.append( + [ + (1, 2, 2, 3, 1), + (1, 1, 1, 1, 2), + *device, + dtype, + torch.tensor( [ - [[[6.0, 6.0], [6.0, 7.0]], [[6.5, 6.0], [7.5, 7.0]], [[7.0, 8.0], [8.0, 8.0]]], - [[[9.0, 9.0], [9.0, 10.0]], [[9.5, 9.0], [10.5, 10.0]], [[10.0, 11.0], [11.0, 11.0]]], - ], - ], - dtype=np.float32, - ) - if is_5d - else np.asarray( - [ - [[[0.5, 0.0], [0.0, 2.0], [1.5, 1.0]], [[3.5, 3.0], [3.0, 5.0], [4.5, 4.0]]], - [[[6.5, 6.0], [6.0, 8.0], [7.5, 7.0]], [[9.5, 9.0], [9.0, 11.0], [10.5, 10.0]]], - ], - dtype=np.float32, - ) + [[[0.5, 0.0], [0.0, 2.0], [1.5, 1.0]], [[3.5, 3.0], [3.0, 5.0], [4.5, 4.0]]], + [[[6.5, 6.0], [6.0, 8.0], [7.5, 7.0]], [[9.5, 9.0], [9.0, 11.0], [10.5, 10.0]]], + ] + ), + ] ) - assert_allclose(output_data, p_type(expected_data[None]), rtol=1e-2, atol=1e-2) - assert_allclose(output_dst, dst, type_test=False, rtol=1e-2, atol=1e-2) - - def test_ill_affine(self): - img = np.arange(12).reshape(1, 2, 2, 3) - ill_affine = np.asarray( - [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, -1.0, 1.5], [0.0, 0.0, 0.0, 1.0]] + # 5D + TEST_4_5_D.append( + [ + (1, 2, 2, 3, 1, 1), + (1, 1, 1, 1, 2, 2), + *device, + dtype, + torch.tensor( + [ + [ + [[[0.0, 0.0], [0.0, 1.0]], [[0.5, 0.0], [1.5, 1.0]], [[1.0, 2.0], [2.0, 2.0]]], + [[[3.0, 3.0], [3.0, 4.0]], [[3.5, 3.0], [4.5, 4.0]], [[4.0, 5.0], [5.0, 5.0]]], + ], + [ + [[[6.0, 6.0], [6.0, 7.0]], [[6.5, 6.0], [7.5, 7.0]], [[7.0, 8.0], [8.0, 8.0]]], + [[[9.0, 9.0], [9.0, 10.0]], [[9.5, 9.0], [10.5, 10.0]], [[10.0, 11.0], [11.0, 11.0]]], + ], + ] + ), + ] ) + +TEST_TORCH_INPUT = [] +for track_meta in (True,): + for t in TEST_4_5_D: + TEST_TORCH_INPUT.append(t + [track_meta]) + + +class TestSpatialResample(unittest.TestCase): + @parameterized.expand(TESTS) + def test_flips(self, img, device, data_param, expected_output): + for p in TEST_NDARRAYS_ALL: + img = p(img) + if isinstance(img, MetaTensor): + img.affine = torch.eye(4) + if hasattr(img, "to"): + img = img.to(device) + out = SpatialResample()(img=img, **data_param) + assert_allclose(out, expected_output, rtol=1e-2, atol=1e-2) + assert_allclose(out.affine, data_param["dst_affine"]) + + @parameterized.expand(TEST_4_5_D) + def test_4d_5d(self, new_shape, tile, device, dtype, expected_data): + img = np.arange(12).reshape(new_shape) + img = np.tile(img, tile) + img = MetaTensor(img).to(device) + + dst = torch.tensor([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 1.5], [0.0, 0.0, 0.0, 1.0]]) + dst = dst.to(dtype) + out = SpatialResample(dtype=dtype)(img=img, dst_affine=dst) + assert_allclose(out, expected_data[None], rtol=1e-2, atol=1e-2) + assert_allclose(out.affine, dst.to(torch.float32), rtol=1e-2, atol=1e-2) + + @parameterized.expand(TEST_DEVICES) + def test_ill_affine(self, device): + img = MetaTensor(torch.arange(12).reshape(1, 2, 2, 3)).to(device) + ill_affine = torch.tensor([[1, 0, 0, 0], [0, 0, 0, 0], [0, 0, -1, 1.5], [0, 0, 0, 1]]) with self.assertRaises(ValueError): - SpatialResample()(img=img, src_affine=np.eye(4), dst_affine=ill_affine) + img.affine = torch.eye(4) + dst_affine = ill_affine + SpatialResample()(img=img, dst_affine=dst_affine) with self.assertRaises(ValueError): - SpatialResample()(img=img, src_affine=ill_affine, dst_affine=np.eye(3)) + img.affine = ill_affine + dst_affine = torch.eye(4) + SpatialResample()(img=img, dst_affine=dst_affine) with self.assertRaises(ValueError): - SpatialResample(mode=None)(img=img, src_affine=np.eye(4), dst_affine=0.1 * np.eye(4)) + img.affine = torch.eye(4) + dst_affine = torch.eye(4) * 0.1 + SpatialResample(mode=None)(img=img, dst_affine=dst_affine) + + @parameterized.expand(TEST_TORCH_INPUT) + def test_input_torch(self, new_shape, tile, device, dtype, expected_data, track_meta): + set_track_meta(track_meta) + img = np.arange(12).reshape(new_shape) + img = torch.as_tensor(np.tile(img, tile)).to(device) + dst = torch.tensor([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 1.5], [0.0, 0.0, 0.0, 1.0]]) + dst = dst.to(dtype).to(device) + + out = SpatialResample(dtype=dtype)(img=img, dst_affine=dst) + assert_allclose(out, expected_data[None], rtol=1e-2, atol=1e-2) + if track_meta: + self.assertIsInstance(out, MetaTensor) + assert_allclose(out.affine, dst.to(torch.float32), rtol=1e-2, atol=1e-2) + else: + self.assertIsInstance(out, torch.Tensor) + self.assertNotIsInstance(out, MetaTensor) + + @parameterized.expand(TESTS) + def test_inverse(self, img, device, data_param, expected_output): + img = MetaTensor(img, affine=torch.eye(4)).to(device) + tr = SpatialResample() + out = tr(img=img, **data_param) + assert_allclose(out, expected_output, rtol=1e-2, atol=1e-2) + assert_allclose(out.affine, data_param["dst_affine"]) + + # inverse + out = tr.inverse(out) + assert_allclose(img, out) + expected_affine = to_affine_nd(len(out.affine) - 1, torch.eye(4)) + assert_allclose(out.affine, expected_affine) if __name__ == "__main__": diff --git a/tests/test_spatial_resampled.py b/tests/test_spatial_resampled.py index 73f83791d9..3772cf0ddf 100644 --- a/tests/test_spatial_resampled.py +++ b/tests/test_spatial_resampled.py @@ -9,104 +9,100 @@ # See the License for the specific language governing permissions and # limitations under the License. -import itertools import unittest import numpy as np +import torch from parameterized import parameterized from monai.config import USE_COMPILED -from monai.transforms import SpatialResampleD -from tests.utils import TEST_NDARRAYS, assert_allclose +from monai.data.meta_tensor import MetaTensor +from monai.data.utils import to_affine_nd +from monai.transforms.spatial.dictionary import SpatialResampled +from tests.utils import TEST_DEVICES, assert_allclose TESTS = [] -for ind, dst in enumerate( - [ - np.asarray([[1.0, 0.0, 0.0], [0.0, -1.0, 1.0], [0.0, 0.0, 1.0]]), # flip the second - np.asarray([[-1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), # flip the first - ] -): - for p in TEST_NDARRAYS: - for p_src in TEST_NDARRAYS: - for align in (False, True): + +destinations_3d = [ + torch.tensor([[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), + torch.tensor([[-1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), +] +expected_3d = [ + torch.tensor([[[[4.0, 5.0, 6.0], [1.0, 2.0, 3.0]], [[10.0, 11.0, 12.0], [7.0, 8.0, 9.0]]]]), + torch.tensor([[[[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]]), +] + +for dst, expct in zip(destinations_3d, expected_3d): + for device in TEST_DEVICES: + for align in (True, False): + for dtype in (torch.float32, torch.float64): + interp = ("nearest", "bilinear", 0, 1) if align and USE_COMPILED else ("nearest", "bilinear") + for interp_mode in interp: + for padding_mode in ("zeros", "border", "reflection"): + TESTS.append( + [ + np.arange(12).reshape((1, 2, 2, 3)) + 1.0, # data + *device, + dst, + { + "dst_keys": "dst_affine", + "dtype": dtype, + "align_corners": align, + "mode": interp_mode, + "padding_mode": padding_mode, + }, + expct, + ] + ) + +destinations_2d = [ + torch.tensor([[1.0, 0.0, 0.0], [0.0, -1.0, 1.0], [0.0, 0.0, 1.0]]), # flip the second + torch.tensor([[-1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), # flip the first +] + +expected_2d = [torch.tensor([[[2.0, 1.0], [4.0, 3.0]]]), torch.tensor([[[3.0, 4.0], [1.0, 2.0]]])] + +for dst, expct in zip(destinations_2d, expected_2d): + for device in TEST_DEVICES: + for align in (False, True): + for dtype in (torch.float32, torch.float64): for interp_mode in ("nearest", "bilinear"): TESTS.append( [ - {}, # default no params np.arange(4).reshape((1, 2, 2)) + 1.0, # data + *device, + dst, { - "src": p_src(np.eye(3)), - "dst": p(dst), - "dtype": np.float32, + "dst_keys": "dst_affine", + "dtype": dtype, "align_corners": align, "mode": interp_mode, "padding_mode": "zeros", }, - np.array([[[2.0, 1.0], [4.0, 3.0]]]) if ind == 0 else np.array([[[3.0, 4.0], [1.0, 2.0]]]), - ] - ) - -for ind, dst in enumerate( - [ - np.asarray([[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), - np.asarray([[-1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]), - ] -): - for p_src in TEST_NDARRAYS: - for align in (True, False): - if align and USE_COMPILED: - interp = ("nearest", "bilinear", 0, 1) - else: - interp = ("nearest", "bilinear") # type: ignore - for interp_mode in interp: # type: ignore - for padding_mode in ("zeros", "border", "reflection"): - TESTS.append( - [ - {}, # default no params - np.arange(12).reshape((1, 2, 2, 3)) + 1.0, # data - { - "src": p_src(np.eye(4)), - "dst": p_src(dst), - "dtype": np.float64, - "align_corners": align, - "mode": interp_mode, - "padding_mode": padding_mode, - }, - np.array([[[[4.0, 5.0, 6.0], [1.0, 2.0, 3.0]], [[10.0, 11.0, 12.0], [7.0, 8.0, 9.0]]]]) - if ind == 0 - else np.array( - [[[[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]], [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]] - ), + expct, ] ) class TestSpatialResample(unittest.TestCase): - @parameterized.expand(itertools.product(TEST_NDARRAYS, TESTS)) - def test_flips_inverse(self, p_type, args): - _, img, data_param, expected_output = args - _img = p_type(img) - _expected_output = p_type(expected_output) - input_dict = {"img": _img, "img_meta_dict": {"src": data_param.get("src"), "dst": data_param.get("dst")}} - xform = SpatialResampleD( - keys="img", - meta_src_keys="src", - meta_dst_keys="dst", - mode=data_param.get("mode"), - padding_mode=data_param.get("padding_mode"), - align_corners=data_param.get("align_corners"), - ) - output_data = xform(input_dict) - assert_allclose(output_data["img"], _expected_output, rtol=1e-2, atol=1e-2) - assert_allclose( - output_data["img_meta_dict"]["src"], data_param.get("dst"), type_test=False, rtol=1e-2, atol=1e-2 - ) - - inverted = xform.inverse(output_data) - self.assertEqual(inverted["img_transforms"], []) # no further invert after inverting - assert_allclose(inverted["img_meta_dict"]["src"], data_param.get("src"), type_test=False, rtol=1e-2, atol=1e-2) - assert_allclose(inverted["img"], _img, rtol=1e-2, atol=1e-2) + @parameterized.expand(TESTS) + def test_flips_inverse(self, img, device, dst_affine, kwargs, expected_output): + img = MetaTensor(img, affine=torch.eye(4)).to(device) + data = {"img": img, "dst_affine": dst_affine} + + xform = SpatialResampled(keys="img", **kwargs) + output_data = xform(data) + out = output_data["img"] + + assert_allclose(out, expected_output, rtol=1e-2, atol=1e-2) + assert_allclose(out.affine, dst_affine, rtol=1e-2, atol=1e-2) + + inverted = xform.inverse(output_data)["img"] + self.assertEqual(inverted.applied_operations, []) # no further invert after inverting + expected_affine = to_affine_nd(len(out.affine) - 1, torch.eye(4)) + assert_allclose(inverted.affine, expected_affine, rtol=1e-2, atol=1e-2) + assert_allclose(inverted, img, rtol=1e-2, atol=1e-2) if __name__ == "__main__": diff --git a/tests/test_split_channel.py b/tests/test_split_channel.py index 75216227e4..4b41c334e8 100644 --- a/tests/test_split_channel.py +++ b/tests/test_split_channel.py @@ -30,6 +30,7 @@ class TestSplitChannel(unittest.TestCase): def test_shape(self, input_param, test_data, expected_shape): result = SplitChannel(**input_param)(test_data) for data in result: + self.assertEqual(type(data), type(test_data)) self.assertTupleEqual(data.shape, expected_shape) diff --git a/tests/test_splitdim.py b/tests/test_splitdim.py index 623396a8fe..d6ee4fc55e 100644 --- a/tests/test_splitdim.py +++ b/tests/test_splitdim.py @@ -30,6 +30,7 @@ def test_correct_shape(self, shape, keepdim, im_type): for dim in range(arr.ndim): out = SplitDim(dim, keepdim)(arr) self.assertIsInstance(out, (list, tuple)) + self.assertEqual(type(out[0]), type(arr)) self.assertEqual(len(out), arr.shape[dim]) expected_ndim = arr.ndim if keepdim else arr.ndim - 1 self.assertEqual(out[0].ndim, expected_ndim) diff --git a/tests/test_splitdimd.py b/tests/test_splitdimd.py index 6b164a3cb8..1e39439b86 100644 --- a/tests/test_splitdimd.py +++ b/tests/test_splitdimd.py @@ -13,8 +13,10 @@ from copy import deepcopy import numpy as np +import torch from parameterized import parameterized +from monai.data.meta_tensor import MetaTensor from monai.transforms import LoadImaged from monai.transforms.utility.dictionary import SplitDimd from tests.utils import TEST_NDARRAYS, assert_allclose, make_nifti_image, make_rand_affine @@ -33,7 +35,8 @@ def setUpClass(cls): affine = make_rand_affine() data = {"i": make_nifti_image(arr, affine)} - cls.data = LoadImaged("i")(data) + loader = LoadImaged("i") + cls.data: MetaTensor = loader(data) @parameterized.expand(TESTS) def test_correct(self, keepdim, im_type, update_meta): @@ -43,9 +46,8 @@ def test_correct(self, keepdim, im_type, update_meta): for dim in range(arr.ndim): out = SplitDimd("i", dim=dim, keepdim=keepdim, update_meta=update_meta)(data) self.assertIsInstance(out, dict) - num_new_keys = 2 if update_meta else 1 - self.assertEqual(len(out.keys()), len(data.keys()) + num_new_keys * arr.shape[dim]) - # if updating meta data, pick some random points and + self.assertEqual(len(out.keys()), len(data.keys()) + arr.shape[dim]) + # if updating metadata, pick some random points and # check same world coordinates between input and output if update_meta: for _ in range(10): @@ -53,10 +55,12 @@ def test_correct(self, keepdim, im_type, update_meta): split_im_idx = idx[dim] split_idx = deepcopy(idx) split_idx[dim] = 0 - # idx[1:] to remove channel and then add 1 for 4th element - real_world = data["i_meta_dict"]["affine"] @ (idx[1:] + [1]) - real_world2 = out[f"i_{split_im_idx}_meta_dict"]["affine"] @ (split_idx[1:] + [1]) - assert_allclose(real_world, real_world2) + split_im = out[f"i_{split_im_idx}"] + if isinstance(data, MetaTensor) and isinstance(split_im, MetaTensor): + # idx[1:] to remove channel and then add 1 for 4th element + real_world = data.affine @ torch.tensor(idx[1:] + [1]).double() + real_world2 = split_im.affine @ torch.tensor(split_idx[1:] + [1]).double() + assert_allclose(real_world, real_world2) out = out["i_0"] expected_ndim = arr.ndim if keepdim else arr.ndim - 1 diff --git a/tests/test_std_shift_intensity.py b/tests/test_std_shift_intensity.py index 55750161ec..a5549bf187 100644 --- a/tests/test_std_shift_intensity.py +++ b/tests/test_std_shift_intensity.py @@ -15,6 +15,7 @@ import torch from monai.transforms import ShiftIntensity, StdShiftIntensity +from monai.utils import dtype_numpy_to_torch from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D @@ -67,7 +68,7 @@ def test_dtype(self): factor = np.random.rand() std_shifter = StdShiftIntensity(factor=factor, dtype=trans_dtype) result = std_shifter(image) - np.testing.assert_equal(result.dtype, trans_dtype) + np.testing.assert_equal(result.dtype, dtype_numpy_to_torch(trans_dtype)) if __name__ == "__main__": diff --git a/tests/test_std_shift_intensityd.py b/tests/test_std_shift_intensityd.py index 595da5cbc2..b86f6bd5e6 100644 --- a/tests/test_std_shift_intensityd.py +++ b/tests/test_std_shift_intensityd.py @@ -14,6 +14,7 @@ import numpy as np from monai.transforms import ShiftIntensityd, StdShiftIntensityd +from monai.utils import dtype_numpy_to_torch from tests.utils import NumpyImageTestCase2D @@ -64,7 +65,7 @@ def test_dtype(self): factor = np.random.rand() std_shifter = StdShiftIntensityd(keys=[key], factor=factor, dtype=trans_dtype) result = std_shifter({key: image}) - np.testing.assert_equal(result[key].dtype, trans_dtype) + np.testing.assert_equal(result[key].dtype, dtype_numpy_to_torch(trans_dtype)) if __name__ == "__main__": diff --git a/tests/test_testtimeaugmentation.py b/tests/test_testtimeaugmentation.py index 21186adc3c..75f3fdc181 100644 --- a/tests/test_testtimeaugmentation.py +++ b/tests/test_testtimeaugmentation.py @@ -32,7 +32,7 @@ RandScaleIntensityd, ) from monai.transforms.croppad.dictionary import SpatialPadd -from monai.transforms.spatial.dictionary import RandFlipd, Spacingd +from monai.transforms.spatial.dictionary import RandFlipd from monai.utils import optional_import, set_determinism from monai.utils.enums import PostFix from tests.utils import TEST_NDARRAYS @@ -176,12 +176,6 @@ def test_image_no_label(self): tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x, orig_key="image") tta(self.get_data(1, (20, 20), include_label=False)) - @unittest.skipUnless(has_nib, "Requires nibabel") - def test_requires_meta_dict(self): - transforms = Compose([AddChanneld("image"), RandFlipd("image"), Spacingd("image", pixdim=1.1)]) - tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x, orig_key="image") - tta(self.get_data(1, (20, 20), include_label=False)) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_threshold_intensity.py b/tests/test_threshold_intensity.py index 01321f1b0b..3c0a2033ee 100644 --- a/tests/test_threshold_intensity.py +++ b/tests/test_threshold_intensity.py @@ -29,7 +29,7 @@ class TestThresholdIntensity(unittest.TestCase): def test_value(self, in_type, input_param, expected_value): test_data = in_type(np.arange(10)) result = ThresholdIntensity(**input_param)(test_data) - assert_allclose(result, in_type(expected_value)) + assert_allclose(result, in_type(expected_value), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_threshold_intensityd.py b/tests/test_threshold_intensityd.py index e0610ebb5b..8aade12322 100644 --- a/tests/test_threshold_intensityd.py +++ b/tests/test_threshold_intensityd.py @@ -47,9 +47,9 @@ class TestThresholdIntensityd(unittest.TestCase): def test_value(self, in_type, input_param, expected_value): test_data = {"image": in_type(np.arange(10)), "label": in_type(np.arange(10)), "extra": in_type(np.arange(10))} result = ThresholdIntensityd(**input_param)(test_data) - assert_allclose(result["image"], in_type(expected_value)) - assert_allclose(result["label"], in_type(expected_value)) - assert_allclose(result["extra"], in_type(expected_value)) + assert_allclose(result["image"], in_type(expected_value), type_test="tensor") + assert_allclose(result["label"], in_type(expected_value), type_test="tensor") + assert_allclose(result["extra"], in_type(expected_value), type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_transchex.py b/tests/test_transchex.py index 462ce64fd6..713bc35f56 100644 --- a/tests/test_transchex.py +++ b/tests/test_transchex.py @@ -38,7 +38,7 @@ "num_classes": num_classes, "drop_out": drop_out, }, - (2, num_classes), # type: ignore + (2, num_classes), ] TEST_CASE_TRANSCHEX.append(test_case) diff --git a/tests/test_varautoencoder.py b/tests/test_varautoencoder.py index 95fea8afcb..04fc07f53f 100644 --- a/tests/test_varautoencoder.py +++ b/tests/test_varautoencoder.py @@ -91,7 +91,7 @@ def test_script(self): spatial_dims=2, in_shape=(1, 32, 32), out_channels=1, latent_size=2, channels=(4, 8), strides=(2, 2) ) test_data = torch.randn(2, 1, 32, 32) - test_script_save(net, test_data) + test_script_save(net, test_data, rtol=1e-3, atol=1e-3) if __name__ == "__main__": diff --git a/tests/test_warp.py b/tests/test_warp.py index c039b57211..31f3540c9e 100644 --- a/tests/test_warp.py +++ b/tests/test_warp.py @@ -153,8 +153,9 @@ def test_grad(self): def load_img_and_sample_ddf(): # load image img = LoadImaged(keys="img")({"img": FILE_PATH})["img"] + img = img.detach().numpy() # W, H, D -> D, H, W - img = img.transpose((2, 1, 0)) + img = img.transpose((2, 1, 0)).copy() # randomly sample ddf such that maximum displacement in each direction equals to one-tenth of the image dimension in # that direction. diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index ac8477ba84..a0a076b682 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -20,7 +20,7 @@ from monai.data import DataLoader, Dataset from monai.data.image_reader import WSIReader -from monai.transforms import Compose, LoadImaged, ToTensord +from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix from tests.utils import download_url_or_skip_test, testing_data_config @@ -193,6 +193,7 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte train_transform = Compose( [ LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), ToTensord(keys=["image"]), ] ) diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 7f0a776aff..0d5e5892e6 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -19,7 +19,7 @@ from monai.data import DataLoader, Dataset from monai.data.wsi_reader import WSIReader -from monai.transforms import Compose, LoadImaged, ToTensord +from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config @@ -230,6 +230,7 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte train_transform = Compose( [ LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), ToTensord(keys=["image"]), ] ) @@ -245,6 +246,7 @@ def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, e train_transform = Compose( [ LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), ToTensord(keys=["image"]), ] ) diff --git a/tests/test_zoom.py b/tests/test_zoom.py index 1a7694072e..78beec69a1 100644 --- a/tests/test_zoom.py +++ b/tests/test_zoom.py @@ -16,8 +16,9 @@ from parameterized import parameterized from scipy.ndimage import zoom as zoom_scipy +from monai.data import MetaTensor, set_track_meta from monai.transforms import Zoom -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion VALID_CASES = [(1.5, "nearest"), (1.5, "nearest"), (0.8, "bilinear"), (0.8, "area")] @@ -27,9 +28,11 @@ class TestZoom(NumpyImageTestCase2D): @parameterized.expand(VALID_CASES) def test_correct_results(self, zoom, mode): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: zoom_fn = Zoom(zoom=zoom, mode=mode, keep_size=False) - zoomed = zoom_fn(p(self.imt[0])) + im = p(self.imt[0]) + zoomed = zoom_fn(im) + test_local_inversion(zoom_fn, zoomed, im) _order = 0 if mode.endswith("linear"): _order = 1 @@ -37,27 +40,37 @@ def test_correct_results(self, zoom, mode): for channel in self.imt[0]: expected.append(zoom_scipy(channel, zoom=zoom, mode="nearest", order=_order, prefilter=False)) expected = np.stack(expected).astype(np.float32) - assert_allclose(zoomed, p(expected), atol=1.0) + assert_allclose(zoomed, p(expected), atol=1.0, type_test=False) def test_keep_size(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: zoom_fn = Zoom(zoom=[0.6, 0.6], keep_size=True, align_corners=True) - zoomed = zoom_fn(p(self.imt[0]), mode="bilinear") - assert_allclose(zoomed.shape, self.imt.shape[1:]) + im = p(self.imt[0]) + zoomed = zoom_fn(im, mode="bilinear") + assert_allclose(zoomed.shape, self.imt.shape[1:], type_test=False) + test_local_inversion(zoom_fn, zoomed, im) zoom_fn = Zoom(zoom=[1.3, 1.3], keep_size=True) - zoomed = zoom_fn(p(self.imt[0])) - assert_allclose(zoomed.shape, self.imt.shape[1:]) + im = p(self.imt[0]) + zoomed = zoom_fn(im) + assert_allclose(zoomed.shape, self.imt.shape[1:], type_test=False) + test_local_inversion(zoom_fn, zoomed, p(self.imt[0])) + + set_track_meta(False) + rotated = zoom_fn(im) + self.assertNotIsInstance(rotated, MetaTensor) + np.testing.assert_allclose(zoomed.shape, self.imt.shape[1:]) + set_track_meta(True) @parameterized.expand(INVALID_CASES) def test_invalid_inputs(self, zoom, mode, raises): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: with self.assertRaises(raises): zoom_fn = Zoom(zoom=zoom, mode=mode) zoom_fn(p(self.imt[0])) def test_padding_mode(self): - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: zoom_fn = Zoom(zoom=0.5, mode="nearest", padding_mode="constant", keep_size=True) test_data = p([[[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]]]) zoomed = zoom_fn(test_data) diff --git a/tests/test_zoomd.py b/tests/test_zoomd.py index 87a5cec22b..b6ff86e474 100644 --- a/tests/test_zoomd.py +++ b/tests/test_zoomd.py @@ -16,7 +16,7 @@ from scipy.ndimage import zoom as zoom_scipy from monai.transforms import Zoomd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose +from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion VALID_CASES = [(1.5, "nearest", False), (0.3, "bilinear", False), (0.8, "bilinear", False)] @@ -28,8 +28,10 @@ class TestZoomd(NumpyImageTestCase2D): def test_correct_results(self, zoom, mode, keep_size): key = "img" zoom_fn = Zoomd(key, zoom=zoom, mode=mode, keep_size=keep_size) - for p in TEST_NDARRAYS: - zoomed = zoom_fn({key: p(self.imt[0])}) + for p in TEST_NDARRAYS_ALL: + im = p(self.imt[0]) + zoomed = zoom_fn({key: im}) + test_local_inversion(zoom_fn, zoomed, {key: im}, key) _order = 0 if mode.endswith("linear"): _order = 1 @@ -38,12 +40,12 @@ def test_correct_results(self, zoom, mode, keep_size): ] expected = np.stack(expected).astype(np.float32) - assert_allclose(zoomed[key], p(expected), atol=1.0) + assert_allclose(zoomed[key], p(expected), atol=1.0, type_test=False) def test_keep_size(self): key = "img" zoom_fn = Zoomd(key, zoom=0.6, keep_size=True, padding_mode="constant", constant_values=2) - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: zoomed = zoom_fn({key: p(self.imt[0])}) np.testing.assert_array_equal(zoomed[key].shape, self.imt.shape[1:]) @@ -54,7 +56,7 @@ def test_keep_size(self): @parameterized.expand(INVALID_CASES) def test_invalid_inputs(self, _, zoom, mode, raises): key = "img" - for p in TEST_NDARRAYS: + for p in TEST_NDARRAYS_ALL: with self.assertRaises(raises): zoom_fn = Zoomd(key, zoom=zoom, mode=mode) zoom_fn({key: p(self.imt[0])}) diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json index 5b2c6ac23a..254314d1b8 100644 --- a/tests/testing_data/data_config.json +++ b/tests/testing_data/data_config.json @@ -34,6 +34,26 @@ "url": "https://github.com/rcuocolo/PROSTATEx_masks/raw/master/Files/lesions/Images/ADC/ProstateX-0000_ep2d_diff_tra_7.nii.gz", "hash_type": "md5", "hash_val": "f12a11ad0ebb0b1876e9e010564745d2" + }, + "ref_avg152T1_LR": { + "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/avg152T1_LR_nifti.nii.gz", + "hash_type": "sha256", + "hash_val": "c01a50caa7a563158ecda43d93a1466bfc8aa939bc16b06452ac1089c54661c8" + }, + "ref_avg152T1_RL": { + "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/avg152T1_RL_nifti.nii.gz", + "hash_type": "sha256", + "hash_val": "8a731128dac4de46ccb2cc60d972b98f75a52f21fb63ddb040ca96f0aed8b51a" + }, + "MNI152_T1_2mm": { + "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/MNI152_T1_2mm.nii.gz", + "hash_type": "sha256", + "hash_val": "0585cd056bf5ccfb8bf97a5f6a66082d4e7caad525718fc11e40d80a827fcb92" + }, + "MNI152_T1_2mm_strucseg": { + "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/MNI152_T1_2mm_strucseg.nii.gz", + "hash_type": "sha256", + "hash_val": "eb4f1e596ca85aadaefc359d409fb9a3e27d733e6def04b996953b7c54bc26d4" } }, "models": { diff --git a/tests/testing_data/inference.json b/tests/testing_data/inference.json index 031757c0f2..46aa206d03 100644 --- a/tests/testing_data/inference.json +++ b/tests/testing_data/inference.json @@ -46,10 +46,6 @@ "_target_": "RandRotated", "_disabled_": true, "keys": "image" - }, - { - "_target_": "EnsureTyped", - "keys": "image" } ] }, @@ -91,7 +87,6 @@ { "_target_": "SaveImaged", "keys": "pred", - "meta_keys": "image_meta_dict", "output_dir": "@_meta_#output_dir" }, { diff --git a/tests/testing_data/inference.yaml b/tests/testing_data/inference.yaml index 1f8c4fc6d9..90f0bb35b9 100644 --- a/tests/testing_data/inference.yaml +++ b/tests/testing_data/inference.yaml @@ -34,8 +34,6 @@ preprocessing: - _target_: RandRotated _disabled_: true keys: image - - _target_: EnsureTyped - keys: image dataset: _target_: need override data: "@_meta_#datalist" @@ -65,7 +63,6 @@ postprocessing: argmax: true - _target_: SaveImaged keys: pred - meta_keys: image_meta_dict output_dir: "@_meta_#output_dir" - _target_: Lambdad keys: pred diff --git a/tests/testing_data/matshow3d_patch_test.png b/tests/testing_data/matshow3d_patch_test.png index a4d89e3446989eef851b28c7fa49697103a3691c..0a4632a7639df774ffa2726c66955f8f1c600383 100644 GIT binary patch literal 5005 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 I&F1O90R1W}J^%m! literal 5050 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 NdarrayTensor: def assert_allclose( actual: NdarrayOrTensor, desired: NdarrayOrTensor, - type_test: bool = True, + type_test: Union[bool, str] = True, device_test: bool = False, *args, **kwargs, @@ -89,13 +89,22 @@ def assert_allclose( actual: Pytorch Tensor or numpy array for comparison. desired: Pytorch Tensor or numpy array to compare against. type_test: whether to test that `actual` and `desired` are both numpy arrays or torch tensors. + if type_test == "tensor", it checks whether the `actual` is a torch.tensor or metatensor according to + `get_track_meta`. device_test: whether to test the device property. args: extra arguments to pass on to `np.testing.assert_allclose`. kwargs: extra arguments to pass on to `np.testing.assert_allclose`. """ - if type_test: + if isinstance(type_test, str) and type_test == "tensor": + if get_track_meta(): + np.testing.assert_equal(isinstance(actual, MetaTensor), True, "must be a MetaTensor") + else: + np.testing.assert_equal( + isinstance(actual, torch.Tensor) and not isinstance(actual, MetaTensor), True, "must be a torch.Tensor" + ) + elif type_test: # check both actual and desired are of the same type np.testing.assert_equal(isinstance(actual, np.ndarray), isinstance(desired, np.ndarray), "numpy type") np.testing.assert_equal(isinstance(actual, torch.Tensor), isinstance(desired, torch.Tensor), "torch type") @@ -708,23 +717,34 @@ def query_memory(n=2): return ",".join(f"{int(x)}" for x in ids) -TEST_NDARRAYS: Tuple[Callable] = (np.array, torch.as_tensor) # type: ignore -if torch.cuda.is_available(): - gpu_tensor: Callable = partial(torch.as_tensor, device="cuda") - TEST_NDARRAYS = TEST_NDARRAYS + (gpu_tensor,) # type: ignore +def test_local_inversion(invertible_xform, to_invert, im, dict_key=None): + """test that invertible_xform can bring to_invert back to im""" + im_item = im if dict_key is None else im[dict_key] + if not isinstance(im_item, MetaTensor): + return + im_inv = invertible_xform.inverse(to_invert) + if dict_key: + im_inv = im_inv[dict_key] + im = im[dict_key] + np.testing.assert_array_equal(im_inv.applied_operations, []) + assert_allclose(im_inv.shape, im.shape) + assert_allclose(im_inv.affine, im.affine, atol=1e-3, rtol=1e-3) + -TEST_TORCH_TENSORS: Tuple[Callable] = (torch.as_tensor,) # type: ignore +TEST_TORCH_TENSORS: Tuple[Callable] = (torch.as_tensor,) if torch.cuda.is_available(): - gpu_tensor: Callable = partial(torch.as_tensor, device="cuda") # type: ignore - TEST_NDARRAYS = TEST_TORCH_TENSORS + (gpu_tensor,) # type: ignore + gpu_tensor: Callable = partial(torch.as_tensor, device="cuda") + TEST_NDARRAYS = TEST_TORCH_TENSORS + (gpu_tensor,) DEFAULT_TEST_AFFINE = torch.tensor( [[2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0], [0.0, 0.0, 0.0, 1.0]] ) _metatensor_creator = partial(MetaTensor, meta={"a": "b", "affine": DEFAULT_TEST_AFFINE}) TEST_NDARRAYS_NO_META_TENSOR: Tuple[Callable] = (np.array,) + TEST_TORCH_TENSORS # type: ignore +TEST_NDARRAYS: Tuple[Callable] = TEST_NDARRAYS_NO_META_TENSOR + (_metatensor_creator,) # type: ignore TEST_TORCH_AND_META_TENSORS: Tuple[Callable] = TEST_TORCH_TENSORS + (_metatensor_creator,) # type: ignore -TEST_NDARRAYS_ALL: Tuple[Callable] = TEST_NDARRAYS_NO_META_TENSOR + (_metatensor_creator,) # type: ignore +# alias for branch tests +TEST_NDARRAYS_ALL = TEST_NDARRAYS TEST_DEVICES = [[torch.device("cpu")]] From 99b9dd7cf0667113e56f0dd90d40699115048d59 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 5 Jul 2022 13:18:14 +0100 Subject: [PATCH 180/183] compat. torch 1.7/1.8 sw integration Signed-off-by: Wenqi Li --- monai/data/meta_tensor.py | 2 ++ monai/inferers/utils.py | 2 +- tests/test_integration_sliding_window.py | 9 ++++++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index 1582652f53..969f93872f 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -66,6 +66,8 @@ class MetaTensor(MetaObj, torch.Tensor): not work if `im` is of type `MetaTensor`. This can be resolved with `torch.jit.trace(net, im.as_tensor())`. - For pytorch < 1.8, sharing `MetaTensor` instances across processes may not be supported. + - For pytorch < 1.9, next(iter(meta_tensor)) returns a torch.Tensor. + see: https://github.com/pytorch/pytorch/issues/54457 - A warning will be raised if in the constructor `affine` is not `None` and `meta` already contains the key `affine`. - You can query whether the `MetaTensor` is a batch with the `is_batch` attribute. diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index 2fa0b79476..5126b23c0a 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -137,7 +137,7 @@ def sliding_window_inference( diff = max(roi_size[k - 2] - inputs.shape[k], 0) half = diff // 2 pad_size.extend([half, diff - half]) - inputs = F.pad(inputs, pad=pad_size, mode=look_up_option(padding_mode, PytorchPadMode).value, value=cval) + inputs = F.pad(inputs, pad=pad_size, mode=look_up_option(padding_mode, PytorchPadMode), value=cval) scan_interval = _get_scan_interval(image_size, roi_size, num_spatial_dims, overlap) diff --git a/tests/test_integration_sliding_window.py b/tests/test_integration_sliding_window.py index ba1f96c1bc..9b1c7e5200 100644 --- a/tests/test_integration_sliding_window.py +++ b/tests/test_integration_sliding_window.py @@ -24,7 +24,7 @@ from monai.networks import eval_mode, predict_segmentation from monai.networks.nets import UNet from monai.transforms import AddChannel, SaveImage -from monai.utils import set_determinism +from monai.utils import pytorch_after, set_determinism from tests.utils import DistTestCase, TimedCall, make_nifti_image, skip_if_quick @@ -47,8 +47,11 @@ def _sliding_window_processor(_engine, batch): return predict_segmentation(seg_probs) def save_func(engine): - for m in engine.state.output: - saver(m) + if pytorch_after(1, 9, 1): + for m in engine.state.output: + saver(m) + else: + saver(engine.state.output[0]) infer_engine = Engine(_sliding_window_processor) infer_engine.add_event_handler(Events.ITERATION_COMPLETED, save_func) From 6638abf2857110e43bb5d870fe1976a8594d238c Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 6 Jul 2022 18:20:57 +0100 Subject: [PATCH 181/183] 4641 PadListDataCollate for metatensor (#4642) fixes #4641 inverse padlist Signed-off-by: Wenqi Li --- monai/transforms/croppad/batch.py | 24 +++++++++++++++--------- tests/test_pad_collation.py | 5 ++++- tests/test_testtimeaugmentation.py | 4 +--- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py index a4fc952745..34945b6b84 100644 --- a/monai/transforms/croppad/batch.py +++ b/monai/transforms/croppad/batch.py @@ -19,6 +19,7 @@ import numpy as np import torch +from monai.data.meta_tensor import MetaTensor from monai.data.utils import list_data_collate from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad from monai.transforms.inverse import InvertibleTransform @@ -112,13 +113,18 @@ def inverse(data: dict) -> Dict[Hashable, np.ndarray]: d = deepcopy(data) for key in d: - transform_key = InvertibleTransform.trace_key(key) - if transform_key in d: - transform = d[transform_key][-1] - if not isinstance(transform, Dict): - continue - if transform.get(TraceKeys.CLASS_NAME) == PadListDataCollate.__name__: - d[key] = CenterSpatialCrop(transform.get("orig_size", -1))(d[key]) # fallback to image size - # remove transform - d[transform_key].pop() + transforms = None + if isinstance(d[key], MetaTensor): + transforms = d[key].applied_operations + else: + transform_key = InvertibleTransform.trace_key(key) + if transform_key in d: + transforms = d[transform_key] + if not transforms or not isinstance(transforms[-1], Dict): + continue + if transforms[-1].get(TraceKeys.CLASS_NAME) == PadListDataCollate.__name__: + xform = transforms.pop() + cropping = CenterSpatialCrop(xform.get(TraceKeys.ORIG_SIZE, -1)) + with cropping.trace_transform(False): + d[key] = cropping(d[key]) # fallback to image size return d diff --git a/tests/test_pad_collation.py b/tests/test_pad_collation.py index 9ea3a7bc73..76a49b5b54 100644 --- a/tests/test_pad_collation.py +++ b/tests/test_pad_collation.py @@ -98,9 +98,12 @@ def test_pad_collation(self, t_type, collate_method, transform): # check collation in forward direction for data in loader: if t_type == dict: + shapes = [] decollated_data = decollate_batch(data) for d in decollated_data: - PadListDataCollate.inverse(d) + output = PadListDataCollate.inverse(d) + shapes.append(output["image"].shape) + self.assertTrue(len(set(shapes)) > 1) # inverted shapes must be different because of random xforms if __name__ == "__main__": diff --git a/tests/test_testtimeaugmentation.py b/tests/test_testtimeaugmentation.py index 75f3fdc181..d5aa1af688 100644 --- a/tests/test_testtimeaugmentation.py +++ b/tests/test_testtimeaugmentation.py @@ -58,9 +58,7 @@ def get_data(num_examples, input_size, data_type=np.asarray, include_label=True) data = [] for i in range(num_examples): im, label = custom_create_test_image_2d() - d = {} - d["image"] = data_type(im[:, i:]) - d[PostFix.meta("image")] = {"affine": np.eye(4)} + d = {"image": data_type(im[:, i:])} if include_label: d["label"] = data_type(label[:, i:]) d[PostFix.meta("label")] = {"affine": np.eye(4)} From 4ddd2bc3870a86fb0a300c20e680de48886bbfc1 Mon Sep 17 00:00:00 2001 From: vale-salvatelli Date: Wed, 6 Jul 2022 20:34:16 +0100 Subject: [PATCH 182/183] Fix casting of location dtype in GridPatch (#4635) Signed-off-by: vale-salvatelli --- monai/transforms/spatial/array.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 0a3b7779ef..d875d3adee 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -3129,7 +3129,10 @@ def __call__(self, array: NdarrayOrTensor): # Convert to original data type output = list( - zip(convert_to_dst_type(src=patched_image, dst=array)[0], convert_to_dst_type(src=locations, dst=array)[0]) + zip( + convert_to_dst_type(src=patched_image, dst=array)[0], + convert_to_dst_type(src=locations, dst=array, dtype=int)[0], + ) ) # Pad the patch list to have the requested number of patches From e2707418c1d41ff06c7cddb0b98ca977720d4c87 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 7 Jul 2022 03:05:55 +0100 Subject: [PATCH 183/183] 4636 4637 backward compatible types (#4638) Signed-off-by: Wenqi Li --- monai/data/meta_tensor.py | 43 +++++++++++++--- monai/transforms/io/array.py | 7 ++- monai/transforms/io/dictionary.py | 6 ++- monai/transforms/utility/array.py | 15 +++++- monai/transforms/utility/dictionary.py | 22 +++------ monai/utils/type_conversion.py | 9 ++-- tests/test_arraydataset.py | 14 +++--- tests/test_decollate.py | 6 +-- tests/test_ensure_channel_first.py | 6 +-- tests/test_ensure_type.py | 7 +-- tests/test_ensure_typed.py | 7 ++- tests/test_image_rw.py | 6 +-- tests/test_integration_bundle_run.py | 2 +- tests/test_integration_classification_2d.py | 6 ++- tests/test_invertd.py | 4 +- tests/test_load_image.py | 49 +++++++++---------- tests/test_load_imaged.py | 16 +++--- tests/test_meta_affine.py | 4 +- tests/test_meta_tensor.py | 11 ++++- tests/test_nifti_rw.py | 2 +- tests/test_nifti_saver.py | 4 +- ...test_scale_intensity_range_percentilesd.py | 2 +- .../transform_metatensor_cases.yaml | 3 ++ 23 files changed, 152 insertions(+), 99 deletions(-) diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py index 969f93872f..d0818bd25e 100644 --- a/monai/data/meta_tensor.py +++ b/monai/data/meta_tensor.py @@ -15,13 +15,15 @@ from copy import deepcopy from typing import Any, Sequence +import numpy as np import torch from monai.config.type_definitions import NdarrayTensor from monai.data.meta_obj import MetaObj, get_track_meta from monai.data.utils import affine_to_spacing, decollate_batch, list_data_collate, remove_extra_metadata +from monai.utils import look_up_option from monai.utils.enums import PostFix -from monai.utils.type_conversion import convert_to_tensor +from monai.utils.type_conversion import convert_data_type, convert_to_tensor __all__ = ["MetaTensor"] @@ -307,6 +309,33 @@ def as_dict(self, key: str) -> dict: PostFix.transforms(key): deepcopy(self.applied_operations), } + def astype(self, dtype, device=None, *unused_args, **unused_kwargs): + """ + Cast to ``dtype``, sharing data whenever possible. + + Args: + dtype: dtypes such as np.float32, torch.float, "np.float32", float. + device: the device if `dtype` is a torch data type. + unused_args: additional args (currently unused). + unused_kwargs: additional kwargs (currently unused). + + Returns: + data array instance + """ + if isinstance(dtype, str): + mod_str, *dtype = dtype.split(".", 1) + dtype = mod_str if not dtype else dtype[0] + else: + mod_str = getattr(dtype, "__module__", "torch") + mod_str = look_up_option(mod_str, {"torch", "numpy", "np"}, default="numpy") + if mod_str == "torch": + out_type = torch.Tensor + elif mod_str in ("numpy", "np"): + out_type = np.ndarray + else: + out_type = None + return convert_data_type(self, output_type=out_type, device=device, dtype=dtype, wrap_sequence=True)[0] + @property def affine(self) -> torch.Tensor: """Get the affine.""" @@ -334,7 +363,7 @@ def new_empty(self, size, dtype=None, device=None, requires_grad=False): ) @staticmethod - def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict): + def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict, simple_keys: bool = False): """ Convert the image to `torch.Tensor`. If `affine` is in the `meta` dictionary, convert that to `torch.Tensor`, too. Remove any superfluous metadata. @@ -353,12 +382,12 @@ def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict): if not get_track_meta() or meta is None: return img - # ensure affine is of type `torch.Tensor` - if "affine" in meta: - meta["affine"] = convert_to_tensor(meta["affine"]) - # remove any superfluous metadata. - remove_extra_metadata(meta) + if simple_keys: + # ensure affine is of type `torch.Tensor` + if "affine" in meta: + meta["affine"] = convert_to_tensor(meta["affine"]) # bc-breaking + remove_extra_metadata(meta) # bc-breaking # return the `MetaTensor` return MetaTensor(img, meta=meta) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 52bfebcd76..8dd849d33e 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -108,9 +108,10 @@ class LoadImage(Transform): def __init__( self, reader=None, - image_only: bool = True, + image_only: bool = False, dtype: DtypeLike = np.float32, ensure_channel_first: bool = False, + simple_keys: bool = False, *args, **kwargs, ) -> None: @@ -127,6 +128,7 @@ def __init__( dtype: if not None convert the loaded image to this data type. ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert the image array shape to `channel first`. default to `False`. + simple_keys: whether to remove redundant metadata keys, default to False for backward compatibility. args: additional parameters for reader if providing a reader name. kwargs: additional parameters for reader if providing a reader name. @@ -145,6 +147,7 @@ def __init__( self.image_only = image_only self.dtype = dtype self.ensure_channel_first = ensure_channel_first + self.simple_keys = simple_keys self.readers: List[ImageReader] = [] for r in SUPPORTED_READERS: # set predefined readers as default @@ -255,7 +258,7 @@ def __call__(self, filename: Union[Sequence[PathLike], PathLike], reader: Option meta_data = switch_endianness(meta_data, "<") meta_data[Key.FILENAME_OR_OBJ] = f"{ensure_tuple(filename)[0]}" # Path obj should be strings for data loader - img = MetaTensor.ensure_torch_and_prune_meta(img_array, meta_data) + img = MetaTensor.ensure_torch_and_prune_meta(img_array, meta_data, self.simple_keys) if self.ensure_channel_first: img = EnsureChannelFirst()(img) if self.image_only: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index c166b44956..42918f5e63 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -72,8 +72,9 @@ def __init__( meta_keys: Optional[KeysCollection] = None, meta_key_postfix: str = DEFAULT_POST_FIX, overwriting: bool = False, - image_only: bool = True, + image_only: bool = False, ensure_channel_first: bool = False, + simple_keys: bool = False, allow_missing_keys: bool = False, *args, **kwargs, @@ -103,12 +104,13 @@ def __init__( dictionary containing image data array and header dict per input key. ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert the image array shape to `channel first`. default to `False`. + simple_keys: whether to remove redundant metadata keys, default to False for backward compatibility. allow_missing_keys: don't raise exception if key is missing. args: additional parameters for reader if providing a reader name. kwargs: additional parameters for reader if providing a reader name. """ super().__init__(keys, allow_missing_keys) - self._loader = LoadImage(reader, image_only, dtype, ensure_channel_first, *args, **kwargs) + self._loader = LoadImage(reader, image_only, dtype, ensure_channel_first, simple_keys, *args, **kwargs) if not isinstance(meta_key_postfix, str): raise TypeError(f"meta_key_postfix must be a str but is {type(meta_key_postfix).__name__}.") self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 8f84eb2531..41973ccb47 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -435,6 +435,8 @@ class EnsureType(Transform): device: for Tensor data type, specify the target device. wrap_sequence: if `False`, then lists will recursively call this function, default to `True`. E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`. + track_meta: whether to convert to `MetaTensor` when `data_type` is "tensor". + If False, the output data type will be `torch.Tensor`. Default to the return value of ``get_track_meta``. """ @@ -446,11 +448,13 @@ def __init__( dtype: Optional[Union[DtypeLike, torch.dtype]] = None, device: Optional[torch.device] = None, wrap_sequence: bool = True, + track_meta: Optional[bool] = None, ) -> None: self.data_type = look_up_option(data_type.lower(), {"tensor", "numpy"}) self.dtype = dtype self.device = device self.wrap_sequence = wrap_sequence + self.track_meta = get_track_meta() if track_meta is None else bool(track_meta) def __call__(self, data: NdarrayOrTensor): """ @@ -461,10 +465,17 @@ def __call__(self, data: NdarrayOrTensor): if applicable and `wrap_sequence=False`. """ - output_type = torch.Tensor if self.data_type == "tensor" else np.ndarray + if self.data_type == "tensor": + output_type = MetaTensor if self.track_meta else torch.Tensor + else: + output_type = np.ndarray # type: ignore out: NdarrayOrTensor out, *_ = convert_data_type( - data=data, output_type=output_type, dtype=self.dtype, device=self.device, wrap_sequence=self.wrap_sequence + data=data, + output_type=output_type, # type: ignore + dtype=self.dtype, + device=self.device, + wrap_sequence=self.wrap_sequence, ) return out diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index e5f4b2f058..f8b5bb737b 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -62,7 +62,7 @@ ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.transforms.utils_pytorch_numpy_unification import concatenate -from monai.utils import convert_to_numpy, deprecated, deprecated_arg, ensure_tuple, ensure_tuple_rep +from monai.utils import deprecated, deprecated_arg, ensure_tuple, ensure_tuple_rep from monai.utils.enums import PostFix, TraceKeys, TransformBackends from monai.utils.type_conversion import convert_to_dst_type @@ -519,7 +519,7 @@ def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Nd return d -class EnsureTyped(MapTransform, InvertibleTransform): +class EnsureTyped(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.EnsureType`. @@ -541,6 +541,7 @@ def __init__( dtype: Union[DtypeLike, torch.dtype] = None, device: Optional[torch.device] = None, wrap_sequence: bool = True, + track_meta: Optional[bool] = None, allow_missing_keys: bool = False, ) -> None: """ @@ -552,28 +553,21 @@ def __init__( device: for Tensor data type, specify the target device. wrap_sequence: if `False`, then lists will recursively call this function, default to `True`. E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`. + track_meta: whether to convert to `MetaTensor` when `data_type` is "tensor". + If False, the output data type will be `torch.Tensor`. Default to the return value of `get_track_meta`. allow_missing_keys: don't raise exception if key is missing. """ super().__init__(keys, allow_missing_keys) - self.converter = EnsureType(data_type=data_type, dtype=dtype, device=device, wrap_sequence=wrap_sequence) + self.converter = EnsureType( + data_type=data_type, dtype=dtype, device=device, wrap_sequence=wrap_sequence, track_meta=track_meta + ) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): - self.push_transform(d, key) d[key] = self.converter(d[key]) return d - def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: - d = deepcopy(dict(data)) - for key in self.key_iterator(d): - # FIXME: currently, only convert tensor data to numpy array or scalar number, - # need to also invert numpy array but it's not easy to determine the previous data type - d[key] = convert_to_numpy(d[key]) - # Remove the applied transform - self.pop_transform(d, key) - return d - class ToNumpyd(MapTransform): """ diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index 93112fd572..e33a155568 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -172,7 +172,7 @@ def convert_to_numpy(data, dtype: DtypeLike = None, wrap_sequence: bool = False) E.g., `[1, 2]` -> `[array(1), array(2)]`. If `True`, then `[1, 2]` -> `array([1, 2])`. """ if isinstance(data, torch.Tensor): - data = data.detach().to(dtype=get_equivalent_dtype(dtype, torch.Tensor), device="cpu").numpy() + data = np.asarray(data.detach().to(device="cpu").numpy(), dtype=get_equivalent_dtype(dtype, np.ndarray)) elif has_cp and isinstance(data, cp_ndarray): data = cp.asnumpy(data).astype(dtype, copy=False) elif isinstance(data, (np.ndarray, float, int, bool)): @@ -235,12 +235,13 @@ def convert_data_type( wrap_sequence: bool = False, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: """ - Convert to `torch.Tensor`/`np.ndarray` from `torch.Tensor`/`np.ndarray`/`float`/`int` etc. + Convert to `MetaTensor`, `torch.Tensor` or `np.ndarray` from `MetaTensor`, `torch.Tensor`, + `np.ndarray`, `float`, `int`, etc. Args: data: data to be converted - output_type: `torch.Tensor` or `np.ndarray` (if `None`, unchanged) - device: if output is `torch.Tensor`, select device (if `None`, unchanged) + output_type: `monai.data.MetaTensor`, `torch.Tensor`, or `np.ndarray` (if `None`, unchanged) + device: if output is `MetaTensor` or `torch.Tensor`, select device (if `None`, unchanged) dtype: dtype of output data. Converted to correct library type (e.g., `np.float32` is converted to `torch.float32` if output type is `torch.Tensor`). If left blank, it remains unchanged. diff --git a/tests/test_arraydataset.py b/tests/test_arraydataset.py index eb1a767f6a..380a6372b2 100644 --- a/tests/test_arraydataset.py +++ b/tests/test_arraydataset.py @@ -23,15 +23,15 @@ from monai.transforms import AddChannel, Compose, LoadImage, RandAdjustContrast, RandGaussianNoise, Spacing TEST_CASE_1 = [ - Compose([LoadImage(), AddChannel(), RandGaussianNoise(prob=1.0)]), - Compose([LoadImage(), AddChannel(), RandGaussianNoise(prob=1.0)]), + Compose([LoadImage(image_only=True), AddChannel(), RandGaussianNoise(prob=1.0)]), + Compose([LoadImage(image_only=True), AddChannel(), RandGaussianNoise(prob=1.0)]), (0, 1), (1, 128, 128, 128), ] TEST_CASE_2 = [ - Compose([LoadImage(), AddChannel(), RandAdjustContrast(prob=1.0)]), - Compose([LoadImage(), AddChannel(), RandAdjustContrast(prob=1.0)]), + Compose([LoadImage(image_only=True), AddChannel(), RandAdjustContrast(prob=1.0)]), + Compose([LoadImage(image_only=True), AddChannel(), RandAdjustContrast(prob=1.0)]), (0, 1), (1, 128, 128, 128), ] @@ -48,13 +48,13 @@ def __call__(self, input_): TEST_CASE_3 = [ - TestCompose([LoadImage(), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), - TestCompose([LoadImage(), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), + TestCompose([LoadImage(image_only=True), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), + TestCompose([LoadImage(image_only=True), AddChannel(), Spacing(pixdim=(2, 2, 4)), RandAdjustContrast(prob=1.0)]), (0, 2), (1, 64, 64, 33), ] -TEST_CASE_4 = [Compose([LoadImage(), AddChannel(), RandGaussianNoise(prob=1.0)]), (1, 128, 128, 128)] +TEST_CASE_4 = [Compose([LoadImage(image_only=True), AddChannel(), RandGaussianNoise(prob=1.0)]), (1, 128, 128, 128)] class TestArrayDataset(unittest.TestCase): diff --git a/tests/test_decollate.py b/tests/test_decollate.py index ac9220d538..440f5e59e7 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -131,7 +131,7 @@ def test_decollation_dict(self, *transforms): t_compose = Compose([AddChanneld(KEYS), Compose(transforms), ToTensord(KEYS)]) # If nibabel present, read from disk if has_nib: - t_compose = Compose([LoadImaged("image"), t_compose]) + t_compose = Compose([LoadImaged("image", image_only=True), t_compose]) dataset = CacheDataset(self.data_dict, t_compose, progress=False) self.check_decollate(dataset=dataset) @@ -141,7 +141,7 @@ def test_decollation_tensor(self, *transforms): t_compose = Compose([AddChannel(), Compose(transforms), ToTensor()]) # If nibabel present, read from disk if has_nib: - t_compose = Compose([LoadImage(), t_compose]) + t_compose = Compose([LoadImage(image_only=True), t_compose]) dataset = Dataset(self.data_list, t_compose) self.check_decollate(dataset=dataset) @@ -151,7 +151,7 @@ def test_decollation_list(self, *transforms): t_compose = Compose([AddChannel(), Compose(transforms), ToTensor()]) # If nibabel present, read from disk if has_nib: - t_compose = Compose([LoadImage(), t_compose]) + t_compose = Compose([LoadImage(image_only=True), t_compose]) dataset = Dataset(self.data_list, t_compose) self.check_decollate(dataset=dataset) diff --git a/tests/test_ensure_channel_first.py b/tests/test_ensure_channel_first.py index b97578ba1d..1cb5ac6dec 100644 --- a/tests/test_ensure_channel_first.py +++ b/tests/test_ensure_channel_first.py @@ -52,13 +52,13 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) - result = LoadImage(**input_param)(filenames) + result = LoadImage(image_only=True, **input_param)(filenames) result = EnsureChannelFirst()(result) self.assertEqual(result.shape[0], len(filenames)) @parameterized.expand([TEST_CASE_7]) def test_itk_dicom_series_reader(self, input_param, filenames, _): - result = LoadImage(**input_param)(filenames) + result = LoadImage(image_only=True, **input_param)(filenames) result = EnsureChannelFirst()(result) self.assertEqual(result.shape[0], 1) @@ -68,7 +68,7 @@ def test_load_png(self): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_image.png") Image.fromarray(test_image.astype("uint8")).save(filename) - result = LoadImage()(filename) + result = LoadImage(image_only=True)(filename) result = EnsureChannelFirst()(result) self.assertEqual(result.shape[0], 3) diff --git a/tests/test_ensure_type.py b/tests/test_ensure_type.py index f8a6ee30ff..55423838b8 100644 --- a/tests/test_ensure_type.py +++ b/tests/test_ensure_type.py @@ -14,6 +14,7 @@ import numpy as np import torch +from monai.data import MetaTensor from monai.transforms import EnsureType from tests.utils import assert_allclose @@ -59,9 +60,9 @@ def test_string(self): def test_list_tuple(self): for dtype in ("tensor", "numpy"): - result = EnsureType(data_type=dtype, wrap_sequence=False)([[1, 2], [3, 4]]) + result = EnsureType(data_type=dtype, wrap_sequence=False, track_meta=True)([[1, 2], [3, 4]]) self.assertTrue(isinstance(result, list)) - self.assertTrue(isinstance(result[0][1], torch.Tensor if dtype == "tensor" else np.ndarray)) + self.assertTrue(isinstance(result[0][1], MetaTensor if dtype == "tensor" else np.ndarray)) torch.testing.assert_allclose(result[1][0], torch.as_tensor(3)) # tuple of numpy arrays result = EnsureType(data_type=dtype, wrap_sequence=False)((np.array([1, 2]), np.array([3, 4]))) @@ -77,7 +78,7 @@ def test_dict(self): "extra": None, } for dtype in ("tensor", "numpy"): - result = EnsureType(data_type=dtype)(test_data) + result = EnsureType(data_type=dtype, track_meta=False)(test_data) self.assertTrue(isinstance(result, dict)) self.assertTrue(isinstance(result["img"], torch.Tensor if dtype == "tensor" else np.ndarray)) torch.testing.assert_allclose(result["img"], torch.as_tensor([1.0, 2.0])) diff --git a/tests/test_ensure_typed.py b/tests/test_ensure_typed.py index cadab9bd56..d57170e2a6 100644 --- a/tests/test_ensure_typed.py +++ b/tests/test_ensure_typed.py @@ -14,6 +14,7 @@ import numpy as np import torch +from monai.data import MetaTensor from monai.transforms import EnsureTyped from tests.utils import assert_allclose @@ -61,9 +62,11 @@ def test_string(self): def test_list_tuple(self): for dtype in ("tensor", "numpy"): - result = EnsureTyped(keys="data", data_type=dtype, wrap_sequence=False)({"data": [[1, 2], [3, 4]]})["data"] + result = EnsureTyped(keys="data", data_type=dtype, wrap_sequence=False, track_meta=True)( + {"data": [[1, 2], [3, 4]]} + )["data"] self.assertTrue(isinstance(result, list)) - self.assertTrue(isinstance(result[0][1], torch.Tensor if dtype == "tensor" else np.ndarray)) + self.assertTrue(isinstance(result[0][1], MetaTensor if dtype == "tensor" else np.ndarray)) torch.testing.assert_allclose(result[1][0], torch.as_tensor(3)) # tuple of numpy arrays result = EnsureTyped(keys="data", data_type=dtype, wrap_sequence=False)( diff --git a/tests/test_image_rw.py b/tests/test_image_rw.py index 159a99c27e..f234b32c24 100644 --- a/tests/test_image_rw.py +++ b/tests/test_image_rw.py @@ -52,7 +52,7 @@ def nifti_rw(self, test_data, reader, writer, dtype, resample=True): saver(test_data) saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) self.assertTrue(os.path.exists(saved_path)) - loader = LoadImage(reader=reader, squeeze_non_spatial_dims=True) + loader = LoadImage(image_only=True, reader=reader, squeeze_non_spatial_dims=True) data = loader(saved_path) meta = data.meta if meta["original_channel_dim"] == -1: @@ -101,7 +101,7 @@ def png_rw(self, test_data, reader, writer, dtype, resample=True): saver(test_data) saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) self.assertTrue(os.path.exists(saved_path)) - loader = LoadImage(reader=reader) + loader = LoadImage(image_only=True, reader=reader) data = loader(saved_path) meta = data.meta if meta["original_channel_dim"] == -1: @@ -157,7 +157,7 @@ def nrrd_rw(self, test_data, reader, writer, dtype, resample=True): ) saver(test_data) saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext) - loader = LoadImage(reader=reader) + loader = LoadImage(image_only=True, reader=reader) data = loader(saved_path) assert_allclose(data, torch.as_tensor(test_data)) diff --git a/tests/test_integration_bundle_run.py b/tests/test_integration_bundle_run.py index c81836099d..6f20c55fe2 100644 --- a/tests/test_integration_bundle_run.py +++ b/tests/test_integration_bundle_run.py @@ -97,7 +97,7 @@ def test_shape(self, config_file, expected_shape): test_env = os.environ.copy() print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES")) subprocess.check_call(la + ["--args_file", def_args_file], env=test_env) - loader = LoadImage() + loader = LoadImage(image_only=True) self.assertTupleEqual(loader(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape) # here test the script with `google fire` tool as CLI diff --git a/tests/test_integration_classification_2d.py b/tests/test_integration_classification_2d.py index 9bfe7648d0..78f0bf9f36 100644 --- a/tests/test_integration_classification_2d.py +++ b/tests/test_integration_classification_2d.py @@ -61,7 +61,7 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", # define transforms for image and classification train_transforms = Compose( [ - LoadImage(image_only=True), + LoadImage(image_only=True, simple_keys=True), AddChannel(), Transpose(indices=[0, 2, 1]), ScaleIntensity(), @@ -71,7 +71,9 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", ] ) train_transforms.set_random_state(1234) - val_transforms = Compose([LoadImage(image_only=True), AddChannel(), Transpose(indices=[0, 2, 1]), ScaleIntensity()]) + val_transforms = Compose( + [LoadImage(image_only=True, simple_keys=True), AddChannel(), Transpose(indices=[0, 2, 1]), ScaleIntensity()] + ) y_pred_trans = Compose([Activations(softmax=True)]) y_trans = AsDiscrete(to_onehot=len(np.unique(train_y))) auc_metric = ROCAUCMetric() diff --git a/tests/test_invertd.py b/tests/test_invertd.py index 1c364cd02e..fc4725d98b 100644 --- a/tests/test_invertd.py +++ b/tests/test_invertd.py @@ -46,7 +46,7 @@ def test_invert(self): im_fname, seg_fname = (make_nifti_image(i) for i in create_test_image_3d(101, 100, 107, noise_max=100)) transform = Compose( [ - LoadImaged(KEYS), + LoadImaged(KEYS, image_only=True), EnsureChannelFirstd(KEYS), Orientationd(KEYS, "RPS"), Spacingd(KEYS, pixdim=(1.2, 1.01, 0.9), mode=["bilinear", "nearest"], dtype=np.float32), @@ -121,7 +121,7 @@ def test_invert(self): # check labels match reverted = item["label_inverted"].detach().cpu().numpy().astype(np.int32) - original = LoadImaged(KEYS)(data[-1])["label"] + original = LoadImaged(KEYS, image_only=True)(data[-1])["label"] n_good = np.sum(np.isclose(reverted, original, atol=1e-3)) reverted_name = item["label_inverted"].meta["filename_or_obj"] original_name = data[-1]["label"] diff --git a/tests/test_load_image.py b/tests/test_load_image.py index aaa9e89211..aad82df1db 100644 --- a/tests/test_load_image.py +++ b/tests/test_load_image.py @@ -122,22 +122,17 @@ def get_data(self, _obj): ] # test same dicom data with PydicomReader -TEST_CASE_19 = [ - {"image_only": True, "reader": PydicomReader()}, - "tests/testing_data/CT_DICOM", - (16, 16, 4), - (16, 16, 4), -] +TEST_CASE_19 = [{"reader": PydicomReader()}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4)] TEST_CASE_20 = [ - {"image_only": True, "reader": "PydicomReader", "ensure_channel_first": True}, + {"reader": "PydicomReader", "ensure_channel_first": True}, "tests/testing_data/CT_DICOM", (16, 16, 4), (1, 16, 16, 4), ] TEST_CASE_21 = [ - {"image_only": True, "reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"}, + {"reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4), @@ -163,7 +158,7 @@ def test_nibabel_reader(self, input_param, filenames, expected_shape): for i, name in enumerate(filenames): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) - result = LoadImage(**input_param)(filenames) + result = LoadImage(image_only=True, **input_param)(filenames) ext = "".join(Path(name).suffixes) self.assertEqual(result.meta["filename_or_obj"], os.path.join(tempdir, "test_image" + ext)) assert_allclose(result.affine, torch.eye(4)) @@ -177,7 +172,7 @@ def test_itk_reader(self, input_param, filenames, expected_shape): filenames[i] = os.path.join(tempdir, name) itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filenames[i]) - result = LoadImage(**input_param)(filenames) + result = LoadImage(image_only=True, **input_param)(filenames) self.assertEqual(result.meta["filename_or_obj"], os.path.join(tempdir, "test_image.nii.gz")) diag = torch.as_tensor(np.diag([-1, -1, 1, 1])) np.testing.assert_allclose(result.affine, diag) @@ -185,7 +180,7 @@ def test_itk_reader(self, input_param, filenames, expected_shape): @parameterized.expand([TEST_CASE_10, TEST_CASE_11, TEST_CASE_12, TEST_CASE_19, TEST_CASE_20, TEST_CASE_21]) def test_itk_dicom_series_reader(self, input_param, filenames, expected_shape, expected_np_shape): - result = LoadImage(**input_param)(filenames) + result = LoadImage(image_only=True, **input_param)(filenames) self.assertEqual(result.meta["filename_or_obj"], f"{Path(filenames)}") assert_allclose( result.affine, @@ -207,7 +202,7 @@ def test_itk_reader_multichannel(self): itk_np_view = itk.image_view_from_array(test_image, is_vector=True) itk.imwrite(itk_np_view, filename) for flag in (False, True): - result = LoadImage(reader=ITKReader(reverse_indexing=flag))(Path(filename)) + result = LoadImage(image_only=True, reader=ITKReader(reverse_indexing=flag))(Path(filename)) test_image = test_image.transpose(1, 0, 2) np.testing.assert_allclose(result[:, :, 0], test_image[:, :, 0]) np.testing.assert_allclose(result[:, :, 1], test_image[:, :, 1]) @@ -220,8 +215,8 @@ def test_dicom_reader_consistency(self, filenames): for affine_flag in [True, False]: itk_param["affine_lps_to_ras"] = affine_flag pydicom_param["affine_lps_to_ras"] = affine_flag - itk_result = LoadImage(**itk_param)(filenames) - pydicom_result = LoadImage(**pydicom_param)(filenames) + itk_result = LoadImage(image_only=True, **itk_param)(filenames) + pydicom_result = LoadImage(image_only=True, **pydicom_param)(filenames) np.testing.assert_allclose(pydicom_result, itk_result) np.testing.assert_allclose(pydicom_result.affine, itk_result.affine) @@ -232,10 +227,10 @@ def test_load_nifti_multichannel(self): itk_np_view = itk.image_view_from_array(test_image, is_vector=True) itk.imwrite(itk_np_view, filename) - itk_img = LoadImage(reader=ITKReader())(Path(filename)) + itk_img = LoadImage(image_only=True, reader=ITKReader())(Path(filename)) self.assertTupleEqual(tuple(itk_img.shape), (16, 64, 31, 2)) - nib_image = LoadImage(reader=NibabelReader(squeeze_non_spatial_dims=True))(Path(filename)) + nib_image = LoadImage(image_only=True, reader=NibabelReader(squeeze_non_spatial_dims=True))(Path(filename)) self.assertTupleEqual(tuple(nib_image.shape), (16, 64, 31, 2)) np.testing.assert_allclose(itk_img, nib_image, atol=1e-3, rtol=1e-3) @@ -246,7 +241,7 @@ def test_load_png(self): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_image.png") Image.fromarray(test_image.astype("uint8")).save(filename) - result = LoadImage()(filename) + result = LoadImage(image_only=True)(filename) self.assertTupleEqual(result.shape, spatial_size[::-1]) np.testing.assert_allclose(result.T, test_image) @@ -258,7 +253,7 @@ def test_register(self): itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filename) - loader = LoadImage() + loader = LoadImage(image_only=True) loader.register(ITKReader()) result = loader(filename) self.assertTupleEqual(result.shape, spatial_size[::-1]) @@ -271,7 +266,7 @@ def test_kwargs(self): itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filename) - loader = LoadImage() + loader = LoadImage(image_only=True) reader = ITKReader(fallback_only=False) loader.register(reader) result = loader(filename) @@ -284,19 +279,21 @@ def test_kwargs(self): def test_my_reader(self): """test customised readers""" - out = LoadImage(reader=_MiniReader, is_compatible=True)("test") + out = LoadImage(image_only=True, reader=_MiniReader, is_compatible=True)("test") self.assertEqual(out.meta["name"], "my test") - out = LoadImage(reader=_MiniReader, is_compatible=False)("test") + out = LoadImage(image_only=True, reader=_MiniReader, is_compatible=False)("test") self.assertEqual(out.meta["name"], "my test") for item in (_MiniReader, _MiniReader(is_compatible=False)): - out = LoadImage(reader=item)("test") + out = LoadImage(image_only=True, reader=item)("test") self.assertEqual(out.meta["name"], "my test") - out = LoadImage()("test", reader=_MiniReader(is_compatible=False)) + out = LoadImage(image_only=True)("test", reader=_MiniReader(is_compatible=False)) self.assertEqual(out.meta["name"], "my test") def test_itk_meta(self): """test metadata from a directory""" - out = LoadImage(reader="ITKReader", pixel_type=itk.UC, series_meta=True)("tests/testing_data/CT_DICOM") + out = LoadImage(image_only=True, reader="ITKReader", pixel_type=itk.UC, series_meta=True)( + "tests/testing_data/CT_DICOM" + ) idx = "0008|103e" label = itk.GDCMImageIO.GetLabelFromTag(idx, "")[1] val = out.meta[idx] @@ -309,7 +306,7 @@ def test_channel_dim(self, input_param, filename, expected_shape): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, filename) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filename) - result = LoadImage(**input_param)(filename) + result = LoadImage(image_only=True, **input_param)(filename) self.assertTupleEqual( result.shape, (3, 128, 128, 128) if input_param.get("ensure_channel_first", False) else expected_shape @@ -334,7 +331,7 @@ def tearDownClass(cls): @parameterized.expand(TESTS_META) def test_correct(self, input_param, expected_shape, track_meta): set_track_meta(track_meta) - r = LoadImage(**input_param)(self.test_data) + r = LoadImage(image_only=True, **input_param)(self.test_data) self.assertTupleEqual(r.shape, expected_shape) if track_meta: self.assertIsInstance(r, MetaTensor) diff --git a/tests/test_load_imaged.py b/tests/test_load_imaged.py index 5dfb266f6c..1f2ce31ad2 100644 --- a/tests/test_load_imaged.py +++ b/tests/test_load_imaged.py @@ -49,7 +49,7 @@ def test_shape(self, input_param, expected_shape): for key in KEYS: nib.save(test_image, os.path.join(tempdir, key + ".nii.gz")) test_data.update({key: os.path.join(tempdir, key + ".nii.gz")}) - result = LoadImaged(**input_param)(test_data) + result = LoadImaged(image_only=True, **input_param)(test_data) for key in KEYS: self.assertTupleEqual(result[key].shape, expected_shape) @@ -62,7 +62,7 @@ def test_register(self): itk_np_view = itk.image_view_from_array(test_image) itk.imwrite(itk_np_view, filename) - loader = LoadImaged(keys="img") + loader = LoadImaged(keys="img", image_only=True) loader.register(ITKReader()) result = loader({"img": Path(filename)}) self.assertTupleEqual(result["img"].shape, spatial_size[::-1]) @@ -74,7 +74,7 @@ def test_channel_dim(self): filename = os.path.join(tempdir, "test_image.nii.gz") nib.save(nib.Nifti1Image(test_image, affine=np.eye(4)), filename) - loader = LoadImaged(keys="img") + loader = LoadImaged(keys="img", image_only=True) loader.register(ITKReader(channel_dim=2)) t = Compose([EnsureChannelFirstD("img"), FromMetaTensord("img")]) result = t(loader({"img": filename})) @@ -82,16 +82,16 @@ def test_channel_dim(self): def test_no_file(self): with self.assertRaises(RuntimeError): - LoadImaged(keys="img")({"img": "unknown"}) + LoadImaged(keys="img", image_only=True)({"img": "unknown"}) with self.assertRaises(RuntimeError): - LoadImaged(keys="img", reader="nibabelreader")({"img": "unknown"}) + LoadImaged(keys="img", reader="nibabelreader", image_only=True)({"img": "unknown"}) class TestConsistency(unittest.TestCase): def _cmp(self, filename, ch_shape, reader_1, reader_2, outname, ext): data_dict = {"img": filename} keys = data_dict.keys() - xforms = Compose([LoadImaged(keys, reader=reader_1, ensure_channel_first=True)]) + xforms = Compose([LoadImaged(keys, reader=reader_1, ensure_channel_first=True, image_only=True)]) img_dict = xforms(data_dict) # load dicom with itk self.assertTupleEqual(img_dict["img"].shape, ch_shape) @@ -101,7 +101,7 @@ def _cmp(self, filename, ch_shape, reader_1, reader_2, outname, ext): new_xforms = Compose( [ - LoadImaged(keys, reader=reader_2), + LoadImaged(keys, reader=reader_2, image_only=True), EnsureChannelFirstD(keys), FromMetaTensord(keys), ToMetaTensord(keys), @@ -166,7 +166,7 @@ def tearDownClass(cls): @parameterized.expand(TESTS_META) def test_correct(self, input_param, expected_shape, track_meta): set_track_meta(track_meta) - result = LoadImaged(**input_param)(self.test_data) + result = LoadImaged(image_only=True, **input_param)(self.test_data) # shouldn't have any extra meta data keys self.assertEqual(len(result), len(KEYS)) diff --git a/tests/test_meta_affine.py b/tests/test_meta_affine.py index 437fee112d..269db33ef4 100644 --- a/tests/test_meta_affine.py +++ b/tests/test_meta_affine.py @@ -146,7 +146,7 @@ def run_transform(self, img, xform_cls, args_dict): @parameterized.expand(TEST_CASES_ARRAY) def test_linear_consistent(self, xform_cls, input_dict, atol): """xform cls testing itk consistency""" - img = LoadImage()(FILE_PATH) + img = LoadImage(image_only=True, simple_keys=True)(FILE_PATH) img = EnsureChannelFirst()(img) ref_1 = _create_itk_obj(img[0], img.affine) output = self.run_transform(img, xform_cls, input_dict) @@ -162,7 +162,7 @@ def test_linear_consistent(self, xform_cls, input_dict, atol): @parameterized.expand(TEST_CASES_DICT) def test_linear_consistent_dict(self, xform_cls, input_dict, atol): """xform cls testing itk consistency""" - img = LoadImaged(keys)({keys[0]: FILE_PATH, keys[1]: FILE_PATH_1}) + img = LoadImaged(keys, image_only=True, simple_keys=True)({keys[0]: FILE_PATH, keys[1]: FILE_PATH_1}) img = EnsureChannelFirstd(keys)(img) ref_1 = {k: _create_itk_obj(img[k][0], img[k].affine) for k in keys} output = self.run_transform(img, xform_cls, input_dict) diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 8fafdd7976..c1b8bc5046 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -20,6 +20,7 @@ from multiprocessing.reduction import ForkingPickler from typing import Optional, Union +import numpy as np import torch import torch.multiprocessing from parameterized import parameterized @@ -433,6 +434,14 @@ def test_str(self): for s in (s1, s2): self.assertEqual(s, expected_out) + def test_astype(self): + t = MetaTensor([1.0], affine=torch.tensor(1), meta={"fname": "filename"}) + for np_types in ("float32", "np.float32", "numpy.float32", np.float32, float, "int", np.compat.long, np.uint16): + self.assertIsInstance(t.astype(np_types), np.ndarray) + for pt_types in ("torch.float", torch.float, "torch.float64"): + self.assertIsInstance(t.astype(pt_types), torch.Tensor) + self.assertIsInstance(t.astype("torch.float", device="cpu"), torch.Tensor) + def test_transforms(self): key = "im" _, im = self.get_im() @@ -441,7 +450,6 @@ def test_transforms(self): data = {key: im, PostFix.meta(key): {"affine": torch.eye(4)}} # apply one at a time - is_meta = isinstance(im, MetaTensor) for i, _tr in enumerate(tr.transforms): data = _tr(data) is_meta = isinstance(_tr, (ToMetaTensord, BorderPadd, DivisiblePadd)) @@ -458,7 +466,6 @@ def test_transforms(self): self.assertEqual(n_applied, i + 1) # inverse one at a time - is_meta = isinstance(im, MetaTensor) for i, _tr in enumerate(tr.transforms[::-1]): data = _tr.inverse(data) is_meta = isinstance(_tr, (FromMetaTensord, BorderPadd, DivisiblePadd)) diff --git a/tests/test_nifti_rw.py b/tests/test_nifti_rw.py index 27bd5e0ce1..fae53394c3 100644 --- a/tests/test_nifti_rw.py +++ b/tests/test_nifti_rw.py @@ -115,7 +115,7 @@ def test_orientation(self, array, affine, reader_param, expected): def test_consistency(self): np.set_printoptions(suppress=True, precision=3) test_image = make_nifti_image(np.arange(64).reshape(1, 8, 8), np.diag([1.5, 1.5, 1.5, 1])) - data = LoadImage(reader="NibabelReader", as_closest_canonical=False)(test_image) + data = LoadImage(image_only=True, reader="NibabelReader", as_closest_canonical=False)(test_image) header = data.meta data = Spacing([0.8, 0.8, 0.8])(data[None], header["affine"], mode="nearest") original_affine = data.meta["original_affine"] diff --git a/tests/test_nifti_saver.py b/tests/test_nifti_saver.py index bd1bf86207..54489904df 100644 --- a/tests/test_nifti_saver.py +++ b/tests/test_nifti_saver.py @@ -80,7 +80,7 @@ def test_saved_3d_no_resize_content(self): saver.save_batch(torch.randint(0, 255, (8, 8, 1, 2, 2)), meta_data) for i in range(8): filepath = os.path.join(tempdir, "testfile" + str(i), "testfile" + str(i) + "_seg.nii.gz") - img = LoadImage("nibabelreader")(filepath) + img = LoadImage("nibabelreader", image_only=True)(filepath) self.assertEqual(img.shape, (1, 2, 2, 8)) def test_squeeze_end_dims(self): @@ -102,7 +102,7 @@ def test_squeeze_end_dims(self): # 2d image w channel saver.save(torch.randint(0, 255, (1, 2, 2)), meta_data) - im = LoadImage()(os.path.join(tempdir, fname, fname + ".nii.gz")) + im = LoadImage(image_only=True)(os.path.join(tempdir, fname, fname + ".nii.gz")) self.assertTrue(im.ndim == 2 if squeeze_end_dims else 4) diff --git a/tests/test_scale_intensity_range_percentilesd.py b/tests/test_scale_intensity_range_percentilesd.py index 50438532d1..b35f937b95 100644 --- a/tests/test_scale_intensity_range_percentilesd.py +++ b/tests/test_scale_intensity_range_percentilesd.py @@ -54,7 +54,7 @@ def test_relative_scaling(self): expected_img = (img - expected_a_min) / (expected_a_max - expected_a_min) expected_img = (expected_img * (expected_b_max - expected_b_min)) + expected_b_min - np.testing.assert_allclose(expected_img, scaler(data)["img"]) + np.testing.assert_allclose(expected_img, scaler(data)["img"], rtol=1e-3, atol=0.1) def test_invalid_instantiation(self): self.assertRaises( diff --git a/tests/testing_data/transform_metatensor_cases.yaml b/tests/testing_data/transform_metatensor_cases.yaml index b8bcf1ed12..75275b9df5 100644 --- a/tests/testing_data/transform_metatensor_cases.yaml +++ b/tests/testing_data/transform_metatensor_cases.yaml @@ -9,6 +9,7 @@ TEST_CASE_1: - _target_: LoadImageD keys: "@input_keys" ensure_channel_first: True + image_only: True - _target_: ToDeviced keys: "@input_keys" device: "@test_device" @@ -40,6 +41,7 @@ TEST_CASE_2: - _target_: LoadImaged keys: "@input_keys" ensure_channel_first: False + image_only: True - _target_: ToDeviced keys: "@input_keys" device: "@test_device" @@ -99,6 +101,7 @@ TEST_CASE_3: - _target_: LoadImageD keys: "@input_keys" ensure_channel_first: True + image_only: True - _target_: CenterScaleCropD keys: "@input_keys" roi_scale: 0.98
  • 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�d=$yCUG4Jup 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