From 63ad63cff562e261c6690ea1d9eb976138dd7929 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 31 Mar 2021 16:25:43 -0400 Subject: [PATCH 01/55] Update CuCIM (#1909) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- docs/requirements.txt | 2 +- requirements-dev.txt | 2 +- setup.cfg | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index f05bc5b9ca..c31f06f2ca 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,7 @@ pytorch-ignite==0.4.4 numpy>=1.17 itk>=5.0 nibabel -cucim==0.18.1 +cucim==0.18.2 openslide-python==1.1.2 parameterized scikit-image>=0.14.2 diff --git a/requirements-dev.txt b/requirements-dev.txt index dc4181b310..dfa1eb1853 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -30,5 +30,5 @@ Sphinx==3.3.0 recommonmark==0.6.0 sphinx-autodoc-typehints==1.11.1 sphinx-rtd-theme==0.5.0 -cucim==0.18.1 +cucim==0.18.2 openslide-python==1.1.2 diff --git a/setup.cfg b/setup.cfg index f06c56d001..a41081cd11 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,7 @@ all = torchvision itk>=5.0 tqdm>=4.47.0 - cucim==0.18.1 + cucim==0.18.2 openslide-python==1.1.2 nibabel = nibabel @@ -63,7 +63,7 @@ lmdb = psutil = psutil cucim = - cucim==0.18.1 + cucim==0.18.2 openslide = openslide-python==1.1.2 From e7614339a75c621e935dac229e90d764d298702c Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 31 Mar 2021 17:58:05 -0400 Subject: [PATCH 02/55] Update pathology unittests (#1910) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- tests/test_cuimage_reader.py | 4 +-- tests/test_masked_inference_wsi_dataset.py | 38 ++++++++++++---------- tests/test_openslide_reader.py | 2 +- tests/test_patch_wsi_dataset.py | 2 +- tests/test_smartcache_patch_wsi_dataset.py | 2 +- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/test_cuimage_reader.py b/tests/test_cuimage_reader.py index 1b0293f159..c096bad0c2 100644 --- a/tests/test_cuimage_reader.py +++ b/tests/test_cuimage_reader.py @@ -14,7 +14,7 @@ PILImage, has_pil = optional_import("PIL.Image") FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", os.path.basename(FILE_URL)) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + os.path.basename(FILE_URL)) HEIGHT = 32914 WIDTH = 46000 @@ -105,7 +105,7 @@ def test_read_rgba(self, img_expected): image = {} reader = WSIReader("cuCIM") for mode in ["RGB", "RGBA"]: - file_path = self.create_rgba_image(img_expected, "test_cu_tiff_image", mode=mode) + file_path = self.create_rgba_image(img_expected, "temp_cu_tiff_image", mode=mode) img_obj = reader.read(file_path) image[mode], _ = reader.get_data(img_obj) diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py index 7c8a815c2e..88af8c05c0 100644 --- a/tests/test_masked_inference_wsi_dataset.py +++ b/tests/test_masked_inference_wsi_dataset.py @@ -15,11 +15,13 @@ _, has_osl = optional_import("openslide") FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", os.path.basename(FILE_URL)) +base_name, extension = os.path.splitext(os.path.basename(FILE_URL)) +FILE_NAME = "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", "tissue_mask1.npy") -MASK2 = os.path.join(os.path.dirname(__file__), "testing_data", "tissue_mask2.npy") -MASK4 = os.path.join(os.path.dirname(__file__), "testing_data", "tissue_mask4.npy") +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") HEIGHT = 32914 WIDTH = 46000 @@ -47,7 +49,7 @@ def prepare_data(): [ { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, ], @@ -62,12 +64,12 @@ def prepare_data(): [ { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [101, 100], }, ], @@ -82,22 +84,22 @@ def prepare_data(): [ { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 101], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [101, 100], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [101, 101], }, ], @@ -121,7 +123,7 @@ def prepare_data(): ], dtype=np.uint8, ), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, ], @@ -139,17 +141,17 @@ def prepare_data(): [ { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [101, 100], }, ], @@ -167,7 +169,7 @@ def prepare_data(): [ { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, ], @@ -182,12 +184,12 @@ def prepare_data(): [ { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [100, 100], }, { "image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), - "name": "CMU-1", + "name": FILE_NAME, "mask_location": [101, 100], }, ], diff --git a/tests/test_openslide_reader.py b/tests/test_openslide_reader.py index ca50cec4de..e005dbd1c4 100644 --- a/tests/test_openslide_reader.py +++ b/tests/test_openslide_reader.py @@ -14,7 +14,7 @@ FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", os.path.basename(FILE_URL)) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + os.path.basename(FILE_URL)) HEIGHT = 32914 WIDTH = 46000 diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index d030671d06..c4a94a60c4 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -14,7 +14,7 @@ _, has_osl = optional_import("openslide") FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", os.path.basename(FILE_URL)) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + os.path.basename(FILE_URL)) TEST_CASE_0 = [ { diff --git a/tests/test_smartcache_patch_wsi_dataset.py b/tests/test_smartcache_patch_wsi_dataset.py index a7c90b5205..d7c2ce5bd1 100644 --- a/tests/test_smartcache_patch_wsi_dataset.py +++ b/tests/test_smartcache_patch_wsi_dataset.py @@ -13,7 +13,7 @@ _, has_cim = optional_import("cucim") FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", os.path.basename(FILE_URL)) +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + os.path.basename(FILE_URL)) TEST_CASE_0 = [ { From 8337cd204a04a3ab2e4477a0b1e874ba016ace6f Mon Sep 17 00:00:00 2001 From: Alvin Ihsani Date: Wed, 31 Mar 2021 16:40:21 -0700 Subject: [PATCH 03/55] added stain norm transform Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 monai/apps/pathology/transforms.py diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py new file mode 100644 index 0000000000..edb557b1b2 --- /dev/null +++ b/monai/apps/pathology/transforms.py @@ -0,0 +1,133 @@ +# modified from sources: +# - Original implementation from Macenko paper in Matlab: https://github.com/mitkovetta/staining-normalization +# - Implementation in Python: https://github.com/schaugf/HEnorm_python +import openslide +import cupy as cp +from PIL import Image +from typing import Tuple +from monai.transforms.transform import Transform + + +class StainNormalizer(Transform): + """ + Stain Normalize patches of a digital pathology image. Performs Stain Deconvolution using the Macenko method. + A source patch can be normalized using a reference stain matrix, or using a target image from + which a target stain is extracted. For using the reference stain, run only the normalize_patch function. + To use the stain from a target image, run extract_stain first to modify the target stain matrix used, then + run normalize_patch on each patch to be stain normalized. + + Args: + Io: (optional) transmitted light intensity + alpha: (optional) tolerance for the pseudo-min and pseudo-max + beta: (optional) OD threshold for transparent pixels + target_image: (optional) OpenSlide image to perform stain deconvolution of, + to obtain target stain matrix + + """ + def __init__(self, Io: float=240, alpha: float=1, beta: float=0.15, target_image: openslide.OpenSlide=None) -> None: + self.Io = Io + self.alpha = alpha + self.beta = beta + + # reference maximum stain concentrations for H&E + self.maxCRef = cp.array([1.9705, 1.0308]) + + # target H&E stain is set to reference H&E OD matrix + self.target_HE = cp.array([[0.5626, 0.2159], + [0.7201, 0.8012], + [0.4062, 0.5581]]) + if target_image!=None: + self._extract_stain(target_image) + + def _stain_deconvolution(self, img: cp.ndarray) -> Tuple[cp.ndarray, cp.ndarray]: + """Perform Stain Deconvolution using the Macenko Method. + + Args: + img: image to perform stain deconvolution of + + Return: + HE: H&E OD matrix for the image (first column is H, second column is E) + C2: stain concentration matrix for the input image + """ + # define height and width of image + h, w, c = img.shape + + # RGBA to RGB + img = img[:, :, :-1] + + # reshape image + img = img.reshape((-1,3)) + + # calculate optical density + OD = -cp.log((img.astype(cp.float)+1)/self.Io) + + # remove transparent pixels + ODhat = OD[~cp.any(OD vMax[0]: + HE = cp.array((vMin[:,0], vMax[:,0])).T + else: + HE = cp.array((vMax[:,0], vMin[:,0])).T + + # rows correspond to channels (RGB), columns to OD values + Y = cp.reshape(OD, (-1, 3)).T + + # determine concentrations of the individual stains + C = cp.linalg.lstsq(HE,Y, rcond=None)[0] + + # normalize stain concentrations + maxC = cp.array([cp.percentile(C[0,:], 99), cp.percentile(C[1,:],99)]) + tmp = cp.divide(maxC,self.maxCRef) + C2 = cp.divide(C,tmp[:, cp.newaxis]) + return HE, C2 + + def _extract_stain(self, target_image: openslide.OpenSlide) -> None: + """Extract a reference stain from a target image. + + To extract reference stain, the image at the highest level (the level with + lowest resolution) is used. Then, stain deconvolution provides the stain matrix. + + Args: + target_image: (optional) OpenSlide image to perform stain deconvolution of, + to obtain target stain matrix + """ + highest_level = target_image.level_count - 1 + dims = target_image.level_dimensions[highest_level] + target_image_at_level = target_image.read_region((0,0), highest_level, dims) + target_img = cp.array(target_image_at_level) + self.target_HE, _ = self._stain_deconvolution(target_img) + + def __call__(self, data: cp.ndarray) -> cp.ndarray: + """Normalize a patch to a reference / target image stain. + + Performs stain deconvolution of the patch to obtain the stain concentration matrix + for the patch. Then, performs the inverse Beer-Lambert transform to recreate the + patch using the target H&E stain. + + Args: + patch: image patch to stain normalize + + Return: + patch_norm: normalized patch + """ + h, w, _ = data.shape + _, patch_C = self._stain_deconvolution(data) + + patch_norm = cp.multiply(self.Io, cp.exp(-self.target_HE.dot(patch_C))) + patch_norm[patch_norm>255] = 254 + patch_norm = cp.reshape(patch_norm.T, (h, w, 3)).astype(cp.uint8) + return patch_norm \ No newline at end of file From 71ce959b81f7efcd96fa6bbfd79c35c512ef55f9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Thu, 1 Apr 2021 03:14:57 -0400 Subject: [PATCH 04/55] Update prob map handler (#1911) * Rename the prob map producer unittest to match the module Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Change probs_map to prob_map Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Prepend image_inference_outputs with temp to be ignored Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- monai/utils/prob_nms.py | 32 +++++++++---------- tests/min_tests.py | 2 +- ...r.py => test_handler_prob_map_producer.py} | 6 ++-- 3 files changed, 20 insertions(+), 20 deletions(-) rename tests/{test_handler_prob_map_generator.py => test_handler_prob_map_producer.py} (94%) diff --git a/monai/utils/prob_nms.py b/monai/utils/prob_nms.py index c789dab0bb..c25223d524 100644 --- a/monai/utils/prob_nms.py +++ b/monai/utils/prob_nms.py @@ -65,36 +65,36 @@ def __init__( def __call__( self, - probs_map: Union[np.ndarray, torch.Tensor], + prob_map: Union[np.ndarray, torch.Tensor], ): """ - probs_map: the input probabilities map, it must have shape (H[, W, ...]). + prob_map: the input probabilities map, it must have shape (H[, W, ...]). """ if self.sigma != 0: - if not isinstance(probs_map, torch.Tensor): - probs_map = torch.as_tensor(probs_map, dtype=torch.float) - self.filter.to(probs_map) - probs_map = self.filter(probs_map) + if not isinstance(prob_map, torch.Tensor): + prob_map = torch.as_tensor(prob_map, dtype=torch.float) + self.filter.to(prob_map) + prob_map = self.filter(prob_map) else: - if not isinstance(probs_map, torch.Tensor): - probs_map = probs_map.copy() + if not isinstance(prob_map, torch.Tensor): + prob_map = prob_map.copy() - if isinstance(probs_map, torch.Tensor): - probs_map = probs_map.detach().cpu().numpy() + if isinstance(prob_map, torch.Tensor): + prob_map = prob_map.detach().cpu().numpy() - probs_map_shape = probs_map.shape + prob_map_shape = prob_map.shape outputs = [] - while np.max(probs_map) > self.prob_threshold: - max_idx = np.unravel_index(probs_map.argmax(), probs_map_shape) - prob_max = probs_map[max_idx] + while np.max(prob_map) > self.prob_threshold: + max_idx = np.unravel_index(prob_map.argmax(), prob_map_shape) + prob_max = prob_map[max_idx] max_idx_arr = np.asarray(max_idx) outputs.append([prob_max] + list(max_idx_arr)) idx_min_range = (max_idx_arr - self.box_lower_bd).clip(0, None) - idx_max_range = (max_idx_arr + self.box_upper_bd).clip(None, probs_map_shape) + idx_max_range = (max_idx_arr + self.box_upper_bd).clip(None, prob_map_shape) # for each dimension, set values during index ranges to 0 slices = tuple(slice(idx_min_range[i], idx_max_range[i]) for i in range(self.spatial_dims)) - probs_map[slices] = 0 + prob_map[slices] = 0 return outputs diff --git a/tests/min_tests.py b/tests/min_tests.py index e896e81c70..06231af0a1 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -43,7 +43,7 @@ def run_testsuit(): "test_handler_confusion_matrix_dist", "test_handler_hausdorff_distance", "test_handler_mean_dice", - "test_handler_prob_map_generator", + "test_handler_prob_map_producer", "test_handler_rocauc", "test_handler_rocauc_dist", "test_handler_segmentation_saver", diff --git a/tests/test_handler_prob_map_generator.py b/tests/test_handler_prob_map_producer.py similarity index 94% rename from tests/test_handler_prob_map_generator.py rename to tests/test_handler_prob_map_producer.py index 4882060be9..8bf42131b4 100644 --- a/tests/test_handler_prob_map_generator.py +++ b/tests/test_handler_prob_map_producer.py @@ -23,9 +23,9 @@ from monai.engines import Evaluator from monai.handlers import ValidationHandler -TEST_CASE_0 = ["image_inference_output_1", 2] -TEST_CASE_1 = ["image_inference_output_2", 9] -TEST_CASE_2 = ["image_inference_output_3", 1000] +TEST_CASE_0 = ["temp_image_inference_output_1", 2] +TEST_CASE_1 = ["temp_image_inference_output_2", 9] +TEST_CASE_2 = ["temp_image_inference_output_3", 1000] class TestDataset(Dataset): From 40afac884e10a2b039a07e77226d3744178b4482 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 1 Apr 2021 20:55:24 +0800 Subject: [PATCH 05/55] Implement dice_focal loss (#1914) Signed-off-by: Yiheng Wang Signed-off-by: Neha Srivathsa --- docs/source/losses.rst | 5 ++ monai/losses/__init__.py | 3 + monai/losses/dice.py | 150 ++++++++++++++++++++++++++++++---- monai/losses/focal_loss.py | 14 +++- monai/networks/nets/senet.py | 4 +- tests/test_dice_ce_loss.py | 14 ++++ tests/test_dice_focal_loss.py | 80 ++++++++++++++++++ tests/test_focal_loss.py | 10 +++ 8 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 tests/test_dice_focal_loss.py diff --git a/docs/source/losses.rst b/docs/source/losses.rst index 5e19219fee..eea6656a24 100644 --- a/docs/source/losses.rst +++ b/docs/source/losses.rst @@ -48,6 +48,11 @@ Segmentation Losses .. autoclass:: DiceCELoss :members: +`DiceFocalLoss` +~~~~~~~~~~~~~~~ +.. autoclass:: DiceFocalLoss + :members: + `FocalLoss` ~~~~~~~~~~~ .. autoclass:: FocalLoss diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py index b9146a6962..78a0fbc191 100644 --- a/monai/losses/__init__.py +++ b/monai/losses/__init__.py @@ -13,11 +13,14 @@ from .dice import ( Dice, DiceCELoss, + DiceFocalLoss, DiceLoss, GeneralizedDiceLoss, GeneralizedWassersteinDiceLoss, MaskedDiceLoss, dice, + dice_ce, + dice_focal, generalized_dice, generalized_wasserstein_dice, ) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 65bf47f388..47af8ea171 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -10,7 +10,7 @@ # limitations under the License. import warnings -from typing import Callable, List, Optional, Union +from typing import Callable, List, Optional, Sequence, Union import numpy as np import torch @@ -18,6 +18,7 @@ import torch.nn.functional as F from torch.nn.modules.loss import _Loss +from monai.losses.focal_loss import FocalLoss from monai.networks import one_hot from monai.utils import LossReduction, Weight @@ -600,15 +601,12 @@ def _compute_alpha_generalized_true_positives(self, flat_target: torch.Tensor) - class DiceCELoss(_Loss): """ - Compute both Dice loss and Cross Entropy Loss, and return the sum of these two losses. - Input logits `input` (BNHW[D] where N is number of classes) is compared with ground truth `target` (BNHW[D]). - Axis N of `input` is expected to have logit predictions for each class rather than being image channels, - while the same axis of `target` can be 1 or N (one-hot format). The `smooth_nr` and `smooth_dr` parameters are - values added for dice loss part to the intersection and union components of the inter-over-union calculation - to smooth results respectively, these values should be small. The `include_background` class attribute can be - set to False for an instance of the loss to exclude the first category (channel index 0) which is by convention - assumed to be background. If the non-background segmentations are small compared to the total image size they can get - overwhelmed by the signal from the background so excluding it in such cases helps convergence. + Compute both Dice loss and Cross Entropy Loss, and return the weighted sum of these two losses. + The details of Dice loss is shown in ``monai.losses.DiceLoss``. + The details of Cross Entropy Loss is shown in ``torch.nn.CrossEntropyLoss``. In this implementation, + two deprecated parameters ``size_average`` and ``reduce``, and the parameter ``ignore_index`` are + not supported. + """ def __init__( @@ -625,11 +623,13 @@ def __init__( smooth_dr: float = 1e-5, batch: bool = False, ce_weight: Optional[torch.Tensor] = None, + lambda_dice: float = 1.0, + lambda_ce: float = 1.0, ) -> None: """ Args: - ``ce_weight`` is only used for cross entropy loss, ``reduction`` is used for both losses and other - parameters are only used for dice loss. + ``ce_weight`` and ``lambda_ce`` are only used for cross entropy loss. + ``reduction`` is used for both losses and other parameters are only used for dice loss. include_background: if False channel index 0 (background category) is excluded from the calculation. to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. @@ -655,6 +655,10 @@ def __init__( before any `reduction`. ce_weight: a rescaling weight given to each class for cross entropy loss. See ``torch.nn.CrossEntropyLoss()`` for more information. + lambda_dice: the trade-off weight value for dice loss. The value should be no less than 0.0. + Defaults to 1.0. + lambda_ce: the trade-off weight value for cross entropy loss. The value should be no less than 0.0. + Defaults to 1.0. """ super().__init__() @@ -675,6 +679,12 @@ def __init__( weight=ce_weight, reduction=reduction, ) + if lambda_dice < 0.0: + raise ValueError("lambda_dice should be no less than 0.0.") + if lambda_ce < 0.0: + raise ValueError("lambda_ce should be no less than 0.0.") + self.lambda_dice = lambda_dice + self.lambda_ce = lambda_ce def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """ @@ -684,7 +694,7 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: Raises: ValueError: When number of dimensions for input and target are different. - ValueError: When number of channels for target is nither 1 or the same as input. + ValueError: When number of channels for target is neither 1 nor the same as input. """ if len(input.shape) != len(target.shape): @@ -700,11 +710,123 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: target = torch.squeeze(target, dim=1) target = target.long() ce_loss = self.cross_entropy(input, target) - total_loss: torch.Tensor = dice_loss + ce_loss + total_loss: torch.Tensor = self.lambda_dice * dice_loss + self.lambda_ce * ce_loss + return total_loss + + +class DiceFocalLoss(_Loss): + """ + Compute both Dice loss and Focal Loss, and return the weighted sum of these two losses. + The details of Dice loss is shown in ``monai.losses.DiceLoss``. + The details of Focal Loss is shown in ``monai.losses.FocalLoss``. + + """ + + def __init__( + self, + include_background: bool = True, + to_onehot_y: bool = False, + sigmoid: bool = False, + softmax: bool = False, + other_act: Optional[Callable] = None, + squared_pred: bool = False, + jaccard: bool = False, + reduction: str = "mean", + smooth_nr: float = 1e-5, + smooth_dr: float = 1e-5, + batch: bool = False, + gamma: float = 2.0, + focal_weight: Optional[Union[Sequence[float], float, int, torch.Tensor]] = None, + lambda_dice: float = 1.0, + lambda_focal: float = 1.0, + ) -> None: + """ + Args: + ``gamma``, ``focal_weight`` and ``lambda_focal`` are only used for focal loss. + ``include_background``, ``to_onehot_y``and ``reduction`` are used for both losses + and other parameters are only used for dice loss. + include_background: if False channel index 0 (background category) is excluded from the calculation. + to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. + sigmoid: if True, apply a sigmoid function to the prediction. + softmax: if True, apply a softmax function to the prediction. + other_act: if don't want to use `sigmoid` or `softmax`, use other callable function to execute + other activation layers, Defaults to ``None``. for example: + `other_act = torch.tanh`. + squared_pred: use squared versions of targets and predictions in the denominator or not. + jaccard: compute Jaccard Index (soft IoU) instead of dice or not. + reduction: {``"none"``, ``"mean"``, ``"sum"``} + Specifies the reduction to apply to the output. Defaults to ``"mean"``. + + - ``"none"``: no reduction will be applied. + - ``"mean"``: the sum of the output will be divided by the number of elements in the output. + - ``"sum"``: the output will be summed. + + smooth_nr: a small constant added to the numerator to avoid zero. + smooth_dr: a small constant added to the denominator to avoid nan. + batch: whether to sum the intersection and union areas over the batch dimension before the dividing. + Defaults to False, a Dice loss value is computed independently from each item in the batch + before any `reduction`. + gamma: value of the exponent gamma in the definition of the Focal loss. + focal_weight: weights to apply to the voxels of each class. If None no weights are applied. + The input can be a single value (same weight for all classes), a sequence of values (the length + of the sequence should be the same as the number of classes). + lambda_dice: the trade-off weight value for dice loss. The value should be no less than 0.0. + Defaults to 1.0. + lambda_focal: the trade-off weight value for focal loss. The value should be no less than 0.0. + Defaults to 1.0. + + """ + super().__init__() + self.dice = DiceLoss( + include_background=include_background, + to_onehot_y=to_onehot_y, + sigmoid=sigmoid, + softmax=softmax, + other_act=other_act, + squared_pred=squared_pred, + jaccard=jaccard, + reduction=reduction, + smooth_nr=smooth_nr, + smooth_dr=smooth_dr, + batch=batch, + ) + self.focal = FocalLoss( + include_background=include_background, + to_onehot_y=to_onehot_y, + gamma=gamma, + weight=focal_weight, + reduction=reduction, + ) + if lambda_dice < 0.0: + raise ValueError("lambda_dice should be no less than 0.0.") + if lambda_focal < 0.0: + raise ValueError("lambda_focal should be no less than 0.0.") + self.lambda_dice = lambda_dice + self.lambda_focal = lambda_focal + + def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """ + Args: + input: the shape should be BNH[WD]. The input should be the original logits + due to the restriction of ``monai.losses.FocalLoss``. + target: the shape should be BNH[WD] or B1H[WD]. + + Raises: + ValueError: When number of dimensions for input and target are different. + ValueError: When number of channels for target is neither 1 nor the same as input. + + """ + if len(input.shape) != len(target.shape): + raise ValueError("the number of dimensions for input and target should be the same.") + + dice_loss = self.dice(input, target) + focal_loss = self.focal(input, target) + total_loss: torch.Tensor = self.lambda_dice * dice_loss + self.lambda_focal * focal_loss return total_loss dice = Dice = DiceLoss dice_ce = DiceCELoss +dice_focal = DiceFocalLoss generalized_dice = GeneralizedDiceLoss generalized_wasserstein_dice = GeneralizedWassersteinDiceLoss diff --git a/monai/losses/focal_loss.py b/monai/losses/focal_loss.py index 664e7673a4..5e0ccd3179 100644 --- a/monai/losses/focal_loss.py +++ b/monai/losses/focal_loss.py @@ -45,7 +45,9 @@ def __init__( weight: weights to apply to the voxels of each class. If None no weights are applied. This corresponds to the weights `\alpha` in [1]. The input can be a single value (same weight for all classes), a sequence of values (the length - of the sequence should be the same as the number of classes). + of the sequence should be the same as the number of classes, if not ``include_background``, the + number should not include class 0). + The value/values should be no less than 0. Defaults to None. reduction: {``"none"``, ``"mean"``, ``"sum"``} Specifies the reduction to apply to the output. Defaults to ``"mean"``. @@ -83,6 +85,9 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: AssertionError: When input and target (after one hot transform if setted) have different shapes. ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"]. + ValueError: When ``self.weight`` is a sequence and the length is not equal to the + number of classes. + ValueError: When ``self.weight`` is/contains a value that is less than 0. """ n_pred_ch = input.shape[1] @@ -122,6 +127,13 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: class_weight = torch.as_tensor([self.weight] * i.size(1)) else: class_weight = torch.as_tensor(self.weight) + if class_weight.size(0) != i.size(1): + raise ValueError( + "the length of the weight sequence should be the same as the number of classes. " + + "If `include_background=False`, the number should not include class 0." + ) + if class_weight.min() < 0: + raise ValueError("the value/values of weights should be no less than 0.") class_weight = class_weight.to(i) # Convert the weight to a map in which each voxel # has the weight associated with the ground-truth label diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py index f5738edeeb..1e04e02973 100644 --- a/monai/networks/nets/senet.py +++ b/monai/networks/nets/senet.py @@ -263,8 +263,8 @@ def _load_state_dict(model, arch, progress): model_url = model_urls[arch] else: raise ValueError( - "only 'senet154', 'se_resnet50', 'se_resnet101', 'se_resnet152', 'se_resnext50_32x4d', \ - and se_resnext101_32x4d are supported to load pretrained weights." + "only 'senet154', 'se_resnet50', 'se_resnet101', 'se_resnet152', 'se_resnext50_32x4d', " + + "and se_resnext101_32x4d are supported to load pretrained weights." ) pattern_conv = re.compile(r"^(layer[1-4]\.\d\.(?:conv)\d\.)(\w*)$") diff --git a/tests/test_dice_ce_loss.py b/tests/test_dice_ce_loss.py index 8627c6d130..3423e1425b 100644 --- a/tests/test_dice_ce_loss.py +++ b/tests/test_dice_ce_loss.py @@ -43,6 +43,20 @@ }, 0.2088, ], + [ # shape: (2, 2, 3), (2, 1, 3) lambda_dice: 1.0, lambda_ce: 2.0 + { + "include_background": False, + "to_onehot_y": True, + "ce_weight": torch.tensor([1.0, 1.0]), + "lambda_dice": 1.0, + "lambda_ce": 2.0, + }, + { + "input": torch.tensor([[[100.0, 100.0, 0.0], [0.0, 0.0, 1.0]], [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]]]), + "target": torch.tensor([[[0.0, 0.0, 1.0]], [[0.0, 1.0, 0.0]]]), + }, + 0.4176, + ], [ # shape: (2, 2, 3), (2, 1, 3), do not include class 0 {"include_background": False, "to_onehot_y": True, "ce_weight": torch.tensor([0.0, 1.0])}, { diff --git a/tests/test_dice_focal_loss.py b/tests/test_dice_focal_loss.py new file mode 100644 index 0000000000..4bab68131c --- /dev/null +++ b/tests/test_dice_focal_loss.py @@ -0,0 +1,80 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch + +from monai.losses import DiceFocalLoss, DiceLoss, FocalLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save + + +class TestDiceFocalLoss(unittest.TestCase): + def test_result_onehot_target_include_bg(self): + size = [3, 3, 5, 5] + label = torch.randint(low=0, high=2, size=size) + pred = torch.randn(size) + for reduction in ["sum", "mean", "none"]: + common_params = { + "include_background": True, + "to_onehot_y": False, + "reduction": reduction, + } + for focal_weight in [None, torch.tensor([1.0, 1.0, 2.0]), (3, 2.0, 1)]: + for lambda_focal in [0.5, 1.0, 1.5]: + dice_focal = DiceFocalLoss( + focal_weight=focal_weight, gamma=1.0, lambda_focal=lambda_focal, **common_params + ) + dice = DiceLoss(**common_params) + focal = FocalLoss(weight=focal_weight, gamma=1.0, **common_params) + result = dice_focal(pred, label) + expected_val = dice(pred, label) + lambda_focal * focal(pred, label) + np.testing.assert_allclose(result, expected_val) + + def test_result_no_onehot_no_bg(self): + size = [3, 3, 5, 5] + label = torch.randint(low=0, high=2, size=size) + label = torch.argmax(label, dim=1, keepdim=True) + pred = torch.randn(size) + for reduction in ["sum", "mean", "none"]: + common_params = { + "include_background": False, + "to_onehot_y": True, + "reduction": reduction, + } + for focal_weight in [2.0, torch.tensor([1.0, 2.0]), (2.0, 1)]: + for lambda_focal in [0.5, 1.0, 1.5]: + dice_focal = DiceFocalLoss(focal_weight=focal_weight, lambda_focal=lambda_focal, **common_params) + dice = DiceLoss(**common_params) + focal = FocalLoss(weight=focal_weight, **common_params) + result = dice_focal(pred, label) + expected_val = dice(pred, label) + lambda_focal * focal(pred, label) + np.testing.assert_allclose(result, expected_val) + + def test_ill_shape(self): + loss = DiceFocalLoss() + with self.assertRaisesRegex(ValueError, ""): + loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + + def test_ill_lambda(self): + with self.assertRaisesRegex(ValueError, ""): + loss = DiceFocalLoss(lambda_dice=-1.0) + + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + loss = DiceFocalLoss() + test_input = torch.ones(2, 1, 8, 8) + test_script_save(loss, test_input, test_input) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_focal_loss.py b/tests/test_focal_loss.py index 4512dac4b9..66665774ef 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -187,6 +187,16 @@ def test_ill_shape(self): with self.assertRaisesRegex(AssertionError, ""): FocalLoss(reduction="mean")(chn_input, chn_target) + def test_ill_class_weight(self): + chn_input = torch.ones((1, 4, 3, 3)) + chn_target = torch.ones((1, 4, 3, 3)) + with self.assertRaisesRegex(ValueError, ""): + FocalLoss(include_background=True, weight=(1.0, 1.0, 2.0))(chn_input, chn_target) + with self.assertRaisesRegex(ValueError, ""): + FocalLoss(include_background=False, weight=(1.0, 1.0, 1.0, 1.0))(chn_input, chn_target) + with self.assertRaisesRegex(ValueError, ""): + FocalLoss(include_background=False, weight=(1.0, 1.0, -1.0))(chn_input, chn_target) + @SkipIfBeforePyTorchVersion((1, 7, 0)) def test_script(self): loss = FocalLoss() From 51133968aea997d6b5dd1bb709b30eb78cee562d Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 1 Apr 2021 17:50:35 +0100 Subject: [PATCH 06/55] followup of #1878 (#1913) * followup of #1878, fixes tests, remove json loading Signed-off-by: Wenqi Li * Update test ordinal numbers Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Co-authored-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- monai/apps/pathology/metrics.py | 15 +++------------ tests/test_lesion_froc.py | 9 +++++---- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/monai/apps/pathology/metrics.py b/monai/apps/pathology/metrics.py index 63b9d073a7..ae01d8a1db 100644 --- a/monai/apps/pathology/metrics.py +++ b/monai/apps/pathology/metrics.py @@ -9,8 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Tuple import numpy as np @@ -51,7 +50,7 @@ class LesionFROC: def __init__( self, - data: Union[List[Dict], str], + data: List[Dict], grow_distance: int = 75, itc_diameter: int = 200, eval_thresholds: Tuple = (0.25, 0.5, 1, 2, 4, 8), @@ -61,10 +60,7 @@ def __init__( image_reader_name: str = "cuCIM", ) -> None: - if isinstance(data, str): - self.data = self._load_data(data) - else: - self.data = data + self.data = data self.grow_distance = grow_distance self.itc_diameter = itc_diameter self.eval_thresholds = eval_thresholds @@ -75,11 +71,6 @@ def __init__( box_size=nms_box_size, ) - def _load_data(self, file_path: str) -> List[Dict]: - with open(file_path, "r") as f: - data: List[Dict] = json.load(f) - return data - def prepare_inference_result(self, sample: Dict): """ Prepare the probability map for detection evaluation. diff --git a/tests/test_lesion_froc.py b/tests/test_lesion_froc.py index 6702997c64..1f2926631f 100644 --- a/tests/test_lesion_froc.py +++ b/tests/test_lesion_froc.py @@ -185,7 +185,7 @@ def prepare_test_data(): ] -TEST_CASE_5 = [ +TEST_CASE_6 = [ { "data": [ { @@ -207,7 +207,7 @@ def prepare_test_data(): 2.0 / 3.0, ] -TEST_CASE_6 = [ +TEST_CASE_7 = [ { "data": [ { @@ -229,7 +229,7 @@ def prepare_test_data(): 0.4, ] -TEST_CASE_7 = [ +TEST_CASE_8 = [ { "data": [ { @@ -257,7 +257,7 @@ def prepare_test_data(): 1.0 / 3.0, ] -TEST_CASE_8 = [ +TEST_CASE_9 = [ { "data": [ { @@ -305,6 +305,7 @@ def setUp(self): TEST_CASE_6, TEST_CASE_7, TEST_CASE_8, + TEST_CASE_9, ] ) def test_read_patches_cucim(self, input_parameters, expected): From 75522797ddf04b7bda4daf32742c3a8af9f394ea Mon Sep 17 00:00:00 2001 From: Yiwen Li <44606435+kate-sann5100@users.noreply.github.com> Date: Thu, 1 Apr 2021 19:15:32 +0100 Subject: [PATCH 07/55] 1868-fix-potential-inf-in-lncc-loss (#1915) * clip variance to be >= 0 Signed-off-by: kate-sann5100 * max for torch1.6 Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/losses/image_dissimilarity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/monai/losses/image_dissimilarity.py b/monai/losses/image_dissimilarity.py index 67b2d177f6..eed5808aa3 100644 --- a/monai/losses/image_dissimilarity.py +++ b/monai/losses/image_dissimilarity.py @@ -65,8 +65,8 @@ def __init__( kernel_size: int = 3, kernel_type: str = "rectangular", reduction: Union[LossReduction, str] = LossReduction.MEAN, - smooth_nr: float = 1e-7, - smooth_dr: float = 1e-7, + smooth_nr: float = 1e-5, + smooth_dr: float = 1e-5, ) -> None: """ Args: @@ -146,6 +146,8 @@ def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: cross = tp_sum - p_avg * t_sum t_var = t2_sum - t_avg * t_sum # std[t] ** 2 p_var = p2_sum - p_avg * p_sum # std[p] ** 2 + t_var = torch.max(t_var, torch.zeros_like(t_var)) + p_var = torch.max(p_var, torch.zeros_like(p_var)) ncc: torch.Tensor = (cross * cross + self.smooth_nr) / (t_var * p_var + self.smooth_dr) # shape = (batch, 1, D, H, W) From 47a30698144995833ffd703dc2e55a198b9a2fd1 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 2 Apr 2021 12:12:09 +0100 Subject: [PATCH 08/55] 1919 - test pt 2103 (#1920) * update to use pytorch2103 Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- .github/workflows/cron.yml | 4 ++-- .github/workflows/pythonapp.yml | 2 +- Dockerfile | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 761b1f7ebc..3562672232 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -60,7 +60,7 @@ jobs: cron-pt-image: if: github.repository == 'Project-MONAI/MONAI' container: - image: nvcr.io/nvidia/pytorch:21.02-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:21.03-py3 # testing with the latest pytorch base image options: "--gpus all" runs-on: [self-hosted, linux, x64, common] steps: @@ -133,7 +133,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' needs: cron-gpu # so that monai itself is verified first container: - image: nvcr.io/nvidia/pytorch:21.02-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:21.03-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.yml b/.github/workflows/pythonapp.yml index e5803028a0..738d657211 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -186,7 +186,7 @@ jobs: - environment: PT18+CUDA112 # we explicitly set pytorch to -h to avoid pip install error pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:21.02-py3" + base: "nvcr.io/nvidia/pytorch:21.03-py3" container: image: ${{ matrix.base }} options: --gpus all diff --git a/Dockerfile b/Dockerfile index 57ea567869..54d1f02275 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # To build with a different base image # please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.02-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.03-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" From 89447c45806750a450a396b8bd049c7792ba7908 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 2 Apr 2021 16:04:14 +0100 Subject: [PATCH 09/55] fixes typos (#1924) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- docs/source/networks.rst | 2 +- monai/apps/pathology/datasets.py | 2 +- .../bilateral/bilateralfilter_cuda_phl.cu | 2 +- .../filtering/permutohedral/hash_table.cuh | 2 +- monai/networks/blocks/crf.py | 24 +++++++++---------- monai/networks/blocks/regunet_block.py | 2 +- monai/networks/layers/filtering.py | 4 ++-- monai/transforms/compose.py | 2 +- tests/test_crf_cpu.py | 6 ++--- tests/test_crf_cuda.py | 6 ++--- 10 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/source/networks.rst b/docs/source/networks.rst index 15d7cb80b0..abf75bda1d 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -99,7 +99,7 @@ Blocks .. autoclass:: SEResNetBottleneck :members: -`Squeeze-and-Excitation ResneXt Bottleneck` +`Squeeze-and-Excitation ResNeXt Bottleneck` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: SEResNeXtBottleneck :members: diff --git a/monai/apps/pathology/datasets.py b/monai/apps/pathology/datasets.py index 01902d1ee2..cba8cd2da9 100644 --- a/monai/apps/pathology/datasets.py +++ b/monai/apps/pathology/datasets.py @@ -283,7 +283,7 @@ def _load_a_patch(self, index): """ Load sample given the index - Since index is sequential and the patches are comming in an stream from different images, + Since index is sequential and the patches are coming in an stream from different images, this method, first, finds the whole slide image and the patch that should be extracted, then it loads the patch and provide it with its image name and the corresponding mask location. """ diff --git a/monai/csrc/filtering/bilateral/bilateralfilter_cuda_phl.cu b/monai/csrc/filtering/bilateral/bilateralfilter_cuda_phl.cu index 603ab689cf..17dc9e7ebd 100644 --- a/monai/csrc/filtering/bilateral/bilateralfilter_cuda_phl.cu +++ b/monai/csrc/filtering/bilateral/bilateralfilter_cuda_phl.cu @@ -95,7 +95,7 @@ void BilateralFilterPHLCuda( cudaMalloc(&data, desc.batchCount * desc.channelStride * desc.channelCount * sizeof(scalar_t)); cudaMalloc(&features, desc.batchCount * desc.channelStride * featureChannelCount * sizeof(scalar_t)); - // Prparing constant memory + // Preparing constant memory cudaMemcpyToSymbol(cBatchStride, &desc.batchStride, sizeof(int)); cudaMemcpyToSymbol(cChannelStride, &desc.channelStride, sizeof(int)); cudaMemcpyToSymbol(cSpatialStrides, desc.strides, sizeof(int) * desc.dimensions); diff --git a/monai/csrc/filtering/permutohedral/hash_table.cuh b/monai/csrc/filtering/permutohedral/hash_table.cuh index 7d9d7eb163..f9893dffe2 100644 --- a/monai/csrc/filtering/permutohedral/hash_table.cuh +++ b/monai/csrc/filtering/permutohedral/hash_table.cuh @@ -15,7 +15,7 @@ limitations under the License. //#define USE_ADDITIVE_HASH -// turn this on if you want to get slighly less memory consumption and slightly longer run times. +// turn this on if you want to get slightly less memory consumption and slightly longer run times. //#define LINEAR_D_MEMORY #define USE_CUSTOM_MODULO diff --git a/monai/networks/blocks/crf.py b/monai/networks/blocks/crf.py index 27556a2c72..635c750ba9 100644 --- a/monai/networks/blocks/crf.py +++ b/monai/networks/blocks/crf.py @@ -20,7 +20,7 @@ class CRF(torch.nn.Module): """ Conditional Random Field: Combines message passing with a class - compatability convolution into an iterative process designed + compatibility convolution into an iterative process designed to successively minimise the energy of the class labeling. In this implementation, the message passing step is a weighted @@ -40,7 +40,7 @@ def __init__( bilateral_color_sigma: float = 0.5, gaussian_spatial_sigma: float = 5.0, update_factor: float = 3.0, - compatability_kernel_range: int = 1, + compatibility_kernel_range: int = 1, iterations: int = 5, ): """ @@ -51,7 +51,7 @@ def __init__( bilateral_color_sigma: standard deviation in color space for the bilateral term. gaussian_spatial_sigma: standard deviation in spatial coordinates for the gaussian term. update_factor: determines the magnitude of each update. - compatability_kernel_range: the range of the kernel used in the compatability convolution. + compatibility_kernel_range: the range of the kernel used in the compatibility convolution. iterations: the number of iterations. """ super(CRF, self).__init__() @@ -61,14 +61,14 @@ def __init__( self.bilateral_color_sigma = bilateral_color_sigma self.gaussian_spatial_sigma = gaussian_spatial_sigma self.update_factor = update_factor - self.compatability_kernel_range = compatability_kernel_range + self.compatibility_kernel_range = compatibility_kernel_range self.iterations = iterations def forward(self, input_tensor: torch.Tensor, reference_tensor: torch.Tensor): """ Args: input_tensor: tensor containing initial class logits. - referenece_tensor: the reference tensor used to guide the message passing. + reference_tensor: the reference tensor used to guide the message passing. Returns: output (torch.Tensor): output tensor. @@ -77,7 +77,7 @@ def forward(self, input_tensor: torch.Tensor, reference_tensor: torch.Tensor): # useful values spatial_dim = input_tensor.dim() - 2 class_count = input_tensor.size(1) - padding = self.compatability_kernel_range + padding = self.compatibility_kernel_range # constructing spatial feature tensor spatial_features = _create_coordinate_tensor(reference_tensor) @@ -88,18 +88,18 @@ def forward(self, input_tensor: torch.Tensor, reference_tensor: torch.Tensor): ) gaussian_features = spatial_features / self.gaussian_spatial_sigma - # compatability matrix (potts model (1 - diag) for now) - compatability_matrix = _potts_model_weights(class_count).to(device=input_tensor.device) + # compatibility matrix (potts model (1 - diag) for now) + compatibility_matrix = _potts_model_weights(class_count).to(device=input_tensor.device) # expanding matrix to kernel - compatability_kernel = _expand_matrix_to_kernel( - compatability_matrix, spatial_dim, self.compatability_kernel_range + compatibility_kernel = _expand_matrix_to_kernel( + compatibility_matrix, spatial_dim, self.compatibility_kernel_range ) # choosing convolution function conv = [conv1d, conv2d, conv3d][spatial_dim - 1] - # seting up output tensor + # setting up output tensor output_tensor = softmax(input_tensor, dim=1) # mean field loop @@ -114,7 +114,7 @@ def forward(self, input_tensor: torch.Tensor, reference_tensor: torch.Tensor): # compatibility convolution combined_output = pad(combined_output, 2 * spatial_dim * [padding], mode="replicate") - compatibility_update = conv(combined_output, compatability_kernel) + compatibility_update = conv(combined_output, compatibility_kernel) # update and normalize output_tensor = softmax(input_tensor - self.update_factor * compatibility_update, dim=1) diff --git a/monai/networks/blocks/regunet_block.py b/monai/networks/blocks/regunet_block.py index f4c2c1f3a7..591837be75 100644 --- a/monai/networks/blocks/regunet_block.py +++ b/monai/networks/blocks/regunet_block.py @@ -227,7 +227,7 @@ def __init__( spatial_dims: number of spatial dimensions extract_levels: spatial levels to extract feature from, 0 refers to the input scale num_channels: number of channels at each scale level, - List or Tuple of lenth equals to `depth` of the RegNet + List or Tuple of length equals to `depth` of the RegNet out_channels: number of output channels kernel_initializer: kernel initializer activation: kernel activation function diff --git a/monai/networks/layers/filtering.py b/monai/networks/layers/filtering.py index fc6c0a38b5..3b2214d59a 100644 --- a/monai/networks/layers/filtering.py +++ b/monai/networks/layers/filtering.py @@ -32,7 +32,7 @@ class BilateralFilter(torch.autograd.Function): input: input tensor. spatial sigma: the standard deviation of the spatial blur. Higher values can - hurt performace when not using the approximate method (see fast approx). + hurt performance when not using the approximate method (see fast approx). color sigma: the standard deviation of the color blur. Lower values preserve edges better whilst higher values tend to a simple gaussian spatial blur. @@ -95,7 +95,7 @@ def forward(ctx, input, features, sigmas=None): @staticmethod def backward(ctx, grad_output): - raise NotImplementedError("PHLFilter does not currently support backpropergation") + raise NotImplementedError("PHLFilter does not currently support Backpropagation") # scaled_features, = ctx.saved_variables # grad_input = _C.phl_filter(grad_output, scaled_features) # return grad_input diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index d509ea33a1..dd40663e2a 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -19,7 +19,7 @@ from monai.transforms.inverse import InvertibleTransform -# For backwards compatiblity (so this still works: from monai.transforms.compose import MapTransform) +# For backwards compatibility (so this still works: from monai.transforms.compose import MapTransform) from monai.transforms.transform import ( # noqa: F401 MapTransform, Randomizable, diff --git a/tests/test_crf_cpu.py b/tests/test_crf_cpu.py index f6e82d16a5..41ae75f4b4 100644 --- a/tests/test_crf_cpu.py +++ b/tests/test_crf_cpu.py @@ -30,7 +30,7 @@ 0.5, # bilateral_color_sigma 5.0, # gaussian_spatial_sigma 1.0, # update_factor - 1, # compatability_kernel_range + 1, # compatibility_kernel_range 5, # iterations ], # Input @@ -92,7 +92,7 @@ 0.5, # bilateral_color_sigma 5.0, # gaussian_spatial_sigma 1.0, # update_factor - 1, # compatability_kernel_range + 1, # compatibility_kernel_range 5, # iterations ], # Input @@ -189,7 +189,7 @@ 0.1, # bilateral_color_sigma 5.0, # gaussian_spatial_sigma 1.0, # update_factor - 1, # compatability_kernel_range + 1, # compatibility_kernel_range 2, # iterations ], # Input diff --git a/tests/test_crf_cuda.py b/tests/test_crf_cuda.py index 55d57d67bf..6e67d4ec8c 100644 --- a/tests/test_crf_cuda.py +++ b/tests/test_crf_cuda.py @@ -30,7 +30,7 @@ 0.5, # bilateral_color_sigma 5.0, # gaussian_spatial_sigma 1.0, # update_factor - 1, # compatability_kernel_range + 1, # compatibility_kernel_range 5, # iterations ], # Input @@ -92,7 +92,7 @@ 0.5, # bilateral_color_sigma 5.0, # gaussian_spatial_sigma 1.0, # update_factor - 1, # compatability_kernel_range + 1, # compatibility_kernel_range 5, # iterations ], # Input @@ -189,7 +189,7 @@ 0.1, # bilateral_color_sigma 5.0, # gaussian_spatial_sigma 1.0, # update_factor - 1, # compatability_kernel_range + 1, # compatibility_kernel_range 2, # iterations ], # Input From 26749ff77c7b6539a59f9a846fadaa9e769cdacd Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 3 Apr 2021 10:06:46 +0800 Subject: [PATCH 10/55] 1900 Add support to save patch data into NIfTI or PNG files (#1922) * [DLMED] SaveImage supports patch data Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/data/nifti_saver.py | 30 +++++++++++++++++------ monai/data/png_saver.py | 30 +++++++++++++++++------ monai/data/utils.py | 8 +++++- monai/handlers/segmentation_saver.py | 7 ++++-- monai/transforms/croppad/dictionary.py | 10 +++++++- monai/transforms/io/array.py | 17 ++++++++++--- monai/transforms/io/dictionary.py | 10 +++++--- monai/utils/misc.py | 1 + tests/test_file_basename.py | 8 ++++++ tests/test_handler_segmentation_saver.py | 9 +++++-- tests/test_rand_crop_by_pos_neg_labeld.py | 2 ++ tests/test_rand_spatial_crop_samplesd.py | 2 ++ tests/test_save_imaged.py | 17 +++++++++++-- 13 files changed, 123 insertions(+), 28 deletions(-) diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index 15e61c79e1..34df819d32 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional, Union +from typing import Dict, Optional, Sequence, Union import numpy as np import torch @@ -93,7 +93,12 @@ def __init__( self.squeeze_end_dims = squeeze_end_dims self.data_root_dir = data_root_dir - def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: + def save( + self, + data: Union[torch.Tensor, np.ndarray], + meta_data: Optional[Dict] = None, + patch_index: Optional[int] = None, + ) -> None: """ Save data into a Nifti file. The meta_data could optionally have the following keys: @@ -112,6 +117,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] data: target data content that to be saved as a NIfTI format file. Assuming the data shape starts with a channel dimension and followed by spatial dimensions. meta_data: the meta data information corresponding to the data. + patch_index: if the data is a patch of big image, need to append the patch index to filename. See Also :py:meth:`monai.data.nifti_writer.write_nifti` @@ -125,8 +131,8 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() - filename = create_file_basename(self.output_postfix, filename, self.output_dir, self.data_root_dir) - filename = f"{filename}{self.output_ext}" + path = create_file_basename(self.output_postfix, filename, self.output_dir, self.data_root_dir, patch_index) + path = f"{path}{self.output_ext}" # change data shape to be (channel, h, w, d) while len(data.shape) < 4: data = np.expand_dims(data, -1) @@ -140,7 +146,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] write_nifti( data, - file_name=filename, + file_name=path, affine=affine, target_affine=original_affine, resample=self.resample, @@ -152,7 +158,12 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] output_dtype=self.output_dtype, ) - def save_batch(self, batch_data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: + def save_batch( + self, + batch_data: Union[torch.Tensor, np.ndarray], + meta_data: Optional[Dict] = None, + patch_indice: Optional[Sequence[int]] = None, + ) -> None: """ Save a batch of data into Nifti format files. @@ -169,6 +180,11 @@ def save_batch(self, batch_data: Union[torch.Tensor, np.ndarray], meta_data: Opt Args: batch_data: target batch data content that save into NIfTI format. meta_data: every key-value in the meta_data is corresponding to a batch of data. + patch_indice: if the data is a patch of big image, need to append the patch index to filename. """ for i, data in enumerate(batch_data): # save a batch of files - self.save(data, {k: meta_data[k][i] for k in meta_data} if meta_data else None) + self.save( + data=data, + meta_data={k: meta_data[k][i] for k in meta_data} if meta_data is not None else None, + patch_index=patch_indice[i] if patch_indice is not None else None, + ) diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py index a6cc0e89a2..17087fcaca 100644 --- a/monai/data/png_saver.py +++ b/monai/data/png_saver.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional, Union +from typing import Dict, Optional, Sequence, Union import numpy as np import torch @@ -71,7 +71,12 @@ def __init__( self._data_index = 0 - def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: + def save( + self, + data: Union[torch.Tensor, np.ndarray], + meta_data: Optional[Dict] = None, + patch_index: Optional[int] = None, + ) -> None: """ Save data into a png file. The meta_data could optionally have the following keys: @@ -87,6 +92,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] Shape of the spatial dimensions (C,H,W). C should be 1, 3 or 4 meta_data: the meta data information corresponding to the data. + patch_index: if the data is a patch of big image, need to append the patch index to filename. Raises: ValueError: When ``data`` channels is not one of [1, 3, 4]. @@ -102,8 +108,8 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() - filename = create_file_basename(self.output_postfix, filename, self.output_dir, self.data_root_dir) - filename = f"{filename}{self.output_ext}" + path = create_file_basename(self.output_postfix, filename, self.output_dir, self.data_root_dir, patch_index) + path = f"{path}{self.output_ext}" if data.shape[0] == 1: data = data.squeeze(0) @@ -114,18 +120,28 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] write_png( np.asarray(data), - file_name=filename, + file_name=path, output_spatial_shape=spatial_shape, mode=self.mode, scale=self.scale, ) - def save_batch(self, batch_data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: + def save_batch( + self, + batch_data: Union[torch.Tensor, np.ndarray], + meta_data: Optional[Dict] = None, + patch_indice: Optional[Sequence[int]] = None, + ) -> None: """Save a batch of data into png format files. Args: batch_data: target batch data content that save into png format. meta_data: every key-value in the meta_data is corresponding to a batch of data. + patch_indice: if the data is a patch of big image, need to append the patch index to filename. """ for i, data in enumerate(batch_data): # save a batch of files - self.save(data, {k: meta_data[k][i] for k in meta_data} if meta_data else None) + self.save( + data=data, + meta_data={k: meta_data[k][i] for k in meta_data} if meta_data is not None else None, + patch_index=patch_indice[i] if patch_indice is not None else None, + ) diff --git a/monai/data/utils.py b/monai/data/utils.py index a3d8f3128e..938365460b 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -600,6 +600,7 @@ def create_file_basename( input_file_name: str, folder_path: str, data_root_dir: str = "", + patch_index: Optional[int] = None, ) -> str: """ Utility function to create the path to the output file based on the input @@ -623,6 +624,7 @@ def create_file_basename( absolute path. This is used to compute `input_file_rel_path`, the relative path to the file from `data_root_dir` to preserve folder structure when saving in case there are files in different folders with the same file names. + patch_index: if not None, append the patch index to filename. """ # get the filename and directory @@ -641,11 +643,15 @@ def create_file_basename( if not os.path.exists(subfolder_path): os.makedirs(subfolder_path) - if postfix: + if len(postfix) > 0: # add the sub-folder plus the postfix name to become the file basename in the output path output = os.path.join(subfolder_path, filename + "_" + postfix) else: output = os.path.join(subfolder_path, filename) + + if patch_index is not None: + output += f"_{patch_index}" + return os.path.abspath(output) diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index 9ee7ca67f9..7df10b9dad 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -16,7 +16,9 @@ from monai.config import DtypeLike from monai.transforms import SaveImage -from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, exact_version, optional_import +from monai.utils import GridSampleMode, GridSamplePadMode +from monai.utils import ImageMetaKey as Key +from monai.utils import InterpolateMode, exact_version, optional_import Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: @@ -143,5 +145,6 @@ def __call__(self, engine: Engine) -> None: """ meta_data = self.batch_transform(engine.state.batch) engine_output = self.output_transform(engine.state.output) - self._saver(engine_output, meta_data) + patch_indice = engine.state.batch.get(Key.PATCH_INDEX, None) + self._saver(engine_output, meta_data, patch_indice) self.logger.info("saved all the model outputs into files.") diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 64e9f862f9..428e35335c 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -41,6 +41,7 @@ map_binary_to_indices, weighted_patch_samples, ) +from monai.utils import ImageMetaKey as Key from monai.utils import Method, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple from monai.utils.enums import InverseKeys @@ -528,7 +529,12 @@ def randomize(self, data: Optional[Any] = None) -> None: pass def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, np.ndarray]]: - return [self.cropper(data) for _ in range(self.num_samples)] + ret = [] + for i in range(self.num_samples): + cropped = self.cropper(data) + cropped[Key.PATCH_INDEX] = i # type: ignore + ret.append(cropped) + return ret class CropForegroundd(MapTransform, InvertibleTransform): @@ -783,6 +789,8 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n # fill in the extra keys with unmodified data for key in set(data.keys()).difference(set(self.keys)): results[i][key] = data[key] + # add patch index in the meta data + results[i][Key.PATCH_INDEX] = i # type: ignore return results diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 61439c0355..b138b97cb2 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -269,8 +269,19 @@ def __init__( self.save_batch = save_batch - def __call__(self, img: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None): + def __call__( + self, + img: Union[torch.Tensor, np.ndarray], + meta_data: Optional[Dict] = None, + patch_index=None, # type is Union[Sequence[int], int, None], can't be compatible with save and save_batch + ): + """ + Args: + img: target data content that save into file. + meta_data: key-value pairs of meta_data corresponding to the data. + patch_index: if the data is a patch of big image, need to append the patch index to filename. + """ if self.save_batch: - self.saver.save_batch(img, meta_data) + self.saver.save_batch(img, meta_data, patch_index) else: - self.saver.save(img, meta_data) + self.saver.save(img, meta_data, patch_index) diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 6a82ff2267..5b8f0a41d3 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -23,7 +23,9 @@ from monai.data.image_reader import ImageReader from monai.transforms.io.array import LoadImage, SaveImage from monai.transforms.transform import MapTransform -from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode +from monai.utils import GridSampleMode, GridSamplePadMode +from monai.utils import ImageMetaKey as Key +from monai.utils import InterpolateMode __all__ = [ "LoadImaged", @@ -124,7 +126,9 @@ class SaveImaged(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.SaveImage`. - NB: image should include channel dimension: [B],C,H,W,[D]. + Note: + Image should include channel dimension: [B],C,H,W,[D]. + If the data is a patch of big image, will append the patch index to filename. Args: keys: keys of the corresponding items to be transformed. @@ -225,7 +229,7 @@ def __call__(self, data): d = dict(data) for key in self.key_iterator(d): meta_data = d[f"{key}_{self.meta_key_postfix}"] if self.meta_key_postfix is not None else None - self._saver(img=d[key], meta_data=meta_data) + self._saver(img=d[key], meta_data=meta_data, patch_index=d.get(Key.PATCH_INDEX, None)) return d diff --git a/monai/utils/misc.py b/monai/utils/misc.py index ee0963548c..bd8e46d8b5 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -358,3 +358,4 @@ class ImageMetaKey: """ FILENAME_OR_OBJ = "filename_or_obj" + PATCH_INDEX = "patch_index" diff --git a/tests/test_file_basename.py b/tests/test_file_basename.py index 1b67baea8c..cb7ee77e62 100644 --- a/tests/test_file_basename.py +++ b/tests/test_file_basename.py @@ -57,10 +57,18 @@ def test_value(self): expected = os.path.join(output_tmp, "test", "test") self.assertEqual(result, expected) + result = create_file_basename("", "test.txt", output_tmp, "foo", 5) + expected = os.path.join(output_tmp, "test", "test_5") + self.assertEqual(result, expected) + result = create_file_basename("post", "test.tar.gz", output_tmp, "foo") expected = os.path.join(output_tmp, "test", "test_post") self.assertEqual(result, expected) + result = create_file_basename("post", "test.tar.gz", output_tmp, "foo", 8) + expected = os.path.join(output_tmp, "test", "test_post_8") + self.assertEqual(result, expected) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_handler_segmentation_saver.py b/tests/test_handler_segmentation_saver.py index 1a2bbb7fbd..5449530b50 100644 --- a/tests/test_handler_segmentation_saver.py +++ b/tests/test_handler_segmentation_saver.py @@ -40,10 +40,15 @@ def _train_func(engine, batch): saver = SegmentationSaver(output_dir=tempdir, output_postfix="seg", output_ext=output_ext, scale=255) saver.attach(engine) - data = [{"filename_or_obj": ["testfile" + str(i) + ".nii.gz" for i in range(8)]}] + data = [ + { + "filename_or_obj": ["testfile" + str(i) + ".nii.gz" for i in range(8)], + "patch_index": list(range(8)), + } + ] engine.run(data, max_epochs=1) for i in range(8): - filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_seg" + output_ext) + filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_seg" + f"_{i}" + output_ext) self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) @parameterized.expand([TEST_CASE_0, TEST_CASE_1]) diff --git a/tests/test_rand_crop_by_pos_neg_labeld.py b/tests/test_rand_crop_by_pos_neg_labeld.py index 06e63c14e8..2744d729a1 100644 --- a/tests/test_rand_crop_by_pos_neg_labeld.py +++ b/tests/test_rand_crop_by_pos_neg_labeld.py @@ -91,6 +91,8 @@ def test_type_shape(self, input_param, input_data, expected_type, expected_shape self.assertTupleEqual(result[0]["image"].shape, expected_shape) self.assertTupleEqual(result[0]["extral"].shape, expected_shape) self.assertTupleEqual(result[0]["label"].shape, expected_shape) + for i, item in enumerate(result): + self.assertEqual(item["patch_index"], i) if __name__ == "__main__": diff --git a/tests/test_rand_spatial_crop_samplesd.py b/tests/test_rand_spatial_crop_samplesd.py index afd7ab602c..5b745add18 100644 --- a/tests/test_rand_spatial_crop_samplesd.py +++ b/tests/test_rand_spatial_crop_samplesd.py @@ -70,6 +70,8 @@ def test_shape(self, input_param, input_data, expected_shape, expected_last): for item, expected in zip(result, expected_shape): self.assertTupleEqual(item["img"].shape, expected) self.assertTupleEqual(item["seg"].shape, expected) + for i, item in enumerate(result): + self.assertEqual(item["patch_index"], i) np.testing.assert_allclose(item["img"], expected_last["img"]) np.testing.assert_allclose(item["seg"], expected_last["seg"]) diff --git a/tests/test_save_imaged.py b/tests/test_save_imaged.py index a6ebfe0d8d..b5293473c2 100644 --- a/tests/test_save_imaged.py +++ b/tests/test_save_imaged.py @@ -87,9 +87,20 @@ False, ] +TEST_CASE_6 = [ + { + "img": torch.randint(0, 255, (1, 2, 3, 4)), + "img_meta_dict": {"filename_or_obj": "testfile0.nii.gz"}, + "patch_index": 6, + }, + ".nii.gz", + False, + False, +] + class TestSaveImaged(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) def test_saved_content(self, test_data, output_ext, resample, save_batch): with tempfile.TemporaryDirectory() as tempdir: trans = SaveImaged( @@ -106,7 +117,9 @@ def test_saved_content(self, test_data, output_ext, resample, save_batch): filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_trans" + output_ext) self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) else: - filepath = os.path.join("testfile0", "testfile0" + "_trans" + output_ext) + patch_index = test_data.get("patch_index", None) + patch_index = f"_{patch_index}" if patch_index is not None else "" + filepath = os.path.join("testfile0", "testfile0" + "_trans" + patch_index + output_ext) self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) From 7300b926e5c7b4fe634689bb5147dbb5b22bb3ef Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sat, 3 Apr 2021 04:02:12 +0100 Subject: [PATCH 11/55] Revert "1919 - test pt 2103 (#1920)" (#1925) This reverts commit 4bd26f9f6c18e7c6e68320d19f556705d9afab60. Signed-off-by: Wenqi Li Co-authored-by: Nic Ma Signed-off-by: Neha Srivathsa --- .github/workflows/cron.yml | 4 ++-- .github/workflows/pythonapp.yml | 2 +- Dockerfile | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 3562672232..761b1f7ebc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -60,7 +60,7 @@ jobs: cron-pt-image: if: github.repository == 'Project-MONAI/MONAI' container: - image: nvcr.io/nvidia/pytorch:21.03-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:21.02-py3 # testing with the latest pytorch base image options: "--gpus all" runs-on: [self-hosted, linux, x64, common] steps: @@ -133,7 +133,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' needs: cron-gpu # so that monai itself is verified first container: - image: nvcr.io/nvidia/pytorch:21.03-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:21.02-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.yml b/.github/workflows/pythonapp.yml index 738d657211..e5803028a0 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -186,7 +186,7 @@ jobs: - environment: PT18+CUDA112 # we explicitly set pytorch to -h to avoid pip install error pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:21.03-py3" + base: "nvcr.io/nvidia/pytorch:21.02-py3" container: image: ${{ matrix.base }} options: --gpus all diff --git a/Dockerfile b/Dockerfile index 54d1f02275..57ea567869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # To build with a different base image # please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.03-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.02-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" From 11e6a81e619ba676686315724e4bf8fe2ec4a702 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sat, 3 Apr 2021 13:24:30 +0100 Subject: [PATCH 12/55] skip warp tests before torch 18 (#1927) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- tests/test_warp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_warp.py b/tests/test_warp.py index 4ed1562b29..37a8551241 100644 --- a/tests/test_warp.py +++ b/tests/test_warp.py @@ -8,6 +8,7 @@ from monai.config.deviceconfig import USE_COMPILED from monai.networks.blocks.warp import Warp from monai.utils import GridSampleMode, GridSamplePadMode +from tests.utils import SkipIfBeforePyTorchVersion LOW_POWER_TEST_CASES = [ # run with BUILD_MONAI=1 to test csrc/resample, BUILD_MONAI=0 to test native grid_sample [ @@ -103,6 +104,7 @@ def test_ill_shape(self): with self.assertRaisesRegex(ValueError, ""): warp_layer(image=torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), ddf=torch.zeros(1, 2, 3, 3)) + @SkipIfBeforePyTorchVersion((1, 8)) def test_grad(self): for b in GridSampleMode: for p in GridSamplePadMode: From d5afef37054d97484eb18d5afeb9d0cc159176f9 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sat, 3 Apr 2021 14:22:48 +0100 Subject: [PATCH 13/55] update docstrings (#1931) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/apps/pathology/handlers.py | 11 ++++++++++ .../filtering/permutohedral/permutohedral.cpp | 13 +++++++++++ monai/networks/blocks/localnet_block.py | 11 ++++++++++ monai/networks/blocks/regunet_block.py | 1 + monai/networks/blocks/warp.py | 11 ++++++++++ monai/networks/nets/torchvision_fc.py | 22 +++++++++++-------- monai/optimizers/lr_finder.py | 11 ++++++++++ monai/utils/state_cacher.py | 11 ++++++++++ tests/test_cuimage_reader.py | 11 ++++++++++ tests/test_dvf2ddf.py | 11 ++++++++++ tests/test_globalnet.py | 11 ++++++++++ tests/test_lesion_froc.py | 11 ++++++++++ ...local_normalized_cross_correlation_loss.py | 2 +- tests/test_localnet.py | 11 ++++++++++ tests/test_localnet_block.py | 11 ++++++++++ tests/test_masked_inference_wsi_dataset.py | 11 ++++++++++ tests/test_nifti_endianness.py | 11 ++++++++++ tests/test_openslide_reader.py | 11 ++++++++++ tests/test_patch_wsi_dataset.py | 11 ++++++++++ tests/test_smartcache_patch_wsi_dataset.py | 11 ++++++++++ tests/test_warp.py | 11 ++++++++++ 21 files changed, 215 insertions(+), 10 deletions(-) diff --git a/monai/apps/pathology/handlers.py b/monai/apps/pathology/handlers.py index 046e403e0f..f0790c20b1 100644 --- a/monai/apps/pathology/handlers.py +++ b/monai/apps/pathology/handlers.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import logging import os from typing import TYPE_CHECKING, Dict, Optional diff --git a/monai/csrc/filtering/permutohedral/permutohedral.cpp b/monai/csrc/filtering/permutohedral/permutohedral.cpp index 5d6916b8f4..04ef6fa4da 100644 --- a/monai/csrc/filtering/permutohedral/permutohedral.cpp +++ b/monai/csrc/filtering/permutohedral/permutohedral.cpp @@ -1,3 +1,16 @@ +/* +Copyright 2020 - 2021 MONAI Consortium +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + #include "utils/common_utils.h" #include "utils/meta_macros.h" diff --git a/monai/networks/blocks/localnet_block.py b/monai/networks/blocks/localnet_block.py index 4166c08774..cc90e6ed1d 100644 --- a/monai/networks/blocks/localnet_block.py +++ b/monai/networks/blocks/localnet_block.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from typing import Optional, Sequence, Tuple, Type, Union import torch diff --git a/monai/networks/blocks/regunet_block.py b/monai/networks/blocks/regunet_block.py index 591837be75..d2cd3518b9 100644 --- a/monai/networks/blocks/regunet_block.py +++ b/monai/networks/blocks/regunet_block.py @@ -8,6 +8,7 @@ # 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 List, Optional, Sequence, Tuple, Type, Union import torch diff --git a/monai/networks/blocks/warp.py b/monai/networks/blocks/warp.py index b9967f2b62..43ada86b27 100644 --- a/monai/networks/blocks/warp.py +++ b/monai/networks/blocks/warp.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import warnings from typing import List diff --git a/monai/networks/nets/torchvision_fc.py b/monai/networks/nets/torchvision_fc.py index 4fdd0d64ef..8b8a223b55 100644 --- a/monai/networks/nets/torchvision_fc.py +++ b/monai/networks/nets/torchvision_fc.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from typing import Tuple, Union import torch @@ -13,15 +24,8 @@ class TorchVisionFullyConvModel(torch.nn.Module): Args: model_name: name of any torchvision with adaptive avg pooling and fully connected layer at the end. - - resnet18 (default) - - resnet34 - - resnet50 - - resnet101 - - resnet152 - - resnext50_32x4d - - resnext101_32x8d - - wide_resnet50_2 - - wide_resnet101_2 + ``resnet18`` (default), ``resnet34m``, ``resnet50``, ``resnet101``, ``resnet152``, + ``resnext50_32x4d``, ``resnext101_32x8d``, ``wide_resnet50_2``, ``wide_resnet101_2``. n_classes: number of classes for the last classification layer. Default to 1. pool_size: the kernel size for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to (7, 7). pool_stride: the stride for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to 1. diff --git a/monai/optimizers/lr_finder.py b/monai/optimizers/lr_finder.py index 9e753a1ced..49d4427b3d 100644 --- a/monai/optimizers/lr_finder.py +++ b/monai/optimizers/lr_finder.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import warnings from functools import partial from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union diff --git a/monai/utils/state_cacher.py b/monai/utils/state_cacher.py index 66e9080724..65a6118670 100644 --- a/monai/utils/state_cacher.py +++ b/monai/utils/state_cacher.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import copy import os import tempfile diff --git a/tests/test_cuimage_reader.py b/tests/test_cuimage_reader.py index c096bad0c2..2cbfaec113 100644 --- a/tests/test_cuimage_reader.py +++ b/tests/test_cuimage_reader.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import unittest from unittest import skipUnless diff --git a/tests/test_dvf2ddf.py b/tests/test_dvf2ddf.py index bf04fed8b6..cc3323cf13 100644 --- a/tests/test_dvf2ddf.py +++ b/tests/test_dvf2ddf.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import unittest import numpy as np diff --git a/tests/test_globalnet.py b/tests/test_globalnet.py index 0aab57d272..32bc58f610 100644 --- a/tests/test_globalnet.py +++ b/tests/test_globalnet.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import unittest import numpy as np diff --git a/tests/test_lesion_froc.py b/tests/test_lesion_froc.py index 1f2926631f..2454de88fa 100644 --- a/tests/test_lesion_froc.py +++ b/tests/test_lesion_froc.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import unittest from unittest import skipUnless diff --git a/tests/test_local_normalized_cross_correlation_loss.py b/tests/test_local_normalized_cross_correlation_loss.py index bb0bd7b642..31954e727b 100644 --- a/tests/test_local_normalized_cross_correlation_loss.py +++ b/tests/test_local_normalized_cross_correlation_loss.py @@ -1,4 +1,4 @@ -# Copyright 2020 MONAI Consortium +# Copyright 2020 - 2021 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/tests/test_localnet.py b/tests/test_localnet.py index df1d9f61cb..dc680f15f9 100644 --- a/tests/test_localnet.py +++ b/tests/test_localnet.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import unittest import torch diff --git a/tests/test_localnet_block.py b/tests/test_localnet_block.py index e6171aeae9..f4e857a0fa 100644 --- a/tests/test_localnet_block.py +++ b/tests/test_localnet_block.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import unittest import torch diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py index 88af8c05c0..ed79b4f3a7 100644 --- a/tests/test_masked_inference_wsi_dataset.py +++ b/tests/test_masked_inference_wsi_dataset.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import unittest from unittest import skipUnless diff --git a/tests/test_nifti_endianness.py b/tests/test_nifti_endianness.py index b725e2462c..57f26d2247 100644 --- a/tests/test_nifti_endianness.py +++ b/tests/test_nifti_endianness.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import tempfile import unittest diff --git a/tests/test_openslide_reader.py b/tests/test_openslide_reader.py index e005dbd1c4..c0b395fd02 100644 --- a/tests/test_openslide_reader.py +++ b/tests/test_openslide_reader.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import unittest from unittest import skipUnless diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index c4a94a60c4..7c34997872 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import unittest from unittest import skipUnless diff --git a/tests/test_smartcache_patch_wsi_dataset.py b/tests/test_smartcache_patch_wsi_dataset.py index d7c2ce5bd1..876a30a3b8 100644 --- a/tests/test_smartcache_patch_wsi_dataset.py +++ b/tests/test_smartcache_patch_wsi_dataset.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import unittest from unittest import skipUnless diff --git a/tests/test_warp.py b/tests/test_warp.py index 37a8551241..c6c79a369a 100644 --- a/tests/test_warp.py +++ b/tests/test_warp.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import unittest import numpy as np From 7a2ac8a69aed23bccc934efee01e821f011ee1f7 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sat, 3 Apr 2021 15:34:56 +0100 Subject: [PATCH 14/55] update get_package_version (#1930) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/utils/module.py | 16 +++------------- tests/test_get_package_version.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 tests/test_get_package_version.py diff --git a/monai/utils/module.py b/monai/utils/module.py index 0e11a6531d..c12eaf101d 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -250,21 +250,11 @@ def has_option(obj, keywords: Union[str, Sequence[str]]) -> bool: def get_package_version(dep_name, default="NOT INSTALLED or UNKNOWN VERSION."): """ Try to load package and get version. If not found, return `default`. - - If the package was already loaded, leave it. If wasn't previously loaded, unload it. """ - dep_ver = default - dep_already_loaded = dep_name not in sys.modules - dep, has_dep = optional_import(dep_name) - if has_dep: - if hasattr(dep, "__version__"): - dep_ver = dep.__version__ - # if not previously loaded, unload it - if not dep_already_loaded: - del dep - del sys.modules[dep_name] - return dep_ver + if has_dep and hasattr(dep, "__version__"): + return dep.__version__ + return default def get_torch_version_tuple(): diff --git a/tests/test_get_package_version.py b/tests/test_get_package_version.py new file mode 100644 index 0000000000..beddb340ab --- /dev/null +++ b/tests/test_get_package_version.py @@ -0,0 +1,31 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from monai.utils.module import get_package_version + + +class TestGetVersion(unittest.TestCase): + def test_default(self): + output = get_package_version("42foobarnoexist") + self.assertTrue("UNKNOWN" in output) + + output = get_package_version("numpy") + self.assertFalse("UNKNOWN" in output) + + def test_msg(self): + output = get_package_version("42foobarnoexist", "test") + self.assertTrue("test" in output) + + +if __name__ == "__main__": + unittest.main() From f602cdcc1767b3d30e4dc3f8dc6be79e7b1cdc2a Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 3 Apr 2021 23:56:03 +0800 Subject: [PATCH 15/55] 1904 Add early stop handler (#1921) * [DLMED] add EarlyStop handler Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] enhance validation handler Signed-off-by: Nic Ma * [DLMED] add set_trainer support Signed-off-by: Nic Ma * [DLMED] add more check Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma Co-authored-by: monai-bot Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- docs/source/handlers.rst | 5 ++ monai/handlers/__init__.py | 1 + monai/handlers/checkpoint_saver.py | 1 - monai/handlers/earlystop_handler.py | 95 +++++++++++++++++++++++++ monai/handlers/metric_logger.py | 2 +- monai/handlers/validation_handler.py | 19 +++-- tests/min_tests.py | 1 + tests/test_handler_early_stop.py | 66 +++++++++++++++++ tests/test_handler_prob_map_producer.py | 3 +- tests/test_handler_validation.py | 2 +- 10 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 monai/handlers/earlystop_handler.py create mode 100644 tests/test_handler_early_stop.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index a629b28b27..080e7e138c 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -110,3 +110,8 @@ SmartCache handler ------------------ .. autoclass:: SmartCacheHandler :members: + +EarlyStop handler +----------------- +.. autoclass:: EarlyStopHandler + :members: diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 5669e8a9ee..a1f86310ae 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -13,6 +13,7 @@ from .checkpoint_saver import CheckpointSaver from .classification_saver import ClassificationSaver from .confusion_matrix import ConfusionMatrix +from .earlystop_handler import EarlyStopHandler from .hausdorff_distance import HausdorffDistance from .iteration_metric import IterationMetric from .lr_schedule_handler import LrScheduleHandler diff --git a/monai/handlers/checkpoint_saver.py b/monai/handlers/checkpoint_saver.py index fd80182ba2..68857e17ff 100644 --- a/monai/handlers/checkpoint_saver.py +++ b/monai/handlers/checkpoint_saver.py @@ -17,7 +17,6 @@ Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") Checkpoint, _ = optional_import("ignite.handlers", "0.4.4", exact_version, "Checkpoint") -BaseSaveHandler, _ = optional_import("ignite.handlers.checkpoint", "0.4.4", exact_version, "BaseSaveHandler") if TYPE_CHECKING: from ignite.engine import Engine diff --git a/monai/handlers/earlystop_handler.py b/monai/handlers/earlystop_handler.py new file mode 100644 index 0000000000..99e072b81f --- /dev/null +++ b/monai/handlers/earlystop_handler.py @@ -0,0 +1,95 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import TYPE_CHECKING, Callable, Optional + +from monai.utils import exact_version, optional_import + +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") +EarlyStopping, _ = optional_import("ignite.handlers", "0.4.4", exact_version, "EarlyStopping") + +if TYPE_CHECKING: + from ignite.engine import Engine +else: + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + + +class EarlyStopHandler: + """ + EarlyStopHandler acts as an Ignite handler to stop training if no improvement after a given number of events. + It‘s based on the `EarlyStopping` handler in ignite. + + Args: + patience: number of events to wait if no improvement and then stop the training. + score_function: It should be a function taking a single argument, an :class:`~ignite.engine.engine.Engine` + object that the handler attached, can be a trainer or validator, and return a score `float`. + an improvement is considered if the score is higher. + trainer: trainer engine to stop the run if no improvement, if None, must call `set_trainer()` before training. + min_delta: a minimum increase in the score to qualify as an improvement, + i.e. an increase of less than or equal to `min_delta`, will count as no improvement. + cumulative_delta: if True, `min_delta` defines an increase since the last `patience` reset, otherwise, + it defines an increase after the last event, default to False. + epoch_level: check early stopping for every epoch or every iteration of the attached engine, + `True` is epoch level, `False` is iteration level, defaut to epoch level. + + Note: + If in distributed training and uses loss value of every iteration to detect earlystopping, + the values may be different in different ranks. + User may attach this handler to validator engine to detect validation metrics and stop the training, + in this case, the `score_function` is executed on validator engine and `trainer` is the trainer engine. + + """ + + def __init__( + self, + patience: int, + score_function: Callable, + trainer: Optional[Engine] = None, + min_delta: float = 0.0, + cumulative_delta: bool = False, + epoch_level: bool = True, + ) -> None: + self.patience = patience + self.score_function = score_function + self.min_delta = min_delta + self.cumulative_delta = cumulative_delta + self.epoch_level = epoch_level + self._handler = None + + if trainer is not None: + self.set_trainer(trainer=trainer) + + def attach(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + if self.epoch_level: + engine.add_event_handler(Events.EPOCH_COMPLETED, self) + else: + engine.add_event_handler(Events.ITERATION_COMPLETED, self) + + def set_trainer(self, trainer: Engine): + """ + Set trainer to execute early stop if not setting properly in `__init__()`. + """ + self._handler = EarlyStopping( + patience=self.patience, + score_function=self.score_function, + trainer=trainer, + min_delta=self.min_delta, + cumulative_delta=self.cumulative_delta, + ) + + def __call__(self, engine: Engine) -> None: + if self._handler is None: + raise RuntimeError("please set trainer in __init__() or call set_trainer() before training.") + self._handler(engine) diff --git a/monai/handlers/metric_logger.py b/monai/handlers/metric_logger.py index 778ec13900..f9a3913c56 100644 --- a/monai/handlers/metric_logger.py +++ b/monai/handlers/metric_logger.py @@ -48,7 +48,7 @@ class MetricLogger: logger = MetricLogger(evaluator=evaluator) # construct the trainer with the logger passed in as a handler so that it logs loss values - trainer = SupervisedTrainer(..., train_handlers=[logger, ValidationHandler(evaluator, 1)]) + trainer = SupervisedTrainer(..., train_handlers=[logger, ValidationHandler(1, evaluator)]) # run training, logger.loss will be a list of (iteration, loss) values, logger.metrics a dict with key # "val_mean_dice" storing a list of (iteration, metric) values diff --git a/monai/handlers/validation_handler.py b/monai/handlers/validation_handler.py index 4458a17380..fbd4b7862e 100644 --- a/monai/handlers/validation_handler.py +++ b/monai/handlers/validation_handler.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from monai.engines.evaluator import Evaluator from monai.utils import exact_version, optional_import @@ -28,11 +28,12 @@ class ValidationHandler: """ - def __init__(self, validator: Evaluator, interval: int, epoch_level: bool = True) -> None: + def __init__(self, interval: int, validator: Optional[Evaluator] = None, epoch_level: bool = True) -> None: """ Args: - validator: run the validator when trigger validation, suppose to be Evaluator. interval: do validation every N epochs or every N iterations during training. + validator: run the validator when trigger validation, suppose to be Evaluator. + if None, should call `set_validator()` before training. epoch_level: execute validation every N epochs or N iterations. `True` is epoch level, `False` is iteration level. @@ -40,12 +41,20 @@ def __init__(self, validator: Evaluator, interval: int, epoch_level: bool = True TypeError: When ``validator`` is not a ``monai.engines.evaluator.Evaluator``. """ - if not isinstance(validator, Evaluator): + if validator is not None and not isinstance(validator, Evaluator): raise TypeError(f"validator must be a monai.engines.evaluator.Evaluator but is {type(validator).__name__}.") self.validator = validator self.interval = interval self.epoch_level = epoch_level + def set_validator(self, validator: Evaluator): + """ + Set validator if not setting in the __init__(). + """ + if not isinstance(validator, Evaluator): + raise TypeError(f"validator must be a monai.engines.evaluator.Evaluator but is {type(validator).__name__}.") + self.validator = validator + def attach(self, engine: Engine) -> None: """ Args: @@ -61,4 +70,6 @@ def __call__(self, engine: Engine) -> None: Args: engine: Ignite Engine, it can be a trainer, validator or evaluator. """ + if self.validator is None: + raise RuntimeError("please set validator in __init__() or call `set_validator()` before training.") self.validator.run(engine.state.epoch) diff --git a/tests/min_tests.py b/tests/min_tests.py index 06231af0a1..abb5b73764 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -112,6 +112,7 @@ def run_testsuit(): "test_save_imaged", "test_ensure_channel_first", "test_ensure_channel_firstd", + "test_handler_early_stop", ] assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}" diff --git a/tests/test_handler_early_stop.py b/tests/test_handler_early_stop.py new file mode 100644 index 0000000000..efe8e89825 --- /dev/null +++ b/tests/test_handler_early_stop.py @@ -0,0 +1,66 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from ignite.engine import Engine, Events + +from monai.handlers import EarlyStopHandler + + +class TestHandlerEarlyStop(unittest.TestCase): + def test_early_stop_train_loss(self): + def _train_func(engine, batch): + return {"loss": 1.5} + + trainer = Engine(_train_func) + EarlyStopHandler( + patience=5, + score_function=lambda x: x.state.output["loss"], + trainer=trainer, + epoch_level=False, + ).attach(trainer) + + trainer.run(range(4), max_epochs=2) + self.assertEqual(trainer.state.iteration, 6) + self.assertEqual(trainer.state.epoch, 2) + + def test_early_stop_val_metric(self): + def _train_func(engine, batch): + pass + + trainer = Engine(_train_func) + validator = Engine(_train_func) + validator.state.metrics["val_acc"] = 0.90 + + @trainer.on(Events.EPOCH_COMPLETED) + def run_validation(engine): + validator.state.metrics["val_acc"] += 0.01 + validator.run(range(3)) + + handler = EarlyStopHandler( + patience=3, + score_function=lambda x: x.state.metrics["val_acc"], + trainer=None, + min_delta=0.1, + cumulative_delta=True, + epoch_level=True, + ) + handler.attach(validator) + handler.set_trainer(trainer=trainer) + + trainer.run(range(3), max_epochs=5) + self.assertEqual(trainer.state.iteration, 12) + self.assertEqual(trainer.state.epoch, 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handler_prob_map_producer.py b/tests/test_handler_prob_map_producer.py index 8bf42131b4..4f719fccc0 100644 --- a/tests/test_handler_prob_map_producer.py +++ b/tests/test_handler_prob_map_producer.py @@ -82,8 +82,9 @@ def inference(enging, batch): evaluator = TestEvaluator(torch.device("cpu:0"), data_loader, size, val_handlers=[prob_map_gen]) # set up validation handler - validation = ValidationHandler(evaluator, interval=1) + validation = ValidationHandler(interval=1, validator=None) validation.attach(engine) + validation.set_validator(validator=evaluator) engine.run(data_loader) diff --git a/tests/test_handler_validation.py b/tests/test_handler_validation.py index 11a51c7213..06f400109d 100644 --- a/tests/test_handler_validation.py +++ b/tests/test_handler_validation.py @@ -37,7 +37,7 @@ def _train_func(engine, batch): # set up testing handler val_data_loader = torch.utils.data.DataLoader(Dataset(data)) evaluator = TestEvaluator(torch.device("cpu:0"), val_data_loader) - saver = ValidationHandler(evaluator, interval=2) + saver = ValidationHandler(interval=2, validator=evaluator) saver.attach(engine) engine.run(data, max_epochs=5) From b67aed78f50a798b82b35418f3358783ba45d924 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sun, 4 Apr 2021 03:03:17 +0800 Subject: [PATCH 16/55] 1900 Change to save `patch_index` in the meta_dict (#1933) * [DLMED] change to save patch_index in meta_dict Signed-off-by: Nic Ma * [DLMED] remove error import Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix CI tests Signed-off-by: Nic Ma Co-authored-by: monai-bot Signed-off-by: Neha Srivathsa --- monai/data/nifti_saver.py | 27 +++++-------------- monai/data/png_saver.py | 27 +++++-------------- monai/handlers/segmentation_saver.py | 7 ++--- monai/transforms/croppad/dictionary.py | 32 +++++++++++++++++++---- monai/transforms/io/array.py | 13 +++------ monai/transforms/io/dictionary.py | 6 ++--- tests/test_rand_crop_by_pos_neg_labeld.py | 2 +- tests/test_rand_spatial_crop_samplesd.py | 3 ++- tests/test_save_imaged.py | 2 +- 9 files changed, 53 insertions(+), 66 deletions(-) diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index 34df819d32..0ff719023c 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional, Sequence, Union +from typing import Dict, Optional, Union import numpy as np import torch @@ -93,12 +93,7 @@ def __init__( self.squeeze_end_dims = squeeze_end_dims self.data_root_dir = data_root_dir - def save( - self, - data: Union[torch.Tensor, np.ndarray], - meta_data: Optional[Dict] = None, - patch_index: Optional[int] = None, - ) -> None: + def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: """ Save data into a Nifti file. The meta_data could optionally have the following keys: @@ -107,6 +102,7 @@ def save( - ``'original_affine'`` -- for data orientation handling, defaulting to an identity matrix. - ``'affine'`` -- for data output affine, defaulting to an identity matrix. - ``'spatial_shape'`` -- for data output shape. + - ``'patch_index'`` -- if the data is a patch of big image, append the patch index to filename. When meta_data is specified, the saver will try to resample batch data from the space defined by "affine" to the space defined by "original_affine". @@ -117,7 +113,6 @@ def save( data: target data content that to be saved as a NIfTI format file. Assuming the data shape starts with a channel dimension and followed by spatial dimensions. meta_data: the meta data information corresponding to the data. - patch_index: if the data is a patch of big image, need to append the patch index to filename. See Also :py:meth:`monai.data.nifti_writer.write_nifti` @@ -127,6 +122,7 @@ def save( original_affine = meta_data.get("original_affine", None) if meta_data else None affine = meta_data.get("affine", None) if meta_data else None spatial_shape = meta_data.get("spatial_shape", None) if meta_data else None + patch_index = meta_data.get(Key.PATCH_INDEX, None) if meta_data else None if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() @@ -158,12 +154,7 @@ def save( output_dtype=self.output_dtype, ) - def save_batch( - self, - batch_data: Union[torch.Tensor, np.ndarray], - meta_data: Optional[Dict] = None, - patch_indice: Optional[Sequence[int]] = None, - ) -> None: + def save_batch(self, batch_data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: """ Save a batch of data into Nifti format files. @@ -180,11 +171,7 @@ def save_batch( Args: batch_data: target batch data content that save into NIfTI format. meta_data: every key-value in the meta_data is corresponding to a batch of data. - patch_indice: if the data is a patch of big image, need to append the patch index to filename. + """ for i, data in enumerate(batch_data): # save a batch of files - self.save( - data=data, - meta_data={k: meta_data[k][i] for k in meta_data} if meta_data is not None else None, - patch_index=patch_indice[i] if patch_indice is not None else None, - ) + self.save(data=data, meta_data={k: meta_data[k][i] for k in meta_data} if meta_data is not None else None) diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py index 17087fcaca..880f6b204f 100644 --- a/monai/data/png_saver.py +++ b/monai/data/png_saver.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Optional, Sequence, Union +from typing import Dict, Optional, Union import numpy as np import torch @@ -71,18 +71,14 @@ def __init__( self._data_index = 0 - def save( - self, - data: Union[torch.Tensor, np.ndarray], - meta_data: Optional[Dict] = None, - patch_index: Optional[int] = None, - ) -> None: + def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: """ Save data into a png file. The meta_data could optionally have the following keys: - ``'filename_or_obj'`` -- for output file name creation, corresponding to filename or object. - ``'spatial_shape'`` -- for data output shape. + - ``'patch_index'`` -- if the data is a patch of big image, append the patch index to filename. If meta_data is None, use the default index (starting from 0) as the filename. @@ -92,7 +88,6 @@ def save( Shape of the spatial dimensions (C,H,W). C should be 1, 3 or 4 meta_data: the meta data information corresponding to the data. - patch_index: if the data is a patch of big image, need to append the patch index to filename. Raises: ValueError: When ``data`` channels is not one of [1, 3, 4]. @@ -104,6 +99,7 @@ def save( filename = meta_data[Key.FILENAME_OR_OBJ] if meta_data else str(self._data_index) self._data_index += 1 spatial_shape = meta_data.get("spatial_shape", None) if meta_data and self.resample else None + patch_index = meta_data.get(Key.PATCH_INDEX, None) if meta_data else None if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() @@ -126,22 +122,13 @@ def save( scale=self.scale, ) - def save_batch( - self, - batch_data: Union[torch.Tensor, np.ndarray], - meta_data: Optional[Dict] = None, - patch_indice: Optional[Sequence[int]] = None, - ) -> None: + def save_batch(self, batch_data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: """Save a batch of data into png format files. Args: batch_data: target batch data content that save into png format. meta_data: every key-value in the meta_data is corresponding to a batch of data. - patch_indice: if the data is a patch of big image, need to append the patch index to filename. + """ for i, data in enumerate(batch_data): # save a batch of files - self.save( - data=data, - meta_data={k: meta_data[k][i] for k in meta_data} if meta_data is not None else None, - patch_index=patch_indice[i] if patch_indice is not None else None, - ) + self.save(data=data, meta_data={k: meta_data[k][i] for k in meta_data} if meta_data is not None else None) diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index 7df10b9dad..9ee7ca67f9 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -16,9 +16,7 @@ from monai.config import DtypeLike from monai.transforms import SaveImage -from monai.utils import GridSampleMode, GridSamplePadMode -from monai.utils import ImageMetaKey as Key -from monai.utils import InterpolateMode, exact_version, optional_import +from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, exact_version, optional_import Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: @@ -145,6 +143,5 @@ def __call__(self, engine: Engine) -> None: """ meta_data = self.batch_transform(engine.state.batch) engine_output = self.output_transform(engine.state.output) - patch_indice = engine.state.batch.get(Key.PATCH_INDEX, None) - self._saver(engine_output, meta_data, patch_indice) + self._saver(engine_output, meta_data) self.logger.info("saved all the model outputs into files.") diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 428e35335c..1d4fcfdb1f 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -483,7 +483,7 @@ class RandSpatialCropSamplesd(RandomizableTransform, MapTransform): Crop image with random size or specific size ROI to generate a list of N samples. It can crop at a random position as center or at the image center. And allows to set the minimum size to limit the randomly generated ROI. Suppose all the expected fields - specified by `keys` have same shape. + specified by `keys` have same shape, and add `patch_index` to the corresponding meta data. It will return a list of dictionaries for all the cropped images. Args: @@ -495,6 +495,9 @@ class RandSpatialCropSamplesd(RandomizableTransform, MapTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. + meta_key_postfix: use `key_{postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + used to add `patch_index` to the meta dict. allow_missing_keys: don't raise exception if key is missing. Raises: @@ -509,6 +512,7 @@ def __init__( num_samples: int, random_center: bool = True, random_size: bool = True, + meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: RandomizableTransform.__init__(self, prob=1.0, do_transform=True) @@ -517,6 +521,7 @@ def __init__( raise ValueError(f"num_samples must be positive, got {num_samples}.") self.num_samples = num_samples self.cropper = RandSpatialCropd(keys, roi_size, random_center, random_size, allow_missing_keys) + self.meta_key_postfix = meta_key_postfix def set_random_state( self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None @@ -530,9 +535,15 @@ def randomize(self, data: Optional[Any] = None) -> None: def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, np.ndarray]]: ret = [] + d = dict(data) for i in range(self.num_samples): - cropped = self.cropper(data) - cropped[Key.PATCH_INDEX] = i # type: ignore + cropped = self.cropper(d) + # add `patch_index` to the meta data + for key in self.key_iterator(d): + meta_data_key = f"{key}_{self.meta_key_postfix}" + if meta_data_key not in cropped: + cropped[meta_data_key] = {} # type: ignore + cropped[meta_data_key][Key.PATCH_INDEX] = i ret.append(cropped) return ret @@ -687,6 +698,8 @@ class RandCropByPosNegLabeld(RandomizableTransform, MapTransform): Dictionary-based version :py:class:`monai.transforms.RandCropByPosNegLabel`. Crop random fixed sized regions with the center being a foreground or background voxel based on the Pos Neg Ratio. + Suppose all the expected fields specified by `keys` have same shape, + and add `patch_index` to the corresponding meta data. And will return a list of dictionaries for all the cropped images. Args: @@ -712,6 +725,9 @@ class RandCropByPosNegLabeld(RandomizableTransform, MapTransform): `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `FgBgToIndicesd` transform first and cache the results. + meta_key_postfix: use `key_{postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + used to add `patch_index` to the meta dict. allow_missing_keys: don't raise exception if key is missing. Raises: @@ -732,6 +748,7 @@ def __init__( image_threshold: float = 0.0, fg_indices_key: Optional[str] = None, bg_indices_key: Optional[str] = None, + meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: RandomizableTransform.__init__(self) @@ -748,6 +765,7 @@ def __init__( self.image_threshold = image_threshold self.fg_indices_key = fg_indices_key self.bg_indices_key = bg_indices_key + self.meta_key_postfix = meta_key_postfix self.centers: Optional[List[List[np.ndarray]]] = None def randomize( @@ -789,8 +807,12 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n # fill in the extra keys with unmodified data for key in set(data.keys()).difference(set(self.keys)): results[i][key] = data[key] - # add patch index in the meta data - results[i][Key.PATCH_INDEX] = i # type: ignore + # add `patch_index` to the meta data + for key in self.key_iterator(d): + meta_data_key = f"{key}_{self.meta_key_postfix}" + if meta_data_key not in results[i]: + results[i][meta_data_key] = {} # type: ignore + results[i][meta_data_key][Key.PATCH_INDEX] = i return results diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index b138b97cb2..7a7fcb8cda 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -269,19 +269,14 @@ def __init__( self.save_batch = save_batch - def __call__( - self, - img: Union[torch.Tensor, np.ndarray], - meta_data: Optional[Dict] = None, - patch_index=None, # type is Union[Sequence[int], int, None], can't be compatible with save and save_batch - ): + def __call__(self, img: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None): """ Args: img: target data content that save into file. meta_data: key-value pairs of meta_data corresponding to the data. - patch_index: if the data is a patch of big image, need to append the patch index to filename. + """ if self.save_batch: - self.saver.save_batch(img, meta_data, patch_index) + self.saver.save_batch(img, meta_data) else: - self.saver.save(img, meta_data, patch_index) + self.saver.save(img, meta_data) diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 5b8f0a41d3..58d6431c74 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -23,9 +23,7 @@ from monai.data.image_reader import ImageReader from monai.transforms.io.array import LoadImage, SaveImage from monai.transforms.transform import MapTransform -from monai.utils import GridSampleMode, GridSamplePadMode -from monai.utils import ImageMetaKey as Key -from monai.utils import InterpolateMode +from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode __all__ = [ "LoadImaged", @@ -229,7 +227,7 @@ def __call__(self, data): d = dict(data) for key in self.key_iterator(d): meta_data = d[f"{key}_{self.meta_key_postfix}"] if self.meta_key_postfix is not None else None - self._saver(img=d[key], meta_data=meta_data, patch_index=d.get(Key.PATCH_INDEX, None)) + self._saver(img=d[key], meta_data=meta_data) return d diff --git a/tests/test_rand_crop_by_pos_neg_labeld.py b/tests/test_rand_crop_by_pos_neg_labeld.py index 2744d729a1..d52ba900ac 100644 --- a/tests/test_rand_crop_by_pos_neg_labeld.py +++ b/tests/test_rand_crop_by_pos_neg_labeld.py @@ -92,7 +92,7 @@ def test_type_shape(self, input_param, input_data, expected_type, expected_shape self.assertTupleEqual(result[0]["extral"].shape, expected_shape) self.assertTupleEqual(result[0]["label"].shape, expected_shape) for i, item in enumerate(result): - self.assertEqual(item["patch_index"], i) + self.assertEqual(item["image_meta_dict"]["patch_index"], i) if __name__ == "__main__": diff --git a/tests/test_rand_spatial_crop_samplesd.py b/tests/test_rand_spatial_crop_samplesd.py index 5b745add18..09688f44b7 100644 --- a/tests/test_rand_spatial_crop_samplesd.py +++ b/tests/test_rand_spatial_crop_samplesd.py @@ -71,7 +71,8 @@ def test_shape(self, input_param, input_data, expected_shape, expected_last): self.assertTupleEqual(item["img"].shape, expected) self.assertTupleEqual(item["seg"].shape, expected) for i, item in enumerate(result): - self.assertEqual(item["patch_index"], i) + self.assertEqual(item["img_meta_dict"]["patch_index"], i) + self.assertEqual(item["seg_meta_dict"]["patch_index"], i) np.testing.assert_allclose(item["img"], expected_last["img"]) np.testing.assert_allclose(item["seg"], expected_last["seg"]) diff --git a/tests/test_save_imaged.py b/tests/test_save_imaged.py index b5293473c2..d6536b3d51 100644 --- a/tests/test_save_imaged.py +++ b/tests/test_save_imaged.py @@ -117,7 +117,7 @@ def test_saved_content(self, test_data, output_ext, resample, save_batch): filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_trans" + output_ext) self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) else: - patch_index = test_data.get("patch_index", None) + patch_index = test_data["img_meta_dict"].get("patch_index", None) patch_index = f"_{patch_index}" if patch_index is not None else "" filepath = os.path.join("testfile0", "testfile0" + "_trans" + patch_index + output_ext) self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) From b5dbcbe84d2ca67fceae20b89571edcaadb4d080 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sun, 4 Apr 2021 01:30:43 +0100 Subject: [PATCH 17/55] enhance min/exact version check (#1937) Signed-off-by: Neha Srivathsa --- monai/utils/module.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/monai/utils/module.py b/monai/utils/module.py index c12eaf101d..afb4dd9f08 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -11,6 +11,7 @@ import inspect import sys +import warnings from importlib import import_module from pkgutil import walk_packages from re import match @@ -95,6 +96,9 @@ def min_version(the_module, min_version_str: str = "") -> bool: Returns True if the module's version is greater or equal to the 'min_version'. When min_version_str is not provided, it always returns True. """ + if not hasattr(the_module, "__version__"): + warnings.warn(f"{the_module} has no attribute __version__ in min_version check.") + return True # min_version is the default, shouldn't be noisy if min_version_str: mod_version = tuple(int(x) for x in the_module.__version__.split(".")[:2]) required = tuple(int(x) for x in min_version_str.split(".")[:2]) @@ -106,6 +110,9 @@ def exact_version(the_module, version_str: str = "") -> bool: """ Returns True if the module's __version__ matches version_str """ + if not hasattr(the_module, "__version__"): + warnings.warn(f"{the_module} has no attribute __version__ in exact_version check.") + return False return bool(the_module.__version__ == version_str) From 18b50a52bd88bad87739808c75232affd9d906a6 Mon Sep 17 00:00:00 2001 From: Petru-Daniel Tudosiu Date: Sun, 4 Apr 2021 03:00:43 +0100 Subject: [PATCH 18/55] Enabled partial checkpoint loading (#1936) * Enabled partial checkpoint loading Allowing partial loading of a model via strict=False. Signed-off-by: Petru-Daniel Tudosiu * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] simplify strict arg Signed-off-by: Nic Ma Co-authored-by: monai-bot Co-authored-by: Nic Ma Signed-off-by: Neha Srivathsa --- monai/handlers/checkpoint_loader.py | 6 ++- tests/test_handler_checkpoint_loader.py | 54 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/monai/handlers/checkpoint_loader.py b/monai/handlers/checkpoint_loader.py index bb67428bef..40483e8c85 100644 --- a/monai/handlers/checkpoint_loader.py +++ b/monai/handlers/checkpoint_loader.py @@ -44,6 +44,8 @@ class CheckpointLoader: first load the module to CPU and then copy each parameter to where it was saved, which would result in all processes on the same machine using the same set of devices. + strict: whether to strictly enforce that the keys in :attr:`state_dict` match the keys + returned by this module's :meth:`~torch.nn.Module.state_dict` function. Default: ``True`` """ @@ -53,6 +55,7 @@ def __init__( load_dict: Dict, name: Optional[str] = None, map_location: Optional[Dict] = None, + strict: bool = True, ) -> None: if load_path is None: raise AssertionError("must provide clear path to load checkpoint.") @@ -63,6 +66,7 @@ def __init__( self.load_dict = load_dict self._name = name self.map_location = map_location + self.strict = strict def attach(self, engine: Engine) -> None: """ @@ -82,7 +86,7 @@ def __call__(self, engine: Engine) -> None: # save current max epochs setting in the engine, don't overwrite it if larger than max_epochs in checkpoint prior_max_epochs = engine.state.max_epochs - Checkpoint.load_objects(to_load=self.load_dict, checkpoint=checkpoint) + Checkpoint.load_objects(to_load=self.load_dict, checkpoint=checkpoint, strict=self.strict) if engine.state.epoch > prior_max_epochs: raise ValueError( f"Epoch count ({engine.state.epoch}) in checkpoint is larger than " diff --git a/tests/test_handler_checkpoint_loader.py b/tests/test_handler_checkpoint_loader.py index 838cc3f4dd..d58260ac8c 100644 --- a/tests/test_handler_checkpoint_loader.py +++ b/tests/test_handler_checkpoint_loader.py @@ -38,7 +38,7 @@ def test_one_save_one_load(self): engine1.run([0] * 8, max_epochs=5) path = tempdir + "/checkpoint_final_iteration=40.pt" engine2 = Engine(lambda e, b: None) - CheckpointLoader(load_path=path, load_dict={"net": net2, "eng": engine2}).attach(engine2) + CheckpointLoader(load_path=path, load_dict={"net": net2, "eng": engine2}, strict=True).attach(engine2) @engine2.on(Events.STARTED) def check_epoch(engine: Engine): @@ -49,7 +49,7 @@ def check_epoch(engine: Engine): # test bad case with max_epochs smaller than current epoch engine3 = Engine(lambda e, b: None) - CheckpointLoader(load_path=path, load_dict={"net": net2, "eng": engine3}).attach(engine3) + CheckpointLoader(load_path=path, load_dict={"net": net2, "eng": engine3}, strict=True).attach(engine3) try: engine3.run([0] * 8, max_epochs=3) @@ -75,7 +75,7 @@ def test_two_save_one_load(self): engine.run([0] * 8, max_epochs=5) path = tempdir + "/checkpoint_final_iteration=40.pt" engine = Engine(lambda e, b: None) - CheckpointLoader(load_path=path, load_dict={"net": net2}).attach(engine) + CheckpointLoader(load_path=path, load_dict={"net": net2}, strict=True).attach(engine) engine.run([0] * 8, max_epochs=1) torch.testing.assert_allclose(net2.state_dict()["weight"], torch.tensor([0.1])) @@ -96,10 +96,56 @@ def test_save_single_device_load_multi_devices(self): engine.run([0] * 8, max_epochs=5) path = tempdir + "/net_final_iteration=40.pt" engine = Engine(lambda e, b: None) - CheckpointLoader(load_path=path, load_dict={"net": net2}).attach(engine) + CheckpointLoader(load_path=path, load_dict={"net": net2}, strict=True).attach(engine) engine.run([0] * 8, max_epochs=1) torch.testing.assert_allclose(net2.state_dict()["module.weight"].cpu(), torch.tensor([0.1])) + def test_partial_under_load(self): + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + net1 = torch.nn.Sequential(*[torch.nn.PReLU(), torch.nn.PReLU()]) + data1 = net1.state_dict() + data1["0.weight"] = torch.tensor([0.1]) + data1["1.weight"] = torch.tensor([0.2]) + net1.load_state_dict(data1) + + net2 = torch.nn.Sequential(*[torch.nn.PReLU()]) + data2 = net2.state_dict() + data2["0.weight"] = torch.tensor([0.3]) + net2.load_state_dict(data2) + + with tempfile.TemporaryDirectory() as tempdir: + engine = Engine(lambda e, b: None) + CheckpointSaver(save_dir=tempdir, save_dict={"net": net1}, save_final=True).attach(engine) + engine.run([0] * 8, max_epochs=5) + path = tempdir + "/net_final_iteration=40.pt" + engine = Engine(lambda e, b: None) + CheckpointLoader(load_path=path, load_dict={"net": net2}, strict=False).attach(engine) + engine.run([0] * 8, max_epochs=1) + torch.testing.assert_allclose(net2.state_dict()["0.weight"].cpu(), torch.tensor([0.1])) + + def test_partial_over_load(self): + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + net1 = torch.nn.Sequential(*[torch.nn.PReLU()]) + data1 = net1.state_dict() + data1["0.weight"] = torch.tensor([0.1]) + net1.load_state_dict(data1) + + net2 = torch.nn.Sequential(*[torch.nn.PReLU(), torch.nn.PReLU()]) + data2 = net2.state_dict() + data2["0.weight"] = torch.tensor([0.2]) + data2["1.weight"] = torch.tensor([0.3]) + net2.load_state_dict(data2) + + with tempfile.TemporaryDirectory() as tempdir: + engine = Engine(lambda e, b: None) + CheckpointSaver(save_dir=tempdir, save_dict={"net": net1}, save_final=True).attach(engine) + engine.run([0] * 8, max_epochs=5) + path = tempdir + "/net_final_iteration=40.pt" + engine = Engine(lambda e, b: None) + CheckpointLoader(load_path=path, load_dict={"net": net2}, strict=False).attach(engine) + engine.run([0] * 8, max_epochs=1) + torch.testing.assert_allclose(net2.state_dict()["0.weight"].cpu(), torch.tensor([0.1])) + if __name__ == "__main__": unittest.main() From 4c2894dfa9b6fa54c966f3f9aba1006171cd93a7 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 5 Apr 2021 03:21:15 +0100 Subject: [PATCH 19/55] fixes issue of typing with py3.6 (#1942) * less warning msg; remove PILImage types Signed-off-by: Wenqi Li * remove engine type Signed-off-by: Wenqi Li * temp tests Signed-off-by: Wenqi Li * add quick py36 37 tests Signed-off-by: Wenqi Li * temp tests Signed-off-by: Wenqi Li * Revert "temp tests" This reverts commit deaed40aa64edeb771f5fe0ce3257d3746b3045d. Signed-off-by: Wenqi Li * Revert "temp tests" This reverts commit 3e8d8f4cf1d9463a5a2075897eed053091b56587. Signed-off-by: Wenqi Li * min test exclude senet test Signed-off-by: Wenqi Li * update Signed-off-by: Wenqi Li * temp test Signed-off-by: Wenqi Li * Revert "temp test" This reverts commit 31f40a00c1456950a0a050d81f067f38ddede70d. Signed-off-by: Wenqi Li * update get gpu id Signed-off-by: Wenqi Li * update docsstrings Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- .github/workflows/pythonapp.yml | 49 ++++++++++++++++++++++++-- monai/handlers/utils.py | 6 ++-- monai/transforms/utility/array.py | 18 ++++------ monai/transforms/utility/dictionary.py | 23 +++--------- monai/utils/module.py | 10 +++--- tests/min_tests.py | 1 + tests/utils.py | 3 +- 7 files changed, 69 insertions(+), 41 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index e5803028a0..9425f9fa77 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -41,7 +41,7 @@ jobs: # Git hub actions have 2 cores, so parallize pytype $(pwd)/runtests.sh --codeformat -j 2 - quick-py3: # full dependencies installed + quick-py3: # full dependencies installed tests for different OS runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -105,7 +105,7 @@ jobs: env: QUICKTEST: True - min-dep-py3: # min dependencies installed + min-dep-os: # min dependencies installed tests for different OS runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -154,6 +154,51 @@ jobs: env: QUICKTEST: True + min-dep-py3: # min dependencies installed tests for different python + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.6, 3.7] + timeout-minutes: 40 + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Prepare pip wheel + run: | + which python + python -m pip install --user --upgrade pip setuptools wheel + - name: cache weekly timestamp + id: pip-cache + run: | + echo "::set-output name=datew::$(date '+%Y-%V')" + echo "::set-output name=dir::$(pip cache dir)" + shell: bash + - name: cache for pip + uses: actions/cache@v2 + id: cache + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ubuntu-latest-latest-pip-${{ steps.pip-cache.outputs.datew }} + - name: Install the dependencies + run: | + # min. requirements + python -m pip install torch==1.8.1 + python -m pip install -r requirements-min.txt + python -m pip list + BUILD_MONAI=0 python setup.py develop # no compile of extensions + shell: bash + - name: Run quick tests (CPU ${{ runner.os }}) + run: | + python -c 'import torch; print(torch.__version__); print(torch.rand(5,3))' + python -c "import monai; monai.config.print_config()" + python -m tests.min_tests + env: + QUICKTEST: True + GPU-quick-py3: # GPU with full dependencies if: github.repository == 'Project-MONAI/MONAI' strategy: diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index 2eaf3ab932..4ae38b908a 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -11,7 +11,7 @@ import os from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union import numpy as np import torch @@ -33,7 +33,7 @@ ] -def stopping_fn_from_metric(metric_name: str) -> Callable[[Engine], Any]: +def stopping_fn_from_metric(metric_name: str): """ Returns a stopping function for ignite.handlers.EarlyStopping using the given metric name. """ @@ -44,7 +44,7 @@ def stopping_fn(engine: Engine): return stopping_fn -def stopping_fn_from_loss() -> Callable[[Engine], Any]: +def stopping_fn_from_loss(): """ Returns a stopping function for ignite.handlers.EarlyStopping using the loss value. """ diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 4ad0676fba..8e0dabafb2 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -16,7 +16,7 @@ import logging import sys import time -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -26,14 +26,8 @@ from monai.transforms.utils import extreme_points_to_image, get_extreme_points, map_binary_to_indices from monai.utils import ensure_tuple, min_version, optional_import -if TYPE_CHECKING: - from PIL.Image import Image as PILImageImage - from PIL.Image import fromarray as pil_image_fromarray - - has_pil = True -else: - PILImageImage, has_pil = optional_import("PIL.Image", name="Image") - pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") +PILImageImage, has_pil = optional_import("PIL.Image", name="Image") +pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") __all__ = [ "Identity", @@ -302,7 +296,7 @@ class ToTensor(Transform): Converts the input image to a tensor without applying any other transformations. """ - def __call__(self, img: Union[np.ndarray, torch.Tensor, PILImageImage]) -> torch.Tensor: + def __call__(self, img) -> torch.Tensor: """ Apply the transform to `img` and make it contiguous. """ @@ -316,7 +310,7 @@ class ToNumpy(Transform): Converts the input data to numpy array, can support list or tuple of numbers and PyTorch Tensor. """ - def __call__(self, img: Union[List, Tuple, np.ndarray, torch.Tensor, PILImageImage]) -> np.ndarray: + def __call__(self, img) -> np.ndarray: """ Apply the transform to `img` and make it contiguous. """ @@ -330,7 +324,7 @@ class ToPIL(Transform): Converts the input image (in the form of NumPy array or PyTorch Tensor) to PIL image """ - def __call__(self, img: Union[np.ndarray, torch.Tensor, PILImageImage]) -> PILImageImage: + def __call__(self, img): """ Apply the transform to `img` and make it contiguous. """ diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 63ed6ec305..f57cbd1116 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -17,7 +17,7 @@ import copy import logging -from typing import TYPE_CHECKING, Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -48,14 +48,7 @@ ToTensor, ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points -from monai.utils import ensure_tuple, ensure_tuple_rep, optional_import - -if TYPE_CHECKING: - from PIL.Image import Image as PILImageImage - - has_pil = True -else: - PILImageImage, has_pil = optional_import("PIL.Image", name="Image") +from monai.utils import ensure_tuple, ensure_tuple_rep __all__ = [ "Identityd", @@ -401,9 +394,7 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.converter = ToTensor() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]]: + def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) @@ -425,9 +416,7 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.converter = ToNumpy() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]]: + def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) @@ -449,9 +438,7 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.converter = ToPIL() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]]: + def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) diff --git a/monai/utils/module.py b/monai/utils/module.py index afb4dd9f08..448046b9e6 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -96,14 +96,14 @@ def min_version(the_module, min_version_str: str = "") -> bool: Returns True if the module's version is greater or equal to the 'min_version'. When min_version_str is not provided, it always returns True. """ + if not min_version_str: + return True # always valid version if not hasattr(the_module, "__version__"): warnings.warn(f"{the_module} has no attribute __version__ in min_version check.") return True # min_version is the default, shouldn't be noisy - if min_version_str: - mod_version = tuple(int(x) for x in the_module.__version__.split(".")[:2]) - required = tuple(int(x) for x in min_version_str.split(".")[:2]) - return mod_version >= required - return True # always valid version + mod_version = tuple(int(x) for x in the_module.__version__.split(".")[:2]) + required = tuple(int(x) for x in min_version_str.split(".")[:2]) + return mod_version >= required def exact_version(the_module, version_str: str = "") -> bool: diff --git a/tests/min_tests.py b/tests/min_tests.py index abb5b73764..4433081c46 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -94,6 +94,7 @@ def run_testsuit(): "test_smartcachedataset", "test_spacing", "test_spacingd", + "test_senet", "test_surface_distance", "test_zoom", "test_zoom_affine", diff --git a/tests/utils.py b/tests/utils.py index 20f94cd1eb..5fa67f3e49 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -569,13 +569,14 @@ def query_memory(n=2): """ Find best n idle devices and return a string of device ids. """ - bash_string = "nvidia-smi --query-gpu=utilization.gpu,power.draw,memory.used --format=csv,noheader,nounits" + bash_string = "nvidia-smi --query-gpu=power.draw,temperature.gpu,memory.used --format=csv,noheader,nounits" try: p1 = Popen(bash_string.split(), stdout=PIPE) output, error = p1.communicate() free_memory = [x.split(",") for x in output.decode("utf-8").split("\n")[:-1]] free_memory = np.asarray(free_memory, dtype=float).T + free_memory[1] += free_memory[0] # combine 0/1 column measures ids = np.lexsort(free_memory)[:n] except (FileNotFoundError, TypeError, IndexError): ids = range(n) if isinstance(n, int) else [] From fc547d30eb91240a25fb2301f002e53db611d2b5 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 5 Apr 2021 09:08:33 -0400 Subject: [PATCH 20/55] Make ProbNMS a Transform (#1941) * Add ProbNMS to transforts/post/array Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Implement ProbNMSDict Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update the usage and add unittests Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Correct docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Correct test case Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- docs/source/transforms.rst | 5 + docs/source/utils.rst | 5 - monai/apps/pathology/utils.py | 3 +- monai/transforms/__init__.py | 4 + monai/transforms/post/array.py | 95 ++++++++++++++++++ monai/transforms/post/dictionary.py | 57 +++++++++++ monai/utils/__init__.py | 1 - monai/utils/prob_nms.py | 100 ------------------- tests/{test_prob_nms.py => test_probnms.py} | 2 +- tests/test_probnmsd.py | 103 ++++++++++++++++++++ 10 files changed, 267 insertions(+), 108 deletions(-) delete mode 100644 monai/utils/prob_nms.py rename tests/{test_prob_nms.py => test_probnms.py} (98%) create mode 100644 tests/test_probnmsd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index a726b25435..4f039b9c35 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -305,6 +305,11 @@ Post-processing :members: :special-members: __call__ +`Prob NMS` +"""""""""" +.. autoclass:: ProbNMS + :members: + `VoteEnsemble` """""""""""""" .. autoclass:: VoteEnsemble diff --git a/docs/source/utils.rst b/docs/source/utils.rst index 071d9ecefd..855954fd29 100644 --- a/docs/source/utils.rst +++ b/docs/source/utils.rst @@ -27,11 +27,6 @@ Misc .. automodule:: monai.utils.misc :members: -Prob NMS --------- -.. automodule:: monai.utils.prob_nms -.. autoclass:: ProbNMS - :members: Profiling --------- diff --git a/monai/apps/pathology/utils.py b/monai/apps/pathology/utils.py index ae77bfafd1..0d1f530bff 100644 --- a/monai/apps/pathology/utils.py +++ b/monai/apps/pathology/utils.py @@ -14,7 +14,8 @@ import numpy as np import torch -from monai.utils import ProbNMS, optional_import +from monai.transforms.post.array import ProbNMS +from monai.utils import optional_import measure, _ = optional_import("skimage.measure") ndimage, _ = optional_import("scipy.ndimage") diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index b8cc832db1..b66567e71a 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -160,6 +160,7 @@ KeepLargestConnectedComponent, LabelToContour, MeanEnsemble, + ProbNMS, VoteEnsemble, ) from .post.dictionary import ( @@ -182,6 +183,9 @@ MeanEnsembled, MeanEnsembleD, MeanEnsembleDict, + ProbNMSd, + ProbNMSD, + ProbNMSDict, VoteEnsembled, VoteEnsembleD, VoteEnsembleDict, diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 6462753cf9..7ac0e6799c 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -21,6 +21,7 @@ import torch.nn.functional as F from monai.networks import one_hot +from monai.networks.layers import GaussianFilter from monai.transforms.transform import Transform from monai.transforms.utils import get_largest_connected_component_mask from monai.utils import ensure_tuple @@ -422,3 +423,97 @@ def __call__(self, img: Union[Sequence[torch.Tensor], torch.Tensor]) -> torch.Te return torch.argmax(img_, dim=1, keepdim=has_ch_dim) # for One-Hot data, round the float number to 0 or 1 return torch.round(img_) + + +class ProbNMS(Transform): + """ + Performs probability based non-maximum suppression (NMS) on the probabilities map via + iteratively selecting the coordinate with highest probability and then move it as well + as its surrounding values. The remove range is determined by the parameter `box_size`. + If multiple coordinates have the same highest probability, only one of them will be + selected. + + Args: + spatial_dims: number of spatial dimensions of the input probabilities map. + Defaults to 2. + sigma: the standard deviation for gaussian filter. + It could be a single value, or `spatial_dims` number of values. Defaults to 0.0. + prob_threshold: the probability threshold, the function will stop searching if + the highest probability is no larger than the threshold. The value should be + no less than 0.0. Defaults to 0.5. + box_size: the box size (in pixel) to be removed around the the pixel with the maximum probability. + It can be an integer that defines the size of a square or cube, + or a list containing different values for each dimensions. Defaults to 48. + + Return: + a list of selected lists, where inner lists contain probability and coordinates. + For example, for 3D input, the inner lists are in the form of [probability, x, y, z]. + + Raises: + ValueError: When ``prob_threshold`` is less than 0.0. + ValueError: When ``box_size`` is a list or tuple, and its length is not equal to `spatial_dims`. + ValueError: When ``box_size`` has a less than 1 value. + + """ + + def __init__( + self, + spatial_dims: int = 2, + sigma: Union[Sequence[float], float, Sequence[torch.Tensor], torch.Tensor] = 0.0, + prob_threshold: float = 0.5, + box_size: Union[int, Sequence[int]] = 48, + ) -> None: + self.sigma = sigma + self.spatial_dims = spatial_dims + if self.sigma != 0: + self.filter = GaussianFilter(spatial_dims=spatial_dims, sigma=sigma) + if prob_threshold < 0: + raise ValueError("prob_threshold should be no less than 0.0.") + self.prob_threshold = prob_threshold + if isinstance(box_size, int): + self.box_size = np.asarray([box_size] * spatial_dims) + else: + if len(box_size) != spatial_dims: + raise ValueError("the sequence length of box_size should be the same as spatial_dims.") + self.box_size = np.asarray(box_size) + if self.box_size.min() <= 0: + raise ValueError("box_size should be larger than 0.") + + self.box_lower_bd = self.box_size // 2 + self.box_upper_bd = self.box_size - self.box_lower_bd + + def __call__( + self, + prob_map: Union[np.ndarray, torch.Tensor], + ): + """ + prob_map: the input probabilities map, it must have shape (H[, W, ...]). + """ + if self.sigma != 0: + if not isinstance(prob_map, torch.Tensor): + prob_map = torch.as_tensor(prob_map, dtype=torch.float) + self.filter.to(prob_map) + prob_map = self.filter(prob_map) + else: + if not isinstance(prob_map, torch.Tensor): + prob_map = prob_map.copy() + + if isinstance(prob_map, torch.Tensor): + prob_map = prob_map.detach().cpu().numpy() + + prob_map_shape = prob_map.shape + + outputs = [] + while np.max(prob_map) > self.prob_threshold: + max_idx = np.unravel_index(prob_map.argmax(), prob_map_shape) + prob_max = prob_map[max_idx] + max_idx_arr = np.asarray(max_idx) + outputs.append([prob_max] + list(max_idx_arr)) + + idx_min_range = (max_idx_arr - self.box_lower_bd).clip(0, None) + idx_max_range = (max_idx_arr + self.box_upper_bd).clip(None, prob_map_shape) + # for each dimension, set values during index ranges to 0 + slices = tuple(slice(idx_min_range[i], idx_max_range[i]) for i in range(self.spatial_dims)) + prob_map[slices] = 0 + + return outputs diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 6d28f780d4..52bde4ab79 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -28,6 +28,7 @@ KeepLargestConnectedComponent, LabelToContour, MeanEnsemble, + ProbNMS, VoteEnsemble, ) from monai.transforms.transform import MapTransform @@ -340,10 +341,66 @@ def __call__(self, data: dict) -> List[dict]: return monai.data.decollate_batch(data, self.batch_size) +class ProbNMSd(MapTransform): + """ + Performs probability based non-maximum suppression (NMS) on the probabilities map via + iteratively selecting the coordinate with highest probability and then move it as well + as its surrounding values. The remove range is determined by the parameter `box_size`. + If multiple coordinates have the same highest probability, only one of them will be + selected. + + Args: + spatial_dims: number of spatial dimensions of the input probabilities map. + Defaults to 2. + sigma: the standard deviation for gaussian filter. + It could be a single value, or `spatial_dims` number of values. Defaults to 0.0. + prob_threshold: the probability threshold, the function will stop searching if + the highest probability is no larger than the threshold. The value should be + no less than 0.0. Defaults to 0.5. + box_size: the box size (in pixel) to be removed around the the pixel with the maximum probability. + It can be an integer that defines the size of a square or cube, + or a list containing different values for each dimensions. Defaults to 48. + + Return: + a list of selected lists, where inner lists contain probability and coordinates. + For example, for 3D input, the inner lists are in the form of [probability, x, y, z]. + + Raises: + ValueError: When ``prob_threshold`` is less than 0.0. + ValueError: When ``box_size`` is a list or tuple, and its length is not equal to `spatial_dims`. + ValueError: When ``box_size`` has a less than 1 value. + + """ + + def __init__( + self, + keys: KeysCollection, + spatial_dims: int = 2, + sigma: Union[Sequence[float], float, Sequence[torch.Tensor], torch.Tensor] = 0.0, + prob_threshold: float = 0.5, + box_size: Union[int, Sequence[int]] = 48, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.prob_nms = ProbNMS( + spatial_dims=spatial_dims, + sigma=sigma, + prob_threshold=prob_threshold, + box_size=box_size, + ) + + def __call__(self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]]): + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.prob_nms(d[key]) + return d + + ActivationsD = ActivationsDict = Activationsd AsDiscreteD = AsDiscreteDict = AsDiscreted KeepLargestConnectedComponentD = KeepLargestConnectedComponentDict = KeepLargestConnectedComponentd LabelToContourD = LabelToContourDict = LabelToContourd MeanEnsembleD = MeanEnsembleDict = MeanEnsembled +ProbNMSD = ProbNMSDict = ProbNMSd VoteEnsembleD = VoteEnsembleDict = VoteEnsembled DecollateD = DecollateDict = Decollated diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index f6a137f47d..d622ce96ae 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -69,6 +69,5 @@ min_version, optional_import, ) -from .prob_nms import ProbNMS from .profiling import PerfContext, torch_profiler_full, torch_profiler_time_cpu_gpu, torch_profiler_time_end_to_end from .state_cacher import StateCacher diff --git a/monai/utils/prob_nms.py b/monai/utils/prob_nms.py deleted file mode 100644 index c25223d524..0000000000 --- a/monai/utils/prob_nms.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import List, Sequence, Tuple, Union - -import numpy as np -import torch - -from monai.networks.layers import GaussianFilter - - -class ProbNMS: - """ - Performs probability based non-maximum suppression (NMS) on the probabilities map via - iteratively selecting the coordinate with highest probability and then move it as well - as its surrounding values. The remove range is determined by the parameter `box_size`. - If multiple coordinates have the same highest probability, only one of them will be - selected. - - Args: - spatial_dims: number of spatial dimensions of the input probabilities map. - Defaults to 2. - sigma: the standard deviation for gaussian filter. - It could be a single value, or `spatial_dims` number of values. Defaults to 0.0. - prob_threshold: the probability threshold, the function will stop searching if - the highest probability is no larger than the threshold. The value should be - no less than 0.0. Defaults to 0.5. - box_size: the box size (in pixel) to be removed around the the pixel with the maximum probability. - It can be an integer that defines the size of a square or cube, - or a list containing different values for each dimensions. Defaults to 48. - - Return: - a list of selected lists, where inner lists contain probability and coordinates. - For example, for 3D input, the inner lists are in the form of [probability, x, y, z]. - - Raises: - ValueError: When ``prob_threshold`` is less than 0.0. - ValueError: When ``box_size`` is a list or tuple, and its length is not equal to `spatial_dims`. - ValueError: When ``box_size`` has a less than 1 value. - - """ - - def __init__( - self, - spatial_dims: int = 2, - sigma: Union[Sequence[float], float, Sequence[torch.Tensor], torch.Tensor] = 0.0, - prob_threshold: float = 0.5, - box_size: Union[int, List[int], Tuple[int]] = 48, - ) -> None: - self.sigma = sigma - self.spatial_dims = spatial_dims - if self.sigma != 0: - self.filter = GaussianFilter(spatial_dims=spatial_dims, sigma=sigma) - if prob_threshold < 0: - raise ValueError("prob_threshold should be no less than 0.0.") - self.prob_threshold = prob_threshold - if isinstance(box_size, int): - self.box_size = np.asarray([box_size] * spatial_dims) - else: - if len(box_size) != spatial_dims: - raise ValueError("the sequence length of box_size should be the same as spatial_dims.") - self.box_size = np.asarray(box_size) - if self.box_size.min() <= 0: - raise ValueError("box_size should be larger than 0.") - - self.box_lower_bd = self.box_size // 2 - self.box_upper_bd = self.box_size - self.box_lower_bd - - def __call__( - self, - prob_map: Union[np.ndarray, torch.Tensor], - ): - """ - prob_map: the input probabilities map, it must have shape (H[, W, ...]). - """ - if self.sigma != 0: - if not isinstance(prob_map, torch.Tensor): - prob_map = torch.as_tensor(prob_map, dtype=torch.float) - self.filter.to(prob_map) - prob_map = self.filter(prob_map) - else: - if not isinstance(prob_map, torch.Tensor): - prob_map = prob_map.copy() - - if isinstance(prob_map, torch.Tensor): - prob_map = prob_map.detach().cpu().numpy() - - prob_map_shape = prob_map.shape - - outputs = [] - while np.max(prob_map) > self.prob_threshold: - max_idx = np.unravel_index(prob_map.argmax(), prob_map_shape) - prob_max = prob_map[max_idx] - max_idx_arr = np.asarray(max_idx) - outputs.append([prob_max] + list(max_idx_arr)) - - idx_min_range = (max_idx_arr - self.box_lower_bd).clip(0, None) - idx_max_range = (max_idx_arr + self.box_upper_bd).clip(None, prob_map_shape) - # for each dimension, set values during index ranges to 0 - slices = tuple(slice(idx_min_range[i], idx_max_range[i]) for i in range(self.spatial_dims)) - prob_map[slices] = 0 - - return outputs diff --git a/tests/test_prob_nms.py b/tests/test_probnms.py similarity index 98% rename from tests/test_prob_nms.py rename to tests/test_probnms.py index fb88d9cfb4..e51d1017d8 100644 --- a/tests/test_prob_nms.py +++ b/tests/test_probnms.py @@ -15,7 +15,7 @@ import torch from parameterized import parameterized -from monai.utils import ProbNMS +from monai.transforms.post.array import ProbNMS probs_map_1 = np.random.rand(100, 100).clip(0, 0.5) TEST_CASES_2D_1 = [{"spatial_dims": 2, "prob_threshold": 0.5, "box_size": 10}, probs_map_1, []] diff --git a/tests/test_probnmsd.py b/tests/test_probnmsd.py new file mode 100644 index 0000000000..5b75d4310f --- /dev/null +++ b/tests/test_probnmsd.py @@ -0,0 +1,103 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.transforms.post.dictionary import ProbNMSD + +probs_map_1 = np.random.rand(100, 100).clip(0, 0.5) +TEST_CASES_2D_1 = [{"spatial_dims": 2, "prob_threshold": 0.5, "box_size": 10}, {"prob_map": probs_map_1}, []] + +probs_map_2 = np.random.rand(100, 100).clip(0, 0.5) +probs_map_2[33, 33] = 0.7 +probs_map_2[66, 66] = 0.9 +expected_2 = [[0.9, 66, 66], [0.7, 33, 33]] +TEST_CASES_2D_2 = [ + {"spatial_dims": 2, "prob_threshold": 0.5, "box_size": [10, 10]}, + {"prob_map": probs_map_2}, + expected_2, +] + +probs_map_3 = np.random.rand(100, 100).clip(0, 0.5) +probs_map_3[56, 58] = 0.7 +probs_map_3[60, 66] = 0.8 +probs_map_3[66, 66] = 0.9 +expected_3 = [[0.9, 66, 66], [0.8, 60, 66]] +TEST_CASES_2D_3 = [ + {"spatial_dims": 2, "prob_threshold": 0.5, "box_size": (10, 20)}, + {"prob_map": probs_map_3}, + expected_3, +] + +probs_map_4 = np.random.rand(100, 100).clip(0, 0.5) +probs_map_4[33, 33] = 0.7 +probs_map_4[66, 66] = 0.9 +expected_4 = [[0.9, 66, 66]] +TEST_CASES_2D_4 = [ + {"spatial_dims": 2, "prob_threshold": 0.8, "box_size": 10}, + {"prob_map": probs_map_4}, + expected_4, +] + +probs_map_5 = np.random.rand(100, 100).clip(0, 0.5) +TEST_CASES_2D_5 = [{"spatial_dims": 2, "prob_threshold": 0.5, "sigma": 0.1}, {"prob_map": probs_map_5}, []] + +probs_map_6 = torch.as_tensor(np.random.rand(100, 100).clip(0, 0.5)) +TEST_CASES_2D_6 = [{"spatial_dims": 2, "prob_threshold": 0.5, "sigma": 0.1}, {"prob_map": probs_map_6}, []] + +probs_map_7 = torch.as_tensor(np.random.rand(100, 100).clip(0, 0.5)) +probs_map_7[33, 33] = 0.7 +probs_map_7[66, 66] = 0.9 +if torch.cuda.is_available(): + probs_map_7 = probs_map_7.cuda() +expected_7 = [[0.9, 66, 66], [0.7, 33, 33]] +TEST_CASES_2D_7 = [ + {"spatial_dims": 2, "prob_threshold": 0.5, "sigma": 0.1}, + {"prob_map": probs_map_7}, + expected_7, +] + +probs_map_3d = torch.rand([50, 50, 50]).uniform_(0, 0.5) +probs_map_3d[25, 25, 25] = 0.7 +probs_map_3d[45, 45, 45] = 0.9 +expected_3d = [[0.9, 45, 45, 45], [0.7, 25, 25, 25]] +TEST_CASES_3D = [ + {"spatial_dims": 3, "prob_threshold": 0.5, "box_size": (10, 10, 10)}, + {"prob_map": probs_map_3d}, + expected_3d, +] + + +class TestProbNMS(unittest.TestCase): + @parameterized.expand( + [ + TEST_CASES_2D_1, + TEST_CASES_2D_2, + TEST_CASES_2D_3, + TEST_CASES_2D_4, + TEST_CASES_2D_5, + TEST_CASES_2D_6, + TEST_CASES_2D_7, + TEST_CASES_3D, + ] + ) + def test_output(self, class_args, probs_map, expected): + nms = ProbNMSD(keys="prob_map", **class_args) + output = nms(probs_map) + np.testing.assert_allclose(output["prob_map"], expected) + + +if __name__ == "__main__": + unittest.main() From 29d3ae505e63fada23458137fc0623f9e4482203 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 5 Apr 2021 15:36:57 +0100 Subject: [PATCH 21/55] fixes ThreadDataLoader (#1945) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/data/thread_buffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index da5f864900..8ea71e3555 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -88,7 +88,7 @@ def __init__(self, dataset: Dataset, num_workers: int = 0, **kwargs): super().__init__(dataset, num_workers, **kwargs) # ThreadBuffer will use the inherited __iter__ instead of the one defined below - self.buffer = ThreadBuffer(super()) + self.buffer = ThreadBuffer(super().__iter__()) def __iter__(self): yield from self.buffer From a5c631d4e10ab5d1ecffb3bcaf12add68e1588ff Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 5 Apr 2021 18:02:23 +0100 Subject: [PATCH 22/55] fixes itk dep. version (#1944) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- .github/workflows/pythonapp.yml | 9 ++++++++- docs/requirements.txt | 2 +- requirements-dev.txt | 2 +- setup.cfg | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 9425f9fa77..514301ad5b 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -281,12 +281,19 @@ jobs: python -m pip install --upgrade pip wheel python -m pip install ${{ matrix.pytorch }} python -m pip install -r requirements-dev.txt + python -m pip list - name: Run quick tests (GPU) run: | - python -m pip list nvidia-smi + export LAUNCH_DELAY=$(( RANDOM % 30 * 5 )) + echo "Sleep $LAUNCH_DELAY" + sleep $LAUNCH_DELAY export CUDA_VISIBLE_DEVICES=$(coverage run -m tests.utils) echo $CUDA_VISIBLE_DEVICES + stop_time=$((LAUNCH_DELAY + $(date +%s))) + while [ $(date +%s) -lt $stop_time ]; do + python -c 'import torch; torch.rand(5, 3, device=torch.device("cuda:0"))'; + done python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' python -c "import monai; monai.config.print_config()" diff --git a/docs/requirements.txt b/docs/requirements.txt index c31f06f2ca..c03e3327f4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,7 +2,7 @@ torch>=1.5 pytorch-ignite==0.4.4 numpy>=1.17 -itk>=5.0 +itk>=5.0, <=5.1.2 nibabel cucim==0.18.2 openslide-python==1.1.2 diff --git a/requirements-dev.txt b/requirements-dev.txt index dfa1eb1853..f9a2464495 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,7 @@ pytorch-ignite==0.4.4 gdown>=3.6.4 scipy -itk>=5.0 +itk>=5.0, <=5.1.2 nibabel pillow tensorboard diff --git a/setup.cfg b/setup.cfg index a41081cd11..702c8638c1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ all = pytorch-ignite==0.4.4 gdown>=3.6.4 torchvision - itk>=5.0 + itk>=5.0, <=5.1.2 tqdm>=4.47.0 cucim==0.18.2 openslide-python==1.1.2 @@ -55,7 +55,7 @@ ignite = torchvision = torchvision itk = - itk>=5.0 + itk>=5.0, <=5.1.2 tqdm = tqdm>=4.47.0 lmdb = From ec0b5adab0eb6cef5d68ba8d75e598e599e6c8cf Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 5 Apr 2021 14:09:28 -0400 Subject: [PATCH 23/55] Garbage Collector Handler (#1940) * Implement garbage collector handler Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Make trigger_event lower case Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add unittest for garbage collector Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Exclude from min test Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix a typo Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix a bug Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- docs/source/handlers.rst | 5 ++ monai/handlers/__init__.py | 1 + monai/handlers/garbage_collector.py | 80 +++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_handler_garbage_collector.py | 77 ++++++++++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 monai/handlers/garbage_collector.py create mode 100644 tests/test_handler_garbage_collector.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index 080e7e138c..869467c496 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -115,3 +115,8 @@ EarlyStop handler ----------------- .. autoclass:: EarlyStopHandler :members: + +GarbageCollector handler +------------------------ +.. autoclass:: GarbageCollector + :members: diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index a1f86310ae..2112b074a0 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -14,6 +14,7 @@ from .classification_saver import ClassificationSaver from .confusion_matrix import ConfusionMatrix from .earlystop_handler import EarlyStopHandler +from .garbage_collector import GarbageCollector from .hausdorff_distance import HausdorffDistance from .iteration_metric import IterationMetric from .lr_schedule_handler import LrScheduleHandler diff --git a/monai/handlers/garbage_collector.py b/monai/handlers/garbage_collector.py new file mode 100644 index 0000000000..7bb59c9049 --- /dev/null +++ b/monai/handlers/garbage_collector.py @@ -0,0 +1,80 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import gc +from typing import TYPE_CHECKING + +from monai.utils import exact_version, optional_import + +if TYPE_CHECKING: + from ignite.engine import Engine, Events +else: + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") + + +class GarbageCollector: + """ + Run garbage collector after each epoch + + Args: + trigger_event: the event that trigger a call to this handler. + - "epoch", after completion of each epoch (equivalent of ignite.engine.Events.EPOCH_COMPLETED) + - "iteration", after completion of each iteration (equivalent of ignite.engine.Events.ITERATION_COMPLETED) + - any ignite built-in event from ignite.engine.Events. + Defaults to "epoch". + log_level: log level (integer) for some garbage collection information as below. Defaults to 10 (DEBUG). + - 50 (CRITICAL) + - 40 (ERROR) + - 30 (WARNING) + - 20 (INFO) + - 10 (DEBUG) + - 0 (NOTSET) + """ + + def __init__(self, trigger_event: str = "epoch", log_level: int = 10): + if isinstance(trigger_event, Events): + self.trigger_event = trigger_event + elif trigger_event.lower() == "epoch": + self.trigger_event = Events.EPOCH_COMPLETED + elif trigger_event.lower() == "iteration": + self.trigger_event = Events.ITERATION_COMPLETED + else: + raise ValueError( + f"'trigger_event' should be either epoch, iteration, or an ignite built-in event from" + f" ignite.engine.Events, '{trigger_event}' was given." + ) + + self.log_level = log_level + + def attach(self, engine: Engine) -> None: + if not engine.has_event_handler(self, self.trigger_event): + engine.add_event_handler(self.trigger_event, self) + + def __call__(self, engine: Engine) -> None: + """ + This method calls python garbage collector. + + Args: + engine: Ignite Engine, it should be either a trainer or validator. + """ + # get count before garbage collection + pre_count = gc.get_count() + # fits call to garbage collector + gc.collect() + # second call to garbage collector + unreachable = gc.collect() + # get count after garbage collection + after_count = gc.get_count() + engine.logger.log( + self.log_level, + f"Garbage Count: [before: {pre_count}] -> [after: {after_count}] (unreachable : {unreachable})", + ) diff --git a/tests/min_tests.py b/tests/min_tests.py index 4433081c46..9b96e7eaab 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -42,6 +42,7 @@ def run_testsuit(): "test_handler_confusion_matrix", "test_handler_confusion_matrix_dist", "test_handler_hausdorff_distance", + "test_handler_garbage_collector", "test_handler_mean_dice", "test_handler_prob_map_producer", "test_handler_rocauc", diff --git a/tests/test_handler_garbage_collector.py b/tests/test_handler_garbage_collector.py new file mode 100644 index 0000000000..5e6bd7275c --- /dev/null +++ b/tests/test_handler_garbage_collector.py @@ -0,0 +1,77 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import gc +import unittest +from unittest import skipUnless + +import torch +from ignite.engine import Engine +from parameterized import parameterized + +from monai.data import Dataset +from monai.handlers import GarbageCollector +from monai.utils import exact_version, optional_import + +Events, has_ignite = optional_import("ignite.engine", "0.4.4", exact_version, "Events") + + +TEST_CASE_0 = [[0, 1, 2], "epoch"] + +TEST_CASE_1 = [[0, 1, 2], "iteration"] + +TEST_CASE_2 = [[0, 1, 2], Events.EPOCH_COMPLETED] + + +class TestHandlerGarbageCollector(unittest.TestCase): + @skipUnless(has_ignite, "Requires ignite") + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_1, + TEST_CASE_2, + ] + ) + def test_content(self, data, trigger_event): + # set up engine + gb_count_dict = {} + + def _train_func(engine, batch): + # store garbage collection counts + if trigger_event == Events.EPOCH_COMPLETED or trigger_event.lower() == "epoch": + if engine.state.iteration % engine.state.epoch_length == 1: + gb_count_dict[engine.state.epoch] = gc.get_count() + elif trigger_event.lower() == "iteration": + gb_count_dict[engine.state.iteration] = gc.get_count() + + engine = Engine(_train_func) + + # set up testing handler + dataset = Dataset(data, transform=None) + data_loader = torch.utils.data.DataLoader(dataset, batch_size=1) + GarbageCollector(trigger_event=trigger_event, log_level=30).attach(engine) + + engine.run(data_loader, max_epochs=5) + print(gb_count_dict) + + first_count = 0 + for epoch, gb_count in gb_count_dict.items(): + # At least one zero-generation object + self.assertGreater(gb_count[0], 0) + if epoch == 1: + first_count = gb_count[0] + else: + # The should be less number of collected objects in the next calls. + self.assertLess(gb_count[0], first_count) + + +if __name__ == "__main__": + unittest.main() From a5ed4a41c8071975dfdbc92eb642da4f35a0d019 Mon Sep 17 00:00:00 2001 From: Alvin Ihsani Date: Mon, 5 Apr 2021 12:14:24 -0700 Subject: [PATCH 24/55] style and api changes Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 259 +++++++++++++++++------------ 1 file changed, 154 insertions(+), 105 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index edb557b1b2..41fbaf2856 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -1,133 +1,182 @@ -# modified from sources: +# modified from sources: # - Original implementation from Macenko paper in Matlab: https://github.com/mitkovetta/staining-normalization # - Implementation in Python: https://github.com/schaugf/HEnorm_python -import openslide +# - Link to Macenko et al., 2009 paper: http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf import cupy as cp -from PIL import Image -from typing import Tuple + from monai.transforms.transform import Transform -class StainNormalizer(Transform): +class ExtractStainsMacenko(Transform): + """Class to extract a target stain from an image, using the Macenko method for stain deconvolution. + + Args: + tli: (optional) transmitted light intensity + alpha: (optional) tolerance for the pseudo-min and pseudo-max + beta: (optional) Optical Density (OD) threshold for transparent pixels + max_cref: (optional) reference maximum stain concentrations for Hematoxylin & Eosin (H&E) """ - Stain Normalize patches of a digital pathology image. Performs Stain Deconvolution using the Macenko method. - A source patch can be normalized using a reference stain matrix, or using a target image from - which a target stain is extracted. For using the reference stain, run only the normalize_patch function. - To use the stain from a target image, run extract_stain first to modify the target stain matrix used, then - run normalize_patch on each patch to be stain normalized. - + + def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_cref: cp.ndarray = None) -> None: + self.tli = tli + self.alpha = alpha + self.beta = beta + + self.max_cref = max_cref + if self.max_cref is None: + self.max_cref = cp.array([1.9705, 1.0308]) + + def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: + """Perform Stain Deconvolution using the Macenko Method, and return stain matrix for the image. + + Args: + img: RGB image to perform stain deconvolution of + + Return: + he: H&E OD matrix for the image (first column is H, second column is E, rows are RGB values) + """ + # reshape image + img = img.reshape((-1, 3)) + + # calculate optical density + od = -cp.log((img.astype(cp.float) + 1) / self.tli) + + # remove transparent pixels + od_hat = od[~cp.any(od < self.beta, axis=1)] + + # compute eigenvectors + _, eigvecs = cp.linalg.eigh(cp.cov(od_hat.T)) + + # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues + t_hat = od_hat.dot(eigvecs[:, 1:3]) + + # find the min and max vectors and project back to OD space + phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) + min_phi = cp.percentile(phi, self.alpha) + max_phi = cp.percentile(phi, 100 - self.alpha) + v_min = eigvecs[:, 1:3].dot(cp.array([(cp.cos(min_phi), cp.sin(min_phi))]).T) + v_max = eigvecs[:, 1:3].dot(cp.array([(cp.cos(max_phi), cp.sin(max_phi))]).T) + + # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second + if v_min[0] > v_max[0]: + he = cp.array((v_min[:, 0], v_max[:, 0])).T + else: + he = cp.array((v_max[:, 0], v_min[:, 0])).T + + return he + + def __call__(self, image: cp.ndarray) -> cp.ndarray: + """Perform stain extraction. + + Args: + image: RGB image to extract stain from + + return: + target_he: H&E OD matrix for the image (first column is H, second column is E, rows are RGB values) + """ + target_he = self._deconvolution_extract_stain(image) + return target_he + + +class NormalizeStainsMacenko(Transform): + """Class to normalize patches/images to a reference or target image stain, using the Macenko method. + + Performs stain deconvolution of the source image to obtain the stain concentration matrix + for the image. Then, performs the inverse Beer-Lambert transform to recreate the + patch using the target H&E stain matrix provided. If no target stain provided, a default + reference stain is used. Similarly, if no maximum stain concentrations are provided, a + reference maximum stain concentrations matrix is used. + Args: - Io: (optional) transmitted light intensity + tli: (optional) transmitted light intensity alpha: (optional) tolerance for the pseudo-min and pseudo-max - beta: (optional) OD threshold for transparent pixels - target_image: (optional) OpenSlide image to perform stain deconvolution of, - to obtain target stain matrix - + beta: (optional) Optical Density (OD) threshold for transparent pixels + target_he: (optional) target stain matrix + max_cref: (optional) reference maximum stain concentrations for Hematoxylin & Eosin (H&E) """ - def __init__(self, Io: float=240, alpha: float=1, beta: float=0.15, target_image: openslide.OpenSlide=None) -> None: - self.Io = Io + + def __init__( + self, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + target_he: cp.ndarray = None, + max_cref: cp.ndarray = None, + ) -> None: + self.tli = tli self.alpha = alpha self.beta = beta - - # reference maximum stain concentrations for H&E - self.maxCRef = cp.array([1.9705, 1.0308]) - - # target H&E stain is set to reference H&E OD matrix - self.target_HE = cp.array([[0.5626, 0.2159], - [0.7201, 0.8012], - [0.4062, 0.5581]]) - if target_image!=None: - self._extract_stain(target_image) - - def _stain_deconvolution(self, img: cp.ndarray) -> Tuple[cp.ndarray, cp.ndarray]: - """Perform Stain Deconvolution using the Macenko Method. - + + self.target_he = target_he + if self.target_he is None: + self.target_he = cp.array([[0.5626, 0.2159], [0.7201, 0.8012], [0.4062, 0.5581]]) + + self.max_cref = max_cref + if self.max_cref is None: + self.max_cref = cp.array([1.9705, 1.0308]) + + def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: + """Perform Stain Deconvolution using the Macenko Method, and return stain concentration. + Args: - img: image to perform stain deconvolution of - + img: RGB image to perform stain deconvolution of + Return: - HE: H&E OD matrix for the image (first column is H, second column is E) - C2: stain concentration matrix for the input image + conc_norm: stain concentration matrix for the input image """ - # define height and width of image - h, w, c = img.shape - - # RGBA to RGB - img = img[:, :, :-1] - # reshape image - img = img.reshape((-1,3)) - + img = img.reshape((-1, 3)) + # calculate optical density - OD = -cp.log((img.astype(cp.float)+1)/self.Io) - + od = -cp.log((img.astype(cp.float) + 1) / self.tli) + # remove transparent pixels - ODhat = OD[~cp.any(OD vMax[0]: - HE = cp.array((vMin[:,0], vMax[:,0])).T + if v_min[0] > v_max[0]: + he = cp.array((v_min[:, 0], v_max[:, 0])).T else: - HE = cp.array((vMax[:,0], vMin[:,0])).T - + he = cp.array((v_max[:, 0], v_min[:, 0])).T + # rows correspond to channels (RGB), columns to OD values - Y = cp.reshape(OD, (-1, 3)).T - + y = cp.reshape(od, (-1, 3)).T + # determine concentrations of the individual stains - C = cp.linalg.lstsq(HE,Y, rcond=None)[0] - + conc = cp.linalg.lstsq(he, y, rcond=None)[0] + # normalize stain concentrations - maxC = cp.array([cp.percentile(C[0,:], 99), cp.percentile(C[1,:],99)]) - tmp = cp.divide(maxC,self.maxCRef) - C2 = cp.divide(C,tmp[:, cp.newaxis]) - return HE, C2 - - def _extract_stain(self, target_image: openslide.OpenSlide) -> None: - """Extract a reference stain from a target image. - - To extract reference stain, the image at the highest level (the level with - lowest resolution) is used. Then, stain deconvolution provides the stain matrix. - - Args: - target_image: (optional) OpenSlide image to perform stain deconvolution of, - to obtain target stain matrix - """ - highest_level = target_image.level_count - 1 - dims = target_image.level_dimensions[highest_level] - target_image_at_level = target_image.read_region((0,0), highest_level, dims) - target_img = cp.array(target_image_at_level) - self.target_HE, _ = self._stain_deconvolution(target_img) - - def __call__(self, data: cp.ndarray) -> cp.ndarray: - """Normalize a patch to a reference / target image stain. - - Performs stain deconvolution of the patch to obtain the stain concentration matrix - for the patch. Then, performs the inverse Beer-Lambert transform to recreate the - patch using the target H&E stain. - + max_conc = cp.array([cp.percentile(conc[0, :], 99), cp.percentile(conc[1, :], 99)]) + tmp = cp.divide(max_conc, self.max_cref) + conc_norm = cp.divide(conc, tmp[:, cp.newaxis]) + return conc_norm + + def __call__(self, image: cp.ndarray) -> cp.ndarray: + """Perform stain normalization. + Args: - patch: image patch to stain normalize - + image: RGB image/patch to stain normalize + Return: - patch_norm: normalized patch + image_norm: stain normalized image/patch """ - h, w, _ = data.shape - _, patch_C = self._stain_deconvolution(data) - - patch_norm = cp.multiply(self.Io, cp.exp(-self.target_HE.dot(patch_C))) - patch_norm[patch_norm>255] = 254 - patch_norm = cp.reshape(patch_norm.T, (h, w, 3)).astype(cp.uint8) - return patch_norm \ No newline at end of file + h, w, _ = image.shape + image_c = self._deconvolution_extract_conc(image) + + image_norm = cp.multiply(self.tli, cp.exp(-self.target_he.dot(image_c))) + image_norm[image_norm > 255] = 254 + image_norm = cp.reshape(image_norm.T, (h, w, 3)).astype(cp.uint8) + return image_norm From 5e7be9b73edbccb0fcde73e73e8c11d1d45fe1b3 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 6 Apr 2021 03:20:56 +0800 Subject: [PATCH 25/55] 1939 Add strict_shape option in CheckpointLoader (#1946) * [DLMED] add strict_shape option Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * update test case Signed-off-by: Wenqi Li * fixes test config Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- .github/workflows/pythonapp.yml | 4 ++-- monai/handlers/checkpoint_loader.py | 25 +++++++++++++++++++++++-- tests/test_handler_checkpoint_loader.py | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 514301ad5b..30e6102965 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -285,7 +285,7 @@ jobs: - name: Run quick tests (GPU) run: | nvidia-smi - export LAUNCH_DELAY=$(( RANDOM % 30 * 5 )) + export LAUNCH_DELAY=$(python -c "import numpy; print(numpy.random.randint(30) * 5)") echo "Sleep $LAUNCH_DELAY" sleep $LAUNCH_DELAY export CUDA_VISIBLE_DEVICES=$(coverage run -m tests.utils) @@ -298,7 +298,7 @@ jobs: python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' python -c "import monai; monai.config.print_config()" BUILD_MONAI=1 ./runtests.sh --quick --unittests - if [ ${{ matrix.environment }} == "PT18+CUDA112" ]; then + if [ ${{ matrix.environment }} = "PT18+CUDA112" ]; then # test the clang-format tool downloading once coverage run -m tests.clang_format_utils fi diff --git a/monai/handlers/checkpoint_loader.py b/monai/handlers/checkpoint_loader.py index 40483e8c85..6d8f065f1e 100644 --- a/monai/handlers/checkpoint_loader.py +++ b/monai/handlers/checkpoint_loader.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Dict, Optional import torch +import torch.nn as nn from monai.utils import exact_version, optional_import @@ -44,8 +45,12 @@ class CheckpointLoader: first load the module to CPU and then copy each parameter to where it was saved, which would result in all processes on the same machine using the same set of devices. - strict: whether to strictly enforce that the keys in :attr:`state_dict` match the keys - returned by this module's :meth:`~torch.nn.Module.state_dict` function. Default: ``True`` + strict: whether to strictly enforce that the keys in `state_dict` match the keys + returned by `torch.nn.Module.state_dict` function. default to `True`. + strict_shape: whether to enforce the data shape of the matched layers in the checkpoint, + `if `False`, it will skip the layers that have different data shape with checkpoint content. + This can be useful advanced feature for transfer learning. users should totally + understand which layers will have different shape. default to `True`. """ @@ -56,6 +61,7 @@ def __init__( name: Optional[str] = None, map_location: Optional[Dict] = None, strict: bool = True, + strict_shape: bool = True, ) -> None: if load_path is None: raise AssertionError("must provide clear path to load checkpoint.") @@ -67,6 +73,7 @@ def __init__( self._name = name self.map_location = map_location self.strict = strict + self.strict_shape = strict_shape def attach(self, engine: Engine) -> None: """ @@ -84,6 +91,20 @@ def __call__(self, engine: Engine) -> None: """ checkpoint = torch.load(self.load_path, map_location=self.map_location) + if not self.strict_shape: + k, _ = list(self.load_dict.items())[0] + # single object and checkpoint is directly a state_dict + if len(self.load_dict) == 1 and k not in checkpoint: + checkpoint = {k: checkpoint} + + # skip items that don't match data shape + for k, obj in self.load_dict.items(): + if isinstance(obj, (nn.DataParallel, nn.parallel.DistributedDataParallel)): + obj = obj.module + if isinstance(obj, torch.nn.Module): + d = obj.state_dict() + checkpoint[k] = {k: v for k, v in checkpoint[k].items() if k in d and v.shape == d[k].shape} + # save current max epochs setting in the engine, don't overwrite it if larger than max_epochs in checkpoint prior_max_epochs = engine.state.max_epochs Checkpoint.load_objects(to_load=self.load_dict, checkpoint=checkpoint, strict=self.strict) diff --git a/tests/test_handler_checkpoint_loader.py b/tests/test_handler_checkpoint_loader.py index d58260ac8c..a69193c98c 100644 --- a/tests/test_handler_checkpoint_loader.py +++ b/tests/test_handler_checkpoint_loader.py @@ -146,6 +146,30 @@ def test_partial_over_load(self): engine.run([0] * 8, max_epochs=1) torch.testing.assert_allclose(net2.state_dict()["0.weight"].cpu(), torch.tensor([0.1])) + def test_strict_shape(self): + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + net1 = torch.nn.Sequential(*[torch.nn.PReLU(num_parameters=5)]) + data1 = net1.state_dict() + data1["0.weight"] = torch.tensor([1, 2, 3, 4, 5]) + data1["new"] = torch.tensor(0.1) + net1.load_state_dict(data1, strict=False) + + net2 = torch.nn.Sequential(*[torch.nn.PReLU(), torch.nn.PReLU()]) + data2 = net2.state_dict() + data2["0.weight"] = torch.tensor([0.2]) + data2["1.weight"] = torch.tensor([0.3]) + net2.load_state_dict(data2) + + with tempfile.TemporaryDirectory() as tempdir: + engine = Engine(lambda e, b: None) + CheckpointSaver(save_dir=tempdir, save_dict={"net": net1}, save_final=True).attach(engine) + engine.run([0] * 8, max_epochs=5) + path = tempdir + "/net_final_iteration=40.pt" + engine = Engine(lambda e, b: None) + CheckpointLoader(load_path=path, load_dict={"net": net2}, strict=False, strict_shape=False).attach(engine) + engine.run([0] * 8, max_epochs=1) + torch.testing.assert_allclose(net2.state_dict()["0.weight"].cpu(), torch.tensor([0.2])) + if __name__ == "__main__": unittest.main() From b420c792037c456a14389ab473b36581a27e626d Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 6 Apr 2021 05:44:54 +0800 Subject: [PATCH 26/55] 1947 Enhance load decathlon datalist API (#1948) * [DLMED] enhance decathlon datalist Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] add unit test Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma Co-authored-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- monai/data/decathlon_datalist.py | 30 ++++++++++++++++++-------- monai/transforms/utility/dictionary.py | 4 ---- tests/test_load_decathlon_datalist.py | 25 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/monai/data/decathlon_datalist.py b/monai/data/decathlon_datalist.py index 6167e83e47..11fb5edd28 100644 --- a/monai/data/decathlon_datalist.py +++ b/monai/data/decathlon_datalist.py @@ -17,34 +17,43 @@ @overload -def _compute_path(base_dir: str, element: str) -> str: +def _compute_path(base_dir: str, element: str, check_path: bool = False) -> str: ... @overload -def _compute_path(base_dir: str, element: List[str]) -> List[str]: +def _compute_path(base_dir: str, element: List[str], check_path: bool = False) -> List[str]: ... -def _compute_path(base_dir, element): +def _compute_path(base_dir, element, check_path=False): """ Args: base_dir: the base directory of the dataset. element: file path(s) to append to directory. + check_path: if `True`, only compute when the result is an existing path. Raises: TypeError: When ``element`` contains a non ``str``. TypeError: When ``element`` type is not in ``Union[list, str]``. """ + + def _join_path(base_dir: str, item: str): + result = os.path.normpath(os.path.join(base_dir, item)) + if check_path and not os.path.exists(result): + # if not an existing path, don't join with base dir + return item + return result + if isinstance(element, str): - return os.path.normpath(os.path.join(base_dir, element)) + return _join_path(base_dir, element) if isinstance(element, list): for e in element: if not isinstance(e, str): - raise TypeError(f"Every file path in element must be a str but got {type(element).__name__}.") - return [os.path.normpath(os.path.join(base_dir, e)) for e in element] - raise TypeError(f"element must be one of (str, list) but is {type(element).__name__}.") + return element + return [_join_path(base_dir, e) for e in element] + return element def _append_paths(base_dir: str, is_segmentation: bool, items: List[Dict]) -> List[Dict]: @@ -63,9 +72,12 @@ def _append_paths(base_dir: str, is_segmentation: bool, items: List[Dict]) -> Li raise TypeError(f"Every item in items must be a dict but got {type(item).__name__}.") for k, v in item.items(): if k == "image": - item[k] = _compute_path(base_dir, v) + item[k] = _compute_path(base_dir, v, check_path=False) elif is_segmentation and k == "label": - item[k] = _compute_path(base_dir, v) + item[k] = _compute_path(base_dir, v, check_path=False) + else: + # for other items, auto detect whether it's a valid path + item[k] = _compute_path(base_dir, v, check_path=True) return items diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index f57cbd1116..c437cd055b 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -656,10 +656,6 @@ def __init__(self, keys: KeysCollection, name: str, dim: int = 0, allow_missing_ name: the name corresponding to the key to store the concatenated data. dim: on which dimension to concatenate the items, default is 0. allow_missing_keys: don't raise exception if key is missing. - - Raises: - ValueError: When insufficient keys are given (``len(self.keys) < 2``). - """ super().__init__(keys, allow_missing_keys) self.name = name diff --git a/tests/test_load_decathlon_datalist.py b/tests/test_load_decathlon_datalist.py index 90b9d3ab03..fe7ff6f8a2 100644 --- a/tests/test_load_decathlon_datalist.py +++ b/tests/test_load_decathlon_datalist.py @@ -96,6 +96,31 @@ def test_seg_no_labels(self): result = load_decathlon_datalist(file_path, True, "test", tempdir) self.assertEqual(result[0]["image"], os.path.join(tempdir, "spleen_15.nii.gz")) + def test_additional_items(self): + with tempfile.TemporaryDirectory() as tempdir: + with open(os.path.join(tempdir, "mask31.txt"), "w") as f: + f.write("spleen31 mask") + + test_data = { + "name": "Spleen", + "description": "Spleen Segmentation", + "labels": {"0": "background", "1": "spleen"}, + "training": [ + {"image": "spleen_19.nii.gz", "label": "spleen_19.nii.gz", "mask": "spleen mask"}, + {"image": "spleen_31.nii.gz", "label": "spleen_31.nii.gz", "mask": "mask31.txt"}, + ], + "test": ["spleen_15.nii.gz", "spleen_23.nii.gz"], + } + json_str = json.dumps(test_data) + file_path = os.path.join(tempdir, "test_data.json") + with open(file_path, "w") as json_file: + json_file.write(json_str) + result = load_decathlon_datalist(file_path, True, "training", tempdir) + self.assertEqual(result[0]["image"], os.path.join(tempdir, "spleen_19.nii.gz")) + self.assertEqual(result[0]["label"], os.path.join(tempdir, "spleen_19.nii.gz")) + self.assertEqual(result[1]["mask"], os.path.join(tempdir, "mask31.txt")) + self.assertEqual(result[0]["mask"], "spleen mask") + if __name__ == "__main__": unittest.main() From aa251eaee17c9a456df57ad9fa59ff1edbf04717 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Mon, 5 Apr 2021 19:32:08 -0400 Subject: [PATCH 27/55] Add progress bar to LesionFROC (#1951) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- monai/apps/pathology/metrics.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/metrics.py b/monai/apps/pathology/metrics.py index ae01d8a1db..2140de0080 100644 --- a/monai/apps/pathology/metrics.py +++ b/monai/apps/pathology/metrics.py @@ -9,13 +9,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, List, Tuple +from typing import TYPE_CHECKING, Dict, List, Tuple import numpy as np from monai.apps.pathology.utils import PathologyProbNMS, compute_isolated_tumor_cells, compute_multi_instance_mask from monai.data.image_reader import WSIReader from monai.metrics import compute_fp_tp_probs, compute_froc_curve_data, compute_froc_score +from monai.utils import min_version, optional_import + +if TYPE_CHECKING: + from tqdm import tqdm + + has_tqdm = True +else: + tqdm, has_tqdm = optional_import("tqdm", "4.47.0", min_version, "tqdm") + +if not has_tqdm: + + def tqdm(x): + return x class LesionFROC: @@ -122,7 +135,7 @@ def compute_fp_tp(self): total_num_targets = 0 num_images = len(self.data) - for sample in self.data: + for sample in tqdm(self.data): probs, y_coord, x_coord = self.prepare_inference_result(sample) ground_truth, itc_labels = self.prepare_ground_truth(sample) # compute FP and TP probabilities for a pair of an image and an ground truth mask From d3b5eec73b1a55b53f8e7449788a38cfcd0c8ead Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 6 Apr 2021 12:42:46 +0100 Subject: [PATCH 28/55] pipeline for releasing the docker images (#1953) * adds docker tag action Signed-off-by: Wenqi Li * adds tag info Signed-off-by: Wenqi Li * update versioneer Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- .github/workflows/release.yml | 29 ++++++ .github/workflows/setupapp.yml | 2 + monai/_version.py | 26 ++++-- versioneer.py | 166 ++++++++++++++++++++------------- 4 files changed, 146 insertions(+), 77 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 840194b1da..f36abc9fcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,3 +83,32 @@ jobs: password: ${{ secrets.TEST_PYPI }} repository_url: https://test.pypi.org/legacy/ + release_docker: + if: github.repository == 'Project-MONAI/MONAI' + needs: packaging + runs-on: [ self-hosted, linux, x64, build_only ] + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Set tag + id: versioning + run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + - name: Check tag + env: + RELEASE_VERSION: ${{ steps.versioning.outputs.tag }} + run: | + echo "$RELEASE_VERSION" + - if: startsWith(github.ref, 'refs/tags/') + name: build with the tag + env: + RELEASE_VERSION: ${{ steps.versioning.outputs.tag }} + run: | + git fetch --depth=1 origin +refs/tags/*:refs/tags/* + # remove flake package as it is not needed on hub.docker.com + sed -i '/flake/d' requirements-dev.txt + docker build -t projectmonai/monai:"$RELEASE_VERSION" -f Dockerfile . + # distribute with a tag to hub.docker.com + echo "${{ secrets.DOCKER_PW }}" | docker login -u projectmonai --password-stdin + docker push projectmonai/monai:"$RELEASE_VERSION" + docker logout diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index e5cb9a7cf1..450be403a0 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -159,6 +159,8 @@ jobs: ref: master - name: docker_build run: | + # get tag info for versioning + git fetch --depth=1 origin +refs/tags/*:refs/tags/* # build and run original docker image for local registry docker build -t localhost:5000/local_monai:latest -f Dockerfile . docker push localhost:5000/local_monai:latest diff --git a/monai/_version.py b/monai/_version.py index 1b31d5fd1a..79f569dd79 100644 --- a/monai/_version.py +++ b/monai/_version.py @@ -1,3 +1,4 @@ + # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -5,7 +6,7 @@ # that just contains the computed version number. # This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# versioneer-0.19 (https://github.com/python-versioneer/python-versioneer) """Git implementation of _version.py.""" @@ -56,7 +57,7 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" + """Create decorator to mark a method as the handler of a VCS.""" def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: @@ -92,9 +93,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, if verbose: print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() + stdout = p.communicate()[0].strip().decode() if p.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) @@ -164,6 +163,10 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): raise NotThisMethod("no keywords at all, weird") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -299,6 +302,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # commit date: see ISO-8601 comment in git_versions_from_keywords() date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -337,18 +343,18 @@ def render_pep440(pieces): def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. + """TAG[.post0.devDISTANCE] -- No -dirty. Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0.post0.devDISTANCE """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + rendered += ".post0.dev%d" % pieces["distance"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered @@ -494,7 +500,7 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): # lgtm[py/unused-loop-variable] + for i in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: return {"version": "0+unknown", "full-revisionid": None, diff --git a/versioneer.py b/versioneer.py index 441b3d4c2d..9112ac66a5 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,4 +1,4 @@ -# Version: 0.18 +# Version: 0.19 """The Versioneer - like a rocketeer, but for versions. @@ -6,16 +6,12 @@ ============== * like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer +* https://github.com/python-versioneer/python-versioneer * Brian Warner * License: Public Domain -* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy -* [![Latest Version] -(https://pypip.in/version/versioneer/badge.svg?style=flat) -](https://pypi.python.org/pypi/versioneer/) -* [![Build Status] -(https://travis-ci.org/warner/python-versioneer.png?branch=master) -](https://travis-ci.org/warner/python-versioneer) +* Compatible with: Python 3.6, 3.7, 3.8, 3.9 and pypy3 +* [![Latest Version][pypi-image]][pypi-url] +* [![Build Status][travis-image]][travis-url] This is a tool for managing a recorded version number in distutils-based python projects. The goal is to remove the tedious and error-prone "update @@ -26,9 +22,10 @@ ## Quick Install -* `pip install versioneer` to somewhere to your $PATH -* add a `[versioneer]` section to your setup.cfg (see below) +* `pip install versioneer` to somewhere in your $PATH +* add a `[versioneer]` section to your setup.cfg (see [Install](INSTALL.md)) * run `versioneer install` in your source tree, commit the results +* Verify version information with `python setup.py version` ## Version Identifiers @@ -60,7 +57,7 @@ for example `git describe --tags --dirty --always` reports things like "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. +uncommitted changes). The version identifier is used for multiple purposes: @@ -165,7 +162,7 @@ Some situations are known to cause problems for Versioneer. This details the most significant ones. More can be found on Github -[issues page](https://github.com/warner/python-versioneer/issues). +[issues page](https://github.com/python-versioneer/python-versioneer/issues). ### Subprojects @@ -193,9 +190,9 @@ Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in some later version. -[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking +[Bug #38](https://github.com/python-versioneer/python-versioneer/issues/38) is tracking this issue. The discussion in -[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the +[PR #61](https://github.com/python-versioneer/python-versioneer/pull/61) describes the issue from the Versioneer side in more detail. [pip PR#3176](https://github.com/pypa/pip/pull/3176) and [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve @@ -223,22 +220,10 @@ cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into a different virtualenv), so this can be surprising. -[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes +[Bug #83](https://github.com/python-versioneer/python-versioneer/issues/83) describes this one, but upgrading to a newer version of setuptools should probably resolve it. -### Unicode version strings - -While Versioneer works (and is continually tested) with both Python 2 and -Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. -Newer releases probably generate unicode version strings on py2. It's not -clear that this is wrong, but it may be surprising for applications when then -write these strings to a network connection or include them in bytes-oriented -APIs like cryptographic checksums. - -[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates -this question. - ## Updating Versioneer @@ -264,6 +249,12 @@ direction and include code from all supported VCS systems, reducing the number of intermediate scripts. +## Similar projects + +* [setuptools_scm](https://github.com/pypa/setuptools_scm/) - a non-vendored build-time + dependency +* [minver](https://github.com/jbweston/miniver) - a lightweight reimplementation of + versioneer ## License @@ -273,14 +264,15 @@ Dedication" license (CC0-1.0), as described in https://creativecommons.org/publicdomain/zero/1.0/ . -""" +[pypi-image]: https://img.shields.io/pypi/v/versioneer.svg +[pypi-url]: https://pypi.python.org/pypi/versioneer/ +[travis-image]: +https://img.shields.io/travis/com/python-versioneer/python-versioneer.svg +[travis-url]: https://travis-ci.com/github/python-versioneer/python-versioneer -from __future__ import print_function +""" -try: - import configparser -except ImportError: - import ConfigParser as configparser +import configparser import errno import json import os @@ -340,9 +332,9 @@ def get_config_from_root(root): # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() + parser = configparser.ConfigParser() with open(setup_cfg, "r") as f: - parser.readfp(f) + parser.read_file(f) VCS = parser.get("versioneer", "VCS") # mandatory def get(parser, name): @@ -373,7 +365,7 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" + """Create decorator to mark a method as the handler of a VCS.""" def decorate(f): """Store f in HANDLERS[vcs][method].""" @@ -409,9 +401,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= if verbose: print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() + stdout = p.communicate()[0].strip().decode() if p.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) @@ -422,7 +412,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = ''' +] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -430,7 +420,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= # that just contains the computed version number. # This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# versioneer-0.19 (https://github.com/python-versioneer/python-versioneer) """Git implementation of _version.py.""" @@ -481,7 +471,7 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" + """Create decorator to mark a method as the handler of a VCS.""" def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: @@ -517,9 +507,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, if verbose: print("unable to find command, tried %%s" %% (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() + stdout = p.communicate()[0].strip().decode() if p.returncode != 0: if verbose: print("unable to run %%s (error)" %% dispcmd) @@ -589,6 +577,10 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): raise NotThisMethod("no keywords at all, weird") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -724,6 +716,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # commit date: see ISO-8601 comment in git_versions_from_keywords() date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -762,18 +757,18 @@ def render_pep440(pieces): def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. + """TAG[.post0.devDISTANCE] -- No -dirty. Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0.post0.devDISTANCE """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: - rendered += ".post.dev%%d" %% pieces["distance"] + rendered += ".post0.dev%%d" %% pieces["distance"] else: # exception #1 - rendered = "0.post.dev%%d" %% pieces["distance"] + rendered = "0.post0.dev%%d" %% pieces["distance"] return rendered @@ -981,6 +976,10 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): raise NotThisMethod("no keywords at all, weird") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -1117,6 +1116,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # commit date: see ISO-8601 comment in git_versions_from_keywords() date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -1189,7 +1191,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.18) from +# This file was generated by 'versioneer.py' (0.19) from # revision-control system data, or from the parent directory name of an # unpacked source archive. Distribution tarballs contain a pre-generated copy # of this file. @@ -1263,18 +1265,18 @@ def render_pep440(pieces): def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. + """TAG[.post0.devDISTANCE] -- No -dirty. Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0.post0.devDISTANCE """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + rendered += ".post0.dev%d" % pieces["distance"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered @@ -1310,7 +1312,7 @@ def render_pep440_old(pieces): The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -1493,8 +1495,12 @@ def get_version(): return get_versions()["version"] -def get_cmdclass(): - """Get the custom setuptools/distutils subclasses used by Versioneer.""" +def get_cmdclass(cmdclass=None): + """Get the custom setuptools/distutils subclasses used by Versioneer. + + If the package uses a different cmdclass (e.g. one from numpy), it + should be provide as an argument. + """ if "versioneer" in sys.modules: del sys.modules["versioneer"] # this fixes the "python setup.py develop" case (also 'install' and @@ -1508,9 +1514,9 @@ def get_cmdclass(): # parent is protected against the child's "import versioneer". By # removing ourselves from sys.modules here, before the child build # happens, we protect the child from the parent's versioneer too. - # Also see https://github.com/warner/python-versioneer/issues/52 + # Also see https://github.com/python-versioneer/python-versioneer/issues/52 - cmds = {} + cmds = {} if cmdclass is None else cmdclass.copy() # we add "version" to both distutils and setuptools from distutils.core import Command @@ -1553,7 +1559,9 @@ def run(self): # setup.py egg_info -> ? # we override different "build_py" commands for both environments - if "setuptools" in sys.modules: + if "build_py" in cmds: + _build_py = cmds["build_py"] + elif "setuptools" in sys.modules: from setuptools.command.build_py import build_py as _build_py else: from distutils.command.build_py import build_py as _build_py @@ -1573,6 +1581,31 @@ def run(self): cmds["build_py"] = cmd_build_py + if "setuptools" in sys.modules: + from setuptools.command.build_ext import build_ext as _build_ext + else: + from distutils.command.build_ext import build_ext as _build_ext + + class cmd_build_ext(_build_ext): + def run(self): + root = get_root() + cfg = get_config_from_root(root) + versions = get_versions() + _build_ext.run(self) + if self.inplace: + # build_ext --inplace will only build extensions in + # build/lib<..> dir with no _version.py to write to. + # As in place builds will already have a _version.py + # in the module dir, we do not need to write one. + return + # now locate _version.py in the new build/ directory and replace + # it with an updated value + target_versionfile = os.path.join(self.build_lib, cfg.versionfile_source) + print("UPDATING %s" % target_versionfile) + write_to_version_file(target_versionfile, versions) + + cmds["build_ext"] = cmd_build_ext + if "cx_Freeze" in sys.modules: # cx_freeze enabled? from cx_Freeze.dist import build_exe as _build_exe @@ -1611,10 +1644,7 @@ def run(self): del cmds["build_py"] if "py2exe" in sys.modules: # py2exe enabled? - try: - from py2exe.distutils_buildexe import py2exe as _py2exe # py3 - except ImportError: - from py2exe.build_exe import py2exe as _py2exe # py2 + from py2exe.distutils_buildexe import py2exe as _py2exe class cmd_py2exe(_py2exe): def run(self): @@ -1643,7 +1673,9 @@ def run(self): cmds["py2exe"] = cmd_py2exe # we override different "sdist" commands for both environments - if "setuptools" in sys.modules: + if "sdist" in cmds: + _sdist = cmds["sdist"] + elif "setuptools" in sys.modules: from setuptools.command.sdist import sdist as _sdist else: from distutils.command.sdist import sdist as _sdist @@ -1718,7 +1750,7 @@ def make_release_tree(self, base_dir, files): def do_setup(): - """Main VCS-independent setup function for installing Versioneer.""" + """Do main VCS-independent setup function for installing Versioneer.""" root = get_root() try: cfg = get_config_from_root(root) From 4aad0cfeefc787476cc3619b9b4ecd5cd7a55dd5 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 6 Apr 2021 15:38:57 +0100 Subject: [PATCH 29/55] remove unused RandomizableTransform (#1952) Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/apps/deepgrow/transforms.py | 8 +++----- monai/data/dataset.py | 13 ++++++------- monai/data/image_dataset.py | 5 ++--- monai/data/test_time_augmentation.py | 6 +++--- monai/transforms/compose.py | 6 +++--- monai/transforms/croppad/array.py | 10 +++++----- monai/transforms/croppad/dictionary.py | 14 +++++--------- monai/transforms/intensity/array.py | 7 +++++++ monai/transforms/intensity/dictionary.py | 9 ++++++--- monai/transforms/spatial/array.py | 8 ++++---- monai/transforms/transform.py | 16 +++++++++++++--- monai/transforms/utility/array.py | 4 ++-- monai/transforms/utility/dictionary.py | 8 ++++---- tests/test_compose.py | 8 ++++---- tests/test_rand_lambdad.py | 4 ++-- 15 files changed, 69 insertions(+), 57 deletions(-) diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index c58d4c1123..3d8f08bc01 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -16,7 +16,7 @@ from monai.config import IndexSelection, KeysCollection from monai.networks.layers import GaussianFilter from monai.transforms import Resize, SpatialCrop -from monai.transforms.transform import MapTransform, RandomizableTransform, Transform +from monai.transforms.transform import MapTransform, Randomizable, Transform from monai.transforms.utils import generate_spatial_bounding_box from monai.utils import InterpolateMode, ensure_tuple_rep, min_version, optional_import @@ -61,7 +61,7 @@ def __call__(self, data): return d -class AddInitialSeedPointd(RandomizableTransform): +class AddInitialSeedPointd(Randomizable): """ Add random guidance as initial seed point for a given label. @@ -86,7 +86,6 @@ def __init__( sid: str = "sid", connected_regions: int = 5, ): - super().__init__(prob=1.0, do_transform=True) self.label = label self.sids_key = sids self.sid_key = sid @@ -284,7 +283,7 @@ def __call__(self, data): return d -class AddRandomGuidanced(RandomizableTransform): +class AddRandomGuidanced(Randomizable): """ Add random guidance based on discrepancies that were found between label and prediction. @@ -320,7 +319,6 @@ def __init__( probability: str = "probability", batched: bool = True, ): - super().__init__(prob=1.0, do_transform=True) self.guidance = guidance self.discrepancy = discrepancy self.probability = probability diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 12403bbff1..a09050e5bc 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -29,7 +29,6 @@ from monai.data.utils import first, pickle_hashing from monai.transforms import Compose, Randomizable, Transform, apply_transform -from monai.transforms.transform import RandomizableTransform from monai.utils import MAX_SEED, get_seed, min_version, optional_import if TYPE_CHECKING: @@ -182,7 +181,7 @@ def _pre_transform(self, item_transformed): raise ValueError("transform must be an instance of monai.transforms.Compose.") for _transform in self.transform.transforms: # execute all the deterministic transforms - if isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform): + if isinstance(_transform, Randomizable) or not isinstance(_transform, Transform): break item_transformed = apply_transform(_transform, item_transformed) return item_transformed @@ -204,7 +203,7 @@ def _post_transform(self, item_transformed): for _transform in self.transform.transforms: if ( start_post_randomize_run - or isinstance(_transform, RandomizableTransform) + or isinstance(_transform, Randomizable) or not isinstance(_transform, Transform) ): start_post_randomize_run = True @@ -547,7 +546,7 @@ def _load_cache_item(self, idx: int): raise ValueError("transform must be an instance of monai.transforms.Compose.") for _transform in self.transform.transforms: # execute all the deterministic transforms - if isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform): + if isinstance(_transform, Randomizable) or not isinstance(_transform, Transform): break item = apply_transform(_transform, item) return item @@ -564,7 +563,7 @@ def _transform(self, index: int): if not isinstance(self.transform, Compose): raise ValueError("transform must be an instance of monai.transforms.Compose.") for _transform in self.transform.transforms: - if start_run or isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform): + if start_run or isinstance(_transform, Randomizable) or not isinstance(_transform, Transform): start_run = True data = apply_transform(_transform, data) return data @@ -967,10 +966,10 @@ def __getitem__(self, index: int): # set transforms of each zip component for dataset in self.dataset.data: transform = getattr(dataset, "transform", None) - if isinstance(transform, RandomizableTransform): + if isinstance(transform, Randomizable): transform.set_random_state(seed=self._seed) transform = getattr(self.dataset, "transform", None) - if isinstance(transform, RandomizableTransform): + if isinstance(transform, Randomizable): transform.set_random_state(seed=self._seed) return self.dataset[index] diff --git a/monai/data/image_dataset.py b/monai/data/image_dataset.py index 1074105508..1568e082ee 100644 --- a/monai/data/image_dataset.py +++ b/monai/data/image_dataset.py @@ -17,7 +17,6 @@ from monai.config import DtypeLike from monai.data.image_reader import ImageReader from monai.transforms import LoadImage, Randomizable, apply_transform -from monai.transforms.transform import RandomizableTransform from monai.utils import MAX_SEED, get_seed @@ -107,14 +106,14 @@ def __getitem__(self, index: int): label = self.labels[index] if self.transform is not None: - if isinstance(self.transform, RandomizableTransform): + if isinstance(self.transform, Randomizable): self.transform.set_random_state(seed=self._seed) img = apply_transform(self.transform, img) data = [img] if self.seg_transform is not None: - if isinstance(self.seg_transform, RandomizableTransform): + if isinstance(self.seg_transform, Randomizable): self.seg_transform.set_random_state(seed=self._seed) seg = apply_transform(self.seg_transform, seg) diff --git a/monai/data/test_time_augmentation.py b/monai/data/test_time_augmentation.py index 51b95adc58..06e1f63da5 100644 --- a/monai/data/test_time_augmentation.py +++ b/monai/data/test_time_augmentation.py @@ -20,7 +20,7 @@ from monai.data.utils import list_data_collate, pad_list_data_collate from monai.transforms.compose import Compose from monai.transforms.inverse import InvertibleTransform -from monai.transforms.transform import RandomizableTransform +from monai.transforms.transform import Randomizable from monai.transforms.utils import allow_missing_keys_mode from monai.utils.enums import CommonKeys, InverseKeys @@ -47,7 +47,7 @@ class TestTimeAugmentation: Args: transform: transform (or composed) to be applied to each realisation. At least one transform must be of type - `RandomizableTransform`. All random transforms must be of type `InvertibleTransform`. + `Randomizable`. All random transforms must be of type `InvertibleTransform`. batch_size: number of realisations to infer at once. num_workers: how many subprocesses to use for data. inferrer_fn: function to use to perform inference. @@ -96,7 +96,7 @@ def __init__( def _check_transforms(self): """Should be at least 1 random transform, and all random transforms should be invertible.""" ts = [self.transform] if not isinstance(self.transform, Compose) else self.transform.transforms - randoms = np.array([isinstance(t, RandomizableTransform) for t in ts]) + randoms = np.array([isinstance(t, Randomizable) for t in ts]) invertibles = np.array([isinstance(t, InvertibleTransform) for t in ts]) # check at least 1 random if sum(randoms) == 0: diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index dd40663e2a..ce965b8b18 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -32,7 +32,7 @@ __all__ = ["Compose"] -class Compose(RandomizableTransform, InvertibleTransform): +class Compose(Randomizable, InvertibleTransform): """ ``Compose`` provides the ability to chain a series of calls together in a sequence. Each transform in the sequence must take a single argument and @@ -102,14 +102,14 @@ def __init__(self, transforms: Optional[Union[Sequence[Callable], Callable]] = N def set_random_state(self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None) -> "Compose": super().set_random_state(seed=seed, state=state) for _transform in self.transforms: - if not isinstance(_transform, RandomizableTransform): + if not isinstance(_transform, Randomizable): continue _transform.set_random_state(seed=self.R.randint(MAX_SEED, dtype="uint32")) return self def randomize(self, data: Optional[Any] = None) -> None: for _transform in self.transforms: - if not isinstance(_transform, RandomizableTransform): + if not isinstance(_transform, Randomizable): continue try: _transform.randomize(data) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 159fa1a5f4..c8f7136334 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -20,7 +20,7 @@ from monai.config import IndexSelection from monai.data.utils import get_random_patch, get_valid_patch_size -from monai.transforms.transform import Randomizable, RandomizableTransform, Transform +from monai.transforms.transform import Randomizable, Transform from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, @@ -279,7 +279,7 @@ def __call__(self, img: np.ndarray): return cropper(img) -class RandSpatialCrop(RandomizableTransform): +class RandSpatialCrop(Randomizable): """ Crop image with random size or specific size ROI. It can crop at a random position as center or at the image center. And allows to set the minimum size to limit the randomly generated ROI. @@ -324,7 +324,7 @@ def __call__(self, img: np.ndarray): return cropper(img) -class RandSpatialCropSamples(RandomizableTransform): +class RandSpatialCropSamples(Randomizable): """ Crop image with random size or specific size ROI to generate a list of N samples. It can crop at a random position as center or at the image center. And allows to set @@ -432,7 +432,7 @@ def __call__(self, img: np.ndarray): return cropped -class RandWeightedCrop(RandomizableTransform): +class RandWeightedCrop(Randomizable): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -484,7 +484,7 @@ def __call__(self, img: np.ndarray, weight_map: Optional[np.ndarray] = None) -> return results -class RandCropByPosNegLabel(RandomizableTransform): +class RandCropByPosNegLabel(Randomizable): """ Crop random fixed sized regions with the center being a foreground or background voxel based on the Pos Neg Ratio. diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 1d4fcfdb1f..c8d5ceea40 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -34,7 +34,7 @@ SpatialPad, ) from monai.transforms.inverse import InvertibleTransform -from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform +from monai.transforms.transform import MapTransform, Randomizable from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, @@ -386,7 +386,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar return d -class RandSpatialCropd(RandomizableTransform, MapTransform, InvertibleTransform): +class RandSpatialCropd(Randomizable, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandSpatialCrop`. Crop image with random size or specific size ROI. It can crop at a random position as @@ -413,7 +413,6 @@ def __init__( random_size: bool = True, allow_missing_keys: bool = False, ) -> None: - RandomizableTransform.__init__(self, prob=1.0, do_transform=True) MapTransform.__init__(self, keys, allow_missing_keys) self.roi_size = roi_size self.random_center = random_center @@ -477,7 +476,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar return d -class RandSpatialCropSamplesd(RandomizableTransform, MapTransform): +class RandSpatialCropSamplesd(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandSpatialCropSamples`. Crop image with random size or specific size ROI to generate a list of N samples. @@ -515,7 +514,6 @@ def __init__( meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: - RandomizableTransform.__init__(self, prob=1.0, do_transform=True) MapTransform.__init__(self, keys, allow_missing_keys) if num_samples < 1: raise ValueError(f"num_samples must be positive, got {num_samples}.") @@ -626,7 +624,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar return d -class RandWeightedCropd(RandomizableTransform, MapTransform): +class RandWeightedCropd(Randomizable, MapTransform): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -654,7 +652,6 @@ def __init__( center_coord_key: Optional[str] = None, allow_missing_keys: bool = False, ): - RandomizableTransform.__init__(self, prob=1.0, do_transform=True) MapTransform.__init__(self, keys, allow_missing_keys) self.spatial_size = ensure_tuple(spatial_size) self.w_key = w_key @@ -693,7 +690,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n return results -class RandCropByPosNegLabeld(RandomizableTransform, MapTransform): +class RandCropByPosNegLabeld(Randomizable, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandCropByPosNegLabel`. Crop random fixed sized regions with the center being a foreground or background voxel @@ -751,7 +748,6 @@ def __init__( meta_key_postfix: str = "meta_dict", allow_missing_keys: bool = False, ) -> None: - RandomizableTransform.__init__(self) MapTransform.__init__(self, keys, allow_missing_keys) self.label_key = label_key self.spatial_size: Union[Tuple[int, ...], Sequence[int], int] = spatial_size diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index f89e381daa..62350d4ab0 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -122,6 +122,7 @@ def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1 if len(offsets) != 2: raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) + self._offset = self.offsets[0] def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) @@ -217,6 +218,7 @@ def __init__( if len(factors) != 2: raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) + self.factor = self.factors[0] self.nonzero = nonzero self.channel_wise = channel_wise self.dtype = dtype @@ -294,6 +296,7 @@ def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1 if len(factors) != 2: raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) + self.factor = self.factors[0] def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) @@ -874,6 +877,10 @@ def __init__( self.sigma_z = sigma_z self.approx = approx + self.x = self.sigma_x[0] + self.y = self.sigma_y[0] + self.z = self.sigma_z[0] + def randomize(self, data: Optional[Any] = None) -> None: super().randomize(None) self.x = self.R.uniform(low=self.sigma_x[0], high=self.sigma_x[1]) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 517c34cbf2..a35e5c8ea6 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -206,6 +206,7 @@ def __init__( if len(offsets) != 2: raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) + self._offset = self.offsets[0] def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) @@ -293,6 +294,7 @@ def __init__( if len(factors) != 2: raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) + self.factor = self.factors[0] self.nonzero = nonzero self.channel_wise = channel_wise self.dtype = dtype @@ -380,6 +382,7 @@ def __init__( if len(factors) != 2: raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) + self.factor = self.factors[0] def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) @@ -760,11 +763,11 @@ def __init__( ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) - self.sigma_x = sigma_x - self.sigma_y = sigma_y - self.sigma_z = sigma_z + self.sigma_x, self.sigma_y, self.sigma_z = sigma_x, sigma_y, sigma_z self.approx = approx + self.x, self.y, self.z = self.sigma_x[0], self.sigma_y[0], self.sigma_z[0] + def randomize(self, data: Optional[Any] = None) -> None: super().randomize(None) self.x = self.R.uniform(low=self.sigma_x[0], high=self.sigma_x[1]) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 1c096ba743..a3eb055f7e 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -23,7 +23,7 @@ from monai.data.utils import compute_shape_offset, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.transforms.croppad.array import CenterSpatialCrop -from monai.transforms.transform import RandomizableTransform, Transform +from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( create_control_grid, create_grid, @@ -790,7 +790,7 @@ class RandAxisFlip(RandomizableTransform): """ def __init__(self, prob: float = 0.1) -> None: - RandomizableTransform.__init__(self, min(max(prob, 0.0), 1.0)) + RandomizableTransform.__init__(self, prob) self._axis: Optional[int] = None def randomize(self, data: np.ndarray) -> None: @@ -1004,7 +1004,7 @@ def __call__( return grid if self.as_tensor_output else np.asarray(grid.cpu().numpy()), affine -class RandAffineGrid(RandomizableTransform): +class RandAffineGrid(Randomizable): """ Generate randomised affine grid. """ @@ -1101,7 +1101,7 @@ def get_transformation_matrix(self) -> Optional[Union[np.ndarray, torch.Tensor]] return self.affine -class RandDeformGrid(RandomizableTransform): +class RandDeformGrid(Randomizable): """ Generate random deformation grid. """ diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 6a22db1076..ff5f021739 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -180,17 +180,27 @@ class RandomizableTransform(Randomizable, Transform): """ An interface for handling random state locally, currently based on a class variable `R`, which is an instance of `np.random.RandomState`. - This is mainly for randomized data augmentation transforms. For example:: + This class introduces a randomized flag `_do_transform`, is mainly for randomized data augmentation transforms. + For example: - class RandShiftIntensity(RandomizableTransform): - def randomize(): + .. code-block:: python + + from monai.transforms import RandomizableTransform + + class RandShiftIntensity100(RandomizableTransform): + def randomize(self): + super().randomize(None) self._offset = self.R.uniform(low=0, high=100) + def __call__(self, img): self.randomize() + if not self._do_transform: + return img return img + self._offset transform = RandShiftIntensity() transform.set_random_state(seed=0) + print(transform(10)) """ diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 8e0dabafb2..6903b2628d 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -22,7 +22,7 @@ import torch from monai.config import DtypeLike, NdarrayTensor -from monai.transforms.transform import RandomizableTransform, Transform +from monai.transforms.transform import Randomizable, Transform from monai.transforms.utils import extreme_points_to_image, get_extreme_points, map_binary_to_indices from monai.utils import ensure_tuple, min_version, optional_import @@ -667,7 +667,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return np.stack(result, axis=0) -class AddExtremePointsChannel(RandomizableTransform): +class AddExtremePointsChannel(Randomizable): """ Add extreme points of label to the image as a new channel. This transform generates extreme point from label and applies a gaussian filter. The pixel values in points image are rescaled diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index c437cd055b..9464faa503 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -23,7 +23,7 @@ import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor -from monai.transforms.transform import MapTransform, RandomizableTransform +from monai.transforms.transform import MapTransform, Randomizable from monai.transforms.utility.array import ( AddChannel, AsChannelFirst, @@ -731,9 +731,9 @@ def __call__(self, data): return d -class RandLambdad(Lambdad, RandomizableTransform): +class RandLambdad(Lambdad, Randomizable): """ - RandomizableTransform version :py:class:`monai.transforms.Lambdad`, the input `func` contains random logic. + Randomizable version :py:class:`monai.transforms.Lambdad`, the input `func` contains random logic. It's a randomizable transform so `CacheDataset` will not execute it and cache the results. Args: @@ -853,7 +853,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class AddExtremePointsChanneld(RandomizableTransform, MapTransform): +class AddExtremePointsChanneld(Randomizable, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AddExtremePointsChannel`. diff --git a/tests/test_compose.py b/tests/test_compose.py index bb8a5f08c5..97b044af8f 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -14,11 +14,11 @@ from monai.data import DataLoader, Dataset from monai.transforms import AddChannel, Compose -from monai.transforms.transform import RandomizableTransform +from monai.transforms.transform import Randomizable from monai.utils import set_determinism -class _RandXform(RandomizableTransform): +class _RandXform(Randomizable): def randomize(self): self.val = self.R.random_sample() @@ -80,7 +80,7 @@ def c(d): # transform to handle dict data self.assertDictEqual(item, {"a": 2, "b": 1, "c": 2}) def test_random_compose(self): - class _Acc(RandomizableTransform): + class _Acc(Randomizable): self.rand = 0.0 def randomize(self, data=None): @@ -99,7 +99,7 @@ def __call__(self, data): self.assertAlmostEqual(c(1), 1.90734751) def test_randomize_warn(self): - class _RandomClass(RandomizableTransform): + class _RandomClass(Randomizable): def randomize(self, foo1, foo2): pass diff --git a/tests/test_rand_lambdad.py b/tests/test_rand_lambdad.py index 2ddfeefae0..a450b67413 100644 --- a/tests/test_rand_lambdad.py +++ b/tests/test_rand_lambdad.py @@ -13,11 +13,11 @@ import numpy as np -from monai.transforms.transform import RandomizableTransform +from monai.transforms.transform import Randomizable from monai.transforms.utility.dictionary import RandLambdad -class RandTest(RandomizableTransform): +class RandTest(Randomizable): """ randomisable transform for testing. """ From 4456604b5d6fb2e54777245a73eadbd8f5a4a4ff Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 7 Apr 2021 01:12:13 +0100 Subject: [PATCH 30/55] 1956 - docker image building pipelines (#1957) * refactor docker building Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- .dockerignore | 1 - .github/workflows/docker.yml | 112 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 44 ++++++++++++- .github/workflows/setupapp.yml | 43 ------------- Dockerfile | 3 +- 5 files changed, 154 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/docker.yml diff --git a/.dockerignore b/.dockerignore index 262da4d0dd..4e1161bfb2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ docs/ .coverage/ coverage.xml .readthedocs.yml -*.md *.toml !README.md diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..2745d4169f --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,112 @@ +name: docker +# versioning: compute a static version file +# local_docker: use the version file to build docker images +# docker_test_latest: test the latest internal docker image (has flake) +# docker_test_dockerhub: test the latest dockerhub release (no flake) +on: + # master only docker deployment and quick tests + push: + branches: + - master + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + versioning: + # compute versioning file from python setup.py + # upload as artifact + # (also used in release.yml) + if: github.repository == 'Project-MONAI/MONAI' + container: + image: localhost:5000/local_monai:latest + runs-on: [self-hosted, linux, x64, build_only] + steps: + - uses: actions/checkout@v2 + # full history so that we can git describe + with: + ref: master + fetch-depth: 0 + - shell: bash + run: | + git describe + python setup.py build + cat build/lib/monai/_version.py + - name: Upload version + uses: actions/upload-artifact@v2 + with: + name: _version.py + path: build/lib/monai/_version.py + - name: Clean up directory + shell: bash + run: | + ls -al + rm -rf {*,.[^.]*} + + local_docker: + # builds two versions: local_monai:latest and local_monai:dockerhub + # latest: used for local tests + # dockerhub: release, no flake package + if: github.repository == 'Project-MONAI/MONAI' + needs: versioning + runs-on: [self-hosted, linux, x64, build_only] + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Download version + uses: actions/download-artifact@v2 + with: + name: _version.py + - name: docker_build + shell: bash + run: | + # get tag info for versioning + cat _version.py + mv _version.py monai/ + # build and run original docker image for local registry + docker build -t localhost:5000/local_monai:latest -f Dockerfile . + docker push localhost:5000/local_monai:latest + # build once more w/ tag "latest": remove flake package as it is not needed on hub.docker.com + sed -i '/flake/d' requirements-dev.txt + docker build -t projectmonai/monai:latest -f Dockerfile . + # also push as tag "dockerhub" to local registry + docker image tag projectmonai/monai:latest localhost:5000/local_monai:dockerhub + docker push localhost:5000/local_monai:dockerhub + # distribute as always w/ tag "latest" to hub.docker.com + echo "${{ secrets.DOCKER_PW }}" | docker login -u projectmonai --password-stdin + docker push projectmonai/monai:latest + docker logout + + docker_test_latest: + if: github.repository == 'Project-MONAI/MONAI' + needs: local_docker + container: + image: localhost:5000/local_monai:latest + runs-on: [self-hosted, linux, x64, common] + steps: + - name: Import + run: | + python -c 'import monai; monai.config.print_config()' + cd /opt/monai + ls -al + ngc --version + python -m tests.min_tests + env: + QUICKTEST: True + + docker_test_dockerhub: + if: github.repository == 'Project-MONAI/MONAI' + needs: local_docker + container: + image: localhost:5000/local_monai:dockerhub + runs-on: [self-hosted, linux, x64, common] + steps: + - name: Import + run: | + python -c 'import monai; monai.config.print_config()' + cd /opt/monai + ls -al + ngc --version + python -m tests.min_tests + env: + QUICKTEST: True diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f36abc9fcf..00e28ecd52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,14 +83,49 @@ jobs: password: ${{ secrets.TEST_PYPI }} repository_url: https://test.pypi.org/legacy/ - release_docker: + versioning: + # compute versioning file from python setup.py + # upload as artifact + # (also used in docker.yml) if: github.repository == 'Project-MONAI/MONAI' needs: packaging - runs-on: [ self-hosted, linux, x64, build_only ] + container: + image: localhost:5000/local_monai:latest + runs-on: [self-hosted, linux, x64, build_only] steps: - uses: actions/checkout@v2 + # full history so that we can git describe with: ref: master + fetch-depth: 0 + - shell: bash + run: | + git describe + python setup.py build + cat build/lib/monai/_version.py + - name: Upload version + uses: actions/upload-artifact@v2 + with: + name: _version.py + path: build/lib/monai/_version.py + - name: Clean up directory + shell: bash + run: | + ls -al + rm -rf {*,.[^.]*} + + release_tag_docker: + if: github.repository == 'Project-MONAI/MONAI' + needs: versioning + runs-on: [self-hosted, linux, x64, build_only] + steps: + - uses: actions/checkout@v2 + with: + ref: master + - name: Download version + uses: actions/download-artifact@v2 + with: + name: _version.py - name: Set tag id: versioning run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} @@ -99,12 +134,15 @@ jobs: RELEASE_VERSION: ${{ steps.versioning.outputs.tag }} run: | echo "$RELEASE_VERSION" + cat _version.py - if: startsWith(github.ref, 'refs/tags/') name: build with the tag env: RELEASE_VERSION: ${{ steps.versioning.outputs.tag }} + shell: bash run: | - git fetch --depth=1 origin +refs/tags/*:refs/tags/* + # get tag info for versioning + mv _version.py monai/ # remove flake package as it is not needed on hub.docker.com sed -i '/flake/d' requirements-dev.txt docker build -t projectmonai/monai:"$RELEASE_VERSION" -f Dockerfile . diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 450be403a0..dc65141fe8 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -148,46 +148,3 @@ jobs: python -m tests.min_tests env: QUICKTEST: True - - local_docker: - if: github.repository == 'Project-MONAI/MONAI' - runs-on: [self-hosted, linux, x64, build_only] - # we only push built container if it is built from master branch - steps: - - uses: actions/checkout@v2 - with: - ref: master - - name: docker_build - run: | - # get tag info for versioning - git fetch --depth=1 origin +refs/tags/*:refs/tags/* - # build and run original docker image for local registry - docker build -t localhost:5000/local_monai:latest -f Dockerfile . - docker push localhost:5000/local_monai:latest - # build once more w/ tag "latest": remove flake package as it is not needed on hub.docker.com - sed -i '/flake/d' requirements-dev.txt - docker build -t projectmonai/monai:latest -f Dockerfile . - # also push as tag "dockerhub" to local registry - docker image tag projectmonai/monai:latest localhost:5000/local_monai:dockerhub - docker push localhost:5000/local_monai:dockerhub - # distribute as always w/ tag "latest" to hub.docker.com - echo "${{ secrets.DOCKER_PW }}" | docker login -u projectmonai --password-stdin - docker push projectmonai/monai:latest - docker logout - - docker: - if: github.repository == 'Project-MONAI/MONAI' - needs: local_docker - container: - image: localhost:5000/local_monai:latest - runs-on: [self-hosted, linux, x64, common] - steps: - - name: Import - run: | - python -c 'import monai; monai.config.print_config()' - cd /opt/monai - ls -al - ngc --version - python -m tests.min_tests - env: - QUICKTEST: True diff --git a/Dockerfile b/Dockerfile index 57ea567869..23be9ae1c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,9 @@ RUN cp /tmp/requirements.txt /tmp/req.bak \ # please specify exact files and folders to be copied -- else, basically always, the Docker build process cannot cache # this or anything below it and always will build from at most here; one file change leads to no caching from here on... -COPY LICENSE setup.py setup.cfg versioneer.py runtests.sh .gitignore .gitattributes README.md MANIFEST.in ./ +COPY LICENSE CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md README.md versioneer.py setup.py setup.cfg runtests.sh MANIFEST.in ./ COPY tests ./tests COPY monai ./monai -COPY .git ./.git RUN BUILD_MONAI=1 FORCE_CUDA=1 python setup.py develop \ && rm -rf build __pycache__ From 1c4dcd95b3a71332220875b9119cc64c611bd748 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 7 Apr 2021 08:26:20 +0100 Subject: [PATCH 31/55] 1955 style issue (#1958) * update docs deps. Signed-off-by: Wenqi Li * fixes https://github.com/Project-MONAI/MONAI/runs/2283148095\?check_suite_focus\=true\#step:7:8972 Signed-off-by: Wenqi Li * Revert "fixes https://github.com/Project-MONAI/MONAI/runs/2283148095\?check_suite_focus\=true\#step:7:8972" This reverts commit 1407400da84bebe997ee7b720545f12f105a7025. Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- docs/requirements.txt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index c03e3327f4..acc983129f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,15 +4,13 @@ pytorch-ignite==0.4.4 numpy>=1.17 itk>=5.0, <=5.1.2 nibabel -cucim==0.18.2 -openslide-python==1.1.2 parameterized scikit-image>=0.14.2 tensorboard commonmark==0.9.1 recommonmark==0.6.0 -Sphinx==3.3.0 -sphinx-rtd-theme==0.5.0 +Sphinx==3.5.3 +sphinx-rtd-theme==0.5.2 sphinxcontrib-applehelp sphinxcontrib-devhelp sphinxcontrib-htmlhelp From 6e39205799cfa52abe300eb1ee74f9c9326047b3 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 7 Apr 2021 05:14:20 -0400 Subject: [PATCH 32/55] Update garbage collection assertion (#1959) * Update garbage collection assertion to a more reliable one Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove print Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Signed-off-by: Neha Srivathsa --- tests/test_handler_garbage_collector.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_handler_garbage_collector.py b/tests/test_handler_garbage_collector.py index 5e6bd7275c..9f63211a13 100644 --- a/tests/test_handler_garbage_collector.py +++ b/tests/test_handler_garbage_collector.py @@ -60,17 +60,16 @@ def _train_func(engine, batch): GarbageCollector(trigger_event=trigger_event, log_level=30).attach(engine) engine.run(data_loader, max_epochs=5) - print(gb_count_dict) first_count = 0 - for epoch, gb_count in gb_count_dict.items(): - # At least one zero-generation object + for iter, gb_count in gb_count_dict.items(): + # At least one zero-generation object is collected self.assertGreater(gb_count[0], 0) - if epoch == 1: - first_count = gb_count[0] - else: - # The should be less number of collected objects in the next calls. - self.assertLess(gb_count[0], first_count) + if iter > 1: + # Since we are collecting all objects from all generations manually at each call, + # starting from the second call, there shouldn't be any 1st and 2nd generation objects available to collect. + self.assertEqual(gb_count[1], first_count) + self.assertEqual(gb_count[2], first_count) if __name__ == "__main__": From 2fdee253528867e58e76f5f2ed96591d063755ee Mon Sep 17 00:00:00 2001 From: Petru-Daniel Tudosiu Date: Wed, 7 Apr 2021 12:08:03 +0100 Subject: [PATCH 33/55] Working ParameterScheduler (#1949) * Working ParameterScheduler Added a new ParameterScheduler handler and the required tests. Signed-off-by: Petru-Daniel Tudosiu Signed-off-by: Neha Srivathsa --- docs/source/handlers.rst | 5 + monai/handlers/__init__.py | 1 + monai/handlers/parameter_scheduler.py | 163 ++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_handler_parameter_scheduler.py | 123 ++++++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 monai/handlers/parameter_scheduler.py create mode 100644 tests/test_handler_parameter_scheduler.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index 869467c496..9030fa3ced 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -111,6 +111,11 @@ SmartCache handler .. autoclass:: SmartCacheHandler :members: +Parameter Scheduler handler +--------------------------- +.. autoclass:: ParamSchedulerHandler + :members: + EarlyStop handler ----------------- .. autoclass:: EarlyStopHandler diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 2112b074a0..f88531ea8e 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -21,6 +21,7 @@ from .mean_dice import MeanDice from .metric_logger import MetricLogger, MetricLoggerKeys from .metrics_saver import MetricsSaver +from .parameter_scheduler import ParamSchedulerHandler from .roc_auc import ROCAUC from .segmentation_saver import SegmentationSaver from .smartcache_handler import SmartCacheHandler diff --git a/monai/handlers/parameter_scheduler.py b/monai/handlers/parameter_scheduler.py new file mode 100644 index 0000000000..2aa0224a5a --- /dev/null +++ b/monai/handlers/parameter_scheduler.py @@ -0,0 +1,163 @@ +import logging +from bisect import bisect_right +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union + +from monai.utils import exact_version, optional_import + +if TYPE_CHECKING: + from ignite.engine import Engine, Events +else: + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") + + +class ParamSchedulerHandler: + """ + General purpose scheduler for parameters values. By default it can schedule in a linear, exponential, step or + multistep function. One can also pass Callables to have customized scheduling logic. + + Args: + parameter_setter (Callable): Function that sets the required parameter + value_calculator (Union[str,Callable]): Either a string ('linear', 'exponential', 'step' or 'multistep') + or Callable for custom logic. + vc_kwargs (Dict): Dictionary that stores the required parameters for the value_calculator. + epoch_level (bool): Whether the the step is based on epoch or iteration. Defaults to False. + name (Optional[str]): Identifier of logging.logger to use, if None, defaulting to ``engine.logger``. + event (Optional[str]): Event to which the handler attaches. Defaults to Events.ITERATION_COMPLETED. + """ + + def __init__( + self, + parameter_setter: Callable, + value_calculator: Union[str, Callable], + vc_kwargs: Dict, + epoch_level: bool = False, + name: Optional[str] = None, + event=Events.ITERATION_COMPLETED, + ): + self.epoch_level = epoch_level + self.event = event + + self._calculators = { + "linear": self._linear, + "exponential": self._exponential, + "step": self._step, + "multistep": self._multistep, + } + + self._parameter_setter = parameter_setter + self._vc_kwargs = vc_kwargs + self._value_calculator = self._get_value_calculator(value_calculator=value_calculator) + + self.logger = logging.getLogger(name) + self._name = name + + def _get_value_calculator(self, value_calculator): + if isinstance(value_calculator, str): + return self._calculators[value_calculator] + if callable(value_calculator): + return value_calculator + raise ValueError( + f"value_calculator must be either a string from {list(self._calculators.keys())} or a Callable." + ) + + def __call__(self, engine: Engine): + if self.epoch_level: + self._vc_kwargs["current_step"] = engine.state.epoch + else: + self._vc_kwargs["current_step"] = engine.state.iteration + + new_value = self._value_calculator(**self._vc_kwargs) + self._parameter_setter(new_value) + + def attach(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine that is used for training. + """ + if self._name is None: + self.logger = engine.logger + engine.add_event_handler(self.event, self) + + @staticmethod + def _linear( + initial_value: float, step_constant: int, step_max_value: int, max_value: float, current_step: int + ) -> float: + """ + Keeps the parameter value to zero until step_zero steps passed and then linearly increases it to 1 until an + additional step_one steps passed. Continues the trend until it reaches max_value. + + Args: + initial_value (float): Starting value of the parameter. + step_constant (int): Step index until parameter's value is kept constant. + step_max_value (int): Step index at which parameter's value becomes max_value. + max_value (float): Max parameter value. + current_step (int): Current step index. + + Returns: + float: new parameter value + """ + if current_step <= step_constant: + delta = 0.0 + elif current_step > step_max_value: + delta = max_value - initial_value + else: + delta = (max_value - initial_value) / (step_max_value - step_constant) * (current_step - step_constant) + + return initial_value + delta + + @staticmethod + def _exponential(initial_value: float, gamma: float, current_step: int) -> float: + """ + Decays the parameter value by gamma every step. + + Based on the closed form of ExponentialLR from Pytorch + https://github.com/pytorch/pytorch/blob/master/torch/optim/lr_scheduler.py#L457 + + Args: + initial_value (float): Starting value of the parameter. + gamma (float): Multiplicative factor of parameter value decay. + current_step (int): Current step index. + + Returns: + float: new parameter value + """ + return initial_value * gamma ** current_step + + @staticmethod + def _step(initial_value: float, gamma: float, step_size: int, current_step: int) -> float: + """ + Decays the parameter value by gamma every step_size. + + Based on StepLR from Pytorch. + https://github.com/pytorch/pytorch/blob/master/torch/optim/lr_scheduler.py#L377 + + Args: + initial_value (float): Starting value of the parameter. + gamma (float): Multiplicative factor of parameter value decay. + step_size (int): Period of parameter value decay. + current_step (int): Current step index. + + Returns + float: new parameter value + """ + return initial_value * gamma ** (current_step // step_size) + + @staticmethod + def _multistep(initial_value: float, gamma: float, milestones: List[int], current_step: int) -> float: + """ + Decays the parameter value by gamma once the number of steps reaches one of the milestones. + + Based on MultiStepLR from Pytorch. + https://github.com/pytorch/pytorch/blob/master/torch/optim/lr_scheduler.py#L424 + + Args: + initial_value (float): Starting value of the parameter. + gamma (float): Multiplicative factor of parameter value decay. + milestones (List[int]): List of step indices. Must be increasing. + current_step (int): Current step index. + + Returns: + float: new parameter value + """ + return initial_value * gamma ** bisect_right(milestones, current_step) diff --git a/tests/min_tests.py b/tests/min_tests.py index 9b96e7eaab..98f6d822a7 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -47,6 +47,7 @@ def run_testsuit(): "test_handler_prob_map_producer", "test_handler_rocauc", "test_handler_rocauc_dist", + "test_handler_parameter_scheduler", "test_handler_segmentation_saver", "test_handler_smartcache", "test_handler_stats", diff --git a/tests/test_handler_parameter_scheduler.py b/tests/test_handler_parameter_scheduler.py new file mode 100644 index 0000000000..5b3e845ace --- /dev/null +++ b/tests/test_handler_parameter_scheduler.py @@ -0,0 +1,123 @@ +import unittest + +import torch +from ignite.engine import Engine, Events +from torch.nn import Module + +from monai.handlers.parameter_scheduler import ParamSchedulerHandler + + +class ToyNet(Module): + def __init__(self, value): + super(ToyNet, self).__init__() + self.value = value + + def forward(self, input): + return input + + def get_value(self): + return self.value + + def set_value(self, value): + self.value = value + + +class TestHandlerParameterScheduler(unittest.TestCase): + def test_linear_scheduler(self): + # Testing step_constant + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator="linear", + vc_kwargs={"initial_value": 0, "step_constant": 2, "step_max_value": 5, "max_value": 10}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=2) + torch.testing.assert_allclose(net.get_value(), 0) + + # Testing linear increase + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator="linear", + vc_kwargs={"initial_value": 0, "step_constant": 2, "step_max_value": 5, "max_value": 10}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=3) + torch.testing.assert_allclose(net.get_value(), 3.333333, atol=0.001, rtol=0.0) + + # Testing max_value + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator="linear", + vc_kwargs={"initial_value": 0, "step_constant": 2, "step_max_value": 5, "max_value": 10}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=10) + torch.testing.assert_allclose(net.get_value(), 10) + + def test_exponential_scheduler(self): + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator="exponential", + vc_kwargs={"initial_value": 10, "gamma": 0.99}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=2) + torch.testing.assert_allclose(net.get_value(), 10 * 0.99 * 0.99) + + def test_step_scheduler(self): + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator="step", + vc_kwargs={"initial_value": 10, "gamma": 0.99, "step_size": 5}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=10) + torch.testing.assert_allclose(net.get_value(), 10 * 0.99 * 0.99) + + def test_multistep_scheduler(self): + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator="multistep", + vc_kwargs={"initial_value": 10, "gamma": 0.99, "milestones": [3, 6]}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=10) + torch.testing.assert_allclose(net.get_value(), 10 * 0.99 * 0.99) + + def test_custom_scheduler(self): + def custom_logic(initial_value, gamma, current_step): + return initial_value * gamma ** (current_step % 9) + + net = ToyNet(value=-1) + engine = Engine(lambda e, b: None) + ParamSchedulerHandler( + parameter_setter=net.set_value, + value_calculator=custom_logic, + vc_kwargs={"initial_value": 10, "gamma": 0.99}, + epoch_level=True, + event=Events.EPOCH_COMPLETED, + ).attach(engine) + engine.run([0] * 8, max_epochs=2) + torch.testing.assert_allclose(net.get_value(), 10 * 0.99 * 0.99) + + +if __name__ == "__main__": + unittest.main() From 4803f9d1257c8ec389b91823ef6783aae003a17e Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 7 Apr 2021 22:59:07 +0800 Subject: [PATCH 34/55] [DLMED] remove warning (#1962) Signed-off-by: Nic Ma Signed-off-by: Neha Srivathsa --- monai/utils/module.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/monai/utils/module.py b/monai/utils/module.py index 448046b9e6..b51b2820a8 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -96,11 +96,9 @@ def min_version(the_module, min_version_str: str = "") -> bool: Returns True if the module's version is greater or equal to the 'min_version'. When min_version_str is not provided, it always returns True. """ - if not min_version_str: + if not min_version_str or not hasattr(the_module, "__version__"): return True # always valid version - if not hasattr(the_module, "__version__"): - warnings.warn(f"{the_module} has no attribute __version__ in min_version check.") - return True # min_version is the default, shouldn't be noisy + mod_version = tuple(int(x) for x in the_module.__version__.split(".")[:2]) required = tuple(int(x) for x in min_version_str.split(".")[:2]) return mod_version >= required From 08dfbeb5899578e52f36925ecf75141c03983a33 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 8 Apr 2021 00:36:04 +0800 Subject: [PATCH 35/55] 1916 Add support for custom events in workflows (#1961) * [DLMED] add support for addtional events Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] fix typehints Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * fixes typos Signed-off-by: Wenqi Li Co-authored-by: monai-bot Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/engines/evaluator.py | 39 ++++++++++++++++++------- monai/engines/trainer.py | 19 +++++++----- monai/engines/workflow.py | 39 +++++++++++++++---------- tests/test_ensemble_evaluator.py | 23 ++++++++++++++- tests/test_handler_garbage_collector.py | 3 +- 5 files changed, 89 insertions(+), 34 deletions(-) diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index c1fe79c848..bfa69c0bdd 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union import torch from torch.utils.data import DataLoader @@ -23,11 +23,12 @@ from monai.utils.enums import CommonKeys as Keys if TYPE_CHECKING: - from ignite.engine import Engine + from ignite.engine import Engine, EventEnum from ignite.metrics import Metric else: Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") + EventEnum, _ = optional_import("ignite.engine", "0.4.4", exact_version, "EventEnum") __all__ = ["Evaluator", "SupervisedEvaluator", "EnsembleEvaluator"] @@ -56,6 +57,10 @@ class Evaluator(Workflow): amp: whether to enable auto-mixed-precision evaluation, default is False. mode: model forward mode during evaluation, should be 'eval' or 'train', which maps to `model.eval()` or `model.train()`, default to 'eval'. + event_names: additional custom ignite events that will register to the engine. + new events can be a list of str or `ignite.engine.events.EventEnum`. + event_to_attr: a dictionary to map an event to a state attribute, then add to `engine.state`. + for more details, check: https://github.com/pytorch/ignite/blob/v0.4.4.post1/ignite/engine/engine.py#L160 """ @@ -73,6 +78,8 @@ def __init__( val_handlers: Optional[Sequence] = None, amp: bool = False, mode: Union[ForwardMode, str] = ForwardMode.EVAL, + event_names: Optional[List[Union[str, EventEnum]]] = None, + event_to_attr: Optional[dict] = None, ) -> None: super().__init__( device=device, @@ -87,6 +94,8 @@ def __init__( additional_metrics=additional_metrics, handlers=val_handlers, amp=amp, + event_names=event_names, + event_to_attr=event_to_attr, ) mode = ForwardMode(mode) if mode == ForwardMode.EVAL: @@ -140,6 +149,10 @@ class SupervisedEvaluator(Evaluator): amp: whether to enable auto-mixed-precision evaluation, default is False. mode: model forward mode during evaluation, should be 'eval' or 'train', which maps to `model.eval()` or `model.train()`, default to 'eval'. + event_names: additional custom ignite events that will register to the engine. + new events can be a list of str or `ignite.engine.events.EventEnum`. + event_to_attr: a dictionary to map an event to a state attribute, then add to `engine.state`. + for more details, check: https://github.com/pytorch/ignite/blob/v0.4.4.post1/ignite/engine/engine.py#L160 """ @@ -159,6 +172,8 @@ def __init__( val_handlers: Optional[Sequence] = None, amp: bool = False, mode: Union[ForwardMode, str] = ForwardMode.EVAL, + event_names: Optional[List[Union[str, EventEnum]]] = None, + event_to_attr: Optional[dict] = None, ) -> None: super().__init__( device=device, @@ -173,15 +188,14 @@ def __init__( val_handlers=val_handlers, amp=amp, mode=mode, + # add the iteration events + event_names=[IterationEvents] if event_names is None else event_names + [IterationEvents], + event_to_attr=event_to_attr, ) self.network = network self.inferer = SimpleInferer() if inferer is None else inferer - def _register_additional_events(self): - super()._register_additional_events() - self.register_events(*IterationEvents) - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: """ callback function for the Supervised Evaluation processing logic of 1 iteration in Ignite Engine. @@ -251,6 +265,10 @@ class EnsembleEvaluator(Evaluator): amp: whether to enable auto-mixed-precision evaluation, default is False. mode: model forward mode during evaluation, should be 'eval' or 'train', which maps to `model.eval()` or `model.train()`, default to 'eval'. + event_names: additional custom ignite events that will register to the engine. + new events can be a list of str or `ignite.engine.events.EventEnum`. + event_to_attr: a dictionary to map an event to a state attribute, then add to `engine.state`. + for more details, check: https://github.com/pytorch/ignite/blob/v0.4.4.post1/ignite/engine/engine.py#L160 """ @@ -271,6 +289,8 @@ def __init__( val_handlers: Optional[Sequence] = None, amp: bool = False, mode: Union[ForwardMode, str] = ForwardMode.EVAL, + event_names: Optional[List[Union[str, EventEnum]]] = None, + event_to_attr: Optional[dict] = None, ) -> None: super().__init__( device=device, @@ -285,16 +305,15 @@ def __init__( val_handlers=val_handlers, amp=amp, mode=mode, + # add the iteration events + event_names=[IterationEvents] if event_names is None else event_names + [IterationEvents], + event_to_attr=event_to_attr, ) self.networks = ensure_tuple(networks) self.pred_keys = ensure_tuple(pred_keys) self.inferer = SimpleInferer() if inferer is None else inferer - def _register_additional_events(self): - super()._register_additional_events() - self.register_events(*IterationEvents) - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: """ callback function for the Supervised Evaluation processing logic of 1 iteration in Ignite Engine. diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index a7b1943211..f14ee7e91f 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union import torch from torch.optim.optimizer import Optimizer @@ -23,11 +23,12 @@ from monai.utils.enums import CommonKeys as Keys if TYPE_CHECKING: - from ignite.engine import Engine + from ignite.engine import Engine, EventEnum from ignite.metrics import Metric else: Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") + EventEnum, _ = optional_import("ignite.engine", "0.4.4", exact_version, "EventEnum") __all__ = ["Trainer", "SupervisedTrainer", "GanTrainer"] @@ -78,6 +79,10 @@ class SupervisedTrainer(Trainer): train_handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: CheckpointHandler, StatsHandler, SegmentationSaver, etc. amp: whether to enable auto-mixed-precision training, default is False. + event_names: additional custom ignite events that will register to the engine. + new events can be a list of str or `ignite.engine.events.EventEnum`. + event_to_attr: a dictionary to map an event to a state attribute, then add to `engine.state`. + for more details, check: https://github.com/pytorch/ignite/blob/v0.4.4.post1/ignite/engine/engine.py#L160 """ @@ -99,8 +104,9 @@ def __init__( additional_metrics: Optional[Dict[str, Metric]] = None, train_handlers: Optional[Sequence] = None, amp: bool = False, + event_names: Optional[List[Union[str, EventEnum]]] = None, + event_to_attr: Optional[dict] = None, ) -> None: - # set up Ignite engine and environments super().__init__( device=device, max_epochs=max_epochs, @@ -114,6 +120,9 @@ def __init__( additional_metrics=additional_metrics, handlers=train_handlers, amp=amp, + # add the iteration events + event_names=[IterationEvents] if event_names is None else event_names + [IterationEvents], + event_to_attr=event_to_attr, ) self.network = network @@ -121,10 +130,6 @@ def __init__( self.loss_function = loss_function self.inferer = SimpleInferer() if inferer is None else inferer - def _register_additional_events(self): - super()._register_additional_events() - self.register_events(*IterationEvents) - def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): """ Callback function for the Supervised Training processing logic of 1 iteration in Ignite Engine. diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 61b92ac5dd..50a9f41368 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Sequence, Union import torch import torch.distributed as dist @@ -23,12 +23,14 @@ IgniteEngine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") State, _ = optional_import("ignite.engine", "0.4.4", exact_version, "State") Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") + if TYPE_CHECKING: - from ignite.engine import Engine + from ignite.engine import Engine, EventEnum from ignite.metrics import Metric else: Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") + EventEnum, _ = optional_import("ignite.engine", "0.4.4", exact_version, "EventEnum") class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optional_import @@ -60,6 +62,10 @@ class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optiona handlers: every handler is a set of Ignite Event-Handlers, must have `attach` function, like: CheckpointHandler, StatsHandler, SegmentationSaver, etc. amp: whether to enable auto-mixed-precision training or inference, default is False. + event_names: additional custom ignite events that will register to the engine. + new events can be a list of str or `ignite.engine.events.EventEnum`. + event_to_attr: a dictionary to map an event to a state attribute, then add to `engine.state`. + for more details, check: https://github.com/pytorch/ignite/blob/v0.4.4.post1/ignite/engine/engine.py#L160 Raises: TypeError: When ``device`` is not a ``torch.Device``. @@ -83,6 +89,8 @@ def __init__( additional_metrics: Optional[Dict[str, Metric]] = None, handlers: Optional[Sequence] = None, amp: bool = False, + event_names: Optional[List[Union[str, EventEnum]]] = None, + event_to_attr: Optional[dict] = None, ) -> None: if iteration_update is not None: super().__init__(iteration_update) @@ -128,7 +136,17 @@ def set_sampler_epoch(engine: Engine): self.prepare_batch = prepare_batch self.amp = amp - self._register_additional_events() + if event_names is not None: + if not isinstance(event_names, list): + raise ValueError("event_names must be a list or string or EventEnum.") + for name in event_names: + if isinstance(name, str): + self.register_events(name, event_to_attr=event_to_attr) + elif issubclass(name, EventEnum): + self.register_events(*name, event_to_attr=event_to_attr) + else: + raise ValueError("event_names must be a list or string or EventEnum.") + if post_transform is not None: self._register_post_transforms(post_transform) if key_metric is not None: @@ -136,14 +154,7 @@ def set_sampler_epoch(engine: Engine): if handlers is not None: self._register_handlers(handlers) - def _register_additional_events(self): - """ - Register more ignite Events to the engine. - - """ - pass - - def _register_post_transforms(self, posttrans): + def _register_post_transforms(self, posttrans: Callable): """ Register the post transforms to the engine, will execute them as a chain when iteration completed. @@ -151,11 +162,9 @@ def _register_post_transforms(self, posttrans): @self.on(Events.ITERATION_COMPLETED) def run_post_transform(engine: Engine) -> None: - if posttrans is None: - raise AssertionError engine.state.output = apply_transform(posttrans, engine.state.output) - def _register_metrics(self, k_metric, add_metrics): + def _register_metrics(self, k_metric: Dict, add_metrics: Optional[Dict] = None): """ Register the key metric and additional metrics to the engine, supports ignite Metrics. @@ -180,7 +189,7 @@ def _compare_metrics(engine: Engine) -> None: engine.state.best_metric = current_val_metric engine.state.best_metric_epoch = engine.state.epoch - def _register_handlers(self, handlers): + def _register_handlers(self, handlers: Sequence): """ Register the handlers to the engine, supports ignite Handlers with `attach` API. diff --git a/tests/test_ensemble_evaluator.py b/tests/test_ensemble_evaluator.py index 9cc977d876..28a2d4f941 100644 --- a/tests/test_ensemble_evaluator.py +++ b/tests/test_ensemble_evaluator.py @@ -12,7 +12,7 @@ import unittest import torch -from ignite.engine import Events +from ignite.engine import EventEnum, Events from monai.engines import EnsembleEvaluator @@ -44,11 +44,17 @@ def forward(self, x): net3 = TestNet(lambda x: x + 4) net4 = TestNet(lambda x: x + 5) + class CustomEvents(EventEnum): + FOO_EVENT = "foo_event" + BAR_EVENT = "bar_event" + val_engine = EnsembleEvaluator( device=device, val_data_loader=val_loader, networks=[net0, net1, net2, net3, net4], pred_keys=["pred0", "pred1", "pred2", "pred3", "pred4"], + event_names=["bwd_event", "opt_event", CustomEvents], + event_to_attr={CustomEvents.FOO_EVENT: "foo", "opt_event": "opt"}, ) @val_engine.on(Events.ITERATION_COMPLETED) @@ -57,6 +63,21 @@ def run_post_transform(engine): expected_value = engine.state.iteration + i torch.testing.assert_allclose(engine.state.output[f"pred{i}"], torch.tensor([[expected_value]])) + @val_engine.on(Events.EPOCH_COMPLETED) + def trigger_custom_event(): + val_engine.fire_event(CustomEvents.FOO_EVENT) + val_engine.fire_event(CustomEvents.BAR_EVENT) + val_engine.fire_event("bwd_event") + val_engine.fire_event("opt_event") + + @val_engine.on(CustomEvents.FOO_EVENT) + def do_foo_op(): + self.assertEqual(val_engine.state.foo, 0) + + @val_engine.on("opt_event") + def do_bar_op(): + self.assertEqual(val_engine.state.opt, 0) + val_engine.run() diff --git a/tests/test_handler_garbage_collector.py b/tests/test_handler_garbage_collector.py index 9f63211a13..c2c5dcbfd6 100644 --- a/tests/test_handler_garbage_collector.py +++ b/tests/test_handler_garbage_collector.py @@ -67,7 +67,8 @@ def _train_func(engine, batch): self.assertGreater(gb_count[0], 0) if iter > 1: # Since we are collecting all objects from all generations manually at each call, - # starting from the second call, there shouldn't be any 1st and 2nd generation objects available to collect. + # starting from the second call, there shouldn't be any 1st and 2nd + # generation objects available to collect. self.assertEqual(gb_count[1], first_count) self.assertEqual(gb_count[2], first_count) From b1703b227b81af8aa8eee083f6cb52dcb7f841a2 Mon Sep 17 00:00:00 2001 From: Alvin Ihsani Date: Wed, 7 Apr 2021 14:05:10 -0700 Subject: [PATCH 36/55] added cupy requirement Signed-off-by: Neha Srivathsa --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 5d96284307..07d8699ce8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ torch>=1.5 numpy>=1.17 +cupy From f965d4946c0dc3f8f53f4aa0e4de3612f1cb451c Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 8 Apr 2021 00:54:24 +0100 Subject: [PATCH 37/55] 1965 - register_backward_hook (#1966) * fixes #1965 Signed-off-by: Wenqi Li * adds docstring Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- monai/handlers/parameter_scheduler.py | 11 +++++++++++ monai/networks/blocks/crf.py | 2 +- monai/optimizers/lr_scheduler.py | 11 +++++++++++ monai/visualize/class_activation_maps.py | 10 ++++++++-- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/monai/handlers/parameter_scheduler.py b/monai/handlers/parameter_scheduler.py index 2aa0224a5a..35ba044586 100644 --- a/monai/handlers/parameter_scheduler.py +++ b/monai/handlers/parameter_scheduler.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import logging from bisect import bisect_right from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union diff --git a/monai/networks/blocks/crf.py b/monai/networks/blocks/crf.py index 635c750ba9..29d4ef4216 100644 --- a/monai/networks/blocks/crf.py +++ b/monai/networks/blocks/crf.py @@ -1,4 +1,4 @@ -# Copyright 2020 MONAI Consortium +# Copyright 2020 - 2021 MONAI Consortium # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/monai/optimizers/lr_scheduler.py b/monai/optimizers/lr_scheduler.py index aa9bf2a89b..c4488f6e07 100644 --- a/monai/optimizers/lr_scheduler.py +++ b/monai/optimizers/lr_scheduler.py @@ -1,3 +1,14 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from torch.optim import Optimizer from torch.optim.lr_scheduler import _LRScheduler diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index b310ec0834..c63e8e51d9 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -18,7 +18,7 @@ import torch.nn.functional as F from monai.transforms import ScaleIntensity -from monai.utils import ensure_tuple +from monai.utils import ensure_tuple, get_torch_version_tuple from monai.visualize.visualizer import default_upsampler __all__ = ["CAM", "GradCAM", "GradCAMpp", "ModelWithHooks", "default_normalizer"] @@ -73,7 +73,13 @@ def __init__( continue _registered.append(name) if self.register_backward: - mod.register_backward_hook(self.backward_hook(name)) + if get_torch_version_tuple() < (1, 8): + mod.register_backward_hook(self.backward_hook(name)) + else: + if "inplace" in mod.__dict__ and mod.__dict__["inplace"]: + # inplace=True causes errors for register_full_backward_hook + mod.__dict__["inplace"] = False + mod.register_full_backward_hook(self.backward_hook(name)) if self.register_forward: mod.register_forward_hook(self.forward_hook(name)) if len(_registered) != len(self.target_layers): From 06c97200cbb9f276b29ae3f35d4ffb51d55fc9e3 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 8 Apr 2021 13:52:20 +0800 Subject: [PATCH 38/55] 1968 Enhance meta data doc-string in SegmentationSaver (#1969) * [DLMED] enhance doc-strings Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot Signed-off-by: Neha Srivathsa --- monai/handlers/segmentation_saver.py | 4 ++++ monai/transforms/io/dictionary.py | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index 9ee7ca67f9..6a98abf3ca 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -28,6 +28,9 @@ class SegmentationSaver: """ Event handler triggered on completing every iteration to save the segmentation predictions into files. + It can extract the input image meta data(filename, affine, original_shape, etc.) and resample the predictions + based on the meta data. + """ def __init__( @@ -96,6 +99,7 @@ def __init__( output will be: /output/test1/image/image_seg.nii.gz batch_transform: a callable that is used to transform the ignite.engine.batch into expected format to extract the meta_data dictionary. + it can be used to extract the input image meta data: filename, affine, original_shape, etc. output_transform: a callable that is used to transform the ignite.engine.output into the form expected image data. The first dimension of this transform's output will be treated as the diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 58d6431c74..413f83b62d 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -132,9 +132,10 @@ class SaveImaged(MapTransform): keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` meta_key_postfix: `key_{postfix}` was used to store the metadata in `LoadImaged`. - So need the key to extract metadata to save images, default is `meta_dict`. - The meta data is a dictionary object, if no corresponding metadata, set to `None`. - For example, for data with key `image`, the metadata by default is in `image_meta_dict`. + so need the key to extract metadata to save images, default is `meta_dict`. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the meta data is a dictionary object which contains: filename, affine, original_shape, etc. + if no corresponding metadata, set to `None`. output_dir: output image directory. output_postfix: a string appended to all output file names, default to `trans`. output_ext: output file extension name, available extensions: `.nii.gz`, `.nii`, `.png`. From 43acc448db209eeffc81094305e2672c40f76664 Mon Sep 17 00:00:00 2001 From: Alvin Ihsani Date: Thu, 8 Apr 2021 11:21:56 -0700 Subject: [PATCH 39/55] made cupy an optional import Signed-off-by: Alvin Ihsani Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 5 +++-- requirements.txt | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index 41fbaf2856..d8f5084de1 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -2,9 +2,10 @@ # - Original implementation from Macenko paper in Matlab: https://github.com/mitkovetta/staining-normalization # - Implementation in Python: https://github.com/schaugf/HEnorm_python # - Link to Macenko et al., 2009 paper: http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf -import cupy as cp - from monai.transforms.transform import Transform +from monai.utils import exact_version, optional_import + +cp, _ = optional_import("cupy", "8.6.0", exact_version) class ExtractStainsMacenko(Transform): diff --git a/requirements.txt b/requirements.txt index 07d8699ce8..5d96284307 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ torch>=1.5 numpy>=1.17 -cupy From c18f20c1ad50cbda991bab09ec2f53a5cee1f69e Mon Sep 17 00:00:00 2001 From: Neha Srivathsa Date: Thu, 8 Apr 2021 17:00:00 -0700 Subject: [PATCH 40/55] minor changes per comments Signed-off-by: Neha Srivathsa --- monai/apps/pathology/transforms.py | 54 ++++++++++++++++-------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/monai/apps/pathology/transforms.py b/monai/apps/pathology/transforms.py index d8f5084de1..a2063fbeaa 100644 --- a/monai/apps/pathology/transforms.py +++ b/monai/apps/pathology/transforms.py @@ -12,10 +12,12 @@ class ExtractStainsMacenko(Transform): """Class to extract a target stain from an image, using the Macenko method for stain deconvolution. Args: - tli: (optional) transmitted light intensity - alpha: (optional) tolerance for the pseudo-min and pseudo-max - beta: (optional) Optical Density (OD) threshold for transparent pixels - max_cref: (optional) reference maximum stain concentrations for Hematoxylin & Eosin (H&E) + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) + and pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15 + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. """ def __init__(self, tli: float = 240, alpha: float = 1, beta: float = 0.15, max_cref: cp.ndarray = None) -> None: @@ -34,24 +36,24 @@ def _deconvolution_extract_stain(self, img: cp.ndarray) -> cp.ndarray: img: RGB image to perform stain deconvolution of Return: - he: H&E OD matrix for the image (first column is H, second column is E, rows are RGB values) + he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) """ # reshape image img = img.reshape((-1, 3)) - # calculate optical density - od = -cp.log((img.astype(cp.float) + 1) / self.tli) + # calculate absorbance + absorbance = -cp.log(cp.clip(img.astype(cp.float)+1, a_max=self.tli)/self.tli) # remove transparent pixels - od_hat = od[~cp.any(od < self.beta, axis=1)] + absorbance_hat = absorbance[cp.all(absorbance>self.beta, axis=1)] # compute eigenvectors - _, eigvecs = cp.linalg.eigh(cp.cov(od_hat.T)) + _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T)) # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues - t_hat = od_hat.dot(eigvecs[:, 1:3]) + t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) - # find the min and max vectors and project back to OD space + # find the min and max vectors and project back to absorbance space phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) min_phi = cp.percentile(phi, self.alpha) max_phi = cp.percentile(phi, 100 - self.alpha) @@ -73,7 +75,7 @@ def __call__(self, image: cp.ndarray) -> cp.ndarray: image: RGB image to extract stain from return: - target_he: H&E OD matrix for the image (first column is H, second column is E, rows are RGB values) + target_he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) """ target_he = self._deconvolution_extract_stain(image) return target_he @@ -89,11 +91,13 @@ class NormalizeStainsMacenko(Transform): reference maximum stain concentrations matrix is used. Args: - tli: (optional) transmitted light intensity - alpha: (optional) tolerance for the pseudo-min and pseudo-max - beta: (optional) Optical Density (OD) threshold for transparent pixels - target_he: (optional) target stain matrix - max_cref: (optional) reference maximum stain concentrations for Hematoxylin & Eosin (H&E) + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) and + pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15. + target_he: target stain matrix. Defaults to None. + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. """ def __init__( @@ -128,19 +132,19 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: # reshape image img = img.reshape((-1, 3)) - # calculate optical density - od = -cp.log((img.astype(cp.float) + 1) / self.tli) + # calculate absorbance + absorbance = -cp.log(cp.clip(img.astype(cp.float)+1, a_max=self.tli)/self.tli) # remove transparent pixels - od_hat = od[~cp.any(od < self.beta, axis=1)] + absorbance_hat = absorbance[cp.all(absorbance>self.beta, axis=1)] # compute eigenvectors - _, eigvecs = cp.linalg.eigh(cp.cov(od_hat.T)) + _, eigvecs = cp.linalg.eigh(cp.cov(absorbance_hat.T)) # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues - t_hat = od_hat.dot(eigvecs[:, 1:3]) + t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) - # find the min and max vectors and project back to OD space + # find the min and max vectors and project back to absorbance space phi = cp.arctan2(t_hat[:, 1], t_hat[:, 0]) min_phi = cp.percentile(phi, self.alpha) max_phi = cp.percentile(phi, 100 - self.alpha) @@ -153,8 +157,8 @@ def _deconvolution_extract_conc(self, img: cp.ndarray) -> cp.ndarray: else: he = cp.array((v_max[:, 0], v_min[:, 0])).T - # rows correspond to channels (RGB), columns to OD values - y = cp.reshape(od, (-1, 3)).T + # rows correspond to channels (RGB), columns to absorbance values + y = cp.reshape(absorbance, (-1, 3)).T # determine concentrations of the individual stains conc = cp.linalg.lstsq(he, y, rcond=None)[0] From 5bd8a83073a99c16c57a30cc8b30da0f060f78ce Mon Sep 17 00:00:00 2001 From: masadcv Date: Fri, 9 Apr 2021 09:23:35 +0100 Subject: [PATCH 41/55] Adding EfficientNetsB0-B7 support (#1938) * adding init efficientnet support Signed-off-by: masadcv * fixing flake8 and further refactoring Signed-off-by: masadcv * adding unittests for efficiennet Signed-off-by: masadcv * making unittests backwards compatible python<3.8 Signed-off-by: masadcv * fixed kitty unittests file path Signed-off-by: masadcv * adding docstrings and minor refactoring Signed-off-by: masadcv * fix flake8-py3 failing test Signed-off-by: masadcv * generalize drop_connect for n-dim, fix/add unittests, remove assert Signed-off-by: masadcv * fix failing unittest, CC0-license image for test Signed-off-by: masadcv * refactoring code for review Signed-off-by: masadcv * WIP fix mypy type hint errors Signed-off-by: masadcv * fix cuda test error Signed-off-by: masadcv * WIP fix test errors Signed-off-by: masadcv * adding non-default shape tests Signed-off-by: masadcv * remove 3d case from non-default shape test Signed-off-by: masadcv * refactoring and updating docs Signed-off-by: masadcv Co-authored-by: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- docs/source/networks.rst | 10 + monai/networks/blocks/__init__.py | 2 +- monai/networks/blocks/activation.py | 53 +- monai/networks/layers/factories.py | 7 + monai/networks/nets/__init__.py | 1 + monai/networks/nets/efficientnet.py | 849 ++++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_activations.py | 11 +- tests/test_efficientnet.py | 308 ++++++++++ tests/testing_data/kitty_test.jpg | Bin 0 -> 61168 bytes 10 files changed, 1239 insertions(+), 3 deletions(-) create mode 100644 monai/networks/nets/efficientnet.py create mode 100644 tests/test_efficientnet.py create mode 100644 tests/testing_data/kitty_test.jpg diff --git a/docs/source/networks.rst b/docs/source/networks.rst index abf75bda1d..baee107620 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -35,6 +35,11 @@ Blocks .. autoclass:: Swish :members: +`MemoryEfficientSwish` +~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: MemoryEfficientSwish + :members: + `Mish` ~~~~~~ .. autoclass:: Mish @@ -292,6 +297,11 @@ Nets .. autoclass:: DenseNet :members: +`EfficientNet` +~~~~~~~~~~~~~~ +.. autoclass:: EfficientNet + :members: + `SegResNet` ~~~~~~~~~~~ .. autoclass:: SegResNet diff --git a/monai/networks/blocks/__init__.py b/monai/networks/blocks/__init__.py index cdf7bc3f6d..ed6ac12430 100644 --- a/monai/networks/blocks/__init__.py +++ b/monai/networks/blocks/__init__.py @@ -10,7 +10,7 @@ # limitations under the License. from .acti_norm import ADN -from .activation import Mish, Swish +from .activation import MemoryEfficientSwish, Mish, Swish from .aspp import SimpleASPP from .convolutions import Convolution, ResidualUnit from .crf import CRF diff --git a/monai/networks/blocks/activation.py b/monai/networks/blocks/activation.py index ef6c74f282..f6a04e830e 100644 --- a/monai/networks/blocks/activation.py +++ b/monai/networks/blocks/activation.py @@ -17,7 +17,7 @@ class Swish(nn.Module): r"""Applies the element-wise function: .. math:: - \text{Swish}(x) = x * \text{Sigmoid}(\alpha * x) for constant value alpha. + \text{Swish}(x) = x * \text{Sigmoid}(\alpha * x) ~~~~\text{for constant value}~ \alpha. Citation: Searching for Activation Functions, Ramachandran et al., 2017, https://arxiv.org/abs/1710.05941. @@ -43,6 +43,57 @@ def forward(self, input: torch.Tensor) -> torch.Tensor: return input * torch.sigmoid(self.alpha * input) +class SwishImplementation(torch.autograd.Function): + r"""Memory efficient implementation for training + Follows recommendation from: + https://github.com/lukemelas/EfficientNet-PyTorch/issues/18#issuecomment-511677853 + + Results in ~ 30% memory saving during training as compared to Swish() + """ + + @staticmethod + def forward(ctx, input): + result = input * torch.sigmoid(input) + ctx.save_for_backward(input) + return result + + @staticmethod + def backward(ctx, grad_output): + input = ctx.saved_tensors[0] + sigmoid_input = torch.sigmoid(input) + return grad_output * (sigmoid_input * (1 + input * (1 - sigmoid_input))) + + +class MemoryEfficientSwish(nn.Module): + r"""Applies the element-wise function: + + .. math:: + \text{Swish}(x) = x * \text{Sigmoid}(\alpha * x) ~~~~\text{for constant value}~ \alpha=1. + + Memory efficient implementation for training following recommendation from: + https://github.com/lukemelas/EfficientNet-PyTorch/issues/18#issuecomment-511677853 + + Results in ~ 30% memory saving during training as compared to Swish() + + Citation: Searching for Activation Functions, Ramachandran et al., 2017, https://arxiv.org/abs/1710.05941. + + Shape: + - Input: :math:`(N, *)` where `*` means, any number of additional + dimensions + - Output: :math:`(N, *)`, same shape as the input + + + Examples:: + + >>> m = Act['memswish']() + >>> input = torch.randn(2) + >>> output = m(input) + """ + + def forward(self, input: torch.Tensor): + return SwishImplementation.apply(input) + + class Mish(nn.Module): r"""Applies the element-wise function: diff --git a/monai/networks/layers/factories.py b/monai/networks/layers/factories.py index ec36b2ed95..9165a8ebe4 100644 --- a/monai/networks/layers/factories.py +++ b/monai/networks/layers/factories.py @@ -256,6 +256,13 @@ def swish_factory(): return Swish +@Act.factory_function("memswish") +def memswish_factory(): + from monai.networks.blocks.activation import MemoryEfficientSwish + + return MemoryEfficientSwish + + @Act.factory_function("mish") def mish_factory(): from monai.networks.blocks.activation import Mish diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index 6876293bdb..91f46debf6 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -15,6 +15,7 @@ from .classifier import Classifier, Critic, Discriminator from .densenet import DenseNet, DenseNet121, DenseNet169, DenseNet201, DenseNet264 from .dynunet import DynUNet, DynUnet, Dynunet +from .efficientnet import EfficientNet, EfficientNetBN, drop_connect, get_efficientnet_image_size from .fullyconnectednet import FullyConnectedNet, VarFullyConnectedNet from .generator import Generator from .highresnet import HighResBlock, HighResNet diff --git a/monai/networks/nets/efficientnet.py b/monai/networks/nets/efficientnet.py new file mode 100644 index 0000000000..d8754e3f78 --- /dev/null +++ b/monai/networks/nets/efficientnet.py @@ -0,0 +1,849 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +import operator +import re +from functools import reduce +from typing import List, NamedTuple, Optional, Tuple, Type, Union + +import torch +from torch import nn +from torch.utils import model_zoo + +from monai.networks.layers.factories import Act, Conv, Norm, Pad, Pool + +__all__ = ["EfficientNetBN", "get_efficientnet_image_size", "drop_connect"] + +efficientnet_params = { + # model_name: (width_mult, depth_mult, image_size, dropout_rate, dropconnect_rate) + "efficientnet-b0": (1.0, 1.0, 224, 0.2, 0.2), + "efficientnet-b1": (1.0, 1.1, 240, 0.2, 0.2), + "efficientnet-b2": (1.1, 1.2, 260, 0.3, 0.2), + "efficientnet-b3": (1.2, 1.4, 300, 0.3, 0.2), + "efficientnet-b4": (1.4, 1.8, 380, 0.4, 0.2), + "efficientnet-b5": (1.6, 2.2, 456, 0.4, 0.2), + "efficientnet-b6": (1.8, 2.6, 528, 0.5, 0.2), + "efficientnet-b7": (2.0, 3.1, 600, 0.5, 0.2), +} + + +class MBConvBlock(nn.Module): + def __init__( + self, + spatial_dims: int, + in_channels: int, + out_channels: int, + kernel_size: int, + stride: int, + image_size: List[int], + expand_ratio: int, + se_ratio: Optional[float], + id_skip: Optional[bool] = True, + batch_norm_momentum: float = 0.99, + batch_norm_epsilon: float = 1e-3, + drop_connect_rate: Optional[float] = 0.2, + ) -> None: + """ + Mobile Inverted Residual Bottleneck Block. + + Args: + spatial_dims: number of spatial dimensions. + in_channels: number of input channels. + out_classes: number of output channels. + kernel_size: size of the kernel for conv ops. + stride: stride to use for conv ops. + image_size: input image resolution. + expand_ratio: expansion ratio for inverted bottleneck. + se_ratio: squeeze-excitation ratio for se layers. + id_skip: whether to use skip connection. + batch_norm_momentum: momentum for batch norm. + batch_norm_epsilon: epsilon for batch norm. + drop_connect_rate: dropconnect rate for drop connection (individual weights) layers. + + References: + [1] https://arxiv.org/abs/1704.04861 (MobileNet v1) + [2] https://arxiv.org/abs/1801.04381 (MobileNet v2) + [3] https://arxiv.org/abs/1905.02244 (MobileNet v3) + """ + super().__init__() + + # select the type of N-Dimensional layers to use + # these are based on spatial dims and selected from MONAI factories + conv_type = Conv["conv", spatial_dims] + batchnorm_type = Norm["batch", spatial_dims] + adaptivepool_type = Pool["adaptiveavg", spatial_dims] + + self.in_channels = in_channels + self.out_channels = out_channels + self.id_skip = id_skip + self.stride = stride + self.expand_ratio = expand_ratio + self.drop_connect_rate = drop_connect_rate + + if (se_ratio is not None) and (0.0 < se_ratio <= 1.0): + self.has_se = True + self.se_ratio = se_ratio + else: + self.has_se = False + + bn_mom = 1.0 - batch_norm_momentum # pytorch"s difference from tensorflow + bn_eps = batch_norm_epsilon + + # Expansion phase (Inverted Bottleneck) + inp = in_channels # number of input channels + oup = in_channels * expand_ratio # number of output channels + if self.expand_ratio != 1: + self._expand_conv = conv_type(in_channels=inp, out_channels=oup, kernel_size=1, bias=False) + self._expand_conv_padding = _make_same_padder(self._expand_conv, image_size) + + self._bn0 = batchnorm_type(num_features=oup, momentum=bn_mom, eps=bn_eps) + else: + # need to have the following to fix JIT error: + # "Module 'MBConvBlock' has no attribute '_expand_conv'" + + # FIXME: find a better way to bypass JIT error + self._expand_conv = nn.Identity() + self._expand_conv_padding = nn.Identity() + self._bn0 = nn.Identity() + + # Depthwise convolution phase + self._depthwise_conv = conv_type( + in_channels=oup, + out_channels=oup, + groups=oup, # groups makes it depthwise + kernel_size=kernel_size, + stride=self.stride, + bias=False, + ) + self._depthwise_conv_padding = _make_same_padder(self._depthwise_conv, image_size) + self._bn1 = batchnorm_type(num_features=oup, momentum=bn_mom, eps=bn_eps) + image_size = _calculate_output_image_size(image_size, self.stride) + + # Squeeze and Excitation layer, if desired + if self.has_se: + self._se_adaptpool = adaptivepool_type(1) + num_squeezed_channels = max(1, int(in_channels * self.se_ratio)) + self._se_reduce = conv_type(in_channels=oup, out_channels=num_squeezed_channels, kernel_size=1) + self._se_reduce_padding = _make_same_padder(self._se_reduce, [1, 1]) + self._se_expand = conv_type(in_channels=num_squeezed_channels, out_channels=oup, kernel_size=1) + self._se_expand_padding = _make_same_padder(self._se_expand, [1, 1]) + + # Pointwise convolution phase + final_oup = out_channels + self._project_conv = conv_type(in_channels=oup, out_channels=final_oup, kernel_size=1, bias=False) + self._project_conv_padding = _make_same_padder(self._project_conv, image_size) + self._bn2 = batchnorm_type(num_features=final_oup, momentum=bn_mom, eps=bn_eps) + + # swish activation to use - using memory efficient swish by default + # can be switched to normal swish using self.set_swish() function call + self._swish = Act["memswish"]() + + def forward(self, inputs: torch.Tensor): + """MBConvBlock"s forward function. + + Args: + inputs: Input tensor. + + Returns: + Output of this block after processing. + """ + # Expansion and Depthwise Convolution + x = inputs + if self.expand_ratio != 1: + x = self._expand_conv(self._expand_conv_padding(x)) + x = self._bn0(x) + x = self._swish(x) + + x = self._depthwise_conv(self._depthwise_conv_padding(x)) + x = self._bn1(x) + x = self._swish(x) + + # Squeeze and Excitation + if self.has_se: + x_squeezed = self._se_adaptpool(x) + x_squeezed = self._se_reduce(self._se_reduce_padding(x_squeezed)) + x_squeezed = self._swish(x_squeezed) + x_squeezed = self._se_expand(self._se_expand_padding(x_squeezed)) + x = torch.sigmoid(x_squeezed) * x + + # Pointwise Convolution + x = self._project_conv(self._project_conv_padding(x)) + x = self._bn2(x) + + # Skip connection and drop connect + if self.id_skip and self.stride == 1 and self.in_channels == self.out_channels: + # the combination of skip connection and drop connect brings about stochastic depth. + if self.drop_connect_rate: + x = drop_connect(x, p=self.drop_connect_rate, training=self.training) + x = x + inputs # skip connection + return x + + def set_swish(self, memory_efficient: bool = True) -> None: + """Sets swish function as memory efficient (for training) or standard (for export). + + Args: + memory_efficient (bool): Whether to use memory-efficient version of swish. + """ + self._swish = Act["memswish"]() if memory_efficient else Act["swish"](alpha=1.0) + + +class EfficientNet(nn.Module): + def __init__( + self, + blocks_args_str: List[str], + spatial_dims: int = 2, + in_channels: int = 3, + num_classes: int = 1000, + width_coefficient: float = 1.0, + depth_coefficient: float = 1.0, + dropout_rate: float = 0.2, + image_size: int = 224, + batch_norm_momentum: float = 0.99, + batch_norm_epsilon: float = 1e-3, + drop_connect_rate: float = 0.2, + depth_divisor: int = 8, + ) -> None: + """ + EfficientNet based on `Rethinking Model Scaling for Convolutional Neural Networks `_. + Adapted from `EfficientNet-PyTorch + `_. + + Args: + blocks_args_str: block definitions. + spatial_dims: number of spatial dimensions. + in_channels: number of input channels. + num_classes: number of output classes. + width_coefficient: width multiplier coefficient (w in paper). + depth_coefficient: depth multiplier coefficient (d in paper). + dropout_rate: dropout rate for dropout layers. + image_size: input image resolution. + batch_norm_momentum: momentum for batch norm. + batch_norm_epsilon: epsilon for batch norm. + drop_connect_rate: dropconnect rate for drop connection (individual weights) layers. + depth_divisor: depth divisor for channel rounding. + + Examples:: + + # for pretrained spatial 2D ImageNet + >>> image_size = get_efficientnet_image_size("efficientnet-b0") + >>> inputs = torch.rand(1, 3, image_size, image_size) + >>> model = EfficientNetBN("efficientnet-b0", pretrained=True) + >>> model.eval() + >>> outputs = model(inputs) + + # create spatial 2D + >>> model = EfficientNetBN("efficientnet-b0", spatial_dims=2) + + # create spatial 3D + >>> model = EfficientNetBN("efficientnet-b0", spatial_dims=3) + + # create EfficientNetB7 for spatial 2D + >>> model = EfficientNetBN("efficientnet-b7", spatial_dims=2) + + """ + super().__init__() + + if spatial_dims not in (1, 2, 3): + raise ValueError("spatial_dims can only be 1, 2 or 3.") + + # select the type of N-Dimensional layers to use + # these are based on spatial dims and selected from MONAI factories + conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv["conv", spatial_dims] + batchnorm_type: Type[Union[nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d]] = Norm["batch", spatial_dims] + adaptivepool_type: Type[Union[nn.AdaptiveAvgPool1d, nn.AdaptiveAvgPool2d, nn.AdaptiveAvgPool3d]] = Pool[ + "adaptiveavg", spatial_dims + ] + + # decode blocks args into arguments for MBConvBlock + blocks_args = _decode_block_list(blocks_args_str) + + # checks for successful decoding of blocks_args_str + if not isinstance(blocks_args, list): + raise ValueError("blocks_args must be a list") + + if blocks_args == []: + raise ValueError("block_args must be non-empty") + + self._blocks_args = blocks_args + self.num_classes = num_classes + self.in_channels = in_channels + self.drop_connect_rate = drop_connect_rate + + # expand input image dimensions to list + current_image_size = [image_size] * spatial_dims + + # parameters for batch norm + bn_mom = 1 - batch_norm_momentum # 1 - bn_m to convert tensorflow's arg to pytorch bn compatible + bn_eps = batch_norm_epsilon + + # Stem + stride = 2 + out_channels = _round_filters(32, width_coefficient, depth_divisor) # number of output channels + self._conv_stem = conv_type(self.in_channels, out_channels, kernel_size=3, stride=stride, bias=False) + self._conv_stem_padding = _make_same_padder(self._conv_stem, current_image_size) + self._bn0 = batchnorm_type(num_features=out_channels, momentum=bn_mom, eps=bn_eps) + current_image_size = _calculate_output_image_size(current_image_size, stride) + + # build MBConv blocks + num_blocks = 0 + self._blocks = nn.Sequential() + + # update baseline blocks to input/output filters and number of repeats based on width and depth multipliers. + for idx, block_args in enumerate(self._blocks_args): + block_args = block_args._replace( + input_filters=_round_filters(block_args.input_filters, width_coefficient, depth_divisor), + output_filters=_round_filters(block_args.output_filters, width_coefficient, depth_divisor), + num_repeat=_round_repeats(block_args.num_repeat, depth_coefficient), + ) + self._blocks_args[idx] = block_args + + # calculate the total number of blocks - needed for drop_connect estimation + num_blocks += block_args.num_repeat + + # create and add MBConvBlocks to self._blocks + idx = 0 # block index counter + for block_args in self._blocks_args: + blk_drop_connect_rate = self.drop_connect_rate + + # scale drop connect_rate + if blk_drop_connect_rate: + blk_drop_connect_rate *= float(idx) / num_blocks + + # the first block needs to take care of stride and filter size increase. + self._blocks.add_module( + str(idx), + MBConvBlock( + spatial_dims=spatial_dims, + in_channels=block_args.input_filters, + out_channels=block_args.output_filters, + kernel_size=block_args.kernel_size, + stride=block_args.stride, + image_size=current_image_size, + expand_ratio=block_args.expand_ratio, + se_ratio=block_args.se_ratio, + id_skip=block_args.id_skip, + batch_norm_momentum=batch_norm_momentum, + batch_norm_epsilon=batch_norm_epsilon, + drop_connect_rate=blk_drop_connect_rate, + ), + ) + idx += 1 # increment blocks index counter + + current_image_size = _calculate_output_image_size(current_image_size, block_args.stride) + if block_args.num_repeat > 1: # modify block_args to keep same output size + block_args = block_args._replace(input_filters=block_args.output_filters, stride=1) + + # add remaining block repeated num_repeat times + for _ in range(block_args.num_repeat - 1): + blk_drop_connect_rate = self.drop_connect_rate + + # scale drop connect_rate + if blk_drop_connect_rate: + blk_drop_connect_rate *= float(idx) / num_blocks + + # add blocks + self._blocks.add_module( + str(idx), + MBConvBlock( + spatial_dims=spatial_dims, + in_channels=block_args.input_filters, + out_channels=block_args.output_filters, + kernel_size=block_args.kernel_size, + stride=block_args.stride, + image_size=current_image_size, + expand_ratio=block_args.expand_ratio, + se_ratio=block_args.se_ratio, + id_skip=block_args.id_skip, + batch_norm_momentum=batch_norm_momentum, + batch_norm_epsilon=batch_norm_epsilon, + drop_connect_rate=blk_drop_connect_rate, + ), + ) + idx += 1 # increment blocks index counter + + # sanity check to see if len(self._blocks) equal expected num_blocks + if len(self._blocks) != num_blocks: + raise ValueError("number of blocks created != num_blocks") + + # Head + head_in_channels = block_args.output_filters + out_channels = _round_filters(1280, width_coefficient, depth_divisor) + self._conv_head = conv_type(head_in_channels, out_channels, kernel_size=1, bias=False) + self._conv_head_padding = _make_same_padder(self._conv_head, current_image_size) + self._bn1 = batchnorm_type(num_features=out_channels, momentum=bn_mom, eps=bn_eps) + + # final linear layer + self._avg_pooling = adaptivepool_type(1) + self._dropout = nn.Dropout(dropout_rate) + self._fc = nn.Linear(out_channels, self.num_classes) + + # swish activation to use - using memory efficient swish by default + # can be switched to normal swish using self.set_swish() function call + self._swish = Act["memswish"]() + + # initialize weights using Tensorflow's init method from official impl. + self._initialize_weights() + + def set_swish(self, memory_efficient: bool = True) -> None: + """ + Sets swish function as memory efficient (for training) or standard (for JIT export). + + Args: + memory_efficient: whether to use memory-efficient version of swish. + + """ + self._swish = Act["memswish"]() if memory_efficient else Act["swish"](alpha=1.0) + for block in self._blocks: + block.set_swish(memory_efficient) + + def forward(self, inputs: torch.Tensor): + """ + Args: + inputs: input should have spatially N dimensions + ``(Batch, in_channels, dim_0[, dim_1, ..., dim_N])``, N is defined by `dimensions`. + + Returns: + A torch Tensor of classification prediction in shape + ``(Batch, num_classes)``. + """ + # Stem + x = self._conv_stem(self._conv_stem_padding(inputs)) + x = self._swish(self._bn0(x)) + # Blocks + x = self._blocks(x) + # Head + x = self._conv_head(self._conv_head_padding(x)) + x = self._swish(self._bn1(x)) + + # Pooling and final linear layer + x = self._avg_pooling(x) + + x = x.flatten(start_dim=1) + x = self._dropout(x) + x = self._fc(x) + return x + + def _initialize_weights(self) -> None: + """ + Args: + None, initializes weights for conv/linear/batchnorm layers + following weight init methods from + `official Tensorflow EfficientNet implementation + `_. + Adapted from `EfficientNet-PyTorch's init method + `_. + """ + for _, m in self.named_modules(): + if isinstance(m, (nn.Conv1d, nn.Conv2d, nn.Conv3d)): + fan_out = reduce(operator.mul, m.kernel_size, 1) * m.out_channels + m.weight.data.normal_(0, math.sqrt(2.0 / fan_out)) + if m.bias is not None: + m.bias.data.zero_() + elif isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)): + m.weight.data.fill_(1.0) + m.bias.data.zero_() + elif isinstance(m, nn.Linear): + fan_out = m.weight.size(0) + fan_in = 0 + init_range = 1.0 / math.sqrt(fan_in + fan_out) + m.weight.data.uniform_(-init_range, init_range) + m.bias.data.zero_() + + +class EfficientNetBN(EfficientNet): + def __init__( + self, + model_name: str, + pretrained: bool = True, + progress: bool = True, + spatial_dims: int = 2, + in_channels: int = 3, + num_classes: int = 1000, + ) -> None: + """ + Generic wrapper around EfficientNet, used to initialize EfficientNet-B0 to EfficientNet-B7 models + model_name is mandatory argument as there is no EfficientNetBN itself, + it needs the N in [0, 1, 2, 3, 4, 5, 6, 7] to be a model + + Args: + model_name: name of model to initialize, can be from [efficientnet-b0, ..., efficientnet-b7]. + pretrained: whether to initialize pretrained ImageNet weights, only available for spatial_dims=2. + progress: whether to show download progress for pretrained weights download. + spatial_dims: number of spatial dimensions. + in_channels: number of input channels. + num_classes: number of output classes. + + """ + # block args for EfficientNet-B0 to EfficientNet-B7 + blocks_args_str = [ + "r1_k3_s11_e1_i32_o16_se0.25", + "r2_k3_s22_e6_i16_o24_se0.25", + "r2_k5_s22_e6_i24_o40_se0.25", + "r3_k3_s22_e6_i40_o80_se0.25", + "r3_k5_s11_e6_i80_o112_se0.25", + "r4_k5_s22_e6_i112_o192_se0.25", + "r1_k3_s11_e6_i192_o320_se0.25", + ] + + # check if model_name is valid model + if model_name not in efficientnet_params.keys(): + raise ValueError( + "invalid model_name {} found, must be one of {} ".format( + model_name, ", ".join(efficientnet_params.keys()) + ) + ) + + # get network parameters + weight_coeff, depth_coeff, image_size, drpout_rate, drpconnect_rate = efficientnet_params[model_name] + + # create model and initialize random weights + model = super(EfficientNetBN, self).__init__( + blocks_args_str=blocks_args_str, + spatial_dims=spatial_dims, + in_channels=in_channels, + num_classes=num_classes, + width_coefficient=weight_coeff, + depth_coefficient=depth_coeff, + dropout_rate=drpout_rate, + image_size=image_size, + drop_connect_rate=drpconnect_rate, + ) + + # attempt to load pretrained + is_default_model = (spatial_dims == 2) and (in_channels == 3) + loadable_from_file = pretrained and is_default_model + + if loadable_from_file: + # skip loading fc layers for transfer learning applications + load_fc = num_classes == 1000 + + # only pretrained for when `spatial_dims` is 2 + _load_state_dict(self, model_name, progress, load_fc) + else: + print( + "Skipping loading pretrained weights for non-default {}, pretrained={}, is_default_model={}".format( + model_name, pretrained, is_default_model + ) + ) + + +def get_efficientnet_image_size(model_name: str) -> int: + """ + Get the input image size for a given efficientnet model. + + Args: + model_name: name of model to initialize, can be from [efficientnet-b0, ..., efficientnet-b7]. + + Returns: + Image size for single spatial dimension as integer. + + """ + # check if model_name is valid model + if model_name not in efficientnet_params.keys(): + raise ValueError( + "invalid model_name {} found, must be one of {} ".format(model_name, ", ".join(efficientnet_params.keys())) + ) + + # return input image size (all dims equal so only need to return for one dim) + _, _, res, _, _ = efficientnet_params[model_name] + return res + + +def drop_connect(inputs: torch.Tensor, p: float, training: bool) -> torch.Tensor: + """ + Drop connect layer that drops individual connections. + Differs from dropout as dropconnect drops connections instead of whole neurons as in dropout. + + Based on `Deep Networks with Stochastic Depth `_. + Adapted from `Official Tensorflow EfficientNet utils + `_. + + This function is generalized for MONAI's N-Dimensional spatial activations + e.g. 1D activations [B, C, H], 2D activations [B, C, H, W] and 3D activations [B, C, H, W, D] + + Args: + input: input tensor with [B, C, dim_1, dim_2, ..., dim_N] where N=spatial_dims. + p: probability to use for dropping connections. + training: whether in training or evaluation mode. + + Returns: + output: output tensor after applying drop connection. + """ + if p < 0.0 or p > 1.0: + raise ValueError("p must be in range of [0, 1], found {}".format(p)) + + # eval mode: drop_connect is switched off - so return input without modifying + if not training: + return inputs + + # train mode: calculate and apply drop_connect + batch_size: int = inputs.shape[0] + keep_prob: float = 1 - p + num_dims: int = len(inputs.shape) - 2 + + # build dimensions for random tensor, use num_dims to populate appropriate spatial dims + random_tensor_shape: List[int] = [batch_size, 1] + [1] * num_dims + + # generate binary_tensor mask according to probability (p for 0, 1-p for 1) + random_tensor: torch.Tensor = torch.rand(random_tensor_shape, dtype=inputs.dtype, device=inputs.device) + random_tensor += keep_prob + + # round to form binary tensor + binary_tensor: torch.Tensor = torch.floor(random_tensor) + + # drop connect using binary tensor + output: torch.Tensor = inputs / keep_prob * binary_tensor + return output + + +def _load_state_dict(model: nn.Module, model_name: str, progress: bool, load_fc: bool) -> None: + url_map = { + "efficientnet-b0": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b0-355c32eb.pth", + "efficientnet-b1": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b1-f1951068.pth", + "efficientnet-b2": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b2-8bb594d6.pth", + "efficientnet-b3": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b3-5fb5a3c3.pth", + "efficientnet-b4": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b4-6ed6700e.pth", + "efficientnet-b5": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b5-b6417697.pth", + "efficientnet-b6": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b6-c76e70fd.pth", + "efficientnet-b7": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b7-dcc49843.pth", + } + # load state dict from url + model_url = url_map[model_name] + state_dict = model_zoo.load_url(model_url, progress=progress) + + # load state dict into model parameters + if load_fc: # load everything + ret = model.load_state_dict(state_dict, strict=False) + if ret.missing_keys: + raise ValueError("Found missing keys when loading pretrained weights: {}".format(ret.missing_keys)) + else: # skip final FC layers, for transfer learning cases + state_dict.pop("_fc.weight") + state_dict.pop("_fc.bias") + ret = model.load_state_dict(state_dict, strict=False) + + # check if no other keys missing except FC layer parameters + if set(ret.missing_keys) != {"_fc.weight", "_fc.bias"}: + raise ValueError("Found missing keys when loading pretrained weights: {}".format(ret.missing_keys)) + + # check for any unexpected keys + if ret.unexpected_keys: + raise ValueError("Missing keys when loading pretrained weights: {}".format(ret.unexpected_keys)) + + +def _get_same_padding_conv_nd( + image_size: List[int], kernel_size: Tuple[int, ...], dilation: Tuple[int, ...], stride: Tuple[int, ...] +) -> List[int]: + """ + Helper for getting padding (nn.ConstantPadNd) to be used to get SAME padding + conv operations similar to Tensorflow's SAME padding. + + This function is generalized for MONAI's N-Dimensional spatial operations (e.g. Conv1D, Conv2D, Conv3D) + + Args: + image_size: input image/feature spatial size. + kernel_size: conv kernel's spatial size. + dilation: conv dilation rate for Atrous conv. + stride: stride for conv operation. + + Returns: + paddings for ConstantPadNd padder to be used on input tensor to conv op. + """ + # get number of spatial dimensions, corresponds to kernel size length + num_dims = len(kernel_size) + + # additional checks to populate dilation and stride (in case they are single entry tuples) + if len(dilation) == 1: + dilation = dilation * num_dims + + if len(stride) == 1: + stride = stride * num_dims + + # equation to calculate (pad^+ + pad^-) size + _pad_size: List[int] = [ + max((math.ceil(_i_s / _s) - 1) * _s + (_k_s - 1) * _d + 1 - _i_s, 0) + for _i_s, _k_s, _d, _s in zip(image_size, kernel_size, dilation, stride) + ] + # distribute paddings into pad^+ and pad^- following Tensorflow's same padding strategy + _paddings: List[Tuple[int, int]] = [(_p // 2, _p - _p // 2) for _p in _pad_size] + + # unroll list of tuples to tuples, and then to list + # reversed as nn.ConstantPadNd expects paddings starting with last dimension + _paddings_ret: List[int] = [outer for inner in reversed(_paddings) for outer in inner] + return _paddings_ret + + +def _make_same_padder(conv_op: Union[nn.Conv1d, nn.Conv2d, nn.Conv3d], image_size: List[int]): + """ + Helper for initializing ConstantPadNd with SAME padding similar to Tensorflow. + Uses output of _get_same_padding_conv_nd() to get the padding size. + + This function is generalized for MONAI's N-Dimensional spatial operations (e.g. Conv1D, Conv2D, Conv3D) + + Args: + conv_op: nn.ConvNd operation to extract parameters for op from + image_size: input image/feature spatial size + + Returns: + If padding required then nn.ConstandNd() padder initialized to paddings otherwise nn.Identity() + """ + # calculate padding required + padding: List[int] = _get_same_padding_conv_nd(image_size, conv_op.kernel_size, conv_op.dilation, conv_op.stride) + + # initialize and return padder + padder = Pad["constantpad", len(padding) // 2] + if sum(padding) > 0: + return padder(padding=padding, value=0.0) + else: + return nn.Identity() + + +def _round_filters(filters: int, width_coefficient: Optional[float], depth_divisor: float) -> int: + """ + Calculate and round number of filters based on width coefficient multiplier and depth divisor. + + Args: + filters: number of input filters. + width_coefficient: width coefficient for model. + depth_divisor: depth divisor to use. + + Returns: + new_filters: new number of filters after calculation. + """ + + if not width_coefficient: + return filters + + multiplier: float = width_coefficient + divisor: float = depth_divisor + filters_float: float = filters * multiplier + + # follow the formula transferred from official TensorFlow implementation + new_filters: float = max(divisor, int(filters_float + divisor / 2) // divisor * divisor) + if new_filters < 0.9 * filters_float: # prevent rounding by more than 10% + new_filters += divisor + return int(new_filters) + + +def _round_repeats(repeats: int, depth_coefficient: Optional[float]) -> int: + """ + Re-calculate module's repeat number of a block based on depth coefficient multiplier. + + Args: + repeats: number of original repeats. + depth_coefficient: depth coefficient for model. + + Returns: + new repeat: new number of repeat after calculating. + """ + if not depth_coefficient: + return repeats + + # follow the formula transferred from official TensorFlow impl. + return int(math.ceil(depth_coefficient * repeats)) + + +def _calculate_output_image_size(input_image_size: List[int], stride: Union[int, Tuple[int]]): + """ + Calculates the output image size when using _make_same_padder with a stride. + Required for static padding. + + Args: + input_image_size: input image/feature spatial size. + stride: Conv2d operation"s stride. + + Returns: + output_image_size: output image/feature spatial size. + """ + # get number of spatial dimensions, corresponds to image spatial size length + num_dims = len(input_image_size) + + # checks to extract integer stride in case tuple was received + if isinstance(stride, tuple): + all_strides_equal = all([stride[0] == s for s in stride]) + if not all_strides_equal: + raise ValueError("unequal strides are not possible, got {}".format(stride)) + + stride = stride[0] + + # return output image size + return [int(math.ceil(im_sz / stride)) for im_sz in input_image_size] + + +def _decode_block_list(string_list: List[str]): + """ + Decode a list of string notations to specify blocks inside the network. + + Args: + string_list: a list of strings, each string is a notation of block. + + Returns: + blocks_args: a list of BlockArgs namedtuples of block args. + """ + # Parameters for an individual model block + # namedtuple with defaults for mypy help from: + # https://stackoverflow.com/a/53255358 + class BlockArgs(NamedTuple): + num_repeat: int + kernel_size: int + stride: int + expand_ratio: int + input_filters: int + output_filters: int + id_skip: bool + se_ratio: Optional[float] = None + + def _decode_block_string(block_string: str): + """ + Get a block through a string notation of arguments. + + Args: + block_string (str): A string notation of arguments. + Examples: "r1_k3_s11_e1_i32_o16_se0.25". + + Returns: + BlockArgs: namedtuple defined at the top of this function. + """ + ops = block_string.split("_") + options = {} + for op in ops: + splits = re.split(r"(\d.*)", op) + if len(splits) >= 2: + key, value = splits[:2] + options[key] = value + + # check stride + stride_check = ( + ("s" in options and len(options["s"]) == 1) + or (len(options["s"]) == 2 and options["s"][0] == options["s"][1]) + or (len(options["s"]) == 3 and options["s"][0] == options["s"][1] and options["s"][0] == options["s"][2]) + ) + if not stride_check: + raise ValueError("invalid stride option recieved") + + return BlockArgs( + num_repeat=int(options["r"]), + kernel_size=int(options["k"]), + stride=int(options["s"][0]), + expand_ratio=int(options["e"]), + input_filters=int(options["i"]), + output_filters=int(options["o"]), + id_skip=("noskip" not in block_string), + se_ratio=float(options["se"]) if "se" in options else None, + ) + + # convert block strings into BlockArgs for each entry in string_list list + blocks_args: List[BlockArgs] = [] + for current_string in string_list: + blocks_args.append(_decode_block_string(current_string)) + + # return blocks_args list, to be used for arguments of MBConv layers in EfficientNet + return blocks_args diff --git a/tests/min_tests.py b/tests/min_tests.py index 98f6d822a7..586956eec0 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -33,6 +33,7 @@ def run_testsuit(): "test_cachedataset_parallel", "test_dataset", "test_detect_envelope", + "test_efficientnet", "test_iterable_dataset", "test_ensemble_evaluator", "test_handler_checkpoint_loader", diff --git a/tests/test_activations.py b/tests/test_activations.py index 1614642d6d..5ed9ec2046 100644 --- a/tests/test_activations.py +++ b/tests/test_activations.py @@ -48,6 +48,15 @@ ] TEST_CASE_5 = [ + "memswish", + torch.tensor([[[[-10, -8, -6, -4, -2], [0, 2, 4, 6, 8]]]], dtype=torch.float32), + torch.tensor( + [[[[-4.54e-04, -2.68e-03, -1.48e-02, -7.19e-02, -2.38e-01], [0.00e00, 1.76e00, 3.93e00, 5.99e00, 8.00e00]]]] + ), + (1, 1, 2, 5), +] + +TEST_CASE_6 = [ "mish", torch.tensor([[[[-10, -8, -6, -4, -2], [0, 2, 4, 6, 8]]]], dtype=torch.float32), torch.tensor( @@ -64,7 +73,7 @@ def test_value_shape(self, input_param, img, out, expected_shape): torch.testing.assert_allclose(result, out) self.assertTupleEqual(result.shape, expected_shape) - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) def test_monai_activations_value_shape(self, input_param, img, out, expected_shape): act = Act[input_param]() result = act(img) diff --git a/tests/test_efficientnet.py b/tests/test_efficientnet.py new file mode 100644 index 0000000000..7ef56c52a9 --- /dev/null +++ b/tests/test_efficientnet.py @@ -0,0 +1,308 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from typing import TYPE_CHECKING +from unittest import skipUnless + +import torch +from parameterized import parameterized + +from monai.networks import eval_mode +from monai.networks.nets import EfficientNetBN, drop_connect, get_efficientnet_image_size +from monai.utils import optional_import +from tests.utils import skip_if_quick, test_pretrained_networks, test_script_save + +if TYPE_CHECKING: + import torchvision + + has_torchvision = True +else: + torchvision, has_torchvision = optional_import("torchvision") + +if TYPE_CHECKING: + import PIL + + has_pil = True +else: + PIL, has_pil = optional_import("PIL") + + +def get_model_names(): + return ["efficientnet-b{}".format(d) for d in range(8)] + + +def get_expected_model_shape(model_name): + model_input_shapes = { + "efficientnet-b0": 224, + "efficientnet-b1": 240, + "efficientnet-b2": 260, + "efficientnet-b3": 300, + "efficientnet-b4": 380, + "efficientnet-b5": 456, + "efficientnet-b6": 528, + "efficientnet-b7": 600, + } + return model_input_shapes[model_name] + + +def make_shape_cases(models, spatial_dims, batches, pretrained, in_channels=3, num_classes=1000): + ret_tests = [] + for spatial_dim in spatial_dims: # selected spatial_dims + for batch in batches: # check single batch as well as multiple batch input + for model in models: # selected models + for is_pretrained in pretrained: # pretrained or not pretrained + kwargs = { + "model_name": model, + "pretrained": is_pretrained, + "progress": False, + "spatial_dims": spatial_dim, + "in_channels": in_channels, + "num_classes": num_classes, + } + ret_tests.append( + [ + kwargs, + ( + batch, + in_channels, + ) + + (get_expected_model_shape(model),) * spatial_dim, + (batch, num_classes), + ] + ) + return ret_tests + + +# create list of selected models to speed up redundant tests +# only test the models B0, B3, B7 +SEL_MODELS = [get_model_names()[i] for i in [0, 3, 7]] + +# pretrained=False cases +# 1D models are cheap so do test for all models in 1D +CASES_1D = make_shape_cases( + models=get_model_names(), spatial_dims=[1], batches=[1, 4], pretrained=[False], in_channels=3, num_classes=1000 +) + +# 2D and 3D models are expensive so use selected models +CASES_2D = make_shape_cases( + models=SEL_MODELS, spatial_dims=[2], batches=[1, 4], pretrained=[False], in_channels=3, num_classes=1000 +) +CASES_3D = make_shape_cases( + models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=3, num_classes=1000 +) + +# pretrained=True cases +# tabby kitty test with pretrained model +# needs 'testing_data/kitty_test.jpg' +# image from: https://commons.wikimedia.org/wiki/File:Tabby_cat_with_blue_eyes-3336579.jpg +CASES_KITTY_TRAINED = [ + ( + { + "model_name": "efficientnet-b0", + "pretrained": True, + "progress": False, + "spatial_dims": 2, + "in_channels": 3, + "num_classes": 1000, + }, + os.path.join(os.path.dirname(__file__), "testing_data", "kitty_test.jpg"), + 282, # ~ tiger cat + ), + ( + { + "model_name": "efficientnet-b3", + "pretrained": True, + "progress": False, + "spatial_dims": 2, + "in_channels": 3, + "num_classes": 1000, + }, + os.path.join(os.path.dirname(__file__), "testing_data", "kitty_test.jpg"), + 282, # ~ tiger cat + ), + ( + { + "model_name": "efficientnet-b7", + "pretrained": True, + "progress": False, + "spatial_dims": 2, + "in_channels": 3, + "num_classes": 1000, + }, + os.path.join(os.path.dirname(__file__), "testing_data", "kitty_test.jpg"), + 282, # ~ tiger cat + ), +] + +# varying num_classes and in_channels +CASES_VARIATIONS = [] + +# change num_classes test +# 10 classes +# 2D +CASES_VARIATIONS.extend( + make_shape_cases( + models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=3, num_classes=10 + ) +) +# 3D +CASES_VARIATIONS.extend( + make_shape_cases( + models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=3, num_classes=10 + ) +) + +# change in_channels test +# 1 channel +# 2D +CASES_VARIATIONS.extend( + make_shape_cases( + models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=1, num_classes=1000 + ) +) +# 8 channel +# 2D +CASES_VARIATIONS.extend( + make_shape_cases( + models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=8, num_classes=1000 + ) +) +# 3D +CASES_VARIATIONS.extend( + make_shape_cases( + models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=1, num_classes=1000 + ) +) + + +class TestEFFICIENTNET(unittest.TestCase): + @parameterized.expand(CASES_1D + CASES_2D + CASES_3D + CASES_VARIATIONS) + def test_shape(self, input_param, input_shape, expected_shape): + device = "cuda" if torch.cuda.is_available() else "cpu" + print(input_param) + + # initialize model + net = EfficientNetBN(**input_param).to(device) + + # run inference with random tensor + with eval_mode(net): + result = net(torch.randn(input_shape).to(device)) + + # check output shape + self.assertEqual(result.shape, expected_shape) + + @parameterized.expand(CASES_1D + CASES_2D) + def test_non_default_shapes(self, input_param, input_shape, expected_shape): + device = "cuda" if torch.cuda.is_available() else "cpu" + print(input_param) + + # initialize model + net = EfficientNetBN(**input_param).to(device) + + # override input shape with different variations + num_dims = len(input_shape) - 2 + non_default_sizes = [128, 256, 512] + for candidate_size in non_default_sizes: + input_shape = input_shape[0:2] + (candidate_size,) * num_dims + print(input_shape) + # run inference with random tensor + with eval_mode(net): + result = net(torch.randn(input_shape).to(device)) + + # check output shape + self.assertEqual(result.shape, expected_shape) + + @parameterized.expand(CASES_KITTY_TRAINED) + @skip_if_quick + @skipUnless(has_torchvision, "Requires `torchvision` package.") + @skipUnless(has_pil, "Requires `pillow` package.") + def test_kitty_pretrained(self, input_param, image_path, expected_label): + device = "cuda" if torch.cuda.is_available() else "cpu" + + # open image + image_size = get_efficientnet_image_size(input_param["model_name"]) + img = PIL.Image.open(image_path) + + # defin ImageNet transform + tfms = torchvision.transforms.Compose( + [ + torchvision.transforms.Resize(image_size), + torchvision.transforms.CenterCrop(image_size), + torchvision.transforms.ToTensor(), + torchvision.transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ] + ) + + # preprocess and prepare image tensor + img = tfms(img).unsqueeze(0).to(device) + + # initialize a pretrained model + net = test_pretrained_networks(EfficientNetBN, input_param, device) + + # run inference + with eval_mode(net): + result = net(img) + pred_label = torch.argmax(result, dim=-1) + + # check output + self.assertEqual(pred_label, expected_label) + + def test_drop_connect_layer(self): + p_list = [float(d + 1) / 10.0 for d in range(9)] + # testing 1D, 2D and 3D shape + for rand_tensor_shape in [(512, 16, 4), (384, 16, 4, 4), (256, 16, 4, 4, 4)]: + + # test validation mode, out tensor == in tensor + training = False + for p in p_list: + in_tensor = torch.rand(rand_tensor_shape) + 0.1 + out_tensor = drop_connect(in_tensor, p, training=training) + self.assertTrue(torch.equal(out_tensor, in_tensor)) + + # test training mode, sum((out tensor * (1.0 - p)) != in tensor)/out_tensor.size() == p + # use tolerance of 0.175 to account for rounding errors due to finite set in/out + tol = 0.175 + training = True + for p in p_list: + in_tensor = torch.rand(rand_tensor_shape) + 0.1 + out_tensor = drop_connect(in_tensor, p, training=training) + + p_calculated = 1.0 - torch.sum(torch.isclose(in_tensor, out_tensor * (1.0 - p))) / float( + in_tensor.numel() + ) + p_calculated = p_calculated.cpu().numpy() + + self.assertTrue(abs(p_calculated - p) < tol) + + def test_ill_arg(self): + with self.assertRaises(ValueError): + # wrong spatial_dims + EfficientNetBN(model_name="efficientnet-b0", spatial_dims=4) + # wrong model_name + EfficientNetBN(model_name="efficientnet-b10", spatial_dims=3) + + def test_func_get_efficientnet_input_shape(self): + for model in get_model_names(): + result_shape = get_efficientnet_image_size(model_name=model) + expected_shape = get_expected_model_shape(model) + self.assertEqual(result_shape, expected_shape) + + def test_script(self): + net = EfficientNetBN(model_name="efficientnet-b0", spatial_dims=2, in_channels=3, num_classes=1000) + net.set_swish(memory_efficient=False) # at the moment custom memory efficient swish is not exportable with jit + test_data = torch.randn(1, 3, 224, 224) + test_script_save(net, test_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/testing_data/kitty_test.jpg b/tests/testing_data/kitty_test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f103760de515b22696bdd5a27e6a7b59d244eec8 GIT binary patch literal 61168 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 Date: Fri, 9 Apr 2021 17:59:57 +0800 Subject: [PATCH 42/55] 1866 Add TransformInverter handler (#1970) * [DLMED] add TransformInverter handler Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] add support in SegmentationSaver handler Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix CI test Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot Signed-off-by: Neha Srivathsa --- docs/source/handlers.rst | 5 ++ monai/data/inverse_batch_transform.py | 10 +-- monai/data/utils.py | 8 ++ monai/handlers/__init__.py | 1 + monai/handlers/segmentation_saver.py | 11 ++- monai/handlers/transform_inverter.py | 94 ++++++++++++++++++++++++ monai/transforms/utility/dictionary.py | 17 ++++- tests/min_tests.py | 1 + tests/test_handler_transform_inverter.py | 81 ++++++++++++++++++++ tests/test_inverse_collation.py | 3 +- 10 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 monai/handlers/transform_inverter.py create mode 100644 tests/test_handler_transform_inverter.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index 9030fa3ced..7c8498e37a 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -125,3 +125,8 @@ GarbageCollector handler ------------------------ .. autoclass:: GarbageCollector :members: + +Transform inverter +------------------ +.. autoclass:: TransformInverter + :members: diff --git a/monai/data/inverse_batch_transform.py b/monai/data/inverse_batch_transform.py index fa88114c84..edfaee3758 100644 --- a/monai/data/inverse_batch_transform.py +++ b/monai/data/inverse_batch_transform.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import warnings from typing import Any, Callable, Dict, Hashable, Optional, Sequence import numpy as np @@ -16,7 +17,7 @@ from monai.data.dataloader import DataLoader from monai.data.dataset import Dataset -from monai.data.utils import decollate_batch, pad_list_data_collate +from monai.data.utils import decollate_batch, no_collation, pad_list_data_collate from monai.transforms.croppad.batch import PadListDataCollate from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import Transform @@ -42,13 +43,12 @@ def _transform(self, index: int) -> Dict[Hashable, np.ndarray]: if self.pad_collation_used: data = PadListDataCollate.inverse(data) + if not isinstance(self.invertible_transform, InvertibleTransform): + warnings.warn("transform is not invertible, can't invert transform for the input data.") + return data return self.invertible_transform.inverse(data) -def no_collation(x): - return x - - class BatchInverseTransform(Transform): """Perform inverse on a batch of data. This is useful if you have inferred a batch of images and want to invert them all.""" diff --git a/monai/data/utils.py b/monai/data/utils.py index 938365460b..d39f2702ff 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -65,6 +65,7 @@ "sorted_dict", "decollate_batch", "pad_list_data_collate", + "no_collation", ] @@ -379,6 +380,13 @@ def pad_list_data_collate( return PadListDataCollate(method, mode)(batch) +def no_collation(x): + """ + No any collation operation. + """ + return x + + def worker_init_fn(worker_id: int) -> None: """ Callback function for PyTorch DataLoader `worker_init_fn`. diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index f88531ea8e..b0dbb82127 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -28,6 +28,7 @@ from .stats_handler import StatsHandler from .surface_distance import SurfaceDistance from .tensorboard_handlers import TensorBoardHandler, TensorBoardImageHandler, TensorBoardStatsHandler +from .transform_inverter import TransformInverter from .utils import ( evenly_divisible_all_gather, stopping_fn_from_loss, diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index 6a98abf3ca..279b514bd7 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -119,7 +119,6 @@ def __init__( output_dtype=output_dtype, squeeze_end_dims=squeeze_end_dims, data_root_dir=data_root_dir, - save_batch=True, ) self.batch_transform = batch_transform self.output_transform = output_transform @@ -147,5 +146,13 @@ def __call__(self, engine: Engine) -> None: """ meta_data = self.batch_transform(engine.state.batch) engine_output = self.output_transform(engine.state.output) - self._saver(engine_output, meta_data) + if isinstance(engine_output, (tuple, list)): + # if a list of data in shape: [channel, H, W, [D]], save every item separately + self._saver.save_batch = False + for i, d in enumerate(engine_output): + self._saver(d, {k: meta_data[k][i] for k in meta_data} if meta_data is not None else None) + else: + # if the data is in shape: [batch, channel, H, W, [D]] + self._saver.save_batch = True + self._saver(engine_output, meta_data) self.logger.info("saved all the model outputs into files.") diff --git a/monai/handlers/transform_inverter.py b/monai/handlers/transform_inverter.py new file mode 100644 index 0000000000..42c5bdcf92 --- /dev/null +++ b/monai/handlers/transform_inverter.py @@ -0,0 +1,94 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import warnings +from typing import TYPE_CHECKING, Callable, Optional + +from torch.utils.data import DataLoader as TorchDataLoader + +from monai.data import BatchInverseTransform +from monai.data.utils import no_collation +from monai.engines.utils import CommonKeys +from monai.transforms import InvertibleTransform, allow_missing_keys_mode +from monai.utils import InverseKeys, exact_version, optional_import + +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") +if TYPE_CHECKING: + from ignite.engine import Engine +else: + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + + +class TransformInverter: + """ + Ignite handler to automatically invert all the pre-transforms that support `inverse`. + It takes `engine.state.output` as the input data and uses the transforms infomation from `engine.state.batch`. + + Note: + This handler is experimental API in v0.5, the interpolation mode in the transforms + and inverse transforms are the same, so maybe it's not correct as we may want to use `bilinear` + for input image but use `nearest` when inverting transforms for model outout. + For this case, a solution is to set `batch_key` to the label field if we have labels. + + """ + + def __init__( + self, + transform: InvertibleTransform, + loader: TorchDataLoader, + collate_fn: Optional[Callable] = no_collation, + batch_key: str = CommonKeys.IMAGE, + output_key: str = CommonKeys.PRED, + postfix: str = "inverted", + ) -> None: + """ + Args: + transform: a callable data transform on input data. + loader: data loader used to generate the batch of data. + collate_fn: how to collate data after inverse transformations. + default won't do any collation, so the output will be a list of size batch size. + batch_key: the key of input data in `ignite.engine.batch`. will get the applied transforms + for this input data, then invert them for the model output, default to "image". + output_key: the key of model output in `ignite.engine.output`, invert transforms on it. + postfix: will save the inverted result into `ignite.engine.output` with key `{ouput_key}_{postfix}`. + + """ + self.transform = transform + self.inverter = BatchInverseTransform(transform=transform, loader=loader, collate_fn=collate_fn) + self.batch_key = batch_key + self.output_key = output_key + self.postfix = postfix + + def attach(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + engine.add_event_handler(Events.ITERATION_COMPLETED, self) + + def __call__(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + transform_key = self.batch_key + InverseKeys.KEY_SUFFIX + if transform_key not in engine.state.batch: + warnings.warn("all the pre-transforms are not InvertibleTransform or no need to invert.") + return + + segs_dict = { + self.batch_key: engine.state.output[self.output_key].detach().cpu(), + transform_key: engine.state.batch[transform_key], + } + + with allow_missing_keys_mode(self.transform): # type: ignore + inverted_key = f"{self.output_key}_{self.postfix}" + engine.state.output[inverted_key] = [i[self.batch_key] for i in self.inverter(segs_dict)] diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 9464faa503..7c4ea398f6 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -17,12 +17,14 @@ import copy import logging +from copy import deepcopy from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor +from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import MapTransform, Randomizable from monai.transforms.utility.array import ( AddChannel, @@ -379,7 +381,7 @@ def __call__( return d -class ToTensord(MapTransform): +class ToTensord(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.ToTensor`. """ @@ -397,9 +399,22 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: d = dict(data) for key in self.key_iterator(d): + self.push_transform(d, key) d[key] = self.converter(d[key]) return d + def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + inverse_transform = ToNumpy() + # Apply inverse + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d + class ToNumpyd(MapTransform): """ diff --git a/tests/min_tests.py b/tests/min_tests.py index 586956eec0..47892a143e 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -117,6 +117,7 @@ def run_testsuit(): "test_ensure_channel_first", "test_ensure_channel_firstd", "test_handler_early_stop", + "test_handler_transform_inverter", ] assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}" diff --git a/tests/test_handler_transform_inverter.py b/tests/test_handler_transform_inverter.py new file mode 100644 index 0000000000..48efd5df53 --- /dev/null +++ b/tests/test_handler_transform_inverter.py @@ -0,0 +1,81 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import unittest + +import numpy as np +import torch +from ignite.engine import Engine + +from monai.data import CacheDataset, DataLoader, create_test_image_3d +from monai.handlers import TransformInverter +from monai.transforms import ( + AddChanneld, + Compose, + LoadImaged, + RandAffined, + RandAxisFlipd, + RandFlipd, + RandRotate90d, + RandRotated, + RandZoomd, + ResizeWithPadOrCropd, + ToTensord, +) +from tests.utils import make_nifti_image + +KEYS = ["image", "label"] + + +class TestTransformInverter(unittest.TestCase): + def test_invert(self): + im_fname, seg_fname = [make_nifti_image(i) for i in create_test_image_3d(101, 100, 107)] + transform = Compose( + [ + LoadImaged(KEYS), + AddChanneld(KEYS), + RandFlipd(KEYS, prob=0.5, spatial_axis=[1, 2]), + RandAxisFlipd(KEYS, prob=0.5), + RandRotate90d(KEYS, spatial_axes=(1, 2)), + RandZoomd(KEYS, prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True), + RandRotated(KEYS, prob=0.5, range_x=np.pi), + RandAffined(KEYS, prob=0.5, rotate_range=np.pi), + ResizeWithPadOrCropd(KEYS, 100), + ToTensord(KEYS), + ] + ) + data = [{"image": im_fname, "label": seg_fname} for _ in range(12)] + + # num workers = 0 for mac or gpu transforms + num_workers = 0 if sys.platform == "darwin" or torch.cuda.is_available() else 2 + + dataset = CacheDataset(data, transform=transform, progress=False) + loader = DataLoader(dataset, num_workers=num_workers, batch_size=5) + + # set up engine + def _train_func(engine, batch): + self.assertTupleEqual(batch["image"].shape[1:], (1, 100, 100, 100)) + return batch + + engine = Engine(_train_func) + + # set up testing handler + TransformInverter(transform=transform, loader=loader, output_key="image").attach(engine) + + engine.run(loader, max_epochs=1) + self.assertTupleEqual(engine.state.output["image"].shape, (2, 1, 100, 100, 100)) + for i in engine.state.output["image_inverted"]: + self.assertTupleEqual(i.shape, (1, 100, 101, 107)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_inverse_collation.py b/tests/test_inverse_collation.py index 3e07a8f0e2..c302e04017 100644 --- a/tests/test_inverse_collation.py +++ b/tests/test_inverse_collation.py @@ -29,6 +29,7 @@ RandRotated, RandZoomd, ResizeWithPadOrCropd, + ToTensord, ) from monai.utils import optional_import, set_determinism from tests.utils import make_nifti_image @@ -113,7 +114,7 @@ def test_collation(self, _, transform, collate_fn, ndim): if collate_fn: modified_transform = transform else: - modified_transform = Compose([transform, ResizeWithPadOrCropd(KEYS, 100)]) + modified_transform = Compose([transform, ResizeWithPadOrCropd(KEYS, 100), ToTensord(KEYS)]) # num workers = 0 for mac or gpu transforms num_workers = 0 if sys.platform == "darwin" or torch.cuda.is_available() else 2 From 66c78ab95133d95f260a9a9e5ea686303bb757c0 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 9 Apr 2021 14:23:29 +0100 Subject: [PATCH 43/55] 1889 - changelog for v0.5.0 (#1923) * changelog for 0.5.0 Signed-off-by: Wenqi Li * update changelog Signed-off-by: Wenqi Li Signed-off-by: Neha Srivathsa --- CHANGELOG.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56e65a7d92..7e1c785aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,87 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0] - 2020-04-09 +### Added +* Overview document for [feature highlights in v0.5.0](https://github.com/Project-MONAI/MONAI/blob/master/docs/source/highlights.md) +* Invertible spatial transforms + * `InvertibleTransform` base APIs + * Batch inverse and decollating APIs + * Inverse of `Compose` + * Batch inverse event handling + * Test-time augmentation as an application +* Initial support of learning-based image registration: + * Bending energy, LNCC, and global mutual information loss + * Fully convolutional architectures + * Dense displacement field, dense velocity field computation + * Warping with high-order interpolation with C++/CUDA implementations +* Deepgrow modules for interactive segmentation: + * Workflows with simulations of clicks + * Distance-based transforms for guidance signals +* Digital pathology support: + * Efficient whole slide imaging IO and sampling with Nvidia cuCIM and SmartCache + * FROC measurements for lesion + * Probabilistic post-processing for lesion detection + * TorchVision classification model adaptor for fully convolutional analysis +* 12 new transforms, grid patch dataset, `ThreadDataLoader`, EfficientNets B0-B7 +* 4 iteration events for the engine for finer control of workflows +* New C++/CUDA extensions: + * Conditional random field + * Fast bilateral filtering using the permutohedral lattice +* Metrics summary reporting and saving APIs +* DiceCELoss, DiceFocalLoss, a multi-scale wrapper for segmentation loss computation +* Data loading utilities: + * `decollate_batch` + * `PadListDataCollate` with inverse support +* Support of slicing syntax for `Dataset` +* Initial Torchscript support for the loss modules +* Learning rate finder +* Allow for missing keys in the dictionary-based transforms +* Support of checkpoint loading for transfer learning +* Various summary and plotting utilities for Jupyter notebooks +* Contributor Covenant Code of Conduct +* Major CI/CD enhancements covering the tutorial repository +* Fully compatible with PyTorch 1.8 +* Initial nightly CI/CD pipelines using Nvidia Blossom Infrastructure + +### Changed +* Enhanced `list_data_collate` error handling +* Unified iteration metric APIs +* `densenet*` extensions are renamed to `DenseNet*` +* `se_res*` network extensions are renamed to `SERes*` +* Transform base APIs are rearranged into `compose`, `inverse`, and `transform` +* `_do_transform` flag for the random augmentations is unified via `RandomizableTransform` +* Decoupled post-processing steps, e.g. `softmax`, `to_onehot_y`, from the metrics computations +* Moved the distributed samplers to `monai.data.samplers` from `monai.data.utils` +* Engine's data loaders now accept generic iterables as input +* Workflows now accept additional custom events and state properties +* Various type hints according to Numpy 1.20 +* Refactored testing utility `runtests.sh` to have `--unittest` and `--net` (integration tests) options +* Base Docker image upgraded to `nvcr.io/nvidia/pytorch:21.03-py3` from `nvcr.io/nvidia/pytorch:20.10-py3` +* Docker images are now built with self-hosted environments +* Primary contact email updated to `monai.contact@gmail.com` +* Now using GitHub Discussions as the primary communication forum + +### Removed +* Compatibility tests for PyTorch 1.5.x +* Format specific loaders, e.g. `LoadNifti`, `NiftiDataset` +* Assert statements from non-test files +* `from module import *` statements, addressed flake8 F403 + +### Fixed +* Uses American English spelling for code, as per PyTorch +* Code coverage now takes multiprocessing runs into account +* SmartCache with initial shuffling +* `ConvertToMultiChannelBasedOnBratsClasses` now supports channel-first inputs +* Checkpoint handler to save with non-root permissions +* Fixed an issue for exiting the distributed unit tests +* Unified `DynUNet` to have single tensor output w/o deep supervision +* `SegmentationSaver` now supports user-specified data types and a `squeeze_end_dims` flag +* Fixed `*Saver` event handlers output filenames with a `data_root_dir` option +* Load image functions now ensure little-endian +* Fixed the test runner to support regex-based test case matching +* Usability issues in the event handlers + ## [0.4.0] - 2020-12-15 ### Added * Overview document for [feature highlights in v0.4.0](https://github.com/Project-MONAI/MONAI/blob/master/docs/source/highlights.md) @@ -173,7 +254,8 @@ the postprocessing steps should be used before calling the metrics methods [highlights]: https://github.com/Project-MONAI/MONAI/blob/master/docs/source/highlights.md -[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/0.4.0...HEAD +[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/0.5.0...HEAD +[0.5.0]: https://github.com/Project-MONAI/MONAI/compare/0.4.0...0.5.0 [0.4.0]: https://github.com/Project-MONAI/MONAI/compare/0.3.0...0.4.0 [0.3.0]: https://github.com/Project-MONAI/MONAI/compare/0.2.0...0.3.0 [0.2.0]: https://github.com/Project-MONAI/MONAI/compare/0.1.0...0.2.0 From 4487a5330b88f09a0a54c02ec82b81ae0f5c102d Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 10 Apr 2021 03:36:49 +0800 Subject: [PATCH 44/55] 1977 Support different inverse interpolation mode (#1978) * [DLMED] add TransformInverter handler Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] add support in SegmentationSaver handler Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix CI test Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] save mode into inverse dict Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] fix ToTensor inverse issue Signed-off-by: Nic Ma * [DLMED] change the replacement logic into util function Signed-off-by: Nic Ma * [DLMED] add more tests Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot Signed-off-by: Neha Srivathsa --- monai/handlers/transform_inverter.py | 18 +-- monai/transforms/__init__.py | 1 + monai/transforms/croppad/dictionary.py | 26 ++-- monai/transforms/inverse.py | 2 +- monai/transforms/spatial/dictionary.py | 168 +++++++++++++++++------ monai/transforms/utility/dictionary.py | 1 - monai/transforms/utils.py | 46 ++++++- tests/test_handler_transform_inverter.py | 14 +- tests/test_inverse.py | 5 +- 9 files changed, 214 insertions(+), 67 deletions(-) diff --git a/monai/handlers/transform_inverter.py b/monai/handlers/transform_inverter.py index 42c5bdcf92..68201e44be 100644 --- a/monai/handlers/transform_inverter.py +++ b/monai/handlers/transform_inverter.py @@ -17,7 +17,7 @@ from monai.data import BatchInverseTransform from monai.data.utils import no_collation from monai.engines.utils import CommonKeys -from monai.transforms import InvertibleTransform, allow_missing_keys_mode +from monai.transforms import InvertibleTransform, allow_missing_keys_mode, convert_inverse_interp_mode from monai.utils import InverseKeys, exact_version, optional_import Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") @@ -32,12 +32,6 @@ class TransformInverter: Ignite handler to automatically invert all the pre-transforms that support `inverse`. It takes `engine.state.output` as the input data and uses the transforms infomation from `engine.state.batch`. - Note: - This handler is experimental API in v0.5, the interpolation mode in the transforms - and inverse transforms are the same, so maybe it's not correct as we may want to use `bilinear` - for input image but use `nearest` when inverting transforms for model outout. - For this case, a solution is to set `batch_key` to the label field if we have labels. - """ def __init__( @@ -48,6 +42,7 @@ def __init__( batch_key: str = CommonKeys.IMAGE, output_key: str = CommonKeys.PRED, postfix: str = "inverted", + nearest_interp: bool = True, ) -> None: """ Args: @@ -59,6 +54,8 @@ def __init__( for this input data, then invert them for the model output, default to "image". output_key: the key of model output in `ignite.engine.output`, invert transforms on it. postfix: will save the inverted result into `ignite.engine.output` with key `{ouput_key}_{postfix}`. + nearest_interp: whether to use `nearest` interpolation mode when inverting spatial transforms, + default to `True`. if `False`, use the same interpolation mode as the original transform. """ self.transform = transform @@ -66,6 +63,7 @@ def __init__( self.batch_key = batch_key self.output_key = output_key self.postfix = postfix + self.nearest_interp = nearest_interp def attach(self, engine: Engine) -> None: """ @@ -84,9 +82,13 @@ def __call__(self, engine: Engine) -> None: warnings.warn("all the pre-transforms are not InvertibleTransform or no need to invert.") return + transform_info = engine.state.batch[transform_key] + if self.nearest_interp: + convert_inverse_interp_mode(trans_info=transform_info, mode="nearest", align_corners=None) + segs_dict = { self.batch_key: engine.state.output[self.output_key].detach().cpu(), - transform_key: engine.state.batch[transform_key], + transform_key: transform_info, } with allow_missing_keys_mode(self.transform): # type: ignore diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index b66567e71a..f96194c262 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -371,6 +371,7 @@ ) from .utils import ( allow_missing_keys_mode, + convert_inverse_interp_mode, copypaste_arrays, create_control_grid, create_grid, diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index c8d5ceea40..c4ef659c69 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -16,6 +16,7 @@ """ from copy import deepcopy +from enum import Enum from itertools import chain from math import floor from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union @@ -125,7 +126,7 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key) + self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) d[key] = self.padder(d[key], mode=m) return d @@ -193,7 +194,7 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key) + self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) d[key] = self.padder(d[key], mode=m) return d @@ -259,7 +260,7 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, m in self.key_iterator(d, self.mode): - self.push_transform(d, key) + self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) d[key] = self.padder(d[key], mode=m) return d @@ -826,6 +827,7 @@ class ResizeWithPadOrCropd(MapTransform, InvertibleTransform): ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + It also can be a sequence of string, each element corresponds to a key in ``keys``. allow_missing_keys: don't raise exception if key is missing. """ @@ -834,18 +836,26 @@ def __init__( self, keys: KeysCollection, spatial_size: Union[Sequence[int], int], - mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, + mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) - self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, mode=mode) + self.mode = ensure_tuple_rep(mode, len(self.keys)) + self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.key_iterator(d): + for key, m in self.key_iterator(d, self.mode): orig_size = d[key].shape[1:] - d[key] = self.padcropper(d[key]) - self.push_transform(d, key, orig_size=orig_size) + d[key] = self.padcropper(d[key], mode=m) + self.push_transform( + d, + key, + orig_size=orig_size, + extra_info={ + "mode": m.value if isinstance(m, Enum) else m, + }, + ) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index 3e5b68e8e4..3baef91717 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -76,7 +76,7 @@ def push_transform( info = { InverseKeys.CLASS_NAME: self.__class__.__name__, InverseKeys.ID: id(self), - InverseKeys.ORIG_SIZE: orig_size or data[key].shape[1:], + InverseKeys.ORIG_SIZE: orig_size or (data[key].shape[1:] if hasattr(data[key], "shape") else None), } if extra_info is not None: info[InverseKeys.EXTRA_INFO] = extra_info diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 86c94302a1..9f782bf8fc 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -16,6 +16,7 @@ """ from copy import deepcopy +from enum import Enum from typing import Any, Dict, Hashable, Mapping, Optional, Sequence, Tuple, Union import numpy as np @@ -208,16 +209,24 @@ def __call__( align_corners=align_corners, dtype=dtype, ) - self.push_transform(d, key, extra_info={"meta_data_key": meta_data_key, "old_affine": old_affine}) + self.push_transform( + d, + key, + extra_info={ + "meta_data_key": meta_data_key, + "old_affine": old_affine, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else "none", + }, + ) # set the 'affine' key meta_data["affine"] = new_affine return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode, align_corners, dtype in self.key_iterator( - d, self.mode, self.padding_mode, self.align_corners, self.dtype - ): + for key, dtype in self.key_iterator(d, self.dtype): transform = self.get_most_recent_transform(d, key) if self.spacing_transform.diagonal: raise RuntimeError( @@ -227,6 +236,9 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar # Create inverse transform meta_data = d[transform[InverseKeys.EXTRA_INFO]["meta_data_key"]] old_affine = np.array(transform[InverseKeys.EXTRA_INFO]["old_affine"]) + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] orig_pixdim = np.sqrt(np.sum(np.square(old_affine), 0))[:-1] inverse_transform = Spacing(orig_pixdim, diagonal=self.spacing_transform.diagonal) # Apply inverse @@ -235,7 +247,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar affine=meta_data["affine"], mode=mode, padding_mode=padding_mode, - align_corners=align_corners, + align_corners=False if align_corners == "none" else align_corners, dtype=dtype, ) meta_data["affine"] = new_affine @@ -483,17 +495,26 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): - self.push_transform(d, key) + self.push_transform( + d, + key, + extra_info={ + "mode": mode.value if isinstance(mode, Enum) else mode, + "align_corners": align_corners if align_corners is not None else "none", + }, + ) d[key] = self.resizer(d[key], mode=mode, align_corners=align_corners) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): + for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) orig_size = transform[InverseKeys.ORIG_SIZE] + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] # Create inverse transform - inverse_transform = Resize(orig_size, mode, align_corners) + inverse_transform = Resize(orig_size, mode, None if align_corners == "none" else align_corners) # Apply inverse transform d[key] = inverse_transform(d[key]) # Remove the applied transform @@ -573,17 +594,28 @@ def __call__( for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): orig_size = d[key].shape[1:] d[key], affine = self.affine(d[key], mode=mode, padding_mode=padding_mode) - self.push_transform(d, key, orig_size=orig_size, extra_info={"affine": affine}) + self.push_transform( + d, + key, + orig_size=orig_size, + extra_info={ + "affine": affine, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + }, + ) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) orig_size = transform[InverseKeys.ORIG_SIZE] # Create inverse transform fwd_affine = transform[InverseKeys.EXTRA_INFO]["affine"] + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] inv_affine = np.linalg.inv(fwd_affine) affine_grid = AffineGrid(affine=inv_affine) @@ -701,18 +733,28 @@ def __call__( affine = torch.as_tensor(np.eye(len(sp_size) + 1), device=self.rand_affine.rand_affine_grid.device) for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): - self.push_transform(d, key, extra_info={"affine": affine}) + self.push_transform( + d, + key, + extra_info={ + "affine": affine, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + }, + ) d[key] = self.rand_affine.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) orig_size = transform[InverseKeys.ORIG_SIZE] # Create inverse transform fwd_affine = transform[InverseKeys.EXTRA_INFO]["affine"] + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] inv_affine = np.linalg.inv(fwd_affine) affine_grid = AffineGrid(affine=inv_affine) @@ -1171,24 +1213,35 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda dtype=dtype, ) rot_mat = self.rotator.get_rotation_matrix() - self.push_transform(d, key, orig_size=orig_size, extra_info={"rot_mat": rot_mat}) + self.push_transform( + d, + key, + orig_size=orig_size, + extra_info={ + "rot_mat": rot_mat, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else "none", + }, + ) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode, align_corners, dtype in self.key_iterator( - d, self.mode, self.padding_mode, self.align_corners, self.dtype - ): + for key, dtype in self.key_iterator(d, self.dtype): transform = self.get_most_recent_transform(d, key) # Create inverse transform fwd_rot_mat = transform[InverseKeys.EXTRA_INFO]["rot_mat"] + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] inv_rot_mat = np.linalg.inv(fwd_rot_mat) xform = AffineTransform( normalized=False, mode=mode, padding_mode=padding_mode, - align_corners=align_corners, + align_corners=False if align_corners == "none" else align_corners, reverse_indexing=True, ) output = xform( @@ -1283,10 +1336,6 @@ def randomize(self, data: Optional[Any] = None) -> None: def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: self.randomize() d = dict(data) - if not self._do_transform: - for key in self.keys: - self.push_transform(d, key, extra_info={"rot_mat": np.eye(d[key].ndim)}) - return d angle: Union[Sequence[float], float] = self.x if d[self.keys[0]].ndim == 3 else (self.x, self.y, self.z) rotator = Rotate( angle=angle, @@ -1296,34 +1345,48 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda d, self.mode, self.padding_mode, self.align_corners, self.dtype ): orig_size = d[key].shape[1:] - d[key] = rotator( - d[key], - mode=mode, - padding_mode=padding_mode, - align_corners=align_corners, - dtype=dtype, + if self._do_transform: + d[key] = rotator( + d[key], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + dtype=dtype, + ) + rot_mat = rotator.get_rotation_matrix() + else: + rot_mat = np.eye(d[key].ndim) + self.push_transform( + d, + key, + orig_size=orig_size, + extra_info={ + "rot_mat": rot_mat, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else "none", + }, ) - rot_mat = rotator.get_rotation_matrix() - self.push_transform(d, key, orig_size=orig_size, extra_info={"rot_mat": rot_mat}) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode, align_corners, dtype in self.key_iterator( - d, self.mode, self.padding_mode, self.align_corners, self.dtype - ): + for key, dtype in self.key_iterator(d, self.dtype): transform = self.get_most_recent_transform(d, key) # Check if random transform was actually performed (based on `prob`) if transform[InverseKeys.DO_TRANSFORM]: # Create inverse transform fwd_rot_mat = transform[InverseKeys.EXTRA_INFO]["rot_mat"] + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] inv_rot_mat = np.linalg.inv(fwd_rot_mat) xform = AffineTransform( normalized=False, mode=mode, padding_mode=padding_mode, - align_corners=align_corners, + align_corners=False if align_corners == "none" else align_corners, reverse_indexing=True, ) output = xform( @@ -1384,7 +1447,15 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): - self.push_transform(d, key) + self.push_transform( + d, + key, + extra_info={ + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else "none", + }, + ) d[key] = self.zoomer( d[key], mode=mode, @@ -1395,19 +1466,20 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode, align_corners in self.key_iterator( - d, self.mode, self.padding_mode, self.align_corners - ): + for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Create inverse transform zoom = np.array(self.zoomer.zoom) inverse_transform = Zoom(zoom=1 / zoom, keep_size=self.zoomer.keep_size) + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] # Apply inverse d[key] = inverse_transform( d[key], mode=mode, padding_mode=padding_mode, - align_corners=align_corners, + align_corners=None if align_corners == "none" else align_corners, ) # Size might be out by 1 voxel so pad d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE])(d[key]) @@ -1496,7 +1568,16 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): - self.push_transform(d, key, extra_info={"zoom": self._zoom}) + self.push_transform( + d, + key, + extra_info={ + "zoom": self._zoom, + "mode": mode.value if isinstance(mode, Enum) else mode, + "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode, + "align_corners": align_corners if align_corners is not None else "none", + }, + ) if self._do_transform: d[key] = zoomer( d[key], @@ -1508,21 +1589,22 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = deepcopy(dict(data)) - for key, mode, padding_mode, align_corners in self.key_iterator( - d, self.mode, self.padding_mode, self.align_corners - ): + for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Check if random transform was actually performed (based on `prob`) if transform[InverseKeys.DO_TRANSFORM]: # Create inverse transform zoom = np.array(transform[InverseKeys.EXTRA_INFO]["zoom"]) + mode = transform[InverseKeys.EXTRA_INFO]["mode"] + padding_mode = transform[InverseKeys.EXTRA_INFO]["padding_mode"] + align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] inverse_transform = Zoom(zoom=1 / zoom, keep_size=self.keep_size) # Apply inverse d[key] = inverse_transform( d[key], mode=mode, padding_mode=padding_mode, - align_corners=align_corners, + align_corners=None if align_corners == "none" else align_corners, ) # Size might be out by 1 voxel so pad d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE])(d[key]) diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 7c4ea398f6..67da9ceb35 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -406,7 +406,6 @@ def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: d = deepcopy(dict(data)) for key in self.key_iterator(d): - transform = self.get_most_recent_transform(d, key) # Create inverse transform inverse_transform = ToNumpy() # Apply inverse diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index eb1b194c96..b73a899153 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -22,8 +22,18 @@ from monai.networks.layers import GaussianFilter from monai.transforms.compose import Compose from monai.transforms.transform import MapTransform -from monai.utils import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, min_version, optional_import -from monai.utils.misc import issequenceiterable +from monai.utils import ( + GridSampleMode, + InterpolateMode, + InverseKeys, + ensure_tuple, + ensure_tuple_rep, + ensure_tuple_size, + fall_back_tuple, + issequenceiterable, + min_version, + optional_import, +) measure, _ = optional_import("skimage.measure", "0.14.2", min_version) @@ -53,6 +63,7 @@ "extreme_points_to_image", "map_spatial_axes", "allow_missing_keys_mode", + "convert_inverse_interp_mode", ] @@ -756,3 +767,34 @@ def allow_missing_keys_mode(transform: Union[MapTransform, Compose, Tuple[MapTra # Revert for t, o_s in zip(transforms, orig_states): t.allow_missing_keys = o_s + + +def convert_inverse_interp_mode(trans_info: List, mode: str = "nearest", align_corners: Optional[bool] = None): + """ + Change the interpolation mode when inverting spatial transforms, default to "nearest". + It can support both single data or batch data. + + Args: + trans_info: transforms inverse information list, contains context of every invertible transform. + mode: target interpolation mode to convert, default to "nearest" as it's usually used to save the mode output. + align_corners: target align corner value in PyTorch interpolation API, need to align with the `mode`. + + """ + interp_modes = [i.value for i in InterpolateMode] + [i.value for i in GridSampleMode] + + # set to string for DataLoader collation + align_corners_ = "none" if align_corners is None else align_corners + + for item in ensure_tuple(trans_info): + if InverseKeys.EXTRA_INFO in item: + orig_mode = item[InverseKeys.EXTRA_INFO].get("mode", None) + if orig_mode is not None: + if orig_mode[0] in interp_modes: + item[InverseKeys.EXTRA_INFO]["mode"] = [mode for _ in range(len(mode))] + elif orig_mode in interp_modes: + item[InverseKeys.EXTRA_INFO]["mode"] = mode + if "align_corners" in item[InverseKeys.EXTRA_INFO]: + if issequenceiterable(item[InverseKeys.EXTRA_INFO]["align_corners"]): + item[InverseKeys.EXTRA_INFO]["align_corners"] = [align_corners_ for _ in range(len(mode))] + else: + item[InverseKeys.EXTRA_INFO]["align_corners"] = align_corners_ diff --git a/tests/test_handler_transform_inverter.py b/tests/test_handler_transform_inverter.py index 48efd5df53..87414319cf 100644 --- a/tests/test_handler_transform_inverter.py +++ b/tests/test_handler_transform_inverter.py @@ -20,6 +20,7 @@ from monai.handlers import TransformInverter from monai.transforms import ( AddChanneld, + CastToTyped, Compose, LoadImaged, RandAffined, @@ -29,8 +30,10 @@ RandRotated, RandZoomd, ResizeWithPadOrCropd, + ScaleIntensityd, ToTensord, ) +from monai.utils.misc import set_determinism from tests.utils import make_nifti_image KEYS = ["image", "label"] @@ -38,19 +41,22 @@ class TestTransformInverter(unittest.TestCase): def test_invert(self): - im_fname, seg_fname = [make_nifti_image(i) for i in create_test_image_3d(101, 100, 107)] + set_determinism(seed=0) + im_fname, seg_fname = [make_nifti_image(i) for i in create_test_image_3d(101, 100, 107, noise_max=100)] transform = Compose( [ LoadImaged(KEYS), AddChanneld(KEYS), + ScaleIntensityd(KEYS, minv=1, maxv=10), RandFlipd(KEYS, prob=0.5, spatial_axis=[1, 2]), RandAxisFlipd(KEYS, prob=0.5), RandRotate90d(KEYS, spatial_axes=(1, 2)), RandZoomd(KEYS, prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True), - RandRotated(KEYS, prob=0.5, range_x=np.pi), + RandRotated(KEYS, prob=0.5, range_x=np.pi, mode="bilinear", align_corners=True), RandAffined(KEYS, prob=0.5, rotate_range=np.pi), ResizeWithPadOrCropd(KEYS, 100), ToTensord(KEYS), + CastToTyped(KEYS, dtype=torch.uint8), ] ) data = [{"image": im_fname, "label": seg_fname} for _ in range(12)] @@ -69,11 +75,13 @@ def _train_func(engine, batch): engine = Engine(_train_func) # set up testing handler - TransformInverter(transform=transform, loader=loader, output_key="image").attach(engine) + TransformInverter(transform=transform, loader=loader, output_key="image", nearest_interp=True).attach(engine) engine.run(loader, max_epochs=1) + set_determinism(seed=None) self.assertTupleEqual(engine.state.output["image"].shape, (2, 1, 100, 100, 100)) for i in engine.state.output["image_inverted"]: + np.testing.assert_allclose(i.astype(np.uint8).astype(np.float32), i, rtol=1e-4) self.assertTupleEqual(i.shape, (1, 100, 101, 107)) diff --git a/tests/test_inverse.py b/tests/test_inverse.py index ccc4f366c2..358bf0176a 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -54,6 +54,7 @@ SpatialPadd, Zoomd, allow_missing_keys_mode, + convert_inverse_interp_mode, ) from monai.utils import first, get_seed, optional_import, set_determinism from monai.utils.enums import InverseKeys @@ -572,9 +573,11 @@ def test_inverse_inferred_seg(self): segs_dict = {"label": segs, label_transform_key: data[label_transform_key]} segs_dict_decollated = decollate_batch(segs_dict) - # inverse of individual segmentation seg_dict = first(segs_dict_decollated) + # test to convert interpolation mode for 1 data of model output batch + convert_inverse_interp_mode(seg_dict, mode="nearest", align_corners=None) + with allow_missing_keys_mode(transforms): inv_seg = transforms.inverse(seg_dict)["label"] self.assertEqual(len(data["label_transforms"]), num_invertible_transforms) From 997e6a13c577e3c61342304290d104743717d62e Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 10 Apr 2021 07:15:50 +0800 Subject: [PATCH 45/55] Update technical highlights for v0.5 (#1981) * [DLMED] update highlights for 0.5 Signed-off-by: Nic Ma * [DLMED] add invert transforms Signed-off-by: Nic Ma * [DLMED] add more highlights Signed-off-by: Nic Ma * [DLMED] add checkpointloader Signed-off-by: Nic Ma * downscale image Signed-off-by: Wenqi Li * downscale image Signed-off-by: Wenqi Li * update docs Signed-off-by: Wenqi Li * update desc Signed-off-by: Wenqi Li * update Signed-off-by: Wenqi Li Co-authored-by: Wenqi Li Signed-off-by: Neha Srivathsa --- docs/images/3d_paired.png | Bin 0 -> 84825 bytes docs/images/deepgrow.png | Bin 0 -> 112051 bytes docs/images/invert_transforms.png | Bin 0 -> 353100 bytes docs/images/lr_finder.png | Bin 0 -> 265198 bytes docs/images/metrics_report.png | Bin 0 -> 259387 bytes docs/images/pathology.png | Bin 0 -> 291620 bytes docs/images/tta.png | Bin 0 -> 264279 bytes docs/source/highlights.md | 68 +++++++++++++++++++++++++++--- monai/networks/blocks/warp.py | 2 +- 9 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 docs/images/3d_paired.png create mode 100644 docs/images/deepgrow.png create mode 100644 docs/images/invert_transforms.png create mode 100644 docs/images/lr_finder.png create mode 100644 docs/images/metrics_report.png create mode 100644 docs/images/pathology.png create mode 100644 docs/images/tta.png diff --git a/docs/images/3d_paired.png b/docs/images/3d_paired.png new file mode 100644 index 0000000000000000000000000000000000000000..dd751c8e164be060a7c1534de7cca7a1e4b3802a GIT binary patch literal 84825 zcmeFZWmI0-wk`VM1ShxzJ|sYJcXtvzgaEt}%i053{dmCrzC)EY_HBs8| z6+f!Tq3yf-KUn=m#nfN8y*SscAtCE8P&A`(f_8Q-B5*F;-&GLZ5gBN*gjv6?oz+<{ z85TTV?T-*M5Q~5E{owrkoe6VW#ogX1yZ?PS)3`_fh*{NQc5kic;@wrazQ4h;zrV=I zvC#0vBZgan!}Axe79!`%3U^oO?8{k8TCl8>*S-)pTN{J1~!peMGEdFk9& zU+}PP`E*lRMy!~bV&~lFm*khzmLs8*-v>wN{>{B&@Tmv>aR$O|FWoR z?|x#!!|wCVG3SXSbXIdU9mCQ4-iy$3u+z3?gkw`TI=#G!*v9F0H8j&>)@|Ot`Jif3ALSy$ zd$)FCg#Va%M4ucDb*bh0aC@f^{jGhg`~05Ag4Z;HO-0MqZr${<=W&@>%~|?Xo5fys z+I4nKvR_SdoAh?m-OKNujpt%v@_BgAhx@e3BV#P!sF6G2ip}Fi%D)i4m5}zxZp3BrTdP%w>yD3*UQqT`s6#kzp?iHM!>X>GX!7%Y z_ixve?%$|FYg#*3-%Wilv4XCb3w)zSH{-Bk`m*F`Y4|2a}J zgu373yQO8JosWKzFJf~Ey(Xvpn9{ZEaC*MGj~Uv`aNa2^MTz+U*lRF4H0erGpLn=i^OM;nuHraDqa%9&AA1kwx#JSJ@{xCl=# z&YYe-dpSPv%l;(^=cy<1+TLBuGG0b;VdK8vrVrP?yAk>?yXufl3w7$rEpnO)A0ZDd z?BmBVB+qq;zaY?rqvWs=TxX11G3LAX)NoK}8R`ik*qTEQ-CH@Xi}oMs;thI_YJO1b z+pmc^(_m}#VipNc+Uz*~;rAkyNX`?)8);)0Jp=3DL(tt^HotQLim*+NV~-Qtt_ zKRx!3BVHU^<(uouW4T zkqAYmlE_X`_r};ME(R3FoQ3C~>w^et)aT~~?1V82t-2xd$igoJ32U8lJ)B;V6_;s6 z+#x6Q|7soHGb3&nC{#pzF!9(QVL*OL>Od3a+X8{8i)Md?oI=q!8;0}*IhM;Q=1+J`9nOuJodHV2rv=khW~=`p1;DkF6z@^GB~Tv*S%lZNvS z{5)`s(78i%R9Tmt5n{fLz*R+Cl5yB#5{T20s%fPS6yPuOr+$C1x;(pv&q6mcUorG5v zT{;drEU2MXhf0?Ptn$+mVsAuNS9kV9G&2Wi4I;7-G*QiJ1YlfcF!!~@MQ#iV0q^EJP!KOp?agmT6 z-H^KKK*^QjTQrP@k5mB~644&g&IevTXbgDW22Mzp4hrZV>QAJIP4=H5A8J?@7r^}( zuZqyrqPEY|4Wun$C#K4Il=49!H&-6)DP^uDqc6%lyZ1H*54-uN_tLo18s-OOxDhqj zB|f1?Jp~lPHRDwYP79L|dBwQ&_*>PDbPSWQ`1bfh*)P4_O77-i0+5nNhltB-I5}j+ zp=JIG@Fb*TtePj)#N4G~r6vi9Z6a#r=){nQ=LQPN_J+5N_O#dJnEQzf?Z0bRHdw}O zi%|W~ISvH&-)X5Ak2nPX#AHI>(~!Rp^7iFj#g>%0AclH2Wnt5iHKdcj=vRot+i5tY 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%0DugF-hJl4W%liQ%U_qS)bKPGNzTHe8>-UJd1hyu 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•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