From c9d15f116cb338eb377f2541768db850264f9d26 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 21 Jan 2021 22:11:37 +0800 Subject: [PATCH 01/19] 1478 Fix TorchScript issue in AHnet (#1479) * [DLMED] fix TorchScript issue in AHNet Signed-off-by: Nic Ma * [DLMED] add test cases Signed-off-by: Nic Ma --- monai/networks/nets/ahnet.py | 3 ++- tests/test_ahnet.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/monai/networks/nets/ahnet.py b/monai/networks/nets/ahnet.py index 5146930fca..847993bd44 100644 --- a/monai/networks/nets/ahnet.py +++ b/monai/networks/nets/ahnet.py @@ -371,6 +371,7 @@ def __init__( self.pool_type = pool_type self.spatial_dims = spatial_dims self.psp_block_num = psp_block_num + self.psp = None if spatial_dims not in [2, 3]: raise AssertionError("spatial_dims can only be 2 or 3.") @@ -510,7 +511,7 @@ def forward(self, x): sum4 = self.up3(d3) + conv_x d4 = self.dense4(sum4) - if self.psp_block_num > 0: + if self.psp_block_num > 0 and self.psp is not None: psp = self.psp(d4) x = torch.cat((psp, d4), dim=1) else: diff --git a/tests/test_ahnet.py b/tests/test_ahnet.py index 3dc8c05cf2..777e2637a7 100644 --- a/tests/test_ahnet.py +++ b/tests/test_ahnet.py @@ -191,9 +191,14 @@ def test_ahnet_shape_3d(self, input_param, input_shape, expected_shape): @skip_if_quick def test_script(self): + # test 2D network net = AHNet(spatial_dims=2, out_channels=2) test_data = torch.randn(1, 1, 128, 64) test_script_save(net, test_data) + # test 3D network + net = AHNet(spatial_dims=3, out_channels=2, psp_block_num=0, upsample_mode="nearest") + test_data = torch.randn(1, 1, 32, 32, 64) + test_script_save(net, test_data) class TestAHNETWithPretrain(unittest.TestCase): From edbad4305a942b8fe170134673a08044bc04a967 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Thu, 21 Jan 2021 16:20:23 +0000 Subject: [PATCH 02/19] Fix for device config script (#1480) * Fix for device config script Signed-off-by: Eric Kerfoot * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot Co-authored-by: Wenqi Li --- monai/config/deviceconfig.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/monai/config/deviceconfig.py b/monai/config/deviceconfig.py index f543a5a8d4..9e448a9ac3 100644 --- a/monai/config/deviceconfig.py +++ b/monai/config/deviceconfig.py @@ -196,10 +196,12 @@ def get_gpu_info() -> OrderedDict: _dict_append(output, "Num GPUs", lambda: num_gpus) _dict_append(output, "Has CUDA", lambda: bool(torch.cuda.is_available())) + if output["Has CUDA"]: _dict_append(output, "CUDA version", lambda: torch.version.cuda) cudnn_ver = torch.backends.cudnn.version() _dict_append(output, "cuDNN enabled", lambda: bool(cudnn_ver)) + if cudnn_ver: _dict_append(output, "cuDNN version", lambda: cudnn_ver) @@ -207,17 +209,21 @@ def get_gpu_info() -> OrderedDict: _dict_append(output, "Current device", torch.cuda.current_device) if hasattr(torch.cuda, "get_arch_list"): # get_arch_list is new in torch 1.7.1 _dict_append(output, "Library compiled for CUDA architectures", torch.cuda.get_arch_list) + for gpu in range(num_gpus): - _dict_append(output, "Info for GPU", gpu) gpu_info = torch.cuda.get_device_properties(gpu) - _dict_append(output, "\tName", lambda: gpu_info.name) - _dict_append(output, "\tIs integrated", lambda: bool(gpu_info.is_integrated)) - _dict_append(output, "\tIs multi GPU board", lambda: bool(gpu_info.is_multi_gpu_board)) - _dict_append(output, "\tMulti processor count", lambda: gpu_info.multi_processor_count) - _dict_append(output, "\tTotal memory (GB)", lambda: round(gpu_info.total_memory / 1024 ** 3, 1)) - _dict_append(output, "\tCached memory (GB)", lambda: round(torch.cuda.memory_reserved(gpu) / 1024 ** 3, 1)) - _dict_append(output, "\tAllocated memory (GB)", lambda: round(torch.cuda.memory_allocated(gpu) / 1024 ** 3, 1)) - _dict_append(output, "\tCUDA capability (maj.min)", lambda: f"{gpu_info.major}.{gpu_info.minor}") + _dict_append(output, f"GPU {gpu} Name", lambda: gpu_info.name) + _dict_append(output, f"GPU {gpu} Is integrated", lambda: bool(gpu_info.is_integrated)) + _dict_append(output, f"GPU {gpu} Is multi GPU board", lambda: bool(gpu_info.is_multi_gpu_board)) + _dict_append(output, f"GPU {gpu} Multi processor count", lambda: gpu_info.multi_processor_count) + _dict_append(output, f"GPU {gpu} Total memory (GB)", lambda: round(gpu_info.total_memory / 1024 ** 3, 1)) + _dict_append( + output, f"GPU {gpu} Cached memory (GB)", lambda: round(torch.cuda.memory_reserved(gpu) / 1024 ** 3, 1) + ) + _dict_append( + output, f"GPU {gpu} Allocated memory (GB)", lambda: round(torch.cuda.memory_allocated(gpu) / 1024 ** 3, 1) + ) + _dict_append(output, f"GPU {gpu} CUDA capability (maj.min)", lambda: f"{gpu_info.major}.{gpu_info.minor}") return output From cb7a9a052688eb77f0c1ea8c4e532b6523e0d49c Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 23 Jan 2021 00:07:01 +0800 Subject: [PATCH 03/19] [DLMED] fix flake8 issue (#1493) Signed-off-by: Nic Ma --- monai/utils/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/utils/module.py b/monai/utils/module.py index 4e87e835b1..0e11a6531d 100644 --- a/monai/utils/module.py +++ b/monai/utils/module.py @@ -73,7 +73,7 @@ def load_submodules(basemod, load_all: bool = True, exclude_pattern: str = "(.*[ if (is_pkg or load_all) and name not in sys.modules and match(exclude_pattern, name) is None: try: mod = import_module(name) - importer.find_module(name).load_module(name) + importer.find_module(name).load_module(name) # type: ignore submodules.append(mod) except OptionalImportError: pass # could not import the optional deps., they are ignored From 0b7e6452fbda3b578a589dc92c94a01974ba3230 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 23 Jan 2021 09:56:53 +0800 Subject: [PATCH 04/19] 886 Add IterationMetric refer to to the EpochMetric in ignite (#1488) * [DLMED] add IterationHandler refer to the EpochHandler in ignite Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix the multi-gpu issue Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix distributed tests Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma Co-authored-by: monai-bot --- docs/source/handlers.rst | 6 ++ monai/handlers/__init__.py | 1 + monai/handlers/confusion_matrix.py | 82 ++------------- monai/handlers/hausdorff_distance.py | 55 ++-------- monai/handlers/iteration_metric.py | 105 ++++++++++++++++++++ monai/handlers/mean_dice.py | 56 ++--------- monai/handlers/surface_distance.py | 56 ++--------- monai/metrics/utils.py | 6 +- tests/test_handler_confusion_matrix.py | 11 +- tests/test_handler_confusion_matrix_dist.py | 16 +-- tests/test_handler_hausdorff_distance.py | 3 +- tests/test_handler_surface_distance.py | 3 +- 12 files changed, 156 insertions(+), 244 deletions(-) create mode 100644 monai/handlers/iteration_metric.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index 2962f725d8..d1ce257cb7 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -22,6 +22,12 @@ CSV saver :members: +Iteration Metric +---------------- +.. autoclass:: IterationMetric + :members: + + Mean Dice metrics handler ------------------------- .. autoclass:: MeanDice diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 1df516eaf0..a873cd8b15 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 .hausdorff_distance import HausdorffDistance +from .iteration_metric import IterationMetric from .lr_schedule_handler import LrScheduleHandler from .mean_dice import MeanDice from .metric_logger import MetricLogger diff --git a/monai/handlers/confusion_matrix.py b/monai/handlers/confusion_matrix.py index fe60b964a7..46226f530b 100644 --- a/monai/handlers/confusion_matrix.py +++ b/monai/handlers/confusion_matrix.py @@ -9,21 +9,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional, Sequence +from typing import Any, Callable, Optional import torch +from monai.handlers.iteration_metric import IterationMetric from monai.metrics import ConfusionMatrixMetric, compute_confusion_matrix_metric from monai.metrics.utils import MetricReduction, do_metric_reduction -from monai.utils import exact_version, optional_import -NotComputableError, _ = optional_import("ignite.exceptions", "0.4.2", exact_version, "NotComputableError") -Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") -reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") -sync_all_reduce, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "sync_all_reduce") - -class ConfusionMatrix(Metric): # type: ignore[valid-type, misc] # due to optional_import +class ConfusionMatrix(IterationMetric): """ Compute confusion matrix related metrics from full size Tensor and collects average over batch, class-channels, iterations. """ @@ -32,7 +27,6 @@ def __init__( self, include_background: bool = True, metric_name: str = "hit_rate", - compute_sample: bool = False, output_transform: Callable = lambda x: x, device: Optional[torch.device] = None, ) -> None: @@ -48,79 +42,21 @@ def __init__( ``"informedness"``, ``"markedness"``] Some of the metrics have multiple aliases (as shown in the wikipedia page aforementioned), and you can also input those names instead. - compute_sample: if ``True``, each sample's metric will be computed first. - If ``False``, the confusion matrix for all samples will be accumulated first. Defaults to ``False``. output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. device: device specification in case of distributed computation usage. See also: :py:meth:`monai.metrics.confusion_matrix` """ - super().__init__(output_transform, device=device) - self.confusion_matrix = ConfusionMatrixMetric( + metric_fn = ConfusionMatrixMetric( include_background=include_background, metric_name=metric_name, - compute_sample=compute_sample, - reduction=MetricReduction.MEAN, + compute_sample=False, + reduction=MetricReduction.NONE, ) - self._sum = 0.0 - self._num_examples = 0 - self.compute_sample = compute_sample self.metric_name = metric_name - self._total_tp = 0.0 - self._total_fp = 0.0 - self._total_tn = 0.0 - self._total_fn = 0.0 - - @reinit__is_reduced - def reset(self) -> None: - self._sum = 0.0 - self._num_examples = 0 - self._total_tp = 0.0 - self._total_fp = 0.0 - self._total_tn = 0.0 - self._total_fn = 0.0 - - @reinit__is_reduced - def update(self, output: Sequence[torch.Tensor]) -> None: - """ - Args: - output: sequence with contents [y_pred, y]. - - Raises: - ValueError: When ``output`` length is not 2. This metric can only support y_pred and y. + super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) - """ - if len(output) != 2: - raise ValueError(f"output must have length 2, got {len(output)}.") - y_pred, y = output - if self.compute_sample is True: - score, not_nans = self.confusion_matrix(y_pred, y) - not_nans = int(not_nans.item()) - - # add all items in current batch - self._sum += score.item() * not_nans - self._num_examples += not_nans - else: - confusion_matrix = self.confusion_matrix(y_pred, y) - confusion_matrix, _ = do_metric_reduction(confusion_matrix, MetricReduction.SUM) - self._total_tp += confusion_matrix[0].item() - self._total_fp += confusion_matrix[1].item() - self._total_tn += confusion_matrix[2].item() - self._total_fn += confusion_matrix[3].item() - - @sync_all_reduce("_sum", "_num_examples", "_total_tp", "_total_fp", "_total_tn", "_total_fn") - def compute(self): - """ - Raises: - NotComputableError: When ``compute`` is called before an ``update`` occurs. - - """ - if self.compute_sample is True: - if self._num_examples == 0: - raise NotComputableError( - "ConfusionMatrix metric must have at least one example before it can be computed." - ) - return self._sum / self._num_examples - confusion_matrix = torch.tensor([self._total_tp, self._total_fp, self._total_tn, self._total_fn]) + def _reduce(self, scores) -> Any: + confusion_matrix, _ = do_metric_reduction(scores, MetricReduction.MEAN) return compute_confusion_matrix_metric(self.metric_name, confusion_matrix) diff --git a/monai/handlers/hausdorff_distance.py b/monai/handlers/hausdorff_distance.py index 581550a703..3e4a3d70ba 100644 --- a/monai/handlers/hausdorff_distance.py +++ b/monai/handlers/hausdorff_distance.py @@ -9,20 +9,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional, Sequence +from typing import Callable, Optional import torch +from monai.handlers.iteration_metric import IterationMetric from monai.metrics import HausdorffDistanceMetric -from monai.utils import MetricReduction, exact_version, optional_import +from monai.utils import MetricReduction -NotComputableError, _ = optional_import("ignite.exceptions", "0.4.2", exact_version, "NotComputableError") -Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") -reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") -sync_all_reduce, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "sync_all_reduce") - -class HausdorffDistance(Metric): # type: ignore[valid-type, misc] # due to optional_import +class HausdorffDistance(IterationMetric): """ Computes Hausdorff distance from full size Tensor and collects average over batch, class-channels, iterations. """ @@ -52,48 +48,11 @@ def __init__( """ super().__init__(output_transform, device=device) - self.hd = HausdorffDistanceMetric( + metric_fn = HausdorffDistanceMetric( include_background=include_background, distance_metric=distance_metric, percentile=percentile, directed=directed, - reduction=MetricReduction.MEAN, + reduction=MetricReduction.NONE, ) - self._sum = 0.0 - self._num_examples = 0 - - @reinit__is_reduced - def reset(self) -> None: - self._sum = 0.0 - self._num_examples = 0 - - @reinit__is_reduced - def update(self, output: Sequence[torch.Tensor]) -> None: - """ - Args: - output: sequence with contents [y_pred, y]. - - Raises: - ValueError: When ``output`` length is not 2. The metric can only support y_pred and y. - - """ - if len(output) != 2: - raise ValueError(f"output must have length 2, got {len(output)}.") - y_pred, y = output - score, not_nans = self.hd(y_pred, y) - not_nans = int(not_nans.item()) - - # add all items in current batch - self._sum += score.item() * not_nans - self._num_examples += not_nans - - @sync_all_reduce("_sum", "_num_examples") - def compute(self) -> float: - """ - Raises: - NotComputableError: When ``compute`` is called before an ``update`` occurs. - - """ - if self._num_examples == 0: - raise NotComputableError("HausdorffDistance must have at least one example before it can be computed.") - return self._sum / self._num_examples + super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) diff --git a/monai/handlers/iteration_metric.py b/monai/handlers/iteration_metric.py new file mode 100644 index 0000000000..4d555b9dcb --- /dev/null +++ b/monai/handlers/iteration_metric.py @@ -0,0 +1,105 @@ +# 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 Any, Callable, List, Optional, Sequence + +import torch + +from monai.metrics import do_metric_reduction +from monai.utils import MetricReduction, exact_version, optional_import + +NotComputableError, _ = optional_import("ignite.exceptions", "0.4.2", exact_version, "NotComputableError") +idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") +Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") +reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") + + +class IterationMetric(Metric): # type: ignore[valid-type, misc] # due to optional_import + """ + Class for metrics that should be computed on every iteration and compute final results when epoch completed. + Similar to the `EpochMetric` in ignite: + https://github.com/pytorch/ignite/blob/v0.4.2/ignite/metrics/epoch_metric.py#L13. + + Args: + metric_fn: callable function or class to compute raw metric results after every iteration. + expect to return a Tensor with shape (batch, channel, ...) or tuple (Tensor, not_nans). + output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. + device: device specification in case of distributed computation usage. + + """ + + def __init__( + self, + metric_fn: Callable, + output_transform: Callable = lambda x: x, + device: Optional[torch.device] = None, + ) -> None: + self._is_reduced: bool = False + self.metric_fn = metric_fn + self._scores: List = [] + super().__init__(output_transform, device=device) + + @reinit__is_reduced + def reset(self) -> None: + self._scores = [] + + @reinit__is_reduced + def update(self, output: Sequence[torch.Tensor]) -> None: + """ + Args: + output: sequence with contents [y_pred, y]. + + Raises: + ValueError: When ``output`` length is not 2. metric_fn can only support y_pred and y. + + """ + if len(output) != 2: + raise ValueError(f"output must have length 2, got {len(output)}.") + y_pred, y = output + score = self.metric_fn(y_pred, y) + if isinstance(score, (tuple, list)): + score = score[0] + self._scores.append(score) + + def compute(self) -> Any: + """ + Raises: + NotComputableError: When ``compute`` is called before an ``update`` occurs. + + """ + _scores = torch.cat(self._scores, dim=0) + + ws = idist.get_world_size() + if ws > 1 and not self._is_reduced: + # make sure the _scores is evenly-divisible on multi-GPUs + length = _scores.shape[0] + max_len = max(idist.all_gather(length)).item() + if length < max_len: + size = [max_len - length] + list(_scores.shape[1:]) + _scores = torch.cat([_scores, _scores.new_full(size, float("NaN"))], dim=0) + + # all gather across all processes + _scores = idist.all_gather(_scores) + self._is_reduced = True + + result: torch.Tensor = torch.zeros(1) + if idist.get_rank() == 0: + # run compute_fn on zero rank only + result = self._reduce(_scores) + + if ws > 1: + # broadcast result to all processes + result = idist.broadcast(result, src=0) + + return result.item() if torch.is_tensor(result) else result + + def _reduce(self, scores) -> Any: + return do_metric_reduction(scores, MetricReduction.MEAN)[0] diff --git a/monai/handlers/mean_dice.py b/monai/handlers/mean_dice.py index 3c34948604..057acbee97 100644 --- a/monai/handlers/mean_dice.py +++ b/monai/handlers/mean_dice.py @@ -9,20 +9,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional, Sequence +from typing import Callable, Optional import torch +from monai.handlers.iteration_metric import IterationMetric from monai.metrics import DiceMetric -from monai.utils import MetricReduction, exact_version, optional_import +from monai.utils import MetricReduction -NotComputableError, _ = optional_import("ignite.exceptions", "0.4.2", exact_version, "NotComputableError") -Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") -reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") -sync_all_reduce, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "sync_all_reduce") - -class MeanDice(Metric): # type: ignore[valid-type, misc] # due to optional_import +class MeanDice(IterationMetric): """ Computes Dice score metric from full size Tensor and collects average over batch, class-channels, iterations. """ @@ -44,46 +40,8 @@ def __init__( See also: :py:meth:`monai.metrics.meandice.compute_meandice` """ - super().__init__(output_transform, device=device) - self.dice = DiceMetric( + metric_fn = DiceMetric( include_background=include_background, - reduction=MetricReduction.MEAN, + reduction=MetricReduction.NONE, ) - self._sum = 0.0 - self._num_examples = 0 - - @reinit__is_reduced - def reset(self) -> None: - self._sum = 0.0 - self._num_examples = 0 - - @reinit__is_reduced - def update(self, output: Sequence[torch.Tensor]) -> None: - """ - Args: - output: sequence with contents [y_pred, y]. - - Raises: - ValueError: When ``output`` length is not 2. MeanDice metric can only support y_pred and y. - - """ - if len(output) != 2: - raise ValueError(f"output must have length 2, got {len(output)}.") - y_pred, y = output - score, not_nans = self.dice(y_pred, y) - not_nans = int(not_nans.item()) - - # add all items in current batch - self._sum += score.item() * not_nans - self._num_examples += not_nans - - @sync_all_reduce("_sum", "_num_examples") - def compute(self) -> float: - """ - Raises: - NotComputableError: When ``compute`` is called before an ``update`` occurs. - - """ - if self._num_examples == 0: - raise NotComputableError("MeanDice must have at least one example before it can be computed.") - return self._sum / self._num_examples + super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) diff --git a/monai/handlers/surface_distance.py b/monai/handlers/surface_distance.py index 514cf3e6c7..17b667ab46 100644 --- a/monai/handlers/surface_distance.py +++ b/monai/handlers/surface_distance.py @@ -9,20 +9,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional, Sequence +from typing import Callable, Optional import torch +from monai.handlers.iteration_metric import IterationMetric from monai.metrics import SurfaceDistanceMetric -from monai.utils import MetricReduction, exact_version, optional_import +from monai.utils import MetricReduction -NotComputableError, _ = optional_import("ignite.exceptions", "0.4.2", exact_version, "NotComputableError") -Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") -reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") -sync_all_reduce, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "sync_all_reduce") - -class SurfaceDistance(Metric): # type: ignore[valid-type, misc] # due to optional_import +class SurfaceDistance(IterationMetric): """ Computes surface distance from full size Tensor and collects average over batch, class-channels, iterations. """ @@ -48,48 +44,10 @@ def __init__( device: device specification in case of distributed computation usage. """ - super().__init__(output_transform, device=device) - self.hd = SurfaceDistanceMetric( + metric_fn = SurfaceDistanceMetric( include_background=include_background, symmetric=symmetric, distance_metric=distance_metric, - reduction=MetricReduction.MEAN, + reduction=MetricReduction.NONE, ) - self._sum = 0.0 - self._num_examples = 0 - - @reinit__is_reduced - def reset(self) -> None: - self._sum = 0.0 - self._num_examples = 0 - - @reinit__is_reduced - def update(self, output: Sequence[torch.Tensor]) -> None: - """ - Args: - output: sequence with contents [y_pred, y]. - - Raises: - ValueError: When ``output`` length is not 2. The metric can only support y_pred and y. - - """ - if len(output) != 2: - raise ValueError(f"output must have length 2, got {len(output)}.") - y_pred, y = output - score, not_nans = self.hd(y_pred, y) - not_nans = int(not_nans.item()) - - # add all items in current batch - self._sum += score.item() * not_nans - self._num_examples += not_nans - - @sync_all_reduce("_sum", "_num_examples") - def compute(self) -> float: - """ - Raises: - NotComputableError: When ``compute`` is called before an ``update`` occurs. - - """ - if self._num_examples == 0: - raise NotComputableError("SurfaceDistance must have at least one example before it can be computed.") - return self._sum / self._num_examples + super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index 68f21f1613..cc7049ff81 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -53,7 +53,7 @@ def do_metric_reduction( f: a tensor that contains the calculated metric scores per batch and per class. The first two dims should be batch and class. reduction: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``, - ``"mean_channel"``, ``"sum_channel"``} + ``"mean_channel"``, ``"sum_channel"``}, if "none", return the input f tensor and not_nans. Define the mode to reduce computation result of 1 batch data. Defaults to ``"mean"``. Raises: @@ -65,11 +65,13 @@ def do_metric_reduction( # we need to account for it nans = torch.isnan(f) not_nans = (~nans).float() - f[nans] = 0 t_zero = torch.zeros(1, device=f.device, dtype=f.dtype) reduction = MetricReduction(reduction) + if reduction == MetricReduction.NONE: + return f, not_nans + f[nans] = 0 if reduction == MetricReduction.MEAN: # 2 steps, first, mean by channel (accounting for nans), then by batch not_nans = not_nans.sum(dim=1) diff --git a/tests/test_handler_confusion_matrix.py b/tests/test_handler_confusion_matrix.py index ac5edb72e2..cc231b82db 100644 --- a/tests/test_handler_confusion_matrix.py +++ b/tests/test_handler_confusion_matrix.py @@ -17,11 +17,10 @@ from monai.handlers import ConfusionMatrix -TEST_CASE_1 = [{"include_background": True, "metric_name": "f1", "compute_sample": False}, 0.75] -TEST_CASE_2 = [{"include_background": False, "metric_name": "ppv", "compute_sample": False}, 1.0] +TEST_CASE_1 = [{"include_background": True, "metric_name": "f1"}, 0.75] +TEST_CASE_2 = [{"include_background": False, "metric_name": "ppv"}, 1.0] -TEST_CASE_SEG_1 = [{"include_background": True, "metric_name": "tpr", "compute_sample": True}, 0.8333] -TEST_CASE_SEG_2 = [{"include_background": True, "metric_name": "tpr", "compute_sample": False}, 0.7] +TEST_CASE_SEG_1 = [{"include_background": True, "metric_name": "tpr"}, 0.7] data_1: Dict[Any, Any] = { "y_pred": torch.tensor( @@ -70,7 +69,7 @@ def test_compute(self, input_params, expected_avg): avg_metric = metric.compute() self.assertAlmostEqual(avg_metric, expected_avg, places=4) - @parameterized.expand([TEST_CASE_SEG_1, TEST_CASE_SEG_2]) + @parameterized.expand([TEST_CASE_SEG_1]) def test_compute_seg(self, input_params, expected_avg): metric = ConfusionMatrix(**input_params) @@ -83,8 +82,6 @@ def test_compute_seg(self, input_params, expected_avg): metric.update([y_pred, y]) avg_metric = metric.compute() - if input_params["compute_sample"] is False: - avg_metric = avg_metric.item() self.assertAlmostEqual(avg_metric, expected_avg, places=4) @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) diff --git a/tests/test_handler_confusion_matrix_dist.py b/tests/test_handler_confusion_matrix_dist.py index 583ba716aa..ebe0eb9ca7 100644 --- a/tests/test_handler_confusion_matrix_dist.py +++ b/tests/test_handler_confusion_matrix_dist.py @@ -21,17 +21,13 @@ class DistributedConfusionMatrix(DistTestCase): - @DistCall(nnodes=1, nproc_per_node=2) - def test_compute_sample(self): - self._compute(True) - @DistCall(nnodes=1, nproc_per_node=2) def test_compute(self): - self._compute(False) + self._compute() - def _compute(self, compute_sample=True): + def _compute(self): device = f"cuda:{dist.get_rank()}" if torch.cuda.is_available() else "cpu" - metric = ConfusionMatrix(include_background=True, metric_name="tpr", compute_sample=compute_sample) + metric = ConfusionMatrix(include_background=True, metric_name="tpr") if dist.get_rank() == 0: y_pred = torch.tensor( @@ -62,11 +58,7 @@ def _compute(self, compute_sample=True): metric.update([y_pred, y]) avg_metric = metric.compute() - if compute_sample is False: - avg_metric = avg_metric.item() - np.testing.assert_allclose(avg_metric, 0.7, rtol=1e-04, atol=1e-04) - else: - np.testing.assert_allclose(avg_metric, 0.8333, rtol=1e-04, atol=1e-04) + np.testing.assert_allclose(avg_metric, 0.7, rtol=1e-04, atol=1e-04) if __name__ == "__main__": diff --git a/tests/test_handler_hausdorff_distance.py b/tests/test_handler_hausdorff_distance.py index ee30040cc8..edf59320ea 100644 --- a/tests/test_handler_hausdorff_distance.py +++ b/tests/test_handler_hausdorff_distance.py @@ -71,10 +71,9 @@ def test_compute(self): y_pred, y = TEST_SAMPLE_3 hd_metric.update([y_pred, y]) self.assertEqual(hd_metric.compute(), float("inf")) - self.assertEqual(hd_metric._num_examples, 3) y_pred, y = TEST_SAMPLE_4 hd_metric.update([y_pred, y]) - self.assertEqual(hd_metric._num_examples, 3) + self.assertEqual(hd_metric.compute(), float("inf")) def test_shape_mismatch(self): hd_metric = HausdorffDistance(include_background=True) diff --git a/tests/test_handler_surface_distance.py b/tests/test_handler_surface_distance.py index b4d9584289..656b0d64b2 100644 --- a/tests/test_handler_surface_distance.py +++ b/tests/test_handler_surface_distance.py @@ -71,10 +71,9 @@ def test_compute(self): y_pred, y = TEST_SAMPLE_3 sur_metric.update([y_pred, y]) self.assertAlmostEqual(sur_metric.compute(), float("inf")) - self.assertAlmostEqual(sur_metric._num_examples, 3) y_pred, y = TEST_SAMPLE_4 sur_metric.update([y_pred, y]) - self.assertAlmostEqual(sur_metric._num_examples, 3) + self.assertAlmostEqual(sur_metric.compute(), float("inf")) def test_shape_mismatch(self): sur_metric = SurfaceDistance(include_background=True) From 2eada01efd9ddbcf8d9befe78b6ed67ce0cb454f Mon Sep 17 00:00:00 2001 From: Isaac Yang Date: Sat, 23 Jan 2021 01:00:30 -0800 Subject: [PATCH 05/19] Add some details on weekly preview build process (#1494) Stop build if the tag format is wrong Signed-off-by: Isaac Yang --- .github/workflows/weekly-preview.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index 54e43d6968..9b8d5c58b0 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -24,12 +24,16 @@ jobs: sed -i 's/name\ =\ monai$/name\ =\ monai-weekly/g' setup.cfg echo "__commit_id__ = \"$HEAD_COMMIT_ID\"" >> monai/__init__.py git diff setup.cfg monai/__init__.py - # build tar.gz and wheel git config user.name "CI Builder" git config user.email "monai.miccai2019@gmail.com" git add setup.cfg monai/__init__.py git commit -m "Weekly build at $HEAD_COMMIT_ID" - git tag 0.5.dev$(date +'%y%U') + export YEAR_WEEK=$(date +'%y%U') + echo "Year week for tag is ${YEAR_WEEK}" + if ! [[ $YEAR_WEEK =~ ^[0-9]{4}$ ]] ; then echo "Wrong 'year week' format. Should be 4 digits."; exit 1 ; fi + git tag "0.5.dev${YEAR_WEEK}" + git log -1 + git tag --list python setup.py sdist bdist_wheel - name: Publish to PyPI From f840fc5e87050e2a7f53fd52c28b5ad82b6a20dc Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 25 Jan 2021 15:10:18 +0000 Subject: [PATCH 06/19] remove decorator double negative (#1490) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- tests/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 20de17bbff..d73cb5fdc7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -85,14 +85,14 @@ def skip_if_no_cpp_extention(obj): """ Skip the unit tests if the cpp extention isnt available """ - return unittest.skipIf(not USE_COMPILED, "Skipping cpp extention tests")(obj) + return unittest.skipUnless(USE_COMPILED, "Skipping cpp extention tests")(obj) def skip_if_no_cuda(obj): """ Skip the unit tests if torch.cuda.is_available is False """ - return unittest.skipIf(not torch.cuda.is_available(), "Skipping CUDA-based tests")(obj) + return unittest.skipUnless(torch.cuda.is_available(), "Skipping CUDA-based tests")(obj) def skip_if_windows(obj): From db8f7877da06a9b3710071c626c0488676716be1 Mon Sep 17 00:00:00 2001 From: Francisco Maria Calisto Date: Mon, 25 Jan 2021 19:15:49 +0000 Subject: [PATCH 07/19] Create CODE_OF_CONDUCT.md; update contact (#1495) Co-authored-by: Wenqi Li --- CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..c5a63e4364 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at monai.miccai2019@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq From a2da8a13053041d0a99678888ec44b7ebfafc31a Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 26 Jan 2021 11:31:46 +0000 Subject: [PATCH 08/19] 1014 learning rate finder (#1454) learning rate finder and corresponding test --- monai/data/dataset.py | 8 +- monai/handlers/stats_handler.py | 2 +- monai/optimizers/__init__.py | 1 + monai/optimizers/lr_finder.py | 531 +++++++++++++++++++++++++++++++ monai/optimizers/lr_scheduler.py | 43 +++ monai/utils/__init__.py | 2 + monai/utils/misc.py | 43 ++- monai/utils/state_cacher.py | 92 ++++++ tests/test_lr_finder.py | 81 +++++ tests/test_state_cacher.py | 68 ++++ 10 files changed, 868 insertions(+), 3 deletions(-) create mode 100644 monai/optimizers/lr_finder.py create mode 100644 monai/optimizers/lr_scheduler.py create mode 100644 monai/utils/state_cacher.py create mode 100644 tests/test_lr_finder.py create mode 100644 tests/test_state_cacher.py diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 047587119f..e67c7a2954 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -498,7 +498,13 @@ def _fill_cache(self) -> List: warnings.warn("tqdm is not installed, will not show the caching progress bar.") with ThreadPool(self.num_workers) as p: if has_tqdm: - return list(tqdm(p.imap(self._load_cache_item, range(self.cache_num)), total=self.cache_num)) + return list( + tqdm( + p.imap(self._load_cache_item, range(self.cache_num)), + total=self.cache_num, + desc="Loading dataset", + ) + ) return list(p.imap(self._load_cache_item, range(self.cache_num))) def _load_cache_item(self, idx: int): diff --git a/monai/handlers/stats_handler.py b/monai/handlers/stats_handler.py index c1aef87df0..007fbed413 100644 --- a/monai/handlers/stats_handler.py +++ b/monai/handlers/stats_handler.py @@ -27,7 +27,7 @@ DEFAULT_TAG = "Loss" -class StatsHandler(object): +class StatsHandler: """ StatsHandler defines a set of Ignite Event-handlers for all the log printing logics. It's can be used for any Ignite Engine(trainer, validator and evaluator). diff --git a/monai/optimizers/__init__.py b/monai/optimizers/__init__.py index 850627d588..e53aa8d468 100644 --- a/monai/optimizers/__init__.py +++ b/monai/optimizers/__init__.py @@ -9,5 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .lr_finder import LearningRateFinder from .novograd import Novograd from .utils import generate_param_groups diff --git a/monai/optimizers/lr_finder.py b/monai/optimizers/lr_finder.py new file mode 100644 index 0000000000..6ad4132dd0 --- /dev/null +++ b/monai/optimizers/lr_finder.py @@ -0,0 +1,531 @@ +import warnings +from functools import partial +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union + +import numpy as np +import torch +import torch.nn as nn +from numpy.core.arrayprint import _none_or_positive_arg +from torch.optim import Optimizer +from torch.utils.data import DataLoader + +from monai.networks.utils import eval_mode +from monai.optimizers.lr_scheduler import ExponentialLR, LinearLR +from monai.utils import StateCacher, copy_to_device, optional_import + +if TYPE_CHECKING: + import matplotlib.pyplot as plt + + has_matplotlib = True + import tqdm + + has_tqdm = True +else: + plt, has_matplotlib = optional_import("matplotlib.pyplot") + tqdm, has_tqdm = optional_import("tqdm") + +__all__ = ["LearningRateFinder"] + + +class DataLoaderIter: + def __init__(self, data_loader: DataLoader, image_extractor: Callable, label_extractor: Callable) -> None: + if not isinstance(data_loader, DataLoader): + raise ValueError( + f"Loader has unsupported type: {type(data_loader)}. Expected type was `torch.utils.data.DataLoader`" + ) + self.data_loader = data_loader + self._iterator = iter(data_loader) + self.image_extractor = image_extractor + self.label_extractor = label_extractor + + @property + def dataset(self): + return self.data_loader.dataset + + def inputs_labels_from_batch(self, batch_data): + images = self.image_extractor(batch_data) + labels = self.label_extractor(batch_data) + return images, labels + + def __iter__(self): + return self + + def __next__(self): + batch = next(self._iterator) + return self.inputs_labels_from_batch(batch) + + +class TrainDataLoaderIter(DataLoaderIter): + def __init__( + self, data_loader: DataLoader, image_extractor: Callable, label_extractor: Callable, auto_reset: bool = True + ) -> None: + super().__init__(data_loader, image_extractor, label_extractor) + self.auto_reset = auto_reset + + def __next__(self): + try: + batch = next(self._iterator) + inputs, labels = self.inputs_labels_from_batch(batch) + except StopIteration: + if not self.auto_reset: + raise + self._iterator = iter(self.data_loader) + batch = next(self._iterator) + inputs, labels = self.inputs_labels_from_batch(batch) + + return inputs, labels + + +class ValDataLoaderIter(DataLoaderIter): + """This iterator will reset itself **only** when it is acquired by + the syntax of normal `iterator`. That is, this iterator just works + like a `torch.data.DataLoader`. If you want to restart it, you + should use it like: + + ``` + loader_iter = ValDataLoaderIter(data_loader) + for batch in loader_iter: + ... + + # `loader_iter` should run out of values now, you can restart it by: + # 1. the way we use a `torch.data.DataLoader` + for batch in loader_iter: # __iter__ is called implicitly + ... + + # 2. passing it into `iter()` manually + loader_iter = iter(loader_iter) # __iter__ is called by `iter()` + ``` + """ + + def __init__(self, data_loader: DataLoader, image_extractor: Callable, label_extractor: Callable) -> None: + super().__init__(data_loader, image_extractor, label_extractor) + self.run_limit = len(self.data_loader) + self.run_counter = 0 + + def __iter__(self): + if self.run_counter >= self.run_limit: + self._iterator = iter(self.data_loader) + self.run_counter = 0 + return self + + def __next__(self): + self.run_counter += 1 + return super(ValDataLoaderIter, self).__next__() + + +def default_image_extractor(x: Any) -> torch.Tensor: + """Default callable for getting image from batch data.""" + out: torch.Tensor = x["image"] if isinstance(x, dict) else x[0] + return out + + +def default_label_extractor(x: Any) -> torch.Tensor: + """Default callable for getting label from batch data.""" + out: torch.Tensor = x["label"] if isinstance(x, dict) else x[1] + return out + + +class LearningRateFinder: + """Learning rate range test. + + The learning rate range test increases the learning rate in a pre-training run + between two boundaries in a linear or exponential manner. It provides valuable + information on how well the network can be trained over a range of learning rates + and what is the optimal learning rate. + + Example (fastai approach): + >>> lr_finder = LearningRateFinder(net, optimizer, criterion) + >>> lr_finder.range_test(data_loader, end_lr=100, num_iter=100) + >>> lr_finder.get_steepest_gradient() + >>> lr_finder.plot() # to inspect the loss-learning rate graph + + Example (Leslie Smith's approach): + >>> lr_finder = LearningRateFinder(net, optimizer, criterion) + >>> lr_finder.range_test(train_loader, val_loader=val_loader, end_lr=1, num_iter=100, step_mode="linear") + + Gradient accumulation is supported; example: + >>> train_data = ... # prepared dataset + >>> desired_bs, real_bs = 32, 4 # batch size + >>> accumulation_steps = desired_bs // real_bs # required steps for accumulation + >>> data_loader = torch.utils.data.DataLoader(train_data, batch_size=real_bs, shuffle=True) + >>> acc_lr_finder = LearningRateFinder(net, optimizer, criterion) + >>> acc_lr_finder.range_test(data_loader, end_lr=10, num_iter=100, accumulation_steps=accumulation_steps) + + By default, image will be extracted from data loader with x["image"] and x[0], depending on whether + batch data is a dictionary or not (and similar behaviour for extracting the label). If your data loader + returns something other than this, pass a callable function to extract it, e.g.: + >>> image_extractor = lambda x: x["input"] + >>> label_extractor = lambda x: x[100] + >>> lr_finder = LearningRateFinder(net, optimizer, criterion) + >>> lr_finder.range_test(train_loader, val_loader, image_extractor, label_extractor) + + References: + Modified from: https://github.com/davidtvs/pytorch-lr-finder. + Cyclical Learning Rates for Training Neural Networks: https://arxiv.org/abs/1506.01186 + """ + + def __init__( + self, + model: nn.Module, + optimizer: Optimizer, + criterion: torch.nn.Module, + device: Optional[Union[str, torch.device]] = None, + memory_cache: bool = True, + cache_dir: Optional[str] = None, + amp: bool = False, + verbose: bool = True, + ) -> None: + """Constructor. + + Args: + model: wrapped model. + optimizer: wrapped optimizer. + criterion: wrapped loss function. + device: device on which to test. run a string ("cpu" or "cuda") with an + optional ordinal for the device type (e.g. "cuda:X", where is the ordinal). + Alternatively, can be an object representing the device on which the + computation will take place. Default: None, uses the same device as `model`. + memory_cache: if this flag is set to True, `state_dict` of + model and optimizer will be cached in memory. Otherwise, they will be saved + to files under the `cache_dir`. + cache_dir: path for storing temporary files. If no path is + specified, system-wide temporary directory is used. Notice that this + parameter will be ignored if `memory_cache` is True. + amp: use Automatic Mixed Precision + verbose: verbose output + Returns: + None + """ + # Check if the optimizer is already attached to a scheduler + self.optimizer = optimizer + self._check_for_scheduler() + + self.model = model + self.criterion = criterion + self.history: Dict[str, list] = {"lr": [], "loss": []} + self.memory_cache = memory_cache + self.cache_dir = cache_dir + self.amp = amp + self.verbose = verbose + + # Save the original state of the model and optimizer so they can be restored if + # needed + self.model_device = next(self.model.parameters()).device + self.state_cacher = StateCacher(memory_cache, cache_dir=cache_dir) + self.state_cacher.store("model", self.model.state_dict()) + self.state_cacher.store("optimizer", self.optimizer.state_dict()) + + # If device is None, use the same as the model + self.device = device if device else self.model_device + + def reset(self) -> None: + """Restores the model and optimizer to their initial states.""" + + self.model.load_state_dict(self.state_cacher.retrieve("model")) + self.optimizer.load_state_dict(self.state_cacher.retrieve("optimizer")) + self.model.to(self.model_device) + + def range_test( + self, + train_loader: DataLoader, + val_loader: Optional[DataLoader] = None, + image_extractor: Callable = default_image_extractor, + label_extractor: Callable = default_label_extractor, + start_lr: Optional[float] = None, + end_lr: int = 10, + num_iter: int = 100, + step_mode: str = "exp", + smooth_f: float = 0.05, + diverge_th: int = 5, + accumulation_steps: int = 1, + non_blocking_transfer: bool = True, + auto_reset: bool = True, + ) -> None: + """Performs the learning rate range test. + + Args: + train_loader: training set data loader. + val_loader: validation data loader (if desired). + image_extractor: callable function to get the image from a batch of data. + Default: `x["image"] if isinstance(x, dict) else x[0]`. + label_extractor: callable function to get the label from a batch of data. + Default: `x["label"] if isinstance(x, dict) else x[1]`. + start_lr : the starting learning rate for the range test. + The default is the optimizer's learning rate. + end_lr: the maximum learning rate to test. The test may stop earlier than + this if the result starts diverging. + num_iter: the max number of iterations for test. + step_mode: schedule for increasing learning rate: (`linear` or `exp`). + smooth_f: the loss smoothing factor within the `[0, 1[` interval. Disabled + if set to `0`, otherwise loss is smoothed using exponential smoothing. + diverge_th: test is stopped when loss surpasses threshold: + `diverge_th * best_loss`. + accumulation_steps: steps for gradient accumulation. If set to `1`, + gradients are not accumulated. + non_blocking_transfer: when `True`, moves data to device asynchronously if + possible, e.g., moving CPU Tensors with pinned memory to CUDA devices. + auto_reset: if `True`, returns model and optimizer to original states at end + of test. + Returns: + None + """ + + # Reset test results + self.history = {"lr": [], "loss": []} + best_loss = -float("inf") + + # Move the model to the proper device + self.model.to(self.device) + + # Check if the optimizer is already attached to a scheduler + self._check_for_scheduler() + + # Set the starting learning rate + if start_lr: + self._set_learning_rate(start_lr) + + # Check number of iterations + if num_iter <= 1: + raise ValueError("`num_iter` must be larger than 1") + + # Initialize the proper learning rate policy + lr_schedule: Union[ExponentialLR, LinearLR] + if step_mode.lower() == "exp": + lr_schedule = ExponentialLR(self.optimizer, end_lr, num_iter) + elif step_mode.lower() == "linear": + lr_schedule = LinearLR(self.optimizer, end_lr, num_iter) + else: + raise ValueError(f"expected one of (exp, linear), got {step_mode}") + + if smooth_f < 0 or smooth_f >= 1: + raise ValueError("smooth_f is outside the range [0, 1[") + + # Create an iterator to get data batch by batch + train_iter = TrainDataLoaderIter(train_loader, image_extractor, label_extractor) + if val_loader: + val_iter = ValDataLoaderIter(val_loader, image_extractor, label_extractor) + + trange: Union[partial[tqdm.trange], Type[range]] + if self.verbose and has_tqdm: + trange = partial(tqdm.trange, desc="Computing optimal learning rate") + tprint = tqdm.tqdm.write + else: + trange = range + tprint = print + + for iteration in trange(num_iter): + if self.verbose and not has_tqdm: + print(f"Computing optimal learning rate, iteration {iteration + 1}/{num_iter}") + + # Train on batch and retrieve loss + loss = self._train_batch( + train_iter, + accumulation_steps, + non_blocking_transfer=non_blocking_transfer, + ) + if val_loader: + loss = self._validate(val_iter, non_blocking_transfer=non_blocking_transfer) + + # Update the learning rate + self.history["lr"].append(lr_schedule.get_lr()[0]) + lr_schedule.step() + + # Track the best loss and smooth it if smooth_f is specified + if iteration == 0: + best_loss = loss + else: + if smooth_f > 0: + loss = smooth_f * loss + (1 - smooth_f) * self.history["loss"][-1] + if loss < best_loss: + best_loss = loss + + # Check if the loss has diverged; if it has, stop the test + self.history["loss"].append(loss) + if loss > diverge_th * best_loss: + if self.verbose: + tprint("Stopping early, the loss has diverged") + break + + if auto_reset: + if self.verbose: + print("Resetting model and optimizer") + self.reset() + + def _set_learning_rate(self, new_lrs: Union[float, list]) -> None: + """Set learning rate(s) for optimizer.""" + if not isinstance(new_lrs, list): + new_lrs = [new_lrs] * len(self.optimizer.param_groups) + if len(new_lrs) != len(self.optimizer.param_groups): + raise ValueError( + "Length of `new_lrs` is not equal to the number of parameter groups " + "in the given optimizer" + ) + + for param_group, new_lr in zip(self.optimizer.param_groups, new_lrs): + param_group["lr"] = new_lr + + def _check_for_scheduler(self) -> _none_or_positive_arg: + """Check optimizer doesn't already have scheduler.""" + for param_group in self.optimizer.param_groups: + if "initial_lr" in param_group: + raise RuntimeError("Optimizer already has a scheduler attached to it") + + def _train_batch(self, train_iter, accumulation_steps: int, non_blocking_transfer: bool = True) -> float: + self.model.train() + total_loss = 0 + + self.optimizer.zero_grad() + for i in range(accumulation_steps): + inputs, labels = next(train_iter) + inputs, labels = copy_to_device([inputs, labels], device=self.device, non_blocking=non_blocking_transfer) + + # Forward pass + outputs = self.model(inputs) + loss = self.criterion(outputs, labels) + + # Loss should be averaged in each step + loss /= accumulation_steps + + # Backward pass + if self.amp and hasattr(self.optimizer, "_amp_stash"): + # For minor performance optimization, see also: + # https://nvidia.github.io/apex/advanced.html#gradient-accumulation-across-iterations + delay_unscale = ((i + 1) % accumulation_steps) != 0 + + with torch.cuda.amp.scale_loss(loss, self.optimizer, delay_unscale=delay_unscale) as scaled_loss: # type: ignore + scaled_loss.backward() + else: + loss.backward() + + total_loss += loss.item() + + self.optimizer.step() + + return total_loss + + def _validate(self, val_iter: ValDataLoaderIter, non_blocking_transfer: bool = True) -> float: + # Set model to evaluation mode and disable gradient computation + running_loss = 0 + with eval_mode(self.model): + for inputs, labels in val_iter: + # Copy data to the correct device + inputs, labels = copy_to_device( + [inputs, labels], device=self.device, non_blocking=non_blocking_transfer + ) + + # Forward pass and loss computation + outputs = self.model(inputs) + loss = self.criterion(outputs, labels) + running_loss += loss.item() * len(labels) + + return running_loss / len(val_iter.dataset) + + def get_lrs_and_losses( + self, + skip_start: int = 0, + skip_end: int = 0, + ) -> Tuple[list, list]: + """Get learning rates and their corresponding losses + + Args: + skip_start: number of batches to trim from the start. + skip_end: number of batches to trim from the end. + """ + if skip_start < 0: + raise ValueError("skip_start cannot be negative") + if skip_end < 0: + raise ValueError("skip_end cannot be negative") + + lrs = self.history["lr"] + losses = self.history["loss"] + end_idx = len(lrs) - skip_end - 1 + lrs = lrs[skip_start:end_idx] + losses = losses[skip_start:end_idx] + + return lrs, losses + + def get_steepest_gradient( + self, + skip_start: int = 0, + skip_end: int = 0, + ) -> Union[Tuple[float, float], Tuple[None, None]]: + """Get learning rate which has steepest gradient and its corresponding loss + + Args: + skip_start: number of batches to trim from the start. + skip_end: number of batches to trim from the end. + + Returns: + Learning rate which has steepest gradient and its corresponding loss + """ + lrs, losses = self.get_lrs_and_losses(skip_start, skip_end) + + try: + min_grad_idx = np.gradient(np.array(losses)).argmin() + return lrs[min_grad_idx], losses[min_grad_idx] + except ValueError: + print("Failed to compute the gradients, there might not be enough points.") + return None, None + + def plot( + self, + skip_start: int = 0, + skip_end: int = 0, + log_lr: bool = True, + ax=None, + steepest_lr: bool = True, + ): + """Plots the learning rate range test. + + Args: + skip_start: number of batches to trim from the start. + skip_end: number of batches to trim from the start. + log_lr: True to plot the learning rate in a logarithmic + scale; otherwise, plotted in a linear scale. + ax: the plot is created in the specified matplotlib axes object and the + figure is not be shown. If `None`, then the figure and axes object are + created in this method and the figure is shown. + steepest_lr: plot the learning rate which had the steepest gradient. + + Returns: + The `matplotlib.axes.Axes` object that contains the plot. Returns `None` if + `matplotlib` is not installed. + """ + if not has_matplotlib: + warnings.warn("Matplotlib is missing, can't plot result") + return None + + lrs, losses = self.get_lrs_and_losses(skip_start, skip_end) + + # Create the figure and axes object if axes was not already given + fig = None + if ax is None: + fig, ax = plt.subplots() + + # Plot loss as a function of the learning rate + ax.plot(lrs, losses) + + # Plot the LR with steepest gradient + if steepest_lr: + lr_at_steepest_grad, loss_at_steepest_grad = self.get_steepest_gradient(skip_start, skip_end) + if lr_at_steepest_grad is not None: + ax.scatter( + lr_at_steepest_grad, + loss_at_steepest_grad, + s=75, + marker="o", + color="red", + zorder=3, + label="steepest gradient", + ) + ax.legend() + + if log_lr: + ax.set_xscale("log") + ax.set_xlabel("Learning rate") + ax.set_ylabel("Loss") + + # Show only if the figure was created internally + if fig is not None: + plt.show() + + return ax diff --git a/monai/optimizers/lr_scheduler.py b/monai/optimizers/lr_scheduler.py new file mode 100644 index 0000000000..aa9bf2a89b --- /dev/null +++ b/monai/optimizers/lr_scheduler.py @@ -0,0 +1,43 @@ +from torch.optim import Optimizer +from torch.optim.lr_scheduler import _LRScheduler + +__all__ = ["LinearLR", "ExponentialLR"] + + +class _LRSchedulerMONAI(_LRScheduler): + """Base class for increasing the learning rate between two boundaries over a number + of iterations""" + + def __init__(self, optimizer: Optimizer, end_lr: float, num_iter: int, last_epoch: int = -1) -> None: + """ + Args: + optimizer: wrapped optimizer. + end_lr: the final learning rate. + num_iter: the number of iterations over which the test occurs. + last_epoch: the index of last epoch. + Returns: + None + """ + self.end_lr = end_lr + self.num_iter = num_iter + super(_LRSchedulerMONAI, self).__init__(optimizer, last_epoch) + + +class LinearLR(_LRSchedulerMONAI): + """Linearly increases the learning rate between two boundaries over a number of + iterations. + """ + + def get_lr(self): + r = self.last_epoch / (self.num_iter - 1) + return [base_lr + r * (self.end_lr - base_lr) for base_lr in self.base_lrs] + + +class ExponentialLR(_LRSchedulerMONAI): + """Exponentially increases the learning rate between two boundaries over a number of + iterations. + """ + + def get_lr(self): + r = self.last_epoch / (self.num_iter - 1) + return [base_lr * (self.end_lr / base_lr) ** r for base_lr in self.base_lrs] diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 9bb25d723a..e5567f9f16 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -32,6 +32,7 @@ ) from .misc import ( MAX_SEED, + copy_to_device, dtype_numpy_to_torch, dtype_torch_to_numpy, ensure_tuple, @@ -64,3 +65,4 @@ optional_import, ) 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/misc.py b/monai/utils/misc.py index bf1ff60cbc..2b31392a46 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -10,11 +10,14 @@ # limitations under the License. import collections.abc +import inspect import itertools import random +import types +import warnings from ast import literal_eval from distutils.util import strtobool -from typing import Any, Callable, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Optional, Sequence, Tuple, Union, cast import numpy as np import torch @@ -37,6 +40,7 @@ "dtype_torch_to_numpy", "dtype_numpy_to_torch", "MAX_SEED", + "copy_to_device", ] _seed = None @@ -306,3 +310,40 @@ def dtype_torch_to_numpy(dtype): def dtype_numpy_to_torch(dtype): """Convert a numpy dtype to its torch equivalent.""" return _np_to_torch_dtype[dtype] + + +def copy_to_device( + obj: Any, + device: Optional[Union[str, torch.device]], + non_blocking: bool = True, + verbose: bool = False, +) -> Any: + """ + Copy object or tuple/list/dictionary of objects to ``device``. + + Args: + obj: object or tuple/list/dictionary of objects to move to ``device``. + device: move ``obj`` to this device. Can be a string (e.g., ``cpu``, ``cuda``, + ``cuda:0``, etc.) or of type ``torch.device``. + non_blocking_transfer: when `True`, moves data to device asynchronously if + possible, e.g., moving CPU Tensors with pinned memory to CUDA devices. + verbose: when `True`, will print a warning for any elements of incompatible type + not copied to ``device``. + Returns: + Same as input, copied to ``device`` where possible. Original input will be + unchanged. + """ + + if hasattr(obj, "to"): + return obj.to(device, non_blocking=non_blocking) + elif isinstance(obj, tuple): + return tuple(copy_to_device(o, device, non_blocking) for o in obj) + elif isinstance(obj, list): + return [copy_to_device(o, device, non_blocking) for o in obj] + elif isinstance(obj, dict): + return {k: copy_to_device(o, device, non_blocking) for k, o in obj.items()} + elif verbose: + fn_name = cast(types.FrameType, inspect.currentframe()).f_code.co_name + warnings.warn(f"{fn_name} called with incompatible type: " + f"{type(obj)}. Data will be returned unchanged.") + + return obj diff --git a/monai/utils/state_cacher.py b/monai/utils/state_cacher.py new file mode 100644 index 0000000000..66e9080724 --- /dev/null +++ b/monai/utils/state_cacher.py @@ -0,0 +1,92 @@ +import copy +import os +import tempfile +from typing import Dict, Optional + +import torch + +__all__ = ["StateCacher"] + + +class StateCacher: + """Class to cache and retrieve the state of an object. + + Objects can either be stored in memory or on disk. If stored on disk, they can be + stored in a given directory, or alternatively a temporary location will be used. + + If necessary/possible, restored objects will be returned to their original device. + + Example: + + >>> state_cacher = StateCacher(memory_cache, cache_dir=cache_dir) + >>> state_cacher.store("model", model.state_dict()) + >>> model.load_state_dict(state_cacher.retrieve("model")) + """ + + def __init__( + self, + in_memory: bool, + cache_dir: Optional[str] = None, + allow_overwrite: bool = True, + ) -> None: + """Constructor. + + Args: + in_memory: boolean to determine if the object will be cached in memory or on + disk. + cache_dir: directory for data to be cached if `in_memory==False`. Defaults + to using a temporary directory. Any created files will be deleted during + the `StateCacher`'s destructor. + allow_overwrite: allow the cache to be overwritten. If set to `False`, an + error will be thrown if a matching already exists in the list of cached + objects. + """ + self.in_memory = in_memory + self.cache_dir = cache_dir + self.allow_overwrite = allow_overwrite + + if self.cache_dir is None: + self.cache_dir = tempfile.gettempdir() + else: + if not os.path.isdir(self.cache_dir): + raise ValueError("Given `cache_dir` is not a valid directory.") + + self.cached: Dict[str, str] = {} + + def store(self, key, data_obj): + """Store a given object with the given key name.""" + if key in self.cached and not self.allow_overwrite: + raise RuntimeError("Cached key already exists and overwriting is disabled.") + if self.in_memory: + self.cached.update({key: {"obj": copy.deepcopy(data_obj)}}) + else: + fn = os.path.join(self.cache_dir, f"state_{key}_{id(self)}.pt") + self.cached.update({key: {"obj": fn}}) + torch.save(data_obj, fn) + # store object's device if relevant + if hasattr(data_obj, "device"): + self.cached[key]["device"] = data_obj.device + + def retrieve(self, key): + """Retrieve the object stored under a given key name.""" + if key not in self.cached: + raise KeyError(f"Target {key} was not cached.") + + if self.in_memory: + return self.cached[key]["obj"] + + fn = self.cached[key]["obj"] # pytype: disable=attribute-error + if not os.path.exists(fn): # pytype: disable=wrong-arg-types + raise RuntimeError(f"Failed to load state in {fn}. File doesn't exist anymore.") + data_obj = torch.load(fn, map_location=lambda storage, location: storage) + # copy back to device if necessary + if "device" in self.cached[key]: + data_obj = data_obj.to(self.cached[key]["device"]) + return data_obj + + def __del__(self): + """If necessary, delete any cached files existing in `cache_dir`.""" + if not self.in_memory: + for k in self.cached: + if os.path.exists(self.cached[k]["obj"]): + os.remove(self.cached[k]["obj"]) diff --git a/tests/test_lr_finder.py b/tests/test_lr_finder.py new file mode 100644 index 0000000000..9ee9c8a4d0 --- /dev/null +++ b/tests/test_lr_finder.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 os +import random +import sys +import unittest + +import torch +from torch.utils.data import DataLoader + +from monai.apps import MedNISTDataset +from monai.networks.nets import DenseNet +from monai.optimizers import LearningRateFinder +from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd, ToTensord +from monai.utils import optional_import, set_determinism + +PILImage, has_pil = optional_import("PIL.Image") + +RAND_SEED = 42 +random.seed(RAND_SEED) +set_determinism(seed=RAND_SEED) + +device = "cuda" if torch.cuda.is_available() else "cpu" + + +@unittest.skipUnless(sys.platform == "linux", "requires linux") +@unittest.skipUnless(has_pil, "requires PIL") +class TestLRFinder(unittest.TestCase): + def setUp(self): + + self.root_dir = os.environ.get("MONAI_DATA_DIRECTORY") + if not self.root_dir: + self.root_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data") + + self.transforms = Compose( + [ + LoadImaged(keys="image"), + AddChanneld(keys="image"), + ScaleIntensityd(keys="image"), + ToTensord(keys="image"), + ] + ) + + def test_lr_finder(self): + # 0.001 gives 54 examples + train_ds = MedNISTDataset( + root_dir=self.root_dir, + transform=self.transforms, + section="validation", + val_frac=0.001, + download=True, + num_workers=10, + ) + train_loader = DataLoader(train_ds, batch_size=300, shuffle=True, num_workers=10) + num_classes = train_ds.get_num_classes() + + model = DenseNet( + spatial_dims=2, in_channels=1, out_channels=num_classes, init_features=2, growth_rate=2, block_config=(2,) + ) + loss_function = torch.nn.CrossEntropyLoss() + learning_rate = 1e-5 + optimizer = torch.optim.Adam(model.parameters(), learning_rate) + + lr_finder = LearningRateFinder(model, optimizer, loss_function, device=device) + lr_finder.range_test(train_loader, val_loader=train_loader, end_lr=10, num_iter=5) + print(lr_finder.get_steepest_gradient(0, 0)[0]) + lr_finder.plot(0, 0) # to inspect the loss-learning rate graph + lr_finder.reset() # to reset the model and optimizer to their initial state + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_state_cacher.py b/tests/test_state_cacher.py new file mode 100644 index 0000000000..139e7b8374 --- /dev/null +++ b/tests/test_state_cacher.py @@ -0,0 +1,68 @@ +# 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 os.path import exists, join +from tempfile import gettempdir + +import torch +from parameterized import parameterized + +from monai.utils import StateCacher + +DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu" + +TEST_CASE_0 = [ + torch.Tensor([1]).to(DEVICE), + {"in_memory": True}, +] +TEST_CASE_1 = [ + torch.Tensor([1]).to(DEVICE), + {"in_memory": False, "cache_dir": gettempdir()}, +] +TEST_CASE_2 = [ + torch.Tensor([1]).to(DEVICE), + {"in_memory": False, "allow_overwrite": False}, +] + +TEST_CASES = [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2] + + +class TestStateCacher(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_state_cacher(self, data_obj, params): + + key = "data_obj" + + state_cacher = StateCacher(**params) + # store it + state_cacher.store(key, data_obj) + # create clone then modify original + data_obj_orig = data_obj.clone() + data_obj += 1 + # Restore and check nothing has changed + data_obj_restored = state_cacher.retrieve(key) + self.assertEqual(data_obj_orig, data_obj_restored) + + # If not allow overwrite, check an attempt would raise exception + if "allow_overwrite" in params and params["allow_overwrite"]: + with self.assertRaises(RuntimeError): + state_cacher.store(key, data_obj) + + # If using a cache dir, check file has been deleted et end + if "cache_dir" in params: + i = id(state_cacher) + del state_cacher + self.assertFalse(exists(join(params["cache_dir"], f"state_{key}_{i}.pt"))) + + +if __name__ == "__main__": + unittest.main() From 8fef0e12b4b79aa124b9ba85f8dd8f452baad1cf Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 26 Jan 2021 13:52:24 +0000 Subject: [PATCH 09/19] 1501-enhance-PR-testing-workflow (#1502) * test new action for quick test; add temp tests Signed-off-by: Wenqi Li * testing action Signed-off-by: Wenqi Li * remove temp test Signed-off-by: Wenqi Li --- .github/workflows/pythonapp.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 8e92ea0ed7..7eef0267dd 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -15,6 +15,10 @@ jobs: flake8-py3: runs-on: ubuntu-latest steps: + - name: Cancel Previous Jobs + uses: styfle/cancel-workflow-action@0.7.0 + with: + access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python 3.8 uses: actions/setup-python@v2 @@ -184,6 +188,10 @@ jobs: options: --gpus all runs-on: [self-hosted, linux, x64, common] steps: + - name: Cancel Previous Self-hosted Runs + uses: styfle/cancel-workflow-action@0.7.0 + with: + access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: apt install run: | From 84826b9e849faaa9a43f8b87d329da166bdf3b41 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 26 Jan 2021 17:31:14 +0000 Subject: [PATCH 10/19] change email (#1510) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- .github/workflows/weekly-preview.yml | 2 +- CODE_OF_CONDUCT.md | 2 +- Dockerfile | 2 +- setup.cfg | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index 9b8d5c58b0..dddc03199c 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -25,7 +25,7 @@ jobs: echo "__commit_id__ = \"$HEAD_COMMIT_ID\"" >> monai/__init__.py git diff setup.cfg monai/__init__.py git config user.name "CI Builder" - git config user.email "monai.miccai2019@gmail.com" + git config user.email "monai.contact@gmail.com" git add setup.cfg monai/__init__.py git commit -m "Weekly build at $HEAD_COMMIT_ID" export YEAR_WEEK=$(date +'%y%U') diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index c5a63e4364..c6f6fda20a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at monai.miccai2019@gmail.com. All +reported by contacting the project team at monai.contact@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/Dockerfile b/Dockerfile index 2d01f57301..a600f9de84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:20.10-py3 FROM ${PYTORCH_IMAGE} -LABEL maintainer="monai.miccai2019@gmail.com" +LABEL maintainer="monai.contact@gmail.com" WORKDIR /opt/monai diff --git a/setup.cfg b/setup.cfg index a4793eebc6..aff62045e1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = monai author = MONAI Consortium -author_email = monai.miccai2019@gmail.com +author_email = monai.contact@gmail.com url = https://monai.io/ description = AI Toolkit for Healthcare Imaging long_description = file:README.md From 3025e8d2b58874635047c7446bdb237779fe9c64 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 26 Jan 2021 18:38:56 +0000 Subject: [PATCH 11/19] 1501 workflow cleanup (#1511) Signed-off-by: Wenqi Li --- .github/workflows/cleanup.yml | 20 ++++++++++++++++++++ .github/workflows/pythonapp.yml | 8 -------- .github/workflows/weekly-preview.yml | 1 + 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/cleanup.yml diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000000..f3d297286e --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,20 @@ +name: cleanup-workflow + +on: + workflow_run: + workflows: + - "build" + types: ["requested"] + +jobs: + cancel-duplicated-workflow: + name: "Cancel duplicated workflow" + runs-on: ubuntu-latest + steps: + - uses: potiuk/cancel-workflow-runs@953e057dc81d3458935a18d1184c386b0f6b5738 # tested + name: "Cancel duplicate workflows" + with: + cancelMode: allDuplicates + token: ${{ secrets.GITHUB_TOKEN }} + sourceRunId: ${{ github.event.workflow_run.id }} + skipEventTypes: '["schedule"]' diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 7eef0267dd..8e92ea0ed7 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -15,10 +15,6 @@ jobs: flake8-py3: runs-on: ubuntu-latest steps: - - name: Cancel Previous Jobs - uses: styfle/cancel-workflow-action@0.7.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python 3.8 uses: actions/setup-python@v2 @@ -188,10 +184,6 @@ jobs: options: --gpus all runs-on: [self-hosted, linux, x64, common] steps: - - name: Cancel Previous Self-hosted Runs - uses: styfle/cancel-workflow-action@0.7.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: apt install run: | diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index dddc03199c..bb68a0801d 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -6,6 +6,7 @@ on: jobs: packaging: + if: github.repository == 'Project-MONAI/MONAI' runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 From 8207e1e2a3555ddc3fe938e058552651900dc951 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 27 Jan 2021 20:57:06 +0800 Subject: [PATCH 12/19] Add more Events for MONAI workflow engines (#1474) * [DLMED] add more Events Signed-off-by: Nic Ma * [DLMED] add 3 Events Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] add tests Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] update according to comments Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/engines/__init__.py | 2 +- monai/engines/evaluator.py | 30 +++++++--- monai/engines/trainer.py | 29 +++++++--- monai/engines/utils.py | 36 +++++++++++- monai/engines/workflow.py | 88 ++++++++++++++++++----------- tests/test_integration_workflows.py | 32 ++++++++++- 6 files changed, 164 insertions(+), 53 deletions(-) diff --git a/monai/engines/__init__.py b/monai/engines/__init__.py index 7b926c8469..8256680735 100644 --- a/monai/engines/__init__.py +++ b/monai/engines/__init__.py @@ -12,4 +12,4 @@ from .evaluator import EnsembleEvaluator, Evaluator, SupervisedEvaluator from .multi_gpu_supervised_trainer import create_multigpu_supervised_evaluator, create_multigpu_supervised_trainer from .trainer import GanTrainer, SupervisedTrainer, Trainer -from .utils import CommonKeys, GanKeys, default_make_latent, default_prepare_batch, get_devices_spec +from .utils import CommonKeys, GanKeys, IterationEvents, default_make_latent, default_prepare_batch, get_devices_spec diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index e0ca59558e..0b7167fb3a 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -15,7 +15,7 @@ from torch.utils.data import DataLoader from monai.engines.utils import CommonKeys as Keys -from monai.engines.utils import default_prepare_batch +from monai.engines.utils import IterationEvents, default_prepare_batch from monai.engines.workflow import Workflow from monai.inferers import Inferer, SimpleInferer from monai.networks.utils import eval_mode @@ -164,6 +164,10 @@ def __init__( 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. @@ -190,15 +194,18 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]) -> Dict else: inputs, targets, args, kwargs = batch + # put iteration outputs into engine.state + engine.state.output = output = {Keys.IMAGE: inputs, Keys.LABEL: targets} # execute forward computation with eval_mode(self.network): if self.amp: with torch.cuda.amp.autocast(): - predictions = self.inferer(inputs, self.network, *args, **kwargs) + output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) else: - predictions = self.inferer(inputs, self.network, *args, **kwargs) + output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) + engine.fire_event(IterationEvents.FORWARD_COMPLETED) - return {Keys.IMAGE: inputs, Keys.LABEL: targets, Keys.PRED: predictions} + return output class EnsembleEvaluator(Evaluator): @@ -266,6 +273,10 @@ def __init__( 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. @@ -295,14 +306,15 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]) -> Dict else: inputs, targets, args, kwargs = batch - # execute forward computation - predictions = {Keys.IMAGE: inputs, Keys.LABEL: targets} + # put iteration outputs into engine.state + engine.state.output = output = {Keys.IMAGE: inputs, Keys.LABEL: targets} for idx, network in enumerate(self.networks): with eval_mode(network): if self.amp: with torch.cuda.amp.autocast(): - predictions.update({self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)}) + output.update({self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)}) else: - predictions.update({self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)}) + output.update({self.pred_keys[idx]: self.inferer(inputs, network, *args, **kwargs)}) + engine.fire_event(IterationEvents.FORWARD_COMPLETED) - return predictions + return output diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index 5d4f82b0af..efb2ab12fa 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -16,7 +16,7 @@ from torch.utils.data import DataLoader from monai.engines.utils import CommonKeys as Keys -from monai.engines.utils import GanKeys, default_make_latent, default_prepare_batch +from monai.engines.utils import GanKeys, IterationEvents, default_make_latent, default_prepare_batch from monai.engines.workflow import Workflow from monai.inferers import Inferer, SimpleInferer from monai.transforms import Transform @@ -121,6 +121,10 @@ 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. @@ -147,23 +151,32 @@ def _iteration(self, engine: Engine, batchdata: Dict[str, torch.Tensor]): kwargs: Dict = {} else: inputs, targets, args, kwargs = batch + # put iteration outputs into engine.state + engine.state.output = output = {Keys.IMAGE: inputs, Keys.LABEL: targets} + + def _compute_pred_loss(): + output[Keys.PRED] = self.inferer(inputs, self.network, *args, **kwargs) + engine.fire_event(IterationEvents.FORWARD_COMPLETED) + output[Keys.LOSS] = self.loss_function(output[Keys.PRED], targets).mean() + engine.fire_event(IterationEvents.LOSS_COMPLETED) self.network.train() self.optimizer.zero_grad() if self.amp and self.scaler is not None: with torch.cuda.amp.autocast(): - predictions = self.inferer(inputs, self.network, *args, **kwargs) - loss = self.loss_function(predictions, targets).mean() - self.scaler.scale(loss).backward() + _compute_pred_loss() + self.scaler.scale(output[Keys.LOSS]).backward() + engine.fire_event(IterationEvents.BACKWARD_COMPLETED) self.scaler.step(self.optimizer) self.scaler.update() else: - predictions = self.inferer(inputs, self.network, *args, **kwargs) - loss = self.loss_function(predictions, targets).mean() - loss.backward() + _compute_pred_loss() + output[Keys.LOSS].backward() + engine.fire_event(IterationEvents.BACKWARD_COMPLETED) self.optimizer.step() + engine.fire_event(IterationEvents.OPTIMIZER_COMPLETED) - return {Keys.IMAGE: inputs, Keys.LABEL: targets, Keys.PRED: predictions, Keys.LOSS: loss.item()} + return output class GanTrainer(Trainer): diff --git a/monai/engines/utils.py b/monai/engines/utils.py index 7a2dc40b8d..f603338097 100644 --- a/monai/engines/utils.py +++ b/monai/engines/utils.py @@ -9,11 +9,42 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union import torch -__all__ = ["CommonKeys", "GanKeys", "get_devices_spec", "default_prepare_batch", "default_make_latent"] +from monai.utils import exact_version, optional_import + +if TYPE_CHECKING: + from ignite.engine import EventEnum +else: + EventEnum, _ = optional_import("ignite.engine", "0.4.2", exact_version, "EventEnum") + +__all__ = [ + "IterationEvents", + "CommonKeys", + "GanKeys", + "get_devices_spec", + "default_prepare_batch", + "default_make_latent", +] + + +class IterationEvents(EventEnum): + """ + Addtional Events engine can register and trigger in the iteration process. + Refer to the example in ignite: https://github.com/pytorch/ignite/blob/master/ignite/engine/events.py#L146 + These Events can be triggered during training iteration: + `FORWARD_COMPLETED` is the Event when `network(image, label)` completed. + `LOSS_COMPLETED` is the Event when `loss(pred, label)` completed. + `BACKWARD_COMPLETED` is the Event when `loss.backward()` completed. + + """ + + FORWARD_COMPLETED = "forward_completed" + LOSS_COMPLETED = "loss_completed" + BACKWARD_COMPLETED = "backward_completed" + OPTIMIZER_COMPLETED = "optimizer_completed" class CommonKeys: @@ -36,6 +67,7 @@ class CommonKeys: class GanKeys: """ A set of common keys for generative adversarial networks. + """ REALS = "reals" diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 1d8c74c4bb..67fdacad4a 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -119,44 +119,68 @@ def set_sampler_epoch(engine: Engine): self.data_loader = data_loader self.non_blocking = non_blocking self.prepare_batch = prepare_batch + self.amp = amp + self._register_additional_events() if post_transform is not None: + self._register_post_transforms(post_transform) + if key_metric is not None: + self._register_metrics(key_metric, additional_metrics) + if handlers is not None: + self._register_handlers(handlers) - @self.on(Events.ITERATION_COMPLETED) - def run_post_transform(engine: Engine) -> None: - if post_transform is None: - raise AssertionError - engine.state.output = apply_transform(post_transform, engine.state.output) + def _register_additional_events(self): + """ + Register more ignite Events to the engine. - if key_metric is not None: + """ + pass - if not isinstance(key_metric, dict): - raise TypeError(f"key_metric must be None or a dict but is {type(key_metric).__name__}.") - self.state.key_metric_name = list(key_metric.keys())[0] - metrics = key_metric - if additional_metrics is not None and len(additional_metrics) > 0: - if not isinstance(additional_metrics, dict): - raise TypeError( - f"additional_metrics must be None or a dict but is {type(additional_metrics).__name__}." - ) - metrics.update(additional_metrics) - for name, metric in metrics.items(): - metric.attach(self, name) - - @self.on(Events.EPOCH_COMPLETED) - def _compare_metrics(engine: Engine) -> None: - if engine.state.key_metric_name is not None: - current_val_metric = engine.state.metrics[engine.state.key_metric_name] - if current_val_metric > engine.state.best_metric: - self.logger.info(f"Got new best metric of {engine.state.key_metric_name}: {current_val_metric}") - engine.state.best_metric = current_val_metric - engine.state.best_metric_epoch = engine.state.epoch + def _register_post_transforms(self, posttrans): + """ + Register the post transforms to the engine, will execute them as a chain when iteration completed. - if handlers is not None: - handlers_ = ensure_tuple(handlers) - for handler in handlers_: - handler.attach(self) - self.amp = amp + """ + + @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): + """ + Register the key metric and additional metrics to the engine, supports ignite Metrics. + + """ + if not isinstance(k_metric, dict): + raise TypeError(f"key_metric must be None or a dict but is {type(k_metric).__name__}.") + self.state.key_metric_name = list(k_metric.keys())[0] + metrics = k_metric + if add_metrics is not None and len(add_metrics) > 0: + if not isinstance(add_metrics, dict): + raise TypeError(f"additional metrics must be None or a dict but is {type(add_metrics).__name__}.") + metrics.update(add_metrics) + for name, metric in metrics.items(): + metric.attach(self, name) + + @self.on(Events.EPOCH_COMPLETED) + def _compare_metrics(engine: Engine) -> None: + if engine.state.key_metric_name is not None: + current_val_metric = engine.state.metrics[engine.state.key_metric_name] + if current_val_metric > engine.state.best_metric: + self.logger.info(f"Got new best metric of {engine.state.key_metric_name}: {current_val_metric}") + engine.state.best_metric = current_val_metric + engine.state.best_metric_epoch = engine.state.epoch + + def _register_handlers(self, handlers): + """ + Register the handlers to the engine, supports ignite Handlers with `attach` API. + + """ + handlers_ = ensure_tuple(handlers) + for handler in handlers_: + handler.attach(self) def run(self) -> None: """ diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py index 124224ec3f..aa4ccbb76d 100644 --- a/tests/test_integration_workflows.py +++ b/tests/test_integration_workflows.py @@ -25,7 +25,7 @@ import monai from monai.data import create_test_image_3d -from monai.engines import SupervisedEvaluator, SupervisedTrainer +from monai.engines import IterationEvents, SupervisedEvaluator, SupervisedTrainer from monai.handlers import ( CheckpointLoader, CheckpointSaver, @@ -113,6 +113,14 @@ def run_training_test(root_dir, device="cuda:0", amp=False, num_workers=4): KeepLargestConnectedComponentd(keys="pred", applied_labels=[1]), ] ) + + class _TestEvalIterEvents: + def attach(self, engine): + engine.add_event_handler(IterationEvents.FORWARD_COMPLETED, self._forward_completed) + + def _forward_completed(self, engine): + pass + val_handlers = [ StatsHandler(output_transform=lambda x: None), TensorBoardStatsHandler(log_dir=root_dir, output_transform=lambda x: None), @@ -120,6 +128,7 @@ def run_training_test(root_dir, device="cuda:0", amp=False, num_workers=4): log_dir=root_dir, batch_transform=lambda x: (x["image"], x["label"]), output_transform=lambda x: x["pred"] ), CheckpointSaver(save_dir=root_dir, save_dict={"net": net}, save_key_metric=True), + _TestEvalIterEvents(), ] evaluator = SupervisedEvaluator( @@ -143,12 +152,33 @@ def run_training_test(root_dir, device="cuda:0", amp=False, num_workers=4): KeepLargestConnectedComponentd(keys="pred", applied_labels=[1]), ] ) + + class _TestTrainIterEvents: + def attach(self, engine): + engine.add_event_handler(IterationEvents.FORWARD_COMPLETED, self._forward_completed) + engine.add_event_handler(IterationEvents.LOSS_COMPLETED, self._loss_completed) + engine.add_event_handler(IterationEvents.BACKWARD_COMPLETED, self._backward_completed) + engine.add_event_handler(IterationEvents.OPTIMIZER_COMPLETED, self._optimizer_completed) + + def _forward_completed(self, engine): + pass + + def _loss_completed(self, engine): + pass + + def _backward_completed(self, engine): + pass + + def _optimizer_completed(self, engine): + pass + train_handlers = [ LrScheduleHandler(lr_scheduler=lr_scheduler, print_lr=True), ValidationHandler(validator=evaluator, interval=2, epoch_level=True), StatsHandler(tag_name="train_loss", output_transform=lambda x: x["loss"]), TensorBoardStatsHandler(log_dir=root_dir, tag_name="train_loss", output_transform=lambda x: x["loss"]), CheckpointSaver(save_dir=root_dir, save_dict={"net": net, "opt": opt}, save_interval=2, epoch_level=True), + _TestTrainIterEvents(), ] trainer = SupervisedTrainer( From f75b67a30b052ddb0462183f2e51b09d151845f2 Mon Sep 17 00:00:00 2001 From: Yiwen Li <44606435+kate-sann5100@users.noreply.github.com> Date: Thu, 28 Jan 2021 09:05:33 +0000 Subject: [PATCH 13/19] 1452 adjust-warp-layer-grid-pull-user-case (#1470) * 1442 use pull-grid only for above linear interpolation Signed-off-by: kate-sann5100 --- monai/networks/blocks/warp.py | 64 ++++++++++++++++++++--------------- tests/test_warp.py | 51 +++++++--------------------- 2 files changed, 49 insertions(+), 66 deletions(-) diff --git a/monai/networks/blocks/warp.py b/monai/networks/blocks/warp.py index 56b289e394..60e23f6750 100644 --- a/monai/networks/blocks/warp.py +++ b/monai/networks/blocks/warp.py @@ -4,9 +4,7 @@ from torch import nn from torch.nn import functional as F -from monai.config import USE_COMPILED -from monai.networks.layers import grid_pull -from monai.utils import GridSampleMode, GridSamplePadMode +from monai.utils import GridSamplePadMode class Warp(nn.Module): @@ -17,30 +15,38 @@ class Warp(nn.Module): def __init__( self, spatial_dims: int, - mode: Optional[Union[GridSampleMode, str]] = GridSampleMode.BILINEAR, + mode: int = 1, padding_mode: Optional[Union[GridSamplePadMode, str]] = GridSamplePadMode.ZEROS, ): """ Args: spatial_dims: {2, 3}. number of spatial dimensions - mode: {``"bilinear"``, ``"nearest"``} - Interpolation mode to calculate output values. Defaults to ``"bilinear"``. - See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample + mode: interpolation mode to calculate output values, defaults to 1. + Possible values are:: + + - 0 or 'nearest' or InterpolationType.nearest + - 1 or 'linear' or InterpolationType.linear + - 2 or 'quadratic' or InterpolationType.quadratic + - 3 or 'cubic' or InterpolationType.cubic + - 4 or 'fourth' or InterpolationType.fourth + - etc. padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``} Padding mode for outside grid values. Defaults to ``"border"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample """ super(Warp, self).__init__() if spatial_dims not in [2, 3]: - raise ValueError(f"got unsupported spatial_dims = {spatial_dims}, only support 2-d and 3-d input") + raise ValueError(f"got unsupported spatial_dims={spatial_dims}, only support 2-d and 3-d input") self.spatial_dims = spatial_dims - self.mode: GridSampleMode = GridSampleMode(mode) + if mode < 0: + raise ValueError(f"do not support negative mode, got mode={mode}") + self.mode = mode self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) @staticmethod def get_reference_grid(ddf: torch.Tensor) -> torch.Tensor: mesh_points = [torch.arange(0, dim) for dim in ddf.shape[2:]] - grid = torch.stack(torch.meshgrid(*mesh_points[::-1]), dim=0) # (spatial_dims, ...) + grid = torch.stack(torch.meshgrid(*mesh_points), dim=0) # (spatial_dims, ...) grid = torch.stack([grid] * ddf.shape[0], dim=0) # (batch, spatial_dims, ...) grid = grid.to(ddf) return grid @@ -77,27 +83,31 @@ def forward(self, image: torch.Tensor, ddf: torch.Tensor) -> torch.Tensor: grid = self.get_reference_grid(ddf) + ddf grid = grid.permute([0] + list(range(2, 2 + self.spatial_dims)) + [1]) # (batch, ..., self.spatial_dims) - if USE_COMPILED: - _padding_mode = self.padding_mode.value - if _padding_mode == "zeros": - bound = 7 - elif _padding_mode == "border": - bound = 0 - else: - bound = 1 - _interp_mode = self.mode.value - warped_image: torch.Tensor = grid_pull( - image, - grid, - bound=bound, - extrapolate=True, - interpolation=1 if _interp_mode == "bilinear" else _interp_mode, - ) + if self.mode > 1: + raise ValueError(f"{self.mode}-order interpolation not yet implemented.") + # if not USE_COMPILED: + # raise ValueError(f"cannot perform {self.mode}-order interpolation without C compile.") + # _padding_mode = self.padding_mode.value + # if _padding_mode == "zeros": + # bound = 7 + # elif _padding_mode == "border": + # bound = 0 + # else: + # bound = 1 + # warped_image: torch.Tensor = grid_pull( + # image, + # grid, + # bound=bound, + # extrapolate=True, + # interpolation=self.mode, + # ) else: grid = self.normalize_grid(grid) index_ordering: List[int] = list(range(self.spatial_dims - 1, -1, -1)) grid = grid[..., index_ordering] # z, y, x -> x, y, z + _interp_mode = "bilinear" if self.mode == 1 else "nearest" warped_image = F.grid_sample( - image, grid, mode=self.mode.value, padding_mode=self.padding_mode.value, align_corners=True + image, grid, mode=_interp_mode, padding_mode=self.padding_mode.value, align_corners=True ) + return warped_image diff --git a/tests/test_warp.py b/tests/test_warp.py index ba8bc9a994..69ae997e38 100644 --- a/tests/test_warp.py +++ b/tests/test_warp.py @@ -6,53 +6,22 @@ from monai.networks.blocks.warp import Warp -TEST_CASE = [ +LOW_POWER_TEST_CASES = [ [ - {"spatial_dims": 2, "mode": "bilinear", "padding_mode": "zeros"}, + {"spatial_dims": 2, "mode": 0, "padding_mode": "zeros"}, {"image": torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), "ddf": torch.zeros(1, 2, 2, 2)}, torch.arange(4).reshape((1, 1, 2, 2)), ], [ - {"spatial_dims": 2, "mode": "nearest", "padding_mode": "zeros"}, - {"image": torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), "ddf": torch.zeros(1, 2, 4, 4)}, - torch.tensor([[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 2.0, 3.0, 0.0], [0.0, 0.0, 0.0, 0.0]]) - .unsqueeze(0) - .unsqueeze(0), - ], - [ - {"spatial_dims": 2, "mode": "bilinear", "padding_mode": "border"}, - {"image": torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), "ddf": torch.zeros(1, 2, 4, 4)}, - torch.tensor([[0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0], [2.0, 2.0, 3, 3.0], [2.0, 2.0, 3.0, 3.0]]) - .unsqueeze(0) - .unsqueeze(0), - ], - [ - {"spatial_dims": 2, "mode": "nearest", "padding_mode": "reflection"}, - {"image": torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), "ddf": torch.zeros(1, 2, 4, 4)}, - torch.tensor([[3.0, 2.0, 3.0, 2.0], [1.0, 0.0, 1.0, 0.0], [3.0, 2.0, 3.0, 2.0], [1.0, 0.0, 1.0, 0.0]]) - .unsqueeze(0) - .unsqueeze(0), - ], - [ - {"spatial_dims": 3, "mode": "bilinear", "padding_mode": "zeros"}, - {"image": torch.arange(8).reshape((1, 1, 2, 2, 2)).to(dtype=torch.float), "ddf": torch.zeros(1, 3, 2, 2, 2)}, - torch.arange(8).reshape((1, 1, 2, 2, 2)).to(dtype=torch.float), - ], -] - -TEST_CASES = [ - [ - {"spatial_dims": 2, "mode": "bilinear", "padding_mode": "zeros"}, - {"image": torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), "ddf": torch.zeros(1, 2, 2, 2)}, - torch.arange(4).reshape((1, 1, 2, 2)), - ], - [ - {"spatial_dims": 2, "mode": "bilinear", "padding_mode": "zeros"}, + {"spatial_dims": 2, "mode": 1, "padding_mode": "zeros"}, {"image": torch.arange(4).reshape((1, 1, 2, 2)).to(dtype=torch.float), "ddf": torch.ones(1, 2, 2, 2)}, torch.tensor([[[[3, 0], [0, 0]]]]), ], +] + +HIGH_POWER_TEST_CASES = [ [ - {"spatial_dims": 3, "mode": "nearest", "padding_mode": "border"}, + {"spatial_dims": 3, "mode": 2, "padding_mode": "border"}, { "image": torch.arange(8).reshape((1, 1, 2, 2, 2)).to(dtype=torch.float), "ddf": torch.ones(1, 3, 2, 2, 2) * -1, @@ -60,12 +29,16 @@ torch.tensor([[[[[0, 0], [0, 0]], [[0, 0], [0, 0]]]]]), ], [ - {"spatial_dims": 3, "mode": "nearest", "padding_mode": "reflection"}, + {"spatial_dims": 3, "mode": 3, "padding_mode": "reflection"}, {"image": torch.arange(8).reshape((1, 1, 2, 2, 2)).to(dtype=torch.float), "ddf": torch.ones(1, 3, 2, 2, 2)}, torch.tensor([[[[[7, 6], [5, 4]], [[3, 2], [1, 0]]]]]), ], ] +TEST_CASES = LOW_POWER_TEST_CASES +# if USE_COMPILED: +# TEST_CASES += HIGH_POWER_TEST_CASES + class TestWarp(unittest.TestCase): @parameterized.expand(TEST_CASES) From 50c46597d13c9c44501e60cad0e5cd65c2db7642 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 28 Jan 2021 21:12:07 +0800 Subject: [PATCH 14/19] [DLMED] add overwrite option (#1513) Signed-off-by: Nic Ma Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/transforms/utility/dictionary.py | 17 +++++++++++++---- tests/test_lambdad.py | 15 +++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 1427f24356..ef89dbe32d 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -599,18 +599,27 @@ class Lambdad(MapTransform): See also: :py:class:`monai.transforms.compose.MapTransform` func: Lambda/function to be applied. It also can be a sequence of Callable, each element corresponds to a key in ``keys``. + overwrite: whether to overwrite the original data in the input dictionary with lamdbda function output. + default to True. it also can be a sequence of bool, each element corresponds to a key in ``keys``. """ - def __init__(self, keys: KeysCollection, func: Union[Sequence[Callable], Callable]) -> None: + def __init__( + self, + keys: KeysCollection, + func: Union[Sequence[Callable], Callable], + overwrite: Union[Sequence[bool], bool] = True, + ) -> None: super().__init__(keys) self.func = ensure_tuple_rep(func, len(self.keys)) - self.lambd = Lambda() + self.overwrite = ensure_tuple_rep(overwrite, len(self.keys)) + self._lambd = Lambda() def __call__(self, data): d = dict(data) for idx, key in enumerate(self.keys): - d[key] = self.lambd(d[key], func=self.func[idx]) - + ret = self._lambd(d[key], func=self.func[idx]) + if self.overwrite[idx]: + d[key] = ret return d diff --git a/tests/test_lambdad.py b/tests/test_lambdad.py index 8f7e6b1133..ca28af778b 100644 --- a/tests/test_lambdad.py +++ b/tests/test_lambdad.py @@ -20,16 +20,15 @@ class TestLambdad(NumpyImageTestCase2D): def test_lambdad_identity(self): img = self.imt - data = {} - data["img"] = img + data = {"img": img, "prop": 1.0} - def identity_func(x): - return x + def noise_func(x): + return x + 1.0 - lambd = Lambdad(keys=data.keys(), func=identity_func) - expected = data - expected["img"] = identity_func(data["img"]) - self.assertTrue(np.allclose(expected["img"], lambd(data)["img"])) + expected = {"img": noise_func(data["img"]), "prop": 1.0} + ret = Lambdad(keys=["img", "prop"], func=noise_func, overwrite=[True, False])(data) + self.assertTrue(np.allclose(expected["img"], ret["img"])) + self.assertTrue(np.allclose(expected["prop"], ret["prop"])) def test_lambdad_slicing(self): img = self.imt From 56c88f6f3c2afa5618abf3abbec7aa3ac7eb891a Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 29 Jan 2021 16:14:32 +0800 Subject: [PATCH 15/19] [DLMED] Enhance MaskIntensityd transform (#1521) Signed-off-by: Nic Ma --- monai/transforms/intensity/dictionary.py | 13 +++++++++++-- tests/test_mask_intensityd.py | 11 ++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 34d75faf63..18e2250084 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -478,17 +478,26 @@ class MaskIntensityd(MapTransform): of input image. if multiple channels, the channel number must match input data. mask_data will be converted to `bool` values by `mask_data > 0` before applying transform to input image. + if None, will extract the mask data from input data based on `mask_key`. + mask_key: the key to extract mask data from input dictionary, only works + when `mask_data` is None. """ - def __init__(self, keys: KeysCollection, mask_data: np.ndarray) -> None: + def __init__( + self, + keys: KeysCollection, + mask_data: Optional[np.ndarray] = None, + mask_key: Optional[str] = None, + ) -> None: super().__init__(keys) self.converter = MaskIntensity(mask_data) + self.mask_key = mask_key if mask_data is None else None def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key in self.keys: - d[key] = self.converter(d[key]) + d[key] = self.converter(d[key], d[self.mask_key]) if self.mask_key is not None else self.converter(d[key]) return d diff --git a/tests/test_mask_intensityd.py b/tests/test_mask_intensityd.py index 47f4c0b8a1..0d08952db2 100644 --- a/tests/test_mask_intensityd.py +++ b/tests/test_mask_intensityd.py @@ -34,9 +34,18 @@ np.array([[[0, 0, 0], [0, 2, 0], [0, 0, 0]], [[0, 4, 0], [0, 5, 0], [0, 6, 0]]]), ] +TEST_CASE_4 = [ + {"keys": "img", "mask_key": "mask"}, + { + "img": np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3]], [[4, 4, 4], [5, 5, 5], [6, 6, 6]]]), + "mask": np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]], [[0, 1, 0], [0, 1, 0], [0, 1, 0]]]), + }, + np.array([[[0, 0, 0], [0, 2, 0], [0, 0, 0]], [[0, 4, 0], [0, 5, 0], [0, 6, 0]]]), +] + class TestMaskIntensityd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_value(self, argments, image, expected_data): result = MaskIntensityd(**argments)(image) np.testing.assert_allclose(result["img"], expected_data) From b3d063cc8bf6cb1a293c47cb4aa5300370b2da36 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 29 Jan 2021 21:14:08 +0800 Subject: [PATCH 16/19] 886 add MetricsSaver handler to save metrics and details into files (#1497) * [DLMED] add IterationHandler refer to the EpochHandler in ignite Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix the multi-gpu issue Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix distributed tests Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] add engine to metrics Signed-off-by: Nic Ma * [DLMED] share metric details in engine Signed-off-by: Nic Ma * [DLMED] add metrics report Signed-off-by: Nic Ma * [DLMED] add average value to report Signed-off-by: Nic Ma * [DLMED] add summary report Signed-off-by: Nic Ma * [DLMED] add docs Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] add unit tests and distributed tests Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] remove from min_tests Signed-off-by: Nic Ma * [DLMED] remove useless var Signed-off-by: Nic Ma * [DLMED] add skip flag Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] add dist tests Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] enhance some unit tests Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] remove from min_tests Signed-off-by: Nic Ma * [DLMED] change to standlone APIs to write files Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] add file type check Signed-off-by: Nic Ma * [DLMED] add output_type arg Signed-off-by: Nic Ma * [DLMED] develop standlone API Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 error Signed-off-by: Nic Ma * [DLMED] fix min test Signed-off-by: Nic Ma Co-authored-by: monai-bot --- docs/source/handlers.rst | 7 + monai/engines/workflow.py | 1 + monai/handlers/__init__.py | 3 +- monai/handlers/confusion_matrix.py | 10 +- monai/handlers/hausdorff_distance.py | 10 +- monai/handlers/iteration_metric.py | 46 ++++-- monai/handlers/mean_dice.py | 10 +- monai/handlers/metrics_saver.py | 137 ++++++++++++++++++ monai/handlers/surface_distance.py | 10 +- monai/handlers/utils.py | 132 +++++++++++++++-- tests/min_tests.py | 3 + .../test_evenly_divisible_all_gather_dist.py | 42 ++++++ tests/test_handler_confusion_matrix.py | 11 +- tests/test_handler_confusion_matrix_dist.py | 6 + tests/test_handler_hausdorff_distance.py | 8 + tests/test_handler_mean_dice.py | 17 ++- tests/test_handler_metrics_saver.py | 84 +++++++++++ tests/test_handler_metrics_saver_dist.py | 106 ++++++++++++++ tests/test_handler_surface_distance.py | 8 + tests/test_write_metrics_reports.py | 64 ++++++++ 20 files changed, 682 insertions(+), 33 deletions(-) create mode 100644 monai/handlers/metrics_saver.py create mode 100644 tests/test_evenly_divisible_all_gather_dist.py create mode 100644 tests/test_handler_metrics_saver.py create mode 100644 tests/test_handler_metrics_saver_dist.py create mode 100644 tests/test_write_metrics_reports.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index d1ce257cb7..81d28fb4ac 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -16,6 +16,13 @@ Model checkpoint saver .. autoclass:: CheckpointSaver :members: + +Metrics saver +------------- +.. autoclass:: MetricsSaver + :members: + + CSV saver --------- .. autoclass:: ClassificationSaver diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 67fdacad4a..d6415c1966 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -110,6 +110,7 @@ def set_sampler_epoch(engine: Engine): output=None, batch=None, metrics={}, + metric_details={}, dataloader=None, device=device, key_metric_name=None, # we can set many metrics, only use key_metric to compare and save the best model diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index a873cd8b15..6b190518fb 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -18,11 +18,12 @@ from .lr_schedule_handler import LrScheduleHandler from .mean_dice import MeanDice from .metric_logger import MetricLogger +from .metrics_saver import MetricsSaver from .roc_auc import ROCAUC from .segmentation_saver import SegmentationSaver from .smartcache_handler import SmartCacheHandler from .stats_handler import StatsHandler from .surface_distance import SurfaceDistance from .tensorboard_handlers import TensorBoardImageHandler, TensorBoardStatsHandler -from .utils import all_gather, stopping_fn_from_loss, stopping_fn_from_metric +from .utils import evenly_divisible_all_gather, stopping_fn_from_loss, stopping_fn_from_metric, write_metrics_reports from .validation_handler import ValidationHandler diff --git a/monai/handlers/confusion_matrix.py b/monai/handlers/confusion_matrix.py index 46226f530b..1741aa305a 100644 --- a/monai/handlers/confusion_matrix.py +++ b/monai/handlers/confusion_matrix.py @@ -29,6 +29,7 @@ def __init__( metric_name: str = "hit_rate", output_transform: Callable = lambda x: x, device: Optional[torch.device] = None, + save_details: bool = True, ) -> None: """ @@ -44,6 +45,8 @@ def __init__( and you can also input those names instead. output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. device: device specification in case of distributed computation usage. + save_details: whether to save metric computation details per image, for example: TP/TN/FP/FN of every image. + default to True, will save to `engine.state.metric_details` dict with the metric name as key. See also: :py:meth:`monai.metrics.confusion_matrix` @@ -55,7 +58,12 @@ def __init__( reduction=MetricReduction.NONE, ) self.metric_name = metric_name - super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) + super().__init__( + metric_fn=metric_fn, + output_transform=output_transform, + device=device, + save_details=save_details, + ) def _reduce(self, scores) -> Any: confusion_matrix, _ = do_metric_reduction(scores, MetricReduction.MEAN) diff --git a/monai/handlers/hausdorff_distance.py b/monai/handlers/hausdorff_distance.py index 3e4a3d70ba..7ac52d642a 100644 --- a/monai/handlers/hausdorff_distance.py +++ b/monai/handlers/hausdorff_distance.py @@ -31,6 +31,7 @@ def __init__( directed: bool = False, output_transform: Callable = lambda x: x, device: Optional[torch.device] = None, + save_details: bool = True, ) -> None: """ @@ -45,6 +46,8 @@ def __init__( directed: whether to calculate directed Hausdorff distance. Defaults to ``False``. output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. device: device specification in case of distributed computation usage. + save_details: whether to save metric computation details per image, for example: hausdorff distance + of every image. default to True, will save to `engine.state.metric_details` dict with the metric name as key. """ super().__init__(output_transform, device=device) @@ -55,4 +58,9 @@ def __init__( directed=directed, reduction=MetricReduction.NONE, ) - super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) + super().__init__( + metric_fn=metric_fn, + output_transform=output_transform, + device=device, + save_details=save_details, + ) diff --git a/monai/handlers/iteration_metric.py b/monai/handlers/iteration_metric.py index 4d555b9dcb..bfc7252b2f 100644 --- a/monai/handlers/iteration_metric.py +++ b/monai/handlers/iteration_metric.py @@ -9,17 +9,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Callable, List, Optional, Sequence +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence import torch +from monai.handlers.utils import evenly_divisible_all_gather from monai.metrics import do_metric_reduction from monai.utils import MetricReduction, exact_version, optional_import -NotComputableError, _ = optional_import("ignite.exceptions", "0.4.2", exact_version, "NotComputableError") idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") +if TYPE_CHECKING: + from ignite.engine import Engine +else: + Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") class IterationMetric(Metric): # type: ignore[valid-type, misc] # due to optional_import @@ -33,6 +37,8 @@ class IterationMetric(Metric): # type: ignore[valid-type, misc] # due to option expect to return a Tensor with shape (batch, channel, ...) or tuple (Tensor, not_nans). output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. device: device specification in case of distributed computation usage. + save_details: whether to save metric computation details per image, for example: mean_dice of every image. + default to True, will save to `engine.state.metric_details` dict with the metric name as key. """ @@ -41,10 +47,14 @@ def __init__( metric_fn: Callable, output_transform: Callable = lambda x: x, device: Optional[torch.device] = None, + save_details: bool = True, ) -> None: self._is_reduced: bool = False self.metric_fn = metric_fn + self.save_details = save_details self._scores: List = [] + self._engine: Optional[Engine] = None + self._name: Optional[str] = None super().__init__(output_transform, device=device) @reinit__is_reduced @@ -79,17 +89,16 @@ def compute(self) -> Any: ws = idist.get_world_size() if ws > 1 and not self._is_reduced: - # make sure the _scores is evenly-divisible on multi-GPUs - length = _scores.shape[0] - max_len = max(idist.all_gather(length)).item() - if length < max_len: - size = [max_len - length] + list(_scores.shape[1:]) - _scores = torch.cat([_scores, _scores.new_full(size, float("NaN"))], dim=0) - # all gather across all processes - _scores = idist.all_gather(_scores) + _scores = evenly_divisible_all_gather(data=_scores) self._is_reduced = True + # save score of every image into engine.state for other components + if self.save_details: + if self._engine is None or self._name is None: + raise RuntimeError("plesae call the attach() function to connect expected engine first.") + self._engine.state.metric_details[self._name] = _scores + result: torch.Tensor = torch.zeros(1) if idist.get_rank() == 0: # run compute_fn on zero rank only @@ -103,3 +112,20 @@ def compute(self) -> Any: def _reduce(self, scores) -> Any: return do_metric_reduction(scores, MetricReduction.MEAN)[0] + + def attach(self, engine: Engine, name: str) -> None: + """ + Attaches current metric to provided engine. On the end of engine's run, + `engine.state.metrics` dictionary will contain computed metric's value under provided name. + + Args: + engine: the engine to which the metric must be attached. + name: the name of the metric to attach. + + """ + super().attach(engine=engine, name=name) + # FIXME: record engine for communication, ignite will support it in the future version soon + self._engine = engine + self._name = name + if self.save_details and not hasattr(engine.state, "metric_details"): + engine.state.metric_details = {} diff --git a/monai/handlers/mean_dice.py b/monai/handlers/mean_dice.py index 057acbee97..7decc3ab9b 100644 --- a/monai/handlers/mean_dice.py +++ b/monai/handlers/mean_dice.py @@ -28,6 +28,7 @@ def __init__( include_background: bool = True, output_transform: Callable = lambda x: x, device: Optional[torch.device] = None, + save_details: bool = True, ) -> None: """ @@ -36,6 +37,8 @@ def __init__( Defaults to True. output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. device: device specification in case of distributed computation usage. + save_details: whether to save metric computation details per image, for example: mean dice of every image. + default to True, will save to `engine.state.metric_details` dict with the metric name as key. See also: :py:meth:`monai.metrics.meandice.compute_meandice` @@ -44,4 +47,9 @@ def __init__( include_background=include_background, reduction=MetricReduction.NONE, ) - super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) + super().__init__( + metric_fn=metric_fn, + output_transform=output_transform, + device=device, + save_details=save_details, + ) diff --git a/monai/handlers/metrics_saver.py b/monai/handlers/metrics_saver.py new file mode 100644 index 0000000000..f9deea35df --- /dev/null +++ b/monai/handlers/metrics_saver.py @@ -0,0 +1,137 @@ +# 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, List, Optional, Sequence, Union + +from monai.handlers.utils import write_metrics_reports +from monai.utils import ensure_tuple, exact_version, optional_import +from monai.utils.module import get_torch_version_tuple + +Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") +if TYPE_CHECKING: + from ignite.engine import Engine +else: + Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + + +class MetricsSaver: + """ + ignite handler to save metrics values and details into expected files. + + Args: + save_dir: directory to save the metrics and metric details. + metrics: expected final metrics to save into files, can be: None, "*" or list of strings. + None - don't save any metrics into files. + "*" - save all the existing metrics in `engine.state.metrics` dict into separate files. + list of strings - specify the expected metrics to save. + default to "*" to save all the metrics into `metrics.csv`. + metric_details: expected metric details to save into files, for example: mean dice + of every channel of every image in the validation dataset. + the data in `engine.state.metric_details` must contain at least 2 dims: (batch, classes, ...), + if not, will unsequeeze to 2 dims. + this arg can be: None, "*" or list of strings. + None - don't save any metrics into files. + "*" - save all the existing metrics in `engine.state.metric_details` dict into separate files. + list of strings - specify the expected metrics to save. + if not None, every metric will save a separate `{metric name}_raw.csv` file. + batch_transform: callable function to extract the meta_dict from input batch data if saving metric details. + used to extract filenames from input dict data. + summary_ops: expected computation operations to generate the summary report. + it can be: None, "*" or list of strings. + None - don't generate summary report for every expected metric_details + "*" - generate summary report for every metric_details with all the supported operations. + list of strings - generate summary report for every metric_details with specified operations, they + should be within this list: [`mean`, `median`, `max`, `min`, `90percent`, `std`]. + default to None. + save_rank: only the handler on specified rank will save to files in multi-gpus validation, default to 0. + delimiter: the delimiter charactor in CSV file, default to "\t". + output_type: expected output file type, supported types: ["csv"], default to "csv". + + """ + + def __init__( + self, + save_dir: str, + metrics: Optional[Union[str, Sequence[str]]] = "*", + metric_details: Optional[Union[str, Sequence[str]]] = None, + batch_transform: Callable = lambda x: x, + summary_ops: Optional[Union[str, Sequence[str]]] = None, + save_rank: int = 0, + delimiter: str = "\t", + output_type: str = "csv", + ) -> None: + self.save_dir = save_dir + self.metrics = ensure_tuple(metrics) if metrics is not None else None + self.metric_details = ensure_tuple(metric_details) if metric_details is not None else None + self.batch_transform = batch_transform + self.summary_ops = ensure_tuple(summary_ops) if summary_ops is not None else None + self.save_rank = save_rank + self.deli = delimiter + self.output_type = output_type + self._filenames: List[str] = [] + + def attach(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + engine.add_event_handler(Events.STARTED, self._started) + engine.add_event_handler(Events.ITERATION_COMPLETED, self._get_filenames) + engine.add_event_handler(Events.EPOCH_COMPLETED, self) + + def _started(self, engine: Engine) -> None: + self._filenames = [] + + def _get_filenames(self, engine: Engine) -> None: + if self.metric_details is not None: + _filenames = list(ensure_tuple(self.batch_transform(engine.state.batch)["filename_or_obj"])) + self._filenames += _filenames + + def __call__(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + ws = idist.get_world_size() + if self.save_rank >= ws: + raise ValueError("target rank is greater than the distributed group size.") + + _images = self._filenames + if ws > 1: + _filenames = self.deli.join(_images) + if get_torch_version_tuple() > (1, 6, 0): + # all gather across all processes + _filenames = self.deli.join(idist.all_gather(_filenames)) + else: + raise RuntimeError("MetricsSaver can not save metric details in distributed mode with PyTorch < 1.7.0.") + _images = _filenames.split(self.deli) + + # only save metrics to file in specified rank + if idist.get_rank() == self.save_rank: + _metrics = {} + if self.metrics is not None and len(engine.state.metrics) > 0: + _metrics = {k: v for k, v in engine.state.metrics.items() if k in self.metrics or "*" in self.metrics} + _metric_details = {} + if self.metric_details is not None and len(engine.state.metric_details) > 0: + for k, v in engine.state.metric_details.items(): + if k in self.metric_details or "*" in self.metric_details: + _metric_details[k] = v + + write_metrics_reports( + save_dir=self.save_dir, + images=_images, + metrics=_metrics, + metric_details=_metric_details, + summary_ops=self.summary_ops, + deli=self.deli, + output_type=self.output_type, + ) diff --git a/monai/handlers/surface_distance.py b/monai/handlers/surface_distance.py index 17b667ab46..d3fa69bfce 100644 --- a/monai/handlers/surface_distance.py +++ b/monai/handlers/surface_distance.py @@ -30,6 +30,7 @@ def __init__( distance_metric: str = "euclidean", output_transform: Callable = lambda x: x, device: Optional[torch.device] = None, + save_details: bool = True, ) -> None: """ @@ -42,6 +43,8 @@ def __init__( the metric used to compute surface distance. Defaults to ``"euclidean"``. output_transform: transform the ignite.engine.state.output into [y_pred, y] pair. device: device specification in case of distributed computation usage. + save_details: whether to save metric computation details per image, for example: surface dice + of every image. default to True, will save to `engine.state.metric_details` dict with the metric name as key. """ metric_fn = SurfaceDistanceMetric( @@ -50,4 +53,9 @@ def __init__( distance_metric=distance_metric, reduction=MetricReduction.NONE, ) - super().__init__(metric_fn=metric_fn, output_transform=output_transform, device=device) + super().__init__( + metric_fn=metric_fn, + output_transform=output_transform, + device=device, + save_details=save_details, + ) diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index 8f22501737..ef652efe0a 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -9,19 +9,27 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Any, Callable +import os +from collections import OrderedDict +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence, Union +import numpy as np import torch -import torch.distributed as dist -from monai.utils import exact_version, optional_import +from monai.utils import ensure_tuple, exact_version, optional_import +idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") if TYPE_CHECKING: from ignite.engine import Engine else: Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") -__all__ = ["stopping_fn_from_metric", "stopping_fn_from_loss", "all_gather"] +__all__ = [ + "stopping_fn_from_metric", + "stopping_fn_from_loss", + "evenly_divisible_all_gather", + "write_metrics_reports", +] def stopping_fn_from_metric(metric_name: str) -> Callable[[Engine], Any]: @@ -46,13 +54,113 @@ def stopping_fn(engine: Engine): return stopping_fn -def all_gather(tensor): +def evenly_divisible_all_gather(data: torch.Tensor) -> torch.Tensor: """ - All gather the data of tensor value in distributed data parallel. + Utility function for distributed data parallel to pad at first dim to make it evenly divisible and all_gather. + + Args: + data: source tensor to pad and execute all_gather in distributed data parallel. + """ - if not dist.is_available() or not dist.is_initialized(): - raise RuntimeError("should not execute all_gather operation before torch.distributed is ready.") - # create placeholder to collect the data from all processes - output = [torch.zeros_like(tensor) for _ in range(dist.get_world_size())] - dist.all_gather(output, tensor) - return torch.cat(output, dim=0) + if not torch.is_tensor(data): + raise ValueError("input data must be PyTorch Tensor.") + + if idist.get_world_size() <= 1: + return data + + # make sure the data is evenly-divisible on multi-GPUs + length = data.shape[0] + all_lens = idist.all_gather(length) + max_len = max(all_lens).item() + if length < max_len: + size = [max_len - length] + list(data.shape[1:]) + data = torch.cat([data, data.new_full(size, 0)], dim=0) + # all gather across all processes + data = idist.all_gather(data) + # delete the padding NaN items + return torch.cat([data[i * max_len : i * max_len + l, ...] for i, l in enumerate(all_lens)], dim=0) + + +def write_metrics_reports( + save_dir: str, + images: Optional[Sequence[str]], + metrics: Optional[Dict[str, Union[torch.Tensor, np.ndarray]]], + metric_details: Optional[Dict[str, Union[torch.Tensor, np.ndarray]]], + summary_ops: Optional[Union[str, Sequence[str]]], + deli: str = "\t", + output_type: str = "csv", +): + """ + Utility function to write the metrics into files, contains 3 parts: + 1. if `metrics` dict is not None, write overall metrics into file, every line is a metric name and value pair. + 2. if `metric_details` dict is not None, write raw metric data of every image into file, every line for 1 image. + 3. if `summary_ops` is not None, compute summary based on operations on `metric_details` and write to file. + + Args: + save_dir: directory to save all the metrics reports. + images: name or path of every input image corresponding to the metric_details data. + if None, will use index number as the filename of every input image. + metrics: a dictionary of (metric name, metric value) pairs. + metric_details: a dictionary of (metric name, metric raw values) pairs, + for example, the raw value can be the mean_dice of every channel of every input image. + summary_ops: expected computation operations to generate the summary report. + it can be: None, "*" or list of strings. + None - don't generate summary report for every expected metric_details + "*" - generate summary report for every metric_details with all the supported operations. + list of strings - generate summary report for every metric_details with specified operations, they + should be within this list: [`mean`, `median`, `max`, `min`, `90percent`, `std`]. + default to None. + deli: the delimiter charactor in the file, default to "\t". + output_type: expected output file type, supported types: ["csv"], default to "csv". + + """ + if output_type.lower() != "csv": + raise ValueError(f"unsupported output type: {output_type}.") + + if not os.path.exists(save_dir): + os.makedirs(save_dir) + + if metrics is not None and len(metrics) > 0: + with open(os.path.join(save_dir, "metrics.csv"), "w") as f: + for k, v in metrics.items(): + f.write(f"{k}{deli}{str(v)}\n") + + if metric_details is not None and len(metric_details) > 0: + for k, v in metric_details.items(): + if torch.is_tensor(v): + v = v.cpu().numpy() + if v.ndim == 0: + # reshape to [1, 1] if no batch and class dims + v = v.reshape((1, 1)) + elif v.ndim == 1: + # reshape to [N, 1] if no class dim + v = v.reshape((-1, 1)) + + # add the average value of all classes to v + class_labels = ["class" + str(i) for i in range(v.shape[1])] + ["mean"] + v = np.concatenate([v, np.nanmean(v, axis=1, keepdims=True)], axis=1) + + with open(os.path.join(save_dir, f"{k}_raw.csv"), "w") as f: + f.write(f"filename{deli}{deli.join(class_labels)}\n") + for i, b in enumerate(v): + f.write(f"{images[i] if images is not None else str(i)}{deli}{deli.join([str(c) for c in b])}\n") + + if summary_ops is not None: + supported_ops = OrderedDict( + { + "mean": np.nanmean, + "median": np.nanmedian, + "max": np.nanmax, + "min": np.nanmin, + "90percent": lambda x: np.nanpercentile(x, 10), + "std": np.nanstd, + } + ) + ops = ensure_tuple(summary_ops) + if "*" in ops: + ops = tuple(supported_ops.keys()) + + with open(os.path.join(save_dir, f"{k}_summary.csv"), "w") as f: + f.write(f"class{deli}{deli.join(ops)}\n") + for i, c in enumerate(v.transpose()): + f.write(f"{class_labels[i]}{deli}{deli.join([f'{supported_ops[k](c):.4f}' for k in ops])}\n") diff --git a/tests/min_tests.py b/tests/min_tests.py index 9a2dc0f05f..665ead6cc6 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -100,6 +100,9 @@ def run_testsuit(): "test_occlusion_sensitivity", "test_torchvision", "test_torchvisiond", + "test_handler_metrics_saver", + "test_handler_metrics_saver_dist", + "test_evenly_divisible_all_gather_dist", ] assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}" diff --git a/tests/test_evenly_divisible_all_gather_dist.py b/tests/test_evenly_divisible_all_gather_dist.py new file mode 100644 index 0000000000..70dcd7ca6a --- /dev/null +++ b/tests/test_evenly_divisible_all_gather_dist.py @@ -0,0 +1,42 @@ +# 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 +import torch.distributed as dist + +from monai.handlers.utils import evenly_divisible_all_gather +from tests.utils import DistCall, DistTestCase + + +class DistributedEvenlyDivisibleAllGather(DistTestCase): + @DistCall(nnodes=1, nproc_per_node=2) + def test_data(self): + self._run() + + def _run(self): + if dist.get_rank() == 0: + data1 = torch.tensor([[1, 2], [3, 4]]) + data2 = torch.tensor([[1.0, 2.0]]) + + if dist.get_rank() == 1: + data1 = torch.tensor([[5, 6]]) + data2 = torch.tensor([[3.0, 4.0], [5.0, 6.0]]) + + result1 = evenly_divisible_all_gather(data=data1) + torch.testing.assert_allclose(result1, torch.tensor([[1, 2], [3, 4], [5, 6]])) + result2 = evenly_divisible_all_gather(data=data2) + torch.testing.assert_allclose(result2, torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handler_confusion_matrix.py b/tests/test_handler_confusion_matrix.py index cc231b82db..0524676763 100644 --- a/tests/test_handler_confusion_matrix.py +++ b/tests/test_handler_confusion_matrix.py @@ -13,12 +13,13 @@ from typing import Any, Dict import torch +from ignite.engine import Engine from parameterized import parameterized from monai.handlers import ConfusionMatrix -TEST_CASE_1 = [{"include_background": True, "metric_name": "f1"}, 0.75] -TEST_CASE_2 = [{"include_background": False, "metric_name": "ppv"}, 1.0] +TEST_CASE_1 = [{"include_background": True, "save_details": False, "metric_name": "f1"}, 0.75] +TEST_CASE_2 = [{"include_background": False, "save_details": False, "metric_name": "ppv"}, 1.0] TEST_CASE_SEG_1 = [{"include_background": True, "metric_name": "tpr"}, 0.7] @@ -73,6 +74,12 @@ def test_compute(self, input_params, expected_avg): def test_compute_seg(self, input_params, expected_avg): metric = ConfusionMatrix(**input_params) + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + metric.attach(engine, "confusion_matrix") + y_pred = data_1["y_pred"] y = data_1["y"] metric.update([y_pred, y]) diff --git a/tests/test_handler_confusion_matrix_dist.py b/tests/test_handler_confusion_matrix_dist.py index ebe0eb9ca7..40245bce2e 100644 --- a/tests/test_handler_confusion_matrix_dist.py +++ b/tests/test_handler_confusion_matrix_dist.py @@ -15,6 +15,7 @@ import numpy as np import torch import torch.distributed as dist +from ignite.engine import Engine from monai.handlers import ConfusionMatrix from tests.utils import DistCall, DistTestCase @@ -29,6 +30,11 @@ def _compute(self): device = f"cuda:{dist.get_rank()}" if torch.cuda.is_available() else "cpu" metric = ConfusionMatrix(include_background=True, metric_name="tpr") + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + metric.attach(engine, "confusion_matrix") if dist.get_rank() == 0: y_pred = torch.tensor( [ diff --git a/tests/test_handler_hausdorff_distance.py b/tests/test_handler_hausdorff_distance.py index edf59320ea..c0d2e723ca 100644 --- a/tests/test_handler_hausdorff_distance.py +++ b/tests/test_handler_hausdorff_distance.py @@ -14,6 +14,7 @@ import numpy as np import torch +from ignite.engine import Engine from monai.handlers import HausdorffDistance @@ -62,6 +63,13 @@ class TestHandlerHausdorffDistance(unittest.TestCase): def test_compute(self): hd_metric = HausdorffDistance(include_background=True) + + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + hd_metric.attach(engine, "hausdorff_distance") + y_pred, y = TEST_SAMPLE_1 hd_metric.update([y_pred, y]) self.assertEqual(hd_metric.compute(), 10) diff --git a/tests/test_handler_mean_dice.py b/tests/test_handler_mean_dice.py index 9983918f2d..d15b549d86 100644 --- a/tests/test_handler_mean_dice.py +++ b/tests/test_handler_mean_dice.py @@ -12,20 +12,28 @@ import unittest import torch +from ignite.engine import Engine from parameterized import parameterized from monai.handlers import MeanDice -TEST_CASE_1 = [{"include_background": True}, 0.75] -TEST_CASE_2 = [{"include_background": False}, 0.66666] +TEST_CASE_1 = [{"include_background": True}, 0.75, (4, 2)] +TEST_CASE_2 = [{"include_background": False}, 0.66666, (4, 1)] class TestHandlerMeanDice(unittest.TestCase): # TODO test multi node averaged dice @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_compute(self, input_params, expected_avg): + def test_compute(self, input_params, expected_avg, details_shape): dice_metric = MeanDice(**input_params) + # set up engine + + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + dice_metric.attach(engine=engine, name="mean_dice") y_pred = torch.Tensor([[[0], [1]], [[1], [0]]]) y = torch.Tensor([[[0], [1]], [[0], [1]]]) @@ -37,9 +45,10 @@ def test_compute(self, input_params, expected_avg): avg_dice = dice_metric.compute() self.assertAlmostEqual(avg_dice, expected_avg, places=4) + self.assertTupleEqual(tuple(engine.state.metric_details["mean_dice"].shape), details_shape) @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_shape_mismatch(self, input_params, _expected): + def test_shape_mismatch(self, input_params, _expected_avg, _details_shape): dice_metric = MeanDice(**input_params) with self.assertRaises((AssertionError, ValueError)): y_pred = torch.Tensor([[0, 1], [1, 0]]) diff --git a/tests/test_handler_metrics_saver.py b/tests/test_handler_metrics_saver.py new file mode 100644 index 0000000000..58a6f10d33 --- /dev/null +++ b/tests/test_handler_metrics_saver.py @@ -0,0 +1,84 @@ +# 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 csv +import os +import tempfile +import unittest + +import torch +from ignite.engine import Engine, Events + +from monai.handlers import MetricsSaver + + +class TestHandlerMetricsSaver(unittest.TestCase): + def test_content(self): + with tempfile.TemporaryDirectory() as tempdir: + metrics_saver = MetricsSaver( + save_dir=tempdir, + metrics=["metric1", "metric2"], + metric_details=["metric3", "metric4"], + batch_transform=lambda x: x["image_meta_dict"], + summary_ops=["mean", "median", "max", "90percent"], + ) + # set up engine + data = [ + {"image_meta_dict": {"filename_or_obj": ["filepath1"]}}, + {"image_meta_dict": {"filename_or_obj": ["filepath2"]}}, + ] + + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + + @engine.on(Events.EPOCH_COMPLETED) + def _save_metrics(engine): + engine.state.metrics = {"metric1": 1, "metric2": 2} + engine.state.metric_details = { + "metric3": torch.tensor([[1, 2], [2, 3]]), + "metric4": torch.tensor([[5, 6], [7, 8]]), + } + + metrics_saver.attach(engine) + engine.run(data, max_epochs=1) + + # check the metrics.csv and content + self.assertTrue(os.path.exists(os.path.join(tempdir, "metrics.csv"))) + with open(os.path.join(tempdir, "metrics.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + self.assertEqual(row, [f"metric{i + 1}\t{i + 1}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_raw.csv"))) + # check the metric_raw.csv and content + with open(os.path.join(tempdir, "metric3_raw.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i > 0: + self.assertEqual(row, [f"filepath{i}\t{float(i)}\t{float(i + 1)}\t{i + 0.5}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_summary.csv"))) + # check the metric_summary.csv and content + with open(os.path.join(tempdir, "metric3_summary.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i == 1: + self.assertEqual(row, ["class0\t1.5000\t1.5000\t2.0000\t1.1000"]) + elif i == 2: + self.assertEqual(row, ["class1\t2.5000\t2.5000\t3.0000\t2.1000"]) + elif i == 3: + self.assertEqual(row, ["mean\t2.0000\t2.0000\t2.5000\t1.6000"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_raw.csv"))) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_summary.csv"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handler_metrics_saver_dist.py b/tests/test_handler_metrics_saver_dist.py new file mode 100644 index 0000000000..1b17d0adb4 --- /dev/null +++ b/tests/test_handler_metrics_saver_dist.py @@ -0,0 +1,106 @@ +# 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 csv +import os +import tempfile +import unittest + +import torch +import torch.distributed as dist +from ignite.engine import Engine, Events + +from monai.handlers import MetricsSaver +from tests.utils import DistCall, DistTestCase, SkipIfBeforePyTorchVersion + + +@SkipIfBeforePyTorchVersion((1, 7)) +class DistributedMetricsSaver(DistTestCase): + @DistCall(nnodes=1, nproc_per_node=2) + def test_content(self): + self._run() + + def _run(self): + with tempfile.TemporaryDirectory() as tempdir: + metrics_saver = MetricsSaver( + save_dir=tempdir, + metrics=["metric1", "metric2"], + metric_details=["metric3", "metric4"], + batch_transform=lambda x: x["image_meta_dict"], + summary_ops="*", + ) + + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + + if dist.get_rank() == 0: + data = [{"image_meta_dict": {"filename_or_obj": ["filepath1"]}}] + + @engine.on(Events.EPOCH_COMPLETED) + def _save_metrics0(engine): + engine.state.metrics = {"metric1": 1, "metric2": 2} + engine.state.metric_details = { + "metric3": torch.tensor([[1, 2]]), + "metric4": torch.tensor([[5, 6]]), + } + + if dist.get_rank() == 1: + # different ranks have different data length + data = [ + {"image_meta_dict": {"filename_or_obj": ["filepath2"]}}, + {"image_meta_dict": {"filename_or_obj": ["filepath3"]}}, + ] + + @engine.on(Events.EPOCH_COMPLETED) + def _save_metrics1(engine): + engine.state.metrics = {"metric1": 1, "metric2": 2} + engine.state.metric_details = { + "metric3": torch.tensor([[2, 3], [3, 4]]), + "metric4": torch.tensor([[6, 7], [7, 8]]), + } + + metrics_saver.attach(engine) + engine.run(data, max_epochs=1) + + if dist.get_rank() == 0: + # check the metrics.csv and content + self.assertTrue(os.path.exists(os.path.join(tempdir, "metrics.csv"))) + with open(os.path.join(tempdir, "metrics.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + self.assertEqual(row, [f"metric{i + 1}\t{i + 1}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_raw.csv"))) + # check the metric_raw.csv and content + with open(os.path.join(tempdir, "metric3_raw.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i > 0: + self.assertEqual(row, [f"filepath{i}\t{float(i)}\t{float(i + 1)}\t{i + 0.5}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_summary.csv"))) + # check the metric_summary.csv and content + with open(os.path.join(tempdir, "metric3_summary.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i == 1: + self.assertEqual(row, ["class0\t1.0000\t1.0000\t1.0000\t1.0000\t1.0000\t0.0000"]) + elif i == 2: + self.assertEqual(row, ["class1\t2.0000\t2.0000\t2.0000\t2.0000\t2.0000\t0.0000"]) + elif i == 3: + self.assertEqual(row, ["mean\t1.5000\t1.5000\t1.5000\t1.5000\t1.5000\t0.0000"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_raw.csv"))) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_summary.csv"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handler_surface_distance.py b/tests/test_handler_surface_distance.py index 656b0d64b2..fbd86edb03 100644 --- a/tests/test_handler_surface_distance.py +++ b/tests/test_handler_surface_distance.py @@ -14,6 +14,7 @@ import numpy as np import torch +from ignite.engine import Engine from monai.handlers import SurfaceDistance @@ -62,6 +63,13 @@ class TestHandlerSurfaceDistance(unittest.TestCase): def test_compute(self): sur_metric = SurfaceDistance(include_background=True) + + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + sur_metric.attach(engine, "surface_distance") + y_pred, y = TEST_SAMPLE_1 sur_metric.update([y_pred, y]) self.assertAlmostEqual(sur_metric.compute(), 4.17133, places=4) diff --git a/tests/test_write_metrics_reports.py b/tests/test_write_metrics_reports.py new file mode 100644 index 0000000000..72625ddd9a --- /dev/null +++ b/tests/test_write_metrics_reports.py @@ -0,0 +1,64 @@ +# 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 csv +import os +import tempfile +import unittest + +import torch + +from monai.handlers.utils import write_metrics_reports + + +class TestWriteMetricsReports(unittest.TestCase): + def test_content(self): + with tempfile.TemporaryDirectory() as tempdir: + write_metrics_reports( + save_dir=tempdir, + images=["filepath1", "filepath2"], + metrics={"metric1": 1, "metric2": 2}, + metric_details={"metric3": torch.tensor([[1, 2], [2, 3]]), "metric4": torch.tensor([[5, 6], [7, 8]])}, + summary_ops=["mean", "median", "max", "90percent"], + deli="\t", + output_type="csv", + ) + + # check the metrics.csv and content + self.assertTrue(os.path.exists(os.path.join(tempdir, "metrics.csv"))) + with open(os.path.join(tempdir, "metrics.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + self.assertEqual(row, [f"metric{i + 1}\t{i + 1}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_raw.csv"))) + # check the metric_raw.csv and content + with open(os.path.join(tempdir, "metric3_raw.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i > 0: + self.assertEqual(row, [f"filepath{i}\t{float(i)}\t{float(i + 1)}\t{i + 0.5}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_summary.csv"))) + # check the metric_summary.csv and content + with open(os.path.join(tempdir, "metric3_summary.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i == 1: + self.assertEqual(row, ["class0\t1.5000\t1.5000\t2.0000\t1.1000"]) + elif i == 2: + self.assertEqual(row, ["class1\t2.5000\t2.5000\t3.0000\t2.1000"]) + elif i == 3: + self.assertEqual(row, ["mean\t2.0000\t2.0000\t2.5000\t1.6000"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_raw.csv"))) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_summary.csv"))) + + +if __name__ == "__main__": + unittest.main() From c4353277a68557fa467225262daa4920b5167ffe Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 29 Jan 2021 19:20:55 +0000 Subject: [PATCH 17/19] unify installation instructions (#1524) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- README.md | 16 +++------------- docs/source/installation.md | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c54808d85f..f06a2d146f 100644 --- a/README.md +++ b/README.md @@ -30,23 +30,13 @@ Its ambitions are: ## Installation -### Installing [the current release](https://pypi.org/project/monai/): -```bash -pip install monai -``` +To install [the current release](https://pypi.org/project/monai/), you can simply run: -### Installing the master branch from the source code repository: ```bash -pip install git+https://github.com/Project-MONAI/MONAI#egg=MONAI +pip install monai ``` -### Using the pre-built Docker image [DockerHub](https://hub.docker.com/r/projectmonai/monai): - ```bash - # with docker v19.03+ - docker run --gpus all --rm -ti --ipc=host projectmonai/monai:latest - ``` - -For more details, please refer to [the installation guide](https://docs.monai.io/en/latest/installation.html). +For other installation methods (using the master branch, using Docker, etc.), please refer to [the installation guide](https://docs.monai.io/en/latest/installation.html). ## Getting Started diff --git a/docs/source/installation.md b/docs/source/installation.md index e02e38cb8f..cb540b1559 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -1,17 +1,24 @@ # Installation guide +## Table of Contents +1. [From PyPI](#from-pypi) + 1. [Milestone release](#milestone-release) + 2. [Weekly preview release](#weekly-preview-release) +2. [From GitHub](#from-github) + 1. [System-wide](#milestone-release) + 2. [Editable](#weekly-preview-release) +3. [Validating the install](#validating-the-install) +4. [MONAI version string](#monai-version-string) +5. [From DockerHub](#from-dockerhub) +6. [Installing the recommended dependencies](#Installing-the-recommended-dependencies) + +--- + MONAI's core functionality is written in Python 3 (>= 3.6) and only requires [Numpy](https://numpy.org/) and [Pytorch](https://pytorch.org/). The package is currently distributed via Github as the primary source code repository, and the Python package index (PyPI). The pre-built Docker images are made available on DockerHub. -This page provides steps to: -- [Install MONAI from PyPI](#from-pypi) -- [Install MONAI from GitHub](#from-github) -- [Validate the install](#validating-the-install) -- [Understand MONAI version string](#monai-version-string) -- [Run MONAI From DockerHub](#from-dockerhub) - To install optional features such as handling the NIfTI files using [Nibabel](https://nipy.org/nibabel/), or building workflows using [Pytorch Ignite](https://pytorch.org/ignite/), please follow the instructions: From a6cb37c4ef459c25ea8261fe652f23e4ee14ab8d Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sat, 30 Jan 2021 10:45:32 +0000 Subject: [PATCH 18/19] 1526-DiceCE (#1527) * fixes #1526 Signed-off-by: Wenqi Li * fixes Useless inheritance from object https://deepsource.io/gh/Project-MONAI/MONAI/issue/PYL-R0205 Signed-off-by: Wenqi Li * fixes https://github.com/Project-MONAI/MONAI/pull/1527/checks?check_run_id=1794062607#step:10:7763 Signed-off-by: Wenqi Li --- monai/handlers/tensorboard_handlers.py | 4 ++-- monai/losses/dice.py | 2 +- monai/utils/decorators.py | 2 +- tests/test_dice_ce_loss.py | 4 ++-- tests/test_normalize_intensity.py | 2 +- tests/utils.py | 8 ++++---- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/monai/handlers/tensorboard_handlers.py b/monai/handlers/tensorboard_handlers.py index 56d8f50678..15fa6a5eed 100644 --- a/monai/handlers/tensorboard_handlers.py +++ b/monai/handlers/tensorboard_handlers.py @@ -29,7 +29,7 @@ DEFAULT_TAG = "Loss" -class TensorBoardStatsHandler(object): +class TensorBoardStatsHandler: """ TensorBoardStatsHandler defines a set of Ignite Event-handlers for all the TensorBoard logics. It's can be used for any Ignite Engine(trainer, validator and evaluator). @@ -172,7 +172,7 @@ def _default_iteration_writer(self, engine: Engine, writer: SummaryWriter) -> No writer.flush() -class TensorBoardImageHandler(object): +class TensorBoardImageHandler: """ TensorBoardImageHandler is an Ignite Event handler that can visualize images, labels and outputs as 2D/3D images. 2D output (shape in Batch, channel, H, W) will be shown as simple image using the first element in the batch, diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 9bc5ad28ea..f14aa6955f 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -593,7 +593,7 @@ def _compute_alpha_generalized_true_positives(self, flat_target: torch.Tensor) - return alpha -class DiceCELoss: +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]). diff --git a/monai/utils/decorators.py b/monai/utils/decorators.py index a3e6e3f980..1931d703c9 100644 --- a/monai/utils/decorators.py +++ b/monai/utils/decorators.py @@ -27,7 +27,7 @@ def __iter__(self): return self.create_gen() -class MethodReplacer(object): +class MethodReplacer: """ Base class for method decorators which can be used to replace methods pass to replace_method() with wrapped versions. """ diff --git a/tests/test_dice_ce_loss.py b/tests/test_dice_ce_loss.py index 7e9a0a0153..443d9a9baf 100644 --- a/tests/test_dice_ce_loss.py +++ b/tests/test_dice_ce_loss.py @@ -56,13 +56,13 @@ class TestDiceCELoss(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_result(self, input_param, input_data, expected_val): - result = DiceCELoss(**input_param).forward(**input_data) + result = DiceCELoss(**input_param)(**input_data) np.testing.assert_allclose(result.detach().cpu().numpy(), expected_val, atol=1e-4, rtol=1e-4) def test_ill_shape(self): loss = DiceCELoss() with self.assertRaisesRegex(ValueError, ""): - loss.forward(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) if __name__ == "__main__": diff --git a/tests/test_normalize_intensity.py b/tests/test_normalize_intensity.py index 156725873a..ecf162e12f 100644 --- a/tests/test_normalize_intensity.py +++ b/tests/test_normalize_intensity.py @@ -61,7 +61,7 @@ def test_default(self): normalized = normalizer(self.imt) self.assertTrue(normalized.dtype == np.float32) expected = (self.imt - np.mean(self.imt)) / np.std(self.imt) - np.testing.assert_allclose(normalized, expected, rtol=1e-6) + np.testing.assert_allclose(normalized, expected, rtol=1e-5) @parameterized.expand(TEST_CASES) def test_nonzero(self, input_param, input_data, expected_data): diff --git a/tests/utils.py b/tests/utils.py index d73cb5fdc7..ebc9bff99f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -57,7 +57,7 @@ def skip_if_quick(obj): return unittest.skipIf(is_quick, "Skipping slow tests")(obj) -class SkipIfNoModule(object): +class SkipIfNoModule: """Decorator to be used if test should be skipped when optional module is not present.""" @@ -69,7 +69,7 @@ def __call__(self, obj): return unittest.skipIf(self.module_missing, f"optional module not present: {self.module_name}")(obj) -class SkipIfModule(object): +class SkipIfModule: """Decorator to be used if test should be skipped when optional module is present.""" @@ -102,7 +102,7 @@ def skip_if_windows(obj): return unittest.skipIf(sys.platform == "win32", "Skipping tests on Windows")(obj) -class SkipIfBeforePyTorchVersion(object): +class SkipIfBeforePyTorchVersion: """Decorator to be used if test should be skipped with PyTorch versions older than that given.""" @@ -119,7 +119,7 @@ def __call__(self, obj): )(obj) -class SkipIfAtLeastPyTorchVersion(object): +class SkipIfAtLeastPyTorchVersion: """Decorator to be used if test should be skipped with PyTorch versions newer than that given.""" From c4155099f06f7d65b10b066a95efc96a721b1db0 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 2 Feb 2021 09:32:38 +0000 Subject: [PATCH 19/19] 1534 type hints numpy 1 20 (#1536) * numpy dtype alias Signed-off-by: Wenqi Li * is_tensor => isinstance Signed-off-by: Wenqi Li * type of dtype Signed-off-by: Wenqi Li * relaxes some typing constraints Signed-off-by: Wenqi Li * fixes unit tests Signed-off-by: Wenqi Li * update based on the comments Signed-off-by: Wenqi Li * fixes docstring typos Signed-off-by: Wenqi Li --- monai/config/__init__.py | 2 +- monai/config/deviceconfig.py | 2 +- monai/config/type_definitions.py | 20 +++++- monai/data/csv_saver.py | 2 +- monai/data/image_dataset.py | 3 +- monai/data/image_reader.py | 10 +-- monai/data/nifti_saver.py | 9 +-- monai/data/nifti_writer.py | 9 +-- monai/data/png_saver.py | 6 +- monai/data/png_writer.py | 4 +- monai/data/utils.py | 4 +- monai/engines/utils.py | 2 +- monai/handlers/iteration_metric.py | 4 +- monai/handlers/metrics_saver.py | 2 +- monai/handlers/segmentation_saver.py | 5 +- monai/handlers/stats_handler.py | 6 +- monai/handlers/tensorboard_handlers.py | 14 ++-- monai/handlers/utils.py | 8 +-- monai/losses/dice.py | 2 +- monai/metrics/hausdorff_distance.py | 7 +- monai/metrics/rocauc.py | 4 +- monai/metrics/surface_distance.py | 10 +-- monai/metrics/utils.py | 14 ++-- monai/networks/layers/convutils.py | 12 ++-- monai/networks/layers/filtering.py | 2 +- monai/networks/layers/simplelayers.py | 10 +-- monai/networks/layers/spatial_transforms.py | 4 +- monai/networks/nets/localnet.py | 2 +- monai/networks/nets/regressor.py | 4 +- monai/networks/nets/varautoencoder.py | 4 +- monai/networks/utils.py | 2 +- monai/optimizers/lr_finder.py | 3 +- monai/transforms/croppad/array.py | 23 ++++--- monai/transforms/croppad/dictionary.py | 6 +- monai/transforms/intensity/array.py | 72 ++++++++++++--------- monai/transforms/intensity/dictionary.py | 6 +- monai/transforms/io/array.py | 3 +- monai/transforms/io/dictionary.py | 4 +- monai/transforms/spatial/array.py | 60 +++++++++-------- monai/transforms/spatial/dictionary.py | 16 ++--- monai/transforms/utility/array.py | 31 ++++----- monai/transforms/utility/dictionary.py | 34 +++++----- monai/transforms/utils.py | 42 ++++++------ monai/utils/misc.py | 12 ++-- monai/visualize/img2tensorboard.py | 4 +- tests/test_affine.py | 4 +- tests/test_affine_grid.py | 4 +- tests/test_crop_foregroundd.py | 8 +-- tests/test_detect_envelope.py | 2 +- tests/test_patch_dataset.py | 2 +- tests/test_rand_affine.py | 4 +- tests/test_rand_affine_grid.py | 4 +- tests/test_rand_affined.py | 4 +- tests/test_rand_deform_grid.py | 4 +- tests/test_rand_elastic_2d.py | 4 +- tests/test_rand_elastic_3d.py | 4 +- tests/test_rand_elasticd_2d.py | 4 +- tests/test_rand_elasticd_3d.py | 4 +- tests/test_resampler.py | 4 +- tests/test_spacing.py | 6 +- 60 files changed, 306 insertions(+), 261 deletions(-) diff --git a/monai/config/__init__.py b/monai/config/__init__.py index 251be002f2..f1c7707d1f 100644 --- a/monai/config/__init__.py +++ b/monai/config/__init__.py @@ -18,4 +18,4 @@ print_gpu_info, print_system_info, ) -from .type_definitions import IndexSelection, KeysCollection +from .type_definitions import DtypeLike, IndexSelection, KeysCollection, NdarrayTensor diff --git a/monai/config/deviceconfig.py b/monai/config/deviceconfig.py index 9e448a9ac3..be77a1d975 100644 --- a/monai/config/deviceconfig.py +++ b/monai/config/deviceconfig.py @@ -162,7 +162,7 @@ def get_system_info() -> OrderedDict: _dict_append( output, "Avg. sensor temp. (Celsius)", - lambda: round( + lambda: np.round( np.mean([item.current for sublist in psutil.sensors_temperatures().values() for item in sublist], 1) ), ) diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index ea0c72576c..daa9b10052 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -9,9 +9,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Collection, Hashable, Iterable, Union +from typing import Collection, Hashable, Iterable, TypeVar, Union -__all__ = ["KeysCollection", "IndexSelection"] +import numpy as np +import torch + +__all__ = ["KeysCollection", "IndexSelection", "DtypeLike", "NdarrayTensor"] """Commonly used concepts This module provides naming and type specifications for commonly used concepts @@ -51,3 +54,16 @@ The indices must be integers, and if a container of indices is specified, the container must be iterable. """ + +DtypeLike = Union[ + np.dtype, + type, + None, +] +"""Type of datatypes +adapted from https://github.com/numpy/numpy/blob/master/numpy/typing/_dtype_like.py +""" + +# Generic type which can represent either a numpy.ndarray or a torch.Tensor +# Unlike Union can create a dependence between parameter(s) / return(s) +NdarrayTensor = TypeVar("NdarrayTensor", np.ndarray, torch.Tensor) diff --git a/monai/data/csv_saver.py b/monai/data/csv_saver.py index 5f5e415055..ec9ec562cd 100644 --- a/monai/data/csv_saver.py +++ b/monai/data/csv_saver.py @@ -75,7 +75,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] """ save_key = meta_data["filename_or_obj"] if meta_data else str(self._data_index) self._data_index += 1 - if torch.is_tensor(data): + if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() if not isinstance(data, np.ndarray): raise AssertionError diff --git a/monai/data/image_dataset.py b/monai/data/image_dataset.py index 7dd55431af..1568e082ee 100644 --- a/monai/data/image_dataset.py +++ b/monai/data/image_dataset.py @@ -14,6 +14,7 @@ import numpy as np from torch.utils.data import Dataset +from monai.config import DtypeLike from monai.data.image_reader import ImageReader from monai.transforms import LoadImage, Randomizable, apply_transform from monai.utils import MAX_SEED, get_seed @@ -36,7 +37,7 @@ def __init__( transform: Optional[Callable] = None, seg_transform: Optional[Callable] = None, image_only: bool = True, - dtype: Optional[np.dtype] = np.float32, + dtype: DtypeLike = np.float32, reader: Optional[Union[ImageReader, str]] = None, *args, **kwargs, diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 0fd784af05..d0f5f4aefc 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -16,7 +16,7 @@ import numpy as np from torch.utils.data._utils.collate import np_str_obj_array_pattern -from monai.config import KeysCollection +from monai.config import DtypeLike, KeysCollection from monai.data.utils import correct_nifti_header_if_necessary from monai.utils import ensure_tuple, optional_import @@ -244,7 +244,7 @@ def _get_affine(self, img) -> np.ndarray: affine = np.eye(direction.shape[0] + 1) affine[(slice(-1), slice(-1))] = direction @ np.diag(spacing) affine[(slice(-1), -1)] = origin - return affine + return np.asarray(affine) def _get_spatial_shape(self, img) -> np.ndarray: """ @@ -258,7 +258,7 @@ def _get_spatial_shape(self, img) -> np.ndarray: shape.reverse() return np.asarray(shape) - def _get_array_data(self, img) -> np.ndarray: + def _get_array_data(self, img): """ Get the raw array data of the image, converted to Numpy array. @@ -295,7 +295,7 @@ class NibabelReader(ImageReader): """ - def __init__(self, as_closest_canonical: bool = False, dtype: Optional[np.dtype] = np.float32, **kwargs): + def __init__(self, as_closest_canonical: bool = False, dtype: DtypeLike = np.float32, **kwargs): super().__init__() self.as_closest_canonical = as_closest_canonical self.dtype = dtype @@ -385,7 +385,7 @@ def _get_affine(self, img) -> np.ndarray: img: a Nibabel image object loaded from a image file. """ - return img.affine.copy() + return np.array(img.affine, copy=True) def _get_spatial_shape(self, img) -> np.ndarray: """ diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index f4781f82fd..db559f97f4 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -14,6 +14,7 @@ import numpy as np import torch +from monai.config import DtypeLike from monai.data.nifti_writer import write_nifti from monai.data.utils import create_file_basename from monai.utils import GridSampleMode, GridSamplePadMode @@ -36,8 +37,8 @@ def __init__( mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, align_corners: bool = False, - dtype: Optional[np.dtype] = np.float64, - output_dtype: Optional[np.dtype] = np.float32, + dtype: DtypeLike = np.float64, + output_dtype: DtypeLike = np.float32, ) -> None: """ Args: @@ -100,7 +101,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] affine = meta_data.get("affine", None) if meta_data else None spatial_shape = meta_data.get("spatial_shape", None) if meta_data else None - if torch.is_tensor(data): + if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() filename = create_file_basename(self.output_postfix, filename, self.output_dir) @@ -109,7 +110,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] while len(data.shape) < 4: data = np.expand_dims(data, -1) # change data to "channel last" format and write to nifti format file - data = np.moveaxis(data, 0, -1) + data = np.moveaxis(np.asarray(data), 0, -1) write_nifti( data, file_name=filename, diff --git a/monai/data/nifti_writer.py b/monai/data/nifti_writer.py index 6837ebeb90..29dc62cdec 100644 --- a/monai/data/nifti_writer.py +++ b/monai/data/nifti_writer.py @@ -14,6 +14,7 @@ import numpy as np import torch +from monai.config import DtypeLike from monai.data.utils import compute_shape_offset, to_affine_nd from monai.networks.layers import AffineTransform from monai.utils import GridSampleMode, GridSamplePadMode, optional_import @@ -27,12 +28,12 @@ def write_nifti( affine: Optional[np.ndarray] = None, target_affine: Optional[np.ndarray] = None, resample: bool = True, - output_spatial_shape: Optional[Sequence[int]] = None, + output_spatial_shape: Union[Sequence[int], np.ndarray, None] = None, mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, align_corners: bool = False, - dtype: Optional[np.dtype] = np.float64, - output_dtype: Optional[np.dtype] = np.float32, + dtype: DtypeLike = np.float64, + output_dtype: DtypeLike = np.float32, ) -> None: """ Write numpy data into NIfTI files to disk. This function converts data @@ -126,7 +127,7 @@ def write_nifti( transform = np.linalg.inv(_affine) @ target_affine if output_spatial_shape is None: output_spatial_shape, _ = compute_shape_offset(data.shape, _affine, target_affine) - output_spatial_shape_ = list(output_spatial_shape) + output_spatial_shape_ = list(output_spatial_shape) if output_spatial_shape is not None else [] if data.ndim > 3: # multi channel, resampling each channel while len(output_spatial_shape_) < 3: output_spatial_shape_ = output_spatial_shape_ + [1] diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py index 450e327d6b..8ed8b234f4 100644 --- a/monai/data/png_saver.py +++ b/monai/data/png_saver.py @@ -86,7 +86,7 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] self._data_index += 1 spatial_shape = meta_data.get("spatial_shape", None) if meta_data and self.resample else None - if torch.is_tensor(data): + if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() filename = create_file_basename(self.output_postfix, filename, self.output_dir) @@ -95,12 +95,12 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] if data.shape[0] == 1: data = data.squeeze(0) elif 2 < data.shape[0] < 5: - data = np.moveaxis(data, 0, -1) + data = np.moveaxis(np.asarray(data), 0, -1) else: raise ValueError(f"Unsupported number of channels: {data.shape[0]}, available options are [1, 3, 4]") write_png( - data, + np.asarray(data), file_name=filename, output_spatial_shape=spatial_shape, mode=self.mode, diff --git a/monai/data/png_writer.py b/monai/data/png_writer.py index d7baa6ea79..e6b9f1e8cf 100644 --- a/monai/data/png_writer.py +++ b/monai/data/png_writer.py @@ -65,10 +65,10 @@ def write_png( data = np.expand_dims(data, 0) # make a channel data = xform(data)[0] # first channel if mode != InterpolateMode.NEAREST: - data = np.clip(data, _min, _max) + data = np.clip(data, _min, _max) # type: ignore if scale is not None: - data = np.clip(data, 0.0, 1.0) # png writer only can scale data in range [0, 1] + data = np.clip(data, 0.0, 1.0) # type: ignore # png writer only can scale data in range [0, 1] if scale == np.iinfo(np.uint8).max: data = (scale * data).astype(np.uint8) elif scale == np.iinfo(np.uint16).max: diff --git a/monai/data/utils.py b/monai/data/utils.py index ca8f3b1017..acc6d2e97a 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -329,7 +329,7 @@ def rectify_header_sform_qform(img_nii): return img_nii -def zoom_affine(affine: np.ndarray, scale: Sequence[float], diagonal: bool = True) -> np.ndarray: +def zoom_affine(affine: np.ndarray, scale: Sequence[float], diagonal: bool = True): """ To make column norm of `affine` the same as `scale`. If diagonal is False, returns an affine that combines orthogonal rotation and the new scale. @@ -379,7 +379,7 @@ def zoom_affine(affine: np.ndarray, scale: Sequence[float], diagonal: bool = Tru def compute_shape_offset( - spatial_shape: np.ndarray, in_affine: np.ndarray, out_affine: np.ndarray + spatial_shape: Union[np.ndarray, Sequence[int]], in_affine: np.ndarray, out_affine: np.ndarray ) -> Tuple[np.ndarray, np.ndarray]: """ Given input and output affine, compute appropriate shapes diff --git a/monai/engines/utils.py b/monai/engines/utils.py index f603338097..8f5899f2a5 100644 --- a/monai/engines/utils.py +++ b/monai/engines/utils.py @@ -32,7 +32,7 @@ class IterationEvents(EventEnum): """ - Addtional Events engine can register and trigger in the iteration process. + Additional Events engine can register and trigger in the iteration process. Refer to the example in ignite: https://github.com/pytorch/ignite/blob/master/ignite/engine/events.py#L146 These Events can be triggered during training iteration: `FORWARD_COMPLETED` is the Event when `network(image, label)` completed. diff --git a/monai/handlers/iteration_metric.py b/monai/handlers/iteration_metric.py index bfc7252b2f..641efad243 100644 --- a/monai/handlers/iteration_metric.py +++ b/monai/handlers/iteration_metric.py @@ -96,7 +96,7 @@ def compute(self) -> Any: # save score of every image into engine.state for other components if self.save_details: if self._engine is None or self._name is None: - raise RuntimeError("plesae call the attach() function to connect expected engine first.") + raise RuntimeError("please call the attach() function to connect expected engine first.") self._engine.state.metric_details[self._name] = _scores result: torch.Tensor = torch.zeros(1) @@ -108,7 +108,7 @@ def compute(self) -> Any: # broadcast result to all processes result = idist.broadcast(result, src=0) - return result.item() if torch.is_tensor(result) else result + return result.item() if isinstance(result, torch.Tensor) else result def _reduce(self, scores) -> Any: return do_metric_reduction(scores, MetricReduction.MEAN)[0] diff --git a/monai/handlers/metrics_saver.py b/monai/handlers/metrics_saver.py index f9deea35df..d67f0f6c39 100644 --- a/monai/handlers/metrics_saver.py +++ b/monai/handlers/metrics_saver.py @@ -53,7 +53,7 @@ class MetricsSaver: should be within this list: [`mean`, `median`, `max`, `min`, `90percent`, `std`]. default to None. save_rank: only the handler on specified rank will save to files in multi-gpus validation, default to 0. - delimiter: the delimiter charactor in CSV file, default to "\t". + delimiter: the delimiter character in CSV file, default to "\t". output_type: expected output file type, supported types: ["csv"], default to "csv". """ diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index c712ce9a9e..8321a49851 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -14,6 +14,7 @@ import numpy as np +from monai.config import DtypeLike from monai.data import NiftiSaver, PNGSaver from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, exact_version, optional_import @@ -38,8 +39,8 @@ def __init__( mode: Union[GridSampleMode, InterpolateMode, str] = "nearest", padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, scale: Optional[int] = None, - dtype: Optional[np.dtype] = np.float64, - output_dtype: Optional[np.dtype] = np.float32, + dtype: DtypeLike = np.float64, + output_dtype: DtypeLike = np.float32, batch_transform: Callable = lambda x: x, output_transform: Callable = lambda x: x, name: Optional[str] = None, diff --git a/monai/handlers/stats_handler.py b/monai/handlers/stats_handler.py index 007fbed413..24d844569f 100644 --- a/monai/handlers/stats_handler.py +++ b/monai/handlers/stats_handler.py @@ -196,10 +196,12 @@ def _default_iteration_print(self, engine: Engine) -> None: " {}:{}".format(name, type(value)) ) continue # not printing multi dimensional output - out_str += self.key_var_format.format(name, value.item() if torch.is_tensor(value) else value) + out_str += self.key_var_format.format(name, value.item() if isinstance(value, torch.Tensor) else value) else: if is_scalar(loss): # not printing multi dimensional output - out_str += self.key_var_format.format(self.tag_name, loss.item() if torch.is_tensor(loss) else loss) + out_str += self.key_var_format.format( + self.tag_name, loss.item() if isinstance(loss, torch.Tensor) else loss + ) else: warnings.warn( "ignoring non-scalar output in StatsHandler," diff --git a/monai/handlers/tensorboard_handlers.py b/monai/handlers/tensorboard_handlers.py index 15fa6a5eed..acdfb84c8c 100644 --- a/monai/handlers/tensorboard_handlers.py +++ b/monai/handlers/tensorboard_handlers.py @@ -159,9 +159,13 @@ def _default_iteration_writer(self, engine: Engine, writer: SummaryWriter) -> No " {}:{}".format(name, type(value)) ) continue # not plot multi dimensional output - writer.add_scalar(name, value.item() if torch.is_tensor(value) else value, engine.state.iteration) + writer.add_scalar( + name, value.item() if isinstance(value, torch.Tensor) else value, engine.state.iteration + ) elif is_scalar(loss): # not printing multi dimensional output - writer.add_scalar(self.tag_name, loss.item() if torch.is_tensor(loss) else loss, engine.state.iteration) + writer.add_scalar( + self.tag_name, loss.item() if isinstance(loss, torch.Tensor) else loss, engine.state.iteration + ) else: warnings.warn( "ignoring non-scalar output in TensorBoardStatsHandler," @@ -261,7 +265,7 @@ def __call__(self, engine: Engine) -> None: """ step = self.global_iter_transform(engine.state.epoch if self.epoch_level else engine.state.iteration) show_images = self.batch_transform(engine.state.batch)[0] - if torch.is_tensor(show_images): + if isinstance(show_images, torch.Tensor): show_images = show_images.detach().cpu().numpy() if show_images is not None: if not isinstance(show_images, np.ndarray): @@ -274,7 +278,7 @@ def __call__(self, engine: Engine) -> None: ) show_labels = self.batch_transform(engine.state.batch)[1] - if torch.is_tensor(show_labels): + if isinstance(show_labels, torch.Tensor): show_labels = show_labels.detach().cpu().numpy() if show_labels is not None: if not isinstance(show_labels, np.ndarray): @@ -287,7 +291,7 @@ def __call__(self, engine: Engine) -> None: ) show_outputs = self.output_transform(engine.state.output) - if torch.is_tensor(show_outputs): + if isinstance(show_outputs, torch.Tensor): show_outputs = show_outputs.detach().cpu().numpy() if show_outputs is not None: if not isinstance(show_outputs, np.ndarray): diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index ef652efe0a..a4b5c02f61 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -62,7 +62,7 @@ def evenly_divisible_all_gather(data: torch.Tensor) -> torch.Tensor: data: source tensor to pad and execute all_gather in distributed data parallel. """ - if not torch.is_tensor(data): + if not isinstance(data, torch.Tensor): raise ValueError("input data must be PyTorch Tensor.") if idist.get_world_size() <= 1: @@ -110,7 +110,7 @@ def write_metrics_reports( list of strings - generate summary report for every metric_details with specified operations, they should be within this list: [`mean`, `median`, `max`, `min`, `90percent`, `std`]. default to None. - deli: the delimiter charactor in the file, default to "\t". + deli: the delimiter character in the file, default to "\t". output_type: expected output file type, supported types: ["csv"], default to "csv". """ @@ -127,7 +127,7 @@ def write_metrics_reports( if metric_details is not None and len(metric_details) > 0: for k, v in metric_details.items(): - if torch.is_tensor(v): + if isinstance(v, torch.Tensor): v = v.cpu().numpy() if v.ndim == 0: # reshape to [1, 1] if no batch and class dims @@ -162,5 +162,5 @@ def write_metrics_reports( with open(os.path.join(save_dir, f"{k}_summary.csv"), "w") as f: f.write(f"class{deli}{deli.join(ops)}\n") - for i, c in enumerate(v.transpose()): + for i, c in enumerate(np.transpose(v)): f.write(f"{class_labels[i]}{deli}{deli.join([f'{supported_ops[k](c):.4f}' for k in ops])}\n") diff --git a/monai/losses/dice.py b/monai/losses/dice.py index f14aa6955f..c284660cc6 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -508,7 +508,7 @@ def wasserstein_distance_map(self, flat_proba: torch.Tensor, flat_target: torch. flat_target: the target tensor. """ # Turn the distance matrix to a map of identical matrix - m = torch.clone(self.m).to(flat_proba.device) + m = torch.clone(torch.as_tensor(self.m)).to(flat_proba.device) m_extended = torch.unsqueeze(m, dim=0) m_extended = torch.unsqueeze(m_extended, dim=3) m_extended = m_extended.expand((flat_proba.size(0), m_extended.size(1), m_extended.size(2), flat_proba.size(2))) diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py index 8ecc19ec46..6570ace800 100644 --- a/monai/metrics/hausdorff_distance.py +++ b/monai/metrics/hausdorff_distance.py @@ -127,9 +127,10 @@ def compute_hausdorff_distance( y_pred=y_pred, y=y, ) - - y = y.float() - y_pred = y_pred.float() + if isinstance(y, torch.Tensor): + y = y.float() + if isinstance(y_pred, torch.Tensor): + y_pred = y_pred.float() if y.shape != y_pred.shape: raise ValueError("y_pred and y should have same shapes.") diff --git a/monai/metrics/rocauc.py b/monai/metrics/rocauc.py index 9f081d1698..80a6671dfa 100644 --- a/monai/metrics/rocauc.py +++ b/monai/metrics/rocauc.py @@ -10,7 +10,7 @@ # limitations under the License. import warnings -from typing import Callable, List, Optional, Union, cast +from typing import Callable, Optional, Union, cast import numpy as np import torch @@ -57,7 +57,7 @@ def compute_roc_auc( softmax: bool = False, other_act: Optional[Callable] = None, average: Union[Average, str] = Average.MACRO, -) -> Union[np.ndarray, List[float], float]: +): """Computes Area Under the Receiver Operating Characteristic Curve (ROC AUC). Referring to: `sklearn.metrics.roc_auc_score `_. diff --git a/monai/metrics/surface_distance.py b/monai/metrics/surface_distance.py index 9e2f130bd2..b605fdb88f 100644 --- a/monai/metrics/surface_distance.py +++ b/monai/metrics/surface_distance.py @@ -120,8 +120,10 @@ def compute_average_surface_distance( y=y, ) - y = y.float() - y_pred = y_pred.float() + if isinstance(y, torch.Tensor): + y = y.float() + if isinstance(y_pred, torch.Tensor): + y_pred = y_pred.float() if y.shape != y_pred.shape: raise ValueError("y_pred and y should have same shapes.") @@ -135,7 +137,7 @@ def compute_average_surface_distance( if surface_distance.shape == (0,): avg_surface_distance = np.nan else: - avg_surface_distance = surface_distance.mean() + avg_surface_distance = surface_distance.mean() # type: ignore if not symmetric: asd[b, c] = avg_surface_distance else: @@ -143,7 +145,7 @@ def compute_average_surface_distance( if surface_distance_2.shape == (0,): avg_surface_distance_2 = np.nan else: - avg_surface_distance_2 = surface_distance_2.mean() + avg_surface_distance_2 = surface_distance_2.mean() # type: ignore asd[b, c] = np.mean((avg_surface_distance, avg_surface_distance_2)) return torch.from_numpy(asd) diff --git a/monai/metrics/utils.py b/monai/metrics/utils.py index cc7049ff81..0a254d9901 100644 --- a/monai/metrics/utils.py +++ b/monai/metrics/utils.py @@ -26,8 +26,8 @@ def ignore_background( - y_pred: torch.Tensor, - y: torch.Tensor, + y_pred: Union[np.ndarray, torch.Tensor], + y: Union[np.ndarray, torch.Tensor], ): """ This function is used to remove background (the first channel) for `y_pred` and `y`. @@ -138,9 +138,9 @@ def get_mask_edges( """ # Get both labelfields as np arrays - if torch.is_tensor(seg_pred): + if isinstance(seg_pred, torch.Tensor): seg_pred = seg_pred.detach().cpu().numpy() - if torch.is_tensor(seg_gt): + if isinstance(seg_gt, torch.Tensor): seg_gt = seg_gt.detach().cpu().numpy() if seg_pred.shape != seg_gt.shape: @@ -157,7 +157,7 @@ def get_mask_edges( return (np.zeros_like(seg_pred), np.zeros_like(seg_gt)) seg_pred, seg_gt = np.expand_dims(seg_pred, 0), np.expand_dims(seg_gt, 0) - box_start, box_end = generate_spatial_bounding_box(seg_pred | seg_gt) + box_start, box_end = generate_spatial_bounding_box(np.asarray(seg_pred | seg_gt)) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) seg_pred, seg_gt = np.squeeze(cropper(seg_pred)), np.squeeze(cropper(seg_gt)) @@ -192,7 +192,7 @@ def get_surface_distance( else: if not np.any(seg_pred): dis = np.inf * np.ones_like(seg_gt) - return dis[seg_gt] + return np.asarray(dis[seg_gt]) if distance_metric == "euclidean": dis = distance_transform_edt(~seg_gt) elif distance_metric in ["chessboard", "taxicab"]: @@ -200,4 +200,4 @@ def get_surface_distance( else: raise ValueError(f"distance_metric {distance_metric} is not implemented.") - return dis[seg_pred] + return np.asarray(dis[seg_pred]) diff --git a/monai/networks/layers/convutils.py b/monai/networks/layers/convutils.py index c4f798699c..994ca05b85 100644 --- a/monai/networks/layers/convutils.py +++ b/monai/networks/layers/convutils.py @@ -57,7 +57,7 @@ def stride_minus_kernel_padding( def calculate_out_shape( - in_shape: Union[Sequence[int], int], + in_shape: Union[Sequence[int], int, np.ndarray], kernel_size: Union[Sequence[int], int], stride: Union[Sequence[int], int], padding: Union[Sequence[int], int], @@ -104,7 +104,7 @@ def gaussian_1d( 1D torch tensor """ - sigma = torch.as_tensor(sigma, dtype=torch.float, device=sigma.device if torch.is_tensor(sigma) else None) + sigma = torch.as_tensor(sigma, dtype=torch.float, device=sigma.device if isinstance(sigma, torch.Tensor) else None) device = sigma.device if truncated <= 0.0: raise ValueError(f"truncated must be positive, got {truncated}.") @@ -149,7 +149,7 @@ def polyval(coef, x) -> torch.Tensor: Returns: 1D torch tensor """ - device = x.device if torch.is_tensor(x) else None + device = x.device if isinstance(x, torch.Tensor) else None coef = torch.as_tensor(coef, dtype=torch.float, device=device) if coef.ndim == 0 or (len(coef) < 1): return torch.zeros(x.shape) @@ -161,7 +161,7 @@ def polyval(coef, x) -> torch.Tensor: def _modified_bessel_0(x: torch.Tensor) -> torch.Tensor: - x = torch.as_tensor(x, dtype=torch.float, device=x.device if torch.is_tensor(x) else None) + x = torch.as_tensor(x, dtype=torch.float, device=x.device if isinstance(x, torch.Tensor) else None) if torch.abs(x) < 3.75: y = x * x / 14.0625 return polyval([0.45813e-2, 0.360768e-1, 0.2659732, 1.2067492, 3.0899424, 3.5156229, 1.0], y) @@ -182,7 +182,7 @@ def _modified_bessel_0(x: torch.Tensor) -> torch.Tensor: def _modified_bessel_1(x: torch.Tensor) -> torch.Tensor: - x = torch.as_tensor(x, dtype=torch.float, device=x.device if torch.is_tensor(x) else None) + x = torch.as_tensor(x, dtype=torch.float, device=x.device if isinstance(x, torch.Tensor) else None) if torch.abs(x) < 3.75: y = x * x / 14.0625 _coef = [0.32411e-3, 0.301532e-2, 0.2658733e-1, 0.15084934, 0.51498869, 0.87890594, 0.5] @@ -207,7 +207,7 @@ def _modified_bessel_1(x: torch.Tensor) -> torch.Tensor: def _modified_bessel_i(n: int, x: torch.Tensor) -> torch.Tensor: if n < 2: raise ValueError(f"n must be greater than 1, got n={n}.") - x = torch.as_tensor(x, dtype=torch.float, device=x.device if torch.is_tensor(x) else None) + x = torch.as_tensor(x, dtype=torch.float, device=x.device if isinstance(x, torch.Tensor) else None) if x == 0.0: return x device = x.device diff --git a/monai/networks/layers/filtering.py b/monai/networks/layers/filtering.py index 83a33bc609..1bec725c7e 100644 --- a/monai/networks/layers/filtering.py +++ b/monai/networks/layers/filtering.py @@ -62,7 +62,7 @@ class PHLFilter(torch.autograd.Function): """ Filters input based on arbitrary feature vectors. Uses a permutohedral lattice data structure to efficiently approximate n-dimensional gaussian - filtering. Complexity is broadly independant of kernel size. Most applicable + filtering. Complexity is broadly independent of kernel size. Most applicable to higher filter dimensions and larger kernel sizes. See: diff --git a/monai/networks/layers/simplelayers.py b/monai/networks/layers/simplelayers.py index 285b0d629f..f560526db8 100644 --- a/monai/networks/layers/simplelayers.py +++ b/monai/networks/layers/simplelayers.py @@ -182,12 +182,12 @@ def separable_filtering( TypeError: When ``x`` is not a ``torch.Tensor``. """ - if not torch.is_tensor(x): + if not isinstance(x, torch.Tensor): raise TypeError(f"x must be a torch.Tensor but is {type(x).__name__}.") spatial_dims = len(x.shape) - 2 _kernels = [ - torch.as_tensor(s, dtype=torch.float, device=s.device if torch.is_tensor(s) else None) + torch.as_tensor(s, dtype=torch.float, device=s.device if isinstance(s, torch.Tensor) else None) for s in ensure_tuple_rep(kernels, spatial_dims) ] _paddings = [cast(int, (same_padding(k.shape[0]))) for k in _kernels] @@ -251,7 +251,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: """ # Make input a real tensor on the CPU - x = torch.as_tensor(x, device=x.device if torch.is_tensor(x) else None) + x = torch.as_tensor(x, device=x.device if isinstance(x, torch.Tensor) else None) if torch.is_complex(x): raise ValueError("x must be real.") else: @@ -317,7 +317,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: """ # Make input a real tensor - x = torch.as_tensor(x, device=x.device if torch.is_tensor(x) else None) + x = torch.as_tensor(x, device=x.device if isinstance(x, torch.Tensor) else None) if torch.is_complex(x): raise ValueError("x must be real.") x = x.to(dtype=torch.float) @@ -384,7 +384,7 @@ def __init__( super().__init__() self.sigma = [ torch.nn.Parameter( - torch.as_tensor(s, dtype=torch.float, device=s.device if torch.is_tensor(s) else None), + torch.as_tensor(s, dtype=torch.float, device=s.device if isinstance(s, torch.Tensor) else None), requires_grad=requires_grad, ) for s in ensure_tuple_rep(sigma, int(spatial_dims)) diff --git a/monai/networks/layers/spatial_transforms.py b/monai/networks/layers/spatial_transforms.py index c0f22502c8..175fd05694 100644 --- a/monai/networks/layers/spatial_transforms.py +++ b/monai/networks/layers/spatial_transforms.py @@ -487,7 +487,7 @@ def forward( """ # validate `theta` - if not torch.is_tensor(theta): + if not isinstance(theta, torch.Tensor): raise TypeError(f"theta must be torch.Tensor but is {type(theta).__name__}.") if theta.dim() not in (2, 3): raise ValueError(f"theta must be Nxdxd or dxd, got {theta.shape}.") @@ -504,7 +504,7 @@ def forward( raise ValueError(f"theta must be Nx3x3 or Nx4x4, got {theta.shape}.") # validate `src` - if not torch.is_tensor(src): + if not isinstance(src, torch.Tensor): raise TypeError(f"src must be torch.Tensor but is {type(src).__name__}.") sr = src.dim() - 2 # input spatial rank if sr not in (2, 3): diff --git a/monai/networks/nets/localnet.py b/monai/networks/nets/localnet.py index ea8abca185..e9df68104d 100644 --- a/monai/networks/nets/localnet.py +++ b/monai/networks/nets/localnet.py @@ -99,7 +99,7 @@ def forward(self, x) -> torch.Tensor: if size % (2 ** self.extract_max_level) != 0: raise ValueError( f"given extract_max_level {self.extract_max_level}, " - f"all input spatial dimension must be devidable by {2 ** self.extract_max_level}, " + f"all input spatial dimension must be divisible by {2 ** self.extract_max_level}, " f"got input of size {image_size}" ) mid_features = [] # 0 -> self.extract_max_level - 1 diff --git a/monai/networks/nets/regressor.py b/monai/networks/nets/regressor.py index a1abadb6ba..d64ad2fc10 100644 --- a/monai/networks/nets/regressor.py +++ b/monai/networks/nets/regressor.py @@ -78,7 +78,7 @@ def __init__( padding = same_padding(kernel_size) - self.final_size = np.asarray(self.in_shape, np.int) + self.final_size = np.asarray(self.in_shape, dtype=int) self.reshape = Reshape(*self.out_shape) # encode stage @@ -86,7 +86,7 @@ def __init__( layer = self._get_layer(echannel, c, s, i == len(channels) - 1) echannel = c # use the output channel number as the input for the next loop self.net.add_module("layer_%i" % i, layer) - self.final_size = calculate_out_shape(self.final_size, kernel_size, s, padding) + self.final_size = calculate_out_shape(self.final_size, kernel_size, s, padding) # type: ignore self.final = self._get_final_layer((echannel,) + self.final_size) diff --git a/monai/networks/nets/varautoencoder.py b/monai/networks/nets/varautoencoder.py index b68350e8b1..30ee806dbb 100644 --- a/monai/networks/nets/varautoencoder.py +++ b/monai/networks/nets/varautoencoder.py @@ -46,7 +46,7 @@ def __init__( self.in_channels, *self.in_shape = in_shape self.latent_size = latent_size - self.final_size = np.asarray(self.in_shape, np.int) + self.final_size = np.asarray(self.in_shape, dtype=int) super().__init__( dimensions, @@ -68,7 +68,7 @@ def __init__( padding = same_padding(self.kernel_size) for s in strides: - self.final_size = calculate_out_shape(self.final_size, self.kernel_size, s, padding) + self.final_size = calculate_out_shape(self.final_size, self.kernel_size, s, padding) # type: ignore linear_size = int(np.product(self.final_size)) * self.encoded_channels self.mu = nn.Linear(linear_size, self.latent_size) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 175d3d8b73..847bfc97c2 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -150,7 +150,7 @@ def to_norm_affine( ValueError: When ``src_size`` or ``dst_size`` dimensions differ from ``affine``. """ - if not torch.is_tensor(affine): + if not isinstance(affine, torch.Tensor): raise TypeError(f"affine must be a torch.Tensor but is {type(affine).__name__}.") if affine.ndimension() != 3 or affine.shape[1] != affine.shape[2]: raise ValueError(f"affine must be Nxdxd, got {tuple(affine.shape)}.") diff --git a/monai/optimizers/lr_finder.py b/monai/optimizers/lr_finder.py index 6ad4132dd0..9e753a1ced 100644 --- a/monai/optimizers/lr_finder.py +++ b/monai/optimizers/lr_finder.py @@ -5,7 +5,6 @@ import numpy as np import torch import torch.nn as nn -from numpy.core.arrayprint import _none_or_positive_arg from torch.optim import Optimizer from torch.utils.data import DataLoader @@ -363,7 +362,7 @@ def _set_learning_rate(self, new_lrs: Union[float, list]) -> None: for param_group, new_lr in zip(self.optimizer.param_groups, new_lrs): param_group["lr"] = new_lr - def _check_for_scheduler(self) -> _none_or_positive_arg: + def _check_for_scheduler(self): """Check optimizer doesn't already have scheduler.""" for param_group in self.optimizer.param_groups: if "initial_lr" in param_group: diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index e59eb89ac7..b4444803a4 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -16,6 +16,7 @@ from typing import Any, Callable, List, Optional, Sequence, Tuple, Union import numpy as np +import torch from monai.config import IndexSelection from monai.data.utils import get_random_patch, get_valid_patch_size @@ -128,7 +129,7 @@ def __init__( self.spatial_border = spatial_border self.mode: NumpyPadMode = NumpyPadMode(mode) - def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None) -> np.ndarray: + def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None): """ Args: img: data to be transformed, assuming `img` is channel-first and @@ -219,10 +220,10 @@ class SpatialCrop(Transform): def __init__( self, - roi_center: Optional[Sequence[int]] = None, - roi_size: Optional[Sequence[int]] = None, - roi_start: Optional[Sequence[int]] = None, - roi_end: Optional[Sequence[int]] = None, + roi_center: Union[Sequence[int], np.ndarray, None] = None, + roi_size: Union[Sequence[int], np.ndarray, None] = None, + roi_start: Union[Sequence[int], np.ndarray, None] = None, + roi_end: Union[Sequence[int], np.ndarray, None] = None, ) -> None: """ Args: @@ -242,14 +243,14 @@ def __init__( self.roi_start = np.maximum(np.asarray(roi_start, dtype=np.int16), 0) self.roi_end = np.maximum(np.asarray(roi_end, dtype=np.int16), self.roi_start) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> np.ndarray: """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. """ sd = min(len(self.roi_start), len(self.roi_end), len(img.shape[1:])) # spatial dims slices = [slice(None)] + [slice(s, e) for s, e in zip(self.roi_start[:sd], self.roi_end[:sd])] - return img[tuple(slices)] + return np.asarray(img[tuple(slices)]) class CenterSpatialCrop(Transform): @@ -264,7 +265,7 @@ class CenterSpatialCrop(Transform): def __init__(self, roi_size: Union[Sequence[int], int]) -> None: self.roi_size = roi_size - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. @@ -306,7 +307,7 @@ def randomize(self, img_size: Sequence[int]) -> None: valid_size = get_valid_patch_size(img_size, self._size) self._slices = (slice(None),) + get_random_patch(img_size, valid_size, self.R) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. @@ -590,6 +591,8 @@ def __call__( """ if label is None: label = self.label + if label is None: + raise ValueError("label should be provided.") if image is None: image = self.image if fg_indices is None or bg_indices is None: @@ -602,7 +605,7 @@ def __call__( results: List[np.ndarray] = [] if self.centers is not None: for center in self.centers: - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore results.append(cropper(img)) return results diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 8bf33dd632..1faed25605 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -412,8 +412,8 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda box_start, box_end = generate_spatial_bounding_box( d[self.source_key], self.select_fn, self.channel_indices, self.margin ) - d[self.start_coord_key] = box_start - d[self.end_coord_key] = box_end + d[self.start_coord_key] = np.asarray(box_start) + d[self.end_coord_key] = np.asarray(box_end) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) for key in self.keys: d[key] = cropper(d[key]) @@ -583,7 +583,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n if key in self.keys: img = d[key] for i, center in enumerate(self.centers): - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) + cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore results[i][key] = cropper(img) else: for i in range(self.num_samples): diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 205b719246..87091f6237 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -20,6 +20,7 @@ import numpy as np import torch +from monai.config import DtypeLike from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter from monai.transforms.compose import Randomizable, Transform from monai.transforms.utils import rescale_array @@ -97,7 +98,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: """ Apply the transform to `img`. """ - return (img + self.offset).astype(img.dtype) + return np.asarray((img + self.offset), dtype=img.dtype) class RandShiftIntensity(Randomizable, Transform): @@ -165,9 +166,9 @@ def __call__(self, img: np.ndarray) -> np.ndarray: """ if self.minv is not None and self.maxv is not None: - return rescale_array(img, self.minv, self.maxv, img.dtype) + return np.asarray(rescale_array(img, self.minv, self.maxv, img.dtype)) if self.factor is not None: - return (img * (1 + self.factor)).astype(img.dtype) + return np.asarray(img * (1 + self.factor), dtype=img.dtype) raise ValueError("Incompatible values: minv=None or maxv=None and factor=None.") @@ -229,11 +230,11 @@ class NormalizeIntensity(Transform): def __init__( self, - subtrahend: Optional[Sequence] = None, - divisor: Optional[Sequence] = None, + subtrahend: Union[Sequence, np.ndarray, None] = None, + divisor: Union[Sequence, np.ndarray, None] = None, nonzero: bool = False, channel_wise: bool = False, - dtype: np.dtype = np.float32, + dtype: DtypeLike = np.float32, ) -> None: self.subtrahend = subtrahend self.divisor = divisor @@ -304,7 +305,9 @@ def __call__(self, img: np.ndarray) -> np.ndarray: """ Apply the transform to `img`. """ - return np.where(img > self.threshold if self.above else img < self.threshold, img, self.cval).astype(img.dtype) + return np.asarray( + np.where(img > self.threshold if self.above else img < self.threshold, img, self.cval), dtype=img.dtype + ) class ScaleIntensityRange(Transform): @@ -327,7 +330,7 @@ def __init__(self, a_min: float, a_max: float, b_min: float, b_max: float, clip: self.b_max = b_max self.clip = clip - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Apply the transform to `img`. """ @@ -338,8 +341,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: img = (img - self.a_min) / (self.a_max - self.a_min) img = img * (self.b_max - self.b_min) + self.b_min if self.clip: - img = np.clip(img, self.b_min, self.b_max) - + img = np.asarray(np.clip(img, self.b_min, self.b_max)) return img @@ -358,7 +360,7 @@ def __init__(self, gamma: float) -> None: raise AssertionError("gamma must be a float or int number.") self.gamma = gamma - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Apply the transform to `img`. """ @@ -483,7 +485,7 @@ def __init__( self.clip = clip self.relative = relative - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Apply the transform to `img`. """ @@ -500,7 +502,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: img = scalar(img) if self.clip: - img = np.clip(img, self.b_min, self.b_max) + img = np.asarray(np.clip(img, self.b_min, self.b_max)) return img @@ -513,36 +515,44 @@ class MaskIntensity(Transform): data will be set to `0`, others will keep the original value. Args: - mask_data: if mask data is single channel, apply to evey channel - of input image. if multiple channels, the channel number must - match input data. mask_data will be converted to `bool` values + mask_data: if `mask_data` is single channel, apply to every channel + of input image. if multiple channels, the number of channels must + match the input data. `mask_data` will be converted to `bool` values by `mask_data > 0` before applying transform to input image. """ - def __init__(self, mask_data: np.ndarray) -> None: + def __init__(self, mask_data: Optional[np.ndarray]) -> None: self.mask_data = mask_data def __call__(self, img: np.ndarray, mask_data: Optional[np.ndarray] = None) -> np.ndarray: """ Args: - mask_data: if mask data is single channel, apply to evey channel + mask_data: if mask data is single channel, apply to every channel of input image. if multiple channels, the channel number must match input data. mask_data will be converted to `bool` values by `mask_data > 0` before applying transform to input image. Raises: - ValueError: When ``mask_data`` and ``img`` channels differ and ``mask_data`` is not single channel. - - """ - mask_data_ = self.mask_data > 0 if mask_data is None else mask_data > 0 + - ValueError: When both ``mask_data`` and ``self.mask_data`` are None. + - ValueError: When ``mask_data`` and ``img`` channels differ and ``mask_data`` is not single channel. + + """ + if self.mask_data is None and mask_data is None: + raise ValueError("Unknown mask_data.") + mask_data_ = np.array([[1]]) + if self.mask_data is not None and mask_data is None: + mask_data_ = self.mask_data > 0 + if mask_data is not None: + mask_data_ = mask_data > 0 + mask_data_ = np.asarray(mask_data_) if mask_data_.shape[0] != 1 and mask_data_.shape[0] != img.shape[0]: raise ValueError( "When mask_data is not single channel, mask_data channels must match img, " f"got img={img.shape[0]} mask_data={mask_data_.shape[0]}." ) - return img * mask_data_ + return np.asarray(img * mask_data_) class SavitzkyGolaySmooth(Transform): @@ -567,7 +577,7 @@ def __init__(self, window_length: int, order: int, axis: int = 1, mode: str = "z self.axis = axis self.mode = mode - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Args: img: numpy.ndarray containing input data. Must be real and in shape [channels, spatial1, spatial2, ...]. @@ -606,7 +616,7 @@ def __init__(self, axis: int = 1, n: Union[int, None] = None) -> None: self.axis = axis self.n = n - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): """ Args: @@ -641,7 +651,7 @@ def __init__(self, sigma: Union[Sequence[float], float] = 1.0, approx: str = "er self.sigma = sigma self.approx = approx - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): gaussian_filter = GaussianFilter(img.ndim - 1, self.sigma, approx=self.approx) input_data = torch.as_tensor(np.ascontiguousarray(img), dtype=torch.float).unsqueeze(0) return gaussian_filter(input_data).squeeze(0).detach().numpy() @@ -682,7 +692,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.y = self.R.uniform(low=self.sigma_y[0], high=self.sigma_y[1]) self.z = self.R.uniform(low=self.sigma_z[0], high=self.sigma_z[1]) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): self.randomize() if not self._do_transform: return img @@ -729,7 +739,7 @@ def __init__( self.alpha = alpha self.approx = approx - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): gaussian_filter1 = GaussianFilter(img.ndim - 1, self.sigma1, approx=self.approx) gaussian_filter2 = GaussianFilter(img.ndim - 1, self.sigma2, approx=self.approx) input_data = torch.as_tensor(np.ascontiguousarray(img), dtype=torch.float).unsqueeze(0) @@ -796,7 +806,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.z2 = self.R.uniform(low=sigma2_z[0], high=sigma2_z[1]) self.a = self.R.uniform(low=self.alpha[0], high=self.alpha[1]) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray): self.randomize() if not self._do_transform: return img @@ -848,4 +858,6 @@ def __call__(self, img: np.ndarray) -> np.ndarray: img_min, img_max = img.min(), img.max() reference_control_points_scaled = self.reference_control_points * (img_max - img_min) + img_min floating_control_points_scaled = self.floating_control_points * (img_max - img_min) + img_min - return np.interp(img, reference_control_points_scaled, floating_control_points_scaled).astype(img.dtype) + return np.asarray( + np.interp(img, reference_control_points_scaled, floating_control_points_scaled), dtype=img.dtype + ) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 18e2250084..48f0657ab0 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -21,7 +21,7 @@ import numpy as np import torch -from monai.config import KeysCollection +from monai.config import DtypeLike, KeysCollection from monai.transforms.compose import MapTransform, Randomizable from monai.transforms.intensity.array import ( AdjustContrast, @@ -294,7 +294,7 @@ def __init__( divisor: Optional[np.ndarray] = None, nonzero: bool = False, channel_wise: bool = False, - dtype: np.dtype = np.float32, + dtype: DtypeLike = np.float32, ) -> None: super().__init__(keys) self.normalizer = NormalizeIntensity(subtrahend, divisor, nonzero, channel_wise, dtype) @@ -474,7 +474,7 @@ class MaskIntensityd(MapTransform): Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` - mask_data: if mask data is single channel, apply to evey channel + mask_data: if mask data is single channel, apply to every channel of input image. if multiple channels, the channel number must match input data. mask_data will be converted to `bool` values by `mask_data > 0` before applying transform to input image. diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 3b359cc460..772c7cf74f 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -17,6 +17,7 @@ import numpy as np +from monai.config import DtypeLike from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader from monai.transforms.compose import Transform from monai.utils import ensure_tuple, optional_import @@ -42,7 +43,7 @@ def __init__( self, reader: Optional[Union[ImageReader, str]] = None, image_only: bool = False, - dtype: np.dtype = np.float32, + dtype: DtypeLike = np.float32, *args, **kwargs, ) -> None: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 62ac4c8562..40737374cf 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -19,7 +19,7 @@ import numpy as np -from monai.config import KeysCollection +from monai.config import DtypeLike, KeysCollection from monai.data.image_reader import ImageReader from monai.transforms.compose import MapTransform from monai.transforms.io.array import LoadImage @@ -52,7 +52,7 @@ def __init__( self, keys: KeysCollection, reader: Optional[Union[ImageReader, str]] = None, - dtype: Optional[np.dtype] = np.float32, + dtype: DtypeLike = np.float32, meta_key_postfix: str = "meta_dict", overwriting: bool = False, *args, diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 3e1ded4e94..75a25459e8 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -19,7 +19,7 @@ import numpy as np import torch -from monai.config import USE_COMPILED +from monai.config import USE_COMPILED, DtypeLike 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.compose import Randomizable, Transform @@ -81,7 +81,7 @@ def __init__( mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, align_corners: bool = False, - dtype: Optional[np.dtype] = np.float64, + dtype: DtypeLike = np.float64, ) -> None: """ Args: @@ -123,7 +123,7 @@ def __call__( mode: Optional[Union[GridSampleMode, str]] = None, padding_mode: Optional[Union[GridSamplePadMode, str]] = None, align_corners: Optional[bool] = None, - dtype: Optional[np.dtype] = None, + dtype: DtypeLike = None, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Args: @@ -192,7 +192,7 @@ def __call__( torch.as_tensor(np.ascontiguousarray(transform).astype(_dtype)), spatial_size=output_shape, ) - output_data = output_data.squeeze(0).detach().cpu().numpy().astype(np.float32) + output_data = np.asarray(output_data.squeeze(0).detach().cpu().numpy(), dtype=np.float32) # type: ignore new_affine = to_affine_nd(affine, new_affine) return output_data, affine, new_affine @@ -372,7 +372,7 @@ def __call__( align_corners=self.align_corners if align_corners is None else align_corners, ) resized = resized.squeeze(0).detach().cpu().numpy() - return resized + return np.asarray(resized) class Rotate(Transform): @@ -404,7 +404,7 @@ def __init__( mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, align_corners: bool = False, - dtype: Optional[np.dtype] = np.float64, + dtype: DtypeLike = np.float64, ) -> None: self.angle = angle self.keep_size = keep_size @@ -419,7 +419,7 @@ def __call__( mode: Optional[Union[GridSampleMode, str]] = None, padding_mode: Optional[Union[GridSamplePadMode, str]] = None, align_corners: Optional[bool] = None, - dtype: Optional[np.dtype] = None, + dtype: DtypeLike = None, ) -> np.ndarray: """ Args: @@ -457,7 +457,7 @@ def __call__( (len(im_shape), -1) ) corners = transform[:-1, :-1] @ corners - output_shape = (corners.ptp(axis=1) + 0.5).astype(int) + output_shape = np.asarray(corners.ptp(axis=1) + 0.5, dtype=int) shift_1 = create_translate(input_ndim, -(output_shape - 1) / 2) transform = shift @ transform @ shift_1 @@ -473,8 +473,7 @@ def __call__( torch.as_tensor(np.ascontiguousarray(transform).astype(_dtype)), spatial_size=output_shape, ) - output = output.squeeze(0).detach().cpu().numpy().astype(np.float32) - return output + return np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) class Zoom(Transform): @@ -522,7 +521,7 @@ def __call__( mode: Optional[Union[InterpolateMode, str]] = None, padding_mode: Optional[Union[NumpyPadMode, str]] = None, align_corners: Optional[bool] = None, - ) -> np.ndarray: + ): """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]). @@ -670,7 +669,7 @@ def __init__( mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.BORDER, align_corners: bool = False, - dtype: Optional[np.dtype] = np.float64, + dtype: DtypeLike = np.float64, ) -> None: self.range_x = ensure_tuple(range_x) if len(self.range_x) == 1: @@ -706,7 +705,7 @@ def __call__( mode: Optional[Union[GridSampleMode, str]] = None, padding_mode: Optional[Union[GridSamplePadMode, str]] = None, align_corners: Optional[bool] = None, - dtype: Optional[np.dtype] = None, + dtype: DtypeLike = None, ) -> np.ndarray: """ Args: @@ -856,12 +855,15 @@ def __call__( # if 2 zoom factors provided for 3D data, use the first factor for H and W dims, second factor for D dim self._zoom = ensure_tuple_rep(self._zoom[0], img.ndim - 2) + ensure_tuple(self._zoom[-1]) zoomer = Zoom(self._zoom, keep_size=self.keep_size) - return zoomer( - img, - mode=mode or self.mode, - padding_mode=padding_mode or self.padding_mode, - align_corners=self.align_corners if align_corners is None else align_corners, - ).astype(_dtype) + return np.asarray( + zoomer( + img, + mode=mode or self.mode, + padding_mode=padding_mode or self.padding_mode, + align_corners=self.align_corners if align_corners is None else align_corners, + ), + dtype=_dtype, + ) class AffineGrid(Transform): @@ -937,13 +939,15 @@ def __call__( affine = affine @ create_scale(spatial_dims, self.scale_params) affine = torch.as_tensor(np.ascontiguousarray(affine), device=self.device) - grid = torch.tensor(grid) if not torch.is_tensor(grid) else grid.detach().clone() + grid = torch.tensor(grid) if not isinstance(grid, torch.Tensor) else grid.detach().clone() if self.device: grid = grid.to(self.device) grid = (affine.float() @ grid.reshape((grid.shape[0], -1)).float()).reshape([-1] + list(grid.shape[1:])) + if grid is None or not isinstance(grid, torch.Tensor): + raise ValueError("Unknown grid.") if self.as_tensor_output: return grid - return grid.cpu().numpy() + return np.asarray(grid.cpu().numpy()) class RandAffineGrid(Randomizable, Transform): @@ -1069,7 +1073,7 @@ def randomize(self, grid_size: Sequence[int]) -> None: self.random_offset = self.R.normal(size=([len(grid_size)] + list(grid_size))).astype(np.float32) self.rand_mag = self.R.uniform(self.magnitude[0], self.magnitude[1]) - def __call__(self, spatial_size: Sequence[int]) -> Union[np.ndarray, torch.Tensor]: + def __call__(self, spatial_size: Sequence[int]): """ Args: spatial_size: spatial size of the grid. @@ -1129,11 +1133,11 @@ def __call__( See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample """ - if not torch.is_tensor(img): + if not isinstance(img, torch.Tensor): img = torch.as_tensor(np.ascontiguousarray(img)) if grid is None: raise AssertionError("Error, grid argument must be supplied as an ndarray or tensor ") - grid = torch.tensor(grid) if not torch.is_tensor(grid) else grid.detach().clone() + grid = torch.tensor(grid) if not isinstance(grid, torch.Tensor) else grid.detach().clone() if self.device: img = img.to(self.device) grid = grid.to(self.device) @@ -1173,8 +1177,8 @@ def __call__( align_corners=True, )[0] if self.as_tensor_output: - return out - return out.cpu().numpy() + return torch.as_tensor(out) + return np.asarray(out.cpu().numpy()) class Affine(Transform): @@ -1499,12 +1503,12 @@ def __call__( grid = self.rand_affine_grid(grid=grid) grid = torch.nn.functional.interpolate( # type: ignore recompute_scale_factor=True, - input=grid.unsqueeze(0), + input=torch.as_tensor(grid).unsqueeze(0), scale_factor=list(ensure_tuple(self.deform_grid.spacing)), mode=InterpolateMode.BICUBIC.value, align_corners=False, ) - grid = CenterSpatialCrop(roi_size=sp_size)(grid[0]) + grid = CenterSpatialCrop(roi_size=sp_size)(np.asarray(grid[0])) else: grid = create_grid(spatial_size=sp_size) return self.resampler(img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 615a327d90..e612a25ef8 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -20,7 +20,7 @@ import numpy as np import torch -from monai.config import KeysCollection +from monai.config import DtypeLike, KeysCollection from monai.networks.layers.simplelayers import GaussianFilter from monai.transforms.compose import MapTransform, Randomizable from monai.transforms.croppad.array import CenterSpatialCrop @@ -120,7 +120,7 @@ def __init__( mode: GridSampleModeSequence = GridSampleMode.BILINEAR, padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, - dtype: Optional[Union[Sequence[np.dtype], np.dtype]] = np.float64, + dtype: Optional[Union[Sequence[DtypeLike], DtypeLike]] = np.float64, meta_key_postfix: str = "meta_dict", ) -> None: """ @@ -152,7 +152,7 @@ def __init__( dtype: data type for resampling computation. Defaults to ``np.float64`` for best precision. If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. - It also can be a sequence of np.dtype, each element corresponds to a key in ``keys``. + It also can be a sequence of dtypes, each element corresponds to a key in ``keys``. meta_key_postfix: use `key_{postfix}` to to fetch the meta data according to the key data, default is `meta_dict`, the meta data is a dictionary object. For example, to handle key `image`, read/write affine matrices from the @@ -175,13 +175,13 @@ def __init__( def __call__( self, data: Mapping[Union[Hashable, str], Dict[str, np.ndarray]] ) -> Dict[Union[Hashable, str], Union[np.ndarray, Dict[str, np.ndarray]]]: - d = dict(data) + d: Dict = dict(data) for idx, key in enumerate(self.keys): meta_data = d[f"{key}_{self.meta_key_postfix}"] # resample array of each corresponding key # using affine fetched from d[affine_key] d[key], _, new_affine = self.spacing_transform( - data_array=d[key], + data_array=np.asarray(d[key]), affine=meta_data["affine"], mode=self.mode[idx], padding_mode=self.padding_mode[idx], @@ -244,7 +244,7 @@ def __init__( def __call__( self, data: Mapping[Union[Hashable, str], Dict[str, np.ndarray]] ) -> Dict[Union[Hashable, str], Union[np.ndarray, Dict[str, np.ndarray]]]: - d = dict(data) + d: Dict = dict(data) for key in self.keys: meta_data = d[f"{key}_{self.meta_key_postfix}"] d[key], _, new_affine = self.ornt_transform(d[key], affine=meta_data["affine"]) @@ -796,7 +796,7 @@ def __init__( mode: GridSampleModeSequence = GridSampleMode.BILINEAR, padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, - dtype: Union[Sequence[Optional[np.dtype]], Optional[np.dtype]] = np.float64, + dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, ) -> None: super().__init__(keys) self.rotator = Rotate(angle=angle, keep_size=keep_size) @@ -864,7 +864,7 @@ def __init__( mode: GridSampleModeSequence = GridSampleMode.BILINEAR, padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, - dtype: Union[Sequence[Optional[np.dtype]], Optional[np.dtype]] = np.float64, + dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, ) -> None: super().__init__(keys) self.range_x = ensure_tuple(range_x) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 5476e800f4..c0ae40de59 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -15,11 +15,12 @@ import logging import time -from typing import Callable, List, Optional, Sequence, Tuple, TypeVar, Union +from typing import Callable, List, Optional, Sequence, Tuple, Union import numpy as np import torch +from monai.config import DtypeLike, NdarrayTensor from monai.transforms.compose 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 @@ -46,10 +47,6 @@ "TorchVision", ] -# Generic type which can represent either a numpy.ndarray or a torch.Tensor -# Unlike Union can create a dependence between parameter(s) / return(s) -NdarrayTensor = TypeVar("NdarrayTensor", np.ndarray, torch.Tensor) - class Identity(Transform): """ @@ -135,7 +132,7 @@ class AddChannel(Transform): transforms. """ - def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + def __call__(self, img: NdarrayTensor): """ Apply the transform to `img`. """ @@ -209,7 +206,7 @@ class CastToType(Transform): specified PyTorch data type. """ - def __init__(self, dtype: Union[np.dtype, torch.dtype] = np.float32) -> None: + def __init__(self, dtype=np.float32) -> None: """ Args: dtype: convert image to this data type, default is `np.float32`. @@ -217,7 +214,7 @@ def __init__(self, dtype: Union[np.dtype, torch.dtype] = np.float32) -> None: self.dtype = dtype def __call__( - self, img: Union[np.ndarray, torch.Tensor], dtype: Optional[Union[np.dtype, torch.dtype]] = None + self, img: Union[np.ndarray, torch.Tensor], dtype: Optional[Union[DtypeLike, torch.dtype]] = None ) -> Union[np.ndarray, torch.Tensor]: """ Apply the transform to `img`, assuming `img` is a numpy array or PyTorch Tensor. @@ -230,8 +227,8 @@ def __call__( """ if isinstance(img, np.ndarray): - return img.astype(self.dtype if dtype is None else dtype) - if torch.is_tensor(img): + return img.astype(self.dtype if dtype is None else dtype) # type: ignore + if isinstance(img, torch.Tensor): return torch.as_tensor(img, dtype=self.dtype if dtype is None else dtype) raise TypeError(f"img must be one of (numpy.ndarray, torch.Tensor) but is {type(img).__name__}.") @@ -245,7 +242,7 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> torch.Tensor: """ Apply the transform to `img` and make it contiguous. """ - if torch.is_tensor(img): + if isinstance(img, torch.Tensor): return img.contiguous() return torch.as_tensor(np.ascontiguousarray(img)) @@ -259,7 +256,7 @@ def __call__(self, img: Union[List, Tuple, np.ndarray, torch.Tensor]) -> np.ndar """ Apply the transform to `img` and make it contiguous. """ - if torch.is_tensor(img): + if isinstance(img, torch.Tensor): img = img.detach().cpu().numpy() # type: ignore return np.ascontiguousarray(img) @@ -276,7 +273,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: """ Apply the transform to `img`. """ - return img.transpose(self.indices) + return img.transpose(self.indices) # type: ignore class SqueezeDim(Transform): @@ -303,7 +300,7 @@ def __call__(self, img: NdarrayTensor) -> NdarrayTensor: Args: img: numpy arrays with required dimension `dim` removed """ - return img.squeeze(self.dim) + return img.squeeze(self.dim) # type: ignore class DataStats(Transform): @@ -372,7 +369,7 @@ def __call__( if self.value_range if value_range is None else value_range: if isinstance(img, np.ndarray): lines.append(f"Value range: ({np.min(img)}, {np.max(img)})") - elif torch.is_tensor(img): + elif isinstance(img, torch.Tensor): lines.append(f"Value range: ({torch.min(img)}, {torch.max(img)})") else: lines.append(f"Value range: (not a PyTorch or Numpy array, type: {type(img)})") @@ -497,7 +494,7 @@ def __init__( # pytype: disable=annotation-type-mismatch def __call__( self, img: np.ndarray, select_labels: Optional[Union[Sequence[int], int]] = None, merge_channels: bool = False - ) -> np.ndarray: + ): """ Args: select_labels: labels to generate mask from. for 1 channel label, the `select_labels` @@ -617,7 +614,7 @@ def __call__( sigma: Union[Sequence[float], float, Sequence[torch.Tensor], torch.Tensor] = 3.0, rescale_min: float = -1.0, rescale_max: float = 1.0, - ) -> np.ndarray: + ): """ Args: img: the image that we want to add new channel to. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index ef89dbe32d..951c9dd459 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -22,7 +22,7 @@ import numpy as np import torch -from monai.config import KeysCollection +from monai.config import DtypeLike, KeysCollection, NdarrayTensor from monai.transforms.compose import MapTransform, Randomizable from monai.transforms.utility.array import ( AddChannel, @@ -127,7 +127,9 @@ def __init__(self, keys: KeysCollection) -> None: super().__init__(keys) self.identity = Identity() - def __call__(self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]]) -> Dict[Hashable, np.ndarray]: + def __call__( + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: d = dict(data) for key in self.keys: d[key] = self.identity(d[key]) @@ -192,9 +194,7 @@ def __init__(self, keys: KeysCollection) -> None: super().__init__(keys) self.adder = AddChannel() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for key in self.keys: d[key] = self.adder(d[key]) @@ -279,14 +279,14 @@ class CastToTyped(MapTransform): def __init__( self, keys: KeysCollection, - dtype: Union[Sequence[Union[np.dtype, torch.dtype]], np.dtype, torch.dtype] = np.float32, + dtype: Union[Sequence[Union[DtypeLike, torch.dtype]], DtypeLike, torch.dtype] = np.float32, ) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` dtype: convert image to this data type, default is `np.float32`. - it also can be a sequence of np.dtype or torch.dtype, + it also can be a sequence of dtypes or torch.dtype, each element corresponds to a key in ``keys``. """ @@ -318,7 +318,9 @@ def __init__(self, keys: KeysCollection) -> None: super().__init__(keys) self.converter = ToTensor() - def __call__(self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]]) -> Dict[Hashable, torch.Tensor]: + def __call__( + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: d = dict(data) for key in self.keys: d[key] = self.converter(d[key]) @@ -339,7 +341,9 @@ def __init__(self, keys: KeysCollection) -> None: super().__init__(keys) self.converter = ToNumpy() - def __call__(self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]]) -> Dict[Hashable, np.ndarray]: + def __call__( + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: d = dict(data) for key in self.keys: d[key] = self.converter(d[key]) @@ -382,9 +386,7 @@ def __init__(self, keys: KeysCollection, dim: int = 0) -> None: super().__init__(keys) self.converter = SqueezeDim(dim=dim) - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for key in self.keys: d[key] = self.converter(d[key]) @@ -435,9 +437,7 @@ def __init__( self.logger_handler = logger_handler self.printer = DataStats(logger_handler=logger_handler) - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for idx, key in enumerate(self.keys): d[key] = self.printer( @@ -469,9 +469,7 @@ def __init__(self, keys: KeysCollection, delay_time: Union[Sequence[float], floa self.delay_time = ensure_tuple_rep(delay_time, len(self.keys)) self.delayer = SimulateDelay() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for idx, key in enumerate(self.keys): d[key] = self.delayer(d[key], delay_time=self.delay_time[idx]) diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 23c6bd100a..e5e9f81cc6 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -17,7 +17,7 @@ import numpy as np import torch -from monai.config import IndexSelection +from monai.config import DtypeLike, IndexSelection from monai.networks.layers import GaussianFilter from monai.utils import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, min_version, optional_import @@ -58,7 +58,7 @@ def rand_choice(prob: float = 0.5) -> bool: return bool(random.random() <= prob) -def img_bounds(img: np.ndarray) -> np.ndarray: +def img_bounds(img: np.ndarray): """ Returns the minimum and maximum indices of non-zero lines in axis 0 of `img`, followed by that for axis 1. """ @@ -91,9 +91,7 @@ def zero_margins(img: np.ndarray, margin: int) -> bool: return not np.any(img[:, :margin, :]) and not np.any(img[:, -margin:, :]) -def rescale_array( - arr: np.ndarray, minv: float = 0.0, maxv: float = 1.0, dtype: Optional[np.dtype] = np.float32 -) -> np.ndarray: +def rescale_array(arr: np.ndarray, minv: float = 0.0, maxv: float = 1.0, dtype: DtypeLike = np.float32): """ Rescale the values of numpy array `arr` to be from `minv` to `maxv`. """ @@ -111,7 +109,7 @@ def rescale_array( def rescale_instance_array( - arr: np.ndarray, minv: float = 0.0, maxv: float = 1.0, dtype: np.dtype = np.float32 + arr: np.ndarray, minv: float = 0.0, maxv: float = 1.0, dtype: DtypeLike = np.float32 ) -> np.ndarray: """ Rescale each array slice along the first dimension of `arr` independently. @@ -123,12 +121,12 @@ def rescale_instance_array( return out -def rescale_array_int_max(arr: np.ndarray, dtype: np.dtype = np.uint16) -> np.ndarray: +def rescale_array_int_max(arr: np.ndarray, dtype: DtypeLike = np.uint16) -> np.ndarray: """ Rescale the array `arr` to be between the minimum and maximum values of the type `dtype`. """ info: np.iinfo = np.iinfo(dtype) - return rescale_array(arr, info.min, info.max).astype(dtype) + return np.asarray(rescale_array(arr, info.min, info.max), dtype=dtype) def copypaste_arrays( @@ -191,9 +189,7 @@ def copypaste_arrays( return tuple(srcslices), tuple(destslices) -def resize_center( - img: np.ndarray, *resize_dims: Optional[int], fill_value: float = 0.0, inplace: bool = True -) -> np.ndarray: +def resize_center(img: np.ndarray, *resize_dims: Optional[int], fill_value: float = 0.0, inplace: bool = True): """ Resize `img` by cropping or expanding the image from the center. The `resize_dims` values are the output dimensions (or None to use original dimension of `img`). If a dimension is smaller than that of `img` then the result will be @@ -208,7 +204,7 @@ def resize_center( srcslices, destslices = copypaste_arrays(img.shape, resize_dims, half_img_shape, half_dest_shape, resize_dims) if not inplace: - dest = np.full(resize_dims, fill_value, img.dtype) + dest = np.full(resize_dims, fill_value, img.dtype) # type: ignore dest[destslices] = img[srcslices] return dest return img[srcslices] @@ -271,8 +267,8 @@ def weighted_patch_samples( raise ValueError("w must be an ND array.") if r_state is None: r_state = np.random.RandomState() - img_size = np.asarray(w.shape, dtype=np.int) - win_size = np.asarray(fall_back_tuple(spatial_size, img_size), dtype=np.int) + img_size = np.asarray(w.shape, dtype=int) + win_size = np.asarray(fall_back_tuple(spatial_size, img_size), dtype=int) s = tuple(slice(w // 2, m - w + w // 2) if m > w else slice(m // 2, m // 2 + 1) for w, m in zip(win_size, img_size)) v = w[s] # weight map in the 'valid' mode @@ -287,7 +283,7 @@ def weighted_patch_samples( idx = v.searchsorted(r_state.random(n_samples) * v[-1], side="right") # compensate 'valid' mode diff = np.minimum(win_size, img_size) // 2 - return [np.unravel_index(i, v_size) + diff for i in np.asarray(idx, dtype=np.int)] + return [np.unravel_index(i, v_size) + diff for i in np.asarray(idx, dtype=int)] def generate_pos_neg_label_crop_centers( @@ -395,8 +391,8 @@ def create_grid( spatial_size: Sequence[int], spacing: Optional[Sequence[float]] = None, homogeneous: bool = True, - dtype: np.dtype = float, -) -> np.ndarray: + dtype: DtypeLike = float, +): """ compute a `spatial_size` mesh. @@ -415,8 +411,8 @@ def create_grid( def create_control_grid( - spatial_shape: Sequence[int], spacing: Sequence[float], homogeneous: bool = True, dtype: np.dtype = float -) -> np.ndarray: + spatial_shape: Sequence[int], spacing: Sequence[float], homogeneous: bool = True, dtype: DtypeLike = float +): """ control grid with two additional point in each direction """ @@ -461,11 +457,15 @@ def create_rotate(spatial_dims: int, radians: Union[Sequence[float], float]) -> ) if len(radians) >= 2: sin_, cos_ = np.sin(radians[1]), np.cos(radians[1]) + if affine is None: + raise ValueError("Affine should be a matrix.") affine = affine @ np.array( [[cos_, 0.0, sin_, 0.0], [0.0, 1.0, 0.0, 0.0], [-sin_, 0.0, cos_, 0.0], [0.0, 0.0, 0.0, 1.0]] ) if len(radians) >= 3: sin_, cos_ = np.sin(radians[2]), np.cos(radians[2]) + if affine is None: + raise ValueError("Affine should be a matrix.") affine = affine @ np.array( [[cos_, -sin_, 0.0, 0.0], [sin_, cos_, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] ) @@ -504,7 +504,7 @@ def create_shear(spatial_dims: int, coefs: Union[Sequence[float], float]) -> np. raise NotImplementedError("Currently only spatial_dims in [2, 3] are supported.") -def create_scale(spatial_dims: int, scaling_factor: Union[Sequence[float], float]) -> np.ndarray: +def create_scale(spatial_dims: int, scaling_factor: Union[Sequence[float], float]): """ create a scaling matrix @@ -528,7 +528,7 @@ def create_translate(spatial_dims: int, shift: Union[Sequence[float], float]) -> affine = np.eye(spatial_dims + 1) for i, a in enumerate(shift[:spatial_dims]): affine[i, spatial_dims] = a - return affine + return np.asarray(affine) def generate_spatial_bounding_box( diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 2b31392a46..c5e8318db3 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -76,7 +76,7 @@ def issequenceiterable(obj: Any) -> bool: """ Determine if the object is an iterable sequence and is not a string. """ - if torch.is_tensor(obj): + if isinstance(obj, torch.Tensor): return int(obj.dim()) > 0 # a 0-d tensor is not iterable return isinstance(obj, collections.abc.Iterable) and not isinstance(obj, str) @@ -130,7 +130,9 @@ def ensure_tuple_rep(tup: Any, dim: int) -> Tuple[Any, ...]: raise ValueError(f"Sequence must have length {dim}, got {len(tup)}.") -def fall_back_tuple(user_provided: Any, default: Sequence, func: Callable = lambda x: x and x > 0) -> Tuple[Any, ...]: +def fall_back_tuple( + user_provided: Any, default: Union[Sequence, np.ndarray], func: Callable = lambda x: x and x > 0 +) -> Tuple[Any, ...]: """ Refine `user_provided` according to the `default`, and returns as a validated tuple. @@ -175,13 +177,13 @@ def fall_back_tuple(user_provided: Any, default: Sequence, func: Callable = lamb def is_scalar_tensor(val: Any) -> bool: - if torch.is_tensor(val) and val.ndim == 0: + if isinstance(val, torch.Tensor) and val.ndim == 0: return True return False def is_scalar(val: Any) -> bool: - if torch.is_tensor(val) and val.ndim == 0: + if isinstance(val, torch.Tensor) and val.ndim == 0: return True return bool(np.isscalar(val)) @@ -287,7 +289,7 @@ def _parse_var(s): _torch_to_np_dtype = { - torch.bool: np.bool, + torch.bool: bool, torch.uint8: np.uint8, torch.int8: np.int8, torch.int16: np.int16, diff --git a/monai/visualize/img2tensorboard.py b/monai/visualize/img2tensorboard.py index 8f6eca5482..b02a7a80ea 100644 --- a/monai/visualize/img2tensorboard.py +++ b/monai/visualize/img2tensorboard.py @@ -96,7 +96,7 @@ def make_animated_gif_summary( for it_i in range(min(max_out, list(image.shape)[0])): one_channel_img: Union[torch.Tensor, np.ndarray] = ( - image[it_i, :, :, :].squeeze(dim=0) if torch.is_tensor(image) else image[it_i, :, :, :] + image[it_i, :, :, :].squeeze(dim=0) if isinstance(image, torch.Tensor) else image[it_i, :, :, :] ) summary_op = _image3_animated_gif(tag + suffix.format(it_i), one_channel_img, scale_factor) return summary_op @@ -182,7 +182,7 @@ def plot_2d_or_3d_image( max_frames: number of frames for 2D-t plot. tag: tag of the plotted image on TensorBoard. """ - d = data[index].detach().cpu().numpy() if torch.is_tensor(data) else data[index] + d = data[index].detach().cpu().numpy() if isinstance(data, torch.Tensor) else data[index] if d.ndim == 2: d = rescale_array(d, 0, 1) diff --git a/tests/test_affine.py b/tests/test_affine.py index fbda818437..934473fc5c 100644 --- a/tests/test_affine.py +++ b/tests/test_affine.py @@ -79,8 +79,8 @@ class TestAffine(unittest.TestCase): def test_affine(self, input_param, input_data, expected_val): g = Affine(**input_param) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_affine_grid.py b/tests/test_affine_grid.py index c7caae29b4..2906cd18b6 100644 --- a/tests/test_affine_grid.py +++ b/tests/test_affine_grid.py @@ -93,8 +93,8 @@ class TestAffineGrid(unittest.TestCase): def test_affine_grid(self, input_param, input_data, expected_val): g = AffineGrid(**input_param) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_crop_foregroundd.py b/tests/test_crop_foregroundd.py index f4283514de..cacf990763 100644 --- a/tests/test_crop_foregroundd.py +++ b/tests/test_crop_foregroundd.py @@ -65,14 +65,14 @@ def test_value(self, argments, image, expected_data): @parameterized.expand([TEST_CASE_1]) def test_foreground_position(self, argments, image, _): result = CropForegroundd(**argments)(image) - self.assertListEqual(result["foreground_start_coord"], [1, 1]) - self.assertListEqual(result["foreground_end_coord"], [4, 4]) + np.testing.assert_allclose(result["foreground_start_coord"], np.array([1, 1])) + np.testing.assert_allclose(result["foreground_end_coord"], np.array([4, 4])) argments["start_coord_key"] = "test_start_coord" argments["end_coord_key"] = "test_end_coord" result = CropForegroundd(**argments)(image) - self.assertListEqual(result["test_start_coord"], [1, 1]) - self.assertListEqual(result["test_end_coord"], [4, 4]) + np.testing.assert_allclose(result["test_start_coord"], np.array([1, 1])) + np.testing.assert_allclose(result["test_end_coord"], np.array([4, 4])) if __name__ == "__main__": diff --git a/tests/test_detect_envelope.py b/tests/test_detect_envelope.py index 08c699c84f..47b3a66305 100644 --- a/tests/test_detect_envelope.py +++ b/tests/test_detect_envelope.py @@ -98,7 +98,7 @@ TEST_CASE_INVALID_DTYPE = [ {}, - np.expand_dims(np.array(hann_windowed_sine, dtype=np.complex), 0), # complex numbers are invalid + np.expand_dims(np.array(hann_windowed_sine, dtype=complex), 0), # complex numbers are invalid "__call__", # method expected to raise exception ] diff --git a/tests/test_patch_dataset.py b/tests/test_patch_dataset.py index 59174123ca..3dadbe3d92 100644 --- a/tests/test_patch_dataset.py +++ b/tests/test_patch_dataset.py @@ -42,7 +42,7 @@ def test_shape(self): def test_loading_array(self): set_determinism(seed=1234) # image dataset - images = [np.arange(16, dtype=np.float).reshape(1, 4, 4), np.arange(16, dtype=np.float).reshape(1, 4, 4)] + images = [np.arange(16, dtype=float).reshape(1, 4, 4), np.arange(16, dtype=float).reshape(1, 4, 4)] # image patch sampler n_samples = 8 sampler = RandSpatialCropSamples(roi_size=(3, 3), num_samples=n_samples, random_center=True, random_size=False) diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index 72fa772d96..68126f5c8e 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -74,8 +74,8 @@ def test_rand_affine(self, input_param, input_data, expected_val): g = RandAffine(**input_param) g.set_random_state(123) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index c3fe078afd..605d0a30ba 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -187,8 +187,8 @@ def test_rand_affine_grid(self, input_param, input_data, expected_val): g = RandAffineGrid(**input_param) g.set_random_state(123) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index 51bb59cd37..54d71ad8f7 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -146,8 +146,8 @@ def test_rand_affined(self, input_param, input_data, expected_val): for key in res: result = res[key] expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_deform_grid.py b/tests/test_rand_deform_grid.py index 0b969f8f4b..7c12c263d2 100644 --- a/tests/test_rand_deform_grid.py +++ b/tests/test_rand_deform_grid.py @@ -129,8 +129,8 @@ def test_rand_deform_grid(self, input_param, input_data, expected_val): g = RandDeformGrid(**input_param) g.set_random_state(123) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_elastic_2d.py b/tests/test_rand_elastic_2d.py index c9db225742..aa408f0fdc 100644 --- a/tests/test_rand_elastic_2d.py +++ b/tests/test_rand_elastic_2d.py @@ -95,8 +95,8 @@ def test_rand_2d_elastic(self, input_param, input_data, expected_val): g = Rand2DElastic(**input_param) g.set_random_state(123) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_elastic_3d.py b/tests/test_rand_elastic_3d.py index f2b1669a46..8cd74c6be7 100644 --- a/tests/test_rand_elastic_3d.py +++ b/tests/test_rand_elastic_3d.py @@ -74,8 +74,8 @@ def test_rand_3d_elastic(self, input_param, input_data, expected_val): g = Rand3DElastic(**input_param) g.set_random_state(123) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_elasticd_2d.py b/tests/test_rand_elasticd_2d.py index 054a0c2150..f8eb026088 100644 --- a/tests/test_rand_elasticd_2d.py +++ b/tests/test_rand_elasticd_2d.py @@ -144,8 +144,8 @@ def test_rand_2d_elasticd(self, input_param, input_data, expected_val): for key in res: result = res[key] expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected, rtol=1e-4, atol=1e-4) diff --git a/tests/test_rand_elasticd_3d.py b/tests/test_rand_elasticd_3d.py index 97df8a43e3..47ab814882 100644 --- a/tests/test_rand_elasticd_3d.py +++ b/tests/test_rand_elasticd_3d.py @@ -115,8 +115,8 @@ def test_rand_3d_elasticd(self, input_param, input_data, expected_val): for key in res: result = res[key] expected = expected_val[key] if isinstance(expected_val, dict) else expected_val - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected, rtol=1e-4, atol=1e-4) diff --git a/tests/test_resampler.py b/tests/test_resampler.py index a4536967fa..2be94acebd 100644 --- a/tests/test_resampler.py +++ b/tests/test_resampler.py @@ -75,8 +75,8 @@ class TestResample(unittest.TestCase): def test_resample(self, input_param, input_data, expected_val): g = Resample(**input_param) result = g(**input_data) - self.assertEqual(torch.is_tensor(result), torch.is_tensor(expected_val)) - if torch.is_tensor(result): + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + if isinstance(result, torch.Tensor): np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) else: np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) diff --git a/tests/test_spacing.py b/tests/test_spacing.py index bc491f2f82..9a1ee88679 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -19,19 +19,19 @@ TEST_CASES = [ [ - {"pixdim": (1.0, 1.5, 1.0), "padding_mode": "zeros", "dtype": np.float}, + {"pixdim": (1.0, 1.5, 1.0), "padding_mode": "zeros", "dtype": float}, np.arange(4).reshape((1, 2, 2)) + 1.0, # data {"affine": np.eye(4)}, np.array([[[1.0, 1.0], [3.0, 2.0]]]), ], [ - {"pixdim": 1.0, "padding_mode": "zeros", "dtype": np.float}, + {"pixdim": 1.0, "padding_mode": "zeros", "dtype": float}, np.ones((1, 2, 1, 2)), # data {"affine": np.eye(4)}, np.array([[[[1.0, 1.0]], [[1.0, 1.0]]]]), ], [ - {"pixdim": (1.0, 1.0, 1.0), "padding_mode": "zeros", "dtype": np.float}, + {"pixdim": (1.0, 1.0, 1.0), "padding_mode": "zeros", "dtype": float}, np.ones((1, 2, 1, 2)), # data {"affine": np.eye(4)}, np.array([[[[1.0, 1.0]], [[1.0, 1.0]]]]),