From 8de4af1e39611346b98e98c13d2fdd5223c70532 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 16 Sep 2022 17:33:45 +0100 Subject: [PATCH 01/60] 5090 releasing 1.0.0 (#5170) Signed-off-by: Wenqi Li Fixes #5090 ### Description update version numbers ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. 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 8fa69615c3..2ed2aeb8b1 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.10.dev${YEAR_WEEK}" + git tag "1.1.dev${YEAR_WEEK}" git log -1 git tag --list python setup.py sdist bdist_wheel diff --git a/CITATION.cff b/CITATION.cff index a2ac1845f8..ae079695ea 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-07-25 -version: "0.9.1" +date-released: 2022-09-16 +version: "1.0.0" identifiers: - description: "This DOI represents all versions of MONAI, and will always resolve to the latest one." type: doi From 0a4b08504426e24d22b3fb3b6a76b622db4f28d2 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Wed, 21 Sep 2022 00:18:14 +0800 Subject: [PATCH 02/60] Remove batch dim in `SobelGradients` and `SobelGradientsd` (#5182) Signed-off-by: KumoLiu Fixes #5176. ### Description Remove batch dim in `SobelGradients` and `SobelGradientsd` to make it consistent with other post transforms. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: KumoLiu --- monai/transforms/post/array.py | 5 +++-- tests/test_sobel_gradient.py | 5 ++--- tests/test_sobel_gradientd.py | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index fb3e019f63..2ecf6b6566 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -852,8 +852,9 @@ def __call__(self, image: NdarrayOrTensor) -> torch.Tensor: image_tensor = convert_to_tensor(image, track_meta=get_track_meta()) kernel_v = self.kernel.to(image_tensor.device) kernel_h = kernel_v.T + image_tensor = image_tensor.unsqueeze(0) # adds a batch dim grad_v = apply_filter(image_tensor, kernel_v, padding=self.padding) grad_h = apply_filter(image_tensor, kernel_h, padding=self.padding) - grad = torch.cat([grad_h, grad_v]) - + grad = torch.cat([grad_h, grad_v], dim=1) + grad, *_ = convert_to_dst_type(grad.squeeze(0), image_tensor) return grad diff --git a/tests/test_sobel_gradient.py b/tests/test_sobel_gradient.py index 0e07eecc4b..17f6bffdfb 100644 --- a/tests/test_sobel_gradient.py +++ b/tests/test_sobel_gradient.py @@ -17,8 +17,8 @@ from monai.transforms import SobelGradients from tests.utils import assert_allclose -IMAGE = torch.zeros(1, 1, 16, 16, dtype=torch.float32) -IMAGE[0, 0, 8, :] = 1 +IMAGE = torch.zeros(1, 16, 16, dtype=torch.float32) +IMAGE[0, 8, :] = 1 OUTPUT_3x3 = torch.zeros(2, 16, 16, dtype=torch.float32) OUTPUT_3x3[0, 7, :] = 2.0 OUTPUT_3x3[0, 9, :] = -2.0 @@ -28,7 +28,6 @@ OUTPUT_3x3[1, 8, 0] = 1.0 OUTPUT_3x3[1, 8, -1] = -1.0 OUTPUT_3x3[1, 7, -1] = OUTPUT_3x3[1, 9, -1] = -0.5 -OUTPUT_3x3 = OUTPUT_3x3.unsqueeze(1) TEST_CASE_0 = [IMAGE, {"kernel_size": 3, "dtype": torch.float32}, OUTPUT_3x3] TEST_CASE_1 = [IMAGE, {"kernel_size": 3, "dtype": torch.float64}, OUTPUT_3x3] diff --git a/tests/test_sobel_gradientd.py b/tests/test_sobel_gradientd.py index c25b2cefc6..b3e04da0bf 100644 --- a/tests/test_sobel_gradientd.py +++ b/tests/test_sobel_gradientd.py @@ -17,8 +17,8 @@ from monai.transforms import SobelGradientsd from tests.utils import assert_allclose -IMAGE = torch.zeros(1, 1, 16, 16, dtype=torch.float32) -IMAGE[0, 0, 8, :] = 1 +IMAGE = torch.zeros(1, 16, 16, dtype=torch.float32) +IMAGE[0, 8, :] = 1 OUTPUT_3x3 = torch.zeros(2, 16, 16, dtype=torch.float32) OUTPUT_3x3[0, 7, :] = 2.0 OUTPUT_3x3[0, 9, :] = -2.0 @@ -28,7 +28,6 @@ OUTPUT_3x3[1, 8, 0] = 1.0 OUTPUT_3x3[1, 8, -1] = -1.0 OUTPUT_3x3[1, 7, -1] = OUTPUT_3x3[1, 9, -1] = -0.5 -OUTPUT_3x3 = OUTPUT_3x3.unsqueeze(1) TEST_CASE_0 = [{"image": IMAGE}, {"keys": "image", "kernel_size": 3, "dtype": torch.float32}, {"image": OUTPUT_3x3}] TEST_CASE_1 = [{"image": IMAGE}, {"keys": "image", "kernel_size": 3, "dtype": torch.float64}, {"image": OUTPUT_3x3}] From 1c1815cc85c22db9f7fe911c8ec5714754fb38e5 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Wed, 21 Sep 2022 23:18:06 +0800 Subject: [PATCH 03/60] Add channel dim in `ComputeHoVerMaps` and `ComputeHoVerMapsd` (#5183) Signed-off-by: KumoLiu Fixes #5177. ### Description Add channel dim in `ComputeHoVerMaps` and `ComputeHoVerMapsd` to make it consistent with other transforms. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: KumoLiu Co-authored-by: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 4 +++- tests/test_compute_ho_ver_maps.py | 10 +++++----- tests/test_compute_ho_ver_maps_d.py | 10 +++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 5c8c77408b..25110ac0dd 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2293,6 +2293,7 @@ def __call__(self, image: NdarrayOrTensor): class ComputeHoVerMaps(Transform): """Compute horizontal and vertical maps from an instance mask It generates normalized horizontal and vertical distances to the center of mass of each region. + Input data with the size of [1xHxW[xD]], which channel dim will temporarily removed for calculating coordinates. Args: dtype: the data type of output Tensor. Defaults to `"float32"`. @@ -2311,6 +2312,7 @@ def __call__(self, mask: NdarrayOrTensor): h_map = instance_mask.astype(self.dtype, copy=True) v_map = instance_mask.astype(self.dtype, copy=True) + instance_mask = instance_mask.squeeze(0) # remove channel dim for region in skimage.measure.regionprops(instance_mask): v_dist = region.coords[:, 0] - region.centroid[0] @@ -2325,5 +2327,5 @@ def __call__(self, mask: NdarrayOrTensor): h_map[h_map == region.label] = h_dist v_map[v_map == region.label] = v_dist - hv_maps = convert_to_tensor(np.stack([h_map, v_map]), track_meta=get_track_meta()) + hv_maps = convert_to_tensor(np.concatenate([h_map, v_map]), track_meta=get_track_meta()) return hv_maps diff --git a/tests/test_compute_ho_ver_maps.py b/tests/test_compute_ho_ver_maps.py index ca8576488a..f5091a57af 100644 --- a/tests/test_compute_ho_ver_maps.py +++ b/tests/test_compute_ho_ver_maps.py @@ -22,11 +22,11 @@ _, has_skimage = optional_import("skimage", "0.19.0", min_version) -INSTANCE_MASK = np.zeros((16, 16), dtype="int16") -INSTANCE_MASK[5:8, 4:11] = 1 -INSTANCE_MASK[3:5, 6:9] = 1 -INSTANCE_MASK[8:10, 6:9] = 1 -INSTANCE_MASK[13:, 13:] = 2 +INSTANCE_MASK = np.zeros((1, 16, 16), dtype="int16") +INSTANCE_MASK[:, 5:8, 4:11] = 1 +INSTANCE_MASK[:, 3:5, 6:9] = 1 +INSTANCE_MASK[:, 8:10, 6:9] = 1 +INSTANCE_MASK[:, 13:, 13:] = 2 H_MAP = torch.zeros((16, 16), dtype=torch.float32) H_MAP[5:8, 4] = -1.0 H_MAP[5:8, 5] = -2.0 / 3.0 diff --git a/tests/test_compute_ho_ver_maps_d.py b/tests/test_compute_ho_ver_maps_d.py index cf7b2ee1ec..3c20c7f200 100644 --- a/tests/test_compute_ho_ver_maps_d.py +++ b/tests/test_compute_ho_ver_maps_d.py @@ -22,11 +22,11 @@ _, has_skimage = optional_import("skimage", "0.19.0", min_version) -INSTANCE_MASK = np.zeros((16, 16), dtype="int16") -INSTANCE_MASK[5:8, 4:11] = 1 -INSTANCE_MASK[3:5, 6:9] = 1 -INSTANCE_MASK[8:10, 6:9] = 1 -INSTANCE_MASK[13:, 13:] = 2 +INSTANCE_MASK = np.zeros((1, 16, 16), dtype="int16") +INSTANCE_MASK[:, 5:8, 4:11] = 1 +INSTANCE_MASK[:, 3:5, 6:9] = 1 +INSTANCE_MASK[:, 8:10, 6:9] = 1 +INSTANCE_MASK[:, 13:, 13:] = 2 H_MAP = torch.zeros((16, 16), dtype=torch.float32) H_MAP[5:8, 4] = -1.0 H_MAP[5:8, 5] = -2.0 / 3.0 From ed5fd2d5558088ab065d239d8574fa592b644116 Mon Sep 17 00:00:00 2001 From: Holger Roth <6304754+holgerroth@users.noreply.github.com> Date: Thu, 22 Sep 2022 12:26:50 -0400 Subject: [PATCH 04/60] update federated learning figure (#5194) Fixes #5031. ### Description Update federated learning figure. Using SVG format. Also removes from svg from gitignore. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Holger Roth --- .gitignore | 1 - docs/images/federated.png | Bin 30104 -> 0 bytes docs/images/federated.svg | 245 ++++++++++++++++++++++++++++++++++++ docs/source/modules.md | 2 +- docs/source/whatsnew_1_0.md | 2 +- 5 files changed, 247 insertions(+), 3 deletions(-) delete mode 100644 docs/images/federated.png create mode 100644 docs/images/federated.svg diff --git a/.gitignore b/.gitignore index b771f963cf..5fe164e470 100644 --- a/.gitignore +++ b/.gitignore @@ -133,7 +133,6 @@ tests/testing_data/*.tiff tests/testing_data/schema.json tests/testing_data/endo.mp4 tests/testing_data/ultrasound.avi -*.svg # clang format tool .clang-format-bin/ diff --git a/docs/images/federated.png b/docs/images/federated.png deleted file mode 100644 index 430776e7752926eb36ca371d9bec9961b85a8c98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30104 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 + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/modules.md b/docs/source/modules.md index f6aa83fcb0..a4f9252713 100644 --- a/docs/source/modules.md +++ b/docs/source/modules.md @@ -268,7 +268,7 @@ A step-by-step [get started](https://github.com/Project-MONAI/tutorials/blob/mas ## Federated Learning -![federated-learning](../images/federated.png) +![federated-learning](../images/federated.svg) Using the MONAI bundle configurations, we can use MONAI's [`MonaiAlgo`](https://docs.monai.io/en/latest/fl.html#monai.fl.client.MonaiAlgo) class (an implementation of the abstract [`ClientAlgo`](https://docs.monai.io/en/latest/fl.html#clientalgo) class for federated learning) to execute bundles from the [MONAI model zoo](https://github.com/Project-MONAI/model-zoo). diff --git a/docs/source/whatsnew_1_0.md b/docs/source/whatsnew_1_0.md index 7279de627c..bb30451746 100644 --- a/docs/source/whatsnew_1_0.md +++ b/docs/source/whatsnew_1_0.md @@ -29,7 +29,7 @@ The solution offers different levels of user experience for beginners and advanc It has been tested on large-scale 3D medical imaging datasets in different modalities. ## Federated Learning Client -![federated-learning](../images/federated.png) +![federated-learning](../images/federated.svg) MONAI now includes the federated learning client algorithm APIs that are exposed as an abstract base class for defining an algorithm to be run on any federated learning platform. From 6a4dd92ee8dbd4773a617f80415b36b608b0d1d8 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Mon, 26 Sep 2022 17:33:35 +0800 Subject: [PATCH 05/60] Fix typo in `RandScaleIntensityd` (#5210) Signed-off-by: KumoLiu Fixes #5209. ### Description Fix typo in `RandScaleIntensityd` ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] In-line docstrings updated. Signed-off-by: KumoLiu --- monai/transforms/intensity/dictionary.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 7f671a052d..8ec6b6f3e3 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -382,8 +382,8 @@ def __init__( 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.) + prob: probability of shift. + (Default 0.1, with 10% probability it returns an array shifted intensity.) allow_missing_keys: don't raise exception if key is missing. """ MapTransform.__init__(self, keys, allow_missing_keys) @@ -580,8 +580,8 @@ def __init__( See also: :py:class:`monai.transforms.compose.MapTransform` factors: factor range to randomly scale by ``v = v * (1 + factor)``. if single number, factor value is picked from (-factors, factors). - prob: probability of rotating. - (Default 0.1, with 10% probability it returns a rotated array.) + prob: probability of scale. + (Default 0.1, with 10% probability it returns a scaled array.) dtype: output data type, if None, same as input image. defaults to float32. allow_missing_keys: don't raise exception if key is missing. From b9f5dd8bdc07d9b376627e9ab49c0cbdd90507ae Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 26 Sep 2022 12:26:27 +0100 Subject: [PATCH 06/60] 5193 5204 import error msg/warnings (#5205) fixes #5193 fixes #5204 ### Description revise the import error messages and ignore a torchaudio warning. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/transforms/signal/array.py | 5 ++++- monai/utils/module.py | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/monai/transforms/signal/array.py b/monai/transforms/signal/array.py index 4a16041570..6b904ad1c5 100644 --- a/monai/transforms/signal/array.py +++ b/monai/transforms/signal/array.py @@ -13,6 +13,7 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ +import warnings from typing import Any, Optional, Sequence import numpy as np @@ -27,7 +28,9 @@ shift, has_shift = optional_import("scipy.ndimage.interpolation", name="shift") iirnotch, has_iirnotch = optional_import("scipy.signal", name="iirnotch") -filtfilt, has_filtfilt = optional_import("torchaudio.functional", name="filtfilt") +with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) # project-monai/monai#5204 + filtfilt, has_filtfilt = optional_import("torchaudio.functional", name="filtfilt") central_frequency, has_central_frequency = optional_import("pywt", name="central_frequency") cwt, has_cwt = optional_import("pywt", name="cwt") diff --git a/monai/utils/module.py b/monai/utils/module.py index 06351ebe17..435b07fcac 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -202,11 +202,11 @@ def load_submodules(basemod, load_all: bool = True, exclude_pattern: str = "(.*[ except OptionalImportError: pass # could not import the optional deps., they are ignored except ImportError as e: - raise ImportError( - "Multiple versions of MONAI may have been installed,\n" - "please uninstall existing packages (both monai and monai-weekly) and install a version again.\n" - "See also: https://docs.monai.io/en/stable/installation.html\n" - ) from e + msg = ( + "\nMultiple versions of MONAI may have been installed?\n" + "Please see the installation guide: https://docs.monai.io/en/stable/installation.html\n" + ) # issue project-monai/monai#5193 + raise type(e)(f"{e}\n{msg}").with_traceback(e.__traceback__) from e # raise with modified message return submodules, err_mod From c71df934bb4cb265987e29e99a6500cfb3299b57 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 27 Sep 2022 09:53:32 +0100 Subject: [PATCH 07/60] 5211 5214 infer device from first item RandElastic, simplify `first_key()` default (#5213) Signed-off-by: Wenqi Li Fixes #5211 Fixes #5214 ### Description - set device based on the first item: ```py device = self.rand_2d_elastic.device if device is None and isinstance(d[first_key], torch.Tensor): device = d[first_key].device # type: ignore self.rand_2d_elastic.set_device(device) ``` - simplied transform `first_key()` default to a tuple ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/apps/deepgrow/transforms.py | 6 +- monai/apps/detection/transforms/dictionary.py | 6 +- monai/auto3dseg/utils.py | 2 +- monai/transforms/intensity/dictionary.py | 24 ++++---- monai/transforms/spatial/array.py | 11 ++++ monai/transforms/spatial/dictionary.py | 58 +++++++++++-------- monai/transforms/transform.py | 4 +- tests/test_rand_elasticd_2d.py | 2 + tests/test_rand_elasticd_3d.py | 2 + tests/test_video_datasets.py | 2 +- 10 files changed, 73 insertions(+), 44 deletions(-) diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index e127097d75..9340a80f7a 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import json -from typing import Callable, Dict, Hashable, List, Optional, Sequence, Union +from typing import Callable, Dict, Hashable, Optional, Sequence, Union import numpy as np import torch @@ -656,8 +656,8 @@ def bounding_box(self, points, img_shape): def __call__(self, data): d: Dict = dict(data) - first_key: Union[Hashable, List] = self.first_key(d) - if first_key == []: + first_key: Hashable = self.first_key(d) + if first_key == (): return d guidance = d[self.guidance] diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py index 7dffc95296..fa365895b5 100644 --- a/monai/apps/detection/transforms/dictionary.py +++ b/monai/apps/detection/transforms/dictionary.py @@ -523,14 +523,14 @@ def set_random_state( 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 == []: + first_key: Hashable = 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 + self.rand_zoom.randomize(d[first_key]) # zoom box for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys): diff --git a/monai/auto3dseg/utils.py b/monai/auto3dseg/utils.py index 46f55a3874..22195a6e4c 100644 --- a/monai/auto3dseg/utils.py +++ b/monai/auto3dseg/utils.py @@ -174,7 +174,7 @@ def concat_val_to_np( elif ragged: return np.concatenate(np_list, **kwargs) # type: ignore else: - return np.concatenate([np_list], **kwargs) # type: ignore + return np.concatenate([np_list], **kwargs) def concat_multikeys_to_dict( diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 8ec6b6f3e3..d3847eb4b6 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -15,7 +15,7 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ -from typing import Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, Hashable, Mapping, Optional, Sequence, Tuple, Union import numpy as np @@ -206,13 +206,14 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d # all the keys share the same random noise - first_key: Union[Hashable, List] = self.first_key(d) - if first_key == []: + first_key: Hashable = 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 + self.rand_gaussian_noise.randomize(d[first_key]) + for key in self.key_iterator(d): d[key] = self.rand_gaussian_noise(img=d[key], randomize=False) return d @@ -661,13 +662,14 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d # all the keys share the same random bias factor - first_key: Union[Hashable, List] = self.first_key(d) - if first_key == []: + first_key: Hashable = 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 + self.rand_bias_field.randomize(img_size=d[first_key].shape[1:]) + for key in self.key_iterator(d): d[key] = self.rand_bias_field(d[key], randomize=False) return d @@ -1551,8 +1553,8 @@ def __call__(self, data): 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 == []: + first_key: Hashable = 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 @@ -1624,8 +1626,8 @@ def __call__(self, data): 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 == []: + first_key: Hashable = 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 diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 092fa7ca27..496dfcbb46 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2644,6 +2644,12 @@ def set_random_state( super().set_random_state(seed, state) return self + def set_device(self, device): + self.deform_grid.device = device + self.rand_affine_grid.device = device + self.resampler.device = device + self.device = device + def randomize(self, spatial_size: Sequence[int]) -> None: super().randomize(None) if not self._do_transform: @@ -2812,6 +2818,11 @@ def set_random_state( super().set_random_state(seed, state) return self + def set_device(self, device): + self.rand_affine_grid.device = device + self.resampler.device = device + self.device = device + def randomize(self, grid_size: Sequence[int]) -> None: super().randomize(None) if not self._do_transform: diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 19a065ab8b..d10d0ae587 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -837,8 +837,8 @@ def set_random_state( 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 == []: + first_key: Hashable = self.first_key(d) + if first_key == (): out: Dict[Hashable, NdarrayOrTensor] = convert_to_tensor(d, track_meta=get_track_meta()) return out @@ -846,7 +846,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N # all the keys share the same random Affine factor self.rand_affine.randomize() - spatial_size = d[first_key].shape[1:] # type: ignore + spatial_size = d[first_key].shape[1:] + 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)) @@ -985,14 +986,19 @@ def set_random_state( 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 == []: + first_key: Hashable = self.first_key(d) + + if first_key == (): out: Dict[Hashable, NdarrayOrTensor] = convert_to_tensor(d, track_meta=get_track_meta()) return out self.randomize(None) + device = self.rand_2d_elastic.device + if device is None and isinstance(d[first_key], torch.Tensor): + device = d[first_key].device # type: ignore + self.rand_2d_elastic.set_device(device) + sp_size = fall_back_tuple(self.rand_2d_elastic.spatial_size, d[first_key].shape[1:]) - sp_size = fall_back_tuple(self.rand_2d_elastic.spatial_size, d[first_key].shape[1:]) # type: ignore # all the keys share the same random elastic factor self.rand_2d_elastic.randomize(sp_size) @@ -1008,8 +1014,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N ) grid = CenterSpatialCrop(roi_size=sp_size)(grid[0]) else: - _device = self.rand_2d_elastic.deform_grid.device - grid = create_grid(spatial_size=sp_size, device=_device, backend="torch") + grid = create_grid(spatial_size=sp_size, device=device, backend="torch") for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): d[key] = self.rand_2d_elastic.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) # type: ignore @@ -1123,21 +1128,25 @@ def set_random_state( 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 == []: + first_key: Hashable = self.first_key(d) + + if first_key == (): out: Dict[Hashable, torch.Tensor] = convert_to_tensor(d, track_meta=get_track_meta()) return out self.randomize(None) - sp_size = fall_back_tuple(self.rand_3d_elastic.spatial_size, d[first_key].shape[1:]) # type: ignore + sp_size = fall_back_tuple(self.rand_3d_elastic.spatial_size, d[first_key].shape[1:]) + # all the keys share the same random elastic factor self.rand_3d_elastic.randomize(sp_size) - _device = self.rand_3d_elastic.device - grid = create_grid(spatial_size=sp_size, device=_device, backend="torch") + device = self.rand_3d_elastic.device + if device is None and isinstance(d[first_key], torch.Tensor): + device = d[first_key].device + self.rand_3d_elastic.set_device(device) + grid = create_grid(spatial_size=sp_size, device=device, backend="torch") if self._do_transform: - device = self.rand_3d_elastic.device gaussian = GaussianFilter(spatial_dims=3, sigma=self.rand_3d_elastic.sigma, truncated=3.0).to(device) offset = torch.as_tensor(self.rand_3d_elastic.rand_offset, device=device).unsqueeze(0) grid[:3] += gaussian(offset)[0] * self.rand_3d_elastic.magnitude @@ -1273,14 +1282,15 @@ def set_random_state( 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 == []: + first_key: Hashable = self.first_key(d) + if first_key == (): return d self.randomize(None) # all the keys share the same random selected axis - self.flipper.randomize(d[first_key]) # type: ignore + self.flipper.randomize(d[first_key]) + for key in self.key_iterator(d): if self._do_transform: d[key] = self.flipper(d[key], randomize=False) @@ -1603,15 +1613,16 @@ def set_random_state( 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 == []: + first_key: Hashable = self.first_key(d) + if first_key == (): out: Dict[Hashable, torch.Tensor] = convert_to_tensor(d, track_meta=get_track_meta()) return out self.randomize(None) # all the keys share the same random zoom factor - self.rand_zoom.randomize(d[first_key]) # type: ignore + self.rand_zoom.randomize(d[first_key]) + for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): @@ -1756,12 +1767,13 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc 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 == []: + first_key: Hashable = self.first_key(d) + if first_key == (): out = convert_to_tensor(d, track_meta=get_track_meta()) return out - self.rand_grid_distortion.randomize(d[first_key].shape[1:]) # type: ignore + self.rand_grid_distortion.randomize(d[first_key].shape[1:]) + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): d[key] = self.rand_grid_distortion(d[key], mode=mode, padding_mode=padding_mode, randomize=False) return d diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 1df248a9a8..21d057f5d3 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -415,10 +415,10 @@ def key_iterator(self, data: Mapping[Hashable, Any], *extra_iterables: Optional[ def first_key(self, data: Dict[Hashable, Any]): """ Get the first available key of `self.keys` in the input `data` dictionary. - If no available key, return an empty list `[]`. + If no available key, return an empty tuple `()`. Args: data: data that the transform will be applied to. """ - return first(self.key_iterator(data), []) + return first(self.key_iterator(data), ()) diff --git a/tests/test_rand_elasticd_2d.py b/tests/test_rand_elasticd_2d.py index 759ba2c4da..d6f7a0cbba 100644 --- a/tests/test_rand_elasticd_2d.py +++ b/tests/test_rand_elasticd_2d.py @@ -161,6 +161,8 @@ class TestRand2DElasticd(unittest.TestCase): @parameterized.expand(TESTS) def test_rand_2d_elasticd(self, input_param, input_data, expected_val): g = Rand2DElasticd(**input_param) + if input_param.get("device", None) is None and isinstance(input_data["img"], torch.Tensor): + input_data["img"].to("cuda:0" if torch.cuda.is_available() else "cpu") g.set_random_state(123) res = g(input_data) for key in res: diff --git a/tests/test_rand_elasticd_3d.py b/tests/test_rand_elasticd_3d.py index eaba06c953..9db474861e 100644 --- a/tests/test_rand_elasticd_3d.py +++ b/tests/test_rand_elasticd_3d.py @@ -141,6 +141,8 @@ class TestRand3DElasticd(unittest.TestCase): def test_rand_3d_elasticd(self, input_param, input_data, expected_val): g = Rand3DElasticd(**input_param) g.set_random_state(123) + if input_param.get("device", None) is None and isinstance(input_data["img"], torch.Tensor): + input_data["img"].to("cuda:0" if torch.cuda.is_available() else "cpu") res = g(input_data) for key in res: result = res[key] diff --git a/tests/test_video_datasets.py b/tests/test_video_datasets.py index ac9e151f63..7dcc3d1a6c 100644 --- a/tests/test_video_datasets.py +++ b/tests/test_video_datasets.py @@ -26,7 +26,7 @@ NUM_CAPTURE_DEVICES = CameraDataset.get_num_devices() TRANSFORMS = mt.Compose( - [mt.EnsureChannelFirst(), mt.DivisiblePad(16), mt.ScaleIntensity(), mt.CastToType(torch.float32)] + [mt.EnsureChannelFirst(True, "no_channel"), mt.DivisiblePad(16), mt.ScaleIntensity(), mt.CastToType(torch.float32)] ) From e672b70f0522e00ea7bb97777b15c3e18e4d02d0 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 27 Sep 2022 12:43:51 +0100 Subject: [PATCH 08/60] 3844 flexible min/max_pixdim options for Spacing (#5212) Signed-off-by: Wenqi Li Fixes #3844 ### Description adding the flexibility for skipping the transform if the pixdim is in the specified range ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/transforms/spatial/array.py | 27 ++++++++++++++++++++++- monai/transforms/spatial/dictionary.py | 12 ++++++++++- tests/test_spacing.py | 30 ++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 496dfcbb46..9ea7c4cddf 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -15,6 +15,7 @@ import warnings from copy import deepcopy from enum import Enum +from itertools import zip_longest from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import numpy as np @@ -24,7 +25,7 @@ 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 AFFINE_TOL, compute_shape_offset, iter_patch, to_affine_nd, zoom_affine +from monai.data.utils import AFFINE_TOL, affine_to_spacing, compute_shape_offset, iter_patch, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.networks.utils import meshgrid_ij, normalize_transform from monai.transforms.croppad.array import CenterSpatialCrop, ResizeWithPadOrCrop @@ -437,6 +438,8 @@ def __init__( dtype: DtypeLike = np.float64, scale_extent: bool = False, recompute_affine: bool = False, + min_pixdim: Union[Sequence[float], float, np.ndarray, None] = None, + max_pixdim: Union[Sequence[float], float, np.ndarray, None] = None, image_only: bool = False, ) -> None: """ @@ -483,13 +486,25 @@ def __init__( recompute_affine: whether to recompute affine based on the output shape. The affine computed analytically does not reflect the potential quantization errors in terms of the output shape. Set this flag to True to recompute the output affine based on the actual pixdim. Default to ``False``. + min_pixdim: minimal input spacing to be resampled. If provided, input image with a larger spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. + max_pixdim: maximal input spacing to be resampled. If provided, input image with a smaller spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. """ self.pixdim = np.array(ensure_tuple(pixdim), dtype=np.float64) + self.min_pixdim = np.array(ensure_tuple(min_pixdim), dtype=np.float64) + self.max_pixdim = np.array(ensure_tuple(max_pixdim), dtype=np.float64) self.diagonal = diagonal self.scale_extent = scale_extent self.recompute_affine = recompute_affine + for mn, mx in zip(self.min_pixdim, self.max_pixdim): + if (not np.isnan(mn)) and (not np.isnan(mx)) and ((mx < mn) or (mn < 0)): + raise ValueError(f"min_pixdim {self.min_pixdim} must be positive, smaller than max {self.max_pixdim}.") + self.sp_resample = SpatialResample( mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype ) @@ -560,6 +575,16 @@ def __call__( out_d = self.pixdim[:sr] if out_d.size < sr: out_d = np.append(out_d, [1.0] * (sr - out_d.size)) + orig_d = affine_to_spacing(affine_, sr, out_d.dtype) + for idx, (_d, mn, mx) in enumerate( + zip_longest(orig_d, self.min_pixdim[:sr], self.max_pixdim[:sr], fillvalue=np.nan) + ): + target = out_d[idx] + mn = target if np.isnan(mn) else min(mn, target) + mx = target if np.isnan(mx) else max(mx, target) + if mn > mx: + raise ValueError(f"min_pixdim is larger than max_pixdim at dim {idx}: min {mn} max {mx} out {target}.") + out_d[idx] = _d if (mn - AFFINE_TOL) <= _d <= (mx + AFFINE_TOL) else target if not align_corners and scale_extent: warnings.warn("align_corners=False is not compatible with scale_extent=True.") diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d10d0ae587..3ee26c8525 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -335,6 +335,8 @@ def __init__( recompute_affine: bool = False, meta_keys: Optional[KeysCollection] = None, meta_key_postfix: str = "meta_dict", + min_pixdim: Union[Sequence[float], float, None] = None, + max_pixdim: Union[Sequence[float], float, None] = None, allow_missing_keys: bool = False, ) -> None: """ @@ -386,11 +388,19 @@ def __init__( recompute_affine: whether to recompute affine based on the output shape. The affine computed analytically does not reflect the potential quantization errors in terms of the output shape. Set this flag to True to recompute the output affine based on the actual pixdim. Default to ``False``. + min_pixdim: minimal input spacing to be resampled. If provided, input image with a larger spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. + max_pixdim: maximal input spacing to be resampled. If provided, input image with a smaller spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. allow_missing_keys: don't raise exception if key is missing. """ super().__init__(keys, allow_missing_keys) - self.spacing_transform = Spacing(pixdim, diagonal=diagonal, recompute_affine=recompute_affine) + self.spacing_transform = Spacing( + pixdim, diagonal=diagonal, recompute_affine=recompute_affine, min_pixdim=min_pixdim, max_pixdim=max_pixdim + ) 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)) diff --git a/tests/test_spacing.py b/tests/test_spacing.py index ff3b04f25b..d9f8168883 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -19,7 +19,7 @@ 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 monai.utils import fall_back_tuple from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, assert_allclose TESTS = [] @@ -245,7 +245,6 @@ def test_spacing(self, init_param, img, affine, data_param, expected_output, dev 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(res.affine, sr).cpu().numpy() assert_allclose(fall_back_tuple(init_pixdim, norm), norm, type_test=False) @@ -287,6 +286,33 @@ def test_inverse(self, device, recompute, align, scale_extent): l2_norm_affine = ((affine - img.affine) ** 2).sum() ** 0.5 self.assertLess(l2_norm_affine, 5e-2) + @parameterized.expand(TEST_INVERSE) + def test_inverse_mn_mx(self, device, recompute, align, scale_extent): + 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="cpu" + ) + img = MetaTensor(img_t, affine=affine, meta={"fname": "somewhere"}) + choices = [(None, None), [1.2, None], [None, 0.7], [0.7, 0.9]] + idx = np.random.choice(range(len(choices)), size=1)[0] + tr = Spacing( + pixdim=[1.1, 1.2, 0.9], + recompute_affine=recompute, + align_corners=align, + scale_extent=scale_extent, + min_pixdim=[0.9, None, choices[idx][0]], + max_pixdim=[1.1, 1.1, choices[idx][1]], + ) + img_out = tr(img) + if isinstance(img_out, MetaTensor): + assert_allclose( + img_out.pixdim, [1.0, 1.125, 0.888889] if recompute else [1.0, 1.2, 0.9], type_test=False, rtol=1e-4 + ) + img_out = tr.inverse(img_out) + self.assertEqual(img_out.applied_operations, []) + self.assertEqual(img_out.shape, img_t.shape) + self.assertLess(((affine - img_out.affine) ** 2).sum() ** 0.5, 5e-2) + if __name__ == "__main__": unittest.main() From bacc6bb08fadd35a3a599a09eb7cbb91db402a60 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 29 Sep 2022 18:36:47 +0800 Subject: [PATCH 09/60] 4264 Add antialiasing option for `Resized` (#5223) Signed-off-by: Yiheng Wang Fixes #4264 . ### Description This PR adds the antialiasing option for `Resized` transform. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Yiheng Wang --- monai/transforms/spatial/dictionary.py | 25 +++++++++++- tests/test_resized.py | 55 +++++++++++++++++++++----- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 3ee26c8525..706e8d7f8b 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -609,6 +609,15 @@ class Resized(MapTransform, InvertibleTransform): '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``. + 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. allow_missing_keys: don't raise exception if key is missing. """ @@ -621,17 +630,29 @@ def __init__( size_mode: str = "all", mode: SequenceStr = InterpolateMode.AREA, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, + anti_aliasing: Union[Sequence[bool], bool] = False, + anti_aliasing_sigma: Union[Sequence[Union[Sequence[float], float, None]], Sequence[float], float, None] = None, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) + self.anti_aliasing = ensure_tuple_rep(anti_aliasing, len(self.keys)) + self.anti_aliasing_sigma = ensure_tuple_rep(anti_aliasing_sigma, len(self.keys)) self.resizer = Resize(spatial_size=spatial_size, size_mode=size_mode) 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): - d[key] = self.resizer(d[key], mode=mode, align_corners=align_corners) + for key, mode, align_corners, anti_aliasing, anti_aliasing_sigma in self.key_iterator( + d, self.mode, self.align_corners, self.anti_aliasing, self.anti_aliasing_sigma + ): + d[key] = self.resizer( + d[key], + mode=mode, + align_corners=align_corners, + anti_aliasing=anti_aliasing, + anti_aliasing_sigma=anti_aliasing_sigma, + ) return d def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: diff --git a/tests/test_resized.py b/tests/test_resized.py index c1d987b898..a9da604b15 100644 --- a/tests/test_resized.py +++ b/tests/test_resized.py @@ -13,23 +13,44 @@ import numpy as np import skimage.transform +import torch from parameterized import parameterized from monai.data import MetaTensor, set_track_meta -from monai.transforms import Invertd, Resized +from monai.transforms import Invertd, Resize, Resized from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion TEST_CASE_0 = [{"keys": "img", "spatial_size": 15}, (6, 10, 15)] -TEST_CASE_1 = [{"keys": "img", "spatial_size": 15, "mode": "area"}, (6, 10, 15)] +TEST_CASE_1 = [ + {"keys": "img", "spatial_size": 15, "mode": "area", "anti_aliasing": True, "anti_aliasing_sigma": None}, + (6, 10, 15), +] -TEST_CASE_2 = [{"keys": "img", "spatial_size": 6, "mode": "trilinear", "align_corners": True}, (2, 4, 6)] +TEST_CASE_2 = [ + {"keys": "img", "spatial_size": 6, "mode": "trilinear", "align_corners": True, "anti_aliasing_sigma": 2.0}, + (2, 4, 6), +] TEST_CASE_3 = [ - {"keys": ["img", "label"], "spatial_size": 6, "mode": ["trilinear", "nearest"], "align_corners": [True, None]}, + { + "keys": ["img", "label"], + "spatial_size": 6, + "mode": ["trilinear", "nearest"], + "align_corners": [True, None], + "anti_aliasing": [False, True], + "anti_aliasing_sigma": (None, 2.0), + }, (2, 4, 6), ] +TEST_CORRECT_CASES = [ + ((32, -1), "area", False), + ((64, 64), "area", True), + ((32, 32, 32), "area", True), + ((256, 256), "bilinear", False), +] + class TestResized(NumpyImageTestCase2D): def test_invalid_inputs(self): @@ -41,9 +62,9 @@ def test_invalid_inputs(self): resize = Resized(keys="img", spatial_size=(128,), mode="order") resize({"img": self.imt[0]}) - @parameterized.expand([((32, -1), "area"), ((64, 64), "area"), ((32, 32, 32), "area"), ((256, 256), "bilinear")]) - def test_correct_results(self, spatial_size, mode): - resize = Resized("img", spatial_size, mode=mode) + @parameterized.expand(TEST_CORRECT_CASES) + def test_correct_results(self, spatial_size, mode, anti_aliasing): + resize = Resized("img", spatial_size, mode=mode, anti_aliasing=anti_aliasing) _order = 0 if mode.endswith("linear"): _order = 1 @@ -51,7 +72,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] ] @@ -61,7 +82,7 @@ def test_correct_results(self, spatial_size, mode): 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) + assert_allclose(out["img"], expected, type_test=False, atol=1.0) @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_longest_shape(self, input_param, expected_shape): @@ -88,6 +109,22 @@ def test_identical_spatial(self): transform_inverse = Invertd(keys="Y", transform=xform, orig_keys="X") assert_allclose(transform_inverse(out)["Y"].array, np.ones((1, 10, 16, 17)) * 2) + def test_consistent_resize(self): + spatial_size = (16, 16, 16) + rescaler_1 = Resize(spatial_size=spatial_size, anti_aliasing=True, anti_aliasing_sigma=(0.5, 1.0, 2.0)) + rescaler_2 = Resize(spatial_size=spatial_size, anti_aliasing=True, anti_aliasing_sigma=None) + rescaler_dict = Resized( + keys=["img1", "img2"], + spatial_size=spatial_size, + anti_aliasing=(True, True), + anti_aliasing_sigma=[(0.5, 1.0, 2.0), None], + ) + test_input_1 = torch.randn([3, 32, 32, 32]) + test_input_2 = torch.randn([3, 32, 32, 32]) + test_input_dict = {"img1": test_input_1, "img2": test_input_2} + assert_allclose(rescaler_1(test_input_1), rescaler_dict(test_input_dict)["img1"]) + assert_allclose(rescaler_2(test_input_2), rescaler_dict(test_input_dict)["img2"]) + if __name__ == "__main__": unittest.main() From a7e62d740f329f3335e54afde646dd6c22168805 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 29 Sep 2022 13:16:57 +0100 Subject: [PATCH 10/60] Visualisation classes to allow kwargs. combine GradCAM and ++ test files (#5224) ### Description Some users may have models that take adjoint information. This PR allows the adjoint information to be passed through the visualisation classes to the model via `**kwargs`. Also combined the test files of GradCAM and GradCAM++ as they were the same. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] New tests added to cover the changes. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/visualize/class_activation_maps.py | 31 ++--- monai/visualize/gradient_based.py | 20 ++-- monai/visualize/occlusion_sensitivity.py | 17 +-- tests/test_occlusion_sensitivity.py | 20 +++- tests/test_vis_gradbased.py | 28 ++++- tests/test_vis_gradcam.py | 143 ++++++++++++++++------- tests/test_vis_gradcampp.py | 78 ------------- 7 files changed, 177 insertions(+), 160 deletions(-) delete mode 100644 tests/test_vis_gradcampp.py diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index ba1f5d2589..06999ebf1b 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -125,10 +125,10 @@ def get_layer(self, layer_id: Union[str, Callable]): def class_score(self, logits, class_idx): return logits[:, class_idx].squeeze() - def __call__(self, x, class_idx=None, retain_graph=False): + def __call__(self, x, class_idx=None, retain_graph=False, **kwargs): train = self.model.training self.model.eval() - logits = self.model(x) + logits = self.model(x, **kwargs) self.class_idx = logits.max(1)[-1] if class_idx is None else class_idx acti, grad = None, None if self.register_forward: @@ -175,17 +175,18 @@ def __init__( self.upsampler = upsampler self.postprocessing = postprocessing - def feature_map_size(self, input_size, device="cpu", layer_idx=-1): + def feature_map_size(self, input_size, device="cpu", layer_idx=-1, **kwargs): """ Computes the actual feature map size given `nn_module` and the target_layer name. Args: input_size: shape of the input tensor device: the device used to initialise the input tensor layer_idx: index of the target layer if there are multiple target layers. Defaults to -1. + kwargs: any extra arguments to be passed on to the module as part of its `__call__`. Returns: shape of the actual feature map. """ - return self.compute_map(torch.zeros(*input_size, device=device), layer_idx=layer_idx).shape + return self.compute_map(torch.zeros(*input_size, device=device), layer_idx=layer_idx, **kwargs).shape def compute_map(self, x, class_idx=None, layer_idx=-1): """ @@ -286,8 +287,8 @@ def __init__( ) self.fc_layers = fc_layers - def compute_map(self, x, class_idx=None, layer_idx=-1): - logits, acti, _ = self.nn_module(x) + def compute_map(self, x, class_idx=None, layer_idx=-1, **kwargs): + logits, acti, _ = self.nn_module(x, **kwargs) acti = acti[layer_idx] if class_idx is None: class_idx = logits.max(1)[-1] @@ -298,7 +299,7 @@ def compute_map(self, x, class_idx=None, layer_idx=-1): output = torch.stack([output[i, b : b + 1] for i, b in enumerate(class_idx)], dim=0) return output.reshape(b, 1, *spatial) # resume the spatial dims on the selected class - def __call__(self, x, class_idx=None, layer_idx=-1): + def __call__(self, x, class_idx=None, layer_idx=-1, **kwargs): """ Compute the activation map with upsampling and postprocessing. @@ -306,11 +307,12 @@ def __call__(self, x, class_idx=None, layer_idx=-1): x: input tensor, shape must be compatible with `nn_module`. class_idx: index of the class to be visualized. Default to argmax(logits) layer_idx: index of the target layer if there are multiple target layers. Defaults to -1. + kwargs: any extra arguments to be passed on to the module as part of its `__call__`. Returns: activation maps """ - acti_map = self.compute_map(x, class_idx, layer_idx) + acti_map = self.compute_map(x, class_idx, layer_idx, **kwargs) return self._upsample_and_post_process(acti_map, x) @@ -356,15 +358,15 @@ class GradCAM(CAMBase): """ - def compute_map(self, x, class_idx=None, retain_graph=False, layer_idx=-1): - _, acti, grad = self.nn_module(x, class_idx=class_idx, retain_graph=retain_graph) + def compute_map(self, x, class_idx=None, retain_graph=False, layer_idx=-1, **kwargs): + _, acti, grad = self.nn_module(x, class_idx=class_idx, retain_graph=retain_graph, **kwargs) acti, grad = acti[layer_idx], grad[layer_idx] b, c, *spatial = grad.shape weights = grad.view(b, c, -1).mean(2).view(b, c, *[1] * len(spatial)) acti_map = (weights * acti).sum(1, keepdim=True) return F.relu(acti_map) - def __call__(self, x, class_idx=None, layer_idx=-1, retain_graph=False): + def __call__(self, x, class_idx=None, layer_idx=-1, retain_graph=False, **kwargs): """ Compute the activation map with upsampling and postprocessing. @@ -373,11 +375,12 @@ def __call__(self, x, class_idx=None, layer_idx=-1, retain_graph=False): class_idx: index of the class to be visualized. Default to argmax(logits) layer_idx: index of the target layer if there are multiple target layers. Defaults to -1. retain_graph: whether to retain_graph for torch module backward call. + kwargs: any extra arguments to be passed on to the module as part of its `__call__`. Returns: activation maps """ - acti_map = self.compute_map(x, class_idx=class_idx, retain_graph=retain_graph, layer_idx=layer_idx) + acti_map = self.compute_map(x, class_idx=class_idx, retain_graph=retain_graph, layer_idx=layer_idx, **kwargs) return self._upsample_and_post_process(acti_map, x) @@ -395,8 +398,8 @@ class GradCAMpp(GradCAM): """ - def compute_map(self, x, class_idx=None, retain_graph=False, layer_idx=-1): - _, acti, grad = self.nn_module(x, class_idx=class_idx, retain_graph=retain_graph) + def compute_map(self, x, class_idx=None, retain_graph=False, layer_idx=-1, **kwargs): + _, acti, grad = self.nn_module(x, class_idx=class_idx, retain_graph=retain_graph, **kwargs) acti, grad = acti[layer_idx], grad[layer_idx] b, c, *spatial = grad.shape alpha_nr = grad.pow(2) diff --git a/monai/visualize/gradient_based.py b/monai/visualize/gradient_based.py index 32b8110b6d..6727c8c239 100644 --- a/monai/visualize/gradient_based.py +++ b/monai/visualize/gradient_based.py @@ -68,17 +68,17 @@ def model(self, 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: + def get_grad(self, x: torch.Tensor, index: torch.Tensor | int | None, retain_graph=True, **kwargs) -> 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) + self._model(x, class_idx=index, retain_graph=retain_graph, **kwargs) 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) + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **kwargs) -> torch.Tensor: + return self.get_grad(x, index, **kwargs) class SmoothGrad(VanillaGrad): @@ -105,7 +105,7 @@ def __init__( else: self.range = range - def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **kwargs) -> torch.Tensor: stdev = (self.stdev_spread * (x.max() - x.min())).item() total_gradients = torch.zeros_like(x) for _ in self.range(self.n_samples): @@ -115,7 +115,7 @@ def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> x_plus_noise = x_plus_noise.detach() # get gradient and accumulate - grad = self.get_grad(x_plus_noise, index) + grad = self.get_grad(x_plus_noise, index, **kwargs) total_gradients += (grad * grad) if self.magnitude else grad # average @@ -126,12 +126,12 @@ def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> class GuidedBackpropGrad(VanillaGrad): - def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **kwargs) -> torch.Tensor: with replace_modules_temp(self.model, "relu", _GradReLU(), strict_match=False): - return super().__call__(x, index) + return super().__call__(x, index, **kwargs) class GuidedBackpropSmoothGrad(SmoothGrad): - def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None) -> torch.Tensor: + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **kwargs) -> torch.Tensor: with replace_modules_temp(self.model, "relu", _GradReLU(), strict_match=False): - return super().__call__(x, index) + return super().__call__(x, index, **kwargs) diff --git a/monai/visualize/occlusion_sensitivity.py b/monai/visualize/occlusion_sensitivity.py index d87b93396a..0630fd0539 100644 --- a/monai/visualize/occlusion_sensitivity.py +++ b/monai/visualize/occlusion_sensitivity.py @@ -68,10 +68,10 @@ def _check_input_bounding_box(b_box, im_shape): return b_box_min, b_box_max -def _append_to_sensitivity_ims(model, batch_images, sensitivity_ims): +def _append_to_sensitivity_ims(model, batch_images, sensitivity_ims, **kwargs): """Infer given images. Append to previous evaluations. Store each class separately.""" batch_images = torch.cat(batch_images, dim=0) - scores = model(batch_images).detach() + scores = model(batch_images, **kwargs).detach() for i in range(scores.shape[1]): sensitivity_ims[i] = torch.cat((sensitivity_ims[i], scores[:, i])) return sensitivity_ims @@ -183,14 +183,14 @@ def __init__( self.per_channel = per_channel self.verbose = verbose - def _compute_occlusion_sensitivity(self, x, b_box): + def _compute_occlusion_sensitivity(self, x, b_box, **kwargs): # Get bounding box im_shape = np.array(x.shape[1:]) b_box_min, b_box_max = _check_input_bounding_box(b_box, im_shape) # Get the number of prediction classes - num_classes = self.nn_module(x).numel() + num_classes = self.nn_module(x, **kwargs).numel() # If pad val not supplied, get the mean of the image pad_val = x.mean() if self.pad_val is None else self.pad_val @@ -266,7 +266,7 @@ def _compute_occlusion_sensitivity(self, x, b_box): # Once the batch is complete (or on last iteration) if len(batch_images) == self.n_batch or i == num_required_predictions - 1: # Do the predictions and append to sensitivity maps - sensitivity_ims = _append_to_sensitivity_ims(self.nn_module, batch_images, sensitivity_ims) + sensitivity_ims = _append_to_sensitivity_ims(self.nn_module, batch_images, sensitivity_ims, **kwargs) # Clear lists batch_images = [] @@ -276,7 +276,9 @@ def _compute_occlusion_sensitivity(self, x, b_box): return sensitivity_ims, output_im_shape - def __call__(self, x: torch.Tensor, b_box: Optional[Sequence] = None) -> Tuple[torch.Tensor, torch.Tensor]: + def __call__( + self, x: torch.Tensor, b_box: Optional[Sequence] = None, **kwargs + ) -> Tuple[torch.Tensor, torch.Tensor]: """ Args: x: Image to use for inference. Should be a tensor consisting of 1 batch. @@ -286,6 +288,7 @@ def __call__(self, x: torch.Tensor, b_box: Optional[Sequence] = None) -> Tuple[t be useful for larger images. * Min and max are inclusive, so ``[0, 63, ...]`` will have size ``(64, ...)``. * Use -ve to use ``min=0`` and ``max=im.shape[x]-1`` for xth dimension. + kwargs: any extra arguments to be passed on to the module as part of its `__call__`. Returns: * Occlusion map: @@ -305,7 +308,7 @@ def __call__(self, x: torch.Tensor, b_box: Optional[Sequence] = None) -> Tuple[t _check_input_image(x) # Generate sensitivity images - sensitivity_ims_list, output_im_shape = self._compute_occlusion_sensitivity(x, b_box) + sensitivity_ims_list, output_im_shape = self._compute_occlusion_sensitivity(x, b_box, **kwargs) # Loop over image for each classification for i, sens_i in enumerate(sensitivity_ims_list): diff --git a/tests/test_occlusion_sensitivity.py b/tests/test_occlusion_sensitivity.py index f258dfc557..ce29b55edf 100644 --- a/tests/test_occlusion_sensitivity.py +++ b/tests/test_occlusion_sensitivity.py @@ -17,6 +17,14 @@ from monai.networks.nets import DenseNet, DenseNet121 from monai.visualize import OcclusionSensitivity + +class DenseNetAdjoint(DenseNet121): + def __call__(self, x, adjoint_info): + if adjoint_info != 42: + raise ValueError + return super().__call__(x) + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") out_channels_2d = 4 out_channels_3d = 3 @@ -25,9 +33,12 @@ model_3d = DenseNet( spatial_dims=3, in_channels=1, out_channels=out_channels_3d, init_features=2, growth_rate=2, block_config=(6,) ).to(device) +model_2d_adjoint = DenseNetAdjoint(spatial_dims=2, in_channels=1, out_channels=out_channels_2d).to(device) model_2d.eval() model_2d_2c.eval() model_3d.eval() +model_2d_adjoint.eval() + # 2D w/ bounding box TEST_CASE_0 = [ @@ -59,10 +70,17 @@ (1, 1, 48, 64, out_channels_2d), (1, 1, 48, 64), ] +# 2D w/ bounding box and adjoint +TEST_CASE_ADJOINT = [ + {"nn_module": model_2d_adjoint}, + {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [-1, -1, 2, 40, 1, 62], "adjoint_info": 42}, + (1, 1, 39, 62, out_channels_2d), + (1, 1, 39, 62), +] class TestComputeOcclusionSensitivity(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_MULTI_CHANNEL]) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_MULTI_CHANNEL, TEST_CASE_ADJOINT]) def test_shape(self, init_data, call_data, map_expected_shape, most_prob_expected_shape): occ_sens = OcclusionSensitivity(**init_data) m, most_prob = occ_sens(**call_data) diff --git a/tests/test_vis_gradbased.py b/tests/test_vis_gradbased.py index 7655ca661e..035cb2967b 100644 --- a/tests/test_vis_gradbased.py +++ b/tests/test_vis_gradbased.py @@ -17,32 +17,48 @@ from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 from monai.visualize import GuidedBackpropGrad, GuidedBackpropSmoothGrad, SmoothGrad, VanillaGrad + +class DenseNetAdjoint(DenseNet121): + def __call__(self, x, adjoint_info): + if adjoint_info != 42: + raise ValueError + return super().__call__(x) + + 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) +DENSENET2DADJOINT = DenseNetAdjoint(spatial_dims=2, in_channels=1, out_channels=3) + TESTS = [] for type in (VanillaGrad, SmoothGrad, GuidedBackpropGrad, GuidedBackpropSmoothGrad): # 2D densenet - TESTS.append([type, DENSENET2D, (1, 1, 48, 64), (1, 1, 48, 64)]) + TESTS.append([type, DENSENET2D, (1, 1, 48, 64)]) # 3D densenet - TESTS.append([type, DENSENET3D, (1, 1, 6, 6, 6), (1, 1, 6, 6, 6)]) + TESTS.append([type, DENSENET3D, (1, 1, 6, 6, 6)]) # 2D senet - TESTS.append([type, SENET2D, (1, 3, 64, 64), (1, 1, 64, 64)]) + TESTS.append([type, SENET2D, (1, 3, 64, 64)]) # 3D senet - TESTS.append([type, SENET3D, (1, 3, 8, 8, 48), (1, 1, 8, 8, 48)]) + TESTS.append([type, SENET3D, (1, 3, 8, 8, 48)]) + # 2D densenet - adjoint + TESTS.append([type, DENSENET2DADJOINT, (1, 1, 48, 64)]) class TestGradientClassActivationMap(unittest.TestCase): @parameterized.expand(TESTS) - def test_shape(self, vis_type, model, shape, expected_shape): + def test_shape(self, vis_type, model, shape): device = "cuda:0" if torch.cuda.is_available() else "cpu" + + # optionally test for adjoint info + kwargs = {"adjoint_info": 42} if isinstance(model, DenseNetAdjoint) else {} + model.to(device) model.eval() vis = vis_type(model) x = torch.rand(shape, device=device) - result = vis(x) + result = vis(x, **kwargs) self.assertTupleEqual(result.shape, x.shape) diff --git a/tests/test_vis_gradcam.py b/tests/test_vis_gradcam.py index 08a1d8deb0..d81007aa15 100644 --- a/tests/test_vis_gradcam.py +++ b/tests/test_vis_gradcam.py @@ -10,81 +10,136 @@ # limitations under the License. import unittest +from typing import Any, List import numpy as np import torch from parameterized import parameterized from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 -from monai.visualize import GradCAM +from monai.visualize import GradCAM, GradCAMpp from tests.utils import assert_allclose -# 2D -TEST_CASE_0 = [ - { - "model": "densenet2d", - "shape": (2, 1, 48, 64), - "feature_shape": (2, 1, 1, 2), - "target_layers": "class_layers.relu", - }, - (2, 1, 48, 64), -] -# 3D -TEST_CASE_1 = [ - { - "model": "densenet3d", - "shape": (2, 1, 6, 6, 6), - "feature_shape": (2, 1, 2, 2, 2), - "target_layers": "class_layers.relu", - }, - (2, 1, 6, 6, 6), -] -# 2D -TEST_CASE_2 = [ - {"model": "senet2d", "shape": (2, 3, 64, 64), "feature_shape": (2, 1, 2, 2), "target_layers": "layer4"}, - (2, 1, 64, 64), -] - -# 3D -TEST_CASE_3 = [ - {"model": "senet3d", "shape": (2, 3, 8, 8, 48), "feature_shape": (2, 1, 1, 1, 2), "target_layers": "layer4"}, - (2, 1, 8, 8, 48), -] + +class DenseNetAdjoint(DenseNet121): + def __call__(self, x, adjoint_info): + if adjoint_info != 42: + raise ValueError + return super().__call__(x) + + +TESTS: List[Any] = [] +TESTS_ILL: List[Any] = [] + +for cam in (GradCAM, GradCAMpp): + # 2D + TESTS.append( + [ + cam, + { + "model": "densenet2d", + "shape": (2, 1, 48, 64), + "feature_shape": (2, 1, 1, 2), + "target_layers": "class_layers.relu", + }, + (2, 1, 48, 64), + ] + ) + # 3D + TESTS.append( + [ + cam, + { + "model": "densenet3d", + "shape": (2, 1, 6, 6, 6), + "feature_shape": (2, 1, 2, 2, 2), + "target_layers": "class_layers.relu", + }, + (2, 1, 6, 6, 6), + ] + ) + # 2D + TESTS.append( + [ + cam, + {"model": "senet2d", "shape": (2, 3, 64, 64), "feature_shape": (2, 1, 2, 2), "target_layers": "layer4"}, + (2, 1, 64, 64), + ] + ) + + # 3D + TESTS.append( + [ + cam, + { + "model": "senet3d", + "shape": (2, 3, 8, 8, 48), + "feature_shape": (2, 1, 1, 1, 2), + "target_layers": "layer4", + }, + (2, 1, 8, 8, 48), + ] + ) + + # adjoint info + TESTS.append( + [ + cam, + { + "model": "adjoint", + "shape": (2, 1, 48, 64), + "feature_shape": (2, 1, 1, 2), + "target_layers": "class_layers.relu", + }, + (2, 1, 48, 64), + ] + ) + + TESTS_ILL.append([cam]) class TestGradientClassActivationMap(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_data, expected_shape): + @parameterized.expand(TESTS) + def test_shape(self, cam_class, input_data, expected_shape): if input_data["model"] == "densenet2d": model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) - if input_data["model"] == "densenet3d": + elif input_data["model"] == "densenet3d": model = DenseNet( spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,) ) - if input_data["model"] == "senet2d": + elif input_data["model"] == "senet2d": model = SEResNet50(spatial_dims=2, in_channels=3, num_classes=4) - if input_data["model"] == "senet3d": + elif input_data["model"] == "senet3d": model = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) + elif input_data["model"] == "adjoint": + model = DenseNetAdjoint(spatial_dims=2, in_channels=1, out_channels=3) + + # optionally test for adjoint info + kwargs = {"adjoint_info": 42} if input_data["model"] == "adjoint" else {} + device = "cuda:0" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() - cam = GradCAM(nn_module=model, target_layers=input_data["target_layers"]) + cam = cam_class(nn_module=model, target_layers=input_data["target_layers"]) image = torch.rand(input_data["shape"], device=device) - result = cam(x=image, layer_idx=-1) - np.testing.assert_array_equal(cam.nn_module.class_idx.cpu(), model(image).max(1)[-1].cpu()) - fea_shape = cam.feature_map_size(input_data["shape"], device=device) + inferred = model(image, **kwargs).max(1)[-1].cpu() + result = cam(x=image, layer_idx=-1, **kwargs) + np.testing.assert_array_equal(cam.nn_module.class_idx.cpu(), inferred) + + fea_shape = cam.feature_map_size(input_data["shape"], device=device, **kwargs) self.assertTupleEqual(fea_shape, input_data["feature_shape"]) self.assertTupleEqual(result.shape, expected_shape) # check result is same whether class_idx=None is used or not - result2 = cam(x=image, layer_idx=-1, class_idx=model(image).max(1)[-1].cpu()) + result2 = cam(x=image, layer_idx=-1, class_idx=inferred, **kwargs) assert_allclose(result, result2) - def test_ill(self): + @parameterized.expand(TESTS_ILL) + def test_ill(self, cam_class): model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) for name, x in model.named_parameters(): if "features" in name: x.requires_grad = False - cam = GradCAM(nn_module=model, target_layers="class_layers.relu") + cam = cam_class(nn_module=model, target_layers="class_layers.relu") image = torch.rand((2, 1, 48, 64)) with self.assertRaises(IndexError): cam(x=image) diff --git a/tests/test_vis_gradcampp.py b/tests/test_vis_gradcampp.py deleted file mode 100644 index a261b6055b..0000000000 --- a/tests/test_vis_gradcampp.py +++ /dev/null @@ -1,78 +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 - -import torch -from parameterized import parameterized - -from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 -from monai.visualize import GradCAMpp - -# 2D -TEST_CASE_0 = [ - { - "model": "densenet2d", - "shape": (2, 1, 48, 64), - "feature_shape": (2, 1, 1, 2), - "target_layers": "class_layers.relu", - }, - (2, 1, 48, 64), -] -# 3D -TEST_CASE_1 = [ - { - "model": "densenet3d", - "shape": (2, 1, 6, 6, 6), - "feature_shape": (2, 1, 2, 2, 2), - "target_layers": "class_layers.relu", - }, - (2, 1, 6, 6, 6), -] -# 2D -TEST_CASE_2 = [ - {"model": "senet2d", "shape": (2, 3, 64, 64), "feature_shape": (2, 1, 2, 2), "target_layers": "layer4"}, - (2, 1, 64, 64), -] - -# 3D -TEST_CASE_3 = [ - {"model": "senet3d", "shape": (2, 3, 8, 8, 48), "feature_shape": (2, 1, 1, 1, 2), "target_layers": "layer4"}, - (2, 1, 8, 8, 48), -] - - -class TestGradientClassActivationMapPP(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_data, expected_shape): - if input_data["model"] == "densenet2d": - model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) - if input_data["model"] == "densenet3d": - model = DenseNet( - spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,) - ) - if input_data["model"] == "senet2d": - model = SEResNet50(spatial_dims=2, in_channels=3, num_classes=4) - if input_data["model"] == "senet3d": - model = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) - device = "cuda:0" if torch.cuda.is_available() else "cpu" - model.to(device) - model.eval() - cam = GradCAMpp(nn_module=model, target_layers=input_data["target_layers"]) - image = torch.rand(input_data["shape"], device=device) - result = cam(x=image, layer_idx=-1) - fea_shape = cam.feature_map_size(input_data["shape"], device=device) - self.assertTupleEqual(fea_shape, input_data["feature_shape"]) - self.assertTupleEqual(result.shape, expected_shape) - - -if __name__ == "__main__": - unittest.main() From 3d493ea2d1a43226714031916355147b93f135b8 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 29 Sep 2022 18:37:34 +0100 Subject: [PATCH 11/60] protobuf should use PYEXE not pip (#5231) ### Description We should be using `PY_EXE` in runtests as opposed to raw `pip`. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- runtests.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/runtests.sh b/runtests.sh index db6022e9b1..490b50b136 100755 --- a/runtests.sh +++ b/runtests.sh @@ -14,13 +14,6 @@ # script for running all tests set -e -# FIXME: https://github.com/Project-MONAI/MONAI/issues/4354 -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="" blue="" @@ -118,6 +111,13 @@ function print_usage { exit 1 } +# FIXME: https://github.com/Project-MONAI/MONAI/issues/4354 +protobuf_major_version=$(${PY_EXE} -m 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 + function check_import { echo "Python: ${PY_EXE}" ${cmdPrefix}${PY_EXE} -W error -W ignore::DeprecationWarning -c "import monai" From 4026c5e0d01947f8b0da7e8804dddf2a29d183f5 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 30 Sep 2022 13:46:53 +0800 Subject: [PATCH 12/60] 5232 Enhance bundle page with more information (#5234) Fixes #5232 . ### Description According to user's feedback, this PR enhanced the MONAI bundle doc page to include tutorial examples and model-zoo info. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Nic Ma --- docs/source/bundle_intro.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/bundle_intro.rst b/docs/source/bundle_intro.rst index 3d71de24b6..c43093db96 100644 --- a/docs/source/bundle_intro.rst +++ b/docs/source/bundle_intro.rst @@ -8,3 +8,7 @@ Bundle mb_specification config_syntax.md + +Detailed bundle examples and get started tutorial: https://github.com/Project-MONAI/tutorials/tree/main/bundle + +A collection of medical imaging models in the MONAI Bundle format: https://github.com/Project-MONAI/model-zoo From 0cf3088b4d20f7931357deee10427ceab868947b Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 30 Sep 2022 08:00:15 +0100 Subject: [PATCH 13/60] 5163 metatensor support for `OneOf` (#5217) Signed-off-by: Wenqi Li Fixes #5163 ### Description enhance metatensor compatibility for `OneOf` ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/transforms/compose.py | 26 ++++++++++--------- tests/test_one_of.py | 51 +++++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index 1d60c34c3e..7b55a993a1 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -17,6 +17,7 @@ import numpy as np +import monai from monai.transforms.inverse import InvertibleTransform # For backwards compatibility (so this still works: from monai.transforms.compose import MapTransform) @@ -254,26 +255,27 @@ def __call__(self, data): _transform = self.transforms[index] data = apply_transform(_transform, data, self.map_items, self.unpack_items, self.log_stats) # if the data is a mapping (dictionary), append the OneOf transform to the end - if isinstance(data, Mapping): - for key in data.keys(): - if self.trace_key(key) in data: + if isinstance(data, monai.data.MetaTensor): + self.push_transform(data, extra_info={"index": index}) + elif isinstance(data, Mapping): + for key in data: # dictionary not change size during iteration + if isinstance(data[key], monai.data.MetaTensor) or self.trace_key(key) in data: self.push_transform(data, key, extra_info={"index": index}) return data def inverse(self, data): if len(self.transforms) == 0: return data - if not isinstance(data, Mapping): - raise RuntimeError("Inverse only implemented for Mapping (dictionary) data") - # loop until we get an index and then break (since they'll all be the same) index = None - for key in data.keys(): - if self.trace_key(key) in data: - # get the index of the applied OneOf transform - index = self.get_most_recent_transform(data, key)[TraceKeys.EXTRA_INFO]["index"] - # and then remove the OneOf transform - self.pop_transform(data, key) + if isinstance(data, monai.data.MetaTensor): + index = self.pop_transform(data)[TraceKeys.EXTRA_INFO]["index"] + elif isinstance(data, Mapping): + for key in data: + if isinstance(data[key], monai.data.MetaTensor) or self.trace_key(key) in data: + index = self.pop_transform(data, key)[TraceKeys.EXTRA_INFO]["index"] + else: + raise RuntimeError("Inverse only implemented for Mapping (dictionary) or MetaTensor data.") if index is None: # no invertible transforms have been applied return data diff --git a/tests/test_one_of.py b/tests/test_one_of.py index 29d13d7d0c..8171acb6da 100644 --- a/tests/test_one_of.py +++ b/tests/test_one_of.py @@ -15,11 +15,15 @@ import numpy as np from parameterized import parameterized +from monai.data import MetaTensor from monai.transforms import ( InvertibleTransform, OneOf, + RandScaleIntensity, RandScaleIntensityd, + RandShiftIntensity, RandShiftIntensityd, + Resize, Resized, TraceableTransform, Transform, @@ -106,10 +110,10 @@ def __init__(self, keys): KEYS = ["x", "y"] TEST_INVERSES = [ - (OneOf((InvA(KEYS), InvB(KEYS))), True), - (OneOf((OneOf((InvA(KEYS), InvB(KEYS))), OneOf((InvB(KEYS), InvA(KEYS))))), True), - (OneOf((Compose((InvA(KEYS), InvB(KEYS))), Compose((InvB(KEYS), InvA(KEYS))))), True), - (OneOf((NonInv(KEYS), NonInv(KEYS))), False), + (OneOf((InvA(KEYS), InvB(KEYS))), True, True), + (OneOf((OneOf((InvA(KEYS), InvB(KEYS))), OneOf((InvB(KEYS), InvA(KEYS))))), True, False), + (OneOf((Compose((InvA(KEYS), InvB(KEYS))), Compose((InvB(KEYS), InvA(KEYS))))), True, False), + (OneOf((NonInv(KEYS), NonInv(KEYS))), False, False), ] @@ -148,13 +152,17 @@ def _match(a, b): _match(p, f) @parameterized.expand(TEST_INVERSES) - def test_inverse(self, transform, invertible): - data = {k: (i + 1) * 10.0 for i, k in enumerate(KEYS)} + def test_inverse(self, transform, invertible, use_metatensor): + data = {k: (i + 1) * 10.0 if not use_metatensor else MetaTensor((i + 1) * 10.0) for i, k in enumerate(KEYS)} fwd_data = transform(data) if invertible: for k in KEYS: - t = fwd_data[TraceableTransform.trace_key(k)][-1] + t = ( + fwd_data[TraceableTransform.trace_key(k)][-1] + if not use_metatensor + else fwd_data[k].applied_operations[-1] + ) # make sure the OneOf index was stored self.assertEqual(t[TraceKeys.CLASS_NAME], OneOf.__name__) # make sure index exists and is in bounds @@ -166,9 +174,11 @@ def test_inverse(self, transform, invertible): if invertible: for k in KEYS: # check transform was removed - self.assertTrue( - len(fwd_inv_data[TraceableTransform.trace_key(k)]) < len(fwd_data[TraceableTransform.trace_key(k)]) - ) + if not use_metatensor: + self.assertTrue( + len(fwd_inv_data[TraceableTransform.trace_key(k)]) + < len(fwd_data[TraceableTransform.trace_key(k)]) + ) # check data is same as original (and different from forward) self.assertEqual(fwd_inv_data[k], data[k]) self.assertNotEqual(fwd_inv_data[k], fwd_data[k]) @@ -186,15 +196,34 @@ def test_inverse_compose(self): RandShiftIntensityd(keys="img", offsets=0.5, prob=1.0), ] ), + OneOf( + [ + RandScaleIntensityd(keys="img", factors=0.5, prob=1.0), + RandShiftIntensityd(keys="img", offsets=0.5, prob=1.0), + ] + ), ] ) transform.set_random_state(seed=0) result = transform({"img": np.ones((1, 101, 102, 103))}) - result = transform.inverse(result) # invert to the original spatial shape self.assertTupleEqual(result["img"].shape, (1, 101, 102, 103)) + def test_inverse_metatensor(self): + transform = Compose( + [ + Resize(spatial_size=[100, 100, 100]), + OneOf([RandScaleIntensity(factors=0.5, prob=1.0), RandShiftIntensity(offsets=0.5, prob=1.0)]), + OneOf([RandScaleIntensity(factors=0.5, prob=1.0), RandShiftIntensity(offsets=0.5, prob=1.0)]), + ] + ) + transform.set_random_state(seed=0) + result = transform(np.ones((1, 101, 102, 103))) + self.assertTupleEqual(result.shape, (1, 100, 100, 100)) + result = transform.inverse(result) + self.assertTupleEqual(result.shape, (1, 101, 102, 103)) + def test_one_of(self): p = OneOf((A(), B(), C()), (1, 2, 1)) counts = [0] * 3 From b862c75d037f6b30f5d80b2a855b21c288c63af1 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 30 Sep 2022 16:07:42 +0800 Subject: [PATCH 14/60] 5233 Add file check for the logging config (#5235) Fixes #5233 . ### Description This PR added file existing check for the logging config, requested by @holgerroth . ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Nic Ma Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- monai/bundle/scripts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 18517b8c43..3dfdd8e7a5 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -494,6 +494,8 @@ def run( _args, "config_file", meta_file=None, runner_id="", logging_file=None ) if logging_file_ is not None: + if not os.path.exists(logging_file_): + raise FileNotFoundError(f"can't find the logging config file: {logging_file_}.") logger.info(f"set logging properties based on config: {logging_file_}.") fileConfig(logging_file_, disable_existing_loggers=False) From df84c3d69339aecc07857bd45edda45ec11db6e7 Mon Sep 17 00:00:00 2001 From: YunLiu <55491388+KumoLiu@users.noreply.github.com> Date: Fri, 30 Sep 2022 18:13:51 +0800 Subject: [PATCH 15/60] Fix 1D data error in `VarAutoEncoder` (#5236) Signed-off-by: KumoLiu Fixes #5225 . ### Description 1. fix 1d data error in `VarAutoEncoder` 2. add `use_sigmoid` flag ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: KumoLiu Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- monai/networks/layers/convutils.py | 2 +- monai/networks/nets/varautoencoder.py | 5 ++++- tests/test_varautoencoder.py | 29 ++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/monai/networks/layers/convutils.py b/monai/networks/layers/convutils.py index 1e9ce954e8..fe688b24ff 100644 --- a/monai/networks/layers/convutils.py +++ b/monai/networks/layers/convutils.py @@ -74,7 +74,7 @@ def calculate_out_shape( out_shape_np = ((in_shape_np - kernel_size_np + padding_np + padding_np) // stride_np) + 1 out_shape = tuple(int(s) for s in out_shape_np) - return out_shape if len(out_shape) > 1 else out_shape[0] + return out_shape def gaussian_1d( diff --git a/monai/networks/nets/varautoencoder.py b/monai/networks/nets/varautoencoder.py index 7c6928afc0..31c2a5cfe6 100644 --- a/monai/networks/nets/varautoencoder.py +++ b/monai/networks/nets/varautoencoder.py @@ -48,6 +48,7 @@ class VarAutoEncoder(AutoEncoder): 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. + use_sigmoid: whether to use the sigmoid function on final output. Defaults to True. Examples:: @@ -86,9 +87,11 @@ def __init__( norm: Union[Tuple, str] = Norm.INSTANCE, dropout: Optional[Union[Tuple, str, float]] = None, bias: bool = True, + use_sigmoid: bool = True, ) -> None: self.in_channels, *self.in_shape = in_shape + self.use_sigmoid = use_sigmoid self.latent_size = latent_size self.final_size = np.asarray(self.in_shape, dtype=int) @@ -148,4 +151,4 @@ def reparameterize(self, mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: mu, logvar = self.encode_forward(x) z = self.reparameterize(mu, logvar) - return self.decode_forward(z), mu, logvar, z + return self.decode_forward(z, self.use_sigmoid), mu, logvar, z diff --git a/tests/test_varautoencoder.py b/tests/test_varautoencoder.py index 04fc07f53f..a6315ebc63 100644 --- a/tests/test_varautoencoder.py +++ b/tests/test_varautoencoder.py @@ -75,7 +75,34 @@ (1, 3, 128, 128, 128), ] -CASES = [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3] +TEST_CASE_4 = [ # 4-channel 1D, batch 4 + { + "spatial_dims": 1, + "in_shape": (4, 128), + "out_channels": 3, + "latent_size": 2, + "channels": (4, 8, 16), + "strides": (2, 2, 2), + }, + (1, 4, 128), + (1, 3, 128), +] + +TEST_CASE_5 = [ # 4-channel 1D, batch 4, use_sigmoid = False + { + "spatial_dims": 1, + "in_shape": (4, 128), + "out_channels": 3, + "latent_size": 2, + "channels": (4, 8, 16), + "strides": (2, 2, 2), + "use_sigmoid": False, + }, + (1, 4, 128), + (1, 3, 128), +] + +CASES = [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5] class TestVarAutoEncoder(unittest.TestCase): From 06aee554730137a726fe98da647faf469604f1a2 Mon Sep 17 00:00:00 2001 From: macdavid Date: Fri, 30 Sep 2022 20:44:38 +0800 Subject: [PATCH 16/60] Solve LoadImage issue when track_meta is False (#5239) 1. Fix the issue #5227 2. fix a small code flaw Signed-off-by: David --- monai/data/meta_obj.py | 3 ++- monai/transforms/io/array.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py index 3a1bee508c..5061efc1ce 100644 --- a/monai/data/meta_obj.py +++ b/monai/data/meta_obj.py @@ -174,7 +174,8 @@ def meta(self, d) -> None: """Set the meta.""" if d == TraceKeys.NONE: self._meta = MetaObj.get_default_meta() - self._meta = d + else: + self._meta = d @property def applied_operations(self) -> list[dict]: diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 5ee17e6268..3cfb2b1953 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -278,7 +278,7 @@ def __call__(self, filename: Union[Sequence[PathLike], PathLike], reader: Option img = EnsureChannelFirst()(img) if self.image_only: return img - return img, img.meta # for compatibility purpose + return img, img.meta if isinstance(img, MetaTensor) else meta_data class SaveImage(Transform): From 21e0ad5b15d5bdcafa49f872e931db0504264e53 Mon Sep 17 00:00:00 2001 From: Holger Roth <6304754+holgerroth@users.noreply.github.com> Date: Fri, 30 Sep 2022 10:25:10 -0400 Subject: [PATCH 17/60] Add multi-gpu support to MonaiAlgo (#5228) Signed-off-by: Holger Roth Fixes #5195. ### Description Add support for MonaiAlgo to be run with torchrun for multi-gpu training. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Holger Roth --- monai/fl/client/monai_algo.py | 34 ++++++++++++++++++++++++++++++++++ monai/networks/utils.py | 2 ++ 2 files changed, 36 insertions(+) diff --git a/monai/fl/client/monai_algo.py b/monai/fl/client/monai_algo.py index f9b6dc0da6..040be39bf9 100644 --- a/monai/fl/client/monai_algo.py +++ b/monai/fl/client/monai_algo.py @@ -15,6 +15,7 @@ from typing import Optional, Union import torch +import torch.distributed as dist import monai from monai.bundle import ConfigParser @@ -112,6 +113,9 @@ class MonaiAlgo(ClientAlgo): benchmark: set benchmark to `False` for full deterministic behavior in cuDNN components. Note, full determinism in federated learning depends also on deterministic behavior of other FL components, e.g., the aggregator, which is not controlled by this class. + multi_gpu: whether to run MonaiAlgo in a multi-GPU setting; defaults to `False`. + backend: backend to use for torch.distributed; defaults to "nccl". + init_method: init_method for torch.distributed; defaults to "env://". """ def __init__( @@ -128,6 +132,9 @@ def __init__( save_dict_key: Optional[str] = "model", seed: Optional[int] = None, benchmark: bool = True, + multi_gpu: bool = False, + backend: str = "nccl", + init_method: str = "env://", ): self.logger = logging.getLogger(self.__class__.__name__) if config_evaluate_filename == "default": @@ -144,6 +151,9 @@ def __init__( self.save_dict_key = save_dict_key self.seed = seed self.benchmark = benchmark + self.multi_gpu = multi_gpu + self.backend = backend + self.init_method = init_method self.app_root = None self.train_parser = None @@ -156,6 +166,7 @@ def __init__( self.post_evaluate_filters = None self.iter_of_start_time = 0 self.global_weights = None + self.rank = 0 self.phase = FlPhase.IDLE self.client_name = None @@ -174,6 +185,15 @@ def initialize(self, extra=None): self.client_name = extra.get(ExtraItems.CLIENT_NAME, "noname") self.logger.info(f"Initializing {self.client_name} ...") + if self.multi_gpu: + dist.init_process_group(backend=self.backend, init_method=self.init_method) + self._set_cuda_device() + self.logger.info( + f"Using multi-gpu training on rank {self.rank} (available devices: {torch.cuda.device_count()})" + ) + if self.rank > 0: + self.logger.setLevel(logging.WARNING) + if self.seed: monai.utils.set_determinism(seed=self.seed) torch.backends.cudnn.benchmark = self.benchmark @@ -243,6 +263,8 @@ def train(self, data: ExchangeObject, extra=None): extra: Dict with additional information that can be provided by FL system. """ + self._set_cuda_device() + if extra is None: extra = {} if not isinstance(data, ExchangeObject): @@ -284,6 +306,8 @@ def get_weights(self, extra=None): or load requested model type from disk (`ModelType.BEST_MODEL` or `ModelType.FINAL_MODEL`). """ + self._set_cuda_device() + if extra is None: extra = {} @@ -361,6 +385,8 @@ def evaluate(self, data: ExchangeObject, extra=None): return_metrics: `ExchangeObject` containing evaluation metrics. """ + self._set_cuda_device() + if extra is None: extra = {} if not isinstance(data, ExchangeObject): @@ -421,6 +447,9 @@ def finalize(self, extra=None): self.logger.info(f"Terminating {self.client_name} evaluator...") self.evaluator.terminate() + if self.multi_gpu: + dist.destroy_process_group() + def _check_converted(self, global_weights, local_var_dict, n_converted): if n_converted == 0: self.logger.warning( @@ -447,3 +476,8 @@ def _add_config_files(self, config_files): f"Expected config files to be of type str or list but got {type(config_files)}: {config_files}" ) return files + + def _set_cuda_device(self): + if self.multi_gpu: + self.rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(self.rank) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index d93d592367..b353168a8a 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -516,6 +516,8 @@ def copy_model_state( unchanged_keys = sorted(set(all_keys).difference(updated_keys)) logger.info(f"'dst' model updated: {len(updated_keys)} of {len(dst_dict)} variables.") if inplace and isinstance(dst, torch.nn.Module): + if isinstance(dst, (nn.DataParallel, nn.parallel.DistributedDataParallel)): + dst = dst.module dst.load_state_dict(dst_dict) return dst_dict, updated_keys, unchanged_keys From e8879a0492affb16034b6cd9e0ddac921f42aa9e Mon Sep 17 00:00:00 2001 From: Behrooz Hashemian <3968947+drbeh@users.noreply.github.com> Date: Fri, 30 Sep 2022 11:24:44 -0400 Subject: [PATCH 18/60] HoVerNet Mode and Branch to independent StrEnum (#5219) Fixes #5218 ### Description This PR moves `HoVerNet` Mode and Branch outside of the module and make them independent `StrEnums`. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/networks/nets/hovernet.py | 55 ++++++++++----------------------- monai/utils/__init__.py | 2 ++ monai/utils/enums.py | 27 ++++++++++++++++ tests/test_hovernet.py | 1 + 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/monai/networks/nets/hovernet.py b/monai/networks/nets/hovernet.py index d7c876c848..c024463348 100644 --- a/monai/networks/nets/hovernet.py +++ b/monai/networks/nets/hovernet.py @@ -28,7 +28,6 @@ # ========================================================================= from collections import OrderedDict -from enum import Enum from typing import Callable, Dict, List, Optional, Sequence, Type, Union import torch @@ -37,8 +36,8 @@ 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 -from monai.utils.enums import StrEnum +from monai.utils.enums import HoVerNetBranch, HoVerNetMode, InterpolateMode, UpsampleMode +from monai.utils.module import export, look_up_option __all__ = ["HoVerNet", "Hovernet", "HoVernet", "HoVerNet"] @@ -380,6 +379,8 @@ class HoVerNet(nn.Module): Medical Image Analysis 2019 Args: + mode: use original implementation (`HoVerNetMODE.ORIGINAL` or "original") or + a faster implementation (`HoVerNetMODE.FAST` or "fast"). Defaults to `HoVerNetMODE.FAST`. in_channels: number of the input channel. out_classes: number of the nuclear type classes. act: activation type and arguments. Defaults to relu. @@ -387,33 +388,12 @@ class HoVerNet(nn.Module): dropout_prob: dropout rate after each dense layer. """ - class Mode(Enum): - FAST: int = 0 - ORIGINAL: int = 1 - - class Branch(StrEnum): - """ - Three branches of HoVerNet model, which results in three outputs: - `HOVER` is horizontal and vertical regressed gradient map of each nucleus, - `NUCLEUS` is the segmentation of all nuclei, and - `TYPE` is the type of each nucleus. - - """ - - HV = "horizontal_vertical" - NP = "nucleus_prediction" - NC = "type_prediction" - - def _mode_to_int(self, mode) -> int: - - if mode == self.Mode.FAST: - return 0 - else: - return 1 + Mode = HoVerNetMode + Branch = HoVerNetBranch def __init__( self, - mode: Mode = Mode.FAST, + mode: Union[HoVerNetMode, str] = HoVerNetMode.FAST, in_channels: int = 3, out_classes: int = 0, act: Union[str, tuple] = ("relu", {"inplace": True}), @@ -423,10 +403,9 @@ def __init__( 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 isinstance(mode, str): + mode = mode.upper() + self.mode = look_up_option(mode, HoVerNetMode) if out_classes > 128: raise ValueError("Number of nuclear types classes exceeds maximum (128)") @@ -441,7 +420,7 @@ def __init__( # number of layers in each pooling block. _block_config: Sequence[int] = (3, 4, 6, 3) - if mode == self.Mode.FAST: + if self.mode == HoVerNetMode.FAST: _ksize = 3 _pad = 3 else: @@ -510,12 +489,12 @@ def __init__( def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: - if self.mode == 1: + if self.mode == HoVerNetMode.ORIGINAL.value: if x.shape[-1] != 270 or x.shape[-2] != 270: - raise ValueError("Input size should be 270 x 270 when using Mode.ORIGINAL") + raise ValueError("Input size should be 270 x 270 when using HoVerNetMode.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") + raise ValueError("Input size should be 256 x 256 when using HoVerNetMode.FAST") x = x / 255.0 # to 0-1 range to match XY x = self.input_features(x) @@ -531,11 +510,11 @@ def forward(self, x: torch.Tensor) -> Dict[str, torch.Tensor]: x = self.upsample(x) output = { - HoVerNet.Branch.NP.value: self.nucleus_prediction(x, short_cuts), - HoVerNet.Branch.HV.value: self.horizontal_vertical(x, short_cuts), + HoVerNetBranch.NP.value: self.nucleus_prediction(x, short_cuts), + HoVerNetBranch.HV.value: self.horizontal_vertical(x, short_cuts), } if self.type_prediction is not None: - output[HoVerNet.Branch.NC.value] = self.type_prediction(x, short_cuts) + output[HoVerNetBranch.NC.value] = self.type_prediction(x, short_cuts) return output diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 2428da88a2..8eccac8f70 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -29,6 +29,8 @@ GridPatchSort, GridSampleMode, GridSamplePadMode, + HoVerNetBranch, + HoVerNetMode, InterpolateMode, InverseKeys, JITMetadataKeys, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index d69c184dae..12e82cd378 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -52,6 +52,8 @@ "ImageStatsKeys", "LabelStatsKeys", "AlgoEnsembleKeys", + "HoVerNetMode", + "HoVerNetBranch", ] @@ -587,3 +589,28 @@ class AlgoEnsembleKeys(StrEnum): ID = "identifier" ALGO = "infer_algo" SCORE = "best_metric" + + +class HoVerNetMode(StrEnum): + """ + Modes for HoVerNet model: + `FAST`: a faster implementation (than original) + `ORIGINAL`: the original implementation + """ + + FAST = "FAST" + ORIGINAL = "ORIGINAL" + + +class HoVerNetBranch(StrEnum): + """ + Three branches of HoVerNet model, which results in three outputs: + `HV` is horizontal and vertical gradient map of each nucleus (regression), + `NP` is the pixel prediction of all nuclei (segmentation), and + `NC` is the type of each nucleus (classification). + + """ + + HV = "horizontal_vertical" + NP = "nucleus_prediction" + NC = "type_prediction" diff --git a/tests/test_hovernet.py b/tests/test_hovernet.py index 45a6bb55b9..2365210f55 100644 --- a/tests/test_hovernet.py +++ b/tests/test_hovernet.py @@ -54,6 +54,7 @@ ILL_CASES = [ [{"out_classes": 6, "mode": 3}], + [{"out_classes": 6, "mode": "Wrong"}], [{"out_classes": 1000, "mode": HoVerNet.Mode.ORIGINAL}], [{"out_classes": 1, "mode": HoVerNet.Mode.ORIGINAL}], [{"out_classes": 6, "mode": HoVerNet.Mode.ORIGINAL, "dropout_prob": 100}], From 68d1b34593bca48cce9d11b2125f4c1a9121dfa2 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 30 Sep 2022 17:29:27 +0100 Subject: [PATCH 19/60] Occlusion sensitivity to use slidiing_window_inference (#5230) ### Description Occlusion sensitivity currently works by setting the logits at the center of the occluded region. Even though neighbouring voxels are occluded at the same time, this is not recorded. A better way to do it would be to do an average each time a voxel is occluded. This is taken care of by switching to using `sliding_window_inference`. We had to do a bit of hacking to get this to work. `sliding_window_inference` normally takes a subset of the whole image and infers that. We want `sliding_window_inference` to tell us which part of the image we should occlude, then we occlude it and infer the **whole** image. To that end, we use a meshgrid to tell us the coordinates of the region `sliding_window_inference` wants us to occlude. We occlude, infer and then crop back to the size of the region that was requested by `sliding_window_inference`. This PR also allows for different occlusion kernels. We currently have: - gaussian (actually inverse gaussian): the center of the occluded region is zero, and towards the edge of the image is unchanged. This doesn't introduce hard edges into the image, which might undermine the visualisation process. - mean_patch: the occluded region is replaced with the mean of the patch it is occluding. - mean_img: the occluded region is replaced with the mean of the whole image (current implementation). ## Changes to input arguments This PR is backwards incompatible, as using `sliding_window_inference` means changing the API significantly. - pad_val: now determined by `mode` - stride: `overlap` used instead - per_channel: all channels are done simultaneously - upsampler: image is no longer downsampled ## Changes to output Output previously had the shape `B,C,H,W,[D],N` where `C` and `N` were the number of input and output channels of the network, respectively. Now, we output the shape `B,N,H,W,[D]` as the `per_channel` feature is no longer present. Columns 2-4 are occlusion sensitivity done with Gaussian, mean of patch and mean of image: ![vis](https://user-images.githubusercontent.com/33289025/193261000-b879bce8-3aab-433b-af6c-cbb9c885d0a3.png) ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/visualize/occlusion_sensitivity.py | 450 ++++++++++++----------- tests/test_occlusion_sensitivity.py | 105 +++--- 2 files changed, 302 insertions(+), 253 deletions(-) diff --git a/monai/visualize/occlusion_sensitivity.py b/monai/visualize/occlusion_sensitivity.py index 0630fd0539..b4845b7e27 100644 --- a/monai/visualize/occlusion_sensitivity.py +++ b/monai/visualize/occlusion_sensitivity.py @@ -10,89 +10,18 @@ # limitations under the License. from collections.abc import Sequence -from functools import partial from typing import Callable, Optional, Tuple, Union import numpy as np import torch import torch.nn as nn +from monai.data.meta_tensor import MetaTensor from monai.networks.utils import eval_mode +from monai.transforms import Compose, GaussianSmooth, Lambda, ScaleIntensity, SpatialCrop +from monai.utils import deprecated_arg, ensure_tuple_rep from monai.visualize.visualizer import default_upsampler -try: - from tqdm import trange - - trange = partial(trange, desc="Computing occlusion sensitivity") -except (ImportError, AttributeError): - trange = range - -# For stride two (for example), -# if input array is: |0|1|2|3|4|5|6|7| -# downsampled output is: | 0 | 1 | 2 | 3 | -# So the upsampling should do it by the corners of the image, not their centres -default_upsampler = partial(default_upsampler, align_corners=True) - - -def _check_input_image(image): - """Check that the input image is as expected.""" - # Only accept batch size of 1 - if image.shape[0] > 1: - raise RuntimeError("Expected batch size of 1.") - - -def _check_input_bounding_box(b_box, im_shape): - """Check that the bounding box (if supplied) is as expected.""" - # If no bounding box has been supplied, set min and max to None - if b_box is None: - b_box_min = b_box_max = None - - # Bounding box has been supplied - else: - # Should be twice as many elements in `b_box` as `im_shape` - if len(b_box) != 2 * len(im_shape): - raise ValueError("Bounding box should contain upper and lower for all dimensions (except batch number)") - - # If any min's or max's are -ve, set them to 0 and im_shape-1, respectively. - b_box_min = np.array(b_box[::2]) - b_box_max = np.array(b_box[1::2]) - b_box_min[b_box_min < 0] = 0 - b_box_max[b_box_max < 0] = im_shape[b_box_max < 0] - 1 - # Check all max's are < im_shape - if np.any(b_box_max >= im_shape): - raise ValueError("Max bounding box should be < image size for all values") - # Check all min's are <= max's - if np.any(b_box_min > b_box_max): - raise ValueError("Min bounding box should be <= max for all values") - - return b_box_min, b_box_max - - -def _append_to_sensitivity_ims(model, batch_images, sensitivity_ims, **kwargs): - """Infer given images. Append to previous evaluations. Store each class separately.""" - batch_images = torch.cat(batch_images, dim=0) - scores = model(batch_images, **kwargs).detach() - for i in range(scores.shape[1]): - sensitivity_ims[i] = torch.cat((sensitivity_ims[i], scores[:, i])) - return sensitivity_ims - - -def _get_as_np_array(val, numel): - # If not a sequence, then convert scalar to numpy array - if not isinstance(val, Sequence): - out = np.full(numel, val, dtype=np.int32) - out[0] = 1 # mask_size and stride always 1 in channel dimension - else: - # Convert to numpy array and check dimensions match - out = np.array(val, dtype=np.int32) - # Add stride of 1 to the channel direction (since user input was only for spatial dimensions) - out = np.insert(out, 0, 1) - if out.size != numel: - raise ValueError( - "If supplying stride/mask_size as sequence, number of elements should match number of spatial dimensions." - ) - return out - class OcclusionSensitivity: """ @@ -142,139 +71,190 @@ class OcclusionSensitivity: - :py:class:`monai.visualize.occlusion_sensitivity.OcclusionSensitivity.` """ + @deprecated_arg( + name="pad_val", + since="1.0", + removed="1.2", + msg_suffix="Please use `mode`. For backwards compatibility, use `mode=mean_img`.", + ) + @deprecated_arg(name="stride", since="1.0", removed="1.2", msg_suffix="Please use `overlap`.") + @deprecated_arg(name="per_channel", since="1.0", removed="1.2") + @deprecated_arg(name="upsampler", since="1.0", removed="1.2") def __init__( self, nn_module: nn.Module, pad_val: Optional[float] = None, - mask_size: Union[int, Sequence] = 15, - n_batch: int = 128, + mask_size: Union[int, Sequence] = 16, + n_batch: int = 16, stride: Union[int, Sequence] = 1, per_channel: bool = True, upsampler: Optional[Callable] = default_upsampler, verbose: bool = True, + mode: Union[str, float, Callable] = "gaussian", + overlap: float = 0.25, + activate: Union[bool, Callable] = True, ) -> None: - """Occlusion sensitivity constructor. + """ + Occlusion sensitivity constructor. Args: nn_module: Classification model to use for inference - pad_val: When occluding part of the image, which values should we put - in the image? If ``None`` is used, then the average of the image will be used. - mask_size: Size of box to be occluded, centred on the central voxel. To ensure that the occluded area - is correctly centred, ``mask_size`` and ``stride`` should both be odd or even. + mask_size: Size of box to be occluded, centred on the central voxel. If a single number + is given, this is used for all dimensions. If a sequence is given, this is used for each dimension + individually. n_batch: Number of images in a batch for inference. - stride: Stride in spatial directions for performing occlusions. Can be single - value or sequence (for varying stride in the different directions). - Should be >= 1. Striding in the channel direction depends on the `per_channel` argument. - per_channel: If `True`, `mask_size` and `stride` both equal 1 in the channel dimension. If `False`, - then both `mask_size` equals the number of channels in the image. If `True`, the output image will be: - `[B, C, H, W, D, num_seg_classes]`. Else, will be `[B, 1, H, W, D, num_seg_classes]` - upsampler: An upsampling method to upsample the output image. Default is - N-dimensional linear (bilinear, trilinear, etc.) depending on num spatial - dimensions of input. - verbose: Use ``tqdm.trange`` output (if available). - """ + verbose: Use progress bar (if ``tqdm`` available). + mode: what should the occluded region be replaced with? If a float is given, that value will be used + throughout the occlusion. Else, ``gaussian``, ``mean_img`` and ``mean_patch`` can be supplied: + + * ``gaussian``: occluded region is multiplied by 1 - gaussian kernel. In this fashion, the occlusion + will be 0 at the center and will be unchanged towards the edges, varying smoothly between. When + gaussian is used, a weighted average will be used to combine overlapping regions. This will be + done using the gaussian (not 1-gaussian) as occluded regions count more. + * ``mean_patch``: occluded region will be replaced with the mean of occluded region. + * ``mean_img``: occluded region will be replaced with the mean of the whole image. + + overlap: overlap between inferred regions. Should be in range 0<=x<1. + activate: if ``True``, do softmax activation if num_channels > 1 else do ``sigmoid``. If ``False``, don't do any + activation. If ``callable``, use callable on inferred outputs. + """ self.nn_module = nn_module - self.upsampler = upsampler - self.pad_val = pad_val self.mask_size = mask_size self.n_batch = n_batch - self.stride = stride - self.per_channel = per_channel self.verbose = verbose + self.overlap = overlap + self.activate = activate + # mode + if isinstance(mode, str) and mode not in ("gaussian", "mean_patch", "mean_img"): + raise NotImplementedError + self.mode = mode + + @staticmethod + def constant_occlusion(x: torch.Tensor, val: float, mask_size: tuple) -> Tuple[float, torch.Tensor]: + """Occlude with a constant occlusion. Multiplicative is zero, additive is constant value.""" + ones = torch.ones((*x.shape[:2], *mask_size), device=x.device, dtype=x.dtype) + return 0, ones * val + + @staticmethod + def gaussian_occlusion(x: torch.Tensor, mask_size, sigma=0.25) -> Tuple[torch.Tensor, float]: + """ + For Gaussian occlusion, Multiplicative is 1-Gaussian, additive is zero. + Default sigma of 0.25 empirically shown to give reasonable kernel, see here: + https://github.com/Project-MONAI/MONAI/pull/5230#discussion_r984520714. + """ + kernel = torch.zeros((x.shape[1], *mask_size), device=x.device, dtype=x.dtype) + spatial_shape = kernel.shape[1:] + # all channels (as occluded shape already takes into account per_channel), center in spatial dimensions + center = [slice(None)] + [slice(s // 2, s // 2 + 1) for s in spatial_shape] + # place value of 1 at center + kernel[center] = 1.0 + # Smooth with sigma equal to quarter of image, flip +ve/-ve so largest values are at edge + # and smallest at center. Scale to [0, 1]. + gaussian = Compose( + [GaussianSmooth(sigma=[b * sigma for b in spatial_shape]), Lambda(lambda x: -x), ScaleIntensity()] + ) + # transform and add batch + mul: torch.Tensor = gaussian(kernel)[None] # type: ignore + return mul, 0 + + @staticmethod + def predictor( + cropped_grid: torch.Tensor, + nn_module: nn.Module, + x: torch.Tensor, + mul: Union[torch.Tensor, float], + add: Union[torch.Tensor, float], + mask_size: Sequence, + occ_mode: str, + activate: Union[bool, Callable], + module_kwargs, + ) -> torch.Tensor: + """ + Predictor function to be passed to the sliding window inferer. Takes a cropped meshgrid, + referring to the coordinates in the input image. We use the index of the top-left corner + in combination ``mask_size`` to figure out which region of the image is to be occluded. The + occlusion is performed on the original image, ``x``, using ``cropped_region * mul + add``. ``mul`` + and ``add`` are sometimes pre-computed (e.g., a constant Gaussian blur), or they are + sometimes calculated on the fly (e.g., the mean of the occluded patch). For this reason + ``occ_mode`` is given. Lastly, ``activate`` is used to activate after each call of the model. - def _compute_occlusion_sensitivity(self, x, b_box, **kwargs): - - # Get bounding box - im_shape = np.array(x.shape[1:]) - b_box_min, b_box_max = _check_input_bounding_box(b_box, im_shape) - - # Get the number of prediction classes - num_classes = self.nn_module(x, **kwargs).numel() - - # If pad val not supplied, get the mean of the image - pad_val = x.mean() if self.pad_val is None else self.pad_val - - # List containing a batch of images to be inferred - batch_images = [] - - # List of sensitivity images, one for each inferred class - sensitivity_ims = num_classes * [torch.empty(0, dtype=torch.float32, device=x.device)] - - # If no bounding box supplied, output shape is same as input shape. - # If bounding box is present, shape is max - min + 1 - output_im_shape = im_shape if b_box is None else b_box_max - b_box_min + 1 - - # Get the stride and mask_size as numpy arrays - stride = _get_as_np_array(self.stride, len(im_shape)) - mask_size = _get_as_np_array(self.mask_size, len(im_shape)) - - # If not doing it on a per-channel basis, then the output image will have 1 output channel - # (since all will be occluded together) - if not self.per_channel: - output_im_shape[0] = 1 - stride[0] = x.shape[1] - mask_size[0] = x.shape[1] - - # For each dimension, ... - for o, s in zip(output_im_shape, stride): - # if the size is > 1, then check that the stride is a factor of the output image shape - if o > 1 and o % s != 0: - raise ValueError( - "Stride should be a factor of the image shape. Im shape " - + f"(taking bounding box into account): {output_im_shape}, stride: {stride}" - ) - - # to ensure the occluded area is nicely centred if stride is even, ensure that so is the mask_size - if np.any(mask_size % 2 != stride % 2): - raise ValueError( - "Stride and mask size should both be odd or even (element-wise). " - + f"``stride={stride}``, ``mask_size={mask_size}``" - ) - - downsampled_im_shape = (output_im_shape / stride).astype(np.int32) - downsampled_im_shape[downsampled_im_shape == 0] = 1 # make sure dimension sizes are >= 1 - num_required_predictions = np.prod(downsampled_im_shape) - - # Get bottom left and top right corners of occluded region - lower_corner = (stride - mask_size) // 2 - upper_corner = (stride + mask_size) // 2 - - # Loop 1D over image - verbose_range = trange if self.verbose else range - for i in verbose_range(num_required_predictions): - # Get corresponding ND index - idx = np.unravel_index(i, downsampled_im_shape) - # Multiply by stride - idx *= stride - # If a bounding box is being used, we need to add on - # the min to shift to start of region of interest - if b_box_min is not None: - idx += b_box_min - - # Get min and max index of box to occlude (and make sure it's in bounds) - min_idx = np.maximum(idx + lower_corner, 0) - max_idx = np.minimum(idx + upper_corner, im_shape) - - # Clone and replace target area with `pad_val` - occlu_im = x.detach().clone() - occlu_im[(...,) + tuple(slice(i, j) for i, j in zip(min_idx, max_idx))] = pad_val - - # Add to list - batch_images.append(occlu_im) - - # Once the batch is complete (or on last iteration) - if len(batch_images) == self.n_batch or i == num_required_predictions - 1: - # Do the predictions and append to sensitivity maps - sensitivity_ims = _append_to_sensitivity_ims(self.nn_module, batch_images, sensitivity_ims, **kwargs) - # Clear lists - batch_images = [] - - # Reshape to match downsampled image, and unsqueeze to add batch dimension back in - for i in range(num_classes): - sensitivity_ims[i] = sensitivity_ims[i].reshape(tuple(downsampled_im_shape)).unsqueeze(0) - - return sensitivity_ims, output_im_shape + Args: + cropped_grid: subsection of the meshgrid, where each voxel refers to the coordinate of + the input image. The meshgrid is created by the ``OcclusionSensitivity`` class, and + the generation of the subset is determined by ``sliding_window_inference``. + nn_module: module to call on data. + x: the image that was originally passed into ``OcclusionSensitivity.__call__``. + mul: occluded region will be multiplied by this. Can be ``torch.Tensor`` or ``float``. + add: after multiplication, this is added to the occluded region. Can be ``torch.Tensor`` or ``float``. + mask_size: Size of box to be occluded, centred on the central voxel. Should be + a sequence, one value for each spatial dimension. + occ_mode: might be used to calculate ``mul`` and ``add`` on the fly. + activate: if ``True``, do softmax activation if num_channels > 1 else do ``sigmoid``. If ``False``, don't do any + activation. If ``callable``, use callable on inferred outputs. + module_kwargs: kwargs to be passed onto module when inferring + """ + n_batch = cropped_grid.shape[0] + sd = cropped_grid.ndim - 2 + # start with copies of x to infer + im = torch.repeat_interleave(x, n_batch, 0) + # get coordinates of top left corner of occluded region (possible because we use meshgrid) + corner_coord_slices = [slice(None)] * 2 + [slice(1)] * sd + top_corners = cropped_grid[corner_coord_slices] + + # replace occluded regions + for b, t in enumerate(top_corners): + # starting from corner, get the slices to extract the occluded region from the image + slices = [slice(b, b + 1), slice(None)] + [slice(int(j), int(j) + m) for j, m in zip(t, mask_size)] + to_occlude = im[slices] + if occ_mode == "mean_patch": + add, mul = OcclusionSensitivity.constant_occlusion(x, to_occlude.mean().item(), mask_size) + + if callable(occ_mode): + to_occlude = occ_mode(x, to_occlude) + else: + to_occlude = to_occlude * mul + add + if add is None or mul is None: + raise RuntimeError("Shouldn't be here, something's gone wrong...") + im[slices] = to_occlude + # infer + out: torch.Tensor = nn_module(im, **module_kwargs) + + # if activation is callable, call it + if callable(activate): + out = activate(out) + # else if True (should be boolean), sigmoid if n_chan == 1 else softmax + elif activate: + out = out.sigmoid() if x.shape[1] == 1 else out.softmax(1) + + # the output will have shape [B,C] where C is number of channels output by model (inference classes) + # we need to return it to sliding window inference with shape [B,C,H,W,[D]], so add dims and repeat values + for m in mask_size: + out = torch.repeat_interleave(out.unsqueeze(-1), m, dim=-1) + + return out + + @staticmethod + def crop_meshgrid(grid: MetaTensor, b_box: Sequence, mask_size: Sequence) -> Tuple[MetaTensor, SpatialCrop]: + """Crop the meshgrid so we only perform occlusion sensitivity on a subsection of the image.""" + # distance from center of mask to edge is -1 // 2. + mask_edge = [(m - 1) // 2 for m in mask_size] + bbox_min = [max(b - m, 0) for b, m in zip(b_box[::2], mask_edge)] + bbox_max = [] + for b, m, s in zip(b_box[1::2], mask_edge, grid.shape[2:]): + # if bbox is -ve for that dimension, no cropping so use current image size + if b == -1: + bbox_max.append(s) + # else bounding box plus distance to mask edge. Make sure it's not bigger than the size of the image + else: + bbox_max.append(min(b + m, s)) + # bbox_max = [min(b + m, s) if b >= 0 else s for b, m, s in zip(b_box[1::2], mask_edge, grid.shape[2:])] + # No need for batch and channel slices. Batch will be removed and added back in, and + # SpatialCrop doesn't act on the first dimension anyway. + slices = [slice(s, e) for s, e in zip(bbox_min, bbox_max)] + cropper = SpatialCrop(roi_slices=slices) + cropped: MetaTensor = cropper(grid[0])[None] # type: ignore + return cropped, cropper def __call__( self, x: torch.Tensor, b_box: Optional[Sequence] = None, **kwargs @@ -283,11 +263,13 @@ def __call__( Args: x: Image to use for inference. Should be a tensor consisting of 1 batch. b_box: Bounding box on which to perform the analysis. The output image will be limited to this size. - There should be a minimum and maximum for all dimensions except batch: ``[min1, max1, min2, max2,...]``. + There should be a minimum and maximum for all spatial dimensions: ``[min1, max1, min2, max2,...]``. * By default, the whole image will be used. Decreasing the size will speed the analysis up, which might be useful for larger images. * Min and max are inclusive, so ``[0, 63, ...]`` will have size ``(64, ...)``. * Use -ve to use ``min=0`` and ``max=im.shape[x]-1`` for xth dimension. + * N.B.: we add half of the mask size to the bounding box to ensure that the region of interest has a + sufficiently large area surrounding it. kwargs: any extra arguments to be passed on to the module as part of its `__call__`. Returns: @@ -301,29 +283,73 @@ def __call__( * The most probable class when the corresponding part of the image is occluded (``argmax(dim=-1)``). Both images will be cropped if a bounding box used, but voxel sizes will always match the input. """ + if x.shape[0] > 1: + raise ValueError("Expected batch size of 1.") + + sd = x.ndim - 2 + mask_size = ensure_tuple_rep(self.mask_size, sd) + + # get the meshgrid (so that sliding_window_inference can tell us which bit to occlude) + grid: MetaTensor = MetaTensor( + np.stack(np.meshgrid(*[np.arange(0, i) for i in x.shape[2:]], indexing="ij"))[None], + device=x.device, + dtype=x.dtype, + ) + # if bounding box given, crop the grid to only infer subsections of the image + if b_box is not None: + grid, cropper = self.crop_meshgrid(grid, b_box, mask_size) + + # check that the grid is bigger than the mask size + if any(m > g for g, m in zip(grid.shape[2:], mask_size)): + raise ValueError("Image (after cropping with bounding box) should be bigger than mask.") + + # get additive and multiplicative factors if they are unchanged for all patches (i.e., not mean_patch) + add: Optional[Union[float, torch.Tensor]] + mul: Optional[Union[float, torch.Tensor]] + # multiply by 0, add value + if isinstance(self.mode, float): + mul, add = self.constant_occlusion(x, self.mode, mask_size) + # multiply by 0, add mean of image + elif self.mode == "mean_img": + mul, add = self.constant_occlusion(x, x.mean().item(), mask_size) + # for gaussian, additive = 0, multiplicative = gaussian + elif self.mode == "gaussian": + mul, add = self.gaussian_occlusion(x, mask_size) + # else will be determined on each patch individually so calculated later + else: + add, mul = None, None with eval_mode(self.nn_module): + # needs to go here to avoid cirular import + from monai.inferers import sliding_window_inference + + sensitivity_im: MetaTensor = sliding_window_inference( # type: ignore + grid, + roi_size=mask_size, + sw_batch_size=self.n_batch, + predictor=OcclusionSensitivity.predictor, + overlap=self.overlap, + mode="gaussian" if self.mode == "gaussian" else "constant", + progress=self.verbose, + nn_module=self.nn_module, + x=x, + add=add, + mul=mul, + mask_size=mask_size, + occ_mode=self.mode, + activate=self.activate, + module_kwargs=kwargs, + ) - # Check input arguments - _check_input_image(x) - - # Generate sensitivity images - sensitivity_ims_list, output_im_shape = self._compute_occlusion_sensitivity(x, b_box, **kwargs) - - # Loop over image for each classification - for i, sens_i in enumerate(sensitivity_ims_list): - # upsample - if self.upsampler is not None: - if len(sens_i.shape) != len(x.shape): - raise AssertionError - if np.any(sens_i.shape != x.shape): - img_spatial = tuple(output_im_shape[1:]) - sensitivity_ims_list[i] = self.upsampler(img_spatial)(sens_i) - - # Convert list of tensors to tensor - sensitivity_ims = torch.stack(sensitivity_ims_list, dim=-1) - - # The most probable class is the max in the classification dimension (last) - most_probable_class = sensitivity_ims.argmax(dim=-1) - - return sensitivity_ims, most_probable_class + if b_box is not None: + # undo the cropping that was applied to the meshgrid + sensitivity_im = cropper.inverse(sensitivity_im[0])[None] # type: ignore + # crop using the bounding box (ignoring the mask size this time) + bbox_min = [max(b, 0) for b in b_box[::2]] + bbox_max = [b if b > 0 else s for b, s in zip(b_box[1::2], x.shape[2:])] + cropper = SpatialCrop(roi_start=bbox_min, roi_end=bbox_max) + sensitivity_im = cropper(sensitivity_im[0])[None] # type: ignore + + # The most probable class is the max in the classification dimension (1) + most_probable_class = sensitivity_im.argmax(dim=1, keepdim=True) + return sensitivity_im, most_probable_class diff --git a/tests/test_occlusion_sensitivity.py b/tests/test_occlusion_sensitivity.py index ce29b55edf..cedc8ed1a3 100644 --- a/tests/test_occlusion_sensitivity.py +++ b/tests/test_occlusion_sensitivity.py @@ -10,6 +10,7 @@ # limitations under the License. import unittest +from typing import Any, List import torch from parameterized import parameterized @@ -40,47 +41,69 @@ def __call__(self, x, adjoint_info): model_2d_adjoint.eval() -# 2D w/ bounding box -TEST_CASE_0 = [ - {"nn_module": model_2d}, - {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [-1, -1, 2, 40, 1, 62]}, - (1, 1, 39, 62, out_channels_2d), - (1, 1, 39, 62), -] -# 3D w/ bounding box and stride -TEST_CASE_1 = [ - {"nn_module": model_3d, "n_batch": 10, "stride": (2, 1, 2), "mask_size": (16, 15, 14)}, - {"x": torch.rand(1, 1, 6, 6, 6).to(device), "b_box": [-1, -1, 2, 3, -1, -1, -1, -1]}, - (1, 1, 2, 6, 6, out_channels_3d), - (1, 1, 2, 6, 6), -] - -TEST_CASE_FAIL_0 = [ # 2D should fail, since 3 stride values given - {"nn_module": model_2d, "n_batch": 10, "stride": (2, 2, 2)}, - {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [-1, -1, 2, 3, -1, -1]}, -] - -TEST_CASE_FAIL_1 = [ # 2D should fail, since stride is not a factor of image size - {"nn_module": model_2d, "stride": 3}, - {"x": torch.rand(1, 1, 48, 64).to(device)}, -] -TEST_MULTI_CHANNEL = [ - {"nn_module": model_2d_2c, "per_channel": False}, - {"x": torch.rand(1, 2, 48, 64).to(device)}, - (1, 1, 48, 64, out_channels_2d), - (1, 1, 48, 64), -] +TESTS: List[Any] = [] +TESTS_FAIL: List[Any] = [] + +# 2D w/ bounding box with all modes +for mode in ("gaussian", "mean_patch", "mean_img"): + TESTS.append( + [ + {"nn_module": model_2d, "mode": mode}, + {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [2, 40, 1, 62]}, + (1, out_channels_2d, 38, 61), + (1, 1, 38, 61), + ] + ) +# 3D w/ bounding box +TESTS.append( + [ + {"nn_module": model_3d, "n_batch": 10, "mask_size": (16, 15, 14)}, + {"x": torch.rand(1, 1, 64, 32, 16).to(device), "b_box": [2, 43, -1, -1, -1, -1]}, + (1, out_channels_3d, 41, 32, 16), + (1, 1, 41, 32, 16), + ] +) +TESTS.append( + [ + {"nn_module": model_2d_2c}, + {"x": torch.rand(1, 2, 48, 64).to(device)}, + (1, out_channels_2d, 48, 64), + (1, 1, 48, 64), + ] +) # 2D w/ bounding box and adjoint -TEST_CASE_ADJOINT = [ - {"nn_module": model_2d_adjoint}, - {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [-1, -1, 2, 40, 1, 62], "adjoint_info": 42}, - (1, 1, 39, 62, out_channels_2d), - (1, 1, 39, 62), -] +TESTS.append( + [ + {"nn_module": model_2d_adjoint}, + {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [2, 40, 1, 62], "adjoint_info": 42}, + (1, out_channels_2d, 38, 61), + (1, 1, 38, 61), + ] +) +# 2D should fail: bbox makes image too small +TESTS_FAIL.append( + [ + {"nn_module": model_2d, "n_batch": 10, "mask_size": 15}, + {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [2, 3, -1, -1]}, + ValueError, + ] +) +# 2D should fail: batch > 1 +TESTS_FAIL.append( + [ + {"nn_module": model_2d, "n_batch": 10}, + {"x": torch.rand(2, 1, 48, 64).to(device), "b_box": [2, 3, -1, -1]}, + ValueError, + ] +) +# 2D should fail: unknown mode +TESTS_FAIL.append( + [{"nn_module": model_2d, "mode": "test"}, {"x": torch.rand(1, 1, 48, 64).to(device)}, NotImplementedError] +) class TestComputeOcclusionSensitivity(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_MULTI_CHANNEL, TEST_CASE_ADJOINT]) + @parameterized.expand(TESTS) def test_shape(self, init_data, call_data, map_expected_shape, most_prob_expected_shape): occ_sens = OcclusionSensitivity(**init_data) m, most_prob = occ_sens(**call_data) @@ -91,10 +114,10 @@ def test_shape(self, init_data, call_data, map_expected_shape, most_prob_expecte self.assertGreaterEqual(most_prob.min(), 0) self.assertLess(most_prob.max(), m.shape[-1]) - @parameterized.expand([TEST_CASE_FAIL_0, TEST_CASE_FAIL_1]) - def test_fail(self, init_data, call_data): - occ_sens = OcclusionSensitivity(**init_data) - with self.assertRaises(ValueError): + @parameterized.expand(TESTS_FAIL) + def test_fail(self, init_data, call_data, error_type): + with self.assertRaises(error_type): + occ_sens = OcclusionSensitivity(**init_data) occ_sens(**call_data) From 2687b72114917d8c45409f90b68a22fb5fbbde65 Mon Sep 17 00:00:00 2001 From: myron Date: Sat, 1 Oct 2022 08:04:05 -0700 Subject: [PATCH 20/60] UpSample optional kernel_size for deconv mode (#5221) ### Description Adds (optional) kernel_size parameter to UpSample, used for deconv (convolution transpose up-sampling). This allows to upsample, e.g to upscale to 2x with a kernel_size 3. (currently the default is to upscale to 2x with a kernel size 2) if this parameter is not set, the behavior is the same as before ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron --- monai/networks/blocks/upsample.py | 15 ++++++++++++++- tests/test_milmodel.py | 5 +++-- tests/test_upsample_block.py | 25 +++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/monai/networks/blocks/upsample.py b/monai/networks/blocks/upsample.py index 68b7759a69..9c0afc714e 100644 --- a/monai/networks/blocks/upsample.py +++ b/monai/networks/blocks/upsample.py @@ -40,6 +40,7 @@ def __init__( in_channels: Optional[int] = None, out_channels: Optional[int] = None, scale_factor: Union[Sequence[float], float] = 2, + kernel_size: Optional[Union[Sequence[float], float]] = None, size: Optional[Union[Tuple[int], int]] = None, mode: Union[UpsampleMode, str] = UpsampleMode.DECONV, pre_conv: Optional[Union[nn.Module, str]] = "default", @@ -54,6 +55,7 @@ def __init__( in_channels: number of channels of the input image. out_channels: number of channels of the output image. Defaults to `in_channels`. scale_factor: multiplier for spatial size. Has to match input size if it is a tuple. Defaults to 2. + kernel_size: kernel size used during UpsampleMode.DECONV. Defaults to `scale_factor`. size: spatial size of the output image. Only used when ``mode`` is ``UpsampleMode.NONTRAINABLE``. In torch.nn.functional.interpolate, only one of `size` or `scale_factor` should be defined, @@ -83,13 +85,24 @@ def __init__( if up_mode == UpsampleMode.DECONV: if not in_channels: raise ValueError(f"in_channels needs to be specified in the '{mode}' mode.") + + if not kernel_size: + kernel_size_ = scale_factor_ + output_padding = padding = 0 + else: + kernel_size_ = ensure_tuple_rep(kernel_size, spatial_dims) + padding = tuple((k - 1) // 2 for k in kernel_size_) # type: ignore + output_padding = tuple(s - 1 - (k - 1) % 2 for k, s in zip(kernel_size_, scale_factor_)) # type: ignore + self.add_module( "deconv", Conv[Conv.CONVTRANS, spatial_dims]( in_channels=in_channels, out_channels=out_channels or in_channels, - kernel_size=scale_factor_, + kernel_size=kernel_size_, stride=scale_factor_, + padding=padding, + output_padding=output_padding, bias=bias, ), ) diff --git a/tests/test_milmodel.py b/tests/test_milmodel.py index ad04e96c60..3a6ea9a1bd 100644 --- a/tests/test_milmodel.py +++ b/tests/test_milmodel.py @@ -17,7 +17,7 @@ from monai.networks import eval_mode from monai.networks.nets import MILModel from monai.utils.module import optional_import -from tests.utils import test_script_save +from tests.utils import skip_if_downloading_fails, test_script_save models, _ = optional_import("torchvision.models") @@ -65,7 +65,8 @@ class TestMilModel(unittest.TestCase): @parameterized.expand(TEST_CASE_MILMODEL) def test_shape(self, input_param, input_shape, expected_shape): - net = MILModel(**input_param).to(device) + with skip_if_downloading_fails(): + net = MILModel(**input_param).to(device) with eval_mode(net): result = net(torch.randn(input_shape, dtype=torch.float).to(device)) self.assertEqual(result.shape, expected_shape) diff --git a/tests/test_upsample_block.py b/tests/test_upsample_block.py index fe27de65d5..f6bf5dc6ae 100644 --- a/tests/test_upsample_block.py +++ b/tests/test_upsample_block.py @@ -88,13 +88,34 @@ TEST_CASES_EQ.append(test_case) +TEST_CASES_EQ2 = [] # type: ignore +for s in range(2, 5): + for k in range(1, 7): + expected_shape = (16, 5, 4 * s, 5 * s, 6 * s) + for t in UpsampleMode: + test_case = [ + { + "spatial_dims": 3, + "in_channels": 3, + "out_channels": 5, + "mode": t, + "scale_factor": s, + "kernel_size": k, + "align_corners": False, + }, + (16, 3, 4, 5, 6), + expected_shape, + ] + TEST_CASES_EQ.append(test_case) + + class TestUpsample(unittest.TestCase): - @parameterized.expand(TEST_CASES + TEST_CASES_EQ) + @parameterized.expand(TEST_CASES + TEST_CASES_EQ + TEST_CASES_EQ2) def test_shape(self, input_param, input_shape, expected_shape): net = UpSample(**input_param) with eval_mode(net): result = net(torch.randn(input_shape)) - self.assertEqual(result.shape, expected_shape) + self.assertEqual(result.shape, expected_shape, msg=str(input_param)) if __name__ == "__main__": From 6e63284c75e798528cd2f1e89392f2a303d47567 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 3 Oct 2022 16:30:33 +0100 Subject: [PATCH 21/60] 5251 replace None type metadict content with 'none' (#5252) Signed-off-by: Wenqi Li Fixes #5251 ### Description replace `None` with `"none"` in the readers ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/data/image_reader.py | 13 ++++++++-- monai/metrics/active_learning_metrics.py | 31 +++++++++++------------- monai/visualize/occlusion_sensitivity.py | 3 ++- tests/test_pil_reader.py | 1 + 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 904a5bb2d2..087d4d1950 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -28,7 +28,16 @@ orientation_ras_lps, ) from monai.transforms.utility.array import EnsureChannelFirst -from monai.utils import MetaKeys, SpaceKeys, deprecated, ensure_tuple, ensure_tuple_rep, optional_import, require_pkg +from monai.utils import ( + MetaKeys, + SpaceKeys, + TraceKeys, + deprecated, + ensure_tuple, + ensure_tuple_rep, + optional_import, + require_pkg, +) if TYPE_CHECKING: import itk @@ -131,7 +140,7 @@ def _copy_compatible_dict(from_dict: Dict, to_dict: Dict): datum = from_dict[key] if isinstance(datum, np.ndarray) and np_str_obj_array_pattern.search(datum.dtype.str) is not None: continue - to_dict[key] = datum + to_dict[key] = str(TraceKeys.NONE) if datum is None else datum # NoneType to string for default_collate else: affine_key, shape_key = MetaKeys.AFFINE, MetaKeys.SPATIAL_SHAPE if affine_key in from_dict and not np.allclose(from_dict[affine_key], to_dict[affine_key]): diff --git a/monai/metrics/active_learning_metrics.py b/monai/metrics/active_learning_metrics.py index ad62935f44..9d2de8b1a2 100644 --- a/monai/metrics/active_learning_metrics.py +++ b/monai/metrics/active_learning_metrics.py @@ -15,6 +15,7 @@ import torch from monai.metrics.utils import ignore_background +from monai.utils import MetricReduction from .metric import Metric @@ -135,7 +136,7 @@ def compute_variance( n_len = len(y_pred.shape) - if n_len < 4 and spatial_map is True: + if n_len < 4 and spatial_map: warnings.warn("Spatial map requires a 2D/3D image with N-repeats and C-channels") return None @@ -149,16 +150,14 @@ def compute_variance( y_reshaped = torch.reshape(y_pred, new_shape) variance = torch.var(y_reshaped, dim=0, unbiased=False) - if spatial_map is True: + if spatial_map: return variance - elif spatial_map is False: - if scalar_reduction == "mean": - var_mean = torch.mean(variance) - return var_mean - elif scalar_reduction == "sum": - var_sum = torch.sum(variance) - return var_sum + if scalar_reduction == MetricReduction.MEAN: + return torch.mean(variance) + if scalar_reduction == MetricReduction.SUM: + return torch.sum(variance) + raise ValueError(f"scalar_reduction={scalar_reduction} not supported.") def label_quality_score( @@ -196,13 +195,11 @@ def label_quality_score( abs_diff_map = torch.abs(y_pred - y) - if scalar_reduction == "none": + if scalar_reduction == MetricReduction.NONE: return abs_diff_map - elif scalar_reduction != "none": - if scalar_reduction == "mean": - lbl_score_mean = torch.mean(abs_diff_map, dim=list(range(1, n_len))) - return lbl_score_mean - elif scalar_reduction == "sum": - lbl_score_sum = torch.sum(abs_diff_map, dim=list(range(1, n_len))) - return lbl_score_sum + if scalar_reduction == MetricReduction.MEAN: + return torch.mean(abs_diff_map, dim=list(range(1, n_len))) + if scalar_reduction == MetricReduction.SUM: + return torch.sum(abs_diff_map, dim=list(range(1, n_len))) + raise ValueError(f"scalar_reduction={scalar_reduction} not supported.") diff --git a/monai/visualize/occlusion_sensitivity.py b/monai/visualize/occlusion_sensitivity.py index b4845b7e27..cfc2d2b675 100644 --- a/monai/visualize/occlusion_sensitivity.py +++ b/monai/visualize/occlusion_sensitivity.py @@ -155,7 +155,8 @@ def gaussian_occlusion(x: torch.Tensor, mask_size, sigma=0.25) -> Tuple[torch.Te [GaussianSmooth(sigma=[b * sigma for b in spatial_shape]), Lambda(lambda x: -x), ScaleIntensity()] ) # transform and add batch - mul: torch.Tensor = gaussian(kernel)[None] # type: ignore + mul: torch.Tensor = gaussian(kernel)[None] + return mul, 0 @staticmethod diff --git a/tests/test_pil_reader.py b/tests/test_pil_reader.py index 0f7792a56c..4f0b891b72 100644 --- a/tests/test_pil_reader.py +++ b/tests/test_pil_reader.py @@ -64,6 +64,7 @@ def test_converter(self, data_shape, filenames, expected_shape, meta_shape): Image.fromarray(test_image.astype("uint8")).save(filenames[i]) reader = PILReader(converter=lambda image: image.convert("LA")) result = reader.get_data(reader.read(filenames, mode="r")) + self.assertEqual(result[1]["format"], "none") # project-monai/monai issue#5251 # load image by PIL and compare the result test_image = np.asarray(Image.open(filenames[0]).convert("LA")) From 1ee8ba090ada9a8a148a0a7e27484ef1dc064dc9 Mon Sep 17 00:00:00 2001 From: Can Zhao <69829124+Can-Zhao@users.noreply.github.com> Date: Mon, 3 Oct 2022 16:02:16 -0400 Subject: [PATCH 22/60] fix bug in retina detector (#5255) Signed-off-by: Can Zhao Fixes #5250. ### Description Fixed bug that allow_low_quality_matches in set_regular_matcher was always set to True and it's not using value from the argument, in retinanet. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Can Zhao Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/apps/detection/networks/retinanet_detector.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/monai/apps/detection/networks/retinanet_detector.py b/monai/apps/detection/networks/retinanet_detector.py index 290310e009..b4ccafbd01 100644 --- a/monai/apps/detection/networks/retinanet_detector.py +++ b/monai/apps/detection/networks/retinanet_detector.py @@ -318,13 +318,17 @@ def set_regular_matcher(self, fg_iou_thresh: float, bg_iou_thresh: float, allow_ 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 + allow_low_quality_matches: if True, produce additional matches + for predictions that have only low-quality match candidates. """ 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) + self.proposal_matcher = Matcher( + fg_iou_thresh, bg_iou_thresh, allow_low_quality_matches=allow_low_quality_matches + ) def set_atss_matcher(self, num_candidates: int = 4, center_in_gt: bool = False) -> None: """ From 94673a97986327b09d9d5c1e26695ea8c6b9c178 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Oct 2022 11:47:29 +0000 Subject: [PATCH 23/60] [pre-commit.ci] pre-commit suggestions/consolidate autofixes (#5256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.34.0 → v2.38.2](https://github.com/asottile/pyupgrade/compare/v2.34.0...v2.38.2) - [github.com/asottile/yesqa: v1.3.0 → v1.4.0](https://github.com/asottile/yesqa/compare/v1.3.0...v1.4.0) - [github.com/hadialqattan/pycln: v1.3.5 → v2.1.1](https://github.com/hadialqattan/pycln/compare/v1.3.5...v2.1.1) Signed-off-by: Wenqi Li Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li --- .pre-commit-config.yaml | 6 +++--- monai/bundle/config_item.py | 2 +- monai/data/dataloader.py | 2 +- monai/data/wsi_reader.py | 6 ------ monai/networks/blocks/feature_pyramid_network.py | 2 +- monai/networks/blocks/fft_utils_t.py | 12 ++++++------ monai/networks/layers/weight_init.py | 2 +- monai/transforms/utility/dictionary.py | 2 +- monai/transforms/utils_create_transform_ims.py | 2 -- monai/utils/misc.py | 4 ++-- tests/test_apply_filter.py | 1 - tests/test_fpn_block.py | 4 +++- tests/test_gmm.py | 2 +- tests/test_k_space_spike_noised.py | 2 +- tests/test_monai_env_vars.py | 2 +- tests/test_rand_k_space_spike_noised.py | 2 +- tests/test_separable_filter.py | 1 - tests/test_transform.py | 2 +- tests/test_varnet.py | 3 ++- 19 files changed, 26 insertions(+), 33 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d2a6990830..62550f51d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: - id: mixed-line-ending - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v2.38.2 hooks: - id: pyupgrade args: [--py37-plus] @@ -40,7 +40,7 @@ repos: )$ - repo: https://github.com/asottile/yesqa - rev: v1.3.0 + rev: v1.4.0 hooks: - id: yesqa name: Unused noqa @@ -58,7 +58,7 @@ repos: )$ - repo: https://github.com/hadialqattan/pycln - rev: v1.3.5 + rev: v2.1.1 hooks: - id: pycln args: [--config=pyproject.toml] diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py index 98babfb225..34a390be5f 100644 --- a/monai/bundle/config_item.py +++ b/monai/bundle/config_item.py @@ -106,7 +106,7 @@ def get_component_module_name(self, name: str) -> Optional[Union[List[str], str] # init component and module mapping table self._components_table = self._find_classes_or_functions(self._find_module_names()) - mods: Optional[Union[List[str], str]] = self._components_table.get(name, None) + mods: Optional[Union[List[str], str]] = self._components_table.get(name) if isinstance(mods, list) and len(mods) == 1: mods = mods[0] return mods diff --git a/monai/data/dataloader.py b/monai/data/dataloader.py index e8ff23a5a3..f43211f184 100644 --- a/monai/data/dataloader.py +++ b/monai/data/dataloader.py @@ -76,7 +76,7 @@ def __init__(self, dataset: Dataset, num_workers: int = 0, **kwargs) -> None: # when num_workers > 0, random states are determined by worker_init_fn # this is to make the behavior consistent when num_workers == 0 # torch.int64 doesn't work well on some versions of windows - _g = torch.random.default_generator if kwargs.get("generator", None) is None else kwargs["generator"] + _g = torch.random.default_generator if kwargs.get("generator") is None else kwargs["generator"] init_seed = _g.initial_seed() _seed = torch.empty((), dtype=torch.int64).random_(generator=_g).item() set_rnd(dataset, int(_seed)) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 71c933adb7..0d3924182c 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -555,9 +555,6 @@ class OpenSlideWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "openslide" - def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): - super().__init__(level, channel_dim, **kwargs) - @staticmethod def get_level_count(wsi) -> int: """ @@ -702,9 +699,6 @@ class TiffFileWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "tifffile" - def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): - super().__init__(level, channel_dim, **kwargs) - @staticmethod def get_level_count(wsi) -> int: """ diff --git a/monai/networks/blocks/feature_pyramid_network.py b/monai/networks/blocks/feature_pyramid_network.py index 2373cfc099..13897a51a0 100644 --- a/monai/networks/blocks/feature_pyramid_network.py +++ b/monai/networks/blocks/feature_pyramid_network.py @@ -258,6 +258,6 @@ def forward(self, x: Dict[str, Tensor]) -> Dict[str, Tensor]: 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)]) + out = OrderedDict(list(zip(names, results))) return out diff --git a/monai/networks/blocks/fft_utils_t.py b/monai/networks/blocks/fft_utils_t.py index 26205041be..1283f05c6b 100644 --- a/monai/networks/blocks/fft_utils_t.py +++ b/monai/networks/blocks/fft_utils_t.py @@ -139,12 +139,12 @@ def ifftn_centered_t(ksp: Tensor, spatial_dims: int, is_complex: bool = True) -> output2 = ifftn_centered(ksp, spatial_dims=2, is_complex=True) """ # define spatial dims to perform ifftshift, fftshift, and ifft - shift = [i for i in range(-spatial_dims, 0)] # noqa: C416 + shift = list(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 = [i for i in range(-spatial_dims - 1, -1)] # noqa: C416 - dims = [i for i in range(-spatial_dims, 0)] # noqa: C416 + shift = list(range(-spatial_dims - 1, -1)) + dims = list(range(-spatial_dims, 0)) x = ifftshift(ksp, shift) @@ -187,12 +187,12 @@ def fftn_centered_t(im: Tensor, spatial_dims: int, is_complex: bool = True) -> T output2 = fftn_centered(im, spatial_dims=2, is_complex=True) """ # define spatial dims to perform ifftshift, fftshift, and fft - shift = [i for i in range(-spatial_dims, 0)] # noqa: C416 + shift = list(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 = [i for i in range(-spatial_dims - 1, -1)] # noqa: C416 - dims = [i for i in range(-spatial_dims, 0)] # noqa: C416 + shift = list(range(-spatial_dims - 1, -1)) + dims = list(range(-spatial_dims, 0)) x = ifftshift(im, shift) diff --git a/monai/networks/layers/weight_init.py b/monai/networks/layers/weight_init.py index 9b81ef17f8..b0c6fae2c2 100644 --- a/monai/networks/layers/weight_init.py +++ b/monai/networks/layers/weight_init.py @@ -55,7 +55,7 @@ def trunc_normal_(tensor, mean=0.0, std=1.0, a=-2.0, b=2.0): b: the maximum cutoff value """ - if not std > 0: + if std <= 0: raise ValueError("the standard deviation should be greater than zero.") if a >= b: diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 53b0646379..53293a1729 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -423,7 +423,7 @@ def __call__( output = [] results = [self.splitter(d[key]) for key in all_keys] for row in zip(*results): - new_dict = {k: v for k, v in zip(all_keys, row)} + new_dict = dict(zip(all_keys, row)) # fill in the extra keys with unmodified data for k in set(d.keys()).difference(set(all_keys)): new_dict[k] = deepcopy(d[k]) diff --git a/monai/transforms/utils_create_transform_ims.py b/monai/transforms/utils_create_transform_ims.py index 66f25d1198..68ecd25128 100644 --- a/monai/transforms/utils_create_transform_ims.py +++ b/monai/transforms/utils_create_transform_ims.py @@ -427,8 +427,6 @@ def create_transform_im( seed = seed + 1 if isinstance(transform, MapTransform) else seed transform.set_random_state(seed) - from monai.utils.misc import MONAIEnvVars - out_dir = MONAIEnvVars.doc_images() if out_dir is None: raise RuntimeError( diff --git a/monai/utils/misc.py b/monai/utils/misc.py index ae62f26635..d4bea4d27c 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -397,7 +397,7 @@ class MONAIEnvVars: @staticmethod def data_dir() -> Optional[str]: - return os.environ.get("MONAI_DATA_DIRECTORY", None) + return os.environ.get("MONAI_DATA_DIRECTORY") @staticmethod def debug() -> bool: @@ -406,7 +406,7 @@ def debug() -> bool: @staticmethod def doc_images() -> Optional[str]: - return os.environ.get("MONAI_DOC_IMAGES", None) + return os.environ.get("MONAI_DOC_IMAGES") class ImageMetaKey: diff --git a/tests/test_apply_filter.py b/tests/test_apply_filter.py index 3174211f34..62372516a5 100644 --- a/tests/test_apply_filter.py +++ b/tests/test_apply_filter.py @@ -64,7 +64,6 @@ def test_3d(self): ], ] ) - expected = expected # testing shapes k = torch.tensor([[[1, 1, 1], [1, 1, 1], [1, 1, 1]]]) for kernel in (k, k[None], k[None][None]): diff --git a/tests/test_fpn_block.py b/tests/test_fpn_block.py index 420fd04367..a86cd22a19 100644 --- a/tests/test_fpn_block.py +++ b/tests/test_fpn_block.py @@ -19,7 +19,7 @@ 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 +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save _, has_torchvision = optional_import("torchvision") @@ -53,6 +53,7 @@ def test_fpn_block(self, input_param, input_shape, expected_shape): self.assertEqual(result["feat1"].shape, expected_shape[1]) @parameterized.expand(TEST_CASES) + @SkipIfBeforePyTorchVersion((1, 9, 1)) def test_script(self, input_param, input_shape, expected_shape): # test whether support torchscript net = FeaturePyramidNetwork(**input_param) @@ -73,6 +74,7 @@ def test_fpn(self, input_param, input_shape, expected_shape): self.assertEqual(result["pool"].shape, expected_shape[1]) @parameterized.expand(TEST_CASES2) + @SkipIfBeforePyTorchVersion((1, 9, 1)) 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]) diff --git a/tests/test_gmm.py b/tests/test_gmm.py index f085dd916c..66bd6079e6 100644 --- a/tests/test_gmm.py +++ b/tests/test_gmm.py @@ -259,7 +259,7 @@ @skip_if_no_cuda class GMMTestCase(unittest.TestCase): def setUp(self): - self._var = os.environ.get("TORCH_EXTENSIONS_DIR", None) + self._var = os.environ.get("TORCH_EXTENSIONS_DIR") self.tempdir = tempfile.mkdtemp() os.environ["TORCH_EXTENSIONS_DIR"] = self.tempdir diff --git a/tests/test_k_space_spike_noised.py b/tests/test_k_space_spike_noised.py index 7a6403655c..03c99d1533 100644 --- a/tests/test_k_space_spike_noised.py +++ b/tests/test_k_space_spike_noised.py @@ -43,7 +43,7 @@ def get_data(im_shape, im_type): create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5) ims = [im_type(im[None]) for im in ims] - return {k: v for k, v in zip(KEYS, ims)} + return dict(zip(KEYS, ims)) @parameterized.expand(TESTS) def test_same_result(self, im_shape, im_type): diff --git a/tests/test_monai_env_vars.py b/tests/test_monai_env_vars.py index 68d2755af7..663dcdd98d 100644 --- a/tests/test_monai_env_vars.py +++ b/tests/test_monai_env_vars.py @@ -19,7 +19,7 @@ class TestMONAIEnvVars(unittest.TestCase): @classmethod def setUpClass(cls): super(__class__, cls).setUpClass() - cls.orig_value = os.environ.get("MONAI_DEBUG", None) + cls.orig_value = os.environ.get("MONAI_DEBUG") @classmethod def tearDownClass(cls): diff --git a/tests/test_rand_k_space_spike_noised.py b/tests/test_rand_k_space_spike_noised.py index 156c95822f..7f493ef276 100644 --- a/tests/test_rand_k_space_spike_noised.py +++ b/tests/test_rand_k_space_spike_noised.py @@ -40,7 +40,7 @@ def get_data(im_shape, im_type): create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5) ims = [im_type(im[None]) for im in ims] - return {k: v for k, v in zip(KEYS, ims)} + return dict(zip(KEYS, ims)) @parameterized.expand(TESTS) def test_same_result(self, im_shape, im_type): diff --git a/tests/test_separable_filter.py b/tests/test_separable_filter.py index e152ad2c2b..e6838e2f9b 100644 --- a/tests/test_separable_filter.py +++ b/tests/test_separable_filter.py @@ -64,7 +64,6 @@ def test_3d(self): ], ] ) - expected = expected # testing shapes k = torch.tensor([1, 1, 1]) for kernel in (k, [k] * 3): diff --git a/tests/test_transform.py b/tests/test_transform.py index 9cf18b8dbd..a6c5001147 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -29,7 +29,7 @@ class TestTransform(unittest.TestCase): @classmethod def setUpClass(cls): super(__class__, cls).setUpClass() - cls.orig_value = os.environ.get("MONAI_DEBUG", None) + cls.orig_value = os.environ.get("MONAI_DEBUG") @classmethod def tearDownClass(cls): diff --git a/tests/test_varnet.py b/tests/test_varnet.py index 226aff6809..c715e7d37f 100644 --- a/tests/test_varnet.py +++ b/tests/test_varnet.py @@ -18,7 +18,7 @@ from monai.apps.reconstruction.networks.nets.complex_unet import ComplexUnet from monai.apps.reconstruction.networks.nets.varnet import VariationalNetworkModel from monai.networks import eval_mode -from tests.utils import test_script_save +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save device = torch.device("cuda" if torch.cuda.is_available() else "cpu") coil_sens_model = CoilSensitivityModel(spatial_dims=2, features=[8, 16, 32, 64, 128, 8]) @@ -43,6 +43,7 @@ def test_shape(self, coil_sens_model, refinement_model, num_cascades, input_shap self.assertEqual(result.shape, expected_shape) @parameterized.expand(TESTS) + @SkipIfBeforePyTorchVersion((1, 9, 1)) def test_script(self, coil_sens_model, refinement_model, num_cascades, input_shape, expected_shape): net = VariationalNetworkModel(coil_sens_model, refinement_model, num_cascades) From d76adc73791dc83aa70481b84f23fe9075160ef7 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Tue, 4 Oct 2022 14:58:30 +0100 Subject: [PATCH 24/60] 5253 fixes output offset - spacing (#5254) Signed-off-by: Wenqi Li Fixes #5253 ### Description enhance computing offset for spacing ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/data/utils.py | 12 +++++++----- tests/test_spacing.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 4c024f0d5a..2f395c9065 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -875,17 +875,19 @@ def compute_shape_offset( corners_out = inv_mat @ corners corners_out = corners_out[:-1] / corners_out[-1] out_shape = np.round(corners_out.ptp(axis=1)) if scale_extent else np.round(corners_out.ptp(axis=1) + 1.0) - mat = inv_mat[:-1, :-1] - i = 0 + all_dist = inv_mat[:-1, :-1] @ corners[:-1, :] + offset = None for i in range(corners.shape[1]): - min_corner = np.min(mat @ corners[:-1, :] - mat @ corners[:-1, i : i + 1], 1) + min_corner = np.min(all_dist - all_dist[:, i : i + 1], 1) if np.allclose(min_corner, 0.0, rtol=AFFINE_TOL): + offset = corners[:-1, i] # corner is the smallest, shift the corner to origin break - offset = corners[:-1, i] + if offset is None: # otherwise make output image center aligned with the input image center + offset = in_affine_[:-1, :-1] @ (shape / 2.0) + in_affine_[:-1, -1] - out_affine_[:-1, :-1] @ (out_shape / 2.0) if scale_extent: in_offset = np.append(0.5 * (shape / out_shape - 1.0), 1.0) offset = np.abs((in_affine_ @ in_offset / in_offset[-1])[:-1]) * np.sign(offset) - return out_shape.astype(int, copy=False), offset + return out_shape.astype(int, copy=False), offset # type: ignore def to_affine_nd(r: Union[np.ndarray, int], affine: NdarrayTensor, dtype=np.float64) -> NdarrayTensor: diff --git a/tests/test_spacing.py b/tests/test_spacing.py index d9f8168883..c16e8b4d48 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -220,6 +220,24 @@ *device, ] ) + TESTS.append( # 5D input + [ + {"pixdim": 0.5, "padding_mode": "zeros", "mode": "nearest", "scale_extent": True}, + torch.ones((1, 368, 336, 368)), # data + torch.tensor( + [ + [0.41, 0.005, 0.008, -79.7], + [-0.0049, 0.592, 0.0664, -57.4], + [-0.0073, -0.0972, 0.404, -32.1], + [0.0, 0.0, 0.0, 1.0], + ] + ), + {}, + torch.ones((1, 302, 403, 301)), + *device, + ] + ) + TESTS_TORCH = [] for track_meta in (False, True): From c9d1a71e1c1cc39d46b80bc07cad77421291553b Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Wed, 5 Oct 2022 12:24:54 +0100 Subject: [PATCH 25/60] 4320 update docstrings for gradient based saliency (#5268) Signed-off-by: Wenqi Li Fixes #4320 it seems we also have sufficient modules to close #3542 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- docs/source/visualize.rst | 8 +++++++ monai/visualize/gradient_based.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/source/visualize.rst b/docs/source/visualize.rst index 3779feec88..1860b65e03 100644 --- a/docs/source/visualize.rst +++ b/docs/source/visualize.rst @@ -25,7 +25,15 @@ Occlusion sensitivity .. automodule:: monai.visualize.occlusion_sensitivity :members: +Gradient-based saliency maps +---------------------------- + +.. automodule:: monai.visualize.gradient_based + :members: + + Utilities --------- + .. automodule:: monai.visualize.utils :members: diff --git a/monai/visualize/gradient_based.py b/monai/visualize/gradient_based.py index 6727c8c239..106378dff2 100644 --- a/monai/visualize/gradient_based.py +++ b/monai/visualize/gradient_based.py @@ -45,12 +45,29 @@ def backward(ctx, grad_output): class _GradReLU(torch.nn.Module): + """ + A customized ReLU with the backward pass imputed for guided backpropagation (https://arxiv.org/abs/1412.6806). + """ + def forward(self, x: torch.Tensor) -> torch.Tensor: out: torch.Tensor = _AutoGradReLU.apply(x) return out class VanillaGrad: + """ + Given an input image ``x``, calling this class will perform the forward pass, then set to zero + all activations except one (defined by ``index``) and propagate back to the image to achieve a gradient-based + saliency map. + + If ``index`` is None, argmax of the output logits will be used. + + See also: + + - Simonyan et al. Deep Inside Convolutional Networks: Visualising Image Classification Models and Saliency Maps + (https://arxiv.org/abs/1312.6034) + """ + 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) @@ -83,7 +100,11 @@ def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **k class SmoothGrad(VanillaGrad): """ + Compute averaged sensitivity map based on ``n_samples`` (Gaussian additive) of noisy versions + of the input image ``x``. + See also: + - Smilkov et al. SmoothGrad: removing noise by adding noise https://arxiv.org/abs/1706.03825 """ @@ -126,12 +147,26 @@ def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **k class GuidedBackpropGrad(VanillaGrad): + """ + Based on Springenberg and Dosovitskiy et al. https://arxiv.org/abs/1412.6806, + compute gradient-based saliency maps by backpropagating positive graidents and inputs (see ``_AutoGradReLU``). + + See also: + + - Springenberg and Dosovitskiy et al. Striving for Simplicity: The All Convolutional Net + (https://arxiv.org/abs/1412.6806) + """ + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **kwargs) -> torch.Tensor: with replace_modules_temp(self.model, "relu", _GradReLU(), strict_match=False): return super().__call__(x, index, **kwargs) class GuidedBackpropSmoothGrad(SmoothGrad): + """ + Compute gradient-based saliency maps based on both ``GuidedBackpropGrad`` and ``SmoothGrad``. + """ + def __call__(self, x: torch.Tensor, index: torch.Tensor | int | None = None, **kwargs) -> torch.Tensor: with replace_modules_temp(self.model, "relu", _GradReLU(), strict_match=False): return super().__call__(x, index, **kwargs) From eb0795ee6f8115b6de3789ef57a7990d989ea3e4 Mon Sep 17 00:00:00 2001 From: TrellixVulnTeam <112716341+TrellixVulnTeam@users.noreply.github.com> Date: Thu, 6 Oct 2022 02:56:23 -0500 Subject: [PATCH 26/60] CVE-2007-4559 Patch (#5274) # Patching CVE-2007-4559 Hi, we are security researchers from the Advanced Research Center at [Trellix](https://www.trellix.com). We have began a campaign to patch a widespread bug named CVE-2007-4559. CVE-2007-4559 is a 15 year old bug in the Python tarfile package. By using extract() or extractall() on a tarfile object without sanitizing input, a maliciously crafted .tar file could perform a directory path traversal attack. We found at least one unsantized extractall() in your codebase and are providing a patch for you via pull request. The patch essentially checks to see if all tarfile members will be extracted safely and throws an exception otherwise. We encourage you to use this patch or your own solution to secure against CVE-2007-4559. Further technical information about the vulnerability can be found in this [blog](https://www.trellix.com/en-us/about/newsroom/stories/research/tarfile-exploiting-the-world.html). If you have further questions you may contact us through this projects lead researcher [Kasimir Schulz](mailto:kasimir.schulz@trellix.com). 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/networks/nets/transchex.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/monai/networks/nets/transchex.py b/monai/networks/nets/transchex.py index b03ff5a17d..e16858368a 100644 --- a/monai/networks/nets/transchex.py +++ b/monai/networks/nets/transchex.py @@ -72,7 +72,26 @@ def from_pretrained( else: tempdir = tempfile.mkdtemp() with tarfile.open(resolved_archive_file, "r:gz") as archive: - archive.extractall(tempdir) + + def is_within_directory(directory, target): + + abs_directory = os.path.abspath(directory) + abs_target = os.path.abspath(target) + + prefix = os.path.commonprefix([abs_directory, abs_target]) + + return prefix == abs_directory + + def safe_extract(tar, path=".", members=None, *, numeric_owner=False): + + for member in tar.getmembers(): + member_path = os.path.join(path, member.name) + if not is_within_directory(path, member_path): + raise Exception("Attempted Path Traversal in Tar File") + + tar.extractall(path, members, numeric_owner=numeric_owner) + + safe_extract(archive, tempdir) serialization_dir = tempdir model = cls(num_language_layers, num_vision_layers, num_mixed_layers, bert_config, *inputs, **kwargs) if state_dict is None and not from_tf: From 5a517fb7f4702f40967ed0736c8e6fad9f152cbb Mon Sep 17 00:00:00 2001 From: myron Date: Sat, 8 Oct 2022 11:08:18 -0700 Subject: [PATCH 27/60] Upsample mode deconvgroup (#5282) This adds an additional UpSample mode DECONVGROUP to upsample with groupwise convolution transpose. Unit tests are passed too. A few sentences describing the changes proposed in this pull request. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron --- monai/networks/blocks/upsample.py | 44 +++++++++++++++++++++++-------- monai/utils/enums.py | 1 + tests/test_upsample_block.py | 10 +++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/monai/networks/blocks/upsample.py b/monai/networks/blocks/upsample.py index 9c0afc714e..ee03aa4e67 100644 --- a/monai/networks/blocks/upsample.py +++ b/monai/networks/blocks/upsample.py @@ -27,6 +27,7 @@ class UpSample(nn.Sequential): Supported modes are: - "deconv": uses a transposed convolution. + - "deconvgroup": uses a transposed group convolution. - "nontrainable": uses :py:class:`torch.nn.Upsample`. - "pixelshuffle": uses :py:class:`monai.networks.blocks.SubpixelUpsample`. @@ -55,13 +56,13 @@ def __init__( in_channels: number of channels of the input image. out_channels: number of channels of the output image. Defaults to `in_channels`. scale_factor: multiplier for spatial size. Has to match input size if it is a tuple. Defaults to 2. - kernel_size: kernel size used during UpsampleMode.DECONV. Defaults to `scale_factor`. + kernel_size: kernel size used during transposed convolutions. Defaults to `scale_factor`. size: spatial size of the output image. Only used when ``mode`` is ``UpsampleMode.NONTRAINABLE``. In torch.nn.functional.interpolate, only one of `size` or `scale_factor` should be defined, thus if size is defined, `scale_factor` will not be used. Defaults to None. - mode: {``"deconv"``, ``"nontrainable"``, ``"pixelshuffle"``}. Defaults to ``"deconv"``. + mode: {``"deconv"``, ``"deconvgroup"``, ``"nontrainable"``, ``"pixelshuffle"``}. Defaults to ``"deconv"``. pre_conv: a conv block applied before upsampling. Defaults to "default". When ``conv_block`` is ``"default"``, one reserved conv layer will be utilized when Only used in the "nontrainable" or "pixelshuffle" mode. @@ -82,18 +83,18 @@ def __init__( super().__init__() scale_factor_ = ensure_tuple_rep(scale_factor, spatial_dims) up_mode = look_up_option(mode, UpsampleMode) + + if not kernel_size: + kernel_size_ = scale_factor_ + output_padding = padding = 0 + else: + kernel_size_ = ensure_tuple_rep(kernel_size, spatial_dims) + padding = tuple((k - 1) // 2 for k in kernel_size_) # type: ignore + output_padding = tuple(s - 1 - (k - 1) % 2 for k, s in zip(kernel_size_, scale_factor_)) # type: ignore + if up_mode == UpsampleMode.DECONV: if not in_channels: raise ValueError(f"in_channels needs to be specified in the '{mode}' mode.") - - if not kernel_size: - kernel_size_ = scale_factor_ - output_padding = padding = 0 - else: - kernel_size_ = ensure_tuple_rep(kernel_size, spatial_dims) - padding = tuple((k - 1) // 2 for k in kernel_size_) # type: ignore - output_padding = tuple(s - 1 - (k - 1) % 2 for k, s in zip(kernel_size_, scale_factor_)) # type: ignore - self.add_module( "deconv", Conv[Conv.CONVTRANS, spatial_dims]( @@ -106,6 +107,27 @@ def __init__( bias=bias, ), ) + elif up_mode == UpsampleMode.DECONVGROUP: + if not in_channels: + raise ValueError(f"in_channels needs to be specified in the '{mode}' mode.") + + if out_channels is None: + out_channels = in_channels + groups = out_channels if in_channels % out_channels == 0 else 1 + + self.add_module( + "deconvgroup", + Conv[Conv.CONVTRANS, spatial_dims]( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size_, + stride=scale_factor_, + padding=padding, + output_padding=output_padding, + groups=groups, + bias=bias, + ), + ) elif up_mode == UpsampleMode.NONTRAINABLE: if pre_conv == "default" and (out_channels != in_channels): # defaults to no conv if out_chns==in_chns if not in_channels: diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 12e82cd378..cd120f6c6e 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -169,6 +169,7 @@ class UpsampleMode(StrEnum): """ DECONV = "deconv" + DECONVGROUP = "deconvgroup" NONTRAINABLE = "nontrainable" # e.g. using torch.nn.Upsample PIXELSHUFFLE = "pixelshuffle" diff --git a/tests/test_upsample_block.py b/tests/test_upsample_block.py index f6bf5dc6ae..71884e2db1 100644 --- a/tests/test_upsample_block.py +++ b/tests/test_upsample_block.py @@ -67,6 +67,16 @@ (16, 1, 10, 15, 20), (16, 3, 20, 30, 40), ], # 1-channel 3D, batch 16, pre_conv + [ + {"spatial_dims": 3, "in_channels": 8, "out_channels": 4, "mode": "deconvgroup"}, + (16, 8, 16, 16, 16), + (16, 4, 32, 32, 32), + ], # 8-channel 3D, batch 16 + [ + {"spatial_dims": 2, "in_channels": 32, "out_channels": 16, "mode": "deconvgroup", "scale_factor": 2}, + (8, 32, 16, 16), + (8, 16, 32, 32), + ], # 32-channel 2D, batch 8 ] TEST_CASES_EQ = [] From 9b098632239f9673e0e7ec69c089e6450518ab3e Mon Sep 17 00:00:00 2001 From: binliunls <107988372+binliunls@users.noreply.github.com> Date: Sun, 9 Oct 2022 02:51:57 +0800 Subject: [PATCH 28/60] 5187 update the docstring of the is_pad parameter (#5296) Signed-off-by: binliu Fixes #5187 . ### Description Updated the docstring of the is_pad parameter in flexible-unet. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: binliu --- monai/networks/nets/flexible_unet.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/monai/networks/nets/flexible_unet.py b/monai/networks/nets/flexible_unet.py index 4901decceb..168b5ee90d 100644 --- a/monai/networks/nets/flexible_unet.py +++ b/monai/networks/nets/flexible_unet.py @@ -217,9 +217,6 @@ def __init__( and the spatial size of each dimension must be a multiple of 32 if is pad parameter is False - TODO(binliu@nvidia.com): Add more backbones/encoders to this class and make a general - encoder-decoder structure. ETC:2022.09.01 - Args: in_channels: number of input channels. out_channels: number of output channels. @@ -240,8 +237,9 @@ def __init__( ``"nontrainable"``. interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``} Only used in the "nontrainable" mode. - is_pad: whether to use padding feature maps to enable the input spatial not necessary - to be a multiple of 32. Default to True. + is_pad: whether to pad upsampling features to fit features from encoder. Default to True. + If this parameter is set to "True", the spatial dim of network input can be arbitary + size, which is not supported by TensorRT. Otherwise, it must be a multiple of 32. """ super().__init__() From b61b21eef023bfec51eea78dee4fe329a42a4b5b Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Sun, 9 Oct 2022 16:08:50 +0100 Subject: [PATCH 29/60] 4206 adds ext tests and deps (#4207) Signed-off-by: Wenqi Li Fixes #4206 ### Description Currently ninja+jit is not tested on the cpu-only cases, this adds ninja (optional dep.) and premerge tests on ubuntu for ninja + jit extension. ### Status **Ready** ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- docs/source/installation.md | 4 ++-- monai/_extensions/gmm/gmm.cpp | 6 ++---- setup.cfg | 3 +++ tests/min_tests.py | 1 + tests/test_gmm.py | 14 ++++++++++++-- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/source/installation.md b/docs/source/installation.md index f7a675919f..725ae2dee1 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -210,9 +210,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, pydicom, h5py, nni, optuna] +[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema, ninja, pynrrd, pydicom, h5py, nni, optuna] ``` 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`, `pydicom`, `h5py`, `nni`, `optuna`, respectively. +`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, `ninja`, `pynrrd`, `pydicom`, `h5py`, `nni`, `optuna`, respectively. - `pip install 'monai[all]'` installs all the optional dependencies. diff --git a/monai/_extensions/gmm/gmm.cpp b/monai/_extensions/gmm/gmm.cpp index 4087095340..577e5b117e 100644 --- a/monai/_extensions/gmm/gmm.cpp +++ b/monai/_extensions/gmm/gmm.cpp @@ -58,12 +58,10 @@ torch::Tensor apply(torch::Tensor gmm_tensor, torch::Tensor input_tensor) { 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)); + auto output_size = input_tensor.sizes().vec(); 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; + torch::empty(c10::IntArrayRef(output_size), torch::dtype(torch::kFloat32).device(device_type)); const float* gmm = gmm_tensor.data_ptr(); const float* input = input_tensor.data_ptr(); diff --git a/setup.cfg b/setup.cfg index e9ee943da9..c0a3a1f973 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ install_requires = [options.extras_require] all = nibabel + ninja scikit-image>=0.14.2 pillow tensorboard @@ -60,6 +61,8 @@ all = optuna nibabel = nibabel +ninja = + ninja skimage = scikit-image>=0.14.2 pillow = diff --git a/tests/min_tests.py b/tests/min_tests.py index bb43c5221f..026514e29e 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -63,6 +63,7 @@ def run_testsuit(): "test_foreground_maskd", "test_global_mutual_information_loss", "test_grid_patch", + "test_gmm", "test_handler_checkpoint_loader", "test_handler_checkpoint_saver", "test_handler_classification_saver", diff --git a/tests/test_gmm.py b/tests/test_gmm.py index 66bd6079e6..ad5e383a6a 100644 --- a/tests/test_gmm.py +++ b/tests/test_gmm.py @@ -18,8 +18,9 @@ import torch from parameterized import parameterized +from monai._extensions import load_module from monai.networks.layers import GaussianMixtureModel -from tests.utils import skip_if_no_cuda +from tests.utils import skip_if_darwin, skip_if_no_cuda, skip_if_windows TEST_CASES = [ [ @@ -256,7 +257,6 @@ ] -@skip_if_no_cuda class GMMTestCase(unittest.TestCase): def setUp(self): self._var = os.environ.get("TORCH_EXTENSIONS_DIR") @@ -271,6 +271,7 @@ def tearDown(self) -> None: shutil.rmtree(self.tempdir) @parameterized.expand(TEST_CASES) + @skip_if_no_cuda def test_cuda(self, test_case_description, mixture_count, class_count, features, labels, expected): # Device to run on @@ -297,6 +298,15 @@ def test_cuda(self, test_case_description, mixture_count, class_count, features, # Ensure result are as expected np.testing.assert_allclose(results, expected, atol=1e-3) + @skip_if_darwin + @skip_if_windows + def test_load(self): + if not torch.cuda.is_available(): + with self.assertRaisesRegex(ImportError, ".*symbol.*"): # expecting import error if no cuda + load_module("gmm", {"CHANNEL_COUNT": 2, "MIXTURE_COUNT": 2, "MIXTURE_SIZE": 3}, verbose_build=True) + else: + load_module("gmm", {"CHANNEL_COUNT": 2, "MIXTURE_COUNT": 2, "MIXTURE_SIZE": 3}, verbose_build=True) + if __name__ == "__main__": unittest.main() From f6f65294d1b1d71f8dc7998974eec62011dd7d33 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 10 Oct 2022 18:08:26 +0100 Subject: [PATCH 30/60] fixes cherrypicking issues of spacing Signed-off-by: Wenqi Li --- tests/test_spacing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_spacing.py b/tests/test_spacing.py index c16e8b4d48..cbbb3d0069 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -222,7 +222,7 @@ ) TESTS.append( # 5D input [ - {"pixdim": 0.5, "padding_mode": "zeros", "mode": "nearest", "scale_extent": True}, + {"pixdim": (0.5, 0.5, 0.5), "padding_mode": "zeros", "mode": "nearest", "scale_extent": True}, torch.ones((1, 368, 336, 368)), # data torch.tensor( [ From 9fa35cc6c8be736a6becba94045b62142c38d664 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Tue, 11 Oct 2022 02:32:31 +0800 Subject: [PATCH 31/60] 5304 Add authenticated option for bundle api (#5306) Signed-off-by: Yiheng Wang Fixes #5304 . ### Description This PR adds the authenticated option for bundle api, which the authenticated token, users can increase their rate limits when using our bundle APIs. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Yiheng Wang --- monai/bundle/scripts.py | 49 ++++++++++++++++++++++++++++++----- tests/test_bundle_get_data.py | 17 ++++++++++-- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 3dfdd8e7a5..e3c94a501a 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -322,10 +322,16 @@ def load( return model -def _get_all_bundles_info(repo: str = "Project-MONAI/model-zoo", tag: str = "hosting_storage_v1"): +def _get_all_bundles_info( + repo: str = "Project-MONAI/model-zoo", tag: str = "hosting_storage_v1", auth_token: Optional[str] = None +): if has_requests: request_url = f"https://api.github.com/repos/{repo}/releases" - resp = requests_get(request_url) + if auth_token is not None: + headers = {"Authorization": f"Bearer {auth_token}"} + resp = requests_get(request_url, headers=headers) + else: + resp = requests_get(request_url) resp.raise_for_status() else: raise ValueError("requests package is required, please install it.") @@ -353,21 +359,30 @@ def _get_all_bundles_info(repo: str = "Project-MONAI/model-zoo", tag: str = "hos return bundles_info -def get_all_bundles_list(repo: str = "Project-MONAI/model-zoo", tag: str = "hosting_storage_v1"): +def get_all_bundles_list( + repo: str = "Project-MONAI/model-zoo", tag: str = "hosting_storage_v1", auth_token: Optional[str] = None +): """ Get all bundles names (and the latest versions) that are stored in the release of specified repository with the provided tag. The default values of arguments correspond to the release of MONAI model zoo. + In order to increase the rate limits of calling GIthub APIs, you can input your personal access token. + Please check the following link for more details about rate limiting: + https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting + + The following link shows how to create your personal access token: + https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token Args: repo: it should be in the form of "repo_owner/repo_name/". tag: the tag name of the release. + auth_token: github personal access token. Returns: a list of tuple in the form of (bundle name, latest version). """ - bundles_info = _get_all_bundles_info(repo=repo, tag=tag) + bundles_info = _get_all_bundles_info(repo=repo, tag=tag, auth_token=auth_token) bundles_list = [] for bundle_name in bundles_info.keys(): latest_version = sorted(bundles_info[bundle_name].keys())[-1] @@ -376,22 +391,34 @@ def get_all_bundles_list(repo: str = "Project-MONAI/model-zoo", tag: str = "host return bundles_list -def get_bundle_versions(bundle_name: str, repo: str = "Project-MONAI/model-zoo", tag: str = "hosting_storage_v1"): +def get_bundle_versions( + bundle_name: str, + repo: str = "Project-MONAI/model-zoo", + tag: str = "hosting_storage_v1", + auth_token: Optional[str] = None, +): """ Get the latest version, as well as all existing versions of a bundle that is stored in the release of specified repository with the provided tag. + In order to increase the rate limits of calling GIthub APIs, you can input your personal access token. + Please check the following link for more details about rate limiting: + https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting + + The following link shows how to create your personal access token: + https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token Args: bundle_name: bundle name. repo: it should be in the form of "repo_owner/repo_name/". tag: the tag name of the release. + auth_token: github personal access token. Returns: a dictionary that contains the latest version and all versions of a bundle. """ - bundles_info = _get_all_bundles_info(repo=repo, tag=tag) + bundles_info = _get_all_bundles_info(repo=repo, tag=tag, auth_token=auth_token) if bundle_name not in bundles_info: raise ValueError(f"bundle: {bundle_name} is not existing.") bundle_info = bundles_info[bundle_name] @@ -405,24 +432,32 @@ def get_bundle_info( version: Optional[str] = None, repo: str = "Project-MONAI/model-zoo", tag: str = "hosting_storage_v1", + auth_token: Optional[str] = None, ): """ Get all information (include "id", "name", "size", "download_count", "browser_download_url", "created_at", "updated_at") of a bundle with the specified bundle name and version. + In order to increase the rate limits of calling GIthub APIs, you can input your personal access token. + Please check the following link for more details about rate limiting: + https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting + + The following link shows how to create your personal access token: + https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token Args: bundle_name: bundle name. version: version name of the target bundle, if None, the latest version will be used. repo: it should be in the form of "repo_owner/repo_name/". tag: the tag name of the release. + auth_token: github personal access token. Returns: a dictionary that contains the bundle's information. """ - bundles_info = _get_all_bundles_info(repo=repo, tag=tag) + bundles_info = _get_all_bundles_info(repo=repo, tag=tag, auth_token=auth_token) if bundle_name not in bundles_info: raise ValueError(f"bundle: {bundle_name} is not existing.") bundle_info = bundles_info[bundle_name] diff --git a/tests/test_bundle_get_data.py b/tests/test_bundle_get_data.py index 060505f2fe..c36409f724 100644 --- a/tests/test_bundle_get_data.py +++ b/tests/test_bundle_get_data.py @@ -14,14 +14,20 @@ from parameterized import parameterized from monai.bundle import get_all_bundles_list, get_bundle_info, get_bundle_versions -from tests.utils import skip_if_downloading_fails, skip_if_quick, skip_if_windows +from monai.utils import optional_import +from tests.utils import SkipIfNoModule, skip_if_downloading_fails, skip_if_quick, skip_if_windows + +requests, _ = optional_import("requests") TEST_CASE_1 = [{"bundle_name": "brats_mri_segmentation"}] -TEST_CASE_2 = [{"bundle_name": "spleen_ct_segmentation", "version": "0.1.0"}] +TEST_CASE_2 = [{"bundle_name": "spleen_ct_segmentation", "version": "0.1.0", "auth_token": None}] + +TEST_CASE_FAKE_TOKEN = [{"bundle_name": "spleen_ct_segmentation", "version": "0.1.0", "auth_token": "ghp_errortoken"}] @skip_if_windows +@SkipIfNoModule("requests") class TestGetBundleData(unittest.TestCase): @skip_if_quick def test_get_all_bundles_list(self): @@ -49,6 +55,13 @@ def test_get_bundle_info(self, params): for key in ["id", "name", "size", "download_count", "browser_download_url"]: self.assertTrue(key in output) + @parameterized.expand([TEST_CASE_FAKE_TOKEN]) + @skip_if_quick + def test_fake_token(self, params): + with skip_if_downloading_fails(): + with self.assertRaises(requests.exceptions.HTTPError): + get_bundle_info(**params) + if __name__ == "__main__": unittest.main() From f040ec02d9085168183dd64b791c76709229d648 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 11 Oct 2022 19:02:45 +0800 Subject: [PATCH 32/60] 5269 5291 Update PyTorch base docker to 22.09 (#5293) Fixes #5269 #5291 . ### Description This PR updated the PyTorch base docker to 22.09. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Nic Ma Signed-off-by: monai-bot Signed-off-by: Wenqi Li Co-authored-by: monai-bot Co-authored-by: Wenqi Li --- .github/workflows/cron.yml | 6 ++-- .github/workflows/pythonapp-gpu.yml | 8 ++--- Dockerfile | 2 +- docs/requirements.txt | 1 + docs/source/apps.rst | 5 ++++ docs/source/data.rst | 12 ++------ monai/__init__.py | 12 +++++++- monai/data/__init__.py | 4 ++- tests/__init__.py | 2 +- tests/test_cv2_dist.py | 46 +++++++++++++++++++++++++++++ 10 files changed, 78 insertions(+), 20 deletions(-) create mode 100644 tests/test_cv2_dist.py diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 573a43df73..6329ab0ffa 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.08"] # 21.02, 21.10 for backward comp. + container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.09"] # 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.08"] # 21.02, 21.10 for backward comp. + container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.09"] # 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.08-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:22.09-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 3278a6b77c..b20e0e283e 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -45,11 +45,11 @@ jobs: # 21.10: 1.10.0a0+0aef44c pytorch: "-h" base: "nvcr.io/nvidia/pytorch:21.10-py3" - - environment: PT112+CUDA117 + - environment: PT112+CUDA118 # we explicitly set pytorch to -h to avoid pip install error - # 22.08: 1.13.0a0+d321be6 + # 22.09: 1.13.0a0+d0d6b1f pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:22.08-py3" + base: "nvcr.io/nvidia/pytorch:22.09-py3" - environment: PT110+CUDA102 pytorch: "torch==1.10.2 torchvision==0.11.3" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" @@ -64,7 +64,7 @@ jobs: - uses: actions/checkout@v3 - name: apt install run: | - # workaround for https://github.com/Project-MONAI/MONAI/issues/4200 + # FIXME: 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 diff --git a/Dockerfile b/Dockerfile index 20e7a93422..cfa721412d 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.08-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.09-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" diff --git a/docs/requirements.txt b/docs/requirements.txt index 2bf04a6f2e..a5009b1ac0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -33,3 +33,4 @@ pydicom h5py nni optuna +opencv-python-headless diff --git a/docs/source/apps.rst b/docs/source/apps.rst index b9da2625fc..a3e0e19247 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -191,6 +191,11 @@ Applications `Reconstruction` ---------------- +FastMRIReader +~~~~~~~~~~~~~ +.. autoclass:: monai.apps.reconstruction.fastmri_reader.FastMRIReader + :members: + `ConvertToTensorComplex` ~~~~~~~~~~~~~~~~~~~~~~~~ .. autofunction:: monai.apps.reconstruction.complex_utils.convert_to_tensor_complex diff --git a/docs/source/data.rst b/docs/source/data.rst index f2d51f1fe2..8cb27cd347 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -153,12 +153,6 @@ PILReader :members: -FastMRIReader -~~~~~~~~~~~~~ -.. autoclass:: monai.apps.reconstruction.fastmri_reader.FastMRIReader - :members: - - Image writer ------------ @@ -352,12 +346,12 @@ Video datasets VideoDataset ~~~~~~~~~~~~ -.. autoclass:: monai.data.VideoDataset +.. autoclass:: monai.data.video_dataset.VideoDataset VideoFileDataset ~~~~~~~~~~~~~~~~ -.. autoclass:: monai.data.VideoFileDataset +.. autoclass:: monai.data.video_dataset.VideoFileDataset CameraDataset ~~~~~~~~~~~~~ -.. autoclass:: monai.data.CameraDataset +.. autoclass:: monai.data.video_dataset.CameraDataset diff --git a/monai/__init__.py b/monai/__init__.py index 7f9fe2b33d..3f6c06d82d 100644 --- a/monai/__init__.py +++ b/monai/__init__.py @@ -39,7 +39,17 @@ # handlers_* have some external decorators the users may not have installed # *.so files and folder "_C" may not exist when the cpp extensions are not compiled -excludes = "(^(monai.handlers))|(^(monai.bundle))|(^(monai.fl))|((\\.so)$)|(^(monai._C))|(.*(__main__)$)" +excludes = "|".join( + [ + "(^(monai.handlers))", + "(^(monai.bundle))", + "(^(monai.fl))", + "((\\.so)$)", + "(^(monai._C))", + "(.*(__main__)$)", + "(.*(video_dataset)$)", + ] +) # load directory modules only, skip loading individual files load_submodules(sys.modules[__name__], False, exclude_pattern=excludes) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index aad5b20dd8..65ee8c377f 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -106,7 +106,9 @@ worker_init_fn, zoom_affine, ) -from .video_dataset import CameraDataset, VideoDataset, VideoFileDataset + +# FIXME: workaround for https://github.com/Project-MONAI/MONAI/issues/5291 +# from .video_dataset import CameraDataset, VideoDataset, VideoFileDataset from .wsi_datasets import MaskedPatchWSIDataset, PatchWSIDataset, SlidingPatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, TiffFileWSIReader, WSIReader diff --git a/tests/__init__.py b/tests/__init__.py index 4639a58496..0d6e28a679 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,7 +29,7 @@ def _enter_pr_4800(self): return self -# workaround for https://bugs.python.org/issue29620 +# FIXME: workaround for https://bugs.python.org/issue29620 try: # Suppression for issue #494: tests/__init__.py:34: error: Cannot assign to a method unittest.case._AssertWarnsContext.__enter__ = _enter_pr_4800 # type: ignore diff --git a/tests/test_cv2_dist.py b/tests/test_cv2_dist.py new file mode 100644 index 0000000000..59cf98dda3 --- /dev/null +++ b/tests/test_cv2_dist.py @@ -0,0 +1,46 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +import torch.distributed as dist +from torch.cuda.amp import autocast + +# FIXME: test for the workaround of https://github.com/Project-MONAI/MONAI/issues/5291 +from monai.config.deviceconfig import print_config +from tests.utils import skip_if_no_cuda + + +def main_worker(rank, ngpus_per_node): + dist.init_process_group(backend="nccl", init_method="tcp://127.0.0.1:12345", world_size=ngpus_per_node, rank=rank) + # `benchmark = True` is not compatible with openCV in PyTorch 22.09 docker for multi-gpu training + torch.backends.cudnn.benchmark = True + + model = torch.nn.Conv3d(in_channels=1, out_channels=32, kernel_size=3, bias=True).to(rank) + model = torch.nn.parallel.DistributedDataParallel( + model, device_ids=[rank], output_device=rank, find_unused_parameters=False + ) + x = torch.ones(1, 1, 192, 192, 192).to(rank) + with autocast(enabled=True): + model(x) + + +@skip_if_no_cuda +class TestCV2Dist(unittest.TestCase): + def test_cv2_cuda_ops(self): + print_config() + ngpus_per_node = torch.cuda.device_count() + torch.multiprocessing.spawn(main_worker, nprocs=ngpus_per_node, args=(ngpus_per_node,)) + + +if __name__ == "__main__": + unittest.main() From d1fafc42fefcca65f977e3295f73d10f6ed7ce59 Mon Sep 17 00:00:00 2001 From: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> Date: Tue, 11 Oct 2022 20:13:46 +0800 Subject: [PATCH 33/60] [ReadyForReview] Auto3DSeg DataAnalyzer OOM and other minor issue (#5278) Fixes #5277 . ### Updated results In my local test env, I have the following results: - The change of GPU memory before/after DataAnalyzer is less than 5MB after the fix. Previously, there are lots of cached PyTorch tensors and CuPy variables that are not released for trainings that takes up to several GBs of GPU mem. - DataAnalyzer can also process larger images now because leaks are fix (3D image with a size 512x512x512 passed for 12GB RTX 3080Ti) ### Description Auto3DSeg DataAnalyzer occupied a large trunk of memory and was unable to release them during the training. The reasons behind are possibly due to: - Training are done by subprocess call, and PyTorch in the subprocess is unable to find the memory pool allocated by the main process - GPU memory leakage ( DataAnalyzer math operations uses torch functions and CuPy) plus test functions need improvements and AutoRunner needs to expose the API call to change device of DataAnalyzer ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> --- .github/workflows/pythonapp-gpu.yml | 2 +- monai/apps/auto3dseg/data_analyzer.py | 17 +- monai/auto3dseg/__init__.py | 1 + monai/auto3dseg/analyzer.py | 38 +++-- monai/auto3dseg/seg_summarizer.py | 2 +- monai/auto3dseg/utils.py | 5 +- tests/test_auto3dseg.py | 219 +++++++++++++++----------- tests/test_cv2_dist.py | 2 +- 8 files changed, 174 insertions(+), 112 deletions(-) diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index b20e0e283e..39cb182e7d 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -23,7 +23,7 @@ jobs: - "PT17+CUDA102" - "PT18+CUDA102" - "PT18+CUDA112" - - "PT112+CUDA117" + - "PT112+CUDA118" - "PT110+CUDA102" - "PT112+CUDA102" include: diff --git a/monai/apps/auto3dseg/data_analyzer.py b/monai/apps/auto3dseg/data_analyzer.py index 68670b1a91..17d8dcefd5 100644 --- a/monai/apps/auto3dseg/data_analyzer.py +++ b/monai/apps/auto3dseg/data_analyzer.py @@ -122,8 +122,8 @@ def __init__( output_path: str = "./data_stats.yaml", average: bool = True, do_ccp: bool = True, - device: Union[str, torch.device] = "cuda", - worker: int = 0, + device: Union[str, torch.device] = "cpu", + worker: int = 2, image_key: str = "image", label_key: Optional[str] = "label", ): @@ -137,13 +137,10 @@ def __init__( self.average = average self.do_ccp = do_ccp self.device = torch.device(device) - self.worker = worker + self.worker = 0 if (self.device.type == "cuda") else worker self.image_key = image_key self.label_key = label_key - if (self.device.type == "cuda") and (worker > 0): - raise ValueError("CUDA does not support multiple subprocess. If device is GPU, please set worker to 0") - @staticmethod def _check_data_uniformity(keys: List[str], result: Dict): """ @@ -232,8 +229,14 @@ def get_all_case_stats(self): result[DataStatsKeys.SUMMARY] = summarizer.summarize(result[DataStatsKeys.BY_CASE]) if not self._check_data_uniformity([ImageStatsKeys.SPACING], result): - logger.warning("Data is not completely uniform. MONAI transforms may provide unexpected result") + logger.warning("data spacing is not completely uniform. MONAI transforms may provide unexpected result") ConfigParser.export_config_file(result, self.output_path, fmt="yaml", default_flow_style=None) + del d["image"], d["label"] + if self.device.type == "cuda": + # release unreferenced tensors to mitigate OOM + # limitation: https://github.com/pytorch/pytorch/issues/12873#issuecomment-482916237 + torch.cuda.empty_cache() + return result diff --git a/monai/auto3dseg/__init__.py b/monai/auto3dseg/__init__.py index 77eceedc86..9d35026045 100644 --- a/monai/auto3dseg/__init__.py +++ b/monai/auto3dseg/__init__.py @@ -11,6 +11,7 @@ from .algo_gen import Algo, AlgoGen from .analyzer import ( + Analyzer, FgImageStats, FgImageStatsSumm, FilenameStats, diff --git a/monai/auto3dseg/analyzer.py b/monai/auto3dseg/analyzer.py index 45024d6d7c..93a46417b4 100644 --- a/monai/auto3dseg/analyzer.py +++ b/monai/auto3dseg/analyzer.py @@ -229,8 +229,10 @@ def __call__(self, data): """ d = dict(data) start = time.time() - ndas = data[self.image_key] - ndas = [ndas[i] for i in range(ndas.shape[0])] + restore_grad_state = torch.is_grad_enabled() + torch.set_grad_enabled(False) + + ndas = [d[self.image_key][i] for i in range(d[self.image_key].shape[0])] if "nda_croppeds" not in d: nda_croppeds = [get_foreground_image(nda) for nda in ndas] @@ -250,8 +252,10 @@ def __call__(self, data): if not verify_report_format(report, self.get_report_format()): raise RuntimeError(f"report generated by {self.__class__} differs from the report format.") - logger.debug(f"Get image stats spent {time.time()-start}") d[self.stats_name] = report + + torch.set_grad_enabled(restore_grad_state) + logger.debug(f"Get image stats spent {time.time()-start}") return d @@ -307,9 +311,11 @@ def __call__(self, data) -> dict: """ d = dict(data) + start = time.time() + restore_grad_state = torch.is_grad_enabled() + torch.set_grad_enabled(False) - ndas = d[self.image_key] # (1,H,W,D) or (C,H,W,D) - ndas = [ndas[i] for i in range(ndas.shape[0])] + ndas = [d[self.image_key][i] for i in range(d[self.image_key].shape[0])] ndas_label = d[self.label_key] # (H,W,D) nda_foregrounds = [get_foreground_label(nda, ndas_label) for nda in ndas] @@ -324,6 +330,9 @@ def __call__(self, data) -> dict: raise RuntimeError(f"report generated by {self.__class__} differs from the report format.") d[self.stats_name] = report + + torch.set_grad_enabled(restore_grad_state) + logger.debug(f"Get foreground image stats spent {time.time()-start}") return d @@ -423,9 +432,12 @@ def __call__(self, data): functions. If the input has nan/inf, the stats results will be nan/inf. """ d = dict(data) + start = time.time() + using_cuda = True if d[self.image_key].device.type == "cuda" else False + restore_grad_state = torch.is_grad_enabled() + torch.set_grad_enabled(False) - ndas = d[self.image_key] # (1,H,W,D) or (C,H,W,D) - ndas = [ndas[i] for i in range(ndas.shape[0])] + ndas = [d[self.image_key][i] for i in range(d[self.image_key].shape[0])] ndas_label = d[self.label_key] # (H,W,D) nda_foregrounds = [get_foreground_label(nda, ndas_label) for nda in ndas] @@ -435,7 +447,6 @@ def __call__(self, data): unique_label = unique_label.astype(np.int8).tolist() - start = time.time() label_substats = [] # each element is one label pixel_sum = 0 pixel_arr = [] @@ -444,13 +455,20 @@ def __call__(self, data): label_dict: Dict[str, Any] = {} mask_index = ndas_label == index + nda_masks = [nda[mask_index] for nda in ndas] label_dict[LabelStatsKeys.IMAGE_INTST] = [ - self.ops[LabelStatsKeys.IMAGE_INTST].evaluate(nda[mask_index]) for nda in ndas + self.ops[LabelStatsKeys.IMAGE_INTST].evaluate(nda_m) for nda_m in nda_masks ] + pixel_count = sum(mask_index) pixel_arr.append(pixel_count) pixel_sum += pixel_count if self.do_ccp: # apply connected component + if using_cuda: + # The back end of get_label_ccp is CuPy + # which is unable to automatically release CUDA GPU memory held by PyTorch + del nda_masks + torch.cuda.empty_cache() shape_list, ncomponents = get_label_ccp(mask_index) label_dict[LabelStatsKeys.LABEL_SHAPE] = shape_list label_dict[LabelStatsKeys.LABEL_NCOMP] = ncomponents @@ -472,6 +490,8 @@ def __call__(self, data): raise RuntimeError(f"report generated by {self.__class__} differs from the report format.") d[self.stats_name] = report + + torch.set_grad_enabled(restore_grad_state) logger.debug(f"Get label stats spent {time.time()-start}") return d diff --git a/monai/auto3dseg/seg_summarizer.py b/monai/auto3dseg/seg_summarizer.py index 8ebf93fb21..eb7e9d567f 100644 --- a/monai/auto3dseg/seg_summarizer.py +++ b/monai/auto3dseg/seg_summarizer.py @@ -104,7 +104,7 @@ def add_analyzer(self, case_analyzer, summary_analyzer) -> None: .. code-block:: python - from monai.auto3dseg.analyzer import Analyzer + from monai.auto3dseg import Analyzer from monai.auto3dseg.utils import concat_val_to_np from monai.auto3dseg.analyzer_engine import SegSummarizer diff --git a/monai/auto3dseg/utils.py b/monai/auto3dseg/utils.py index 22195a6e4c..7f10fdd25b 100644 --- a/monai/auto3dseg/utils.py +++ b/monai/auto3dseg/utils.py @@ -106,6 +106,9 @@ def get_label_ccp(mask_index: MetaTensor, use_gpu: bool = True) -> Tuple[List[An shape_list.append(bbox_shape) ncomponents = len(vals) + del mask_cupy, labeled, vals, comp_idx, ncomp + cp.get_default_memory_pool().free_all_blocks() + elif has_measure: labeled, ncomponents = measure_np.label(mask_index.data.cpu().numpy(), background=-1, return_num=True) for ncomp in range(1, ncomponents + 1): @@ -174,7 +177,7 @@ def concat_val_to_np( elif ragged: return np.concatenate(np_list, **kwargs) # type: ignore else: - return np.concatenate([np_list], **kwargs) + return np.concatenate([np_list], **kwargs) # type: ignore def concat_multikeys_to_dict( diff --git a/tests/test_auto3dseg.py b/tests/test_auto3dseg.py index 43212af107..eb3022d848 100644 --- a/tests/test_auto3dseg.py +++ b/tests/test_auto3dseg.py @@ -9,26 +9,19 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import tempfile import unittest from copy import deepcopy from numbers import Number -from os import path import nibabel as nib import numpy as np import torch +from parameterized import parameterized -from monai.apps.auto3dseg.data_analyzer import DataAnalyzer +from monai.apps.auto3dseg import DataAnalyzer from monai.auto3dseg import ( - Operations, - SampleOperations, - SegSummarizer, - SummaryOperations, - datafold_read, - verify_report_format, -) -from monai.auto3dseg.analyzer import ( Analyzer, FgImageStats, FgImageStatsSumm, @@ -37,9 +30,15 @@ ImageStatsSumm, LabelStats, LabelStatsSumm, + Operations, + SampleOperations, + SegSummarizer, + SummaryOperations, + datafold_read, + verify_report_format, ) from monai.bundle import ConfigParser -from monai.data import DataLoader, Dataset, create_test_image_3d +from monai.data import DataLoader, Dataset, create_test_image_2d, create_test_image_3d from monai.data.meta_tensor import MetaTensor from monai.data.utils import no_collation from monai.transforms import ( @@ -53,11 +52,12 @@ ToDeviced, ) from monai.utils.enums import DataStatsKeys +from tests.utils import skip_if_no_cuda -device = "cuda" if torch.cuda.is_available() else "cpu" -n_workers = 2 if device == "cpu" else 0 +device = "cpu" +n_workers = 2 -fake_datalist = { +sim_datalist = { "testing": [{"image": "val_001.fake.nii.gz"}, {"image": "val_002.fake.nii.gz"}], "training": [ {"fold": 0, "image": "tr_image_001.fake.nii.gz", "label": "tr_label_001.fake.nii.gz"}, @@ -67,6 +67,53 @@ ], } +SIM_CPU_TEST_CASES = [ + [{"sim_dim": (32, 32, 32), "label_key": "label"}], + [{"sim_dim": (32, 32, 32, 2), "label_key": "label"}], + [{"sim_dim": (32, 32, 32), "label_key": None}], +] + +SIM_GPU_TEST_CASES = [[{"sim_dim": (32, 32, 32)}]] + + +def create_sim_data(dataroot: str, sim_datalist: dict, sim_dim: tuple, **kwargs) -> None: + """ + Create simulated data using create_test_image_3d. + + Args: + dataroot: data directory path that hosts the "nii.gz" image files. + sim_datalist: a list of data to create. + sim_dim: the image sizes, for examples: a tuple of (64, 64, 64) for 3d, or (128, 128) for 2d + """ + if not os.path.isdir(dataroot): + os.makedirs(dataroot) + + # Generate a fake dataset + for d in sim_datalist["testing"] + sim_datalist["training"]: + if len(sim_dim) == 2: # 2D image + im, seg = create_test_image_2d(sim_dim[0], sim_dim[1], **kwargs) + elif len(sim_dim) == 3: # 3D image + im, seg = create_test_image_3d(sim_dim[0], sim_dim[1], sim_dim[2], **kwargs) + elif len(sim_dim) == 4: # multi-modality 3D image + im_list = [] + seg_list = [] + for _ in range(sim_dim[3]): + im_3d, seg_3d = create_test_image_3d(sim_dim[0], sim_dim[1], sim_dim[2], **kwargs) + im_list.append(im_3d[..., np.newaxis]) + seg_list.append(seg_3d[..., np.newaxis]) + im = np.concatenate(im_list, axis=3) + seg = np.concatenate(seg_list, axis=3) + else: + raise ValueError(f"Invalid argument input. sim_dim has f{len(sim_dim)} values. 2-4 values are expected.") + nib_image = nib.Nifti1Image(im, affine=np.eye(4)) + image_fpath = os.path.join(dataroot, d["image"]) + nib.save(nib_image, image_fpath) + + if "label" in d: + nib_image = nib.Nifti1Image(seg, affine=np.eye(4)) + label_fpath = os.path.join(dataroot, d["label"]) + nib.save(nib_image, label_fpath) + class TestOperations(Operations): """ @@ -118,51 +165,38 @@ def __call__(self, data): class TestDataAnalyzer(unittest.TestCase): def setUp(self): self.test_dir = tempfile.TemporaryDirectory() - dataroot = self.test_dir.name - - # Generate a fake dataset - for d in fake_datalist["testing"] + fake_datalist["training"]: - im, seg = create_test_image_3d(39, 47, 46, rad_max=10) - nib_image = nib.Nifti1Image(im, affine=np.eye(4)) - image_fpath = path.join(dataroot, d["image"]) - nib.save(nib_image, image_fpath) - - if "label" in d: - nib_image = nib.Nifti1Image(seg, affine=np.eye(4)) - label_fpath = path.join(dataroot, d["label"]) - nib.save(nib_image, label_fpath) - - # write to a json file - self.fake_json_datalist = path.join(dataroot, "fake_input.json") - ConfigParser.export_config_file(fake_datalist, self.fake_json_datalist) - - def test_data_analyzer(self): - dataroot = self.test_dir.name - yaml_fpath = path.join(dataroot, "data_stats.yaml") - analyser = DataAnalyzer(fake_datalist, dataroot, output_path=yaml_fpath, device=device, worker=n_workers) - datastat = analyser.get_all_case_stats() - - assert len(datastat["stats_by_cases"]) == len(fake_datalist["training"]) + work_dir = self.test_dir.name + self.dataroot_dir = os.path.join(work_dir, "sim_dataroot") + self.datalist_file = os.path.join(work_dir, "sim_datalist.json") + self.datastat_file = os.path.join(work_dir, "data_stats.yaml") + ConfigParser.export_config_file(sim_datalist, self.datalist_file) + + @parameterized.expand(SIM_CPU_TEST_CASES) + def test_data_analyzer_cpu(self, input_params): + + sim_dim = input_params["sim_dim"] + create_sim_data( + self.dataroot_dir, sim_datalist, sim_dim, rad_max=max(int(sim_dim[0] / 4), 1), rad_min=1, num_seg_classes=1 + ) - def test_data_analyzer_image_only(self): - dataroot = self.test_dir.name - yaml_fpath = path.join(dataroot, "data_stats.yaml") analyser = DataAnalyzer( - fake_datalist, dataroot, output_path=yaml_fpath, device=device, worker=n_workers, label_key=None + self.datalist_file, self.dataroot_dir, output_path=self.datastat_file, label_key=input_params["label_key"] ) datastat = analyser.get_all_case_stats() - assert len(datastat["stats_by_cases"]) == len(fake_datalist["training"]) + assert len(datastat["stats_by_cases"]) == len(sim_datalist["training"]) - def test_data_analyzer_from_yaml(self): - dataroot = self.test_dir.name - yaml_fpath = path.join(dataroot, "data_stats.yaml") - analyser = DataAnalyzer( - self.fake_json_datalist, dataroot, output_path=yaml_fpath, device=device, worker=n_workers + @parameterized.expand(SIM_GPU_TEST_CASES) + @skip_if_no_cuda + def test_data_analyzer_gpu(self, input_params): + sim_dim = input_params["sim_dim"] + create_sim_data( + self.dataroot_dir, sim_datalist, sim_dim, rad_max=max(int(sim_dim[0] / 4), 1), rad_min=1, num_seg_classes=1 ) + analyser = DataAnalyzer(self.datalist_file, self.dataroot_dir, output_path=self.datastat_file, device="cuda") datastat = analyser.get_all_case_stats() - assert len(datastat["stats_by_cases"]) == len(fake_datalist["training"]) + assert len(datastat["stats_by_cases"]) == len(sim_datalist["training"]) def test_basic_operation_class(self): op = TestOperations() @@ -217,10 +251,9 @@ def test_basic_analyzer_class(self): assert result["test"]["stats"]["mean"] == np.mean(test_data["image_test"]) def test_transform_analyzer_class(self): - transform_list = [LoadImaged(keys=["image"]), TestImageAnalyzer(image_key="image")] - transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + transform = Compose([LoadImaged(keys=["image"]), TestImageAnalyzer(image_key="image")]) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=0, collate_fn=no_collation) for batch_data in self.dataset: @@ -233,17 +266,18 @@ def test_transform_analyzer_class(self): def test_image_stats_case_analyzer(self): analyzer = ImageStats(image_key="image") - transform_list = [ - LoadImaged(keys=["image"]), - EnsureChannelFirstd(keys=["image"]), # this creates label to be (1,H,W,D) - ToDeviced(keys=["image"], device=device, non_blocking=True), - Orientationd(keys=["image"], axcodes="RAS"), - EnsureTyped(keys=["image"], data_type="tensor"), - analyzer, - ] - transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + transform = Compose( + [ + LoadImaged(keys=["image"]), + EnsureChannelFirstd(keys=["image"]), # this creates label to be (1,H,W,D) + ToDeviced(keys=["image"], device=device, non_blocking=True), + Orientationd(keys=["image"], axcodes="RAS"), + EnsureTyped(keys=["image"], data_type="tensor"), + analyzer, + ] + ) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) for batch_data in self.dataset: @@ -264,8 +298,8 @@ def test_foreground_image_stats_cases_analyzer(self): analyzer, ] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) for batch_data in self.dataset: @@ -275,19 +309,20 @@ def test_foreground_image_stats_cases_analyzer(self): def test_label_stats_case_analyzer(self): analyzer = LabelStats(image_key="image", label_key="label") - transform_list = [ - LoadImaged(keys=["image", "label"]), - EnsureChannelFirstd(keys=["image", "label"]), # this creates label to be (1,H,W,D) - ToDeviced(keys=["image", "label"], device=device, non_blocking=True), - Orientationd(keys=["image", "label"], axcodes="RAS"), - EnsureTyped(keys=["image", "label"], data_type="tensor"), - Lambdad(keys=["label"], func=lambda x: torch.argmax(x, dim=0, keepdim=True) if x.shape[0] > 1 else x), - SqueezeDimd(keys=["label"], dim=0), - analyzer, - ] - transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + transform = Compose( + [ + LoadImaged(keys=["image", "label"]), + EnsureChannelFirstd(keys=["image", "label"]), # this creates label to be (1,H,W,D) + ToDeviced(keys=["image", "label"], device=device, non_blocking=True), + Orientationd(keys=["image", "label"], axcodes="RAS"), + EnsureTyped(keys=["image", "label"], data_type="tensor"), + Lambdad(keys=["label"], func=lambda x: torch.argmax(x, dim=0, keepdim=True) if x.shape[0] > 1 else x), + SqueezeDimd(keys=["label"], dim=0), + analyzer, + ] + ) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) for batch_data in self.dataset: @@ -300,8 +335,8 @@ def test_filename_case_analyzer(self): analyzer_label = FilenameStats("label", DataStatsKeys.BY_CASE_IMAGE_PATH) transform_list = [LoadImaged(keys=["image", "label"]), analyzer_image, analyzer_label] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) for batch_data in self.dataset: @@ -314,8 +349,8 @@ def test_filename_case_analyzer_image_only(self): analyzer_label = FilenameStats(None, DataStatsKeys.BY_CASE_IMAGE_PATH) transform_list = [LoadImaged(keys=["image"]), analyzer_image, analyzer_label] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) for batch_data in self.dataset: @@ -335,8 +370,8 @@ def test_image_stats_summary_analyzer(self): ImageStats(image_key="image"), ] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) stats = [] @@ -360,8 +395,8 @@ def test_fg_image_stats_summary_analyzer(self): FgImageStats(image_key="image", label_key="label"), ] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) stats = [] @@ -385,8 +420,8 @@ def test_label_stats_summary_analyzer(self): LabelStats(image_key="image", label_key="label"), ] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) stats = [] @@ -410,8 +445,8 @@ def test_seg_summarizer(self): summarizer, ] transform = Compose(transform_list) - dataroot = self.test_dir.name - files, _ = datafold_read(self.fake_json_datalist, dataroot, fold=-1) + create_sim_data(self.dataroot_dir, sim_datalist, (32, 32, 32), rad_max=8, rad_min=1, num_seg_classes=1) + files, _ = datafold_read(sim_datalist, self.dataroot_dir, fold=-1) ds = Dataset(data=files) self.dataset = DataLoader(ds, batch_size=1, shuffle=False, num_workers=n_workers, collate_fn=no_collation) stats = [] diff --git a/tests/test_cv2_dist.py b/tests/test_cv2_dist.py index 59cf98dda3..cf4c77cfe4 100644 --- a/tests/test_cv2_dist.py +++ b/tests/test_cv2_dist.py @@ -29,7 +29,7 @@ def main_worker(rank, ngpus_per_node): model = torch.nn.parallel.DistributedDataParallel( model, device_ids=[rank], output_device=rank, find_unused_parameters=False ) - x = torch.ones(1, 1, 192, 192, 192).to(rank) + x = torch.ones(1, 1, 12, 12, 12).to(rank) with autocast(enabled=True): model(x) From c77521ebd05b31e294a1971e5ad8aa1dce507b98 Mon Sep 17 00:00:00 2001 From: myron Date: Wed, 12 Oct 2022 02:49:24 -0700 Subject: [PATCH 34/60] updates to Gaussian map for sliding window inference (#5302) This updates the calculation of Gaussian map (weights) during sliding window inference with "gaussian" Current version had multiple small issues - it computed Gaussian weight map (image) via nn.Conv1d sequence with an empty image (with a single 1 in the middle). We don't need to run any convolutions, it's much simpler to directly calculate the Gaussian map (it's also faster and takes less memory) - For patch_sizes of even size (e.g. 128x128) it centered Gaussian on patch_size//2 which is 0.5 pixel off-center (I'm not sure why we did it. - Finally the Gaussian 1d convolutions were done approximately (with 'erf' internal approximation and truncated to sigma=4). I'm not sure why we need any approximations here at all, it's trivial to compute the Gaussian weight map directly ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron --- monai/data/utils.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 2f395c9065..40f3de4831 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -30,7 +30,6 @@ from monai import config 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, BlendMode, @@ -1067,17 +1066,16 @@ def compute_importance_map( if mode == BlendMode.CONSTANT: 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)) sigmas = [i * sigma_s for i, sigma_s in zip(patch_size, sigma_scale)] - importance_map = torch.zeros(patch_size, device=device) - importance_map[tuple(center_coords)] = 1 - pt_gaussian = GaussianFilter(len(patch_size), sigmas).to(device=device, dtype=torch.float) - importance_map = pt_gaussian(importance_map.unsqueeze(0).unsqueeze(0)) - importance_map = importance_map.squeeze(0).squeeze(0) - importance_map = importance_map / torch.max(importance_map) - importance_map = importance_map.float() + for i in range(len(patch_size)): + x = torch.arange( + start=-(patch_size[i] - 1) / 2.0, end=(patch_size[i] - 1) / 2.0 + 1, dtype=torch.float, device=device + ) + x = torch.exp(x**2 / (-2 * sigmas[i] ** 2)) # 1D gaussian + importance_map = importance_map.unsqueeze(-1) * x[(None,) * i] if i > 0 else x else: raise ValueError( f"Unsupported mode: {mode}, available options are [{BlendMode.CONSTANT}, {BlendMode.CONSTANT}]." From a215ea6db4b9e886314c1b2b24987069b4c80b44 Mon Sep 17 00:00:00 2001 From: myron Date: Wed, 12 Oct 2022 12:02:12 -0700 Subject: [PATCH 35/60] fixes DiceCELoss for multichannel targets (#5292) Fixes DiceCELoss for multichannel targets. Currently if "target" (ground truth label) is provided as a multichannel data (each channel is binary or float), then current DiceCELoss attempts to convert it to 1-channel using argmax (which could be impossible with overlapping labels). There is no need for argmax, since pytorch's cross entropy can handle multi-channel targets already. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/data/box_utils.py | 2 +- monai/losses/dice.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index afe8e11167..3aaedb3850 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -657,7 +657,7 @@ def boxes_center_distance( center2 = box_centers(boxes2_t.to(COMPUTE_DTYPE)) # (M, spatial_dims) if euclidean: - dists = (center1[:, None] - center2[None]).pow(2).sum(-1).sqrt() + dists = (center1[:, None] - center2[None]).pow(2).sum(-1).sqrt() # type: ignore else: # before sum: (N, M, spatial_dims) dists = (center1[:, None] - center2[None]).sum(-1) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 892d71d06a..0e53e099bf 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -21,7 +21,7 @@ from monai.losses.focal_loss import FocalLoss from monai.losses.spatial_mask import MaskedLoss from monai.networks import one_hot -from monai.utils import DiceCEReduction, LossReduction, Weight, look_up_option +from monai.utils import DiceCEReduction, LossReduction, Weight, look_up_option, pytorch_after class DiceLoss(_Loss): @@ -692,6 +692,7 @@ def __init__( raise ValueError("lambda_ce should be no less than 0.0.") self.lambda_dice = lambda_dice self.lambda_ce = lambda_ce + self.old_pt_ver = not pytorch_after(1, 10) def ce(self, input: torch.Tensor, target: torch.Tensor): """ @@ -701,12 +702,16 @@ def ce(self, input: torch.Tensor, target: torch.Tensor): """ n_pred_ch, n_target_ch = input.shape[1], target.shape[1] - if n_pred_ch == n_target_ch: - # target is in the one-hot format, convert to BH[WD] format to calculate ce loss - target = torch.argmax(target, dim=1) - else: + if n_pred_ch != n_target_ch and n_target_ch == 1: target = torch.squeeze(target, dim=1) - target = target.long() + target = target.long() + elif self.old_pt_ver: + warnings.warn( + f"Multichannel targets are not supported in this older Pytorch version {torch.__version__}. " + "Using argmax (as a workaround) to convert target to a single channel." + ) + target = torch.argmax(target, dim=1) + return self.cross_entropy(input, target) def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: From ef6fe38eb7fcfdb5e03abcecdc2598bb50d9e9e3 Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Thu, 13 Oct 2022 07:45:22 +0100 Subject: [PATCH 36/60] auto updates (#5321) Signed-off-by: monai-bot Signed-off-by: monai-bot Signed-off-by: Wenqi Li --- monai/apps/auto3dseg/bundle_gen.py | 6 +- monai/apps/auto3dseg/ensemble_builder.py | 2 +- monai/apps/auto3dseg/hpo_gen.py | 8 +- monai/auto3dseg/utils.py | 2 +- monai/bundle/config_item.py | 4 +- monai/inferers/utils.py | 3 +- monai/metrics/active_learning_metrics.py | 4 +- monai/metrics/confusion_matrix.py | 4 +- monai/metrics/generalized_dice.py | 2 +- monai/metrics/hausdorff_distance.py | 2 +- monai/metrics/meandice.py | 2 +- monai/metrics/meaniou.py | 2 +- monai/metrics/metric.py | 4 +- monai/metrics/regression.py | 2 +- monai/metrics/rocauc.py | 2 +- monai/metrics/surface_dice.py | 2 +- monai/metrics/surface_distance.py | 2 +- monai/networks/layers/simplelayers.py | 133 ++++++++++++++++++++++- 18 files changed, 155 insertions(+), 31 deletions(-) diff --git a/monai/apps/auto3dseg/bundle_gen.py b/monai/apps/auto3dseg/bundle_gen.py index bc96691ac4..06908913f6 100644 --- a/monai/apps/auto3dseg/bundle_gen.py +++ b/monai/apps/auto3dseg/bundle_gen.py @@ -73,7 +73,7 @@ def __init__(self, template_path: str): # track records when filling template config: {"": {"": value, ...}, ...} self.fill_records: dict = {} - def set_data_stats(self, data_stats_files: str): # type: ignore + def set_data_stats(self, data_stats_files: str): """ Set the data analysis report (generated by DataAnalyzer). @@ -351,7 +351,7 @@ def __init__(self, algo_path: str = ".", algos=None, data_stats_filename=None, d self.data_src_cfg_filename = data_src_cfg_name self.history: List[Dict] = [] - def set_data_stats(self, data_stats_filename: str): # type: ignore + def set_data_stats(self, data_stats_filename: str): """ Set the data stats filename @@ -377,7 +377,7 @@ def get_data_src(self): """Get the data source filename""" return self.data_src_cfg_filename - def get_history(self) -> List: # type: ignore + def get_history(self) -> List: """get the history of the bundleAlgo object with their names/identifiers""" return self.history diff --git a/monai/apps/auto3dseg/ensemble_builder.py b/monai/apps/auto3dseg/ensemble_builder.py index 52f3121c44..629318d69e 100644 --- a/monai/apps/auto3dseg/ensemble_builder.py +++ b/monai/apps/auto3dseg/ensemble_builder.py @@ -176,7 +176,7 @@ def sort_score(self): scores = concat_val_to_np(self.algos, [AlgoEnsembleKeys.SCORE]) return np.argsort(scores).tolist() - def collect_algos(self, n_best: int = -1): # type: ignore + def collect_algos(self, n_best: int = -1): """ Rank the algos by finding the top N (n_best) validation scores. """ diff --git a/monai/apps/auto3dseg/hpo_gen.py b/monai/apps/auto3dseg/hpo_gen.py index 997fe8dd10..f9f709053b 100644 --- a/monai/apps/auto3dseg/hpo_gen.py +++ b/monai/apps/auto3dseg/hpo_gen.py @@ -154,7 +154,7 @@ def get_hyperparameters(self): warn("NNI is not detected. The code will continue to run without NNI.") return {} - def update_params(self, params: dict): # type: ignore + def update_params(self, params: dict): """ Translate the parameter from monai bundle to meet NNI requirements. @@ -198,7 +198,7 @@ def set_score(self, acc): else: warn("NNI is not detected. The code will continue to run without NNI.") - def run_algo(self, obj_filename: str, output_folder: str = ".", template_path=None) -> None: # type: ignore + def run_algo(self, obj_filename: str, output_folder: str = ".", template_path=None) -> None: """ The python interface for NNI to run. @@ -333,7 +333,7 @@ def __call__(self, trial, obj_filename: str, output_folder: str = ".", template_ self.run_algo(obj_filename, output_folder, template_path) return self.acc - def update_params(self, params: dict): # type: ignore + def update_params(self, params: dict): """ Translate the parameter from monai bundle. @@ -368,7 +368,7 @@ def generate(self, output_folder: str = ".") -> None: ConfigParser.export_config_file(self.params, write_path) logger.info(write_path) - def run_algo(self, obj_filename: str, output_folder: str = ".", template_path=None) -> None: # type: ignore + def run_algo(self, obj_filename: str, output_folder: str = ".", template_path=None) -> None: """ The python interface for NNI to run. diff --git a/monai/auto3dseg/utils.py b/monai/auto3dseg/utils.py index 7f10fdd25b..78593f8369 100644 --- a/monai/auto3dseg/utils.py +++ b/monai/auto3dseg/utils.py @@ -177,7 +177,7 @@ def concat_val_to_np( elif ragged: return np.concatenate(np_list, **kwargs) # type: ignore else: - return np.concatenate([np_list], **kwargs) # type: ignore + return np.concatenate([np_list], **kwargs) def concat_multikeys_to_dict( diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py index 34a390be5f..0c46665bf5 100644 --- a/monai/bundle/config_item.py +++ b/monai/bundle/config_item.py @@ -257,7 +257,7 @@ def resolve_args(self): """ return {k: v for k, v in self.get_config().items() if k not in self.non_arg_keys} - def is_disabled(self) -> bool: # type: ignore + def is_disabled(self) -> bool: """ Utility function used in `instantiate()` to check whether to skip the instantiation. @@ -265,7 +265,7 @@ def is_disabled(self) -> bool: # type: ignore _is_disabled = self.get_config().get("_disabled_", False) return _is_disabled.lower().strip() == "true" if isinstance(_is_disabled, str) else bool(_is_disabled) - def instantiate(self, **kwargs) -> object: # type: ignore + def instantiate(self, **kwargs) -> object: """ Instantiate component based on ``self.config`` content. The target component must be a `class` or a `function`, otherwise, return `None`. diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index 5126b23c0a..689e34c991 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -276,7 +276,8 @@ def sliding_window_inference( final_output = dict(zip(dict_key, output_image_list)) else: final_output = tuple(output_image_list) # type: ignore - final_output = final_output[0] if is_tensor_output else final_output # type: ignore + final_output = final_output[0] if is_tensor_output else final_output + if isinstance(inputs, MetaTensor): final_output = convert_to_dst_type(final_output, inputs)[0] # type: ignore return final_output diff --git a/monai/metrics/active_learning_metrics.py b/monai/metrics/active_learning_metrics.py index 9d2de8b1a2..f41a0a96b5 100644 --- a/monai/metrics/active_learning_metrics.py +++ b/monai/metrics/active_learning_metrics.py @@ -49,7 +49,7 @@ def __init__( self.scalar_reduction = scalar_reduction self.threshold = threshold - def __call__(self, y_pred: Any) -> Any: # type: ignore + def __call__(self, y_pred: Any) -> Any: """ Args: y_pred: Predicted segmentation, typically segmentation model output. @@ -88,7 +88,7 @@ def __init__(self, include_background: bool = True, scalar_reduction: str = "sum self.include_background = include_background self.scalar_reduction = scalar_reduction - def __call__(self, y_pred: Any, y: Any): # type: ignore + def __call__(self, y_pred: Any, y: Any): """ Args: y_pred: Predicted segmentation, typically segmentation model output. diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index cdde195d3a..da8561f45c 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -100,9 +100,7 @@ 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( # type: ignore - self, compute_sample: bool = False, reduction: Union[MetricReduction, str, None] = None - ): + def aggregate(self, compute_sample: bool = False, reduction: Union[MetricReduction, str, None] = None): """ Execute reduction for the confusion matrix values. diff --git a/monai/metrics/generalized_dice.py b/monai/metrics/generalized_dice.py index f223664bea..3a0e90d587 100644 --- a/monai/metrics/generalized_dice.py +++ b/monai/metrics/generalized_dice.py @@ -80,7 +80,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor 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 + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): """ Execute reduction logic for the output of `compute_generalized_dice`. diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 61bea4c87d..54de8b1d4d 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -104,7 +104,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor directed=self.directed, ) - def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): """ Execute reduction logic for the output of `compute_hausdorff_distance`. diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index 30ef0845c7..7a45f73b3a 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -84,7 +84,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor 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 + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): """ Execute reduction logic for the output of `compute_meandice`. diff --git a/monai/metrics/meaniou.py b/monai/metrics/meaniou.py index 8b07552a8c..f32f39327d 100644 --- a/monai/metrics/meaniou.py +++ b/monai/metrics/meaniou.py @@ -84,7 +84,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor 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 + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): """ Execute reduction logic for the output of `compute_meaniou`. diff --git a/monai/metrics/metric.py b/monai/metrics/metric.py index fa8b3354de..e92ed73dd6 100644 --- a/monai/metrics/metric.py +++ b/monai/metrics/metric.py @@ -45,7 +45,7 @@ class IterationMetric(Metric): Subclasses typically implement the `_compute_tensor` function for the actual tensor computation logic. """ - def __call__(self, y_pred: TensorOrList, y: Optional[TensorOrList] = None): # type: ignore + def __call__(self, y_pred: TensorOrList, y: Optional[TensorOrList] = None): """ Execute basic computation for model prediction `y_pred` and ground truth `y` (optional). It supports inputs of a list of "channel-first" Tensor and a "batch-first" Tensor. @@ -310,7 +310,7 @@ class CumulativeIterationMetric(Cumulative, IterationMetric): """ - def __call__(self, y_pred: TensorOrList, y: Optional[TensorOrList] = None): # type: ignore + def __call__(self, y_pred: TensorOrList, y: Optional[TensorOrList] = None): """ Execute basic computation for model prediction and ground truth. It can support both `list of channel-first Tensor` and `batch-first Tensor`. diff --git a/monai/metrics/regression.py b/monai/metrics/regression.py index d1cd44e4bb..1c48ded306 100644 --- a/monai/metrics/regression.py +++ b/monai/metrics/regression.py @@ -48,7 +48,7 @@ def __init__( self.reduction = reduction self.get_not_nans = get_not_nans - def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): """ Args: reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values, diff --git a/monai/metrics/rocauc.py b/monai/metrics/rocauc.py index 0b3e488922..2bb8dc2b32 100644 --- a/monai/metrics/rocauc.py +++ b/monai/metrics/rocauc.py @@ -51,7 +51,7 @@ 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, average: Union[Average, str, None] = None): # type: ignore + def aggregate(self, average: Union[Average, str, None] = None): """ 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. diff --git a/monai/metrics/surface_dice.py b/monai/metrics/surface_dice.py index 8bc34d4afc..80869ce583 100644 --- a/monai/metrics/surface_dice.py +++ b/monai/metrics/surface_dice.py @@ -86,7 +86,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor distance_metric=self.distance_metric, ) - def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): r""" Aggregates the output of `_compute_tensor`. diff --git a/monai/metrics/surface_distance.py b/monai/metrics/surface_distance.py index e463702458..8bb688b4e0 100644 --- a/monai/metrics/surface_distance.py +++ b/monai/metrics/surface_distance.py @@ -94,7 +94,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor distance_metric=self.distance_metric, ) - def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore + def aggregate(self, reduction: Union[MetricReduction, str, None] = None): """ Execute reduction logic for the output of `compute_average_surface_distance`. diff --git a/monai/networks/layers/simplelayers.py b/monai/networks/layers/simplelayers.py index 8c421719a7..08c75bd4ef 100644 --- a/monai/networks/layers/simplelayers.py +++ b/monai/networks/layers/simplelayers.py @@ -11,7 +11,7 @@ import math from copy import deepcopy -from typing import List, Sequence, Union +from typing import List, Optional, Sequence, Union import torch import torch.nn.functional as F @@ -20,8 +20,16 @@ from monai.networks.layers.convutils import gaussian_1d from monai.networks.layers.factories import Conv -from monai.utils import ChannelMatching, SkipMode, look_up_option, optional_import, pytorch_after -from monai.utils.misc import issequenceiterable +from monai.utils import ( + ChannelMatching, + SkipMode, + convert_to_tensor, + ensure_tuple_rep, + issequenceiterable, + look_up_option, + optional_import, + pytorch_after, +) _C, _ = optional_import("monai._C") fft, _ = optional_import("torch.fft") @@ -32,10 +40,12 @@ "GaussianFilter", "HilbertTransform", "LLTM", + "MedianFilter", "Reshape", "SavitzkyGolayFilter", "SkipConnection", "apply_filter", + "median_filter", "separable_filtering", ] @@ -168,7 +178,6 @@ def _separable_filtering_conv( paddings: List[int], num_channels: int, ) -> torch.Tensor: - if d < 0: return input_ @@ -434,6 +443,122 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return torch.as_tensor(ht, device=ht.device, dtype=ht.dtype) +def get_binary_kernel(window_size: Sequence[int], dtype=torch.float, device=None) -> torch.Tensor: + """ + Create a binary kernel to extract the patches. + The window size HxWxD will create a (H*W*D)xHxWxD kernel. + """ + win_size = convert_to_tensor(window_size, int, wrap_sequence=True) + prod = torch.prod(win_size) + s = [prod, 1, *win_size] + return torch.diag(torch.ones(prod, dtype=dtype, device=device)).view(s) # type: ignore + + +def median_filter( + in_tensor: torch.Tensor, + kernel_size: Sequence[int] = (3, 3, 3), + spatial_dims: int = 3, + kernel: Optional[torch.Tensor] = None, + **kwargs, +) -> torch.Tensor: + """ + Apply median filter to an image. + + Args: + in_tensor: input tensor; median filtering will be applied to the last `spatial_dims` dimensions. + kernel_size: the convolution kernel size. + spatial_dims: number of spatial dimensions to apply median filtering. + kernel: an optional customized kernel. + kwargs: additional parameters to the `conv`. + + Returns: + the filtered input tensor, shape remains the same as ``in_tensor`` + + Example:: + + >>> from monai.networks.layers import median_filter + >>> import torch + >>> x = torch.rand(4, 5, 7, 6) + >>> output = median_filter(x, (3, 3, 3)) + >>> output.shape + torch.Size([4, 5, 7, 6]) + + """ + if not isinstance(in_tensor, torch.Tensor): + raise TypeError(f"Input type is not a torch.Tensor. Got {type(in_tensor)}") + + original_shape = in_tensor.shape + oshape, sshape = original_shape[: len(original_shape) - spatial_dims], original_shape[-spatial_dims:] + oprod = torch.prod(convert_to_tensor(oshape, int, wrap_sequence=True)) + # prepare kernel + if kernel is None: + kernel_size = ensure_tuple_rep(kernel_size, spatial_dims) + kernel = get_binary_kernel(kernel_size, in_tensor.dtype, in_tensor.device) + else: + kernel = kernel.to(in_tensor) + # map the local window to single vector + conv = [F.conv1d, F.conv2d, F.conv3d][spatial_dims - 1] + + if "padding" not in kwargs: + if pytorch_after(1, 10): + kwargs["padding"] = "same" + else: + # even-sized kernels are not supported + kwargs["padding"] = [(k - 1) // 2 for k in kernel.shape[2:]] + elif kwargs["padding"] == "same" and not pytorch_after(1, 10): + # even-sized kernels are not supported + kwargs["padding"] = [(k - 1) // 2 for k in kernel.shape[2:]] + features: torch.Tensor = conv(in_tensor.reshape(oprod, 1, *sshape), kernel, stride=1, **kwargs) # type: ignore + features = features.view(oprod, -1, *sshape) # type: ignore + + # compute the median along the feature axis + median: torch.Tensor = torch.median(features, dim=1)[0] + median = median.reshape(original_shape) + + return median + + +class MedianFilter(nn.Module): + """ + Apply median filter to an image. + + Args: + radius: the blurring kernel radius (radius of 1 corresponds to 3x3x3 kernel when spatial_dims=3). + + Returns: + filtered input tensor. + + Example:: + + >>> from monai.networks.layers import MedianFilter + >>> import torch + >>> in_tensor = torch.rand(4, 5, 7, 6) + >>> blur = MedianFilter([1, 1, 1]) # 3x3x3 kernel + >>> output = blur(in_tensor) + >>> output.shape + torch.Size([4, 5, 7, 6]) + + """ + + def __init__(self, radius: Union[Sequence[int], int], spatial_dims: int = 3, device="cpu") -> None: + super().__init__() + self.spatial_dims = spatial_dims + self.radius: Sequence[int] = ensure_tuple_rep(radius, spatial_dims) + self.window: Sequence[int] = [1 + 2 * deepcopy(r) for r in self.radius] + self.kernel = get_binary_kernel(self.window, device=device) + + def forward(self, in_tensor: torch.Tensor, number_of_passes=1) -> torch.Tensor: + """ + Args: + in_tensor: input tensor, median filtering will be applied to the last `spatial_dims` dimensions. + number_of_passes: median filtering will be repeated this many times + """ + x = in_tensor + for _ in range(number_of_passes): + x = median_filter(x, kernel=self.kernel, spatial_dims=self.spatial_dims) + return x + + class GaussianFilter(nn.Module): def __init__( self, From 1e74bd64fc7c12916f03c18eaffcfc4bc6c5a271 Mon Sep 17 00:00:00 2001 From: monai-bot <64792179+monai-bot@users.noreply.github.com> Date: Thu, 13 Oct 2022 10:47:02 +0100 Subject: [PATCH 37/60] auto updates (#5322) Signed-off-by: monai-bot Signed-off-by: monai-bot --- monai/apps/auto3dseg/__main__.py | 1 - monai/apps/auto3dseg/ensemble_builder.py | 1 - monai/apps/deepedit/transforms.py | 1 - monai/apps/detection/metrics/coco.py | 1 - monai/apps/detection/metrics/matching.py | 1 - monai/apps/detection/networks/retinanet_detector.py | 1 - monai/apps/detection/networks/retinanet_network.py | 1 - monai/apps/detection/utils/ATSS_matcher.py | 1 - monai/apps/detection/utils/anchor_utils.py | 1 - monai/apps/detection/utils/box_coder.py | 1 - monai/apps/detection/utils/box_selector.py | 1 - monai/apps/detection/utils/hard_negative_sampler.py | 1 - monai/apps/mmars/mmars.py | 1 - monai/apps/mmars/model_desc.py | 1 - monai/apps/reconstruction/transforms/array.py | 1 - monai/apps/reconstruction/transforms/dictionary.py | 1 - monai/apps/tcia/label_desc.py | 2 -- monai/bundle/__main__.py | 1 - monai/bundle/utils.py | 2 -- monai/config/type_definitions.py | 1 - monai/data/box_utils.py | 2 -- monai/data/dataset.py | 1 - monai/data/image_writer.py | 1 - monai/data/thread_buffer.py | 1 - monai/data/utils.py | 1 - monai/handlers/logfile_handler.py | 1 - monai/handlers/nvtx_handlers.py | 1 - monai/losses/contrastive.py | 1 - monai/losses/giou_loss.py | 1 - monai/losses/ssim_loss.py | 1 - monai/losses/tversky.py | 1 - monai/metrics/froc.py | 1 - monai/networks/blocks/backbone_fpn_utils.py | 1 - monai/networks/blocks/dints_block.py | 1 - monai/networks/blocks/feature_pyramid_network.py | 1 - monai/networks/blocks/unetr_block.py | 1 - monai/networks/layers/factories.py | 2 -- monai/networks/nets/dints.py | 1 - monai/networks/nets/dynunet.py | 1 - monai/networks/nets/senet.py | 1 - monai/networks/nets/torchvision_fc.py | 1 - monai/networks/nets/vit.py | 1 - monai/networks/nets/vitautoenc.py | 1 - monai/transforms/adaptors.py | 1 - monai/transforms/signal/array.py | 1 - monai/transforms/smooth_field/array.py | 1 - monai/transforms/smooth_field/dictionary.py | 1 - monai/transforms/utility/array.py | 1 - monai/utils/aliases.py | 1 - monai/utils/jupyter_utils.py | 1 - monai/utils/type_conversion.py | 1 - monai/visualize/gradient_based.py | 1 - monai/visualize/visualizer.py | 1 - tests/profile_subclass/cprofile_profiling.py | 1 - tests/profile_subclass/min_classes.py | 2 -- tests/profile_subclass/profiling.py | 1 - tests/profile_subclass/pyspy_profiling.py | 1 - tests/test_affine_grid.py | 1 - tests/test_cachedataset.py | 1 - tests/test_cast_to_typed.py | 1 - tests/test_center_scale_crop.py | 1 - tests/test_center_scale_cropd.py | 1 - tests/test_compute_ho_ver_maps.py | 1 - tests/test_compute_ho_ver_maps_d.py | 1 - tests/test_config_parser.py | 1 - tests/test_convert_data_type.py | 1 - tests/test_cucim_dict_transform.py | 1 - tests/test_cucim_transform.py | 1 - tests/test_decollate.py | 1 - tests/test_deepedit_transforms.py | 1 - tests/test_densenet.py | 3 --- tests/test_deprecated.py | 1 - tests/test_fill_holes.py | 1 - tests/test_fill_holesd.py | 1 - tests/test_fl_exchange_object.py | 1 - tests/test_fl_monai_algo.py | 1 - tests/test_get_unique_labels.py | 1 - tests/test_globalnet.py | 1 - tests/test_handler_confusion_matrix_dist.py | 1 - tests/test_handler_garbage_collector.py | 1 - tests/test_handler_mean_dice.py | 1 + tests/test_handler_mean_iou.py | 1 + tests/test_handler_metrics_saver_dist.py | 1 - tests/test_handler_rocauc_dist.py | 1 - tests/test_integration_stn.py | 1 - tests/test_label_filter.py | 2 -- tests/test_label_filterd.py | 2 -- tests/test_lesion_froc.py | 3 --- tests/test_load_image.py | 2 -- tests/test_loader_semaphore.py | 1 - tests/test_local_normalized_cross_correlation_loss.py | 1 - tests/test_localnet.py | 1 - tests/test_masked_inference_wsi_dataset.py | 1 - tests/test_masked_patch_wsi_dataset.py | 1 - tests/test_milmodel.py | 2 -- tests/test_nvtx_decorator.py | 1 - tests/test_nvtx_transform.py | 1 - tests/test_occlusion_sensitivity.py | 1 - tests/test_one_of.py | 1 + tests/test_ori_ras_lps.py | 1 - tests/test_orientation.py | 1 - tests/test_patch_wsi_dataset.py | 5 ----- tests/test_pathology_he_stain.py | 1 - tests/test_prepare_batch_default_dist.py | 1 - tests/test_pytorch_version_after.py | 1 - tests/test_rand_cucim_dict_transform.py | 1 - tests/test_rand_cucim_transform.py | 1 - tests/test_rand_rotated.py | 1 - tests/test_rand_weighted_crop.py | 1 - tests/test_reference_based_normalize_intensity.py | 1 - tests/test_reference_based_spatial_cropd.py | 1 - tests/test_regunet.py | 1 - tests/test_resnet.py | 1 - tests/test_retinanet.py | 1 - tests/test_retinanet_detector.py | 1 - tests/test_senet.py | 2 -- tests/test_sliding_patch_wsi_dataset.py | 2 -- tests/test_spacing.py | 1 - tests/test_spacingd.py | 1 - tests/test_spatial_resample.py | 2 -- tests/test_spatial_resampled.py | 1 - tests/test_subpixel_upsample.py | 1 - tests/test_torchvision_fc_model.py | 1 - tests/test_unetr_block.py | 1 - tests/test_upsample_block.py | 1 - tests/test_version_leq.py | 1 - tests/test_video_datasets.py | 1 - tests/test_vis_gradbased.py | 1 - tests/test_wsireader.py | 2 -- tests/utils.py | 2 -- 130 files changed, 3 insertions(+), 149 deletions(-) diff --git a/monai/apps/auto3dseg/__main__.py b/monai/apps/auto3dseg/__main__.py index 41996d6a01..eec56b7582 100644 --- a/monai/apps/auto3dseg/__main__.py +++ b/monai/apps/auto3dseg/__main__.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from monai.apps.auto3dseg.auto_runner import AutoRunner from monai.apps.auto3dseg.bundle_gen import BundleAlgo, BundleGen from monai.apps.auto3dseg.data_analyzer import DataAnalyzer diff --git a/monai/apps/auto3dseg/ensemble_builder.py b/monai/apps/auto3dseg/ensemble_builder.py index 629318d69e..72ea557dc4 100644 --- a/monai/apps/auto3dseg/ensemble_builder.py +++ b/monai/apps/auto3dseg/ensemble_builder.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import json import os from abc import ABC, abstractmethod diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index 94669e5900..76b9e18cc7 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -28,7 +28,6 @@ logger = logging.getLogger(__name__) - distance_transform_cdt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_cdt") diff --git a/monai/apps/detection/metrics/coco.py b/monai/apps/detection/metrics/coco.py index 2ffd99aec6..8dba3fa7da 100644 --- a/monai/apps/detection/metrics/coco.py +++ b/monai/apps/detection/metrics/coco.py @@ -56,7 +56,6 @@ # 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. diff --git a/monai/apps/detection/metrics/matching.py b/monai/apps/detection/metrics/matching.py index 59748045d0..37e6e2fa06 100644 --- a/monai/apps/detection/metrics/matching.py +++ b/monai/apps/detection/metrics/matching.py @@ -56,7 +56,6 @@ # 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, diff --git a/monai/apps/detection/networks/retinanet_detector.py b/monai/apps/detection/networks/retinanet_detector.py index b4ccafbd01..4c6f165439 100644 --- a/monai/apps/detection/networks/retinanet_detector.py +++ b/monai/apps/detection/networks/retinanet_detector.py @@ -32,7 +32,6 @@ # * 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 diff --git a/monai/apps/detection/networks/retinanet_network.py b/monai/apps/detection/networks/retinanet_network.py index 4539a913ac..4a0d8dc228 100644 --- a/monai/apps/detection/networks/retinanet_network.py +++ b/monai/apps/detection/networks/retinanet_network.py @@ -32,7 +32,6 @@ # * 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 diff --git a/monai/apps/detection/utils/ATSS_matcher.py b/monai/apps/detection/utils/ATSS_matcher.py index c04faa3ffd..c208fcd41c 100644 --- a/monai/apps/detection/utils/ATSS_matcher.py +++ b/monai/apps/detection/utils/ATSS_matcher.py @@ -59,7 +59,6 @@ # 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 diff --git a/monai/apps/detection/utils/anchor_utils.py b/monai/apps/detection/utils/anchor_utils.py index baaaeb7147..55c256248a 100644 --- a/monai/apps/detection/utils/anchor_utils.py +++ b/monai/apps/detection/utils/anchor_utils.py @@ -32,7 +32,6 @@ # * 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 diff --git a/monai/apps/detection/utils/box_coder.py b/monai/apps/detection/utils/box_coder.py index c2c1b89fc4..6458360fcd 100644 --- a/monai/apps/detection/utils/box_coder.py +++ b/monai/apps/detection/utils/box_coder.py @@ -43,7 +43,6 @@ # 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, diff --git a/monai/apps/detection/utils/box_selector.py b/monai/apps/detection/utils/box_selector.py index f50b398289..e0e82dbef7 100644 --- a/monai/apps/detection/utils/box_selector.py +++ b/monai/apps/detection/utils/box_selector.py @@ -32,7 +32,6 @@ # * 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 diff --git a/monai/apps/detection/utils/hard_negative_sampler.py b/monai/apps/detection/utils/hard_negative_sampler.py index 4f0e9f2e6a..ee423bb4bc 100644 --- a/monai/apps/detection/utils/hard_negative_sampler.py +++ b/monai/apps/detection/utils/hard_negative_sampler.py @@ -24,7 +24,6 @@ # 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 diff --git a/monai/apps/mmars/mmars.py b/monai/apps/mmars/mmars.py index 055de3f328..6e1770b19e 100644 --- a/monai/apps/mmars/mmars.py +++ b/monai/apps/mmars/mmars.py @@ -8,7 +8,6 @@ # 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. - """ Utilities for accessing Nvidia MMARs diff --git a/monai/apps/mmars/model_desc.py b/monai/apps/mmars/model_desc.py index 47bbfe2eaa..e0a7f26117 100644 --- a/monai/apps/mmars/model_desc.py +++ b/monai/apps/mmars/model_desc.py @@ -8,7 +8,6 @@ # 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. - """ Collection of the remote MMAR descriptors diff --git a/monai/apps/reconstruction/transforms/array.py b/monai/apps/reconstruction/transforms/array.py index 660eab396b..ed58439d29 100644 --- a/monai/apps/reconstruction/transforms/array.py +++ b/monai/apps/reconstruction/transforms/array.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from abc import abstractmethod from typing import Sequence diff --git a/monai/apps/reconstruction/transforms/dictionary.py b/monai/apps/reconstruction/transforms/dictionary.py index dfc7c1c504..baa9bdb2ce 100644 --- a/monai/apps/reconstruction/transforms/dictionary.py +++ b/monai/apps/reconstruction/transforms/dictionary.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Dict, Hashable, Mapping, Optional, Sequence import numpy as np diff --git a/monai/apps/tcia/label_desc.py b/monai/apps/tcia/label_desc.py index 582f83154a..e3875e4095 100644 --- a/monai/apps/tcia/label_desc.py +++ b/monai/apps/tcia/label_desc.py @@ -9,12 +9,10 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Dict __all__ = ["TCIA_LABEL_DICT"] - TCIA_LABEL_DICT: Dict[str, Dict[str, int]] = { "C4KC-KiTS": {"Kidney": 0, "Renal Tumor": 1}, "NSCLC-Radiomics": { diff --git a/monai/bundle/__main__.py b/monai/bundle/__main__.py index ace3701d19..a9671fe385 100644 --- a/monai/bundle/__main__.py +++ b/monai/bundle/__main__.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from monai.bundle.scripts import ckpt_export, download, init_bundle, run, verify_metadata, verify_net_in_out if __name__ == "__main__": diff --git a/monai/bundle/utils.py b/monai/bundle/utils.py index c3a8343163..f382fd820e 100644 --- a/monai/bundle/utils.py +++ b/monai/bundle/utils.py @@ -21,13 +21,11 @@ __all__ = ["ID_REF_KEY", "ID_SEP_KEY", "EXPR_KEY", "MACRO_KEY"] - ID_REF_KEY = "@" # start of a reference to a ConfigItem 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 = { diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index bb6f87e97a..5c360b5536 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -41,7 +41,6 @@ "SequenceStr", ] - #: KeysCollection # # The KeyCollection type is used to for defining variables diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py index 3aaedb3850..a1e321b623 100644 --- a/monai/data/box_utils.py +++ b/monai/data/box_utils.py @@ -8,7 +8,6 @@ # 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 @@ -36,7 +35,6 @@ # 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 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 boxes, diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 0298ee9b4c..22e8bdb610 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import collections.abc import math import pickle diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py index cf7f3e0291..28465f35a4 100644 --- a/monai/data/image_writer.py +++ b/monai/data/image_writer.py @@ -46,7 +46,6 @@ nib, _ = optional_import("nibabel") PILImage, _ = optional_import("PIL.Image") - __all__ = [ "ImageWriter", "ITKWriter", diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index 964ea21be3..0a9079ae0c 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from multiprocessing.context import SpawnContext from queue import Empty, Full, Queue from threading import Thread diff --git a/monai/data/utils.py b/monai/data/utils.py index 40f3de4831..0fb5c9a33a 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -52,7 +52,6 @@ DataFrame, _ = optional_import("pandas", name="DataFrame") nib, _ = optional_import("nibabel") - __all__ = [ "AFFINE_TOL", "SUPPORTED_PICKLE_MOD", diff --git a/monai/handlers/logfile_handler.py b/monai/handlers/logfile_handler.py index d46ac8760f..73c58431a9 100644 --- a/monai/handlers/logfile_handler.py +++ b/monai/handlers/logfile_handler.py @@ -22,7 +22,6 @@ else: Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") - __all__ = ["LogfileHandler"] diff --git a/monai/handlers/nvtx_handlers.py b/monai/handlers/nvtx_handlers.py index 327c156f63..66462a698c 100644 --- a/monai/handlers/nvtx_handlers.py +++ b/monai/handlers/nvtx_handlers.py @@ -24,7 +24,6 @@ Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") - __all__ = ["RangeHandler", "RangePushHandler", "RangePopHandler", "MarkHandler"] diff --git a/monai/losses/contrastive.py b/monai/losses/contrastive.py index 7292b4bc56..ad53269f82 100644 --- a/monai/losses/contrastive.py +++ b/monai/losses/contrastive.py @@ -19,7 +19,6 @@ class ContrastiveLoss(_Loss): - """ Compute the Contrastive loss defined in: diff --git a/monai/losses/giou_loss.py b/monai/losses/giou_loss.py index ec7e358f42..623e55921b 100644 --- a/monai/losses/giou_loss.py +++ b/monai/losses/giou_loss.py @@ -19,7 +19,6 @@ 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 diff --git a/monai/losses/ssim_loss.py b/monai/losses/ssim_loss.py index 240023cdc4..4a1ceb5a16 100644 --- a/monai/losses/ssim_loss.py +++ b/monai/losses/ssim_loss.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import torch import torch.nn.functional as F from torch import nn diff --git a/monai/losses/tversky.py b/monai/losses/tversky.py index ee6d7d933b..a0735c24e0 100644 --- a/monai/losses/tversky.py +++ b/monai/losses/tversky.py @@ -20,7 +20,6 @@ class TverskyLoss(_Loss): - """ Compute the Tversky loss defined in: diff --git a/monai/metrics/froc.py b/monai/metrics/froc.py index 93ad625b90..56e0755b99 100644 --- a/monai/metrics/froc.py +++ b/monai/metrics/froc.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import List, Optional, Tuple, Union import numpy as np diff --git a/monai/networks/blocks/backbone_fpn_utils.py b/monai/networks/blocks/backbone_fpn_utils.py index c663485583..145a4ac2e1 100644 --- a/monai/networks/blocks/backbone_fpn_utils.py +++ b/monai/networks/blocks/backbone_fpn_utils.py @@ -43,7 +43,6 @@ # 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. diff --git a/monai/networks/blocks/dints_block.py b/monai/networks/blocks/dints_block.py index b7365f50e3..1823845adf 100644 --- a/monai/networks/blocks/dints_block.py +++ b/monai/networks/blocks/dints_block.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Tuple, Union import torch diff --git a/monai/networks/blocks/feature_pyramid_network.py b/monai/networks/blocks/feature_pyramid_network.py index 13897a51a0..f950321297 100644 --- a/monai/networks/blocks/feature_pyramid_network.py +++ b/monai/networks/blocks/feature_pyramid_network.py @@ -43,7 +43,6 @@ # 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. diff --git a/monai/networks/blocks/unetr_block.py b/monai/networks/blocks/unetr_block.py index a9d871a644..452a535a2a 100644 --- a/monai/networks/blocks/unetr_block.py +++ b/monai/networks/blocks/unetr_block.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Sequence, Tuple, Union import torch diff --git a/monai/networks/layers/factories.py b/monai/networks/layers/factories.py index 4cde478f7a..a58dbc161f 100644 --- a/monai/networks/layers/factories.py +++ b/monai/networks/layers/factories.py @@ -8,7 +8,6 @@ # 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. - """ Defines factories for creating layers in generic, extensible, and dimensionally independent ways. A separate factory object is created for each type of layer, and factory functions keyed to names are added to these objects. Whenever @@ -70,7 +69,6 @@ def use_factory(fact_args): InstanceNorm3dNVFuser, has_nvfuser = optional_import("apex.normalization", name="InstanceNorm3dNVFuser") - __all__ = ["LayerFactory", "Dropout", "Norm", "Act", "Conv", "Pool", "Pad", "split_args"] diff --git a/monai/networks/nets/dints.py b/monai/networks/nets/dints.py index b7f3921a47..334d0abf0d 100644 --- a/monai/networks/nets/dints.py +++ b/monai/networks/nets/dints.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import warnings from typing import List, Optional, Tuple, Union diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index 053ab255b8..ad7251241b 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import List, Optional, Sequence, Tuple, Union import torch diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py index 8933cbe7e9..b4c024b1f2 100644 --- a/monai/networks/nets/senet.py +++ b/monai/networks/nets/senet.py @@ -34,7 +34,6 @@ "SE_NET_MODELS", ] - SE_NET_MODELS = { "senet154": "http://data.lip6.fr/cadene/pretrainedmodels/senet154-c7b49a05.pth", "se_resnet50": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet50-ce0d4300.pth", diff --git a/monai/networks/nets/torchvision_fc.py b/monai/networks/nets/torchvision_fc.py index 50436e8516..85103a2c04 100644 --- a/monai/networks/nets/torchvision_fc.py +++ b/monai/networks/nets/torchvision_fc.py @@ -16,7 +16,6 @@ models, _ = optional_import("torchvision.models") - __all__ = ["TorchVisionFCModel"] diff --git a/monai/networks/nets/vit.py b/monai/networks/nets/vit.py index 971a7bd126..e4166c78b6 100644 --- a/monai/networks/nets/vit.py +++ b/monai/networks/nets/vit.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Sequence, Union import torch diff --git a/monai/networks/nets/vitautoenc.py b/monai/networks/nets/vitautoenc.py index 9e5490f9d6..6197f6bd99 100644 --- a/monai/networks/nets/vitautoenc.py +++ b/monai/networks/nets/vitautoenc.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Sequence, Union import torch diff --git a/monai/transforms/adaptors.py b/monai/transforms/adaptors.py index 92fd11cf79..1edbcc63e2 100644 --- a/monai/transforms/adaptors.py +++ b/monai/transforms/adaptors.py @@ -8,7 +8,6 @@ # 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. - """ How to use the adaptor function ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/monai/transforms/signal/array.py b/monai/transforms/signal/array.py index 6b904ad1c5..07f29a3039 100644 --- a/monai/transforms/signal/array.py +++ b/monai/transforms/signal/array.py @@ -34,7 +34,6 @@ central_frequency, has_central_frequency = optional_import("pywt", name="central_frequency") cwt, has_cwt = optional_import("pywt", name="cwt") - __all__ = [ "SignalRandDrop", "SignalRandScale", diff --git a/monai/transforms/smooth_field/array.py b/monai/transforms/smooth_field/array.py index 3f6ddf7704..13507339e1 100644 --- a/monai/transforms/smooth_field/array.py +++ b/monai/transforms/smooth_field/array.py @@ -8,7 +8,6 @@ # 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. - """Transforms using a smooth spatial field generated by interpolating from smaller randomized fields.""" from typing import Any, Optional, Sequence, Union diff --git a/monai/transforms/smooth_field/dictionary.py b/monai/transforms/smooth_field/dictionary.py index eb975ceb04..08fb71edb4 100644 --- a/monai/transforms/smooth_field/dictionary.py +++ b/monai/transforms/smooth_field/dictionary.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Any, Hashable, Mapping, Optional, Sequence, Union import numpy as np diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index bfea091f20..c48a740c63 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -58,7 +58,6 @@ pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") cp, has_cp = optional_import("cupy") - __all__ = [ "Identity", "AsChannelFirst", diff --git a/monai/utils/aliases.py b/monai/utils/aliases.py index 0ae79e26ff..1a63c3aba8 100644 --- a/monai/utils/aliases.py +++ b/monai/utils/aliases.py @@ -8,7 +8,6 @@ # 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 module is written for configurable workflow, not currently in use. """ diff --git a/monai/utils/jupyter_utils.py b/monai/utils/jupyter_utils.py index bc7d703d4b..f9eb00aa02 100644 --- a/monai/utils/jupyter_utils.py +++ b/monai/utils/jupyter_utils.py @@ -8,7 +8,6 @@ # 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 set of utility function is meant to make using Jupyter notebooks easier with MONAI. Plotting functions using Matplotlib produce common plots for metrics and images. diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index cf86e2bde7..33c7bb5f3b 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -36,7 +36,6 @@ "convert_to_dst_type", ] - # conversion map for types unsupported by torch.as_tensor UNSUPPORTED_TYPES = {np.dtype("uint16"): np.int32, np.dtype("uint32"): np.int64, np.dtype("uint64"): np.int64} diff --git a/monai/visualize/gradient_based.py b/monai/visualize/gradient_based.py index 106378dff2..7f4ddce1d0 100644 --- a/monai/visualize/gradient_based.py +++ b/monai/visualize/gradient_based.py @@ -22,7 +22,6 @@ trange, has_trange = optional_import("tqdm", name="trange") - __all__ = ["VanillaGrad", "SmoothGrad", "GuidedBackpropGrad", "GuidedBackpropSmoothGrad"] diff --git a/monai/visualize/visualizer.py b/monai/visualize/visualizer.py index 5f19e4f63f..05ebb2e280 100644 --- a/monai/visualize/visualizer.py +++ b/monai/visualize/visualizer.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from typing import Callable import torch diff --git a/tests/profile_subclass/cprofile_profiling.py b/tests/profile_subclass/cprofile_profiling.py index a6c940c9c0..0befa0f450 100644 --- a/tests/profile_subclass/cprofile_profiling.py +++ b/tests/profile_subclass/cprofile_profiling.py @@ -8,7 +8,6 @@ # 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 """ diff --git a/tests/profile_subclass/min_classes.py b/tests/profile_subclass/min_classes.py index 87c0ce671d..702ba73e21 100644 --- a/tests/profile_subclass/min_classes.py +++ b/tests/profile_subclass/min_classes.py @@ -8,8 +8,6 @@ # 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 diff --git a/tests/profile_subclass/profiling.py b/tests/profile_subclass/profiling.py index 28740e82e1..46047b619c 100644 --- a/tests/profile_subclass/profiling.py +++ b/tests/profile_subclass/profiling.py @@ -8,7 +8,6 @@ # 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 diff --git a/tests/profile_subclass/pyspy_profiling.py b/tests/profile_subclass/pyspy_profiling.py index 302bfd39c3..1caeee69e7 100644 --- a/tests/profile_subclass/pyspy_profiling.py +++ b/tests/profile_subclass/pyspy_profiling.py @@ -8,7 +8,6 @@ # 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 diff --git a/tests/test_affine_grid.py b/tests/test_affine_grid.py index b481601df5..23651c8b6b 100644 --- a/tests/test_affine_grid.py +++ b/tests/test_affine_grid.py @@ -129,7 +129,6 @@ ] ) - _rtol = 5e-2 if is_tf32_env() else 1e-4 diff --git a/tests/test_cachedataset.py b/tests/test_cachedataset.py index 4fa1b5ea69..e30a34b335 100644 --- a/tests/test_cachedataset.py +++ b/tests/test_cachedataset.py @@ -26,7 +26,6 @@ TEST_CASE_2 = [None, (128, 128, 128)] - TEST_DS = [] for c in (0, 1, 2): for l in (0, 1, 2): diff --git a/tests/test_cast_to_typed.py b/tests/test_cast_to_typed.py index 4c7623a9e0..1ac23314a5 100644 --- a/tests/test_cast_to_typed.py +++ b/tests/test_cast_to_typed.py @@ -36,7 +36,6 @@ {"img": torch.float64, "seg": torch.int8}, ] - TESTS_CUPY = [ [ {"keys": "image", "dtype": np.uint8}, diff --git a/tests/test_center_scale_crop.py b/tests/test_center_scale_crop.py index ab07a44eb5..3fe7a453d3 100644 --- a/tests/test_center_scale_crop.py +++ b/tests/test_center_scale_crop.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import numpy as np diff --git a/tests/test_center_scale_cropd.py b/tests/test_center_scale_cropd.py index 894692530d..088c1c70e7 100644 --- a/tests/test_center_scale_cropd.py +++ b/tests/test_center_scale_cropd.py @@ -23,7 +23,6 @@ [{"keys": "img", "roi_scale": 0.5}, (3, 3, 3, 3), (3, 2, 2, 2)], ] - TEST_VALUES = [ [ {"keys": "img", "roi_scale": [0.4, 0.4]}, diff --git a/tests/test_compute_ho_ver_maps.py b/tests/test_compute_ho_ver_maps.py index f5091a57af..5c4674dd04 100644 --- a/tests/test_compute_ho_ver_maps.py +++ b/tests/test_compute_ho_ver_maps.py @@ -21,7 +21,6 @@ _, has_skimage = optional_import("skimage", "0.19.0", min_version) - INSTANCE_MASK = np.zeros((1, 16, 16), dtype="int16") INSTANCE_MASK[:, 5:8, 4:11] = 1 INSTANCE_MASK[:, 3:5, 6:9] = 1 diff --git a/tests/test_compute_ho_ver_maps_d.py b/tests/test_compute_ho_ver_maps_d.py index 3c20c7f200..475e50bc70 100644 --- a/tests/test_compute_ho_ver_maps_d.py +++ b/tests/test_compute_ho_ver_maps_d.py @@ -21,7 +21,6 @@ _, has_skimage = optional_import("skimage", "0.19.0", min_version) - INSTANCE_MASK = np.zeros((1, 16, 16), dtype="int16") INSTANCE_MASK[:, 5:8, 4:11] = 1 INSTANCE_MASK[:, 3:5, 6:9] = 1 diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py index 18c91d719c..d02a05c914 100644 --- a/tests/test_config_parser.py +++ b/tests/test_config_parser.py @@ -87,7 +87,6 @@ def __call__(self, a, b): } ] - TEST_CASE_3 = [ { "A": 1, diff --git a/tests/test_convert_data_type.py b/tests/test_convert_data_type.py index b49e7ebd52..d411f3f972 100644 --- a/tests/test_convert_data_type.py +++ b/tests/test_convert_data_type.py @@ -39,7 +39,6 @@ ) ) - UNSUPPORTED_TYPES = {np.dtype("uint16"): torch.int32, np.dtype("uint32"): torch.int64, np.dtype("uint64"): torch.int64} diff --git a/tests/test_cucim_dict_transform.py b/tests/test_cucim_dict_transform.py index f8b54c3147..4a6d2f9d51 100644 --- a/tests/test_cucim_dict_transform.py +++ b/tests/test_cucim_dict_transform.py @@ -41,7 +41,6 @@ np.array([[[1.0, 0.0], [3.0, 2.0]], [[1.0, 0.0], [3.0, 2.0]], [[1.0, 0.0], [3.0, 2.0]]], dtype=np.float32), ] - TEST_CASE_ROTATE_1 = [ {"name": "image_rotate_90", "k": 1, "spatial_axis": (-2, -1)}, np.array([[[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]]], dtype=np.float32), diff --git a/tests/test_cucim_transform.py b/tests/test_cucim_transform.py index 2bf9791bce..dd73ad94c0 100644 --- a/tests/test_cucim_transform.py +++ b/tests/test_cucim_transform.py @@ -41,7 +41,6 @@ np.array([[[1.0, 0.0], [3.0, 2.0]], [[1.0, 0.0], [3.0, 2.0]], [[1.0, 0.0], [3.0, 2.0]]], dtype=np.float32), ] - TEST_CASE_ROTATE_1 = [ {"name": "image_rotate_90", "k": 1, "spatial_axis": (-2, -1)}, np.array([[[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]]], dtype=np.float32), diff --git a/tests/test_decollate.py b/tests/test_decollate.py index a634471be5..538eb38311 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -55,7 +55,6 @@ TESTS_LIST.append((RandRotate90(prob=0.0, max_k=1),)) TESTS_LIST.append((RandAffine(prob=0.0, translate_range=10),)) - TEST_BASIC = [ [("channel", "channel"), ["channel", "channel"]], [torch.Tensor([1, 2, 3]), [torch.tensor(1.0), torch.tensor(2.0), torch.tensor(3.0)]], diff --git a/tests/test_deepedit_transforms.py b/tests/test_deepedit_transforms.py index 3d4870d2fd..f608a4342f 100644 --- a/tests/test_deepedit_transforms.py +++ b/tests/test_deepedit_transforms.py @@ -140,7 +140,6 @@ 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 diff --git a/tests/test_densenet.py b/tests/test_densenet.py index 47f584297e..66f27cba51 100644 --- a/tests/test_densenet.py +++ b/tests/test_densenet.py @@ -28,7 +28,6 @@ else: torchvision, has_torchvision = optional_import("torchvision") - device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_1 = [ # 4-channel 3D, batch 2 @@ -54,10 +53,8 @@ for model in [DenseNet121, Densenet169, densenet201, DenseNet264]: TEST_CASES.append([model, *case]) - TEST_SCRIPT_CASES = [[model, *TEST_CASE_1] for model in [DenseNet121, Densenet169, densenet201, DenseNet264]] - TEST_PRETRAINED_2D_CASE_1 = [ # 4-channel 2D, batch 2 DenseNet121, {"pretrained": True, "progress": True, "spatial_dims": 2, "in_channels": 2, "out_channels": 3}, diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index 3d27994404..c94c300175 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import warnings diff --git a/tests/test_fill_holes.py b/tests/test_fill_holes.py index 4292ff3a22..688c65005e 100644 --- a/tests/test_fill_holes.py +++ b/tests/test_fill_holes.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import torch diff --git a/tests/test_fill_holesd.py b/tests/test_fill_holesd.py index fce90fd86a..7711df36b3 100644 --- a/tests/test_fill_holesd.py +++ b/tests/test_fill_holesd.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import torch diff --git a/tests/test_fl_exchange_object.py b/tests/test_fl_exchange_object.py index 7c8087da27..bb2d0372db 100644 --- a/tests/test_fl_exchange_object.py +++ b/tests/test_fl_exchange_object.py @@ -21,7 +21,6 @@ models, has_torchvision = optional_import("torchvision.models") - TEST_INIT_1 = [{"weights": None, "optim": None, "metrics": None, "weight_type": None, "statistics": None}, "{}"] TEST_INIT_2: list = [] if has_torchvision: diff --git a/tests/test_fl_monai_algo.py b/tests/test_fl_monai_algo.py index 8c2709c582..66722d0086 100644 --- a/tests/test_fl_monai_algo.py +++ b/tests/test_fl_monai_algo.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import json import os import unittest diff --git a/tests/test_get_unique_labels.py b/tests/test_get_unique_labels.py index 9bc6f9b152..67953a3205 100644 --- a/tests/test_get_unique_labels.py +++ b/tests/test_get_unique_labels.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import torch diff --git a/tests/test_globalnet.py b/tests/test_globalnet.py index ef0209e397..4a3e9c124c 100644 --- a/tests/test_globalnet.py +++ b/tests/test_globalnet.py @@ -40,7 +40,6 @@ ], ] - TEST_CASES_GLOBAL_NET = [ [ { diff --git a/tests/test_handler_confusion_matrix_dist.py b/tests/test_handler_confusion_matrix_dist.py index 325a799098..511e84d22a 100644 --- a/tests/test_handler_confusion_matrix_dist.py +++ b/tests/test_handler_confusion_matrix_dist.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import numpy as np diff --git a/tests/test_handler_garbage_collector.py b/tests/test_handler_garbage_collector.py index 0350ba62fb..e3bc3411b9 100644 --- a/tests/test_handler_garbage_collector.py +++ b/tests/test_handler_garbage_collector.py @@ -24,7 +24,6 @@ Events, has_ignite = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") - TEST_CASE_0 = [[0, 1, 2], "epoch"] TEST_CASE_1 = [[0, 1, 2], "iteration"] diff --git a/tests/test_handler_mean_dice.py b/tests/test_handler_mean_dice.py index 587062c3b3..88eb4fbdcd 100644 --- a/tests/test_handler_mean_dice.py +++ b/tests/test_handler_mean_dice.py @@ -33,6 +33,7 @@ class TestHandlerMeanDice(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_compute(self, input_params, expected_avg, details_shape): dice_metric = MeanDice(**input_params) + # set up engine def _val_func(engine, batch): diff --git a/tests/test_handler_mean_iou.py b/tests/test_handler_mean_iou.py index 4b0bfb09c3..fdd4a5d04d 100644 --- a/tests/test_handler_mean_iou.py +++ b/tests/test_handler_mean_iou.py @@ -33,6 +33,7 @@ class TestHandlerMeanIoU(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_compute(self, input_params, expected_avg, details_shape): iou_metric = MeanIoUHandler(**input_params) + # set up engine def _val_func(engine, batch): diff --git a/tests/test_handler_metrics_saver_dist.py b/tests/test_handler_metrics_saver_dist.py index a92fdf93d3..426d99c223 100644 --- a/tests/test_handler_metrics_saver_dist.py +++ b/tests/test_handler_metrics_saver_dist.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import csv import os import tempfile diff --git a/tests/test_handler_rocauc_dist.py b/tests/test_handler_rocauc_dist.py index 5113911d7c..994cbe139b 100644 --- a/tests/test_handler_rocauc_dist.py +++ b/tests/test_handler_rocauc_dist.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import numpy as np diff --git a/tests/test_integration_stn.py b/tests/test_integration_stn.py index e655ff6755..5b9b22668a 100644 --- a/tests/test_integration_stn.py +++ b/tests/test_integration_stn.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import numpy as np diff --git a/tests/test_label_filter.py b/tests/test_label_filter.py index b782f90441..42aa419b1d 100644 --- a/tests/test_label_filter.py +++ b/tests/test_label_filter.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import torch @@ -51,7 +50,6 @@ VALID_TESTS.append(["filter_all", {"applied_labels": [1, 2, 3, 4, 5, 6, 7, 8, 9]}, p(grid_1), p(grid_1)]) - ITEST_CASE_1 = ["invalid_image_data_type", {"applied_labels": 1}, [[[[1, 1, 1]]]], NotImplementedError] INVALID_CASES = [ITEST_CASE_1] diff --git a/tests/test_label_filterd.py b/tests/test_label_filterd.py index d53dc21faf..eea18d0278 100644 --- a/tests/test_label_filterd.py +++ b/tests/test_label_filterd.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import torch @@ -51,7 +50,6 @@ VALID_TESTS.append(["filter_all", {"applied_labels": [1, 2, 3, 4, 5, 6, 7, 8, 9]}, p(grid_1), p(grid_1)]) - ITEST_CASE_1 = ["invalid_image_data_type", {"applied_labels": 1}, [[[[1, 1, 1]]]], NotImplementedError] INVALID_CASES = [ITEST_CASE_1] diff --git a/tests/test_lesion_froc.py b/tests/test_lesion_froc.py index b135b3eaeb..8c9f751b1e 100644 --- a/tests/test_lesion_froc.py +++ b/tests/test_lesion_froc.py @@ -114,7 +114,6 @@ def prepare_test_data(): np.nan, ] - TEST_CASE_1 = [ { "data": [ @@ -163,7 +162,6 @@ def prepare_test_data(): 1.0, ] - TEST_CASE_4 = [ { "data": [ @@ -196,7 +194,6 @@ def prepare_test_data(): 0.5, ] - TEST_CASE_6 = [ { "data": [ diff --git a/tests/test_load_image.py b/tests/test_load_image.py index cc227021a2..7dffa845ba 100644 --- a/tests/test_load_image.py +++ b/tests/test_load_image.py @@ -83,7 +83,6 @@ def get_data(self, _obj): (384, 128, 128), ] - TEST_CASE_9 = [ {"reader": ITKReader()}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], @@ -141,7 +140,6 @@ def get_data(self, _obj): # test reader consistency between PydicomReader and ITKReader on dicom data TEST_CASE_22 = ["tests/testing_data/CT_DICOM"] - TESTS_META = [] for track_meta in (False, True): TESTS_META.append([{}, (128, 128, 128), track_meta]) diff --git a/tests/test_loader_semaphore.py b/tests/test_loader_semaphore.py index bbb2d4eef6..85cf5593f8 100644 --- a/tests/test_loader_semaphore.py +++ b/tests/test_loader_semaphore.py @@ -8,7 +8,6 @@ # 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 test should not generate errors or UserWarning: semaphore_tracker: There appear to be 1 leaked semaphores""" import multiprocessing as mp diff --git a/tests/test_local_normalized_cross_correlation_loss.py b/tests/test_local_normalized_cross_correlation_loss.py index 93e0281885..394e514f43 100644 --- a/tests/test_local_normalized_cross_correlation_loss.py +++ b/tests/test_local_normalized_cross_correlation_loss.py @@ -147,6 +147,5 @@ def test_ill_opts(self): # loss = LocalNormalizedCrossCorrelationLoss(**input_param) # test_script_save(loss, input_data["pred"], input_data["target"]) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_localnet.py b/tests/test_localnet.py index 1a288fb447..9296edab99 100644 --- a/tests/test_localnet.py +++ b/tests/test_localnet.py @@ -20,7 +20,6 @@ device = "cuda" if torch.cuda.is_available() else "cpu" - TEST_CASE_LOCALNET_2D = [ [ { diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py index 85683ea88c..661f728e47 100644 --- a/tests/test_masked_inference_wsi_dataset.py +++ b/tests/test_masked_inference_wsi_dataset.py @@ -134,7 +134,6 @@ def prepare_data(): ], ] - TEST_CASE_OPENSLIDE_0 = [ {"data": [{"image": FILE_PATH, "mask": MASK1}], "patch_size": 1, "image_reader_name": "OpenSlide"}, [{"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "name": FILE_NAME, "mask_location": [100, 100]}], diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py index e0846c867b..9783c2d7cf 100644 --- a/tests/test_masked_patch_wsi_dataset.py +++ b/tests/test_masked_patch_wsi_dataset.py @@ -30,7 +30,6 @@ _, 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" diff --git a/tests/test_milmodel.py b/tests/test_milmodel.py index 3a6ea9a1bd..2d58af4a2b 100644 --- a/tests/test_milmodel.py +++ b/tests/test_milmodel.py @@ -23,7 +23,6 @@ device = "cuda" if torch.cuda.is_available() else "cpu" - TEST_CASE_MILMODEL = [] for num_classes in [1, 5]: for mil_mode in ["mean", "max", "att", "att_trans", "att_trans_pyramid"]: @@ -34,7 +33,6 @@ ] TEST_CASE_MILMODEL.append(test_case) - for trans_blocks in [1, 3]: test_case = [ {"num_classes": 5, "pretrained": False, "trans_blocks": trans_blocks, "trans_dropout": 0.5}, diff --git a/tests/test_nvtx_decorator.py b/tests/test_nvtx_decorator.py index 9932b678c9..7dd2dd81b5 100644 --- a/tests/test_nvtx_decorator.py +++ b/tests/test_nvtx_decorator.py @@ -39,7 +39,6 @@ _, has_tvt = optional_import("torchvision.transforms") _, has_cut = optional_import("cucim.core.operations.expose.transform") - TEST_CASE_ARRAY_0 = [np.random.randn(3, 3)] TEST_CASE_ARRAY_1 = [np.random.randn(3, 10, 10)] diff --git a/tests/test_nvtx_transform.py b/tests/test_nvtx_transform.py index 01a069ed8a..fd784f6d32 100644 --- a/tests/test_nvtx_transform.py +++ b/tests/test_nvtx_transform.py @@ -34,7 +34,6 @@ _, has_nvtx = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") - TEST_CASE_ARRAY_0 = [np.random.randn(3, 3)] TEST_CASE_ARRAY_1 = [np.random.randn(3, 10, 10)] TEST_CASE_DICT_0 = [{"image": np.random.randn(3, 3)}] diff --git a/tests/test_occlusion_sensitivity.py b/tests/test_occlusion_sensitivity.py index cedc8ed1a3..5239e120e5 100644 --- a/tests/test_occlusion_sensitivity.py +++ b/tests/test_occlusion_sensitivity.py @@ -40,7 +40,6 @@ def __call__(self, x, adjoint_info): model_3d.eval() model_2d_adjoint.eval() - TESTS: List[Any] = [] TESTS_FAIL: List[Any] = [] diff --git a/tests/test_one_of.py b/tests/test_one_of.py index 8171acb6da..2ea41c6e50 100644 --- a/tests/test_one_of.py +++ b/tests/test_one_of.py @@ -140,6 +140,7 @@ def test_len_and_flatten(self): def test_compose_flatten_does_not_affect_one_of(self): p = Compose([A(), B(), OneOf([C(), Inv(KEYS), Compose([X(), Y()])])]) f = p.flatten() + # in this case the flattened transform should be the same. def _match(a, b): diff --git a/tests/test_ori_ras_lps.py b/tests/test_ori_ras_lps.py index 4ed223bf5b..d0a9b034e4 100644 --- a/tests/test_ori_ras_lps.py +++ b/tests/test_ori_ras_lps.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import numpy as np diff --git a/tests/test_orientation.py b/tests/test_orientation.py index 7c4a1863c7..979f6ae485 100644 --- a/tests/test_orientation.py +++ b/tests/test_orientation.py @@ -167,7 +167,6 @@ for device in TEST_DEVICES: TESTS_TORCH.append([{"axcodes": "LPS"}, torch.zeros((1, 3, 4, 5)), track_meta, *device]) - ILL_CASES = [ # too short axcodes [{"axcodes": "RA"}, torch.arange(12).reshape((2, 1, 2, 3)), torch.eye(4)] diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 8f1ec12990..0ba1a4a649 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -71,7 +71,6 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] - TEST_CASE_DEP_1 = [ { "data": [{"image": FILE_PATH, WSIPatchKeys.LOCATION.value: [10004, 20004], "label": [0, 0, 0, 1]}], @@ -88,7 +87,6 @@ ], ] - TEST_CASE_DEP_1_L0 = [ { "data": [{"image": FILE_PATH, WSIPatchKeys.LOCATION.value: [10004, 20004], "label": [0, 0, 0, 1]}], @@ -106,7 +104,6 @@ ], ] - TEST_CASE_DEP_1_L1 = [ { "data": [{"image": FILE_PATH, WSIPatchKeys.LOCATION.value: [10004, 20004], "label": [0, 0, 0, 1]}], @@ -180,7 +177,6 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] - TEST_CASE_DEP_OPENSLIDE_0_L2 = [ { "data": [{"image": FILE_PATH, WSIPatchKeys.LOCATION.value: [0, 0], "label": [1]}], @@ -209,7 +205,6 @@ ], ] - TEST_CASE_0 = [ { "data": [{"image": FILE_PATH, WSIPatchKeys.LOCATION.value: [0, 0], "label": [1], "patch_level": 0}], diff --git a/tests/test_pathology_he_stain.py b/tests/test_pathology_he_stain.py index 7b884315fc..ac4e94144a 100644 --- a/tests/test_pathology_he_stain.py +++ b/tests/test_pathology_he_stain.py @@ -49,7 +49,6 @@ np.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), ] - # input pixels all transparent and below the beta absorbance threshold NORMALIZE_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] diff --git a/tests/test_prepare_batch_default_dist.py b/tests/test_prepare_batch_default_dist.py index 95d01d2a16..3c7532e916 100644 --- a/tests/test_prepare_batch_default_dist.py +++ b/tests/test_prepare_batch_default_dist.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest import torch diff --git a/tests/test_pytorch_version_after.py b/tests/test_pytorch_version_after.py index 68abb9571f..be43e49f82 100644 --- a/tests/test_pytorch_version_after.py +++ b/tests/test_pytorch_version_after.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import unittest from parameterized import parameterized diff --git a/tests/test_rand_cucim_dict_transform.py b/tests/test_rand_cucim_dict_transform.py index a109bee845..e72101e470 100644 --- a/tests/test_rand_cucim_dict_transform.py +++ b/tests/test_rand_cucim_dict_transform.py @@ -41,7 +41,6 @@ np.array([[[1.0, 3.0], [0.0, 2.0]], [[1.0, 3.0], [0.0, 2.0]], [[1.0, 3.0], [0.0, 2.0]]], dtype=np.float32), ] - TEST_CASE_RAND_ROTATE_2 = [ {"name": "rand_image_rotate_90", "prob": 0.0, "max_k": 1, "spatial_axis": (-2, -1)}, np.array([[[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]]], dtype=np.float32), diff --git a/tests/test_rand_cucim_transform.py b/tests/test_rand_cucim_transform.py index 30164e4170..0f37b3f6cb 100644 --- a/tests/test_rand_cucim_transform.py +++ b/tests/test_rand_cucim_transform.py @@ -41,7 +41,6 @@ np.array([[[1.0, 3.0], [0.0, 2.0]], [[1.0, 3.0], [0.0, 2.0]], [[1.0, 3.0], [0.0, 2.0]]], dtype=np.float32), ] - TEST_CASE_RAND_ROTATE_2 = [ {"name": "rand_image_rotate_90", "prob": 0.0, "max_k": 1, "spatial_axis": (-2, -1)}, np.array([[[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]], [[0.0, 1.0], [2.0, 3.0]]], dtype=np.float32), diff --git a/tests/test_rand_rotated.py b/tests/test_rand_rotated.py index a22c33187b..e19f8a513f 100644 --- a/tests/test_rand_rotated.py +++ b/tests/test_rand_rotated.py @@ -28,7 +28,6 @@ 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_ALL: TEST_CASES_3D.append( diff --git a/tests/test_rand_weighted_crop.py b/tests/test_rand_weighted_crop.py index 53913ce987..2e1fcad4b2 100644 --- a/tests/test_rand_weighted_crop.py +++ b/tests/test_rand_weighted_crop.py @@ -28,7 +28,6 @@ def get_data(ndim): 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: diff --git a/tests/test_reference_based_normalize_intensity.py b/tests/test_reference_based_normalize_intensity.py index 0f5fa7d627..01811e5907 100644 --- a/tests/test_reference_based_normalize_intensity.py +++ b/tests/test_reference_based_normalize_intensity.py @@ -24,7 +24,6 @@ # which focuses on (1) automatic target normalization and (2) mean-std # return values - TESTS = [] for p in TEST_NDARRAYS_NO_META_TENSOR: TESTS.append( diff --git a/tests/test_reference_based_spatial_cropd.py b/tests/test_reference_based_spatial_cropd.py index d1f6230da4..ab5573044d 100644 --- a/tests/test_reference_based_spatial_cropd.py +++ b/tests/test_reference_based_spatial_cropd.py @@ -22,7 +22,6 @@ # here, we test TargetBasedSpatialCropd's functionality # which focuses on automatic input crop based on target image's shape. - TESTS = [] for p in TEST_NDARRAYS: # 2D diff --git a/tests/test_regunet.py b/tests/test_regunet.py index e37ca49538..04f971d2eb 100644 --- a/tests/test_regunet.py +++ b/tests/test_regunet.py @@ -20,7 +20,6 @@ device = "cuda" if torch.cuda.is_available() else "cpu" - TEST_CASE_REGUNET_2D = [ [ { diff --git a/tests/test_resnet.py b/tests/test_resnet.py index 88499f78d0..ae05f36210 100644 --- a/tests/test_resnet.py +++ b/tests/test_resnet.py @@ -28,7 +28,6 @@ else: torchvision, has_torchvision = optional_import("torchvision") - device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_1 = [ # 3D, batch 3, 2 input channel diff --git a/tests/test_retinanet.py b/tests/test_retinanet.py index 3c136a4cf2..f067e82962 100644 --- a/tests/test_retinanet.py +++ b/tests/test_retinanet.py @@ -22,7 +22,6 @@ _, has_torchvision = optional_import("torchvision") - device = "cuda" if torch.cuda.is_available() else "cpu" num_anchors = 7 diff --git a/tests/test_retinanet_detector.py b/tests/test_retinanet_detector.py index 99a70fb5fa..243828432d 100644 --- a/tests/test_retinanet_detector.py +++ b/tests/test_retinanet_detector.py @@ -23,7 +23,6 @@ _, has_torchvision = optional_import("torchvision") - num_anchors = 7 TEST_CASE_1 = [ # 3D, batch 3, 2 input channel diff --git a/tests/test_senet.py b/tests/test_senet.py index 34f140638e..b0d8ac0c0a 100644 --- a/tests/test_senet.py +++ b/tests/test_senet.py @@ -30,10 +30,8 @@ else: pretrainedmodels, has_cadene_pretrain = optional_import("pretrainedmodels") - device = "cuda" if torch.cuda.is_available() else "cpu" - NET_ARGS = {"spatial_dims": 3, "in_channels": 2, "num_classes": 2} TEST_CASE_1 = [SENet154, NET_ARGS] TEST_CASE_2 = [SEResNet50, NET_ARGS] diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index 819a363b67..06395cf26c 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -30,7 +30,6 @@ _, 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" @@ -143,7 +142,6 @@ ], ] - TEST_CASE_SMALL_7 = [ {"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:]}], diff --git a/tests/test_spacing.py b/tests/test_spacing.py index cbbb3d0069..ed5a770cd7 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -238,7 +238,6 @@ ] ) - TESTS_TORCH = [] for track_meta in (False, True): for p in TEST_NDARRAYS_ALL: diff --git a/tests/test_spacingd.py b/tests/test_spacingd.py index d3c7bbc629..22729fd1b2 100644 --- a/tests/test_spacingd.py +++ b/tests/test_spacingd.py @@ -81,7 +81,6 @@ ) ) - TESTS_TORCH = [] for track_meta in (False, True): for device in TEST_DEVICES: diff --git a/tests/test_spatial_resample.py b/tests/test_spatial_resample.py index 33ff971a71..30bf33149b 100644 --- a/tests/test_spatial_resample.py +++ b/tests/test_spatial_resample.py @@ -25,7 +25,6 @@ TESTS = [] - 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]]), @@ -59,7 +58,6 @@ TESTS.append(deepcopy(TESTS[-1])) TESTS[-1][2].update({"align_corners": True, "mode": 1, "padding_mode": "reflect"}) # type: ignore - 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 diff --git a/tests/test_spatial_resampled.py b/tests/test_spatial_resampled.py index b9c221124c..5ace0b3774 100644 --- a/tests/test_spatial_resampled.py +++ b/tests/test_spatial_resampled.py @@ -22,7 +22,6 @@ TESTS = [] - 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]]), diff --git a/tests/test_subpixel_upsample.py b/tests/test_subpixel_upsample.py index 3e5370473c..bd46aecb97 100644 --- a/tests/test_subpixel_upsample.py +++ b/tests/test_subpixel_upsample.py @@ -57,7 +57,6 @@ TEST_CASE_SUBPIXEL.append(TEST_CASE_SUBPIXEL_3D_EXTRA) TEST_CASE_SUBPIXEL.append(TEST_CASE_SUBPIXEL_CONV_BLOCK_EXTRA) - # add every test back with the pad/pool sequential component omitted for tests in list(TEST_CASE_SUBPIXEL): args: dict = tests[0] # type: ignore diff --git a/tests/test_torchvision_fc_model.py b/tests/test_torchvision_fc_model.py index 942d8f907f..d7341bc71e 100644 --- a/tests/test_torchvision_fc_model.py +++ b/tests/test_torchvision_fc_model.py @@ -135,7 +135,6 @@ -0.010419349186122417, ] - TEST_CASE_PRETRAINED_6 = [ { "model_name": "inception_v3", diff --git a/tests/test_unetr_block.py b/tests/test_unetr_block.py index c0f14c829d..8a4ee3a163 100644 --- a/tests/test_unetr_block.py +++ b/tests/test_unetr_block.py @@ -67,7 +67,6 @@ ] TEST_UP_BLOCK.append(test_case) - TEST_PRUP_BLOCK = [] in_channels, out_channels = 4, 2 for spatial_dims in range(1, 4): diff --git a/tests/test_upsample_block.py b/tests/test_upsample_block.py index 71884e2db1..535ad80c11 100644 --- a/tests/test_upsample_block.py +++ b/tests/test_upsample_block.py @@ -97,7 +97,6 @@ ] TEST_CASES_EQ.append(test_case) - TEST_CASES_EQ2 = [] # type: ignore for s in range(2, 5): for k in range(1, 7): diff --git a/tests/test_version_leq.py b/tests/test_version_leq.py index 86fccca9fb..725c1ee128 100644 --- a/tests/test_version_leq.py +++ b/tests/test_version_leq.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import itertools import unittest diff --git a/tests/test_video_datasets.py b/tests/test_video_datasets.py index 7dcc3d1a6c..78e015e350 100644 --- a/tests/test_video_datasets.py +++ b/tests/test_video_datasets.py @@ -23,7 +23,6 @@ cv2, has_cv2 = optional_import("cv2") - NUM_CAPTURE_DEVICES = CameraDataset.get_num_devices() TRANSFORMS = mt.Compose( [mt.EnsureChannelFirst(True, "no_channel"), mt.DivisiblePad(16), mt.ScaleIntensity(), mt.CastToType(torch.float32)] diff --git a/tests/test_vis_gradbased.py b/tests/test_vis_gradbased.py index 035cb2967b..5af8769872 100644 --- a/tests/test_vis_gradbased.py +++ b/tests/test_vis_gradbased.py @@ -31,7 +31,6 @@ def __call__(self, x, adjoint_info): SENET3D = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) DENSENET2DADJOINT = DenseNetAdjoint(spatial_dims=2, in_channels=1, out_channels=3) - TESTS = [] for type in (VanillaGrad, SmoothGrad, GuidedBackpropGrad, GuidedBackpropSmoothGrad): # 2D densenet diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 288dacc0be..5fb113e193 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -107,7 +107,6 @@ np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), ] - TEST_CASE_5 = [ FILE_PATH, {"level": 2}, @@ -127,7 +126,6 @@ ), ] - 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 diff --git a/tests/utils.py b/tests/utils.py index 49efbe3f99..1f632261dd 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -771,12 +771,10 @@ def command_line_tests(cmd, copy_env=True): # alias for branch tests TEST_NDARRAYS_ALL = TEST_NDARRAYS - TEST_DEVICES = [[torch.device("cpu")]] if torch.cuda.is_available(): TEST_DEVICES.append([torch.device("cuda")]) - if __name__ == "__main__": print("\n", query_memory(), sep="\n") # print to stdout sys.exit(0) From dd49e37ee0f3f60ad8d4d042d7e6840da827b9cf Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Thu, 13 Oct 2022 14:22:37 +0100 Subject: [PATCH 38/60] tutorial 982 984 fixes occ sens, 987 ce loss (#5323) Signed-off-by: Wenqi Li fixes https://github.com/Project-MONAI/tutorials/issues/982 fixes https://github.com/Project-MONAI/tutorials/issues/984 fixes https://github.com/Project-MONAI/tutorials/issues/987 ### Description robust mask size and grid ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- .github/workflows/cron-mmar.yml | 4 ++-- .github/workflows/cron.yml | 14 +++++++------- .github/workflows/docker.yml | 2 +- .github/workflows/integration.yml | 2 +- .github/workflows/pythonapp-gpu.yml | 4 ++-- .github/workflows/pythonapp-min.yml | 18 +++++++++--------- .github/workflows/pythonapp.yml | 22 +++++++++++----------- .github/workflows/release.yml | 6 +++--- .github/workflows/setupapp.yml | 18 +++++++++--------- .github/workflows/weekly-preview.yml | 2 +- monai/losses/dice.py | 3 ++- monai/visualize/occlusion_sensitivity.py | 24 +++++++++++++++--------- tests/test_dice_ce_loss.py | 8 ++++++++ tests/test_occlusion_sensitivity.py | 20 ++++++++++---------- 14 files changed, 81 insertions(+), 66 deletions(-) diff --git a/.github/workflows/cron-mmar.yml b/.github/workflows/cron-mmar.yml index 46bf7ff384..9753747214 100644 --- a/.github/workflows/cron-mmar.yml +++ b/.github/workflows/cron-mmar.yml @@ -18,12 +18,12 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: cache weekly timestamp id: pip-cache - run: echo "::set-output name=datew::$(date '+%Y-%V')" + run: echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 6329ab0ffa..48288fecf7 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -53,10 +53,10 @@ jobs: coverage xml --ignore-errors if pgrep python; then pkill python; fi - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: fail_ci_if_error: false - file: ./coverage.xml + files: ./coverage.xml cron-pt-image: if: github.repository == 'Project-MONAI/MONAI' @@ -96,10 +96,10 @@ jobs: coverage xml --ignore-errors if pgrep python; then pkill python; fi - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: fail_ci_if_error: false - file: ./coverage.xml + files: ./coverage.xml cron-pip: # pip install monai[all] and use it to run unit tests @@ -195,10 +195,10 @@ jobs: coverage xml --ignore-errors if pgrep python; then pkill python; fi - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: fail_ci_if_error: false - file: ./coverage.xml + files: ./coverage.xml cron-tutorial-notebooks: if: github.repository == 'Project-MONAI/MONAI' @@ -219,7 +219,7 @@ jobs: nvidia-smi export CUDA_VISIBLE_DEVICES=$(python -m tests.utils | tail -n 1) echo $CUDA_VISIBLE_DEVICES - echo "::set-output name=devices::$CUDA_VISIBLE_DEVICES" + echo "devices=$CUDA_VISIBLE_DEVICES" >> $GITHUB_OUTPUT - name: Checkout tutorials and install their requirements run: | cd /opt diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ef03e9df4f..316c9484b6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -25,7 +25,7 @@ jobs: ref: dev fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - shell: bash diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3d9a697be0..1cdf708976 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -20,7 +20,7 @@ jobs: - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 39cb182e7d..83175172fc 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -146,6 +146,6 @@ jobs: if pgrep python; then pkill python; fi shell: bash - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: - file: ./coverage.xml + files: ./coverage.xml diff --git a/.github/workflows/pythonapp-min.yml b/.github/workflows/pythonapp-min.yml index 2198bb5c85..ba3432ed70 100644 --- a/.github/workflows/pythonapp-min.yml +++ b/.github/workflows/pythonapp-min.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: Prepare pip wheel @@ -39,8 +39,8 @@ jobs: - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" - echo "::set-output name=dir::$(pip cache dir)" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT shell: bash - name: cache for pip uses: actions/cache@v3 @@ -79,7 +79,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Prepare pip wheel @@ -89,8 +89,8 @@ jobs: - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" - echo "::set-output name=dir::$(pip cache dir)" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT shell: bash - name: cache for pip uses: actions/cache@v3 @@ -124,7 +124,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: Prepare pip wheel @@ -134,8 +134,8 @@ jobs: - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" - echo "::set-output name=dir::$(pip cache dir)" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT shell: bash - name: cache for pip uses: actions/cache@v3 diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index babd0e0e7d..484026626f 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -24,13 +24,13 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache @@ -65,7 +65,7 @@ jobs: disk-root: "D:" - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: Prepare pip wheel @@ -75,8 +75,8 @@ jobs: - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" - echo "::set-output name=dir::$(pip cache dir)" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT shell: bash - name: cache for pip uses: actions/cache@v3 @@ -117,13 +117,13 @@ jobs: with: fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache @@ -150,9 +150,9 @@ jobs: python setup.py check -m -s python setup.py sdist bdist_wheel python -m twine check dist/* - - run: echo "::set-output name=pwd::$PWD" + - run: echo "pwd=$PWD" >> $GITHUB_OUTPUT id: root - - run: echo "::set-output name=tmp_dir::$(mktemp -d)" + - run: echo "tmp_dir=$(mktemp -d)" >> $GITHUB_OUTPUT id: mktemp - name: Move packages run: | @@ -198,13 +198,13 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c79fb0a496..bb350ceb2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install setuptools @@ -96,7 +96,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - shell: bash @@ -127,7 +127,7 @@ jobs: name: _version.py - name: Set tag id: versioning - run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - name: Check tag env: RELEASE_VERSION: ${{ steps.versioning.outputs.tag }} diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 3aa4c606b3..71c59457fb 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -29,7 +29,7 @@ jobs: - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache @@ -65,10 +65,10 @@ jobs: if pgrep python; then pkill python; fi shell: bash - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: fail_ci_if_error: false - file: ./coverage.xml + files: ./coverage.xml test-py3x: runs-on: ubuntu-latest @@ -80,13 +80,13 @@ jobs: with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache @@ -107,22 +107,22 @@ jobs: BUILD_MONAI=1 ./runtests.sh --build --quick --unittests --disttests coverage xml --ignore-errors - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: fail_ci_if_error: false - file: ./coverage.xml + files: ./coverage.xml install: # pip install from github url, the default branch is dev runs-on: ubuntu-latest steps: - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: cache weekly timestamp id: pip-cache run: | - echo "::set-output name=datew::$(date '+%Y-%V')" + echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip uses: actions/cache@v3 id: cache diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index 2ed2aeb8b1..173dd1211a 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -14,7 +14,7 @@ jobs: ref: dev fetch-depth: 0 - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: 3.8 - name: Install setuptools diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 0e53e099bf..7de4efd664 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -711,7 +711,8 @@ def ce(self, input: torch.Tensor, target: torch.Tensor): "Using argmax (as a workaround) to convert target to a single channel." ) target = torch.argmax(target, dim=1) - + else: # target has the same shape as input, class probabilities in [0, 1], as floats + target = target.to(input) # check its values are in [0, 1]?? return self.cross_entropy(input, target) def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: diff --git a/monai/visualize/occlusion_sensitivity.py b/monai/visualize/occlusion_sensitivity.py index cfc2d2b675..03c69f8978 100644 --- a/monai/visualize/occlusion_sensitivity.py +++ b/monai/visualize/occlusion_sensitivity.py @@ -53,18 +53,19 @@ class OcclusionSensitivity: # densenet 2d from monai.networks.nets import DenseNet121 from monai.visualize import OcclusionSensitivity + import torch model_2d = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) occ_sens = OcclusionSensitivity(nn_module=model_2d) - occ_map, most_probable_class = occ_sens(x=torch.rand((1, 1, 48, 64)), b_box=[-1, -1, 2, 40, 1, 62]) + occ_map, most_probable_class = occ_sens(x=torch.rand((1, 1, 48, 64)), b_box=[2, 40, 1, 62]) # densenet 3d from monai.networks.nets import DenseNet from monai.visualize import OcclusionSensitivity model_3d = DenseNet(spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,)) - occ_sens = OcclusionSensitivity(nn_module=model_3d, n_batch=10, stride=3) - occ_map, most_probable_class = occ_sens(torch.rand(1, 1, 6, 6, 6), b_box=[-1, -1, 1, 3, -1, -1, -1, -1]) + occ_sens = OcclusionSensitivity(nn_module=model_3d, n_batch=10) + occ_map, most_probable_class = occ_sens(torch.rand(1, 1, 6, 6, 6), b_box=[1, 3, -1, -1, -1, -1]) See Also: @@ -131,7 +132,7 @@ def __init__( self.mode = mode @staticmethod - def constant_occlusion(x: torch.Tensor, val: float, mask_size: tuple) -> Tuple[float, torch.Tensor]: + def constant_occlusion(x: torch.Tensor, val: float, mask_size: Sequence) -> Tuple[float, torch.Tensor]: """Occlude with a constant occlusion. Multiplicative is zero, additive is constant value.""" ones = torch.ones((*x.shape[:2], *mask_size), device=x.device, dtype=x.dtype) return 0, ones * val @@ -236,7 +237,9 @@ def predictor( return out @staticmethod - def crop_meshgrid(grid: MetaTensor, b_box: Sequence, mask_size: Sequence) -> Tuple[MetaTensor, SpatialCrop]: + def crop_meshgrid( + grid: MetaTensor, b_box: Sequence, mask_size: Sequence + ) -> Tuple[MetaTensor, SpatialCrop, Sequence]: """Crop the meshgrid so we only perform occlusion sensitivity on a subsection of the image.""" # distance from center of mask to edge is -1 // 2. mask_edge = [(m - 1) // 2 for m in mask_size] @@ -255,7 +258,10 @@ def crop_meshgrid(grid: MetaTensor, b_box: Sequence, mask_size: Sequence) -> Tup slices = [slice(s, e) for s, e in zip(bbox_min, bbox_max)] cropper = SpatialCrop(roi_slices=slices) cropped: MetaTensor = cropper(grid[0])[None] # type: ignore - return cropped, cropper + mask_size = list(mask_size) + for i, s in enumerate(cropped.shape[2:]): + mask_size[i] = min(s, mask_size[i]) + return cropped, cropper, mask_size def __call__( self, x: torch.Tensor, b_box: Optional[Sequence] = None, **kwargs @@ -288,7 +294,7 @@ def __call__( raise ValueError("Expected batch size of 1.") sd = x.ndim - 2 - mask_size = ensure_tuple_rep(self.mask_size, sd) + mask_size: Sequence = ensure_tuple_rep(self.mask_size, sd) # get the meshgrid (so that sliding_window_inference can tell us which bit to occlude) grid: MetaTensor = MetaTensor( @@ -298,11 +304,11 @@ def __call__( ) # if bounding box given, crop the grid to only infer subsections of the image if b_box is not None: - grid, cropper = self.crop_meshgrid(grid, b_box, mask_size) + grid, cropper, mask_size = self.crop_meshgrid(grid, b_box, mask_size) # check that the grid is bigger than the mask size if any(m > g for g, m in zip(grid.shape[2:], mask_size)): - raise ValueError("Image (after cropping with bounding box) should be bigger than mask.") + raise ValueError(f"Image (spatial shape) {grid.shape[2:]} should be bigger than mask {mask_size}.") # get additive and multiplicative factors if they are unchanged for all patches (i.e., not mean_patch) add: Optional[Union[float, torch.Tensor]] diff --git a/tests/test_dice_ce_loss.py b/tests/test_dice_ce_loss.py index 83ad5b8d9a..1f43dd8c9a 100644 --- a/tests/test_dice_ce_loss.py +++ b/tests/test_dice_ce_loss.py @@ -35,6 +35,14 @@ }, 0.3133, ], + [ # shape: (2, 2, 3), (2, 2, 3), one-hot target + {"to_onehot_y": False}, + { + "input": torch.tensor([[[1.0, 1.0, 0.0], [0.0, 0.0, 1.0]], [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]]]), + "target": torch.tensor([[[1, 1, 0], [0, 0, 1]], [[1, 0, 1], [0, 1, 0]]], dtype=torch.uint8), + }, + 0.3133, + ], [ # shape: (2, 2, 3), (2, 1, 3) {"include_background": False, "to_onehot_y": True, "ce_weight": torch.tensor([1.0, 1.0])}, { diff --git a/tests/test_occlusion_sensitivity.py b/tests/test_occlusion_sensitivity.py index 5239e120e5..02e34704f1 100644 --- a/tests/test_occlusion_sensitivity.py +++ b/tests/test_occlusion_sensitivity.py @@ -62,6 +62,14 @@ def __call__(self, x, adjoint_info): (1, 1, 41, 32, 16), ] ) +TESTS.append( + [ + {"nn_module": model_3d, "n_batch": 10}, + {"x": torch.rand(1, 1, 6, 7, 8).to(device), "b_box": [1, 3, -1, -1, -1, -1]}, + (1, out_channels_3d, 2, 7, 8), + (1, 1, 2, 7, 8), + ] +) TESTS.append( [ {"nn_module": model_2d_2c}, @@ -81,19 +89,11 @@ def __call__(self, x, adjoint_info): ) # 2D should fail: bbox makes image too small TESTS_FAIL.append( - [ - {"nn_module": model_2d, "n_batch": 10, "mask_size": 15}, - {"x": torch.rand(1, 1, 48, 64).to(device), "b_box": [2, 3, -1, -1]}, - ValueError, - ] + [{"nn_module": model_2d, "n_batch": 10, "mask_size": 200}, {"x": torch.rand(1, 1, 48, 64).to(device)}, ValueError] ) # 2D should fail: batch > 1 TESTS_FAIL.append( - [ - {"nn_module": model_2d, "n_batch": 10}, - {"x": torch.rand(2, 1, 48, 64).to(device), "b_box": [2, 3, -1, -1]}, - ValueError, - ] + [{"nn_module": model_2d, "n_batch": 10, "mask_size": 100}, {"x": torch.rand(2, 1, 48, 64).to(device)}, ValueError] ) # 2D should fail: unknown mode TESTS_FAIL.append( From 2b455e334ded4b26448d029d4979449369481467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C5=BEenan=20Zuki=C4=87?= Date: Thu, 13 Oct 2022 15:21:10 -0400 Subject: [PATCH 39/60] Fix installation verification command (#5328) Current command did not work on my machine: ```batch M:\Dev\MONAI>python -c 'import monai; monai.config.print_config()' File "", line 1 'import ^ SyntaxError: EOL while scanning string literal M:\Dev\MONAI>python -c "import monai; monai.config.print_config()" MONAI version: 1.0.0+39.g9355f1c2 Numpy version: 1.23.4 Pytorch version: 1.12.1+cu113 MONAI flags: HAS_EXT = False, USE_COMPILED = False, USE_META_DICT = False MONAI rev id: 9355f1c264c7e69e28b005b8e8eb65c26e0c4ab0 MONAI __file__: M:\Dev\MONAI\monai\__init__.py Optional dependencies: Pytorch Ignite version: NOT INSTALLED or UNKNOWN VERSION. Nibabel version: NOT INSTALLED or UNKNOWN VERSION. scikit-image version: NOT INSTALLED or UNKNOWN VERSION. Pillow version: 9.2.0 Tensorboard version: NOT INSTALLED or UNKNOWN VERSION. gdown version: NOT INSTALLED or UNKNOWN VERSION. TorchVision version: 0.13.1+cu113 tqdm version: NOT INSTALLED or UNKNOWN VERSION. lmdb version: NOT INSTALLED or UNKNOWN VERSION. psutil version: NOT INSTALLED or UNKNOWN VERSION. pandas version: NOT INSTALLED or UNKNOWN VERSION. einops version: NOT INSTALLED or UNKNOWN VERSION. transformers version: NOT INSTALLED or UNKNOWN VERSION. mlflow version: NOT INSTALLED or UNKNOWN VERSION. pynrrd version: NOT INSTALLED or UNKNOWN VERSION. For details about installing the optional dependencies, please visit: https://docs.monai.io/en/latest/installation.html#installing-the-recommended-dependencies M:\Dev\MONAI> ``` --- docs/source/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/installation.md b/docs/source/installation.md index 725ae2dee1..ca5ed9a938 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -139,7 +139,7 @@ and the codebase is ready to use (without the additional features of MONAI C++/C ## Validating the install You can verify the installation by: ```bash -python -c 'import monai; monai.config.print_config()' +python -c "import monai; monai.config.print_config()" ``` If the installation is successful, this command will print out the MONAI version information, and this confirms the core modules of MONAI are ready-to-use. From 258f3ca8c87086e5d19ecc8b6ff5fbf87a130f5c Mon Sep 17 00:00:00 2001 From: myron Date: Thu, 13 Oct 2022 13:03:31 -0700 Subject: [PATCH 40/60] Segresnet improvements (#5281) This adds improvements to SegResNet semantic segmentation to include deep supervision feature (among other things). I've decided to add a new network named SegResnetDS instead of replacing the existing SegResNet, because it would have broken all previously pretrained model checkpoints. Please take a look, and if it looks good, I can add unit tests. All the helper functions, and blocks are inside of the same file (which I prefer, since it's easier to read) instead of spreading it among other files (e.g segresnet_ds_block or segresnet_utils). ### Description A few sentences describing the changes proposed in this pull request. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- monai/networks/nets/__init__.py | 1 + monai/networks/nets/segresnet_ds.py | 430 ++++++++++++++++++++++++++++ tests/test_segresnet_ds.py | 132 +++++++++ 3 files changed, 563 insertions(+) create mode 100644 monai/networks/nets/segresnet_ds.py create mode 100644 tests/test_segresnet_ds.py diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index b2a21cd88b..e6301390bb 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -52,6 +52,7 @@ from .regunet import GlobalNet, LocalNet, RegUNet from .resnet import ResNet, resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200 from .segresnet import SegResNet, SegResNetVAE +from .segresnet_ds import SegResNetDS from .senet import ( SENet, SEnet, diff --git a/monai/networks/nets/segresnet_ds.py b/monai/networks/nets/segresnet_ds.py new file mode 100644 index 0000000000..a440e28ba7 --- /dev/null +++ b/monai/networks/nets/segresnet_ds.py @@ -0,0 +1,430 @@ +# 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, List, Optional, Tuple, Union + +import numpy as np +import torch +import torch.nn as nn + +from monai.networks.blocks.upsample import UpSample +from monai.networks.layers.factories import Act, Conv, Norm, split_args +from monai.networks.layers.utils import get_act_layer, get_norm_layer +from monai.utils import UpsampleMode, has_option + +__all__ = ["SegResNetDS"] + + +def scales_for_resolution(resolution: Union[Tuple, List], n_stages: Optional[int] = None): + """ + A helper function to compute a schedule of scale at different downsampling levels, + given the input resolution. + + .. code-block:: python + + scales_for_resolution(resolution=[1,1,5], n_stages=5) + + Args: + resolution: input image resolution (in mm) + n_stages: optionally the number of stages of the network + """ + + ndim = len(resolution) + res = np.array(resolution) + if not all(res > 0): + raise ValueError("Resolution must be positive") + + nl = np.floor(np.log2(np.max(res) / res)).astype(np.int32) + scales = [tuple(np.where(2**i >= 2**nl, 1, 2)) for i in range(max(nl))] + if n_stages and n_stages > max(nl): + scales = scales + [(2,) * ndim] * (n_stages - max(nl)) + else: + scales = scales[:n_stages] + return scales + + +def aniso_kernel(scale: Union[Tuple, List]): + """ + A helper function to compute kernel_size, padding and stride for the given scale + + Args: + scale: scale from a current scale level + """ + kernel_size = [3 if scale[k] > 1 else 1 for k in range(len(scale))] + padding = [k // 2 for k in kernel_size] + return kernel_size, padding, scale + + +class SegResBlock(nn.Module): + """ + Residual network block used SegResNet based on `3D MRI brain tumor segmentation using autoencoder regularization + `_. + """ + + def __init__( + self, + spatial_dims: int, + in_channels: int, + norm: Union[Tuple, str], + kernel_size: Union[Tuple, int] = 3, + act: Union[Tuple, str] = "relu", + ) -> None: + """ + Args: + spatial_dims: number of spatial dimensions, could be 1, 2 or 3. + in_channels: number of input channels. + norm: feature normalization type and arguments. + kernel_size: convolution kernel size. Defaults to 3. + act: activation type and arguments. Defaults to ``RELU``. + """ + super().__init__() + + if isinstance(kernel_size, (tuple, list)): + padding = tuple(k // 2 for k in kernel_size) + else: + padding = kernel_size // 2 # type: ignore + + self.norm1 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=in_channels) + self.act1 = get_act_layer(act) + self.conv1 = Conv[Conv.CONV, spatial_dims]( + in_channels=in_channels, + out_channels=in_channels, + kernel_size=kernel_size, + stride=1, + padding=padding, + bias=False, + ) + + self.norm2 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=in_channels) + self.act2 = get_act_layer(act) + self.conv2 = Conv[Conv.CONV, spatial_dims]( + in_channels=in_channels, + out_channels=in_channels, + kernel_size=kernel_size, + stride=1, + padding=padding, + bias=False, + ) + + def forward(self, x): + identity = x + x = self.conv1(self.act1(self.norm1(x))) + x = self.conv2(self.act2(self.norm2(x))) + x += identity + return x + + +class SegResEncoder(nn.Module): + """ + SegResEncoder based on the econder structure in `3D MRI brain tumor segmentation using autoencoder regularization + `_. + + Args: + spatial_dims: spatial dimension of the input data. Defaults to 3. + init_filters: number of output channels for initial convolution layer. Defaults to 32. + in_channels: number of input channels for the network. Defaults to 1. + out_channels: number of output channels for the network. Defaults to 2. + act: activation type and arguments. Defaults to ``RELU``. + norm: feature normalization type and arguments. Defaults to ``BATCH``. + blocks_down: number of downsample blocks in each layer. Defaults to ``[1,2,2,4]``. + head_module: optional callable module to apply to the final features. + anisotropic_scales: optional list of scale for each scale level. + """ + + def __init__( + self, + spatial_dims: int = 3, + init_filters: int = 32, + in_channels: int = 1, + act: Union[Tuple, str] = "relu", + norm: Union[Tuple, str] = "batch", + blocks_down: tuple = (1, 2, 2, 4), + head_module: Optional[nn.Module] = None, + anisotropic_scales: Optional[Tuple] = None, + ): + + super().__init__() + + if spatial_dims not in (1, 2, 3): + raise ValueError("`spatial_dims` can only be 1, 2 or 3.") + + # ensure normalization has affine trainable parameters (if not specified) + norm = split_args(norm) + if has_option(Norm[norm[0], spatial_dims], "affine"): + norm[1].setdefault("affine", True) # type: ignore + + # ensure activation is inplace (if not specified) + act = split_args(act) + if has_option(Act[act[0]], "inplace"): + act[1].setdefault("inplace", True) # type: ignore + + filters = init_filters # base number of features + + kernel_size, padding, _ = aniso_kernel(anisotropic_scales[0]) if anisotropic_scales else (3, 1, 1) + self.conv_init = Conv[Conv.CONV, spatial_dims]( + in_channels=in_channels, + out_channels=filters, + kernel_size=kernel_size, + padding=padding, + stride=1, + bias=False, + ) + self.layers = nn.ModuleList() + + for i in range(len(blocks_down)): + level = nn.ModuleDict() + + kernel_size, padding, stride = aniso_kernel(anisotropic_scales[i]) if anisotropic_scales else (3, 1, 2) + blocks = [ + SegResBlock(spatial_dims=spatial_dims, in_channels=filters, kernel_size=kernel_size, norm=norm, act=act) + for _ in range(blocks_down[i]) + ] + level["blocks"] = nn.Sequential(*blocks) + + if i < len(blocks_down) - 1: + level["downsample"] = Conv[Conv.CONV, spatial_dims]( + in_channels=filters, + out_channels=2 * filters, + bias=False, + kernel_size=kernel_size, + stride=stride, + padding=padding, + ) + else: + level["downsample"] = nn.Identity() + + self.layers.append(level) + filters *= 2 + + self.head_module = head_module + self.in_channels = in_channels + self.blocks_down = blocks_down + self.init_filters = init_filters + self.norm = norm + self.act = act + self.spatial_dims = spatial_dims + + def _forward(self, x: torch.Tensor) -> List[torch.Tensor]: + + outputs = [] + x = self.conv_init(x) + + for level in self.layers: + x = level["blocks"](x) + outputs.append(x) + x = level["downsample"](x) + + if self.head_module is not None: + outputs = self.head_module(outputs) + + return outputs + + def forward(self, x: torch.Tensor) -> List[torch.Tensor]: + return self._forward(x) + + +class SegResNetDS(nn.Module): + """ + SegResNetDS based on `3D MRI brain tumor segmentation using autoencoder regularization + `_. + It is similar to https://docs.monai.io/en/stable/networks.html#segresnet, with several + improvements including deep supervision and non-isotropic kernel support. + + Args: + spatial_dims: spatial dimension of the input data. Defaults to 3. + init_filters: number of output channels for initial convolution layer. Defaults to 32. + in_channels: number of input channels for the network. Defaults to 1. + out_channels: number of output channels for the network. Defaults to 2. + act: activation type and arguments. Defaults to ``RELU``. + norm: feature normalization type and arguments. Defaults to ``BATCH``. + blocks_down: number of downsample blocks in each layer. Defaults to ``[1,2,2,4]``. + blocks_up: number of upsample blocks (optional). + dsdepth: number of levels for deep supervision. This will be the length of the list of outputs at each scale level. + At dsdepth==1,only a single output is returned. + preprocess: optional callable function to apply before the model's forward pass + resolution: optional input image resolution. When provided, the nework will first use non-isotropic kernels to bring + image spacing into an approximately isotropic space. + Otherwise, by default, the kernel size and downsampling is always isotropic. + + """ + + def __init__( + self, + spatial_dims: int = 3, + init_filters: int = 32, + in_channels: int = 1, + out_channels: int = 2, + act: Union[Tuple, str] = "relu", + norm: Union[Tuple, str] = "batch", + blocks_down: tuple = (1, 2, 2, 4), + blocks_up: Optional[Tuple] = None, + dsdepth: int = 1, + preprocess: Optional[Union[nn.Module, Callable]] = None, + upsample_mode: Union[UpsampleMode, str] = "deconv", + resolution: Optional[Tuple] = None, + ): + + super().__init__() + + if spatial_dims not in (1, 2, 3): + raise ValueError("`spatial_dims` can only be 1, 2 or 3.") + + self.spatial_dims = spatial_dims + self.init_filters = init_filters + self.in_channels = in_channels + self.out_channels = out_channels + self.act = act + self.norm = norm + self.blocks_down = blocks_down + self.dsdepth = max(dsdepth, 1) + self.resolution = resolution + self.preprocess = preprocess + + if resolution is not None: + if not isinstance(resolution, (list, tuple)): + raise TypeError("resolution must be a tuple") + elif not all(r > 0 for r in resolution): + raise ValueError("resolution must be positive") + + # ensure normalization had affine trainable parameters (if not specified) + norm = split_args(norm) + if has_option(Norm[norm[0], spatial_dims], "affine"): + norm[1].setdefault("affine", True) # type: ignore + + # ensure activation is inplace (if not specified) + act = split_args(act) + if has_option(Act[act[0]], "inplace"): + act[1].setdefault("inplace", True) # type: ignore + + anisotropic_scales = None + if resolution: + anisotropic_scales = scales_for_resolution(resolution, n_stages=len(blocks_down)) + self.anisotropic_scales = anisotropic_scales + + self.encoder = SegResEncoder( + spatial_dims=spatial_dims, + init_filters=init_filters, + in_channels=in_channels, + act=act, + norm=norm, + blocks_down=blocks_down, + anisotropic_scales=anisotropic_scales, + ) + + n_up = len(blocks_down) - 1 + if blocks_up is None: + blocks_up = (1,) * n_up # assume 1 upsample block per level + self.blocks_up = blocks_up + + filters = init_filters * 2**n_up + self.up_layers = nn.ModuleList() + + for i in range(n_up): + + filters = filters // 2 + kernel_size, _, stride = ( + aniso_kernel(anisotropic_scales[len(blocks_up) - i - 1]) if anisotropic_scales else (3, 1, 2) + ) + + level = nn.ModuleDict() + level["upsample"] = UpSample( + mode=upsample_mode, + spatial_dims=spatial_dims, + in_channels=2 * filters, + out_channels=filters, + kernel_size=kernel_size, + scale_factor=stride, + bias=False, + align_corners=False, + ) + blocks = [ + SegResBlock(spatial_dims=spatial_dims, in_channels=filters, kernel_size=kernel_size, norm=norm, act=act) + for _ in range(blocks_up[i]) + ] + level["blocks"] = nn.Sequential(*blocks) + + if len(blocks_up) - i <= dsdepth: # deep supervision heads + level["head"] = Conv[Conv.CONV, spatial_dims]( + in_channels=filters, out_channels=out_channels, kernel_size=1, bias=True + ) + else: + level["head"] = nn.Identity() + + self.up_layers.append(level) + + if n_up == 0: # in a corner case of flat structure (no downsampling), attache a single head + level = nn.ModuleDict( + { + "upsample": nn.Identity(), + "blocks": nn.Identity(), + "head": Conv[Conv.CONV, spatial_dims]( + in_channels=filters, out_channels=out_channels, kernel_size=1, bias=True + ), + } + ) + self.up_layers.append(level) + + def shape_factor(self): + """ + Calculate the factors (divisors) that the input image shape must be divisible by + """ + if self.anisotropic_scales is None: + d = [2 ** (len(self.blocks_down) - 1)] * self.spatial_dims + else: + d = list(np.prod(np.array(self.anisotropic_scales[:-1]), axis=0)) + return d + + def is_valid_shape(self, x): + """ + Calculate if the input shape is divisible by the minimum factors for the current nework configuration + """ + a = [i % j == 0 for i, j in zip(x.shape[2:], self.shape_factor())] + return all(a) + + def _forward(self, x: torch.Tensor) -> Union[torch.Tensor, List[torch.Tensor]]: + + if self.preprocess is not None: + x = self.preprocess(x) + + if not self.is_valid_shape(x): + raise ValueError(f"Input spatial dims {x.shape} must be divisible by {self.shape_factor()}") + + x_down = self.encoder(x) + + x_down.reverse() + x = x_down.pop(0) + + if len(x_down) == 0: + x_down = [torch.zeros(1, device=x.device, dtype=x.dtype)] + + outputs: List[torch.Tensor] = [] + + i = 0 + for level in self.up_layers: + x = level["upsample"](x) + x = x + x_down[i] + x = level["blocks"](x) + + if len(self.up_layers) - i <= self.dsdepth: + outputs.append(level["head"](x)) + i = i + 1 + + outputs.reverse() + + # in eval() mode, always return a single final output + if not self.training or len(outputs) == 1: + return outputs[0] + + # return a list of DS outputs + return outputs + + def forward(self, x: torch.Tensor) -> Union[torch.Tensor, List[torch.Tensor]]: + return self._forward(x) diff --git a/tests/test_segresnet_ds.py b/tests/test_segresnet_ds.py new file mode 100644 index 0000000000..b9a5d873dc --- /dev/null +++ b/tests/test_segresnet_ds.py @@ -0,0 +1,132 @@ +# 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 SegResNetDS +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save + +device = "cuda" if torch.cuda.is_available() else "cpu" +TEST_CASE_SEGRESNET_DS = [] +for spatial_dims in range(2, 4): + for init_filters in [8, 16]: + for act in ["relu", "leakyrelu"]: + for norm in ["BATCH", ("instance", {"affine": True})]: + for upsample_mode in ["deconv", "nontrainable"]: + test_case = [ + { + "spatial_dims": spatial_dims, + "init_filters": init_filters, + "act": act, + "norm": norm, + "upsample_mode": upsample_mode, + }, + (2, 1, *([16] * spatial_dims)), + (2, 2, *([16] * spatial_dims)), + ] + TEST_CASE_SEGRESNET_DS.append(test_case) + +TEST_CASE_SEGRESNET_DS2 = [] +for spatial_dims in range(2, 4): + for out_channels in [1, 2]: + for dsdepth in [1, 2, 3]: + test_case = [ + {"spatial_dims": spatial_dims, "init_filters": 8, "out_channels": out_channels, "dsdepth": dsdepth}, + (2, 1, *([16] * spatial_dims)), + (2, out_channels, *([16] * spatial_dims)), + ] + TEST_CASE_SEGRESNET_DS2.append(test_case) + +TEST_CASE_SEGRESNET_DS3 = [ + ({"init_filters": 8, "dsdepth": 2, "resolution": None}, (2, 1, 16, 16, 16), ((2, 2, 16, 16, 16), (2, 2, 8, 8, 8))), + ( + {"init_filters": 8, "dsdepth": 3, "resolution": None}, + (2, 1, 16, 16, 16), + ((2, 2, 16, 16, 16), (2, 2, 8, 8, 8), (2, 2, 4, 4, 4)), + ), + ( + {"init_filters": 8, "dsdepth": 3, "resolution": [1, 1, 5]}, + (2, 1, 16, 16, 16), + ((2, 2, 16, 16, 16), (2, 2, 8, 8, 16), (2, 2, 4, 4, 16)), + ), + ( + {"init_filters": 8, "dsdepth": 3, "resolution": [1, 2, 5]}, + (2, 1, 16, 16, 16), + ((2, 2, 16, 16, 16), (2, 2, 8, 8, 16), (2, 2, 4, 8, 16)), + ), +] + + +class TestResNetDS(unittest.TestCase): + @parameterized.expand(TEST_CASE_SEGRESNET_DS) + def test_shape(self, input_param, input_shape, expected_shape): + net = SegResNetDS(**input_param).to(device) + with eval_mode(net): + result = net(torch.randn(input_shape).to(device)) + self.assertEqual(result.shape, expected_shape, msg=str(input_param)) + + @parameterized.expand(TEST_CASE_SEGRESNET_DS2) + def test_shape2(self, input_param, input_shape, expected_shape): + + dsdepth = input_param.get("dsdepth", 1) + net = SegResNetDS(**input_param).to(device) + + net.train() + result = net(torch.randn(input_shape).to(device)) + if dsdepth > 1: + assert isinstance(result, list) + self.assertEqual(dsdepth, len(result)) + for i in range(dsdepth): + self.assertEqual( + result[i].shape, + expected_shape[:2] + tuple(e // (2**i) for e in expected_shape[2:]), + msg=str(input_param), + ) + else: + assert isinstance(result, torch.Tensor) + self.assertEqual(result.shape, expected_shape, msg=str(input_param)) + + net.eval() + result = net(torch.randn(input_shape).to(device)) + assert isinstance(result, torch.Tensor) + self.assertEqual(result.shape, expected_shape, msg=str(input_param)) + + @parameterized.expand(TEST_CASE_SEGRESNET_DS3) + def test_shape3(self, input_param, input_shape, expected_shapes): + + dsdepth = input_param.get("dsdepth", 1) + net = SegResNetDS(**input_param).to(device) + + net.train() + result = net(torch.randn(input_shape).to(device)) + assert isinstance(result, list) + self.assertEqual(dsdepth, len(result)) + for i in range(dsdepth): + self.assertEqual(result[i].shape, expected_shapes[i], msg=str(input_param)) + + def test_ill_arg(self): + with self.assertRaises(ValueError): + SegResNetDS(spatial_dims=4) + + @SkipIfBeforePyTorchVersion((1, 10)) + def test_script(self): + input_param, input_shape, _ = TEST_CASE_SEGRESNET_DS[0] + net = SegResNetDS(**input_param) + test_data = torch.randn(input_shape) + test_script_save(net, test_data) + + +if __name__ == "__main__": + unittest.main() From 9d43d19c864c80a063959635441052d65d6f63ab Mon Sep 17 00:00:00 2001 From: myron Date: Thu, 13 Oct 2022 15:06:15 -0700 Subject: [PATCH 41/60] SlidingWindowInferer: option to adaptively stitch in cpu memory for large images (#5297) SlidingWindowInferer: option to adaptively stitch in cpu memory for large images. This adds an option to provide maximum input image volume (number of elements) to dynamically change stitching to cpu memory (to avoid gpu memory crashes). For example with `cpu_thresh=400*400*400`, all input images with large volume will be stitched on cpu. At the moment, a user must decide beforehand, to stitch ALL images on cpu or gpu (by specifying the 'device' parameter). But in many datasets, only a few large images require device==cpu, and running inference on cpu for ALL will be unnecessary slow. It's related to https://github.com/Project-MONAI/MONAI/discussions/4625 https://github.com/Project-MONAI/MONAI/discussions/4495 https://github.com/Project-MONAI/MONAI/discussions/3497 https://github.com/Project-MONAI/MONAI/discussions/4726 https://github.com/Project-MONAI/MONAI/discussions/4588 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron Co-authored-by: Wenqi Li <831580+wyli@users.noreply.github.com> --- monai/inferers/inferer.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/monai/inferers/inferer.py b/monai/inferers/inferer.py index 084b1021c2..1c4b6b3db2 100644 --- a/monai/inferers/inferer.py +++ b/monai/inferers/inferer.py @@ -122,6 +122,9 @@ class SlidingWindowInferer(Inferer): `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. + cpu_thresh: when provided, dynamically switch to stitching on cpu (to save gpu memory) + when input image volume is larger than this threshold (in pixels/volxels). + Otherwise use ``"device"``. Thus, the output may end-up on either cpu or gpu. Note: ``sw_batch_size`` denotes the max number of windows per network inference iteration, @@ -142,8 +145,9 @@ def __init__( device: Union[torch.device, str, None] = None, progress: bool = False, cache_roi_weight_map: bool = False, + cpu_thresh: Optional[int] = None, ) -> None: - Inferer.__init__(self) + super().__init__() self.roi_size = roi_size self.sw_batch_size = sw_batch_size self.overlap = overlap @@ -154,6 +158,7 @@ def __init__( self.sw_device = sw_device self.device = device self.progress = progress + self.cpu_thresh = cpu_thresh # 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 @@ -189,6 +194,11 @@ def __call__( kwargs: optional keyword args to be passed to ``network``. """ + + device = self.device + if device is None and self.cpu_thresh is not None and inputs.shape[2:].numel() > self.cpu_thresh: + device = "cpu" # stitch in cpu memory if image is too large + return sliding_window_inference( inputs, self.roi_size, @@ -200,7 +210,7 @@ def __call__( self.padding_mode, self.cval, self.sw_device, - self.device, + device, self.progress, self.roi_weight_map, *args, From fe5a3bf61f62b23d2b1285e80c091b408e268564 Mon Sep 17 00:00:00 2001 From: Maxime-Perret <43537593+Maxime-Perret@users.noreply.github.com> Date: Fri, 14 Oct 2022 08:36:03 +0200 Subject: [PATCH 42/60] Command format fix with backslashes on Windows System (#5280) Fixes MONAI/apps/Auto3DSeg/bundle_gen.py ### Description Commands ran in subprocess currently cause issues with string formatting and backslashes not being escaped properly. Changing from Back Flash to Forward Slash solves the issue. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Maxime Signed-off-by: Mingxin Zheng Signed-off-by: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> Co-authored-by: Maxime Co-authored-by: Mingxin Zheng Co-authored-by: Mingxin Zheng Co-authored-by: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com --- monai/apps/auto3dseg/bundle_gen.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/monai/apps/auto3dseg/bundle_gen.py b/monai/apps/auto3dseg/bundle_gen.py index 06908913f6..6c04b2e7e2 100644 --- a/monai/apps/auto3dseg/bundle_gen.py +++ b/monai/apps/auto3dseg/bundle_gen.py @@ -15,6 +15,7 @@ import subprocess import sys from copy import deepcopy +from pathlib import Path from tempfile import TemporaryDirectory from typing import Any, Dict, List, Mapping @@ -28,7 +29,7 @@ from monai.utils import ensure_tuple logger = get_logger(module_name=__name__) -ALGO_HASH = os.environ.get("MONAI_ALGO_HASH", "004a63c") +ALGO_HASH = os.environ.get("MONAI_ALGO_HASH", "d7bf36c") __all__ = ["BundleAlgo", "BundleGen"] @@ -152,7 +153,8 @@ def _create_cmd(self, train_params=None): base_cmd += f"{train_py} run --config_file=" else: base_cmd += "," # Python Fire does not accept space - config_yaml = os.path.join(config_dir, file) + # Python Fire may be confused by single-quoted WindowsPath + config_yaml = Path(os.path.join(config_dir, file)).as_posix() base_cmd += f"'{config_yaml}'" if "CUDA_VISIBLE_DEVICES" in params: From 7c3e8380f1beadd52dde186950ab8f433a005e1e Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 14 Oct 2022 19:46:03 +0100 Subject: [PATCH 43/60] adding a multigpu test of TestFLMonaiAlgo (#5327) Signed-off-by: Wenqi Li Fixes https://github.com/Project-MONAI/MONAI/issues/5133 ### Description adds a test ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li Signed-off-by: monai-bot Co-authored-by: monai-bot --- .github/workflows/pythonapp-gpu.yml | 7 +- tests/test_auto3dseg_ensemble.py | 9 +- tests/test_auto3dseg_hpo.py | 10 +- tests/test_fl_monai_algo.py | 118 +++++++++------------ tests/test_fl_monai_algo_dist.py | 97 +++++++++++++++++ tests/test_resample_backends.py | 3 +- tests/testing_data/config_fl_evaluate.json | 6 +- tests/testing_data/config_fl_train.json | 6 +- tests/testing_data/multi_gpu_evaluate.json | 27 +++++ tests/testing_data/multi_gpu_train.json | 30 ++++++ tests/utils.py | 18 ++-- 11 files changed, 237 insertions(+), 94 deletions(-) create mode 100644 tests/test_fl_monai_algo_dist.py create mode 100644 tests/testing_data/multi_gpu_evaluate.json create mode 100644 tests/testing_data/multi_gpu_train.json diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 83175172fc..09f0b85009 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -7,6 +7,7 @@ on: - main - releasing/* pull_request: + types: [opened, synchronize, closed] concurrency: # automatically cancel the previously triggered workflows when there's a newer version @@ -63,6 +64,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: apt install + if: github.event.pull_request.merged != true run: | # FIXME: 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 @@ -112,6 +114,7 @@ jobs: name: Optional Cupy dependency (cuda114) run: echo "cupy-cuda114" >> requirements-dev.txt - name: Install dependencies + if: github.event.pull_request.merged != true run: | which python python -m pip install --upgrade pip wheel @@ -137,7 +140,8 @@ jobs: python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' python -c "import monai; monai.config.print_config()" # build for the current self-hosted CI Tesla V100 - BUILD_MONAI=1 TORCH_CUDA_ARCH_LIST="7.0" ./runtests.sh --build --quick --unittests --disttests + BUILD_MONAI=1 TORCH_CUDA_ARCH_LIST="7.0" ./runtests.sh --build --disttests + ./runtests.sh --quick --unittests if [ ${{ matrix.environment }} = "PT110+CUDA102" ]; then # test the clang-format tool downloading once coverage run -m tests.clang_format_utils @@ -146,6 +150,7 @@ jobs: if pgrep python; then pkill python; fi shell: bash - name: Upload coverage + if: github.event.pull_request.merged != true uses: codecov/codecov-action@v3 with: files: ./coverage.xml diff --git a/tests/test_auto3dseg_ensemble.py b/tests/test_auto3dseg_ensemble.py index de56247257..7b9656f1ac 100644 --- a/tests/test_auto3dseg_ensemble.py +++ b/tests/test_auto3dseg_ensemble.py @@ -22,7 +22,7 @@ from monai.data import create_test_image_3d from monai.utils import optional_import from monai.utils.enums import AlgoEnsembleKeys -from tests.utils import SkipIfBeforePyTorchVersion, skip_if_no_cuda, skip_if_quick +from tests.utils import SkipIfBeforePyTorchVersion, skip_if_downloading_fails, skip_if_no_cuda, skip_if_quick _, has_tb = optional_import("torch.utils.tensorboard", name="SummaryWriter") @@ -110,9 +110,10 @@ def test_ensemble(self) -> None: ConfigParser.export_config_file(data_src, data_src_cfg) - bundle_generator = BundleGen( - algo_path=work_dir, data_stats_filename=da_output_yaml, data_src_cfg_name=data_src_cfg - ) + with skip_if_downloading_fails(): + bundle_generator = BundleGen( + algo_path=work_dir, data_stats_filename=da_output_yaml, data_src_cfg_name=data_src_cfg + ) bundle_generator.generate(work_dir, num_fold=2) history = bundle_generator.get_history() diff --git a/tests/test_auto3dseg_hpo.py b/tests/test_auto3dseg_hpo.py index 7cd53d99dc..708828eed4 100644 --- a/tests/test_auto3dseg_hpo.py +++ b/tests/test_auto3dseg_hpo.py @@ -23,7 +23,7 @@ from monai.bundle.config_parser import ConfigParser from monai.data import create_test_image_3d from monai.utils import optional_import -from tests.utils import SkipIfBeforePyTorchVersion, skip_if_no_cuda +from tests.utils import SkipIfBeforePyTorchVersion, skip_if_downloading_fails, skip_if_no_cuda _, has_tb = optional_import("torch.utils.tensorboard", name="SummaryWriter") optuna, has_optuna = optional_import("optuna") @@ -104,10 +104,10 @@ def setUp(self) -> None: } ConfigParser.export_config_file(data_src, data_src_cfg) - - bundle_generator = BundleGen( - algo_path=work_dir, data_stats_filename=da_output_yaml, data_src_cfg_name=data_src_cfg - ) + with skip_if_downloading_fails(): + bundle_generator = BundleGen( + algo_path=work_dir, data_stats_filename=da_output_yaml, data_src_cfg_name=data_src_cfg + ) bundle_generator.generate(work_dir, num_fold=2) self.history = bundle_generator.get_history() diff --git a/tests/test_fl_monai_algo.py b/tests/test_fl_monai_algo.py index 66722d0086..0627235a18 100644 --- a/tests/test_fl_monai_algo.py +++ b/tests/test_fl_monai_algo.py @@ -9,7 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json import os import unittest @@ -21,107 +20,110 @@ from monai.fl.utils.exchange_object import ExchangeObject from tests.utils import SkipIfNoModule +_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__))) +_data_dir = os.path.join(_root_dir, "testing_data") + TEST_TRAIN_1 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), - "config_train_filename": os.path.join("testing_data", "config_fl_train.json"), + "bundle_root": _data_dir, + "config_train_filename": os.path.join(_data_dir, "config_fl_train.json"), "config_evaluate_filename": None, - "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), } ] TEST_TRAIN_2 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), - "config_train_filename": os.path.join("testing_data", "config_fl_train.json"), + "bundle_root": _data_dir, + "config_train_filename": os.path.join(_data_dir, "config_fl_train.json"), "config_evaluate_filename": None, "config_filters_filename": None, } ] TEST_TRAIN_3 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), + "bundle_root": _data_dir, "config_train_filename": [ - os.path.join("testing_data", "config_fl_train.json"), - os.path.join("testing_data", "config_fl_train.json"), + os.path.join(_data_dir, "config_fl_train.json"), + os.path.join(_data_dir, "config_fl_train.json"), ], "config_evaluate_filename": None, "config_filters_filename": [ - os.path.join("testing_data", "config_fl_filters.json"), - os.path.join("testing_data", "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), ], } ] TEST_EVALUATE_1 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), + "bundle_root": _data_dir, "config_train_filename": None, - "config_evaluate_filename": os.path.join("testing_data", "config_fl_evaluate.json"), - "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"), + "config_evaluate_filename": os.path.join(_data_dir, "config_fl_evaluate.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), } ] TEST_EVALUATE_2 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), + "bundle_root": _data_dir, "config_train_filename": None, - "config_evaluate_filename": os.path.join("testing_data", "config_fl_evaluate.json"), + "config_evaluate_filename": os.path.join(_data_dir, "config_fl_evaluate.json"), "config_filters_filename": None, } ] TEST_EVALUATE_3 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), + "bundle_root": _data_dir, "config_train_filename": None, "config_evaluate_filename": [ - os.path.join("testing_data", "config_fl_evaluate.json"), - os.path.join("testing_data", "config_fl_evaluate.json"), + os.path.join(_data_dir, "config_fl_evaluate.json"), + os.path.join(_data_dir, "config_fl_evaluate.json"), ], "config_filters_filename": [ - os.path.join("testing_data", "config_fl_filters.json"), - os.path.join("testing_data", "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), ], } ] TEST_GET_WEIGHTS_1 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), - "config_train_filename": os.path.join("testing_data", "config_fl_train.json"), + "bundle_root": _data_dir, + "config_train_filename": os.path.join(_data_dir, "config_fl_train.json"), "config_evaluate_filename": None, "send_weight_diff": False, - "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), } ] TEST_GET_WEIGHTS_2 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), + "bundle_root": _data_dir, "config_train_filename": None, "config_evaluate_filename": None, "send_weight_diff": False, - "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), } ] TEST_GET_WEIGHTS_3 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), - "config_train_filename": os.path.join("testing_data", "config_fl_train.json"), + "bundle_root": _data_dir, + "config_train_filename": os.path.join(_data_dir, "config_fl_train.json"), "config_evaluate_filename": None, "send_weight_diff": True, - "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), } ] TEST_GET_WEIGHTS_4 = [ { - "bundle_root": os.path.join(os.path.dirname(__file__)), + "bundle_root": _data_dir, "config_train_filename": [ - os.path.join("testing_data", "config_fl_train.json"), - os.path.join("testing_data", "config_fl_train.json"), + os.path.join(_data_dir, "config_fl_train.json"), + os.path.join(_data_dir, "config_fl_train.json"), ], "config_evaluate_filename": None, "send_weight_diff": True, "config_filters_filename": [ - os.path.join("testing_data", "config_fl_filters.json"), - os.path.join("testing_data", "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), ], } ] @@ -133,24 +135,20 @@ class TestFLMonaiAlgo(unittest.TestCase): def test_train(self, input_params): # get testing data dir and update train config; using the first to define data dir if isinstance(input_params["config_train_filename"], list): - config_train_filename = input_params["config_train_filename"][0] + config_train_filename = [ + os.path.join(input_params["bundle_root"], x) for x in input_params["config_train_filename"] + ] else: - config_train_filename = input_params["config_train_filename"] - with open(os.path.join(input_params["bundle_root"], config_train_filename)) as f: - config_train = json.load(f) - - config_train["dataset_dir"] = os.path.join(os.path.dirname(__file__), "testing_data") - - with open(os.path.join(input_params["bundle_root"], config_train_filename), "w") as f: - json.dump(config_train, f, indent=4) + config_train_filename = os.path.join(input_params["bundle_root"], input_params["config_train_filename"]) # initialize algo algo = MonaiAlgo(**input_params) algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"}) + algo.abort() # initialize model parser = ConfigParser() - parser.read_config(os.path.join(input_params["bundle_root"], config_train_filename)) + parser.read_config(config_train_filename) parser.parse() network = parser.get_parsed_content("network") @@ -158,21 +156,17 @@ def test_train(self, input_params): # test train algo.train(data=data, extra={}) + algo.finalize() @parameterized.expand([TEST_EVALUATE_1, TEST_EVALUATE_2, TEST_EVALUATE_3]) def test_evaluate(self, input_params): # get testing data dir and update train config; using the first to define data dir if isinstance(input_params["config_evaluate_filename"], list): - config_eval_filename = input_params["config_evaluate_filename"][0] + config_eval_filename = [ + os.path.join(input_params["bundle_root"], x) for x in input_params["config_evaluate_filename"] + ] else: - config_eval_filename = input_params["config_evaluate_filename"] - with open(os.path.join(input_params["bundle_root"], config_eval_filename)) as f: - config_evaluate = json.load(f) - - config_evaluate["dataset_dir"] = os.path.join(os.path.dirname(__file__), "testing_data") - - with open(os.path.join(input_params["bundle_root"], config_eval_filename), "w") as f: - json.dump(config_evaluate, f, indent=4) + config_eval_filename = os.path.join(input_params["bundle_root"], input_params["config_evaluate_filename"]) # initialize algo algo = MonaiAlgo(**input_params) @@ -180,7 +174,7 @@ def test_evaluate(self, input_params): # initialize model parser = ConfigParser() - parser.read_config(os.path.join(input_params["bundle_root"], config_eval_filename)) + parser.read_config(config_eval_filename) parser.parse() network = parser.get_parsed_content("network") @@ -191,20 +185,6 @@ def test_evaluate(self, input_params): @parameterized.expand([TEST_GET_WEIGHTS_1, TEST_GET_WEIGHTS_2, TEST_GET_WEIGHTS_3, TEST_GET_WEIGHTS_4]) def test_get_weights(self, input_params): - # get testing data dir and update train config; using the first to define data dir - if input_params["config_train_filename"]: - if isinstance(input_params["config_train_filename"], list): - config_train_filename = input_params["config_train_filename"][0] - else: - config_train_filename = input_params["config_train_filename"] - with open(os.path.join(input_params["bundle_root"], config_train_filename)) as f: - config_train = json.load(f) - - config_train["dataset_dir"] = os.path.join(os.path.dirname(__file__), "testing_data") - - with open(os.path.join(input_params["bundle_root"], config_train_filename), "w") as f: - json.dump(config_train, f, indent=4) - # initialize algo algo = MonaiAlgo(**input_params) algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"}) @@ -217,8 +197,6 @@ def test_get_weights(self, input_params): weights = algo.get_weights(extra={}) self.assertIsInstance(weights, ExchangeObject) - # TODO: test abort and finalize - if __name__ == "__main__": unittest.main() diff --git a/tests/test_fl_monai_algo_dist.py b/tests/test_fl_monai_algo_dist.py new file mode 100644 index 0000000000..11f64ea318 --- /dev/null +++ b/tests/test_fl_monai_algo_dist.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 os +import unittest +from os.path import join as pathjoin + +import torch.distributed as dist +from parameterized import parameterized + +from monai.bundle import ConfigParser +from monai.fl.client.monai_algo import MonaiAlgo +from monai.fl.utils.constants import ExtraItems +from monai.fl.utils.exchange_object import ExchangeObject +from tests.utils import DistCall, DistTestCase, SkipIfBeforePyTorchVersion, SkipIfNoModule, skip_if_no_cuda + +_root_dir = os.path.abspath(pathjoin(os.path.dirname(__file__))) +_data_dir = pathjoin(_root_dir, "testing_data") +TEST_TRAIN_1 = [ + { + "bundle_root": _data_dir, + "config_train_filename": [ + pathjoin(_data_dir, "config_fl_train.json"), + pathjoin(_data_dir, "multi_gpu_train.json"), + ], + "config_evaluate_filename": None, + "config_filters_filename": pathjoin(_root_dir, "testing_data", "config_fl_filters.json"), + "multi_gpu": True, + } +] + +TEST_EVALUATE_1 = [ + { + "bundle_root": _data_dir, + "config_train_filename": None, + "config_evaluate_filename": [ + pathjoin(_data_dir, "config_fl_evaluate.json"), + pathjoin(_data_dir, "multi_gpu_evaluate.json"), + ], + "config_filters_filename": pathjoin(_data_dir, "config_fl_filters.json"), + "multi_gpu": True, + } +] + + +@SkipIfNoModule("ignite") +@SkipIfBeforePyTorchVersion((1, 11, 1)) +class TestFLMonaiAlgo(DistTestCase): + @parameterized.expand([TEST_TRAIN_1]) + @DistCall(nnodes=1, nproc_per_node=2, init_method="no_init") + @skip_if_no_cuda + def test_train(self, input_params): + # initialize algo + algo = MonaiAlgo(**input_params) + algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"}) + self.assertTrue(dist.get_rank() in (0, 1)) + + # initialize model + parser = ConfigParser() + parser.read_config([pathjoin(input_params["bundle_root"], x) for x in input_params["config_train_filename"]]) + parser.parse() + network = parser.get_parsed_content("network") + data = ExchangeObject(weights=network.state_dict()) + # test train + algo.train(data=data, extra={}) + + @parameterized.expand([TEST_EVALUATE_1]) + @DistCall(nnodes=1, nproc_per_node=2, init_method="no_init") + @skip_if_no_cuda + def test_evaluate(self, input_params): + # initialize algo + algo = MonaiAlgo(**input_params) + algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"}) + self.assertTrue(dist.get_rank() in (0, 1)) + + # initialize model + parser = ConfigParser() + parser.read_config( + [os.path.join(input_params["bundle_root"], x) for x in input_params["config_evaluate_filename"]] + ) + parser.parse() + network = parser.get_parsed_content("network") + data = ExchangeObject(weights=network.state_dict()) + # test evaluate + algo.evaluate(data=data, extra={}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_resample_backends.py b/tests/test_resample_backends.py index 912a97378c..6d231183a9 100644 --- a/tests/test_resample_backends.py +++ b/tests/test_resample_backends.py @@ -20,7 +20,7 @@ from monai.transforms import Resample from monai.transforms.utils import create_grid from monai.utils import GridSampleMode, GridSamplePadMode, NdimageMode, SplineMode, convert_to_numpy -from tests.utils import assert_allclose, is_tf32_env +from tests.utils import SkipIfBeforePyTorchVersion, assert_allclose, is_tf32_env _rtol = 1e-3 if is_tf32_env() else 1e-4 @@ -40,6 +40,7 @@ TEST_IDENTITY.append([dict(device=device), p_s, interp_s, pad_s, (1, 21, 23, 24)]) +@SkipIfBeforePyTorchVersion((1, 9, 1)) class TestResampleBackends(unittest.TestCase): @parameterized.expand(TEST_IDENTITY) def test_resample_identity(self, input_param, im_type, interp, padding, input_shape): diff --git a/tests/testing_data/config_fl_evaluate.json b/tests/testing_data/config_fl_evaluate.json index 84ab7988f6..113596070a 100644 --- a/tests/testing_data/config_fl_evaluate.json +++ b/tests/testing_data/config_fl_evaluate.json @@ -1,6 +1,6 @@ { "bundle_root": "tests/testing_data", - "dataset_dir": "tests/testing_data", + "dataset_dir": "@bundle_root", "imports": [ "$import os" ], @@ -22,7 +22,7 @@ "image_only": true }, { - "_target_": "AddChanneld", + "_target_": "EnsureChannelFirstD", "keys": [ "image" ] @@ -62,7 +62,7 @@ "dataloader": { "_target_": "DataLoader", "dataset": "@validate#dataset", - "batch_size": 128, + "batch_size": 3, "shuffle": false, "num_workers": 4 }, diff --git a/tests/testing_data/config_fl_train.json b/tests/testing_data/config_fl_train.json index 5954d2cfbc..f53a95bc02 100644 --- a/tests/testing_data/config_fl_train.json +++ b/tests/testing_data/config_fl_train.json @@ -1,6 +1,6 @@ { "bundle_root": "tests/testing_data", - "dataset_dir": "tests/testing_data", + "dataset_dir": "@bundle_root", "imports": [ "$import os" ], @@ -30,7 +30,7 @@ "image_only": true }, { - "_target_": "AddChanneld", + "_target_": "EnsureChannelFirstD", "keys": [ "image" ] @@ -96,7 +96,7 @@ "dataloader": { "_target_": "DataLoader", "dataset": "@train#dataset", - "batch_size": 128, + "batch_size": 3, "shuffle": true, "num_workers": 2 }, diff --git a/tests/testing_data/multi_gpu_evaluate.json b/tests/testing_data/multi_gpu_evaluate.json new file mode 100644 index 0000000000..7af24a6b2e --- /dev/null +++ b/tests/testing_data/multi_gpu_evaluate.json @@ -0,0 +1,27 @@ +{ + "device": "$torch.device(f'cuda:{dist.get_rank()}')", + "network": { + "_target_": "torch.nn.parallel.DistributedDataParallel", + "module": "$@network_def.to(@device)", + "device_ids": [ + "@device" + ] + }, + "validate#sampler": { + "_target_": "DistributedSampler", + "dataset": "@validate#dataset", + "even_divisible": false, + "shuffle": false + }, + "validate#dataloader#sampler": "@validate#sampler", + "evaluating": [ + "$import torch.distributed as dist", + "$dist.init_process_group(backend='nccl')", + "$torch.cuda.set_device(@device)", + "$setattr(torch.backends.cudnn, 'benchmark', True)", + "$import logging", + "$@validate#evaluator.logger.setLevel(logging.WARNING if dist.get_rank() > 0 else logging.INFO)", + "$@validate#evaluator.run()", + "$dist.destroy_process_group()" + ] +} diff --git a/tests/testing_data/multi_gpu_train.json b/tests/testing_data/multi_gpu_train.json new file mode 100644 index 0000000000..41fd7698db --- /dev/null +++ b/tests/testing_data/multi_gpu_train.json @@ -0,0 +1,30 @@ +{ + "device": "$torch.device(f'cuda:{dist.get_rank()}')", + "network": { + "_target_": "torch.nn.parallel.DistributedDataParallel", + "module": "$@network_def.to(@device)", + "device_ids": [ + "@device" + ] + }, + "train#sampler": { + "_target_": "DistributedSampler", + "dataset": "@train#dataset", + "even_divisible": true, + "shuffle": true + }, + "train#dataloader#sampler": "@train#sampler", + "train#dataloader#shuffle": false, + "train#trainer#train_handlers": "$@train#handlers[: -2 if dist.get_rank() > 0 else None]", + "training": [ + "$import torch.distributed as dist", + "$dist.init_process_group(backend='nccl')", + "$torch.cuda.set_device(@device)", + "$monai.utils.set_determinism(seed=123)", + "$setattr(torch.backends.cudnn, 'benchmark', True)", + "$import logging", + "$@train#trainer.logger.setLevel(logging.WARNING if dist.get_rank() > 0 else logging.INFO)", + "$@train#trainer.run()", + "$dist.destroy_process_group()" + ] +} diff --git a/tests/utils.py b/tests/utils.py index 1f632261dd..b16b4b13fb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -137,6 +137,8 @@ def skip_if_downloading_fails(): raise unittest.SkipTest(f"error while downloading: {rt_e}") from rt_e if "md5 check" in str(rt_e): raise unittest.SkipTest(f"error while downloading: {rt_e}") from rt_e + if "account limit" in str(rt_e): # HTTP Error 503: Egress is over the account limit + raise unittest.SkipTest(f"error while downloading: {rt_e}") from rt_e raise rt_e @@ -404,6 +406,7 @@ def __init__( timeout: Timeout for operations executed against the process group. init_method: URL specifying how to initialize the process group. Default is "env://" or "file:///d:/a_temp" (windows) if unspecified. + If ``"no_init"``, the `dist.init_process_group` must be called within the code to be tested. backend: The backend to use. Depending on build-time configurations, valid values include ``mpi``, ``gloo``, and ``nccl``. daemon: the process’s daemon flag. @@ -451,13 +454,14 @@ def run_process(self, func, local_rank, args, kwargs, results): if torch.cuda.is_available(): torch.cuda.set_device(int(local_rank)) # using device ids from CUDA_VISIBILE_DEVICES - dist.init_process_group( - backend=self.backend, - init_method=self.init_method, - timeout=self.timeout, - world_size=int(os.environ["WORLD_SIZE"]), - rank=int(os.environ["RANK"]), - ) + if self.init_method != "no_init": + dist.init_process_group( + backend=self.backend, + init_method=self.init_method, + timeout=self.timeout, + world_size=int(os.environ["WORLD_SIZE"]), + rank=int(os.environ["RANK"]), + ) func(*args, **kwargs) # the primary node lives longer to # avoid _store_based_barrier, RuntimeError: Broken pipe From 50749ececbce9b86e3d2bd8f95bcd6fde50f25ea Mon Sep 17 00:00:00 2001 From: myron Date: Sat, 15 Oct 2022 02:00:55 -0700 Subject: [PATCH 44/60] Dicece check for float target (#5326) Fixes https://github.com/Project-MONAI/tutorials/issues/987 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron --- .github/workflows/pythonapp-gpu.yml | 3 ++- monai/losses/dice.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 09f0b85009..ce7419739b 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -16,7 +16,7 @@ concurrency: jobs: GPU-quick-py3: # GPU with full dependencies - if: github.repository == 'Project-MONAI/MONAI' + if: ${{ github.repository == 'Project-MONAI/MONAI' && github.event.pull_request.merged != true }} strategy: matrix: environment: @@ -124,6 +124,7 @@ jobs: python -m pip install -r requirements-dev.txt python -m pip list - name: Run quick tests (GPU) + if: github.event.pull_request.merged != true run: | git clone --depth 1 \ https://github.com/Project-MONAI/MONAI-extra-test-data.git /MONAI-extra-test-data diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 7de4efd664..869dd05ac2 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -711,8 +711,9 @@ def ce(self, input: torch.Tensor, target: torch.Tensor): "Using argmax (as a workaround) to convert target to a single channel." ) target = torch.argmax(target, dim=1) - else: # target has the same shape as input, class probabilities in [0, 1], as floats - target = target.to(input) # check its values are in [0, 1]?? + elif not torch.is_floating_point(target): + target = target.to(dtype=input.dtype) + return self.cross_entropy(input, target) def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: From 59fb2c3cd15e0c352f06b6b28b4f2377d4478e45 Mon Sep 17 00:00:00 2001 From: Holger Roth <6304754+holgerroth@users.noreply.github.com> Date: Sat, 15 Oct 2022 06:48:04 -0400 Subject: [PATCH 45/60] MonaiAlgo FedStats (#5240) Fixes #5196, #5316. ### Description Add api function that allows FL systems to request summary statistics, i.e. `get_data_stats` in new `ClientStats` class. `MonaiStats` is added to provide implementation based on Monai bundle. Utilizes DataAnalyzer functions from Auto3DSeg. Changes to DataAnalyzer are supposed to not change the behavior. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Holger Roth --- .gitignore | 2 + docs/Makefile | 1 + monai/apps/auto3dseg/data_analyzer.py | 57 +++- monai/auto3dseg/analyzer.py | 203 ++++++++++++- monai/auto3dseg/seg_summarizer.py | 49 +++- monai/fl/client/__init__.py | 4 +- monai/fl/client/client_algo.py | 85 ++++-- monai/fl/client/monai_algo.py | 273 ++++++++++++++++-- monai/fl/utils/constants.py | 13 + monai/utils/enums.py | 2 + tests/test_fl_monai_algo_stats.py | 69 +++++ tests/testing_data/anatomical_label.nii.gz | Bin 0 -> 274 bytes tests/testing_data/config_fl_stats_1.json | 23 ++ tests/testing_data/config_fl_stats_2.json | 39 +++ .../reoriented_anat_moved_label.nii.gz | Bin 0 -> 190 bytes 15 files changed, 748 insertions(+), 72 deletions(-) create mode 100644 tests/test_fl_monai_algo_stats.py create mode 100644 tests/testing_data/anatomical_label.nii.gz create mode 100644 tests/testing_data/config_fl_stats_1.json create mode 100644 tests/testing_data/config_fl_stats_2.json create mode 100644 tests/testing_data/reoriented_anat_moved_label.nii.gz diff --git a/.gitignore b/.gitignore index 5fe164e470..3da001d0ce 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,8 @@ tests/testing_data/*.tiff tests/testing_data/schema.json tests/testing_data/endo.mp4 tests/testing_data/ultrasound.avi +tests/testing_data/train_data_stats.yaml +tests/testing_data/eval_data_stats.yaml # clang format tool .clang-format-bin/ diff --git a/docs/Makefile b/docs/Makefile index d9a870064a..5afe804955 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -20,6 +20,7 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile + PIP_ROOT_USER_ACTION=ignore pip install -r requirements.txt @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) clean: diff --git a/monai/apps/auto3dseg/data_analyzer.py b/monai/apps/auto3dseg/data_analyzer.py index 17d8dcefd5..1eeae86543 100644 --- a/monai/apps/auto3dseg/data_analyzer.py +++ b/monai/apps/auto3dseg/data_analyzer.py @@ -78,6 +78,10 @@ class DataAnalyzer: label_key: a string that user specify for the label. The DataAnalyzer will look it up in the datalist to locate the label files of the dataset. If label_key is None, the DataAnalyzer will skip looking for labels and all label-related operations. + hist_bins: bins to compute histogram for each image channel. + hist_range: ranges to compute histogram for each image channel. + fmt: format used to save the analysis results. Defaults to "yaml". + histogram_only: whether to only compute histograms. Defaults to False. Raises: ValueError if device is GPU and worker > 0. @@ -126,6 +130,10 @@ def __init__( worker: int = 2, image_key: str = "image", label_key: Optional[str] = "label", + hist_bins: Optional[Union[list, int]] = 0, + hist_range: Optional[list] = None, + fmt: Optional[str] = "yaml", + histogram_only: bool = False, ): if path.isfile(output_path): warnings.warn(f"File {output_path} already exists and will be overwritten.") @@ -140,6 +148,10 @@ def __init__( self.worker = 0 if (self.device.type == "cuda") else worker self.image_key = image_key self.label_key = label_key + self.hist_bins = hist_bins + self.hist_range: list = [-500, 500] if hist_range is None else hist_range + self.fmt = fmt + self.histogram_only = histogram_only @staticmethod def _check_data_uniformity(keys: List[str], result: Dict): @@ -162,11 +174,15 @@ def _check_data_uniformity(keys: List[str], result: Dict): return True - def get_all_case_stats(self): + def get_all_case_stats(self, key="training", transform_list=None): """ Get all case stats. Caller of the DataAnalyser class. The function iterates datalist and call get_case_stats to generate stats. Then get_case_summary is called to combine results. + Args: + key: dataset key + transform_list: option list of transforms before SegSummarizer + Returns: A data statistics dictionary containing "stats_summary" (summary statistics of the entire datasets). Within stats_summary @@ -187,25 +203,35 @@ def get_all_case_stats(self): dictionary will include .nan/.inf in the statistics. """ - summarizer = SegSummarizer(self.image_key, self.label_key, average=self.average, do_ccp=self.do_ccp) + summarizer = SegSummarizer( + self.image_key, + self.label_key, + average=self.average, + do_ccp=self.do_ccp, + hist_bins=self.hist_bins, + hist_range=self.hist_range, + histogram_only=self.histogram_only, + ) keys = list(filter(None, [self.image_key, self.label_key])) - transform_list = [ - LoadImaged(keys=keys), - EnsureChannelFirstd(keys=keys), # this creates label to be (1,H,W,D) - Orientationd(keys=keys, axcodes="RAS"), - EnsureTyped(keys=keys, data_type="tensor"), - Lambdad(keys=self.label_key, func=_argmax_if_multichannel) if self.label_key else None, - SqueezeDimd(keys=["label"], dim=0) if self.label_key else None, - ToDeviced(keys=keys, device=self.device), - summarizer, - ] + if transform_list is None: + transform_list = [ + LoadImaged(keys=keys), + EnsureChannelFirstd(keys=keys), # this creates label to be (1,H,W,D) + Orientationd(keys=keys, axcodes="RAS"), + EnsureTyped(keys=keys, data_type="tensor"), + Lambdad(keys=self.label_key, func=_argmax_if_multichannel) if self.label_key else None, + SqueezeDimd(keys=["label"], dim=0) if self.label_key else None, + ToDeviced(keys=keys, device=self.device), + ] + transform_list.append(summarizer) transform = Compose(transforms=list(filter(None, transform_list))) - files, _ = datafold_read(datalist=self.datalist, basedir=self.dataroot, fold=-1) + files, _ = datafold_read(datalist=self.datalist, basedir=self.dataroot, fold=-1, key=key) dataset = Dataset(data=files, transform=transform) dataloader = DataLoader(dataset, batch_size=1, shuffle=False, num_workers=self.worker, collate_fn=no_collation) result = {DataStatsKeys.SUMMARY: {}, DataStatsKeys.BY_CASE: []} + if not has_tqdm: warnings.warn("tqdm is not installed. not displaying the caching progress.") @@ -216,6 +242,8 @@ def get_all_case_stats(self): DataStatsKeys.BY_CASE_LABEL_PATH: d[DataStatsKeys.BY_CASE_LABEL_PATH], DataStatsKeys.IMAGE_STATS: d[DataStatsKeys.IMAGE_STATS], } + if self.hist_bins != 0: + stats_by_cases.update({DataStatsKeys.IMAGE_HISTOGRAM: d[DataStatsKeys.IMAGE_HISTOGRAM]}) if self.label_key is not None: stats_by_cases.update( @@ -231,7 +259,8 @@ def get_all_case_stats(self): if not self._check_data_uniformity([ImageStatsKeys.SPACING], result): logger.warning("data spacing is not completely uniform. MONAI transforms may provide unexpected result") - ConfigParser.export_config_file(result, self.output_path, fmt="yaml", default_flow_style=None) + if self.output_path: + ConfigParser.export_config_file(result, self.output_path, fmt=self.fmt, default_flow_style=None) del d["image"], d["label"] if self.device.type == "cuda": diff --git a/monai/auto3dseg/analyzer.py b/monai/auto3dseg/analyzer.py index 93a46417b4..68f398402d 100644 --- a/monai/auto3dseg/analyzer.py +++ b/monai/auto3dseg/analyzer.py @@ -32,7 +32,8 @@ from monai.data.meta_tensor import MetaTensor from monai.transforms.transform import MapTransform from monai.transforms.utils_pytorch_numpy_unification import sum, unique -from monai.utils.enums import ImageStatsKeys, LabelStatsKeys +from monai.utils import convert_to_numpy +from monai.utils.enums import DataStatsKeys, ImageStatsKeys, LabelStatsKeys from monai.utils.misc import ImageMetaKey, label_union logger = get_logger(module_name=__name__) @@ -46,6 +47,8 @@ "FgImageStatsSumm", "LabelStatsSumm", "FilenameStats", + "ImageHistogram", + "ImageHistogramSumm", ] @@ -799,3 +802,201 @@ def __call__(self, data): d[self.stats_name] = "None" return d + + +class ImageHistogram(Analyzer): + """ + Analyzer to compute intensity histogram. + + Args: + image_key: the key to find image data in the callable function input (data) + hist_bins: list of positive integers (one for each channel) for setting the number of bins used to + compute the histogram. Defaults to [100]. + hist_range: list of lists of two floats (one for each channel) setting the intensity range to + compute the histogram. Defaults to [-500, 500]. + + Examples: + + .. code-block:: python + + import numpy as np + from monai.auto3dseg.analyzer import ImageHistogram + + input = {} + input['image'] = np.random.rand(1,30,30,30) + input['label'] = np.ones([30,30,30]) + analyzer = ImageHistogram(image_key='image') + print(analyzer(input)) + + """ + + def __init__( + self, + image_key: str, + stats_name: str = DataStatsKeys.IMAGE_HISTOGRAM, + hist_bins: Optional[list] = None, + hist_range: Optional[list] = None, + ): + + self.image_key = image_key + + # set defaults + self.hist_bins: list = [100] if hist_bins is None else hist_bins + self.hist_range: list = [-500, 500] if hist_range is None else hist_range + + report_format = {"counts": None, "bin_edges": None} + + super().__init__(stats_name, report_format) + self.update_ops(ImageStatsKeys.HISTOGRAM, SampleOperations()) + + # check histogram configurations for each channel in list + if not isinstance(self.hist_bins, list): + self.hist_bins = [self.hist_bins] + if not all(isinstance(hr, list) for hr in self.hist_range): + self.hist_range = [self.hist_range] + if len(self.hist_bins) != len(self.hist_range): + raise ValueError( + f"Number of histogram bins ({len(self.hist_bins)}) and " + f"histogram ranges ({len(self.hist_range)}) need to be the same!" + ) + for i, hist_params in enumerate(zip(self.hist_bins, self.hist_range)): + _hist_bins, _hist_range = hist_params + if not isinstance(_hist_bins, int) or _hist_bins < 0: + raise ValueError(f"Expected {i+1}. hist_bins value to be positive integer but got {_hist_bins}") + if not isinstance(_hist_range, list) or len(_hist_range) != 2: + raise ValueError(f"Expected {i+1}. hist_range values to be list of length 2 but received {_hist_range}") + + def __call__(self, data) -> dict: + """ + Callable to execute the pre-defined functions + + Returns: + A dictionary. The dict has the key in self.report_format and value + + Raises: + RuntimeError if the stats report generated is not consistent with the pre- + defined report_format. + + Note: + The stats operation uses numpy and torch to compute max, min, and other + functions. If the input has nan/inf, the stats results will be nan/inf. + """ + + d = dict(data) + + ndas = convert_to_numpy(d[self.image_key], wrap_sequence=True) # (1,H,W,D) or (C,H,W,D) + nr_channels = np.shape(ndas)[0] + + # adjust histogram params to match channels + if len(self.hist_bins) == 1: + self.hist_bins = nr_channels * self.hist_bins + if len(self.hist_bins) != nr_channels: + raise ValueError( + f"There is a mismatch between the number of channels ({nr_channels}) " + f"and number histogram bins ({len(self.hist_bins)})." + ) + if len(self.hist_range) == 1: + self.hist_range = nr_channels * self.hist_range + if len(self.hist_range) != nr_channels: + raise ValueError( + f"There is a mismatch between the number of channels ({nr_channels}) " + f"and histogram ranges ({len(self.hist_range)})." + ) + + # perform calculation + reports = [] + for channel in range(nr_channels): + counts, bin_edges = np.histogram( + ndas[channel, ...], + bins=self.hist_bins[channel], + range=(self.hist_range[channel][0], self.hist_range[channel][1]), + ) + _report = {"counts": counts.tolist(), "bin_edges": bin_edges.tolist()} + if not verify_report_format(_report, self.get_report_format()): + raise RuntimeError(f"report generated by {self.__class__} differs from the report format.") + reports.append(_report) + + d[self.stats_name] = reports + return d + + +class ImageHistogramSumm(Analyzer): + """ + This summary analyzer processes the values of specific key `stats_name` in a list of dict. + Typically, the list of dict is the output of case analyzer under the same prefix + (ImageHistogram). + + Args: + stats_name: the key of the to-process value in the dict. + average: whether to average the statistical value across different image modalities. + + """ + + def __init__(self, stats_name: str = DataStatsKeys.IMAGE_HISTOGRAM, average: Optional[bool] = True): + self.summary_average = average + report_format = {ImageStatsKeys.HISTOGRAM: None} + super().__init__(stats_name, report_format) + + self.update_ops(ImageStatsKeys.HISTOGRAM, SummaryOperations()) + + def __call__(self, data: List[Dict]): + """ + Callable to execute the pre-defined functions + + Returns: + A dictionary. The dict has the key in self.report_format and value + in a list format. Each element of the value list has stats pre-defined + by SampleOperations (max, min, ....). + + Raises: + RuntimeError if the stats report generated is not consistent with the pre- + defined report_format. + + Examples: + output dict contains a dictionary for all of the following keys{ + ImageStatsKeys.SHAPE:{...} + ImageStatsKeys.CHANNELS: {...}, + ImageStatsKeys.CROPPED_SHAPE: {...}, + ImageStatsKeys.SPACING: {...}, + ImageStatsKeys.INTENSITY: {...}, + } + + Notes: + The stats operation uses numpy and torch to compute max, min, and other + functions. If the input has nan/inf, the stats results will be nan/inf. + """ + if not isinstance(data, list): + return ValueError(f"Callable {self.__class__} requires list inputs") + + if len(data) == 0: + return ValueError(f"Callable {self.__class__} input list is empty") + + if self.stats_name not in data[0]: + return KeyError(f"{self.stats_name} is not in input data") + + summ_histogram: Dict = {} + + for d in data: + if not summ_histogram: + summ_histogram = d[DataStatsKeys.IMAGE_HISTOGRAM] + # convert to numpy for computing total histogram + for k in range(len(summ_histogram)): + summ_histogram[k]["counts"] = np.array(summ_histogram[k]["counts"]) + else: + for k in range(len(summ_histogram)): + summ_histogram[k]["counts"] += np.array(d[DataStatsKeys.IMAGE_HISTOGRAM][k]["counts"]) + if np.all(summ_histogram[k]["bin_edges"] != d[DataStatsKeys.IMAGE_HISTOGRAM][k]["bin_edges"]): + raise ValueError( + f"bin edges are not consistent! {summ_histogram[k]['bin_edges']} vs. " + f"{d[DataStatsKeys.IMAGE_HISTOGRAM][k]['bin_edges']}" + ) + + # convert back to list + for k in range(len(summ_histogram)): + summ_histogram[k]["counts"] = summ_histogram[k]["counts"].tolist() + + report = {ImageStatsKeys.HISTOGRAM: summ_histogram} + if not verify_report_format(report, self.get_report_format()): + raise RuntimeError(f"report generated by {self.__class__} differs from the report format.") + + return report diff --git a/monai/auto3dseg/seg_summarizer.py b/monai/auto3dseg/seg_summarizer.py index eb7e9d567f..e158068d4e 100644 --- a/monai/auto3dseg/seg_summarizer.py +++ b/monai/auto3dseg/seg_summarizer.py @@ -9,12 +9,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from monai.auto3dseg.analyzer import ( FgImageStats, FgImageStatsSumm, FilenameStats, + ImageHistogram, + ImageHistogramSumm, ImageStats, ImageStatsSumm, LabelStats, @@ -42,6 +44,11 @@ class SegSummarizer(Compose): datalist to locate the label files of the dataset. If label_key is None, the DataAnalyzer will skip looking for labels and all label-related operations. do_ccp: apply the connected component algorithm to process the labels/images. + hist_bins: list of positive integers (one for each channel) for setting the number of bins used to + compute the histogram. Defaults to [100]. + hist_range: list of lists of two floats (one for each channel) setting the intensity range to + compute the histogram. Defaults to [-500, 500]. + histogram_only: whether to only compute histograms. Defaults to False. Examples: .. code-block:: python @@ -70,26 +77,46 @@ class SegSummarizer(Compose): report = summarizer.summarize(stats) """ - def __init__(self, image_key: str, label_key: str, average=True, do_ccp: bool = True) -> None: + def __init__( + self, + image_key: str, + label_key: str, + average=True, + do_ccp: bool = True, + hist_bins: Optional[list] = None, + hist_range: Optional[list] = None, + histogram_only: bool = False, + ) -> None: self.image_key = image_key self.label_key = label_key + # set defaults + self.hist_bins: list = [100] if hist_bins is None else hist_bins + self.hist_range: list = [-500, 500] if hist_range is None else hist_range + self.histogram_only = histogram_only self.summary_analyzers: List[Any] = [] super().__init__() - self.add_analyzer(FilenameStats(image_key, DataStatsKeys.BY_CASE_IMAGE_PATH), None) - self.add_analyzer(FilenameStats(label_key, DataStatsKeys.BY_CASE_LABEL_PATH), None) - self.add_analyzer(ImageStats(image_key), ImageStatsSumm(average=average)) + if not self.histogram_only: + self.add_analyzer(FilenameStats(image_key, DataStatsKeys.BY_CASE_IMAGE_PATH), None) + self.add_analyzer(FilenameStats(label_key, DataStatsKeys.BY_CASE_LABEL_PATH), None) + self.add_analyzer(ImageStats(image_key), ImageStatsSumm(average=average)) - if label_key is None: - return + if label_key is None: + return - self.add_analyzer(FgImageStats(image_key, label_key), FgImageStatsSumm(average=average)) + self.add_analyzer(FgImageStats(image_key, label_key), FgImageStatsSumm(average=average)) - self.add_analyzer( - LabelStats(image_key, label_key, do_ccp=do_ccp), LabelStatsSumm(average=average, do_ccp=do_ccp) - ) + self.add_analyzer( + LabelStats(image_key, label_key, do_ccp=do_ccp), LabelStatsSumm(average=average, do_ccp=do_ccp) + ) + + # compute histograms + if self.hist_bins != 0: # type: ignore + self.add_analyzer( + ImageHistogram(image_key=image_key, hist_bins=hist_bins, hist_range=hist_range), ImageHistogramSumm() + ) def add_analyzer(self, case_analyzer, summary_analyzer) -> None: """ diff --git a/monai/fl/client/__init__.py b/monai/fl/client/__init__.py index cca669e0eb..7acb82c635 100644 --- a/monai/fl/client/__init__.py +++ b/monai/fl/client/__init__.py @@ -9,5 +9,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .client_algo import ClientAlgo -from .monai_algo import MonaiAlgo +from .client_algo import BaseClient, ClientAlgo, ClientAlgoStats +from .monai_algo import MonaiAlgo, MonaiAlgoStats diff --git a/monai/fl/client/client_algo.py b/monai/fl/client/client_algo.py index 6afd78c437..77ff2a4440 100644 --- a/monai/fl/client/client_algo.py +++ b/monai/fl/client/client_algo.py @@ -14,55 +14,98 @@ from monai.fl.utils.exchange_object import ExchangeObject -class ClientAlgo: +class BaseClient: """ - objective: provide an abstract base class for defining algo to run on any platform. - To define a new algo script, subclass this class and implement the + Provide an abstract base class to allow the client to return summary statistics of the data. + + To define a new stats script, subclass this class and implement the following abstract methods: - - self.train() - - self.get_weights() - - self.evaluate() + - self.get_data_stats() - initialize(), abort(), and finalize() can be optionally be implemented to help with lifecycle management - of the class object. + initialize(), abort(), and finalize() - inherited from `ClientAlgoStats` - can be optionally be implemented + to help with lifecycle management of the class object. """ def initialize(self, extra: Optional[dict] = None): """ - Call to initialize the ClientAlgo class + Call to initialize the ClientAlgo class. Args: - extra: optional extra information, e.g. dict of `ExtraItems.CLIENT_NAME` and/or `ExtraItems.APP_ROOT` + extra: optional extra information, e.g. dict of `ExtraItems.CLIENT_NAME` and/or `ExtraItems.APP_ROOT`. """ pass def finalize(self, extra: Optional[dict] = None): """ - Call to finalize the ClientAlgo class + Call to finalize the ClientAlgo class. Args: - extra: optional extra information + extra: Dict with additional information that can be provided by the FL system. """ pass def abort(self, extra: Optional[dict] = None): """ - Call to abort the ClientAlgo training or evaluation + Call to abort the ClientAlgo training or evaluation. Args: - extra: optional extra information + extra: Dict with additional information that can be provided by the FL system. """ pass + +class ClientAlgoStats(BaseClient): + def get_data_stats(self, extra: Optional[dict] = None) -> ExchangeObject: + """ + Get summary statistics about the local data. + + Args: + extra: Dict with additional information that can be provided by the FL system. + For example, requested statistics. + + Returns: + ExchangeObject: summary statistics. + + Extra dict example: + requested_stats = { + FlStatistics.STATISTICS: metrics, + FlStatistics.NUM_OF_BINS: num_of_bins, + FlStatistics.BIN_RANGES: bin_ranges + } + + Returned ExchangeObject example: + + ExchangeObject( + statistics = {...} + ) + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + +class ClientAlgo(ClientAlgoStats): + """ + Provide an abstract base class for defining algo to run on any platform. + To define a new algo script, subclass this class and implement the + following abstract methods: + + - self.train() + - self.get_weights() + - self.evaluate() + - self.get_data_stats() (optional, inherited from `ClientAlgoStats`) + + initialize(), abort(), and finalize() - inherited from `ClientAlgoStats` - can be optionally be implemented + to help with lifecycle management of the class object. + """ + def train(self, data: ExchangeObject, extra: Optional[dict] = None) -> None: """ Train network and produce new network from train data. Args: data: ExchangeObject containing current network weights to base training on. - extra: optional extra information + extra: Dict with additional information that can be provided by the FL system. Returns: None @@ -71,15 +114,17 @@ def train(self, data: ExchangeObject, extra: Optional[dict] = None) -> None: def get_weights(self, extra: Optional[dict] = None) -> ExchangeObject: """ - Get current local weights or weight differences + Get current local weights or weight differences. Args: - extra: optional extra information + extra: Dict with additional information that can be provided by the FL system. Returns: ExchangeObject: current local weights or weight differences. - ExchangeObject example:: + `ExchangeObject` example: + + .. code-block:: python ExchangeObject( weights = self.trainer.network.state_dict(), @@ -95,8 +140,8 @@ def evaluate(self, data: ExchangeObject, extra: Optional[dict] = None) -> Exchan Get evaluation metrics on test data. Args: - data: ExchangeObject with network weights to use for evaluation - extra: optional extra information + data: ExchangeObject with network weights to use for evaluation. + extra: Dict with additional information that can be provided by the FL system. Returns: metrics: ExchangeObject with evaluation metrics. diff --git a/monai/fl/client/monai_algo.py b/monai/fl/client/monai_algo.py index 040be39bf9..363d4fc122 100644 --- a/monai/fl/client/monai_algo.py +++ b/monai/fl/client/monai_algo.py @@ -18,9 +18,11 @@ import torch.distributed as dist import monai +from monai.apps.auto3dseg.data_analyzer import DataAnalyzer +from monai.auto3dseg import SegSummarizer from monai.bundle import ConfigParser from monai.bundle.config_item import ConfigComponent, ConfigItem -from monai.fl.client.client_algo import ClientAlgo +from monai.fl.client import ClientAlgo, ClientAlgoStats from monai.fl.utils.constants import ( BundleKeys, ExtraItems, @@ -34,6 +36,7 @@ from monai.fl.utils.exchange_object import ExchangeObject from monai.networks.utils import copy_model_state, get_state_dict from monai.utils import min_version, require_pkg +from monai.utils.enums import DataStatsKeys logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s - %(message)s") @@ -88,8 +91,232 @@ def disable_ckpt_loaders(parser): h["_disabled_"] = True +class MonaiAlgoStats(ClientAlgoStats): + """ + Implementation of ``ClientAlgo`` to allow federated learning with MONAI bundle configurations. + + Args: + bundle_root: path of bundle. + config_train_filename: bundle training config path relative to bundle_root. Can be a list of files; + defaults to "configs/train.json". + config_filters_filename: filter configuration file. Can be a list of files; defaults to `None`. + histogram_only: whether to only compute histograms. Defaults to False. + """ + + def __init__( + self, + bundle_root: str, + config_train_filename: Optional[Union[str, list]] = "configs/train.json", + config_filters_filename: Optional[Union[str, list]] = None, + train_data_key: Optional[str] = BundleKeys.TRAIN_DATA, + eval_data_key: Optional[str] = BundleKeys.VALID_DATA, + data_stats_transform_list: Optional[list] = None, + histogram_only: bool = False, + ): + self.logger = logging.getLogger(self.__class__.__name__) + self.bundle_root = bundle_root + self.config_train_filename = config_train_filename + self.config_filters_filename = config_filters_filename + self.train_data_key = train_data_key + self.eval_data_key = eval_data_key + self.data_stats_transform_list = data_stats_transform_list + self.histogram_only = histogram_only + + self.client_name = None + self.app_root = None + self.train_parser = None + self.filter_parser = None + self.post_statistics_filters = None + self.phase = FlPhase.IDLE + self.dataset_root = None + + def initialize(self, extra=None): + """ + Initialize routine to parse configuration files and extract main components such as trainer, evaluator, and filters. + + Args: + extra: Dict with additional information that should be provided by FL system, + i.e., `ExtraItems.CLIENT_NAME` and `ExtraItems.APP_ROOT`. + + """ + if extra is None: + extra = {} + self.client_name = extra.get(ExtraItems.CLIENT_NAME, "noname") + self.logger.info(f"Initializing {self.client_name} ...") + + # FL platform needs to provide filepath to configuration files + self.app_root = extra.get(ExtraItems.APP_ROOT, "") + + # Read bundle config files + self.bundle_root = os.path.join(self.app_root, self.bundle_root) + + config_train_files = self._add_config_files(self.config_train_filename) + config_filter_files = self._add_config_files(self.config_filters_filename) + + # Parse + self.train_parser = ConfigParser() + self.filter_parser = ConfigParser() + if len(config_train_files) > 0: + self.train_parser.read_config(config_train_files) + check_bundle_config(self.train_parser) + if len(config_filter_files) > 0: + self.filter_parser.read_config(config_filter_files) + + # override some config items + self.train_parser[RequiredBundleKeys.BUNDLE_ROOT] = self.bundle_root + + # Get data location + self.dataset_root = self.train_parser.get_parsed_content( + BundleKeys.DATASET_DIR, default=ConfigItem(None, BundleKeys.DATASET_DIR) + ) + + # Get filters + self.post_statistics_filters = self.filter_parser.get_parsed_content( + FiltersType.POST_STATISTICS_FILTERS, default=ConfigItem(None, FiltersType.POST_STATISTICS_FILTERS) + ) + + self.logger.info(f"Initialized {self.client_name}.") + + def get_data_stats(self, extra: Optional[dict] = None) -> ExchangeObject: + """ + Returns summary statistics about the local data. + + Args: + extra: Dict with additional information that can be provided by the FL system. + + Returns: + stats: ExchangeObject with summary statistics. + + """ + + if self.dataset_root: + self.phase = FlPhase.GET_DATA_STATS + self.logger.info(f"Computing statistics on {self.dataset_root}") + + if FlStatistics.HIST_BINS not in extra: + raise ValueError("FlStatistics.NUM_OF_BINS not specified in `extra`") + else: + hist_bins = extra[FlStatistics.HIST_BINS] + if FlStatistics.HIST_RANGE not in extra: + raise ValueError("FlStatistics.HIST_RANGE not specified in `extra`") + else: + hist_range = extra[FlStatistics.HIST_RANGE] + + stats_dict = {} + + # train data stats + train_summary_stats, train_case_stats = self._get_data_key_stats( + parser=self.train_parser, + data_key=self.train_data_key, + hist_bins=hist_bins, + hist_range=hist_range, + output_path=os.path.join(self.app_root, "train_data_stats.yaml"), + ) + if train_case_stats: + # Only return summary statistics to FL server + stats_dict.update({self.train_data_key: train_summary_stats}) + + # eval data stats + eval_summary_stats, eval_case_stats = self._get_data_key_stats( + parser=self.train_parser, + data_key=self.eval_data_key, + hist_bins=hist_bins, + hist_range=hist_range, + output_path=os.path.join(self.app_root, "eval_data_stats.yaml"), + ) + if eval_summary_stats: + # Only return summary statistics to FL server + stats_dict.update({self.eval_data_key: eval_summary_stats}) + + # total stats + if train_case_stats and eval_case_stats: + # Compute total summary + total_summary_stats = self._compute_total_stats( + [train_case_stats, eval_case_stats], hist_bins, hist_range + ) + stats_dict.update({FlStatistics.TOTAL_DATA: total_summary_stats}) + + # optional filter of data stats + stats = ExchangeObject(statistics=stats_dict) + if self.post_statistics_filters is not None: + for _filter in self.post_statistics_filters: + stats = _filter(stats, extra) + + return stats + else: + raise ValueError("data_root not set!") + + def _get_data_key_stats(self, parser, data_key, hist_bins, hist_range, output_path=None): + if data_key not in parser: + self.logger.warning(f"Data key {data_key} not available in bundle configs.") + return None, None + data = parser.get_parsed_content(data_key) + + datalist = {data_key: data} + + analyzer = DataAnalyzer( + datalist=datalist, + dataroot=self.dataset_root, + hist_bins=hist_bins, + hist_range=hist_range, + output_path=output_path, + histogram_only=self.histogram_only, + ) + + self.logger.info(f"{self.client_name} compute data statistics on {data_key}...") + all_stats = analyzer.get_all_case_stats(transform_list=self.data_stats_transform_list, key=data_key) + + case_stats = all_stats[DataStatsKeys.BY_CASE] + + summary_stats = { + FlStatistics.DATA_STATS: all_stats[DataStatsKeys.SUMMARY], + FlStatistics.DATA_COUNT: len(data), + FlStatistics.FAIL_COUNT: len(data) - len(case_stats), + # TODO: add shapes, voxels sizes, etc. + } + + return summary_stats, case_stats + + @staticmethod + def _compute_total_stats(case_stats_lists, hist_bins, hist_range): + # Compute total summary + total_case_stats = [] + for case_stats_list in case_stats_lists: + total_case_stats += case_stats_list + + summarizer = SegSummarizer( + "image", "label", average=True, do_ccp=True, hist_bins=hist_bins, hist_range=hist_range + ) + total_summary_stats = summarizer.summarize(total_case_stats) + + summary_stats = { + FlStatistics.DATA_STATS: total_summary_stats, + FlStatistics.DATA_COUNT: len(total_case_stats), + FlStatistics.FAIL_COUNT: 0, + } + + return summary_stats + + def _add_config_files(self, config_files): + files = [] + if config_files: + if isinstance(config_files, str): + files.append(os.path.join(self.bundle_root, config_files)) + elif isinstance(config_files, list): + for file in config_files: + if isinstance(file, str): + files.append(os.path.join(self.bundle_root, file)) + else: + raise ValueError(f"Expected config file to be of type str but got {type(file)}: {file}") + else: + raise ValueError( + f"Expected config files to be of type str or list but got {type(config_files)}: {config_files}" + ) + return files + + @require_pkg(pkg_name="ignite", version="0.4.10", version_checker=min_version) -class MonaiAlgo(ClientAlgo): +class MonaiAlgo(ClientAlgo, MonaiAlgoStats): """ Implementation of ``ClientAlgo`` to allow federated learning with MONAI bundle configurations. @@ -135,6 +362,9 @@ def __init__( multi_gpu: bool = False, backend: str = "nccl", init_method: str = "env://", + train_data_key: Optional[str] = BundleKeys.TRAIN_DATA, + eval_data_key: Optional[str] = BundleKeys.VALID_DATA, + data_stats_transform_list: Optional[list] = None, ): self.logger = logging.getLogger(self.__class__.__name__) if config_evaluate_filename == "default": @@ -154,6 +384,9 @@ def __init__( self.multi_gpu = multi_gpu self.backend = backend self.init_method = init_method + self.train_data_key = train_data_key + self.eval_data_key = eval_data_key + self.data_stats_transform_list = data_stats_transform_list self.app_root = None self.train_parser = None @@ -170,6 +403,7 @@ def __init__( self.phase = FlPhase.IDLE self.client_name = None + self.dataset_root = None def initialize(self, extra=None): """ @@ -251,6 +485,14 @@ def initialize(self, extra=None): self.post_evaluate_filters = self.filter_parser.get_parsed_content( FiltersType.POST_EVALUATE_FILTERS, default=ConfigItem(None, FiltersType.POST_EVALUATE_FILTERS) ) + self.post_statistics_filters = self.filter_parser.get_parsed_content( + FiltersType.POST_STATISTICS_FILTERS, default=ConfigItem(None, FiltersType.POST_STATISTICS_FILTERS) + ) + + # Get data location + self.dataset_root = self.train_parser.get_parsed_content( + BundleKeys.DATASET_DIR, default=ConfigItem(None, BundleKeys.DATASET_DIR) + ) self.logger.info(f"Initialized {self.client_name}.") @@ -260,7 +502,7 @@ def train(self, data: ExchangeObject, extra=None): Args: data: `ExchangeObject` containing the current global model weights. - extra: Dict with additional information that can be provided by FL system. + extra: Dict with additional information that can be provided by the FL system. """ self._set_cuda_device() @@ -299,7 +541,7 @@ def get_weights(self, extra=None): Returns the current weights of the model. Args: - extra: Dict with additional information that can be provided by FL system. + extra: Dict with additional information that can be provided by the FL system. Returns: return_weights: `ExchangeObject` containing current weights (default) @@ -379,7 +621,7 @@ def evaluate(self, data: ExchangeObject, extra=None): Args: data: `ExchangeObject` containing the current global model weights. - extra: Dict with additional information that can be provided by FL system. + extra: Dict with additional information that can be provided by the FL system. Returns: return_metrics: `ExchangeObject` containing evaluation metrics. @@ -423,7 +665,7 @@ def abort(self, extra=None): """ Abort the training or evaluation. Args: - extra: Dict with additional information that can be provided by FL system. + extra: Dict with additional information that can be provided by the FL system. """ self.logger.info(f"Aborting {self.client_name} during {self.phase} phase.") if isinstance(self.trainer, monai.engines.Trainer): @@ -437,7 +679,7 @@ def finalize(self, extra=None): """ Finalize the training or evaluation. Args: - extra: Dict with additional information that can be provided by FL system. + extra: Dict with additional information that can be provided by the FL system. """ self.logger.info(f"Terminating {self.client_name} during {self.phase} phase.") if isinstance(self.trainer, monai.engines.Trainer): @@ -460,23 +702,6 @@ def _check_converted(self, global_weights, local_var_dict, n_converted): f"Converted {n_converted} global variables to match {len(local_var_dict)} local variables." ) - def _add_config_files(self, config_files): - files = [] - if config_files: - if isinstance(config_files, str): - files.append(os.path.join(self.bundle_root, config_files)) - elif isinstance(config_files, list): - for file in config_files: - if isinstance(file, str): - files.append(os.path.join(self.bundle_root, file)) - else: - raise ValueError(f"Expected config file to be of type str but got {type(file)}: {file}") - else: - raise ValueError( - f"Expected config files to be of type str or list but got {type(config_files)}: {config_files}" - ) - return files - def _set_cuda_device(self): if self.multi_gpu: self.rank = int(os.environ["LOCAL_RANK"]) diff --git a/monai/fl/utils/constants.py b/monai/fl/utils/constants.py index f6da8d4ea0..cd24e6093d 100644 --- a/monai/fl/utils/constants.py +++ b/monai/fl/utils/constants.py @@ -34,10 +34,19 @@ class FlPhase(StrEnum): TRAIN = "fl_train" EVALUATE = "fl_evaluate" GET_WEIGHTS = "fl_get_weights" + GET_DATA_STATS = "fl_get_data_stats" class FlStatistics(StrEnum): NUM_EXECUTED_ITERATIONS = "num_executed_iterations" + STATISTICS = "statistics" + HIST_BINS = "hist_bins" + HIST_RANGE = "hist_range" + DATA_STATS = "data_stats" + DATA_COUNT = "data_count" + FAIL_COUNT = "fail_count" + TOTAL_DATA = "total_data" + FEATURE_NAMES = "feature_names" class RequiredBundleKeys(StrEnum): @@ -49,9 +58,13 @@ class BundleKeys(StrEnum): EVALUATOR = "validate#evaluator" TRAIN_TRAINER_MAX_EPOCHS = "train#trainer#max_epochs" VALIDATE_HANDLERS = "validate#handlers" + DATASET_DIR = "dataset_dir" + TRAIN_DATA = "train#dataset#data" + VALID_DATA = "validate#dataset#data" class FiltersType(StrEnum): PRE_FILTERS = "pre_filters" POST_WEIGHT_FILTERS = "post_weight_filters" POST_EVALUATE_FILTERS = "post_evaluate_filters" + POST_STATISTICS_FILTERS = "post_statistics_filters" diff --git a/monai/utils/enums.py b/monai/utils/enums.py index cd120f6c6e..d77de4e6d3 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -553,6 +553,7 @@ class DataStatsKeys(StrEnum): IMAGE_STATS = "image_stats" FG_IMAGE_STATS = "image_foreground_stats" LABEL_STATS = "label_stats" + IMAGE_HISTOGRAM = "image_histogram" class ImageStatsKeys(StrEnum): @@ -566,6 +567,7 @@ class ImageStatsKeys(StrEnum): CROPPED_SHAPE = "cropped_shape" SPACING = "spacing" INTENSITY = "intensity" + HISTOGRAM = "histogram" class LabelStatsKeys(StrEnum): diff --git a/tests/test_fl_monai_algo_stats.py b/tests/test_fl_monai_algo_stats.py new file mode 100644 index 0000000000..fd2b73ea85 --- /dev/null +++ b/tests/test_fl_monai_algo_stats.py @@ -0,0 +1,69 @@ +# 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 parameterized import parameterized + +from monai.fl.client import MonaiAlgoStats +from monai.fl.utils.constants import ExtraItems, FlStatistics +from monai.fl.utils.exchange_object import ExchangeObject +from tests.utils import SkipIfNoModule + +_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__))) +_data_dir = os.path.join(_root_dir, "testing_data") + +TEST_GET_DATA_STATS_1 = [ + { + "bundle_root": _data_dir, + "config_train_filename": os.path.join(_data_dir, "config_fl_stats_1.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), + } +] +TEST_GET_DATA_STATS_2 = [ + { + "bundle_root": _data_dir, + "config_train_filename": os.path.join(_data_dir, "config_fl_stats_2.json"), + "config_filters_filename": os.path.join(_data_dir, "config_fl_filters.json"), + } +] +TEST_GET_DATA_STATS_3 = [ + { + "bundle_root": _data_dir, + "config_train_filename": [ + os.path.join(_data_dir, "config_fl_stats_1.json"), + os.path.join(_data_dir, "config_fl_stats_2.json"), + ], + "config_filters_filename": [ + os.path.join(_data_dir, "config_fl_filters.json"), + os.path.join(_data_dir, "config_fl_filters.json"), + ], + } +] + + +@SkipIfNoModule("ignite") +class TestFLMonaiAlgo(unittest.TestCase): + @parameterized.expand([TEST_GET_DATA_STATS_1, TEST_GET_DATA_STATS_2, TEST_GET_DATA_STATS_3]) + def test_get_data_stats(self, input_params): + # initialize algo + algo = MonaiAlgoStats(**input_params) + algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl", ExtraItems.APP_ROOT: _data_dir}) + + requested_stats = {FlStatistics.HIST_BINS: 100, FlStatistics.HIST_RANGE: [-500, 500]} + # test train + stats = algo.get_data_stats(extra=requested_stats) + self.assertIsInstance(stats, ExchangeObject) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/testing_data/anatomical_label.nii.gz b/tests/testing_data/anatomical_label.nii.gz new file mode 100644 index 0000000000000000000000000000000000000000..a31ef9f7a42ae4861e1020069e2326dcf478b125 GIT binary patch literal 274 zcmb2|=3oE==C@Z4<{mN-VM|yZyC6L8X++^GmIYVuVL2+?^Z_FtYI+hl? b?|i$Z;YSvnwm~EoTwz=xTYO^?CnEy@8(3%X literal 0 HcmV?d00001 diff --git a/tests/testing_data/config_fl_stats_1.json b/tests/testing_data/config_fl_stats_1.json new file mode 100644 index 0000000000..41b42eb3bb --- /dev/null +++ b/tests/testing_data/config_fl_stats_1.json @@ -0,0 +1,23 @@ +{ + "imports": [ + "$import os" + ], + "bundle_root": "tests/testing_data", + "dataset_dir": "@bundle_root", + "train": { + "dataset": { + "_target_": "Dataset", + "data": [ + { + "image": "$os.path.join(@dataset_dir, 'anatomical.nii')", + "label": "$os.path.join(@dataset_dir, 'anatomical_label.nii.gz')" + }, + { + "image": "$os.path.join(@dataset_dir, 'reoriented_anat_moved.nii')", + "label": "$os.path.join(@dataset_dir, 'reoriented_anat_moved_label.nii.gz')" + } + ], + "transform": "@train#preprocessing" + } + } +} diff --git a/tests/testing_data/config_fl_stats_2.json b/tests/testing_data/config_fl_stats_2.json new file mode 100644 index 0000000000..bf55673f67 --- /dev/null +++ b/tests/testing_data/config_fl_stats_2.json @@ -0,0 +1,39 @@ +{ + "imports": [ + "$import os" + ], + "bundle_root": "tests/testing_data", + "dataset_dir": "@bundle_root", + "train": { + "dataset": { + "_target_": "Dataset", + "data": [ + { + "image": "$os.path.join(@dataset_dir, 'anatomical.nii')", + "label": "$os.path.join(@dataset_dir, 'anatomical_label.nii.gz')" + }, + { + "image": "$os.path.join(@dataset_dir, 'reoriented_anat_moved.nii')", + "label": "$os.path.join(@dataset_dir, 'reoriented_anat_moved_label.nii.gz')" + } + ], + "transform": "@train#preprocessing" + } + }, + "validate": { + "dataset": { + "_target_": "Dataset", + "data": [ + { + "image": "$os.path.join(@dataset_dir, 'anatomical.nii')", + "label": "$os.path.join(@dataset_dir, 'anatomical_label.nii.gz')" + }, + { + "image": "$os.path.join(@dataset_dir, 'reoriented_anat_moved.nii')", + "label": "$os.path.join(@dataset_dir, 'reoriented_anat_moved_label.nii.gz')" + } + ], + "transform": "@train#preprocessing" + } + } +} diff --git a/tests/testing_data/reoriented_anat_moved_label.nii.gz b/tests/testing_data/reoriented_anat_moved_label.nii.gz new file mode 100644 index 0000000000000000000000000000000000000000..2d148d1999520449567d6f4505fae794da1a8b36 GIT binary patch literal 190 zcmb2|=3oE==C`+Y^Byt~VGUrvrEt;VAXmst1&=icc|?2;Nc0@!T9V2tmcT8bpz5Mg zIK`py+(sSq>*x1ho2}w zJtxL}epIk;&aZ3t^fvEPh1drqAEev;;mz?VJ9%tv;M!X;`~RH0v9Ev0rKHKZyVP%& hp5~qTrq143^}cbgJ|A2Uknv}~^t!Z;DvNjq1^{5@QS|@- literal 0 HcmV?d00001 From ee049096046d708de4026faaad32d40b1a9f69f5 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:13:21 +0100 Subject: [PATCH 46/60] 5284 Fixes release integration tests (#5342) part of #5284 fixes incorrect ignite version used https://github.com/Project-MONAI/MONAI/actions/runs/3262265183 fixes autorunner test error: https://github.com/Project-MONAI/MONAI/actions/runs/3255478854/jobs/5347066136 fixes https://github.com/Project-MONAI/MONAI/issues/5343 ### Description tests are fixed by upgrading dep version and moving `test_auto3dseg_autorunner` unit test to `test_integration_autorunner` integration test. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- .github/workflows/setupapp.yml | 2 +- environment-dev.yml | 2 +- tests/min_tests.py | 2 +- ...nner.py => test_integration_autorunner.py} | 0 tests/test_masked_inference_wsi_dataset.py | 24 ++++++++++++------- 5 files changed, 19 insertions(+), 11 deletions(-) rename tests/{test_auto3dseg_autorunner.py => test_integration_autorunner.py} (100%) diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 71c59457fb..db8f43dad7 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -23,7 +23,7 @@ jobs: container: image: nvcr.io/nvidia/pytorch:21.06-py3 # CUDA 11.3 options: --gpus all - runs-on: [self-hosted, linux, x64, common] + runs-on: [self-hosted, linux, x64, integration] steps: - uses: actions/checkout@v3 - name: cache weekly timestamp diff --git a/environment-dev.yml b/environment-dev.yml index 1f1d9c773f..1b91cb38fc 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -9,7 +9,7 @@ dependencies: - coverage>=5.5 - parameterized - setuptools>=50.3.0,!=60.0.0 - - ignite==0.4.8 + - ignite==0.4.10 - gdown>=4.4.0 - scipy - nibabel diff --git a/tests/min_tests.py b/tests/min_tests.py index 026514e29e..439d95db5f 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -29,7 +29,6 @@ def run_testsuit(): exclude_cases = [ # these cases use external dependencies "test_ahnet", "test_arraydataset", - "test_auto3dseg_autorunner", "test_auto3dseg_ensemble", "test_auto3dseg_hpo", "test_auto3dseg", @@ -107,6 +106,7 @@ def run_testsuit(): "test_integration_workflows", "test_integration_workflows_gan", "test_integration_bundle_run", + "test_integration_autorunner", "test_invert", "test_invertd", "test_iterable_dataset", diff --git a/tests/test_auto3dseg_autorunner.py b/tests/test_integration_autorunner.py similarity index 100% rename from tests/test_auto3dseg_autorunner.py rename to tests/test_integration_autorunner.py diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py index 661f728e47..c424edd897 100644 --- a/tests/test_masked_inference_wsi_dataset.py +++ b/tests/test_masked_inference_wsi_dataset.py @@ -10,6 +10,7 @@ # limitations under the License. import os +import tempfile import unittest from unittest import skipUnless @@ -30,23 +31,21 @@ FILE_NAME = f"temp_{base_name}" FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", FILE_NAME + extension) -MASK1 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tissue_mask1.npy") -MASK2 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tissue_mask2.npy") -MASK4 = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tissue_mask4.npy") +MASK1, MASK2, MASK4 = "mask1.npy", "mask2.npy", "mask4.npy" HEIGHT = 32914 WIDTH = 46000 -def prepare_data(): +def prepare_data(*masks): mask = np.zeros((HEIGHT // 2, WIDTH // 2)) mask[100, 100] = 1 - np.save(MASK1, mask) + np.save(masks[0], mask) mask[100, 101] = 1 - np.save(MASK2, mask) + np.save(masks[1], mask) mask[100:102, 100:102] = 1 - np.save(MASK4, mask) + np.save(masks[2], mask) TEST_CASE_0 = [ @@ -156,17 +155,24 @@ def prepare_data(): ] +@skip_if_quick class TestMaskedInferenceWSIDataset(unittest.TestCase): def setUp(self): - prepare_data() + self.base_dir = tempfile.TemporaryDirectory() + prepare_data(*[os.path.join(self.base_dir.name, m) for m in [MASK1, MASK2, MASK4]]) 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) + def tearDown(self): + self.base_dir.cleanup() + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) @skipUnless(has_cim, "Requires CuCIM") @skip_if_quick def test_read_patches_cucim(self, input_parameters, expected): + for m in input_parameters["data"]: + m["mask"] = os.path.join(self.base_dir.name, m["mask"]) dataset = MaskedInferenceWSIDataset(**input_parameters) self.compare_samples_expected(dataset, expected) @@ -174,6 +180,8 @@ def test_read_patches_cucim(self, input_parameters, expected): @skipUnless(has_osl, "Requires OpenSlide") @skip_if_quick def test_read_patches_openslide(self, input_parameters, expected): + for m in input_parameters["data"]: + m["mask"] = os.path.join(self.base_dir.name, m["mask"]) dataset = MaskedInferenceWSIDataset(**input_parameters) self.compare_samples_expected(dataset, expected) From c0e3fa8b8a744a10f8df2e059f78edde7568f9f3 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 17 Oct 2022 12:21:11 +0100 Subject: [PATCH 47/60] skip cache for releasing branch; update test info Signed-off-by: Wenqi Li --- .github/workflows/setupapp.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index db8f43dad7..3d69da3737 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -31,6 +31,7 @@ jobs: run: | echo "datew=$(date '+%Y-%V')" >> $GITHUB_OUTPUT - name: cache for pip + if: ${{ startsWith(github.ref, 'refs/heads/dev') }} uses: actions/cache@v3 id: cache with: @@ -49,6 +50,9 @@ jobs: - name: Run unit tests report coverage run: | python -m pip list + git clean -ffdx + df -h + python -m pip cache info export LAUNCH_DELAY=$[ $RANDOM % 16 * 60 ] echo "Sleep $LAUNCH_DELAY" sleep $LAUNCH_DELAY From e1d7a2e7cb32bf5229f36f63c0291982c94fe5a8 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 18 Oct 2022 11:20:39 +0100 Subject: [PATCH 48/60] skip test if no downloading Signed-off-by: Wenqi Li --- tests/test_integration_autorunner.py | 56 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/tests/test_integration_autorunner.py b/tests/test_integration_autorunner.py index d4899acdfe..f62a8563b9 100644 --- a/tests/test_integration_autorunner.py +++ b/tests/test_integration_autorunner.py @@ -21,7 +21,7 @@ from monai.bundle.config_parser import ConfigParser from monai.data import create_test_image_3d from monai.utils import optional_import -from tests.utils import SkipIfBeforePyTorchVersion, skip_if_no_cuda, skip_if_quick +from tests.utils import SkipIfBeforePyTorchVersion, skip_if_downloading_fails, skip_if_no_cuda, skip_if_quick _, has_tb = optional_import("torch.utils.tensorboard", name="SummaryWriter") _, has_nni = optional_import("nni") @@ -104,35 +104,37 @@ def test_autorunner(self) -> None: runner = AutoRunner(work_dir=work_dir, input=self.data_src_cfg) runner.set_training_params(train_param) # 2 epochs runner.set_num_fold(1) - runner.run() + with skip_if_downloading_fails(): + runner.run() @skip_if_no_cuda + @unittest.skipIf(not has_nni, "nni required") def test_autorunner_hpo(self) -> None: - if has_nni: - work_dir = os.path.join(self.test_path, "work_dir") - runner = AutoRunner(work_dir=work_dir, input=self.data_src_cfg, hpo=True) - hpo_param = { - "num_iterations": 8, - "num_iterations_per_validation": 4, - "num_images_per_batch": 2, - "num_epochs": 2, - "num_warmup_iterations": 4, - # below are to shorten the time for dints - "training#num_iterations": 8, - "training#num_iterations_per_validation": 4, - "training#num_images_per_batch": 2, - "training#num_epochs": 2, - "training#num_warmup_iterations": 4, - "searching#num_iterations": 8, - "searching#num_iterations_per_validation": 4, - "searching#num_images_per_batch": 2, - "searching#num_epochs": 2, - "searching#num_warmup_iterations": 4, - } - search_space = {"learning_rate": {"_type": "choice", "_value": [0.0001, 0.001, 0.01, 0.1]}} - runner.set_num_fold(1) - runner.set_nni_search_space(search_space) - runner.set_hpo_params(params=hpo_param) + work_dir = os.path.join(self.test_path, "work_dir") + runner = AutoRunner(work_dir=work_dir, input=self.data_src_cfg, hpo=True) + hpo_param = { + "num_iterations": 8, + "num_iterations_per_validation": 4, + "num_images_per_batch": 2, + "num_epochs": 2, + "num_warmup_iterations": 4, + # below are to shorten the time for dints + "training#num_iterations": 8, + "training#num_iterations_per_validation": 4, + "training#num_images_per_batch": 2, + "training#num_epochs": 2, + "training#num_warmup_iterations": 4, + "searching#num_iterations": 8, + "searching#num_iterations_per_validation": 4, + "searching#num_images_per_batch": 2, + "searching#num_epochs": 2, + "searching#num_warmup_iterations": 4, + } + search_space = {"learning_rate": {"_type": "choice", "_value": [0.0001, 0.001, 0.01, 0.1]}} + runner.set_num_fold(1) + runner.set_nni_search_space(search_space) + runner.set_hpo_params(params=hpo_param) + with skip_if_downloading_fails(): runner.run() def tearDown(self) -> None: From ba5fea2196786a2bfb5b355ad1929ecfdf82a08f Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 17 Oct 2022 19:00:25 +0100 Subject: [PATCH 49/60] 5345 update output devices slidingwindow (#5346) Signed-off-by: Wenqi Li Fixes #5345 sliding window output should prefer `device` instead of `inputs.device` ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- monai/inferers/utils.py | 2 +- monai/utils/type_conversion.py | 11 ++++++++--- tests/test_sliding_window_inference.py | 15 ++++++++------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/monai/inferers/utils.py b/monai/inferers/utils.py index 689e34c991..a910992840 100644 --- a/monai/inferers/utils.py +++ b/monai/inferers/utils.py @@ -279,7 +279,7 @@ def sliding_window_inference( final_output = final_output[0] if is_tensor_output else final_output if isinstance(inputs, MetaTensor): - final_output = convert_to_dst_type(final_output, inputs)[0] # type: ignore + final_output = convert_to_dst_type(final_output, inputs, device=device)[0] # type: ignore return final_output diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index 33c7bb5f3b..b21edf6496 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -243,7 +243,7 @@ def convert_to_cupy(data, dtype: Optional[np.dtype] = None, wrap_sequence: bool def convert_data_type( data: Any, output_type: Optional[Type[NdarrayTensor]] = None, - device: Optional[torch.device] = None, + device: Union[None, str, torch.device] = None, dtype: Union[DtypeLike, torch.dtype] = None, wrap_sequence: bool = False, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: @@ -307,7 +307,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, + device: Union[None, str, torch.device] = None, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: """ Convert source data to the same data type and device as the destination data. @@ -321,12 +325,13 @@ 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])`. + device: target device to put the converted Tensor data. If unspecified, `dst.device` will be used if possible. See Also: :func:`convert_data_type` """ - device = dst.device if isinstance(dst, torch.Tensor) else None + device = dst.device if device is None and isinstance(dst, torch.Tensor) else device if dtype is None: dtype = dst.dtype diff --git a/tests/test_sliding_window_inference.py b/tests/test_sliding_window_inference.py index 8b8ec47d32..b10c1c659e 100644 --- a/tests/test_sliding_window_inference.py +++ b/tests/test_sliding_window_inference.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import itertools import unittest import numpy as np @@ -85,20 +86,20 @@ 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]) + @parameterized.expand(list(itertools.product(TEST_TORCH_AND_META_TENSORS, ("cpu", "cuda"), ("cpu", "cuda", None)))) @skip_if_no_cuda - def test_sw_device(self, data_type): - inputs = data_type(torch.ones((3, 16, 15, 7))).to(device="cpu") + def test_sw_device(self, data_type, device, sw_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 def compute(data): - self.assertEqual(data.device.type, "cuda") - return data + torch.tensor(1, device="cuda") + self.assertEqual(data.device.type, sw_device or device) + return data + torch.tensor(1, device=sw_device or device) - result = sliding_window_inference(inputs, roi_shape, sw_batch_size, compute, sw_device="cuda") - np.testing.assert_string_equal(inputs.device.type, result.device.type) + result = sliding_window_inference(inputs, roi_shape, sw_batch_size, compute, sw_device=sw_device, device="cpu") + np.testing.assert_string_equal("cpu", result.device.type) expected_val = np.ones((1, 3, 16, 15, 7), dtype=np.float32) + 1 np.testing.assert_allclose(result.cpu().numpy(), expected_val) From 9e444d4c6e664b84d2d9fdf5aa1aba7b339f1e31 Mon Sep 17 00:00:00 2001 From: myron Date: Tue, 18 Oct 2022 11:52:38 -0700 Subject: [PATCH 50/60] Enhancement for WarmupCosineSchedule to specify the beginning fraction of the linear warmup (#5351) small enhancement to WarmupCosineSchedule input, to optionally specify the beginning of the linear warmup from something above 0 ( e.g from a fraction 0.1 * initial_lr). a unit test is added too. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [x] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron --- monai/optimizers/lr_scheduler.py | 18 +++++++++++++++--- tests/test_lr_scheduler.py | 13 ++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/monai/optimizers/lr_scheduler.py b/monai/optimizers/lr_scheduler.py index 83412c61ea..cb047f8bc5 100644 --- a/monai/optimizers/lr_scheduler.py +++ b/monai/optimizers/lr_scheduler.py @@ -62,7 +62,13 @@ class WarmupCosineSchedule(LambdaLR): """ def __init__( - self, optimizer: Optimizer, warmup_steps: int, t_total: int, cycles: float = 0.5, last_epoch: int = -1 + self, + optimizer: Optimizer, + warmup_steps: int, + t_total: int, + cycles: float = 0.5, + last_epoch: int = -1, + warmup_multiplier: float = 0, ) -> None: """ Args: @@ -71,16 +77,22 @@ def __init__( t_total: total number of training iterations. cycles: cosine cycles parameter. last_epoch: the index of last epoch. + warmup_multiplier: if provided, starts the linear warmup from this fraction of the intial lr. + Must be in 0..1 interval. Defaults to 0 Returns: None """ - self.warmup_steps = warmup_steps + self.warmup_steps = min(max(warmup_steps, 0), t_total) + self.warmup_multiplier = warmup_multiplier self.t_total = t_total self.cycles = cycles + if warmup_multiplier < 0 or warmup_multiplier > 1: + raise ValueError("warmup_multiplier must be in 0..1 range") super().__init__(optimizer, self.lr_lambda, last_epoch) def lr_lambda(self, step): if step < self.warmup_steps: - return float(step) / float(max(1.0, self.warmup_steps)) + f = float(step) / float(max(1.0, self.warmup_steps)) + return self.warmup_multiplier + (1 - self.warmup_multiplier) * f progress = float(step - self.warmup_steps) / float(max(1, self.t_total - self.warmup_steps)) return max(0.0, 0.5 * (1.0 + math.cos(math.pi * float(self.cycles) * 2.0 * progress))) diff --git a/tests/test_lr_scheduler.py b/tests/test_lr_scheduler.py index a3e1ea9dd6..44f4c50c0f 100644 --- a/tests/test_lr_scheduler.py +++ b/tests/test_lr_scheduler.py @@ -28,7 +28,11 @@ def forward(self, x): TEST_CASE_LRSCHEDULER = [ - [{"warmup_steps": 2, "t_total": 10}, [0.000, 0.500, 1.00, 0.962, 0.854, 0.691, 0.500, 0.309, 0.146, 0.038]] + [{"warmup_steps": 2, "t_total": 10}, [0.000, 0.500, 1.00, 0.962, 0.854, 0.691, 0.500, 0.309, 0.146, 0.038]], + [ + {"warmup_steps": 2, "t_total": 10, "warmup_multiplier": 0.1}, + [0.1, 0.55, 1.00, 0.962, 0.854, 0.691, 0.500, 0.309, 0.146, 0.038], + ], ] @@ -47,6 +51,13 @@ def test_shape(self, input_param, expected_lr): for a, b in zip(lrs_1, expected_lr): self.assertEqual(a, b, msg=f"LR is wrong ! expected {b}, got {a}") + def test_error(self): + """Should fail because warmup_multiplier is outside 0..1""" + net = SchedulerTestNet() + optimizer = torch.optim.Adam(net.parameters(), lr=1.0) + with self.assertRaises(ValueError): + WarmupCosineSchedule(optimizer, warmup_steps=2, t_total=10, warmup_multiplier=-1) + if __name__ == "__main__": unittest.main() From 652511f1273dfe352e9e7626806a7cb3c9c82426 Mon Sep 17 00:00:00 2001 From: Holger Roth <6304754+holgerroth@users.noreply.github.com> Date: Wed, 19 Oct 2022 06:58:07 -0400 Subject: [PATCH 51/60] MonaiAlgo: fix logging in multi-gpu training (#5355) Signed-off-by: Holger Roth Fixes #5354. ### Description Previous output: ``` Current output: 2022-10-18 12:45:59,679 - MonaiAlgo - INFO - Using multi-gpu training on rank 1 (available devices: 2) 2022-10-18 12:45:59,681 - MonaiAlgo - INFO - Using multi-gpu training on rank 0 (available devices: 2) 2022-10-18 12:49:48,790 - ignite.engine.engine.SupervisedTrainer - INFO - Got new best metric of train_accuracy: 0.802879669048168 2022-10-18 12:49:48,790 - ignite.engine.engine.SupervisedTrainer - INFO - Got new best metric of train_accuracy: 0.802879669048168 2022-10-18 12:49:56,579 - ignite.engine.engine.SupervisedEvaluator - INFO - Got new best metric of val_mean_dice: 0.1470419466495514 2022-10-18 12:49:56,579 - ignite.engine.engine.SupervisedEvaluator - INFO - Got new best metric of val_mean_dice: 0.1470419466495514 ``` Output after fix: ``` 2022-10-18 12:51:05,400 - MonaiAlgo - INFO - Using multi-gpu training on rank 0 (available devices: 2) 2022-10-18 12:51:05,410 - MonaiAlgo - INFO - Using multi-gpu training on rank 1 (available devices: 2) 2022-10-18 12:53:09,889 - ignite.engine.engine.SupervisedTrainer - INFO - Got new best metric of train_accuracy: 0.6750877521656178 2022-10-18 12:53:25,170 - ignite.engine.engine.SupervisedEvaluator - INFO - Got new best metric of val_mean_dice: 0.06980131566524506 ``` ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Holger Roth --- monai/fl/client/monai_algo.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/monai/fl/client/monai_algo.py b/monai/fl/client/monai_algo.py index 363d4fc122..f18e7faa63 100644 --- a/monai/fl/client/monai_algo.py +++ b/monai/fl/client/monai_algo.py @@ -494,6 +494,11 @@ def initialize(self, extra=None): BundleKeys.DATASET_DIR, default=ConfigItem(None, BundleKeys.DATASET_DIR) ) + if self.multi_gpu: + if self.rank > 0 and self.trainer: + self.trainer.logger.setLevel(logging.WARNING) + if self.rank > 0 and self.evaluator: + self.evaluator.logger.setLevel(logging.WARNING) self.logger.info(f"Initialized {self.client_name}.") def train(self, data: ExchangeObject, extra=None): From 10ab34a46a95458d72da08f8005a173ef8d3391d Mon Sep 17 00:00:00 2001 From: myron Date: Wed, 19 Oct 2022 05:49:16 -0700 Subject: [PATCH 52/60] str2list utility for commandline parsing of comma separated lists (#5358) This adds a utility function str2list to convert a string to a list. Useful with argparse commandline arguments: ``` parser.add_argument("--blocks", default=[1,2,3], type=str2list) ... python mycode.py --blocks=1,2,2,4 ``` Unit tests added. It also includes a small fix for str2bool to accept input as bool (and return it right away). ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ x Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x]Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [x] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: myron --- monai/utils/__init__.py | 1 + monai/utils/misc.py | 47 ++++++++++++++++++++++++++++++++++++++--- tests/test_str2bool.py | 4 ++-- tests/test_str2list.py | 30 ++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/test_str2list.py diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 8eccac8f70..c5419cb9af 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -78,6 +78,7 @@ set_determinism, star_zip_with, str2bool, + str2list, zip_with, ) from .module import ( diff --git a/monai/utils/misc.py b/monai/utils/misc.py index d4bea4d27c..1071f37840 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -47,6 +47,7 @@ "MAX_SEED", "copy_to_device", "str2bool", + "str2list", "MONAIEnvVars", "ImageMetaKey", "is_module_ver_at_least", @@ -363,21 +364,29 @@ def copy_to_device( return obj -def str2bool(value: str, default: bool = False, raise_exc: bool = True) -> bool: +def str2bool(value: Union[str, bool], default: bool = False, raise_exc: bool = True) -> bool: """ Convert a string to a boolean. Case insensitive. True: yes, true, t, y, 1. False: no, false, f, n, 0. Args: - value: string to be converted to a boolean. + value: string to be converted to a boolean. If value is a bool already, simply return it. raise_exc: if value not in tuples of expected true or false inputs, - should we raise an exception? If not, return `None`. + should we raise an exception? If not, return `default`. Raises ValueError: value not in tuples of expected true or false inputs and `raise_exc` is `True`. + Useful with argparse, for example: + parser.add_argument("--convert", default=False, type=str2bool) + python mycode.py --convert=True """ + + if isinstance(value, bool): + return value + true_set = ("yes", "true", "t", "y", "1") false_set = ("no", "false", "f", "n", "0") + if isinstance(value, str): value = value.lower() if value in true_set: @@ -390,6 +399,38 @@ def str2bool(value: str, default: bool = False, raise_exc: bool = True) -> bool: return default +def str2list(value: Optional[Union[str, list]], raise_exc: bool = True) -> Optional[list]: + """ + Convert a string to a list. Useful with argparse commandline arguments: + parser.add_argument("--blocks", default=[1,2,3], type=str2list) + python mycode.py --blocks=1,2,2,4 + + Args: + value: string (comma separated) to be converted to a list + raise_exc: if not possible to convert to a list, raise an exception + Raises + ValueError: value not a string or list or not possible to convert + """ + + if value is None: + return None + elif isinstance(value, list): + return value + elif isinstance(value, str): + v = value.split(",") + for i in range(len(v)): + try: + a = literal_eval(v[i].strip()) # attempt to convert + v[i] = a + except Exception: + pass + return v + elif raise_exc: + raise ValueError(f'Unable to convert "{value}", expected a comma-separated str, e.g. 1,2,3') + + return None + + class MONAIEnvVars: """ Environment variables used by MONAI. diff --git a/tests/test_str2bool.py b/tests/test_str2bool.py index a7132aa97e..e1d9ca1ee3 100644 --- a/tests/test_str2bool.py +++ b/tests/test_str2bool.py @@ -16,9 +16,9 @@ class TestStr2Bool(unittest.TestCase): def test_str_2_bool(self): - for i in ("yes", "true", "t", "y", "1"): + for i in ("yes", "true", "t", "y", "1", True): self.assertTrue(str2bool(i)) - for i in ("no", "false", "f", "n", "0"): + for i in ("no", "false", "f", "n", "0", False): self.assertFalse(str2bool(i)) for bad_value in ("test", 0, 1, 2, None): self.assertFalse(str2bool(bad_value, default=False, raise_exc=False)) diff --git a/tests/test_str2list.py b/tests/test_str2list.py new file mode 100644 index 0000000000..95a4dcaef0 --- /dev/null +++ b/tests/test_str2list.py @@ -0,0 +1,30 @@ +# 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 monai.utils.misc import str2list + + +class TestStr2List(unittest.TestCase): + def test_str_2_list(self): + for i in ("1,2,3", "1, 2, 3", "1,2e-0,3.0", [1, 2, 3]): + self.assertEqual(str2list(i), [1, 2, 3]) + for i in ("1,2,3", "1,2,3,4.3", [1, 2, 3, 4.001]): + self.assertNotEqual(str2list(i), [1, 2, 3, 4]) + for bad_value in ((1, 3), int): + self.assertIsNone(str2list(bad_value, raise_exc=False)) + with self.assertRaises(ValueError): + self.assertIsNone(str2list(bad_value)) + + +if __name__ == "__main__": + unittest.main() From 9c423b66db293ab563b47ffacf421b8b195cde86 Mon Sep 17 00:00:00 2001 From: Holger Roth <6304754+holgerroth@users.noreply.github.com> Date: Thu, 20 Oct 2022 09:57:27 -0400 Subject: [PATCH 53/60] update fl docs (#5364) Signed-off-by: Holger Roth Fixes #5363. ### Description Update what's new and module description with new Monai FL features. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Holger Roth --- docs/source/fl.rst | 16 ++++++++++++++-- docs/source/modules.md | 16 +++++++++++----- docs/source/whatsnew_1_0.md | 11 ++++++++--- monai/fl/client/client_algo.py | 11 +++++++---- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/docs/source/fl.rst b/docs/source/fl.rst index cd4bc587ec..412063013e 100644 --- a/docs/source/fl.rst +++ b/docs/source/fl.rst @@ -6,11 +6,23 @@ Federated Learning ================== .. currentmodule:: monai.fl.client -`ClientAlgo` ------------- +`Client Base Classes` +--------------------- + +.. autoclass:: BaseClient + :members: .. autoclass:: ClientAlgo :members: +.. autoclass:: ClientAlgoStats + :members: + +`MONAI Bundle Reference Implementations` +---------------------------------------- + .. autoclass:: MonaiAlgo :members: + +.. autoclass:: MonaiAlgoStats + :members: diff --git a/docs/source/modules.md b/docs/source/modules.md index a4f9252713..0063466b80 100644 --- a/docs/source/modules.md +++ b/docs/source/modules.md @@ -270,15 +270,21 @@ A step-by-step [get started](https://github.com/Project-MONAI/tutorials/blob/mas ![federated-learning](../images/federated.svg) -Using the MONAI bundle configurations, we can use MONAI's [`MonaiAlgo`](https://docs.monai.io/en/latest/fl.html#monai.fl.client.MonaiAlgo) class (an implementation of the abstract [`ClientAlgo`](https://docs.monai.io/en/latest/fl.html#clientalgo) class for federated learning) +Using the MONAI bundle configurations, we can use MONAI's [`MonaiAlgo`](https://docs.monai.io/en/latest/fl.html#monai.fl.client.MonaiAlgo) +class, an implementation of the abstract [`ClientAlgo`](https://docs.monai.io/en/latest/fl.html#clientalgo) class for federated learning (FL), to execute bundles from the [MONAI model zoo](https://github.com/Project-MONAI/model-zoo). -Note that [`ClientAlgo`](https://docs.monai.io/en/latest/fl.html#clientalgo) is provided as an abstract base class for defining an algorithm to be run on any federated learning platform. -[`MonaiAlgo`](https://docs.monai.io/en/latest/fl.html#monai.fl.client.MonaiAlgo) implements the main functionalities needed to run federated learning experiments, namely `train()`, `get_weights()`, and `evaluate()`. +Note that [`ClientAlgo`](https://docs.monai.io/en/latest/fl.html#clientalgo) is provided as an abstract base class for +defining an algorithm to be run on any federated learning platform. +[`MonaiAlgo`](https://docs.monai.io/en/latest/fl.html#monai.fl.client.MonaiAlgo) implements the main functionalities needed +to run federated learning experiments, namely `train()`, `get_weights()`, and `evaluate()`, that can be run using single- or multi-GPU training. On top, it provides implementations for life-cycle management of the component such as `initialize()`, `abort()`, and `finalize()`. - +The MONAI FL client also allows computing summary data statistics (e.g., intensity histograms) on the datasets defined in the bundle configs +using the [`MonaiAlgoStats`](https://docs.monai.io/en/latest/fl.html#monai.fl.client.MonaiAlgoStats) class. +These statistics can be shared and visualized on the FL server. [NVIDIA FLARE](https://github.com/NVIDIA/NVFlare), the federated learning platform developed by NVIDIA, has already built [the integration piece](https://github.com/NVIDIA/NVFlare/tree/2.2/integration/monai) with [`ClientAlgo`](https://docs.monai.io/en/latest/fl.html#clientalgo) to allow easy experimentation with MONAI bundles within their federated environment. -[[Federated learning tutorial]](https://github.com/Project-MONAI/tutorials/tree/main/federated_learning/nvflare) +Our [[federated learning tutorials]](https://github.com/Project-MONAI/tutorials/tree/main/federated_learning/nvflare) shows +examples of single- & multi-GPU training and federated statistics workflows. ## Auto3dseg diff --git a/docs/source/whatsnew_1_0.md b/docs/source/whatsnew_1_0.md index bb30451746..36ab393af1 100644 --- a/docs/source/whatsnew_1_0.md +++ b/docs/source/whatsnew_1_0.md @@ -31,11 +31,16 @@ It has been tested on large-scale 3D medical imaging datasets in different modal ## Federated Learning Client ![federated-learning](../images/federated.svg) -MONAI now includes the federated learning client algorithm APIs that are exposed as an abstract base class +MONAI now includes the federated learning (FL) client algorithm APIs that are exposed as an abstract base class for defining an algorithm to be run on any federated learning platform. [NVIDIA FLARE](https://github.com/NVIDIA/NVFlare), the federated learning platform developed by [NVIDIA](https://www.nvidia.com/en-us/), -has already built [the integration piece](https://github.com/NVIDIA/NVFlare/tree/2.2/integration/monai) with these new APIs. -With [the new federated learning APIs](https://docs.monai.io/en/latest/fl.html), MONAI bundles can seamlessly be extended to a federated paradigm. +has already built [the integration piece](https://github.com/NVIDIA/NVFlare/tree/dev/integration/monai) with these new APIs. +With [the new federated learning APIs](https://docs.monai.io/en/latest/fl.html), MONAI bundles can seamlessly be extended to a federated paradigm +and executed using single- or multi-GPU training. +The MONAI FL client also allows computing summary data statistics (e.g., intensity histograms) on the datasets defined in the bundle configs. +These can be shared and visualized on the FL server, for example, using NVIDIA FLARE's federated statistics operators, +see [here](https://github.com/NVIDIA/NVFlare/tree/dev/integration/monai/examples/spleen_ct_segmentation) for an example. + We welcome other federated learning toolkits to integrate with MONAI FL APIs, building a common foundation for collaborative learning in medical imaging. diff --git a/monai/fl/client/client_algo.py b/monai/fl/client/client_algo.py index 77ff2a4440..9c54f2891b 100644 --- a/monai/fl/client/client_algo.py +++ b/monai/fl/client/client_algo.py @@ -19,11 +19,11 @@ class BaseClient: Provide an abstract base class to allow the client to return summary statistics of the data. To define a new stats script, subclass this class and implement the - following abstract methods: + following abstract methods:: - self.get_data_stats() - initialize(), abort(), and finalize() - inherited from `ClientAlgoStats` - can be optionally be implemented + initialize(), abort(), and finalize() -- inherited from `ClientAlgoStats`; can be optionally be implemented to help with lifecycle management of the class object. """ @@ -66,20 +66,23 @@ def get_data_stats(self, extra: Optional[dict] = None) -> ExchangeObject: For example, requested statistics. Returns: + ExchangeObject: summary statistics. - Extra dict example: + Extra dict example:: + requested_stats = { FlStatistics.STATISTICS: metrics, FlStatistics.NUM_OF_BINS: num_of_bins, FlStatistics.BIN_RANGES: bin_ranges } - Returned ExchangeObject example: + Returned ExchangeObject example:: ExchangeObject( statistics = {...} ) + """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") From 4609206d3fc2a70389a5a88d4c5c761108c96bd9 Mon Sep 17 00:00:00 2001 From: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> Date: Fri, 21 Oct 2022 00:41:31 +0800 Subject: [PATCH 54/60] Remove meta_dict and fix affine to spacing conversion (#5367) Signed-off-by: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> Fixes #5201 Fixes #5332 ### Description - Remove deprecated meta_dict usage from Auto3DSeg. - Fix affine -> spacing conversion - Update docstring - Change "pixel_percentage" to "foreground_percentage" to unify foreground "pixel"/"voxel" naming ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] In-line docstrings updated. Signed-off-by: Mingxin Zheng <18563433+mingxin-zheng@users.noreply.github.com> --- monai/auto3dseg/analyzer.py | 55 +++++++++++++++++++++---------------- monai/utils/enums.py | 2 +- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/monai/auto3dseg/analyzer.py b/monai/auto3dseg/analyzer.py index 68f398402d..19cd95b906 100644 --- a/monai/auto3dseg/analyzer.py +++ b/monai/auto3dseg/analyzer.py @@ -29,7 +29,7 @@ ) from monai.bundle.config_parser import ConfigParser from monai.bundle.utils import ID_SEP_KEY -from monai.data.meta_tensor import MetaTensor +from monai.data import MetaTensor, affine_to_spacing from monai.transforms.transform import MapTransform from monai.transforms.utils_pytorch_numpy_unification import sum, unique from monai.utils import convert_to_numpy @@ -174,32 +174,33 @@ class ImageStats(Analyzer): Args: image_key: the key to find image data in the callable function input (data) - meta_key_postfix: the postfix to append for meta_dict ("image_meta_dict"). Examples: .. code-block:: python import numpy as np - from monai.auto3dseg.analyzer import ImageStats + from monai.auto3dseg import ImageStats + from monai.data import MetaTensor input = {} input['image'] = np.random.rand(1,30,30,30) - input['image_meta_dict'] = {'affine': np.eye(4)} + input['image'] = MetaTensor(np.random.rand(1,30,30,30)) # MetaTensor analyzer = ImageStats(image_key="image") - print(analyzer(input)) + print(analyzer(input)["image_stats"]) + + Notes: + if the image data is NumPy array, the spacing stats will be [1.0] * `ndims` of the array, + where the `ndims` is the lesser value between the image dimension and 3. """ - def __init__( - self, image_key: str, stats_name: str = "image_stats", meta_key_postfix: Optional[str] = "meta_dict" - ) -> None: + def __init__(self, image_key: str, stats_name: str = "image_stats") -> None: if not isinstance(image_key, str): raise ValueError("image_key input must be str") self.image_key = image_key - self.image_meta_key = f"{self.image_key}_{meta_key_postfix}" report_format = { ImageStatsKeys.SHAPE: None, @@ -245,9 +246,11 @@ def __call__(self, data): report[ImageStatsKeys.SHAPE] = [list(nda.shape) for nda in ndas] report[ImageStatsKeys.CHANNELS] = len(ndas) report[ImageStatsKeys.CROPPED_SHAPE] = [list(nda_c.shape) for nda_c in nda_croppeds] - report[ImageStatsKeys.SPACING] = np.tile( - np.diag(data[self.image_meta_key]["affine"])[:3], [len(ndas), 1] - ).tolist() + report[ImageStatsKeys.SPACING] = ( + affine_to_spacing(data[self.image_key].affine).tolist() + if isinstance(data[self.image_key], MetaTensor) + else [1.0] * min(3, data[self.image_key].ndim) + ) report[ImageStatsKeys.INTENSITY] = [ self.ops[ImageStatsKeys.INTENSITY].evaluate(nda_c) for nda_c in nda_croppeds ] @@ -275,13 +278,13 @@ class FgImageStats(Analyzer): .. code-block:: python import numpy as np - from monai.auto3dseg.analyzer import FgImageStats + from monai.auto3dseg import FgImageStats input = {} input['image'] = np.random.rand(1,30,30,30) input['label'] = np.ones([30,30,30]) analyzer = FgImageStats(image_key='image', label_key='label') - print(analyzer(input)) + print(analyzer(input)["image_foreground_stats"]) """ @@ -353,13 +356,13 @@ class LabelStats(Analyzer): .. code-block:: python import numpy as np - from monai.auto3dseg.analyzer import LabelStats + from monai.auto3dseg import LabelStats input = {} input['image'] = np.random.rand(1,30,30,30) input['label'] = np.ones([30,30,30]) analyzer = LabelStats(image_key='image', label_key='label') - print(analyzer(input)) + print(analyzer(input)["label_stats"]) """ @@ -436,7 +439,10 @@ def __call__(self, data): """ d = dict(data) start = time.time() - using_cuda = True if d[self.image_key].device.type == "cuda" else False + if isinstance(d[self.image_key], (torch.Tensor, MetaTensor)) and d[self.image_key].device.type == "cuda": + using_cuda = True + else: + using_cuda = False restore_grad_state = torch.is_grad_enabled() torch.set_grad_enabled(False) @@ -784,20 +790,21 @@ class FilenameStats(Analyzer): """ - def __init__(self, key: str, stats_name: str, meta_key_postfix: Optional[str] = "meta_dict") -> None: + def __init__(self, key: str, stats_name: str) -> None: self.key = key - self.meta_key = None if key is None else f"{key}_{meta_key_postfix}" super().__init__(stats_name, {}) def __call__(self, data): d = dict(data) - if self.meta_key: + if self.key: # when there is no (label) file, key can be None if self.key not in d: # check whether image/label is in the data - raise ValueError(f"Data with key {self.key} is missing ") - if self.meta_key not in d: - raise ValueError(f"Meta data with key {self.meta_key} is missing") - d[self.stats_name] = d[self.meta_key][ImageMetaKey.FILENAME_OR_OBJ] + raise ValueError(f"Data with key {self.key} is missing.") + if not isinstance(d[self.key], MetaTensor): + raise ValueError(f"Value type of {self.key} is not MetaTensor.") + if ImageMetaKey.FILENAME_OR_OBJ not in d[self.key].meta: + raise ValueError(f"{ImageMetaKey.FILENAME_OR_OBJ} not found in MetaTensor {d[self.key]}.") + d[self.stats_name] = d[self.key].meta[ImageMetaKey.FILENAME_OR_OBJ] else: d[self.stats_name] = "None" diff --git a/monai/utils/enums.py b/monai/utils/enums.py index d77de4e6d3..dc4b69983a 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -577,7 +577,7 @@ class LabelStatsKeys(StrEnum): """ LABEL_UID = "labels" - PIXEL_PCT = "pixel_percentage" + PIXEL_PCT = "foreground_percentage" IMAGE_INTST = "image_intensity" LABEL = "label" LABEL_SHAPE = "shape" From f6325e2e345c8ef5e99261e781c50f1070604975 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 21 Oct 2022 12:41:00 +0100 Subject: [PATCH 55/60] adding premerge tests for py3.10 (#5370) ### Description adding tests for python 3.10 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- .github/workflows/pythonapp-min.yml | 2 +- .github/workflows/setupapp.yml | 2 +- requirements-dev.txt | 2 +- tests/test_ensure_channel_first.py | 29 ++++++++++++---------- tests/test_image_rw.py | 7 +++++- tests/test_load_image.py | 37 +++++++++++++++++++---------- tests/test_load_imaged.py | 7 +++++- tests/test_meta_tensor.py | 10 ++++---- tests/test_resample_to_match.py | 4 ++++ tests/test_save_image.py | 4 ++++ tests/test_save_imaged.py | 4 ++++ 11 files changed, 74 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pythonapp-min.yml b/.github/workflows/pythonapp-min.yml index ba3432ed70..f9c46b880b 100644 --- a/.github/workflows/pythonapp-min.yml +++ b/.github/workflows/pythonapp-min.yml @@ -74,7 +74,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9, '3.10'] timeout-minutes: 40 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 3d69da3737..5d991e3e43 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -78,7 +78,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9, '3.10'] steps: - uses: actions/checkout@v3 with: diff --git a/requirements-dev.txt b/requirements-dev.txt index e71bc88593..8b3faee47f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,7 @@ pytorch-ignite==0.4.10 gdown>=4.4.0 scipy -itk>=5.2 +itk>=5.2; python_version < "3.10" nibabel pillow!=8.3.0 # https://github.com/python-pillow/Pillow/issues/5571 tensorboard diff --git a/tests/test_ensure_channel_first.py b/tests/test_ensure_channel_first.py index fca2f90139..53490a6135 100644 --- a/tests/test_ensure_channel_first.py +++ b/tests/test_ensure_channel_first.py @@ -13,16 +13,18 @@ import tempfile import unittest -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 monai.utils import optional_import + +itk, has_itk = optional_import("itk", allow_namespace_pkg=True) +ITKReader, _ = optional_import("monai.data", name="ITKReader", as_type="decorator") TEST_CASE_1 = [{}, ["test_image.nii.gz"], None] @@ -30,19 +32,20 @@ TEST_CASE_3 = [{}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], None] -TEST_CASE_4 = [{"reader": ITKReader()}, ["test_image.nii.gz"], None] - -TEST_CASE_5 = [{"reader": ITKReader()}, ["test_image.nii.gz"], -1] - -TEST_CASE_6 = [{"reader": ITKReader()}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], None] +TEST_CASE_4 = [{"reader": ITKReader() if has_itk else "itkreader"}, ["test_image.nii.gz"], None] -TEST_CASE_7 = [{"reader": ITKReader(pixel_type=itk.UC)}, "tests/testing_data/CT_DICOM", None] +TEST_CASE_5 = [{"reader": ITKReader() if has_itk else "itkreader"}, ["test_image.nii.gz"], -1] -itk.ProcessObject.SetGlobalWarningDisplay(False) +TEST_CASE_6 = [ + {"reader": ITKReader() if has_itk else "itkreader"}, + ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], + None, +] class TestEnsureChannelFirst(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) + @unittest.skipUnless(has_itk, "itk not installed") def test_load_nifti(self, input_param, filenames, original_channel_dim): if original_channel_dim is None: test_image = np.random.rand(8, 8, 8) @@ -58,9 +61,11 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim): 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(image_only=True, **input_param)(filenames) + @unittest.skipUnless(has_itk, "itk not installed") + def test_itk_dicom_series_reader(self): + filenames = "tests/testing_data/CT_DICOM" + itk.ProcessObject.SetGlobalWarningDisplay(False) + result = LoadImage(image_only=True, reader=ITKReader(pixel_type=itk.UC))(filenames) result = EnsureChannelFirst()(result) self.assertEqual(result.shape[0], 1) diff --git a/tests/test_image_rw.py b/tests/test_image_rw.py index 80b7304ea2..35fd61b2dc 100644 --- a/tests/test_image_rw.py +++ b/tests/test_image_rw.py @@ -23,10 +23,13 @@ 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 MetaKeys, OptionalImportError +from monai.utils import MetaKeys, OptionalImportError, optional_import from tests.utils import TEST_NDARRAYS, assert_allclose +_, has_itk = optional_import("itk", allow_namespace_pkg=True) + +@unittest.skipUnless(has_itk, "itk not installed") class TestLoadSaveNifti(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() @@ -82,6 +85,7 @@ def test_4d(self, reader, writer): self.nifti_rw(test_data, reader, writer, np.float16) +@unittest.skipUnless(has_itk, "itk not installed") class TestLoadSavePNG(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() @@ -137,6 +141,7 @@ def test_1_new(self): self.assertEqual(resolve_writer("new")[0](0), 1) +@unittest.skipUnless(has_itk, "itk not installed") class TestLoadSaveNrrd(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() diff --git a/tests/test_load_image.py b/tests/test_load_image.py index 7dffa845ba..834c9f9d59 100644 --- a/tests/test_load_image.py +++ b/tests/test_load_image.py @@ -15,19 +15,23 @@ import unittest from pathlib import Path -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 import NibabelReader, PydicomReader from monai.data.meta_obj import set_track_meta from monai.data.meta_tensor import MetaTensor from monai.transforms import LoadImage +from monai.utils import optional_import from tests.utils import assert_allclose +itk, has_itk = optional_import("itk", allow_namespace_pkg=True) +ITKReader, _ = optional_import("monai.data", name="ITKReader", as_type="decorator") +itk_uc, _ = optional_import("itk", name="UC", allow_namespace_pkg=True) + class _MiniReader: """a test case customised reader""" @@ -67,34 +71,39 @@ def get_data(self, _obj): TEST_CASE_5 = [{"reader": NibabelReader(mmap=False)}, ["test_image.nii.gz"], (128, 128, 128)] -TEST_CASE_6 = [{"reader": ITKReader()}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_6 = [{"reader": ITKReader() if has_itk else "itkreader"}, ["test_image.nii.gz"], (128, 128, 128)] -TEST_CASE_7 = [{"reader": ITKReader()}, ["test_image.nii.gz"], (128, 128, 128)] +TEST_CASE_7 = [{"reader": ITKReader() if has_itk else "itkreader"}, ["test_image.nii.gz"], (128, 128, 128)] TEST_CASE_8 = [ - {"reader": ITKReader()}, + {"reader": ITKReader() if has_itk else "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)}, + {"reader": ITKReader(channel_dim=0) if has_itk else "itkreader"}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (384, 128, 128), ] TEST_CASE_9 = [ - {"reader": ITKReader()}, + {"reader": ITKReader() if has_itk else "itkreader"}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128), ] -TEST_CASE_10 = [{"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) if has_itk else "itkreader"}, + "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_11 = [{"reader": "ITKReader", "pixel_type": itk_uc}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4)] TEST_CASE_12 = [ - {"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), @@ -124,14 +133,14 @@ def get_data(self, _obj): TEST_CASE_19 = [{"reader": PydicomReader()}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4)] TEST_CASE_20 = [ - {"reader": "PydicomReader", "ensure_channel_first": True}, + {"reader": "PydicomReader", "ensure_channel_first": True, "force": True}, "tests/testing_data/CT_DICOM", (16, 16, 4), (1, 16, 16, 4), ] TEST_CASE_21 = [ - {"reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"}, + {"reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB", "force": True}, "tests/testing_data/CT_DICOM", (16, 16, 4), (16, 16, 4), @@ -146,6 +155,7 @@ def get_data(self, _obj): TESTS_META.append([{"reader": "ITKReader", "fallback_only": False}, (128, 128, 128), track_meta]) +@unittest.skipUnless(has_itk, "itk not installed") 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] @@ -290,7 +300,7 @@ def test_my_reader(self): def test_itk_meta(self): """test metadata from a directory""" - out = LoadImage(image_only=True, reader="ITKReader", pixel_type=itk.UC, series_meta=True)( + out = LoadImage(image_only=True, reader="ITKReader", pixel_type=itk_uc, series_meta=True)( "tests/testing_data/CT_DICOM" ) idx = "0008|103e" @@ -313,6 +323,7 @@ def test_channel_dim(self, input_param, filename, expected_shape): self.assertEqual(result.meta["original_channel_dim"], input_param["channel_dim"]) +@unittest.skipUnless(has_itk, "itk not installed") class TestLoadImageMeta(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/test_load_imaged.py b/tests/test_load_imaged.py index cd8b476a58..8210d2f0d1 100644 --- a/tests/test_load_imaged.py +++ b/tests/test_load_imaged.py @@ -15,7 +15,6 @@ import unittest from pathlib import Path -import itk import nibabel as nib import numpy as np import torch @@ -26,8 +25,11 @@ 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 monai.utils import optional_import from tests.utils import assert_allclose +itk, has_itk = optional_import("itk", allow_namespace_pkg=True) + KEYS = ["image", "label", "extra"] TEST_CASE_1 = [{"keys": KEYS}, (128, 128, 128)] @@ -40,6 +42,7 @@ TESTS_META.append([{"keys": KEYS, "reader": "ITKReader", "fallback_only": False}, (128, 128, 128), track_meta]) +@unittest.skipUnless(has_itk, "itk not installed") class TestLoadImaged(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) def test_shape(self, input_param, expected_shape): @@ -87,6 +90,7 @@ def test_no_file(self): LoadImaged(keys="img", reader="nibabelreader", image_only=True)({"img": "unknown"}) +@unittest.skipUnless(has_itk, "itk not installed") class TestConsistency(unittest.TestCase): def _cmp(self, filename, ch_shape, reader_1, reader_2, outname, ext): data_dict = {"img": filename} @@ -147,6 +151,7 @@ def test_png(self): self._cmp(filename, (3, 224, 256), "itkreader", "nibabelreader", output_name, ".png") +@unittest.skipUnless(has_itk, "itk not installed") class TestLoadImagedMeta(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py index 2f873c2d73..b46905f3c1 100644 --- a/tests/test_meta_tensor.py +++ b/tests/test_meta_tensor.py @@ -499,7 +499,7 @@ def test_construct_with_pre_applied_transforms(self): 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) + t = MetaTensor([0, 0] if dtype in (torch.int32, torch.int64) else [0.0, 0.0], device=device, dtype=dtype) t.is_batch = True if t.is_cuda: with self.assertRaises(NotImplementedError): @@ -518,7 +518,9 @@ def test_array_function(self, device="cpu", dtype=float): assert_allclose(np.sum(a), np.sum(b)) assert_allclose(np.sum(a, axis=1), np.sum(b, axis=1)) assert_allclose(np.linalg.qr(a), np.linalg.qr(b)) - c = MetaTensor([1.0, 2.0, 3.0], device=device, dtype=dtype) + c = MetaTensor( + [1, 2, 3] if dtype in (torch.int32, torch.int64) else [1.0, 2.0, 3.0], device=device, dtype=dtype + ) assert_allclose(np.argwhere(c == 1.0).astype(int).tolist(), [[0]]) assert_allclose(np.concatenate([c, c]), np.asarray([1.0, 2.0, 3.0, 1.0, 2.0, 3.0])) if pytorch_after(1, 8, 1): @@ -530,7 +532,7 @@ def test_array_function(self, device="cpu", dtype=float): @parameterized.expand(TESTS) def test_numpy(self, device=None, dtype=None): """device, dtype""" - t = MetaTensor([0.0], device=device, dtype=dtype) + t = MetaTensor([0 if dtype in (torch.int32, torch.int64) else 0.0], device=device, dtype=dtype) self.assertIsInstance(t, MetaTensor) assert_allclose(t.array, np.asarray([0.0])) t.array = np.asarray([1.0]) @@ -540,7 +542,7 @@ def test_numpy(self, device=None, dtype=None): self.check_meta(t, MetaTensor([2.0])) assert_allclose(t.as_tensor(), torch.as_tensor([2.0])) if not t.is_cuda: - t.array[0] = torch.as_tensor(3.0, device=device, dtype=dtype) + t.array[0] = torch.as_tensor(3 if dtype in (torch.int32, torch.int64) else 3.0, device=device, dtype=dtype) self.check_meta(t, MetaTensor([3.0])) assert_allclose(t.as_tensor(), torch.as_tensor([3.0])) diff --git a/tests/test_resample_to_match.py b/tests/test_resample_to_match.py index d419dc3525..30df565a26 100644 --- a/tests/test_resample_to_match.py +++ b/tests/test_resample_to_match.py @@ -24,8 +24,11 @@ from monai.data.image_reader import ITKReader, NibabelReader from monai.data.image_writer import ITKWriter from monai.transforms import Compose, EnsureChannelFirstd, LoadImaged, ResampleToMatch, SaveImaged +from monai.utils import optional_import from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config +_, has_itk = optional_import("itk", allow_namespace_pkg=True) + TEST_CASES = ["itkreader", "nibabelreader"] @@ -36,6 +39,7 @@ def get_rand_fname(len=10, suffix=".nii.gz"): return out +@unittest.skipUnless(has_itk, "itk not installed") class TestResampleToMatch(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/test_save_image.py b/tests/test_save_image.py index 6591283c22..1f4039763e 100644 --- a/tests/test_save_image.py +++ b/tests/test_save_image.py @@ -18,6 +18,9 @@ from monai.data.meta_tensor import MetaTensor from monai.transforms import SaveImage +from monai.utils import optional_import + +_, has_itk = optional_import("itk", allow_namespace_pkg=True) TEST_CASE_1 = [torch.randint(0, 255, (1, 2, 3, 4)), {"filename_or_obj": "testfile0.nii.gz"}, ".nii.gz", False] @@ -33,6 +36,7 @@ ] +@unittest.skipUnless(has_itk, "itk not installed") 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): diff --git a/tests/test_save_imaged.py b/tests/test_save_imaged.py index 96b6fb1626..4b079b73fd 100644 --- a/tests/test_save_imaged.py +++ b/tests/test_save_imaged.py @@ -18,6 +18,9 @@ from monai.data.meta_tensor import MetaTensor from monai.transforms import SaveImaged +from monai.utils import optional_import + +_, has_itk = optional_import("itk", allow_namespace_pkg=True) TEST_CASE_1 = [ {"img": MetaTensor(torch.randint(0, 255, (1, 2, 3, 4)), meta={"filename_or_obj": "testfile0.nii.gz"})}, @@ -44,6 +47,7 @@ ] +@unittest.skipUnless(has_itk, "itk not installed") class TestSaveImaged(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_saved_content(self, test_data, output_ext, resample): From 8a7747d9bb338b999e8e09eb7c916b81ba8081cf Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 21 Oct 2022 12:49:33 +0100 Subject: [PATCH 56/60] releasing test py310 Signed-off-by: Wenqi Li --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb350ceb2d..9db67af48a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9, '3.10'] steps: - uses: actions/checkout@v3 with: From 52a7fde440123460f2ce13a515a0f3d023292f95 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 21 Oct 2022 13:15:58 +0100 Subject: [PATCH 57/60] skip test.pypi.org Signed-off-by: Wenqi Li --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9db67af48a..ed4c7d3f25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,12 +77,12 @@ jobs: rm dist/monai*.tar.gz ls -al dist/ - - if: matrix.python-version == '3.8' && startsWith(github.ref, 'refs/tags/') - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.TEST_PYPI }} - repository_url: https://test.pypi.org/legacy/ + # - if: matrix.python-version == '3.8' && startsWith(github.ref, 'refs/tags/') + # name: Publish to Test PyPI + # uses: pypa/gh-action-pypi-publish@release/v1 + # with: + # password: ${{ secrets.TEST_PYPI }} + # repository_url: https://test.pypi.org/legacy/ versioning: # compute versioning file from python setup.py From ce7fc18451224b12348c4a7e1adc6ca90dc915b7 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Fri, 21 Oct 2022 15:24:15 +0100 Subject: [PATCH 58/60] 5284 adds a changelog v1.0.1 (#5319) Signed-off-by: Wenqi Li part of #5284 ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [x] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c09d707ff3..6c0ad44d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [1.0.1] - 2022-10-24 +### Fixes +* DiceCELoss for multichannel targets +* Auto3DSeg DataAnalyzer out-of-memory error and other minor issues +* An optional flag issue in the RetinaNet detector +* An issue with output offset for Spacing +* A `LoadImage` issue when `track_meta` is `False` +* 1D data output error in `VarAutoEncoder` +* An issue with resolution computing in `ImageStats` +### Added +* Flexible min/max pixdim options for Spacing +* Upsample mode `deconvgroup` and optional kernel sizes +* Docstrings for gradient-based saliency maps +* Occlusion sensitivity to use sliding window inference +* Enhanced Gaussian window and device assignments for sliding window inference +* Multi-GPU support for MonaiAlgo +* `ClientAlgoStats` and `MonaiAlgoStats` for federated summary statistics +* MetaTensor support for `OneOf` +* Add a file check for bundle logging config +* Additional content and an authentication token option for bundle info API +* An anti-aliasing option for `Resized` +* `SlidingWindowInferer` adaptive device based on `cpu_thresh` +* `SegResNetDS` with deep supervision and non-isotropic kernel support +* Premerge tests for Python 3.10 +### Changed +* Base Docker image upgraded to `nvcr.io/nvidia/pytorch:22.09-py3` from `nvcr.io/nvidia/pytorch:22.08-py3` +* Replace `None` type metadata content with `"none"` for `collate_fn` compatibility +* HoVerNet Mode and Branch to independent StrEnum +* Automatically infer device from the first item in random elastic deformation dict +* Add channel dim in `ComputeHoVerMaps` and `ComputeHoVerMapsd` +* Remove batch dim in `SobelGradients` and `SobelGradientsd` + ## [1.0.0] - 2022-09-16 ### Added * `monai.auto3dseg` base APIs and `monai.apps.auto3dseg` components for automated machine learning (AutoML) workflow @@ -608,8 +640,9 @@ 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/1.0.0...HEAD -[1.0.0]: https://github.com/Project-MONAI/MONAI/compare/1.0.0...HEAD +[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/1.0.1...HEAD +[1.0.1]: https://github.com/Project-MONAI/MONAI/compare/1.0.0...1.0.1 +[1.0.0]: https://github.com/Project-MONAI/MONAI/compare/0.9.1...1.0.0 [0.9.1]: https://github.com/Project-MONAI/MONAI/compare/0.9.0...0.9.1 [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 From bc09a4ce642b7ae32d0264e9025938b416fd56a3 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 21 Oct 2022 20:53:40 +0100 Subject: [PATCH 59/60] enable test.pypi.org This reverts commit 52a7fde440123460f2ce13a515a0f3d023292f95. Signed-off-by: Wenqi Li --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed4c7d3f25..9db67af48a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,12 +77,12 @@ jobs: rm dist/monai*.tar.gz ls -al dist/ - # - if: matrix.python-version == '3.8' && startsWith(github.ref, 'refs/tags/') - # name: Publish to Test PyPI - # uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # password: ${{ secrets.TEST_PYPI }} - # repository_url: https://test.pypi.org/legacy/ + - if: matrix.python-version == '3.8' && startsWith(github.ref, 'refs/tags/') + name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI }} + repository_url: https://test.pypi.org/legacy/ versioning: # compute versioning file from python setup.py From 8271a193229fe4437026185e218d5b06f7c8ce69 Mon Sep 17 00:00:00 2001 From: Wenqi Li <831580+wyli@users.noreply.github.com> Date: Mon, 24 Oct 2022 09:58:04 +0100 Subject: [PATCH 60/60] 5381 and deprecate `compute_meandice` `compute_meaniou` (#5382) Signed-off-by: Wenqi Li Fixes #5381 - non-breaking changes to rename `compute_meandice` to `compute_dice` - non-breaking changes to rename `compute_meaniou` to `compute_iou` - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [x] New tests added to cover the changes. - [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. - [x] Quick tests passed locally by running `./runtests.sh --quick --unittests --disttests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. Signed-off-by: Wenqi Li --- CHANGELOG.md | 3 +++ monai/metrics/__init__.py | 4 ++-- monai/metrics/active_learning_metrics.py | 2 +- monai/metrics/meandice.py | 15 ++++++++++----- monai/metrics/meaniou.py | 18 ++++++++++++------ tests/test_compute_meandice.py | 4 ++-- tests/test_compute_meaniou.py | 4 ++-- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0ad44d6f..abc8f703d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). * Automatically infer device from the first item in random elastic deformation dict * Add channel dim in `ComputeHoVerMaps` and `ComputeHoVerMapsd` * Remove batch dim in `SobelGradients` and `SobelGradientsd` +### Deprecated +* Deprecating `compute_meandice`, `compute_meaniou` in `monai.metrics`, in favor of +`compute_dice` and `compute_iou` respectively ## [1.0.0] - 2022-09-16 ### Added diff --git a/monai/metrics/__init__.py b/monai/metrics/__init__.py index ac5de7e71b..4e0acbe603 100644 --- a/monai/metrics/__init__.py +++ b/monai/metrics/__init__.py @@ -15,8 +15,8 @@ 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 .meaniou import MeanIoU, compute_meaniou +from .meandice import DiceMetric, compute_dice, compute_meandice +from .meaniou import MeanIoU, compute_iou, compute_meaniou from .metric import Cumulative, CumulativeIterationMetric, IterationMetric, Metric from .regression import MAEMetric, MSEMetric, PSNRMetric, RMSEMetric, SSIMMetric from .rocauc import ROCAUCMetric, compute_roc_auc diff --git a/monai/metrics/active_learning_metrics.py b/monai/metrics/active_learning_metrics.py index f41a0a96b5..eddc82e87a 100644 --- a/monai/metrics/active_learning_metrics.py +++ b/monai/metrics/active_learning_metrics.py @@ -22,7 +22,7 @@ class VarianceMetric(Metric): """ - Compute the Variance of a given T-repeats N-dimensional array/tensor. The primary usage is as a uncertainty based + Compute the Variance of a given T-repeats N-dimensional array/tensor. The primary usage is as an uncertainty based metric for Active Learning. It can return the spatial variance/uncertainty map based on user choice or a single scalar value via mean/sum of the diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index 7a45f73b3a..a9d4e7182a 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -14,7 +14,7 @@ import torch from monai.metrics.utils import do_metric_reduction, ignore_background, is_binary_tensor -from monai.utils import MetricReduction +from monai.utils import MetricReduction, deprecated from .metric import CumulativeIterationMetric @@ -80,7 +80,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor if dims < 3: 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( + return compute_dice( y_pred=y_pred, y=y, include_background=self.include_background, ignore_empty=self.ignore_empty ) @@ -103,10 +103,10 @@ def aggregate(self, reduction: Union[MetricReduction, str, None] = None): return (f, not_nans) if self.get_not_nans else f -def compute_meandice( +def compute_dice( 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. + """Computes Dice score metric for a batch of predictions. Args: y_pred: input data to compute, typical segmentation model output. @@ -146,6 +146,11 @@ def compute_meandice( y_pred_o = torch.sum(y_pred, dim=reduce_axis) denominator = y_o + y_pred_o - if ignore_empty is True: + if ignore_empty: 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)) + + +@deprecated(since="1.0.0", msg_suffix="use `compute_dice` instead.") +def compute_meandice(*args, **kwargs): + return compute_dice(*args, **kwargs) diff --git a/monai/metrics/meaniou.py b/monai/metrics/meaniou.py index f32f39327d..55fa73e1ff 100644 --- a/monai/metrics/meaniou.py +++ b/monai/metrics/meaniou.py @@ -14,14 +14,15 @@ import torch from monai.metrics.utils import do_metric_reduction, ignore_background, is_binary_tensor -from monai.utils import MetricReduction +from monai.utils import MetricReduction, deprecated from .metric import CumulativeIterationMetric class MeanIoU(CumulativeIterationMetric): """ - Compute average IoU score between two tensors. It can support both multi-classes and multi-labels tasks. + Compute average Intersection over Union (IoU) score between two tensors. + It supports both multi-classes and multi-labels tasks. Input `y_pred` is compared with ground truth `y`. `y_pred` 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. @@ -80,7 +81,7 @@ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignor if dims < 3: raise ValueError(f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {dims}.") # compute IoU (BxC) for each channel for each batch - return compute_meaniou( + return compute_iou( y_pred=y_pred, y=y, include_background=self.include_background, ignore_empty=self.ignore_empty ) @@ -103,10 +104,10 @@ def aggregate(self, reduction: Union[MetricReduction, str, None] = None): return (f, not_nans) if self.get_not_nans else f -def compute_meaniou( +def compute_iou( y_pred: torch.Tensor, y: torch.Tensor, include_background: bool = True, ignore_empty: bool = True ) -> torch.Tensor: - """Computes IoU score metric from full size Tensor and collects average. + """Computes Intersection over Union (IoU) score metric from a batch of predictions. Args: y_pred: input data to compute, typical segmentation model output. @@ -146,6 +147,11 @@ def compute_meaniou( y_pred_o = torch.sum(y_pred, dim=reduce_axis) union = y_o + y_pred_o - intersection - if ignore_empty is True: + if ignore_empty: return torch.where(y_o > 0, (intersection) / union, torch.tensor(float("nan"), device=y_o.device)) return torch.where(union > 0, (intersection) / union, torch.tensor(1.0, device=y_o.device)) + + +@deprecated(since="1.0.0", msg_suffix="use `compute_iou` instead.") +def compute_meaniou(*args, **kwargs): + return compute_iou(*args, **kwargs) diff --git a/tests/test_compute_meandice.py b/tests/test_compute_meandice.py index 478d749120..4dd5d77c4f 100644 --- a/tests/test_compute_meandice.py +++ b/tests/test_compute_meandice.py @@ -15,7 +15,7 @@ import torch from parameterized import parameterized -from monai.metrics import DiceMetric, compute_meandice +from monai.metrics import DiceMetric, compute_dice, compute_meandice _device = "cuda:0" if torch.cuda.is_available() else "cpu" # keep background @@ -187,7 +187,7 @@ class TestComputeMeanDice(unittest.TestCase): @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) + result = compute_dice(**input_data) np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4) @parameterized.expand([TEST_CASE_3]) diff --git a/tests/test_compute_meaniou.py b/tests/test_compute_meaniou.py index 16fc695e02..52a0223a2d 100644 --- a/tests/test_compute_meaniou.py +++ b/tests/test_compute_meaniou.py @@ -15,7 +15,7 @@ import torch from parameterized import parameterized -from monai.metrics import MeanIoU, compute_meaniou +from monai.metrics import MeanIoU, compute_iou, compute_meaniou _device = "cuda:0" if torch.cuda.is_available() else "cpu" # keep background @@ -192,7 +192,7 @@ def test_value(self, input_data, expected_value): @parameterized.expand([TEST_CASE_3]) def test_nans(self, input_data, expected_value): - result = compute_meaniou(**input_data) + result = compute_iou(**input_data) self.assertTrue(np.allclose(np.isnan(result.cpu().numpy()), expected_value)) # MeanIoU class tests