diff --git a/.github/workflows/blossom-ci.yml b/.github/workflows/blossom-ci.yml
index 44ffdb12a8..14f768111e 100644
--- a/.github/workflows/blossom-ci.yml
+++ b/.github/workflows/blossom-ci.yml
@@ -29,9 +29,12 @@ jobs:
args: ${{ env.args }}
# This job only runs for pull request comments
- if: |
- contains( 'Nic-Ma,wyli,pxLi,', format('{0},', github.actor)) &&
- github.event.comment.body == '/build'
+ if: contains('\
+ Nic-Ma,\
+ wyli,\
+ pxLi,\
+ YanxuanLiu,\
+ ', format('{0},', github.actor)) && github.event.comment.body == '/build'
steps:
- name: Check if comment is issued by authorized person
run: blossom-ci
diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml
index dca7f39617..e95adedcbc 100644
--- a/.github/workflows/cron.yml
+++ b/.github/workflows/cron.yml
@@ -50,7 +50,7 @@ jobs:
python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))'
BUILD_MONAI=1 ./runtests.sh --build --coverage --unittests --disttests # unit tests with coverage report
BUILD_MONAI=1 ./runtests.sh --build --coverage --net # integration tests with coverage report
- coverage xml
+ coverage xml --ignore-errors
if pgrep python; then pkill python; fi
- name: Upload coverage
uses: codecov/codecov-action@v1
@@ -62,7 +62,7 @@ jobs:
if: github.repository == 'Project-MONAI/MONAI'
strategy:
matrix:
- container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.06"] # 21.02, 21.10 for backward comp.
+ container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.07"] # 21.02, 21.10 for backward comp.
container:
image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image
options: "--gpus all"
@@ -93,7 +93,7 @@ jobs:
python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))'
BUILD_MONAI=1 ./runtests.sh --build --coverage --unittests --disttests # unit tests with coverage report
BUILD_MONAI=1 ./runtests.sh --build --coverage --net # integration tests with coverage report
- coverage xml
+ coverage xml --ignore-errors
if pgrep python; then pkill python; fi
- name: Upload coverage
uses: codecov/codecov-action@v1
@@ -106,7 +106,7 @@ jobs:
if: github.repository == 'Project-MONAI/MONAI'
strategy:
matrix:
- container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.06"] # 21.02, 21.10 for backward comp.
+ container: ["pytorch:21.02", "pytorch:21.10", "pytorch:22.07"] # 21.02, 21.10 for backward comp.
container:
image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image
options: "--gpus all"
@@ -192,7 +192,7 @@ jobs:
ngc --version
BUILD_MONAI=1 ./runtests.sh --build --coverage --pytype --unittests --disttests # unit tests with pytype checks, coverage report
BUILD_MONAI=1 ./runtests.sh --build --coverage --net # integration tests with coverage report
- coverage xml
+ coverage xml --ignore-errors
if pgrep python; then pkill python; fi
- name: Upload coverage
uses: codecov/codecov-action@v1
@@ -204,7 +204,7 @@ jobs:
if: github.repository == 'Project-MONAI/MONAI'
needs: cron-gpu # so that monai itself is verified first
container:
- image: nvcr.io/nvidia/pytorch:22.06-py3 # testing with the latest pytorch base image
+ image: nvcr.io/nvidia/pytorch:22.07-py3 # testing with the latest pytorch base image
options: "--gpus all --ipc=host"
runs-on: [self-hosted, linux, x64, common]
steps:
diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml
index 52e588a555..e9a60408fb 100644
--- a/.github/workflows/pythonapp-gpu.yml
+++ b/.github/workflows/pythonapp-gpu.yml
@@ -23,7 +23,7 @@ jobs:
- "PT17+CUDA102"
- "PT18+CUDA102"
- "PT18+CUDA112"
- - "PT112+CUDA116"
+ - "PT112+CUDA117"
- "PT110+CUDA102"
- "PT112+CUDA102"
include:
@@ -45,11 +45,11 @@ jobs:
# 21.10: 1.10.0a0+0aef44c
pytorch: "-h"
base: "nvcr.io/nvidia/pytorch:21.10-py3"
- - environment: PT112+CUDA116
+ - environment: PT112+CUDA117
# we explicitly set pytorch to -h to avoid pip install error
- # 22.06: 1.13.0a0+340c412
+ # 22.07: 1.13.0a0+08820cb
pytorch: "-h"
- base: "nvcr.io/nvidia/pytorch:22.06-py3"
+ base: "nvcr.io/nvidia/pytorch:22.07-py3"
- environment: PT110+CUDA102
pytorch: "torch==1.10.2 torchvision==0.11.3"
base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04"
@@ -142,7 +142,7 @@ jobs:
# test the clang-format tool downloading once
coverage run -m tests.clang_format_utils
fi
- coverage xml
+ coverage xml --ignore-errors
if pgrep python; then pkill python; fi
shell: bash
- name: Upload coverage
diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml
index a0b1070bd4..b97279d23a 100644
--- a/.github/workflows/pythonapp.yml
+++ b/.github/workflows/pythonapp.yml
@@ -222,5 +222,6 @@ jobs:
cd docs/
make clean
make html 2>&1 | tee tmp_log
+ if [[ $(grep -c "ERROR:" tmp_log) != 0 ]]; then echo "found errors"; grep "ERROR:" tmp_log; exit 1; fi
if [[ $(grep -c "WARNING:" tmp_log) != 0 ]]; then echo "found warnings"; grep "WARNING:" tmp_log; exit 1; fi
shell: bash
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 69f68d2119..c79fb0a496 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -79,7 +79,7 @@ jobs:
- if: matrix.python-version == '3.8' && startsWith(github.ref, 'refs/tags/')
name: Publish to Test PyPI
- uses: pypa/gh-action-pypi-publish@master
+ uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.TEST_PYPI }}
repository_url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml
index 4a2a46cfc1..ee3f275c64 100644
--- a/.github/workflows/setupapp.yml
+++ b/.github/workflows/setupapp.yml
@@ -61,7 +61,7 @@ jobs:
python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))'
BUILD_MONAI=1 ./runtests.sh --build --coverage --unittests --disttests # unit tests with coverage report
BUILD_MONAI=1 ./runtests.sh --build --coverage --net # integration tests with coverage report
- coverage xml
+ coverage xml --ignore-errors
if pgrep python; then pkill python; fi
shell: bash
- name: Upload coverage
@@ -105,7 +105,7 @@ jobs:
python -m pip list
python -c 'import torch; print(torch.__version__); print(torch.rand(5,3))'
BUILD_MONAI=1 ./runtests.sh --build --quick --unittests --disttests
- coverage xml
+ coverage xml --ignore-errors
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
diff --git a/.gitignore b/.gitignore
index 9c0554dca9..b771f963cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,6 +131,8 @@ tests/testing_data/MedNIST*
tests/testing_data/*Hippocampus*
tests/testing_data/*.tiff
tests/testing_data/schema.json
+tests/testing_data/endo.mp4
+tests/testing_data/ultrasound.avi
*.svg
# clang format tool
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb0e8e31f8..771dd9265b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,38 @@
# Changelog
All notable changes to MONAI are documented in this file.
-The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
-and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
## [Unreleased]
+## [0.9.1] - 2022-07-22
+### Added
+* Support of `monai.data.MetaTensor` as core data structure across the modules
+* Support of `inverse` in array-based transforms
+* `monai.apps.TciaDataset` APIs for The Cancer Imaging Archive (TCIA) datasets, including a pydicom-backend reader
+* Initial release of components for MRI reconstruction in `monai.apps.reconstruction`, including various FFT utilities
+* New metrics and losses, including mean IoU and structural similarity index
+* `monai.utils.StrEnum` class to simplify Enum-based type annotations
+### Changed
+* Base Docker image upgraded to `nvcr.io/nvidia/pytorch:22.06-py3` from `nvcr.io/nvidia/pytorch:22.04-py3`
+* Optionally depend on PyTorch-Ignite v0.4.9 instead of v0.4.8
+### Fixed
+* Fixed issue of not skipping post activations in `Convolution` when input arguments are None
+* Fixed issue of ignoring dropout arguments in `DynUNet`
+* Fixed issue of hard-coded non-linear function in ViT classification head
+* Fixed issue of in-memory config overriding with `monai.bundle.ConfigParser.update`
+* 2D SwinUNETR incompatible shapes
+* Fixed issue with `monai.bundle.verify_metadata` not raising exceptions
+* Fixed issue with `monai.transforms.GridPatch` returns inconsistent type location when padding
+* Wrong generalized Dice score metric when denominator is 0 but prediction is non-empty
+* Docker image build error due to NGC CLI upgrade
+* Optional default value when parsing id unavailable in a ConfigParser instance
+* Immutable data input for the patch-based WSI datasets
+### Deprecated
+* `*_transforms` and `*_meta_dict` fields in dictionary-based transforms in favor of MetaTensor
+* `meta_keys`, `meta_key_postfix`, `src_affine` arguments in various transforms, in favor of MetaTensor
+* `AsChannelFirst` and `AddChannel`, in favor of `EnsureChannelFirst` transform
+
## [0.9.0] - 2022-06-08
### Added
* `monai.bundle` primary module with a `ConfigParser` and command-line interfaces for configuration-based workflows
@@ -534,7 +561,8 @@ the postprocessing steps should be used before calling the metrics methods
[highlights]: https://github.com/Project-MONAI/MONAI/blob/master/docs/source/highlights.md
-[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/0.9.0...HEAD
+[Unreleased]: https://github.com/Project-MONAI/MONAI/compare/0.9.1...HEAD
+[0.9.1]: https://github.com/Project-MONAI/MONAI/compare/0.9.0...0.9.1
[0.9.0]: https://github.com/Project-MONAI/MONAI/compare/0.8.1...0.9.0
[0.8.1]: https://github.com/Project-MONAI/MONAI/compare/0.8.0...0.8.1
[0.8.0]: https://github.com/Project-MONAI/MONAI/compare/0.7.0...0.8.0
diff --git a/CITATION.cff b/CITATION.cff
index 9fad6a709e..a2ac1845f8 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -6,8 +6,8 @@ title: "MONAI: Medical Open Network for AI"
abstract: "AI Toolkit for Healthcare Imaging"
authors:
- name: "MONAI Consortium"
-date-released: 2022-06-13
-version: "0.9.0"
+date-released: 2022-07-25
+version: "0.9.1"
identifiers:
- description: "This DOI represents all versions of MONAI, and will always resolve to the latest one."
type: doi
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ac373bad75..1fef0789e3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -283,10 +283,25 @@ for example, ``import monai.transforms.Spacing`` is the equivalent of ``monai.tr
For string definition, [f-string](https://www.python.org/dev/peps/pep-0498/) is recommended to use over `%-print` and `format-print`. So please try to use `f-string` if you need to define any string object.
#### Backwards compatibility
-MONAI is currently under active development, and with major version zero (following the [Semantic Versioning](https://semver.org/)).
-The backwards compatibility of the API is not always guaranteed at this initial development stage.
-However, utility functions are provided in the `monai.utils.deprecated` modules to help users migrate to the new API.
-The use of these functions is encouraged.
+MONAI in general follows [PyTorch's policy for backward compatibility](https://github.com/pytorch/pytorch/wiki/PyTorch's-Python-Frontend-Backward-and-Forward-Compatibility-Policy).
+Utility functions are provided in `monai.utils.deprecated` to help migrate from the deprecated to new APIs. The use of these utilities is encouraged.
+The pull request [template contains checkboxes](https://github.com/Project-MONAI/MONAI/blame/dev/.github/pull_request_template.md#L11-L12) that
+the contributor should use accordingly to clearly indicate breaking changes.
+
+The process of releasing backwards incompatible API changes is as follows:
+1. discuss the breaking changes during pull requests or in dev meetings with a feature proposal if needed.
+1. add a warning message in the upcoming release (version `X.Y`), the warning message should include a forecast of removing the deprecated API in:
+ 1. `X+1.0` -- major version `X+1` and minor version `0` the next major version if it's a significant change,
+ 1. `X.Y+2` -- major version `X` and minor version `Y+2` (the minor version after the next one), if it's a minor API change.
+ 1. Note that the versioning policy is similar to PyTorch's approach which does not precisely follow [the semantic versioning](https://semver.org/) definition.
+ Major version numbers are instead used to represent major product version (which is currently not planned to be greater than 1),
+ minor version for both compatible and incompatible, and patch version for bug fixes.
+ 1. when recommending new API to use in place of a deprecated API, the recommended version should
+ provide exact feature-like behaviour otherwise users will have a harder time migrating.
+1. add new test cases by extending the existing unit tests to cover both the deprecated and updated APIs.
+1. collect feedback from the users during the subsequent few releases, and reconsider step 1 if needed.
+1. before each release, review the deprecating APIs and relevant tests, and clean up the removed APIs described in step 2.
+
### Submitting pull requests
@@ -349,12 +364,11 @@ When major features are ready for a milestone, to prepare for a new release:
- Merge `releasing/[version number]` to `dev`, this step must make sure that the tagging commit unchanged on `dev`.
- Publish the release note.
-Note that the release should be tagged with a [PEP440](https://www.python.org/dev/peps/pep-0440/) compliant
-[semantic versioning](https://semver.org/spec/v2.0.0.html) number.
+Note that the release should be tagged with a [PEP440](https://www.python.org/dev/peps/pep-0440/) compliant version number.
If any error occurs during the release process, first check out a new hotfix branch from the `releasing/[version number]`,
then make PRs to the `releasing/[version number]` to fix the bugs via the regular contribution procedure.
If any error occurs after the release process, first check out a new hotfix branch from the `main` branch,
-make a minor version release following the semantic versioning, for example, `releasing/0.1.1`.
+make a patch version release following the semantic versioning, for example, `releasing/0.1.1`.
Make sure the `releasing/0.1.1` is merged back into both `dev` and `main` and all the test pipelines succeed.
diff --git a/Dockerfile b/Dockerfile
index 26a1fc242f..ed35ac408d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,7 @@
# To build with a different base image
# please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag.
-ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.06-py3
+ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:22.07-py3
FROM ${PYTORCH_IMAGE}
LABEL maintainer="monai.contact@gmail.com"
diff --git a/docs/images/blend_images.png b/docs/images/blend_images.png
new file mode 100644
index 0000000000..55c415cc39
Binary files /dev/null and b/docs/images/blend_images.png differ
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 09fff56b8b..d3a6ed9576 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -30,3 +30,4 @@ fire
jsonschema
pynrrd
pydicom
+h5py
diff --git a/docs/source/api.rst b/docs/source/api.rst
index c2b19adeb2..db312e2b13 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -7,6 +7,7 @@ API Reference
:maxdepth: 1
apps
+ fl
bundle
transforms
losses
diff --git a/docs/source/apps.rst b/docs/source/apps.rst
index 3bb85c9296..3c5eed4002 100644
--- a/docs/source/apps.rst
+++ b/docs/source/apps.rst
@@ -15,6 +15,9 @@ Applications
.. autoclass:: DecathlonDataset
:members:
+.. autoclass:: TciaDataset
+ :members:
+
.. autoclass:: CrossValidation
:members:
@@ -184,3 +187,26 @@ Applications
:members:
.. automodule:: monai.apps.detection.metrics.matching
:members:
+
+`Reconstruction`
+----------------
+
+`ConvertToTensorComplex`
+~~~~~~~~~~~~~~~~~~~~~~~~
+.. autofunction:: monai.apps.reconstruction.complex_utils.convert_to_tensor_complex
+
+`ComplexAbs`
+~~~~~~~~~~~~
+.. autofunction:: monai.apps.reconstruction.complex_utils.complex_abs
+
+`RootSumOfSquares`
+~~~~~~~~~~~~~~~~~~
+.. autofunction:: monai.apps.reconstruction.mri_utils.root_sum_of_squares
+
+`ComplexMul`
+~~~~~~~~~~~~
+.. autofunction:: monai.apps.reconstruction.complex_utils.complex_mul
+
+`ComplexConj`
+~~~~~~~~~~~~~
+.. autofunction:: monai.apps.reconstruction.complex_utils.complex_conj
diff --git a/docs/source/conf.py b/docs/source/conf.py
index db0ca11be3..a4db053d78 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -40,6 +40,7 @@
"engines",
"data",
"apps",
+ "fl",
"bundle",
"config",
"handlers",
diff --git a/docs/source/config_syntax.md b/docs/source/config_syntax.md
index 6cd4e62ef3..7cd71b507f 100644
--- a/docs/source/config_syntax.md
+++ b/docs/source/config_syntax.md
@@ -15,7 +15,7 @@ Content:
- [`@` to reference Python objects in configurations](#to-reference-python-objects-in-configurations)
- [`$` to evaluate as Python expressions](#to-evaluate-as-python-expressions)
- [`%` to textually replace configuration elements](#to-textually-replace-configuration-elements)
- - [`_target_` (`_disabled_` and `_requires_`) to instantiate a Python object](#instantiate-a-python-object)
+ - [`_target_` (`_disabled_`, `_desc_`, and `_requires_`) to instantiate a Python object](#instantiate-a-python-object)
- [The command line interface](#the-command-line-interface)
- [Recommendations](#recommendations)
@@ -67,7 +67,7 @@ or additionally, tune the input parameters then instantiate the component:
BasicUNet features: (32, 32, 32, 64, 64, 64).
```
-For more details on the `ConfigParser` API, please see https://docs.monai.io/en/latest/bundle.html#config-parser.
+For more details on the `ConfigParser` API, please see [`monai.bundle.ConfigParser`](https://docs.monai.io/en/latest/bundle.html#config-parser).
## Syntax examples explained
@@ -141,17 +141,19 @@ This dictionary will be instantiated as a Pytorch object at runtime.
{
"component_name": {
"_target_": "my.module.Class",
+ "_desc_": "this is a customized class which also triggers 'cudnn_opt' reference",
"_requires_": "@cudnn_opt",
"_disabled_": "true"}
}
```
-_Description:_ `_requires_` and `_disabled_` are optional keys.
-`_requires_` specifies references (string starts with `@`) or
-Python expression that will be evaluated/instantiated before `_target_` object is instantiated.
-It is useful when the component does not explicitly depend on the other ConfigItems via
-its arguments, but requires the dependencies to be instantiated/evaluated beforehand.
-`_disabled_` specifies a flag to indicate whether to skip the instantiation.
+_Description:_ `_requires_`, `_disabled_`, and `_desc_` are optional keys.
+- `_requires_` specifies references (string starts with `@`) or
+ Python expression that will be evaluated/instantiated before `_target_` object is instantiated.
+ It is useful when the component does not explicitly depend on the other ConfigItems via
+ its arguments, but requires the dependencies to be instantiated/evaluated beforehand.
+- `_disabled_` specifies a flag to indicate whether to skip the instantiation.
+- `_desc_` can be used for providing free text descriptions.
## The command line interface
diff --git a/docs/source/data.rst b/docs/source/data.rst
index eab4f867af..6cf19b119d 100644
--- a/docs/source/data.rst
+++ b/docs/source/data.rst
@@ -153,6 +153,12 @@ PILReader
:members:
+FastMRIReader
+~~~~~~~~~~~~~
+.. autoclass:: monai.apps.reconstruction.fastmri_reader.FastMRIReader
+ :members:
+
+
Image writer
------------
@@ -278,12 +284,14 @@ N-Dim Fourier Transform
Meta Object
-----------
.. automodule:: monai.data.meta_obj
- :members:
+ :members:
MetaTensor
----------
.. autoclass:: monai.data.MetaTensor
- :members:
+ :members:
+ :show-inheritance:
+ :inherited-members: MetaObj
@@ -332,3 +340,18 @@ Bounding box
------------
.. automodule:: monai.data.box_utils
:members:
+
+Video datasets
+--------------
+
+VideoDataset
+~~~~~~~~~~~~
+.. autoclass:: monai.data.VideoDataset
+
+VideoFileDataset
+~~~~~~~~~~~~~~~~
+.. autoclass:: monai.data.VideoFileDataset
+
+CameraDataset
+~~~~~~~~~~~~~
+.. autoclass:: monai.data.CameraDataset
diff --git a/docs/source/engines.rst b/docs/source/engines.rst
index 0cd40afb78..6d2694124b 100644
--- a/docs/source/engines.rst
+++ b/docs/source/engines.rst
@@ -16,11 +16,6 @@ Workflows
.. currentmodule:: monai.engines
-`BaseWorkflow`
-~~~~~~~~~~~~~~
-.. autoclass:: BaseWorkflow
- :members:
-
`Workflow`
~~~~~~~~~~
.. autoclass:: Workflow
diff --git a/docs/source/fl.rst b/docs/source/fl.rst
new file mode 100644
index 0000000000..cd4bc587ec
--- /dev/null
+++ b/docs/source/fl.rst
@@ -0,0 +1,16 @@
+:github_url: https://github.com/Project-MONAI/MONAI
+
+.. _fl:
+
+Federated Learning
+==================
+.. currentmodule:: monai.fl.client
+
+`ClientAlgo`
+------------
+
+.. autoclass:: ClientAlgo
+ :members:
+
+.. autoclass:: MonaiAlgo
+ :members:
diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst
index 0172529f40..189df18c43 100644
--- a/docs/source/handlers.rst
+++ b/docs/source/handlers.rst
@@ -41,6 +41,12 @@ Mean Dice metrics handler
:members:
+Mean IoU metric handler
+-----------------------
+.. autoclass:: MeanIoUHandler
+ :members:
+
+
ROC AUC metrics handler
-----------------------
.. autoclass:: ROCAUC
diff --git a/docs/source/highlights.md b/docs/source/highlights.md
index 31e8eb21ee..4e00471d4a 100644
--- a/docs/source/highlights.md
+++ b/docs/source/highlights.md
@@ -511,7 +511,7 @@ For example:
```py
train_transforms = [
LoadImaged(...),
- AddChanneld(...),
+ EnsureChannelFirstd(...),
Orientationd(...),
Spacingd(...),
ScaleIntensityRanged(...),
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 6bd8097ed7..6eed227dbf 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -12,13 +12,13 @@ Project MONAI
*Medical Open Network for AI*
MONAI is a `PyTorch `_-based, `open-source `_ framework
-for deep learning in healthcare imaging, part of `PyTorch Ecosystem `_.
+for deep learning in healthcare imaging, part of the `PyTorch Ecosystem `_.
Its ambitions are:
- developing a community of academic, industrial and clinical researchers collaborating on a common foundation;
- creating state-of-the-art, end-to-end training workflows for healthcare imaging;
-- providing researchers with the optimized and standardized way to create and evaluate deep learning models.
+- providing researchers with an optimized and standardized way to create and evaluate deep learning models.
Features
--------
diff --git a/docs/source/installation.md b/docs/source/installation.md
index 8623faee21..fd2e0fdf89 100644
--- a/docs/source/installation.md
+++ b/docs/source/installation.md
@@ -4,6 +4,7 @@
1. [From PyPI](#from-pypi)
1. [Milestone release](#milestone-release)
2. [Weekly preview release](#weekly-preview-release)
+ 3. [Uninstall the packages](#uninstall-the-packages)
1. [From conda-forge](#from-conda-forge)
2. [From GitHub](#from-github)
1. [System-wide](#milestone-release)
@@ -48,6 +49,19 @@ To report any issues on the weekly preview, please include the version and commi
python -c "import monai; print(monai.__version__); print(monai.__commit_id__)"
```
+Coexistence of package `monai` and `monai-weekly` in a system may cause namespace conflicts
+and `ImportError`.
+This is usually a result of running both `pip install monai` and `pip install monai-weekly`
+without uninstalling the existing one first.
+To address this issue, please uninstall both packages, and retry the installation.
+
+### Uninstall the packages
+The packages installed using `pip install` could be removed by:
+```bash
+pip uninstall -y monai
+pip uninstall -y monai-weekly
+```
+
## From conda-forge
To install the [current milestone release](https://anaconda.org/conda-forge/monai):
@@ -69,7 +83,7 @@ for the latest features:
```bash
pip install git+https://github.com/Project-MONAI/MONAI#egg=monai
```
-or, to build with MONAI Cpp/CUDA extensions:
+or, to build with MONAI C++/CUDA extensions:
```bash
BUILD_MONAI=1 pip install git+https://github.com/Project-MONAI/MONAI#egg=monai
```
@@ -95,7 +109,7 @@ You can install it by running:
cd MONAI/
python setup.py develop
```
-or, to build with MONAI Cpp/CUDA extensions and install:
+or, to build with MONAI C++/CUDA extensions and install:
```bash
cd MONAI/
BUILD_MONAI=1 python setup.py develop
@@ -115,6 +129,12 @@ python setup.py develop --uninstall
Alternatively, simply adding the root directory of the cloned source code (e.g., ``/workspace/Documents/MONAI``) to your ``$PYTHONPATH``
and the codebase is ready to use (without the additional features of MONAI C++/CUDA extensions).
+> The C++/CUDA extension features are currently experimental, a pre-compiled version is made available via
+> [the recent docker image releases](https://hub.docker.com/r/projectmonai/monai).
+> Building the extensions from source may require [Ninja](https://ninja-build.org/) and [CUDA Toolkit](https://developer.nvidia.com/cuda-toolkit).
+> By default, CUDA extension is built if `torch.cuda.is_available()`. It's possible to force building by
+> setting ``FORCE_CUDA=1`` environment variable.
+
## Validating the install
You can verify the installation by:
@@ -190,9 +210,9 @@ Since MONAI v0.2.0, the extras syntax such as `pip install 'monai[nibabel]'` is
- The options are
```
-[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema, pynrrd, pydicom]
+[nibabel, skimage, pillow, tensorboard, gdown, ignite, torchvision, itk, tqdm, lmdb, psutil, cucim, openslide, pandas, einops, transformers, mlflow, matplotlib, tensorboardX, tifffile, imagecodecs, pyyaml, fire, jsonschema, pynrrd, pydicom, h5py]
```
which correspond to `nibabel`, `scikit-image`, `pillow`, `tensorboard`,
-`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, `pynrrd`, `pydicom`, respectively.
+`gdown`, `pytorch-ignite`, `torchvision`, `itk`, `tqdm`, `lmdb`, `psutil`, `cucim`, `openslide-python`, `pandas`, `einops`, `transformers`, `mlflow`, `matplotlib`, `tensorboardX`, `tifffile`, `imagecodecs`, `pyyaml`, `fire`, `jsonschema`, `pynrrd`, `pydicom`, h5py , respectively.
- `pip install 'monai[all]'` installs all the optional dependencies.
diff --git a/docs/source/losses.rst b/docs/source/losses.rst
index f05e4dc9ff..b544eb0b73 100644
--- a/docs/source/losses.rst
+++ b/docs/source/losses.rst
@@ -91,6 +91,15 @@ Registration Losses
.. autoclass:: GlobalMutualInformationLoss
:members:
+Reconstruction Losses
+---------------------
+
+`SSIMLoss`
+~~~~~~~~~~
+.. autoclass:: monai.losses.ssim_loss.SSIMLoss
+ :members:
+
+
Loss Wrappers
-------------
diff --git a/docs/source/metrics.rst b/docs/source/metrics.rst
index cb9cbe3c1d..aea3f1789a 100644
--- a/docs/source/metrics.rst
+++ b/docs/source/metrics.rst
@@ -39,6 +39,13 @@ Metrics
.. autoclass:: DiceMetric
:members:
+`Mean IoU`
+----------
+.. autofunction:: compute_meaniou
+
+.. autoclass:: MeanIoU
+ :members:
+
`Generalized Dice Score`
------------------------
.. autofunction:: compute_generalized_dice
@@ -103,6 +110,10 @@ Metrics
.. autoclass:: PSNRMetric
:members:
+`Structural similarity index measure`
+-------------------------------------
+.. autoclass:: monai.metrics.regression.SSIMMetric
+
`Cumulative average`
--------------------
.. autoclass:: CumulativeAverage
diff --git a/docs/source/networks.rst b/docs/source/networks.rst
index a7b6d8cdc0..811084753f 100644
--- a/docs/source/networks.rst
+++ b/docs/source/networks.rst
@@ -89,6 +89,11 @@ Blocks
.. autoclass:: UnetOutBlock
:members:
+`DenseBlock`
+~~~~~~~~~~~~~
+.. autoclass:: DenseBlock
+ :members:
+
`SegResnet Block`
~~~~~~~~~~~~~~~~~
.. autoclass:: ResBlock
@@ -233,6 +238,10 @@ Blocks
.. autoclass:: DVF2DDF
:members:
+`VarNetBlock`
+~~~~~~~~~~~~~
+.. autoclass:: monai.apps.reconstruction.networks.blocks.varnetblock.VarNetBlock
+ :members:
N-Dim Fourier Transform
~~~~~~~~~~~~~~~~~~~~~~~~
@@ -519,6 +528,11 @@ Nets
.. autoclass:: BasicUnet
.. autoclass:: Basicunet
+`FlexibleUNet`
+~~~~~~~~~~~~~~
+.. autoclass:: FlexibleUNet
+ :members:
+
`VNet`
~~~~~~
.. autoclass:: VNet
@@ -634,7 +648,25 @@ Nets
.. autoclass:: TopologySearch
:members:
+`ComplexUnet`
+~~~~~~~~~~~~~
+.. autoclass:: monai.apps.reconstruction.networks.nets.complex_unet.ComplexUnet
+ :members:
+
+`CoilSensitivityModel`
+~~~~~~~~~~~~~~~~~~~~~~
+.. autoclass:: monai.apps.reconstruction.networks.nets.coil_sensitivity_model.CoilSensitivityModel
+ :members:
+
+`e2e-VarNet`
+~~~~~~~~~~~~
+.. autoclass:: monai.apps.reconstruction.networks.nets.varnet.VariationalNetworkModel
+ :members:
+
Utilities
---------
.. automodule:: monai.networks.utils
:members:
+
+.. automodule:: monai.apps.reconstruction.networks.nets.utils
+ :members:
diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst
index 7eaa17ea43..f8dc318e09 100644
--- a/docs/source/transforms.rst
+++ b/docs/source/transforms.rst
@@ -798,6 +798,23 @@ Smooth Field
:members:
:special-members: __call__
+
+MRI Transforms
+^^^^^^^^^^^^^^
+
+`Kspace under-sampling`
+"""""""""""""""""""""""
+.. autoclass:: monai.apps.reconstruction.transforms.array.KspaceMask
+ :members:
+ :special-members: __call__
+
+.. autoclass:: monai.apps.reconstruction.transforms.array.RandomKspaceMask
+ :special-members: __call__
+
+.. autoclass:: monai.apps.reconstruction.transforms.array.EquispacedKspaceMask
+ :special-members: __call__
+
+
Utility
^^^^^^^
@@ -1683,6 +1700,34 @@ Smooth Field (Dict)
:members:
:special-members: __call__
+
+`MRI transforms (Dict)`
+^^^^^^^^^^^^^^^^^^^^^^^
+
+`Kspace under-sampling (Dict)`
+""""""""""""""""""""""""""""""
+.. autoclass:: monai.apps.reconstruction.transforms.dictionary.RandomKspaceMaskd
+ :special-members: __call__
+
+.. autoclass:: monai.apps.reconstruction.transforms.dictionary.EquispacedKspaceMaskd
+ :special-members: __call__
+
+`ExtractDataKeyFromMetaKeyd`
+""""""""""""""""""""""""""""
+.. autoclass:: monai.apps.reconstruction.transforms.dictionary.ExtractDataKeyFromMetaKeyd
+ :special-members: __call__
+
+`ReferenceBasedSpatialCropd`
+""""""""""""""""""""""""""""
+.. autoclass:: monai.apps.reconstruction.transforms.dictionary.ReferenceBasedSpatialCropd
+ :special-members: __call__
+
+`ReferenceBasedNormalizeIntensityd`
+"""""""""""""""""""""""""""""""""""
+.. autoclass:: monai.apps.reconstruction.transforms.dictionary.ReferenceBasedNormalizeIntensityd
+ :special-members: __call__
+
+
Utility (Dict)
^^^^^^^^^^^^^^
diff --git a/environment-dev.yml b/environment-dev.yml
index e71f434dc4..5b0ac2d922 100644
--- a/environment-dev.yml
+++ b/environment-dev.yml
@@ -21,7 +21,6 @@ dependencies:
- flake8>=3.8.1
- flake8-bugbear
- flake8-comprehensions
- - flake8-pyi
- pylint
- mccabe
- pep8-naming
@@ -47,6 +46,7 @@ dependencies:
- jsonschema
- pynrrd
- pydicom
+ - h5py
- pip
- pip:
# pip for itk as conda-forge version only up to v5.1
diff --git a/monai/README.md b/monai/README.md
index 2c30531bf3..64af824b1d 100644
--- a/monai/README.md
+++ b/monai/README.md
@@ -12,6 +12,8 @@
* **engines**: engine-derived classes for extending Ignite behaviour.
+* **fl**: federated learning components to allow pipeline integration with any federated learning framework.
+
* **handlers**: defines handlers for implementing functionality at various stages in the training process.
* **inferers**: defines model inference methods.
diff --git a/monai/__init__.py b/monai/__init__.py
index e56a2f3444..6bec0b1084 100644
--- a/monai/__init__.py
+++ b/monai/__init__.py
@@ -39,7 +39,7 @@
# handlers_* have some external decorators the users may not have installed
# *.so files and folder "_C" may not exist when the cpp extensions are not compiled
-excludes = "(^(monai.handlers))|(^(monai.bundle))|((\\.so)$)|(^(monai._C))"
+excludes = "(^(monai.handlers))|(^(monai.bundle))|(^(monai.fl))|((\\.so)$)|(^(monai._C))"
# load directory modules only, skip loading individual files
load_submodules(sys.modules[__name__], False, exclude_pattern=excludes)
@@ -53,6 +53,7 @@
"config",
"data",
"engines",
+ "fl",
"handlers",
"inferers",
"losses",
diff --git a/monai/apps/__init__.py b/monai/apps/__init__.py
index 893f7877d2..3df0e95a98 100644
--- a/monai/apps/__init__.py
+++ b/monai/apps/__init__.py
@@ -9,6 +9,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset
+from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset, TciaDataset
from .mmars import MODEL_DESC, RemoteMMARKeys, download_mmar, get_model_spec, load_from_mmar
from .utils import SUPPORTED_HASH_TYPES, check_hash, download_and_extract, download_url, extractall, get_logger, logger
diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py
index 1bfb97abd9..75fda6da34 100644
--- a/monai/apps/datasets.py
+++ b/monai/apps/datasets.py
@@ -9,16 +9,26 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import os
+import shutil
import sys
+import warnings
from pathlib import Path
-from typing import Callable, Dict, List, Optional, Sequence, Union
+from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union
import numpy as np
+from monai.apps.tcia import (
+ download_tcia_series_instance,
+ get_tcia_metadata,
+ get_tcia_ref_uid,
+ match_tcia_ref_uid_in_study,
+)
from monai.apps.utils import download_and_extract
from monai.config.type_definitions import PathLike
from monai.data import (
CacheDataset,
+ PydicomReader,
load_decathlon_datalist,
load_decathlon_properties,
partition_dataset,
@@ -27,7 +37,7 @@
from monai.transforms import LoadImaged, Randomizable
from monai.utils import ensure_tuple
-__all__ = ["MedNISTDataset", "DecathlonDataset", "CrossValidation"]
+__all__ = ["MedNISTDataset", "DecathlonDataset", "CrossValidation", "TciaDataset"]
class MedNISTDataset(Randomizable, CacheDataset):
@@ -191,11 +201,11 @@ class DecathlonDataset(Randomizable, CacheDataset):
"Task08_HepaticVessel", "Task09_Spleen", "Task10_Colon").
section: expected data section, can be: `training`, `validation` or `test`.
transform: transforms to execute operations on input data.
- for further usage, use `AddChanneld` or `AsChannelFirstd` to convert the shape to [C, H, W, D].
+ for further usage, use `EnsureChannelFirstd` to convert the shape to [C, H, W, D].
download: whether to download and extract the Decathlon from resource link, default is False.
if expected file already exists, skip downloading even set it to True.
- val_frac: percentage of of validation fraction in the whole dataset, default is 0.2.
user can manually copy tar file or dataset folder to the root directory.
+ val_frac: percentage of of validation fraction in the whole dataset, default is 0.2.
seed: random seed to randomly shuffle the datalist before splitting into training and validation, default is 0.
note to set same seed for `training` and `validation` sections.
cache_num: number of items to be cached. Default is `sys.maxsize`.
@@ -226,7 +236,7 @@ class DecathlonDataset(Randomizable, CacheDataset):
transform = Compose(
[
LoadImaged(keys=["image", "label"]),
- AddChanneld(keys=["image", "label"]),
+ EnsureChannelFirstd(keys=["image", "label"]),
ScaleIntensityd(keys="image"),
ToTensord(keys=["image", "label"]),
]
@@ -379,6 +389,270 @@ def _split_datalist(self, datalist: List[Dict]) -> List[Dict]:
return [datalist[i] for i in self.indices]
+class TciaDataset(Randomizable, CacheDataset):
+ """
+ The Dataset to automatically download the data from a public The Cancer Imaging Archive (TCIA) dataset
+ and generate items for training, validation or test.
+
+ The Highdicom library is used to load dicom data with modality "SEG", but only a part of collections are
+ supoorted, such as: "C4KC-KiTS", "NSCLC-Radiomics", "NSCLC-Radiomics-Interobserver1", " QIN-PROSTATE-Repeatability"
+ and "PROSTATEx". Therefore, if "seg" is included in `keys` of the `LoadImaged` transform and loading some
+ other collections, errors may be raised. For supported collections, the original "SEG" information may not
+ always be consistent for each dicom file. Therefore, to avoid creating different format of labels, please use
+ the `label_dict` argument of `PydicomReader` when calling the `LoadImaged` transform. The prepared label dicts
+ of collections that are mentioned above is also saved in: `monai.apps.tcia.TCIA_LABEL_DICT`. You can also refer
+ to the second example bellow.
+
+
+ This class is based on :py:class:`monai.data.CacheDataset` to accelerate the training process.
+
+ Args:
+ root_dir: user's local directory for caching and loading the TCIA dataset.
+ collection: name of a TCIA collection.
+ a TCIA dataset is defined as a collection. Please check the following list to browse
+ the collection list (only public collections can be downloaded):
+ https://www.cancerimagingarchive.net/collections/
+ section: expected data section, can be: `training`, `validation` or `test`.
+ transform: transforms to execute operations on input data.
+ for further usage, use `EnsureChannelFirstd` to convert the shape to [C, H, W, D].
+ If not specified, `LoadImaged(reader="PydicomReader", keys=["image"])` will be used as the default
+ transform. In addition, we suggest to set the argument `labels` for `PydicomReader` if segmentations
+ are needed to be loaded. The original labels for each dicom series may be different, using this argument
+ is able to unify the format of labels.
+ download: whether to download and extract the dataset, default is False.
+ if expected file already exists, skip downloading even set it to True.
+ user can manually copy tar file or dataset folder to the root directory.
+ download_len: number of series that will be downloaded, the value should be larger than 0 or -1, where -1 means
+ all series will be downloaded. Default is -1.
+ seg_type: modality type of segmentation that is used to do the first step download. Default is "SEG".
+ modality_tag: tag of modality. Default is (0x0008, 0x0060).
+ ref_series_uid_tag: tag of referenced Series Instance UID. Default is (0x0020, 0x000e).
+ ref_sop_uid_tag: tag of referenced SOP Instance UID. Default is (0x0008, 0x1155).
+ specific_tags: tags that will be loaded for "SEG" series. This argument will be used in
+ `monai.data.PydicomReader`. Default is [(0x0008, 0x1115), (0x0008,0x1140), (0x3006, 0x0010),
+ (0x0020,0x000D), (0x0010,0x0010), (0x0010,0x0020), (0x0020,0x0011), (0x0020,0x0012)].
+ val_frac: percentage of of validation fraction in the whole dataset, default is 0.2.
+ seed: random seed to randomly shuffle the datalist before splitting into training and validation, default is 0.
+ note to set same seed for `training` and `validation` sections.
+ cache_num: number of items to be cached. Default is `sys.maxsize`.
+ will take the minimum of (cache_num, data_length x cache_rate, data_length).
+ cache_rate: percentage of cached data in total, default is 0.0 (no cache).
+ will take the minimum of (cache_num, data_length x cache_rate, data_length).
+ num_workers: the number of worker threads to use.
+ If num_workers is None then the number returned by os.cpu_count() is used.
+ If a value less than 1 is speficied, 1 will be used instead.
+ progress: whether to display a progress bar when downloading dataset and computing the transform cache content.
+ copy_cache: whether to `deepcopy` the cache content before applying the random transforms,
+ default to `True`. if the random transforms don't modify the cached content
+ (for example, randomly crop from the cached image and deepcopy the crop region)
+ or if every cache item is only used once in a `multi-processing` environment,
+ may set `copy=False` for better performance.
+ as_contiguous: whether to convert the cached NumPy array or PyTorch tensor to be contiguous.
+ it may help improve the performance of following logic.
+
+ Example::
+
+ # collection is "Pancreatic-CT-CBCT-SEG", seg_type is "RTSTRUCT"
+ data = TciaDataset(
+ root_dir="./", collection="Pancreatic-CT-CBCT-SEG", seg_type="RTSTRUCT", download=True
+ )
+
+ # collection is "C4KC-KiTS", seg_type is "SEG", and load both images and segmentations
+ from monai.apps.tcia import TCIA_LABEL_DICT
+ transform = Compose(
+ [
+ LoadImaged(reader="PydicomReader", keys=["image", "seg"], label_dict=TCIA_LABEL_DICT["C4KC-KiTS"]),
+ EnsureChannelFirstd(keys=["image", "seg"]),
+ ResampleToMatchd(keys="image", key_dst="seg"),
+ ]
+ )
+ data = TciaDataset(
+ root_dir="./", collection="C4KC-KiTS", section="validation", seed=12345, download=True
+ )
+
+ print(data[0]["seg"].shape)
+
+ """
+
+ def __init__(
+ self,
+ root_dir: PathLike,
+ collection: str,
+ section: str,
+ transform: Union[Sequence[Callable], Callable] = (),
+ download: bool = False,
+ download_len: int = -1,
+ seg_type: str = "SEG",
+ modality_tag: Tuple = (0x0008, 0x0060),
+ ref_series_uid_tag: Tuple = (0x0020, 0x000E),
+ ref_sop_uid_tag: Tuple = (0x0008, 0x1155),
+ specific_tags: Tuple = (
+ (0x0008, 0x1115), # Referenced Series Sequence
+ (0x0008, 0x1140), # Referenced Image Sequence
+ (0x3006, 0x0010), # Referenced Frame of Reference Sequence
+ (0x0020, 0x000D), # Study Instance UID
+ (0x0010, 0x0010), # Patient's Name
+ (0x0010, 0x0020), # Patient ID
+ (0x0020, 0x0011), # Series Number
+ (0x0020, 0x0012), # Acquisition Number
+ ),
+ seed: int = 0,
+ val_frac: float = 0.2,
+ cache_num: int = sys.maxsize,
+ cache_rate: float = 0.0,
+ num_workers: int = 1,
+ progress: bool = True,
+ copy_cache: bool = True,
+ as_contiguous: bool = True,
+ ) -> None:
+ root_dir = Path(root_dir)
+ if not root_dir.is_dir():
+ raise ValueError("Root directory root_dir must be a directory.")
+
+ self.section = section
+ self.val_frac = val_frac
+ self.seg_type = seg_type
+ self.modality_tag = modality_tag
+ self.ref_series_uid_tag = ref_series_uid_tag
+ self.ref_sop_uid_tag = ref_sop_uid_tag
+
+ self.set_random_state(seed=seed)
+ download_dir = os.path.join(root_dir, collection)
+ load_tags = list(specific_tags)
+ load_tags += [modality_tag]
+ self.load_tags = load_tags
+ if download:
+ seg_series_list = get_tcia_metadata(
+ query=f"getSeries?Collection={collection}&Modality={seg_type}", attribute="SeriesInstanceUID"
+ )
+ if download_len > 0:
+ seg_series_list = seg_series_list[:download_len]
+ if len(seg_series_list) == 0:
+ raise ValueError(f"Cannot find data with collection: {collection} seg_type: {seg_type}")
+ for series_uid in seg_series_list:
+ self._download_series_reference_data(series_uid, download_dir)
+
+ if not os.path.exists(download_dir):
+ raise RuntimeError(f"Cannot find dataset directory: {download_dir}.")
+
+ self.indices: np.ndarray = np.array([])
+ self.datalist = self._generate_data_list(download_dir)
+
+ if transform == ():
+ transform = LoadImaged(reader="PydicomReader", keys=["image"])
+ CacheDataset.__init__(
+ self,
+ data=self.datalist,
+ transform=transform,
+ cache_num=cache_num,
+ cache_rate=cache_rate,
+ num_workers=num_workers,
+ progress=progress,
+ copy_cache=copy_cache,
+ as_contiguous=as_contiguous,
+ )
+
+ def get_indices(self) -> np.ndarray:
+ """
+ Get the indices of datalist used in this dataset.
+
+ """
+ return self.indices
+
+ def randomize(self, data: np.ndarray) -> None:
+ self.R.shuffle(data)
+
+ def _download_series_reference_data(self, series_uid: str, download_dir: str):
+ """
+ First of all, download a series from TCIA according to `series_uid`.
+ Then find all referenced series and download.
+ """
+ seg_first_dir = os.path.join(download_dir, "raw", series_uid)
+ download_tcia_series_instance(
+ series_uid=series_uid, download_dir=download_dir, output_dir=seg_first_dir, check_md5=False
+ )
+ dicom_files = [f for f in os.listdir(seg_first_dir) if f.endswith(".dcm")]
+ # achieve series number and patient id from the first dicom file
+ dcm_path = os.path.join(seg_first_dir, dicom_files[0])
+ ds = PydicomReader(stop_before_pixels=True, specific_tags=self.load_tags).read(dcm_path)
+ # (0x0010,0x0020) and (0x0010,0x0010), better to be contained in `specific_tags`
+ patient_id = ds.PatientID if ds.PatientID else ds.PatientName
+ if not patient_id:
+ warnings.warn(f"unable to find patient name of dicom file: {dcm_path}, use 'patient' instead.")
+ patient_id = "patient"
+ # (0x0020,0x0011) and (0x0020,0x0012), better to be contained in `specific_tags`
+ series_num = ds.SeriesNumber if ds.SeriesNumber else ds.AcquisitionNumber
+ if not series_num:
+ warnings.warn(f"unable to find series number of dicom file: {dcm_path}, use '0' instead.")
+ series_num = 0
+
+ series_num = str(series_num)
+ seg_dir = os.path.join(download_dir, patient_id, series_num, self.seg_type.lower())
+ dcm_dir = os.path.join(download_dir, patient_id, series_num, "image")
+
+ # get ref uuid
+ ref_uid_list = []
+ for dcm_file in dicom_files:
+ dcm_path = os.path.join(seg_first_dir, dcm_file)
+ ds = PydicomReader(stop_before_pixels=True, specific_tags=self.load_tags).read(dcm_path)
+ if ds[self.modality_tag].value == self.seg_type:
+ ref_uid = get_tcia_ref_uid(
+ ds, find_sop=False, ref_series_uid_tag=self.ref_series_uid_tag, ref_sop_uid_tag=self.ref_sop_uid_tag
+ )
+ if ref_uid == "":
+ ref_sop_uid = get_tcia_ref_uid(
+ ds,
+ find_sop=True,
+ ref_series_uid_tag=self.ref_series_uid_tag,
+ ref_sop_uid_tag=self.ref_sop_uid_tag,
+ )
+ ref_uid = match_tcia_ref_uid_in_study(ds.StudyInstanceUID, ref_sop_uid)
+ if ref_uid != "":
+ ref_uid_list.append(ref_uid)
+ if not ref_uid_list:
+ warnings.warn(f"Cannot find the referenced Series Instance UID from series: {series_uid}.")
+ else:
+ download_tcia_series_instance(
+ series_uid=ref_uid_list[0], download_dir=download_dir, output_dir=dcm_dir, check_md5=False
+ )
+ if not os.path.exists(seg_dir):
+ shutil.copytree(seg_first_dir, seg_dir)
+
+ def _generate_data_list(self, dataset_dir: PathLike) -> List[Dict]:
+ # the types of the item in data list should be compatible with the dataloader
+ dataset_dir = Path(dataset_dir)
+ datalist = []
+ patient_list = [f.name for f in os.scandir(dataset_dir) if f.is_dir() and f.name != "raw"]
+ for patient_id in patient_list:
+ series_list = [f.name for f in os.scandir(os.path.join(dataset_dir, patient_id)) if f.is_dir()]
+ for series_num in series_list:
+ seg_key = self.seg_type.lower()
+ image_path = os.path.join(dataset_dir, patient_id, series_num, "image")
+ mask_path = os.path.join(dataset_dir, patient_id, series_num, seg_key)
+
+ if os.path.exists(image_path):
+ datalist.append({"image": image_path, seg_key: mask_path})
+ else:
+ datalist.append({seg_key: mask_path})
+
+ return self._split_datalist(datalist)
+
+ def _split_datalist(self, datalist: List[Dict]) -> List[Dict]:
+ if self.section == "test":
+ return datalist
+ length = len(datalist)
+ indices = np.arange(length)
+ self.randomize(indices)
+
+ val_length = int(length * self.val_frac)
+ if self.section == "training":
+ self.indices = indices[val_length:]
+ else:
+ self.indices = indices[:val_length]
+
+ return [datalist[i] for i in self.indices]
+
+
class CrossValidation:
"""
Cross validation dataset based on the general dataset which must have `_split_datalist` API.
diff --git a/monai/apps/deepedit/interaction.py b/monai/apps/deepedit/interaction.py
index 04fabec06d..dce81f095e 100644
--- a/monai/apps/deepedit/interaction.py
+++ b/monai/apps/deepedit/interaction.py
@@ -37,6 +37,7 @@ class Interaction:
train: True for training mode or False for evaluation mode
click_probability_key: key to click/interaction probability
label_names: Dict of label names
+ max_interactions: maximum number of interactions per iteration
"""
def __init__(
@@ -44,8 +45,9 @@ def __init__(
deepgrow_probability: float,
transforms: Union[Sequence[Callable], Callable],
train: bool,
- label_names: Dict[str, int],
+ label_names: Union[None, Dict[str, int]] = None,
click_probability_key: str = "probability",
+ max_interactions: int = 1,
) -> None:
self.deepgrow_probability = deepgrow_probability
@@ -53,40 +55,38 @@ def __init__(
self.train = train
self.label_names = label_names
self.click_probability_key = click_probability_key
+ self.max_interactions = max_interactions
def __call__(self, engine: Union[SupervisedTrainer, SupervisedEvaluator], batchdata: Dict[str, torch.Tensor]):
-
if batchdata is None:
raise ValueError("Must provide batch data for current iteration.")
if np.random.choice([True, False], p=[self.deepgrow_probability, 1 - self.deepgrow_probability]):
-
- # Run the inner loop only once
- inputs, _ = engine.prepare_batch(batchdata)
- inputs = inputs.to(engine.state.device)
-
- engine.fire_event(IterationEvents.INNER_ITERATION_STARTED)
-
- engine.network.eval()
- with torch.no_grad():
- if engine.amp:
- with torch.cuda.amp.autocast():
+ for j in range(self.max_interactions):
+ inputs, _ = engine.prepare_batch(batchdata)
+ inputs = inputs.to(engine.state.device)
+
+ engine.fire_event(IterationEvents.INNER_ITERATION_STARTED)
+ engine.network.eval()
+
+ with torch.no_grad():
+ if engine.amp:
+ with torch.cuda.amp.autocast():
+ predictions = engine.inferer(inputs, engine.network)
+ else:
predictions = engine.inferer(inputs, engine.network)
- else:
- predictions = engine.inferer(inputs, engine.network)
- batchdata.update({CommonKeys.PRED: predictions})
-
- # decollate/collate batchdata to execute click transforms
- batchdata_list = decollate_batch(batchdata, detach=True)
-
- for i in range(len(batchdata_list)):
- batchdata_list[i][self.click_probability_key] = 1.0
- batchdata_list[i] = self.transforms(batchdata_list[i])
-
- batchdata = list_data_collate(batchdata_list)
-
- engine.fire_event(IterationEvents.INNER_ITERATION_COMPLETED)
-
+ batchdata.update({CommonKeys.PRED: predictions})
+
+ # decollate/collate batchdata to execute click transforms
+ batchdata_list = decollate_batch(batchdata, detach=True)
+ for i in range(len(batchdata_list)):
+ batchdata_list[i][self.click_probability_key] = (
+ (1.0 - ((1.0 / self.max_interactions) * j)) if self.train else 1.0
+ )
+ batchdata_list[i] = self.transforms(batchdata_list[i])
+
+ batchdata = list_data_collate(batchdata_list)
+ engine.fire_event(IterationEvents.INNER_ITERATION_COMPLETED)
else:
# zero out input guidance channels
batchdata_list = decollate_batch(batchdata, detach=True)
@@ -96,5 +96,4 @@ def __call__(self, engine: Union[SupervisedTrainer, SupervisedEvaluator], batchd
# first item in batch only
engine.state.batch = batchdata
-
return engine._iteration(engine, batchdata)
diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py
index 5a7068291a..4d282ed493 100644
--- a/monai/apps/deepedit/transforms.py
+++ b/monai/apps/deepedit/transforms.py
@@ -19,6 +19,7 @@
import torch
from monai.config import KeysCollection
+from monai.data import MetaTensor
from monai.networks.layers import GaussianFilter
from monai.transforms.transform import MapTransform, Randomizable, Transform
from monai.utils import min_version, optional_import
@@ -71,7 +72,11 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
d: Dict = dict(data)
for key in self.key_iterator(d):
if key == "image":
- d[key] = self._apply(d[key])
+ tmp_image = self._apply(d[key])
+ if isinstance(d[key], MetaTensor):
+ d[key].array = tmp_image
+ else:
+ d[key] = tmp_image
else:
print("This transform only applies to the image")
return d
@@ -93,22 +98,22 @@ def __init__(self, keys: KeysCollection, label_names=None, allow_missing_keys: b
def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]:
d: Dict = dict(data)
for key in self.key_iterator(d):
- if key == "label":
- # Dictionary containing new label numbers
- new_label_names = {}
- label = np.zeros(d[key].shape)
- # Making sure the range values and number of labels are the same
- for idx, (key_label, val_label) in enumerate(self.label_names.items(), start=1):
- if key_label != "background":
- new_label_names[key_label] = idx
- label[d[key] == val_label] = idx
- if key_label == "background":
- new_label_names["background"] = 0
-
- d["label_names"] = new_label_names
- d[key] = label
+ # Dictionary containing new label numbers
+ new_label_names = {}
+ label = np.zeros(d[key].shape)
+ # Making sure the range values and number of labels are the same
+ for idx, (key_label, val_label) in enumerate(self.label_names.items(), start=1):
+ if key_label != "background":
+ new_label_names[key_label] = idx
+ label[d[key] == val_label] = idx
+ if key_label == "background":
+ new_label_names["background"] = 0
+
+ d["label_names"] = new_label_names
+ if isinstance(d[key], MetaTensor):
+ d[key].array = label
else:
- warnings.warn("This transform only applies to the label")
+ d[key] = label
return d
@@ -239,7 +244,10 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
# Getting signal based on guidance
signal = self._get_signal(image, guidance[key_label])
tmp_image = np.concatenate([tmp_image, signal], axis=0)
- d[key] = tmp_image
+ if isinstance(d[key], MetaTensor):
+ d[key].array = tmp_image
+ else:
+ d[key] = tmp_image
return d
else:
print("This transform only applies to image key")
diff --git a/monai/apps/deepgrow/dataset.py b/monai/apps/deepgrow/dataset.py
index d51fd4e238..7614a85a12 100644
--- a/monai/apps/deepgrow/dataset.py
+++ b/monai/apps/deepgrow/dataset.py
@@ -15,7 +15,7 @@
import numpy as np
-from monai.transforms import AsChannelFirstd, Compose, FromMetaTensord, LoadImaged, Orientationd, Spacingd, ToNumpyd
+from monai.transforms import Compose, EnsureChannelFirstd, FromMetaTensord, LoadImaged, Orientationd, Spacingd, ToNumpyd
from monai.utils import GridSampleMode
from monai.utils.enums import PostFix
@@ -85,12 +85,12 @@ def create_dataset(
transforms = _default_transforms(image_key, label_key, pixdim) if transforms is None else transforms
new_datalist = []
- for idx in range(len(datalist)):
+ for idx, item in enumerate(datalist):
if limit and idx >= limit:
break
- image = datalist[idx][image_key]
- label = datalist[idx].get(label_key, None)
+ image = item[image_key]
+ label = item.get(label_key, None)
if base_dir:
image = os.path.join(base_dir, image)
label = os.path.join(base_dir, label) if label else None
@@ -126,7 +126,7 @@ def _default_transforms(image_key, label_key, pixdim):
return Compose(
[
LoadImaged(keys=keys),
- AsChannelFirstd(keys=keys),
+ EnsureChannelFirstd(keys=keys),
Orientationd(keys=keys, axcodes="RAS"),
Spacingd(keys=keys, pixdim=pixdim, mode=mode),
FromMetaTensord(keys=keys),
@@ -137,7 +137,6 @@ def _default_transforms(image_key, label_key, pixdim):
def _save_data_2d(vol_idx, vol_image, vol_label, dataset_dir, relative_path):
data_list = []
-
if len(vol_image.shape) == 4:
logging.info(
"4D-Image, pick only first series; Image: {}; Label: {}".format(
@@ -146,6 +145,9 @@ def _save_data_2d(vol_idx, vol_image, vol_label, dataset_dir, relative_path):
)
vol_image = vol_image[0]
vol_image = np.moveaxis(vol_image, -1, 0)
+ if vol_label is not None:
+ vol_label = vol_label[0]
+ vol_label = np.moveaxis(vol_label, -1, 0)
image_count = 0
label_count = 0
diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py
index c439469aea..537a21a966 100644
--- a/monai/apps/deepgrow/transforms.py
+++ b/monai/apps/deepgrow/transforms.py
@@ -52,7 +52,7 @@ def _apply(self, label):
def __call__(self, data):
d: Dict = dict(data)
- label = d[self.label]
+ label = d[self.label].numpy() if isinstance(data[self.label], torch.Tensor) else data[self.label]
if label.shape[0] != 1:
raise ValueError(f"Only supports single channel labels, got label shape {label.shape}!")
@@ -208,6 +208,10 @@ def _get_signal(self, image, guidance):
def _apply(self, image, guidance):
signal = self._get_signal(image, guidance)
+
+ if isinstance(image, torch.Tensor):
+ image = image.detach().cpu().numpy()
+
image = image[0 : 0 + self.number_intensity_ch, ...]
return np.concatenate([image, signal], axis=0)
diff --git a/monai/apps/detection/transforms/box_ops.py b/monai/apps/detection/transforms/box_ops.py
index 1562f28b29..e809ebd207 100644
--- a/monai/apps/detection/transforms/box_ops.py
+++ b/monai/apps/detection/transforms/box_ops.py
@@ -162,7 +162,7 @@ def flip_boxes(
boxes: NdarrayOrTensor,
spatial_size: Union[Sequence[int], int],
flip_axes: Optional[Union[Sequence[int], int]] = None,
-) -> NdarrayOrTensor:
+):
"""
Flip boxes when the corresponding image is flipped
@@ -185,7 +185,11 @@ def flip_boxes(
flip_axes = ensure_tuple(flip_axes)
# flip box
- _flip_boxes = deepcopy(boxes)
+ if isinstance(boxes, torch.Tensor):
+ _flip_boxes = boxes.clone()
+ else:
+ _flip_boxes = deepcopy(boxes) # type: ignore
+
for axis in flip_axes:
_flip_boxes[:, axis + spatial_dims] = spatial_size[axis] - boxes[:, axis] - TO_REMOVE
_flip_boxes[:, axis] = spatial_size[axis] - boxes[:, axis + spatial_dims] - TO_REMOVE
@@ -224,7 +228,7 @@ def convert_box_to_mask(
spatial_size = ensure_tuple_rep(spatial_size, spatial_dims)
# if no box, return empty mask
- if len(labels) == 0:
+ if labels.shape[0] == 0:
boxes_mask_np = np.ones((1,) + spatial_size, dtype=np.int16) * np.int16(bg_label)
boxes_mask, *_ = convert_to_dst_type(src=boxes_mask_np, dst=boxes, dtype=torch.int16)
return boxes_mask
@@ -249,11 +253,11 @@ def convert_box_to_mask(
box_size = [boxes_np[b, axis + spatial_dims] - boxes_np[b, axis] for axis in range(spatial_dims)]
if ellipse_mask:
# initialize a square/cube mask
- max_box_size = max(box_size)
+ max_box_size = max(box_size) # max of box w/h/d
radius = max_box_size / 2.0
center = (max_box_size - 1) / 2.0
boxes_only_mask = np.ones([max_box_size] * spatial_dims, dtype=np.int16) * np.int16(bg_label)
- # apply label intensity to circle/ball foreground
+ # apply label intensity to generate circle/ball foreground
ranges = tuple(slice(0, max_box_size) for _ in range(spatial_dims))
dist_from_center = sum((grid - center) ** 2 for grid in np.ogrid[ranges])
boxes_only_mask[dist_from_center <= radius**2] = np.int16(labels_np[b])
@@ -338,10 +342,10 @@ def select_labels(
labels_select_list = []
keep_t: torch.Tensor = convert_data_type(keep, torch.Tensor)[0]
- for i in range(len(labels_tuple)):
- labels_t: torch.Tensor = convert_data_type(labels_tuple[i], torch.Tensor)[0]
+ for item in labels_tuple:
+ labels_t: torch.Tensor = convert_data_type(item, torch.Tensor)[0]
labels_t = labels_t[keep_t, ...]
- labels_select_list.append(convert_to_dst_type(src=labels_t, dst=labels_tuple[i])[0])
+ labels_select_list.append(convert_to_dst_type(src=labels_t, dst=item)[0])
if isinstance(labels, (torch.Tensor, np.ndarray)):
return labels_select_list[0] # type: ignore
@@ -349,7 +353,7 @@ def select_labels(
return tuple(labels_select_list)
-def swapaxes_boxes(boxes: NdarrayOrTensor, axis1: int, axis2: int) -> NdarrayOrTensor:
+def swapaxes_boxes(boxes: NdarrayOrTensor, axis1: int, axis2: int):
"""
Interchange two axes of boxes.
@@ -363,7 +367,11 @@ def swapaxes_boxes(boxes: NdarrayOrTensor, axis1: int, axis2: int) -> NdarrayOrT
"""
spatial_dims: int = get_spatial_dims(boxes=boxes)
- boxes_swap: NdarrayOrTensor = deepcopy(boxes)
+
+ if isinstance(boxes, torch.Tensor):
+ boxes_swap = boxes.clone()
+ else:
+ boxes_swap = deepcopy(boxes) # type: ignore
boxes_swap[:, [axis1, axis2]] = boxes_swap[:, [axis2, axis1]] # type: ignore
boxes_swap[:, [spatial_dims + axis1, spatial_dims + axis2]] = boxes_swap[ # type: ignore
:, [spatial_dims + axis2, spatial_dims + axis1]
@@ -373,7 +381,7 @@ def swapaxes_boxes(boxes: NdarrayOrTensor, axis1: int, axis2: int) -> NdarrayOrT
def rot90_boxes(
boxes: NdarrayOrTensor, spatial_size: Union[Sequence[int], int], k: int = 1, axes: Tuple[int, int] = (0, 1)
-) -> NdarrayOrTensor:
+):
"""
Rotate boxes by 90 degrees in the plane specified by axes.
Rotation direction is from the first towards the second axis.
diff --git a/monai/apps/detection/transforms/dictionary.py b/monai/apps/detection/transforms/dictionary.py
index cb08d0ed69..8d4f08d282 100644
--- a/monai/apps/detection/transforms/dictionary.py
+++ b/monai/apps/detection/transforms/dictionary.py
@@ -15,7 +15,6 @@
Class names are ended with 'd' to denote dictionary-based transforms.
"""
from copy import deepcopy
-from enum import Enum
from typing import Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Type, Union
import numpy as np
@@ -37,15 +36,15 @@
from monai.config import KeysCollection, SequenceStr
from monai.config.type_definitions import NdarrayOrTensor
from monai.data.box_utils import COMPUTE_DTYPE, BoxMode, clip_boxes_to_image
+from monai.data.meta_tensor import MetaTensor, get_track_meta
from monai.data.utils import orientation_ras_lps
-from monai.transforms import Flip, RandFlip, RandRotate90d, RandZoom, Rotate90, SpatialCrop, SpatialPad, Zoom
+from monai.transforms import Flip, RandFlip, RandRotate90d, RandZoom, Rotate90, SpatialCrop, Zoom
from monai.transforms.inverse import InvertibleTransform
from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform
from monai.transforms.utils import generate_pos_neg_label_crop_centers, map_binary_to_indices
-from monai.utils import ImageMetaKey as Key
-from monai.utils import InterpolateMode, NumpyPadMode, ensure_tuple, ensure_tuple_rep
+from monai.utils import InterpolateMode, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple
from monai.utils.enums import PostFix, TraceKeys
-from monai.utils.type_conversion import convert_data_type
+from monai.utils.type_conversion import convert_data_type, convert_to_tensor
__all__ = [
"ConvertBoxModed",
@@ -131,12 +130,12 @@ def __init__(
def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
d = dict(data)
for key in self.key_iterator(d):
- self.push_transform(d, key, extra_info={"src": self.converter.src_mode, "dst": self.converter.dst_mode})
d[key] = self.converter(d[key])
+ self.push_transform(d, key, extra_info={"src": self.converter.src_mode, "dst": self.converter.dst_mode})
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
tr = self.get_most_recent_transform(d, key)
src_mode, dst_mode = tr[TraceKeys.EXTRA_INFO]["src"], tr[TraceKeys.EXTRA_INFO]["dst"]
@@ -186,12 +185,12 @@ def __init__(
def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
d = dict(data)
for key in self.key_iterator(d):
- self.push_transform(d, key, extra_info={"mode": self.converter.mode})
d[key] = self.converter(d[key])
+ self.push_transform(d, key, extra_info={"mode": self.converter.mode})
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
tr = self.get_most_recent_transform(d, key)
original_mode = tr[TraceKeys.EXTRA_INFO]["mode"]
@@ -242,23 +241,28 @@ def __init__(
"Please provide a single key for box_ref_image_keys.\
All boxes of box_keys are attached to box_ref_image_keys."
)
+ self.box_ref_image_keys = box_ref_image_keys
self.image_meta_key = image_meta_key or f"{box_ref_image_keys}_{image_meta_key_postfix}"
self.converter_to_image_coordinate = AffineBox()
self.affine_lps_to_ras = affine_lps_to_ras
- def extract_affine(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Tuple[NdarrayOrTensor, NdarrayOrTensor]:
+ def extract_affine(self, data: Mapping[Hashable, torch.Tensor]) -> Tuple[NdarrayOrTensor, torch.Tensor]:
d = dict(data)
meta_key = self.image_meta_key
- # extract affine matrix from meta_data
- if meta_key not in d:
+ # extract affine matrix from metadata
+ if isinstance(d[self.box_ref_image_keys], MetaTensor):
+ meta_dict = d[self.box_ref_image_keys].meta # type: ignore
+ elif meta_key in d:
+ meta_dict = d[meta_key]
+ else:
raise ValueError(f"{meta_key} is not found. Please check whether it is the correct the image meta key.")
- if "affine" not in d[meta_key]:
+ if "affine" not in meta_dict:
raise ValueError(
f"'affine' is not found in {meta_key}. \
Please check whether it is the correct the image meta key."
)
- affine: NdarrayOrTensor = d[meta_key]["affine"] # type: ignore
+ affine: NdarrayOrTensor = meta_dict["affine"] # type: ignore
if self.affine_lps_to_ras: # RAS affine
affine = orientation_ras_lps(affine)
@@ -272,15 +276,15 @@ def extract_affine(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Tuple[Ndar
def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
d = dict(data)
- affine, inv_affine_t = self.extract_affine(data)
+ affine, inv_affine_t = self.extract_affine(data) # type: ignore
for key in self.key_iterator(d):
- self.push_transform(d, key, extra_info={"affine": affine})
d[key] = self.converter_to_image_coordinate(d[key], affine=inv_affine_t)
+ self.push_transform(d, key, extra_info={"affine": affine})
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
transform = self.get_most_recent_transform(d, key)
affine = transform["extra_info"]["affine"]
@@ -329,11 +333,11 @@ def __init__(
def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
d = dict(data)
- affine, inv_affine_t = self.extract_affine(data)
+ affine, inv_affine_t = self.extract_affine(data) # type: ignore
for key in self.key_iterator(d):
- self.push_transform(d, key, extra_info={"affine": inv_affine_t})
d[key] = self.converter_to_world_coordinate(d[key], affine=affine)
+ self.push_transform(d, key, extra_info={"affine": inv_affine_t})
return d
@@ -401,58 +405,32 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
src_spatial_size = d[box_ref_image_key].shape[1:]
dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self.zoomer.zoom, src_spatial_size)] # type: ignore
self.zoomer.zoom = [ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)]
+ d[box_key] = ZoomBox(zoom=self.zoomer.zoom, keep_size=self.keep_size)(
+ d[box_key], src_spatial_size=src_spatial_size
+ )
self.push_transform(
d,
box_key,
extra_info={"zoom": self.zoomer.zoom, "src_spatial_size": src_spatial_size, "type": "box_key"},
)
- d[box_key] = ZoomBox(zoom=self.zoomer.zoom, keep_size=self.keep_size)(
- d[box_key], src_spatial_size=src_spatial_size
- )
- # zoom image, copied from monai.transforms.spatial.dictionary.Zoomd
+ # zoom image
for key, mode, padding_mode, align_corners in zip(
self.image_keys, self.mode, self.padding_mode, self.align_corners
):
- self.push_transform(
- d,
- key,
- extra_info={
- "mode": mode.value if isinstance(mode, Enum) else mode,
- "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode,
- "align_corners": align_corners if align_corners is not None else TraceKeys.NONE,
- "original_shape": d[key].shape[1:],
- "type": "image_key",
- },
- )
d[key] = self.zoomer(d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners)
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
- transform = self.get_most_recent_transform(d, key)
- key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+ transform = self.get_most_recent_transform(d, key, check=False)
+ key_type = transform[TraceKeys.EXTRA_INFO].get("type", "image_key")
# zoom image, copied from monai.transforms.spatial.dictionary.Zoomd
if key_type == "image_key":
- # Create inverse transform
- zoom = np.array(self.zoomer.zoom)
- inverse_transform = Zoom(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size)
- mode = transform[TraceKeys.EXTRA_INFO]["mode"]
- padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"]
- align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"]
- # Apply inverse
- d[key] = inverse_transform(
- d[key],
- mode=mode,
- padding_mode=padding_mode,
- align_corners=None if align_corners == TraceKeys.NONE else align_corners,
- )
- # Size might be out by 1 voxel so pad
- orig_shape = transform[TraceKeys.EXTRA_INFO]["original_shape"]
- d[key] = SpatialPad(orig_shape, mode="edge")(d[key])
+ d[key] = self.zoomer.inverse(d[key])
# zoom boxes
if key_type == "box_key":
@@ -460,9 +438,8 @@ def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch
src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"]
box_inverse_transform = ZoomBox(zoom=(1 / zoom).tolist(), keep_size=self.zoomer.keep_size)
d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size)
-
- # Remove the applied transform
- self.pop_transform(d, key)
+ # Remove the applied transform
+ self.pop_transform(d, key)
return d
@@ -561,62 +538,44 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
dst_spatial_size = [int(round(z * ss)) for z, ss in zip(self.rand_zoom._zoom, src_spatial_size)]
self.rand_zoom._zoom = [ds / float(ss) for ss, ds in zip(src_spatial_size, dst_spatial_size)]
+ d[box_key] = ZoomBox(zoom=self.rand_zoom._zoom, keep_size=self.keep_size)(
+ d[box_key], src_spatial_size=src_spatial_size
+ )
self.push_transform(
d,
box_key,
extra_info={"zoom": self.rand_zoom._zoom, "src_spatial_size": src_spatial_size, "type": "box_key"},
)
- d[box_key] = ZoomBox(zoom=self.rand_zoom._zoom, keep_size=self.keep_size)(
- d[box_key], src_spatial_size=src_spatial_size
- )
# zoom image, copied from monai.transforms.spatial.dictionary.RandZoomd
for key, mode, padding_mode, align_corners in zip(
self.image_keys, self.mode, self.padding_mode, self.align_corners
):
if self._do_transform:
- self.push_transform(
- d,
- key,
- extra_info={
- "zoom": self.rand_zoom._zoom,
- "mode": mode.value if isinstance(mode, Enum) else mode,
- "padding_mode": padding_mode.value if isinstance(padding_mode, Enum) else padding_mode,
- "align_corners": align_corners if align_corners is not None else TraceKeys.NONE,
- "original_shape": d[key].shape[1:],
- "type": "image_key",
- },
- )
d[key] = self.rand_zoom(
d[key], mode=mode, padding_mode=padding_mode, align_corners=align_corners, randomize=False
)
+ else:
+ d[key] = convert_to_tensor(d[key], track_meta=get_track_meta())
+ if get_track_meta():
+ xform = self.pop_transform(d[key], check=False) if self._do_transform else {}
+ self.push_transform(d[key], extra_info=xform)
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
- transform = self.get_most_recent_transform(d, key)
- key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+ transform = self.get_most_recent_transform(d, key, check=False)
+ key_type = transform[TraceKeys.EXTRA_INFO].get("type", "image_key")
# Check if random transform was actually performed (based on `prob`)
if transform[TraceKeys.DO_TRANSFORM]:
# zoom image, copied from monai.transforms.spatial.dictionary.Zoomd
if key_type == "image_key":
- zoom = np.array(transform[TraceKeys.EXTRA_INFO]["zoom"])
- mode = transform[TraceKeys.EXTRA_INFO]["mode"]
- padding_mode = transform[TraceKeys.EXTRA_INFO]["padding_mode"]
- align_corners = transform[TraceKeys.EXTRA_INFO]["align_corners"]
- inverse_transform = Zoom(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size)
- d[key] = inverse_transform(
- d[key],
- mode=mode,
- padding_mode=padding_mode,
- align_corners=None if align_corners == TraceKeys.NONE else align_corners,
- )
- # Size might be out by 1 voxel so pad
- orig_shape = transform[TraceKeys.EXTRA_INFO]["original_shape"]
- d[key] = SpatialPad(orig_shape, mode="edge")(d[key])
+ xform = self.pop_transform(d[key])
+ d[key].applied_operations.append(xform[TraceKeys.EXTRA_INFO]) # type: ignore
+ d[key] = self.rand_zoom.inverse(d[key])
# zoom boxes
if key_type == "box_key":
@@ -625,9 +584,8 @@ def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch
src_spatial_size = transform[TraceKeys.EXTRA_INFO]["src_spatial_size"]
box_inverse_transform = ZoomBox(zoom=(1.0 / zoom).tolist(), keep_size=self.rand_zoom.keep_size)
d[key] = box_inverse_transform(d[key], src_spatial_size=src_spatial_size)
-
- # Remove the applied transform
- self.pop_transform(d, key)
+ # Remove the applied transform
+ self.pop_transform(d, key)
return d
@@ -666,7 +624,6 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
for key in self.image_keys:
d[key] = self.flipper(d[key])
- self.push_transform(d, key, extra_info={"type": "image_key"})
for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys):
spatial_size = d[box_ref_image_key].shape[1:]
@@ -675,23 +632,22 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
- transform = self.get_most_recent_transform(d, key)
- key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+ transform = self.get_most_recent_transform(d, key, check=False)
+ key_type = transform.get(TraceKeys.EXTRA_INFO, {}).get("type", "image_key")
# flip image, copied from monai.transforms.spatial.dictionary.Flipd
if key_type == "image_key":
- d[key] = self.flipper(d[key])
+ d[key] = self.flipper.inverse(d[key])
# flip boxes
if key_type == "box_key":
spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"]
d[key] = self.box_flipper(d[key], spatial_size)
-
- # Remove the applied transform
- self.pop_transform(d, key)
+ # Remove the applied transform
+ self.pop_transform(d, key)
return d
@@ -725,14 +681,13 @@ def __init__(
RandomizableTransform.__init__(self, prob)
self.box_ref_image_keys = ensure_tuple_rep(box_ref_image_keys, len(self.box_keys))
- self.flipper = RandFlip(prob=1.0, spatial_axis=spatial_axis)
+ self.flipper = Flip(spatial_axis=spatial_axis)
self.box_flipper = FlipBox(spatial_axis=spatial_axis)
def set_random_state(
self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None
) -> "RandFlipBoxd":
super().set_random_state(seed, state)
- self.flipper.set_random_state(seed, state)
return self
def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
@@ -741,8 +696,12 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
for key in self.image_keys:
if self._do_transform:
- d[key] = self.flipper(d[key], randomize=False)
- self.push_transform(d, key, extra_info={"type": "image_key"})
+ d[key] = self.flipper(d[key])
+ else:
+ d[key] = convert_to_tensor(d[key], track_meta=get_track_meta())
+ if get_track_meta():
+ xform_info = self.pop_transform(d[key], check=False) if self._do_transform else {}
+ self.push_transform(d[key], extra_info=xform_info)
for box_key, box_ref_image_key in zip(self.box_keys, self.box_ref_image_keys):
spatial_size = d[box_ref_image_key].shape[1:]
@@ -752,16 +711,17 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
- transform = self.get_most_recent_transform(d, key)
- key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+ transform = self.get_most_recent_transform(d, key, check=False)
+ key_type = transform[TraceKeys.EXTRA_INFO].get("type", "image_key")
# Check if random transform was actually performed (based on `prob`)
if transform[TraceKeys.DO_TRANSFORM]:
# flip image, copied from monai.transforms.spatial.dictionary.RandFlipd
if key_type == "image_key":
- d[key] = self.flipper(d[key], randomize=False)
+ with self.flipper.trace_transform(False):
+ d[key] = self.flipper(d[key])
# flip boxes
if key_type == "box_key":
@@ -769,7 +729,7 @@ def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch
d[key] = self.box_flipper(d[key], spatial_size)
# Remove the applied transform
- self.pop_transform(d, key)
+ self.pop_transform(d, key, check=False)
return d
@@ -1114,7 +1074,7 @@ def __init__(
self.allow_smaller = allow_smaller
def generate_fg_center_boxes_np(self, boxes: NdarrayOrTensor, image_size: Sequence[int]) -> np.ndarray:
- # We don't require crop center to be whthin the boxes.
+ # We don't require crop center to be within the boxes.
# As along as the cropped patch contains a box, it is considered as a foreground patch.
# Positions within extended_boxes are crop centers for foreground patches
spatial_dims = len(image_size)
@@ -1174,9 +1134,8 @@ def randomize( # type: ignore
def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]:
d = dict(data)
- spatial_dims = len(d[self.image_keys[0]].shape) - 1
image_size = d[self.image_keys[0]].shape[1:]
- self.spatial_size = ensure_tuple_rep(self.spatial_size_, spatial_dims)
+ self.spatial_size = fall_back_tuple(self.spatial_size_, image_size)
# randomly sample crop centers
boxes = d[self.box_keys]
@@ -1212,13 +1171,6 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable,
for label_key, cropped_labels_i in zip(self.label_keys, cropped_labels):
results[i][label_key] = cropped_labels_i
- # add `patch_index` to the meta data
- for key, meta_key, meta_key_postfix in zip(self.image_keys, self.meta_keys, self.meta_key_postfix):
- meta_key = meta_key or f"{key}_{meta_key_postfix}"
- if meta_key not in results[i]:
- results[i][meta_key] = {} # type: ignore
- results[i][meta_key][Key.PATCH_INDEX] = i # type: ignore
-
return results
@@ -1270,25 +1222,23 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Mapping[Hashable, t
for key in self.image_keys:
d[key] = self.img_rotator(d[key])
- self.push_transform(d, key, extra_info={"type": "image_key"})
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
- transform = self.get_most_recent_transform(d, key)
- key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+ transform = self.get_most_recent_transform(d, key, check=False)
+ key_type = transform[TraceKeys.EXTRA_INFO].get("type", "image_key")
num_times_to_rotate = 4 - self.img_rotator.k
if key_type == "image_key":
- inverse_transform = Rotate90(num_times_to_rotate, self.img_rotator.spatial_axes)
- d[key] = inverse_transform(d[key])
+ d[key] = self.img_rotator.inverse(d[key])
if key_type == "box_key":
spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"]
inverse_transform = RotateBox90(num_times_to_rotate, self.box_rotator.spatial_axes)
d[key] = inverse_transform(d[key], spatial_size)
- self.pop_transform(d, key)
+ self.pop_transform(d, key)
return d
@@ -1355,31 +1305,37 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Mapping[Hashable, t
for key in self.image_keys:
if self._do_transform:
- d[key] = img_rotator(d[key])
- self.push_transform(d, key, extra_info={"rand_k": self._rand_k, "type": "image_key"})
+ d[key] = (
+ img_rotator(d[key])
+ if self._do_transform
+ else convert_to_tensor(d[key], track_meta=get_track_meta())
+ )
+ if get_track_meta():
+ xform = self.pop_transform(d[key], check=False) if self._do_transform else {}
+ self.push_transform(d[key], extra_info=xform)
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
if self._rand_k % 4 == 0:
return d
for key in self.key_iterator(d):
- transform = self.get_most_recent_transform(d, key)
- key_type = transform[TraceKeys.EXTRA_INFO]["type"]
+ transform = self.get_most_recent_transform(d, key, check=False)
+ key_type = transform[TraceKeys.EXTRA_INFO].get("type", "image_key")
# Check if random transform was actually performed (based on `prob`)
if transform[TraceKeys.DO_TRANSFORM]:
- num_times_rotated = transform[TraceKeys.EXTRA_INFO]["rand_k"]
- num_times_to_rotate = 4 - num_times_rotated
# flip image, copied from monai.transforms.spatial.dictionary.RandFlipd
if key_type == "image_key":
- inverse_transform = Rotate90(num_times_to_rotate, self.spatial_axes)
- d[key] = inverse_transform(d[key])
+ xform = self.pop_transform(d, key, check=False)
+ d[key] = Rotate90().inverse_transform(d[key], xform[TraceKeys.EXTRA_INFO])
if key_type == "box_key":
+ num_times_rotated = transform[TraceKeys.EXTRA_INFO]["rand_k"]
+ num_times_to_rotate = 4 - num_times_rotated
spatial_size = transform[TraceKeys.EXTRA_INFO]["spatial_size"]
inverse_transform = RotateBox90(num_times_to_rotate, self.spatial_axes)
d[key] = inverse_transform(d[key], spatial_size)
- self.pop_transform(d, key)
+ self.pop_transform(d, key)
return d
diff --git a/monai/apps/detection/utils/ATSS_matcher.py b/monai/apps/detection/utils/ATSS_matcher.py
index f0170422bb..67d063b920 100644
--- a/monai/apps/detection/utils/ATSS_matcher.py
+++ b/monai/apps/detection/utils/ATSS_matcher.py
@@ -74,7 +74,7 @@
"""
import logging
-from abc import ABC
+from abc import ABC, abstractmethod
from typing import Callable, Sequence, Tuple, TypeVar
import torch
@@ -139,6 +139,7 @@ def __call__(
num_anchors_per_loc=num_anchors_per_loc,
)
+ @abstractmethod
def compute_matches(
self, boxes: torch.Tensor, anchors: torch.Tensor, num_anchors_per_level: Sequence[int], num_anchors_per_loc: int
) -> Tuple[torch.Tensor, torch.Tensor]:
diff --git a/monai/apps/detection/utils/hard_negative_sampler.py b/monai/apps/detection/utils/hard_negative_sampler.py
index 3067a41a0e..c10e8a3af6 100644
--- a/monai/apps/detection/utils/hard_negative_sampler.py
+++ b/monai/apps/detection/utils/hard_negative_sampler.py
@@ -31,14 +31,13 @@
"""
import logging
-from abc import ABC
from typing import List, Tuple
import torch
from torch import Tensor
-class HardNegativeSamplerBase(ABC):
+class HardNegativeSamplerBase:
"""
Base class of hard negative sampler.
diff --git a/monai/apps/nuclick/transforms.py b/monai/apps/nuclick/transforms.py
index 28c6417a42..f572aa2184 100644
--- a/monai/apps/nuclick/transforms.py
+++ b/monai/apps/nuclick/transforms.py
@@ -16,6 +16,7 @@
import numpy as np
from monai.config import KeysCollection
+from monai.data import MetaTensor
from monai.transforms import MapTransform, Randomizable, SpatialPad
from monai.utils import StrEnum, optional_import
@@ -60,7 +61,8 @@ def __init__(self, keys: KeysCollection, connectivity: int = 1, allow_missing_ke
def __call__(self, data):
d = dict(data)
for key in self.keys:
- d[key] = measure.label(d[key], connectivity=self.connectivity).astype(np.uint8)
+ img = d[key].array if isinstance(d[key], MetaTensor) else d[key]
+ d[key] = measure.label(img, connectivity=self.connectivity).astype(np.uint8)
return d
@@ -159,8 +161,9 @@ def __call__(self, data):
for key in self.keys:
self.label = key
- label = d[self.label]
- mask_value = d[self.mask_value]
+ label = d[self.label].array if isinstance(d[self.label], MetaTensor) else d[self.label]
+ mask_value = d[self.mask_value].array if isinstance(d[self.mask_value], MetaTensor) else d[self.mask_value]
+
mask = np.uint8(label == mask_value)
others = (1 - mask) * label
others = self._mask_relabeling(others[0], min_area=self.min_area)[np.newaxis]
@@ -200,7 +203,7 @@ def __init__(self, keys: KeysCollection, min_size: int = 500, allow_missing_keys
def __call__(self, data):
d = dict(data)
for key in self.keys:
- img = d[key]
+ img = d[key].array if isinstance(d[key], MetaTensor) else d[key]
d[key] = self.filter(img)
return d
@@ -284,9 +287,9 @@ def __init__(
def __call__(self, data):
d = dict(data)
- image = d[self.image]
- mask = d[self.label]
- others = d[self.others]
+ image = d[self.image].array if isinstance(d[self.image], MetaTensor) else d[self.image]
+ mask = d[self.label].array if isinstance(d[self.label], MetaTensor) else d[self.label]
+ others = d[self.others].array if isinstance(d[self.others], MetaTensor) else d[self.others]
inc_sig = self.inclusion_map(mask[0])
exc_sig = self.exclusion_map(others[0], drop_rate=self.drop_rate, jitter_range=self.jitter_range)
@@ -353,7 +356,8 @@ def __call__(self, data):
cx = [xy[0] for xy in pos]
cy = [xy[1] for xy in pos]
- img = d[self.image].astype(np.uint8)
+ img = d[self.image].array if isinstance(d[self.image], MetaTensor) else d[self.image]
+ img = img.astype(np.uint8)
img_width = img.shape[-1]
img_height = img.shape[-2]
@@ -427,8 +431,7 @@ def get_patches_and_signals(self, img, click_map, bounding_boxes, cx, cy, m, n,
cx = np.delete(cx, del_indices)
cy = np.delete(cy, del_indices)
- for i in range(len(bounding_boxes)):
- bounding_box = bounding_boxes[i]
+ for i, bounding_box in enumerate(bounding_boxes):
x_start = bounding_box[0]
y_start = bounding_box[1]
x_end = bounding_box[2]
@@ -523,9 +526,9 @@ def post_processing(self, preds, thresh=0.33, min_size=10, min_hole=30, do_recon
def gen_instance_map(self, masks, bounding_boxes, m, n, flatten=True):
instance_map = np.zeros((m, n), dtype=np.uint16)
- for i in range(len(masks)):
+ for i, item in enumerate(masks):
this_bb = bounding_boxes[i]
- this_mask_pos = np.argwhere(masks[i] > 0)
+ this_mask_pos = np.argwhere(item > 0)
this_mask_pos[:, 0] = this_mask_pos[:, 0] + this_bb[1]
this_mask_pos[:, 1] = this_mask_pos[:, 1] + this_bb[0]
instance_map[this_mask_pos[:, 0], this_mask_pos[:, 1]] = 1 if flatten else i + 1
diff --git a/monai/apps/pathology/data/datasets.py b/monai/apps/pathology/data/datasets.py
index 71f3214ea4..a25e54e999 100644
--- a/monai/apps/pathology/data/datasets.py
+++ b/monai/apps/pathology/data/datasets.py
@@ -17,11 +17,12 @@
from monai.data import Dataset, SmartCacheDataset
from monai.data.image_reader import WSIReader
-from monai.utils import ensure_tuple_rep
+from monai.utils import deprecated, ensure_tuple_rep
__all__ = ["PatchWSIDataset", "SmartCachePatchWSIDataset", "MaskedInferenceWSIDataset"]
+@deprecated(since="0.8", msg_suffix="use `monai.data.PatchWSIDataset` instead.")
class PatchWSIDataset(Dataset):
"""
This dataset reads whole slide images, extracts regions, and creates patches.
@@ -103,6 +104,7 @@ def __getitem__(self, index):
return patches
+@deprecated(since="0.8", msg_suffix="use `monai.data.SmartCacheDataset` with `monai.data.PatchWSIDataset` instead.")
class SmartCachePatchWSIDataset(SmartCacheDataset):
"""Add SmartCache functionality to `PatchWSIDataset`.
@@ -177,6 +179,7 @@ def __init__(
)
+@deprecated(since="0.8", msg_suffix="use `monai.data.MaskedPatchWSIDataset` instead.")
class MaskedInferenceWSIDataset(Dataset):
"""
This dataset load the provided foreground masks at an arbitrary resolution level,
diff --git a/monai/apps/pathology/handlers/prob_map_producer.py b/monai/apps/pathology/handlers/prob_map_producer.py
index 62507dc0cb..d5b1b50c47 100644
--- a/monai/apps/pathology/handlers/prob_map_producer.py
+++ b/monai/apps/pathology/handlers/prob_map_producer.py
@@ -27,7 +27,10 @@
@deprecated(
since="0.8",
- msg_suffix="use `monai.handler.ProbMapProducer` (with `monai.data.wsi_dataset.SlidingPatchWSIDataset`) instead.",
+ msg_suffix=(
+ "use `monai.handler.ProbMapProducer` (with `monai.data.wsi_dataset.MaskedPatchWSIDataset` or "
+ "`monai.data.wsi_dataset.SlidingPatchWSIDataset`) instead."
+ ),
)
class ProbMapProducer:
"""
diff --git a/monai/apps/reconstruction/__init__.py b/monai/apps/reconstruction/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/apps/reconstruction/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/apps/reconstruction/complex_utils.py b/monai/apps/reconstruction/complex_utils.py
new file mode 100644
index 0000000000..acf2920bb6
--- /dev/null
+++ b/monai/apps/reconstruction/complex_utils.py
@@ -0,0 +1,232 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+This script contains utility functions for complex-value PyTorch tensor.
+"""
+
+import re
+from typing import Optional
+
+import numpy as np
+import torch
+from torch import Tensor
+
+from monai.config.type_definitions import NdarrayOrTensor
+from monai.utils.type_conversion import convert_to_numpy, convert_to_tensor
+
+
+def convert_to_tensor_complex(
+ data,
+ dtype: Optional[torch.dtype] = None,
+ device: Optional[torch.device] = None,
+ wrap_sequence: bool = True,
+ track_meta: bool = False,
+) -> Tensor:
+ """
+ Convert complex-valued data to a 2-channel PyTorch tensor.
+ The real and imaginary parts are stacked along the last dimension.
+ This function relies on 'monai.utils.type_conversion.convert_to_tensor'
+
+ Args:
+ data: input data can be PyTorch Tensor, numpy array, list, int, and float.
+ will convert Tensor, Numpy array, float, int, bool to Tensor, strings and objects keep the original.
+ for list, convert every item to a Tensor if applicable.
+ dtype: target data type to when converting to Tensor.
+ device: target device to put the converted Tensor data.
+ wrap_sequence: if `False`, then lists will recursively call this function.
+ E.g., `[1, 2]` -> `[tensor(1), tensor(2)]`. If `True`, then `[1, 2]` -> `tensor([1, 2])`.
+ track_meta: whether to track the meta information, if `True`, will convert to `MetaTensor`.
+ default to `False`.
+
+ Returns:
+ PyTorch version of the data
+
+ Example:
+ .. code-block:: python
+
+ import numpy as np
+ data = np.array([ [1+1j, 1-1j], [2+2j, 2-2j] ])
+ # the following line prints (2,2)
+ print(data.shape)
+ # the following line prints torch.Size([2, 2, 2])
+ print(convert_to_tensor_complex(data).shape)
+ """
+ # if data is not complex, just turn it into a tensor
+ if isinstance(data, Tensor):
+ if not torch.is_complex(data):
+ converted_data: Tensor = convert_to_tensor(
+ data, dtype=dtype, device=device, wrap_sequence=wrap_sequence, track_meta=track_meta
+ )
+ return converted_data
+ else:
+ if not np.iscomplexobj(data):
+ converted_data = convert_to_tensor(
+ data, dtype=dtype, device=device, wrap_sequence=wrap_sequence, track_meta=track_meta
+ )
+ return converted_data
+
+ # if data is complex, turn its stacked version into a tensor
+ if isinstance(data, torch.Tensor):
+ data = torch.stack([data.real, data.imag], dim=-1)
+
+ elif isinstance(data, np.ndarray):
+ if re.search(r"[SaUO]", data.dtype.str) is None:
+ # numpy array with 0 dims is also sequence iterable,
+ # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims
+ if data.ndim > 0:
+ data = np.ascontiguousarray(data)
+ data = np.stack((data.real, data.imag), axis=-1)
+
+ elif isinstance(data, (float, int)):
+ data = [[data.real, data.imag]]
+
+ elif isinstance(data, list):
+ data = convert_to_numpy(data, wrap_sequence=True)
+ data = np.stack((data.real, data.imag), axis=-1).tolist()
+
+ converted_data = convert_to_tensor(
+ data, dtype=dtype, device=device, wrap_sequence=wrap_sequence, track_meta=track_meta
+ )
+ return converted_data
+
+
+def complex_abs_t(x: Tensor) -> Tensor:
+ """
+ Compute the absolute value of a complex tensor.
+
+ Args:
+ x: Input tensor with 2 channels in the last dimension representing real and imaginary parts.
+
+ Returns:
+ Absolute value along the last dimention
+ """
+ if x.shape[-1] != 2:
+ raise ValueError(f"x.shape[-1] is not 2 ({x.shape[-1]}).")
+ return (x[..., 0] ** 2 + x[..., 1] ** 2) ** 0.5 # type: ignore
+
+
+def complex_abs(x: NdarrayOrTensor) -> NdarrayOrTensor:
+ """
+ Compute the absolute value of a complex array.
+
+ Args:
+ x: Input array/tensor with 2 channels in the last dimension representing real and imaginary parts.
+
+ Returns:
+ Absolute value along the last dimention
+
+ Example:
+ .. code-block:: python
+
+ import numpy as np
+ x = np.array([3,4])[np.newaxis]
+ # the following line prints 5
+ print(complex_abs(x))
+ """
+ return complex_abs_t(x) # type: ignore
+
+
+def complex_mul_t(x: Tensor, y: Tensor) -> Tensor:
+ """
+ Compute complex-valued multiplication. Supports Ndim inputs with last dim equal to 2 (real/imaginary channels)
+
+ Args:
+ x: Input tensor with 2 channels in the last dimension representing real and imaginary parts.
+ y: Input tensor with 2 channels in the last dimension representing real and imaginary parts.
+
+ Returns:
+ Complex multiplication of x and y
+ """
+ if x.shape[-1] != 2 or y.shape[-1] != 2:
+ raise ValueError(f"last dim must be 2, but x.shape[-1] is {x.shape[-1]} and y.shape[-1] is {y.shape[-1]}.")
+
+ real_part = x[..., 0] * y[..., 0] - x[..., 1] * y[..., 1]
+ imag_part = x[..., 0] * y[..., 1] + x[..., 1] * y[..., 0]
+
+ return torch.stack((real_part, imag_part), dim=-1) # type: ignore
+
+
+def complex_mul(x: NdarrayOrTensor, y: NdarrayOrTensor) -> NdarrayOrTensor:
+ """
+ Compute complex-valued multiplication. Supports Ndim inputs with last dim equal to 2 (real/imaginary channels)
+
+ Args:
+ x: Input array/tensor with 2 channels in the last dimension representing real and imaginary parts.
+ y: Input array/tensor with 2 channels in the last dimension representing real and imaginary parts.
+
+ Returns:
+ Complex multiplication of x and y
+
+ Example:
+ .. code-block:: python
+
+ import numpy as np
+ x = np.array([[1,2],[3,4]])
+ y = np.array([[1,1],[1,1]])
+ # the following line prints array([[-1, 3], [-1, 7]])
+ print(complex_mul(x,y))
+ """
+ if x.shape[-1] != 2 or y.shape[-1] != 2:
+ raise ValueError(f"last dim must be 2, but x.shape[-1] is {x.shape[-1]} and y.shape[-1] is {y.shape[-1]}.")
+
+ if isinstance(x, Tensor):
+ return complex_mul_t(x, y) # type: ignore
+
+ else:
+ real_part = x[..., 0] * y[..., 0] - x[..., 1] * y[..., 1]
+ imag_part = x[..., 0] * y[..., 1] + x[..., 1] * y[..., 0]
+
+ mult: np.ndarray = np.stack((real_part, imag_part), axis=-1)
+ return mult
+
+
+def complex_conj_t(x: Tensor) -> Tensor:
+ """
+ Compute complex conjugate of a tensor. Supports Ndim inputs with last dim equal to 2 (real/imaginary channels)
+
+ Args:
+ x: Input tensor with 2 channels in the last dimension representing real and imaginary parts.
+
+ Returns:
+ Complex conjugate of x
+ """
+ if x.shape[-1] != 2:
+ raise ValueError(f"last dim must be 2, but x.shape[-1] is {x.shape[-1]}.")
+
+ return torch.stack((x[..., 0], -x[..., 1]), dim=-1)
+
+
+def complex_conj(x: NdarrayOrTensor) -> NdarrayOrTensor:
+ """
+ Compute complex conjugate of an/a array/tensor. Supports Ndim inputs with last dim equal to 2 (real/imaginary channels)
+
+ Args:
+ x: Input array/tensor with 2 channels in the last dimension representing real and imaginary parts.
+
+ Returns:
+ Complex conjugate of x
+
+ Example:
+ .. code-block:: python
+
+ import numpy as np
+ x = np.array([[1,2],[3,4]])
+ # the following line prints array([[ 1, -2], [ 3, -4]])
+ print(complex_conj(x))
+ """
+ if x.shape[-1] != 2:
+ raise ValueError(f"last dim must be 2, but x.shape[-1] is {x.shape[-1]}.")
+
+ if isinstance(x, Tensor):
+ return complex_conj_t(x)
+ else:
+ np_conj: np.ndarray = np.stack((x[..., 0], -x[..., 1]), axis=-1)
+ return np_conj
diff --git a/monai/apps/reconstruction/fastmri_reader.py b/monai/apps/reconstruction/fastmri_reader.py
new file mode 100644
index 0000000000..ece4cce607
--- /dev/null
+++ b/monai/apps/reconstruction/fastmri_reader.py
@@ -0,0 +1,101 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+from typing import Dict, Sequence, Tuple, Union
+
+import numpy as np
+from numpy import ndarray
+
+from monai.config import PathLike
+from monai.data.image_reader import ImageReader
+from monai.data.utils import is_supported_format
+from monai.utils import FastMRIKeys, optional_import, require_pkg
+from monai.utils.type_conversion import convert_to_tensor
+
+h5py, has_h5py = optional_import("h5py")
+
+
+@require_pkg(pkg_name="h5py")
+class FastMRIReader(ImageReader):
+ """
+ Load fastMRI files with '.h5' suffix. fastMRI files, when loaded with "h5py",
+ are HDF5 dictionary-like datasets. The keys are:
+
+ - kspace: contains the fully-sampled kspace
+ - reconstruction_rss: contains the root sum of squares of ifft of kspace. This
+ is the ground-truth image.
+
+ It also has several attributes with the following keys:
+
+ - acquisition (str): acquisition mode of the data (e.g., AXT2 denotes T2 brain MRI scans)
+ - max (float): dynamic range of the data
+ - norm (float): norm of the kspace
+ - patient_id (str): the patient's id whose measurements were recorded
+ """
+
+ def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool:
+ """
+ Verify whether the specified file format is supported by h5py reader.
+
+ Args:
+ filename: file name
+ """
+ suffixes: Sequence[str] = [".h5"]
+ return has_h5py and is_supported_format(filename, suffixes)
+
+ def read(self, data: Union[Sequence[PathLike], PathLike]) -> Dict: # type: ignore
+ """
+ Read data from specified h5 file.
+ Note that the returned object is a dictionary.
+
+ Args:
+ data: file name to read.
+ """
+ if isinstance(data, (tuple, list)):
+ data = data[0]
+
+ with h5py.File(data, "r") as f:
+ # extract everything from the ht5 file
+ dat = dict(
+ [(key, f[key][()]) for key in f]
+ + [(key, f.attrs[key]) for key in f.attrs]
+ + [(FastMRIKeys.FILENAME, os.path.basename(data))] # type: ignore
+ )
+ f.close()
+
+ return dat
+
+ def get_data(self, dat: Dict) -> Tuple[ndarray, dict]:
+ """
+ Extract data array and metadata from the loaded data and return them.
+ This function returns two objects, first is numpy array of image data, second is dict of metadata.
+
+ Args:
+ dat: a dictionary loaded from an h5 file
+ """
+ header = self._get_meta_dict(dat)
+ data: ndarray = np.array(dat[FastMRIKeys.KSPACE])
+ header[FastMRIKeys.MASK] = (
+ convert_to_tensor(np.array(dat[FastMRIKeys.MASK])).unsqueeze(0)[None, ..., None]
+ if FastMRIKeys.MASK in dat.keys()
+ else np.zeros(data.shape)
+ )
+ return data, header
+
+ def _get_meta_dict(self, dat) -> Dict:
+ """
+ Get all the metadata of the loaded dict and return the meta dict.
+
+ Args:
+ dat: a dictionary object loaded from an h5 file.
+ """
+ return {k.value: dat[k.value] for k in FastMRIKeys if k.value in dat}
diff --git a/monai/apps/reconstruction/mri_utils.py b/monai/apps/reconstruction/mri_utils.py
new file mode 100644
index 0000000000..9c06b492d5
--- /dev/null
+++ b/monai/apps/reconstruction/mri_utils.py
@@ -0,0 +1,60 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from torch import Tensor
+
+from monai.config.type_definitions import NdarrayOrTensor
+
+
+def root_sum_of_squares_t(x: Tensor, spatial_dim: int) -> Tensor:
+ """
+ Compute the root sum of squares (rss) of the data (typically done for multi-coil MRI samples)
+
+ Args:
+ x: Input tensor
+ spatial_dim: dimension along which rss is applied
+
+ Returns:
+ rss of x along spatial_dim
+
+ Example:
+ .. code-block:: python
+
+ import numpy as np
+ x = torch.ones([2,3])
+ # the following line prints Tensor([1.41421356, 1.41421356, 1.41421356])
+ print(rss(x,spatial_dim=0))
+ """
+ rss_x: Tensor = (x**2).sum(spatial_dim) ** 0.5
+ return rss_x
+
+
+def root_sum_of_squares(x: NdarrayOrTensor, spatial_dim: int) -> NdarrayOrTensor:
+ """
+ Compute the root sum of squares (rss) of the data (typically done for multi-coil MRI samples)
+
+ Args:
+ x: Input array/tensor
+ spatial_dim: dimension along which rss is applied
+
+ Returns:
+ rss of x along spatial_dim
+
+ Example:
+ .. code-block:: python
+
+ import numpy as np
+ x = np.ones([2,3])
+ # the following line prints array([1.41421356, 1.41421356, 1.41421356])
+ print(rss(x,spatial_dim=0))
+ """
+ rss_x: NdarrayOrTensor = root_sum_of_squares_t(x, spatial_dim) # type: ignore
+ return rss_x
diff --git a/monai/apps/reconstruction/networks/__init__.py b/monai/apps/reconstruction/networks/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/apps/reconstruction/networks/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/apps/reconstruction/networks/blocks/__init__.py b/monai/apps/reconstruction/networks/blocks/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/apps/reconstruction/networks/blocks/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/apps/reconstruction/networks/blocks/varnetblock.py b/monai/apps/reconstruction/networks/blocks/varnetblock.py
new file mode 100644
index 0000000000..daaa3efbf3
--- /dev/null
+++ b/monai/apps/reconstruction/networks/blocks/varnetblock.py
@@ -0,0 +1,79 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import torch
+import torch.nn as nn
+from torch import Tensor
+
+from monai.apps.reconstruction.networks.nets.utils import sensitivity_map_expand, sensitivity_map_reduce
+
+
+class VarNetBlock(nn.Module):
+ """
+ A variational block based on Sriram et. al., "End-to-end variational networks for accelerated MRI reconstruction".
+ It applies data consistency and refinement to the intermediate kspace and combines those results.
+
+ Modified and adopted from: https://github.com/facebookresearch/fastMRI
+
+ Args:
+ refinement_model: the model used for refinement (typically a U-Net but can be any deep learning model
+ that performs well when the input and output are in image domain (e.g., a convolutional network).
+ spatial_dims: is 2 for 2D data and is 3 for 3D data
+ """
+
+ def __init__(self, refinement_model: nn.Module, spatial_dims: int = 2):
+ super().__init__()
+ self.model = refinement_model
+ self.spatial_dims = spatial_dims
+ self.dc_weight = nn.Parameter(torch.ones(1)) # learned scalar as the multiplier of the DC block
+
+ buffer_shape = [1 for _ in range(spatial_dims + 3)] # 3 denotes the batch, channel, and real/complex dimensions
+ self.register_buffer("zeros", torch.zeros(buffer_shape))
+
+ def soft_dc(self, x: Tensor, ref_kspace: Tensor, mask: Tensor) -> Tensor:
+ """
+ Applies data consistency to input x. Suppose x is an intermediate estimate of the kspace and ref_kspace
+ is the reference under-sampled measurement. This function returns mask * (x - ref_kspace). View this as the
+ residual between the original under-sampled kspace and the estimate given by the network.
+
+ Args:
+ x: 2D kspace (B,C,H,W,2) with the last dimension being 2 (for real/imaginary parts) and C denoting the
+ coil dimension. 3D data will have the shape (B,C,H,W,D,2).
+ ref_kspace: original under-sampled kspace with the same shape as x.
+ mask: the under-sampling mask with shape (1,1,1,W,1) for 2D data or (1,1,1,1,D,1) for 3D data.
+
+ Returns:
+ Output of DC block with the same shape as x
+ """
+ return torch.where(mask, x - ref_kspace, self.zeros) * self.dc_weight # type: ignore
+
+ def forward(self, current_kspace: Tensor, ref_kspace: Tensor, mask: Tensor, sens_maps: Tensor) -> Tensor:
+ """
+ Args:
+ current_kspace: Predicted kspace from the previous block. It's a 2D kspace (B,C,H,W,2)
+ with the last dimension being 2 (for real/imaginary parts) and C denoting the
+ coil dimension. 3D data will have the shape (B,C,H,W,D,2).
+ ref_kspace: reference kspace for applying data consistency (is the under-sampled kspace in MRI reconstruction).
+ Its shape is the same as current_kspace.
+ mask: the under-sampling mask with shape (1,1,1,W,1) for 2D data or (1,1,1,1,D,1) for 3D data.
+ sens_maps: coil sensitivity maps with the same shape as current_kspace
+
+ Returns:
+ Output of VarNetBlock with the same shape as current_kspace
+ """
+ dc_out = self.soft_dc(current_kspace, ref_kspace, mask) # output of DC block
+ refinement_out = sensitivity_map_expand(
+ self.model(sensitivity_map_reduce(current_kspace, sens_maps, spatial_dims=self.spatial_dims)),
+ sens_maps,
+ spatial_dims=self.spatial_dims,
+ ) # output of refinement model
+ output = current_kspace - dc_out - refinement_out
+ return output
diff --git a/monai/apps/reconstruction/networks/nets/__init__.py b/monai/apps/reconstruction/networks/nets/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/apps/reconstruction/networks/nets/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/apps/reconstruction/networks/nets/coil_sensitivity_model.py b/monai/apps/reconstruction/networks/nets/coil_sensitivity_model.py
new file mode 100644
index 0000000000..605ead743a
--- /dev/null
+++ b/monai/apps/reconstruction/networks/nets/coil_sensitivity_model.py
@@ -0,0 +1,139 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Optional, Sequence, Tuple, Union
+
+import torch
+import torch.nn as nn
+from torch import Tensor
+
+from monai.apps.reconstruction.mri_utils import root_sum_of_squares_t
+from monai.apps.reconstruction.networks.nets.complex_unet import ComplexUnet
+from monai.apps.reconstruction.networks.nets.utils import (
+ reshape_batch_channel_to_channel_dim,
+ reshape_channel_to_batch_dim,
+)
+from monai.networks.blocks.fft_utils_t import ifftn_centered_t
+
+
+class CoilSensitivityModel(nn.Module):
+ """
+ This class uses a convolutional model to learn coil sensitivity maps for multi-coil MRI reconstruction.
+ The convolutional model is :py:class:`monai.apps.reconstruction.networks.nets.complex_unet` by default
+ but can be specified by the user as well. Learning is done on the center of the under-sampled
+ kspace (that region is fully sampled).
+
+ The data being a (complex) 2-channel tensor is a requirement for using this model.
+
+ Modified and adopted from: https://github.com/facebookresearch/fastMRI
+
+ Args:
+ spatial_dims: number of spatial dimensions.
+ features: six integers as numbers of features. denotes number of channels in each layer.
+ act: activation type and arguments. Defaults to LeakyReLU.
+ norm: feature normalization type and arguments. Defaults to instance norm.
+ bias: whether to have a bias term in convolution blocks. Defaults to True.
+ dropout: dropout ratio. Defaults to 0.0.
+ upsample: upsampling mode, available options are
+ ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``.
+ coil_dim: coil dimension in the data
+ conv_net: the learning model used to estimate the coil sensitivity maps. default
+ is :py:class:`monai.apps.reconstruction.networks.nets.complex_unet`. The only
+ requirement on the model is to have 2 as input and output number of channels.
+ """
+
+ def __init__(
+ self,
+ spatial_dims: int = 2,
+ features: Sequence[int] = (32, 32, 64, 128, 256, 32),
+ act: Union[str, tuple] = ("LeakyReLU", {"negative_slope": 0.1, "inplace": True}),
+ norm: Union[str, tuple] = ("instance", {"affine": True}),
+ bias: bool = True,
+ dropout: Union[float, tuple] = 0.0,
+ upsample: str = "deconv",
+ coil_dim: int = 1,
+ conv_net: Optional[nn.Module] = None,
+ ):
+ super().__init__()
+ if conv_net is None:
+ self.conv_net = ComplexUnet(
+ spatial_dims=spatial_dims,
+ features=features,
+ act=act,
+ norm=norm,
+ bias=bias,
+ dropout=dropout,
+ upsample=upsample,
+ )
+ else:
+ # assume the first layer is convolutional and
+ # check whether in_channels == 2
+ params = [p.shape for p in conv_net.parameters()]
+ if params[0][1] != 2:
+ raise ValueError(f"in_channels should be 2 but it's {params[0][1]}.")
+ self.conv_net = conv_net # type: ignore
+ self.spatial_dims = spatial_dims
+ self.coil_dim = coil_dim
+
+ def get_fully_sampled_region(self, mask: Tensor) -> Tuple[int, int]:
+ """
+ Extracts the size of the fully-sampled part of the kspace. Note that when a kspace
+ is under-sampled, a part of its center is fully sampled. This part is called the Auto
+ Calibration Region (ACR). ACR is used for sensitivity map computation.
+
+ Args:
+ mask: the under-sampling mask of shape (..., S, 1) where S denotes the sampling dimension
+
+ Returns:
+ A tuple containing
+ (1) left index of the region
+ (2) right index of the region
+
+ Note:
+ Suppose the mask is of shape (1,1,20,1). If this function returns 8,12 as left and right
+ indices, then it means that the fully-sampled center region has size 4 starting from 8 to 12.
+ """
+ left = right = mask.shape[-2] // 2
+ while mask[..., right, :]:
+ right += 1
+
+ while mask[..., left, :]:
+ left -= 1
+
+ return left + 1, right
+
+ def forward(self, masked_kspace: Tensor, mask: Tensor) -> Tensor:
+ """
+ Args:
+ masked_kspace: the under-sampled kspace (which is the input measurement). Its shape
+ is (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data.
+ mask: the under-sampling mask with shape (1,1,1,W,1) for 2D data or (1,1,1,1,D,1) for 3D data.
+
+ Returns:
+ predicted coil sensitivity maps with shape (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data.
+ """
+ left, right = self.get_fully_sampled_region(mask)
+ num_low_freqs = right - left # size of the fully-sampled center
+
+ # take out the fully-sampled region and set the rest of the data to zero
+ x = torch.zeros_like(masked_kspace)
+ start = (mask.shape[-2] - num_low_freqs + 1) // 2 # this marks the start of center extraction
+ x[..., start : start + num_low_freqs, :] = masked_kspace[..., start : start + num_low_freqs, :]
+
+ # apply inverse fourier to the extracted fully-sampled data
+ x = ifftn_centered_t(x, spatial_dims=self.spatial_dims, is_complex=True)
+
+ x, b = reshape_channel_to_batch_dim(x) # shape of x will be (B*C,1,...)
+ x = self.conv_net(x)
+ x = reshape_batch_channel_to_channel_dim(x, b) # shape will be (B,C,...)
+ # normalize the maps
+ x = x / root_sum_of_squares_t(x, spatial_dim=self.coil_dim).unsqueeze(self.coil_dim) # type: ignore
+ return x
diff --git a/monai/apps/reconstruction/networks/nets/complex_unet.py b/monai/apps/reconstruction/networks/nets/complex_unet.py
new file mode 100644
index 0000000000..f34b1442ad
--- /dev/null
+++ b/monai/apps/reconstruction/networks/nets/complex_unet.py
@@ -0,0 +1,111 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Optional, Sequence, Union
+
+import torch.nn as nn
+from torch import Tensor
+
+from monai.apps.reconstruction.networks.nets.utils import (
+ complex_normalize,
+ divisible_pad_t,
+ inverse_divisible_pad_t,
+ reshape_channel_complex_to_last_dim,
+ reshape_complex_to_channel_dim,
+)
+from monai.networks.nets.basic_unet import BasicUNet
+
+
+class ComplexUnet(nn.Module):
+ """
+ This variant of U-Net handles complex-value input/output. It can be
+ used as a model to learn sensitivity maps in multi-coil MRI data. It is
+ built based on :py:class:`monai.networks.nets.BasicUNet` by default but the user
+ can input their convolutional model as well.
+ ComplexUnet also applies default normalization to the input which makes it more stable to train.
+
+ The data being a (complex) 2-channel tensor is a requirement for using this model.
+
+ Modified and adopted from: https://github.com/facebookresearch/fastMRI
+
+ Args:
+ spatial_dims: number of spatial dimensions.
+ features: six integers as numbers of features. denotes number of channels in each layer.
+ act: activation type and arguments. Defaults to LeakyReLU.
+ norm: feature normalization type and arguments. Defaults to instance norm.
+ bias: whether to have a bias term in convolution blocks. Defaults to True.
+ dropout: dropout ratio. Defaults to 0.0.
+ upsample: upsampling mode, available options are
+ ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``.
+ pad_factor: an integer denoting the number which each padded dimension will be divisible to.
+ For example, 16 means each dimension will be divisible by 16 after padding
+ conv_net: the learning model used inside the ComplexUnet. The default
+ is :py:class:`monai.networks.nets.basic_unet`. The only requirement on the model is to
+ have 2 as input and output number of channels.
+ """
+
+ def __init__(
+ self,
+ spatial_dims: int = 2,
+ features: Sequence[int] = (32, 32, 64, 128, 256, 32),
+ act: Union[str, tuple] = ("LeakyReLU", {"negative_slope": 0.1, "inplace": True}),
+ norm: Union[str, tuple] = ("instance", {"affine": True}),
+ bias: bool = True,
+ dropout: Union[float, tuple] = 0.0,
+ upsample: str = "deconv",
+ pad_factor: int = 16,
+ conv_net: Optional[nn.Module] = None,
+ ):
+ super().__init__()
+ if conv_net is None:
+ self.unet = BasicUNet(
+ spatial_dims=spatial_dims,
+ in_channels=2,
+ out_channels=2,
+ features=features,
+ act=act,
+ norm=norm,
+ bias=bias,
+ dropout=dropout,
+ upsample=upsample,
+ )
+ else:
+ # assume the first layer is convolutional and
+ # check whether in_channels == 2
+ params = [p.shape for p in conv_net.parameters()]
+ if params[0][1] != 2:
+ raise ValueError(f"in_channels should be 2 but it's {params[0][1]}.")
+ self.unet = conv_net
+ self.pad_factor = pad_factor
+
+ def forward(self, x: Tensor) -> Tensor:
+ """
+ Args:
+ x: input of shape (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data
+
+ Returns:
+ output of shape (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data
+ """
+ # suppose the input is 2D, the comment in front of each operator below shows the shape after that operator
+ x = reshape_complex_to_channel_dim(x) # x will be of shape (B,C*2,H,W)
+ x, mean, std = complex_normalize(x) # x will be of shape (B,C*2,H,W)
+ # pad input
+ x, padding_sizes = divisible_pad_t(
+ x, k=self.pad_factor
+ ) # x will be of shape (B,C*2,H',W') where H' and W' are for after padding
+
+ x = self.unet(x)
+ # inverse padding
+ x = inverse_divisible_pad_t(x, padding_sizes) # x will be of shape (B,C*2,H,W)
+
+ x = x * std + mean
+ x = reshape_channel_complex_to_last_dim(x) # x will be of shape (B,C,H,W,2)
+ return x
diff --git a/monai/apps/reconstruction/networks/nets/utils.py b/monai/apps/reconstruction/networks/nets/utils.py
new file mode 100644
index 0000000000..c8d122a3bb
--- /dev/null
+++ b/monai/apps/reconstruction/networks/nets/utils.py
@@ -0,0 +1,307 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+This script contains utility functions for developing new networks/blocks in PyTorch.
+"""
+
+import math
+from typing import Tuple
+
+from torch import Tensor
+from torch.nn import functional as F
+
+from monai.apps.reconstruction.complex_utils import complex_conj_t, complex_mul_t
+from monai.networks.blocks.fft_utils_t import fftn_centered_t, ifftn_centered_t
+
+
+def reshape_complex_to_channel_dim(x: Tensor) -> Tensor: # type: ignore
+ """
+ Swaps the complex dimension with the channel dimension so that the network treats real/imaginary
+ parts as two separate channels.
+
+ Args:
+ x: input of shape (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data
+
+ Returns:
+ output of shape (B,C*2,H,W) for 2D data or (B,C*2,H,W,D) for 3D data
+ """
+ if x.shape[-1] != 2:
+ raise ValueError(f"last dim must be 2, but x.shape[-1] is {x.shape[-1]}.")
+
+ if len(x.shape) == 5: # this is 2D
+ b, c, h, w, two = x.shape
+ return x.permute(0, 4, 1, 2, 3).contiguous().view(b, 2 * c, h, w)
+
+ elif len(x.shape) == 6: # this is 3D
+ b, c, h, w, d, two = x.shape
+ return x.permute(0, 5, 1, 2, 3, 4).contiguous().view(b, 2 * c, h, w, d)
+
+ else:
+ raise ValueError(f"only 2D (B,C,H,W,2) and 3D (B,C,H,W,D,2) data are supported but x has shape {x.shape}")
+
+
+def reshape_channel_complex_to_last_dim(x: Tensor) -> Tensor: # type: ignore
+ """
+ Swaps the complex dimension with the channel dimension so that the network output has 2 as its last dimension
+
+ Args:
+ x: input of shape (B,C*2,H,W) for 2D data or (B,C*2,H,W,D) for 3D data
+
+ Returns:
+ output of shape (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data
+ """
+ if x.shape[1] % 2 != 0:
+ raise ValueError(f"channel dimension should be even but ({x.shape[1]}) is odd.")
+
+ if len(x.shape) == 4: # this is 2D
+ b, c2, h, w = x.shape # c2 means c*2
+ c = c2 // 2
+ return x.view(b, 2, c, h, w).permute(0, 2, 3, 4, 1)
+
+ elif len(x.shape) == 5: # this is 3D
+ b, c2, h, w, d = x.shape # c2 means c*2
+ c = c2 // 2
+ return x.view(b, 2, c, h, w, d).permute(0, 2, 3, 4, 5, 1)
+
+ else:
+ raise ValueError(f"only 2D (B,C*2,H,W) and 3D (B,C*2,H,W,D) data are supported but x has shape {x.shape}")
+
+
+def reshape_channel_to_batch_dim(x: Tensor) -> Tuple[Tensor, int]:
+ """
+ Combines batch and channel dimensions.
+
+ Args:
+ x: input of shape (B,C,H,W,2) for 2D data or (B,C,H,W,D,2) for 3D data
+
+ Returns:
+ A tuple containing:
+ (1) output of shape (B*C,1,...)
+ (2) batch size
+ """
+
+ if len(x.shape) == 5: # this is 2D
+ b, c, h, w, two = x.shape
+ return x.contiguous().view(b * c, 1, h, w, two), b
+
+ elif len(x.shape) == 6: # this is 3D
+ b, c, h, w, d, two = x.shape
+ return x.contiguous().view(b * c, 1, h, w, d, two), b
+
+ else:
+ raise ValueError(f"only 2D (B,C,H,W,2) and 3D (B,C,H,W,D,2) data are supported but x has shape {x.shape}")
+
+
+def reshape_batch_channel_to_channel_dim(x: Tensor, batch_size: int) -> Tensor:
+ """
+ Detaches batch and channel dimensions.
+
+ Args:
+ x: input of shape (B*C,1,H,W,2) for 2D data or (B*C,1,H,W,D,2) for 3D data
+ batch_size: batch size
+
+ Returns:
+ output of shape (B,C,...)
+ """
+ if len(x.shape) == 5: # this is 2D
+ bc, one, h, w, two = x.shape # bc represents B*C
+ c = bc // batch_size
+ return x.view(batch_size, c, h, w, two)
+
+ elif len(x.shape) == 6: # this is 3D
+ bc, one, h, w, d, two = x.shape # bc represents B*C
+ c = bc // batch_size
+ return x.view(batch_size, c, h, w, d, two)
+
+ else:
+ raise ValueError(f"only 2D (B*C,1,H,W,2) and 3D (B*C,1,H,W,D,2) data are supported but x has shape {x.shape}")
+
+
+def complex_normalize(x: Tensor) -> Tuple[Tensor, Tensor, Tensor]: # type: ignore
+ """
+ Performs layer mean-std normalization for complex data. Normalization is done for each batch member
+ along each part (part refers to real and imaginary parts), separately.
+
+ Args:
+ x: input of shape (B,C,H,W) for 2D data or (B,C,H,W,D) for 3D data
+
+ Returns:
+ A tuple containing
+ (1) normalized output of shape (B,C,H,W) for 2D data or (B,C,H,W,D) for 3D data
+ (2) mean
+ (3) std
+ """
+ if len(x.shape) == 4: # this is 2D
+ b, c, h, w = x.shape
+ x = x.contiguous().view(b, 2, c // 2 * h * w)
+ mean = x.mean(dim=2).view(b, 2, 1, 1, 1).expand(b, 2, c // 2, 1, 1).contiguous().view(b, c, 1, 1)
+ std = x.std(dim=2, unbiased=False).view(b, 2, 1, 1, 1).expand(b, 2, c // 2, 1, 1).contiguous().view(b, c, 1, 1)
+ x = x.view(b, c, h, w)
+ return (x - mean) / std, mean, std
+
+ elif len(x.shape) == 5: # this is 3D
+ b, c, h, w, d = x.shape
+ x = x.contiguous().view(b, 2, c // 2 * h * w * d)
+ mean = x.mean(dim=2).view(b, 2, 1, 1, 1, 1).expand(b, 2, c // 2, 1, 1, 1).contiguous().view(b, c, 1, 1, 1)
+ std = (
+ x.std(dim=2, unbiased=False)
+ .view(b, 2, 1, 1, 1, 1)
+ .expand(b, 2, c // 2, 1, 1, 1)
+ .contiguous()
+ .view(b, c, 1, 1, 1)
+ )
+ x = x.view(b, c, h, w, d)
+ return (x - mean) / std, mean, std # type: ignore
+
+ else:
+ raise ValueError(f"only 2D (B,C,H,W) and 3D (B,C,H,W,D) data are supported but x has shape {x.shape}")
+
+
+def divisible_pad_t(
+ x: Tensor, k: int = 16
+) -> Tuple[Tensor, Tuple[Tuple[int, int], Tuple[int, int], Tuple[int, int], int, int, int]]: # type: ignore
+ """
+ Pad input to feed into the network (torch script compatible)
+
+ Args:
+ x: input of shape (B,C,H,W) for 2D data or (B,C,H,W,D) for 3D data
+ k: padding factor. each padded dimension will be divisible by k.
+
+ Returns:
+ A tuple containing
+ (1) padded input
+ (2) pad sizes (in order to reverse padding if needed)
+
+ Example:
+ .. code-block:: python
+
+ import torch
+
+ # 2D data
+ x = torch.ones([3,2,50,70])
+ x_pad,padding_sizes = divisible_pad_t(x, k=16)
+ # the following line should print (3, 2, 64, 80)
+ print(x_pad.shape)
+
+ # 3D data
+ x = torch.ones([3,2,50,70,80])
+ x_pad,padding_sizes = divisible_pad_t(x, k=16)
+ # the following line should print (3, 2, 64, 80, 80)
+ print(x_pad.shape)
+
+ """
+ if len(x.shape) == 4: # this is 2D
+ b, c, h, w = x.shape
+ w_mult = ((w - 1) | (k - 1)) + 1 # OR with (k-1) and then +1 makes sure padding is divisible by k
+ h_mult = ((h - 1) | (k - 1)) + 1
+ w_pad = floor_ceil((w_mult - w) / 2)
+ h_pad = floor_ceil((h_mult - h) / 2)
+ x = F.pad(x, w_pad + h_pad)
+ # dummy values for the 3rd spatial dimension
+ d_mult = -1
+ d_pad = (-1, -1)
+ pad_sizes = (h_pad, w_pad, d_pad, h_mult, w_mult, d_mult)
+
+ elif len(x.shape) == 5: # this is 3D
+ b, c, h, w, d = x.shape
+ w_mult = ((w - 1) | (k - 1)) + 1
+ h_mult = ((h - 1) | (k - 1)) + 1
+ d_mult = ((d - 1) | (k - 1)) + 1
+ w_pad = floor_ceil((w_mult - w) / 2)
+ h_pad = floor_ceil((h_mult - h) / 2)
+ d_pad = floor_ceil((d_mult - d) / 2)
+ x = F.pad(x, d_pad + w_pad + h_pad)
+ pad_sizes = (h_pad, w_pad, d_pad, h_mult, w_mult, d_mult)
+
+ else:
+ raise ValueError(f"only 2D (B,C,H,W) and 3D (B,C,H,W,D) data are supported but x has shape {x.shape}")
+
+ return x, pad_sizes
+
+
+def inverse_divisible_pad_t(
+ x: Tensor, pad_sizes: Tuple[Tuple[int, int], Tuple[int, int], Tuple[int, int], int, int, int]
+) -> Tensor: # type: ignore
+ """
+ De-pad network output to match its original shape
+
+ Args:
+ x: input of shape (B,C,H,W) for 2D data or (B,C,H,W,D) for 3D data
+ pad_sizes: padding values
+
+ Returns:
+ de-padded input
+ """
+ h_pad, w_pad, d_pad, h_mult, w_mult, d_mult = pad_sizes
+
+ if len(x.shape) == 4: # this is 2D
+ return x[..., h_pad[0] : h_mult - h_pad[1], w_pad[0] : w_mult - w_pad[1]]
+
+ elif len(x.shape) == 5: # this is 3D
+ return x[..., h_pad[0] : h_mult - h_pad[1], w_pad[0] : w_mult - w_pad[1], d_pad[0] : d_mult - d_pad[1]]
+
+ else:
+ raise ValueError(f"only 2D (B,C,H,W) and 3D (B,C,H,W,D) data are supported but x has shape {x.shape}")
+
+
+def floor_ceil(n: float) -> Tuple[int, int]:
+ """
+ Returns floor and ceil of the input
+
+ Args:
+ n: input number
+
+ Returns:
+ A tuple containing:
+ (1) floor(n)
+ (2) ceil(n)
+ """
+ return math.floor(n), math.ceil(n)
+
+
+def sensitivity_map_reduce(kspace: Tensor, sens_maps: Tensor, spatial_dims: int = 2) -> Tensor:
+ """
+ Reduces coil measurements to a corresponding image based on the given sens_maps. Let's say there
+ are C coil measurements inside kspace, then this function multiplies the conjugate of each coil sensitivity map with the
+ corresponding coil image. The result of this process will be C images. Summing those images together gives the
+ resulting "reduced image."
+
+ Args:
+ kspace: 2D kspace (B,C,H,W,2) with the last dimension being 2 (for real/imaginary parts) and C denoting the
+ coil dimension. 3D data will have the shape (B,C,H,W,D,2).
+ sens_maps: sensitivity maps of the same shape as input x.
+ spatial_dims: is 2 for 2D data and is 3 for 3D data
+
+ Returns:
+ reduction of x to (B,1,H,W,2) for 2D data or (B,1,H,W,D,2) for 3D data.
+ """
+ img = ifftn_centered_t(kspace, spatial_dims=spatial_dims, is_complex=True) # inverse fourier transform
+ return complex_mul_t(img, complex_conj_t(sens_maps)).sum(dim=1, keepdim=True) # type: ignore
+
+
+def sensitivity_map_expand(img: Tensor, sens_maps: Tensor, spatial_dims: int = 2) -> Tensor:
+ """
+ Expands an image to its corresponding coil images based on the given sens_maps. Let's say there
+ are C coils. This function multiples image img with each coil sensitivity map in sens_maps and stacks
+ the resulting C coil images along the channel dimension which is reserved for coils.
+
+ Args:
+ img: 2D image (B,1,H,W,2) with the last dimension being 2 (for real/imaginary parts). 3D data will have
+ the shape (B,1,H,W,D,2).
+ sens_maps: Sensitivity maps for combining coil images. The shape is (B,C,H,W,2) for 2D data
+ or (B,C,H,W,D,2) for 3D data (C denotes the coil dimension).
+ spatial_dims: is 2 for 2D data and is 3 for 3D data
+
+ Returns:
+ Expansion of x to (B,C,H,W,2) for 2D data and (B,C,H,W,D,2) for 3D data. The output is transferred
+ to the frquency domain to yield coil measurements.
+ """
+ return fftn_centered_t(complex_mul_t(img, sens_maps), spatial_dims=spatial_dims, is_complex=True) # type: ignore
diff --git a/monai/apps/reconstruction/networks/nets/varnet.py b/monai/apps/reconstruction/networks/nets/varnet.py
new file mode 100644
index 0000000000..7402dea826
--- /dev/null
+++ b/monai/apps/reconstruction/networks/nets/varnet.py
@@ -0,0 +1,75 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import torch.nn as nn
+from torch import Tensor
+
+from monai.apps.reconstruction.complex_utils import complex_abs_t
+from monai.apps.reconstruction.mri_utils import root_sum_of_squares_t
+from monai.apps.reconstruction.networks.blocks.varnetblock import VarNetBlock
+from monai.networks.blocks.fft_utils_t import ifftn_centered_t
+
+
+class VariationalNetworkModel(nn.Module):
+ """
+ The end-to-end variational network (or simply e2e-VarNet) based on Sriram et. al., "End-to-end variational
+ networks for accelerated MRI reconstruction".
+ It comprises several cascades each consisting of refinement and data consistency steps. The network takes in
+ the under-sampled kspace and estimates the ground-truth reconstruction.
+
+ Modified and adopted from: https://github.com/facebookresearch/fastMRI
+
+ Args:
+ coil_sensitivity_model: A convolutional model for learning coil sensitivity maps. An example is
+ :py:class:`monai.apps.reconstruction.networks.nets.coil_sensitivity_model.CoilSensitivityModel`.
+ refinement_model: A convolutional network used in the refinement step of e2e-VarNet. An example
+ is :py:class:`monai.apps.reconstruction.networks.nets.complex_unet.ComplexUnet`.
+ num_cascades: Number of cascades. Each cascade is a
+ :py:class:`monai.apps.reconstruction.networks.blocks.varnetblock.VarNetBlock` which consists of
+ refinement and data consistency steps.
+ spatial_dims: number of spatial dimensions.
+ """
+
+ def __init__(
+ self,
+ coil_sensitivity_model: nn.Module,
+ refinement_model: nn.Module,
+ num_cascades: int = 12,
+ spatial_dims: int = 2,
+ ):
+ super().__init__()
+ self.coil_sensitivity_model = coil_sensitivity_model
+ self.cascades = nn.ModuleList([VarNetBlock(refinement_model) for i in range(num_cascades)])
+ self.spatial_dims = spatial_dims
+
+ def forward(self, masked_kspace: Tensor, mask: Tensor) -> Tensor:
+ """
+ Args:
+ masked_kspace: The under-sampled kspace. It's a 2D kspace (B,C,H,W,2)
+ with the last dimension being 2 (for real/imaginary parts) and C denoting the
+ coil dimension. 3D data will have the shape (B,C,H,W,D,2).
+ mask: The under-sampling mask with shape (1,1,1,W,1) for 2D data or (1,1,1,1,D,1) for 3D data.
+
+ Returns:
+ The reconstructed image which is the root sum of squares (rss) of the absolute value
+ of the inverse fourier of the predicted kspace (note that rss combines coil images into one image).
+ """
+ sensitivity_maps = self.coil_sensitivity_model(masked_kspace, mask) # shape is simlar to masked_kspace
+ kspace_pred = masked_kspace.clone()
+
+ for cascade in self.cascades:
+ kspace_pred = cascade(kspace_pred, masked_kspace, mask, sensitivity_maps)
+
+ output_image = root_sum_of_squares_t(
+ complex_abs_t(ifftn_centered_t(kspace_pred, spatial_dims=self.spatial_dims)),
+ spatial_dim=1, # 1 is for C which is the coil dimension
+ ) # shape is (B,H,W) for 2D and (B,H,W,D) for 3D data.
+ return output_image # type: ignore
diff --git a/monai/apps/reconstruction/transforms/__init__.py b/monai/apps/reconstruction/transforms/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/apps/reconstruction/transforms/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/apps/reconstruction/transforms/array.py b/monai/apps/reconstruction/transforms/array.py
new file mode 100644
index 0000000000..660eab396b
--- /dev/null
+++ b/monai/apps/reconstruction/transforms/array.py
@@ -0,0 +1,288 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from abc import abstractmethod
+from typing import Sequence
+
+import numpy as np
+from torch import Tensor
+
+from monai.apps.reconstruction.complex_utils import complex_abs, convert_to_tensor_complex
+from monai.apps.reconstruction.mri_utils import root_sum_of_squares
+from monai.config.type_definitions import NdarrayOrTensor
+from monai.data.fft_utils import ifftn_centered
+from monai.transforms.transform import RandomizableTransform
+from monai.utils.enums import TransformBackends
+from monai.utils.type_conversion import convert_to_tensor
+
+
+class KspaceMask(RandomizableTransform):
+ """
+ A basic class for under-sampling mask setup. It provides common
+ features for under-sampling mask generators.
+ For example, RandomMaskFunc and EquispacedMaskFunc (two mask
+ transform objects defined right after this module)
+ both inherit MaskFunc to properly setup properties like the
+ acceleration factor.
+ """
+
+ def __init__(
+ self,
+ center_fractions: Sequence[float],
+ accelerations: Sequence[float],
+ spatial_dims: int = 2,
+ is_complex: bool = True,
+ ):
+ """
+ Args:
+ center_fractions: Fraction of low-frequency columns to be retained.
+ If multiple values are provided, then one of these numbers
+ is chosen uniformly each time.
+ accelerations: Amount of under-sampling. This should have the
+ same length as center_fractions. If multiple values are
+ provided, then one of these is chosen uniformly each time.
+ spatial_dims: Number of spatial dims (e.g., it's 2 for a 2D data;
+ it's also 2 for psuedo-3D datasets like the fastMRI dataset).
+ The last spatial dim is selected for sampling. For the fastMRI
+ dataset, k-space has the form (...,num_slices,num_coils,H,W)
+ and sampling is done along W. For a general 3D data with the
+ shape (...,num_coils,H,W,D), sampling is done along D.
+ is_complex: if True, then the last dimension will be reserved for
+ real/imaginary parts.
+ """
+ if len(center_fractions) != len(accelerations):
+ raise ValueError(
+ "Number of center fractions \
+ should match number of accelerations"
+ )
+
+ self.center_fractions = center_fractions
+ self.accelerations = accelerations
+ self.spatial_dims = spatial_dims
+ self.is_complex = is_complex
+
+ @abstractmethod
+ def __call__(self, kspace: NdarrayOrTensor):
+ """
+ This is an extra instance to allow for defining new mask generators.
+ For creating other mask transforms, define a new class and simply
+ override __call__. See an example of this in
+ :py:class:`monai.apps.reconstruction.transforms.array.RandomKspacemask`.
+
+ Args:
+ kspace: The input k-space data. The shape is (...,num_coils,H,W,2)
+ for complex 2D inputs and (...,num_coils,H,W,D) for real 3D
+ data.
+ """
+ raise NotImplementedError
+
+ def randomize_choose_acceleration(self) -> Sequence[float]:
+ """
+ If multiple values are provided for center_fractions and
+ accelerations, this function selects one value uniformly
+ for each training/test sample.
+
+ Returns:
+ A tuple containing
+ (1) center_fraction: chosen fraction of center kspace
+ lines to exclude from under-sampling
+ (2) acceleration: chosen acceleration factor
+ """
+ choice = self.R.randint(0, len(self.accelerations))
+ center_fraction = self.center_fractions[choice]
+ acceleration = self.accelerations[choice]
+ return center_fraction, acceleration
+
+
+class RandomKspaceMask(KspaceMask):
+ """
+ This k-space mask transform under-samples the k-space according to a
+ random sampling pattern. Precisely, it uniformly selects a subset of
+ columns from the input k-space data. If the k-space data has N columns,
+ the mask picks out:
+
+ 1. N_low_freqs = (N * center_fraction) columns in the center
+ corresponding to low-frequencies
+
+ 2. The other columns are selected uniformly at random with a probability
+ equal to:
+ prob = (N / acceleration - N_low_freqs) / (N - N_low_freqs).
+ This ensures that the expected number of columns selected is equal to
+ (N / acceleration)
+
+ It is possible to use multiple center_fractions and accelerations,
+ in which case one possible (center_fraction, acceleration) is chosen
+ uniformly at random each time the transform is called.
+
+ Example:
+ If accelerations = [4, 8] and center_fractions = [0.08, 0.04],
+ then there is a 50% probability that 4-fold acceleration with 8%
+ center fraction is selected and a 50% probability that 8-fold
+ acceleration with 4% center fraction is selected.
+
+ Modified and adopted from:
+ https://github.com/facebookresearch/fastMRI/tree/master/fastmri
+ """
+
+ backend = [TransformBackends.TORCH]
+
+ def __call__(self, kspace: NdarrayOrTensor) -> Sequence[Tensor]:
+ """
+ Args:
+ kspace: The input k-space data. The shape is (...,num_coils,H,W,2)
+ for complex 2D inputs and (...,num_coils,H,W,D) for real 3D
+ data. The last spatial dim is selected for sampling. For the
+ fastMRI dataset, k-space has the form
+ (...,num_slices,num_coils,H,W) and sampling is done along W.
+ For a general 3D data with the shape (...,num_coils,H,W,D),
+ sampling is done along D.
+
+ Returns:
+ A tuple containing
+ (1) the under-sampled kspace
+ (2) absolute value of the inverse fourier of the under-sampled kspace
+ """
+ kspace_t = convert_to_tensor_complex(kspace)
+ spatial_size = kspace_t.shape
+ num_cols = spatial_size[-1]
+ if self.is_complex: # for complex data
+ num_cols = spatial_size[-2]
+
+ center_fraction, acceleration = self.randomize_choose_acceleration()
+
+ # Create the mask
+ num_low_freqs = int(round(num_cols * center_fraction))
+ prob = (num_cols / acceleration - num_low_freqs) / (num_cols - num_low_freqs)
+ mask = self.R.uniform(size=num_cols) < prob
+ pad = (num_cols - num_low_freqs + 1) // 2
+ mask[pad : pad + num_low_freqs] = True
+
+ # Reshape the mask
+ mask_shape = [1 for _ in spatial_size]
+ if self.is_complex:
+ mask_shape[-2] = num_cols
+ else:
+ mask_shape[-1] = num_cols
+
+ mask = convert_to_tensor(mask.reshape(*mask_shape).astype(np.float32))
+
+ # under-sample the ksapce
+ masked = mask * kspace_t
+ masked_kspace: Tensor = convert_to_tensor(masked)
+ self.mask = mask
+
+ # compute inverse fourier of the masked kspace
+ masked_kspace_ifft: Tensor = convert_to_tensor(
+ complex_abs(ifftn_centered(masked_kspace, spatial_dims=self.spatial_dims, is_complex=self.is_complex))
+ )
+ # combine coil images (it is assumed that the coil dimension is
+ # the first dimension before spatial dimensions)
+ masked_kspace_ifft_rss: Tensor = convert_to_tensor(
+ root_sum_of_squares(masked_kspace_ifft, spatial_dim=-self.spatial_dims - 1)
+ )
+ return masked_kspace, masked_kspace_ifft_rss
+
+
+class EquispacedKspaceMask(KspaceMask):
+ """
+ This k-space mask transform under-samples the k-space according to an
+ equi-distant sampling pattern. Precisely, it selects an equi-distant
+ subset of columns from the input k-space data. If the k-space data has N
+ columns, the mask picks out:
+
+ 1. N_low_freqs = (N * center_fraction) columns in the center corresponding
+ to low-frequencies
+
+ 2. The other columns are selected with equal spacing at a proportion that
+ reaches the desired acceleration rate taking into consideration the number
+ of low frequencies. This ensures that the expected number of columns
+ selected is equal to (N / acceleration)
+
+ It is possible to use multiple center_fractions and accelerations, in
+ which case one possible (center_fraction, acceleration) is chosen
+ uniformly at random each time the EquispacedMaskFunc object is called.
+
+ Example:
+ If accelerations = [4, 8] and center_fractions = [0.08, 0.04],
+ then there is a 50% probability that 4-fold acceleration with 8%
+ center fraction is selected and a 50% probability that 8-fold
+ acceleration with 4% center fraction is selected.
+
+ Modified and adopted from:
+ https://github.com/facebookresearch/fastMRI/tree/master/fastmri
+ """
+
+ backend = [TransformBackends.TORCH]
+
+ def __call__(self, kspace: NdarrayOrTensor) -> Sequence[Tensor]:
+ """
+ Args:
+ kspace: The input k-space data. The shape is (...,num_coils,H,W,2)
+ for complex 2D inputs and (...,num_coils,H,W,D) for real 3D
+ data. The last spatial dim is selected for sampling. For the
+ fastMRI multi-coil dataset, k-space has the form
+ (...,num_slices,num_coils,H,W) and sampling is done along W.
+ For a general 3D data with the shape (...,num_coils,H,W,D),
+ sampling is done along D.
+
+ Returns:
+ A tuple containing
+ (1) the under-sampled kspace
+ (2) absolute value of the inverse fourier of the under-sampled kspace
+ """
+ kspace_t = convert_to_tensor_complex(kspace)
+ spatial_size = kspace_t.shape
+ num_cols = spatial_size[-1]
+ if self.is_complex: # for complex data
+ num_cols = spatial_size[-2]
+
+ center_fraction, acceleration = self.randomize_choose_acceleration()
+ num_low_freqs = int(round(num_cols * center_fraction))
+
+ # Create the mask
+ mask = np.zeros(num_cols, dtype=np.float32)
+ pad = (num_cols - num_low_freqs + 1) // 2
+ mask[pad : pad + num_low_freqs] = True
+
+ # Determine acceleration rate by adjusting for the
+ # number of low frequencies
+ adjusted_accel = (acceleration * (num_low_freqs - num_cols)) / (num_low_freqs * acceleration - num_cols)
+ offset = self.R.randint(0, round(adjusted_accel))
+
+ accel_samples = np.arange(offset, num_cols - 1, adjusted_accel)
+ accel_samples = np.around(accel_samples).astype(np.uint)
+ mask[accel_samples] = True
+
+ # Reshape the mask
+ mask_shape = [1 for _ in spatial_size]
+ if self.is_complex:
+ mask_shape[-2] = num_cols
+ else:
+ mask_shape[-1] = num_cols
+
+ mask = convert_to_tensor(mask.reshape(*mask_shape).astype(np.float32))
+
+ # under-sample the ksapce
+ masked = mask * kspace_t
+ masked_kspace: Tensor = convert_to_tensor(masked)
+ self.mask = mask
+
+ # compute inverse fourier of the masked kspace
+ masked_kspace_ifft: Tensor = convert_to_tensor(
+ complex_abs(ifftn_centered(masked_kspace, spatial_dims=self.spatial_dims, is_complex=self.is_complex))
+ )
+ # combine coil images (it is assumed that the coil dimension is
+ # the first dimension before spatial dimensions)
+ masked_kspace_ifft_rss: Tensor = convert_to_tensor(
+ root_sum_of_squares(masked_kspace_ifft, spatial_dim=-self.spatial_dims - 1)
+ )
+ return masked_kspace, masked_kspace_ifft_rss
diff --git a/monai/apps/reconstruction/transforms/dictionary.py b/monai/apps/reconstruction/transforms/dictionary.py
new file mode 100644
index 0000000000..cf270b3a60
--- /dev/null
+++ b/monai/apps/reconstruction/transforms/dictionary.py
@@ -0,0 +1,370 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from typing import Dict, Hashable, Mapping, Optional, Sequence
+
+import numpy as np
+from numpy import ndarray
+from torch import Tensor
+
+from monai.apps.reconstruction.transforms.array import EquispacedKspaceMask, RandomKspaceMask
+from monai.config import DtypeLike, KeysCollection
+from monai.config.type_definitions import NdarrayOrTensor
+from monai.transforms.croppad.array import SpatialCrop
+from monai.transforms.croppad.dictionary import Cropd
+from monai.transforms.intensity.array import NormalizeIntensity
+from monai.transforms.transform import MapTransform, RandomizableTransform
+from monai.utils import FastMRIKeys
+from monai.utils.type_conversion import convert_to_tensor
+
+
+class ExtractDataKeyFromMetaKeyd(MapTransform):
+ """
+ Moves keys from meta to data. It is useful when a dataset of paired samples
+ is loaded and certain keys should be moved from meta to data.
+
+ Args:
+ keys: keys to be transferred from meta to data
+ meta_key: the meta key where all the meta-data is stored
+ allow_missing_keys: don't raise exception if key is missing
+
+ Example:
+ When the fastMRI dataset is loaded, "kspace" is stored in the data dictionary,
+ but the ground-truth image with the key "reconstruction_rss" is stored in the meta data.
+ In this case, ExtractDataKeyFromMetaKeyd moves "reconstruction_rss" to data.
+ """
+
+ def __init__(self, keys: KeysCollection, meta_key: str, allow_missing_keys: bool = False) -> None:
+ MapTransform.__init__(self, keys, allow_missing_keys)
+ self.meta_key = meta_key
+
+ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Tensor]:
+ """
+ Args:
+ data: is a dictionary containing (key,value) pairs from the
+ loaded dataset
+
+ Returns:
+ the new data dictionary
+ """
+ d = dict(data)
+ for key in self.keys:
+ if key in d[self.meta_key]:
+ d[key] = d[self.meta_key][key] # type: ignore
+ elif not self.allow_missing_keys:
+ raise KeyError(
+ f"Key `{key}` of transform `{self.__class__.__name__}` was missing in the meta data"
+ " and allow_missing_keys==False."
+ )
+ return d # type: ignore
+
+
+class RandomKspaceMaskd(RandomizableTransform, MapTransform):
+ """
+ Dictionary-based wrapper of :py:class:`monai.apps.reconstruction.transforms.array.RandomKspacemask`.
+ Other mask transforms can inherit from this class, for example:
+ :py:class:`monai.apps.reconstruction.transforms.dictionary.EquispacedKspaceMaskd`.
+
+ Args:
+ keys: keys of the corresponding items to be transformed.
+ See also: monai.transforms.MapTransform
+ center_fractions: Fraction of low-frequency columns to be retained.
+ If multiple values are provided, then one of these numbers is
+ chosen uniformly each time.
+ accelerations: Amount of under-sampling. This should have the
+ same length as center_fractions. If multiple values are provided,
+ then one of these is chosen uniformly each time.
+ spatial_dims: Number of spatial dims (e.g., it's 2 for a 2D data; it's
+ also 2 for psuedo-3D datasets like the fastMRI dataset).
+ The last spatial dim is selected for sampling. For the fastMRI
+ dataset, k-space has the form (...,num_slices,num_coils,H,W)
+ and sampling is done along W. For a general 3D data with the
+ shape (...,num_coils,H,W,D), sampling is done along D.
+ is_complex: if True, then the last dimension will be reserved
+ for real/imaginary parts.
+ allow_missing_keys: don't raise exception if key is missing.
+ """
+
+ backend = RandomKspaceMask.backend
+
+ def __init__(
+ self,
+ keys: KeysCollection,
+ center_fractions: Sequence[float],
+ accelerations: Sequence[float],
+ spatial_dims: int = 2,
+ is_complex: bool = True,
+ allow_missing_keys: bool = False,
+ ) -> None:
+ MapTransform.__init__(self, keys, allow_missing_keys)
+ self.masker = RandomKspaceMask(
+ center_fractions=center_fractions,
+ accelerations=accelerations,
+ spatial_dims=spatial_dims,
+ is_complex=is_complex,
+ )
+
+ def set_random_state(
+ self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None
+ ) -> "RandomKspaceMaskd":
+ super().set_random_state(seed, state)
+ self.masker.set_random_state(seed, state)
+ return self
+
+ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, Tensor]:
+ """
+ Args:
+ data: is a dictionary containing (key,value) pairs from the
+ loaded dataset
+
+ Returns:
+ the new data dictionary
+ """
+ d = dict(data)
+ for key in self.key_iterator(d):
+ d[key + "_masked"], d[key + "_masked_ifft"] = self.masker(d[key])
+ d[FastMRIKeys.MASK] = self.masker.mask # type: ignore
+ return d # type: ignore
+
+
+class EquispacedKspaceMaskd(RandomKspaceMaskd):
+ """
+ Dictionary-based wrapper of
+ :py:class:`monai.apps.reconstruction.transforms.array.EquispacedKspaceMask`.
+
+ Args:
+ keys: keys of the corresponding items to be transformed.
+ See also: monai.transforms.MapTransform
+ center_fractions: Fraction of low-frequency columns to be retained.
+ If multiple values are provided, then one of these numbers is
+ chosen uniformly each time.
+ accelerations: Amount of under-sampling. This should have the same
+ length as center_fractions. If multiple values are provided,
+ then one of these is chosen uniformly each time.
+ spatial_dims: Number of spatial dims (e.g., it's 2 for a 2D data;
+ it's also 2 for psuedo-3D datasets like the fastMRI dataset).
+ The last spatial dim is selected for sampling. For the fastMRI
+ dataset, k-space has the form (...,num_slices,num_coils,H,W)
+ and sampling is done along W. For a general 3D data with the shape
+ (...,num_coils,H,W,D), sampling is done along D.
+ is_complex: if True, then the last dimension will be reserved
+ for real/imaginary parts.
+ allow_missing_keys: don't raise exception if key is missing.
+ """
+
+ backend = EquispacedKspaceMask.backend
+
+ def __init__(
+ self,
+ keys: KeysCollection,
+ center_fractions: Sequence[float],
+ accelerations: Sequence[float],
+ spatial_dims: int = 2,
+ is_complex: bool = True,
+ allow_missing_keys: bool = False,
+ ) -> None:
+ MapTransform.__init__(self, keys, allow_missing_keys)
+ self.masker = EquispacedKspaceMask( # type: ignore
+ center_fractions=center_fractions,
+ accelerations=accelerations,
+ spatial_dims=spatial_dims,
+ is_complex=is_complex,
+ )
+
+ def set_random_state(
+ self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None
+ ) -> "EquispacedKspaceMaskd":
+ super().set_random_state(seed, state)
+ self.masker.set_random_state(seed, state)
+ return self
+
+
+class ReferenceBasedSpatialCropd(Cropd):
+ """
+ Dictionary-based wrapper of :py:class:`monai.transforms.SpatialCrop`.
+ This is similar to :py:class:`monai.transforms.SpatialCropd` which is a
+ general purpose cropper to produce sub-volume region of interest (ROI).
+ Their difference is that this transform does cropping according to a reference image.
+
+ If a dimension of the expected ROI size is larger than the input image size, will not crop that dimension.
+
+ Args:
+ keys: keys of the corresponding items to be transformed.
+ See also: :py:class:`monai.transforms.compose.MapTransform`
+ ref_key: key of the item to be used to crop items of "keys"
+ allow_missing_keys: don't raise exception if key is missing.
+
+ Example:
+ In an image reconstruction task, let keys=["image"] and ref_key=["target"].
+ Also, let data be the data dictionary. Then, ReferenceBasedSpatialCropd
+ center-crops data["image"] based on the spatial size of data["target"] by
+ calling :py:class:`monai.transforms.SpatialCrop`.
+ """
+
+ def __init__(self, keys: KeysCollection, ref_key: str, allow_missing_keys: bool = False) -> None:
+
+ super().__init__(keys, cropper=None, allow_missing_keys=allow_missing_keys) # type: ignore
+ self.ref_key = ref_key
+
+ def __call__(self, data: Mapping[Hashable, Tensor]) -> Dict[Hashable, Tensor]:
+ """
+ This transform can support to crop ND spatial (channel-first) data.
+ It also supports pseudo ND spatial data (e.g., (C,H,W) is a pseudo-3D
+ data point where C is the number of slices)
+
+ Args:
+ data: is a dictionary containing (key,value) pairs from
+ the loaded dataset
+
+ Returns:
+ the new data dictionary
+ """
+ d = dict(data)
+
+ # compute roi_size according to self.ref_key
+ roi_size = d[self.ref_key].shape[1:] # first dimension is not spatial (could be channel)
+
+ # crop keys
+ for key in self.key_iterator(d):
+ image = d[key]
+ roi_center = tuple(i // 2 for i in image.shape[1:])
+ cropper = SpatialCrop(roi_center=roi_center, roi_size=roi_size)
+ d[key] = convert_to_tensor(cropper(d[key]))
+ return d
+
+
+class ReferenceBasedNormalizeIntensityd(MapTransform):
+ """
+ Dictionary-based wrapper of
+ :py:class:`monai.transforms.NormalizeIntensity`.
+ This is similar to :py:class:`monai.transforms.NormalizeIntensityd`
+ and can normalize non-zero values or the entire image. The difference
+ is that this transform does normalization according to a reference image.
+
+ Args:
+ keys: keys of the corresponding items to be transformed.
+ See also: monai.transforms.MapTransform
+ ref_key: key of the item to be used to normalize items of "keys"
+ subtrahend: the amount to subtract by (usually the mean)
+ divisor: the amount to divide by (usually the standard deviation)
+ nonzero: whether only normalize non-zero values.
+ channel_wise: if True, calculate on each channel separately,
+ otherwise, calculate on the entire image directly. default
+ to False.
+ dtype: output data type, if None, same as input image. defaults
+ to float32.
+ allow_missing_keys: don't raise exception if key is missing.
+
+ Example:
+ In an image reconstruction task, let keys=["image", "target"] and ref_key=["image"].
+ Also, let data be the data dictionary. Then, ReferenceBasedNormalizeIntensityd
+ normalizes data["target"] and data["image"] based on the mean-std of data["image"] by
+ calling :py:class:`monai.transforms.NormalizeIntensity`.
+ """
+
+ backend = NormalizeIntensity.backend
+
+ def __init__(
+ self,
+ keys: KeysCollection,
+ ref_key: str,
+ subtrahend: Optional[NdarrayOrTensor] = None,
+ divisor: Optional[NdarrayOrTensor] = None,
+ nonzero: bool = False,
+ channel_wise: bool = False,
+ dtype: DtypeLike = np.float32,
+ allow_missing_keys: bool = False,
+ ) -> None:
+ super().__init__(keys, allow_missing_keys)
+ self.default_normalizer = NormalizeIntensity(subtrahend, divisor, nonzero, channel_wise, dtype)
+ self.ref_key = ref_key
+
+ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+ """
+ This transform can support to normalize ND spatial (channel-first) data.
+ It also supports pseudo ND spatial data (e.g., (C,H,W) is a pseudo-3D
+ data point where C is the number of slices)
+
+ Args:
+ data: is a dictionary containing (key,value) pairs from
+ the loaded dataset
+
+ Returns:
+ the new data dictionary
+ """
+ d = dict(data)
+
+ # prepare the normalizer based on self.ref_key
+ if self.default_normalizer.channel_wise:
+ # perform channel-wise normalization
+ # compute mean of each channel in the input for mean-std normalization
+ # subtrahend will have the same shape as image, for example (C,W,D) for a 2D data
+ if self.default_normalizer.subtrahend is None:
+ subtrahend = np.array(
+ [val.mean() if isinstance(val, ndarray) else val.float().mean().item() for val in d[self.ref_key]]
+ )
+ # users can define default values instead of mean
+ else:
+ subtrahend = self.default_normalizer.subtrahend # type: ignore
+
+ # compute std of each channel in the input for mean-std normalization
+ # will have the same shape as subtrahend
+ if self.default_normalizer.divisor is None:
+ divisor = np.array(
+ [
+ val.std() if isinstance(val, ndarray) else val.float().std(unbiased=False).item()
+ for val in d[self.ref_key]
+ ]
+ )
+ else:
+ # users can define default values instead of std
+ divisor = self.default_normalizer.divisor # type: ignore
+ else:
+ # perform ordinary normalization (not channel-wise)
+ # subtrahend will be a scalar and is the mean of d[self.ref_key], unless user specifies another value
+ if self.default_normalizer.subtrahend is None:
+ if isinstance(d[self.ref_key], ndarray):
+ subtrahend = d[self.ref_key].mean() # type: ignore
+ else:
+ subtrahend = d[self.ref_key].float().mean().item() # type: ignore
+ # users can define default values instead of mean
+ else:
+ subtrahend = self.default_normalizer.subtrahend # type: ignore
+
+ # divisor will be a scalar and is the std of d[self.ref_key], unless user specifies another value
+ if self.default_normalizer.divisor is None:
+ if isinstance(d[self.ref_key], ndarray):
+ divisor = d[self.ref_key].std() # type: ignore
+ else:
+ divisor = d[self.ref_key].float().std(unbiased=False).item() # type: ignore
+ else:
+ # users can define default values instead of std
+ divisor = self.default_normalizer.divisor # type: ignore
+
+ # this creates a new normalizer instance based on self.ref_key
+ normalizer = NormalizeIntensity(
+ subtrahend,
+ divisor,
+ self.default_normalizer.nonzero,
+ self.default_normalizer.channel_wise,
+ self.default_normalizer.dtype,
+ )
+
+ # save mean and std
+ d["mean"] = subtrahend
+ d["std"] = divisor
+
+ # perform normalization
+ for key in self.key_iterator(d):
+ d[key] = normalizer(d[key])
+
+ return d
diff --git a/monai/apps/tcia/__init__.py b/monai/apps/tcia/__init__.py
new file mode 100644
index 0000000000..bd266704b8
--- /dev/null
+++ b/monai/apps/tcia/__init__.py
@@ -0,0 +1,13 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .label_desc import TCIA_LABEL_DICT
+from .utils import download_tcia_series_instance, get_tcia_metadata, get_tcia_ref_uid, match_tcia_ref_uid_in_study
diff --git a/monai/apps/tcia/label_desc.py b/monai/apps/tcia/label_desc.py
new file mode 100644
index 0000000000..582f83154a
--- /dev/null
+++ b/monai/apps/tcia/label_desc.py
@@ -0,0 +1,49 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from typing import Dict
+
+__all__ = ["TCIA_LABEL_DICT"]
+
+
+TCIA_LABEL_DICT: Dict[str, Dict[str, int]] = {
+ "C4KC-KiTS": {"Kidney": 0, "Renal Tumor": 1},
+ "NSCLC-Radiomics": {
+ "Esophagus": 0,
+ "GTV-1": 1,
+ "Lungs-Total": 2,
+ "Spinal-Cord": 3,
+ "Lung-Left": 4,
+ "Lung-Right": 5,
+ "Heart": 6,
+ },
+ "NSCLC-Radiomics-Interobserver1": {
+ "GTV-1auto-1": 0,
+ "GTV-1auto-2": 1,
+ "GTV-1auto-3": 2,
+ "GTV-1auto-4": 3,
+ "GTV-1auto-5": 4,
+ "GTV-1vis-1": 5,
+ "GTV-1vis-2": 6,
+ "GTV-1vis-3": 7,
+ "GTV-1vis-4": 8,
+ "GTV-1vis-5": 9,
+ },
+ "QIN-PROSTATE-Repeatability": {"NormalROI_PZ_1": 0, "TumorROI_PZ_1": 1, "PeripheralZone": 2, "WholeGland": 3},
+ "PROSTATEx": {
+ "Prostate": 0,
+ "Peripheral zone of prostate": 1,
+ "Transition zone of prostate": 2,
+ "Distal prostatic urethra": 3,
+ "Anterior fibromuscular stroma of prostate": 4,
+ },
+}
diff --git a/monai/apps/tcia/utils.py b/monai/apps/tcia/utils.py
new file mode 100644
index 0000000000..ad95596223
--- /dev/null
+++ b/monai/apps/tcia/utils.py
@@ -0,0 +1,150 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+from typing import List, Optional
+
+import monai
+from monai.config.type_definitions import PathLike
+from monai.utils import optional_import
+
+requests_get, has_requests = optional_import("requests", name="get")
+pd, has_pandas = optional_import("pandas")
+
+__all__ = ["get_tcia_metadata", "download_tcia_series_instance", "get_tcia_ref_uid", "match_tcia_ref_uid_in_study"]
+
+BASE_URL = "https://services.cancerimagingarchive.net/nbia-api/services/v1/"
+
+
+def get_tcia_metadata(query: str, attribute: Optional[str] = None):
+ """
+ Achieve metadata of a public The Cancer Imaging Archive (TCIA) dataset.
+
+ This function makes use of The National Biomedical Imaging Archive (NBIA) REST APIs to access the metadata
+ of objects in the TCIA database.
+ Please refer to the following link for more details:
+ https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide
+
+ This function relies on `requests` package.
+
+ Args:
+ query: queries used to achieve the corresponding metadata. A query is consisted with query name and
+ query parameters. The format is like: ?&.
+ For example: "getSeries?Collection=C4KC-KiTS&Modality=SEG"
+ Please refer to the section of Image Metadata APIs in the link mentioned
+ above for more details.
+ attribute: Achieved metadata may contain multiple attributes, if specifying an attribute name, other attributes
+ will be ignored.
+
+ """
+
+ if not has_requests:
+ raise ValueError("requests package is necessary, please install it.")
+ full_url = f"{BASE_URL}{query}"
+ resp = requests_get(full_url)
+ resp.raise_for_status()
+ metadata_list: List = []
+ if len(resp.text) == 0:
+ return metadata_list
+ for d in resp.json():
+ if attribute is not None and attribute in d:
+ metadata_list.append(d[attribute])
+ else:
+ metadata_list.append(d)
+
+ return metadata_list
+
+
+def download_tcia_series_instance(
+ series_uid: str,
+ download_dir: PathLike,
+ output_dir: PathLike,
+ check_md5: bool = False,
+ hashes_filename: str = "md5hashes.csv",
+ progress: bool = True,
+):
+ """
+ Download a dicom series from a public The Cancer Imaging Archive (TCIA) dataset.
+ The downloaded compressed file will be stored in `download_dir`, and the uncompressed folder will be saved
+ in `output_dir`.
+
+ Args:
+ series_uid: SeriesInstanceUID of a dicom series.
+ download_dir: the path to store the downloaded compressed file. The full path of the file is:
+ `os.path.join(download_dir, f"{series_uid}.zip")`.
+ output_dir: target directory to save extracted dicom series.
+ check_md5: whether to download the MD5 hash values as well. If True, will check hash values for all images in
+ the downloaded dicom series.
+ hashes_filename: file that contains hashes.
+ progress: whether to display progress bar.
+
+ """
+ query_name = "getImageWithMD5Hash" if check_md5 else "getImage"
+ download_url = f"{BASE_URL}{query_name}?SeriesInstanceUID={series_uid}"
+
+ monai.apps.utils.download_and_extract(
+ url=download_url,
+ filepath=os.path.join(download_dir, f"{series_uid}.zip"),
+ output_dir=output_dir,
+ progress=progress,
+ )
+ if check_md5:
+ if not has_pandas:
+ raise ValueError("pandas package is necessary, please install it.")
+ hashes_df = pd.read_csv(os.path.join(output_dir, hashes_filename))
+ for dcm, md5hash in hashes_df.values:
+ monai.apps.utils.check_hash(filepath=os.path.join(output_dir, dcm), val=md5hash, hash_type="md5")
+
+
+def get_tcia_ref_uid(ds, find_sop: bool = False, ref_series_uid_tag=(0x0020, 0x000E), ref_sop_uid_tag=(0x0008, 0x1155)):
+ """
+ Achieve the referenced UID from the referenced Series Sequence for the input pydicom dataset object.
+ The referenced UID could be Series Instance UID or SOP Instance UID. The UID will be detected from
+ the data element of the input object. If the data element is a sequence, each dataset within the sequence
+ will be detected iteratively. The first detected UID will be returned.
+
+ Args:
+ ds: a pydicom dataset object.
+ find_sop: whether to achieve the referenced SOP Instance UID.
+ ref_series_uid_tag: tag of the referenced Series Instance UID.
+ ref_sop_uid_tag: tag of the referenced SOP Instance UID.
+
+ """
+ ref_uid_tag = ref_sop_uid_tag if find_sop else ref_series_uid_tag
+ output = ""
+
+ for elem in ds:
+ if elem.VR == "SQ":
+ for item in elem:
+ output = get_tcia_ref_uid(item, find_sop)
+ if elem.tag == ref_uid_tag:
+ return elem.value
+
+ return output
+
+
+def match_tcia_ref_uid_in_study(study_uid, ref_sop_uid):
+ """
+ Match the SeriesInstanceUID from all series in a study according to the input SOPInstanceUID.
+
+ Args:
+ study_uid: StudyInstanceUID.
+ ref_sop_uid: SOPInstanceUID.
+
+ """
+ series_list = get_tcia_metadata(query=f"getSeries?StudyInstanceUID={study_uid}", attribute="SeriesInstanceUID")
+ for series_id in series_list:
+ sop_id_list = get_tcia_metadata(
+ query=f"getSOPInstanceUIDs?SeriesInstanceUID={series_id}", attribute="SOPInstanceUID"
+ )
+ if ref_sop_uid in sop_id_list:
+ return series_id
+ return ""
diff --git a/monai/bundle/config_item.py b/monai/bundle/config_item.py
index b410e5f641..9726d75df9 100644
--- a/monai/bundle/config_item.py
+++ b/monai/bundle/config_item.py
@@ -176,6 +176,7 @@ class ConfigComponent(ConfigItem, Instantiable):
component doesn't explicitly depend on the other `ConfigItems` via its arguments,
but requires the dependencies to be instantiated/evaluated beforehand.
- ``"_disabled_"`` (optional): a flag to indicate whether to skip the instantiation.
+ - ``"_desc_"`` (optional): free text descriptions of the component for code readability.
Other fields in the config content are input arguments to the python module.
@@ -203,7 +204,7 @@ class ConfigComponent(ConfigItem, Instantiable):
"""
- non_arg_keys = {"_target_", "_disabled_", "_requires_"}
+ non_arg_keys = {"_target_", "_disabled_", "_requires_", "_desc_"}
def __init__(
self,
@@ -315,7 +316,7 @@ class ConfigExpression(ConfigItem):
"""
prefix = EXPR_KEY
- run_eval = not os.environ.get("MONAI_EVAL_EXPR", "1") == "0"
+ run_eval = os.environ.get("MONAI_EVAL_EXPR", "1") != "0"
def __init__(self, config: Any, id: str = "", globals: Optional[Dict] = None) -> None:
super().__init__(config=config, id=id)
diff --git a/monai/bundle/config_parser.py b/monai/bundle/config_parser.py
index 791759fd65..aa577c6b75 100644
--- a/monai/bundle/config_parser.py
+++ b/monai/bundle/config_parser.py
@@ -173,16 +173,32 @@ def get(self, id: str = "", default: Optional[Any] = None):
"""
try:
return self[id]
- except KeyError:
+ except (KeyError, IndexError): # Index error for integer indexing
return default
def set(self, config: Any, id: str = ""):
"""
- Set config by ``id``. See also :py:meth:`__setitem__`.
+ Set config by ``id``.
+
+ Args:
+ config: config to set at location ``id``.
+ id: id to specify the expected position. See also :py:meth:`__setitem__`.
"""
self[id] = config
+ def update(self, pairs: Dict[str, Any]):
+ """
+ Set the ``id`` and the corresponding config content in pairs, see also :py:meth:`__setitem__`.
+ For example, ``parser.update({"train#epoch": 100, "train#lr": 0.02})``
+
+ Args:
+ pairs: dictionary of `id` and config pairs.
+
+ """
+ for k, v in pairs.items():
+ self[k] = v
+
def __contains__(self, id: Union[str, int]) -> bool:
"""
Returns True if `id` is stored in this configuration.
@@ -193,7 +209,7 @@ def __contains__(self, id: Union[str, int]) -> bool:
try:
_ = self[id]
return True
- except KeyError:
+ except (KeyError, IndexError): # Index error for integer indexing
return False
def parse(self, reset: bool = True):
diff --git a/monai/bundle/reference_resolver.py b/monai/bundle/reference_resolver.py
index 7ed5ae3522..5ad46518fd 100644
--- a/monai/bundle/reference_resolver.py
+++ b/monai/bundle/reference_resolver.py
@@ -53,7 +53,7 @@ class ReferenceResolver:
# match a reference string, e.g. "@id#key", "@id#key#0", "@_target_#key"
id_matcher = re.compile(rf"{ref}(?:\w*)(?:{sep}\w*)*")
# if `allow_missing_reference` and can't find a reference ID, will just raise a warning and don't update the config
- allow_missing_reference = not os.environ.get("MONAI_ALLOW_MISSING_REFERENCE", "0") == "0"
+ allow_missing_reference = os.environ.get("MONAI_ALLOW_MISSING_REFERENCE", "0") != "0"
def __init__(self, items: Optional[Sequence[ConfigItem]] = None):
# save the items in a dictionary with the `ConfigItem.id` as key
diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py
index ff8144397f..c01498bb09 100644
--- a/monai/bundle/scripts.py
+++ b/monai/bundle/scripts.py
@@ -372,8 +372,7 @@ def run(
parser.read_meta(f=meta_file_)
# the rest key-values in the _args are to override config content
- for k, v in _args.items():
- parser[k] = v
+ parser.update(pairs=_args)
# resolve and execute the specified runner expressions in the config, return the results
return [parser.get_parsed_content(i, lazy=True, eval_expr=True, instantiate=True) for i in ensure_tuple(runner_id_)]
@@ -434,8 +433,9 @@ def verify_metadata(
validate(instance=metadata, schema=schema, **_args)
except ValidationError as e: # pylint: disable=E0712
# as the error message is very long, only extract the key information
- logger.info(re.compile(r".*Failed validating", re.S).findall(str(e))[0] + f" against schema `{url}`.")
- return
+ raise ValueError(
+ re.compile(r".*Failed validating", re.S).findall(str(e))[0] + f" against schema `{url}`."
+ ) from e
logger.info("metadata is verified with no error.")
diff --git a/monai/bundle/utils.py b/monai/bundle/utils.py
index e3eb362941..c3a8343163 100644
--- a/monai/bundle/utils.py
+++ b/monai/bundle/utils.py
@@ -56,7 +56,7 @@
"_target_": "Compose",
"transforms": [
{"_target_": "LoadImaged", "keys": "image"},
- {"_target_": "AddChanneld", "keys": "image"},
+ {"_target_": "EnsureChannelFirstd", "keys": "image"},
{"_target_": "ScaleIntensityd", "keys": "image"},
{"_target_": "EnsureTyped", "keys": "image", "device": "@device"},
],
diff --git a/monai/config/__init__.py b/monai/config/__init__.py
index 5f67ea6584..f494202a56 100644
--- a/monai/config/__init__.py
+++ b/monai/config/__init__.py
@@ -11,6 +11,7 @@
from .deviceconfig import (
USE_COMPILED,
+ USE_META_DICT,
IgniteInfo,
get_config_values,
get_gpu_info,
diff --git a/monai/config/deviceconfig.py b/monai/config/deviceconfig.py
index ad633a133d..87d46895aa 100644
--- a/monai/config/deviceconfig.py
+++ b/monai/config/deviceconfig.py
@@ -27,6 +27,8 @@
except (OptionalImportError, ImportError, AttributeError):
HAS_EXT = USE_COMPILED = False
+USE_META_DICT = os.environ.get("USE_META_DICT", "0") == "1" # set to True for compatibility, use meta dict.
+
psutil, has_psutil = optional_import("psutil")
psutil_version = psutil.__version__ if has_psutil else "NOT INSTALLED or UNKNOWN VERSION."
@@ -38,6 +40,7 @@
"print_gpu_info",
"print_debug_info",
"USE_COMPILED",
+ "USE_META_DICT",
"IgniteInfo",
]
@@ -89,7 +92,7 @@ def print_config(file=sys.stdout):
"""
for k, v in get_config_values().items():
print(f"{k} version: {v}", file=file, flush=True)
- print(f"MONAI flags: HAS_EXT = {HAS_EXT}, USE_COMPILED = {USE_COMPILED}")
+ print(f"MONAI flags: HAS_EXT = {HAS_EXT}, USE_COMPILED = {USE_COMPILED}, USE_META_DICT = {USE_META_DICT}")
print(f"MONAI rev id: {monai.__revision_id__}")
print(f"MONAI __file__: {monai.__file__}")
diff --git a/monai/data/__init__.py b/monai/data/__init__.py
index b7a160b3de..5f73366768 100644
--- a/monai/data/__init__.py
+++ b/monai/data/__init__.py
@@ -71,6 +71,7 @@
from .thread_buffer import ThreadBuffer, ThreadDataLoader
from .torchscript_utils import load_net_with_metadata, save_net_with_metadata
from .utils import (
+ PICKLE_KEY_SUFFIX,
affine_to_spacing,
compute_importance_map,
compute_shape_offset,
@@ -105,6 +106,7 @@
worker_init_fn,
zoom_affine,
)
+from .video_dataset import CameraDataset, VideoDataset, VideoFileDataset
from .wsi_datasets import MaskedPatchWSIDataset, PatchWSIDataset, SlidingPatchWSIDataset
from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader
@@ -112,22 +114,17 @@
from multiprocessing.reduction import ForkingPickler
def _rebuild_meta(cls, storage, metadata):
- storage_offset, size, stride, meta_obj, applied_operations = metadata
- t = cls([], meta=meta_obj, applied_operations=applied_operations, dtype=storage.dtype, device=storage.device)
+ storage_offset, size, stride, meta_dict = metadata
+ t = cls([], dtype=storage.dtype, device=storage.device)
t.set_(storage._untyped() if hasattr(storage, "_untyped") else storage, storage_offset, size, stride)
+ t.__dict__ = meta_dict
return t
def reduce_meta_tensor(meta_tensor):
storage = meta_tensor.storage()
if storage.is_cuda:
raise NotImplementedError("sharing CUDA metatensor across processes not implemented")
- metadata = (
- meta_tensor.storage_offset(),
- meta_tensor.size(),
- meta_tensor.stride(),
- meta_tensor.meta,
- meta_tensor.applied_operations,
- )
+ metadata = (meta_tensor.storage_offset(), meta_tensor.size(), meta_tensor.stride(), meta_tensor.__dict__)
return _rebuild_meta, (type(meta_tensor), storage, metadata)
ForkingPickler.register(MetaTensor, reduce_meta_tensor)
diff --git a/monai/data/box_utils.py b/monai/data/box_utils.py
index 65e5a06c74..afe8e11167 100644
--- a/monai/data/box_utils.py
+++ b/monai/data/box_utils.py
@@ -960,7 +960,7 @@ def spatial_crop_boxes(
"""
# convert numpy to tensor if needed
- boxes_t, *_ = convert_data_type(deepcopy(boxes), torch.Tensor)
+ boxes_t = convert_data_type(boxes, torch.Tensor)[0].clone()
# convert to float32 since torch.clamp_ does not support float16
boxes_t = boxes_t.to(dtype=COMPUTE_DTYPE)
@@ -972,8 +972,10 @@ def spatial_crop_boxes(
# makes sure the bounding boxes are within the patch
spatial_dims = get_spatial_dims(boxes=boxes, spatial_size=roi_end)
for axis in range(0, spatial_dims):
- boxes_t[:, axis].clamp_(min=roi_start_t[axis], max=roi_end_t[axis] - TO_REMOVE)
- boxes_t[:, axis + spatial_dims].clamp_(min=roi_start_t[axis], max=roi_end_t[axis] - TO_REMOVE)
+ boxes_t[:, axis] = boxes_t[:, axis].clamp(min=roi_start_t[axis], max=roi_end_t[axis] - TO_REMOVE)
+ boxes_t[:, axis + spatial_dims] = boxes_t[:, axis + spatial_dims].clamp(
+ min=roi_start_t[axis], max=roi_end_t[axis] - TO_REMOVE
+ )
boxes_t[:, axis] -= roi_start_t[axis]
boxes_t[:, axis + spatial_dims] -= roi_start_t[axis]
@@ -1061,7 +1063,7 @@ def non_max_suppression(
# initialize the list of picked indexes
pick = []
- idxs = torch.Tensor(list(range(0, boxes_sort.shape[0]))).to(torch.long)
+ idxs = torch.Tensor(list(range(0, boxes_sort.shape[0]))).to(device=boxes_t.device, dtype=torch.long)
# keep looping while some indexes still remain in the indexes list
while len(idxs) > 0:
diff --git a/monai/data/dataset.py b/monai/data/dataset.py
index 59a33bbf20..dd66d7ee14 100644
--- a/monai/data/dataset.py
+++ b/monai/data/dataset.py
@@ -31,7 +31,15 @@
from torch.utils.data import Subset
from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hashing
-from monai.transforms import Compose, Randomizable, ThreadUnsafe, Transform, apply_transform, convert_to_contiguous
+from monai.transforms import (
+ Compose,
+ Randomizable,
+ ThreadUnsafe,
+ Transform,
+ apply_transform,
+ convert_to_contiguous,
+ reset_ops_id,
+)
from monai.utils import MAX_SEED, deprecated_arg, get_seed, look_up_option, min_version, optional_import
from monai.utils.misc import first
@@ -191,9 +199,10 @@ class PersistentDataset(Dataset):
Note:
The input data must be a list of file paths and will hash them as cache keys.
- When loading persistent cache content, it can't guarantee the cached data matches current
- transform chain, so please make sure to use exactly the same non-random transforms and the
- args as the cache content, otherwise, it may cause unexpected errors.
+ The filenames of the cached files also try to contain the hash of the transforms. In this
+ fashion, `PersistentDataset` should be robust to changes in transforms. This, however, is
+ not guaranteed, so caution should be used when modifying transforms to avoid unexpected
+ errors. If in doubt, it is advisable to clear the cache directory.
"""
@@ -205,6 +214,8 @@ def __init__(
hash_func: Callable[..., bytes] = pickle_hashing,
pickle_module: str = "pickle",
pickle_protocol: int = DEFAULT_PROTOCOL,
+ hash_transform: Optional[Callable[..., bytes]] = None,
+ reset_ops_id: bool = True,
) -> None:
"""
Args:
@@ -232,6 +243,13 @@ def __init__(
pickle_protocol: can be specified to override the default protocol, default to `2`.
this arg is used by `torch.save`, for more details, please check:
https://pytorch.org/docs/stable/generated/torch.save.html#torch.save.
+ hash_transform: a callable to compute hash from the transform information when caching.
+ This may reduce errors due to transforms changing during experiments. Default to None (no hash).
+ Other options are `pickle_hashing` and `json_hashing` functions from `monai.data.utils`.
+ reset_ops_id: whether to set `TraceKeys.ID` to ``Tracekys.NONE``, defaults to ``True``.
+ When this is enabled, the traced transform instance IDs will be removed from the cached MetaTensors.
+ This is useful for skipping the transform instance checks when inverting applied operations
+ using the cached content and with re-created transform instances.
"""
if not isinstance(transform, Compose):
@@ -246,6 +264,30 @@ def __init__(
self.cache_dir.mkdir(parents=True, exist_ok=True)
if not self.cache_dir.is_dir():
raise ValueError("cache_dir must be a directory.")
+ self.transform_hash = ""
+ if hash_transform is not None:
+ self.set_transform_hash(hash_transform)
+ self.reset_ops_id = reset_ops_id
+
+ def set_transform_hash(self, hash_xform_func):
+ """Get hashable transforms, and then hash them. Hashable transforms
+ are deterministic transforms that inherit from `Transform`. We stop
+ at the first non-deterministic transform, or first that does not
+ inherit from MONAI's `Transform` class."""
+ hashable_transforms = []
+ for _tr in self.transform.flatten().transforms:
+ if isinstance(_tr, Randomizable) or not isinstance(_tr, Transform):
+ break
+ hashable_transforms.append(_tr)
+ # Try to hash. Fall back to a hash of their names
+ try:
+ self.transform_hash = hash_xform_func(hashable_transforms)
+ except TypeError as te:
+ if "is not JSON serializable" not in str(te):
+ raise te
+ names = "".join(tr.__class__.__name__ for tr in hashable_transforms)
+ self.transform_hash = hash_xform_func(names)
+ self.transform_hash = self.transform_hash.decode("utf-8")
def set_data(self, data: Sequence):
"""
@@ -276,6 +318,8 @@ def _pre_transform(self, item_transformed):
# this is to be consistent with CacheDataset even though it's not in a multi-thread situation.
_xform = deepcopy(_transform) if isinstance(_transform, ThreadUnsafe) else _transform
item_transformed = apply_transform(_xform, item_transformed)
+ if self.reset_ops_id:
+ reset_ops_id(item_transformed)
return item_transformed
def _post_transform(self, item_transformed):
@@ -317,14 +361,14 @@ def _cachecheck(self, item_transformed):
Warning:
The current implementation does not encode transform information as part of the
- hashing mechanism used for generating cache names. If the transforms applied are
- changed in any way, the objects in the cache dir will be invalid. The hash for the
- cache is ONLY dependant on the input filename paths.
+ hashing mechanism used for generating cache names when `hash_transform` is None.
+ If the transforms applied are changed in any way, the objects in the cache dir will be invalid.
"""
hashfile = None
if self.cache_dir is not None:
data_item_md5 = self.hash_func(item_transformed).decode("utf-8")
+ data_item_md5 += self.transform_hash
hashfile = self.cache_dir / f"{data_item_md5}.pt"
if hashfile is not None and hashfile.is_file(): # cache hit
@@ -380,6 +424,8 @@ def __init__(
hash_func: Callable[..., bytes] = pickle_hashing,
pickle_module: str = "pickle",
pickle_protocol: int = DEFAULT_PROTOCOL,
+ hash_transform: Optional[Callable[..., bytes]] = None,
+ reset_ops_id: bool = True,
) -> None:
"""
Args:
@@ -408,6 +454,13 @@ def __init__(
pickle_protocol: can be specified to override the default protocol, default to `2`.
this arg is used by `torch.save`, for more details, please check:
https://pytorch.org/docs/stable/generated/torch.save.html#torch.save.
+ hash_transform: a callable to compute hash from the transform information when caching.
+ This may reduce errors due to transforms changing during experiments. Default to None (no hash).
+ Other options are `pickle_hashing` and `json_hashing` functions from `monai.data.utils`.
+ reset_ops_id: whether to set `TraceKeys.ID` to ``Tracekys.NONE``, defaults to ``True``.
+ When this is enabled, the traced transform instance IDs will be removed from the cached MetaTensors.
+ This is useful for skipping the transform instance checks when inverting applied operations
+ using the cached content and with re-created transform instances.
"""
super().__init__(
@@ -417,6 +470,8 @@ def __init__(
hash_func=hash_func,
pickle_module=pickle_module,
pickle_protocol=pickle_protocol,
+ hash_transform=hash_transform,
+ reset_ops_id=reset_ops_id,
)
self.cache_n_trans = cache_n_trans
@@ -437,6 +492,7 @@ def _pre_transform(self, item_transformed):
break
_xform = deepcopy(_transform) if isinstance(_transform, ThreadUnsafe) else _transform
item_transformed = apply_transform(_xform, item_transformed)
+ reset_ops_id(item_transformed)
return item_transformed
def _post_transform(self, item_transformed):
@@ -482,6 +538,8 @@ def __init__(
db_name: str = "monai_cache",
progress: bool = True,
pickle_protocol=pickle.HIGHEST_PROTOCOL,
+ hash_transform: Optional[Callable[..., bytes]] = None,
+ reset_ops_id: bool = True,
lmdb_kwargs: Optional[dict] = None,
) -> None:
"""
@@ -501,11 +559,24 @@ def __init__(
progress: whether to display a progress bar.
pickle_protocol: pickle protocol version. Defaults to pickle.HIGHEST_PROTOCOL.
https://docs.python.org/3/library/pickle.html#pickle-protocols
+ hash_transform: a callable to compute hash from the transform information when caching.
+ This may reduce errors due to transforms changing during experiments. Default to None (no hash).
+ Other options are `pickle_hashing` and `json_hashing` functions from `monai.data.utils`.
+ reset_ops_id: whether to set `TraceKeys.ID` to ``Tracekys.NONE``, defaults to ``True``.
+ When this is enabled, the traced transform instance IDs will be removed from the cached MetaTensors.
+ This is useful for skipping the transform instance checks when inverting applied operations
+ using the cached content and with re-created transform instances.
lmdb_kwargs: additional keyword arguments to the lmdb environment.
for more details please visit: https://lmdb.readthedocs.io/en/release/#environment-class
"""
super().__init__(
- data=data, transform=transform, cache_dir=cache_dir, hash_func=hash_func, pickle_protocol=pickle_protocol
+ data=data,
+ transform=transform,
+ cache_dir=cache_dir,
+ hash_func=hash_func,
+ pickle_protocol=pickle_protocol,
+ hash_transform=hash_transform,
+ reset_ops_id=reset_ops_id,
)
self.progress = progress
if not self.cache_dir:
@@ -639,7 +710,7 @@ class CacheDataset(Dataset):
transforms = Compose([
LoadImaged(),
- AddChanneld(),
+ EnsureChannelFirstd(),
Spacingd(),
Orientationd(),
ScaleIntensityRanged(),
@@ -649,7 +720,7 @@ class CacheDataset(Dataset):
when `transforms` is used in a multi-epoch training pipeline, before the first training epoch,
this dataset will cache the results up to ``ScaleIntensityRanged``, as
- all non-random transforms `LoadImaged`, `AddChanneld`, `Spacingd`, `Orientationd`, `ScaleIntensityRanged`
+ all non-random transforms `LoadImaged`, `EnsureChannelFirstd`, `Spacingd`, `Orientationd`, `ScaleIntensityRanged`
can be cached. During training, the dataset will load the cached results and run
``RandCropByPosNegLabeld`` and ``ToTensord``, as ``RandCropByPosNegLabeld`` is a randomized transform
and the outcome not cached.
@@ -1159,7 +1230,7 @@ class ArrayDataset(Randomizable, _TorchDataset):
img_transform = Compose(
[
LoadImage(image_only=True),
- AddChannel(),
+ EnsureChannelFirst(),
RandAdjustContrast()
]
)
@@ -1179,7 +1250,7 @@ def __call__(self, input_):
img_transform = TestCompose(
[
LoadImage(image_only=False),
- AddChannel(),
+ EnsureChannelFirst(),
Spacing(pixdim=(1.5, 1.5, 3.0)),
RandAdjustContrast()
]
diff --git a/monai/data/dataset_summary.py b/monai/data/dataset_summary.py
index 6af46d11ea..785e8c7b88 100644
--- a/monai/data/dataset_summary.py
+++ b/monai/data/dataset_summary.py
@@ -9,6 +9,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import warnings
from itertools import chain
from typing import List, Optional
@@ -18,9 +19,10 @@
from monai.config import KeysCollection
from monai.data.dataloader import DataLoader
from monai.data.dataset import Dataset
+from monai.data.meta_tensor import MetaTensor
from monai.data.utils import affine_to_spacing
from monai.transforms import concatenate
-from monai.utils import PostFix, convert_data_type
+from monai.utils import PostFix, convert_data_type, convert_to_tensor
DEFAULT_POST_FIX = PostFix.meta()
@@ -30,9 +32,9 @@ class DatasetSummary:
This class provides a way to calculate a reasonable output voxel spacing according to
the input dataset. The achieved values can used to resample the input in 3d segmentation tasks
(like using as the `pixdim` parameter in `monai.transforms.Spacingd`).
- In addition, it also supports to count the mean, std, min and max intensities of the input,
+ In addition, it also supports to compute the mean, std, min and max intensities of the input,
and these statistics are helpful for image normalization
- (like using in `monai.transforms.ScaleIntensityRanged` and `monai.transforms.NormalizeIntensityd`).
+ (as parameters of `monai.transforms.ScaleIntensityRanged` and `monai.transforms.NormalizeIntensityd`).
The algorithm for calculation refers to:
`Automated Design of Deep Learning Methods for Biomedical Image Segmentation `_.
@@ -58,6 +60,7 @@ def __init__(
for example, for data with key `image`, the metadata by default is in `image_meta_dict`.
the metadata is a dictionary object which contains: filename, affine, original_shape, etc.
if None, will try to construct meta_keys by `{image_key}_{meta_key_postfix}`.
+ This is not required if `data[image_key]` is a MetaTensor.
meta_key_postfix: use `{image_key}_{meta_key_postfix}` to fetch the metadata from dict,
the metadata is a dictionary object (default: ``meta_dict``).
num_workers: how many subprocesses to use for data loading.
@@ -80,17 +83,21 @@ def collect_meta_data(self):
"""
for data in self.data_loader:
- if self.meta_key not in data:
- raise ValueError(f"To collect metadata for the dataset, key `{self.meta_key}` must exist in `data`.")
- self.all_meta_data.append(data[self.meta_key])
+ if isinstance(data[self.image_key], MetaTensor):
+ meta_dict = data[self.image_key].meta
+ elif self.meta_key in data:
+ meta_dict = data[self.meta_key]
+ else:
+ warnings.warn(f"To collect metadata for the dataset, `{self.meta_key}` or `data.meta` must exist.")
+ self.all_meta_data.append(meta_dict)
def get_target_spacing(self, spacing_key: str = "affine", anisotropic_threshold: int = 3, percentile: float = 10.0):
"""
Calculate the target spacing according to all spacings.
If the target spacing is very anisotropic,
decrease the spacing value of the maximum axis according to percentile.
- So far, this function only supports NIFTI images which store spacings in headers with key "pixdim".
- After loading with `monai.DataLoader`, "pixdim" is in the form of `torch.Tensor` with size `(batch_size, 8)`.
+ The spacing is computed from `affine_to_spacing(data[spacing_key][0], 3)` if `data[spacing_key]` is a matrix,
+ otherwise, the `data[spacing_key]` must be a vector of pixdim values.
Args:
spacing_key: key of the affine used to compute spacing in metadata (default: ``affine``).
@@ -103,7 +110,15 @@ def get_target_spacing(self, spacing_key: str = "affine", anisotropic_threshold:
self.collect_meta_data()
if spacing_key not in self.all_meta_data[0]:
raise ValueError("The provided spacing_key is not in self.all_meta_data.")
- spacings = [affine_to_spacing(data[spacing_key][0], 3)[None] for data in self.all_meta_data]
+ spacings = []
+ for data in self.all_meta_data:
+ spacing_vals = convert_to_tensor(data[spacing_key][0], track_meta=False, wrap_sequence=True)
+ if spacing_vals.ndim == 1: # vector
+ spacings.append(spacing_vals[:3][None])
+ elif spacing_vals.ndim == 2: # matrix
+ spacings.append(affine_to_spacing(spacing_vals, 3)[None])
+ else:
+ raise ValueError("data[spacing_key] must be a vector or a matrix.")
all_spacings = concatenate(to_cat=spacings, axis=0)
all_spacings, *_ = convert_data_type(data=all_spacings, output_type=np.ndarray, wrap_sequence=True)
diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py
index 743896c99a..904a5bb2d2 100644
--- a/monai/data/image_reader.py
+++ b/monai/data/image_reader.py
@@ -15,7 +15,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple, Union
import numpy as np
from torch.utils.data._utils.collate import np_str_obj_array_pattern
@@ -28,7 +28,7 @@
orientation_ras_lps,
)
from monai.transforms.utility.array import EnsureChannelFirst
-from monai.utils import ensure_tuple, ensure_tuple_rep, optional_import, require_pkg
+from monai.utils import MetaKeys, SpaceKeys, deprecated, ensure_tuple, ensure_tuple_rep, optional_import, require_pkg
if TYPE_CHECKING:
import itk
@@ -133,7 +133,7 @@ def _copy_compatible_dict(from_dict: Dict, to_dict: Dict):
continue
to_dict[key] = datum
else:
- affine_key, shape_key = "affine", "spatial_shape"
+ affine_key, shape_key = MetaKeys.AFFINE, MetaKeys.SPATIAL_SHAPE
if affine_key in from_dict and not np.allclose(from_dict[affine_key], to_dict[affine_key]):
raise RuntimeError(
"affine matrix of all images should be the same for channel-wise concatenation. "
@@ -149,11 +149,11 @@ def _copy_compatible_dict(from_dict: Dict, to_dict: Dict):
def _stack_images(image_list: List, meta_dict: Dict):
if len(image_list) <= 1:
return image_list[0]
- if meta_dict.get("original_channel_dim", None) not in ("no_channel", None):
- channel_dim = int(meta_dict["original_channel_dim"])
+ if meta_dict.get(MetaKeys.ORIGINAL_CHANNEL_DIM, None) not in ("no_channel", None):
+ channel_dim = int(meta_dict[MetaKeys.ORIGINAL_CHANNEL_DIM])
return np.concatenate(image_list, axis=channel_dim)
# stack at a new first dim as the channel dim, if `'original_channel_dim'` is unspecified
- meta_dict["original_channel_dim"] = 0
+ meta_dict[MetaKeys.ORIGINAL_CHANNEL_DIM] = 0
return np.stack(image_list, axis=0)
@@ -286,13 +286,16 @@ def get_data(self, img):
data = self._get_array_data(i)
img_array.append(data)
header = self._get_meta_dict(i)
- header["original_affine"] = self._get_affine(i, self.affine_lps_to_ras)
- header["affine"] = header["original_affine"].copy()
- header["spatial_shape"] = self._get_spatial_shape(i)
+ header[MetaKeys.ORIGINAL_AFFINE] = self._get_affine(i, self.affine_lps_to_ras)
+ header[MetaKeys.SPACE] = SpaceKeys.RAS if self.affine_lps_to_ras else SpaceKeys.LPS
+ header[MetaKeys.AFFINE] = header[MetaKeys.ORIGINAL_AFFINE].copy()
+ header[MetaKeys.SPATIAL_SHAPE] = self._get_spatial_shape(i)
if self.channel_dim is None: # default to "no_channel" or -1
- header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = (
+ "no_channel" if len(data.shape) == len(header[MetaKeys.SPATIAL_SHAPE]) else -1
+ )
else:
- header["original_channel_dim"] = self.channel_dim
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = self.channel_dim
_copy_compatible_dict(header, compatible_meta)
return _stack_images(img_array, compatible_meta), compatible_meta
@@ -379,9 +382,15 @@ class PydicomReader(ImageReader):
All the supported image formats can be found at:
https://dicom.nema.org/medical/dicom/current/output/chtml/part10/chapter_7.html
+ PydicomReader is also able to load segmentations, if a dicom file contains tag: `SegmentSequence`, the reader
+ will consider it as segmentation data, and to load it successfully, `PerFrameFunctionalGroupsSequence` is required
+ for dicom file, and for each frame of dicom file, `SegmentIdentificationSequence` is required.
+ This method refers to the Highdicom library.
+
This class refers to:
https://nipy.org/nibabel/dicom/dicom_orientation.html#dicom-affine-formula
https://github.com/pydicom/contrib-pydicom/blob/master/input-output/pydicom_series.py
+ https://highdicom.readthedocs.io/en/latest/usage.html#parsing-segmentation-seg-images
Args:
channel_dim: the channel dimension of the input image, default is None.
@@ -392,6 +401,12 @@ class PydicomReader(ImageReader):
otherwise the affine matrix remains in the Dicom convention.
swap_ij: whether to swap the first two spatial axes. Default to ``True``, so that the outputs
are consistent with the other readers.
+ prune_metadata: whether to prune the saved information in metadata. This argument is used for
+ `get_data` function. If True, only items that are related to the affine matrix will be saved.
+ Default to ``True``.
+ label_dict: label of the dicom data. If provided, it will be used when loading segmentation data.
+ Keys of the dict are the classes, and values are the corresponding class number. For example:
+ for TCIA collection "C4KC-KiTS", it can be: {"Kidney": 0, "Renal Tumor": 1}.
kwargs: additional args for `pydicom.dcmread` API. more details about available args:
https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.filereader.dcmread.html#pydicom.filereader.dcmread
If the `get_data` function will be called
@@ -401,13 +416,21 @@ class PydicomReader(ImageReader):
"""
def __init__(
- self, channel_dim: Optional[int] = None, affine_lps_to_ras: bool = True, swap_ij: bool = True, **kwargs
+ self,
+ channel_dim: Optional[int] = None,
+ affine_lps_to_ras: bool = True,
+ swap_ij: bool = True,
+ prune_metadata: bool = True,
+ label_dict: Optional[Dict] = None,
+ **kwargs,
):
super().__init__()
self.kwargs = kwargs
self.channel_dim = channel_dim
self.affine_lps_to_ras = affine_lps_to_ras
self.swap_ij = swap_ij
+ self.prune_metadata = prune_metadata
+ self.label_dict = label_dict
def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool:
"""
@@ -448,9 +471,12 @@ def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs):
name = f"{name}"
if Path(name).is_dir():
# read DICOM series
- slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in glob.glob(os.path.join(name, "**"))]
+ series_slcs = glob.glob(os.path.join(name, "*"))
+ series_slcs = [slc for slc in series_slcs if "LICENSE" not in slc]
+ slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in series_slcs]
img_.append(slices if len(slices) > 1 else slices[0])
- self.has_series = True
+ if len(slices) > 1:
+ self.has_series = True
else:
ds = pydicom.dcmread(fp=name, **kwargs_)
img_.append(ds)
@@ -514,12 +540,12 @@ def _combine_dicom_series(self, data):
stack_metadata["spacing"] = np.asarray(spacing)
if hasattr(slices[-1], "ImagePositionPatient"):
stack_metadata["lastImagePositionPatient"] = np.asarray(slices[-1].ImagePositionPatient)
- stack_metadata["spatial_shape"] = shape + (len(slices),)
+ stack_metadata[MetaKeys.SPATIAL_SHAPE] = shape + (len(slices),)
else:
stack_array = stack_array[0]
stack_metadata = self._get_meta_dict(first_slice)
stack_metadata["spacing"] = np.asarray(spacing)
- stack_metadata["spatial_shape"] = shape
+ stack_metadata[MetaKeys.SPATIAL_SHAPE] = shape
return stack_array, stack_metadata
@@ -532,8 +558,13 @@ def get_data(self, data):
When loading a list of files (dicom file, or stacked dicom series), they are stacked together at a new
dimension as the first dimension, and the metadata of the first image is used to represent the output metadata.
- To use this function, all pydicom dataset objects should contain: `pixel_array`, `PixelSpacing`,
- `ImagePositionPatient` and `ImageOrientationPatient`.
+ To use this function, all pydicom dataset objects (if not segmentation data) should contain:
+ `pixel_array`, `PixelSpacing`, `ImagePositionPatient` and `ImageOrientationPatient`.
+
+ For segmentation data, we assume that the input is not a dicom series, and the object should contain
+ `SegmentSequence` in order to identify it.
+ In addition, tags (5200, 9229) and (5200, 9230) are required to achieve
+ `PixelSpacing`, `ImageOrientationPatient` and `ImagePositionPatient`.
Args:
data: a pydicom dataset object, or a list of pydicom dataset objects, or a list of list of
@@ -556,10 +587,12 @@ def get_data(self, data):
if not isinstance(data, List):
data = [data]
for d in data:
- data_array = self._get_array_data(d)
- metadata = self._get_meta_dict(d)
- metadata["spatial_shape"] = data_array.shape
- metadata["spacing"] = np.asarray(metadata.get("00280030", {}).get("Value", (1.0, 1.0, 1.0)))
+ if hasattr(d, "SegmentSequence"):
+ data_array, metadata = self._get_seg_data(d)
+ else:
+ data_array = self._get_array_data(d)
+ metadata = self._get_meta_dict(d)
+ metadata[MetaKeys.SPATIAL_SHAPE] = data_array.shape
dicom_data.append((data_array, metadata))
img_array: List[np.ndarray] = []
@@ -568,20 +601,24 @@ def get_data(self, data):
for (data_array, metadata) in ensure_tuple(dicom_data):
img_array.append(np.ascontiguousarray(np.swapaxes(data_array, 0, 1) if self.swap_ij else data_array))
affine = self._get_affine(metadata, self.affine_lps_to_ras)
+ metadata[MetaKeys.SPACE] = SpaceKeys.RAS if self.affine_lps_to_ras else SpaceKeys.LPS
if self.swap_ij:
affine = affine @ np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]])
- sp_size = list(metadata["spatial_shape"])
+ sp_size = list(metadata[MetaKeys.SPATIAL_SHAPE])
sp_size[0], sp_size[1] = sp_size[1], sp_size[0]
- metadata["spatial_shape"] = ensure_tuple(sp_size)
- metadata["original_affine"] = affine
- metadata["affine"] = affine.copy()
+ metadata[MetaKeys.SPATIAL_SHAPE] = ensure_tuple(sp_size)
+ metadata[MetaKeys.ORIGINAL_AFFINE] = affine
+ metadata[MetaKeys.AFFINE] = affine.copy()
if self.channel_dim is None: # default to "no_channel" or -1
- metadata["original_channel_dim"] = (
- "no_channel" if len(data_array.shape) == len(metadata["spatial_shape"]) else -1
+ metadata[MetaKeys.ORIGINAL_CHANNEL_DIM] = (
+ "no_channel" if len(data_array.shape) == len(metadata[MetaKeys.SPATIAL_SHAPE]) else -1
)
else:
- metadata["original_channel_dim"] = self.channel_dim
- metadata["spacing"] = affine_to_spacing(metadata["original_affine"], r=len(metadata["spatial_shape"]))
+ metadata[MetaKeys.ORIGINAL_CHANNEL_DIM] = self.channel_dim
+ metadata["spacing"] = affine_to_spacing(
+ metadata[MetaKeys.ORIGINAL_AFFINE], r=len(metadata[MetaKeys.SPATIAL_SHAPE])
+ )
+
_copy_compatible_dict(metadata, compatible_meta)
return _stack_images(img_array, compatible_meta), compatible_meta
@@ -594,19 +631,23 @@ def _get_meta_dict(self, img) -> Dict:
img: a Pydicom dataset object.
"""
- if not hasattr(img, "ImagePositionPatient"):
- raise ValueError(f"dicom data: {img.filename} does not have ImagePositionPatient.")
- if not hasattr(img, "ImageOrientationPatient"):
- raise ValueError(f"dicom data: {img.filename} does not have ImageOrientationPatient.")
- meta_dict = img.to_json_dict()
- # remove Pixel Data "7FE00008" or "7FE00009" or "7FE00010"
- # remove Data Set Trailing Padding "FFFCFFFC"
+ metadata = img.to_json_dict()
+
+ if self.prune_metadata:
+ prune_metadata = {}
+ for key in ["00200037", "00200032", "52009229", "52009230"]:
+ if key in metadata.keys():
+ prune_metadata[key] = metadata[key]
+ return prune_metadata
+
+ # always remove Pixel Data "7FE00008" or "7FE00009" or "7FE00010"
+ # always remove Data Set Trailing Padding "FFFCFFFC"
for key in ["7FE00008", "7FE00009", "7FE00010", "FFFCFFFC"]:
- if key in meta_dict.keys():
- meta_dict.pop(key)
+ if key in metadata.keys():
+ metadata.pop(key)
- return meta_dict # type: ignore
+ return metadata # type: ignore
def _get_affine(self, metadata: Dict, lps_to_ras: bool = True):
"""
@@ -640,7 +681,7 @@ def _get_affine(self, metadata: Dict, lps_to_ras: bool = True):
# 3d
if "lastImagePositionPatient" in metadata:
t1n, t2n, t3n = metadata["lastImagePositionPatient"]
- n = metadata["spatial_shape"][-1]
+ n = metadata[MetaKeys.SPATIAL_SHAPE][-1]
k1, k2, k3 = (t1n - sx) / (n - 1), (t2n - sy) / (n - 1), (t3n - sz) / (n - 1)
affine[0, 2] = k1
affine[1, 2] = k2
@@ -650,6 +691,132 @@ def _get_affine(self, metadata: Dict, lps_to_ras: bool = True):
affine = orientation_ras_lps(affine)
return affine
+ def _get_frame_data(self, img) -> Iterator:
+ """
+ yield frames and description from the segmentation image.
+ This function is adapted from Highdicom:
+ https://github.com/herrmannlab/highdicom/blob/v0.18.2/src/highdicom/seg/utils.py
+
+ which has the following license...
+
+ # =========================================================================
+ # https://github.com/herrmannlab/highdicom/blob/v0.18.2/LICENSE
+ #
+ # Copyright 2020 MGH Computational Pathology
+ # Permission is hereby granted, free of charge, to any person obtaining a
+ # copy of this software and associated documentation files (the
+ # "Software"), to deal in the Software without restriction, including
+ # without limitation the rights to use, copy, modify, merge, publish,
+ # distribute, sublicense, and/or sell copies of the Software, and to
+ # permit persons to whom the Software is furnished to do so, subject to
+ # the following conditions:
+ # The above copyright notice and this permission notice shall be included
+ # in all copies or substantial portions of the Software.
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ # =========================================================================
+
+ (https://github.com/herrmannlab/highdicom/issues/188)
+
+ Args:
+ img: a Pydicom dataset object that has attribute "SegmentSequence".
+
+ """
+
+ if not hasattr(img, "PerFrameFunctionalGroupsSequence"):
+ raise NotImplementedError(
+ f"To read dicom seg: {img.filename}, 'PerFrameFunctionalGroupsSequence' is required."
+ )
+
+ frame_seg_nums = []
+ for f in img.PerFrameFunctionalGroupsSequence:
+ if not hasattr(f, "SegmentIdentificationSequence"):
+ raise NotImplementedError(
+ f"To read dicom seg: {img.filename}, 'SegmentIdentificationSequence' is required for each frame."
+ )
+ frame_seg_nums.append(int(f.SegmentIdentificationSequence[0].ReferencedSegmentNumber))
+
+ frame_seg_nums_arr = np.array(frame_seg_nums)
+
+ seg_descriptions = {int(f.SegmentNumber): f for f in img.SegmentSequence}
+
+ for i in np.unique(frame_seg_nums_arr):
+ indices = np.where(frame_seg_nums_arr == i)[0]
+ yield (img.pixel_array[indices, ...], seg_descriptions[i])
+
+ def _get_seg_data(self, img):
+ """
+ Get the array data and metadata of the segmentation image.
+
+ Aegs:
+ img: a Pydicom dataset object that has attribute "SegmentSequence".
+
+ """
+
+ metadata = self._get_meta_dict(img)
+ n_classes = len(img.SegmentSequence)
+ spatial_shape = list(img.pixel_array.shape)
+ spatial_shape[0] = spatial_shape[0] // n_classes
+
+ if self.label_dict is not None:
+ metadata["labels"] = self.label_dict
+ all_segs = np.zeros([*spatial_shape, len(self.label_dict)])
+ else:
+ metadata["labels"] = {}
+ all_segs = np.zeros([*spatial_shape, n_classes])
+
+ for i, (frames, description) in enumerate(self._get_frame_data(img)):
+ class_name = description.SegmentDescription
+ if class_name not in metadata["labels"].keys():
+ metadata["labels"][class_name] = i
+ class_num = metadata["labels"][class_name]
+ all_segs[..., class_num] = frames
+
+ all_segs = all_segs.transpose([1, 2, 0, 3])
+ metadata[MetaKeys.SPATIAL_SHAPE] = all_segs.shape[:-1]
+
+ if "52009229" in metadata.keys():
+ shared_func_group_seq = metadata["52009229"]["Value"][0]
+
+ # get `ImageOrientationPatient`
+ if "00209116" in shared_func_group_seq.keys():
+ plane_orient_seq = shared_func_group_seq["00209116"]["Value"][0]
+ if "00200037" in plane_orient_seq.keys():
+ metadata["00200037"] = plane_orient_seq["00200037"]
+
+ # get `PixelSpacing`
+ if "00289110" in shared_func_group_seq.keys():
+ pixel_measure_seq = shared_func_group_seq["00289110"]["Value"][0]
+
+ if "00280030" in pixel_measure_seq.keys():
+ pixel_spacing = pixel_measure_seq["00280030"]["Value"]
+ metadata["spacing"] = pixel_spacing
+ if "00180050" in pixel_measure_seq.keys():
+ metadata["spacing"] += pixel_measure_seq["00180050"]["Value"]
+
+ if self.prune_metadata:
+ metadata.pop("52009229")
+
+ # get `ImagePositionPatient`
+ if "52009230" in metadata.keys():
+ first_frame_func_group_seq = metadata["52009230"]["Value"][0]
+ if "00209113" in first_frame_func_group_seq.keys():
+ plane_position_seq = first_frame_func_group_seq["00209113"]["Value"][0]
+ if "00200032" in plane_position_seq.keys():
+ metadata["00200032"] = plane_position_seq["00200032"]
+ metadata["lastImagePositionPatient"] = metadata["52009230"]["Value"][-1]["00209113"]["Value"][0][
+ "00200032"
+ ]["Value"]
+ if self.prune_metadata:
+ metadata.pop("52009230")
+
+ return all_segs, metadata
+
def _get_array_data(self, img):
"""
Get the array data of the image. If `RescaleSlope` and `RescaleIntercept` are available, the raw array data
@@ -764,23 +931,26 @@ def get_data(self, img):
for i in ensure_tuple(img):
header = self._get_meta_dict(i)
- header["affine"] = self._get_affine(i)
- header["original_affine"] = self._get_affine(i)
+ header[MetaKeys.AFFINE] = self._get_affine(i)
+ header[MetaKeys.ORIGINAL_AFFINE] = self._get_affine(i)
header["as_closest_canonical"] = self.as_closest_canonical
if self.as_closest_canonical:
i = nib.as_closest_canonical(i)
- header["affine"] = self._get_affine(i)
- header["spatial_shape"] = self._get_spatial_shape(i)
+ header[MetaKeys.AFFINE] = self._get_affine(i)
+ header[MetaKeys.SPATIAL_SHAPE] = self._get_spatial_shape(i)
+ header[MetaKeys.SPACE] = SpaceKeys.RAS
data = self._get_array_data(i)
if self.squeeze_non_spatial_dims:
- for d in range(len(data.shape), len(header["spatial_shape"]), -1):
+ for d in range(len(data.shape), len(header[MetaKeys.SPATIAL_SHAPE]), -1):
if data.shape[d - 1] == 1:
data = data.squeeze(axis=d - 1)
img_array.append(data)
if self.channel_dim is None: # default to "no_channel" or -1
- header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = (
+ "no_channel" if len(data.shape) == len(header[MetaKeys.SPATIAL_SHAPE]) else -1
+ )
else:
- header["original_channel_dim"] = self.channel_dim
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = self.channel_dim
_copy_compatible_dict(header, compatible_meta)
return _stack_images(img_array, compatible_meta), compatible_meta
@@ -936,9 +1106,12 @@ def get_data(self, img):
spatial_shape = np.asarray(i.shape)
if isinstance(self.channel_dim, int):
spatial_shape = np.delete(spatial_shape, self.channel_dim)
- header["spatial_shape"] = spatial_shape
+ header[MetaKeys.SPATIAL_SHAPE] = spatial_shape
+ header[MetaKeys.SPACE] = SpaceKeys.RAS
img_array.append(i)
- header["original_channel_dim"] = self.channel_dim if isinstance(self.channel_dim, int) else "no_channel"
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = (
+ self.channel_dim if isinstance(self.channel_dim, int) else "no_channel"
+ )
_copy_compatible_dict(header, compatible_meta)
return _stack_images(img_array, compatible_meta), compatible_meta
@@ -1017,10 +1190,12 @@ def get_data(self, img):
for i in ensure_tuple(img):
header = self._get_meta_dict(i)
- header["spatial_shape"] = self._get_spatial_shape(i)
+ header[MetaKeys.SPATIAL_SHAPE] = self._get_spatial_shape(i)
data = np.moveaxis(np.asarray(i), 0, 1)
img_array.append(data)
- header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = (
+ "no_channel" if len(data.shape) == len(header[MetaKeys.SPATIAL_SHAPE]) else -1
+ )
_copy_compatible_dict(header, compatible_meta)
return _stack_images(img_array, compatible_meta), compatible_meta
@@ -1043,6 +1218,7 @@ def _get_spatial_shape(self, img):
return np.asarray((img.width, img.height))
+@deprecated(since="0.8", msg_suffix="use `monai.wsi_reader.WSIReader` instead.")
class WSIReader(ImageReader):
"""
Read whole slide images and extract patches.
@@ -1157,8 +1333,8 @@ def get_data(
# Add necessary metadata
metadata: Dict = {}
- metadata["spatial_shape"] = np.asarray(region.shape[:-1])
- metadata["original_channel_dim"] = -1
+ metadata[MetaKeys.SPATIAL_SHAPE] = np.asarray(region.shape[:-1])
+ metadata[MetaKeys.ORIGINAL_CHANNEL_DIM] = -1
# Make it channel first
region = EnsureChannelFirst()(region, metadata)
@@ -1393,16 +1569,18 @@ def get_data(self, img: Union[NrrdImage, List[NrrdImage]]) -> Tuple[np.ndarray,
header = dict(i.header)
if self.index_order == "C":
header = self._convert_f_to_c_order(header)
- header["original_affine"] = self._get_affine(i)
+ header[MetaKeys.ORIGINAL_AFFINE] = self._get_affine(i)
header = self._switch_lps_ras(header)
- header["affine"] = header["original_affine"].copy()
- header["spatial_shape"] = header["sizes"]
+ header[MetaKeys.AFFINE] = header[MetaKeys.ORIGINAL_AFFINE].copy()
+ header[MetaKeys.SPATIAL_SHAPE] = header["sizes"]
[header.pop(k) for k in ("sizes", "space origin", "space directions")] # rm duplicated data in header
if self.channel_dim is None: # default to "no_channel" or -1
- header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else 0
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = (
+ "no_channel" if len(data.shape) == len(header[MetaKeys.SPATIAL_SHAPE]) else 0
+ )
else:
- header["original_channel_dim"] = self.channel_dim
+ header[MetaKeys.ORIGINAL_CHANNEL_DIM] = self.channel_dim
_copy_compatible_dict(header, compatible_meta)
return _stack_images(img_array, compatible_meta), compatible_meta
@@ -1438,8 +1616,8 @@ def _switch_lps_ras(self, header: dict) -> dict:
"""
if "space" not in header or header["space"] == "left-posterior-superior":
- header["space"] = "right-anterior-superior"
- header["original_affine"] = orientation_ras_lps(header["original_affine"])
+ header[MetaKeys.ORIGINAL_AFFINE] = orientation_ras_lps(header[MetaKeys.ORIGINAL_AFFINE])
+ header[MetaKeys.SPACE] = SpaceKeys.RAS
return header
def _convert_f_to_c_order(self, header: dict) -> dict:
diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py
index 27cfef6db1..cf7f3e0291 100644
--- a/monai/data/image_writer.py
+++ b/monai/data/image_writer.py
@@ -23,7 +23,9 @@
GridSampleMode,
GridSamplePadMode,
InterpolateMode,
+ MetaKeys,
OptionalImportError,
+ SpaceKeys,
convert_data_type,
convert_to_tensor,
look_up_option,
@@ -326,13 +328,13 @@ def convert_to_channel_last(
def get_meta_info(cls, metadata: Optional[Mapping] = None):
"""
Extracts relevant meta information from the metadata object (using ``.get``).
- Optional keys are ``"spatial_shape"``, ``"affine"``, ``"original_affine"``.
+ Optional keys are ``"spatial_shape"``, ``MetaKeys.AFFINE``, ``"original_affine"``.
"""
if not metadata:
- metadata = {"original_affine": None, "affine": None, "spatial_shape": None}
+ metadata = {"original_affine": None, MetaKeys.AFFINE: None, MetaKeys.SPATIAL_SHAPE: None}
original_affine = metadata.get("original_affine")
- affine = metadata.get("affine")
- spatial_shape = metadata.get("spatial_shape")
+ affine = metadata.get(MetaKeys.AFFINE)
+ spatial_shape = metadata.get(MetaKeys.SPATIAL_SHAPE)
return original_affine, affine, spatial_shape
@@ -484,6 +486,8 @@ def create_backend_obj(
- https://github.com/InsightSoftwareConsortium/ITK/blob/v5.2.1/Wrapping/Generators/Python/itk/support/extras.py#L389
"""
+ if isinstance(data_array, MetaTensor) and data_array.meta.get(MetaKeys.SPACE, SpaceKeys.LPS) != SpaceKeys.LPS:
+ affine_lps_to_ras = False # do the converting from LPS to RAS only if the space type is currently LPS.
data_array = super().create_backend_obj(data_array)
_is_vec = channel_dim is not None
if _is_vec:
@@ -740,7 +744,7 @@ def write(self, filename: PathLike, verbose: bool = False, **kwargs):
@classmethod
def get_meta_info(cls, metadata: Optional[Mapping] = None):
- return None if not metadata else metadata.get("spatial_shape")
+ return None if not metadata else metadata.get(MetaKeys.SPATIAL_SHAPE)
@classmethod
def resample_and_clip(
diff --git a/monai/data/iterable_dataset.py b/monai/data/iterable_dataset.py
index f1906a80fe..009bd31031 100644
--- a/monai/data/iterable_dataset.py
+++ b/monai/data/iterable_dataset.py
@@ -72,6 +72,25 @@ class ShuffleBuffer(Randomizable, IterableDataset):
every iter() call, refer to the PyTorch idea:
https://github.com/pytorch/pytorch/blob/v1.10.0/torch/utils/data/distributed.py#L98.
+ Note:
+ Both ``monai.data.DataLoader`` and ``torch.utils.data.DataLoader`` do not seed this class (as a subclass of
+ ``IterableDataset``) at run time. ``persistent_workers=True`` flag (and pytorch>1.8) is therefore required
+ for multiple epochs of loading when ``num_workers>0``. For example::
+
+ import monai
+
+ def run():
+ dss = monai.data.ShuffleBuffer([1, 2, 3, 4], buffer_size=30, seed=42)
+
+ dataloader = monai.data.DataLoader(
+ dss, batch_size=1, num_workers=2, persistent_workers=True)
+ for epoch in range(3):
+ for item in dataloader:
+ print(f"epoch: {epoch} item: {item}.")
+
+ if __name__ == '__main__':
+ run()
+
"""
def __init__(self, data, transform=None, buffer_size: int = 512, seed: int = 0) -> None:
@@ -80,36 +99,31 @@ def __init__(self, data, transform=None, buffer_size: int = 512, seed: int = 0)
self.seed = seed
self._idx = 0
+ def randomized_pop(self, buffer):
+ """Return the item at a randomized location `self._idx` in `buffer`."""
+ self.randomize(len(buffer))
+ ret, buffer[self._idx] = buffer[self._idx], buffer[-1]
+ buffer.pop()
+ return ret
+
+ def generate_item(self):
+ """Fill a `buffer` list up to `self.size`, then generate randomly popped items."""
+ buffer = []
+ for item in iter(self.data):
+ if len(buffer) >= self.size:
+ yield self.randomized_pop(buffer)
+ buffer.append(item)
+ while buffer:
+ yield self.randomized_pop(buffer)
+
def __iter__(self):
"""
- Fetch data from the source, if buffer is not full, fill into buffer, otherwise,
- randomly pop items from the buffer.
- After loading all the data from source, randomly pop items from the buffer.
-
+ Randomly pop buffered items from `self.data`.
+ Multiple dataloader workers sharing this dataset will generate identical item sequences.
"""
self.seed += 1
super().set_random_state(seed=self.seed) # make all workers in sync
- buffer = []
- source = self.data
-
- def _pop_item():
- self.randomize(len(buffer))
- # switch random index data and the last index data
- ret, buffer[self._idx] = buffer[self._idx], buffer[-1]
- buffer.pop()
- return ret
-
- def _get_item():
- for item in source:
- if len(buffer) >= self.size:
- yield _pop_item()
- buffer.append(item)
-
- while buffer:
- yield _pop_item()
-
- self.data = _get_item()
- return super().__iter__()
+ yield from IterableDataset(self.generate_item(), transform=self.transform)
def randomize(self, size: int) -> None:
self._idx = self.R.randint(size)
diff --git a/monai/data/meta_obj.py b/monai/data/meta_obj.py
index 7d2e99ff79..3a1bee508c 100644
--- a/monai/data/meta_obj.py
+++ b/monai/data/meta_obj.py
@@ -16,7 +16,11 @@
from copy import deepcopy
from typing import Any, Iterable
+import numpy as np
+import torch
+
from monai.utils.enums import TraceKeys
+from monai.utils.misc import first
_TRACK_META = True
@@ -71,7 +75,7 @@ class MetaObj:
* For `c = a + b`, then auxiliary data (e.g., metadata) will be copied from the
first instance of `MetaObj` if `a.is_batch` is False
- (For batched data, the metdata will be shallow copied for efficiency purposes).
+ (For batched data, the metadata will be shallow copied for efficiency purposes).
"""
@@ -99,53 +103,30 @@ def flatten_meta_objs(*args: Iterable):
elif isinstance(a, MetaObj):
yield a
- def _copy_attr(self, attributes: list[str], input_objs, defaults: list, deep_copy: bool) -> None:
- """
- Copy attributes from the first in a list of `MetaObj`. In the case of
- `torch.add(a, b)`, both `a` and `b` could be `MetaObj` or something else, so
- check them all. Copy the first to `self`.
-
- We also perform a deep copy of the data if desired.
-
- Args:
- attributes: a sequence of strings corresponding to attributes to be copied (e.g., `['meta']`).
- input_objs: an iterable of `MetaObj` instances. We'll copy the attribute from the first one
- that contains that particular attribute.
- defaults: If none of `input_objs` have the attribute that we're
- interested in, then use this default value/function (e.g., `lambda: {}`.)
- the defaults must be the same length as `attributes`.
- deep_copy: whether to deep copy the corresponding attribute.
-
- Returns:
- Returns `None`, but `self` should be updated to have the copied attribute.
- """
- found = [False] * len(attributes)
- for i, (idx, a) in itertools.product(input_objs, enumerate(attributes)):
- if not found[idx] and hasattr(i, a):
- setattr(self, a, deepcopy(getattr(i, a)) if deep_copy else getattr(i, a))
- found[idx] = True
- if all(found):
- return
- for a, f, d in zip(attributes, found, defaults):
- if not f:
- setattr(self, a, d() if callable(defaults) else d)
- return
-
- def _copy_meta(self, input_objs, deep_copy=False) -> None:
+ @staticmethod
+ def copy_items(data):
+ """returns a copy of the data. list and dict are shallow copied for efficiency purposes."""
+ if isinstance(data, (list, dict, np.ndarray)):
+ return data.copy()
+ if isinstance(data, torch.Tensor):
+ return data.detach().clone()
+ return deepcopy(data)
+
+ def copy_meta_from(self, input_objs, copy_attr=True) -> None:
"""
- Copy metadata from an iterable of `MetaObj` instances. For a given attribute, we copy the
- adjunct data from the first element in the list containing that attribute.
+ Copy metadata from a `MetaObj` or an iterable of `MetaObj` instances.
Args:
input_objs: list of `MetaObj` to copy data from.
-
+ copy_attr: whether to copy each attribute with `MetaObj.copy_item`.
+ note that if the attribute is a nested list or dict, only a shallow copy will be done.
"""
- self._copy_attr(
- ["meta", "applied_operations"],
- input_objs,
- [MetaObj.get_default_meta(), MetaObj.get_default_applied_operations()],
- deep_copy,
- )
+ first_meta = input_objs if isinstance(input_objs, MetaObj) else first(input_objs, default=self)
+ first_meta = first_meta.__dict__
+ if not copy_attr:
+ self.__dict__ = first_meta.copy() # shallow copy for performance
+ else:
+ self.__dict__.update({a: MetaObj.copy_items(first_meta[a]) for a in first_meta})
@staticmethod
def get_default_meta() -> dict:
@@ -167,7 +148,7 @@ def get_default_applied_operations() -> list:
def __repr__(self) -> str:
"""String representation of class."""
- out: str = "\nMetaData\n"
+ out: str = "\nMetadata\n"
if self.meta is not None:
out += "".join(f"\t{k}: {v}\n" for k, v in self.meta.items())
else:
@@ -185,7 +166,7 @@ def __repr__(self) -> str:
@property
def meta(self) -> dict:
- """Get the meta."""
+ """Get the meta. Defaults to ``{}``."""
return self._meta if hasattr(self, "_meta") else MetaObj.get_default_meta()
@meta.setter
@@ -197,7 +178,7 @@ def meta(self, d) -> None:
@property
def applied_operations(self) -> list[dict]:
- """Get the applied operations."""
+ """Get the applied operations. Defaults to ``[]``."""
if hasattr(self, "_applied_operations"):
return self._applied_operations
return MetaObj.get_default_applied_operations()
diff --git a/monai/data/meta_tensor.py b/monai/data/meta_tensor.py
index d0818bd25e..8897371903 100644
--- a/monai/data/meta_tensor.py
+++ b/monai/data/meta_tensor.py
@@ -18,11 +18,12 @@
import numpy as np
import torch
+import monai
from monai.config.type_definitions import NdarrayTensor
from monai.data.meta_obj import MetaObj, get_track_meta
from monai.data.utils import affine_to_spacing, decollate_batch, list_data_collate, remove_extra_metadata
from monai.utils import look_up_option
-from monai.utils.enums import PostFix
+from monai.utils.enums import MetaKeys, PostFix, SpaceKeys
from monai.utils.type_conversion import convert_data_type, convert_to_tensor
__all__ = ["MetaTensor"]
@@ -105,49 +106,50 @@ def __init__(
**_kwargs,
) -> None:
"""
- If `meta` is given, use it. Else, if `meta` exists in the input tensor, use it.
- Else, use the default value. Similar for the affine, except this could come from
- four places.
- Priority: `affine`, `meta["affine"]`, `x.affine`, `get_default_affine`.
+ Args:
+ x: initial array for the MetaTensor. Can be a list, tuple, NumPy ndarray, scalar, and other types.
+ affine: optional 4x4 array.
+ meta: dictionary of metadata.
+ applied_operations: list of previously applied operations on the MetaTensor,
+ the list is typically maintained by `monai.transforms.TraceableTransform`.
+ See also: :py:class:`monai.transforms.TraceableTransform`
+ _args: additional args (currently not in use in this constructor).
+ _kwargs: additional kwargs (currently not in use in this constructor).
+
+ Note:
+ If a `meta` dictionary is given, use it. Else, if `meta` exists in the input tensor `x`, use it.
+ Else, use the default value. Similar for the affine, except this could come from
+ four places, priority: `affine`, `meta["affine"]`, `x.affine`, `get_default_affine`.
+
"""
super().__init__()
# set meta
if meta is not None:
self.meta = meta
elif isinstance(x, MetaObj):
- self.meta = x.meta
+ self.__dict__ = deepcopy(x.__dict__)
# set the affine
if affine is not None:
- if "affine" in self.meta:
+ if MetaKeys.AFFINE in self.meta:
warnings.warn("Setting affine, but the applied meta contains an affine. This will be overwritten.")
self.affine = affine
- elif "affine" in self.meta:
+ elif MetaKeys.AFFINE in self.meta:
# by using the setter function, we ensure it is converted to torch.Tensor if not already
- self.affine = self.meta["affine"]
- elif isinstance(x, MetaTensor):
- self.affine = x.affine
+ self.affine = self.meta[MetaKeys.AFFINE]
else:
self.affine = self.get_default_affine()
# applied_operations
if applied_operations is not None:
self.applied_operations = applied_operations
- elif isinstance(x, MetaTensor):
- self.applied_operations = x.applied_operations
else:
self.applied_operations = MetaObj.get_default_applied_operations()
# if we are creating a new MetaTensor, then deep copy attributes
if isinstance(x, torch.Tensor) and not isinstance(x, MetaTensor):
- self.meta = deepcopy(self.meta)
- self.applied_operations = deepcopy(self.applied_operations)
- self.affine = self.affine.to(self.device)
+ self.copy_meta_from(self)
- def _copy_attr(self, attributes: list[str], input_objs, defaults: list, deep_copy: bool) -> None:
- super()._copy_attr(attributes, input_objs, defaults, deep_copy)
- for a in attributes:
- val = getattr(self, a)
- if isinstance(val, torch.Tensor):
- setattr(self, a, val.to(self.device))
+ if MetaKeys.SPACE not in self.meta:
+ self.meta[MetaKeys.SPACE] = SpaceKeys.RAS # defaulting to the right-anterior-superior space
@staticmethod
def update_meta(rets: Sequence, func, args, kwargs) -> Sequence:
@@ -177,7 +179,7 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence:
the input type was not `MetaTensor`, then no modifications will have been
made. If global parameters have been set to false (e.g.,
`not get_track_meta()`), then any `MetaTensor` will be converted to
- `torch.Tensor`. Else, metadata will be propogated as necessary (see
+ `torch.Tensor`. Else, metadata will be propagated as necessary (see
:py:func:`MetaTensor._copy_meta`).
"""
out = []
@@ -193,8 +195,8 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence:
# else, handle the `MetaTensor` metadata.
else:
meta_args = MetaObj.flatten_meta_objs(args, kwargs.values())
- ret._copy_meta(meta_args, deep_copy=not is_batch)
ret.is_batch = is_batch
+ ret.copy_meta_from(meta_args, copy_attr=not is_batch)
# the following is not implemented but the network arch may run into this case:
# if func == torch.cat and any(m.is_batch if hasattr(m, "is_batch") else False for m in meta_args):
# raise NotImplementedError("torch.cat is not implemented for batch of MetaTensors.")
@@ -212,20 +214,13 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence:
# if using e.g., `batch[:, -1]` or `batch[..., -1]`, then the
# first element will be `slice(None, None, None)` and `Ellipsis`,
# respectively. Don't need to do anything with the metadata.
- if batch_idx not in (slice(None, None, None), Ellipsis, None):
- # only decollate metadata once
- if metas is None:
- metas = decollate_batch(ret.meta)
- meta = metas[batch_idx]
- # if using e.g., `batch[0:2]`, then `is_batch` should still be
- # `True`. Also re-collate the remaining elements.
- if isinstance(meta, list):
- ret.meta = list_data_collate(meta)
- # if using e.g., `batch[0]` or `batch[0, 1]`, then return single
- # element from batch, and set `is_batch` to `False`.
- else:
- ret.meta = meta
- ret.is_batch = False
+ if batch_idx not in (slice(None, None, None), Ellipsis, None) and idx == 0:
+ ret_meta = decollate_batch(args[0], detach=False)[batch_idx]
+ if isinstance(ret_meta, list): # e.g. batch[0:2], re-collate
+ ret_meta = list_data_collate(ret_meta)
+ else: # e.g. `batch[0]` or `batch[0, 1]`, batch index is an integer
+ ret_meta.is_batch = False
+ ret.__dict__ = ret_meta.__dict__.copy()
# `unbind` is used for `next(iter(batch))`. Also for `decollate_batch`.
# But we only want to split the batch if the `unbind` is along the 0th
# dimension.
@@ -238,11 +233,10 @@ def update_meta(rets: Sequence, func, args, kwargs) -> Sequence:
dim = 0
if dim == 0:
if metas is None:
- metas = decollate_batch(ret.meta)
- ret.meta = metas[idx]
+ metas = decollate_batch(args[0], detach=False)
+ ret.__dict__ = metas[idx].__dict__.copy()
ret.is_batch = False
- ret.affine = ret.affine.to(ret.device)
out.append(ret)
# if the input was a tuple, then return it as a tuple
return tuple(out) if isinstance(rets, tuple) else out
@@ -280,8 +274,46 @@ def __torch_function__(cls, func, types, args=(), kwargs=None) -> Any:
ret = MetaTensor.update_meta(ret, func, args, kwargs)
return ret[0] if unpack else ret
+ @staticmethod
+ def _convert(x):
+ if isinstance(x, (MetaTensor, torch.Tensor, tuple, list)):
+ return convert_data_type(x, output_type=np.ndarray, wrap_sequence=False)[0]
+ return x
+
+ def __array_function__(self, func, types, args, kwargs):
+ """for numpy Interoperability, so that we can compute ``np.sum(MetaTensor([1.0]))``."""
+ try:
+ if not func.__module__.startswith("numpy"):
+ return NotImplemented
+ except AttributeError:
+ return NotImplemented
+ _args = list(map(MetaTensor._convert, args))
+ _kwargs = {k: MetaTensor._convert(v) for k, v in kwargs.items()}
+ return func(*_args, **_kwargs)
+
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
+ """
+ For numpy interoperability, so that we can compute ``MetaTensor([1.0]) >= np.asarray([1.0])``.
+ This is for pytorch > 1.8.
+ """
+ try:
+ if not type(ufunc).__module__.startswith("numpy"):
+ return NotImplemented
+ except AttributeError:
+ return NotImplemented
+ if method != "__call__":
+ return NotImplemented
+ _inputs = map(MetaTensor._convert, inputs)
+ _kwargs = {k: MetaTensor._convert(v) for k, v in kwargs.items()}
+ if "out" in _kwargs:
+ return NotImplemented # not supported
+ try:
+ return getattr(ufunc, method)(*_inputs, **_kwargs)
+ except AttributeError:
+ return NotImplemented
+
def get_default_affine(self, dtype=torch.float64) -> torch.Tensor:
- return torch.eye(4, device=self.device, dtype=dtype)
+ return torch.eye(4, device=torch.device("cpu"), dtype=dtype)
def as_tensor(self) -> torch.Tensor:
"""
@@ -290,34 +322,93 @@ def as_tensor(self) -> torch.Tensor:
"""
return self.as_subclass(torch.Tensor) # type: ignore
- def as_dict(self, key: str) -> dict:
+ def get_array(self, output_type=np.ndarray, dtype=None, device=None, *_args, **_kwargs):
+ """
+ Returns a new array in `output_type`, the array shares the same underlying storage when the output is a
+ numpy array. Changes to self tensor will be reflected in the ndarray and vice versa.
+
+ Args:
+ output_type: output type, see also: :py:func:`monai.utils.convert_data_type`.
+ dtype: dtype of output data. Converted to correct library type (e.g.,
+ `np.float32` is converted to `torch.float32` if output type is `torch.Tensor`).
+ If left blank, it remains unchanged.
+ device: if the output is a `torch.Tensor`, select device (if `None`, unchanged).
+ _args: currently unused parameters.
+ _kwargs: currently unused parameters.
+ """
+ return convert_data_type(self, output_type=output_type, dtype=dtype, device=device, wrap_sequence=True)[0]
+
+ def set_array(self, src, non_blocking=False, *_args, **_kwargs):
+ """
+ Copies the elements from src into self tensor and returns self.
+ The src tensor must be broadcastable with the self tensor.
+ It may be of a different data type or reside on a different device.
+
+ See also: `https://pytorch.org/docs/stable/generated/torch.Tensor.copy_.html`
+
+ Args:
+ src: the source tensor to copy from.
+ non_blocking: if True and this copy is between CPU and GPU, the copy may occur
+ asynchronously with respect to the host. For other cases, this argument has no effect.
+ _args: currently unused parameters.
+ _kwargs: currently unused parameters.
+ """
+ src: torch.Tensor = convert_to_tensor(src, track_meta=False, wrap_sequence=True)
+ try:
+ return self.copy_(src, non_blocking=non_blocking)
+ except RuntimeError: # skip the shape checking
+ self.data = src
+ return self
+
+ @property
+ def array(self):
+ """
+ Returns a numpy array of ``self``. The array and ``self`` shares the same underlying storage if self is on cpu.
+ Changes to ``self`` (it's a subclass of torch.Tensor) will be reflected in the ndarray and vice versa.
+ If ``self`` is not on cpu, the call will move the array to cpu and then the storage is not shared.
+
+ :getter: see also: :py:func:`MetaTensor.get_array()`
+ :setter: see also: :py:func:`MetaTensor.set_array()`
+ """
+ return self.get_array()
+
+ @array.setter
+ def array(self, src) -> None:
+ """A default setter using ``self.set_array()``"""
+ self.set_array(src)
+
+ def as_dict(self, key: str, output_type=torch.Tensor, dtype=None) -> dict:
"""
Get the object as a dictionary for backwards compatibility.
- This method makes a copy of the objects.
+ This method does not make a deep copy of the objects.
Args:
- key: Base key to store main data. The key for the metadata will be
- determined using `PostFix.meta`.
+ key: Base key to store main data. The key for the metadata will be determined using `PostFix`.
+ output_type: `torch.Tensor` or `np.ndarray` for the main data.
+ dtype: dtype of output data. Converted to correct library type (e.g.,
+ `np.float32` is converted to `torch.float32` if output type is `torch.Tensor`).
+ If left blank, it remains unchanged.
Return:
- A dictionary consisting of two keys, the main data (stored under `key`) and
- the metadata.
+ A dictionary consisting of three keys, the main data (stored under `key`) and the metadata.
"""
+ if output_type not in (torch.Tensor, np.ndarray):
+ raise ValueError(f"output_type must be torch.Tensor or np.ndarray, got {output_type}.")
return {
- key: self.as_tensor().clone().detach(),
- PostFix.meta(key): deepcopy(self.meta),
- PostFix.transforms(key): deepcopy(self.applied_operations),
+ key: self.get_array(output_type=output_type, dtype=dtype),
+ PostFix.meta(key): self.meta,
+ PostFix.transforms(key): self.applied_operations,
}
- def astype(self, dtype, device=None, *unused_args, **unused_kwargs):
+ def astype(self, dtype, device=None, *_args, **_kwargs):
"""
Cast to ``dtype``, sharing data whenever possible.
Args:
dtype: dtypes such as np.float32, torch.float, "np.float32", float.
device: the device if `dtype` is a torch data type.
- unused_args: additional args (currently unused).
- unused_kwargs: additional kwargs (currently unused).
+ _args: additional args (currently unused).
+ _kwargs: additional kwargs (currently unused).
Returns:
data array instance
@@ -334,21 +425,23 @@ def astype(self, dtype, device=None, *unused_args, **unused_kwargs):
out_type = np.ndarray
else:
out_type = None
- return convert_data_type(self, output_type=out_type, device=device, dtype=dtype, wrap_sequence=True)[0]
+ return self.get_array(output_type=out_type, dtype=dtype, device=device)
@property
def affine(self) -> torch.Tensor:
- """Get the affine."""
- return self.meta.get("affine", self.get_default_affine())
+ """Get the affine. Defaults to ``torch.eye(4, dtype=torch.float64)``"""
+ return self.meta.get(MetaKeys.AFFINE, self.get_default_affine())
@affine.setter
def affine(self, d: NdarrayTensor) -> None:
"""Set the affine."""
- self.meta["affine"] = torch.as_tensor(d, device=self.device)
+ self.meta[MetaKeys.AFFINE] = torch.as_tensor(d, device=torch.device("cpu"))
@property
def pixdim(self):
"""Get the spacing"""
+ if self.is_batch:
+ return [affine_to_spacing(a) for a in self.affine]
return affine_to_spacing(self.affine)
def new_empty(self, size, dtype=None, device=None, requires_grad=False):
@@ -362,8 +455,16 @@ def new_empty(self, size, dtype=None, device=None, requires_grad=False):
self.as_tensor().new_empty(size=size, dtype=dtype, device=device, requires_grad=requires_grad)
)
+ def clone(self):
+ """returns a copy of the MetaTensor instance."""
+ new_inst = MetaTensor(self.as_tensor().clone())
+ new_inst.__dict__ = deepcopy(self.__dict__)
+ return new_inst
+
@staticmethod
- def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict, simple_keys: bool = False):
+ def ensure_torch_and_prune_meta(
+ im: NdarrayTensor, meta: dict, simple_keys: bool = False, pattern: str | None = None, sep: str = "."
+ ):
"""
Convert the image to `torch.Tensor`. If `affine` is in the `meta` dictionary,
convert that to `torch.Tensor`, too. Remove any superfluous metadata.
@@ -371,6 +472,12 @@ def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict, simple_keys: bool
Args:
im: Input image (`np.ndarray` or `torch.Tensor`)
meta: Metadata dictionary.
+ simple_keys: whether to keep only a simple subset of metadata keys.
+ pattern: combined with `sep`, a regular expression used to match and prune keys
+ in the metadata (nested dictionary), default to None, no key deletion.
+ sep: combined with `pattern`, used to match and delete keys in the metadata (nested dictionary).
+ default is ".", see also :py:class:`monai.transforms.DeleteItemsd`.
+ e.g. ``pattern=".*_code$", sep=" "`` removes any meta keys that ends with ``"_code"``.
Returns:
By default, a `MetaTensor` is returned.
@@ -385,12 +492,27 @@ def ensure_torch_and_prune_meta(im: NdarrayTensor, meta: dict, simple_keys: bool
# remove any superfluous metadata.
if simple_keys:
# ensure affine is of type `torch.Tensor`
- if "affine" in meta:
- meta["affine"] = convert_to_tensor(meta["affine"]) # bc-breaking
+ if MetaKeys.AFFINE in meta:
+ meta[MetaKeys.AFFINE] = convert_to_tensor(meta[MetaKeys.AFFINE]) # bc-breaking
remove_extra_metadata(meta) # bc-breaking
+ if pattern is not None:
+ meta = monai.transforms.DeleteItemsd(keys=pattern, sep=sep, use_re=True)(meta)
+
# return the `MetaTensor`
return MetaTensor(img, meta=meta)
def __repr__(self, *, tensor_contents=None):
+ """
+ Prints out a long represention of the MetaTensor object with metadata as well as content data.
+
+ Args:
+ tensor_contents: currently unused
+ """
return self.as_tensor().__repr__() + super().__repr__()
+
+ def __str__(self):
+ """
+ Prints a simpler representation of the tensor identical to torch.Tensor.__str__.
+ """
+ return str(self.as_tensor())
diff --git a/monai/data/synthetic.py b/monai/data/synthetic.py
index 46d555cf11..7f51b687fb 100644
--- a/monai/data/synthetic.py
+++ b/monai/data/synthetic.py
@@ -48,6 +48,9 @@ def create_test_image_2d(
channel_dim: if None, create an image without channel dimension, otherwise create
an image with channel dimension as first dim or last dim. Defaults to `None`.
random_state: the random generator to use. Defaults to `np.random`.
+
+ Returns:
+ Randomised Numpy array with shape (`width`, `height`)
"""
if rad_max <= rad_min:
@@ -120,6 +123,9 @@ def create_test_image_3d(
an image with channel dimension as first dim or last dim. Defaults to `None`.
random_state: the random generator to use. Defaults to `np.random`.
+ Returns:
+ Randomised Numpy array with shape (`width`, `height`, `depth`)
+
See also:
:py:meth:`~create_test_image_2d`
"""
diff --git a/monai/data/utils.py b/monai/data/utils.py
index 45294cc66e..c424d53e29 100644
--- a/monai/data/utils.py
+++ b/monai/data/utils.py
@@ -27,6 +27,7 @@
import torch
from torch.utils.data._utils.collate import default_collate
+from monai import config
from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor, PathLike
from monai.data.meta_obj import MetaObj
from monai.networks.layers.simplelayers import GaussianFilter
@@ -90,6 +91,7 @@
"remove_keys",
"remove_extra_metadata",
"get_extra_metadata_keys",
+ "PICKLE_KEY_SUFFIX",
]
# module to be used by `torch.save`
@@ -284,7 +286,7 @@ def iter_patch(
start_pos = ensure_tuple_size(start_pos, arr.ndim)
# set padded flag to false if pad mode is None
- padded = True if mode else False
+ padded = bool(mode)
# pad image by maximum values needed to ensure patches are taken from inside an image
if padded:
arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts)
@@ -391,6 +393,32 @@ def dev_collate(batch, level: int = 1, logger_name: str = "dev_collate"):
return
+PICKLE_KEY_SUFFIX = TraceKeys.KEY_SUFFIX
+
+
+def pickle_operations(data, key=PICKLE_KEY_SUFFIX, is_encode: bool = True):
+ """
+ Applied_operations are dictionaries with varying sizes, this method converts them to bytes so that we can (de-)collate.
+
+ Args:
+ data: a list or dictionary with substructures to be pickled/unpickled.
+ key: the key suffix for the target substructures, defaults to "_transforms" (`data.utils.PICKLE_KEY_SUFFIX`).
+ is_encode: whether it's encoding using pickle.dumps (True) or decoding using pickle.loads (False).
+ """
+ if isinstance(data, Mapping):
+ data = dict(data)
+ for k in data:
+ if f"{k}".endswith(key):
+ if is_encode and not isinstance(data[k], bytes):
+ data[k] = pickle.dumps(data[k], 0)
+ if not is_encode and isinstance(data[k], bytes):
+ data[k] = pickle.loads(data[k])
+ return {k: pickle_operations(v, key=key, is_encode=is_encode) for k, v in data.items()}
+ elif isinstance(data, (list, tuple)):
+ return [pickle_operations(item, key=key, is_encode=is_encode) for item in data]
+ return data
+
+
def collate_meta_tensor(batch):
"""collate a sequence of meta tensor sequences/dictionaries into
a single batched metatensor or a dictionary of batched metatensor"""
@@ -405,6 +433,9 @@ def collate_meta_tensor(batch):
return collated
if isinstance(elem_0, Mapping):
return {k: collate_meta_tensor([d[k] for d in batch]) for k in elem_0}
+ if isinstance(elem_0, (tuple, list)):
+ return [collate_meta_tensor([d[i] for d in batch]) for i in range(len(elem_0))]
+
# no more recursive search for MetaTensor
return default_collate(batch)
@@ -423,6 +454,8 @@ def list_data_collate(batch: Sequence):
data = [i for k in batch for i in k] if isinstance(elem, list) else batch
key = None
try:
+ if config.USE_META_DICT:
+ data = pickle_operations(data) # bc 0.9.0
if isinstance(elem, Mapping):
ret = {}
for k in elem:
@@ -573,10 +606,16 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None):
deco[k] = [deepcopy(deco[k]) for _ in range(b)]
if isinstance(deco, Mapping):
_gen = zip_longest(*deco.values(), fillvalue=fill_value) if pad else zip(*deco.values())
- return [dict(zip(deco, item)) for item in _gen]
+ ret = [dict(zip(deco, item)) for item in _gen]
+ if not config.USE_META_DICT:
+ return ret
+ return pickle_operations(ret, is_encode=False) # bc 0.9.0
if isinstance(deco, Iterable):
_gen = zip_longest(*deco, fillvalue=fill_value) if pad else zip(*deco)
- return [list(item) for item in _gen]
+ ret_list = [list(item) for item in _gen]
+ if not config.USE_META_DICT:
+ return ret_list
+ return pickle_operations(ret_list, is_encode=False) # bc 0.9.0
raise NotImplementedError(f"Unable to de-collate: {batch}, type: {type(batch)}.")
@@ -631,6 +670,11 @@ def set_rnd(obj, seed: int) -> int:
obj: object to set seed or random state for.
seed: set the random state with an integer seed.
"""
+ if isinstance(obj, (tuple, list)): # ZipDataset.data is a list
+ _seed = seed
+ for item in obj:
+ _seed = set_rnd(item, seed=seed)
+ return seed if _seed == seed else seed + 1 # return a different seed if there are randomizable items
if not hasattr(obj, "__dict__"):
return seed # no attribute
if hasattr(obj, "set_random_state"):
diff --git a/monai/data/video_dataset.py b/monai/data/video_dataset.py
new file mode 100644
index 0000000000..89bbeb586d
--- /dev/null
+++ b/monai/data/video_dataset.py
@@ -0,0 +1,238 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import sys
+import tempfile
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
+
+import numpy as np
+from torch.utils.data import Dataset, IterableDataset
+
+from monai.utils.enums import ColorOrder
+from monai.utils.module import optional_import
+
+if TYPE_CHECKING:
+ import cv2
+
+ has_cv2 = True
+else:
+ cv2, has_cv2 = optional_import("cv2")
+
+__all__ = ["VideoDataset", "VideoFileDataset", "CameraDataset"]
+
+
+class SuppressStderr:
+ """Suppres stderr. Useful as OpenCV (and dependencies) can produce a lot of output."""
+
+ def __enter__(self):
+ self.errnull_file = open(os.devnull, "w")
+ self.old_stderr_fileno_undup = sys.stderr.fileno()
+ self.old_stderr_fileno = os.dup(sys.stderr.fileno())
+ self.old_stderr = sys.stderr
+ os.dup2(self.errnull_file.fileno(), self.old_stderr_fileno_undup)
+ sys.stderr = self.errnull_file
+ return self
+
+ def __exit__(self, *_):
+ sys.stderr = self.old_stderr
+ os.dup2(self.old_stderr_fileno, self.old_stderr_fileno_undup)
+ os.close(self.old_stderr_fileno)
+ self.errnull_file.close()
+
+
+class VideoDataset:
+ def __init__(
+ self,
+ video_source: Union[str, int],
+ transform: Optional[Callable] = None,
+ max_num_frames: Optional[int] = None,
+ color_order: str = ColorOrder.RGB,
+ multiprocessing: bool = False,
+ channel_dim: int = 0,
+ ) -> None:
+ """
+ Base video dataset.
+
+ Args:
+ video_source: filename of video.
+ transform: transform to be applied to each frame.
+ max_num_frames: Max number of frames to iterate across. If `None` is passed,
+ then the dataset will iterate until the end of the file.
+ color_order: Color order to return frame. Default is RGB.
+ multiprocessing: If `True`, open the video source on the fly. This makes
+ things process-safe, which is useful when combined with a DataLoader
+ with `num_workers>0`. However, when using with `num_workers==0`, it
+ makes sense to use `multiprocessing=False`, as the source will then
+ only be opened once, at construction, which will be faster in those
+ circumstances.
+ channel_dim: OpenCV reads with the channel as the last dimension. Use this
+ flag to move it elsewhere. By default this is zero, so the channel
+ dimension is moved to the front.
+
+ Raises:
+ RuntimeError: OpenCV not installed.
+ NotImplementedError: Unknown color order.
+ """
+ if not has_cv2:
+ raise RuntimeError("OpenCV not installed.")
+ if color_order not in ColorOrder:
+ raise NotImplementedError
+
+ self.color_order = color_order
+ self.channel_dim = channel_dim
+ self.video_source = video_source
+ self.multiprocessing = multiprocessing
+ if not multiprocessing:
+ self.cap = self.open_video(video_source)
+ self.transform = transform
+ self.max_num_frames = max_num_frames
+
+ @staticmethod
+ def open_video(video_source: Union[str, int]):
+ """
+ Use OpenCV to open a video source from either file or capture device.
+
+ Args:
+ video_source: filename or index referring to capture device.
+
+ Raises:
+ RuntimeError: Source is a file but file not found.
+ RuntimeError: Failed to open source.
+ """
+ if isinstance(video_source, str) and not os.path.isfile(video_source):
+ raise RuntimeError("Video file does not exist: " + video_source)
+ with SuppressStderr():
+ cap = cv2.VideoCapture(video_source)
+ if not cap.isOpened():
+ raise RuntimeError(f"Failed to open video: {video_source}")
+ return cap
+
+ def _get_cap(self):
+ """Return the cap. If multiprocesing, create a new one. Else return the one from construction time."""
+ return self.open_video(self.video_source) if self.multiprocessing else self.cap
+
+ def get_fps(self) -> int:
+ """Get the FPS of the capture device."""
+ return self._get_cap().get(cv2.CAP_PROP_FPS) # type: ignore
+
+ def get_frame(self) -> Any:
+ """Get next frame. For a file, this will be the next frame, whereas for a camera
+ source, it will be the next available frame."""
+ ret, frame = self._get_cap().read()
+ if not ret:
+ raise RuntimeError("Failed to read frame.")
+ # Switch color order if desired
+ if self.color_order == ColorOrder.RGB:
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ # move channel dim
+ frame = np.moveaxis(frame, -1, self.channel_dim)
+ return self.transform(frame) if self.transform is not None else frame
+
+
+class VideoFileDataset(Dataset, VideoDataset):
+ """
+ Video dataset from file.
+
+ This class requires that OpenCV be installed.
+ """
+
+ def __init__(self, *args, **kwargs) -> None:
+ VideoDataset.__init__(self, *args, **kwargs)
+ num_frames = self.get_num_frames()
+ if self.max_num_frames is None or num_frames < self.max_num_frames:
+ self.max_num_frames = num_frames
+
+ @staticmethod
+ def get_available_codecs() -> Dict[str, str]:
+ """Try different codecs, see which are available.
+ Returns a dictionary with of available codecs with codecs as keys and file extensions as values."""
+ if not has_cv2:
+ return {}
+ all_codecs = {"mp4v": ".mp4", "X264": ".avi", "H264": ".mp4", "MP42": ".mp4", "MJPG": ".mjpeg", "DIVX": ".avi"}
+ codecs = {}
+ with SuppressStderr():
+ writer = cv2.VideoWriter()
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ for codec, ext in all_codecs.items():
+ fname = os.path.join(tmp_dir, f"test{ext}")
+ fourcc = cv2.VideoWriter_fourcc(*codec)
+ noviderr = writer.open(fname, fourcc, 1, (10, 10))
+ if noviderr:
+ codecs[codec] = ext
+ writer.release()
+ return codecs
+
+ def get_num_frames(self) -> int:
+ """
+ Return the number of frames in a video file.
+
+ Raises:
+ RuntimeError: no frames found.
+ """
+ num_frames = int(self._get_cap().get(cv2.CAP_PROP_FRAME_COUNT))
+ if num_frames == 0:
+ raise RuntimeError("0 frames found")
+ return num_frames
+
+ def __len__(self):
+ return self.max_num_frames
+
+ def __getitem__(self, index: int) -> Any:
+ """
+ Fetch single data item from index.
+ """
+ if self.max_num_frames is not None and index >= self.max_num_frames:
+ raise IndexError
+ self._get_cap().set(cv2.CAP_PROP_POS_FRAMES, index)
+ return self.get_frame()
+
+
+class CameraDataset(IterableDataset, VideoDataset):
+ """
+ Video dataset from a capture device (e.g., webcam).
+
+ This class requires that OpenCV be installed.
+
+ Args:
+ video_source: index of capture device.
+ `get_num_devices` can be used to determine possible devices.
+ transform: transform to be applied to each frame.
+ max_num_frames: Max number of frames to iterate across. If `None` is passed,
+ then the dataset will iterate infinitely.
+
+ Raises:
+ RuntimeError: OpenCV not installed.
+ """
+
+ @staticmethod
+ def get_num_devices() -> int:
+ """Get number of possible devices detected by OpenCV that can be used for capture."""
+ if not has_cv2:
+ return 0
+ num_devices = 0
+ while True:
+ cap = cv2.VideoCapture(num_devices)
+ if not cap.read()[0]:
+ break
+ num_devices += 1
+ cap.release()
+ return num_devices
+
+ def __iter__(self):
+ frame_count = 0
+ while True:
+ frame = self.get_frame()
+ frame_count += 1
+ yield frame
+ if self.max_num_frames is not None:
+ if frame_count == self.max_num_frames:
+ break
diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py
index 37439c6e59..dc65bc8187 100644
--- a/monai/data/wsi_datasets.py
+++ b/monai/data/wsi_datasets.py
@@ -214,7 +214,7 @@ def __init__(
**kwargs,
):
super().__init__(
- data=data,
+ data=[],
patch_size=patch_size,
patch_level=patch_level,
transform=transform,
@@ -255,8 +255,8 @@ def __init__(
self.mask_level = mask_level
# Create single sample for each patch (in a sliding window manner)
- self.data = []
- self.image_data = data
+ self.data: list
+ self.image_data = list(data)
for sample in self.image_data:
patch_samples = self._evaluate_patch_locations(sample)
self.data.extend(patch_samples)
@@ -352,7 +352,7 @@ def __init__(
**kwargs,
):
super().__init__(
- data=data,
+ data=[],
patch_size=patch_size,
patch_level=patch_level,
transform=transform,
@@ -365,8 +365,8 @@ def __init__(
self.mask_level = mask_level
# Create single sample for each patch (in a sliding window manner)
- self.data = []
- self.image_data = data
+ self.data: list
+ self.image_data = list(data)
for sample in self.image_data:
patch_samples = self._evaluate_patch_locations(sample)
self.data.extend(patch_samples)
diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py
index 0bb2de987c..148a15fa3d 100644
--- a/monai/data/wsi_reader.py
+++ b/monai/data/wsi_reader.py
@@ -262,6 +262,7 @@ class WSIReader(BaseWSIReader):
backend: the name of backend whole slide image reader library, the default is cuCIM.
level: the level at which patches are extracted.
channel_dim: the desired dimension for color channel. Default to 0 (channel first).
+ num_workers: number of workers for multi-thread image loading (cucim backend only).
kwargs: additional arguments to be passed to the backend library
"""
@@ -356,6 +357,7 @@ class CuCIMWSIReader(BaseWSIReader):
level: the whole slide image level at which the image is extracted. (default=0)
This is overridden if the level argument is provided in `get_data`.
channel_dim: the desired dimension for color channel. Default to 0 (channel first).
+ num_workers: number of workers for multi-thread image loading
kwargs: additional args for `cucim.CuImage` module:
https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h
@@ -364,8 +366,9 @@ class CuCIMWSIReader(BaseWSIReader):
supported_suffixes = ["tif", "tiff", "svs"]
backend = "cucim"
- def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs):
+ def __init__(self, level: int = 0, channel_dim: int = 0, num_workers: int = 0, **kwargs):
super().__init__(level, channel_dim, **kwargs)
+ self.num_workers = num_workers
@staticmethod
def get_level_count(wsi) -> int:
@@ -448,7 +451,9 @@ def get_patch(
"""
# Extract a patch or the entire image
# (reverse the order of location and size to become WxH for cuCIM)
- patch: np.ndarray = wsi.read_region(location=location[::-1], size=size[::-1], level=level)
+ patch: np.ndarray = wsi.read_region(
+ location=location[::-1], size=size[::-1], level=level, num_workers=self.num_workers
+ )
# Convert to numpy
patch = np.asarray(patch, dtype=dtype)
diff --git a/monai/engines/__init__.py b/monai/engines/__init__.py
index 88f094c732..b6e54a6c4e 100644
--- a/monai/engines/__init__.py
+++ b/monai/engines/__init__.py
@@ -13,7 +13,6 @@
from .multi_gpu_supervised_trainer import create_multigpu_supervised_evaluator, create_multigpu_supervised_trainer
from .trainer import GanTrainer, SupervisedTrainer, Trainer
from .utils import (
- GanKeys,
IterationEvents,
PrepareBatch,
PrepareBatchDefault,
@@ -24,4 +23,4 @@
engine_apply_transform,
get_devices_spec,
)
-from .workflow import BaseWorkflow, Workflow
+from .workflow import Workflow
diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py
index 7999bb9bd6..2bf172418a 100644
--- a/monai/engines/evaluator.py
+++ b/monai/engines/evaluator.py
@@ -22,8 +22,9 @@
from monai.inferers import Inferer, SimpleInferer
from monai.networks.utils import eval_mode, train_mode
from monai.transforms import Transform
-from monai.utils import ForwardMode, ensure_tuple, min_version, optional_import
+from monai.utils import ForwardMode, deprecated, ensure_tuple, min_version, optional_import
from monai.utils.enums import CommonKeys as Keys
+from monai.utils.enums import EngineStatsKeys as ESKeys
from monai.utils.module import look_up_option
if TYPE_CHECKING:
@@ -146,7 +147,28 @@ def run(self, global_epoch: int = 1) -> None:
self.state.iteration = 0
super().run()
- def get_validation_stats(self) -> dict[str, float]:
+ def get_stats(self, *vars):
+ """
+ Get the statistics information of the validation process.
+ Default to return the `rank`, `best_validation_epoch` and `best_validation_metric`.
+
+ Args:
+ vars: except for the default stats, other variables name in the `self.state` to return,
+ will use the variable name as the key and the state content as the value.
+ if the variable doesn't exist, default value is `None`.
+
+ """
+ stats = {
+ ESKeys.RANK: self.state.rank,
+ ESKeys.BEST_VALIDATION_EPOCH: self.state.best_metric_epoch,
+ ESKeys.BEST_VALIDATION_METRIC: self.state.best_metric,
+ }
+ for k in vars:
+ stats[k] = getattr(self.state, k, None)
+ return stats
+
+ @deprecated(since="0.9", msg_suffix="please use the `get_stats()` API instead.")
+ def get_validation_stats(self):
return {"best_validation_metric": self.state.best_metric, "best_validation_epoch": self.state.best_metric_epoch}
diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py
index 2b7a1acd2a..12007b6294 100644
--- a/monai/engines/trainer.py
+++ b/monai/engines/trainer.py
@@ -18,18 +18,13 @@
from torch.utils.data import DataLoader
from monai.config import IgniteInfo
-from monai.engines.utils import (
- GanKeys,
- IterationEvents,
- default_make_latent,
- default_metric_cmp_fn,
- default_prepare_batch,
-)
+from monai.engines.utils import IterationEvents, default_make_latent, default_metric_cmp_fn, default_prepare_batch
from monai.engines.workflow import Workflow
from monai.inferers import Inferer, SimpleInferer
from monai.transforms import Transform
-from monai.utils import min_version, optional_import
+from monai.utils import GanKeys, deprecated, min_version, optional_import
from monai.utils.enums import CommonKeys as Keys
+from monai.utils.enums import EngineStatsKeys as ESKeys
if TYPE_CHECKING:
from ignite.engine import Engine, EventEnum
@@ -57,8 +52,31 @@ def run(self) -> None:
self.scaler = torch.cuda.amp.GradScaler() if self.amp else None
super().run()
- def get_train_stats(self) -> dict[str, float]:
- return {"total_epochs": self.state.max_epochs, "total_iterations": self.state.epoch_length}
+ def get_stats(self, *vars):
+ """
+ Get the statistics information of the training process.
+ Default to return the `rank`, `current_epoch`, `current_iteration`, `total_epochs`, `total_iterations`.
+
+ Args:
+ vars: except for the default stats, other variables name in the `self.state` to return,
+ will use the variable name as the key and the state content as the value.
+ if the variable doesn't exist, default value is `None`.
+
+ """
+ stats = {
+ ESKeys.RANK: self.state.rank,
+ ESKeys.CURRENT_EPOCH: self.state.epoch,
+ ESKeys.CURRENT_ITERATION: self.state.iteration,
+ ESKeys.TOTAL_EPOCHS: self.state.max_epochs,
+ ESKeys.TOTAL_ITERATIONS: self.state.epoch_length,
+ }
+ for k in vars:
+ stats[k] = getattr(self.state, k, None)
+ return stats
+
+ @deprecated(since="0.9", msg_suffix="please use the `get_stats()` API instead.")
+ def get_train_stats(self):
+ return self.get_stats()
class SupervisedTrainer(Trainer):
diff --git a/monai/engines/utils.py b/monai/engines/utils.py
index 8f3a57beda..21b77bc756 100644
--- a/monai/engines/utils.py
+++ b/monai/engines/utils.py
@@ -17,7 +17,7 @@
from monai.config import IgniteInfo
from monai.transforms import apply_transform
from monai.utils import ensure_tuple, min_version, optional_import
-from monai.utils.enums import CommonKeys
+from monai.utils.enums import CommonKeys, GanKeys
if TYPE_CHECKING:
from ignite.engine import EventEnum
@@ -26,7 +26,6 @@
__all__ = [
"IterationEvents",
- "GanKeys",
"get_devices_spec",
"default_prepare_batch",
"PrepareBatch",
@@ -59,19 +58,6 @@ class IterationEvents(EventEnum):
INNER_ITERATION_COMPLETED = "inner_iteration_completed"
-class GanKeys:
- """
- A set of common keys for generative adversarial networks.
-
- """
-
- REALS = "reals"
- FAKES = "fakes"
- LATENTS = "latents"
- GLOSS = "g_loss"
- DLOSS = "d_loss"
-
-
def get_devices_spec(devices: Optional[Sequence[torch.device]] = None) -> List[torch.device]:
"""
Get a valid specification for one or more devices. If `devices` is None get devices for all CUDA devices available.
diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py
index 8349ff82ab..0886da6344 100644
--- a/monai/engines/workflow.py
+++ b/monai/engines/workflow.py
@@ -10,7 +10,6 @@
# limitations under the License.
import warnings
-from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Sequence, Union
import torch
@@ -38,18 +37,6 @@
EventEnum, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "EventEnum")
-class BaseWorkflow(ABC):
- """
- Base class for any MONAI style workflow.
- `run()` is designed to execute the train, evaluation or inference logic.
-
- """
-
- @abstractmethod
- def run(self, *args, **kwargs):
- raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.")
-
-
class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optional_import
"""
Workflow defines the core work process inheriting from Ignite engine.
@@ -133,7 +120,7 @@ def __init__(
else:
super().__init__(self._iteration)
if not isinstance(device, torch.device):
- raise TypeError(f"device must be a torch.device but is {type(device).__name__}.")
+ raise TypeError(f"Device must be a torch.device but is {type(device).__name__}.")
if isinstance(data_loader, DataLoader):
sampler = data_loader.__dict__["sampler"]
@@ -147,7 +134,7 @@ def set_sampler_epoch(engine: Engine):
epoch_length = len(data_loader)
else:
if epoch_length is None:
- raise ValueError("if data_loader is not PyTorch DataLoader, must specify the epoch_length.")
+ raise ValueError("If data_loader is not PyTorch DataLoader, must specify the epoch_length.")
# set all sharable data for the workflow based on Ignite engine.state
self.state = State(
@@ -180,7 +167,7 @@ def set_sampler_epoch(engine: Engine):
event_names = [IterationEvents] # type: ignore
else:
if not isinstance(event_names, list):
- raise ValueError("event_names must be a list or string or EventEnum.")
+ raise ValueError("`event_names` must be a list or string or EventEnum.")
event_names += [IterationEvents] # type: ignore
for name in event_names:
if isinstance(name, str):
@@ -188,7 +175,7 @@ def set_sampler_epoch(engine: Engine):
elif issubclass(name, EventEnum): # type: ignore
self.register_events(*name, event_to_attr=event_to_attr)
else:
- raise ValueError("event_names must be a list or string or EventEnum.")
+ raise ValueError("`event_names` must be a list or string or EventEnum.")
if decollate:
self._register_decollate()
@@ -239,12 +226,12 @@ def _register_metrics(self, k_metric: Dict, add_metrics: Optional[Dict] = None):
"""
if not isinstance(k_metric, dict):
- raise TypeError(f"key_metric must be None or a dict but is {type(k_metric).__name__}.")
+ 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 = dict(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__}.")
+ 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)
@@ -256,12 +243,14 @@ def _compare_metrics(engine: Workflow) -> None:
current_val_metric = engine.state.metrics[key_metric_name]
if not is_scalar(current_val_metric):
warnings.warn(
- "key metric is not a scalar value, skip the metric comparison with the current best metric."
- "please set other metrics as the key metric, or change the `reduction` mode to 'mean'."
+ "Key metric is not a scalar value, skip the metric comparison with the current best metric."
+ "Please set other metrics as the key metric, or change the `reduction` mode to 'mean'."
)
return
- if self.metric_cmp_fn(current_val_metric, engine.state.best_metric):
+ if engine.state.best_metric_epoch == -1 or self.metric_cmp_fn(
+ current_val_metric, engine.state.best_metric
+ ):
self.logger.info(f"Got new best metric of {key_metric_name}: {current_val_metric}")
engine.state.best_metric = current_val_metric
engine.state.best_metric_epoch = engine.state.epoch
@@ -278,12 +267,11 @@ def _register_handlers(self, handlers: Sequence):
def run(self) -> None:
"""
Execute training, validation or evaluation based on Ignite Engine.
-
"""
if self.state.epoch_length == 0:
warnings.warn(
"`dataloader` is empty or the specified `epoch_length` is 0, skip the `run`."
- " if running distributed training, the program may hang in `all-gather`, `all-reduce`, etc."
+ " If running distributed training, the program may hang in `all-gather`, `all-reduce`, etc."
" because not all the ranks run the same computation logic."
)
return
@@ -303,3 +291,14 @@ def _iteration(self, engine, batchdata: Dict[str, torch.Tensor]):
"""
raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.")
+
+ def get_stats(self, *vars):
+ """
+ Get the statistics information of the workflow process.
+
+ Args:
+ vars: variables name in the `self.state`, will use the variable name as the key
+ and the state content as the value. if the variable doesn't exist, default value is `None`.
+
+ """
+ return {k: getattr(self.state, k, None) for k in vars}
diff --git a/monai/fl/__init__.py b/monai/fl/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/fl/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/fl/client/__init__.py b/monai/fl/client/__init__.py
new file mode 100644
index 0000000000..cca669e0eb
--- /dev/null
+++ b/monai/fl/client/__init__.py
@@ -0,0 +1,13 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .client_algo import ClientAlgo
+from .monai_algo import MonaiAlgo
diff --git a/monai/fl/client/client_algo.py b/monai/fl/client/client_algo.py
new file mode 100644
index 0000000000..4cab0a4474
--- /dev/null
+++ b/monai/fl/client/client_algo.py
@@ -0,0 +1,79 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import abc
+
+from monai.fl.utils.exchange_object import ExchangeObject
+
+
+class ClientAlgo(abc.ABC):
+ """
+ objective: provide an abstract base class for defining algo to run on any platform.
+ To define a new algo script, subclass this class and implement the
+ following abstract methods:
+
+ - #Algo.train()
+ - #Algo.get_weights()
+ - #Algo.evaluate()
+
+ initialize() and finalize() can be optionally be implemented to help with lifecycle management of the object.
+ """
+
+ def initialize(self, extra=None):
+ """call to initialize the ClientAlgo class"""
+ pass
+
+ def finalize(self, extra=None):
+ """call to finalize the ClientAlgo class"""
+ pass
+
+ def abort(self, extra=None):
+ """call to abort the ClientAlgo training or evaluation"""
+ pass
+
+ @abc.abstractmethod
+ def train(self, data: ExchangeObject, extra=None) -> None:
+ """
+ objective: train network and produce new network from train data.
+ # Arguments
+ data: ExchangeObject containing current network weights to base training on.
+ """
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def get_weights(self, extra=None) -> ExchangeObject:
+ """
+ objective: get current local weights or weight differences
+
+ # Returns
+ ExchangeObject: current local weights or weight differences.
+
+ # Example returns ExchangeObject, e.g.::
+
+ ExchangeObject(
+ weights = self.trainer.network.state_dict(),
+ optim = None, # could be self.optimizer.state_dict()
+ weight_type = WeightType.WEIGHTS
+ )
+ """
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def evaluate(self, data: ExchangeObject, extra=None) -> ExchangeObject:
+ """
+ objective: get evaluation metrics on test data.
+ # Arguments
+ data: ExchangeObject with network weights to use for evaluation
+
+ # Returns
+ metrics: ExchangeObject with evaluation metrics.
+ """
+ raise NotImplementedError
diff --git a/monai/fl/client/monai_algo.py b/monai/fl/client/monai_algo.py
new file mode 100644
index 0000000000..013fe0ed1b
--- /dev/null
+++ b/monai/fl/client/monai_algo.py
@@ -0,0 +1,422 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import os
+import sys
+from typing import TYPE_CHECKING, Optional
+
+import torch
+
+import monai
+from monai.bundle import ConfigParser
+from monai.bundle.config_item import ConfigComponent, ConfigItem
+from monai.config import IgniteInfo
+from monai.fl.client.client_algo import ClientAlgo
+from monai.fl.utils.constants import (
+ BundleKeys,
+ ExtraItems,
+ FiltersType,
+ FlPhase,
+ FlStatistics,
+ ModelType,
+ RequiredBundleKeys,
+ WeightType,
+)
+from monai.fl.utils.exchange_object import ExchangeObject
+from monai.networks.utils import copy_model_state, get_state_dict
+from monai.utils import min_version, optional_import
+
+if TYPE_CHECKING:
+ from ignite.engine import Events
+else:
+ Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events")
+
+logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s - %(message)s")
+
+
+def convert_global_weights(global_weights, local_var_dict):
+ """Helper function to convert global weights to local weights format"""
+ # Before loading weights, tensors might need to be reshaped to support HE for secure aggregation.
+ model_keys = global_weights.keys()
+ for var_name in local_var_dict:
+ if var_name in model_keys:
+ weights = global_weights[var_name]
+ try:
+ # reshape global weights to compute difference later on
+ weights = torch.reshape(torch.as_tensor(weights), local_var_dict[var_name].shape)
+ # update the local dict
+ local_var_dict[var_name] = weights
+ except Exception as e:
+ raise ValueError(f"Convert weight from {var_name} failed.") from e
+ return local_var_dict
+
+
+def compute_weight_diff(global_weights, local_var_dict):
+ if global_weights is None:
+ raise ValueError("Cannot compute weight differences if `global_weights` is None!")
+ if local_var_dict is None:
+ raise ValueError("Cannot compute weight differences if `local_var_dict` is None!")
+ # compute delta model, global model has the primary key set
+ weight_diff = {}
+ for name in global_weights:
+ if name not in local_var_dict:
+ continue
+ weight_diff[name] = local_var_dict[name].cpu() - global_weights[name]
+ if torch.any(torch.isnan(weight_diff[name])):
+ raise ValueError(f"Weights for {name} became NaN...")
+ return weight_diff
+
+
+def check_bundle_config(parser):
+ for k in RequiredBundleKeys:
+ if parser.get(k, None) is None:
+ raise KeyError(f"Bundle config misses required key `{k}`")
+
+
+def remove_ckpt_loader(parser):
+ if BundleKeys.VALIDATE_HANDLERS in parser:
+ for h in parser[BundleKeys.VALIDATE_HANDLERS]:
+ if ConfigComponent.is_instantiable(h):
+ if "CheckpointLoader" in h["_target_"]:
+ h["_disabled_"] = True
+
+
+class MonaiAlgo(ClientAlgo):
+ """
+ Implementation of ``ClientAlgo`` to allow federated learning with MONAI bundle configurations.
+
+ Args:
+ bundle_root: path of bundle.
+ local_epochs: number of local epochs to execute during each round of local training; defaults to 1.
+ send_weight_diff: whether to send weight differences rather than full weights; defaults to `True`.
+ config_train_filename: bundle training config path relative to bundle_root; defaults to "configs/train.json".
+ config_evaluate_filename: bundle evaluation config path relative to bundle_root; defaults to "configs/evaluate.json".
+ config_filters_filename: filter configuration file.
+ disable_ckpt_loader: do not use CheckpointLoader if defined in train/evaluate configs; defaults to `True`.
+ best_model_filepath: location of best model checkpoint; defaults "models/model.pt" relative to `bundle_root`.
+ final_model_filepath: location of final model checkpoint; defaults "models/model_final.pt" relative to `bundle_root`.
+ seed: set random seed for modules to enable or disable deterministic training; defaults to `None`,
+ i.e., non-deterministic training.
+ benchmark: set benchmark to `False` for full deterministic behavior in cuDNN components.
+ Note, full determinism in federated learning depends also on deterministic behavior of other FL components,
+ e.g., the aggregator, which is not controlled by this class.
+ """
+
+ def __init__(
+ self,
+ bundle_root: str,
+ local_epochs: int = 1,
+ send_weight_diff: bool = True,
+ config_train_filename: Optional[str] = "configs/train.json",
+ config_evaluate_filename: Optional[str] = "configs/evaluate.json",
+ config_filters_filename: Optional[str] = None,
+ disable_ckpt_loader: bool = True,
+ best_model_filepath: Optional[str] = "models/model.pt",
+ final_model_filepath: Optional[str] = "models/model_final.pt",
+ seed: Optional[int] = None,
+ benchmark: bool = True,
+ ):
+ self.logger = logging.getLogger(self.__class__.__name__)
+
+ self.bundle_root = bundle_root
+ self.local_epochs = local_epochs
+ self.send_weight_diff = send_weight_diff
+ self.config_train_filename = config_train_filename
+ self.config_evaluate_filename = config_evaluate_filename
+ self.config_filters_filename = config_filters_filename
+ self.disable_ckpt_loader = disable_ckpt_loader
+ self.model_filepaths = {ModelType.BEST_MODEL: best_model_filepath, ModelType.FINAL_MODEL: final_model_filepath}
+ self.seed = seed
+ self.benchmark = benchmark
+
+ self.app_root = None
+ self.train_parser = None
+ self.eval_parser = None
+ self.filter_parser = None
+ self.trainer = None
+ self.evaluator = None
+ self.pre_filters = None
+ self.post_weight_filters = None
+ self.post_evaluate_filters = None
+ self.iter_of_start_time = 0
+ self.global_weights = None
+
+ self.phase = FlPhase.IDLE
+ self.client_name = None
+
+ def initialize(self, extra=None):
+ """
+ Initialize routine to parse configuration files and extract main components such as trainer, evaluator, and filters.
+
+ Args:
+ extra: Dict with additional information that should be provided by FL system,
+ i.e., `ExtraItems.CLIENT_NAME` and `ExtraItems.APP_ROOT`.
+
+ """
+ if extra is None:
+ extra = {}
+ self.client_name = extra.get(ExtraItems.CLIENT_NAME, "noname")
+ self.logger.info(f"Initializing {self.client_name} ...")
+
+ if self.seed:
+ monai.utils.set_determinism(seed=self.seed)
+ torch.backends.cudnn.benchmark = self.benchmark
+
+ # FL platform needs to provide filepath to configuration files
+ self.app_root = extra.get(ExtraItems.APP_ROOT, "")
+
+ # Read bundle config files
+ self.bundle_root = os.path.join(self.app_root, self.bundle_root)
+
+ config_train_files = []
+ config_eval_files = []
+ config_filter_files = []
+ if self.config_train_filename:
+ config_train_files.append(os.path.join(self.bundle_root, self.config_train_filename))
+ config_eval_files.append(os.path.join(self.bundle_root, self.config_train_filename))
+ if self.config_evaluate_filename:
+ config_eval_files.append(os.path.join(self.bundle_root, self.config_evaluate_filename))
+ if self.config_filters_filename: # filters are read by train_parser
+ config_filter_files.append(os.path.join(self.bundle_root, self.config_filters_filename))
+
+ # Parse
+ self.train_parser = ConfigParser()
+ self.eval_parser = ConfigParser()
+ self.filter_parser = ConfigParser()
+ if len(config_train_files) > 0:
+ self.train_parser.read_config(config_train_files)
+ check_bundle_config(self.train_parser)
+ if len(config_eval_files) > 0:
+ self.eval_parser.read_config(config_eval_files)
+ check_bundle_config(self.eval_parser)
+ if len(config_filter_files) > 0:
+ self.filter_parser.read_config(config_filter_files)
+
+ # override some config items
+ self.train_parser[RequiredBundleKeys.BUNDLE_ROOT] = self.bundle_root
+ self.eval_parser[RequiredBundleKeys.BUNDLE_ROOT] = self.bundle_root
+ # number of training epochs for each round
+ if BundleKeys.TRAIN_TRAINER_MAX_EPOCHS in self.train_parser:
+ self.train_parser[BundleKeys.TRAIN_TRAINER_MAX_EPOCHS] = self.local_epochs
+
+ # remove checkpoint loaders
+ if self.disable_ckpt_loader:
+ remove_ckpt_loader(self.train_parser)
+ remove_ckpt_loader(self.eval_parser)
+
+ # Get trainer, evaluator
+ self.trainer = self.train_parser.get_parsed_content(
+ BundleKeys.TRAINER, default=ConfigItem(None, BundleKeys.TRAINER)
+ )
+ self.evaluator = self.eval_parser.get_parsed_content(
+ BundleKeys.EVALUATOR, default=ConfigItem(None, BundleKeys.EVALUATOR)
+ )
+
+ # Get filters
+ self.pre_filters = self.filter_parser.get_parsed_content(
+ FiltersType.PRE_FILTERS, default=ConfigItem(None, FiltersType.PRE_FILTERS)
+ )
+ self.post_weight_filters = self.filter_parser.get_parsed_content(
+ FiltersType.POST_WEIGHT_FILTERS, default=ConfigItem(None, FiltersType.POST_WEIGHT_FILTERS)
+ )
+ self.post_evaluate_filters = self.filter_parser.get_parsed_content(
+ FiltersType.POST_EVALUATE_FILTERS, default=ConfigItem(None, FiltersType.POST_EVALUATE_FILTERS)
+ )
+
+ self.logger.info(f"Initialized {self.client_name}.")
+
+ def train(self, data: ExchangeObject, extra=None):
+ """
+ Train on client's local data.
+
+ Args:
+ data: `ExchangeObject` containing the current global model weights.
+ extra: Dict with additional information that can be provided by FL system.
+
+ """
+ if extra is None:
+ extra = {}
+ if not isinstance(data, ExchangeObject):
+ raise ValueError(f"expected data to be ExchangeObject but received {type(data)}")
+
+ if self.trainer is None:
+ raise ValueError("self.trainer should not be None.")
+ if self.pre_filters is not None:
+ for _filter in self.pre_filters:
+ data = _filter(data, extra)
+ self.phase = FlPhase.TRAIN
+ self.logger.info(f"Load {self.client_name} weights...")
+ self.global_weights = convert_global_weights(
+ global_weights=data.weights, local_var_dict=get_state_dict(self.trainer.network)
+ )
+
+ # set engine state max epochs.
+ self.trainer.state.max_epochs = self.trainer.state.epoch + self.local_epochs
+ # get current iteration when a round starts
+ self.iter_of_start_time = self.trainer.state.iteration
+
+ _, updated_keys, _ = copy_model_state(src=self.global_weights, dst=self.trainer.network)
+ if len(updated_keys) == 0:
+ self.logger.warning("No weights loaded!")
+ self.logger.info(f"Start {self.client_name} training...")
+ self.trainer.run()
+
+ def get_weights(self, extra=None):
+ """
+ Returns the current weights of the model.
+
+ Args:
+ extra: Dict with additional information that can be provided by FL system.
+
+ Returns:
+ return_weights: `ExchangeObject` containing current weights (default)
+ or load requested model type from disk (`ModelType.BEST_MODEL` or `ModelType.FINAL_MODEL`).
+
+ """
+ if extra is None:
+ extra = {}
+
+ # by default return current weights, return best if requested via model type.
+ self.phase = FlPhase.GET_WEIGHTS
+
+ if ExtraItems.MODEL_TYPE in extra:
+ model_type = extra.get(ExtraItems.MODEL_TYPE)
+ if not isinstance(model_type, ModelType):
+ raise ValueError(
+ f"Expected requested model type to be of type `ModelType` but received {type(model_type)}"
+ )
+ if model_type in self.model_filepaths:
+ model_path = os.path.join(self.bundle_root, self.model_filepaths[model_type])
+ if not os.path.isfile(model_path):
+ raise ValueError(f"No best model checkpoint exists at {model_path}")
+ weights = torch.load(model_path, map_location="cpu")
+ weigh_type = WeightType.WEIGHTS
+ stats = dict()
+ self.logger.info(f"Returning best checkpoint weights from {model_path}.")
+ else:
+ raise ValueError(
+ f"Requested model type {model_type} not specified in `model_filepahts`: {self.model_filepaths}"
+ )
+ else:
+ if self.trainer:
+ weights = get_state_dict(self.trainer.network)
+ weigh_type = WeightType.WEIGHTS
+ stats = self.trainer.get_stats()
+ # calculate current iteration and epoch data after training.
+ stats[FlStatistics.NUM_EXECUTED_ITERATIONS] = self.trainer.state.iteration - self.iter_of_start_time
+ # compute weight differences
+ if self.send_weight_diff:
+ weights = compute_weight_diff(global_weights=self.global_weights, local_var_dict=weights)
+ weigh_type = WeightType.WEIGHT_DIFF
+ self.logger.info("Returning current weight differences.")
+ else:
+ self.logger.info("Returning current weights.")
+ else:
+ weights = None
+ weigh_type = None
+ stats = dict()
+
+ if not isinstance(stats, dict):
+ raise ValueError(f"stats is not a dict, {stats}")
+ return_weights = ExchangeObject(
+ weights=weights,
+ optim=None, # could be self.optimizer.state_dict()
+ weight_type=weigh_type,
+ statistics=stats,
+ )
+
+ # filter weights if needed (use to apply differential privacy, encryption, compression, etc.)
+ if self.post_weight_filters is not None:
+ for _filter in self.post_weight_filters:
+ return_weights = _filter(return_weights, extra)
+
+ return return_weights
+
+ def evaluate(self, data: ExchangeObject, extra=None):
+ """
+ Evaluate on client's local data.
+
+ Args:
+ data: `ExchangeObject` containing the current global model weights.
+ extra: Dict with additional information that can be provided by FL system.
+
+ Returns:
+ return_metrics: `ExchangeObject` containing evaluation metrics.
+
+ """
+ if extra is None:
+ extra = {}
+ if not isinstance(data, ExchangeObject):
+ raise ValueError(f"expected data to be ExchangeObject but received {type(data)}")
+
+ if self.evaluator is None:
+ raise ValueError("self.evaluator should not be None.")
+ if self.pre_filters is not None:
+ for _filter in self.pre_filters:
+ data = _filter(data, extra)
+
+ self.phase = FlPhase.EVALUATE
+ self.logger.info(f"Load {self.client_name} weights...")
+ global_weights = convert_global_weights(
+ global_weights=data.weights, local_var_dict=get_state_dict(self.evaluator.network)
+ )
+
+ _, updated_keys, _ = copy_model_state(src=global_weights, dst=self.evaluator.network)
+ if len(updated_keys) == 0:
+ self.logger.warning("No weights loaded!")
+ self.logger.info(f"Start {self.client_name} evaluating...")
+ if isinstance(self.trainer, monai.engines.Trainer):
+ self.evaluator.run(self.trainer.state.epoch + 1)
+ else:
+ self.evaluator.run()
+ return_metrics = ExchangeObject(metrics=self.evaluator.state.metrics)
+
+ if self.post_evaluate_filters is not None:
+ for _filter in self.post_evaluate_filters:
+ return_metrics = _filter(return_metrics, extra)
+ return return_metrics
+
+ def abort(self, extra=None):
+ """
+ Abort the training or evaluation.
+ Args:
+ extra: Dict with additional information that can be provided by FL system.
+ """
+
+ self.logger.info(f"Aborting {self.client_name} during {self.phase} phase.")
+ if isinstance(self.trainer, monai.engines.Trainer):
+ self.logger.info(f"Aborting {self.client_name} trainer...")
+ self.trainer.terminate()
+ self.trainer.state.dataloader_iter = self.trainer._dataloader_iter # type: ignore
+ if self.trainer.state.iteration % self.trainer.state.epoch_length == 0:
+ self.trainer._fire_event(Events.EPOCH_COMPLETED)
+ if isinstance(self.evaluator, monai.engines.Trainer):
+ self.logger.info(f"Aborting {self.client_name} evaluator...")
+ self.evaluator.terminate()
+
+ def finalize(self, extra=None):
+ """
+ Finalize the training or evaluation.
+ Args:
+ extra: Dict with additional information that can be provided by FL system.
+ """
+
+ # TODO: finalize feature could be built into the MONAI Trainer class
+ if extra is None:
+ extra = {}
+ self.logger.info(f"Terminating {self.client_name} during {self.phase} phase.")
+ if isinstance(self.trainer, monai.engines.Trainer):
+ self.logger.info(f"Terminating {self.client_name} trainer...")
+ self.trainer.terminate()
+ if isinstance(self.evaluator, monai.engines.Trainer):
+ self.logger.info(f"Terminating {self.client_name} evaluator...")
+ self.evaluator.terminate()
diff --git a/monai/fl/utils/__init__.py b/monai/fl/utils/__init__.py
new file mode 100644
index 0000000000..1e97f89407
--- /dev/null
+++ b/monai/fl/utils/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/monai/fl/utils/constants.py b/monai/fl/utils/constants.py
new file mode 100644
index 0000000000..f6da8d4ea0
--- /dev/null
+++ b/monai/fl/utils/constants.py
@@ -0,0 +1,57 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from monai.utils.enums import StrEnum
+
+
+class WeightType(StrEnum):
+ WEIGHTS = "fl_weights_full"
+ WEIGHT_DIFF = "fl_weight_diff"
+
+
+class ModelType(StrEnum):
+ BEST_MODEL = "fl_best_model"
+ FINAL_MODEL = "fl_final_model"
+
+
+class ExtraItems(StrEnum):
+ ABORT = "fl_abort"
+ MODEL_TYPE = "fl_model_type"
+ CLIENT_NAME = "fl_client_name"
+ APP_ROOT = "fl_app_root"
+
+
+class FlPhase(StrEnum):
+ IDLE = "fl_idle"
+ TRAIN = "fl_train"
+ EVALUATE = "fl_evaluate"
+ GET_WEIGHTS = "fl_get_weights"
+
+
+class FlStatistics(StrEnum):
+ NUM_EXECUTED_ITERATIONS = "num_executed_iterations"
+
+
+class RequiredBundleKeys(StrEnum):
+ BUNDLE_ROOT = "bundle_root"
+
+
+class BundleKeys(StrEnum):
+ TRAINER = "train#trainer"
+ EVALUATOR = "validate#evaluator"
+ TRAIN_TRAINER_MAX_EPOCHS = "train#trainer#max_epochs"
+ VALIDATE_HANDLERS = "validate#handlers"
+
+
+class FiltersType(StrEnum):
+ PRE_FILTERS = "pre_filters"
+ POST_WEIGHT_FILTERS = "post_weight_filters"
+ POST_EVALUATE_FILTERS = "post_evaluate_filters"
diff --git a/monai/fl/utils/exchange_object.py b/monai/fl/utils/exchange_object.py
new file mode 100644
index 0000000000..9772de8e42
--- /dev/null
+++ b/monai/fl/utils/exchange_object.py
@@ -0,0 +1,107 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Dict, Optional
+
+from monai.fl.utils.constants import WeightType
+
+
+class ExchangeObject(dict):
+ """
+ Contains the information shared between client and server.
+
+ Args:
+ weights: model weights.
+ optim: optimizer weights.
+ metrics: evaluation metrics.
+ weight_type: type of weights (see monai.fl.utils.constants.WeightType).
+ statistics: training statistics, i.e. number executed iterations.
+ """
+
+ def __init__(
+ self,
+ weights=None,
+ optim=None,
+ metrics: Optional[Dict] = None,
+ weight_type: Optional[Dict] = None,
+ statistics: Optional[Dict] = None,
+ ):
+ super().__init__()
+ self.weights = weights
+ self.optim = optim
+ self.metrics = metrics
+ self.weight_type = weight_type
+ self.statistics = statistics
+ self._summary: Dict = {}
+
+ @property
+ def metrics(self):
+ return self._metrics
+
+ @metrics.setter
+ def metrics(self, metrics):
+ if metrics is not None:
+ if not isinstance(metrics, dict):
+ raise ValueError(f"Expected metrics to be of type dict but received {type(metrics)}")
+ self._metrics = metrics
+
+ @property
+ def statistics(self):
+ return self._statistics
+
+ @statistics.setter
+ def statistics(self, statistics):
+ if statistics is not None:
+ if not isinstance(statistics, dict):
+ raise ValueError(f"Expected statistics to be of type dict but received {type(statistics)}")
+ self._statistics = statistics
+
+ @property
+ def weight_type(self):
+ return self._weight_type
+
+ @weight_type.setter
+ def weight_type(self, weight_type):
+ if weight_type is not None:
+ if weight_type not in [WeightType.WEIGHTS, WeightType.WEIGHT_DIFF]:
+ raise ValueError(f"Expected weight type to be either {WeightType.WEIGHTS} or {WeightType.WEIGHT_DIFF}")
+ self._weight_type = weight_type
+
+ def is_valid_weights(self):
+ if not self.weights:
+ return False
+ if not self.weight_type:
+ return False
+ return True
+
+ def _add_to_summary(self, key, value):
+ if value:
+ if isinstance(value, dict):
+ self._summary[key] = len(value)
+ elif isinstance(value, WeightType):
+ self._summary[key] = value
+ else:
+ self._summary[key] = type(value)
+
+ def summary(self):
+ self._summary.update(self)
+ for k, v in zip(
+ ["weights", "optim", "metrics", "weight_type", "statistics"],
+ [self.weights, self.optim, self.metrics, self.weight_type, self.statistics],
+ ):
+ self._add_to_summary(k, v)
+ return self._summary
+
+ def __repr__(self):
+ return str(self.summary())
+
+ def __str__(self):
+ return str(self.summary())
diff --git a/monai/fl/utils/filters.py b/monai/fl/utils/filters.py
new file mode 100644
index 0000000000..b205ffe668
--- /dev/null
+++ b/monai/fl/utils/filters.py
@@ -0,0 +1,55 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import abc
+
+from monai.fl.utils.exchange_object import ExchangeObject
+
+
+class Filter(abc.ABC):
+ """
+ Used to apply filter to content of ExchangeObject.
+ """
+
+ @abc.abstractmethod
+ def __call__(self, data: ExchangeObject, extra=None) -> ExchangeObject:
+ """
+ Run the filtering.
+
+ Arguments:
+ data: ExchangeObject containing some data.
+
+ Returns:
+ ExchangeObject: filtered data.
+ """
+
+ raise NotImplementedError
+
+
+class SummaryFilter(Filter):
+ """
+ Summary filter to content of ExchangeObject.
+ """
+
+ def __call__(self, data: ExchangeObject, extra=None) -> ExchangeObject:
+ """
+ Example filter that doesn't filter anything but only prints data summary.
+
+ Arguments:
+ data: ExchangeObject containing some data.
+
+ Returns:
+ ExchangeObject: filtered data.
+ """
+
+ print(f"Summary of ExchangeObject: {data.summary()}")
+
+ return data
diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py
index cffbe46391..06a5ee6dbf 100644
--- a/monai/handlers/__init__.py
+++ b/monai/handlers/__init__.py
@@ -20,6 +20,7 @@
from .ignite_metric import IgniteMetric
from .lr_schedule_handler import LrScheduleHandler
from .mean_dice import MeanDice
+from .mean_iou import MeanIoUHandler
from .metric_logger import MetricLogger, MetricLoggerKeys
from .metrics_saver import MetricsSaver
from .mlflow_handler import MLFlowHandler
diff --git a/monai/handlers/mean_iou.py b/monai/handlers/mean_iou.py
new file mode 100644
index 0000000000..ee4602e6a7
--- /dev/null
+++ b/monai/handlers/mean_iou.py
@@ -0,0 +1,52 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Callable, Union
+
+from monai.handlers.ignite_metric import IgniteMetric
+from monai.metrics import MeanIoU
+from monai.utils import MetricReduction
+
+
+class MeanIoUHandler(IgniteMetric):
+ """
+ Computes IoU score metric from full size Tensor and collects average over batch, class-channels, iterations.
+ """
+
+ def __init__(
+ self,
+ include_background: bool = True,
+ reduction: Union[MetricReduction, str] = MetricReduction.MEAN,
+ output_transform: Callable = lambda x: x,
+ save_details: bool = True,
+ ) -> None:
+ """
+
+ Args:
+ include_background: whether to include iou computation on the first channel of the predicted output.
+ Defaults to True.
+ reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values,
+ available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
+ ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction.
+ output_transform: callable to extract `y_pred` and `y` from `ignite.engine.state.output` then
+ construct `(y_pred, y)` pair, where `y_pred` and `y` can be `batch-first` Tensors or
+ lists of `channel-first` Tensors. the form of `(y_pred, y)` is required by the `update()`.
+ `engine.state` and `output_transform` inherit from the ignite concept:
+ https://pytorch.org/ignite/concepts.html#state, explanation and usage example are in the tutorial:
+ https://github.com/Project-MONAI/tutorials/blob/master/modules/batch_output_transform.ipynb.
+ save_details: whether to save metric computation details per image, for example: mean iou 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.meaniou.compute_meaniou`
+ """
+ metric_fn = MeanIoU(include_background=include_background, reduction=reduction)
+ super().__init__(metric_fn=metric_fn, output_transform=output_transform, save_details=save_details)
diff --git a/monai/handlers/probability_maps.py b/monai/handlers/probability_maps.py
index 5c15704d50..df20e0604e 100644
--- a/monai/handlers/probability_maps.py
+++ b/monai/handlers/probability_maps.py
@@ -95,7 +95,7 @@ def __call__(self, engine: Engine) -> None:
locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION]
probs = engine.state.output[self.prob_key]
for name, loc, prob in zip(names, locs, probs):
- self.prob_map[name][loc] = prob
+ self.prob_map[name][tuple(loc)] = prob
with self.lock:
self.counter[name] -= 1
if self.counter[name] == 0:
diff --git a/monai/losses/__init__.py b/monai/losses/__init__.py
index c3ae941519..925649d9b1 100644
--- a/monai/losses/__init__.py
+++ b/monai/losses/__init__.py
@@ -31,5 +31,6 @@
from .image_dissimilarity import GlobalMutualInformationLoss, LocalNormalizedCrossCorrelationLoss
from .multi_scale import MultiScaleLoss
from .spatial_mask import MaskedLoss
+from .ssim_loss import SSIMLoss
from .tversky import TverskyLoss
from .unified_focal_loss import AsymmetricUnifiedFocalLoss
diff --git a/monai/losses/ssim_loss.py b/monai/losses/ssim_loss.py
new file mode 100644
index 0000000000..240023cdc4
--- /dev/null
+++ b/monai/losses/ssim_loss.py
@@ -0,0 +1,126 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import torch
+import torch.nn.functional as F
+from torch import nn
+
+from monai.utils.type_conversion import convert_to_dst_type
+
+
+class SSIMLoss(nn.Module):
+ """
+ Build a Pytorch version of the SSIM loss function based on the original formula of SSIM
+
+ Modified and adopted from:
+ https://github.com/facebookresearch/fastMRI/blob/main/banding_removal/fastmri/ssim_loss_mixin.py
+
+ For more info, visit
+ https://vicuesoft.com/glossary/term/ssim-ms-ssim/
+
+ SSIM reference paper:
+ Wang, Zhou, et al. "Image quality assessment: from error visibility to structural
+ similarity." IEEE transactions on image processing 13.4 (2004): 600-612.
+ """
+
+ def __init__(self, win_size: int = 7, k1: float = 0.01, k2: float = 0.03, spatial_dims: int = 2):
+ """
+ Args:
+ win_size: gaussian weighting window size
+ k1: stability constant used in the luminance denominator
+ k2: stability constant used in the contrast denominator
+ spatial_dims: if 2, input shape is expected to be (B,C,H,W). if 3, it is expected to be (B,C,H,W,D)
+ """
+ super().__init__()
+ self.win_size = win_size
+ self.k1, self.k2 = k1, k2
+ self.spatial_dims = spatial_dims
+ self.register_buffer(
+ "w", torch.ones([1, 1] + [win_size for _ in range(spatial_dims)]) / win_size**spatial_dims
+ )
+ self.cov_norm = (win_size**2) / (win_size**2 - 1)
+
+ def forward(self, x: torch.Tensor, y: torch.Tensor, data_range: torch.Tensor) -> torch.Tensor:
+ """
+ Args:
+ x: first sample (e.g., the reference image). Its shape is (B,C,W,H) for 2D and pseudo-3D data,
+ and (B,C,W,H,D) for 3D data,
+ y: second sample (e.g., the reconstructed image). It has similar shape as x.
+ data_range: dynamic range of the data
+
+ Returns:
+ 1-ssim_value (recall this is meant to be a loss function)
+
+ Example:
+ .. code-block:: python
+
+ import torch
+
+ # 2D data
+ x = torch.ones([1,1,10,10])/2
+ y = torch.ones([1,1,10,10])/2
+ data_range = x.max().unsqueeze(0)
+ # the following line should print 1.0 (or 0.9999)
+ print(1-SSIMLoss(spatial_dims=2)(x,y,data_range))
+
+ # pseudo-3D data
+ x = torch.ones([1,5,10,10])/2 # 5 could represent number of slices
+ y = torch.ones([1,5,10,10])/2
+ data_range = x.max().unsqueeze(0)
+ # the following line should print 1.0 (or 0.9999)
+ print(1-SSIMLoss(spatial_dims=2)(x,y,data_range))
+
+ # 3D data
+ x = torch.ones([1,1,10,10,10])/2
+ y = torch.ones([1,1,10,10,10])/2
+ data_range = x.max().unsqueeze(0)
+ # the following line should print 1.0 (or 0.9999)
+ print(1-SSIMLoss(spatial_dims=3)(x,y,data_range))
+ """
+ if x.shape[1] > 1: # handling multiple channels (C>1)
+ if x.shape[1] != y.shape[1]:
+ raise ValueError(
+ f"x and y should have the same number of channels, "
+ f"but x has {x.shape[1]} channels and y has {y.shape[1]} channels."
+ )
+ losses = torch.stack(
+ [
+ SSIMLoss(self.win_size, self.k1, self.k2, self.spatial_dims)(
+ x[:, i, ...].unsqueeze(1), y[:, i, ...].unsqueeze(1), data_range
+ )
+ for i in range(x.shape[1])
+ ]
+ )
+ channel_wise_loss: torch.Tensor = losses.mean()
+ return channel_wise_loss
+
+ data_range = data_range[(None,) * (self.spatial_dims + 2)]
+ # determine whether to work with 2D convolution or 3D
+ conv = getattr(F, f"conv{self.spatial_dims}d")
+ w = convert_to_dst_type(src=self.w, dst=x)[0]
+
+ c1 = (self.k1 * data_range) ** 2 # stability constant for luminance
+ c2 = (self.k2 * data_range) ** 2 # stability constant for contrast
+ ux = conv(x, w) # mu_x
+ uy = conv(y, w) # mu_y
+ uxx = conv(x * x, w) # mu_x^2
+ uyy = conv(y * y, w) # mu_y^2
+ uxy = conv(x * y, w) # mu_xy
+ vx = self.cov_norm * (uxx - ux * ux) # sigma_x
+ vy = self.cov_norm * (uyy - uy * uy) # sigma_y
+ vxy = self.cov_norm * (uxy - ux * uy) # sigma_xy
+
+ numerator = (2 * ux * uy + c1) * (2 * vxy + c2)
+ denom = (ux**2 + uy**2 + c1) * (vx + vy + c2)
+ ssim_value = numerator / denom
+ loss: torch.Tensor = 1 - ssim_value.mean()
+ return loss
diff --git a/monai/metrics/__init__.py b/monai/metrics/__init__.py
index 750f0f3552..4d472a148f 100644
--- a/monai/metrics/__init__.py
+++ b/monai/metrics/__init__.py
@@ -15,8 +15,9 @@
from .generalized_dice import GeneralizedDiceScore, compute_generalized_dice
from .hausdorff_distance import HausdorffDistanceMetric, compute_hausdorff_distance, compute_percent_hausdorff_distance
from .meandice import DiceMetric, compute_meandice
+from .meaniou import MeanIoU, compute_meaniou
from .metric import Cumulative, CumulativeIterationMetric, IterationMetric, Metric
-from .regression import MAEMetric, MSEMetric, PSNRMetric, RMSEMetric
+from .regression import MAEMetric, MSEMetric, PSNRMetric, RMSEMetric, SSIMMetric
from .rocauc import ROCAUCMetric, compute_roc_auc
from .surface_dice import SurfaceDiceMetric, compute_surface_dice
from .surface_distance import SurfaceDistanceMetric, compute_average_surface_distance
diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py
index bb195b0cb8..cdde195d3a 100644
--- a/monai/metrics/confusion_matrix.py
+++ b/monai/metrics/confusion_matrix.py
@@ -32,6 +32,8 @@ class ConfusionMatrixMetric(CumulativeIterationMetric):
segmentations are small compared to the total image size they can get overwhelmed by the signal from the
background.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
include_background: whether to skip metric computation on the first channel of
the predicted output. Defaults to True.
diff --git a/monai/metrics/generalized_dice.py b/monai/metrics/generalized_dice.py
index 0e1f61ac68..f223664bea 100644
--- a/monai/metrics/generalized_dice.py
+++ b/monai/metrics/generalized_dice.py
@@ -29,6 +29,8 @@ class GeneralizedDiceScore(CumulativeIterationMetric):
The inputs `y_pred` and `y` are expected to be one-hot, binarized channel-first
or batch-first tensors, i.e., CHW[D] or BCHW[D].
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
include_background (bool, optional): whether to include the background class (assumed to be in channel 0), in the
score computation. Defaults to True.
diff --git a/monai/metrics/hausdorff_distance.py b/monai/metrics/hausdorff_distance.py
index 1caec2d919..61bea4c87d 100644
--- a/monai/metrics/hausdorff_distance.py
+++ b/monai/metrics/hausdorff_distance.py
@@ -39,6 +39,8 @@ class HausdorffDistanceMetric(CumulativeIterationMetric):
`y_preds` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]).
The implementation refers to `DeepMind's implementation `_.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
include_background: whether to include distance computation on the first channel of
the predicted output. Defaults to ``False``.
diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py
index c86efa3c52..30ef0845c7 100644
--- a/monai/metrics/meandice.py
+++ b/monai/metrics/meandice.py
@@ -31,6 +31,8 @@ class DiceMetric(CumulativeIterationMetric):
background.
`y_preds` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]).
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
include_background: whether to skip Dice computation on the first channel of
the predicted output. Defaults to ``True``.
diff --git a/monai/metrics/meaniou.py b/monai/metrics/meaniou.py
new file mode 100644
index 0000000000..8b07552a8c
--- /dev/null
+++ b/monai/metrics/meaniou.py
@@ -0,0 +1,151 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Union
+
+import torch
+
+from monai.metrics.utils import do_metric_reduction, ignore_background, is_binary_tensor
+from monai.utils import MetricReduction
+
+from .metric import CumulativeIterationMetric
+
+
+class MeanIoU(CumulativeIterationMetric):
+ """
+ Compute average IoU score between two tensors. It can support both multi-classes and multi-labels tasks.
+ Input `y_pred` is compared with ground truth `y`.
+ `y_pred` is expected to have binarized predictions and `y` should be in one-hot format. You can use suitable transforms
+ in ``monai.transforms.post`` first to achieve binarized values.
+ The `include_background` parameter can be set to ``False`` to exclude
+ the first category (channel index 0) which is by convention assumed to be background. If the non-background
+ segmentations are small compared to the total image size they can get overwhelmed by the signal from the
+ background.
+ `y_pred` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]).
+
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
+ Args:
+ include_background: whether to skip IoU computation on the first channel of
+ the predicted output. Defaults to ``True``.
+ reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values,
+ available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
+ ``"mean_channel"``, ``"sum_channel"``}, default to ``"mean"``. if "none", will not do reduction.
+ get_not_nans: whether to return the `not_nans` count, if True, aggregate() returns (metric, not_nans).
+ Here `not_nans` count the number of not nans for the metric, thus its shape equals to the shape of the metric.
+ ignore_empty: whether to ignore empty ground truth cases during calculation.
+ If `True`, NaN value will be set for empty ground truth cases.
+ If `False`, 1 will be set if the predictions of empty ground truth cases are also empty.
+
+ """
+
+ def __init__(
+ self,
+ include_background: bool = True,
+ reduction: Union[MetricReduction, str] = MetricReduction.MEAN,
+ get_not_nans: bool = False,
+ ignore_empty: bool = True,
+ ) -> None:
+ super().__init__()
+ self.include_background = include_background
+ self.reduction = reduction
+ self.get_not_nans = get_not_nans
+ self.ignore_empty = ignore_empty
+
+ def _compute_tensor(self, y_pred: torch.Tensor, y: torch.Tensor): # type: ignore
+ """
+ Args:
+ y_pred: input data to compute, typical segmentation model output.
+ It must be one-hot format and first dim is batch, example shape: [16, 3, 32, 32]. The values
+ should be binarized.
+ y: ground truth to compute mean IoU metric. It must be one-hot format and first dim is batch.
+ The values should be binarized.
+
+ Raises:
+ ValueError: when `y` is not a binarized tensor.
+ ValueError: when `y_pred` has less than three dimensions.
+ """
+ is_binary_tensor(y_pred, "y_pred")
+ is_binary_tensor(y, "y")
+
+ dims = y_pred.ndimension()
+ if dims < 3:
+ raise ValueError(f"y_pred should have at least 3 dimensions (batch, channel, spatial), got {dims}.")
+ # compute IoU (BxC) for each channel for each batch
+ return compute_meaniou(
+ y_pred=y_pred, y=y, include_background=self.include_background, ignore_empty=self.ignore_empty
+ )
+
+ def aggregate(self, reduction: Union[MetricReduction, str, None] = None): # type: ignore
+ """
+ Execute reduction logic for the output of `compute_meaniou`.
+
+ Args:
+ reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values,
+ available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
+ ``"mean_channel"``, ``"sum_channel"``}, default to `self.reduction`. if "none", will not do reduction.
+
+ """
+ data = self.get_buffer()
+ if not isinstance(data, torch.Tensor):
+ raise ValueError("the data to aggregate must be PyTorch Tensor.")
+
+ # do metric reduction
+ f, not_nans = do_metric_reduction(data, reduction or self.reduction)
+ return (f, not_nans) if self.get_not_nans else f
+
+
+def compute_meaniou(
+ y_pred: torch.Tensor, y: torch.Tensor, include_background: bool = True, ignore_empty: bool = True
+) -> torch.Tensor:
+ """Computes IoU score metric from full size Tensor and collects average.
+
+ Args:
+ y_pred: input data to compute, typical segmentation model output.
+ It must be one-hot format and first dim is batch, example shape: [16, 3, 32, 32]. The values
+ should be binarized.
+ y: ground truth to compute mean IoU metric. It must be one-hot format and first dim is batch.
+ The values should be binarized.
+ include_background: whether to skip IoU computation on the first channel of
+ the predicted output. Defaults to True.
+ ignore_empty: whether to ignore empty ground truth cases during calculation.
+ If `True`, NaN value will be set for empty ground truth cases.
+ If `False`, 1 will be set if the predictions of empty ground truth cases are also empty.
+
+ Returns:
+ IoU scores per batch and per class, (shape [batch_size, num_classes]).
+
+ Raises:
+ ValueError: when `y_pred` and `y` have different shapes.
+
+ """
+
+ if not include_background:
+ y_pred, y = ignore_background(y_pred=y_pred, y=y)
+
+ y = y.float()
+ y_pred = y_pred.float()
+
+ if y.shape != y_pred.shape:
+ raise ValueError(f"y_pred and y should have same shapes, got {y_pred.shape} and {y.shape}.")
+
+ # reducing only spatial dimensions (not batch nor channels)
+ n_len = len(y_pred.shape)
+ reduce_axis = list(range(2, n_len))
+ intersection = torch.sum(y * y_pred, dim=reduce_axis)
+
+ y_o = torch.sum(y, reduce_axis)
+ y_pred_o = torch.sum(y_pred, dim=reduce_axis)
+ union = y_o + y_pred_o - intersection
+
+ if ignore_empty is True:
+ return torch.where(y_o > 0, (intersection) / union, torch.tensor(float("nan"), device=y_o.device))
+ return torch.where(union > 0, (intersection) / union, torch.tensor(1.0, device=y_o.device))
diff --git a/monai/metrics/regression.py b/monai/metrics/regression.py
index 62f5fa939e..d1cd44e4bb 100644
--- a/monai/metrics/regression.py
+++ b/monai/metrics/regression.py
@@ -16,6 +16,7 @@
import torch
+from monai.losses.ssim_loss import SSIMLoss
from monai.metrics.utils import do_metric_reduction
from monai.utils import MetricReduction
@@ -29,6 +30,8 @@ class RegressionMetric(CumulativeIterationMetric):
Both `y_pred` and `y` are expected to be real-valued, where `y_pred` is output from a regression model.
`y_preds` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]).
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values,
available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
@@ -89,6 +92,8 @@ class MSEMetric(RegressionMetric):
Input `y_pred` is compared with ground truth `y`.
Both `y_pred` and `y` are expected to be real-valued, where `y_pred` is output from a regression model.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values,
available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
@@ -121,6 +126,8 @@ class MAEMetric(RegressionMetric):
Input `y_pred` is compared with ground truth `y`.
Both `y_pred` and `y` are expected to be real-valued, where `y_pred` is output from a regression model.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values,
available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
@@ -154,6 +161,8 @@ class RMSEMetric(RegressionMetric):
Input `y_pred` is compared with ground truth `y`.
Both `y_pred` and `y` are expected to be real-valued, where `y_pred` is output from a regression model.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
reduction: define the mode to reduce metrics, will only execute reduction on `not-nan` values,
available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
@@ -191,6 +200,8 @@ class PSNRMetric(RegressionMetric):
Input `y_pred` is compared with ground truth `y`.
Both `y_pred` and `y` are expected to be real-valued, where `y_pred` is output from a regression model.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
max_val: The dynamic range of the images/volumes (i.e., the difference between the
maximum and the minimum allowed values e.g. 255 for a uint8 image).
@@ -224,3 +235,64 @@ def compute_mean_error_metrics(y_pred: torch.Tensor, y: torch.Tensor, func) -> t
# reduction of batch handled inside __call__() using do_metric_reduction() in respective calling class
flt = partial(torch.flatten, start_dim=1)
return torch.mean(flt(func(y - y_pred)), dim=-1, keepdim=True)
+
+
+class SSIMMetric(RegressionMetric):
+ r"""
+ Build a Pytorch version of the SSIM metric based on the original formula of SSIM
+
+ .. math::
+ \operatorname {SSIM}(x,y) =\frac {(2 \mu_x \mu_y + c_1)(2 \sigma_{xy} + c_2)}{((\mu_x^2 + \
+ \mu_y^2 + c_1)(\sigma_x^2 + \sigma_y^2 + c_2)}
+
+ For more info, visit
+ https://vicuesoft.com/glossary/term/ssim-ms-ssim/
+
+ Modified and adopted from:
+ https://github.com/facebookresearch/fastMRI/blob/main/banding_removal/fastmri/ssim_loss_mixin.py
+
+ SSIM reference paper:
+ Wang, Zhou, et al. "Image quality assessment: from error visibility to structural
+ similarity." IEEE transactions on image processing 13.4 (2004): 600-612.
+
+ Args:
+ data_range: dynamic range of the data
+ win_size: gaussian weighting window size
+ k1: stability constant used in the luminance denominator
+ k2: stability constant used in the contrast denominator
+ spatial_dims: if 2, input shape is expected to be (B,C,W,H). if 3, it is expected to be (B,C,W,H,D)
+ """
+
+ def __init__(
+ self, data_range: torch.Tensor, win_size: int = 7, k1: float = 0.01, k2: float = 0.03, spatial_dims: int = 2
+ ):
+ super().__init__()
+ self.data_range = data_range
+ self.win_size = win_size
+ self.k1, self.k2 = k1, k2
+ self.spatial_dims = spatial_dims
+
+ def _compute_metric(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
+ """
+ Args:
+ x: first sample (e.g., the reference image). Its shape is (B,C,W,H) for 2D data and (B,C,W,H,D) for 3D.
+ A fastMRI sample should use the 2D format with C being the number of slices.
+ y: second sample (e.g., the reconstructed image). It has similar shape as x
+
+ Returns:
+ ssim_value
+
+ Example:
+ .. code-block:: python
+
+ import torch
+ x = torch.ones([1,1,10,10])/2 # ground truth
+ y = torch.ones([1,1,10,10])/2 # prediction
+ data_range = x.max().unsqueeze(0)
+ # the following line should print 1.0 (or 0.9999)
+ print(SSIMMetric(data_range=data_range,spatial_dims=2)._compute_metric(x,y))
+ """
+ ssim_value: torch.Tensor = 1 - SSIMLoss(self.win_size, self.k1, self.k2, self.spatial_dims)(
+ x, y, self.data_range
+ )
+ return ssim_value
diff --git a/monai/metrics/rocauc.py b/monai/metrics/rocauc.py
index bd3cdb1203..0b3e488922 100644
--- a/monai/metrics/rocauc.py
+++ b/monai/metrics/rocauc.py
@@ -27,6 +27,8 @@ class ROCAUCMetric(CumulativeIterationMetric):
sklearn.metrics.roc_auc_score.html#sklearn.metrics.roc_auc_score>`_.
The input `y_pred` and `y` can be a list of `channel-first` Tensor or a `batch-first` Tensor.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
average: {``"macro"``, ``"weighted"``, ``"micro"``, ``"none"``}
Type of averaging performed if not binary classification.
diff --git a/monai/metrics/surface_dice.py b/monai/metrics/surface_dice.py
index f964b0472a..8bc34d4afc 100644
--- a/monai/metrics/surface_dice.py
+++ b/monai/metrics/surface_dice.py
@@ -30,6 +30,8 @@ class SurfaceDiceMetric(CumulativeIterationMetric):
The class- and batch sample-wise NSD values can be aggregated with the function `aggregate`.
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
class_thresholds: List of class-specific thresholds.
The thresholds relate to the acceptable amount of deviation in the segmentation boundary in pixels.
diff --git a/monai/metrics/surface_distance.py b/monai/metrics/surface_distance.py
index 4b5cdbc2f7..e463702458 100644
--- a/monai/metrics/surface_distance.py
+++ b/monai/metrics/surface_distance.py
@@ -36,6 +36,8 @@ class SurfaceDistanceMetric(CumulativeIterationMetric):
You can use suitable transforms in ``monai.transforms.post`` first to achieve binarized values.
`y_preds` and `y` can be a list of channel-first Tensor (CHW[D]) or a batch-first Tensor (BCHW[D]).
+ Example of the typical execution steps of this metric class follows :py:class:`monai.metrics.metric.Cumulative`.
+
Args:
include_background: whether to skip distance computation on the first channel of
the predicted output. Defaults to ``False``.
diff --git a/monai/networks/__init__.py b/monai/networks/__init__.py
index 0543b11632..b2dc907c18 100644
--- a/monai/networks/__init__.py
+++ b/monai/networks/__init__.py
@@ -15,6 +15,7 @@
eval_mode,
get_state_dict,
icnr_init,
+ look_up_named_module,
normal_init,
normalize_transform,
one_hot,
@@ -23,6 +24,7 @@
replace_modules,
replace_modules_temp,
save_state,
+ set_named_module,
slice_channels,
to_norm_affine,
train_mode,
diff --git a/monai/networks/blocks/__init__.py b/monai/networks/blocks/__init__.py
index 27feffea10..ef8340babe 100644
--- a/monai/networks/blocks/__init__.py
+++ b/monai/networks/blocks/__init__.py
@@ -15,6 +15,7 @@
from .backbone_fpn_utils import BackboneWithFPN
from .convolutions import Convolution, ResidualUnit
from .crf import CRF
+from .denseblock import ConvDenseBlock, DenseBlock
from .dints_block import ActiConvNormBlock, FactorizedIncreaseBlock, FactorizedReduceBlock, P3DActiConvNormBlock
from .downsample import MaxAvgPool
from .dynunet_block import UnetBasicBlock, UnetOutBlock, UnetResBlock, UnetUpBlock, get_output_padding, get_padding
diff --git a/monai/networks/blocks/denseblock.py b/monai/networks/blocks/denseblock.py
new file mode 100644
index 0000000000..dafd8d03a6
--- /dev/null
+++ b/monai/networks/blocks/denseblock.py
@@ -0,0 +1,130 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Optional, Sequence, Tuple, Union
+
+import torch
+import torch.nn as nn
+
+from monai.networks.blocks import Convolution, ResidualUnit
+from monai.networks.layers.factories import Act, Norm
+
+__ALL__ = ["DenseBlock", "ConvDenseBlock"]
+
+
+class DenseBlock(nn.Sequential):
+ """
+ A DenseBlock is a sequence of layers where each layer's outputs are concatenated with their inputs. This has the
+ effect of accumulating outputs from previous layers as inputs to later ones and as the final output of the block.
+
+ Args:
+ layers: sequence of nn.Module objects to define the individual layers of the dense block
+ """
+
+ def __init__(self, layers: Sequence[nn.Module]):
+ super().__init__()
+ for i, l in enumerate(layers):
+ self.add_module(f"layers{i}", l)
+
+ def forward(self, x):
+ for l in self.children():
+ result = l(x)
+ x = torch.cat([x, result], 1)
+
+ return x
+
+
+class ConvDenseBlock(DenseBlock):
+ """
+ This dense block is defined as a sequence of `Convolution` or `ResidualUnit` blocks. The `_get_layer` method returns
+ an object for each layer and can be overridden to change the composition of the block.
+
+ Args:
+ spatial_dims: number of spatial dimensions.
+ in_channels: number of input channels.
+ channels: output channels for each layer.
+ dilations: dilation value for each layer.
+ kernel_size: convolution kernel size. Defaults to 3.
+ num_res_units: number of convolutions. Defaults to 2.
+ adn_ordering: a string representing the ordering of activation, normalization, and dropout. Defaults to "NDA".
+ act: activation type and arguments. Defaults to PReLU.
+ norm: feature normalization type and arguments. Defaults to instance norm.
+ dropout: dropout ratio. Defaults to no dropout.
+ bias: whether to have a bias term. Defaults to True.
+ """
+
+ def __init__(
+ self,
+ spatial_dims: int,
+ in_channels: int,
+ channels: Sequence[int],
+ dilations: Optional[Sequence[int]] = None,
+ kernel_size: Union[Sequence[int], int] = 3,
+ num_res_units: int = 0,
+ adn_ordering: str = "NDA",
+ act: Optional[Union[Tuple, str]] = Act.PRELU,
+ norm: Optional[Union[Tuple, str]] = Norm.INSTANCE,
+ dropout: Optional[int] = None,
+ bias: bool = True,
+ ):
+
+ self.spatial_dims = spatial_dims
+ self.kernel_size = kernel_size
+ self.num_res_units = num_res_units
+ self.adn_ordering = adn_ordering
+ self.act = act
+ self.norm = norm
+ self.dropout = dropout
+ self.bias = bias
+
+ l_channels = in_channels
+ dilations = dilations if dilations is not None else ([1] * len(channels))
+ layers = []
+
+ if len(channels) != len(dilations):
+ raise ValueError("Length of `channels` and `dilations` must match")
+
+ for c, d in zip(channels, dilations):
+ layer = self._get_layer(l_channels, c, d)
+ layers.append(layer)
+ l_channels += c
+
+ super().__init__(layers)
+
+ def _get_layer(self, in_channels, out_channels, dilation):
+ if self.num_res_units > 0:
+ return ResidualUnit(
+ spatial_dims=self.spatial_dims,
+ in_channels=in_channels,
+ out_channels=out_channels,
+ strides=1,
+ kernel_size=self.kernel_size,
+ subunits=self.num_res_units,
+ adn_ordering=self.adn_ordering,
+ act=self.act,
+ norm=self.norm,
+ dropout=self.dropout,
+ dilation=dilation,
+ bias=self.bias,
+ )
+ else:
+ return Convolution(
+ spatial_dims=self.spatial_dims,
+ in_channels=in_channels,
+ out_channels=out_channels,
+ strides=1,
+ kernel_size=self.kernel_size,
+ act=self.act,
+ norm=self.norm,
+ dropout=self.dropout,
+ dilation=dilation,
+ bias=self.bias,
+ )
diff --git a/monai/networks/blocks/fft_utils_t.py b/monai/networks/blocks/fft_utils_t.py
index 0d6b99d7e1..26205041be 100644
--- a/monai/networks/blocks/fft_utils_t.py
+++ b/monai/networks/blocks/fft_utils_t.py
@@ -9,13 +9,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Optional, Sequence
+from typing import List
import torch
from torch import Tensor
-from monai.utils.type_conversion import convert_data_type
-
def roll_1d(x: Tensor, shift: int, shift_dim: int) -> Tensor:
"""
@@ -44,7 +42,7 @@ def roll_1d(x: Tensor, shift: int, shift_dim: int) -> Tensor:
return torch.cat((right, left), dim=shift_dim)
-def roll(x: Tensor, shift: Sequence[int], shift_dims: Sequence[int]) -> Tensor:
+def roll(x: Tensor, shift: List[int], shift_dims: List[int]) -> Tensor:
"""
Similar to np.roll but applies to PyTorch Tensors
@@ -68,7 +66,7 @@ def roll(x: Tensor, shift: Sequence[int], shift_dims: Sequence[int]) -> Tensor:
return x
-def fftshift(x: Tensor, shift_dims: Optional[Sequence[int]] = None) -> Tensor:
+def fftshift(x: Tensor, shift_dims: List[int]) -> Tensor:
"""
Similar to np.fft.fftshift but applies to PyTorch Tensors
@@ -84,18 +82,13 @@ def fftshift(x: Tensor, shift_dims: Optional[Sequence[int]] = None) -> Tensor:
Note:
This function is called when fftshift is not available in the running pytorch version
"""
- if shift_dims is None:
- # for torch.jit.script based on the fastmri repository
- shift_dims = [0] * (x.dim())
- for i in range(1, x.dim()):
- shift_dims[i] = i
shift = [0] * len(shift_dims)
for i, dim_num in enumerate(shift_dims):
shift[i] = x.shape[dim_num] // 2
return roll(x, shift, shift_dims)
-def ifftshift(x: Tensor, shift_dims: Optional[Sequence[int]] = None) -> Tensor:
+def ifftshift(x: Tensor, shift_dims: List[int]) -> Tensor:
"""
Similar to np.fft.ifftshift but applies to PyTorch Tensors
@@ -111,11 +104,6 @@ def ifftshift(x: Tensor, shift_dims: Optional[Sequence[int]] = None) -> Tensor:
Note:
This function is called when ifftshift is not available in the running pytorch version
"""
- if shift_dims is None:
- # for torch.jit.script based on the fastmri repository
- shift_dims = [0] * (x.dim())
- for i in range(1, x.dim()):
- shift_dims[i] = i
shift = [0] * len(shift_dims)
for i, dim_num in enumerate(shift_dims):
shift[i] = (x.shape[dim_num] + 1) // 2
@@ -151,28 +139,21 @@ def ifftn_centered_t(ksp: Tensor, spatial_dims: int, is_complex: bool = True) ->
output2 = ifftn_centered(ksp, spatial_dims=2, is_complex=True)
"""
# define spatial dims to perform ifftshift, fftshift, and ifft
- shift = tuple(range(-spatial_dims, 0))
+ shift = [i for i in range(-spatial_dims, 0)] # noqa: C416
if is_complex:
if ksp.shape[-1] != 2:
raise ValueError(f"ksp.shape[-1] is not 2 ({ksp.shape[-1]}).")
- shift = tuple(range(-spatial_dims - 1, -1))
- dims = tuple(range(-spatial_dims, 0))
+ shift = [i for i in range(-spatial_dims - 1, -1)] # noqa: C416
+ dims = [i for i in range(-spatial_dims, 0)] # noqa: C416
- # apply ifft
- if hasattr(torch.fft, "ifftshift"): # ifftshift was added in pytorch 1.8
- x = torch.fft.ifftshift(ksp, dim=shift)
- else:
- x = ifftshift(ksp, shift)
+ x = ifftshift(ksp, shift)
if is_complex:
x = torch.view_as_real(torch.fft.ifftn(torch.view_as_complex(x), dim=dims, norm="ortho"))
else:
x = torch.view_as_real(torch.fft.ifftn(x, dim=dims, norm="ortho"))
- if hasattr(torch.fft, "fftshift"):
- out = convert_data_type(torch.fft.fftshift(x, dim=shift), torch.Tensor)[0]
- else:
- out = convert_data_type(fftshift(x, shift), torch.Tensor)[0]
+ out: Tensor = fftshift(x, shift)
return out
@@ -206,27 +187,20 @@ def fftn_centered_t(im: Tensor, spatial_dims: int, is_complex: bool = True) -> T
output2 = fftn_centered(im, spatial_dims=2, is_complex=True)
"""
# define spatial dims to perform ifftshift, fftshift, and fft
- shift = tuple(range(-spatial_dims, 0))
+ shift = [i for i in range(-spatial_dims, 0)] # noqa: C416
if is_complex:
if im.shape[-1] != 2:
raise ValueError(f"img.shape[-1] is not 2 ({im.shape[-1]}).")
- shift = tuple(range(-spatial_dims - 1, -1))
- dims = tuple(range(-spatial_dims, 0))
+ shift = [i for i in range(-spatial_dims - 1, -1)] # noqa: C416
+ dims = [i for i in range(-spatial_dims, 0)] # noqa: C416
- # apply fft
- if hasattr(torch.fft, "ifftshift"): # ifftshift was added in pytorch 1.8
- x = torch.fft.ifftshift(im, dim=shift)
- else:
- x = ifftshift(im, shift)
+ x = ifftshift(im, shift)
if is_complex:
x = torch.view_as_real(torch.fft.fftn(torch.view_as_complex(x), dim=dims, norm="ortho"))
else:
x = torch.view_as_real(torch.fft.fftn(x, dim=dims, norm="ortho"))
- if hasattr(torch.fft, "fftshift"):
- out = convert_data_type(torch.fft.fftshift(x, dim=shift), torch.Tensor)[0]
- else:
- out = convert_data_type(fftshift(x, shift), torch.Tensor)[0]
+ out: Tensor = fftshift(x, shift)
return out
diff --git a/monai/networks/blocks/patchembedding.py b/monai/networks/blocks/patchembedding.py
index 7dc84a9837..89e9a6a24f 100644
--- a/monai/networks/blocks/patchembedding.py
+++ b/monai/networks/blocks/patchembedding.py
@@ -180,7 +180,7 @@ def forward(self, x):
x = F.pad(x, (0, 0, 0, 0, 0, self.patch_size[0] - d % self.patch_size[0]))
elif len(x_shape) == 4:
- _, _, h, w = x.size()
+ _, _, h, w = x_shape
if w % self.patch_size[1] != 0:
x = F.pad(x, (0, self.patch_size[1] - w % self.patch_size[1]))
if h % self.patch_size[0] != 0:
diff --git a/monai/networks/layers/spatial_transforms.py b/monai/networks/layers/spatial_transforms.py
index 7a41d79291..d1a0fed021 100644
--- a/monai/networks/layers/spatial_transforms.py
+++ b/monai/networks/layers/spatial_transforms.py
@@ -16,7 +16,14 @@
import monai
from monai.networks import to_norm_affine
-from monai.utils import GridSampleMode, GridSamplePadMode, ensure_tuple, look_up_option, optional_import
+from monai.utils import (
+ GridSampleMode,
+ GridSamplePadMode,
+ convert_to_dst_type,
+ ensure_tuple,
+ look_up_option,
+ optional_import,
+)
_C, _ = optional_import("monai._C")
@@ -118,7 +125,7 @@ def grid_pull(
out: torch.Tensor
out = _GridPull.apply(input, grid, interpolation, bound, extrapolate)
if isinstance(input, monai.data.MetaTensor):
- out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations)
+ out = convert_to_dst_type(out, dst=input)[0]
return out
@@ -222,7 +229,7 @@ def grid_push(
out: torch.Tensor = _GridPush.apply(input, grid, shape, interpolation, bound, extrapolate)
if isinstance(input, monai.data.MetaTensor):
- out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations)
+ out = convert_to_dst_type(out, dst=input)[0]
return out
@@ -321,7 +328,7 @@ def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="ze
out: torch.Tensor = _GridCount.apply(grid, shape, interpolation, bound, extrapolate)
if isinstance(input, monai.data.MetaTensor):
- out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations)
+ out = convert_to_dst_type(out, dst=input)[0]
return out
@@ -419,7 +426,7 @@ def grid_grad(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b
out: torch.Tensor = _GridGrad.apply(input, grid, interpolation, bound, extrapolate)
if isinstance(input, monai.data.MetaTensor):
- out = monai.data.MetaTensor(out, meta=input.meta, applied_operations=input.applied_operations)
+ out = convert_to_dst_type(out, dst=input)[0]
return out
diff --git a/monai/networks/layers/utils.py b/monai/networks/layers/utils.py
index 42fac58716..a630a5edc7 100644
--- a/monai/networks/layers/utils.py
+++ b/monai/networks/layers/utils.py
@@ -11,6 +11,8 @@
from typing import Optional, Tuple, Union
+import torch.nn
+
from monai.networks.layers.factories import Act, Dropout, Norm, Pool, split_args
from monai.utils import has_option
@@ -36,6 +38,8 @@ def get_norm_layer(name: Union[Tuple, str], spatial_dims: Optional[int] = 1, cha
channels: number of features/channels when the normalization layer requires this parameter
but it is not specified in the norm parameters.
"""
+ if name == "":
+ return torch.nn.Identity()
norm_name, norm_args = split_args(name)
norm_type = Norm[norm_name, spatial_dims]
kw_args = dict(norm_args)
@@ -62,6 +66,8 @@ def get_act_layer(name: Union[Tuple, str]):
Args:
name: an activation type string or a tuple of type string and parameters.
"""
+ if name == "":
+ return torch.nn.Identity()
act_name, act_args = split_args(name)
act_type = Act[act_name]
return act_type(**act_args)
@@ -84,6 +90,8 @@ def get_dropout_layer(name: Union[Tuple, str, float, int], dropout_dim: Optional
name: a dropout ratio or a tuple of dropout type and parameters.
dropout_dim: the spatial dimension of the dropout operation.
"""
+ if name == "":
+ return torch.nn.Identity()
if isinstance(name, (int, float)):
# if dropout was specified simply as a p value, use default name and make a keyword map with the value
drop_name = Dropout.DROPOUT
@@ -111,6 +119,8 @@ def get_pool_layer(name: Union[Tuple, str], spatial_dims: Optional[int] = 1):
spatial_dims: number of spatial dimensions of the input.
"""
+ if name == "":
+ return torch.nn.Identity()
pool_name, pool_args = split_args(name)
pool_type = Pool[pool_name, spatial_dims]
return pool_type(**pool_args)
diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py
index 6bf1868122..cd9329f61b 100644
--- a/monai/networks/nets/__init__.py
+++ b/monai/networks/nets/__init__.py
@@ -40,6 +40,7 @@
drop_connect,
get_efficientnet_image_size,
)
+from .flexible_unet import FlexibleUNet
from .fullyconnectednet import FullyConnectedNet, VarFullyConnectedNet
from .generator import Generator
from .highresnet import HighResBlock, HighResNet
@@ -81,7 +82,7 @@
seresnext50,
seresnext101,
)
-from .swin_unetr import SwinUNETR
+from .swin_unetr import PatchMerging, PatchMergingV2, SwinUNETR
from .torchvision_fc import TorchVisionFCModel
from .transchex import BertAttention, BertMixedLayer, BertOutput, BertPreTrainedModel, MultiModal, Pooler, Transchex
from .unet import UNet, Unet
diff --git a/monai/networks/nets/basic_unet.py b/monai/networks/nets/basic_unet.py
index 1e46846576..e03aacd5f4 100644
--- a/monai/networks/nets/basic_unet.py
+++ b/monai/networks/nets/basic_unet.py
@@ -118,6 +118,7 @@ def __init__(
align_corners: Optional[bool] = True,
halves: bool = True,
dim: Optional[int] = None,
+ is_pad: bool = True,
):
"""
Args:
@@ -139,6 +140,7 @@ def __init__(
Only used in the "nontrainable" mode.
halves: whether to halve the number of channels during upsampling.
This parameter does not work on ``nontrainable`` mode if ``pre_conv`` is `None`.
+ is_pad: whether to pad upsampling features to fit features from encoder. Defaults to True.
.. deprecated:: 0.6.0
``dim`` is deprecated, use ``spatial_dims`` instead.
@@ -161,6 +163,7 @@ def __init__(
align_corners=align_corners,
)
self.convs = TwoConv(spatial_dims, cat_chns + up_chns, out_chns, act, norm, bias, dropout)
+ self.is_pad = is_pad
def forward(self, x: torch.Tensor, x_e: Optional[torch.Tensor]):
"""
@@ -172,13 +175,14 @@ def forward(self, x: torch.Tensor, x_e: Optional[torch.Tensor]):
x_0 = self.upsample(x)
if x_e is not None:
- # handling spatial shapes due to the 2x maxpooling with odd edge lengths.
- dimensions = len(x.shape) - 2
- sp = [0] * (dimensions * 2)
- for i in range(dimensions):
- if x_e.shape[-i - 1] != x_0.shape[-i - 1]:
- sp[i * 2 + 1] = 1
- x_0 = torch.nn.functional.pad(x_0, sp, "replicate")
+ if self.is_pad:
+ # handling spatial shapes due to the 2x maxpooling with odd edge lengths.
+ dimensions = len(x.shape) - 2
+ sp = [0] * (dimensions * 2)
+ for i in range(dimensions):
+ if x_e.shape[-i - 1] != x_0.shape[-i - 1]:
+ sp[i * 2 + 1] = 1
+ x_0 = torch.nn.functional.pad(x_0, sp, "replicate")
x = self.convs(torch.cat([x_e, x_0], dim=1)) # input channels: (cat_chns + up_chns)
else:
x = self.convs(x_0)
diff --git a/monai/networks/nets/flexible_unet.py b/monai/networks/nets/flexible_unet.py
new file mode 100644
index 0000000000..4901decceb
--- /dev/null
+++ b/monai/networks/nets/flexible_unet.py
@@ -0,0 +1,307 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List, Optional, Sequence, Tuple, Union
+
+import torch
+from torch import nn
+
+from monai.networks.blocks import UpSample
+from monai.networks.layers.factories import Conv
+from monai.networks.layers.utils import get_act_layer
+from monai.networks.nets import EfficientNetBNFeatures
+from monai.networks.nets.basic_unet import UpCat
+from monai.utils import InterpolateMode
+
+__all__ = ["FlexibleUNet"]
+
+encoder_feature_channel = {
+ "efficientnet-b0": (16, 24, 40, 112, 320),
+ "efficientnet-b1": (16, 24, 40, 112, 320),
+ "efficientnet-b2": (16, 24, 48, 120, 352),
+ "efficientnet-b3": (24, 32, 48, 136, 384),
+ "efficientnet-b4": (24, 32, 56, 160, 448),
+ "efficientnet-b5": (24, 40, 64, 176, 512),
+ "efficientnet-b6": (32, 40, 72, 200, 576),
+ "efficientnet-b7": (32, 48, 80, 224, 640),
+ "efficientnet-b8": (32, 56, 88, 248, 704),
+ "efficientnet-l2": (72, 104, 176, 480, 1376),
+}
+
+
+def _get_encoder_channels_by_backbone(backbone: str, in_channels: int = 3) -> tuple:
+ """
+ Get the encoder output channels by given backbone name.
+
+ Args:
+ backbone: name of backbone to generate features, can be from [efficientnet-b0, ..., efficientnet-b7].
+ in_channels: channel of input tensor, default to 3.
+
+ Returns:
+ A tuple of output feature map channels' length .
+ """
+ encoder_channel_tuple = encoder_feature_channel[backbone]
+ encoder_channel_list = [in_channels] + list(encoder_channel_tuple)
+ encoder_channel = tuple(encoder_channel_list)
+ return encoder_channel
+
+
+class UNetDecoder(nn.Module):
+ """
+ UNet Decoder.
+ This class refers to `segmentation_models.pytorch
+ `_.
+
+ Args:
+ spatial_dims: number of spatial dimensions.
+ encoder_channels: number of output channels for all feature maps in encoder.
+ `len(encoder_channels)` should be no less than 2.
+ decoder_channels: number of output channels for all feature maps in decoder.
+ `len(decoder_channels)` should equal to `len(encoder_channels) - 1`.
+ act: activation type and arguments.
+ norm: feature normalization type and arguments.
+ dropout: dropout ratio.
+ bias: whether to have a bias term in convolution blocks in this decoder.
+ upsample: upsampling mode, available options are
+ ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``.
+ pre_conv: a conv block applied before upsampling.
+ Only used in the "nontrainable" or "pixelshuffle" mode.
+ interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``}
+ Only used in the "nontrainable" mode.
+ align_corners: set the align_corners parameter for upsample. Defaults to True.
+ Only used in the "nontrainable" mode.
+ is_pad: whether to pad upsampling features to fit the encoder spatial dims.
+
+ """
+
+ def __init__(
+ self,
+ spatial_dims: int,
+ encoder_channels: Sequence[int],
+ decoder_channels: Sequence[int],
+ act: Union[str, tuple],
+ norm: Union[str, tuple],
+ dropout: Union[float, tuple],
+ bias: bool,
+ upsample: str,
+ pre_conv: Optional[str],
+ interp_mode: str,
+ align_corners: Optional[bool],
+ is_pad: bool,
+ ):
+
+ super().__init__()
+ if len(encoder_channels) < 2:
+ raise ValueError("the length of `encoder_channels` should be no less than 2.")
+ if len(decoder_channels) != len(encoder_channels) - 1:
+ raise ValueError("`len(decoder_channels)` should equal to `len(encoder_channels) - 1`.")
+
+ in_channels = [encoder_channels[-1]] + list(decoder_channels[:-1])
+ skip_channels = list(encoder_channels[1:-1][::-1]) + [0]
+ halves = [True] * (len(skip_channels) - 1)
+ halves.append(False)
+ blocks = []
+ for in_chn, skip_chn, out_chn, halve in zip(in_channels, skip_channels, decoder_channels, halves):
+ blocks.append(
+ UpCat(
+ spatial_dims=spatial_dims,
+ in_chns=in_chn,
+ cat_chns=skip_chn,
+ out_chns=out_chn,
+ act=act,
+ norm=norm,
+ dropout=dropout,
+ bias=bias,
+ upsample=upsample,
+ pre_conv=pre_conv,
+ interp_mode=interp_mode,
+ align_corners=align_corners,
+ halves=halve,
+ is_pad=is_pad,
+ )
+ )
+ self.blocks = nn.ModuleList(blocks)
+
+ def forward(self, features: List[torch.Tensor], skip_connect: int = 4):
+ skips = features[:-1][::-1]
+ features = features[1:][::-1]
+
+ x = features[0]
+ for i, block in enumerate(self.blocks):
+ if i < skip_connect:
+ skip = skips[i]
+ else:
+ skip = None
+ x = block(x, skip)
+
+ return x
+
+
+class SegmentationHead(nn.Sequential):
+ """
+ Segmentation head.
+ This class refers to `segmentation_models.pytorch
+ `_.
+
+ Args:
+ spatial_dims: number of spatial dimensions.
+ in_channels: number of input channels for the block.
+ out_channels: number of output channels for the block.
+ kernel_size: kernel size for the conv layer.
+ act: activation type and arguments.
+ scale_factor: multiplier for spatial size. Has to match input size if it is a tuple.
+
+ """
+
+ def __init__(
+ self,
+ spatial_dims: int,
+ in_channels: int,
+ out_channels: int,
+ kernel_size: int = 3,
+ act: Optional[Union[Tuple, str]] = None,
+ scale_factor: float = 1.0,
+ ):
+
+ conv_layer = Conv[Conv.CONV, spatial_dims](
+ in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, padding=kernel_size // 2
+ )
+ up_layer: nn.Module = nn.Identity()
+ if scale_factor > 1.0:
+ up_layer = UpSample(
+ spatial_dims=spatial_dims,
+ scale_factor=scale_factor,
+ mode="nontrainable",
+ pre_conv=None,
+ interp_mode=InterpolateMode.LINEAR,
+ )
+ if act is not None:
+ act_layer = get_act_layer(act)
+ else:
+ act_layer = nn.Identity()
+ super().__init__(conv_layer, up_layer, act_layer)
+
+
+class FlexibleUNet(nn.Module):
+ """
+ A flexible implementation of UNet-like encoder-decoder architecture.
+ """
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ backbone: str,
+ pretrained: bool = False,
+ decoder_channels: Tuple = (256, 128, 64, 32, 16),
+ spatial_dims: int = 2,
+ norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.1}),
+ act: Union[str, tuple] = ("relu", {"inplace": True}),
+ dropout: Union[float, tuple] = 0.0,
+ decoder_bias: bool = False,
+ upsample: str = "nontrainable",
+ interp_mode: str = "nearest",
+ is_pad: bool = True,
+ ) -> None:
+ """
+ A flexible implement of UNet, in which the backbone/encoder can be replaced with
+ any efficient network. Currently the input must have a 2 or 3 spatial dimension
+ and the spatial size of each dimension must be a multiple of 32 if is pad parameter
+ is False
+
+ TODO(binliu@nvidia.com): Add more backbones/encoders to this class and make a general
+ encoder-decoder structure. ETC:2022.09.01
+
+ Args:
+ in_channels: number of input channels.
+ out_channels: number of output channels.
+ backbone: name of backbones to initialize, only support efficientnet right now,
+ can be from [efficientnet-b0,..., efficientnet-b8, efficientnet-l2].
+ pretrained: whether to initialize pretrained ImageNet weights, only available
+ for spatial_dims=2 and batch norm is used, default to False.
+ decoder_channels: number of output channels for all feature maps in decoder.
+ `len(decoder_channels)` should equal to `len(encoder_channels) - 1`,default
+ to (256, 128, 64, 32, 16).
+ spatial_dims: number of spatial dimensions, default to 2.
+ norm: normalization type and arguments, default to ("batch", {"eps": 1e-3,
+ "momentum": 0.1}).
+ act: activation type and arguments, default to ("relu", {"inplace": True}).
+ dropout: dropout ratio, default to 0.0.
+ decoder_bias: whether to have a bias term in decoder's convolution blocks.
+ upsample: upsampling mode, available options are``"deconv"``, ``"pixelshuffle"``,
+ ``"nontrainable"``.
+ interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``}
+ Only used in the "nontrainable" mode.
+ is_pad: whether to use padding feature maps to enable the input spatial not necessary
+ to be a multiple of 32. Default to True.
+ """
+ super().__init__()
+
+ if backbone not in encoder_feature_channel:
+ raise ValueError(f"invalid model_name {backbone} found, must be one of {encoder_feature_channel.keys()}.")
+
+ if spatial_dims not in (2, 3):
+ raise ValueError("spatial_dims can only be 2 or 3.")
+
+ adv_prop = "ap" in backbone
+
+ self.backbone = backbone
+ self.spatial_dims = spatial_dims
+ model_name = backbone
+ encoder_channels = _get_encoder_channels_by_backbone(backbone, in_channels)
+ self.encoder = EfficientNetBNFeatures(
+ model_name=model_name,
+ pretrained=pretrained,
+ in_channels=in_channels,
+ spatial_dims=spatial_dims,
+ norm=norm,
+ adv_prop=adv_prop,
+ )
+ self.decoder = UNetDecoder(
+ spatial_dims=spatial_dims,
+ encoder_channels=encoder_channels,
+ decoder_channels=decoder_channels,
+ act=act,
+ norm=norm,
+ dropout=dropout,
+ bias=decoder_bias,
+ upsample=upsample,
+ interp_mode=interp_mode,
+ pre_conv=None,
+ align_corners=None,
+ is_pad=is_pad,
+ )
+ self.segmentation_head = SegmentationHead(
+ spatial_dims=spatial_dims,
+ in_channels=decoder_channels[-1],
+ out_channels=out_channels,
+ kernel_size=3,
+ act=None,
+ )
+
+ def forward(self, inputs: torch.Tensor):
+ """
+ Do a typical encoder-decoder-header inference.
+
+ Args:
+ inputs: input should have spatially N dimensions ``(Batch, in_channels, dim_0[, dim_1, ..., dim_N])``,
+ N is defined by `dimensions`.
+
+ Returns:
+ A torch Tensor of "raw" predictions in shape ``(Batch, out_channels, dim_0[, dim_1, ..., dim_N])``.
+
+ """
+ x = inputs
+ enc_out = self.encoder(x)
+ decoder_out = self.decoder(enc_out)
+ x_seg = self.segmentation_head(decoder_out)
+
+ return x_seg
diff --git a/monai/networks/nets/netadapter.py b/monai/networks/nets/netadapter.py
index 425c1d5820..9c41f566b1 100644
--- a/monai/networks/nets/netadapter.py
+++ b/monai/networks/nets/netadapter.py
@@ -14,14 +14,18 @@
import torch
from monai.networks.layers import Conv, get_pool_layer
-from monai.utils import deprecated_arg
+from monai.networks.utils import look_up_named_module, set_named_module
+from monai.utils import deprecated_arg, look_up_option, optional_import
+
+get_graph_node_names, _has_utils = optional_import("torchvision.models.feature_extraction", name="get_graph_node_names")
+create_feature_extractor, _ = optional_import("torchvision.models.feature_extraction", name="create_feature_extractor")
class NetAdapter(torch.nn.Module):
"""
Wrapper to replace the last layer of model by convolutional layer or FC layer.
- This module expects the output of `model layers[0: -2]` is a feature map with shape [B, C, spatial dims],
- then replace the model's last two layers with an optional `pooling` and a `conv` or `linear` layer.
+
+ See also: :py:class:`monai.networks.nets.TorchVisionFCModel`
Args:
model: a PyTorch model, which can be both 2D and 3D models. typically, it can be a pretrained model
@@ -31,12 +35,15 @@ class NetAdapter(torch.nn.Module):
dim: number of supported spatial dimensions in the specified model, depends on the model implementation.
default to 2 as most Torchvision models are for 2D image processing.
in_channels: number of the input channels of last layer. if None, get it from `in_features` of last layer.
- use_conv: whether use convolutional layer to replace the last layer, default to False.
+ use_conv: whether to use convolutional layer to replace the last layer, default to False.
pool: parameters for the pooling layer, it should be a tuple, the first item is name of the pooling layer,
the second item is dictionary of the initialization args. if None, will not replace the `layers[-2]`.
default to `("avg", {"kernel_size": 7, "stride": 1})`.
bias: the bias value when replacing the last layer. if False, the layer will not learn an additive bias,
default to True.
+ fc_name: the corresponding layer attribute of the last fully connected layer. Defaults to ``"fc"``.
+ node_name: the corresponding feature extractor node name of `model`.
+ Defaults to "", the extractor is not in use.
.. deprecated:: 0.6.0
``n_classes`` is deprecated, use ``num_classes`` instead.
@@ -54,50 +61,68 @@ def __init__(
pool: Optional[Tuple[str, Dict[str, Any]]] = ("avg", {"kernel_size": 7, "stride": 1}),
bias: bool = True,
n_classes: Optional[int] = None,
+ fc_name: str = "fc",
+ node_name: str = "",
):
super().__init__()
# in case the new num_classes is default but you still call deprecated n_classes
if n_classes is not None and num_classes == 1:
num_classes = n_classes
layers = list(model.children())
- orig_fc = layers[-1]
+ orig_fc = look_up_named_module(fc_name, model)
+ if orig_fc is None:
+ orig_fc = layers[-1]
+ # guess the number of input channels of the last fully connected layer
in_channels_: int
-
if in_channels is None:
if not hasattr(orig_fc, "in_features"):
- raise ValueError("please specify the input channels of last layer with arg `in_channels`.")
+ raise ValueError("please specify input channels of the last fully connected layer with `in_channels`.")
in_channels_ = orig_fc.in_features # type: ignore
else:
in_channels_ = in_channels
- if pool is None:
- # remove the last layer
- self.features = torch.nn.Sequential(*layers[:-1])
+ # modify the input model, depending on whether to replace the last pooling layer ``pool``
+ if pool is None: # no modification of pooling
+ if node_name != "":
+ raise ValueError("`node_name` is not compatible with `pool=None`, please set `pool=''`.")
+ # we just drop the model's fully connected layer or set it to identity
+ if look_up_named_module(fc_name, model):
+ self.features = set_named_module(model, fc_name, torch.nn.Identity())
+ else:
+ self.features = torch.nn.Sequential(*layers[:-1]) # assuming FC is the last and model is sequential
self.pool = None
else:
- # remove the last 2 layers
- self.features = torch.nn.Sequential(*layers[:-2])
+ # user-specified new pooling layer, we drop both the pooling and FC layers from the model
+ if node_name and _has_utils:
+ node_name = look_up_option(node_name, get_graph_node_names(model)[0 if model.training else 1])
+ self.features = create_feature_extractor(model, [node_name])
+ else:
+ self.features = torch.nn.Sequential(*layers[:-2]) # assuming the last 2 layers are pooling&FC
self.pool = get_pool_layer(name=pool, spatial_dims=dim)
+ # create new fully connected layer or kernel size 1 convolutional layer
self.fc: Union[torch.nn.Linear, torch.nn.Conv2d, torch.nn.Conv3d]
if use_conv:
- # add 1x1 conv (it behaves like a FC layer)
self.fc = Conv[Conv.CONV, dim](in_channels=in_channels_, out_channels=num_classes, kernel_size=1, bias=bias)
else:
- # remove the last Linear layer (fully connected)
- self.features = torch.nn.Sequential(*layers[:-1])
- # replace the out_features of FC layer
self.fc = torch.nn.Linear(in_features=in_channels_, out_features=num_classes, bias=bias)
self.use_conv = use_conv
+ self.dim = dim
+ self.node_name = node_name
def forward(self, x):
x = self.features(x)
+ if isinstance(x, tuple):
+ x = x[0] # it might be a namedtuple such as torchvision.model.InceptionOutputs
+ elif isinstance(x, dict):
+ x = x[self.node_name] # torchvision create_feature_extractor
if self.pool is not None:
x = self.pool(x)
-
if not self.use_conv:
x = torch.flatten(x, 1)
-
+ else: # user specified `use_conv` but the pooling layer removed the spatial dims
+ while len(x.shape) < self.dim + 2:
+ x = x[..., None]
x = self.fc(x)
return x
diff --git a/monai/networks/nets/resnet.py b/monai/networks/nets/resnet.py
index c8be9f0e89..bf5486f06e 100644
--- a/monai/networks/nets/resnet.py
+++ b/monai/networks/nets/resnet.py
@@ -150,6 +150,9 @@ class ResNet(nn.Module):
Args:
block: which ResNet block to use, either Basic or Bottleneck.
+ ResNet block class or str.
+ for Basic: ResNetBlock or 'basic'
+ for Bottleneck: ResNetBottleneck or 'bottleneck'
layers: how many layers to use.
block_inplanes: determine the size of planes at each step. Also tunable with widen_factor.
spatial_dims: number of spatial dimensions of the input image.
@@ -172,7 +175,7 @@ class ResNet(nn.Module):
@deprecated_arg("n_classes", since="0.6")
def __init__(
self,
- block: Type[Union[ResNetBlock, ResNetBottleneck]],
+ block: Union[Type[Union[ResNetBlock, ResNetBottleneck]], str],
layers: List[int],
block_inplanes: List[int],
spatial_dims: int = 3,
@@ -192,6 +195,14 @@ def __init__(
if n_classes is not None and num_classes == 400:
num_classes = n_classes
+ if isinstance(block, str):
+ if block == "basic":
+ block = ResNetBlock
+ elif block == "bottleneck":
+ block = ResNetBottleneck
+ else:
+ raise ValueError("Unknown block '%s', use basic or bottleneck" % block)
+
conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv[Conv.CONV, spatial_dims]
norm_type: Type[Union[nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d]] = Norm[Norm.BATCH, spatial_dims]
pool_type: Type[Union[nn.MaxPool1d, nn.MaxPool2d, nn.MaxPool3d]] = Pool[Pool.MAX, spatial_dims]
diff --git a/monai/networks/nets/segresnet.py b/monai/networks/nets/segresnet.py
index 299f1ca811..cc908f1640 100644
--- a/monai/networks/nets/segresnet.py
+++ b/monai/networks/nets/segresnet.py
@@ -101,7 +101,7 @@ def __init__(
def _make_down_layers(self):
down_layers = nn.ModuleList()
blocks_down, spatial_dims, filters, norm = (self.blocks_down, self.spatial_dims, self.init_filters, self.norm)
- for i in range(len(blocks_down)):
+ for i, item in enumerate(blocks_down):
layer_in_channels = filters * 2**i
pre_conv = (
get_conv_layer(spatial_dims, layer_in_channels // 2, layer_in_channels, stride=2)
@@ -109,8 +109,7 @@ def _make_down_layers(self):
else nn.Identity()
)
down_layer = nn.Sequential(
- pre_conv,
- *[ResBlock(spatial_dims, layer_in_channels, norm=norm, act=self.act) for _ in range(blocks_down[i])],
+ pre_conv, *[ResBlock(spatial_dims, layer_in_channels, norm=norm, act=self.act) for _ in range(item)]
)
down_layers.append(down_layer)
return down_layers
diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py
index a85d32ba5a..8933cbe7e9 100644
--- a/monai/networks/nets/senet.py
+++ b/monai/networks/nets/senet.py
@@ -54,10 +54,10 @@ class SENet(nn.Module):
Args:
spatial_dims: spatial dimension of the input data.
in_channels: channel number of the input data.
- block: SEBlock class.
- for SENet154: SEBottleneck
- for SE-ResNet models: SEResNetBottleneck
- for SE-ResNeXt models: SEResNeXtBottleneck
+ block: SEBlock class or str.
+ for SENet154: SEBottleneck or 'se_bottleneck'
+ for SE-ResNet models: SEResNetBottleneck or 'se_resnet_bottleneck'
+ for SE-ResNeXt models: SEResNeXtBottleneck or 'se_resnetxt_bottleneck'
layers: number of residual blocks for 4 layers of the network (layer1...layer4).
groups: number of groups for the 3x3 convolution in each bottleneck block.
for SENet154: 64
@@ -95,7 +95,7 @@ def __init__(
self,
spatial_dims: int,
in_channels: int,
- block: Type[Union[SEBottleneck, SEResNetBottleneck, SEResNeXtBottleneck]],
+ block: Union[Type[Union[SEBottleneck, SEResNetBottleneck, SEResNeXtBottleneck]], str],
layers: Sequence[int],
groups: int,
reduction: int,
@@ -109,6 +109,18 @@ def __init__(
super().__init__()
+ if isinstance(block, str):
+ if block == "se_bottleneck":
+ block = SEBottleneck
+ elif block == "se_resnet_bottleneck":
+ block = SEResNetBottleneck
+ elif block == "se_resnetxt_bottleneck":
+ block = SEResNeXtBottleneck
+ else:
+ raise ValueError(
+ "Unknown block '%s', use se_bottleneck, se_resnet_bottleneck or se_resnetxt_bottleneck" % block
+ )
+
relu_type: Type[nn.ReLU] = Act[Act.RELU]
conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv[Conv.CONV, spatial_dims]
pool_type: Type[Union[nn.MaxPool1d, nn.MaxPool2d, nn.MaxPool3d]] = Pool[Pool.MAX, spatial_dims]
diff --git a/monai/networks/nets/swin_unetr.py b/monai/networks/nets/swin_unetr.py
index 994fb50171..933ed06f7f 100644
--- a/monai/networks/nets/swin_unetr.py
+++ b/monai/networks/nets/swin_unetr.py
@@ -9,7 +9,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Sequence, Tuple, Type, Union
+import itertools
+from typing import Optional, Sequence, Tuple, Type, Union
import numpy as np
import torch
@@ -21,10 +22,23 @@
from monai.networks.blocks import MLPBlock as Mlp
from monai.networks.blocks import PatchEmbed, UnetOutBlock, UnetrBasicBlock, UnetrUpBlock
from monai.networks.layers import DropPath, trunc_normal_
-from monai.utils import ensure_tuple_rep, optional_import
+from monai.utils import ensure_tuple_rep, look_up_option, optional_import
rearrange, _ = optional_import("einops", name="rearrange")
+__all__ = [
+ "SwinUNETR",
+ "window_partition",
+ "window_reverse",
+ "WindowAttention",
+ "SwinTransformerBlock",
+ "PatchMerging",
+ "PatchMergingV2",
+ "MERGING_MODE",
+ "BasicLayer",
+ "SwinTransformer",
+]
+
class SwinUNETR(nn.Module):
"""
@@ -48,6 +62,7 @@ def __init__(
normalize: bool = True,
use_checkpoint: bool = False,
spatial_dims: int = 3,
+ downsample="merging",
) -> None:
"""
Args:
@@ -64,6 +79,9 @@ def __init__(
normalize: normalize output intermediate features in each stage.
use_checkpoint: use gradient checkpointing for reduced memory usage.
spatial_dims: number of spatial dims.
+ downsample: module used for downsampling, available options are `"mergingv2"`, `"merging"` and a
+ user-specified `nn.Module` following the API defined in :py:class:`monai.networks.nets.PatchMerging`.
+ The default is currently `"merging"` (the original version defined in v0.9.0).
Examples::
@@ -121,6 +139,7 @@ def __init__(
norm_layer=nn.LayerNorm,
use_checkpoint=use_checkpoint,
spatial_dims=spatial_dims,
+ downsample=look_up_option(downsample, MERGING_MODE) if isinstance(downsample, str) else downsample,
)
self.encoder1 = UnetrBasicBlock(
@@ -350,7 +369,7 @@ def window_reverse(windows, window_size, dims):
elif len(dims) == 3:
b, h, w = dims
- x = windows.view(b, h // window_size[0], w // window_size[0], window_size[0], window_size[1], -1)
+ x = windows.view(b, h // window_size[0], w // window_size[1], window_size[0], window_size[1], -1)
x = x.permute(0, 1, 3, 2, 4, 5).contiguous().view(b, h, w, -1)
return x
@@ -484,7 +503,7 @@ def forward(self, x, mask):
else:
attn = self.softmax(attn)
- attn = self.attn_drop(attn)
+ attn = self.attn_drop(attn).to(v.dtype)
x = (attn @ v).transpose(1, 2).reshape(b, n, c)
x = self.proj(x)
x = self.proj_drop(x)
@@ -570,8 +589,8 @@ def forward_part1(self, x, mask_matrix):
b, h, w, c = x.shape
window_size, shift_size = get_window_size((h, w), self.window_size, self.shift_size)
pad_l = pad_t = 0
- pad_r = (window_size[0] - h % window_size[0]) % window_size[0]
- pad_b = (window_size[1] - w % window_size[1]) % window_size[1]
+ pad_b = (window_size[0] - h % window_size[0]) % window_size[0]
+ pad_r = (window_size[1] - w % window_size[1]) % window_size[1]
x = F.pad(x, (0, 0, pad_l, pad_r, pad_t, pad_b))
_, hp, wp, _ = x.shape
dims = [b, hp, wp]
@@ -657,7 +676,7 @@ def forward(self, x, mask_matrix):
return x
-class PatchMerging(nn.Module):
+class PatchMergingV2(nn.Module):
"""
Patch merging layer based on: "Liu et al.,
Swin Transformer: Hierarchical Vision Transformer using Shifted Windows
@@ -689,33 +708,53 @@ def forward(self, x):
b, d, h, w, c = x_shape
pad_input = (h % 2 == 1) or (w % 2 == 1) or (d % 2 == 1)
if pad_input:
- x = F.pad(x, (0, 0, 0, d % 2, 0, w % 2, 0, h % 2))
- x0 = x[:, 0::2, 0::2, 0::2, :]
- x1 = x[:, 1::2, 0::2, 0::2, :]
- x2 = x[:, 0::2, 1::2, 0::2, :]
- x3 = x[:, 0::2, 0::2, 1::2, :]
- x4 = x[:, 1::2, 0::2, 1::2, :]
- x5 = x[:, 0::2, 1::2, 0::2, :]
- x6 = x[:, 0::2, 0::2, 1::2, :]
- x7 = x[:, 1::2, 1::2, 1::2, :]
- x = torch.cat([x0, x1, x2, x3, x4, x5, x6, x7], -1)
+ x = F.pad(x, (0, 0, 0, w % 2, 0, h % 2, 0, d % 2))
+ x = torch.cat(
+ [x[:, i::2, j::2, k::2, :] for i, j, k in itertools.product(range(2), range(2), range(2))], -1
+ )
elif len(x_shape) == 4:
b, h, w, c = x_shape
pad_input = (h % 2 == 1) or (w % 2 == 1)
if pad_input:
x = F.pad(x, (0, 0, 0, w % 2, 0, h % 2))
- x0 = x[:, 0::2, 0::2, :]
- x1 = x[:, 1::2, 0::2, :]
- x2 = x[:, 0::2, 1::2, :]
- x3 = x[:, 1::2, 1::2, :]
- x = torch.cat([x0, x1, x2, x3], -1)
+ x = torch.cat([x[:, j::2, i::2, :] for i, j in itertools.product(range(2), range(2))], -1)
+
+ x = self.norm(x)
+ x = self.reduction(x)
+ return x
+
+class PatchMerging(PatchMergingV2):
+ """The `PatchMerging` module previously defined in v0.9.0."""
+
+ def forward(self, x):
+ x_shape = x.size()
+ if len(x_shape) == 4:
+ return super().forward(x)
+ if len(x_shape) != 5:
+ raise ValueError(f"expecting 5D x, got {x.shape}.")
+ b, d, h, w, c = x_shape
+ pad_input = (h % 2 == 1) or (w % 2 == 1) or (d % 2 == 1)
+ if pad_input:
+ x = F.pad(x, (0, 0, 0, w % 2, 0, h % 2, 0, d % 2))
+ x0 = x[:, 0::2, 0::2, 0::2, :]
+ x1 = x[:, 1::2, 0::2, 0::2, :]
+ x2 = x[:, 0::2, 1::2, 0::2, :]
+ x3 = x[:, 0::2, 0::2, 1::2, :]
+ x4 = x[:, 1::2, 0::2, 1::2, :]
+ x5 = x[:, 0::2, 1::2, 0::2, :]
+ x6 = x[:, 0::2, 0::2, 1::2, :]
+ x7 = x[:, 1::2, 1::2, 1::2, :]
+ x = torch.cat([x0, x1, x2, x3, x4, x5, x6, x7], -1)
x = self.norm(x)
x = self.reduction(x)
return x
+MERGING_MODE = {"merging": PatchMerging, "mergingv2": PatchMergingV2}
+
+
def compute_mask(dims, window_size, shift_size, device):
"""Computing region masks based on: "Liu et al.,
Swin Transformer: Hierarchical Vision Transformer using Shifted Windows
@@ -776,13 +815,13 @@ def __init__(
drop: float = 0.0,
attn_drop: float = 0.0,
norm_layer: Type[LayerNorm] = nn.LayerNorm,
- downsample: isinstance = None, # type: ignore
+ downsample: Optional[nn.Module] = None,
use_checkpoint: bool = False,
) -> None:
"""
Args:
dim: number of feature channels.
- depths: number of layers in each stage.
+ depth: number of layers in each stage.
num_heads: number of attention heads.
window_size: local window size.
drop_path: stochastic depth rate.
@@ -791,7 +830,7 @@ def __init__(
drop: dropout rate.
attn_drop: attention dropout rate.
norm_layer: normalization layer.
- downsample: downsample layer at the end of the layer.
+ downsample: an optional downsampling layer at the end of the layer.
use_checkpoint: use gradient checkpointing for reduced memory usage.
"""
@@ -820,7 +859,7 @@ def __init__(
]
)
self.downsample = downsample
- if self.downsample is not None:
+ if callable(self.downsample):
self.downsample = downsample(dim=dim, norm_layer=norm_layer, spatial_dims=len(self.window_size))
def forward(self, x):
@@ -881,6 +920,7 @@ def __init__(
patch_norm: bool = False,
use_checkpoint: bool = False,
spatial_dims: int = 3,
+ downsample="merging",
) -> None:
"""
Args:
@@ -899,6 +939,9 @@ def __init__(
patch_norm: add normalization after patch embedding.
use_checkpoint: use gradient checkpointing for reduced memory usage.
spatial_dims: spatial dimension.
+ downsample: module used for downsampling, available options are `"mergingv2"`, `"merging"` and a
+ user-specified `nn.Module` following the API defined in :py:class:`monai.networks.nets.PatchMerging`.
+ The default is currently `"merging"` (the original version defined in v0.9.0).
"""
super().__init__()
@@ -920,6 +963,7 @@ def __init__(
self.layers2 = nn.ModuleList()
self.layers3 = nn.ModuleList()
self.layers4 = nn.ModuleList()
+ down_sample_mod = look_up_option(downsample, MERGING_MODE) if isinstance(downsample, str) else downsample
for i_layer in range(self.num_layers):
layer = BasicLayer(
dim=int(embed_dim * 2**i_layer),
@@ -932,7 +976,7 @@ def __init__(
drop=drop_rate,
attn_drop=attn_drop_rate,
norm_layer=norm_layer,
- downsample=PatchMerging,
+ downsample=down_sample_mod,
use_checkpoint=use_checkpoint,
)
if i_layer == 0:
diff --git a/monai/networks/nets/torchvision_fc.py b/monai/networks/nets/torchvision_fc.py
index ddfee6e041..3e481b1fbd 100644
--- a/monai/networks/nets/torchvision_fc.py
+++ b/monai/networks/nets/torchvision_fc.py
@@ -22,24 +22,78 @@
class TorchVisionFCModel(NetAdapter):
"""
- Customize the fully connected layer of TorchVision model or replace it by convolutional layer.
+ Customize the fully connected layer of (pretrained) TorchVision model or replace it by convolutional layer.
+
+ This class supports two primary use cases:
+
+ - use ``pool=None`` to indicate no modification in the pooling layers. It should be used with ``fc_name``
+ to locate the target FC layer to modify:
+ In this case, the class will load a torchvision classification model,
+ replace the last fully connected (FC) layer with a new FC layer with ``num_classes`` outputs,
+ example input arguments: ``use_conv=False, pool=None, fc_name="heads.head"``.
+ The ``heads.head`` specifies the target FC of the input model, could be found by ``model.named_modules()``,
+ for example::
+
+ from torchvision.models import vit_b_16
+ print([name[0] for name in vit_b_16().named_modules()])
+
+ - use ``pool=""`` or set it to a tuple of pooling parameters to indicate modifications of both
+ the pooling and the FC layer. It should be used with ``node_name`` to locate the model feature outputs:
+ In this case, the class will load a torchvision model, remove the existing pooling and FC layers, and
+
+ - append an additional convolution layer:
+ ``use_conv=True, pool="", node_name="permute"``
+ - append an additional pooling and FC layers:
+ ``use_conv=False, pool=("avg", {"kernel_size": 7, "stride": 1}), node_name="permute"``
+ - append an additional pooling and convolution layers:
+ ``use_conv=True, pool=("avg", {"kernel_size": 7, "stride": 1}), node_name="permute"``
+
+ The ``permute`` in the example is the target feature extraction node of the input
+ `model_name`, could be found by using the torchvision feature extraction utilities, for example::
+
+ from torchvision.models.feature_extraction import get_graph_node_names
+ from torchvision.models import swin_t
+ print(get_graph_node_names(swin_t())[0])
Args:
model_name: name of any torchvision model with fully connected layer at the end.
``resnet18`` (default), ``resnet34``, ``resnet50``, ``resnet101``, ``resnet152``,
- ``resnext50_32x4d``, ``resnext101_32x8d``, ``wide_resnet50_2``, ``wide_resnet101_2``.
+ ``resnext50_32x4d``, ``resnext101_32x8d``, ``wide_resnet50_2``, ``wide_resnet101_2``, ``inception_v3``.
model details: https://pytorch.org/vision/stable/models.html.
num_classes: number of classes for the last classification layer. Default to 1.
dim: number of supported spatial dimensions in the specified model, depends on the model implementation.
default to 2 as most Torchvision models are for 2D image processing.
in_channels: number of the input channels of last layer. if None, get it from `in_features` of last layer.
- use_conv: whether use convolutional layer to replace the last layer, default to False.
- pool: parameters for the pooling layer, it should be a tuple, the first item is name of the pooling layer,
- the second item is dictionary of the initialization args. if None, will not replace the `layers[-2]`.
- default to `("avg", {"kernel_size": 7, "stride": 1})`.
+ use_conv: whether to use convolutional layer to replace the last layer, default to False.
+ pool: parameters for the pooling layer, when it's a tuple, the first item is name of the pooling layer,
+ the second item is dictionary of the initialization args. If None, will not replace the `layers[-2]`.
+ default to `("avg", {"kernel_size": 7, "stride": 1})`. ``""`` indicates not adding a pooling layer.
bias: the bias value when replacing the last layer. if False, the layer will not learn an additive bias,
default to True.
pretrained: whether to use the imagenet pretrained weights. Default to False.
+ fc_name: the corresponding layer attribute of the last fully connected layer. Defaults to ``"fc"``.
+ node_name: the corresponding feature extractor node name of `model`. Defaults to "", not in use.
+ weights: additional weights enum for the torchvision model.
+ kwargs: additional parameters for the torchvision model.
+
+ Example::
+
+ import torch
+ from torchvision.models.inception import Inception_V3_Weights
+
+ from monai.networks.nets import TorchVisionFCModel
+
+ model = TorchVisionFCModel(
+ "inception_v3",
+ num_classes=4,
+ weights=Inception_V3_Weights.IMAGENET1K_V1,
+ use_conv=False,
+ pool=None,
+ )
+ # model = TorchVisionFCModel("vit_b_16", num_classes=4, pool=None, in_channels=768, fc_name="heads")
+ output = model.forward(torch.randn(2, 3, 299, 299))
+ print(output.shape) # torch.Size([2, 4])
+
"""
@deprecated_arg("n_classes", since="0.6")
@@ -54,14 +108,18 @@ def __init__(
bias: bool = True,
pretrained: bool = False,
n_classes: Optional[int] = None,
+ fc_name: str = "fc",
+ node_name: str = "",
+ weights=None,
+ **kwargs,
):
# in case the new num_classes is default but you still call deprecated n_classes
if n_classes is not None and num_classes == 1:
num_classes = n_classes
- model = getattr(models, model_name)(pretrained=pretrained)
- # check if the model is compatible, should have a FC layer at the end
- if not str(list(model.children())[-1]).startswith("Linear"):
- raise ValueError(f"Model ['{model_name}'] does not have a Linear layer at the end.")
+ if weights is not None:
+ model = getattr(models, model_name)(weights=weights, **kwargs)
+ else:
+ model = getattr(models, model_name)(pretrained=pretrained, **kwargs) # 'pretrained' deprecated 0.13
super().__init__(
model=model,
@@ -71,4 +129,6 @@ def __init__(
use_conv=use_conv,
pool=pool,
bias=bias,
+ fc_name=fc_name,
+ node_name=node_name,
)
diff --git a/monai/networks/nets/vit.py b/monai/networks/nets/vit.py
index a5f7963eca..d41ba447e9 100644
--- a/monai/networks/nets/vit.py
+++ b/monai/networks/nets/vit.py
@@ -43,6 +43,7 @@ def __init__(
num_classes: int = 2,
dropout_rate: float = 0.0,
spatial_dims: int = 3,
+ post_activation="Tanh",
) -> None:
"""
Args:
@@ -58,6 +59,8 @@ def __init__(
num_classes: number of classes if classification is used.
dropout_rate: faction of the input units to drop.
spatial_dims: number of spatial dimensions.
+ post_activation: add a final acivation function to the classification head when `classification` is True.
+ Default to "Tanh" for `nn.Tanh()`. Set to other values to remove this function.
Examples::
@@ -97,7 +100,10 @@ def __init__(
self.norm = nn.LayerNorm(hidden_size)
if self.classification:
self.cls_token = nn.Parameter(torch.zeros(1, 1, hidden_size))
- self.classification_head = nn.Sequential(nn.Linear(hidden_size, num_classes), nn.Tanh())
+ if post_activation == "Tanh":
+ self.classification_head = nn.Sequential(nn.Linear(hidden_size, num_classes), nn.Tanh())
+ else:
+ self.classification_head = nn.Linear(hidden_size, num_classes) # type: ignore
def forward(self, x):
x = self.patch_embedding(x)
diff --git a/monai/networks/nets/vnet.py b/monai/networks/nets/vnet.py
index 7669b4678e..9abd2bc5e2 100644
--- a/monai/networks/nets/vnet.py
+++ b/monai/networks/nets/vnet.py
@@ -67,16 +67,19 @@ def __init__(
):
super().__init__()
- if 16 % in_channels != 0:
- raise ValueError(f"16 should be divisible by in_channels, got in_channels={in_channels}.")
+ if out_channels % in_channels != 0:
+ raise ValueError(
+ f"out channels should be divisible by in_channels. Got in_channels={in_channels}, out_channels={out_channels}."
+ )
self.spatial_dims = spatial_dims
self.in_channels = in_channels
- self.act_function = get_acti_layer(act, 16)
+ self.out_channels = out_channels
+ self.act_function = get_acti_layer(act, out_channels)
self.conv_block = Convolution(
spatial_dims=spatial_dims,
in_channels=in_channels,
- out_channels=16,
+ out_channels=out_channels,
kernel_size=5,
act=None,
norm=Norm.BATCH,
@@ -85,7 +88,7 @@ def __init__(
def forward(self, x):
out = self.conv_block(x)
- repeat_num = 16 // self.in_channels
+ repeat_num = self.out_channels // self.in_channels
x16 = x.repeat([1, repeat_num, 1, 1, 1][: self.spatial_dims + 2])
out = self.act_function(torch.add(out, x16))
return out
diff --git a/monai/networks/utils.py b/monai/networks/utils.py
index f9018b0c39..9c9de2cca5 100644
--- a/monai/networks/utils.py
+++ b/monai/networks/utils.py
@@ -21,9 +21,11 @@
import torch
import torch.nn as nn
+from monai.apps.utils import get_logger
from monai.config import PathLike
from monai.utils.deprecate_utils import deprecated, deprecated_arg
from monai.utils.misc import ensure_tuple, save_obj, set_determinism
+from monai.utils.module import look_up_option
__all__ = [
"one_hot",
@@ -41,10 +43,69 @@
"save_state",
"convert_to_torchscript",
"meshgrid_ij",
+ "meshgrid_xy",
"replace_modules",
"replace_modules_temp",
+ "look_up_named_module",
+ "set_named_module",
]
+logger = get_logger(module_name=__name__)
+
+
+def look_up_named_module(name: str, mod, print_all_options=False):
+ """
+ get the named module in `mod` by the attribute name,
+ for example ``look_up_named_module(net, "features.3.1.attn")``
+
+ Args:
+ name: a string representing the module attribute.
+ mod: a pytorch module to be searched (in ``mod.named_modules()``).
+ print_all_options: whether to print all named modules when `name` is not found in `mod`. Defaults to False.
+
+ Returns:
+ the corresponding pytorch module's subcomponent such as ``net.features[3][1].attn``
+ """
+ name_str = look_up_option(
+ name, {n[0] for n in mod.named_modules()}, default=None, print_all_options=print_all_options
+ )
+ if name_str is None:
+ return None
+ if name_str == "":
+ return mod
+ for n in name_str.split("."):
+ if n.isdigit():
+ mod = mod[int(n)]
+ else:
+ n = look_up_option(n, {item[0] for item in mod.named_modules()}, default=None, print_all_options=False)
+ if n is None:
+ return None
+ mod = getattr(mod, n)
+ return mod
+
+
+def set_named_module(mod, name: str, new_layer):
+ """
+ look up `name` in `mod` and replace the layer with `new_layer`, return the updated `mod`.
+
+ Args:
+ mod: a pytorch module to be updated.
+ name: a string representing the target module attribute.
+ new_layer: a new module replacing the corresponding layer at ``mod.name``.
+
+ Returns:
+ an updated ``mod``
+
+ See also: :py:func:`monai.networks.utils.look_up_named_module`.
+ """
+ mods_attr = name.rsplit(".", 1)
+ submods, attr = mods_attr if len(mods_attr) == 2 else ("", name)
+ if not attr:
+ return new_layer
+ _mod = look_up_named_module(submods, mod)
+ setattr(_mod, attr, new_layer)
+ return mod
+
def one_hot(labels: torch.Tensor, num_classes: int, dtype: torch.dtype = torch.float, dim: int = 1) -> torch.Tensor:
"""
@@ -461,7 +522,7 @@ def copy_model_state(
updated_keys = sorted(set(updated_keys))
unchanged_keys = sorted(set(all_keys).difference(updated_keys))
- print(f"'dst' model updated: {len(updated_keys)} of {len(dst_dict)} variables.")
+ logger.info(f"'dst' model updated: {len(updated_keys)} of {len(dst_dict)} variables.")
if inplace and isinstance(dst, torch.nn.Module):
dst.load_state_dict(dst_dict)
return dst_dict, updated_keys, unchanged_keys
@@ -567,9 +628,17 @@ def convert_to_torchscript(
def meshgrid_ij(*tensors):
if torch.meshgrid.__kwdefaults__ is not None and "indexing" in torch.meshgrid.__kwdefaults__:
return torch.meshgrid(*tensors, indexing="ij") # new api pytorch after 1.10
+
return torch.meshgrid(*tensors)
+def meshgrid_xy(*tensors):
+ if torch.meshgrid.__kwdefaults__ is not None and "indexing" in torch.meshgrid.__kwdefaults__:
+ return torch.meshgrid(*tensors, indexing="xy") # new api pytorch after 1.10
+
+ return torch.meshgrid(tensors[1], tensors[0], *tensors[2:])
+
+
def _replace_modules(
parent: torch.nn.Module,
name: str,
diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py
index 0cc7fb3e67..bcb2f56670 100644
--- a/monai/transforms/__init__.py
+++ b/monai/transforms/__init__.py
@@ -257,6 +257,7 @@
Activations,
AsDiscrete,
FillHoles,
+ Invert,
KeepLargestConnectedComponent,
LabelFilter,
LabelToContour,
@@ -574,6 +575,7 @@
from .utils import (
Fourier,
allow_missing_keys_mode,
+ attach_hook,
compute_divisible_spatial_size,
convert_applied_interp_mode,
convert_pad_mode,
@@ -606,7 +608,9 @@
rescale_array,
rescale_array_int_max,
rescale_instance_array,
+ reset_ops_id,
resize_center,
+ sync_meta_info,
weighted_patch_samples,
zero_margins,
)
diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py
index 662eb62eb6..bd9f73bbc9 100644
--- a/monai/transforms/croppad/array.py
+++ b/monai/transforms/croppad/array.py
@@ -85,14 +85,15 @@ class Pad(InvertibleTransform):
in which case `np.pad` will be used.
Args:
- to_pad: the amount to be padded in each dimension [(low_H, high_H), (low_W, high_W), ...].
+ to_pad: the amount to pad in each dimension (including the channel) [(low_H, high_H), (low_W, high_W), ...].
if None, must provide in the `__call__` at runtime.
- mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
+ mode: available modes: (Numpy) {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
- available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
+ (PyTorch) {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
One of the listed string values or a user supplied function. Defaults to ``"constant"``.
See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
+ requires pytorch >= 1.10 for best compatibility.
kwargs: other arguments for the `np.pad` or `torch.pad` function.
note that `np.pad` treats channel dimension as the first dimension.
@@ -122,9 +123,12 @@ def compute_pad_width(self, spatial_shape: Sequence[int]) -> List[Tuple[int, int
def _np_pad(img: torch.Tensor, pad_width, mode, **kwargs) -> torch.Tensor:
img_np = img.detach().cpu().numpy() if isinstance(img, torch.Tensor) else img
mode = convert_pad_mode(dst=img_np, mode=mode).value
+ if mode == "constant" and "value" in kwargs:
+ val = kwargs.pop("value")
+ kwargs["constant_values"] = val
out = torch.as_tensor(np.pad(img, pad_width, mode=mode, **kwargs))
if isinstance(img, MetaTensor):
- out = MetaTensor(out, meta=img.meta, applied_operations=img.applied_operations)
+ out = convert_to_dst_type(out, dst=img)[0]
return out
@staticmethod
@@ -141,9 +145,9 @@ def __call__( # type: ignore
img: data to be transformed, assuming `img` is channel-first and padding doesn't apply to the channel dim.
to_pad: the amount to be padded in each dimension [(low_H, high_H), (low_W, high_W), ...].
default to `self.to_pad`.
- mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
+ mode: available modes: (Numpy) {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
- available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
+ (PyTorch) {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
One of the listed string values or a user supplied function. Defaults to ``"constant"``.
See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
@@ -163,16 +167,26 @@ def __call__( # type: ignore
# all zeros, skip padding
if np.asarray(to_pad_).any():
- if mode in ["linear_ramp", "maximum", "mean", "median", "minimum", "symmetric", "empty"]:
+ to_pad_ = list(to_pad_)
+ if len(to_pad_) < len(img_t.shape):
+ to_pad_ = list(to_pad_) + [(0, 0)] * (len(img_t.shape) - len(to_pad_))
+ if mode_ in {"linear_ramp", "maximum", "mean", "median", "minimum", "symmetric", "empty"}:
out = self._np_pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_)
else:
+ mode_ = convert_pad_mode(dst=img_t, mode=mode_).value
try:
- mode_ = convert_pad_mode(dst=img_t, mode=mode_).value
- out = self._pt_pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_)
- # but if mode or args don't exist in pytorch, use numpy instead
- except (ValueError, TypeError) as err:
- if "Unsupported option" in str(err) or "unexpected keyword" in str(err):
+ _pad = (
+ self._pt_pad
+ if mode_ in {"reflect", "replicate"}
+ and img_t.dtype not in {torch.int16, torch.int64, torch.bool, torch.uint8}
+ else self._np_pad
+ )
+ out = _pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_)
+ except (ValueError, TypeError, RuntimeError) as err:
+ if "supported" in str(err) or "unexpected keyword" in str(err) or "implemented" in str(err):
out = self._np_pad(img_t, pad_width=to_pad_, mode=mode_, **kwargs_)
+ else:
+ raise ValueError(f"{mode_}, {kwargs_}, {img_t.dtype}, {img_t.device}") from err
else:
out = img_t
if get_track_meta():
@@ -184,7 +198,7 @@ def update_meta(self, tensor: MetaTensor, to_pad: List[Tuple[int, int]]):
spatial_rank = max(len(tensor.affine) - 1, 1)
to_shift = [-s[0] for s in to_pad[1:]] # skipping the channel pad
mat = create_translate(spatial_rank, to_shift)
- tensor.meta["affine"] = tensor.affine @ convert_to_dst_type(mat, tensor.affine)[0]
+ tensor.affine = tensor.affine @ convert_to_dst_type(mat, tensor.affine)[0]
def inverse(self, data: MetaTensor) -> MetaTensor:
transform = self.pop_transform(data)
@@ -384,8 +398,8 @@ def compute_slices(
return list(roi_slices)
else:
if roi_center is not None and roi_size is not None:
- roi_center_t = convert_to_tensor(data=roi_center, dtype=torch.int16, wrap_sequence=True)
- roi_size_t = convert_to_tensor(data=roi_size, dtype=torch.int16, wrap_sequence=True)
+ roi_center_t = convert_to_tensor(data=roi_center, dtype=torch.int16, wrap_sequence=True, device="cpu")
+ roi_size_t = convert_to_tensor(data=roi_size, dtype=torch.int16, wrap_sequence=True, device="cpu")
_zeros = torch.zeros_like(roi_center_t)
half = (
torch.divide(roi_size_t, 2, rounding_mode="floor")
@@ -436,7 +450,7 @@ def update_meta(self, tensor: MetaTensor, slices: Tuple[slice, ...]):
spatial_rank = max(len(tensor.affine) - 1, 1)
to_shift = [s.start if s.start is not None else 0 for s in ensure_tuple(slices)[1:]]
mat = create_translate(spatial_rank, to_shift)
- tensor.meta["affine"] = tensor.affine @ convert_to_dst_type(mat, tensor.affine)[0]
+ tensor.affine = tensor.affine @ convert_to_dst_type(mat, tensor.affine)[0]
def inverse(self, img: MetaTensor) -> MetaTensor:
transform = self.pop_transform(img)
diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py
index 34945b6b84..a3cb15144b 100644
--- a/monai/transforms/croppad/batch.py
+++ b/monai/transforms/croppad/batch.py
@@ -13,8 +13,7 @@
https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design
"""
-from copy import deepcopy
-from typing import Any, Dict, Hashable
+from typing import Any, Dict, Hashable, Mapping
import numpy as np
import torch
@@ -100,18 +99,24 @@ def __call__(self, batch: Any):
batch = replace_element(padded, batch, idx, key_or_idx)
# If we have a dictionary of data, append to list
+ # padder transform info is re-added with self.push_transform to ensure one info dict per transform.
if is_list_of_dicts:
- self.push_transform(batch[idx], key_or_idx, orig_size=orig_size)
+ self.push_transform(
+ batch[idx],
+ key_or_idx,
+ orig_size=orig_size,
+ extra_info=self.pop_transform(batch[idx], key_or_idx, check=False),
+ )
# After padding, use default list collator
return list_data_collate(batch)
@staticmethod
def inverse(data: dict) -> Dict[Hashable, np.ndarray]:
- if not isinstance(data, dict):
+ if not isinstance(data, Mapping):
raise RuntimeError("Inverse can only currently be applied on dictionaries.")
- d = deepcopy(data)
+ d = dict(data)
for key in d:
transforms = None
if isinstance(d[key], MetaTensor):
diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py
index a3310a10d1..bae6705c22 100644
--- a/monai/transforms/croppad/dictionary.py
+++ b/monai/transforms/croppad/dictionary.py
@@ -587,12 +587,11 @@ def randomize(self, data: Optional[Any] = None) -> None:
self.sub_seed = self.R.randint(MAX_SEED, dtype="uint32")
def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]:
- # output starts as empty list of dictionaries
- ret: List[Dict[Hashable, torch.Tensor]] = [{} for _ in range(self.cropper.num_samples)]
+ ret: List[Dict[Hashable, torch.Tensor]] = [dict(data) for _ in range(self.cropper.num_samples)]
# deep copy all the unmodified data
- for key in set(data.keys()).difference(set(self.keys)):
- for r in ret:
- r[key] = deepcopy(data[key])
+ for i in range(self.cropper.num_samples):
+ for key in set(data.keys()).difference(set(self.keys)):
+ ret[i][key] = deepcopy(data[key])
# for each key we reset the random state to ensure crops are the same
self.randomize()
@@ -736,11 +735,11 @@ def randomize(self, weight_map: NdarrayOrTensor) -> None:
def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable, torch.Tensor]]:
# output starts as empty list of dictionaries
- ret: List = [{} for _ in range(self.cropper.num_samples)]
+ ret: List = [dict(data) for _ in range(self.cropper.num_samples)]
# deep copy all the unmodified data
- for key in set(data.keys()).difference(set(self.keys)):
- for r in ret:
- r[key] = deepcopy(data[key])
+ for i in range(self.cropper.num_samples):
+ for key in set(data.keys()).difference(set(self.keys)):
+ ret[i][key] = deepcopy(data[key])
self.randomize(weight_map=data[self.w_key])
for key in self.key_iterator(data):
@@ -862,11 +861,11 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> List[Dict[Hashable,
self.randomize(label, fg_indices, bg_indices, image)
# initialize returned list with shallow copy to preserve key ordering
- ret: List = [{} for _ in range(self.cropper.num_samples)]
+ ret: List = [dict(d) for _ in range(self.cropper.num_samples)]
# deep copy all the unmodified data
- for key in set(d.keys()).difference(set(self.keys)):
- for r in ret:
- r[key] = deepcopy(d[key])
+ for i in range(self.cropper.num_samples):
+ for key in set(d.keys()).difference(set(self.keys)):
+ ret[i][key] = deepcopy(d[key])
for key in self.key_iterator(d):
for i, im in enumerate(self.cropper(d[key], label=label, randomize=False)):
@@ -1000,11 +999,11 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, torch.Te
self.randomize(label, indices, image)
# initialize returned list with shallow copy to preserve key ordering
- ret: List = [{} for _ in range(self.cropper.num_samples)]
+ ret: List = [dict(d) for _ in range(self.cropper.num_samples)]
# deep copy all the unmodified data
- for key in set(d.keys()).difference(set(self.keys)):
- for r in ret:
- r[key] = deepcopy(d[key])
+ for i in range(self.cropper.num_samples):
+ for key in set(d.keys()).difference(set(self.keys)):
+ ret[i][key] = deepcopy(d[key])
for key in self.key_iterator(d):
for i, im in enumerate(self.cropper(d[key], label=label, randomize=False)):
diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py
index 41a02989db..31b05daf4e 100644
--- a/monai/transforms/inverse.py
+++ b/monai/transforms/inverse.py
@@ -16,6 +16,7 @@
import torch
+from monai import transforms
from monai.data.meta_tensor import MetaTensor
from monai.transforms.transform import Transform
from monai.utils.enums import TraceKeys
@@ -55,7 +56,7 @@ class TraceableTransform(Transform):
`MONAI_TRACE_TRANSFORM` when initializing the class.
"""
- tracing = not os.environ.get("MONAI_TRACE_TRANSFORM", "1") == "0"
+ tracing = os.environ.get("MONAI_TRACE_TRANSFORM", "1") != "0"
def set_tracing(self, tracing: bool) -> None:
"""Set whether to trace transforms."""
@@ -250,6 +251,22 @@ class InvertibleTransform(TraceableTransform):
"""
+ def inverse_update(self, data):
+ """
+ This function is to be called before every `self.inverse(data)`,
+ update each MetaTensor `data[key]` using `data[key_transforms]` and `data[key_meta_dict]`,
+ for MetaTensor backward compatibility 0.9.0.
+ """
+ if not isinstance(data, dict) or not isinstance(self, transforms.MapTransform):
+ return data
+ d = dict(data)
+ for k in self.key_iterator(data):
+ transform_key = transforms.TraceableTransform.trace_key(k)
+ if transform_key not in data or not data[transform_key]:
+ continue
+ d = transforms.sync_meta_info(k, data, t=False)
+ return d
+
def inverse(self, data: Any) -> Any:
"""
Inverse of ``__call__``.
diff --git a/monai/transforms/inverse_batch_transform.py b/monai/transforms/inverse_batch_transform.py
index cc77a199dd..3bfbb961a8 100644
--- a/monai/transforms/inverse_batch_transform.py
+++ b/monai/transforms/inverse_batch_transform.py
@@ -88,7 +88,9 @@ def __init__(
self.detach = detach
self.pad_batch = pad_batch
self.fill_value = fill_value
- self.pad_collation_used = loader.collate_fn.__doc__ == pad_list_data_collate.__doc__
+ self.pad_collation_used = loader.collate_fn.__doc__ == pad_list_data_collate.__doc__ or isinstance(
+ loader.collate_fn, PadListDataCollate
+ )
def __call__(self, data: Dict[str, Any]) -> Any:
decollated_data = decollate_batch(data, detach=self.detach, pad=self.pad_batch, fill_value=self.fill_value)
diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py
index 8dd849d33e..5ee17e6268 100644
--- a/monai/transforms/io/array.py
+++ b/monai/transforms/io/array.py
@@ -99,6 +99,10 @@ class LoadImage(Transform):
- Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader),
(npz, npy -> NumpyReader), (nrrd -> NrrdReader), (DICOM file -> ITKReader).
+ Please note that for png, jpg, bmp, and other 2D formats, readers often swap axis 0 and 1 after
+ loading the array because the `HW` definition for non-medical specific file formats is different
+ from other common medical packages.
+
See also:
- tutorial: https://github.com/Project-MONAI/tutorials/blob/master/modules/load_medical_images.ipynb
@@ -112,6 +116,8 @@ def __init__(
dtype: DtypeLike = np.float32,
ensure_channel_first: bool = False,
simple_keys: bool = False,
+ prune_meta_pattern: Optional[str] = None,
+ prune_meta_sep: str = ".",
*args,
**kwargs,
) -> None:
@@ -129,6 +135,11 @@ def __init__(
ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert
the image array shape to `channel first`. default to `False`.
simple_keys: whether to remove redundant metadata keys, default to False for backward compatibility.
+ prune_meta_pattern: combined with `prune_meta_sep`, a regular expression used to match and prune keys
+ in the metadata (nested dictionary), default to None, no key deletion.
+ prune_meta_sep: combined with `prune_meta_pattern`, used to match and prune keys
+ in the metadata (nested dictionary). default is ".", see also :py:class:`monai.transforms.DeleteItemsd`.
+ e.g. ``prune_meta_pattern=".*_code$", prune_meta_sep=" "`` removes meta keys that ends with ``"_code"``.
args: additional parameters for reader if providing a reader name.
kwargs: additional parameters for reader if providing a reader name.
@@ -148,6 +159,8 @@ def __init__(
self.dtype = dtype
self.ensure_channel_first = ensure_channel_first
self.simple_keys = simple_keys
+ self.pattern = prune_meta_pattern
+ self.sep = prune_meta_sep
self.readers: List[ImageReader] = []
for r in SUPPORTED_READERS: # set predefined readers as default
@@ -258,7 +271,9 @@ def __call__(self, filename: Union[Sequence[PathLike], PathLike], reader: Option
meta_data = switch_endianness(meta_data, "<")
meta_data[Key.FILENAME_OR_OBJ] = f"{ensure_tuple(filename)[0]}" # Path obj should be strings for data loader
- img = MetaTensor.ensure_torch_and_prune_meta(img_array, meta_data, self.simple_keys)
+ img = MetaTensor.ensure_torch_and_prune_meta(
+ img_array, meta_data, self.simple_keys, pattern=self.pattern, sep=self.sep
+ )
if self.ensure_channel_first:
img = EnsureChannelFirst()(img)
if self.image_only:
diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py
index 42918f5e63..a1f088b498 100644
--- a/monai/transforms/io/dictionary.py
+++ b/monai/transforms/io/dictionary.py
@@ -51,6 +51,10 @@ class LoadImaged(MapTransform):
- Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader),
(npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader).
+ Please note that for png, jpg, bmp, and other 2D formats, readers often swap axis 0 and 1 after
+ loading the array because the `HW` definition for non-medical specific file formats is different
+ from other common medical packages.
+
Note:
- If `reader` is specified, the loader will attempt to use the specified readers and the default supported
@@ -75,6 +79,8 @@ def __init__(
image_only: bool = False,
ensure_channel_first: bool = False,
simple_keys: bool = False,
+ prune_meta_pattern: Optional[str] = None,
+ prune_meta_sep: str = ".",
allow_missing_keys: bool = False,
*args,
**kwargs,
@@ -105,12 +111,27 @@ def __init__(
ensure_channel_first: if `True` and loaded both image array and metadata, automatically convert
the image array shape to `channel first`. default to `False`.
simple_keys: whether to remove redundant metadata keys, default to False for backward compatibility.
+ prune_meta_pattern: combined with `prune_meta_sep`, a regular expression used to match and prune keys
+ in the metadata (nested dictionary), default to None, no key deletion.
+ prune_meta_sep: combined with `prune_meta_pattern`, used to match and prune keys
+ in the metadata (nested dictionary). default is ".", see also :py:class:`monai.transforms.DeleteItemsd`.
+ e.g. ``prune_meta_pattern=".*_code$", prune_meta_sep=" "`` removes meta keys that ends with ``"_code"``.
allow_missing_keys: don't raise exception if key is missing.
args: additional parameters for reader if providing a reader name.
kwargs: additional parameters for reader if providing a reader name.
"""
super().__init__(keys, allow_missing_keys)
- self._loader = LoadImage(reader, image_only, dtype, ensure_channel_first, simple_keys, *args, **kwargs)
+ self._loader = LoadImage(
+ reader,
+ image_only,
+ dtype,
+ ensure_channel_first,
+ simple_keys,
+ prune_meta_pattern,
+ prune_meta_sep,
+ *args,
+ **kwargs,
+ )
if not isinstance(meta_key_postfix, str):
raise TypeError(f"meta_key_postfix must be a str but is {type(meta_key_postfix).__name__}.")
self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys)
diff --git a/monai/transforms/meta_utility/dictionary.py b/monai/transforms/meta_utility/dictionary.py
index 1dcdf3483c..90a6666b95 100644
--- a/monai/transforms/meta_utility/dictionary.py
+++ b/monai/transforms/meta_utility/dictionary.py
@@ -15,14 +15,17 @@
Class names are ended with 'd' to denote dictionary-based transforms.
"""
-from copy import deepcopy
-from typing import Dict, Hashable, Mapping
+from typing import Dict, Hashable, Mapping, Sequence, Union
-from monai.config.type_definitions import NdarrayOrTensor
+import numpy as np
+import torch
+
+from monai.config.type_definitions import KeysCollection, NdarrayOrTensor
from monai.data.meta_tensor import MetaTensor
from monai.transforms.inverse import InvertibleTransform
from monai.transforms.transform import MapTransform
from monai.utils.enums import PostFix, TransformBackends
+from monai.utils.misc import ensure_tuple_rep
__all__ = [
"FromMetaTensord",
@@ -44,16 +47,29 @@ class FromMetaTensord(MapTransform, InvertibleTransform):
backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
+ def __init__(
+ self, keys: KeysCollection, data_type: Union[Sequence[str], str] = "tensor", allow_missing_keys: bool = False
+ ):
+ """
+ Args:
+ keys: keys of the corresponding items to be transformed.
+ See also: :py:class:`monai.transforms.compose.MapTransform`
+ data_type: target data type to convert, should be "tensor" or "numpy".
+ allow_missing_keys: don't raise exception if key is missing.
+ """
+ super().__init__(keys, allow_missing_keys)
+ self.as_tensor_output = tuple(d == "tensor" for d in ensure_tuple_rep(data_type, len(self.keys)))
+
def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
d = dict(data)
- for key in self.key_iterator(d):
+ for key, t in self.key_iterator(d, self.as_tensor_output):
im: MetaTensor = d[key] # type: ignore
- d.update(im.as_dict(key))
+ d.update(im.as_dict(key, output_type=torch.Tensor if t else np.ndarray))
self.push_transform(d, key)
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
# check transform
_ = self.get_most_recent_transform(d, key)
@@ -90,7 +106,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
# check transform
_ = self.get_most_recent_transform(d, key)
diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py
index 29aa39d7ac..aab4aaa12f 100644
--- a/monai/transforms/post/array.py
+++ b/monai/transforms/post/array.py
@@ -21,10 +21,17 @@
from monai.config.type_definitions import NdarrayOrTensor
from monai.data.meta_obj import get_track_meta
+from monai.data.meta_tensor import MetaTensor
from monai.networks import one_hot
from monai.networks.layers import GaussianFilter, apply_filter
+from monai.transforms.inverse import InvertibleTransform
from monai.transforms.transform import Transform
-from monai.transforms.utils import fill_holes, get_largest_connected_component_mask, get_unique_labels
+from monai.transforms.utils import (
+ convert_applied_interp_mode,
+ fill_holes,
+ get_largest_connected_component_mask,
+ get_unique_labels,
+)
from monai.transforms.utils_pytorch_numpy_unification import unravel_index
from monai.utils import (
TransformBackends,
@@ -46,6 +53,7 @@
"MeanEnsemble",
"ProbNMS",
"VoteEnsemble",
+ "Invert",
]
@@ -765,3 +773,45 @@ def __call__(self, prob_map: NdarrayOrTensor):
prob_map[slices] = 0
return outputs
+
+
+class Invert(Transform):
+ """
+ Utility transform to automatically invert the previously applied transforms.
+ """
+
+ def __init__(
+ self,
+ transform: Optional[InvertibleTransform] = None,
+ nearest_interp: Union[bool, Sequence[bool]] = True,
+ device: Union[Union[str, torch.device], Sequence[Union[str, torch.device]]] = "cpu",
+ post_func: Union[Callable, Sequence[Callable]] = lambda x: x,
+ ) -> None:
+ """
+ Args:
+ transform: the previously applied transform.
+ nearest_interp: whether to use `nearest` interpolation mode when inverting the spatial transforms,
+ default to `True`. If `False`, use the same interpolation mode as the original transform.
+ device: move the inverted results to a target device before `post_func`, default to "cpu".
+ post_func: postprocessing for the inverted MetaTensor, should be a callable function.
+ """
+ if not isinstance(transform, InvertibleTransform):
+ raise ValueError("transform is not invertible, can't invert transform for the data.")
+ self.transform = transform
+ self.nearest_interp = nearest_interp
+ self.device = device
+ self.post_func = post_func
+
+ def __call__(self, data):
+ if not isinstance(data, MetaTensor):
+ return data
+
+ if self.nearest_interp:
+ data.applied_operations = convert_applied_interp_mode(
+ trans_info=data.applied_operations, mode="nearest", align_corners=None
+ )
+
+ data = data.detach()
+ inverted = self.transform.inverse(data)
+ inverted = self.post_func(inverted.to(self.device))
+ return inverted
diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py
index 3704d92ec3..67e1938454 100644
--- a/monai/transforms/post/dictionary.py
+++ b/monai/transforms/post/dictionary.py
@@ -21,6 +21,7 @@
import torch
+from monai import config
from monai.config.type_definitions import KeysCollection, NdarrayOrTensor, PathLike
from monai.data.csv_saver import CSVSaver
from monai.data.meta_tensor import MetaTensor
@@ -39,8 +40,7 @@
from monai.transforms.transform import MapTransform
from monai.transforms.utility.array import ToTensor
from monai.transforms.utils import allow_missing_keys_mode, convert_applied_interp_mode
-from monai.utils import convert_to_tensor, deprecated_arg, ensure_tuple, ensure_tuple_rep
-from monai.utils.enums import PostFix
+from monai.utils import PostFix, convert_to_tensor, deprecated_arg, ensure_tuple, ensure_tuple_rep
__all__ = [
"ActivationsD",
@@ -634,15 +634,16 @@ def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]:
warnings.warn(f"transform info of `{orig_key}` is not available or no InvertibleTransform applied.")
continue
+ orig_meta_key = orig_meta_key or f"{orig_key}_{meta_key_postfix}"
if orig_key in d and isinstance(d[orig_key], MetaTensor):
transform_info = d[orig_key].applied_operations
meta_info = d[orig_key].meta
else:
transform_info = d[InvertibleTransform.trace_key(orig_key)]
- meta_info = d.get(orig_meta_key or f"{orig_key}_{meta_key_postfix}", {})
+ meta_info = d.get(orig_meta_key, {})
if nearest_interp:
transform_info = convert_applied_interp_mode(
- trans_info=deepcopy(transform_info), mode="nearest", align_corners=None
+ trans_info=transform_info, mode="nearest", align_corners=None
)
inputs = d[key]
@@ -651,12 +652,14 @@ def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]:
if not isinstance(inputs, MetaTensor):
inputs = convert_to_tensor(inputs, track_meta=True)
- inputs.applied_operations = transform_info
- inputs.meta = meta_info
+ inputs.applied_operations = deepcopy(transform_info)
+ inputs.meta = deepcopy(meta_info)
# construct the input dict data
input_dict = {orig_key: inputs}
-
+ if config.USE_META_DICT:
+ input_dict[InvertibleTransform.trace_key(orig_key)] = transform_info
+ input_dict[PostFix.meta(orig_key)] = meta_info
with allow_missing_keys_mode(self.transform): # type: ignore
inverted = self.transform.inverse(input_dict)
@@ -666,8 +669,10 @@ def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]:
else:
inverted_data = inverted[orig_key]
d[key] = post_func(inverted_data.to(device))
-
- # save the inverted meta dict
+ # save the invertd applied_operations if it's in the source dict
+ if InvertibleTransform.trace_key(orig_key) in d:
+ d[InvertibleTransform.trace_key(orig_key)] = inverted_data.applied_operations
+ # save the inverted meta dict if it's in the source dict
if orig_meta_key in d:
meta_key = meta_key or f"{key}_{meta_key_postfix}"
d[meta_key] = inverted.get(orig_meta_key)
diff --git a/monai/transforms/smooth_field/array.py b/monai/transforms/smooth_field/array.py
index 953c589288..1aa3df017b 100644
--- a/monai/transforms/smooth_field/array.py
+++ b/monai/transforms/smooth_field/array.py
@@ -404,7 +404,8 @@ def __init__(
device=device,
)
- grid_space = spatial_size if spatial_size is not None else self.sfield.field.shape[2:]
+ grid_space = tuple(spatial_size) if spatial_size is not None else self.sfield.field.shape[2:]
+ # grid_space = grid_space[1::-1]+grid_space[2:]
grid_ranges = [torch.linspace(-1, 1, d) for d in grid_space]
grid = meshgrid_ij(*grid_ranges)
@@ -446,6 +447,7 @@ def __call__(
dgrid = self.grid + field.to(self.grid_dtype)
dgrid = moveaxis(dgrid, 1, -1) # type: ignore
+ dgrid = dgrid[..., list(range(dgrid.shape[-1] - 1, -1, -1))] # invert order of coordinates
img_t = convert_to_tensor(img[None], torch.float32, device)
diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py
index d875d3adee..122faaf9e4 100644
--- a/monai/transforms/spatial/array.py
+++ b/monai/transforms/spatial/array.py
@@ -15,11 +15,10 @@
import warnings
from copy import deepcopy
from enum import Enum
-from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
import numpy as np
import torch
-from numpy.lib.stride_tricks import as_strided
from monai.config import USE_COMPILED, DtypeLike
from monai.config.type_definitions import NdarrayOrTensor
@@ -43,13 +42,17 @@
map_spatial_axes,
scale_affine,
)
-from monai.transforms.utils_pytorch_numpy_unification import allclose, linalg_inv, moveaxis
+from monai.transforms.utils_pytorch_numpy_unification import allclose, linalg_inv, moveaxis, where
from monai.utils import (
GridSampleMode,
GridSamplePadMode,
InterpolateMode,
+ NdimageMode,
NumpyPadMode,
+ SplineMode,
+ convert_to_cupy,
convert_to_dst_type,
+ convert_to_numpy,
convert_to_tensor,
ensure_tuple,
ensure_tuple_rep,
@@ -66,6 +69,9 @@
from monai.utils.type_conversion import convert_data_type, get_equivalent_dtype, get_torch_dtype_from_string
nib, has_nib = optional_import("nibabel")
+cupy, _ = optional_import("cupy")
+cupy_ndi, _ = optional_import("cupyx.scipy.ndimage")
+np_ndi, _ = optional_import("scipy.ndimage")
__all__ = [
"SpatialResample",
@@ -113,24 +119,25 @@ class SpatialResample(InvertibleTransform):
def __init__(
self,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.BORDER,
align_corners: bool = False,
dtype: DtypeLike = np.float64,
):
"""
Args:
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
- See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
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``.
@@ -186,9 +193,9 @@ def __call__(
src_affine: Optional[NdarrayOrTensor] = None,
dst_affine: Optional[torch.Tensor] = None,
spatial_size: Optional[Union[Sequence[int], torch.Tensor, int]] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
- align_corners: Optional[bool] = False,
+ align_corners: Optional[bool] = None,
dtype: DtypeLike = None,
) -> torch.Tensor:
"""
@@ -203,17 +210,21 @@ def __call__(
if `spatial_size` and `self.spatial_size` are not defined,
the transform will compute a spatial size automatically containing the previous field of view.
if `spatial_size` is ``-1`` are the transform will use the corresponding input img size.
- mode: {``"bilinear"``, ``"nearest"``}
- Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
+ Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``"border"``.
+ Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ Defaults to ``None``, effectively using the value of `self.align_corners`.
dtype: data type for resampling computation. Defaults to ``self.dtype`` or
``np.float64`` (for best precision). If ``None``, use the data type of input data.
To be compatible with other modules, the output data type is always `float32`.
@@ -227,8 +238,8 @@ def __call__(
# get dtype as torch (e.g., torch.float64)
_dtype = get_equivalent_dtype(dtype or self.dtype or img.dtype, torch.Tensor)
align_corners = self.align_corners if align_corners is None else align_corners
- mode = mode or self.mode
- padding_mode = padding_mode or self.padding_mode
+ mode = mode if mode is not None else self.mode
+ padding_mode = padding_mode if padding_mode is not None else self.padding_mode
original_spatial_shape = img.shape[1:]
src_affine_: torch.Tensor = img.affine if isinstance(img, MetaTensor) else torch.eye(4)
@@ -285,7 +296,7 @@ def __call__(
for idx, d_dst in enumerate(spatial_size[:spatial_rank]):
_t_r[idx, -1] = (max(d_dst, 2) - 1.0) / 2.0
xform = xform @ _t_r
- if not USE_COMPILED:
+ if not USE_COMPILED and not isinstance(mode, int):
_t_l = normalize_transform(
in_spatial_size, xform.device, xform.dtype, align_corners=True # type: ignore
)[0]
@@ -298,7 +309,7 @@ def __call__(
else:
affine_xform = AffineTransform(
normalized=False,
- mode=mode,
+ mode=mode, # type: ignore
padding_mode=padding_mode,
align_corners=align_corners,
reverse_indexing=True,
@@ -353,9 +364,9 @@ def __call__(
img_dst: torch.Tensor,
src_meta: Optional[Dict] = None,
dst_meta: Optional[Dict] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
- align_corners: Optional[bool] = False,
+ align_corners: Optional[bool] = None,
dtype: DtypeLike = None,
) -> torch.Tensor:
"""
@@ -370,17 +381,20 @@ def __call__(
specified, ``src_affine`` is assumed. If ``spatial_shape`` is not specified, spatial size is
automatically computed, containing the previous field of view. Defaults to ``None``.
See also: https://docs.monai.io/en/stable/transforms.html#spatialresample
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
- Defaults to ``False``.
+ Defaults to ``None``, effectively using the value of `self.align_corners`.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
dtype: data type for resampling computation. Defaults to ``self.dtype`` or
``np.float64`` (for best precision). If ``None``, use the data type of input data.
@@ -390,7 +404,7 @@ def __call__(
RuntimeError: When ``dst_meta`` is missing.
ValueError: When the affine matrix of the source image is not invertible.
Returns:
- Resampled input image, Metadata
+ Resampled input tensor or MetaTensor.
"""
if img_dst is None:
raise RuntimeError("`img_dst` is missing.")
@@ -420,7 +434,7 @@ def __init__(
self,
pixdim: Union[Sequence[float], float, np.ndarray],
diagonal: bool = False,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.BORDER,
align_corners: bool = False,
dtype: DtypeLike = np.float64,
@@ -446,15 +460,18 @@ def __init__(
If False, this transform preserves the axes orientation, orthogonal rotation and
translation components from the original affine. This option will not flip/swap axes
of the original data.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
dtype: data type for resampling computation. Defaults to ``np.float64`` for best precision.
@@ -466,10 +483,7 @@ def __init__(
self.diagonal = diagonal
self.sp_resample = SpatialResample(
- mode=look_up_option(mode, GridSampleMode),
- padding_mode=look_up_option(padding_mode, GridSamplePadMode),
- align_corners=align_corners,
- dtype=dtype,
+ mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype
)
@deprecated_arg(name="affine", since="0.9", msg_suffix="Not needed, input should be `MetaTensor`.")
@@ -477,7 +491,7 @@ def __call__(
self,
data_array: torch.Tensor,
affine: Optional[NdarrayOrTensor] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
align_corners: Optional[bool] = None,
dtype: DtypeLike = None,
@@ -486,17 +500,21 @@ def __call__(
"""
Args:
data_array: in shape (num_channels, H[, W, ...]).
- mode: {``"bilinear"``, ``"nearest"``}
- Interpolation mode to calculate output values. Defaults to ``self.mode``.
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
+ Interpolation mode to calculate output values. Defaults to ``"self.mode"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``self.padding_mode``.
+ Padding mode for outside grid values. Defaults to ``"self.padding_mode"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ Defaults to ``None``, effectively using the value of `self.align_corners`.
dtype: data type for resampling computation. Defaults to ``self.dtype``.
If None, use the data type of input data. To be compatible with other modules,
the output data type is always ``np.float32``.
@@ -509,25 +527,23 @@ def __call__(
ValueError: When ``pixdim`` is nonpositive.
Returns:
- data_array (resampled into `self.pixdim`), original affine, current affine.
+ data tensor or MetaTensor (resampled into `self.pixdim`).
"""
- # if the input isn't MetaTensor, create MetaTensor with the default info.
- data_array = convert_to_tensor(data_array, track_meta=get_track_meta())
-
original_spatial_shape = data_array.shape[1:]
sr = len(original_spatial_shape)
if sr <= 0:
raise ValueError("data_array must have at least one spatial dimension.")
+ input_affine: Optional[NdarrayOrTensor] = None
affine_: np.ndarray
- affine_np: np.ndarray
- if isinstance(data_array, MetaTensor):
- affine_np, *_ = convert_data_type(data_array.affine, np.ndarray)
- affine_ = to_affine_nd(sr, affine_np)
- else:
+ if affine is not None:
+ warnings.warn("arg `affine` is deprecated, the affine of MetaTensor in data_array has higher priority.")
+ input_affine = data_array.affine if isinstance(data_array, MetaTensor) else affine
+ if input_affine is None:
warnings.warn("`data_array` is not of type MetaTensor, assuming affine to be identity.")
# default to identity
- affine_ = np.eye(sr + 1, dtype=np.float64)
+ input_affine = np.eye(sr + 1, dtype=np.float64)
+ affine_ = to_affine_nd(sr, convert_data_type(input_affine, np.ndarray)[0])
out_d = self.pixdim[:sr]
if out_d.size < sr:
@@ -617,8 +633,6 @@ def __call__(self, data_array: torch.Tensor) -> torch.Tensor:
`torch.Tensor`.
"""
- data_array = convert_to_tensor(data_array, track_meta=get_track_meta())
-
spatial_shape = data_array.shape[1:]
sr = len(spatial_shape)
if sr <= 0:
@@ -654,6 +668,9 @@ def __call__(self, data_array: torch.Tensor) -> torch.Tensor:
spatial_ornt = nib.orientations.ornt_transform(src, dst)
new_affine = affine_ @ nib.orientations.inv_ornt_aff(spatial_ornt, spatial_shape)
+ # convert to MetaTensor if necessary
+ data_array = convert_to_tensor(data_array, track_meta=get_track_meta())
+
spatial_ornt[:, 0] += 1 # skip channel dim
spatial_ornt = np.concatenate([np.array([[0, 1]]), spatial_ornt])
axes = [ax for ax, flip in enumerate(spatial_ornt[:, 1]) if flip == -1]
@@ -667,7 +684,6 @@ def __call__(self, data_array: torch.Tensor) -> torch.Tensor:
new_affine = to_affine_nd(affine_np, new_affine)
new_affine, *_ = convert_data_type(new_affine, torch.Tensor, dtype=torch.float32, device=data_array.device)
- data_array = convert_to_tensor(data_array, track_meta=get_track_meta())
if get_track_meta():
self.update_meta(data_array, new_affine)
self.push_transform(data_array, extra_info={"original_affine": affine_np})
@@ -845,10 +861,12 @@ def __call__(
scale = self.spatial_size / max(img_size)
spatial_size_ = tuple(int(round(s * scale)) for s in img_size)
- if tuple(img.shape[1:]) == spatial_size_: # spatial shape is already the desired
- return convert_to_tensor(img, track_meta=get_track_meta()) # type: ignore
-
original_sp_size = img.shape[1:]
+ _mode = look_up_option(self.mode if mode is None else mode, InterpolateMode)
+ _align_corners = self.align_corners if align_corners is None else align_corners
+ if tuple(img.shape[1:]) == spatial_size_: # spatial shape is already the desired
+ img = convert_to_tensor(img, track_meta=get_track_meta()) # type: ignore
+ return self._post_process(img, original_sp_size, spatial_size_, _mode, _align_corners, input_ndim)
img_ = convert_to_tensor(img, dtype=torch.float, track_meta=False)
if anti_aliasing and any(x < y for x, y in zip(spatial_size_, img_.shape[1:])):
@@ -865,25 +883,25 @@ def __call__(
img_ = convert_to_tensor(anti_aliasing_filter(img_), track_meta=False)
img = convert_to_tensor(img, track_meta=get_track_meta())
- _mode = look_up_option(self.mode if mode is None else mode, InterpolateMode)
- _align_corners = self.align_corners if align_corners is None else align_corners
-
resized = torch.nn.functional.interpolate(
input=img_.unsqueeze(0), size=spatial_size_, mode=_mode, align_corners=_align_corners
)
out, *_ = convert_to_dst_type(resized.squeeze(0), img)
+ return self._post_process(out, original_sp_size, spatial_size_, _mode, _align_corners, input_ndim)
+
+ def _post_process(self, img: torch.Tensor, orig_size, sp_size, mode, align_corners, ndim) -> torch.Tensor:
if get_track_meta():
- self.update_meta(out, original_sp_size, spatial_size_)
+ self.update_meta(img, orig_size, sp_size)
self.push_transform(
- out,
- orig_size=original_sp_size,
+ img,
+ orig_size=orig_size,
extra_info={
- "mode": _mode,
- "align_corners": _align_corners if _align_corners is not None else TraceKeys.NONE,
- "new_dim": len(original_sp_size) - input_ndim, # additional dims appended
+ "mode": mode,
+ "align_corners": align_corners if align_corners is not None else TraceKeys.NONE,
+ "new_dim": len(orig_size) - ndim, # additional dims appended
},
)
- return out
+ return img
def update_meta(self, img, spatial_size, new_spatial_size):
affine = convert_to_tensor(img.affine, track_meta=False)
@@ -1078,7 +1096,7 @@ class Zoom(InvertibleTransform):
padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
- One of the listed string values or a user supplied function. Defaults to ``"constant"``.
+ One of the listed string values or a user supplied function. Defaults to ``"edge"``.
The mode to pad data after zooming.
See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
@@ -1126,7 +1144,7 @@ def __call__(
padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
- One of the listed string values or a user supplied function. Defaults to ``"constant"``.
+ One of the listed string values or a user supplied function. Defaults to ``"edge"``.
The mode to pad data after zooming.
See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
@@ -1943,12 +1961,12 @@ def __call__(self, spatial_size: Sequence[int]) -> torch.Tensor:
class Resample(Transform):
- backend = [TransformBackends.TORCH]
+ backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
@deprecated_arg(name="as_tensor_output", since="0.6")
def __init__(
self,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.BORDER,
as_tensor_output: bool = True,
norm_coords: bool = True,
@@ -1960,15 +1978,23 @@ def __init__(
supports spatially 2D or 3D (num_channels, H, W[, D]).
Args:
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `USE_COMPILED` is `True`, this argument uses
+ ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
+ See also: https://docs.monai.io/en/stable/networks.html#grid-pull (experimental).
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When `USE_COMPILED` is `True`, this argument uses an integer to represent the padding mode.
+ See also: https://docs.monai.io/en/stable/networks.html#grid-pull (experimental).
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
norm_coords: whether to normalize the coordinates from `[-(size-1)/2, (size-1)/2]` to
`[0, size - 1]` (for ``monai/csrc`` implementation) or
`[-1, 1]` (for torch ``grid_sample`` implementation) to be compatible with the underlying
@@ -1982,17 +2008,17 @@ def __init__(
``as_tensor_output`` is deprecated.
"""
- self.mode: str = look_up_option(mode, GridSampleMode)
- self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode)
+ self.mode = mode
+ self.padding_mode = padding_mode
self.norm_coords = norm_coords
self.device = device
self.dtype = dtype
- def __call__( # type: ignore
+ def __call__(
self,
img: torch.Tensor,
- grid: torch.Tensor,
- mode: Optional[str] = None,
+ grid: Optional[torch.Tensor] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
dtype: DtypeLike = None,
) -> torch.Tensor:
@@ -2003,47 +2029,81 @@ def __call__( # type: ignore
if ``norm_coords`` is True, the grid values must be in `[-(size-1)/2, (size-1)/2]`.
if ``USE_COMPILED=True`` and ``norm_coords=False``, grid values must be in `[0, size-1]`.
if ``USE_COMPILED=False`` and ``norm_coords=False``, grid values must be in `[-1, 1]`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
When `USE_COMPILED` is `True`, this argument uses
``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ See also: https://docs.monai.io/en/stable/networks.html#grid-pull (experimental).
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `USE_COMPILED` is `True`, this argument uses an integer to represent the padding mode.
+ See also: https://docs.monai.io/en/stable/networks.html#grid-pull (experimental).
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
dtype: data type for resampling computation. Defaults to ``self.dtype``.
To be compatible with other modules, the output data type is always `float32`.
See also:
:py:const:`monai.config.USE_COMPILED`
"""
+ img = convert_to_tensor(img, track_meta=get_track_meta())
+ if grid is None:
+ return img
_device = img.device if isinstance(img, torch.Tensor) else self.device
_dtype = dtype or self.dtype or img.dtype
- img = convert_to_tensor(img, track_meta=get_track_meta())
img_t, *_ = convert_data_type(img, torch.Tensor, dtype=_dtype, device=_device)
- grid_t, *_ = convert_to_dst_type(grid, img_t)
- if grid_t is grid: # copy if needed (convert_data_type converts to contiguous)
- grid_t = grid_t.clone(memory_format=torch.contiguous_format)
+ grid_t, *_ = convert_to_dst_type(grid, img_t, dtype=grid.dtype, wrap_sequence=True)
+ grid_t = grid_t.clone(memory_format=torch.contiguous_format) # type: ignore
+ if self.norm_coords:
+ grid_t[-1] = where(grid_t[-1] != 0, grid_t[-1], 1.0) # type: ignore
sr = min(len(img_t.shape[1:]), 3)
- if USE_COMPILED:
+ _interp_mode = self.mode if mode is None else mode
+ _padding_mode = self.padding_mode if padding_mode is None else padding_mode
+ if look_up_option(str(_interp_mode), SplineMode, default=None) is not None:
+ self._backend = TransformBackends.NUMPY
+ else:
+ self._backend = TransformBackends.TORCH
+
+ if USE_COMPILED or self._backend == TransformBackends.NUMPY:
if self.norm_coords:
for i, dim in enumerate(img_t.shape[1 : 1 + sr]):
grid_t[i] = (max(dim, 2) / 2.0 - 0.5 + grid_t[i]) / grid_t[-1:]
- grid_t = moveaxis(grid_t[:sr], 0, -1) # type: ignore
- _padding_mode = self.padding_mode if padding_mode is None else padding_mode
- bound = 1 if _padding_mode == "reflection" else _padding_mode
- _interp_mode = self.mode if mode is None else mode
- if _interp_mode == "bicubic":
- interp = 3
- elif _interp_mode == "bilinear":
- interp = 1
- else:
- interp = _interp_mode # type: ignore
- out = grid_pull(
- img_t.unsqueeze(0), grid_t.unsqueeze(0), bound=bound, extrapolate=True, interpolation=interp
- )[0]
+ grid_t = grid_t[:sr]
+ if USE_COMPILED and self._backend == TransformBackends.TORCH: # compiled is using torch backend param name
+ grid_t = moveaxis(grid_t, 0, -1) # type: ignore
+ bound = 1 if _padding_mode == "reflection" else _padding_mode
+ if _interp_mode == "bicubic":
+ interp = 3
+ elif _interp_mode == "bilinear":
+ interp = 1
+ else:
+ interp = GridSampleMode(_interp_mode) # type: ignore
+ out = grid_pull(
+ img_t.unsqueeze(0),
+ grid_t.unsqueeze(0).to(img_t),
+ bound=bound,
+ extrapolate=True,
+ interpolation=interp,
+ )[0]
+ elif self._backend == TransformBackends.NUMPY:
+ is_cuda = img_t.is_cuda
+ img_np = (convert_to_cupy if is_cuda else convert_to_numpy)(img_t, wrap_sequence=True)
+ grid_np, *_ = convert_to_dst_type(grid_t, img_np, wrap_sequence=True)
+ _map_coord = (cupy_ndi if is_cuda else np_ndi).map_coordinates
+ out = (cupy if is_cuda else np).stack(
+ [
+ _map_coord(c, grid_np, order=int(_interp_mode), mode=look_up_option(_padding_mode, NdimageMode))
+ for c in img_np
+ ]
+ )
+ out = convert_to_dst_type(out, img_t)[0]
else:
if self.norm_coords:
for i, dim in enumerate(img_t.shape[1 : 1 + sr]):
@@ -2052,9 +2112,9 @@ def __call__( # type: ignore
grid_t = moveaxis(grid_t[index_ordering], 0, -1) # type: ignore
out = torch.nn.functional.grid_sample(
img_t.unsqueeze(0),
- grid_t.unsqueeze(0),
- mode=self.mode if mode is None else GridSampleMode(mode),
- padding_mode=self.padding_mode if padding_mode is None else GridSamplePadMode(padding_mode),
+ grid_t.unsqueeze(0).to(img_t),
+ mode=GridSampleMode(_interp_mode),
+ padding_mode=GridSamplePadMode(_padding_mode),
align_corners=True,
)[0]
out_val, *_ = convert_to_dst_type(out, dst=img, dtype=np.float32)
@@ -2080,7 +2140,7 @@ def __init__(
scale_params: Optional[Union[Sequence[float], float]] = None,
affine: Optional[NdarrayOrTensor] = None,
spatial_size: Optional[Union[Sequence[int], int]] = None,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.REFLECTION,
normalized: bool = False,
norm_coords: bool = True,
@@ -2118,15 +2178,18 @@ def __init__(
if some components of the `spatial_size` are non-positive values, the transform will use the
corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted
to `(32, 64)` if the second spatial dimension size of img is `64`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
normalized: indicating whether the provided `affine` is defined to include a normalization
transform converting the coordinates from `[-(size-1)/2, (size-1)/2]` (defined in ``create_grid``) to
`[0, size - 1]` or `[-1, 1]` in order to be compatible with the underlying resampling API.
@@ -2158,14 +2221,14 @@ def __init__(
self.norm_coord = not normalized
self.resampler = Resample(norm_coords=self.norm_coord, device=device, dtype=dtype)
self.spatial_size = spatial_size
- self.mode: str = look_up_option(mode, GridSampleMode)
- self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode)
+ self.mode = mode
+ self.padding_mode: str = padding_mode
def __call__(
self,
img: torch.Tensor,
spatial_size: Optional[Union[Sequence[int], int]] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
) -> Union[torch.Tensor, Tuple[torch.Tensor, NdarrayOrTensor]]:
"""
@@ -2176,27 +2239,28 @@ def __call__(
the transform will use the spatial size of `img`.
if `img` has two spatial dimensions, `spatial_size` should have 2 elements [h, w].
if `img` has three spatial dimensions, `spatial_size` should have 3 elements [h, w, d].
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- When `USE_COMPILED` is `True`, this argument uses
- ``"nearest"``, ``"bilinear"``, ``"bicubic"`` to indicate 0, 1, 3 order interpolations.
- See also: https://docs.monai.io/en/stable/networks.html#grid-pull
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
"""
img = convert_to_tensor(img, track_meta=get_track_meta())
img_size = img.shape[1:]
sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img_size)
- _mode = mode or self.mode
- _padding_mode = padding_mode or self.padding_mode
+ _mode = mode if mode is not None else self.mode
+ _padding_mode = padding_mode if padding_mode is not None else self.padding_mode
grid, affine = self.affine_grid(spatial_size=sp_size)
out = self.resampler(img, grid=grid, mode=_mode, padding_mode=_padding_mode)
if not isinstance(out, MetaTensor):
return out if self.image_only else (out, affine)
- if not self.norm_coord:
- warnings.warn("customized transform may not work with the metadata operation.")
if get_track_meta():
out.meta = img.meta # type: ignore
self.update_meta(out, affine, img_size, sp_size)
@@ -2257,7 +2321,7 @@ def __init__(
translate_range: RandRange = None,
scale_range: RandRange = None,
spatial_size: Optional[Union[Sequence[int], int]] = None,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.REFLECTION,
cache_grid: bool = False,
as_tensor_output: bool = True,
@@ -2295,12 +2359,18 @@ def __init__(
if some components of the `spatial_size` are non-positive values, the transform will use the
corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted
to `(32, 64)` if the second spatial dimension size of img is `64`.
- mode: {``"bilinear"``, ``"nearest"``}
- Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
+ Interpolation mode to calculate output values. Defaults to ``bilinear``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``"reflection"``.
+ Padding mode for outside grid values. Defaults to ``reflection``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
cache_grid: whether to cache the identity sampling grid.
If the spatial size is not dynamically defined by input image, enabling this option could
accelerate the transform.
@@ -2328,8 +2398,8 @@ def __init__(
self.spatial_size = spatial_size
self.cache_grid = cache_grid
self._cached_grid = self._init_identity_cache()
- self.mode: str = GridSampleMode(mode)
- self.padding_mode: str = GridSamplePadMode(padding_mode)
+ self.mode = mode
+ self.padding_mode: str = padding_mode
def _init_identity_cache(self):
"""
@@ -2388,7 +2458,7 @@ def __call__(
self,
img: torch.Tensor,
spatial_size: Optional[Union[Sequence[int], int]] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
randomize: bool = True,
grid=None,
@@ -2401,12 +2471,18 @@ def __call__(
the transform will use the spatial size of `img`.
if `img` has two spatial dimensions, `spatial_size` should have 2 elements [h, w].
if `img` has three spatial dimensions, `spatial_size` should have 3 elements [h, w, d].
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
randomize: whether to execute `randomize()` function first, default to True.
grid: precomputed grid to be used (mainly to accelerate `RandAffined`).
@@ -2417,8 +2493,8 @@ def __call__(
# except convert to float and device
sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img.shape[1:])
do_resampling = self._do_transform or (sp_size != ensure_tuple(img.shape[1:]))
- _mode = mode or self.mode
- _padding_mode = padding_mode or self.padding_mode
+ _mode = mode if mode is not None else self.mode
+ _padding_mode = padding_mode if padding_mode is not None else self.padding_mode
img = convert_to_tensor(img, track_meta=get_track_meta())
if not do_resampling:
out: torch.Tensor = convert_data_type(img, dtype=torch.float32, device=self.resampler.device)[0]
@@ -2493,7 +2569,7 @@ def __init__(
translate_range: RandRange = None,
scale_range: RandRange = None,
spatial_size: Optional[Union[Tuple[int, int], int]] = None,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.REFLECTION,
as_tensor_output: bool = False,
device: Optional[torch.device] = None,
@@ -2531,12 +2607,18 @@ def __init__(
if some components of the `spatial_size` are non-positive values, the transform will use the
corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted
to `(32, 64)` if the second spatial dimension size of img is `64`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
device: device on which the tensor will be allocated.
See also:
@@ -2560,8 +2642,8 @@ def __init__(
self.device = device
self.spatial_size = spatial_size
- self.mode: str = look_up_option(mode, GridSampleMode)
- self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode)
+ self.mode = mode
+ self.padding_mode: str = padding_mode
def set_random_state(
self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None
@@ -2582,7 +2664,7 @@ def __call__(
self,
img: torch.Tensor,
spatial_size: Optional[Union[Tuple[int, int], int]] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
randomize: bool = True,
) -> torch.Tensor:
@@ -2592,12 +2674,18 @@ def __call__(
spatial_size: specifying output image spatial size [h, w].
if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1,
the transform will use the spatial size of `img`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
randomize: whether to execute `randomize()` function first, default to True.
"""
sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img.shape[1:])
@@ -2619,7 +2707,10 @@ def __call__(
_device = img.device if isinstance(img, torch.Tensor) else self.device
grid = create_grid(spatial_size=sp_size, device=_device, backend="torch")
out: torch.Tensor = self.resampler(
- img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode
+ img,
+ grid,
+ mode=mode if mode is not None else self.mode,
+ padding_mode=padding_mode if padding_mode is not None else self.padding_mode,
)
return out
@@ -2644,7 +2735,7 @@ def __init__(
translate_range: RandRange = None,
scale_range: RandRange = None,
spatial_size: Optional[Union[Tuple[int, int, int], int]] = None,
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.REFLECTION,
as_tensor_output: bool = False,
device: Optional[torch.device] = None,
@@ -2685,12 +2776,18 @@ def __init__(
if some components of the `spatial_size` are non-positive values, the transform will use the
corresponding components of img size. For example, `spatial_size=(32, 32, -1)` will be adapted
to `(32, 32, 64)` if the third spatial dimension size of img is `64`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
device: device on which the tensor will be allocated.
See also:
@@ -2714,8 +2811,8 @@ def __init__(
self.sigma_range = sigma_range
self.magnitude_range = magnitude_range
self.spatial_size = spatial_size
- self.mode: str = look_up_option(mode, GridSampleMode)
- self.padding_mode: str = look_up_option(padding_mode, GridSamplePadMode)
+ self.mode = mode
+ self.padding_mode: str = padding_mode
self.device = device
self.rand_offset: np.ndarray
@@ -2742,7 +2839,7 @@ def __call__(
self,
img: torch.Tensor,
spatial_size: Optional[Union[Tuple[int, int, int], int]] = None,
- mode: Optional[str] = None,
+ mode: Union[str, int, None] = None,
padding_mode: Optional[str] = None,
randomize: bool = True,
) -> torch.Tensor:
@@ -2752,12 +2849,18 @@ def __call__(
spatial_size: specifying spatial 3D output image spatial size [h, w, d].
if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1,
the transform will use the spatial size of `img`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
randomize: whether to execute `randomize()` function first, default to True.
"""
sp_size = fall_back_tuple(self.spatial_size if spatial_size is None else spatial_size, img.shape[1:])
@@ -2774,7 +2877,10 @@ def __call__(
grid[:3] += gaussian(offset)[0] * self.magnitude
grid = self.rand_affine_grid(grid=grid)
out: torch.Tensor = self.resampler(
- img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode
+ img,
+ grid,
+ mode=mode if mode is not None else self.mode,
+ padding_mode=padding_mode if padding_mode is not None else self.padding_mode,
)
return out
@@ -2787,7 +2893,7 @@ def __init__(
self,
num_cells: Union[Tuple[int], int],
distort_steps: Sequence[Sequence[float]],
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.BORDER,
device: Optional[torch.device] = None,
) -> None:
@@ -2800,12 +2906,18 @@ def __init__(
distort_steps: This argument is a list of tuples, where each tuple contains the distort steps of the
corresponding dimensions (in the order of H, W[, D]). The length of each tuple equals to `num_cells + 1`.
Each value in the tuple represents the distort step of the related cell.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
device: device on which the tensor will be allocated.
"""
@@ -2827,12 +2939,18 @@ def __call__(
distort_steps: This argument is a list of tuples, where each tuple contains the distort steps of the
corresponding dimensions (in the order of H, W[, D]). The length of each tuple equals to `num_cells + 1`.
Each value in the tuple represents the distort step of the related cell.
- mode: {``"bilinear"``, ``"nearest"``}
- Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
+ Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``"border"``.
+ Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
"""
distort_steps = self.distort_steps if distort_steps is None else distort_steps
@@ -2874,7 +2992,7 @@ def __init__(
num_cells: Union[Tuple[int], int] = 5,
prob: float = 0.1,
distort_limit: Union[Tuple[float, float], float] = (-0.03, 0.03),
- mode: str = GridSampleMode.BILINEAR,
+ mode: Union[str, int] = GridSampleMode.BILINEAR,
padding_mode: str = GridSamplePadMode.BORDER,
device: Optional[torch.device] = None,
) -> None:
@@ -2888,12 +3006,18 @@ def __init__(
distort_limit: range to randomly distort.
If single number, distort_limit is picked from (-distort_limit, distort_limit).
Defaults to (-0.03, 0.03).
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
device: device on which the tensor will be allocated.
"""
@@ -2923,12 +3047,18 @@ def __call__(
"""
Args:
img: shape must be (num_channels, H, W[, D]).
- mode: {``"bilinear"``, ``"nearest"``}
- Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
+ Interpolation mode to calculate output values. Defaults to ``self.mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``"border"``.
+ Padding mode for outside grid values. Defaults to ``self.padding_mode``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
randomize: whether to shuffle the random factors using `randomize()`, default to True.
"""
if randomize:
@@ -2975,31 +3105,31 @@ def __call__(
split_size, steps = self._get_params(image.shape[1:], input_size)
patches: List[NdarrayOrTensor]
+ as_strided_func: Callable
if isinstance(image, torch.Tensor):
- unfolded_image = (
- image.unfold(1, split_size[0], steps[0])
- .unfold(2, split_size[1], steps[1])
- .flatten(1, 2)
- .transpose(0, 1)
- )
- # Make a list of contiguous patches
- patches = [p.contiguous() for p in unfolded_image]
+ as_strided_func = torch.as_strided
+ c_stride, x_stride, y_stride = image.stride() # type: ignore
elif isinstance(image, np.ndarray):
- x_step, y_step = steps
+ as_strided_func = np.lib.stride_tricks.as_strided
c_stride, x_stride, y_stride = image.strides
- n_channels = image.shape[0]
- strided_image = as_strided(
- image,
- shape=(*self.grid, n_channels, split_size[0], split_size[1]),
- strides=(x_stride * x_step, y_stride * y_step, c_stride, x_stride, y_stride),
- )
- # Flatten the first two dimensions
- strided_image = strided_image.reshape(-1, *strided_image.shape[2:])
- # Make a list of contiguous patches
- patches = [np.ascontiguousarray(p) for p in strided_image]
else:
raise ValueError(f"Input type [{type(image)}] is not supported.")
+ x_step, y_step = steps
+ n_channels = image.shape[0]
+ strided_image = as_strided_func(
+ image,
+ (*self.grid, n_channels, split_size[0], split_size[1]),
+ (x_stride * x_step, y_stride * y_step, c_stride, x_stride, y_stride),
+ )
+ # Flatten the first two dimensions
+ strided_image = strided_image.reshape(-1, *strided_image.shape[2:])
+ # Make a list of contiguous patches
+ if isinstance(image, torch.Tensor):
+ patches = [p.contiguous() for p in strided_image]
+ elif isinstance(image, np.ndarray):
+ patches = [np.ascontiguousarray(p) for p in strided_image]
+
return patches
def _get_params(
diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py
index c809d38ba0..385697577b 100644
--- a/monai/transforms/spatial/dictionary.py
+++ b/monai/transforms/spatial/dictionary.py
@@ -178,14 +178,20 @@ def __init__(
"""
Args:
keys: keys of the corresponding items to be transformed.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
- See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
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
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample
It also can be a sequence of bool, each element corresponds to a key in ``keys``.
@@ -221,7 +227,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.sp_transform.inverse(d[key])
return d
@@ -248,14 +254,20 @@ def __init__(
Args:
keys: keys of the corresponding items to be transformed.
key_dst: key of image to resample to match.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
- See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
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
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample
It also can be a sequence of bool, each element corresponds to a key in ``keys``.
@@ -274,7 +286,7 @@ def __init__(
self.resampler = ResampleToMatch()
def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for (key, mode, padding_mode, align_corners, dtype) in self.key_iterator(
d, self.mode, self.padding_mode, self.align_corners, self.dtype
):
@@ -289,7 +301,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.resampler.inverse(d[key])
return d
@@ -347,14 +359,20 @@ def __init__(
translations components from the original affine will be
preserved in the target affine. This option will not flip/swap
axes against the original ones.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
align_corners: Geometrically, we consider the pixels of the input as squares rather than points.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
It also can be a sequence of bool, each element corresponds to a key in ``keys``.
@@ -384,7 +402,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.spacing_transform.inverse(d[key])
return d
@@ -440,7 +458,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.ornt_transform.inverse(d[key])
return d
@@ -473,7 +491,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.rotator.inverse(d[key])
return d
@@ -595,7 +613,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.resizer.inverse(d[key])
return d
@@ -653,14 +671,20 @@ def __init__(
if some components of the `spatial_size` are non-positive values, the transform will use the
corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted
to `(32, 64)` if the second spatial dimension size of img is `64`.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
device: device on which the tensor will be allocated.
dtype: data type for resampling computation. Defaults to ``np.float32``.
If ``None``, use the data type of input data. To be compatible with other modules,
@@ -696,7 +720,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.affine.inverse(d[key])
return d
@@ -759,14 +783,20 @@ def __init__(
scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select
the scale factor to translate for every spatial dims. A value of 1.0 is added to the result.
This allows 0 to correspond to no change (i.e., a scaling of 1.0).
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
cache_grid: whether to cache the identity sampling grid.
If the spatial size is not dynamically defined by input image, enabling this option could
accelerate the transform.
@@ -906,14 +936,20 @@ def __init__(
scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select
the scale factor to translate for every spatial dims. A value of 1.0 is added to the result.
This allows 0 to correspond to no change (i.e., a scaling of 1.0).
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
device: device on which the tensor will be allocated.
allow_missing_keys: don't raise exception if key is missing.
@@ -1043,14 +1079,20 @@ def __init__(
scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select
the scale factor to translate for every spatial dims. A value of 1.0 is added to the result.
This allows 0 to correspond to no change (i.e., a scaling of 1.0).
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
Padding mode for outside grid values. Defaults to ``"reflection"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
device: device on which the tensor will be allocated.
allow_missing_keys: don't raise exception if key is missing.
@@ -1143,7 +1185,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.flipper.inverse(d[key])
return d
@@ -1197,7 +1239,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
xform = self.pop_transform(d[key])
if not xform[TraceKeys.DO_TRANSFORM]:
@@ -1325,7 +1367,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.rotator.inverse(d[key])
return d
@@ -1423,7 +1465,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
xform = self.pop_transform(d[key])
if xform[TraceKeys.DO_TRANSFORM]:
@@ -1448,7 +1490,7 @@ class Zoomd(MapTransform, InvertibleTransform):
padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
- One of the listed string values or a user supplied function. Defaults to ``"constant"``.
+ One of the listed string values or a user supplied function. Defaults to ``"edge"``.
The mode to pad data after zooming.
See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
@@ -1491,7 +1533,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
d[key] = self.zoomer.inverse(d[key])
return d
@@ -1521,7 +1563,7 @@ class RandZoomd(RandomizableTransform, MapTransform, InvertibleTransform):
padding_mode: available modes for numpy array:{``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``,
``"mean"``, ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
available modes for PyTorch Tensor: {``"constant"``, ``"reflect"``, ``"replicate"``, ``"circular"``}.
- One of the listed string values or a user supplied function. Defaults to ``"constant"``.
+ One of the listed string values or a user supplied function. Defaults to ``"edge"``.
The mode to pad data after zooming.
See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
https://pytorch.org/docs/stable/generated/torch.nn.functional.pad.html
@@ -1591,7 +1633,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
xform = self.pop_transform(d[key])
if xform[TraceKeys.DO_TRANSFORM]:
@@ -1624,14 +1666,20 @@ def __init__(
distort_steps: This argument is a list of tuples, where each tuple contains the distort steps of the
corresponding dimensions (in the order of H, W[, D]). The length of each tuple equals to `num_cells + 1`.
Each value in the tuple represents the distort step of the related cell.
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``"reflection"``.
+ Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
device: device on which the tensor will be allocated.
allow_missing_keys: don't raise exception if key is missing.
@@ -1674,14 +1722,20 @@ def __init__(
distort_limit: range to randomly distort.
If single number, distort_limit is picked from (-distort_limit, distort_limit).
Defaults to (-0.03, 0.03).
- mode: {``"bilinear"``, ``"nearest"``}
+ mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers).
Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When it's an integer, the numpy (cpu tensor)/cupy (cuda tensor) backends will be used
+ and the value represents the order of the spline interpolation.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
- Padding mode for outside grid values. Defaults to ``"reflection"``.
+ Padding mode for outside grid values. Defaults to ``"border"``.
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
- It also can be a sequence of string, each element corresponds to a key in ``keys``.
+ When `mode` is an integer, using numpy/cupy backends, this argument accepts
+ {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ It also can be a sequence, each element corresponds to a key in ``keys``.
device: device on which the tensor will be allocated.
allow_missing_keys: don't raise exception if key is missing.
diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py
index 5819d2971d..0c2e53ca09 100644
--- a/monai/transforms/transform.py
+++ b/monai/transforms/transform.py
@@ -19,10 +19,12 @@
import numpy as np
import torch
-from monai import transforms
+from monai import config, transforms
from monai.config import KeysCollection
+from monai.data.meta_tensor import MetaTensor
from monai.utils import MAX_SEED, ensure_tuple, first
from monai.utils.enums import TransformBackends
+from monai.utils.misc import MONAIEnvVars
__all__ = ["ThreadUnsafe", "apply_transform", "Randomizable", "RandomizableTransform", "Transform", "MapTransform"]
@@ -88,7 +90,10 @@ def apply_transform(
return [_apply_transform(transform, item, unpack_items) for item in data]
return _apply_transform(transform, data, unpack_items)
except Exception as e:
-
+ # if in debug mode, don't swallow exception so that the breakpoint
+ # appears where the exception was raised.
+ if MONAIEnvVars.debug():
+ raise
if log_stats and not isinstance(transform, transforms.compose.Compose):
# log the input data information of exact transform in the transform chain
datastats = transforms.utility.array.DataStats(data_shape=False, value_range=False)
@@ -126,7 +131,7 @@ class ThreadUnsafe:
pass
-class Randomizable(ABC, ThreadUnsafe):
+class Randomizable(ThreadUnsafe):
"""
An interface for handling random state locally, currently based on a class
variable `R`, which is an instance of `np.random.RandomState`. This
@@ -311,6 +316,16 @@ def __call__(self, data):
"""
+ def __new__(cls, *args, **kwargs):
+ if config.USE_META_DICT:
+ # call_update after MapTransform.__call__
+ cls.__call__ = transforms.attach_hook(cls.__call__, MapTransform.call_update, "post")
+
+ if hasattr(cls, "inverse"):
+ # inverse_update before InvertibleTransform.inverse
+ cls.inverse = transforms.attach_hook(cls.inverse, transforms.InvertibleTransform.inverse_update)
+ return Transform.__new__(cls)
+
def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None:
self.keys: Tuple[Hashable, ...] = ensure_tuple(keys)
self.allow_missing_keys = allow_missing_keys
@@ -320,6 +335,27 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No
if not isinstance(key, Hashable):
raise TypeError(f"keys must be one of (Hashable, Iterable[Hashable]) but is {type(keys).__name__}.")
+ def call_update(self, data):
+ """
+ This function is to be called after every `self.__call__(data)`,
+ update `data[key_transforms]` and `data[key_meta_dict]` using the content from MetaTensor `data[key]`,
+ for MetaTensor backward compatibility 0.9.0.
+ """
+ if not isinstance(data, (list, tuple, Mapping)):
+ return data
+ is_dict = False
+ if isinstance(data, Mapping):
+ data, is_dict = [data], True
+ if not data or not isinstance(data[0], Mapping):
+ return data[0] if is_dict else data
+ list_d = [dict(x) for x in data] # list of dict for crop samples
+ for idx, dict_i in enumerate(list_d):
+ for k in dict_i:
+ if not isinstance(dict_i[k], MetaTensor):
+ continue
+ list_d[idx] = transforms.sync_meta_info(k, dict_i, t=not isinstance(self, transforms.InvertD))
+ return list_d[0] if is_dict else list_d
+
@abstractmethod
def __call__(self, data):
"""
diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py
index 41973ccb47..2ed036e9a6 100644
--- a/monai/transforms/utility/array.py
+++ b/monai/transforms/utility/array.py
@@ -112,6 +112,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
return img
+@deprecated(since="0.8", msg_suffix="please use MetaTensor data type and monai.transforms.EnsureChannelFirst instead.")
class AsChannelFirst(Transform):
"""
Change the channel dimension of the image to the first dimension.
@@ -173,6 +174,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
return out
+@deprecated(since="0.8", msg_suffix="please use MetaTensor data type and monai.transforms.EnsureChannelFirst instead.")
class AddChannel(Transform):
"""
Adds a 1-length channel dimension to the input image.
@@ -199,45 +201,62 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor:
class EnsureChannelFirst(Transform):
"""
- Automatically adjust or add the channel dimension of input data to ensure `channel_first` shape.
- It extracts the `original_channel_dim` info from provided meta_data dictionary.
- Typical values of `original_channel_dim` can be: "no_channel", 0, -1.
- Convert the data to `channel_first` based on the `original_channel_dim` information.
+ Adjust or add the channel dimension of input data to ensure `channel_first` shape.
+
+ This extracts the `original_channel_dim` info from provided meta_data dictionary or MetaTensor input. This value
+ should state which dimension is the channel dimension so that it can be moved forward, or contain "no_channel" to
+ state no dimension is the channel and so a 1-size first dimension is to be added.
+
+ Args:
+ strict_check: whether to raise an error when the meta information is insufficient.
+ channel_dim: If the input image `img` is not a MetaTensor or `meta_dict` is not given,
+ this argument can be used to specify the original channel dimension (integer) of the input array.
+ If the input array doesn't have a channel dim, this value should be ``'no_channel'`` (default).
+ If this is set to `None`, this class relies on `img` or `meta_dict` to provide the channel dimension.
"""
backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
- def __init__(self, strict_check: bool = True):
- """
- Args:
- strict_check: whether to raise an error when the meta information is insufficient.
- """
+ def __init__(self, strict_check: bool = True, channel_dim: Union[None, str, int] = "no_channel"):
self.strict_check = strict_check
- self.add_channel = AddChannel()
+ self.input_channel_dim = channel_dim
def __call__(self, img: torch.Tensor, meta_dict: Optional[Mapping] = None) -> torch.Tensor:
"""
Apply the transform to `img`.
"""
if not isinstance(img, MetaTensor) and not isinstance(meta_dict, Mapping):
- msg = "metadata not available, EnsureChannelFirst is not in use."
- if self.strict_check:
- raise ValueError(msg)
- warnings.warn(msg)
- return img
+ if self.input_channel_dim is None:
+ msg = "Metadata not available and channel_dim=None, EnsureChannelFirst is not in use."
+ if self.strict_check:
+ raise ValueError(msg)
+ warnings.warn(msg)
+ return img
+ else:
+ img = MetaTensor(img)
+
if isinstance(img, MetaTensor):
meta_dict = img.meta
- channel_dim = meta_dict.get("original_channel_dim") # type: ignore
+
+ channel_dim = meta_dict.get("original_channel_dim", None) if isinstance(meta_dict, Mapping) else None
if channel_dim is None:
- msg = "Unknown original_channel_dim in the meta_dict, EnsureChannelFirst is not in use."
- if self.strict_check:
- raise ValueError(msg)
- warnings.warn(msg)
- return img
+ if self.input_channel_dim is None:
+ msg = "Unknown original_channel_dim in the MetaTensor meta dict or `meta_dict` or `channel_dim`."
+ if self.strict_check:
+ raise ValueError(msg)
+ warnings.warn(msg)
+ return img
+ channel_dim = self.input_channel_dim
+ if isinstance(meta_dict, dict):
+ meta_dict["original_channel_dim"] = self.input_channel_dim
+
if channel_dim == "no_channel":
- return self.add_channel(img) # type: ignore
- return AsChannelFirst(channel_dim=channel_dim)(img) # type: ignore
+ result = img[None]
+ else:
+ result = moveaxis(img, channel_dim, 0) # type: ignore
+
+ return convert_to_tensor(result, track_meta=get_track_meta()) # type: ignore
class RepeatChannel(Transform):
@@ -332,7 +351,7 @@ def __call__(self, img: torch.Tensor) -> List[torch.Tensor]:
outputs[idx] = item.squeeze(self.dim)
if self.update_meta and isinstance(img, MetaTensor):
if not isinstance(item, MetaTensor):
- item = MetaTensor(item, meta=deepcopy(img.meta))
+ item = MetaTensor(item, meta=img.meta)
if self.dim == 0: # don't update affine if channel dim
continue
ndim = len(item.affine)
@@ -400,18 +419,25 @@ class ToTensor(Transform):
device: target device to put the converted Tensor data.
wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
+ track_meta: whether to convert to `MetaTensor` or regular tensor, default to `None`,
+ use the return value of ``get_track_meta``.
"""
backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
def __init__(
- self, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, wrap_sequence: bool = True
+ self,
+ dtype: Optional[torch.dtype] = None,
+ device: Optional[torch.device] = None,
+ wrap_sequence: bool = True,
+ track_meta: Optional[bool] = None,
) -> None:
super().__init__()
self.dtype = dtype
self.device = device
self.wrap_sequence = wrap_sequence
+ self.track_meta = get_track_meta() if track_meta is None else bool(track_meta)
def __call__(self, img: NdarrayOrTensor):
"""
@@ -419,7 +445,9 @@ def __call__(self, img: NdarrayOrTensor):
"""
if isinstance(img, MetaTensor):
img.applied_operations = [] # drops tracking info
- return convert_to_tensor(img, dtype=self.dtype, device=self.device, wrap_sequence=self.wrap_sequence)
+ return convert_to_tensor(
+ img, dtype=self.dtype, device=self.device, wrap_sequence=self.wrap_sequence, track_meta=self.track_meta
+ )
class EnsureType(Transform):
@@ -435,8 +463,8 @@ class EnsureType(Transform):
device: for Tensor data type, specify the target device.
wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
- track_meta: whether to convert to `MetaTensor` when `data_type` is "tensor".
- If False, the output data type will be `torch.Tensor`. Default to the return value of ``get_track_meta``.
+ track_meta: if `True` convert to ``MetaTensor``, otherwise to Pytorch ``Tensor``,
+ if ``None`` behave according to return value of py:func:`monai.data.meta_obj.get_track_meta`.
"""
diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py
index f8b5bb737b..92eaf95d27 100644
--- a/monai/transforms/utility/dictionary.py
+++ b/monai/transforms/utility/dictionary.py
@@ -24,7 +24,7 @@
from monai.config import DtypeLike, KeysCollection
from monai.config.type_definitions import NdarrayOrTensor
-from monai.data.meta_tensor import MetaTensor
+from monai.data.meta_tensor import MetaObj, MetaTensor
from monai.data.utils import no_collation
from monai.transforms.inverse import InvertibleTransform
from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform
@@ -300,22 +300,28 @@ def __init__(
meta_keys: Optional[KeysCollection] = None,
meta_key_postfix: str = DEFAULT_POST_FIX,
strict_check: bool = True,
+ allow_missing_keys: bool = False,
+ channel_dim="no_channel",
) -> None:
"""
Args:
keys: keys of the corresponding items to be transformed.
See also: :py:class:`monai.transforms.compose.MapTransform`
strict_check: whether to raise an error when the meta information is insufficient.
-
+ allow_missing_keys: don't raise exception if key is missing.
+ channel_dim: If the input image `img` is not a MetaTensor or `meta_dict` is not given,
+ this argument can be used to specify the original channel dimension (integer) of the input array.
+ If the input array doesn't have a channel dim, this value should be ``'no_channel'`` (default).
+ If this is set to `None`, this class relies on `img` or `meta_dict` to provide the channel dimension.
"""
- super().__init__(keys)
- self.adjuster = EnsureChannelFirst(strict_check=strict_check)
+ super().__init__(keys, allow_missing_keys)
+ self.adjuster = EnsureChannelFirst(strict_check=strict_check, channel_dim=channel_dim)
self.meta_keys = ensure_tuple_rep(meta_keys, len(self.keys))
self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys))
def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
d = dict(data)
- for key, meta_key, meta_key_postfix in zip(self.keys, self.meta_keys, self.meta_key_postfix):
+ for key, meta_key, meta_key_postfix in self.key_iterator(d, self.meta_keys, self.meta_key_postfix):
d[key] = self.adjuster(d[key], d.get(meta_key or f"{key}_{meta_key_postfix}")) # type: ignore
return d
@@ -485,6 +491,7 @@ def __init__(
dtype: Optional[torch.dtype] = None,
device: Optional[torch.device] = None,
wrap_sequence: bool = True,
+ track_meta: Optional[bool] = None,
allow_missing_keys: bool = False,
) -> None:
"""
@@ -495,10 +502,13 @@ def __init__(
device: specify the target device to put the Tensor data.
wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
+ track_meta: if `True` convert to ``MetaTensor``, otherwise to Pytorch ``Tensor``,
+ if ``None`` behave according to return value of py:func:`monai.data.meta_obj.get_track_meta`.
allow_missing_keys: don't raise exception if key is missing.
+
"""
super().__init__(keys, allow_missing_keys)
- self.converter = ToTensor(dtype=dtype, device=device, wrap_sequence=wrap_sequence)
+ self.converter = ToTensor(dtype=dtype, device=device, wrap_sequence=wrap_sequence, track_meta=track_meta)
def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
d = dict(data)
@@ -508,7 +518,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
return d
def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
# Create inverse transform
inverse_transform = ToNumpy()
@@ -683,7 +693,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
return d
def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key in self.key_iterator(d):
transform = self.get_most_recent_transform(d, key)
# Create inverse transform
@@ -713,7 +723,7 @@ def __init__(self, keys: KeysCollection, sep: str = ".", use_re: Union[Sequence[
See also: :py:class:`monai.transforms.compose.MapTransform`
sep: the separator tag to define nested dictionary keys, default to ".".
use_re: whether the specified key is a regular expression, it also can be
- a list of bool values, map the to keys.
+ a list of bool values, mapping them to `keys`.
"""
super().__init__(keys)
self.sep = sep
@@ -725,7 +735,7 @@ def _delete_item(keys, d, use_re: bool = False):
if len(keys) > 1:
d[key] = _delete_item(keys[1:], d[key], use_re)
return d
- return {k: v for k, v in d.items() if (use_re and not re.search(key, k)) or (not use_re and k != key)}
+ return {k: v for k, v in d.items() if (use_re and not re.search(key, f"{k}")) or (not use_re and k != key)}
d = dict(data)
for key, use_re in zip(self.keys, self.use_re):
@@ -917,17 +927,15 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
if new_key in d:
raise KeyError(f"Key {new_key} already exists in data.")
val = d[key]
- if isinstance(val, torch.Tensor):
- d[new_key] = val.detach().clone()
- else:
- d[new_key] = deepcopy(val)
+ d[new_key] = MetaObj.copy_items(val) if isinstance(val, (torch.Tensor, np.ndarray)) else deepcopy(val)
return d
class ConcatItemsd(MapTransform):
"""
Concatenate specified items from data dictionary together on the first dim to construct a big array.
- Expect all the items are numpy array or PyTorch Tensor.
+ Expect all the items are numpy array or PyTorch Tensor or MetaTensor.
+ Return the first input's meta information when items are MetaTensor.
"""
backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
@@ -949,7 +957,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
"""
Raises:
TypeError: When items in ``data`` differ in type.
- TypeError: When the item type is not in ``Union[numpy.ndarray, torch.Tensor]``.
+ TypeError: When the item type is not in ``Union[numpy.ndarray, torch.Tensor, MetaTensor]``.
"""
d = dict(data)
@@ -967,10 +975,12 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
if data_type is np.ndarray:
d[self.name] = np.concatenate(output, axis=self.dim)
- elif data_type is torch.Tensor:
+ elif issubclass(data_type, torch.Tensor): # type: ignore
d[self.name] = torch.cat(output, dim=self.dim) # type: ignore
else:
- raise TypeError(f"Unsupported data type: {data_type}, available options are (numpy.ndarray, torch.Tensor).")
+ raise TypeError(
+ f"Unsupported data type: {data_type}, available options are (numpy.ndarray, torch.Tensor, MetaTensor)."
+ )
return d
@@ -1030,7 +1040,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc
return d
def inverse(self, data):
- d = deepcopy(dict(data))
+ d = dict(data)
for key, overwrite in self.key_iterator(d, self.overwrite):
ret = self._lambd.inverse(data=d[key])
if overwrite:
@@ -1100,7 +1110,7 @@ def __call__(self, data):
return d
def inverse(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]:
- d = deepcopy(dict(data))
+ d = dict(data)
for key, overwrite in self.key_iterator(d, self.overwrite):
if isinstance(d[key], MetaTensor):
tr = self.pop_transform(d[key])
diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py
index ccc467bda4..e5715a9a7f 100644
--- a/monai/transforms/utils.py
+++ b/monai/transforms/utils.py
@@ -13,6 +13,7 @@
import random
import warnings
from contextlib import contextmanager
+from functools import wraps
from inspect import getmembers, isclass
from typing import Any, Callable, Hashable, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union
@@ -42,6 +43,7 @@
GridSampleMode,
InterpolateMode,
NumpyPadMode,
+ PostFix,
PytorchPadMode,
TraceKeys,
deprecated_arg,
@@ -106,6 +108,9 @@
"convert_to_contiguous",
"get_unique_labels",
"scale_affine",
+ "attach_hook",
+ "sync_meta_info",
+ "reset_ops_id",
]
@@ -581,6 +586,9 @@ def create_grid(
"""
compute a `spatial_size` mesh.
+ - when ``homogeneous=True``, the output shape is (N+1, dim_size_1, dim_size_2, ..., dim_size_N)
+ - when ``homogeneous=False``, the output shape is (N, dim_size_1, dim_size_2, ..., dim_size_N)
+
Args:
spatial_size: spatial size of the grid.
spacing: same len as ``spatial_size``, defaults to 1.0 (dense grid).
@@ -1282,6 +1290,21 @@ def convert_applied_interp_mode(trans_info, mode: str = "nearest", align_corners
return trans_info
+def reset_ops_id(data):
+ """find MetaTensors in list or dict `data` and (in-place) set ``TraceKeys.ID`` to ``Tracekys.NONE``."""
+ if isinstance(data, (list, tuple)):
+ return [reset_ops_id(d) for d in data]
+ if isinstance(data, monai.data.MetaTensor):
+ data.applied_operations = reset_ops_id(data.applied_operations)
+ return data
+ if not isinstance(data, Mapping):
+ return data
+ data = dict(data)
+ if TraceKeys.ID in data:
+ data[TraceKeys.ID] = TraceKeys.NONE
+ return {k: reset_ops_id(v) for k, v in data.items()}
+
+
def compute_divisible_spatial_size(spatial_shape: Sequence[int], k: Union[Sequence[int], int]):
"""
Compute the target spatial size which should be divisible by `k`.
@@ -1600,5 +1623,61 @@ def scale_affine(affine, spatial_size, new_spatial_size, centered: bool = True):
return affine @ convert_to_dst_type(scale, affine)[0]
+def attach_hook(func, hook, mode="pre"):
+ """
+ Adds `hook` before or after a `func` call. If mode is "pre", the wrapper will call hook then func.
+ If the mode is "post", the wrapper will call func then hook.
+ """
+ supported = {"pre", "post"}
+ if look_up_option(mode, supported) == "pre":
+ _hook, _func = hook, func
+ else:
+ _hook, _func = func, hook
+
+ @wraps(func)
+ def wrapper(inst, data):
+ data = _hook(inst, data)
+ return _func(inst, data)
+
+ return wrapper
+
+
+def sync_meta_info(key, data_dict, t: bool = True):
+ """
+ Given the key, sync up between metatensor `data_dict[key]` and meta_dict `data_dict[key_transforms/meta_dict]`.
+ t=True: the one with more applied_operations in metatensor vs meta_dict is the output, False: less is the output.
+ """
+ if not isinstance(data_dict, Mapping):
+ return data_dict
+ d = dict(data_dict)
+
+ # update meta dicts
+ meta_dict_key = PostFix.meta(key)
+ if meta_dict_key not in d:
+ d[meta_dict_key] = monai.data.MetaTensor.get_default_meta()
+ if not isinstance(d[key], monai.data.MetaTensor):
+ d[key] = monai.data.MetaTensor(data_dict[key])
+ d[key].meta = d[meta_dict_key]
+ d[meta_dict_key].update(d[key].meta) # prefer metatensor's data
+
+ # update xform info
+ xform_key = monai.transforms.TraceableTransform.trace_key(key)
+ if xform_key not in d:
+ d[xform_key] = monai.data.MetaTensor.get_default_applied_operations()
+ from_meta, from_dict = d[key].applied_operations, d[xform_key]
+ if not from_meta: # avoid []
+ d[key].applied_operations = d[xform_key] = from_dict
+ return d
+ if not from_dict:
+ d[key].applied_operations = d[xform_key] = from_meta
+ return d
+ if t: # larger transform info stack is used as the result
+ ref = from_meta if len(from_meta) > len(from_dict) else from_dict
+ else: # smaller transform info stack is used as the result
+ ref = from_dict if len(from_meta) > len(from_dict) else from_meta
+ d[key].applied_operations = d[xform_key] = ref
+ return d
+
+
if __name__ == "__main__":
print_transform_backends()
diff --git a/monai/transforms/utils_create_transform_ims.py b/monai/transforms/utils_create_transform_ims.py
index 8f2ae82639..2581483bfc 100644
--- a/monai/transforms/utils_create_transform_ims.py
+++ b/monai/transforms/utils_create_transform_ims.py
@@ -22,11 +22,11 @@
from monai.apps import download_and_extract
from monai.transforms import (
- AddChanneld,
Affine,
Affined,
AsDiscrete,
Compose,
+ EnsureChannelFirstd,
Flip,
Flipd,
LoadImaged,
@@ -178,6 +178,7 @@
Spacingd,
)
from monai.utils.enums import CommonKeys
+from monai.utils.misc import MONAIEnvVars
from monai.utils.module import optional_import
if TYPE_CHECKING:
@@ -195,7 +196,7 @@ def get_data(keys):
Use MarsAtlas as it only contains 1 image for quick download and
that image is parcellated.
"""
- cache_dir = os.environ.get("MONAI_DATA_DIRECTORY") or tempfile.mkdtemp()
+ cache_dir = MONAIEnvVars.data_dir() or tempfile.mkdtemp()
fname = "MarsAtlas-MNI-Colin27.zip"
url = "https://www.dropbox.com/s/ndz8qtqblkciole/" + fname + "?dl=1"
out_path = os.path.join(cache_dir, "MarsAtlas-MNI-Colin27")
@@ -208,7 +209,12 @@ def get_data(keys):
data = {CommonKeys.IMAGE: image, CommonKeys.LABEL: label}
transforms = Compose(
- [LoadImaged(keys), AddChanneld(keys), ScaleIntensityd(CommonKeys.IMAGE), Rotate90d(keys, spatial_axes=[0, 2])]
+ [
+ LoadImaged(keys),
+ EnsureChannelFirstd(keys),
+ ScaleIntensityd(CommonKeys.IMAGE),
+ Rotate90d(keys, spatial_axes=[0, 2]),
+ ]
)
data = transforms(data)
max_size = max(data[keys[0]].shape)
@@ -415,7 +421,9 @@ def create_transform_im(
seed = seed + 1 if isinstance(transform, MapTransform) else seed
transform.set_random_state(seed)
- out_dir = os.environ.get("MONAI_DOC_IMAGES")
+ from monai.utils.misc import MONAIEnvVars
+
+ out_dir = MONAIEnvVars.doc_images()
if out_dir is None:
raise RuntimeError(
"Please git clone https://github.com/Project-MONAI/DocImages"
diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py
index 441ea23b2f..55c2c4de89 100644
--- a/monai/transforms/utils_pytorch_numpy_unification.py
+++ b/monai/transforms/utils_pytorch_numpy_unification.py
@@ -98,17 +98,17 @@ def percentile(
Returns:
Resulting value (scalar)
"""
- if np.isscalar(q):
- if not 0 <= q <= 100: # type: ignore
- raise ValueError
- elif any(q < 0) or any(q > 100):
- raise ValueError
+ q_np = convert_data_type(q, output_type=np.ndarray, wrap_sequence=True)[0]
+ if ((q_np < 0) | (q_np > 100)).any():
+ raise ValueError(f"q values must be in [0, 100], got values: {q}.")
result: Union[NdarrayOrTensor, float, int]
- if isinstance(x, np.ndarray):
- result = np.percentile(x, q, axis=dim, keepdims=keepdim, **kwargs)
+ if isinstance(x, np.ndarray) or (isinstance(x, torch.Tensor) and torch.numel(x) > 1_000_000): # pytorch#64947
+ _x = convert_data_type(x, output_type=np.ndarray)[0]
+ result = np.percentile(_x, q_np, axis=dim, keepdims=keepdim, **kwargs)
+ result = convert_to_dst_type(result, x)[0]
else:
- q = torch.tensor(q, device=x.device)
- result = torch.quantile(x, q / 100.0, dim=dim, keepdim=keepdim)
+ q = convert_to_dst_type(q_np / 100.0, x)[0]
+ result = torch.quantile(x, q, dim=dim, keepdim=keepdim)
return result
@@ -285,7 +285,7 @@ def cumsum(a: NdarrayOrTensor, axis=None, **kwargs) -> NdarrayOrTensor:
def isfinite(x: NdarrayOrTensor) -> NdarrayOrTensor:
"""`np.isfinite` with equivalent implementation for torch."""
if not isinstance(x, torch.Tensor):
- return np.isfinite(x)
+ return np.isfinite(x) # type: ignore
return torch.isfinite(x)
@@ -333,7 +333,7 @@ def isnan(x: NdarrayOrTensor) -> NdarrayOrTensor:
"""
if isinstance(x, np.ndarray):
- return np.isnan(x)
+ return np.isnan(x) # type: ignore
return torch.isnan(x)
diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py
index cb376b7280..06db5f9488 100644
--- a/monai/utils/__init__.py
+++ b/monai/utils/__init__.py
@@ -19,9 +19,13 @@
BlendMode,
BoxModeName,
ChannelMatching,
+ ColorOrder,
CommonKeys,
DiceCEReduction,
+ EngineStatsKeys,
+ FastMRIKeys,
ForwardMode,
+ GanKeys,
GridPatchSort,
GridSampleMode,
GridSamplePadMode,
@@ -29,13 +33,17 @@
InverseKeys,
JITMetadataKeys,
LossReduction,
+ MetaKeys,
Method,
MetricReduction,
+ NdimageMode,
NumpyPadMode,
PostFix,
ProbMapKeys,
PytorchPadMode,
SkipMode,
+ SpaceKeys,
+ SplineMode,
StrEnum,
TraceKeys,
TransformBackends,
@@ -47,6 +55,7 @@
from .misc import (
MAX_SEED,
ImageMetaKey,
+ MONAIEnvVars,
check_parent_dir,
copy_to_device,
ensure_tuple,
@@ -66,6 +75,7 @@
save_obj,
set_determinism,
star_zip_with,
+ str2bool,
zip_with,
)
from .module import (
@@ -87,7 +97,15 @@
version_leq,
)
from .nvtx import Range
-from .profiling import PerfContext, torch_profiler_full, torch_profiler_time_cpu_gpu, torch_profiler_time_end_to_end
+from .profiling import (
+ PerfContext,
+ ProfileHandler,
+ WorkflowProfiler,
+ select_transform_call,
+ torch_profiler_full,
+ torch_profiler_time_cpu_gpu,
+ torch_profiler_time_end_to_end,
+)
from .state_cacher import StateCacher
from .type_conversion import (
convert_data_type,
diff --git a/monai/utils/deprecate_utils.py b/monai/utils/deprecate_utils.py
index a6092c1b63..cbc4977794 100644
--- a/monai/utils/deprecate_utils.py
+++ b/monai/utils/deprecate_utils.py
@@ -27,15 +27,19 @@ class DeprecatedError(Exception):
pass
-def warn_deprecated(obj, msg):
+def warn_deprecated(obj, msg, warning_category=FutureWarning):
"""
Issue the warning message `msg`.
"""
- warnings.warn(msg, category=DeprecationWarning, stacklevel=2)
+ warnings.warn(f"{obj}: {msg}", category=warning_category, stacklevel=2)
def deprecated(
- since: Optional[str] = None, removed: Optional[str] = None, msg_suffix: str = "", version_val: str = __version__
+ since: Optional[str] = None,
+ removed: Optional[str] = None,
+ msg_suffix: str = "",
+ version_val: str = __version__,
+ warning_category=FutureWarning,
):
"""
Marks a function or class as deprecated. If `since` is given this should be a version at or earlier than the
@@ -43,7 +47,7 @@ def deprecated(
this can be any version and marks when the definition was removed.
When the decorated definition is called, that is when the function is called or the class instantiated,
- a `DeprecationWarning` is issued if `since` is given and the current version is at or later than that given.
+ a `warning_category` is issued if `since` is given and the current version is at or later than that given.
a `DeprecatedError` exception is instead raised if `removed` is given and the current version is at or later
than that, or if neither `since` nor `removed` is provided.
@@ -53,9 +57,10 @@ def deprecated(
Args:
since: version at which the definition was marked deprecated but not removed.
- removed: version at which the definition was removed and no longer usable.
+ removed: version at which the definition was/will be removed and no longer usable.
msg_suffix: message appended to warning/exception detailing reasons for deprecation and what to use instead.
version_val: (used for testing) version to compare since and removed against, default is MONAI version.
+ warning_category: a warning category class, defaults to `FutureWarning`.
Returns:
Decorated definition which warns or raises exception when used
@@ -84,7 +89,7 @@ def _decorator(obj):
is_func = isinstance(obj, FunctionType)
call_obj = obj if is_func else obj.__init__
- msg_prefix = f"{'Function' if is_func else 'Class'} `{obj.__name__}`"
+ msg_prefix = f"{'Function' if is_func else 'Class'} `{obj.__qualname__}`"
if is_removed:
msg_infix = f"was removed in version {removed}."
@@ -102,7 +107,7 @@ def _wrapper(*args, **kwargs):
if is_removed:
raise DeprecatedError(msg)
if is_deprecated:
- warn_deprecated(obj, msg)
+ warn_deprecated(obj, msg, warning_category)
return call_obj(*args, **kwargs)
@@ -121,13 +126,14 @@ def deprecated_arg(
msg_suffix: str = "",
version_val: str = __version__,
new_name: Optional[str] = None,
+ warning_category=FutureWarning,
):
"""
Marks a particular named argument of a callable as deprecated. The same conditions for `since` and `removed` as
described in the `deprecated` decorator.
When the decorated definition is called, that is when the function is called or the class instantiated with args,
- a `DeprecationWarning` is issued if `since` is given and the current version is at or later than that given.
+ a `warning_category` is issued if `since` is given and the current version is at or later than that given.
a `DeprecatedError` exception is instead raised if `removed` is given and the current version is at or later
than that, or if neither `since` nor `removed` is provided.
@@ -141,12 +147,13 @@ def deprecated_arg(
Args:
name: name of position or keyword argument to mark as deprecated.
since: version at which the argument was marked deprecated but not removed.
- removed: version at which the argument was removed and no longer usable.
+ removed: version at which the argument was/will be removed and no longer usable.
msg_suffix: message appended to warning/exception detailing reasons for deprecation and what to use instead.
version_val: (used for testing) version to compare since and removed against, default is MONAI version.
new_name: name of position or keyword argument to replace the deprecated argument.
if it is specified and the signature of the decorated function has a `kwargs`, the value to the
deprecated argument `name` will be removed.
+ warning_category: a warning category class, defaults to `FutureWarning`.
Returns:
Decorated callable which warns or raises exception when deprecated argument used.
@@ -171,7 +178,7 @@ def deprecated_arg(
is_removed = removed is not None and version_leq(removed, version_val)
def _decorator(func):
- argname = f"{func.__name__}_{name}"
+ argname = f"{func.__module__} {func.__qualname__}:{name}"
msg_prefix = f"Argument `{name}`"
@@ -212,7 +219,7 @@ def _wrapper(*args, **kwargs):
if is_removed:
raise DeprecatedError(msg)
if is_deprecated:
- warn_deprecated(argname, msg)
+ warn_deprecated(argname, msg, warning_category)
return func(*args, **kwargs)
diff --git a/monai/utils/enums.py b/monai/utils/enums.py
index 88faf88432..613b7d2e80 100644
--- a/monai/utils/enums.py
+++ b/monai/utils/enums.py
@@ -19,10 +19,12 @@
"StrEnum",
"NumpyPadMode",
"GridSampleMode",
+ "SplineMode",
"InterpolateMode",
"UpsampleMode",
"BlendMode",
"PytorchPadMode",
+ "NdimageMode",
"GridSamplePadMode",
"Average",
"MetricReduction",
@@ -35,11 +37,17 @@
"TraceKeys",
"InverseKeys",
"CommonKeys",
+ "GanKeys",
"PostFix",
"ForwardMode",
"TransformBackends",
"BoxModeName",
"GridPatchSort",
+ "FastMRIKeys",
+ "SpaceKeys",
+ "MetaKeys",
+ "ColorOrder",
+ "EngineStatsKeys",
]
@@ -86,6 +94,22 @@ class NumpyPadMode(StrEnum):
EMPTY = "empty"
+class NdimageMode(StrEnum):
+ """
+ The available options determine how the input array is extended beyond its boundaries when interpolating.
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ """
+
+ REFLECT = "reflect"
+ GRID_MIRROR = "grid-mirror"
+ CONSTANT = "constant"
+ GRID_CONSTANT = "grid-constant"
+ NEAREST = "nearest"
+ MIRROR = "mirror"
+ GRID_WRAP = "grid-wrap"
+ WRAP = "wrap"
+
+
class GridSampleMode(StrEnum):
"""
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html
@@ -104,6 +128,21 @@ class GridSampleMode(StrEnum):
BICUBIC = "bicubic"
+class SplineMode(StrEnum):
+ """
+ Order of spline interpolation.
+
+ See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html
+ """
+
+ ZERO = 0
+ ONE = 1
+ TWO = 2
+ THREE = 3
+ FOUR = 4
+ FIVE = 5
+
+
class InterpolateMode(StrEnum):
"""
See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html
@@ -303,6 +342,19 @@ class CommonKeys(StrEnum):
METADATA = "metadata"
+class GanKeys(StrEnum):
+ """
+ A set of common keys for generative adversarial networks.
+
+ """
+
+ REALS = "reals"
+ FAKES = "fakes"
+ LATENTS = "latents"
+ GLOSS = "g_loss"
+ DLOSS = "d_loss"
+
+
class PostFix(StrEnum):
"""Post-fixes."""
@@ -413,3 +465,64 @@ class WSIPatchKeys(StrEnum):
SIZE = "patch_size"
COUNT = "num_patches"
PATH = "path"
+
+
+class FastMRIKeys(StrEnum):
+ """
+ The keys to be used for extracting data from the fastMRI dataset
+ """
+
+ KSPACE = "kspace"
+ MASK = "mask"
+ FILENAME = "filename"
+ RECON = "reconstruction_rss"
+ ACQUISITION = "acquisition"
+ MAX = "max"
+ NORM = "norm"
+ PID = "patient_id"
+
+
+class SpaceKeys(StrEnum):
+ """
+ The coordinate system keys, for example, Nifti1 uses Right-Anterior-Superior or "RAS",
+ DICOM (0020,0032) uses Left-Posterior-Superior or "LPS". This type does not distinguish spatial 1/2/3D.
+ """
+
+ RAS = "RAS"
+ LPS = "LPS"
+
+
+class MetaKeys(StrEnum):
+ """
+ Typical keys for MetaObj.meta
+ """
+
+ AFFINE = "affine" # MetaTensor.affine
+ ORIGINAL_AFFINE = "original_affine" # the affine after image loading before any data processing
+ SPATIAL_SHAPE = "spatial_shape" # optional key for the length in each spatial dimension
+ SPACE = "space" # possible values of space type are defined in `SpaceKeys`
+ ORIGINAL_CHANNEL_DIM = "original_channel_dim" # an integer or "no_channel"
+
+
+class ColorOrder(StrEnum):
+ """
+ Enums for color order. Expand as necessary.
+ """
+
+ RGB = "RGB"
+ BGR = "BGR"
+
+
+class EngineStatsKeys(StrEnum):
+ """
+ Default keys for the statistics of trainer and evaluator engines.
+
+ """
+
+ RANK = "rank"
+ CURRENT_ITERATION = "current_iteration"
+ CURRENT_EPOCH = "current_epoch"
+ TOTAL_EPOCHS = "total_epochs"
+ TOTAL_ITERATIONS = "total_iterations"
+ BEST_VALIDATION_EPOCH = "best_validation_epoch"
+ BEST_VALIDATION_METRIC = "best_validation_metric"
diff --git a/monai/utils/misc.py b/monai/utils/misc.py
index fc38dc5056..48710f7316 100644
--- a/monai/utils/misc.py
+++ b/monai/utils/misc.py
@@ -46,6 +46,8 @@
"list_to_dict",
"MAX_SEED",
"copy_to_device",
+ "str2bool",
+ "MONAIEnvVars",
"ImageMetaKey",
"is_module_ver_at_least",
"has_option",
@@ -360,6 +362,52 @@ def copy_to_device(
return obj
+def str2bool(value: str, default: bool = False, raise_exc: bool = True) -> bool:
+ """
+ Convert a string to a boolean. Case insensitive.
+ True: yes, true, t, y, 1. False: no, false, f, n, 0.
+
+ Args:
+ value: string to be converted to a boolean.
+ raise_exc: if value not in tuples of expected true or false inputs,
+ should we raise an exception? If not, return `None`.
+ Raises
+ ValueError: value not in tuples of expected true or false inputs and
+ `raise_exc` is `True`.
+ """
+ true_set = ("yes", "true", "t", "y", "1")
+ false_set = ("no", "false", "f", "n", "0")
+ if isinstance(value, str):
+ value = value.lower()
+ if value in true_set:
+ return True
+ if value in false_set:
+ return False
+
+ if raise_exc:
+ raise ValueError(f"Got \"{value}\", expected a value from: {', '.join(true_set + false_set)}")
+ return default
+
+
+class MONAIEnvVars:
+ """
+ Environment variables used by MONAI.
+ """
+
+ @staticmethod
+ def data_dir() -> Optional[str]:
+ return os.environ.get("MONAI_DATA_DIRECTORY", None)
+
+ @staticmethod
+ def debug() -> bool:
+ val = os.environ.get("MONAI_DEBUG", False)
+ return val if isinstance(val, bool) else str2bool(val)
+
+ @staticmethod
+ def doc_images() -> Optional[str]:
+ return os.environ.get("MONAI_DOC_IMAGES", None)
+
+
class ImageMetaKey:
"""
Common key names in the metadata header of images
diff --git a/monai/utils/module.py b/monai/utils/module.py
index 747c985af7..aab3829e1f 100644
--- a/monai/utils/module.py
+++ b/monai/utils/module.py
@@ -193,6 +193,12 @@ def load_submodules(basemod, load_all: bool = True, exclude_pattern: str = "(.*[
submodules.append(mod)
except OptionalImportError:
pass # could not import the optional deps., they are ignored
+ except ImportError as e:
+ raise ImportError(
+ "Multiple versions of MONAI may have been installed,\n"
+ "please uninstall existing packages (both monai and monai-weekly) and install a version again.\n"
+ "See also: https://docs.monai.io/en/stable/installation.html\n"
+ ) from e
return submodules, err_mod
diff --git a/monai/utils/profiling.py b/monai/utils/profiling.py
index 8e0742268f..291e58d57f 100644
--- a/monai/utils/profiling.py
+++ b/monai/utils/profiling.py
@@ -9,12 +9,35 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import time
+import csv
+import datetime
+import multiprocessing
+import os
+import sys
+import threading
+from collections import defaultdict, namedtuple
+from contextlib import contextmanager
from functools import wraps
+from inspect import getframeinfo, stack
+from queue import Empty
+from time import perf_counter, perf_counter_ns
+import numpy as np
import torch
-__all__ = ["torch_profiler_full", "torch_profiler_time_cpu_gpu", "torch_profiler_time_end_to_end", "PerfContext"]
+from monai.utils import optional_import
+
+pd, has_pandas = optional_import("pandas")
+
+__all__ = [
+ "torch_profiler_full",
+ "torch_profiler_time_cpu_gpu",
+ "torch_profiler_time_end_to_end",
+ "PerfContext",
+ "WorkflowProfiler",
+ "ProfileHandler",
+ "select_transform_call",
+]
def torch_profiler_full(func):
@@ -74,16 +97,16 @@ def torch_profiler_time_end_to_end(func):
def wrapper(*args, **kwargs):
torch.cuda.synchronize()
- start = time.perf_counter()
+ start = perf_counter()
result = func(*args, **kwargs)
torch.cuda.synchronize()
- end = time.perf_counter()
+ end = perf_counter()
total_time = (end - start) * 1e6
total_time_str = torch.autograd.profiler.format_time(total_time)
- print(f"end to end time: {total_time_str}", flush=True)
+ print(f"End-to-end time: {total_time_str}", flush=True)
return result
@@ -102,9 +125,301 @@ def __init__(self):
self.start_time = None
def __enter__(self):
- self.start_time = time.perf_counter()
+ self.start_time = perf_counter()
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
- self.total_time += time.perf_counter() - self.start_time
+ self.total_time += perf_counter() - self.start_time
self.start_time = None
+
+
+# stores the results from profiling with trace or with other helper methods
+ProfileResult = namedtuple("ProfileResult", ["name", "time", "filename", "lineno", "pid", "timestamp"])
+
+
+def select_transform_call(frame):
+ """Returns True if `frame` is a call to a `Transform` object's `_call__` method."""
+ from monai.transforms import Transform # prevents circular import
+
+ self_obj = frame.f_locals.get("self", None)
+ return frame.f_code.co_name == "__call__" and isinstance(self_obj, Transform)
+
+
+class WorkflowProfiler:
+ """
+ Profiler for timing all aspects of a workflow. This includes using stack tracing to capture call times for
+ all selected calls (by default calls to `Transform.__call__` methods), times within context blocks, times
+ to generate items from iterables, and times to execute decorated functions.
+
+ This profiler must be used only within its context because it uses an internal thread to read results from a
+ multiprocessing queue. This allows the profiler to function across multiple threads and processes, though the
+ multiprocess tracing is at times unreliable and not available in Windows at all.
+
+ The profiler uses `sys.settrace` and `threading.settrace` to find all calls to profile, this will be set when
+ the context enters and cleared when it exits so proper use of the context is essential to prevent excessive
+ tracing. Note that tracing has a high overhead so times will not accurately reflect real world performance
+ but give an idea of relative share of time spent.
+
+ The tracing functionality uses a selector to choose which calls to trace, since tracing all calls induces
+ infinite loops and would be terribly slow even if not. This selector is a callable accepting a `call` trace
+ frame and returns True if the call should be traced. The dedault is `select_transform_call` which will return
+ True for `Transform.__call__` calls only.
+
+ Example showing use of all profiling functions:
+
+ .. code-block:: python
+
+ import monai.transform as mt
+ from monai.utils import WorkflowProfiler
+ import torch
+
+ comp=mt.Compose([mt.ScaleIntensity(),mt.RandAxisFlip(0.5)])
+
+ with WorkflowProfiler() as wp:
+ for _ in wp.profile_iter("range",range(5)):
+ with wp.profile_ctx("Loop"):
+ for i in range(10):
+ comp(torch.rand(1,16,16))
+
+ @wp.profile_callable()
+ def foo(): pass
+
+ foo()
+ foo()
+
+ print(wp.get_times_summary_pd()) # print results
+
+ Args:
+ call_selector: selector to determine which calls to trace, use None to disable tracing
+ """
+
+ def __init__(self, call_selector=select_transform_call):
+ self.results = defaultdict(list)
+ self.parent_pid = os.getpid()
+ self.read_thread = None
+ self.lock = threading.RLock()
+ self.queue = multiprocessing.SimpleQueue()
+ self.queue_timeout = 0.1
+ self.call_selector = call_selector
+
+ def _is_parent(self):
+ """Return True if this is the parent process."""
+ return os.getpid() == self.parent_pid
+
+ def _is_thread_active(self):
+ """Return True if the read thread should be still active."""
+ return self.read_thread is not None or not self.queue.empty()
+
+ def _read_thread_func(self):
+ """Read results from the queue and add to self.results in a thread stared by `__enter__`."""
+ while self._is_parent() and self._is_thread_active():
+ try:
+ result = self.queue.get()
+
+ if result is None:
+ break
+
+ self.add_result(result)
+ except Empty:
+ pass
+
+ if not (not self._is_parent() or self.queue.empty()):
+ raise AssertionError
+
+ def _put_result(self, name, timedelta, filename, lineno):
+ """Add a ProfileResult object to the queue."""
+ ts = str(datetime.datetime.now())
+ self.queue.put(ProfileResult(name, timedelta, filename, lineno, os.getpid(), ts))
+
+ def _trace_call(self, frame, why, arg):
+ """
+ Trace calls, when a call is encountered that is accepted by self.call_selector, create a new function to
+ trace that call and measure the time from the call to a "return" frame.
+ """
+ if why == "call":
+ if self.call_selector(frame):
+ calling_frame = frame
+ start = perf_counter_ns()
+
+ def _call_profiler(frame, why, arg):
+ """Defines a new inner trace function just for this call."""
+ if why == "return":
+ diff = perf_counter_ns() - start
+ f_code = calling_frame.f_code
+ self_obj = calling_frame.f_locals.get("self", None)
+ name = f_code.co_name
+ if self_obj is not None:
+ name = f"{type(self_obj).__name__}.{name}"
+
+ self._put_result(name, diff, f_code.co_filename, f_code.co_firstlineno)
+
+ # This function will be used to trace this specific call now, however any new functions calls
+ # within will cause a "call" frame to be sent to `_trace_call` rather than to it, ie. it's not
+ # actually recursively tracing everything below as the documentation suggests and so cannot
+ # control whether subsequence calls are traced (see https://bugs.python.org/issue11992).
+ return _call_profiler
+ else:
+ return self._trace_call
+
+ def __enter__(self):
+ """Enter the context, creating the read thread and setting up tracing if needed."""
+ self.read_thread = threading.Thread(target=self._read_thread_func)
+ self.read_thread.start()
+
+ if self.call_selector is not None:
+ threading.settrace(self._trace_call)
+ sys.settrace(self._trace_call)
+
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ """Terminate the read thread cleanly and reset tracing if needed."""
+ if not self._is_parent():
+ raise AssertionError
+
+ self.queue.put(None)
+
+ read_thread = self.read_thread
+ self.read_thread = None
+
+ read_thread.join()
+
+ if self.call_selector is not None:
+ threading.settrace(None)
+ sys.settrace(None)
+
+ def add_result(self, result: ProfileResult):
+ """Add a result in a thread-safe manner to the internal results dictionary."""
+ with self.lock:
+ self.results[result.name].append(result)
+
+ def get_results(self):
+ """Get a fresh results dictionary containing fresh tuples of ProfileResult objects."""
+ if not self._is_parent():
+ raise RuntimeError("Only parent process can collect results")
+
+ with self.lock:
+ return {k: tuple(v) for k, v in self.results.items()}
+
+ @contextmanager
+ def profile_ctx(self, name, caller=None):
+ """Creates a context to profile, placing a timing result onto the queue when it exits."""
+ if caller is None:
+ caller = getframeinfo(stack()[2][0]) # caller of context, not something in contextlib
+
+ start = perf_counter_ns()
+ try:
+ yield
+ finally:
+ diff = perf_counter_ns() - start
+ self._put_result(name, diff, caller.filename, caller.lineno)
+
+ def profile_callable(self, name=None):
+ """
+ Decorator which can be applied to a function which profiles any calls to it. All calls to decorated
+ callables must be done within the context of the profiler.
+ """
+
+ def _outer(func):
+ _name = func.__name__ if name is None else name
+ return self.profile_ctx(_name)(func)
+
+ return _outer
+
+ def profile_iter(self, name, iterable):
+ """Wrapper around anything iterable to profile how long it takes to generate items."""
+
+ class _Iterable:
+ def __iter__(_self): # noqa: B902, N805 pylint: disable=E0213
+ do_iter = True
+ orig_iter = iter(iterable)
+ caller = getframeinfo(stack()[1][0])
+
+ while do_iter:
+ try:
+ start = perf_counter_ns()
+ item = next(orig_iter)
+ diff = perf_counter_ns() - start
+ # don't put result when StopIteration is hit
+ self._put_result(name, diff, caller.filename, caller.lineno)
+ yield item
+ except StopIteration:
+ do_iter = False
+
+ return _Iterable()
+
+ def get_times_summary(self, times_in_s=True):
+ """
+ Returns a dictionary mapping results entries to tuples containing the number of items, time sum, time average,
+ time std dev, time min, and time max.
+ """
+ result = {}
+ for k, v in self.get_results().items():
+ timemult = 1e-9 if times_in_s else 1.0
+ all_times = [res.time * timemult for res in v]
+
+ timesum = sum(all_times)
+ timeavg = timesum / len(all_times)
+ timestd = np.std(all_times)
+ timemin = min(all_times)
+ timemax = max(all_times)
+
+ result[k] = (len(v), timesum, timeavg, timestd, timemin, timemax)
+
+ return result
+
+ def get_times_summary_pd(self, times_in_s=True):
+ """Returns the same informatoin as `get_times_summary` but in a Pandas DataFrame."""
+ import pandas as pd
+
+ summ = self.get_times_summary(times_in_s)
+ suffix = "s" if times_in_s else "ns"
+ columns = ["Count", f"Total Time ({suffix})", "Avg", "Std", "Min", "Max"]
+
+ df = pd.DataFrame.from_dict(summ, orient="index", columns=columns)
+ df = df.sort_values(columns[1], ascending=False)
+ return df
+
+ def dump_csv(self, stream=sys.stdout):
+ """Save all results to a csv file."""
+ all_results = list(self.get_results().values())
+ writer = csv.DictWriter(stream, fieldnames=all_results[0][0]._asdict().keys())
+ writer.writeheader()
+
+ for rlist in all_results:
+ for r in rlist:
+ writer.writerow(r._asdict())
+
+
+class ProfileHandler:
+ """
+ Handler for Ignite Engine classes which measures the time from a start event ton an end event. This can be used to
+ profile epoch, iteration, and other events as defined in `ignite.engine.Events`. This class should be used only
+ within the context of a profiler object.
+
+ Args:
+ name: name of event to profile
+ profiler: instance of WorkflowProfiler used by the handler, should be within the context of this object
+ start_event: item in `ignite.engine.Events` stating event at which to start timing
+ end_event: item in `ignite.engine.Events` stating event at which to stop timing
+ """
+
+ def __init__(self, name: str, profiler: WorkflowProfiler, start_event, end_event):
+ self.name = name
+ self.profiler = profiler
+ self.start_event = start_event
+ self.end_event = end_event
+ self.ctx = None
+
+ def attach(self, engine):
+ engine.add_event_handler(self.start_event, self.start)
+ engine.add_event_handler(self.end_event, self.end)
+ return self
+
+ def start(self, engine):
+ self.ctx = self.profiler.profile_ctx(self.name)
+ self.ctx.__enter__()
+
+ def end(self, engine):
+ self.ctx.__exit__(None, None, None)
+ self.ctx = None
diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py
index e33a155568..7bb6f8c962 100644
--- a/monai/utils/type_conversion.py
+++ b/monai/utils/type_conversion.py
@@ -10,7 +10,6 @@
# limitations under the License.
import re
-from copy import deepcopy
from typing import Any, Optional, Sequence, Tuple, Type, Union
import numpy as np
@@ -100,7 +99,7 @@ def get_dtype(data: Any):
def convert_to_tensor(
data,
dtype: Optional[torch.dtype] = None,
- device: Optional[torch.device] = None,
+ device: Union[None, str, torch.device] = None,
wrap_sequence: bool = False,
track_meta: bool = False,
):
@@ -176,6 +175,13 @@ def convert_to_numpy(data, dtype: DtypeLike = None, wrap_sequence: bool = False)
elif has_cp and isinstance(data, cp_ndarray):
data = cp.asnumpy(data).astype(dtype, copy=False)
elif isinstance(data, (np.ndarray, float, int, bool)):
+ # Convert into a contiguous array first if the current dtype's size is smaller than the target dtype's size.
+ # This help improve the performance because (convert to contiguous array) -> (convert dtype) is faster
+ # than (convert dtype) -> (convert to contiguous array) when src dtype (e.g., uint8) is smaller than
+ # target dtype(e.g., float32) and we are going to convert it to contiguous array anyway later in this
+ # method.
+ if isinstance(data, np.ndarray) and data.ndim > 0 and data.dtype.itemsize < np.dtype(dtype).itemsize:
+ data = np.ascontiguousarray(data)
data = np.asarray(data, dtype=dtype)
elif isinstance(data, list):
list_ret = [convert_to_numpy(i, dtype=dtype) for i in data]
@@ -334,7 +340,7 @@ def convert_to_dst_type(
data=src, output_type=output_type, device=device, dtype=dtype, wrap_sequence=wrap_sequence
)
if copy_meta and isinstance(output, monai.data.MetaTensor):
- output.meta, output.applied_operations = deepcopy(dst.meta), deepcopy(dst.applied_operations) # type: ignore
+ output.copy_meta_from(dst)
return output, _type, _device
diff --git a/monai/visualize/utils.py b/monai/visualize/utils.py
index 1ef6d6da57..e722a1f0c5 100644
--- a/monai/visualize/utils.py
+++ b/monai/visualize/utils.py
@@ -9,14 +9,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Optional
+from typing import Optional, Union
import numpy as np
+import torch
from monai.config.type_definitions import NdarrayOrTensor
from monai.transforms.croppad.array import SpatialPad
from monai.transforms.utils import rescale_array
-from monai.transforms.utils_pytorch_numpy_unification import repeat, where
+from monai.transforms.utils_pytorch_numpy_unification import repeat
from monai.utils.module import optional_import
from monai.utils.type_conversion import convert_data_type, convert_to_dst_type
@@ -157,7 +158,12 @@ def matshow3d(
def blend_images(
- image: NdarrayOrTensor, label: NdarrayOrTensor, alpha: float = 0.5, cmap: str = "hsv", rescale_arrays: bool = True
+ image: NdarrayOrTensor,
+ label: NdarrayOrTensor,
+ alpha: Union[float, NdarrayOrTensor] = 0.5,
+ cmap: str = "hsv",
+ rescale_arrays: bool = True,
+ transparent_background: bool = True,
):
"""
Blend an image and a label. Both should have the shape CHW[D].
@@ -167,18 +173,29 @@ def blend_images(
Args:
image: the input image to blend with label data.
label: the input label to blend with image data.
- alpha: when blending image and label, `alpha` is the weight for the image region mapping to `label != 0`,
- and `1 - alpha` is the weight for the label region that `label != 0`, default to `0.5`.
+ alpha: this specifies the weighting given to the label, where 0 is completely
+ transparent and 1 is completely opaque. This can be given as either a
+ single value or an array/tensor that is the same size as the input image.
cmap: specify colormap in the matplotlib, default to `hsv`, for more details, please refer to:
https://matplotlib.org/2.0.2/users/colormaps.html.
rescale_arrays: whether to rescale the array to [0, 1] first, default to `True`.
+ transparent_background: if true, any zeros in the label field will not be colored.
+
+ .. image:: ../../docs/images/blend_images.png
"""
if label.shape[0] != 1:
- raise ValueError("Label should have 1 channel")
+ raise ValueError("Label should have 1 channel.")
if image.shape[0] not in (1, 3):
- raise ValueError("Image should have 1 or 3 channels")
+ raise ValueError("Image should have 1 or 3 channels.")
+ if image.shape[1:] != label.shape[1:]:
+ raise ValueError("image and label should have matching spatial sizes.")
+ if isinstance(alpha, (np.ndarray, torch.Tensor)):
+ if image.shape[1:] != alpha.shape[1:]: # pytype: disable=attribute-error,invalid-directive
+
+ raise ValueError("if alpha is image, size should match input image and label.")
+
# rescale arrays to [0, 1] if desired
if rescale_arrays:
image = rescale_array(image)
@@ -196,6 +213,15 @@ def get_label_rgb(cmap: str, label: NdarrayOrTensor):
return label_rgb
label_rgb = get_label_rgb(cmap, label)
- w_image = where(label == 0, 1.0, alpha)
- w_label = where(label == 0, 0.0, 1 - alpha)
+ if isinstance(alpha, (torch.Tensor, np.ndarray)):
+ w_label = alpha
+ elif isinstance(label, torch.Tensor):
+ w_label = torch.full_like(label, alpha)
+ else:
+ w_label = np.full_like(label, alpha)
+ if transparent_background:
+ # where label == 0 (background), set label alpha to 0
+ w_label[label == 0] = 0 # pytype: disable=unsupported-operands
+
+ w_image = 1 - w_label
return w_image * image + w_label * label_rgb
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 8318a1795c..2568bf9c1e 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -14,7 +14,6 @@ flake8>=3.8.1
flake8-bugbear
flake8-comprehensions
flake8-executable
-flake8-pyi
pylint!=2.13 # https://github.com/PyCQA/pylint/issues/5969
mccabe
pep8-naming
@@ -50,3 +49,4 @@ jsonschema
pynrrd
pre-commit
pydicom
+h5py
diff --git a/runtests.sh b/runtests.sh
index 9e6ef3d0e1..db6022e9b1 100755
--- a/runtests.sh
+++ b/runtests.sh
@@ -593,13 +593,7 @@ then
install_deps
fi
${cmdPrefix}${PY_EXE} -m mypy --version
-
- if [ $doDryRun = true ]
- then
- ${cmdPrefix}MYPYPATH="$(pwd)"/monai ${PY_EXE} -m mypy "$(pwd)"
- else
- MYPYPATH="$(pwd)"/monai ${PY_EXE} -m mypy "$(pwd)" # cmdPrefix does not work with MYPYPATH
- fi
+ ${cmdPrefix}${PY_EXE} -m mypy "$(pwd)"
mypy_status=$?
if [ ${mypy_status} -ne 0 ]
@@ -687,5 +681,5 @@ if [ $doCoverage = true ]
then
echo "${separator}${blue}coverage${noColor}"
${cmdPrefix}${PY_EXE} -m coverage combine --append .coverage/
- ${cmdPrefix}${PY_EXE} -m coverage report
+ ${cmdPrefix}${PY_EXE} -m coverage report --ignore-errors
fi
diff --git a/setup.cfg b/setup.cfg
index 9a5efba82d..09219bfc32 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -55,6 +55,7 @@ all =
jsonschema
pynrrd
pydicom
+ h5py
nibabel =
nibabel
skimage =
@@ -107,6 +108,8 @@ pynrrd =
pynrrd
pydicom =
pydicom
+h5py =
+ h5py
[flake8]
select = B,C,E,F,N,P,T4,W,B9
diff --git a/setup.py b/setup.py
index 219f0eb957..faf63a9246 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,7 @@
BUILD_CPP = True
from torch.utils.cpp_extension import CUDA_HOME, CUDAExtension
- BUILD_CUDA = (CUDA_HOME is not None) if torch.cuda.is_available() else FORCE_CUDA
+ BUILD_CUDA = FORCE_CUDA or (torch.cuda.is_available() and (CUDA_HOME is not None))
_pt_version = pkg_resources.parse_version(torch.__version__).release
if _pt_version is None or len(_pt_version) < 3:
diff --git a/tests/min_tests.py b/tests/min_tests.py
index 898d1b7b00..c12e66bfd1 100644
--- a/tests/min_tests.py
+++ b/tests/min_tests.py
@@ -68,6 +68,7 @@ def run_testsuit():
"test_handler_hausdorff_distance",
"test_handler_lr_scheduler",
"test_handler_mean_dice",
+ "test_handler_mean_iou",
"test_handler_metrics_saver",
"test_handler_metrics_saver_dist",
"test_handler_mlflow",
@@ -98,6 +99,7 @@ def run_testsuit():
"test_integration_workflows",
"test_integration_workflows_gan",
"test_integration_bundle_run",
+ "test_invert",
"test_invertd",
"test_iterable_dataset",
"test_keep_largest_connected_component",
@@ -134,6 +136,7 @@ def run_testsuit():
"test_rand_zoom",
"test_rand_zoomd",
"test_randtorchvisiond",
+ "test_resample_backends",
"test_resize",
"test_resized",
"test_resample_to_match",
@@ -161,7 +164,6 @@ def run_testsuit():
"test_vitautoenc",
"test_write_metrics_reports",
"test_wsireader",
- "test_wsireader_new",
"test_zoom",
"test_zoom_affine",
"test_zoomd",
@@ -172,6 +174,7 @@ def run_testsuit():
"test_bundle_ckpt_export",
"test_bundle_utils",
"test_bundle_init_bundle",
+ "test_fastmri_reader",
]
assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}"
diff --git a/tests/padders.py b/tests/padders.py
index 3fa3280cb5..367d2059b9 100644
--- a/tests/padders.py
+++ b/tests/padders.py
@@ -98,6 +98,8 @@ def pad_test_kwargs(self, unchanged_slices, **input_param):
result = result.cpu()
assert_allclose(result[unchanged_slices], im, type_test=False)
# we should have the same as the input plus some 2s (if value) or 1s and 2s (if constant_values)
+ if isinstance(im, torch.Tensor):
+ im = im.detach().cpu().numpy()
expected_vals = np.unique(im).tolist()
expected_vals += [2] if "value" in kwargs else [1, 2]
assert_allclose(np.unique(result), expected_vals, type_test=False)
diff --git a/tests/test_blend_images.py b/tests/test_blend_images.py
index 6fea53ac30..341b79b949 100644
--- a/tests/test_blend_images.py
+++ b/tests/test_blend_images.py
@@ -12,6 +12,7 @@
import unittest
from unittest.case import skipUnless
+import numpy as np
import torch
from parameterized import parameterized
@@ -23,25 +24,32 @@
plt, has_matplotlib = optional_import("matplotlib.pyplot")
+
+def get_alpha(img):
+ return 0.5 * np.arange(img.size).reshape(img.shape) / img.size
+
+
TESTS = []
for p in TEST_NDARRAYS:
- image, label = create_test_image_2d(100, 101)
- TESTS.append((p(image), p(label)))
+ image, label = create_test_image_2d(100, 101, channel_dim=0)
+ TESTS.append((p(image), p(label), 0.5))
+ TESTS.append((p(image), p(label), p(get_alpha(image))))
- image, label = create_test_image_3d(100, 101, 102)
- TESTS.append((p(image), p(label)))
+ image, label = create_test_image_3d(100, 101, 102, channel_dim=0)
+ TESTS.append((p(image), p(label), 0.5))
+ TESTS.append((p(image), p(label), p(get_alpha(image))))
@skipUnless(has_matplotlib, "Matplotlib required")
class TestBlendImages(unittest.TestCase):
@parameterized.expand(TESTS)
- def test_blend(self, image, label):
- blended = blend_images(image[None], label[None])
+ def test_blend(self, image, label, alpha):
+ blended = blend_images(image, label, alpha)
self.assertEqual(type(image), type(blended))
if isinstance(blended, torch.Tensor):
self.assertEqual(blended.device, image.device)
blended = blended.cpu().numpy()
- self.assertEqual((3,) + image.shape, blended.shape)
+ self.assertEqual((3,) + image[0].shape, blended.shape)
blended = moveaxis(blended, 0, -1) # move RGB component to end
if blended.ndim > 3:
diff --git a/tests/test_box_transform.py b/tests/test_box_transform.py
index 6b0a4a2b19..d597ec76bb 100644
--- a/tests/test_box_transform.py
+++ b/tests/test_box_transform.py
@@ -10,14 +10,30 @@
# limitations under the License.
import unittest
+from copy import deepcopy
import numpy as np
import torch
from parameterized import parameterized
from monai.apps.detection.transforms.box_ops import convert_mask_to_box
-from monai.apps.detection.transforms.dictionary import BoxToMaskd, ConvertBoxModed, MaskToBoxd
-from monai.transforms import CastToTyped
+from monai.apps.detection.transforms.dictionary import (
+ AffineBoxToImageCoordinated,
+ AffineBoxToWorldCoordinated,
+ BoxToMaskd,
+ ClipBoxToImaged,
+ ConvertBoxModed,
+ FlipBoxd,
+ MaskToBoxd,
+ RandCropBoxByPosNegLabeld,
+ RandFlipBoxd,
+ RandRotateBox90d,
+ RandZoomBoxd,
+ RotateBox90d,
+ ZoomBoxd,
+)
+from monai.data.meta_tensor import MetaTensor
+from monai.transforms import CastToTyped, Invertd
from tests.utils import TEST_NDARRAYS, assert_allclose
TESTS_3D = []
@@ -135,157 +151,199 @@ def test_value_3d(
convert_result["boxes"], expected_convert_result, type_test=True, device_test=True, atol=1e-3
)
- # invert_transform_convert_mode = Invertd(
- # keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"]
- # )
- # data_back = invert_transform_convert_mode(convert_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
- #
- # # test ZoomBoxd
- # transform_zoom = ZoomBoxd(
- # image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=False
- # )
- # zoom_result = transform_zoom(data)
- # assert_allclose(zoom_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=1e-3)
- # invert_transform_zoom = Invertd(
- # keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"]
- # )
- # data_back = invert_transform_zoom(zoom_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
- # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
- #
- # transform_zoom = ZoomBoxd(
- # image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=True
- # )
- # zoom_result = transform_zoom(data)
- # assert_allclose(
- # zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3
- # )
- #
- # # test RandZoomBoxd
- # transform_zoom = RandZoomBoxd(
- # image_keys="image",
- # box_keys="boxes",
- # box_ref_image_keys="image",
- # prob=1.0,
- # min_zoom=(0.3,) * 3,
- # max_zoom=(3.0,) * 3,
- # keep_size=False,
- # )
- # zoom_result = transform_zoom(data)
- # invert_transform_zoom = Invertd(
- # keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"]
- # )
- # data_back = invert_transform_zoom(zoom_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
- # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
- #
- # # test AffineBoxToImageCoordinated, AffineBoxToWorldCoordinated
- # transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image")
- # with self.assertRaises(Exception) as context:
- # transform_affine(data)
- # self.assertTrue("Please check whether it is the correct the image meta key." in str(context.exception))
- #
- # data["image_meta_dict"] = {"affine": torch.diag(1.0 / torch.Tensor([0.5, 3, 1.5, 1]))}
- # affine_result = transform_affine(data)
- # assert_allclose(affine_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=0.01)
- # invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"])
- # data_back = invert_transform_affine(affine_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
- # invert_transform_affine = AffineBoxToWorldCoordinated(box_keys="boxes", box_ref_image_keys="image")
- # data_back = invert_transform_affine(affine_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
- #
- # # test FlipBoxd
- # transform_flip = FlipBoxd(
- # image_keys="image", box_keys="boxes", box_ref_image_keys="image", spatial_axis=[0, 1, 2]
- # )
- # flip_result = transform_flip(data)
- # assert_allclose(flip_result["boxes"], expected_flip_result, type_test=True, device_test=True, atol=1e-3)
- # invert_transform_flip = Invertd(
- # keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"]
- # )
- # data_back = invert_transform_flip(flip_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
- # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
- #
- # # test RandFlipBoxd
- # for spatial_axis in [(0,), (1,), (2,), (0, 1), (1, 2)]:
- # transform_flip = RandFlipBoxd(
- # image_keys="image",
- # box_keys="boxes",
- # box_ref_image_keys="image",
- # prob=1.0,
- # spatial_axis=spatial_axis,
- # )
- # flip_result = transform_flip(data)
- # invert_transform_flip = Invertd(
- # keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"]
- # )
- # data_back = invert_transform_flip(flip_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
- # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
- #
- # # test ClipBoxToImaged
- # transform_clip = ClipBoxToImaged(
- # box_keys="boxes", box_ref_image_keys="image", label_keys=["labels", "scores"], remove_empty=True
- # )
- # clip_result = transform_clip(data)
- # assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3)
- # assert_allclose(clip_result["labels"], data["labels"][1:], type_test=True, device_test=True, atol=1e-3)
- # assert_allclose(clip_result["scores"], data["scores"][1:], type_test=True, device_test=True, atol=1e-3)
- #
- # transform_clip = ClipBoxToImaged(
- # box_keys="boxes", box_ref_image_keys="image", label_keys=[], remove_empty=True
- # ) # corner case when label_keys is empty
- # clip_result = transform_clip(data)
- # assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3)
- #
- # # test RandCropBoxByPosNegLabeld
- # transform_crop = RandCropBoxByPosNegLabeld(
- # image_keys="image", box_keys="boxes", label_keys=["labels", "scores"], spatial_size=2, num_samples=3
- # )
- # crop_result = transform_crop(data)
- # assert len(crop_result) == 3
- # for ll in range(3):
- # assert_allclose(
- # crop_result[ll]["boxes"].shape[0],
- # crop_result[ll]["labels"].shape[0],
- # type_test=True,
- # device_test=True,
- # atol=1e-3,
- # )
- # assert_allclose(
- # crop_result[ll]["boxes"].shape[0],
- # crop_result[ll]["scores"].shape[0],
- # type_test=True,
- # device_test=True,
- # atol=1e-3,
- # )
- #
- # # test RotateBox90d
- # transform_rotate = RotateBox90d(
- # image_keys="image", box_keys="boxes", box_ref_image_keys="image", k=1, spatial_axes=[0, 1]
- # )
- # rotate_result = transform_rotate(data)
- # assert_allclose(rotate_result["boxes"], expected_rotate_result, type_test=True, device_test=True, atol=1e-3)
- # invert_transform_rotate = Invertd(
- # keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"]
- # )
- # data_back = invert_transform_rotate(rotate_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
- # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
- #
- # transform_rotate = RandRotateBox90d(
- # image_keys="image", box_keys="boxes", box_ref_image_keys="image", prob=1.0, max_k=3, spatial_axes=[0, 1]
- # )
- # rotate_result = transform_rotate(data)
- # invert_transform_rotate = Invertd(
- # keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"]
- # )
- # data_back = invert_transform_rotate(rotate_result)
- # assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
- # assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+ invert_transform_convert_mode = Invertd(
+ keys=["boxes"], transform=transform_convert_mode, orig_keys=["boxes"]
+ )
+ data_back = invert_transform_convert_mode(convert_result)
+ if "boxes_transforms" in data_back: # if the transform is tracked in dict:
+ self.assertEqual(data_back["boxes_transforms"], []) # it should be updated
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+
+ # test ZoomBoxd
+ transform_zoom = ZoomBoxd(
+ image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=False
+ )
+ zoom_result = transform_zoom(data)
+ self.assertEqual(len(zoom_result["image"].applied_operations), 1)
+ assert_allclose(zoom_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=1e-3)
+ invert_transform_zoom = Invertd(
+ keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"]
+ )
+ data_back = invert_transform_zoom(zoom_result)
+ self.assertEqual(data_back["image"].applied_operations, [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+ assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+ transform_zoom = ZoomBoxd(
+ image_keys="image", box_keys="boxes", box_ref_image_keys="image", zoom=[0.5, 3, 1.5], keep_size=True
+ )
+ zoom_result = transform_zoom(data)
+ self.assertEqual(len(zoom_result["image"].applied_operations), 1)
+ assert_allclose(
+ zoom_result["boxes"], expected_zoom_keepsize_result, type_test=True, device_test=True, atol=1e-3
+ )
+
+ # test RandZoomBoxd
+ transform_zoom = RandZoomBoxd(
+ image_keys="image",
+ box_keys="boxes",
+ box_ref_image_keys="image",
+ prob=1.0,
+ min_zoom=(0.3,) * 3,
+ max_zoom=(3.0,) * 3,
+ keep_size=False,
+ )
+ zoom_result = transform_zoom(data)
+ self.assertEqual(len(zoom_result["image"].applied_operations), 1)
+ invert_transform_zoom = Invertd(
+ keys=["image", "boxes"], transform=transform_zoom, orig_keys=["image", "boxes"]
+ )
+ data_back = invert_transform_zoom(zoom_result)
+ self.assertEqual(data_back["image"].applied_operations, [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
+ assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+ # test AffineBoxToImageCoordinated, AffineBoxToWorldCoordinated
+ transform_affine = AffineBoxToImageCoordinated(box_keys="boxes", box_ref_image_keys="image")
+ if not isinstance(data["image"], MetaTensor): # metadict should be undefined and it's an exception
+ with self.assertRaises(Exception) as context:
+ transform_affine(deepcopy(data))
+ self.assertTrue("Please check whether it is the correct the image meta key." in str(context.exception))
+
+ data["image"] = MetaTensor(data["image"], meta={"affine": torch.diag(1.0 / torch.Tensor([0.5, 3, 1.5, 1]))})
+ affine_result = transform_affine(data)
+ if "boxes_transforms" in affine_result:
+ self.assertEqual(len(affine_result["boxes_transforms"]), 1)
+ assert_allclose(affine_result["boxes"], expected_zoom_result, type_test=True, device_test=True, atol=0.01)
+ invert_transform_affine = Invertd(keys=["boxes"], transform=transform_affine, orig_keys=["boxes"])
+ data_back = invert_transform_affine(affine_result)
+ if "boxes_transforms" in data_back:
+ self.assertEqual(data_back["boxes_transforms"], [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
+ invert_transform_affine = AffineBoxToWorldCoordinated(box_keys="boxes", box_ref_image_keys="image")
+ data_back = invert_transform_affine(affine_result)
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=0.01)
+
+ # test FlipBoxd
+ transform_flip = FlipBoxd(
+ image_keys="image", box_keys="boxes", box_ref_image_keys="image", spatial_axis=[0, 1, 2]
+ )
+ flip_result = transform_flip(data)
+ if "boxes_transforms" in flip_result:
+ self.assertEqual(len(flip_result["boxes_transforms"]), 1)
+ assert_allclose(flip_result["boxes"], expected_flip_result, type_test=True, device_test=True, atol=1e-3)
+ invert_transform_flip = Invertd(
+ keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"]
+ )
+ data_back = invert_transform_flip(flip_result)
+ if "boxes_transforms" in data_back:
+ self.assertEqual(data_back["boxes_transforms"], [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+ assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+ # test RandFlipBoxd
+ for spatial_axis in [(0,), (1,), (2,), (0, 1), (1, 2)]:
+ transform_flip = RandFlipBoxd(
+ image_keys="image",
+ box_keys="boxes",
+ box_ref_image_keys="image",
+ prob=1.0,
+ spatial_axis=spatial_axis,
+ )
+ flip_result = transform_flip(data)
+ if "boxes_transforms" in flip_result:
+ self.assertEqual(len(flip_result["boxes_transforms"]), 1)
+ invert_transform_flip = Invertd(
+ keys=["image", "boxes"], transform=transform_flip, orig_keys=["image", "boxes"]
+ )
+ data_back = invert_transform_flip(flip_result)
+ if "boxes_transforms" in data_back:
+ self.assertEqual(data_back["boxes_transforms"], [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+ assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+ # test ClipBoxToImaged
+ transform_clip = ClipBoxToImaged(
+ box_keys="boxes", box_ref_image_keys="image", label_keys=["labels", "scores"], remove_empty=True
+ )
+ clip_result = transform_clip(data)
+ assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3)
+ assert_allclose(clip_result["labels"], data["labels"][1:], type_test=True, device_test=True, atol=1e-3)
+ assert_allclose(clip_result["scores"], data["scores"][1:], type_test=True, device_test=True, atol=1e-3)
+
+ transform_clip = ClipBoxToImaged(
+ box_keys="boxes", box_ref_image_keys="image", label_keys=[], remove_empty=True
+ ) # corner case when label_keys is empty
+ clip_result = transform_clip(data)
+ assert_allclose(clip_result["boxes"], expected_clip_result, type_test=True, device_test=True, atol=1e-3)
+
+ # test RandCropBoxByPosNegLabeld
+ transform_crop = RandCropBoxByPosNegLabeld(
+ image_keys="image", box_keys="boxes", label_keys=["labels", "scores"], spatial_size=2, num_samples=3
+ )
+ crop_result = transform_crop(data)
+ assert len(crop_result) == 3
+ for ll in range(3):
+ assert_allclose(
+ crop_result[ll]["boxes"].shape[0],
+ crop_result[ll]["labels"].shape[0],
+ type_test=True,
+ device_test=True,
+ atol=1e-3,
+ )
+ assert_allclose(
+ crop_result[ll]["boxes"].shape[0],
+ crop_result[ll]["scores"].shape[0],
+ type_test=True,
+ device_test=True,
+ atol=1e-3,
+ )
+
+ # test RotateBox90d
+ transform_rotate = RotateBox90d(
+ image_keys="image", box_keys="boxes", box_ref_image_keys="image", k=1, spatial_axes=[0, 1]
+ )
+ rotate_result = transform_rotate(data)
+ self.assertEqual(len(rotate_result["image"].applied_operations), 1)
+ assert_allclose(rotate_result["boxes"], expected_rotate_result, type_test=True, device_test=True, atol=1e-3)
+ invert_transform_rotate = Invertd(
+ keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"]
+ )
+ data_back = invert_transform_rotate(rotate_result)
+ self.assertEqual(data_back["image"].applied_operations, [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+ assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+ transform_rotate = RandRotateBox90d(
+ image_keys="image", box_keys="boxes", box_ref_image_keys="image", prob=1.0, max_k=3, spatial_axes=[0, 1]
+ )
+ rotate_result = transform_rotate(data)
+ self.assertEqual(len(rotate_result["image"].applied_operations), 1)
+ invert_transform_rotate = Invertd(
+ keys=["image", "boxes"], transform=transform_rotate, orig_keys=["image", "boxes"]
+ )
+ data_back = invert_transform_rotate(rotate_result)
+ self.assertEqual(data_back["image"].applied_operations, [])
+ assert_allclose(data_back["boxes"], data["boxes"], type_test=False, device_test=False, atol=1e-3)
+ assert_allclose(data_back["image"], data["image"], type_test=False, device_test=False, atol=1e-3)
+
+ def test_crop_shape(self):
+ tt = RandCropBoxByPosNegLabeld(
+ image_keys=["image"],
+ box_keys="box",
+ label_keys="label",
+ spatial_size=[10, 7, -1],
+ whole_box=True,
+ num_samples=1,
+ pos=1,
+ neg=0,
+ )
+ iii = {
+ "image": torch.rand(1, 10, 8, 7),
+ "box": torch.tensor(((1.0, 2.0, 3.0, 4.0, 5.0, 6.0),)),
+ "label": torch.tensor((1,)).long(),
+ }
+ self.assertEqual(tt(iii)[0]["image"].shape, (1, 10, 7, 7))
if __name__ == "__main__":
diff --git a/tests/test_bundle_ckpt_export.py b/tests/test_bundle_ckpt_export.py
index a7cbff22f0..e5847c57ab 100644
--- a/tests/test_bundle_ckpt_export.py
+++ b/tests/test_bundle_ckpt_export.py
@@ -11,7 +11,6 @@
import json
import os
-import subprocess
import tempfile
import unittest
@@ -20,7 +19,7 @@
from monai.bundle import ConfigParser
from monai.data import load_net_with_metadata
from monai.networks import save_state
-from tests.utils import skip_if_windows
+from tests.utils import command_line_tests, skip_if_windows
TEST_CASE_1 = [""]
@@ -49,7 +48,7 @@ def test_export(self, key_in_ckpt):
cmd = ["coverage", "run", "-m", "monai.bundle", "ckpt_export", "network_def", "--filepath", ts_file]
cmd += ["--meta_file", meta_file, "--config_file", f"['{config_file}','{def_args_file}']", "--ckpt_file"]
cmd += [ckpt_file, "--key_in_ckpt", key_in_ckpt, "--args_file", def_args_file]
- subprocess.check_call(cmd)
+ command_line_tests(cmd)
self.assertTrue(os.path.exists(ts_file))
_, metadata, extra_files = load_net_with_metadata(
diff --git a/tests/test_bundle_download.py b/tests/test_bundle_download.py
index 7e609a7b31..90454eca71 100644
--- a/tests/test_bundle_download.py
+++ b/tests/test_bundle_download.py
@@ -11,7 +11,6 @@
import json
import os
-import subprocess
import tempfile
import unittest
@@ -21,7 +20,13 @@
import monai.networks.nets as nets
from monai.apps import check_hash
from monai.bundle import ConfigParser, load
-from tests.utils import SkipIfBeforePyTorchVersion, skip_if_downloading_fails, skip_if_quick, skip_if_windows
+from tests.utils import (
+ SkipIfBeforePyTorchVersion,
+ command_line_tests,
+ skip_if_downloading_fails,
+ skip_if_quick,
+ skip_if_windows,
+)
TEST_CASE_1 = [
["model.pt", "model.ts", "network.json", "test_output.pt", "test_input.pt"],
@@ -64,7 +69,7 @@ def test_download_bundle(self, bundle_files, bundle_name, repo, hash_val):
with tempfile.TemporaryDirectory() as tempdir:
cmd = ["coverage", "run", "-m", "monai.bundle", "download", "--name", bundle_name, "--source", "github"]
cmd += ["--bundle_dir", tempdir, "--repo", repo, "--progress", "False"]
- subprocess.check_call(cmd)
+ command_line_tests(cmd)
for file in bundle_files:
file_path = os.path.join(tempdir, bundle_name, file)
self.assertTrue(os.path.exists(file_path))
@@ -83,7 +88,7 @@ def test_url_download_bundle(self, bundle_files, bundle_name, url, hash_val):
parser.export_config_file(config=def_args, filepath=def_args_file)
cmd = ["coverage", "run", "-m", "monai.bundle", "download", "--args_file", def_args_file]
cmd += ["--url", url]
- subprocess.check_call(cmd)
+ command_line_tests(cmd)
for file in bundle_files:
file_path = os.path.join(tempdir, bundle_name, file)
self.assertTrue(os.path.exists(file_path))
diff --git a/tests/test_bundle_init_bundle.py b/tests/test_bundle_init_bundle.py
index 24fc425c31..6f88d239ff 100644
--- a/tests/test_bundle_init_bundle.py
+++ b/tests/test_bundle_init_bundle.py
@@ -10,14 +10,13 @@
# limitations under the License.
import os
-import subprocess
import tempfile
import unittest
import torch
from monai.networks.nets import UNet
-from tests.utils import skip_if_windows
+from tests.utils import command_line_tests, skip_if_windows
@skip_if_windows
@@ -30,7 +29,7 @@ def test_bundle(self):
bundle_root = tempdir + "/test_bundle"
cmd = ["coverage", "run", "-m", "monai.bundle", "init_bundle", bundle_root, tempdir + "/test.pt"]
- subprocess.check_call(cmd)
+ command_line_tests(cmd)
self.assertTrue(os.path.exists(bundle_root + "/configs/metadata.json"))
self.assertTrue(os.path.exists(bundle_root + "/configs/inference.json"))
diff --git a/tests/test_bundle_utils.py b/tests/test_bundle_utils.py
index 46b29651cd..818caa92be 100644
--- a/tests/test_bundle_utils.py
+++ b/tests/test_bundle_utils.py
@@ -11,7 +11,6 @@
import os
import shutil
-import subprocess
import tempfile
import unittest
@@ -19,7 +18,7 @@
from monai.bundle.utils import load_bundle_config
from monai.networks.nets import UNet
-from tests.utils import skip_if_windows
+from tests.utils import command_line_tests, skip_if_windows
metadata = """
{
@@ -104,7 +103,7 @@ def test_load_config_ts(self):
cmd += ["--config_file", self.test_name]
cmd += ["--ckpt_file", self.modelpt_name]
- subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+ command_line_tests(cmd)
p = load_bundle_config(self.ts_file, "test.json")
diff --git a/tests/test_bundle_verify_metadata.py b/tests/test_bundle_verify_metadata.py
index c816c081eb..773cb40888 100644
--- a/tests/test_bundle_verify_metadata.py
+++ b/tests/test_bundle_verify_metadata.py
@@ -11,14 +11,13 @@
import json
import os
-import subprocess
import tempfile
import unittest
from parameterized import parameterized
-from monai.bundle import ConfigParser
-from tests.utils import download_url_or_skip_test, skip_if_windows, testing_data_config
+from monai.bundle import ConfigParser, verify_metadata
+from tests.utils import command_line_tests, download_url_or_skip_test, skip_if_windows, testing_data_config
SCHEMA_FILE = os.path.join(os.path.dirname(__file__), "testing_data", "schema.json")
@@ -45,7 +44,7 @@ def test_verify(self, meta_file, schema_file):
cmd = ["coverage", "run", "-m", "monai.bundle", "verify_metadata", "--meta_file", meta_file]
cmd += ["--filepath", schema_file, "--hash_val", self.config["hash_val"], "--args_file", def_args_file]
- subprocess.check_call(cmd)
+ command_line_tests(cmd)
def test_verify_error(self):
with tempfile.TemporaryDirectory() as tempdir:
@@ -55,8 +54,8 @@ def test_verify_error(self):
with open(metafile, "w") as f:
json.dump(meta_dict, f)
- cmd = ["coverage", "run", "-m", "monai.bundle", "verify_metadata", metafile, "--filepath", filepath]
- subprocess.check_call(cmd)
+ with self.assertRaises(ValueError):
+ verify_metadata(meta_file=metafile, filepath=filepath)
if __name__ == "__main__":
diff --git a/tests/test_bundle_verify_net.py b/tests/test_bundle_verify_net.py
index 05a0731b43..a372d129c7 100644
--- a/tests/test_bundle_verify_net.py
+++ b/tests/test_bundle_verify_net.py
@@ -10,14 +10,13 @@
# limitations under the License.
import os
-import subprocess
import tempfile
import unittest
from parameterized import parameterized
from monai.bundle import ConfigParser
-from tests.utils import skip_if_windows
+from tests.utils import command_line_tests, skip_if_windows
TEST_CASE_1 = [
os.path.join(os.path.dirname(__file__), "testing_data", "metadata.json"),
@@ -37,10 +36,7 @@ def test_verify(self, meta_file, config_file):
cmd = ["coverage", "run", "-m", "monai.bundle", "verify_net_in_out", "network_def", "--meta_file"]
cmd += [meta_file, "--config_file", config_file, "-n", "4", "--any", "16", "--args_file", def_args_file]
cmd += ["--_meta_#network_data_format#inputs#image#spatial_shape", "[16,'*','2**p*n']"]
-
- test_env = os.environ.copy()
- print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES"))
- subprocess.check_call(cmd, env=test_env)
+ command_line_tests(cmd)
if __name__ == "__main__":
diff --git a/tests/test_complex_utils.py b/tests/test_complex_utils.py
new file mode 100644
index 0000000000..8ba45c294c
--- /dev/null
+++ b/tests/test_complex_utils.py
@@ -0,0 +1,75 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.apps.reconstruction.complex_utils import complex_abs, complex_conj, complex_mul, convert_to_tensor_complex
+from monai.utils.type_conversion import convert_data_type
+from tests.utils import TEST_NDARRAYS, assert_allclose
+
+# test case for convert_to_tensor_complex
+im_complex = [[1.0 + 1.0j, 1.0 + 1.0j], [1.0 + 1.0j, 1.0 + 1.0j]]
+expected_shape = convert_data_type((2, 2, 2), torch.Tensor)[0]
+TESTS = [(im_complex, expected_shape)]
+for p in TEST_NDARRAYS:
+ TESTS.append((p(im_complex), expected_shape))
+
+# test case for complex_abs
+im = [[3.0, 4.0], [3.0, 4.0]]
+res = [5.0, 5.0]
+TESTSC = []
+for p in TEST_NDARRAYS:
+ TESTSC.append((p(im), p(res)))
+
+# test case for complex_mul
+x = [[1.0, 2.0], [3.0, 4.0]]
+y = [[1.0, 1.0], [1.0, 1.0]]
+res = [[-1.0, 3.0], [-1.0, 7.0]] # type: ignore
+TESTSM = []
+for p in TEST_NDARRAYS:
+ TESTSM.append((p(x), p(y), p(res)))
+
+# test case for complex_conj
+im = [[1.0, 2.0], [3.0, 4.0]]
+res = [[1.0, -2.0], [3.0, -4.0]] # type: ignore
+TESTSJ = []
+for p in TEST_NDARRAYS:
+ TESTSJ.append((p(im), p(res)))
+
+
+class TestMRIUtils(unittest.TestCase):
+ @parameterized.expand(TESTS)
+ def test_to_tensor_complex(self, test_data, expected_shape):
+ result = convert_to_tensor_complex(test_data)
+ self.assertTrue(isinstance(result, torch.Tensor))
+ self.assertTupleEqual(result.shape, expected_shape)
+
+ @parameterized.expand(TESTSC)
+ def test_complex_abs(self, test_data, res_data):
+ result = complex_abs(test_data)
+ assert_allclose(result, res_data, type_test=False)
+
+ @parameterized.expand(TESTSM)
+ def test_complex_mul(self, test_x, test_y, res_data):
+ result = complex_mul(test_x, test_y)
+ assert_allclose(result, res_data, type_test=False)
+
+ @parameterized.expand(TESTSJ)
+ def test_complex_conj(self, test_data, res_data):
+ result = complex_conj(test_data)
+ assert_allclose(result, res_data, type_test=False)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_compute_confusion_matrix.py b/tests/test_compute_confusion_matrix.py
index 0e38357d12..56d619a289 100644
--- a/tests/test_compute_confusion_matrix.py
+++ b/tests/test_compute_confusion_matrix.py
@@ -12,7 +12,6 @@
import unittest
from typing import Any, Dict, List
-import numpy as np
import torch
from parameterized import parameterized
@@ -22,20 +21,24 @@
do_metric_reduction,
get_confusion_matrix,
)
+from tests.utils import assert_allclose
+_device = "cuda:0" if torch.cuda.is_available() else "cpu"
# input data
data: Dict[Any, Any] = {
"y_pred": torch.tensor(
[
[[[0.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]]],
[[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 0.0]]],
- ]
+ ],
+ device=_device,
),
"y": torch.tensor(
[
[[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]]],
[[[0.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 0.0]]],
- ]
+ ],
+ device=_device,
),
}
@@ -141,12 +144,12 @@
"mk",
]
result: Any = None
-for idx in range(len(metric_names)):
+for idx, item in enumerate(metric_names):
for reduction in ["mean", "mean_batch"]:
TEST_CASE: List[Any] = [data.copy()]
TEST_CASE[0]["compute_sample"] = True
TEST_CASE[0]["include_background"] = True
- TEST_CASE[0]["metric_name"] = metric_names[idx]
+ TEST_CASE[0]["metric_name"] = item
TEST_CASE[0]["reduction"] = reduction
TEST_CASE[0]["get_not_nans"] = True
if reduction == "mean_batch":
@@ -211,10 +214,10 @@ def test_value(self, input_data, expected_value):
# include or ignore background
input_data["include_background"] = True
result = get_confusion_matrix(**input_data)
- np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
input_data["include_background"] = False
result = get_confusion_matrix(**input_data)
- np.testing.assert_allclose(result, expected_value[:, 1:, :], atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value[:, 1:, :], atol=1e-4, rtol=1e-4)
@parameterized.expand(TEST_CASES_COMPUTE_SAMPLE)
def test_compute_sample(self, input_data, expected_value):
@@ -225,7 +228,7 @@ def test_compute_sample(self, input_data, expected_value):
metric = ConfusionMatrixMetric(**params)
metric(**vals)
result, _ = metric.aggregate()[0]
- np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
@parameterized.expand(TEST_CASES_COMPUTE_SAMPLE_MULTI_METRICS)
def test_compute_sample_multiple_metrics(self, input_data, expected_values):
@@ -236,10 +239,10 @@ def test_compute_sample_multiple_metrics(self, input_data, expected_values):
metric = ConfusionMatrixMetric(**params)
metric(**vals)
results = metric.aggregate()
- for idx in range(len(results)):
- result = results[idx][0]
+ for idx, item in enumerate(results):
+ result = item[0]
expected_value = expected_values[idx]
- np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
@parameterized.expand(TEST_CASES_COMPUTE_SAMPLE_NAN)
def test_compute_sample_with_nan(self, input_data, expected_value, expected_not_nans):
@@ -250,8 +253,8 @@ def test_compute_sample_with_nan(self, input_data, expected_value, expected_not_
metric = ConfusionMatrixMetric(**params)
metric(**vals)
result, not_nans = metric.aggregate()[0]
- np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
- np.testing.assert_allclose(not_nans, expected_not_nans, atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
+ assert_allclose(not_nans, expected_not_nans, atol=1e-4, rtol=1e-4)
@parameterized.expand([TEST_CASES_CLF])
def test_clf_with_nan(self, input_data, expected_value):
@@ -261,11 +264,11 @@ def test_clf_with_nan(self, input_data, expected_value):
vals["y"] = params.pop("y")
metric = ConfusionMatrixMetric(**params)
result = metric(**vals)
- np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
result, _ = metric.aggregate(reduction="mean_channel")[0]
expected_value, _ = do_metric_reduction(expected_value, "mean_channel")
expected_value = compute_confusion_matrix_metric("tpr", expected_value)
- np.testing.assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
+ assert_allclose(result, expected_value, atol=1e-4, rtol=1e-4)
if __name__ == "__main__":
diff --git a/tests/test_compute_froc.py b/tests/test_compute_froc.py
index d68f3f7fb4..91c1ea1977 100644
--- a/tests/test_compute_froc.py
+++ b/tests/test_compute_froc.py
@@ -17,11 +17,12 @@
from monai.metrics import compute_fp_tp_probs, compute_froc_curve_data, compute_froc_score
+_device = "cuda:0" if torch.cuda.is_available() else "cpu"
TEST_CASE_1 = [
{
- "probs": torch.tensor([1, 0.6, 0.8]),
- "y_coord": torch.tensor([0, 2, 3]),
- "x_coord": torch.tensor([3, 0, 1]),
+ "probs": torch.tensor([1, 0.6, 0.8], device=_device),
+ "y_coord": torch.tensor([0, 2, 3], device=_device),
+ "x_coord": torch.tensor([3, 0, 1], device=_device),
"evaluation_mask": np.array([[0, 0, 1, 1], [2, 2, 0, 0], [0, 3, 3, 0], [0, 3, 3, 3]]),
"labels_to_exclude": [2],
"resolution_level": 0,
diff --git a/tests/test_compute_meandice.py b/tests/test_compute_meandice.py
index c925c6f148..478d749120 100644
--- a/tests/test_compute_meandice.py
+++ b/tests/test_compute_meandice.py
@@ -17,11 +17,12 @@
from monai.metrics import DiceMetric, compute_meandice
+_device = "cuda:0" if torch.cuda.is_available() else "cpu"
# keep background
TEST_CASE_1 = [ # y (1, 1, 2, 2), y_pred (1, 1, 2, 2), expected out (1, 1)
{
- "y_pred": torch.tensor([[[[1.0, 0.0], [0.0, 1.0]]]]),
- "y": torch.tensor([[[[1.0, 0.0], [1.0, 1.0]]]]),
+ "y_pred": torch.tensor([[[[1.0, 0.0], [0.0, 1.0]]]], device=_device),
+ "y": torch.tensor([[[[1.0, 0.0], [1.0, 1.0]]]], device=_device),
"include_background": True,
},
[[0.8]],
diff --git a/tests/test_compute_meaniou.py b/tests/test_compute_meaniou.py
new file mode 100644
index 0000000000..16fc695e02
--- /dev/null
+++ b/tests/test_compute_meaniou.py
@@ -0,0 +1,221 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+import torch
+from parameterized import parameterized
+
+from monai.metrics import MeanIoU, compute_meaniou
+
+_device = "cuda:0" if torch.cuda.is_available() else "cpu"
+# keep background
+TEST_CASE_1 = [ # y (1, 1, 2, 2), y_pred (1, 1, 2, 2), expected out (1, 1)
+ {
+ "y_pred": torch.tensor([[[[1.0, 0.0], [0.0, 1.0]]]], device=_device),
+ "y": torch.tensor([[[[1.0, 0.0], [1.0, 1.0]]]], device=_device),
+ "include_background": True,
+ },
+ [[0.6667]],
+]
+
+# remove background and not One-Hot target
+TEST_CASE_2 = [ # y (2, 3, 2, 2), y_pred (2, 3, 2, 2), expected out (2, 2) (no background)
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[0.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]]],
+ [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 0.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]]],
+ [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 0.0]]],
+ ]
+ ),
+ "include_background": False,
+ },
+ [[0.3333, 0.0000], [0.5000, 0.5000]],
+]
+
+# should return Nan for all labels=0 case and skip for MeanIoU
+TEST_CASE_3 = [
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]], [[1.0, 1.0], [1.0, 1.0]]],
+ [[[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]], [[1.0, 1.0], [1.0, 1.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ [[[0.0, 1.0], [1.0, 0.0]], [[1.0, 0.0], [0.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ ]
+ ),
+ "include_background": True,
+ },
+ [[False, True, True], [False, False, True]],
+]
+
+TEST_CASE_4 = [
+ {"include_background": True, "reduction": "mean_batch", "get_not_nans": True},
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]],
+ [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 0.0]]],
+ ]
+ ),
+ },
+ [0.5416, 0.2500, 0.5000],
+]
+
+TEST_CASE_5 = [
+ {"include_background": True, "reduction": "mean", "get_not_nans": True},
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]],
+ [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ [[[0.0, 0.0], [0.0, 1.0]], [[1.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 0.0]]],
+ ]
+ ),
+ },
+ 0.5555,
+]
+
+TEST_CASE_6 = [
+ {"include_background": True, "reduction": "sum_batch", "get_not_nans": True},
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]],
+ [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ ]
+ ),
+ },
+ [1.5000, 0.0000, 0.0000],
+]
+
+TEST_CASE_7 = [
+ {"include_background": True, "reduction": "mean", "get_not_nans": True},
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]],
+ [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ ]
+ ),
+ },
+ 0.7500,
+]
+
+TEST_CASE_8 = [
+ {"include_background": False, "reduction": "sum_batch", "get_not_nans": True},
+ {
+ "y_pred": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 0.0]], [[0.0, 1.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 1.0]]],
+ [[[1.0, 0.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 1.0]], [[0.0, 1.0], [1.0, 0.0]]],
+ ]
+ ),
+ "y": torch.tensor(
+ [
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ [[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]],
+ ]
+ ),
+ },
+ [0.0000, 0.0000],
+]
+
+TEST_CASE_9 = [
+ {"y": torch.ones((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3))},
+ [[1.0000, 1.0000], [1.0000, 1.0000]],
+]
+
+TEST_CASE_10 = [
+ {"y": [torch.ones((2, 3, 3)), torch.ones((2, 3, 3))], "y_pred": [torch.ones((2, 3, 3)), torch.ones((2, 3, 3))]},
+ [[1.0000, 1.0000], [1.0000, 1.0000]],
+]
+
+TEST_CASE_11 = [
+ {"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.zeros((2, 2, 3, 3)), "ignore_empty": False},
+ [[1.0000, 1.0000], [1.0000, 1.0000]],
+]
+
+TEST_CASE_12 = [
+ {"y": torch.zeros((2, 2, 3, 3)), "y_pred": torch.ones((2, 2, 3, 3)), "ignore_empty": False},
+ [[0.0000, 0.0000], [0.0000, 0.0000]],
+]
+
+
+class TestComputeMeanIoU(unittest.TestCase):
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_9, TEST_CASE_11, TEST_CASE_12])
+ def test_value(self, input_data, expected_value):
+ result = compute_meaniou(**input_data)
+ np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4)
+
+ @parameterized.expand([TEST_CASE_3])
+ def test_nans(self, input_data, expected_value):
+ result = compute_meaniou(**input_data)
+ self.assertTrue(np.allclose(np.isnan(result.cpu().numpy()), expected_value))
+
+ # MeanIoU class tests
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_10])
+ def test_value_class(self, input_data, expected_value):
+
+ # same test as for compute_meaniou
+ vals = {}
+ vals["y_pred"] = input_data.pop("y_pred")
+ vals["y"] = input_data.pop("y")
+ iou_metric = MeanIoU(**input_data)
+ iou_metric(**vals)
+ result = iou_metric.aggregate(reduction="none")
+ np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4)
+
+ @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7, TEST_CASE_8])
+ def test_nans_class(self, params, input_data, expected_value):
+
+ iou_metric = MeanIoU(**params)
+ iou_metric(**input_data)
+ result, _ = iou_metric.aggregate()
+ np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_compute_roc_auc.py b/tests/test_compute_roc_auc.py
index 2c9135024f..0e57f1fe4a 100644
--- a/tests/test_compute_roc_auc.py
+++ b/tests/test_compute_roc_auc.py
@@ -19,9 +19,10 @@
from monai.metrics import ROCAUCMetric, compute_roc_auc
from monai.transforms import Activations, AsDiscrete, Compose, ToTensor
+_device = "cuda:0" if torch.cuda.is_available() else "cpu"
TEST_CASE_1 = [
- torch.tensor([[0.1, 0.9], [0.3, 1.4], [0.2, 0.1], [0.1, 0.5]]),
- torch.tensor([[0], [1], [0], [1]]),
+ torch.tensor([[0.1, 0.9], [0.3, 1.4], [0.2, 0.1], [0.1, 0.5]], device=_device),
+ torch.tensor([[0], [1], [0], [1]], device=_device),
True,
2,
"macro",
diff --git a/tests/test_concat_itemsd.py b/tests/test_concat_itemsd.py
index 2f98738233..068abf81c1 100644
--- a/tests/test_concat_itemsd.py
+++ b/tests/test_concat_itemsd.py
@@ -14,6 +14,7 @@
import numpy as np
import torch
+from monai.data import MetaTensor
from monai.transforms import ConcatItemsd
@@ -30,6 +31,20 @@ def test_tensor_values(self):
torch.testing.assert_allclose(result["img1"], torch.tensor([[0, 1], [1, 2]], device=device))
torch.testing.assert_allclose(result["cat_img"], torch.tensor([[1, 2], [2, 3], [1, 2], [2, 3]], device=device))
+ def test_metatensor_values(self):
+ device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu:0")
+ input_data = {
+ "img1": MetaTensor([[0, 1], [1, 2]], device=device),
+ "img2": MetaTensor([[0, 1], [1, 2]], device=device),
+ }
+ result = ConcatItemsd(keys=["img1", "img2"], name="cat_img")(input_data)
+ self.assertTrue("cat_img" in result)
+ self.assertTrue(isinstance(result["cat_img"], MetaTensor))
+ self.assertEqual(result["img1"].meta, result["cat_img"].meta)
+ result["cat_img"] += 1
+ torch.testing.assert_allclose(result["img1"], torch.tensor([[0, 1], [1, 2]], device=device))
+ torch.testing.assert_allclose(result["cat_img"], torch.tensor([[1, 2], [2, 3], [1, 2], [2, 3]], device=device))
+
def test_numpy_values(self):
input_data = {"img1": np.array([[0, 1], [1, 2]]), "img2": np.array([[0, 1], [1, 2]])}
result = ConcatItemsd(keys=["img1", "img2"], name="cat_img")(input_data)
@@ -52,6 +67,13 @@ def test_single_tensor(self):
torch.testing.assert_allclose(result["img"], torch.tensor([[0, 1], [1, 2]]))
torch.testing.assert_allclose(result["cat_img"], torch.tensor([[1, 2], [2, 3]]))
+ def test_single_metatensor(self):
+ input_data = {"img": MetaTensor([[0, 1], [1, 2]])}
+ result = ConcatItemsd(keys="img", name="cat_img")(input_data)
+ result["cat_img"] += 1
+ torch.testing.assert_allclose(result["img"], torch.tensor([[0, 1], [1, 2]]))
+ torch.testing.assert_allclose(result["cat_img"], torch.tensor([[1, 2], [2, 3]]))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_config_item.py b/tests/test_config_item.py
index 4d9df0a870..817175e1e3 100644
--- a/tests/test_config_item.py
+++ b/tests/test_config_item.py
@@ -26,7 +26,7 @@
TEST_CASE_1 = [{"lr": 0.001}, 0.0001]
-TEST_CASE_2 = [{"_target_": "LoadImaged", "keys": ["image"]}, LoadImaged]
+TEST_CASE_2 = [{"_target_": "LoadImaged", "keys": ["image"], "_desc_": "an image reader for 'image'"}, LoadImaged]
# test full module path
TEST_CASE_3 = [{"_target_": "monai.transforms.LoadImaged", "keys": ["image"]}, LoadImaged]
# test `_disabled_`
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
index 81e96d4095..3fa581b6b0 100644
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -104,6 +104,8 @@ def test_config_content(self):
# test nested ids
parser["dataset#_target_"] = "Dataset"
self.assertEqual(parser["dataset#_target_"], "Dataset")
+ parser.update({"dataset#_target_1": "Dataset1"})
+ self.assertEqual(parser["dataset#_target_1"], "Dataset1")
# test int id
parser.set(["test1", "test2", "test3"])
parser[1] = "test4"
@@ -202,7 +204,7 @@ def test_contains(self):
empty_parser = ConfigParser({})
empty_parser.parse()
- parser = ConfigParser({"value": 1, "entry": "string content"})
+ parser = ConfigParser({"value": 1, "entry": "string content", "array": [1, 2]})
parser.parse()
with self.subTest("Testing empty parser"):
@@ -213,6 +215,7 @@ def test_contains(self):
self.assertFalse("value1" in parser)
self.assertTrue("entry" in parser)
self.assertFalse("entr" in parser)
+ self.assertFalse("array#2" in parser)
def test_lambda_reference(self):
configs = {
diff --git a/tests/test_dataloader.py b/tests/test_dataloader.py
index 79126b2dbb..88fd585a32 100644
--- a/tests/test_dataloader.py
+++ b/tests/test_dataloader.py
@@ -16,9 +16,10 @@
import torch
from parameterized import parameterized
-from monai.data import CacheDataset, DataLoader, Dataset
+from monai.data import CacheDataset, DataLoader, Dataset, ZipDataset
from monai.transforms import Compose, DataStatsd, Randomizable, SimulateDelayd
-from monai.utils import set_determinism
+from monai.utils import convert_to_numpy, set_determinism
+from tests.utils import assert_allclose
TEST_CASE_1 = [[{"image": np.asarray([1, 2, 3])}, {"image": np.asarray([4, 5])}]]
@@ -83,6 +84,15 @@ def test_randomize(self):
output.extend(batch.data.numpy().flatten().tolist())
self.assertListEqual(output, [594, 170, 524, 778, 370, 906, 292, 589, 762, 763, 156, 886, 42, 405, 221, 166])
+ def test_zipdataset(self):
+ dataset = ZipDataset([_RandomDataset(), ZipDataset([_RandomDataset(), _RandomDataset()])])
+ dataloader = DataLoader(dataset, batch_size=2, num_workers=2)
+ output = []
+ for _ in range(2):
+ for batch in dataloader:
+ output.extend([convert_to_numpy(batch, wrap_sequence=False)])
+ assert_allclose(np.stack(output).flatten()[:7], np.array([594, 170, 594, 170, 594, 170, 524]))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_dataset_summary.py b/tests/test_dataset_summary.py
index d0531b28a0..a5b5eee28f 100644
--- a/tests/test_dataset_summary.py
+++ b/tests/test_dataset_summary.py
@@ -20,10 +20,8 @@
from monai.data import Dataset, DatasetSummary, create_test_image_3d
from monai.transforms import LoadImaged
from monai.transforms.compose import Compose
-from monai.transforms.meta_utility.dictionary import FromMetaTensord
from monai.transforms.utility.dictionary import ToNumpyd
from monai.utils import set_determinism
-from monai.utils.enums import PostFix
def test_collate(batch):
@@ -56,7 +54,6 @@ def test_spacing_intensity(self):
t = Compose(
[
LoadImaged(keys=["image", "label"]),
- FromMetaTensord(keys=["image", "label"]),
ToNumpyd(keys=["image", "label", "image_meta_dict", "label_meta_dict"]),
]
)
@@ -65,7 +62,7 @@ def test_spacing_intensity(self):
# test **kwargs of `DatasetSummary` for `DataLoader`
calculator = DatasetSummary(dataset, num_workers=4, meta_key="image_meta_dict", collate_fn=test_collate)
- target_spacing = calculator.get_target_spacing()
+ target_spacing = calculator.get_target_spacing(spacing_key="pixdim")
self.assertEqual(target_spacing, (1.0, 1.0, 1.0))
calculator.calculate_statistics()
np.testing.assert_allclose(calculator.data_mean, 0.892599, rtol=1e-5, atol=1e-5)
@@ -93,10 +90,10 @@ def test_anisotropic_spacing(self):
{"image": image_name, "label": label_name} for image_name, label_name in zip(train_images, train_labels)
]
- t = Compose([LoadImaged(keys=["image", "label"]), FromMetaTensord(keys=["image", "label"])])
+ t = Compose([LoadImaged(keys=["image", "label"])])
dataset = Dataset(data=data_dicts, transform=t)
- calculator = DatasetSummary(dataset, num_workers=4, meta_key_postfix=PostFix.meta())
+ calculator = DatasetSummary(dataset, num_workers=4)
target_spacing = calculator.get_target_spacing(anisotropic_threshold=4.0, percentile=20.0)
np.testing.assert_allclose(target_spacing, (1.0, 1.0, 1.8))
diff --git a/tests/test_decollate.py b/tests/test_decollate.py
index 440f5e59e7..a634471be5 100644
--- a/tests/test_decollate.py
+++ b/tests/test_decollate.py
@@ -101,7 +101,8 @@ def check_match(self, in1, in2):
# Transform ids won't match for windows with multiprocessing, so don't check values
if k1 == TraceKeys.ID and sys.platform in ["darwin", "win32"]:
continue
- self.check_match(v1, v2)
+ if not (isinstance(k1, str) and k1.endswith("_transforms")):
+ self.check_match(v1, v2) # transform stack not necessarily match
elif isinstance(in1, (list, tuple)):
for l1, l2 in zip(in1, in2):
self.check_match(l1, l2)
diff --git a/tests/test_deepedit_interaction.py b/tests/test_deepedit_interaction.py
index 6bb723268f..2bcace59fd 100644
--- a/tests/test_deepedit_interaction.py
+++ b/tests/test_deepedit_interaction.py
@@ -23,7 +23,7 @@
FindDiscrepancyRegionsDeepEditd,
SplitPredsLabeld,
)
-from monai.data import Dataset
+from monai.data import DataLoader, Dataset
from monai.engines import SupervisedTrainer
from monai.engines.utils import IterationEvents
from monai.losses import DiceCELoss
@@ -62,7 +62,7 @@ def run_interaction(self, train):
]
)
dataset = Dataset(data, transform=pre_transforms)
- data_loader = torch.utils.data.DataLoader(dataset, batch_size=5)
+ data_loader = DataLoader(dataset, batch_size=5)
iteration_transforms = [
FindDiscrepancyRegionsDeepEditd(keys="label", pred="pred", discrepancy="discrepancy"),
diff --git a/tests/test_denseblock.py b/tests/test_denseblock.py
new file mode 100644
index 0000000000..dd0a103022
--- /dev/null
+++ b/tests/test_denseblock.py
@@ -0,0 +1,103 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch.nn as nn
+
+from monai.networks.blocks import ConvDenseBlock, DenseBlock
+from tests.utils import TorchImageTestCase2D, TorchImageTestCase3D
+
+
+class TestDenseBlock2D(TorchImageTestCase2D):
+ def test_block_empty(self):
+ block = DenseBlock([])
+ out = block(self.imt)
+ expected_shape = self.imt.shape
+ self.assertEqual(out.shape, expected_shape)
+
+ def test_block_conv(self):
+ conv1 = nn.Conv2d(self.input_channels, self.output_channels, 3, padding=1)
+ conv2 = nn.Conv2d(self.input_channels + self.output_channels, self.input_channels, 3, padding=1)
+ block = DenseBlock([conv1, conv2])
+ out = block(self.imt)
+ expected_shape = (1, self.output_channels + self.input_channels * 2, self.im_shape[0], self.im_shape[1])
+ self.assertEqual(out.shape, expected_shape)
+
+
+class TestDenseBlock3D(TorchImageTestCase3D):
+ def test_block_conv(self):
+ conv1 = nn.Conv3d(self.input_channels, self.output_channels, 3, padding=1)
+ conv2 = nn.Conv3d(self.input_channels + self.output_channels, self.input_channels, 3, padding=1)
+ block = DenseBlock([conv1, conv2])
+ out = block(self.imt)
+ expected_shape = (
+ 1,
+ self.output_channels + self.input_channels * 2,
+ self.im_shape[1],
+ self.im_shape[0],
+ self.im_shape[2],
+ )
+ self.assertEqual(out.shape, expected_shape)
+
+
+class TestConvDenseBlock2D(TorchImageTestCase2D):
+ def test_block_empty(self):
+ conv = ConvDenseBlock(spatial_dims=2, in_channels=self.input_channels, channels=[])
+ out = conv(self.imt)
+ expected_shape = self.imt.shape
+ self.assertEqual(out.shape, expected_shape)
+
+ def test_except(self):
+ with self.assertRaises(ValueError):
+ _ = ConvDenseBlock(spatial_dims=2, in_channels=self.input_channels, channels=[1, 2], dilations=[1, 2, 3])
+
+ def test_block1(self):
+ channels = [2, 4]
+ conv = ConvDenseBlock(spatial_dims=2, in_channels=self.input_channels, channels=channels)
+ out = conv(self.imt)
+ expected_shape = (1, self.input_channels + sum(channels), self.im_shape[0], self.im_shape[1])
+ self.assertEqual(out.shape, expected_shape)
+
+ def test_block2(self):
+ channels = [2, 4]
+ dilations = [1, 2]
+ conv = ConvDenseBlock(spatial_dims=2, in_channels=self.input_channels, channels=channels, dilations=dilations)
+ out = conv(self.imt)
+ expected_shape = (1, self.input_channels + sum(channels), self.im_shape[0], self.im_shape[1])
+ self.assertEqual(out.shape, expected_shape)
+
+
+class TestConvDenseBlock3D(TorchImageTestCase3D):
+ def test_block_empty(self):
+ conv = ConvDenseBlock(spatial_dims=3, in_channels=self.input_channels, channels=[])
+ out = conv(self.imt)
+ expected_shape = self.imt.shape
+ self.assertEqual(out.shape, expected_shape)
+
+ def test_block1(self):
+ channels = [2, 4]
+ conv = ConvDenseBlock(spatial_dims=3, in_channels=self.input_channels, channels=channels)
+ out = conv(self.imt)
+ expected_shape = (1, self.input_channels + sum(channels), self.im_shape[1], self.im_shape[0], self.im_shape[2])
+ self.assertEqual(out.shape, expected_shape)
+
+ def test_block2(self):
+ channels = [2, 4]
+ dilations = [1, 2]
+ conv = ConvDenseBlock(spatial_dims=3, in_channels=self.input_channels, channels=channels, dilations=dilations)
+ out = conv(self.imt)
+ expected_shape = (1, self.input_channels + sum(channels), self.im_shape[1], self.im_shape[0], self.im_shape[2])
+ self.assertEqual(out.shape, expected_shape)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py
index 545031321b..3d27994404 100644
--- a/tests/test_deprecated.py
+++ b/tests/test_deprecated.py
@@ -38,7 +38,7 @@ def test_warning_milestone(self):
def foo2():
pass
- self.assertWarns(DeprecationWarning, foo2)
+ self.assertWarns(FutureWarning, foo2)
def test_warning_last(self):
"""Test deprecated decorator with `since` and `removed` set, for the last version"""
@@ -72,7 +72,7 @@ def test_warning1(self):
def foo1():
pass
- self.assertWarns(DeprecationWarning, foo1)
+ self.assertWarns(FutureWarning, foo1)
def test_warning2(self):
"""Test deprecated decorator with `since` and `removed` set."""
@@ -81,7 +81,7 @@ def test_warning2(self):
def foo2():
pass
- self.assertWarns(DeprecationWarning, foo2)
+ self.assertWarns(FutureWarning, foo2)
def test_except1(self):
"""Test deprecated decorator raises exception with no versions set."""
@@ -108,7 +108,7 @@ def test_class_warning1(self):
class Foo1:
pass
- self.assertWarns(DeprecationWarning, Foo1)
+ self.assertWarns(FutureWarning, Foo1)
def test_class_warning2(self):
"""Test deprecated decorator with `since` and `removed` set."""
@@ -117,7 +117,7 @@ def test_class_warning2(self):
class Foo2:
pass
- self.assertWarns(DeprecationWarning, Foo2)
+ self.assertWarns(FutureWarning, Foo2)
def test_class_except1(self):
"""Test deprecated decorator raises exception with no versions set."""
@@ -145,7 +145,7 @@ class Foo5:
def meth1(self):
pass
- self.assertWarns(DeprecationWarning, lambda: Foo5().meth1())
+ self.assertWarns(FutureWarning, lambda: Foo5().meth1())
def test_meth_except1(self):
"""Test deprecated decorator with just `since` set."""
@@ -166,7 +166,7 @@ def afoo1(a, b=None):
afoo1(1) # ok when no b provided
- self.assertWarns(DeprecationWarning, lambda: afoo1(1, 2))
+ self.assertWarns(FutureWarning, lambda: afoo1(1, 2))
def test_arg_warn2(self):
"""Test deprecated_arg decorator with just `since` set."""
@@ -177,7 +177,7 @@ def afoo2(a, **kw):
afoo2(1) # ok when no b provided
- self.assertWarns(DeprecationWarning, lambda: afoo2(1, b=2))
+ self.assertWarns(FutureWarning, lambda: afoo2(1, b=2))
def test_arg_except1(self):
"""Test deprecated_arg decorator raises exception with no versions set."""
@@ -207,8 +207,8 @@ def afoo5(a, b=None, c=None):
afoo5(1) # ok when no b or c provided
- self.assertWarns(DeprecationWarning, lambda: afoo5(1, 2))
- self.assertWarns(DeprecationWarning, lambda: afoo5(1, 2, 3))
+ self.assertWarns(FutureWarning, lambda: afoo5(1, 2))
+ self.assertWarns(FutureWarning, lambda: afoo5(1, 2, 3))
def test_future(self):
"""Test deprecated decorator with `since` set to a future version."""
@@ -217,9 +217,9 @@ def test_future(self):
def future1():
pass
- with self.assertWarns(DeprecationWarning) as aw:
+ with self.assertWarns(FutureWarning) as aw:
future1()
- warnings.warn("fake warning", DeprecationWarning)
+ warnings.warn("fake warning", FutureWarning)
self.assertEqual(aw.warning.args[0], "fake warning")
diff --git a/tests/test_ensure_channel_first.py b/tests/test_ensure_channel_first.py
index 1cb5ac6dec..fca2f90139 100644
--- a/tests/test_ensure_channel_first.py
+++ b/tests/test_ensure_channel_first.py
@@ -38,14 +38,16 @@
TEST_CASE_7 = [{"reader": ITKReader(pixel_type=itk.UC)}, "tests/testing_data/CT_DICOM", None]
+itk.ProcessObject.SetGlobalWarningDisplay(False)
+
class TestEnsureChannelFirst(unittest.TestCase):
@parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6])
def test_load_nifti(self, input_param, filenames, original_channel_dim):
if original_channel_dim is None:
- test_image = np.random.rand(128, 128, 128)
+ test_image = np.random.rand(8, 8, 8)
elif original_channel_dim == -1:
- test_image = np.random.rand(128, 128, 128, 1)
+ test_image = np.random.rand(8, 8, 8, 1)
with tempfile.TemporaryDirectory() as tempdir:
for i, name in enumerate(filenames):
@@ -63,8 +65,8 @@ def test_itk_dicom_series_reader(self, input_param, filenames, _):
self.assertEqual(result.shape[0], 1)
def test_load_png(self):
- spatial_size = (256, 256, 3)
- test_image = np.random.randint(0, 256, size=spatial_size)
+ spatial_size = (6, 6, 3)
+ test_image = np.random.randint(0, 6, size=spatial_size)
with tempfile.TemporaryDirectory() as tempdir:
filename = os.path.join(tempdir, "test_image.png")
Image.fromarray(test_image.astype("uint8")).save(filename)
@@ -74,14 +76,26 @@ def test_load_png(self):
def test_check(self):
im = torch.zeros(1, 2, 3)
+ im_nodim = MetaTensor(im, meta={"original_channel_dim": None})
+
with self.assertRaises(ValueError): # not MetaTensor
- EnsureChannelFirst()(im)
+ EnsureChannelFirst(channel_dim=None)(im)
with self.assertRaises(ValueError): # no meta
- EnsureChannelFirst()(MetaTensor(im))
+ EnsureChannelFirst(channel_dim=None)(MetaTensor(im))
with self.assertRaises(ValueError): # no meta channel
- EnsureChannelFirst()(MetaTensor(im, meta={"original_channel_dim": None}))
- EnsureChannelFirst(strict_check=False)(im)
- EnsureChannelFirst(strict_check=False)(MetaTensor(im, meta={"original_channel_dim": None}))
+ EnsureChannelFirst(channel_dim=None)(im_nodim)
+
+ with self.assertWarns(Warning):
+ EnsureChannelFirst(strict_check=False, channel_dim=None)(im)
+
+ with self.assertWarns(Warning):
+ EnsureChannelFirst(strict_check=False, channel_dim=None)(im_nodim)
+
+ def test_default_channel_first(self):
+ im = torch.rand(4, 4)
+ result = EnsureChannelFirst(channel_dim="no_channel")(im)
+
+ self.assertEqual(result.shape, (1, 4, 4))
if __name__ == "__main__":
diff --git a/tests/test_ensure_channel_firstd.py b/tests/test_ensure_channel_firstd.py
index 8525939f59..44bb7e40f4 100644
--- a/tests/test_ensure_channel_firstd.py
+++ b/tests/test_ensure_channel_firstd.py
@@ -33,9 +33,9 @@ class TestEnsureChannelFirstd(unittest.TestCase):
@parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
def test_load_nifti(self, input_param, filenames, original_channel_dim):
if original_channel_dim is None:
- test_image = np.random.rand(128, 128, 128)
+ test_image = np.random.rand(8, 8, 8)
elif original_channel_dim == -1:
- test_image = np.random.rand(128, 128, 128, 1)
+ test_image = np.random.rand(8, 8, 8, 1)
with tempfile.TemporaryDirectory() as tempdir:
for i, name in enumerate(filenames):
@@ -46,7 +46,7 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim):
self.assertEqual(result["img"].shape[0], len(filenames))
def test_load_png(self):
- spatial_size = (256, 256, 3)
+ spatial_size = (6, 6, 3)
test_image = np.random.randint(0, 256, size=spatial_size)
with tempfile.TemporaryDirectory() as tempdir:
filename = os.path.join(tempdir, "test_image.png")
@@ -57,12 +57,24 @@ def test_load_png(self):
def test_exceptions(self):
im = torch.zeros((1, 2, 3))
+ im_nodim = MetaTensor(im, meta={"original_channel_dim": None})
+
with self.assertRaises(ValueError): # no meta
- EnsureChannelFirstd("img")({"img": im})
+ EnsureChannelFirstd("img", channel_dim=None)({"img": im})
with self.assertRaises(ValueError): # no meta channel
- EnsureChannelFirstd("img")({"img": MetaTensor(im, meta={"original_channel_dim": None})})
- EnsureChannelFirstd("img", strict_check=False)({"img": im})
- EnsureChannelFirstd("img", strict_check=False)({"img": MetaTensor(im, meta={"original_channel_dim": None})})
+ EnsureChannelFirstd("img", channel_dim=None)({"img": im_nodim})
+
+ with self.assertWarns(Warning):
+ EnsureChannelFirstd("img", strict_check=False, channel_dim=None)({"img": im})
+
+ with self.assertWarns(Warning):
+ EnsureChannelFirstd("img", strict_check=False, channel_dim=None)({"img": im_nodim})
+
+ def test_default_channel_first(self):
+ im = torch.rand(4, 4)
+ result = EnsureChannelFirstd("img", channel_dim="no_channel")({"img": im})
+
+ self.assertEqual(result["img"].shape, (1, 4, 4))
if __name__ == "__main__":
diff --git a/tests/test_fastmri_reader.py b/tests/test_fastmri_reader.py
new file mode 100644
index 0000000000..30393ffc56
--- /dev/null
+++ b/tests/test_fastmri_reader.py
@@ -0,0 +1,79 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+from parameterized import parameterized
+
+from monai.apps.reconstruction.fastmri_reader import FastMRIReader
+from tests.utils import assert_allclose
+
+TEST_CASE1 = [
+ {
+ "kspace": np.array([[1.0, 2.0]]),
+ "filename": "test1",
+ "reconstruction_rss": np.array([[1.0, 2.0]]),
+ "acquisition": "FS",
+ "max": 2.0,
+ "norm": 2.2,
+ "patient_id": 12,
+ },
+ np.array([[1.0, 2.0]]),
+ {
+ "filename": "test1",
+ "reconstruction_rss": np.array([[1.0, 2.0]]),
+ "acquisition": "FS",
+ "max": 2.0,
+ "norm": 2.2,
+ "patient_id": 12,
+ "mask": np.zeros([1, 2]),
+ },
+]
+
+TEST_CASE2 = [
+ {
+ "kspace": np.array([[1.0, 2.0], [3.0, 4.0]]),
+ "filename": "test2",
+ "reconstruction_rss": np.array([[1.0, 2.0], [3.0, 4.0]]),
+ "acquisition": "FS",
+ "max": 4.0,
+ "norm": 5.5,
+ "patient_id": 1234,
+ },
+ np.array([[1.0, 2.0], [3.0, 4.0]]),
+ {
+ "filename": "test2",
+ "reconstruction_rss": np.array([[1.0, 2.0], [3.0, 4.0]]),
+ "acquisition": "FS",
+ "max": 4.0,
+ "norm": 5.5,
+ "patient_id": 1234,
+ "mask": np.zeros([2, 2]),
+ },
+]
+
+
+class TestMRIUtils(unittest.TestCase):
+ @parameterized.expand([TEST_CASE1, TEST_CASE2])
+ def test_get_data(self, test_data, test_res, test_meta):
+ reader = FastMRIReader()
+ res, meta = reader.get_data(test_data)
+ assert_allclose(res, test_res)
+ for key in test_meta:
+ if isinstance(test_meta[key], np.ndarray):
+ assert_allclose(test_meta[key], meta[key])
+ else:
+ self.assertEqual(test_meta[key], meta[key])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_fl_exchange_object.py b/tests/test_fl_exchange_object.py
new file mode 100644
index 0000000000..7c8087da27
--- /dev/null
+++ b/tests/test_fl_exchange_object.py
@@ -0,0 +1,62 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.fl.utils.constants import WeightType
+from monai.fl.utils.exchange_object import ExchangeObject
+from monai.utils.module import optional_import
+from tests.utils import SkipIfNoModule
+
+models, has_torchvision = optional_import("torchvision.models")
+
+
+TEST_INIT_1 = [{"weights": None, "optim": None, "metrics": None, "weight_type": None, "statistics": None}, "{}"]
+TEST_INIT_2: list = []
+if has_torchvision:
+ network = models.resnet18()
+ TEST_INIT_2.append(
+ {
+ "weights": network.state_dict(),
+ "optim": torch.optim.Adam(lr=1, params=network.parameters()).state_dict(),
+ "metrics": {"accuracy": 1},
+ "weight_type": WeightType.WEIGHT_DIFF,
+ "statistics": {"some_stat": 1},
+ }
+ )
+ TEST_INIT_2.append("{'weights': 122, 'optim': 2, 'metrics': 1, 'weight_type': fl_weight_diff, 'statistics': 1}")
+
+TEST_FAILURE_METRICS = [{"weights": None, "optim": None, "metrics": 1, "weight_type": None, "statistics": None}]
+TEST_FAILURE_STATISTICS = [{"weights": None, "optim": None, "metrics": None, "weight_type": None, "statistics": 1}]
+TEST_FAILURE_WEIGHT_TYPE = [{"weights": None, "optim": None, "metrics": None, "weight_type": 1, "statistics": None}]
+
+
+@SkipIfNoModule("torchvision")
+@SkipIfNoModule("ignite")
+class TestFLExchangeObject(unittest.TestCase):
+ @parameterized.expand([TEST_INIT_1, TEST_INIT_2])
+ def test_init(self, input_params, expected_str):
+ eo = ExchangeObject(**input_params)
+ self.assertIsInstance(eo, ExchangeObject)
+ eo.summary()
+ self.assertEqual(repr(eo), expected_str)
+
+ @parameterized.expand([TEST_FAILURE_METRICS, TEST_FAILURE_STATISTICS, TEST_FAILURE_WEIGHT_TYPE])
+ def test_failures(self, input_params):
+ with self.assertRaises(ValueError):
+ ExchangeObject(**input_params)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_fl_monai_algo.py b/tests/test_fl_monai_algo.py
new file mode 100644
index 0000000000..c7bb7e0b0a
--- /dev/null
+++ b/tests/test_fl_monai_algo.py
@@ -0,0 +1,170 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import json
+import os
+import unittest
+
+from parameterized import parameterized
+
+from monai.bundle import ConfigParser
+from monai.fl.client.monai_algo import MonaiAlgo
+from monai.fl.utils.constants import ExtraItems
+from monai.fl.utils.exchange_object import ExchangeObject
+from tests.utils import SkipIfNoModule
+
+TEST_TRAIN_1 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": os.path.join("testing_data", "config_fl_train.json"),
+ "config_evaluate_filename": None,
+ "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"),
+ }
+]
+TEST_TRAIN_2 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": os.path.join("testing_data", "config_fl_train.json"),
+ "config_evaluate_filename": None,
+ "config_filters_filename": None,
+ }
+]
+
+TEST_EVALUATE_1 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": None,
+ "config_evaluate_filename": os.path.join("testing_data", "config_fl_evaluate.json"),
+ "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"),
+ }
+]
+TEST_EVALUATE_2 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": None,
+ "config_evaluate_filename": os.path.join("testing_data", "config_fl_evaluate.json"),
+ "config_filters_filename": None,
+ }
+]
+
+TEST_GET_WEIGHTS_1 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": os.path.join("testing_data", "config_fl_train.json"),
+ "config_evaluate_filename": None,
+ "send_weight_diff": False,
+ "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"),
+ }
+]
+TEST_GET_WEIGHTS_2 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": None,
+ "config_evaluate_filename": None,
+ "send_weight_diff": False,
+ "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"),
+ }
+]
+TEST_GET_WEIGHTS_3 = [
+ {
+ "bundle_root": os.path.join(os.path.dirname(__file__)),
+ "config_train_filename": os.path.join("testing_data", "config_fl_train.json"),
+ "config_evaluate_filename": None,
+ "send_weight_diff": True,
+ "config_filters_filename": os.path.join("testing_data", "config_fl_filters.json"),
+ }
+]
+
+
+@SkipIfNoModule("ignite")
+class TestFLMonaiAlgo(unittest.TestCase):
+ @parameterized.expand([TEST_TRAIN_1, TEST_TRAIN_2])
+ def test_train(self, input_params):
+ # get testing data dir and update train config
+ with open(os.path.join(input_params["bundle_root"], input_params["config_train_filename"])) as f:
+ config_train = json.load(f)
+
+ config_train["dataset_dir"] = os.path.join(os.path.dirname(__file__), "testing_data")
+
+ with open(os.path.join(input_params["bundle_root"], input_params["config_train_filename"]), "w") as f:
+ json.dump(config_train, f, indent=4)
+
+ # initialize algo
+ algo = MonaiAlgo(**input_params)
+ algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"})
+
+ # initialize model
+ parser = ConfigParser()
+ parser.read_config(os.path.join(input_params["bundle_root"], input_params["config_train_filename"]))
+ parser.parse()
+ network = parser.get_parsed_content("network")
+
+ data = ExchangeObject(weights=network.state_dict())
+
+ # test train
+ algo.train(data=data, extra={})
+
+ @parameterized.expand([TEST_EVALUATE_1, TEST_EVALUATE_2])
+ def test_evaluate(self, input_params):
+ # get testing data dir and update train config
+ with open(os.path.join(input_params["bundle_root"], input_params["config_evaluate_filename"])) as f:
+ config_evaluate = json.load(f)
+
+ config_evaluate["dataset_dir"] = os.path.join(os.path.dirname(__file__), "testing_data")
+
+ with open(os.path.join(input_params["bundle_root"], input_params["config_evaluate_filename"]), "w") as f:
+ json.dump(config_evaluate, f, indent=4)
+
+ # initialize algo
+ algo = MonaiAlgo(**input_params)
+ algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"})
+
+ # initialize model
+ parser = ConfigParser()
+ parser.read_config(os.path.join(input_params["bundle_root"], input_params["config_evaluate_filename"]))
+ parser.parse()
+ network = parser.get_parsed_content("network")
+
+ data = ExchangeObject(weights=network.state_dict())
+
+ # test evaluate
+ algo.evaluate(data=data, extra={})
+
+ @parameterized.expand([TEST_GET_WEIGHTS_1, TEST_GET_WEIGHTS_2, TEST_GET_WEIGHTS_3])
+ def test_get_weights(self, input_params):
+ # get testing data dir and update train config
+ if input_params["config_train_filename"]:
+ with open(os.path.join(input_params["bundle_root"], input_params["config_train_filename"])) as f:
+ config_train = json.load(f)
+
+ config_train["dataset_dir"] = os.path.join(os.path.dirname(__file__), "testing_data")
+
+ with open(os.path.join(input_params["bundle_root"], input_params["config_train_filename"]), "w") as f:
+ json.dump(config_train, f, indent=4)
+
+ # initialize algo
+ algo = MonaiAlgo(**input_params)
+ algo.initialize(extra={ExtraItems.CLIENT_NAME: "test_fl"})
+
+ # test train
+ if input_params["send_weight_diff"]: # should not work as test doesn't receive a global model
+ with self.assertRaises(ValueError):
+ weights = algo.get_weights(extra={})
+ else:
+ weights = algo.get_weights(extra={})
+ self.assertIsInstance(weights, ExchangeObject)
+
+ # TODO: test abort and finalize
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_flexible_unet.py b/tests/test_flexible_unet.py
new file mode 100644
index 0000000000..1d6746f4eb
--- /dev/null
+++ b/tests/test_flexible_unet.py
@@ -0,0 +1,242 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.networks import eval_mode
+from monai.networks.nets import EfficientNetBNFeatures, FlexibleUNet
+from monai.utils import optional_import
+from tests.utils import skip_if_downloading_fails, skip_if_quick
+
+torchvision, has_torchvision = optional_import("torchvision")
+PIL, has_pil = optional_import("PIL")
+
+
+def get_model_names():
+ return [f"efficientnet-b{d}" for d in range(8)]
+
+
+def make_shape_cases(
+ models,
+ spatial_dims,
+ batches,
+ pretrained,
+ in_channels=3,
+ num_classes=10,
+ input_shape=64,
+ norm=("batch", {"eps": 1e-3, "momentum": 0.01}),
+):
+ ret_tests = []
+ for spatial_dim in spatial_dims: # selected spatial_dims
+ for batch in batches: # check single batch as well as multiple batch input
+ for model in models: # selected models
+ for is_pretrained in pretrained: # pretrained or not pretrained
+ kwargs = {
+ "in_channels": in_channels,
+ "out_channels": num_classes,
+ "backbone": model,
+ "pretrained": is_pretrained,
+ "spatial_dims": spatial_dim,
+ "norm": norm,
+ }
+ ret_tests.append(
+ [
+ kwargs,
+ (batch, in_channels) + (input_shape,) * spatial_dim,
+ (batch, num_classes) + (input_shape,) * spatial_dim,
+ ]
+ )
+ return ret_tests
+
+
+# create list of selected models to speed up redundant tests
+# only test the models B0, B3
+SEL_MODELS = [get_model_names()[i] for i in [0, 3]]
+
+# pretrained=False cases
+# 2D and 3D models are expensive so use selected models
+CASES_2D = make_shape_cases(
+ models=SEL_MODELS,
+ spatial_dims=[2],
+ batches=[1, 4],
+ pretrained=[False],
+ in_channels=3,
+ num_classes=10,
+ norm="instance",
+)
+CASES_3D = make_shape_cases(
+ models=[SEL_MODELS[0]],
+ spatial_dims=[3],
+ batches=[1],
+ pretrained=[False],
+ in_channels=3,
+ num_classes=10,
+ norm="batch",
+)
+
+# varying num_classes and in_channels
+CASES_VARIATIONS = []
+
+# change num_classes test
+# 20 classes
+# 2D
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=3, num_classes=20
+ )
+)
+# 3D
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=3, num_classes=20
+ )
+)
+
+# change in_channels test
+# 1 channel
+# 2D
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=1, num_classes=10
+ )
+)
+# 8 channel
+# 2D
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=8, num_classes=10
+ )
+)
+# 3D
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=1, num_classes=10
+ )
+)
+
+# change input shape test
+# 96
+# 2D 96x96 input
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS,
+ spatial_dims=[2],
+ batches=[1],
+ pretrained=[False, True],
+ in_channels=3,
+ num_classes=10,
+ input_shape=96,
+ )
+)
+# 2D 64x64 input
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS,
+ spatial_dims=[2],
+ batches=[1],
+ pretrained=[False, True],
+ in_channels=3,
+ num_classes=10,
+ input_shape=64,
+ )
+)
+
+# 3D 32x32x32 input
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS,
+ spatial_dims=[2],
+ batches=[1],
+ pretrained=[False],
+ in_channels=3,
+ num_classes=10,
+ input_shape=32,
+ )
+)
+
+# 3D 64x64x64 input
+CASES_VARIATIONS.extend(
+ make_shape_cases(
+ models=SEL_MODELS,
+ spatial_dims=[2],
+ batches=[1],
+ pretrained=[False],
+ in_channels=3,
+ num_classes=10,
+ input_shape=64,
+ )
+)
+
+# pretrain weight verified
+CASES_PRETRAIN = [
+ (
+ {
+ "in_channels": 3,
+ "out_channels": 10,
+ "backbone": SEL_MODELS[0],
+ "pretrained": True,
+ "spatial_dims": 2,
+ "norm": ("batch", {"eps": 1e-3, "momentum": 0.01}),
+ },
+ {
+ "in_channels": 3,
+ "num_classes": 10,
+ "model_name": SEL_MODELS[0],
+ "pretrained": True,
+ "spatial_dims": 2,
+ "norm": ("batch", {"eps": 1e-3, "momentum": 0.01}),
+ },
+ ["_conv_stem.weight"],
+ )
+]
+
+
+@skip_if_quick
+class TestFLEXIBLEUNET(unittest.TestCase):
+ @parameterized.expand(CASES_2D + CASES_3D + CASES_VARIATIONS)
+ def test_shape(self, input_param, input_shape, expected_shape):
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+
+ with skip_if_downloading_fails():
+ net = FlexibleUNet(**input_param).to(device)
+
+ # run inference with random tensor
+ with eval_mode(net):
+ result = net(torch.randn(input_shape).to(device))
+
+ # check output shape
+ self.assertEqual(result.shape, expected_shape)
+
+ @parameterized.expand(CASES_PRETRAIN)
+ def test_pretrain(self, input_param, efficient_input_param, weight_list):
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+
+ with skip_if_downloading_fails():
+ net = FlexibleUNet(**input_param).to(device)
+
+ with skip_if_downloading_fails():
+ eff_net = EfficientNetBNFeatures(**efficient_input_param).to(device)
+
+ for weight_name in weight_list:
+ if weight_name in net.encoder.state_dict() and weight_name in eff_net.state_dict():
+ net_weight = net.encoder.state_dict()[weight_name]
+ download_weight = eff_net.state_dict()[weight_name]
+ weight_diff = torch.abs(net_weight - download_weight)
+ diff_sum = torch.sum(weight_diff)
+ # check if a weight in weight_list equals to the downloaded weight.
+ self.assertLess(abs(diff_sum.item() - 0), 1e-8)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_flipd.py b/tests/test_flipd.py
index c97674b83b..b0e656b83f 100644
--- a/tests/test_flipd.py
+++ b/tests/test_flipd.py
@@ -15,6 +15,7 @@
import torch
from parameterized import parameterized
+from monai import config
from monai.data.meta_obj import set_track_meta
from monai.data.meta_tensor import MetaTensor
from monai.transforms import Flipd
@@ -63,6 +64,12 @@ def test_torch(self, init_param, img: torch.Tensor, track_meta: bool, device):
with self.assertRaisesRegex(ValueError, "MetaTensor"):
xform.inverse(res)
+ @unittest.skipIf(not config.USE_META_DICT, "not using meta dict")
+ def test_meta_dict(self):
+ xform = Flipd("image", [0, 1])
+ res = xform({"image": torch.zeros(1, 3, 4)})
+ self.assertTrue(res["image"].applied_operations == res["image_transforms"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_handler_mean_iou.py b/tests/test_handler_mean_iou.py
new file mode 100644
index 0000000000..9330efca99
--- /dev/null
+++ b/tests/test_handler_mean_iou.py
@@ -0,0 +1,72 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from ignite.engine import Engine, Events
+from parameterized import parameterized
+
+from monai.handlers import MeanIoUHandler, from_engine
+
+TEST_CASE_1 = [{"include_background": True, "output_transform": from_engine(["pred", "label"])}, 0.75, (4, 2)]
+TEST_CASE_2 = [{"include_background": False, "output_transform": from_engine(["pred", "label"])}, 2 / 3, (4, 1)]
+TEST_CASE_3 = [
+ {"reduction": "mean_channel", "output_transform": from_engine(["pred", "label"])},
+ torch.Tensor([1.0, 0.0, 1.0, 1.0]),
+ (4, 2),
+]
+
+
+class TestHandlerMeanIoU(unittest.TestCase):
+ # TODO test multi node averaged iou
+
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
+ def test_compute(self, input_params, expected_avg, details_shape):
+ iou_metric = MeanIoUHandler(**input_params)
+ # set up engine
+
+ def _val_func(engine, batch):
+ pass
+
+ engine = Engine(_val_func)
+ iou_metric.attach(engine=engine, name="mean_iou")
+ # test input a list of channel-first tensor
+ y_pred = [torch.Tensor([[0], [1]]), torch.Tensor([[1], [0]])]
+ y = torch.Tensor([[[0], [1]], [[0], [1]]])
+ engine.state.output = {"pred": y_pred, "label": y}
+ engine.fire_event(Events.ITERATION_COMPLETED)
+
+ y_pred = [torch.Tensor([[0], [1]]), torch.Tensor([[1], [0]])]
+ y = torch.Tensor([[[0], [1]], [[1], [0]]])
+ engine.state.output = {"pred": y_pred, "label": y}
+ engine.fire_event(Events.ITERATION_COMPLETED)
+
+ engine.fire_event(Events.EPOCH_COMPLETED)
+ torch.testing.assert_allclose(engine.state.metrics["mean_iou"], expected_avg)
+ self.assertTupleEqual(tuple(engine.state.metric_details["mean_iou"].shape), details_shape)
+
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
+ def test_shape_mismatch(self, input_params, _expected_avg, _details_shape):
+ iou_metric = MeanIoUHandler(**input_params)
+ with self.assertRaises((AssertionError, ValueError)):
+ y_pred = torch.Tensor([[0, 1], [1, 0]])
+ y = torch.ones((3, 30))
+ iou_metric.update([y_pred, y])
+
+ with self.assertRaises((AssertionError, ValueError)):
+ y_pred = torch.Tensor([[0, 1], [1, 0]])
+ y = torch.ones((8, 30))
+ iou_metric.update([y_pred, y])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_handler_prob_map_producer.py b/tests/test_handler_prob_map_producer.py
index 399657ac60..dbb4f57b62 100644
--- a/tests/test_handler_prob_map_producer.py
+++ b/tests/test_handler_prob_map_producer.py
@@ -36,9 +36,9 @@ def __init__(self, name, size):
"image": name,
ProbMapKeys.COUNT.value: size,
ProbMapKeys.SIZE.value: np.array([size, size]),
- ProbMapKeys.LOCATION.value: np.array([i, i]),
+ ProbMapKeys.LOCATION.value: np.array([i, i + 1]),
}
- for i in range(size)
+ for i in range(size - 1)
]
)
self.image_data = [
@@ -94,7 +94,8 @@ def inference(enging, batch):
engine.run(data_loader)
prob_map = np.load(os.path.join(output_dir, name + ".npy"))
- self.assertListEqual(np.diag(prob_map).astype(int).tolist(), list(range(1, size + 1)))
+ self.assertListEqual(np.vstack(prob_map.nonzero()).T.tolist(), [[i, i + 1] for i in range(size - 1)])
+ self.assertListEqual(prob_map[prob_map.nonzero()].tolist(), [i + 1 for i in range(size - 1)])
if __name__ == "__main__":
diff --git a/tests/test_highresnet.py b/tests/test_highresnet.py
index 76c2203431..cb3a923f14 100644
--- a/tests/test_highresnet.py
+++ b/tests/test_highresnet.py
@@ -58,7 +58,7 @@ def test_script(self):
input_param, input_shape, expected_shape = TEST_CASE_1
net = HighResNet(**input_param)
test_data = torch.randn(input_shape)
- test_script_save(net, test_data)
+ test_script_save(net, test_data, rtol=1e-4, atol=1e-4)
if __name__ == "__main__":
diff --git a/tests/test_image_rw.py b/tests/test_image_rw.py
index f234b32c24..80b7304ea2 100644
--- a/tests/test_image_rw.py
+++ b/tests/test_image_rw.py
@@ -23,7 +23,7 @@
from monai.data.image_writer import ITKWriter, NibabelWriter, PILWriter, register_writer, resolve_writer
from monai.data.meta_tensor import MetaTensor
from monai.transforms import LoadImage, SaveImage, moveaxis
-from monai.utils import OptionalImportError
+from monai.utils import MetaKeys, OptionalImportError
from tests.utils import TEST_NDARRAYS, assert_allclose
@@ -49,6 +49,7 @@ def nifti_rw(self, test_data, reader, writer, dtype, resample=True):
"original_affine": np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]),
}
test_data = MetaTensor(p(test_data), meta=meta_dict)
+ self.assertEqual(test_data.meta[MetaKeys.SPACE], "RAS")
saver(test_data)
saved_path = os.path.join(self.test_dir, filepath + "_trans" + output_ext)
self.assertTrue(os.path.exists(saved_path))
diff --git a/tests/test_integration_bundle_run.py b/tests/test_integration_bundle_run.py
index 6f20c55fe2..1f6570eeeb 100644
--- a/tests/test_integration_bundle_run.py
+++ b/tests/test_integration_bundle_run.py
@@ -12,7 +12,6 @@
import json
import os
import shutil
-import subprocess
import sys
import tempfile
import unittest
@@ -23,6 +22,7 @@
from monai.bundle import ConfigParser
from monai.transforms import LoadImage
+from tests.utils import command_line_tests
TEST_CASE_1 = [os.path.join(os.path.dirname(__file__), "testing_data", "inference.json"), (128, 128, 128)]
@@ -56,7 +56,7 @@ def test_tiny(self):
f,
)
cmd = ["coverage", "run", "-m", "monai.bundle", "run", "training", "--config_file", config_file]
- subprocess.check_call(cmd)
+ command_line_tests(cmd)
@parameterized.expand([TEST_CASE_1, TEST_CASE_2])
def test_shape(self, config_file, expected_shape):
@@ -96,7 +96,7 @@ def test_shape(self, config_file, expected_shape):
la = ["coverage", "run"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file]
test_env = os.environ.copy()
print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES"))
- subprocess.check_call(la + ["--args_file", def_args_file], env=test_env)
+ command_line_tests(la + ["--args_file", def_args_file])
loader = LoadImage(image_only=True)
self.assertTupleEqual(loader(os.path.join(tempdir, "image", "image_seg.nii.gz")).shape, expected_shape)
@@ -104,7 +104,7 @@ def test_shape(self, config_file, expected_shape):
cmd = "-m fire monai.bundle.scripts run --runner_id evaluating"
cmd += f" --evaluator#amp False {override}"
la = ["coverage", "run"] + cmd.split(" ") + ["--meta_file", meta_file] + ["--config_file", config_file]
- subprocess.check_call(la, env=test_env)
+ command_line_tests(la)
self.assertTupleEqual(loader(os.path.join(tempdir, "image", "image_trans.nii.gz")).shape, expected_shape)
diff --git a/tests/test_integration_workflows.py b/tests/test_integration_workflows.py
index 0ef95d4005..342e70cc8e 100644
--- a/tests/test_integration_workflows.py
+++ b/tests/test_integration_workflows.py
@@ -52,7 +52,7 @@
)
from monai.utils import optional_import, set_determinism
from tests.testing_data.integration_answers import test_integration_value
-from tests.utils import DistTestCase, TimedCall, pytorch_after, skip_if_quick
+from tests.utils import DistTestCase, TimedCall, assert_allclose, pytorch_after, skip_if_quick
SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter")
@@ -205,7 +205,12 @@ def _model_completed(self, engine):
)
trainer.run()
- return evaluator.state.best_metric
+ # test train and validation stats
+ train_stats = trainer.get_stats("output")
+ assert_allclose(train_stats["output"][0]["loss"], trainer.state.output[0]["loss"])
+ val_stats = evaluator.get_stats("metrics")
+
+ return val_stats["best_validation_metric"]
def run_inference_test(root_dir, model_file, device="cuda:0", amp=False, num_workers=4):
diff --git a/tests/test_integration_workflows_gan.py b/tests/test_integration_workflows_gan.py
index ff53851ce0..7dd05848bb 100644
--- a/tests/test_integration_workflows_gan.py
+++ b/tests/test_integration_workflows_gan.py
@@ -22,11 +22,11 @@
import monai
from monai.data import create_test_image_2d
from monai.engines import GanTrainer
-from monai.engines.utils import GanKeys as Keys
from monai.handlers import CheckpointSaver, StatsHandler, TensorBoardStatsHandler
from monai.networks import normal_init
from monai.networks.nets import Discriminator, Generator
from monai.transforms import AsChannelFirstd, Compose, LoadImaged, RandFlipd, ScaleIntensityd
+from monai.utils import GanKeys as Keys
from monai.utils import set_determinism
from tests.utils import DistTestCase, TimedCall, skip_if_quick
diff --git a/tests/test_inverse.py b/tests/test_inverse.py
index 82902a09eb..192a8d345e 100644
--- a/tests/test_inverse.py
+++ b/tests/test_inverse.py
@@ -12,6 +12,7 @@
import random
import sys
import unittest
+from copy import deepcopy
from functools import partial
from typing import TYPE_CHECKING, List, Tuple
from unittest.case import skipUnless
@@ -63,6 +64,7 @@
Zoomd,
allow_missing_keys_mode,
convert_applied_interp_mode,
+ reset_ops_id,
)
from monai.utils import first, get_seed, optional_import, set_determinism
from tests.utils import make_nifti_image, make_rand_affine
@@ -497,10 +499,13 @@ def test_inverse_inferred_seg(self, extra_transform):
resizer = ResizeWithPadOrCrop(spatial_size=shape_before_extra_xform)
with resizer.trace_transform(False):
seg_metatensor = resizer(seg_metatensor)
+ no_ops_id_tensor = reset_ops_id(deepcopy(seg_metatensor))
with allow_missing_keys_mode(transforms):
inv_seg = transforms.inverse({"label": seg_metatensor})["label"]
+ inv_seg_1 = transforms.inverse({"label": no_ops_id_tensor})["label"]
self.assertEqual(inv_seg.shape[1:], test_data[0]["label"].shape)
+ self.assertEqual(inv_seg_1.shape[1:], test_data[0]["label"].shape)
# # Inverse of batch
# batch_inverter = BatchInverseTransform(transforms, loader, collate_fn=no_collation, detach=True)
diff --git a/tests/test_invert.py b/tests/test_invert.py
new file mode 100644
index 0000000000..b867a646fa
--- /dev/null
+++ b/tests/test_invert.py
@@ -0,0 +1,91 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+import unittest
+from copy import deepcopy
+
+import numpy as np
+import torch
+
+from monai.data import DataLoader, Dataset, MetaTensor, create_test_image_3d, decollate_batch
+from monai.transforms import (
+ CastToType,
+ Compose,
+ EnsureChannelFirst,
+ Invert,
+ LoadImage,
+ Orientation,
+ RandAffine,
+ RandAxisFlip,
+ RandFlip,
+ RandRotate,
+ RandRotate90,
+ RandZoom,
+ ResizeWithPadOrCrop,
+ Spacing,
+)
+from monai.utils import set_determinism
+from tests.utils import make_nifti_image
+
+
+class TestInvert(unittest.TestCase):
+ def test_invert(self):
+ set_determinism(seed=0)
+ im_fname = make_nifti_image(create_test_image_3d(101, 100, 107, noise_max=100)[1]) # label image, discrete
+ data = [im_fname for _ in range(12)]
+ transform = Compose(
+ [
+ LoadImage(image_only=True),
+ EnsureChannelFirst(),
+ Orientation("RPS"),
+ Spacing(pixdim=(1.2, 1.01, 0.9), mode="bilinear", dtype=np.float32),
+ RandFlip(prob=0.5, spatial_axis=[1, 2]),
+ RandAxisFlip(prob=0.5),
+ RandRotate90(prob=0, spatial_axes=(1, 2)),
+ RandZoom(prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True),
+ RandRotate(prob=0.5, range_x=np.pi, mode="bilinear", align_corners=True, dtype=np.float64),
+ RandAffine(prob=0.5, rotate_range=np.pi, mode="nearest"),
+ ResizeWithPadOrCrop(100),
+ CastToType(dtype=torch.uint8),
+ ]
+ )
+
+ # num workers = 0 for mac or gpu transforms
+ num_workers = 0 if sys.platform != "linux" or torch.cuda.is_available() else 2
+ dataset = Dataset(data, transform=transform)
+ self.assertIsInstance(transform.inverse(dataset[0]), MetaTensor)
+ loader = DataLoader(dataset, num_workers=num_workers, batch_size=1)
+ inverter = Invert(transform=transform, nearest_interp=True, device="cpu")
+
+ for d in loader:
+ d = decollate_batch(d)
+ for item in d:
+ orig = deepcopy(item)
+ i = inverter(item)
+ self.assertTupleEqual(orig.shape[1:], (100, 100, 100))
+ # check the nearest interpolation mode
+ torch.testing.assert_allclose(i.to(torch.uint8).to(torch.float), i.to(torch.float))
+ self.assertTupleEqual(i.shape[1:], (100, 101, 107))
+ # check labels match
+ reverted = i.detach().cpu().numpy().astype(np.int32)
+ original = LoadImage(image_only=True)(data[-1])
+ n_good = np.sum(np.isclose(reverted, original.numpy(), atol=1e-3))
+ reverted_name = i.meta["filename_or_obj"]
+ original_name = original.meta["filename_or_obj"]
+ self.assertEqual(reverted_name, original_name)
+ print("invert diff", reverted.size - n_good)
+ self.assertTrue((reverted.size - n_good) < 300000, f"diff. {reverted.size - n_good}")
+ set_determinism(seed=None)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_k_space_spike_noised.py b/tests/test_k_space_spike_noised.py
index 5a0aed7d67..7a6403655c 100644
--- a/tests/test_k_space_spike_noised.py
+++ b/tests/test_k_space_spike_noised.py
@@ -20,7 +20,7 @@
from monai.data.synthetic import create_test_image_2d, create_test_image_3d
from monai.transforms import KSpaceSpikeNoised
from monai.utils.misc import set_determinism
-from tests.utils import TEST_NDARRAYS
+from tests.utils import TEST_NDARRAYS, assert_allclose
TESTS = []
for shape in ((128, 64), (64, 48, 80)):
@@ -91,7 +91,7 @@ def test_dict_matches(self, im_shape, im_type):
t = KSpaceSpikeNoised(KEYS, loc, k_intensity)
out = t(deepcopy(data))
- np.testing.assert_allclose(out[KEYS[0]], out[KEYS[1]])
+ assert_allclose(out[KEYS[0]], out[KEYS[1]], type_test=False)
if __name__ == "__main__":
diff --git a/tests/test_kspace_mask.py b/tests/test_kspace_mask.py
new file mode 100644
index 0000000000..42b90d1675
--- /dev/null
+++ b/tests/test_kspace_mask.py
@@ -0,0 +1,47 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+import torch
+from parameterized import parameterized
+
+from monai.apps.reconstruction.transforms.array import EquispacedKspaceMask, RandomKspaceMask
+from monai.utils.type_conversion import convert_data_type
+
+# test case for apply_mask
+ksp, *_ = convert_data_type(np.ones([50, 50, 2]), torch.Tensor)
+TESTSM = [(ksp,)]
+
+
+class TestMRIUtils(unittest.TestCase):
+ @parameterized.expand(TESTSM)
+ def test_mask(self, test_data):
+ # random mask
+ masker = RandomKspaceMask(center_fractions=[0.08], accelerations=[4.0], spatial_dims=1, is_complex=True)
+ masker.set_random_state(seed=0)
+ result, _ = masker(test_data)
+ mask = masker.mask
+ result = result[..., mask.squeeze() == 0, :].sum()
+ self.assertEqual(result.item(), 0)
+
+ # equispaced mask
+ masker = EquispacedKspaceMask(center_fractions=[0.08], accelerations=[4.0], spatial_dims=1, is_complex=True)
+ masker.set_random_state(seed=0)
+ result, _ = masker(test_data)
+ mask = masker.mask
+ result = result[..., mask.squeeze() == 0, :].sum()
+ self.assertEqual(result.item(), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_list_data_collate.py b/tests/test_list_data_collate.py
index 93b06cc187..7b6417820c 100644
--- a/tests/test_list_data_collate.py
+++ b/tests/test_list_data_collate.py
@@ -15,18 +15,18 @@
import torch
from parameterized import parameterized
-from monai.data import list_data_collate
+from monai.data import MetaTensor, list_data_collate
-a = {"image": np.array([1, 2, 3]), "label": np.array([4, 5, 6])}
-b = {"image": np.array([7, 8, 9]), "label": np.array([10, 11, 12])}
-c = {"image": np.array([13, 14, 15]), "label": np.array([16, 7, 18])}
-d = {"image": np.array([19, 20, 21]), "label": np.array([22, 23, 24])}
+a = {"image": np.array([1, 2, 3]), "label": MetaTensor([4, 5, 6])}
+b = {"image": np.array([7, 8, 9]), "label": MetaTensor([10, 11, 12])}
+c = {"image": np.array([13, 14, 15]), "label": MetaTensor([16, 7, 18])}
+d = {"image": np.array([19, 20, 21]), "label": MetaTensor([22, 23, 24])}
TEST_CASE_1 = [[[a, b], [c, d]], dict, torch.Size([4, 3])] # dataset returns a list of dictionary data
-e = (np.array([1, 2, 3]), np.array([4, 5, 6]))
-f = (np.array([7, 8, 9]), np.array([10, 11, 12]))
-g = (np.array([13, 14, 15]), np.array([16, 7, 18]))
-h = (np.array([19, 20, 21]), np.array([22, 23, 24]))
+e = (np.array([1, 2, 3]), MetaTensor([4, 5, 6]))
+f = (np.array([7, 8, 9]), MetaTensor([10, 11, 12]))
+g = (np.array([13, 14, 15]), MetaTensor([16, 7, 18]))
+h = (np.array([19, 20, 21]), MetaTensor([22, 23, 24]))
TEST_CASE_2 = [[[e, f], [g, h]], list, torch.Size([4, 3])] # dataset returns a list of tuple data
@@ -36,10 +36,15 @@ def test_type_shape(self, input_data, expected_type, expected_shape):
result = list_data_collate(input_data)
self.assertIsInstance(result, expected_type)
if isinstance(result, dict):
- data = result["image"]
+ image = result["image"]
+ label = result["label"]
else:
- data = result[0]
- self.assertEqual(data.shape, expected_shape)
+ image = result[0]
+ label = result[1]
+ self.assertEqual(image.shape, expected_shape)
+ self.assertEqual(label.shape, expected_shape)
+ self.assertTrue(isinstance(label, MetaTensor))
+ self.assertTrue(label.is_batch, True)
if __name__ == "__main__":
diff --git a/tests/test_load_image.py b/tests/test_load_image.py
index aad82df1db..cc227021a2 100644
--- a/tests/test_load_image.py
+++ b/tests/test_load_image.py
@@ -161,6 +161,7 @@ def test_nibabel_reader(self, input_param, filenames, expected_shape):
result = LoadImage(image_only=True, **input_param)(filenames)
ext = "".join(Path(name).suffixes)
self.assertEqual(result.meta["filename_or_obj"], os.path.join(tempdir, "test_image" + ext))
+ self.assertEqual(result.meta["space"], "RAS")
assert_allclose(result.affine, torch.eye(4))
self.assertTupleEqual(result.shape, expected_shape)
@@ -331,12 +332,13 @@ def tearDownClass(cls):
@parameterized.expand(TESTS_META)
def test_correct(self, input_param, expected_shape, track_meta):
set_track_meta(track_meta)
- r = LoadImage(image_only=True, **input_param)(self.test_data)
+ r = LoadImage(image_only=True, prune_meta_pattern="glmax", prune_meta_sep="%", **input_param)(self.test_data)
self.assertTupleEqual(r.shape, expected_shape)
if track_meta:
self.assertIsInstance(r, MetaTensor)
self.assertTrue(hasattr(r, "affine"))
self.assertIsInstance(r.affine, torch.Tensor)
+ self.assertTrue("glmax" not in r.meta)
else:
self.assertIsInstance(r, torch.Tensor)
self.assertNotIsInstance(r, MetaTensor)
diff --git a/tests/test_load_imaged.py b/tests/test_load_imaged.py
index 1f2ce31ad2..cd8b476a58 100644
--- a/tests/test_load_imaged.py
+++ b/tests/test_load_imaged.py
@@ -164,9 +164,11 @@ def tearDownClass(cls):
super(__class__, cls).tearDownClass()
@parameterized.expand(TESTS_META)
- def test_correct(self, input_param, expected_shape, track_meta):
+ def test_correct(self, input_p, expected_shape, track_meta):
set_track_meta(track_meta)
- result = LoadImaged(image_only=True, **input_param)(self.test_data)
+ result = LoadImaged(image_only=True, prune_meta_pattern=".*_code$", prune_meta_sep=" ", **input_p)(
+ self.test_data
+ )
# shouldn't have any extra meta data keys
self.assertEqual(len(result), len(KEYS))
@@ -177,6 +179,8 @@ def test_correct(self, input_param, expected_shape, track_meta):
self.assertIsInstance(r, MetaTensor)
self.assertTrue(hasattr(r, "affine"))
self.assertIsInstance(r.affine, torch.Tensor)
+ self.assertEqual(r.meta["space"], "RAS")
+ self.assertTrue("qform_code" not in r.meta)
else:
self.assertIsInstance(r, torch.Tensor)
self.assertNotIsInstance(r, MetaTensor)
diff --git a/tests/test_lr_finder.py b/tests/test_lr_finder.py
index a76808be20..aed7976feb 100644
--- a/tests/test_lr_finder.py
+++ b/tests/test_lr_finder.py
@@ -24,6 +24,7 @@
from monai.optimizers import LearningRateFinder
from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd, ToTensord
from monai.utils import optional_import, set_determinism
+from monai.utils.misc import MONAIEnvVars
from tests.utils import skip_if_downloading_fails
if TYPE_CHECKING:
@@ -47,7 +48,7 @@
class TestLRFinder(unittest.TestCase):
def setUp(self):
- self.root_dir = os.environ.get("MONAI_DATA_DIRECTORY")
+ self.root_dir = MONAIEnvVars.data_dir()
if not self.root_dir:
self.root_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data")
@@ -69,9 +70,9 @@ def test_lr_finder(self):
section="validation",
val_frac=0.001,
download=True,
- num_workers=10,
+ num_workers=2,
)
- train_loader = DataLoader(train_ds, batch_size=300, shuffle=True, num_workers=10)
+ train_loader = DataLoader(train_ds, batch_size=300, shuffle=True, num_workers=2)
num_classes = train_ds.get_num_classes()
model = DenseNet(
diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py
index c29b95a2d8..85683ea88c 100644
--- a/tests/test_masked_inference_wsi_dataset.py
+++ b/tests/test_masked_inference_wsi_dataset.py
@@ -179,11 +179,11 @@ def test_read_patches_openslide(self, input_parameters, expected):
self.compare_samples_expected(dataset, expected)
def compare_samples_expected(self, dataset, expected):
- for i in range(len(dataset)):
- self.assertTupleEqual(dataset[i][0]["image"].shape, expected[i]["image"].shape)
- self.assertIsNone(assert_array_equal(dataset[i][0]["image"], expected[i]["image"]))
- self.assertEqual(dataset[i][0]["name"], expected[i]["name"])
- self.assertListEqual(dataset[i][0]["mask_location"], expected[i]["mask_location"])
+ for i, item in enumerate(dataset):
+ self.assertTupleEqual(item[0]["image"].shape, expected[i]["image"].shape)
+ self.assertIsNone(assert_array_equal(item[0]["image"], expected[i]["image"]))
+ self.assertEqual(item[0]["name"], expected[i]["name"])
+ self.assertListEqual(item[0]["mask_location"], expected[i]["mask_location"])
if __name__ == "__main__":
diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py
index a79bb5533b..797a39ee09 100644
--- a/tests/test_masked_patch_wsi_dataset.py
+++ b/tests/test_masked_patch_wsi_dataset.py
@@ -16,8 +16,9 @@
from numpy.testing import assert_array_equal
from parameterized import parameterized
-from monai.data import MaskedPatchWSIDataset
-from monai.utils import WSIPatchKeys, optional_import, set_determinism
+from monai.data import Dataset, MaskedPatchWSIDataset
+from monai.transforms import Lambdad
+from monai.utils import ProbMapKeys, WSIPatchKeys, optional_import, set_determinism
from tests.utils import download_url_or_skip_test, testing_data_config
set_determinism(0)
@@ -47,6 +48,23 @@
},
]
+TEST_CASE_1 = [
+ {
+ "data": Dataset([{"image": FILE_PATH}], transform=Lambdad(keys="image", func=lambda x: x[:])),
+ "mask_level": 8,
+ "patch_level": 8,
+ "patch_size": (2, 2),
+ },
+ {
+ "num_patches": 4256,
+ "wsi_size": [32914, 46000],
+ "mask_level": 8,
+ "patch_level": 8,
+ "mask_size": (128, 179),
+ "patch_size": (2, 2),
+ },
+]
+
@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!")
def setUpModule():
@@ -59,10 +77,15 @@ class MaskedPatchWSIDatasetTests:
class Tests(unittest.TestCase):
backend = None
- @parameterized.expand([TEST_CASE_0])
+ @parameterized.expand([TEST_CASE_0, TEST_CASE_1])
def test_gen_patches(self, input_parameters, expected):
dataset = MaskedPatchWSIDataset(reader=self.backend, **input_parameters)
self.assertEqual(len(dataset), expected["num_patches"])
+ self.assertTrue(isinstance(dataset.image_data, list))
+ for d1, d2 in zip(dataset.image_data, input_parameters["data"]):
+ self.assertTrue(d1["image"] == d2["image"])
+ self.assertTrue(d1[ProbMapKeys.NAME] == os.path.basename(d2["image"]))
+
for i, sample in enumerate(dataset):
self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected["patch_level"])
assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected["patch_size"])
diff --git a/tests/test_meta_tensor.py b/tests/test_meta_tensor.py
index c1b8bc5046..2f873c2d73 100644
--- a/tests/test_meta_tensor.py
+++ b/tests/test_meta_tensor.py
@@ -25,6 +25,7 @@
import torch.multiprocessing
from parameterized import parameterized
+from monai import config
from monai.data import DataLoader, Dataset
from monai.data.meta_obj import get_track_meta, set_track_meta
from monai.data.meta_tensor import MetaTensor
@@ -106,9 +107,7 @@ def check(
# check meta and affine are equal and affine is on correct device
if isinstance(orig, MetaTensor) and isinstance(out, MetaTensor) and meta:
self.check_meta(orig, out)
- self.assertTrue(str(device) in str(out.affine.device))
if check_ids:
- self.check_ids(out.affine, orig.affine, ids)
self.check_ids(out.meta, orig.meta, ids)
@parameterized.expand(TESTS)
@@ -165,7 +164,7 @@ def test_to_cuda(self, device, dtype):
def test_affine_device(self):
m, _ = self.get_im() # device="cuda")
m.affine = torch.eye(4)
- self.assertEqual(m.device, m.affine.device)
+ self.assertTrue("cpu" in str(m.affine.device))
@parameterized.expand(TESTS)
def test_copy(self, device, dtype):
@@ -179,6 +178,8 @@ def test_copy(self, device, dtype):
# clone
a = m.clone()
self.check(a, m, ids=False)
+ a = MetaTensor([[]], device=device, dtype=dtype)
+ self.check(a, deepcopy(a), ids=False)
@parameterized.expand(TESTS)
def test_add(self, device, dtype):
@@ -363,6 +364,7 @@ def test_indexing(self):
# `is_batch==False`, should have first metadata and subset of first image.
d = data[0, 0]
self.check(d, ims[0][0], ids=False)
+ self.assertEqual(d.applied_operations, ims[0][0].applied_operations)
# `is_batch==True`, should have all metadata and subset of all images.
d = data[:, 0]
@@ -387,6 +389,7 @@ def test_indexing(self):
self.assertEqual(len(d), len(ims))
for _d, _im in zip(d, ims):
self.check(_d, _im, ids=False)
+ self.assertEqual(_d.applied_operations, _im.applied_operations)
# `is_batch==True`, tuple split along non-batch dim. Should have all metadata.
d = data.unbind(-1)
@@ -419,20 +422,7 @@ def test_decollate(self, dtype):
def test_str(self):
t = MetaTensor([1.0], affine=torch.tensor(1), meta={"fname": "filename"})
- s1 = str(t)
- s2 = t.__repr__()
- expected_out = (
- "tensor([1.])\n"
- + "MetaData\n"
- + "\tfname: filename\n"
- + "\taffine: 1\n"
- + "\n"
- + "Applied operations\n"
- + "[]\n"
- + "Is batch?: False"
- )
- for s in (s1, s2):
- self.assertEqual(s, expected_out)
+ self.assertEqual(str(t), "tensor([1.])")
def test_astype(self):
t = MetaTensor([1.0], affine=torch.tensor(1), meta={"fname": "filename"})
@@ -454,7 +444,7 @@ def test_transforms(self):
data = _tr(data)
is_meta = isinstance(_tr, (ToMetaTensord, BorderPadd, DivisiblePadd))
if is_meta:
- self.assertEqual(len(data), 1) # im
+ self.assertEqual(len(data), 1 if not config.USE_META_DICT else 2) # im, im_transforms, compatibility
self.assertIsInstance(data[key], MetaTensor)
n_applied = len(data[key].applied_operations)
else:
@@ -510,6 +500,7 @@ def test_multiprocessing(self, device=None, dtype=None):
"""multiprocessing sharing with 'device' and 'dtype'"""
buf = io.BytesIO()
t = MetaTensor([0.0, 0.0], device=device, dtype=dtype)
+ t.is_batch = True
if t.is_cuda:
with self.assertRaises(NotImplementedError):
ForkingPickler(buf).dump(t)
@@ -518,6 +509,40 @@ def test_multiprocessing(self, device=None, dtype=None):
obj = ForkingPickler.loads(buf.getvalue())
self.assertIsInstance(obj, MetaTensor)
assert_allclose(obj.as_tensor(), t)
+ assert_allclose(obj.is_batch, True)
+
+ @parameterized.expand(TESTS)
+ def test_array_function(self, device="cpu", dtype=float):
+ a = np.random.RandomState().randn(100, 100)
+ b = MetaTensor(a, device=device)
+ assert_allclose(np.sum(a), np.sum(b))
+ assert_allclose(np.sum(a, axis=1), np.sum(b, axis=1))
+ assert_allclose(np.linalg.qr(a), np.linalg.qr(b))
+ c = MetaTensor([1.0, 2.0, 3.0], device=device, dtype=dtype)
+ assert_allclose(np.argwhere(c == 1.0).astype(int).tolist(), [[0]])
+ assert_allclose(np.concatenate([c, c]), np.asarray([1.0, 2.0, 3.0, 1.0, 2.0, 3.0]))
+ if pytorch_after(1, 8, 1):
+ assert_allclose(c > np.asarray([1.0, 1.0, 1.0]), np.asarray([False, True, True]))
+ assert_allclose(
+ c > torch.as_tensor([1.0, 1.0, 1.0], device=device), torch.as_tensor([False, True, True], device=device)
+ )
+
+ @parameterized.expand(TESTS)
+ def test_numpy(self, device=None, dtype=None):
+ """device, dtype"""
+ t = MetaTensor([0.0], device=device, dtype=dtype)
+ self.assertIsInstance(t, MetaTensor)
+ assert_allclose(t.array, np.asarray([0.0]))
+ t.array = np.asarray([1.0])
+ self.check_meta(t, MetaTensor([1.0]))
+ assert_allclose(t.as_tensor(), torch.as_tensor([1.0]))
+ t.array = [2.0]
+ self.check_meta(t, MetaTensor([2.0]))
+ assert_allclose(t.as_tensor(), torch.as_tensor([2.0]))
+ if not t.is_cuda:
+ t.array[0] = torch.as_tensor(3.0, device=device, dtype=dtype)
+ self.check_meta(t, MetaTensor([3.0]))
+ assert_allclose(t.as_tensor(), torch.as_tensor([3.0]))
if __name__ == "__main__":
diff --git a/tests/test_metatensor_integration.py b/tests/test_metatensor_integration.py
index d6908815ee..6e8d5f40a3 100644
--- a/tests/test_metatensor_integration.py
+++ b/tests/test_metatensor_integration.py
@@ -12,14 +12,16 @@
import os
import tempfile
import unittest
+from copy import deepcopy
import numpy as np
from parameterized import parameterized
+from monai import config as monai_config
from monai.bundle import ConfigParser
from monai.data import CacheDataset, DataLoader, MetaTensor, decollate_batch
from monai.data.utils import TraceKeys
-from monai.transforms import InvertD, SaveImageD
+from monai.transforms import InvertD, SaveImageD, reset_ops_id
from monai.utils import optional_import, set_determinism
from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config
@@ -54,7 +56,7 @@ def test_transforms(self, case_id):
config = ConfigParser()
config.read_config(TEST_CASES)
config["input_keys"] = keys
- test_case = config.get_parsed_content(id=case_id, instantiate=True) # transform instance
+ test_case = config.get_parsed_content(id=case_id, instantiate=True, lazy=False) # transform instance
dataset = CacheDataset(self.files, transform=test_case)
loader = DataLoader(dataset, batch_size=3, shuffle=True)
@@ -65,7 +67,10 @@ def test_transforms(self, case_id):
# test forward patches
loaded = out[0]
- self.assertEqual(len(loaded), len(keys))
+ if not monai_config.USE_META_DICT:
+ self.assertEqual(len(loaded), len(keys))
+ else:
+ self.assertNotEqual(len(loaded), len(keys))
img, seg = loaded[keys[0]], loaded[keys[1]]
expected = config.get_parsed_content(id=f"{case_id}_answer", instantiate=True) # expected results
self.assertEqual(expected["load_shape"], list(x[keys[0]].shape))
@@ -76,6 +81,10 @@ def test_transforms(self, case_id):
self.assertTrue(len(tracked_cls) <= len(test_cls)) # tracked items should be no more than the compose items.
with tempfile.TemporaryDirectory() as tempdir: # test writer
SaveImageD(keys, resample=False, output_dir=tempdir, output_postfix=case_id)(loaded)
+ test_data = reset_ops_id(deepcopy(loaded))
+ for val in test_data.values():
+ if isinstance(val, MetaTensor) and val.applied_operations:
+ self.assertEqual(val.applied_operations[-1][TraceKeys.ID], TraceKeys.NONE)
# test inverse
inv = InvertD(keys, orig_keys=keys, transform=test_case, nearest_interp=True)
diff --git a/tests/test_monai_env_vars.py b/tests/test_monai_env_vars.py
new file mode 100644
index 0000000000..68d2755af7
--- /dev/null
+++ b/tests/test_monai_env_vars.py
@@ -0,0 +1,41 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import unittest
+
+from monai.utils.misc import MONAIEnvVars
+
+
+class TestMONAIEnvVars(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super(__class__, cls).setUpClass()
+ cls.orig_value = os.environ.get("MONAI_DEBUG", None)
+
+ @classmethod
+ def tearDownClass(cls):
+ if cls.orig_value is not None:
+ os.environ["MONAI_DEBUG"] = cls.orig_value
+ else:
+ os.environ.pop("MONAI_DEBUG")
+ print("MONAI debug value:", os.environ.get("MONAI_DEBUG"))
+ super(__class__, cls).tearDownClass()
+
+ def test_monai_env_vars(self):
+ for debug in (False, True):
+ os.environ["MONAI_DEBUG"] = str(debug)
+ self.assertEqual(os.environ.get("MONAI_DEBUG"), str(debug))
+ self.assertEqual(MONAIEnvVars.debug(), debug)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_mri_utils.py b/tests/test_mri_utils.py
new file mode 100644
index 0000000000..c1e2e788bf
--- /dev/null
+++ b/tests/test_mri_utils.py
@@ -0,0 +1,35 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+from parameterized import parameterized
+
+from monai.apps.reconstruction.mri_utils import root_sum_of_squares
+from tests.utils import TEST_NDARRAYS, assert_allclose
+
+# root_sum_of_squares
+im = [[3.0, 4.0], [3.0, 4.0]]
+res = [5.0, 5.0]
+TESTS = []
+for p in TEST_NDARRAYS:
+ TESTS.append((p(im), p(res)))
+
+
+class TestMRIUtils(unittest.TestCase):
+ @parameterized.expand(TESTS)
+ def test_rss(self, test_data, res_data):
+ result = root_sum_of_squares(test_data, spatial_dim=1)
+ assert_allclose(result, res_data, type_test=False)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_orientation.py b/tests/test_orientation.py
index 3026305d6a..7c4a1863c7 100644
--- a/tests/test_orientation.py
+++ b/tests/test_orientation.py
@@ -221,7 +221,7 @@ def test_bad_params(self, init_param, img: torch.Tensor, affine: torch.Tensor):
def test_inverse(self, device):
img_t = torch.rand((1, 10, 9, 8), dtype=torch.float32, device=device)
affine = torch.tensor(
- [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device=device
+ [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device="cpu"
)
meta = {"fname": "somewhere"}
img = MetaTensor(img_t, affine=affine, meta=meta)
diff --git a/tests/test_pad_collation.py b/tests/test_pad_collation.py
index 76a49b5b54..89a8508967 100644
--- a/tests/test_pad_collation.py
+++ b/tests/test_pad_collation.py
@@ -11,6 +11,7 @@
import random
import unittest
+from functools import wraps
from typing import List, Tuple
import numpy as np
@@ -20,6 +21,7 @@
from monai.data import CacheDataset, DataLoader
from monai.data.utils import decollate_batch, pad_list_data_collate
from monai.transforms import (
+ BatchInverseTransform,
Compose,
PadListDataCollate,
RandRotate,
@@ -34,12 +36,15 @@
)
from monai.utils import set_determinism
+
+@wraps(pad_list_data_collate)
+def _testing_collate(x):
+ return pad_list_data_collate(batch=x, method="end", mode="constant")
+
+
TESTS: List[Tuple] = []
-for pad_collate in [
- lambda x: pad_list_data_collate(batch=x, method="end", mode="constant"),
- PadListDataCollate(method="end", mode="constant"),
-]:
+for pad_collate in [_testing_collate, PadListDataCollate(method="end", mode="constant")]:
TESTS.append((dict, pad_collate, RandSpatialCropd("image", roi_size=[8, 7], random_size=True)))
TESTS.append((dict, pad_collate, RandRotated("image", prob=1, range_x=np.pi, keep_size=False, dtype=np.float64)))
TESTS.append((dict, pad_collate, RandZoomd("image", prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False)))
@@ -57,13 +62,13 @@ class _Dataset(torch.utils.data.Dataset):
def __init__(self, images, labels, transforms):
self.images = images
self.labels = labels
- self.transforms = transforms
+ self.transform = transforms
def __len__(self):
return len(self.images)
def __getitem__(self, index):
- return self.transforms(self.images[index]), self.labels[index]
+ return self.transform(self.images[index]), self.labels[index]
class TestPadCollation(unittest.TestCase):
@@ -103,8 +108,15 @@ def test_pad_collation(self, t_type, collate_method, transform):
for d in decollated_data:
output = PadListDataCollate.inverse(d)
shapes.append(output["image"].shape)
+ self.assertTrue(len(output["image"].applied_operations), len(dataset.transform.transforms))
self.assertTrue(len(set(shapes)) > 1) # inverted shapes must be different because of random xforms
+ if t_type == dict:
+ batch_inverse = BatchInverseTransform(dataset.transform, loader)
+ for data in loader:
+ output = batch_inverse(data)
+ self.assertTrue(output[0]["image"].shape, (1, 10, 9))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_pad_mode.py b/tests/test_pad_mode.py
new file mode 100644
index 0000000000..ae2e94b60c
--- /dev/null
+++ b/tests/test_pad_mode.py
@@ -0,0 +1,37 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+import torch
+
+from monai.transforms import CastToType, Pad
+from monai.utils import NumpyPadMode, PytorchPadMode
+from tests.utils import SkipIfBeforePyTorchVersion
+
+
+@SkipIfBeforePyTorchVersion((1, 10, 1))
+class TestPadMode(unittest.TestCase):
+ def test_pad(self):
+ expected_shapes = {3: (1, 15, 10), 4: (1, 10, 6, 7)}
+ for t in (float, int, np.uint8, np.int16, np.float32, bool):
+ for d in ("cuda:0", "cpu") if torch.cuda.is_available() else ("cpu",):
+ for s in ((1, 10, 10), (1, 5, 6, 7)):
+ for m in list(PytorchPadMode) + list(NumpyPadMode):
+ a = torch.rand(s)
+ to_pad = [(0, 0), (2, 3)] if len(s) == 3 else [(0, 0), (2, 3), (0, 0), (0, 0)]
+ out = Pad(to_pad=to_pad, mode=m)(CastToType(dtype=t)(a).to(d))
+ self.assertEqual(out.shape, expected_shapes[len(s)])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py
index 20d7f22988..6022d5627e 100644
--- a/tests/test_patch_wsi_dataset.py
+++ b/tests/test_patch_wsi_dataset.py
@@ -17,20 +17,25 @@
from numpy.testing import assert_array_equal
from parameterized import parameterized
-from monai.apps.pathology.data import PatchWSIDataset
-from monai.utils import optional_import
+from monai.apps.pathology.data import PatchWSIDataset as PatchWSIDatasetDeprecated
+from monai.data import PatchWSIDataset
+from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader
+from monai.utils import deprecated, optional_import
from tests.utils import download_url_or_skip_test, testing_data_config
-_cucim, has_cim = optional_import("cucim")
-has_cim = has_cim and hasattr(_cucim, "CuImage")
-_, has_osl = optional_import("openslide")
+cucim, has_cim = optional_import("cucim")
+has_cim = has_cim and hasattr(cucim, "CuImage")
+openslide, has_osl = optional_import("openslide")
+imwrite, has_tiff = optional_import("tifffile", name="imwrite")
+_, has_codec = optional_import("imagecodecs")
+has_tiff = has_tiff and has_codec
FILE_KEY = "wsi_img"
FILE_URL = testing_data_config("images", FILE_KEY, "url")
base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff"
FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension)
-TEST_CASE_0 = [
+TEST_CASE_DEP_0 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -41,7 +46,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}],
]
-TEST_CASE_0_L1 = [
+TEST_CASE_DEP_0_L1 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -53,7 +58,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}],
]
-TEST_CASE_0_L2 = [
+TEST_CASE_DEP_0_L2 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -66,7 +71,7 @@
]
-TEST_CASE_1 = [
+TEST_CASE_DEP_1 = [
{
"data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}],
"region_size": (8, 8),
@@ -83,7 +88,7 @@
]
-TEST_CASE_1_L0 = [
+TEST_CASE_DEP_1_L0 = [
{
"data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}],
"region_size": (8, 8),
@@ -101,7 +106,7 @@
]
-TEST_CASE_1_L1 = [
+TEST_CASE_DEP_1_L1 = [
{
"data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}],
"region_size": (8, 8),
@@ -117,7 +122,7 @@
{"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])},
],
]
-TEST_CASE_2 = [
+TEST_CASE_DEP_2 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": 1,
@@ -128,7 +133,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}],
]
-TEST_CASE_3 = [
+TEST_CASE_DEP_3 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}],
"region_size": 1,
@@ -139,7 +144,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}],
]
-TEST_CASE_OPENSLIDE_0 = [
+TEST_CASE_DEP_OPENSLIDE_0 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -150,7 +155,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}],
]
-TEST_CASE_OPENSLIDE_0_L0 = [
+TEST_CASE_DEP_OPENSLIDE_0_L0 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -162,7 +167,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}],
]
-TEST_CASE_OPENSLIDE_0_L1 = [
+TEST_CASE_DEP_OPENSLIDE_0_L1 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -175,7 +180,7 @@
]
-TEST_CASE_OPENSLIDE_0_L2 = [
+TEST_CASE_DEP_OPENSLIDE_0_L2 = [
{
"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}],
"region_size": (1, 1),
@@ -187,7 +192,7 @@
[{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}],
]
-TEST_CASE_OPENSLIDE_1 = [
+TEST_CASE_DEP_OPENSLIDE_1 = [
{
"data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}],
"region_size": (8, 8),
@@ -204,52 +209,191 @@
]
-class TestPatchWSIDataset(unittest.TestCase):
- def setUp(self):
- hash_type = testing_data_config("images", FILE_KEY, "hash_type")
- hash_val = testing_data_config("images", FILE_KEY, "hash_val")
- download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val)
+TEST_CASE_0 = [
+ {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
+]
+
+TEST_CASE_0_L1 = [
+ {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
+]
+TEST_CASE_0_L2 = [
+ {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
+]
+TEST_CASE_1 = [
+ {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
+]
+
+TEST_CASE_2 = [
+ {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
+]
+
+TEST_CASE_3 = [
+ {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])},
+]
+
+TEST_CASE_4 = [
+ {
+ "data": [
+ {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]},
+ {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]},
+ ],
+ "patch_size": 1,
+ },
+ [
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])},
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])},
+ ],
+]
+
+TEST_CASE_5 = [
+ {
+ "data": [
+ {
+ "image": FILE_PATH,
+ "patch_location": [0, 0],
+ "label": [[[0, 1], [1, 0]]],
+ "patch_size": 1,
+ "patch_level": 1,
+ },
+ {
+ "image": FILE_PATH,
+ "patch_location": [100, 100],
+ "label": [[[1, 0], [0, 0]]],
+ "patch_size": 1,
+ "patch_level": 1,
+ },
+ ]
+ },
+ [
+ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])},
+ {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])},
+ ],
+]
+
+
+@skipUnless(has_cim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!")
+def setUpModule():
+ hash_type = testing_data_config("images", FILE_KEY, "hash_type")
+ hash_val = testing_data_config("images", FILE_KEY, "hash_val")
+ download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val)
+
+
+@deprecated(since="0.8", msg_suffix="use tests for `monai.data.PatchWSIDataset` instead, `PatchWSIDatasetTests`.")
+class TestPatchWSIDatasetDeprecated(unittest.TestCase):
@parameterized.expand(
[
- TEST_CASE_0,
- TEST_CASE_0_L1,
- TEST_CASE_0_L2,
- TEST_CASE_1,
- TEST_CASE_1_L0,
- TEST_CASE_1_L1,
- TEST_CASE_2,
- TEST_CASE_3,
+ TEST_CASE_DEP_0,
+ TEST_CASE_DEP_0_L1,
+ TEST_CASE_DEP_0_L2,
+ TEST_CASE_DEP_1,
+ TEST_CASE_DEP_1_L0,
+ TEST_CASE_DEP_1_L1,
+ TEST_CASE_DEP_2,
+ TEST_CASE_DEP_3,
]
)
@skipUnless(has_cim, "Requires CuCIM")
def test_read_patches_cucim(self, input_parameters, expected):
- dataset = PatchWSIDataset(**input_parameters)
+ dataset = PatchWSIDatasetDeprecated(**input_parameters)
samples = dataset[0]
- for i in range(len(samples)):
- self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape)
- self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape)
- self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"]))
- self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"]))
+ for i, item in enumerate(samples):
+ self.assertTupleEqual(item["label"].shape, expected[i]["label"].shape)
+ self.assertTupleEqual(item["image"].shape, expected[i]["image"].shape)
+ self.assertIsNone(assert_array_equal(item["label"], expected[i]["label"]))
+ self.assertIsNone(assert_array_equal(item["image"], expected[i]["image"]))
@parameterized.expand(
[
- TEST_CASE_OPENSLIDE_0,
- TEST_CASE_OPENSLIDE_0_L0,
- TEST_CASE_OPENSLIDE_0_L1,
- TEST_CASE_OPENSLIDE_0_L2,
- TEST_CASE_OPENSLIDE_1,
+ TEST_CASE_DEP_OPENSLIDE_0,
+ TEST_CASE_DEP_OPENSLIDE_0_L0,
+ TEST_CASE_DEP_OPENSLIDE_0_L1,
+ TEST_CASE_DEP_OPENSLIDE_0_L2,
+ TEST_CASE_DEP_OPENSLIDE_1,
]
)
@skipUnless(has_osl, "Requires OpenSlide")
def test_read_patches_openslide(self, input_parameters, expected):
- dataset = PatchWSIDataset(**input_parameters)
+ dataset = PatchWSIDatasetDeprecated(**input_parameters)
samples = dataset[0]
- for i in range(len(samples)):
- self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape)
- self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape)
- self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"]))
- self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"]))
+ for i, item in enumerate(samples):
+ self.assertTupleEqual(item["label"].shape, expected[i]["label"].shape)
+ self.assertTupleEqual(item["image"].shape, expected[i]["image"].shape)
+ self.assertIsNone(assert_array_equal(item["label"], expected[i]["label"]))
+ self.assertIsNone(assert_array_equal(item["image"], expected[i]["image"]))
+
+
+class PatchWSIDatasetTests:
+ class Tests(unittest.TestCase):
+ backend = None
+
+ @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
+ def test_read_patches_str(self, input_parameters, expected):
+ dataset = PatchWSIDataset(reader=self.backend, **input_parameters)
+ sample = dataset[0]
+ self.assertTupleEqual(sample["label"].shape, expected["label"].shape)
+ self.assertTupleEqual(sample["image"].shape, expected["image"].shape)
+ self.assertIsNone(assert_array_equal(sample["label"], expected["label"]))
+ self.assertIsNone(assert_array_equal(sample["image"], expected["image"]))
+
+ @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
+ def test_read_patches_class(self, input_parameters, expected):
+ if self.backend == "openslide":
+ reader = OpenSlideWSIReader
+ elif self.backend == "cucim":
+ reader = CuCIMWSIReader
+ else:
+ raise ValueError("Unsupported backend: {self.backend}")
+ dataset = PatchWSIDataset(reader=reader, **input_parameters)
+ sample = dataset[0]
+ self.assertTupleEqual(sample["label"].shape, expected["label"].shape)
+ self.assertTupleEqual(sample["image"].shape, expected["image"].shape)
+ self.assertIsNone(assert_array_equal(sample["label"], expected["label"]))
+ self.assertIsNone(assert_array_equal(sample["image"], expected["image"]))
+
+ @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
+ def test_read_patches_object(self, input_parameters, expected):
+ if self.backend == "openslide":
+ reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0))
+ elif self.backend == "cucim":
+ reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0))
+ else:
+ raise ValueError("Unsupported backend: {self.backend}")
+ dataset = PatchWSIDataset(reader=reader, **input_parameters)
+ sample = dataset[0]
+ self.assertTupleEqual(sample["label"].shape, expected["label"].shape)
+ self.assertTupleEqual(sample["image"].shape, expected["image"].shape)
+ self.assertIsNone(assert_array_equal(sample["label"], expected["label"]))
+ self.assertIsNone(assert_array_equal(sample["image"], expected["image"]))
+
+ @parameterized.expand([TEST_CASE_4, TEST_CASE_5])
+ def test_read_patches_str_multi(self, input_parameters, expected):
+ dataset = PatchWSIDataset(reader=self.backend, **input_parameters)
+ for i, item in enumerate(dataset):
+ self.assertTupleEqual(item["label"].shape, expected[i]["label"].shape)
+ self.assertTupleEqual(item["image"].shape, expected[i]["image"].shape)
+ self.assertIsNone(assert_array_equal(item["label"], expected[i]["label"]))
+ self.assertIsNone(assert_array_equal(item["image"], expected[i]["image"]))
+
+
+@skipUnless(has_cim, "Requires cucim")
+class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests):
+ @classmethod
+ def setUpClass(cls):
+ cls.backend = "cucim"
+
+
+@skipUnless(has_osl, "Requires openslide")
+class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests):
+ @classmethod
+ def setUpClass(cls):
+ cls.backend = "openslide"
if __name__ == "__main__":
diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py
deleted file mode 100644
index fee8a03068..0000000000
--- a/tests/test_patch_wsi_dataset_new.py
+++ /dev/null
@@ -1,181 +0,0 @@
-# Copyright (c) MONAI Consortium
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-# http://www.apache.org/licenses/LICENSE-2.0
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import os
-import unittest
-from unittest import skipUnless
-
-import numpy as np
-from numpy.testing import assert_array_equal
-from parameterized import parameterized
-
-from monai.data import PatchWSIDataset
-from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader
-from monai.utils import optional_import
-from tests.utils import download_url_or_skip_test, testing_data_config
-
-cucim, has_cucim = optional_import("cucim")
-has_cucim = has_cucim and hasattr(cucim, "CuImage")
-openslide, has_osl = optional_import("openslide")
-imwrite, has_tiff = optional_import("tifffile", name="imwrite")
-_, has_codec = optional_import("imagecodecs")
-has_tiff = has_tiff and has_codec
-
-FILE_KEY = "wsi_img"
-FILE_URL = testing_data_config("images", FILE_KEY, "url")
-base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff"
-FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension)
-
-TEST_CASE_0 = [
- {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
-]
-
-TEST_CASE_0_L1 = [
- {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
-]
-
-TEST_CASE_0_L2 = [
- {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
-]
-TEST_CASE_1 = [
- {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
-]
-
-TEST_CASE_2 = [
- {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])},
-]
-
-TEST_CASE_3 = [
- {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])},
-]
-
-TEST_CASE_4 = [
- {
- "data": [
- {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]},
- {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]},
- ],
- "patch_size": 1,
- },
- [
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])},
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])},
- ],
-]
-
-TEST_CASE_5 = [
- {
- "data": [
- {
- "image": FILE_PATH,
- "patch_location": [0, 0],
- "label": [[[0, 1], [1, 0]]],
- "patch_size": 1,
- "patch_level": 1,
- },
- {
- "image": FILE_PATH,
- "patch_location": [100, 100],
- "label": [[[1, 0], [0, 0]]],
- "patch_size": 1,
- "patch_level": 1,
- },
- ]
- },
- [
- {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])},
- {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])},
- ],
-]
-
-
-@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!")
-def setUpModule():
- hash_type = testing_data_config("images", FILE_KEY, "hash_type")
- hash_val = testing_data_config("images", FILE_KEY, "hash_val")
- download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val)
-
-
-class PatchWSIDatasetTests:
- class Tests(unittest.TestCase):
- backend = None
-
- @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
- def test_read_patches_str(self, input_parameters, expected):
- dataset = PatchWSIDataset(reader=self.backend, **input_parameters)
- sample = dataset[0]
- self.assertTupleEqual(sample["label"].shape, expected["label"].shape)
- self.assertTupleEqual(sample["image"].shape, expected["image"].shape)
- self.assertIsNone(assert_array_equal(sample["label"], expected["label"]))
- self.assertIsNone(assert_array_equal(sample["image"], expected["image"]))
-
- @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
- def test_read_patches_class(self, input_parameters, expected):
- if self.backend == "openslide":
- reader = OpenSlideWSIReader
- elif self.backend == "cucim":
- reader = CuCIMWSIReader
- else:
- raise ValueError("Unsupported backend: {self.backend}")
- dataset = PatchWSIDataset(reader=reader, **input_parameters)
- sample = dataset[0]
- self.assertTupleEqual(sample["label"].shape, expected["label"].shape)
- self.assertTupleEqual(sample["image"].shape, expected["image"].shape)
- self.assertIsNone(assert_array_equal(sample["label"], expected["label"]))
- self.assertIsNone(assert_array_equal(sample["image"], expected["image"]))
-
- @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
- def test_read_patches_object(self, input_parameters, expected):
- if self.backend == "openslide":
- reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0))
- elif self.backend == "cucim":
- reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0))
- else:
- raise ValueError("Unsupported backend: {self.backend}")
- dataset = PatchWSIDataset(reader=reader, **input_parameters)
- sample = dataset[0]
- self.assertTupleEqual(sample["label"].shape, expected["label"].shape)
- self.assertTupleEqual(sample["image"].shape, expected["image"].shape)
- self.assertIsNone(assert_array_equal(sample["label"], expected["label"]))
- self.assertIsNone(assert_array_equal(sample["image"], expected["image"]))
-
- @parameterized.expand([TEST_CASE_4, TEST_CASE_5])
- def test_read_patches_str_multi(self, input_parameters, expected):
- dataset = PatchWSIDataset(reader=self.backend, **input_parameters)
- for i in range(len(dataset)):
- self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape)
- self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape)
- self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"]))
- self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"]))
-
-
-@skipUnless(has_cucim, "Requires cucim")
-class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests):
- @classmethod
- def setUpClass(cls):
- cls.backend = "cucim"
-
-
-@skipUnless(has_osl, "Requires openslide")
-class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests):
- @classmethod
- def setUpClass(cls):
- cls.backend = "openslide"
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_persistentdataset.py b/tests/test_persistentdataset.py
index 17575c79f7..bc52ed1102 100644
--- a/tests/test_persistentdataset.py
+++ b/tests/test_persistentdataset.py
@@ -19,7 +19,7 @@
from parameterized import parameterized
from monai.data import PersistentDataset, json_hashing
-from monai.transforms import Compose, LoadImaged, SimulateDelayd, Transform
+from monai.transforms import Compose, Flip, Identity, LoadImaged, SimulateDelayd, Transform
TEST_CASE_1 = [
Compose(
@@ -77,7 +77,7 @@ def test_cache(self):
@parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3])
def test_shape(self, transform, expected_shape):
- test_image = nib.Nifti1Image(np.random.randint(0, 2, size=[128, 128, 128]), np.eye(4))
+ test_image = nib.Nifti1Image(np.random.randint(0, 2, size=[128, 128, 128]).astype(float), np.eye(4))
with tempfile.TemporaryDirectory() as tempdir:
nib.save(test_image, os.path.join(tempdir, "test_image1.nii.gz"))
nib.save(test_image, os.path.join(tempdir, "test_label1.nii.gz"))
@@ -150,6 +150,19 @@ def test_shape(self, transform, expected_shape):
self.assertEqual(dataset_postcached[0]["label"], os.path.join(tempdir, "test_label1_new.nii.gz"))
self.assertEqual(dataset_postcached[1]["extra"], os.path.join(tempdir, "test_extra2_new.nii.gz"))
+ def test_different_transforms(self):
+ """
+ Different instances of `PersistentDataset` with the same cache_dir,
+ same input data, but different transforms should give different results.
+ """
+ shape = (1, 10, 9, 8)
+ im = np.arange(0, np.prod(shape)).reshape(shape)
+ with tempfile.TemporaryDirectory() as path:
+ im1 = PersistentDataset([im], Identity(), cache_dir=path, hash_transform=json_hashing)[0]
+ im2 = PersistentDataset([im], Flip(1), cache_dir=path, hash_transform=json_hashing)[0]
+ l2 = ((im1 - im2) ** 2).sum() ** 0.5
+ self.assertTrue(l2 > 1)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_profiling.py b/tests/test_profiling.py
new file mode 100644
index 0000000000..40522b07c5
--- /dev/null
+++ b/tests/test_profiling.py
@@ -0,0 +1,204 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import datetime
+import os
+import unittest
+from io import StringIO
+
+import torch
+
+import monai.transforms as mt
+from monai.data import Dataset, ThreadDataLoader
+from monai.utils import first, optional_import
+from monai.utils.enums import CommonKeys
+from monai.utils.profiling import ProfileHandler, ProfileResult, WorkflowProfiler
+from tests.utils import SkipIfNoModule
+
+pd, _ = optional_import("pandas")
+
+
+class TestWorkflowProfiler(unittest.TestCase):
+ def setUp(self):
+ super().setUp()
+
+ self.scale = mt.ScaleIntensity()
+ self.scale_call_name = "ScaleIntensity.__call__"
+ self.test_comp = mt.Compose([mt.ScaleIntensity(), mt.RandAxisFlip(0.5)])
+ self.test_image = torch.rand(1, 16, 16, 16)
+ self.pid = os.getpid()
+
+ def test_empty(self):
+ """Test that the profiler correctly produces an empty result when nothing happens in a context."""
+ wp = WorkflowProfiler()
+
+ with wp:
+ pass
+
+ self.assertEqual(wp.get_results(), {})
+
+ def test_profile_transforms(self):
+ """Test basic reporting when invoking a single transform directly."""
+ with WorkflowProfiler() as wp:
+ self.scale(self.test_image)
+
+ results = wp.get_results()
+ self.assertSequenceEqual(list(results), [self.scale_call_name])
+
+ prs = results[self.scale_call_name]
+
+ self.assertEqual(len(prs), 1)
+
+ pr = prs[0]
+
+ self.assertIsInstance(pr, ProfileResult)
+ self.assertEqual(pr.name, self.scale_call_name)
+ self.assertEqual(pr.pid, self.pid)
+ self.assertGreater(pr.time, 0)
+
+ dt = datetime.datetime.fromisoformat(pr.timestamp)
+
+ self.assertIsInstance(dt, datetime.datetime)
+
+ def test_profile_multithread(self):
+ """Test resulst are gathered from multiple threads using ThreadDataLoader."""
+ ds = Dataset([self.test_image] * 4, self.scale)
+ dl = ThreadDataLoader(ds, batch_size=4, num_workers=4, use_thread_workers=True)
+
+ with WorkflowProfiler() as wp:
+ batch = first(dl)
+
+ self.assertSequenceEqual(batch.shape, (4, 1, 16, 16, 16))
+
+ results = wp.get_results()
+ self.assertSequenceEqual(list(results), [self.scale_call_name])
+
+ prs = results[self.scale_call_name]
+
+ self.assertEqual(len(prs), 4)
+
+ def test_profile_context(self):
+ """Test results from profiling contexts with the same name accumulate correctly."""
+ with WorkflowProfiler() as wp:
+ with wp.profile_ctx("context"):
+ self.scale(self.test_image)
+
+ with wp.profile_ctx("context"):
+ self.scale(self.test_image)
+
+ results = wp.get_results()
+ self.assertSequenceEqual(set(results), {"ScaleIntensity.__call__", "context"})
+
+ prs = results["context"]
+
+ self.assertEqual(len(prs), 2)
+
+ def test_profile_callable(self):
+ """Test profiling functions with default or set names."""
+
+ def funca():
+ pass
+
+ with WorkflowProfiler() as wp:
+ funca = wp.profile_callable()(funca)
+
+ funca()
+
+ @wp.profile_callable("funcb")
+ def _func():
+ pass
+
+ _func()
+ _func()
+
+ results = wp.get_results()
+ self.assertSequenceEqual(set(results), {"funca", "funcb"})
+
+ self.assertEqual(len(results["funca"]), 1)
+ self.assertEqual(len(results["funcb"]), 2)
+
+ def test_profile_iteration(self):
+ """Test iterables are profiled correctly, producing the right output and number of results."""
+ with WorkflowProfiler() as wp:
+ range_vals = []
+
+ for i in wp.profile_iter("range5", range(5)):
+ range_vals.append(i)
+
+ self.assertSequenceEqual(range_vals, list(range(5)))
+
+ results = wp.get_results()
+ self.assertSequenceEqual(set(results), {"range5"})
+
+ self.assertEqual(len(results["range5"]), 5)
+
+ def test_times_summary(self):
+ """Test generating the summary report dictionary."""
+ with WorkflowProfiler() as wp:
+ self.scale(self.test_image)
+
+ tsum = wp.get_times_summary()
+
+ self.assertSequenceEqual(list(tsum), [self.scale_call_name])
+
+ times = tsum[self.scale_call_name]
+
+ self.assertEqual(len(times), 6)
+ self.assertEqual(times[0], 1)
+
+ @SkipIfNoModule("pandas")
+ def test_times_summary_pd(self):
+ """Test generating the Pandas result works if Pandas is present."""
+ with WorkflowProfiler() as wp:
+ self.scale(self.test_image)
+
+ df = wp.get_times_summary_pd()
+
+ self.assertIsInstance(df, pd.DataFrame)
+
+ def test_csv_dump(self):
+ """Test dumping the results to csv file in a local StringIO object."""
+ with WorkflowProfiler() as wp:
+ self.scale(self.test_image)
+
+ sio = StringIO()
+ wp.dump_csv(sio)
+ self.assertGreater(sio.tell(), 0)
+
+ @SkipIfNoModule("ignite")
+ def test_handler(self):
+ """Test profiling Engine objects works if Ignite is present."""
+ from ignite.engine import Events
+
+ from monai.engines import SupervisedTrainer
+
+ net = torch.nn.Conv2d(1, 1, 3, padding=1)
+ im = torch.rand(1, 1, 16, 16)
+
+ with WorkflowProfiler(None) as wp:
+ trainer = SupervisedTrainer(
+ device=torch.device("cpu"),
+ max_epochs=2,
+ train_data_loader=[{CommonKeys.IMAGE: im, CommonKeys.LABEL: im}] * 3,
+ epoch_length=3,
+ network=net,
+ optimizer=torch.optim.Adam(net.parameters()),
+ loss_function=torch.nn.L1Loss(),
+ )
+
+ _ = ProfileHandler("Epoch", wp, Events.EPOCH_STARTED, Events.EPOCH_COMPLETED).attach(trainer)
+
+ trainer.run()
+
+ results = wp.get_results()
+
+ self.assertSequenceEqual(set(results), {"Epoch"})
+ self.assertEqual(len(results["Epoch"]), 2)
diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py
index a33496895c..566eed68ef 100644
--- a/tests/test_rand_affined.py
+++ b/tests/test_rand_affined.py
@@ -221,6 +221,8 @@ def test_rand_affined(self, input_param, input_data, expected_val, track_meta):
if input_param.get("cache_grid", False):
self.assertTrue(g.rand_affine._cached_grid is not None)
for key in res:
+ if isinstance(key, str) and key.endswith("_transforms"):
+ continue
result = res[key]
if track_meta:
self.assertIsInstance(result, MetaTensor)
diff --git a/tests/test_rand_crop_by_label_classesd.py b/tests/test_rand_crop_by_label_classesd.py
index 9a99ebab29..24600a41ef 100644
--- a/tests/test_rand_crop_by_label_classesd.py
+++ b/tests/test_rand_crop_by_label_classesd.py
@@ -126,6 +126,8 @@ def test_type_shape(self, input_param, input_data, expected_type, expected_shape
result = RandCropByLabelClassesd(**input_param)(input_data)
self.assertIsInstance(result, expected_type)
self.assertTupleEqual(result[0]["img"].shape, expected_shape)
+ _len = len(tuple(input_data.keys())) - 1 # except for the indices_key
+ self.assertTupleEqual(tuple(result[0].keys())[:_len], tuple(input_data.keys())[:-1])
if __name__ == "__main__":
diff --git a/tests/test_rand_spatial_crop_samplesd.py b/tests/test_rand_spatial_crop_samplesd.py
index 4da438d2a0..c860cbea4f 100644
--- a/tests/test_rand_spatial_crop_samplesd.py
+++ b/tests/test_rand_spatial_crop_samplesd.py
@@ -85,6 +85,8 @@ def test_shape(self, input_param, input_data, expected_shape, expected_last):
xform = RandSpatialCropSamplesd(**input_param)
xform.set_random_state(1234)
result = xform(input_data)
+ _len = len(tuple(input_data.keys()))
+ self.assertTupleEqual(tuple(result[0].keys())[:_len], tuple(input_data.keys()))
for item, expected in zip(result, expected_shape):
self.assertTupleEqual(item["img"].shape, expected)
self.assertTupleEqual(item["seg"].shape, expected)
diff --git a/tests/test_rand_weighted_cropd.py b/tests/test_rand_weighted_cropd.py
index b3fc92b445..ee5fa5f083 100644
--- a/tests/test_rand_weighted_cropd.py
+++ b/tests/test_rand_weighted_cropd.py
@@ -150,6 +150,8 @@ def test_rand_weighted_cropd(self, _, init_params, input_data, expected_shape, e
crop.set_random_state(10)
result = crop(input_data)
self.assertTrue(len(result) == init_params["num_samples"])
+ _len = len(tuple(input_data.keys()))
+ self.assertTupleEqual(tuple(result[0].keys())[:_len], tuple(input_data.keys()))
if __name__ == "__main__":
diff --git a/tests/test_recon_net_utils.py b/tests/test_recon_net_utils.py
new file mode 100644
index 0000000000..6621bf735e
--- /dev/null
+++ b/tests/test_recon_net_utils.py
@@ -0,0 +1,80 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.apps.reconstruction.networks.nets.utils import (
+ complex_normalize,
+ divisible_pad_t,
+ inverse_divisible_pad_t,
+ reshape_batch_channel_to_channel_dim,
+ reshape_channel_complex_to_last_dim,
+ reshape_channel_to_batch_dim,
+ reshape_complex_to_channel_dim,
+ sensitivity_map_expand,
+ sensitivity_map_reduce,
+)
+from tests.utils import assert_allclose
+
+# no need for checking devices, these functions don't change device format
+# reshape test case
+im_2d, im_3d = torch.ones([3, 4, 50, 70, 2]), torch.ones([3, 4, 50, 70, 80, 2])
+TEST_RESHAPE = [(im_2d,), (im_3d,)]
+
+# normalize test case
+im_2d, im_3d = torch.randint(0, 3, [3, 4, 50, 70]).float(), torch.randint(0, 3, [3, 4, 50, 70, 80]).float()
+TEST_NORMALIZE = [(im_2d,), (im_3d,)]
+
+# pad test case
+im_2d, im_3d = torch.ones([3, 4, 50, 70]), torch.ones([3, 4, 50, 70, 80])
+TEST_PAD = [(im_2d,), (im_3d,)]
+
+# test case for sensitivity map expansion/reduction
+ksp_2d, ksp_3d = torch.ones([3, 4, 50, 70, 2]), torch.ones([3, 4, 50, 70, 80, 2])
+sens_2d, sens_3d = torch.ones([3, 4, 50, 70, 2]), torch.ones([3, 4, 50, 70, 80, 2])
+TEST_SENS = [(ksp_2d, sens_2d), (ksp_3d, sens_3d)]
+
+
+class TestReconNetUtils(unittest.TestCase):
+ @parameterized.expand(TEST_RESHAPE)
+ def test_reshape_channel_complex(self, test_data):
+ result = reshape_complex_to_channel_dim(test_data)
+ result = reshape_channel_complex_to_last_dim(result)
+ self.assertEqual(result.shape, test_data.shape)
+
+ result, batch_size = reshape_channel_to_batch_dim(test_data)
+ result = reshape_batch_channel_to_channel_dim(result, batch_size)
+ self.assertEqual(result.shape, test_data.shape)
+
+ @parameterized.expand(TEST_NORMALIZE)
+ def test_complex_normalize(self, test_data):
+ result, mean, std = complex_normalize(test_data)
+ result = result * std + mean
+ self.assertTrue((((result - test_data) ** 2).mean() ** 0.5).item() < 1e-5)
+
+ @parameterized.expand(TEST_PAD)
+ def test_pad(self, test_data):
+ result, padding_sizes = divisible_pad_t(test_data, k=16)
+ result = inverse_divisible_pad_t(result, padding_sizes)
+ assert_allclose(result, test_data)
+
+ @parameterized.expand(TEST_SENS)
+ def test_sens_expand_reduce(self, test_data, sens):
+ result = sensitivity_map_reduce(test_data, sens)
+ result = sensitivity_map_expand(result, sens)
+ self.assertEqual(result.shape, test_data.shape)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_reference_based_normalize_intensity.py b/tests/test_reference_based_normalize_intensity.py
new file mode 100644
index 0000000000..0f5fa7d627
--- /dev/null
+++ b/tests/test_reference_based_normalize_intensity.py
@@ -0,0 +1,76 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+from parameterized import parameterized
+
+from monai.apps.reconstruction.transforms.dictionary import ReferenceBasedNormalizeIntensityd
+from monai.utils.type_conversion import convert_to_numpy
+from tests.utils import TEST_NDARRAYS_NO_META_TENSOR, assert_allclose
+
+# see test_normalize_intensityd for typical tests (like non-zero
+# normalization, device test, etc.)
+# here, we test DetailedNormalizeIntensityd's functionality
+# which focuses on (1) automatic target normalization and (2) mean-std
+# return values
+
+
+TESTS = []
+for p in TEST_NDARRAYS_NO_META_TENSOR:
+ TESTS.append(
+ [
+ {"keys": ["kspace_masked_ifft", "target"], "ref_key": "kspace_masked_ifft", "channel_wise": True},
+ {"kspace_masked_ifft": p(np.array([[-2.0, 0.0, 2.0]])), "target": p(np.array([[1.0, 2.0, 3.0]]))},
+ p(np.array([[-1.225, 0.0, 1.225]])), # normalized input
+ p(np.array([[0.612, 1.225, 1.837]])), # normalized target
+ np.array([0.0]), # mean
+ np.array([1.633]), # std
+ ]
+ )
+
+ TESTS.append(
+ [
+ {"keys": ["kspace_masked_ifft", "target"], "ref_key": "kspace_masked_ifft", "channel_wise": False},
+ {"kspace_masked_ifft": p(np.array([[-2.0, 0.0, 2.0]])), "target": p(np.array([[1.0, 2.0, 3.0]]))},
+ p(np.array([[-1.225, 0.0, 1.225]])), # normalized input
+ p(np.array([[0.612, 1.225, 1.837]])), # normalized target
+ 0.0, # mean
+ 1.633, # std
+ ]
+ )
+
+
+class TestDetailedNormalizeIntensityd(unittest.TestCase):
+ @parameterized.expand(TESTS)
+ def test_target_mean_std(self, args, data, normalized_data, normalized_target, mean, std):
+ dtype = data[args["keys"][0]].dtype
+ normalizer = ReferenceBasedNormalizeIntensityd(
+ keys=args["keys"], ref_key=args["ref_key"], channel_wise=args["channel_wise"], dtype=dtype
+ )
+ res_data = normalizer(data)
+
+ img = np.round(convert_to_numpy(res_data[args["keys"][0]]), 3)
+ normalized_data = np.round(convert_to_numpy(normalized_data), 3)
+
+ target = np.round(convert_to_numpy(res_data[args["keys"][1]]), 3)
+ normalized_target = np.round(convert_to_numpy(normalized_target), 3)
+
+ assert_allclose(img, normalized_data)
+ assert_allclose(target, normalized_target)
+
+ assert_allclose(np.round(res_data["mean"], 3), mean)
+ assert_allclose(np.round(res_data["std"], 3), std)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_reference_based_spatial_cropd.py b/tests/test_reference_based_spatial_cropd.py
new file mode 100644
index 0000000000..d1f6230da4
--- /dev/null
+++ b/tests/test_reference_based_spatial_cropd.py
@@ -0,0 +1,56 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+from parameterized import parameterized
+
+from monai.apps.reconstruction.transforms.dictionary import ReferenceBasedSpatialCropd
+from tests.utils import TEST_NDARRAYS
+
+# see test_spatial_cropd for typical tests (like roi_start,
+# roi_slices, etc.)
+# here, we test TargetBasedSpatialCropd's functionality
+# which focuses on automatic input crop based on target image's shape.
+
+
+TESTS = []
+for p in TEST_NDARRAYS:
+ # 2D
+ TESTS.append(
+ [
+ {"keys": ["kspace_masked_ifft"], "ref_key": "target"},
+ {"kspace_masked_ifft": p(np.ones([10, 20, 20])), "target": p(np.ones([5, 8, 8]))},
+ (8, 8), # expected shape
+ ]
+ )
+
+ # 3D
+ TESTS.append(
+ [
+ {"keys": ["kspace_masked_ifft"], "ref_key": "target"},
+ {"kspace_masked_ifft": p(np.ones([10, 20, 20, 16])), "target": p(np.ones([5, 8, 8, 6]))},
+ (8, 8, 6), # expected shape
+ ]
+ )
+
+
+class TestTargetBasedSpatialCropd(unittest.TestCase):
+ @parameterized.expand(TESTS)
+ def test_shape(self, args, data, expected_shape):
+ cropper = ReferenceBasedSpatialCropd(keys=args["keys"], ref_key=args["ref_key"])
+ res_data = cropper(data)
+ self.assertTupleEqual(res_data[args["keys"][0]].shape[1:], expected_shape)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_resample_backends.py b/tests/test_resample_backends.py
new file mode 100644
index 0000000000..912a97378c
--- /dev/null
+++ b/tests/test_resample_backends.py
@@ -0,0 +1,62 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+import torch
+from parameterized import parameterized
+
+from monai.config import USE_COMPILED
+from monai.data import MetaTensor
+from monai.transforms import Resample
+from monai.transforms.utils import create_grid
+from monai.utils import GridSampleMode, GridSamplePadMode, NdimageMode, SplineMode, convert_to_numpy
+from tests.utils import assert_allclose, is_tf32_env
+
+_rtol = 1e-3 if is_tf32_env() else 1e-4
+
+TEST_IDENTITY = []
+for interp in GridSampleMode if not USE_COMPILED else ("nearest", "bilinear"): # type: ignore
+ for pad in GridSamplePadMode:
+ for p in (np.float32, np.float64):
+ for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]:
+ TEST_IDENTITY.append([dict(device=device), p, interp, pad, (1, 3, 4)])
+ if interp != "bicubic":
+ TEST_IDENTITY.append([dict(device=device), p, interp, pad, (1, 3, 5, 8)])
+for interp_s in SplineMode if not USE_COMPILED else []: # type: ignore
+ for pad_s in NdimageMode:
+ for p_s in (int, float, np.float32, np.float64):
+ for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]:
+ TEST_IDENTITY.append([dict(device=device), p_s, interp_s, pad_s, (1, 20, 21)])
+ TEST_IDENTITY.append([dict(device=device), p_s, interp_s, pad_s, (1, 21, 23, 24)])
+
+
+class TestResampleBackends(unittest.TestCase):
+ @parameterized.expand(TEST_IDENTITY)
+ def test_resample_identity(self, input_param, im_type, interp, padding, input_shape):
+ """test resampling of an identity grid with padding 2, im_type, interp, padding, input_shape"""
+ xform = Resample(dtype=im_type, **input_param)
+ n_elem = np.prod(input_shape)
+ img = convert_to_numpy(np.arange(n_elem).reshape(input_shape), dtype=im_type)
+ grid = create_grid(input_shape[1:], homogeneous=True, backend="numpy")
+ grid_p = np.stack([np.pad(g, 2, "constant") for g in grid]) # testing pad
+ output = xform(img=img, grid=grid_p, mode=interp, padding_mode=padding)
+ self.assertTrue(not torch.any(torch.isinf(output) | torch.isnan(output)))
+ self.assertIsInstance(output, MetaTensor)
+ slices = [slice(None)]
+ slices.extend([slice(2, -2) for _ in img.shape[1:]])
+ output_c = output[slices]
+ assert_allclose(output_c, img, rtol=_rtol, atol=1e-3, type_test="tensor")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_resize.py b/tests/test_resize.py
index 8927b5dba5..b755bb3faf 100644
--- a/tests/test_resize.py
+++ b/tests/test_resize.py
@@ -74,8 +74,6 @@ def test_correct_results(self, spatial_size, mode, anti_aliasing):
im = p(self.imt[0])
out = resize(im)
if isinstance(im, MetaTensor):
- if not out.applied_operations:
- return # skipped because good shape
im_inv = resize.inverse(out)
self.assertTrue(not im_inv.applied_operations)
assert_allclose(im_inv.shape, im.shape)
diff --git a/tests/test_resized.py b/tests/test_resized.py
index b8db666357..c1d987b898 100644
--- a/tests/test_resized.py
+++ b/tests/test_resized.py
@@ -16,7 +16,7 @@
from parameterized import parameterized
from monai.data import MetaTensor, set_track_meta
-from monai.transforms import Resized
+from monai.transforms import Invertd, Resized
from tests.utils import TEST_NDARRAYS_ALL, NumpyImageTestCase2D, assert_allclose, test_local_inversion
TEST_CASE_0 = [{"keys": "img", "spatial_size": 15}, (6, 10, 15)]
@@ -80,6 +80,14 @@ def test_longest_shape(self, input_param, expected_shape):
np.testing.assert_allclose(result["img"].shape[1:], expected_shape)
set_track_meta(True)
+ def test_identical_spatial(self):
+ test_input = {"X": np.ones((1, 10, 16, 17))}
+ xform = Resized("X", (-1, 16, 17))
+ out = xform(test_input)
+ out["Y"] = 2 * out["X"]
+ transform_inverse = Invertd(keys="Y", transform=xform, orig_keys="X")
+ assert_allclose(transform_inverse(out)["Y"].array, np.ones((1, 10, 16, 17)) * 2)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_resnet.py b/tests/test_resnet.py
index 688f7827b1..88499f78d0 100644
--- a/tests/test_resnet.py
+++ b/tests/test_resnet.py
@@ -16,7 +16,8 @@
from parameterized import parameterized
from monai.networks import eval_mode
-from monai.networks.nets import resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200
+from monai.networks.nets import ResNet, resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200
+from monai.networks.nets.resnet import ResNetBlock
from monai.utils import optional_import
from tests.utils import test_script_save
@@ -95,10 +96,57 @@
((2, 512), (2, 2048)),
]
+TEST_CASE_5 = [ # 1D, batch 1, 2 input channels
+ {
+ "block": "basic",
+ "layers": [1, 1, 1, 1],
+ "block_inplanes": [64, 128, 256, 512],
+ "spatial_dims": 1,
+ "n_input_channels": 2,
+ "num_classes": 3,
+ "conv1_t_size": [3],
+ "conv1_t_stride": 1,
+ },
+ (1, 2, 32),
+ (1, 3),
+]
+
+TEST_CASE_5_A = [ # 1D, batch 1, 2 input channels
+ {
+ "block": ResNetBlock,
+ "layers": [1, 1, 1, 1],
+ "block_inplanes": [64, 128, 256, 512],
+ "spatial_dims": 1,
+ "n_input_channels": 2,
+ "num_classes": 3,
+ "conv1_t_size": [3],
+ "conv1_t_stride": 1,
+ },
+ (1, 2, 32),
+ (1, 3),
+]
+
+TEST_CASE_6 = [ # 1D, batch 1, 2 input channels
+ {
+ "block": "bottleneck",
+ "layers": [3, 4, 6, 3],
+ "block_inplanes": [64, 128, 256, 512],
+ "spatial_dims": 1,
+ "n_input_channels": 2,
+ "num_classes": 3,
+ "conv1_t_size": [3],
+ "conv1_t_stride": 1,
+ },
+ (1, 2, 32),
+ (1, 3),
+]
+
TEST_CASES = []
for case in [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_2_A, TEST_CASE_3_A]:
for model in [resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200]:
TEST_CASES.append([model, *case])
+for case in [TEST_CASE_5, TEST_CASE_5_A, TEST_CASE_6]:
+ TEST_CASES.append([ResNet, *case])
TEST_SCRIPT_CASES = [
[model, *TEST_CASE_1] for model in [resnet10, resnet18, resnet34, resnet50, resnet101, resnet152, resnet200]
diff --git a/tests/test_senet.py b/tests/test_senet.py
index 80d2b071c3..34f140638e 100644
--- a/tests/test_senet.py
+++ b/tests/test_senet.py
@@ -19,7 +19,7 @@
import monai.networks.nets.senet as se_mod
from monai.networks import eval_mode
-from monai.networks.nets import SENet154, SEResNet50, SEResNet101, SEResNet152, SEResNext50, SEResNext101
+from monai.networks.nets import SENet, SENet154, SEResNet50, SEResNet101, SEResNet152, SEResNext50, SEResNext101
from monai.utils import optional_import
from tests.utils import test_is_quick, test_pretrained_networks, test_script_save, testing_data_config
@@ -41,12 +41,24 @@
TEST_CASE_4 = [SEResNet152, NET_ARGS]
TEST_CASE_5 = [SEResNext50, NET_ARGS]
TEST_CASE_6 = [SEResNext101, NET_ARGS]
+TEST_CASE_7 = [
+ SENet,
+ {
+ "spatial_dims": 3,
+ "in_channels": 2,
+ "num_classes": 2,
+ "block": "se_bottleneck",
+ "layers": (3, 8, 36, 3),
+ "groups": 64,
+ "reduction": 16,
+ },
+]
TEST_CASE_PRETRAINED_1 = [SEResNet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}]
class TestSENET(unittest.TestCase):
- @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6])
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7])
def test_senet_shape(self, net, net_args):
input_data = torch.randn(2, 2, 64, 64, 64).to(device)
expected_shape = (2, 2)
diff --git a/tests/test_shuffle_buffer.py b/tests/test_shuffle_buffer.py
index 40012fbf93..8067eee2bd 100644
--- a/tests/test_shuffle_buffer.py
+++ b/tests/test_shuffle_buffer.py
@@ -16,18 +16,26 @@
from monai.data import DataLoader, ShuffleBuffer
from monai.utils import convert_data_type
+from tests.utils import SkipIfBeforePyTorchVersion
+@SkipIfBeforePyTorchVersion((1, 12))
class TestShuffleBuffer(unittest.TestCase):
def test_shape(self):
buffer = ShuffleBuffer([1, 2, 3, 4], seed=0)
num_workers = 2 if sys.platform == "linux" else 0
- dataloader = DataLoader(dataset=buffer, batch_size=2, num_workers=num_workers)
+ dataloader = DataLoader(
+ dataset=buffer, batch_size=2, num_workers=num_workers, persistent_workers=num_workers > 0
+ )
output = [convert_data_type(x, np.ndarray)[0] for x in dataloader]
+ buffer.seed += 1
+ output2 = [convert_data_type(x, np.ndarray)[0] for x in dataloader] # test repeating
if num_workers == 0:
np.testing.assert_allclose(output, [[2, 1], [3, 4]])
+ np.testing.assert_allclose(output2, [[3, 1], [2, 4]])
else: # multiprocess shuffle
- np.testing.assert_allclose(output, [[2, 3], [1, 4]])
+ np.testing.assert_allclose(output, [[2, 3], [1, 4]], err_msg=f"seed {buffer.seed}")
+ np.testing.assert_allclose(output2, [[1, 4], [2, 3]], err_msg=f"seed {buffer.seed}")
if __name__ == "__main__":
diff --git a/tests/test_smartcache_patch_wsi_dataset.py b/tests/test_smartcache_patch_wsi_dataset.py
index e2150edce5..5760264a7b 100644
--- a/tests/test_smartcache_patch_wsi_dataset.py
+++ b/tests/test_smartcache_patch_wsi_dataset.py
@@ -152,8 +152,7 @@ def test_read_patches(self, input_parameters, expected):
dataset.start()
i = 0
for _ in range(num_epochs):
- for j in range(len(dataset)):
- samples = dataset[j]
+ for samples in dataset:
n_patches = len(samples)
self.assert_samples_expected(samples, expected[i : i + n_patches])
i += n_patches
@@ -161,11 +160,11 @@ def test_read_patches(self, input_parameters, expected):
dataset.shutdown()
def assert_samples_expected(self, samples, expected):
- for i in range(len(samples)):
- self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape)
- self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape)
- self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"]))
- self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"]))
+ for i, item in enumerate(samples):
+ self.assertTupleEqual(item["label"].shape, expected[i]["label"].shape)
+ self.assertTupleEqual(item["image"].shape, expected[i]["image"].shape)
+ self.assertIsNone(assert_array_equal(item["label"], expected[i]["label"]))
+ self.assertIsNone(assert_array_equal(item["image"], expected[i]["image"]))
if __name__ == "__main__":
diff --git a/tests/test_smooth_field.py b/tests/test_smooth_field.py
index c67865ba39..b731af36f4 100644
--- a/tests/test_smooth_field.py
+++ b/tests/test_smooth_field.py
@@ -16,12 +16,19 @@
import torch
from parameterized import parameterized
+from monai.networks.utils import meshgrid_xy
from monai.transforms import RandSmoothDeformd, RandSmoothFieldAdjustContrastd, RandSmoothFieldAdjustIntensityd
from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env
_rtol = 5e-3 if is_tf32_env() else 1e-4
-INPUT_SHAPES = ((1, 8, 8), (2, 8, 8), (1, 8, 8, 8))
+x, y = meshgrid_xy(torch.linspace(-1, 2, 11), torch.linspace(-2.1, 1.2, 8))
+pattern2d = x.pow(2).add_(y.pow(2)).sqrt_()
+
+x, y, z = meshgrid_xy(torch.linspace(-1, 2, 11), torch.linspace(-2.1, 1.2, 8), torch.linspace(-0.1, 10.2, 6))
+pattern3d = x.pow(2).add_(y.pow(2)).add_(z.pow(2)).sqrt_()
+
+INPUT_SHAPES = ((1, 8, 8), (1, 12, 7), (2, 8, 8), (2, 13, 8), (1, 8, 8, 8), (3, 7, 4, 5))
TESTS_CONTRAST = []
TESTS_INTENSITY = []
@@ -131,6 +138,27 @@ def test_rand_smooth_deformd(self, input_param, input_data, expected_val):
expected = expected_val[key]
assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor")
+ def test_rand_smooth_nodeformd(self):
+ """Test input is very close to output when deformation is very low, verifies there's no transposition."""
+
+ for label, im in zip(("2D", "3D"), (pattern2d, pattern3d)):
+ with self.subTest(f"Testing {label} case with shape {im.shape}"):
+ rsize = (3,) * len(im.shape)
+ g = RandSmoothDeformd(
+ keys=(KEY,), spatial_size=im.shape, rand_size=rsize, prob=1.0, device=device, def_range=1e-20
+ )
+ g.set_random_state(123)
+
+ expected_val = {KEY: im[None]}
+
+ res = g(expected_val)
+ for key, result in res.items():
+ expected = expected_val[key]
+
+ self.assertSequenceEqual(tuple(result.shape), tuple(expected.shape))
+
+ assert_allclose(result, expected, rtol=_rtol, atol=1e-1, type_test="tensor")
+
def test_rand_smooth_deformd_pad(self):
input_param, input_data, expected_val = TESTS_DEFORM[0]
diff --git a/tests/test_spacing.py b/tests/test_spacing.py
index 6e56a21b63..244f14921c 100644
--- a/tests/test_spacing.py
+++ b/tests/test_spacing.py
@@ -20,7 +20,7 @@
from monai.data.utils import affine_to_spacing
from monai.transforms import Spacing
from monai.utils import ensure_tuple, fall_back_tuple
-from tests.utils import TEST_DEVICES, assert_allclose
+from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, assert_allclose
TESTS = []
for device in TEST_DEVICES:
@@ -223,8 +223,8 @@
TESTS_TORCH = []
for track_meta in (False, True):
- for device in TEST_DEVICES:
- TESTS_TORCH.append([[1.2, 1.3, 0.9], torch.zeros((1, 3, 4, 5)), track_meta, *device])
+ for p in TEST_NDARRAYS_ALL:
+ TESTS_TORCH.append([[1.2, 1.3, 0.9], p(torch.zeros((1, 3, 4, 5))), track_meta])
class TestSpacingCase(unittest.TestCase):
@@ -244,10 +244,9 @@ def test_spacing(self, init_param, img, affine, data_param, expected_output, dev
assert_allclose(fall_back_tuple(init_pixdim, norm), norm, type_test=False)
@parameterized.expand(TESTS_TORCH)
- def test_spacing_torch(self, pixdim, img: torch.Tensor, track_meta: bool, device):
+ def test_spacing_torch(self, pixdim, img, track_meta: bool):
set_track_meta(track_meta)
tr = Spacing(pixdim=pixdim)
- img = img.to(device)
res = tr(img)
if track_meta:
self.assertIsInstance(res, MetaTensor)
@@ -258,12 +257,13 @@ def test_spacing_torch(self, pixdim, img: torch.Tensor, track_meta: bool, device
self.assertIsInstance(res, torch.Tensor)
self.assertNotIsInstance(res, MetaTensor)
self.assertNotEqual(img.shape, res.shape)
+ set_track_meta(True)
@parameterized.expand(TEST_DEVICES)
def test_inverse(self, device):
img_t = torch.rand((1, 10, 9, 8), dtype=torch.float32, device=device)
affine = torch.tensor(
- [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device=device
+ [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device="cpu"
)
meta = {"fname": "somewhere"}
img = MetaTensor(img_t, affine=affine, meta=meta)
diff --git a/tests/test_spatial_resample.py b/tests/test_spatial_resample.py
index 63260373d0..74480cd415 100644
--- a/tests/test_spatial_resample.py
+++ b/tests/test_spatial_resample.py
@@ -10,16 +10,17 @@
# limitations under the License.
import unittest
+from copy import deepcopy
import numpy as np
import torch
from parameterized import parameterized
-from monai.config import USE_COMPILED
from monai.data.meta_obj import set_track_meta
from monai.data.meta_tensor import MetaTensor
from monai.data.utils import to_affine_nd
from monai.transforms import SpatialResample
+from monai.utils import optional_import
from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, assert_allclose
TESTS = []
@@ -37,7 +38,7 @@
for dst, expct in zip(destinations_3d, expected_3d):
for device in TEST_DEVICES:
for align in (False, True):
- interp = ("nearest", "bilinear", 0, 1) if align and USE_COMPILED else ("nearest", "bilinear")
+ interp = ("nearest", "bilinear")
for interp_mode in interp:
for padding_mode in ("zeros", "border", "reflection"):
TESTS.append(
@@ -54,6 +55,9 @@
expct,
]
)
+if optional_import("cupy")[1] and optional_import("scipy.ndimage")[1]:
+ TESTS.append(deepcopy(TESTS[-1]))
+ TESTS[-1][2].update({"align_corners": True, "mode": 1, "padding_mode": "reflect"}) # type: ignore
destinations_2d = [
@@ -148,7 +152,7 @@ def test_4d_5d(self, new_shape, tile, device, dtype, expected_data):
dst = torch.tensor([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 1.5], [0.0, 0.0, 0.0, 1.0]])
dst = dst.to(dtype)
- out = SpatialResample(dtype=dtype)(img=img, dst_affine=dst)
+ out = SpatialResample(dtype=dtype, align_corners=True)(img=img, dst_affine=dst, align_corners=False)
assert_allclose(out, expected_data[None], rtol=1e-2, atol=1e-2)
assert_allclose(out.affine, dst.to(torch.float32), rtol=1e-2, atol=1e-2)
@@ -168,6 +172,12 @@ def test_ill_affine(self, device):
img.affine = torch.eye(4)
dst_affine = torch.eye(4) * 0.1
SpatialResample(mode=None)(img=img, dst_affine=dst_affine)
+ if not (optional_import("scipy")[1] and optional_import("cupy")[1]):
+ return
+ with self.assertRaises(ValueError): # requires scipy
+ SpatialResample(mode=1, align_corners=True)(img=img, dst_affine=dst_affine)
+ with self.assertRaises(ValueError):
+ SpatialResample(mode=1, align_corners=False)(img=img, dst_affine=dst_affine)
@parameterized.expand(TEST_TORCH_INPUT)
def test_input_torch(self, new_shape, tile, device, dtype, expected_data, track_meta):
diff --git a/tests/test_spatial_resampled.py b/tests/test_spatial_resampled.py
index 3772cf0ddf..b9c221124c 100644
--- a/tests/test_spatial_resampled.py
+++ b/tests/test_spatial_resampled.py
@@ -15,7 +15,6 @@
import torch
from parameterized import parameterized
-from monai.config import USE_COMPILED
from monai.data.meta_tensor import MetaTensor
from monai.data.utils import to_affine_nd
from monai.transforms.spatial.dictionary import SpatialResampled
@@ -37,7 +36,7 @@
for device in TEST_DEVICES:
for align in (True, False):
for dtype in (torch.float32, torch.float64):
- interp = ("nearest", "bilinear", 0, 1) if align and USE_COMPILED else ("nearest", "bilinear")
+ interp = ("nearest", "bilinear")
for interp_mode in interp:
for padding_mode in ("zeros", "border", "reflection"):
TESTS.append(
diff --git a/tests/test_ssim_loss.py b/tests/test_ssim_loss.py
new file mode 100644
index 0000000000..d1d2b89056
--- /dev/null
+++ b/tests/test_ssim_loss.py
@@ -0,0 +1,53 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.losses.ssim_loss import SSIMLoss
+
+x = torch.ones([1, 1, 10, 10]) / 2
+y1 = torch.ones([1, 1, 10, 10]) / 2
+y2 = torch.zeros([1, 1, 10, 10])
+data_range = x.max().unsqueeze(0)
+TESTS2D = []
+for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]:
+ TESTS2D.append((x.to(device), y1.to(device), data_range.to(device), torch.tensor(1.0).unsqueeze(0).to(device)))
+ TESTS2D.append((x.to(device), y2.to(device), data_range.to(device), torch.tensor(0.0).unsqueeze(0).to(device)))
+
+x = torch.ones([1, 1, 10, 10, 10]) / 2
+y1 = torch.ones([1, 1, 10, 10, 10]) / 2
+y2 = torch.zeros([1, 1, 10, 10, 10])
+data_range = x.max().unsqueeze(0)
+TESTS3D = []
+for device in [None, "cpu", "cuda"] if torch.cuda.is_available() else [None, "cpu"]:
+ TESTS3D.append((x.to(device), y1.to(device), data_range.to(device), torch.tensor(1.0).unsqueeze(0).to(device)))
+ TESTS3D.append((x.to(device), y2.to(device), data_range.to(device), torch.tensor(0.0).unsqueeze(0).to(device)))
+
+
+class TestSSIMLoss(unittest.TestCase):
+ @parameterized.expand(TESTS2D)
+ def test2d(self, x, y, drange, res):
+ result = 1 - SSIMLoss(spatial_dims=2)(x, y, drange)
+ self.assertTrue(isinstance(result, torch.Tensor))
+ self.assertTrue(torch.abs(res - result).item() < 0.001)
+
+ @parameterized.expand(TESTS3D)
+ def test3d(self, x, y, drange, res):
+ result = 1 - SSIMLoss(spatial_dims=3)(x, y, drange)
+ self.assertTrue(isinstance(result, torch.Tensor))
+ self.assertTrue(torch.abs(res - result).item() < 0.001)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_ssim_metric.py b/tests/test_ssim_metric.py
new file mode 100644
index 0000000000..5cee58c30b
--- /dev/null
+++ b/tests/test_ssim_metric.py
@@ -0,0 +1,47 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.metrics.regression import SSIMMetric
+
+x = torch.ones([1, 1, 10, 10]) / 2
+y1 = torch.ones([1, 1, 10, 10]) / 2
+y2 = torch.zeros([1, 1, 10, 10])
+data_range = x.max().unsqueeze(0)
+TESTS2D = [(x, y1, data_range, torch.tensor(1.0).unsqueeze(0)), (x, y2, data_range, torch.tensor(0.0).unsqueeze(0))]
+
+x = torch.ones([1, 1, 10, 10, 10]) / 2
+y1 = torch.ones([1, 1, 10, 10, 10]) / 2
+y2 = torch.zeros([1, 1, 10, 10, 10])
+data_range = x.max().unsqueeze(0)
+TESTS3D = [(x, y1, data_range, torch.tensor(1.0).unsqueeze(0)), (x, y2, data_range, torch.tensor(0.0).unsqueeze(0))]
+
+
+class TestSSIMMetric(unittest.TestCase):
+ @parameterized.expand(TESTS2D)
+ def test2d(self, x, y, drange, res):
+ result = SSIMMetric(data_range=drange, spatial_dims=2)._compute_metric(x, y)
+ self.assertTrue(isinstance(result, torch.Tensor))
+ self.assertTrue(torch.abs(res - result).item() < 0.001)
+
+ @parameterized.expand(TESTS3D)
+ def test3d(self, x, y, drange, res):
+ result = SSIMMetric(data_range=drange, spatial_dims=3)._compute_metric(x, y)
+ self.assertTrue(isinstance(result, torch.Tensor))
+ self.assertTrue(torch.abs(res - result).item() < 0.001)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_str2bool.py b/tests/test_str2bool.py
new file mode 100644
index 0000000000..a7132aa97e
--- /dev/null
+++ b/tests/test_str2bool.py
@@ -0,0 +1,31 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+from monai.utils.misc import str2bool
+
+
+class TestStr2Bool(unittest.TestCase):
+ def test_str_2_bool(self):
+ for i in ("yes", "true", "t", "y", "1"):
+ self.assertTrue(str2bool(i))
+ for i in ("no", "false", "f", "n", "0"):
+ self.assertFalse(str2bool(i))
+ for bad_value in ("test", 0, 1, 2, None):
+ self.assertFalse(str2bool(bad_value, default=False, raise_exc=False))
+ self.assertTrue(str2bool(bad_value, default=True, raise_exc=False))
+ with self.assertRaises(ValueError):
+ self.assertTrue(str2bool(bad_value))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_swin_unetr.py b/tests/test_swin_unetr.py
index 0d48e99c44..6188d6225a 100644
--- a/tests/test_swin_unetr.py
+++ b/tests/test_swin_unetr.py
@@ -16,36 +16,38 @@
from parameterized import parameterized
from monai.networks import eval_mode
-from monai.networks.nets.swin_unetr import SwinUNETR
+from monai.networks.nets.swin_unetr import PatchMerging, PatchMergingV2, SwinUNETR
from monai.utils import optional_import
einops, has_einops = optional_import("einops")
TEST_CASE_SWIN_UNETR = []
+case_idx = 0
+test_merging_mode = ["mergingv2", "merging", PatchMerging, PatchMergingV2]
for attn_drop_rate in [0.4]:
for in_channels in [1]:
for depth in [[2, 1, 1, 1], [1, 2, 1, 1]]:
for out_channels in [2]:
- for img_size in [64]:
+ for img_size in ((64, 32, 192), (96, 32)):
for feature_size in [12]:
for norm_name in ["instance"]:
- for nd in (2, 3):
- test_case = [
- {
- "in_channels": in_channels,
- "out_channels": out_channels,
- "img_size": (img_size,) * nd,
- "feature_size": feature_size,
- "depths": depth,
- "norm_name": norm_name,
- "attn_drop_rate": attn_drop_rate,
- },
- (2, in_channels, *([img_size] * nd)),
- (2, out_channels, *([img_size] * nd)),
- ]
- if nd == 2:
- test_case[0]["spatial_dims"] = 2 # type: ignore
- TEST_CASE_SWIN_UNETR.append(test_case)
+ test_case = [
+ {
+ "spatial_dims": len(img_size),
+ "in_channels": in_channels,
+ "out_channels": out_channels,
+ "img_size": img_size,
+ "feature_size": feature_size,
+ "depths": depth,
+ "norm_name": norm_name,
+ "attn_drop_rate": attn_drop_rate,
+ "downsample": test_merging_mode[case_idx % 4],
+ },
+ (2, in_channels, *img_size),
+ (2, out_channels, *img_size),
+ ]
+ case_idx += 1
+ TEST_CASE_SWIN_UNETR.append(test_case)
class TestSWINUNETR(unittest.TestCase):
@@ -84,6 +86,11 @@ def test_ill_arg(self):
drop_rate=0.4,
)
+ def test_patch_merging(self):
+ dim = 10
+ t = PatchMerging(dim)(torch.zeros((1, 21, 20, 20, dim)))
+ self.assertEqual(t.shape, torch.Size([1, 11, 10, 10, 20]))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_tciadataset.py b/tests/test_tciadataset.py
new file mode 100644
index 0000000000..4da897f14b
--- /dev/null
+++ b/tests/test_tciadataset.py
@@ -0,0 +1,102 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import shutil
+import unittest
+
+from monai.apps import TciaDataset
+from monai.apps.tcia import TCIA_LABEL_DICT
+from monai.data import MetaTensor
+from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd
+from tests.utils import skip_if_downloading_fails, skip_if_quick
+
+
+class TestTciaDataset(unittest.TestCase):
+ @skip_if_quick
+ def test_values(self):
+ testing_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testing_data")
+ download_len = 1
+ val_frac = 1.0
+ collection = "QIN-PROSTATE-Repeatability"
+
+ transform = Compose(
+ [
+ LoadImaged(keys=["image", "seg"], reader="PydicomReader", label_dict=TCIA_LABEL_DICT[collection]),
+ AddChanneld(keys="image"),
+ ScaleIntensityd(keys="image"),
+ ]
+ )
+
+ def _test_dataset(dataset):
+ self.assertEqual(len(dataset), int(download_len * val_frac))
+ self.assertTrue("image" in dataset[0])
+ self.assertTrue("seg" in dataset[0])
+ self.assertTrue(isinstance(dataset[0]["image"], MetaTensor))
+ self.assertTupleEqual(dataset[0]["image"].shape, (1, 256, 256, 24))
+ self.assertTupleEqual(dataset[0]["seg"].shape, (256, 256, 24, 4))
+
+ with skip_if_downloading_fails():
+ data = TciaDataset(
+ root_dir=testing_dir,
+ collection=collection,
+ transform=transform,
+ section="validation",
+ download=True,
+ download_len=download_len,
+ copy_cache=False,
+ val_frac=val_frac,
+ )
+
+ _test_dataset(data)
+ data = TciaDataset(
+ root_dir=testing_dir,
+ collection=collection,
+ transform=transform,
+ section="validation",
+ download=False,
+ val_frac=val_frac,
+ )
+ _test_dataset(data)
+ self.assertTrue(
+ data[0]["image"].meta["filename_or_obj"].endswith("QIN-PROSTATE-Repeatability/PCAMPMRI-00015/1901/image")
+ )
+ self.assertTrue(
+ data[0]["seg"].meta["filename_or_obj"].endswith("QIN-PROSTATE-Repeatability/PCAMPMRI-00015/1901/seg")
+ )
+ # test validation without transforms
+ data = TciaDataset(
+ root_dir=testing_dir, collection=collection, section="validation", download=False, val_frac=val_frac
+ )
+ self.assertTupleEqual(data[0]["image"].shape, (256, 256, 24))
+ self.assertEqual(len(data), int(download_len * val_frac))
+ data = TciaDataset(
+ root_dir=testing_dir, collection=collection, section="validation", download=False, val_frac=val_frac
+ )
+ self.assertTupleEqual(data[0]["image"].shape, (256, 256, 24))
+ self.assertEqual(len(data), download_len)
+
+ shutil.rmtree(os.path.join(testing_dir, collection))
+ try:
+ TciaDataset(
+ root_dir=testing_dir,
+ collection=collection,
+ transform=transform,
+ section="validation",
+ download=False,
+ val_frac=val_frac,
+ )
+ except RuntimeError as e:
+ self.assertTrue(str(e).startswith("Cannot find dataset directory"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_to_from_meta_tensord.py b/tests/test_to_from_meta_tensord.py
index 6f46055d6a..43a0e99081 100644
--- a/tests/test_to_from_meta_tensord.py
+++ b/tests/test_to_from_meta_tensord.py
@@ -15,22 +15,22 @@
from copy import deepcopy
from typing import Optional, Union
+import numpy as np
import torch
from parameterized import parameterized
+from monai import config
from monai.data.meta_tensor import MetaTensor
from monai.transforms import FromMetaTensord, ToMetaTensord
from monai.utils.enums import PostFix
-from monai.utils.module import get_torch_version_tuple
from tests.utils import TEST_DEVICES, assert_allclose
-PT_VER_MAJ, PT_VER_MIN = get_torch_version_tuple()
-
DTYPES = [[torch.float32], [torch.float64], [torch.float16], [torch.int64], [torch.int32]]
TESTS = []
for _device in TEST_DEVICES:
for _dtype in DTYPES:
- TESTS.append((*_device, *_dtype))
+ for _data_type in ("tensor", "numpy"):
+ TESTS.append((*_device, *_dtype, _data_type))
def rand_string(min_len=5, max_len=10):
@@ -39,11 +39,12 @@ def rand_string(min_len=5, max_len=10):
return "".join(random.choice(chars) for _ in range(str_size))
+@unittest.skipIf(config.USE_META_DICT, "skipping not metatensor")
class TestToFromMetaTensord(unittest.TestCase):
@staticmethod
def get_im(shape=None, dtype=None, device=None):
if shape is None:
- shape = shape = (1, 10, 8)
+ shape = (1, 10, 8)
affine = torch.randint(0, 10, (4, 4))
meta = {"fname": rand_string()}
t = torch.rand(shape)
@@ -68,7 +69,7 @@ def check(
ids: bool = True,
device: Optional[Union[str, torch.device]] = None,
meta: bool = True,
- check_ids: bool = True,
+ check_ids: bool = False,
**kwargs,
):
if device is None:
@@ -92,13 +93,12 @@ def check(
del out_meta_no_affine["affine"]
self.assertEqual(orig_meta_no_affine, out_meta_no_affine)
assert_allclose(out.affine, orig.affine)
- self.assertTrue(str(device) in str(out.affine.device))
if check_ids:
self.check_ids(out.affine, orig.affine, ids)
self.check_ids(out.meta, orig.meta, ids)
@parameterized.expand(TESTS)
- def test_from_to_meta_tensord(self, device, dtype):
+ def test_from_to_meta_tensord(self, device, dtype, data_type="tensor"):
m1 = self.get_im(device=device, dtype=dtype)
m2 = self.get_im(device=device, dtype=dtype)
m3 = self.get_im(device=device, dtype=dtype)
@@ -107,7 +107,7 @@ def test_from_to_meta_tensord(self, device, dtype):
m1_aff = m1.affine
# FROM -> forward
- t_from_meta = FromMetaTensord(["m1", "m2"])
+ t_from_meta = FromMetaTensord(["m1", "m2"], data_type=data_type)
d_dict = t_from_meta(d_metas)
self.assertEqual(
@@ -123,7 +123,10 @@ def test_from_to_meta_tensord(self, device, dtype):
],
)
self.check(d_dict["m3"], m3, ids=True) # unchanged
- self.check(d_dict["m1"], m1.as_tensor(), ids=False)
+ if data_type == "tensor":
+ self.check(d_dict["m1"], m1.as_tensor(), ids=False)
+ else:
+ self.assertIsInstance(d_dict["m1"], np.ndarray)
meta_out = {k: v for k, v in d_dict["m1_meta_dict"].items() if k != "affine"}
aff_out = d_dict["m1_meta_dict"]["affine"]
self.check(aff_out, m1_aff, ids=False)
@@ -132,7 +135,8 @@ def test_from_to_meta_tensord(self, device, dtype):
# FROM -> inverse
d_meta_dict_meta = t_from_meta.inverse(d_dict)
self.assertEqual(sorted(d_meta_dict_meta.keys()), ["m1", "m2", "m3"])
- self.check(d_meta_dict_meta["m3"], m3, ids=False) # unchanged (except deep copy in inverse)
+ if data_type == "numpy":
+ m1, m1_aff = m1.cpu(), m1_aff.cpu()
self.check(d_meta_dict_meta["m1"], m1, ids=False)
meta_out = {k: v for k, v in d_meta_dict_meta["m1"].meta.items() if k != "affine"}
aff_out = d_meta_dict_meta["m1"].affine
@@ -142,7 +146,7 @@ def test_from_to_meta_tensord(self, device, dtype):
# TO -> Forward
t_to_meta = ToMetaTensord(["m1", "m2"])
d_dict_meta = t_to_meta(d_dict)
- self.assertEqual(sorted(d_dict_meta.keys()), ["m1", "m2", "m3"])
+ self.assertEqual(sorted(d_dict_meta.keys()), ["m1", "m2", "m3"], f"flag: {config.USE_META_DICT}")
self.check(d_dict_meta["m3"], m3, ids=True) # unchanged (except deep copy in inverse)
self.check(d_dict_meta["m1"], m1, ids=False)
meta_out = {k: v for k, v in d_dict_meta["m1"].meta.items() if k != "affine"}
diff --git a/tests/test_to_tensor.py b/tests/test_to_tensor.py
index fe6c4fb0d4..cd1a814f21 100644
--- a/tests/test_to_tensor.py
+++ b/tests/test_to_tensor.py
@@ -40,7 +40,7 @@ def test_array_input(self, test_data, expected_shape):
@parameterized.expand(TESTS_SINGLE)
def test_single_input(self, test_data):
- result = ToTensor()(test_data)
+ result = ToTensor(track_meta=True)(test_data)
self.assertTrue(isinstance(result, torch.Tensor))
assert_allclose(result, test_data, type_test=False)
self.assertEqual(result.ndim, 0)
diff --git a/tests/test_torchvision_fc_model.py b/tests/test_torchvision_fc_model.py
index 98b300eeac..942d8f907f 100644
--- a/tests/test_torchvision_fc_model.py
+++ b/tests/test_torchvision_fc_model.py
@@ -16,10 +16,13 @@
from parameterized import parameterized
from monai.networks import eval_mode
-from monai.networks.nets import TorchVisionFCModel
-from monai.utils import optional_import
+from monai.networks.nets import TorchVisionFCModel, UNet
+from monai.networks.utils import look_up_named_module, set_named_module
+from monai.utils import min_version, optional_import
-_, has_tv = optional_import("torchvision")
+Inception_V3_Weights, has_enum = optional_import("torchvision.models.inception", name="Inception_V3_Weights")
+
+_, has_tv = optional_import("torchvision", "0.12", min_version)
device = "cuda" if torch.cuda.is_available() else "cpu"
@@ -71,6 +74,25 @@
(2, 5),
]
+TEST_CASE_7 = [
+ {
+ "model_name": "inception_v3",
+ "num_classes": 5,
+ "use_conv": True,
+ "pool": "",
+ "in_channels": 2048,
+ "node_name": "Mixed_7c.cat_2",
+ },
+ (2, 3, 299, 299),
+ (2, 5, 8, 8),
+]
+
+TEST_CASE_8 = [
+ {"model_name": "vit_b_16", "num_classes": 5, "in_channels": 768, "pool": None, "fc_name": "heads.head"},
+ (2, 3, 224, 224),
+ (2, 5),
+]
+
TEST_CASE_PRETRAINED_0 = [
{"model_name": "resnet18", "num_classes": 1, "use_conv": True, "pretrained": True},
(2, 3, 224, 224),
@@ -114,8 +136,25 @@
]
+TEST_CASE_PRETRAINED_6 = [
+ {
+ "model_name": "inception_v3",
+ "num_classes": 5,
+ "use_conv": False,
+ "pool": None,
+ "weights": Inception_V3_Weights.IMAGENET1K_V1 if has_enum else None,
+ },
+ (2, 3, 299, 299),
+ (2, 5),
+ -0.21029122173786163,
+]
+
+
class TestTorchVisionFCModel(unittest.TestCase):
- @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6])
+ @parameterized.expand(
+ [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7]
+ + ([TEST_CASE_8] if has_enum else [])
+ )
@skipUnless(has_tv, "Requires TorchVision.")
def test_without_pretrained(self, input_param, input_shape, expected_shape):
net = TorchVisionFCModel(**input_param).to(device)
@@ -132,16 +171,28 @@ def test_without_pretrained(self, input_param, input_shape, expected_shape):
TEST_CASE_PRETRAINED_4,
TEST_CASE_PRETRAINED_5,
]
+ + ([TEST_CASE_PRETRAINED_6] if has_enum else [])
)
@skipUnless(has_tv, "Requires TorchVision.")
def test_with_pretrained(self, input_param, input_shape, expected_shape, expected_value):
net = TorchVisionFCModel(**input_param).to(device)
with eval_mode(net):
result = net.forward(torch.randn(input_shape).to(device))
- value = next(net.parameters())[0, 0, 0, 0].item()
+ value = next(net.features.parameters())[0, 0, 0, 0].item()
self.assertEqual(value, expected_value)
self.assertEqual(result.shape, expected_shape)
+class TestLookup(unittest.TestCase):
+ def test_get_module(self):
+ net = UNet(spatial_dims=2, in_channels=1, out_channels=1, channels=(4, 8, 16, 32, 64), strides=(2, 2, 2, 2))
+ self.assertEqual(look_up_named_module("", net), net)
+ mod = look_up_named_module("model.1.submodule.1.submodule.1.submodule.0.conv", net)
+ self.assertTrue(str(mod).startswith("Conv2d"))
+ self.assertIsInstance(set_named_module(net, "model", torch.nn.Identity()).model, torch.nn.Identity)
+ self.assertEqual(look_up_named_module("model.1.submodule.1.submodule.1.submodule.conv", net), None)
+ self.assertEqual(look_up_named_module("test attribute", net), None)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_transform.py b/tests/test_transform.py
new file mode 100644
index 0000000000..9cf18b8dbd
--- /dev/null
+++ b/tests/test_transform.py
@@ -0,0 +1,57 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import unittest
+
+import monai.transforms as mt
+from monai.data import Dataset
+
+
+class FaultyTransform(mt.Transform):
+ def __call__(self, _):
+ raise RuntimeError
+
+
+def faulty_lambda(_):
+ raise RuntimeError
+
+
+class TestTransform(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super(__class__, cls).setUpClass()
+ cls.orig_value = os.environ.get("MONAI_DEBUG", None)
+
+ @classmethod
+ def tearDownClass(cls):
+ if cls.orig_value is not None:
+ os.environ["MONAI_DEBUG"] = cls.orig_value
+ else:
+ os.environ.pop("MONAI_DEBUG")
+ super(__class__, cls).tearDownClass()
+
+ def test_raise(self):
+ for transform in (FaultyTransform(), mt.Lambda(faulty_lambda)):
+ ds = Dataset([None] * 10, transform)
+ for debug in ("False", "True"):
+ os.environ["MONAI_DEBUG"] = debug
+ try:
+ ds[0]
+ except RuntimeError as re:
+ if debug == "False":
+ self.assertTrue("applying transform" in str(re))
+ else:
+ self.assertFalse("applying transform" in str(re))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_utils_pytorch_numpy_unification.py b/tests/test_utils_pytorch_numpy_unification.py
index df4db8a27c..7041a09f52 100644
--- a/tests/test_utils_pytorch_numpy_unification.py
+++ b/tests/test_utils_pytorch_numpy_unification.py
@@ -12,11 +12,12 @@
import unittest
import numpy as np
+import torch
from parameterized import parameterized
from monai.transforms.utils_pytorch_numpy_unification import mode, percentile
from monai.utils import set_determinism
-from tests.utils import TEST_NDARRAYS, assert_allclose
+from tests.utils import TEST_NDARRAYS, assert_allclose, skip_if_quick
TEST_MODE = []
for p in TEST_NDARRAYS:
@@ -33,11 +34,23 @@ def test_percentile(self):
for size in (1, 100):
q = np.random.randint(0, 100, size=size)
results = []
- for p in TEST_NDARRAYS:
- arr = p(np.arange(100 * 101).reshape(1, 100, 101).astype(np.float32))
+ for idx, p in enumerate(TEST_NDARRAYS):
+ dtype = [np.float32, float][idx % 2]
+ arr = p(np.arange(100 * 101).reshape(1, 100, 101).astype(dtype))
results.append(percentile(arr, q))
assert_allclose(results[0], results[-1], type_test=False, atol=1e-4, rtol=1e-4)
+ @skip_if_quick
+ def test_many_elements_quantile(self): # pytorch#64947
+ for p in TEST_NDARRAYS:
+ for elements in (1000, 17_000_000):
+ for t in [*TEST_NDARRAYS, list]:
+ x = p(np.random.randn(elements))
+ q = percentile(x, t([10, 50]))
+ if isinstance(x, torch.Tensor):
+ self.assertIsInstance(q, torch.Tensor)
+ assert_allclose(q.shape, [2], type_test=False)
+
def test_fails(self):
for p in TEST_NDARRAYS:
for q in (-1, 101):
diff --git a/tests/test_varnet.py b/tests/test_varnet.py
new file mode 100644
index 0000000000..4e8fd9c92e
--- /dev/null
+++ b/tests/test_varnet.py
@@ -0,0 +1,60 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.apps.reconstruction.networks.nets.coil_sensitivity_model import CoilSensitivityModel
+from monai.apps.reconstruction.networks.nets.complex_unet import ComplexUnet
+from monai.apps.reconstruction.networks.nets.varnet import VariationalNetworkModel
+from monai.networks import eval_mode
+from tests.utils import test_script_save
+
+device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+coil_sens_model = CoilSensitivityModel(spatial_dims=2, features=[8, 16, 32, 64, 128, 8])
+refinement_model = ComplexUnet(spatial_dims=2, features=[8, 16, 32, 64, 128, 8])
+num_cascades = 12
+TESTS = []
+TESTS.append([coil_sens_model, refinement_model, num_cascades, (1, 10, 300, 200, 2), (1, 300, 200)]) # batch=1
+TESTS.append([coil_sens_model, refinement_model, num_cascades, (2, 10, 300, 200, 2), (2, 300, 200)]) # batch=2
+
+
+class TestVarNet(unittest.TestCase):
+ @parameterized.expand(TESTS)
+ def test_shape(self, coil_sens_model, refinement_model, num_cascades, input_shape, expected_shape):
+ net = VariationalNetworkModel(coil_sens_model, refinement_model, num_cascades).to(device)
+ mask_shape = [1 for _ in input_shape]
+ mask_shape[-2] = input_shape[-2]
+ mask = torch.zeros(mask_shape)
+ mask[..., mask_shape[-2] // 2 - 5 : mask_shape[-2] // 2 + 5, :] = 1
+
+ with eval_mode(net):
+ result = net(torch.randn(input_shape).to(device), mask.byte().to(device))
+ self.assertEqual(result.shape, expected_shape)
+
+ @parameterized.expand(TESTS)
+ def test_script(self, coil_sens_model, refinement_model, num_cascades, input_shape, expected_shape):
+ net = VariationalNetworkModel(coil_sens_model, refinement_model, num_cascades)
+
+ mask_shape = [1 for _ in input_shape]
+ mask_shape[-2] = input_shape[-2]
+ mask = torch.zeros(mask_shape)
+ mask[..., mask_shape[-2] // 2 - 5 : mask_shape[-2] // 2 + 5, :] = 1
+
+ test_data = torch.randn(input_shape)
+
+ test_script_save(net, test_data, mask.byte())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_video_datasets.py b/tests/test_video_datasets.py
new file mode 100644
index 0000000000..ac9e151f63
--- /dev/null
+++ b/tests/test_video_datasets.py
@@ -0,0 +1,145 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import unittest
+from typing import Type, Union
+
+import torch
+
+import monai.transforms as mt
+from monai.data.dataloader import DataLoader
+from monai.data.video_dataset import CameraDataset, VideoDataset, VideoFileDataset
+from monai.utils.module import optional_import
+from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config
+
+cv2, has_cv2 = optional_import("cv2")
+
+
+NUM_CAPTURE_DEVICES = CameraDataset.get_num_devices()
+TRANSFORMS = mt.Compose(
+ [mt.EnsureChannelFirst(), mt.DivisiblePad(16), mt.ScaleIntensity(), mt.CastToType(torch.float32)]
+)
+
+
+class Base:
+ class TestVideoDataset(unittest.TestCase):
+ video_source: Union[int, str]
+ ds: Type[VideoDataset]
+
+ def get_video_source(self):
+ return self.video_source
+
+ def get_ds(self, *args, **kwargs) -> VideoDataset:
+ return self.ds(video_source=self.get_video_source(), transform=TRANSFORMS, *args, **kwargs) # type: ignore
+
+ @unittest.skipIf(has_cv2, "Only tested when OpenCV not installed.")
+ def test_no_opencv_raises(self):
+ with self.assertRaises(RuntimeError):
+ _ = self.get_ds(max_num_frames=10)
+
+ @unittest.skipUnless(has_cv2, "OpenCV required.")
+ def test_multiprocessing(self):
+ for num_workers in (0, 2):
+ multiprocessing = num_workers > 0
+ ds = self.get_ds(max_num_frames=100, multiprocessing=multiprocessing)
+ dl = DataLoader(ds, num_workers=num_workers, batch_size=2)
+ _ = next(iter(dl))
+
+ @unittest.skipUnless(has_cv2, "OpenCV required.")
+ def test_multiple_sources(self, should_match: bool = True):
+ ds1 = self.get_ds()
+ ds2 = self.get_ds()
+ if should_match:
+ assert_allclose(ds1.get_frame(), ds2.get_frame())
+
+ @unittest.skipUnless(has_cv2, "OpenCV required.")
+ def test_dataset(self, known_num_frames=None, known_fps=None):
+ num_frames = (10,) if known_num_frames is None else (10, None)
+ for max_num_frames in num_frames:
+ ds = self.get_ds(max_num_frames=max_num_frames)
+ if known_fps is not None:
+ self.assertEqual(ds.get_fps(), known_fps)
+ frames = list(ds)
+ if max_num_frames is not None:
+ self.assertEqual(len(frames), max_num_frames)
+ elif known_num_frames is not None:
+ self.assertEqual(len(frames), len(ds))
+ for f in frames:
+ self.assertTupleEqual(f.shape, frames[0].shape)
+
+
+@unittest.skipIf(NUM_CAPTURE_DEVICES == 0, "At least one capture device required.")
+class TestCameraDataset(Base.TestVideoDataset):
+ video_source = 0
+ ds = CameraDataset
+
+ @unittest.skipUnless(has_cv2, "OpenCV required.")
+ def test_multiple_sources(self):
+ super().test_multiple_sources(should_match=False)
+
+ @unittest.skipUnless(has_cv2, "OpenCV required.")
+ def test_device_out_of_range(self):
+ capture_device = NUM_CAPTURE_DEVICES + 1
+ with self.assertRaises(RuntimeError):
+ _ = CameraDataset(capture_device, TRANSFORMS, 0)
+
+
+class TestVideoFileDataset(Base.TestVideoDataset):
+ ds = VideoFileDataset
+
+ @classmethod
+ def setUpClass(cls):
+ super(__class__, cls).setUpClass()
+ codecs = VideoFileDataset.get_available_codecs()
+ if ".mp4" in codecs.values():
+ fname = "endo.mp4"
+ config = testing_data_config("videos", "endovis")
+ cls.known_fps = 2.0
+ cls.known_num_frames = 23
+ elif ".avi" in codecs.values():
+ fname = "ultrasound.avi"
+ config = testing_data_config("videos", "ultrasound")
+ cls.known_fps = 2.0
+ cls.known_num_frames = 523
+ else:
+ cls.known_fps = None
+ cls.known_num_frames = None
+ cls.video_source = None
+ return
+ cls.video_source = os.path.join(os.path.dirname(__file__), "testing_data", fname)
+ download_url_or_skip_test(
+ url=config["url"],
+ filepath=cls.video_source,
+ hash_val=config.get("hash_val"),
+ hash_type=config.get("hash_type", "sha256"),
+ )
+
+ @unittest.skipUnless(has_cv2, "OpenCV required.")
+ def test_dataset(self):
+ super().test_dataset(self.known_num_frames, self.known_fps)
+ self.assertEqual(self.get_ds().get_num_frames(), self.known_num_frames)
+
+ def test_available_codecs(self):
+ codecs = VideoFileDataset.get_available_codecs()
+ if not has_cv2:
+ self.assertEqual(codecs, {})
+ else:
+ self.assertGreaterEqual(len(codecs), 0)
+
+ def get_video_source(self):
+ if self.video_source is None:
+ raise unittest.SkipTest("missing required codecs")
+ return super().get_video_source()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_vit.py b/tests/test_vit.py
index 3ef847626d..33a7902fad 100644
--- a/tests/test_vit.py
+++ b/tests/test_vit.py
@@ -50,6 +50,8 @@
]
if nd == 2:
test_case[0]["spatial_dims"] = 2 # type: ignore
+ if classification:
+ test_case[0]["post_activation"] = False # type: ignore
if test_case[0]["classification"]: # type: ignore
test_case[2] = (2, test_case[0]["num_classes"]) # type: ignore
TEST_CASE_Vit.append(test_case)
diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py
index a0a076b682..6597434945 100644
--- a/tests/test_wsireader.py
+++ b/tests/test_wsireader.py
@@ -14,16 +14,16 @@
from unittest import skipUnless
import numpy as np
-import torch
from numpy.testing import assert_array_equal
from parameterized import parameterized
from monai.data import DataLoader, Dataset
-from monai.data.image_reader import WSIReader
+from monai.data.image_reader import WSIReader as WSIReaderDeprecated
+from monai.data.wsi_reader import WSIReader
from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord
-from monai.utils import first, optional_import
+from monai.utils import deprecated, first, optional_import
from monai.utils.enums import PostFix
-from tests.utils import download_url_or_skip_test, testing_data_config
+from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config
cucim, has_cucim = optional_import("cucim")
has_cucim = has_cucim and hasattr(cucim, "CuImage")
@@ -44,19 +44,19 @@
TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)]
-TEST_CASE_1 = [
+TEST_CASE_DEP_1 = [
FILE_PATH,
{"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0},
np.array([[[246], [246]], [[246], [246]], [[246], [246]]]),
]
-TEST_CASE_2 = [
+TEST_CASE_DEP_2 = [
FILE_PATH,
{"location": (0, 0), "size": (2, 1), "level": 2},
np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
]
-TEST_CASE_3 = [
+TEST_CASE_DEP_3 = [
FILE_PATH,
{"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2},
np.array(
@@ -67,18 +67,58 @@
),
]
-TEST_CASE_4 = [
+TEST_CASE_DEP_4 = [
FILE_PATH,
{"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1},
np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]),
]
-TEST_CASE_5 = [
+TEST_CASE_DEP_5 = [
FILE_PATH,
{"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)},
np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]),
]
+TEST_CASE_1 = [
+ FILE_PATH,
+ {},
+ {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0},
+ np.array([[[246], [246]], [[246], [246]], [[246], [246]]]),
+]
+
+TEST_CASE_2 = [
+ FILE_PATH,
+ {},
+ {"location": (0, 0), "size": (2, 1), "level": 2},
+ np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
+]
+
+TEST_CASE_3 = [
+ FILE_PATH,
+ {"channel_dim": -1},
+ {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0},
+ np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1),
+]
+
+TEST_CASE_4 = [
+ FILE_PATH,
+ {"channel_dim": 2},
+ {"location": (0, 0), "size": (2, 1), "level": 2},
+ np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1),
+]
+
+TEST_CASE_MULTI_WSI = [
+ [FILE_PATH, FILE_PATH],
+ {"location": (0, 0), "size": (2, 1), "level": 2},
+ np.concatenate(
+ [
+ np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
+ np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
+ ],
+ axis=0,
+ ),
+]
+
TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW
@@ -108,6 +148,20 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str):
return filename
+def save_gray_tiff(array: np.ndarray, filename: str):
+ """
+ Save numpy array into a TIFF file
+
+ Args:
+ array: numpy ndarray with any shape
+ filename: the filename to be used for the tiff file.
+ """
+ img_gray = array
+ imwrite(filename, img_gray, shape=img_gray.shape)
+
+ return filename
+
+
@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!")
def setUpModule():
hash_type = testing_data_config("images", FILE_KEY, "hash_type")
@@ -115,21 +169,22 @@ def setUpModule():
download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val)
-class WSIReaderTests:
+@deprecated(since="0.8", msg_suffix="use tests for `monai.wsi_reader.WSIReader` instead, `WSIReaderTests`.")
+class WSIReaderDeprecatedTests:
class Tests(unittest.TestCase):
backend = None
@parameterized.expand([TEST_CASE_0])
def test_read_whole_image(self, file_path, level, expected_shape):
- reader = WSIReader(self.backend, level=level)
+ reader = WSIReaderDeprecated(self.backend, level=level)
with reader.read(file_path) as img_obj:
img = reader.get_data(img_obj)[0]
self.assertTupleEqual(img.shape, expected_shape)
- @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_5])
+ @parameterized.expand([TEST_CASE_DEP_1, TEST_CASE_DEP_2, TEST_CASE_DEP_5])
def test_read_region(self, file_path, patch_info, expected_img):
kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {}
- reader = WSIReader(self.backend, **kwargs)
+ reader = WSIReaderDeprecated(self.backend, **kwargs)
with reader.read(file_path, **kwargs) as img_obj:
if self.backend == "tifffile":
with self.assertRaises(ValueError):
@@ -143,9 +198,9 @@ def test_read_region(self, file_path, patch_info, expected_img):
self.assertTupleEqual(img.shape, expected_img.shape)
self.assertIsNone(assert_array_equal(img, expected_img))
- @parameterized.expand([TEST_CASE_3, TEST_CASE_4])
+ @parameterized.expand([TEST_CASE_DEP_3, TEST_CASE_DEP_4])
def test_read_patches(self, file_path, patch_info, expected_img):
- reader = WSIReader(self.backend)
+ reader = WSIReaderDeprecated(self.backend)
with reader.read(file_path) as img_obj:
if self.backend == "tifffile":
with self.assertRaises(ValueError):
@@ -155,6 +210,115 @@ def test_read_patches(self, file_path, patch_info, expected_img):
self.assertTupleEqual(img.shape, expected_img.shape)
self.assertIsNone(assert_array_equal(img, expected_img))
+ @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1])
+ @skipUnless(has_tiff, "Requires tifffile.")
+ def test_read_rgba(self, img_expected):
+ # skip for OpenSlide since not working with images without tiles
+ if self.backend == "openslide":
+ return
+ image = {}
+ reader = WSIReaderDeprecated(self.backend)
+ for mode in ["RGB", "RGBA"]:
+ file_path = save_rgba_tiff(
+ img_expected,
+ os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"),
+ mode=mode,
+ )
+ with reader.read(file_path) as img_obj:
+ image[mode], _ = reader.get_data(img_obj)
+
+ self.assertIsNone(assert_array_equal(image["RGB"], img_expected))
+ self.assertIsNone(assert_array_equal(image["RGBA"], img_expected))
+
+ @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D])
+ @skipUnless(has_tiff, "Requires tifffile.")
+ def test_read_malformats(self, img_expected):
+ if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1):
+ # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230
+ return
+ reader = WSIReaderDeprecated(self.backend)
+ file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff")
+ imwrite(file_path, img_expected, shape=img_expected.shape)
+ with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)):
+ with reader.read(file_path) as img_obj:
+ reader.get_data(img_obj)
+
+ @parameterized.expand([TEST_CASE_TRANSFORM_0])
+ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape):
+ train_transform = Compose(
+ [
+ LoadImaged(keys=["image"], reader=WSIReaderDeprecated, backend=self.backend, level=level),
+ FromMetaTensord(keys=["image"]),
+ ToTensord(keys=["image"]),
+ ]
+ )
+ dataset = Dataset([{"image": file_path}], transform=train_transform)
+ data_loader = DataLoader(dataset)
+ data: dict = first(data_loader)
+ for s in data[PostFix.meta("image")]["spatial_shape"]:
+ assert_allclose(s, expected_spatial_shape, type_test=False)
+ self.assertTupleEqual(data["image"].shape, expected_shape)
+
+
+class WSIReaderTests:
+ class Tests(unittest.TestCase):
+ backend = None
+
+ @parameterized.expand([TEST_CASE_0])
+ def test_read_whole_image(self, file_path, level, expected_shape):
+ reader = WSIReader(self.backend, level=level)
+ with reader.read(file_path) as img_obj:
+ img, meta = reader.get_data(img_obj)
+ self.assertTupleEqual(img.shape, expected_shape)
+ self.assertEqual(meta["backend"], self.backend)
+ self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
+ self.assertEqual(meta["patch_level"], level)
+ assert_array_equal(meta["patch_size"], expected_shape[1:])
+ assert_array_equal(meta["patch_location"], (0, 0))
+
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
+ def test_read_region(self, file_path, kwargs, patch_info, expected_img):
+ reader = WSIReader(self.backend, **kwargs)
+ with reader.read(file_path) as img_obj:
+ if self.backend == "tifffile":
+ with self.assertRaises(ValueError):
+ reader.get_data(img_obj, **patch_info)[0]
+ else:
+ # Read twice to check multiple calls
+ img, meta = reader.get_data(img_obj, **patch_info)
+ img2 = reader.get_data(img_obj, **patch_info)[0]
+ self.assertTupleEqual(img.shape, img2.shape)
+ self.assertIsNone(assert_array_equal(img, img2))
+ self.assertTupleEqual(img.shape, expected_img.shape)
+ self.assertIsNone(assert_array_equal(img, expected_img))
+ self.assertEqual(meta["backend"], self.backend)
+ self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
+ self.assertEqual(meta["patch_level"], patch_info["level"])
+ assert_array_equal(meta["patch_size"], patch_info["size"])
+ assert_array_equal(meta["patch_location"], patch_info["location"])
+
+ @parameterized.expand([TEST_CASE_MULTI_WSI])
+ def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img):
+ kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {}
+ reader = WSIReader(self.backend, **kwargs)
+ img_obj_list = reader.read(file_path_list, **kwargs)
+ if self.backend == "tifffile":
+ with self.assertRaises(ValueError):
+ reader.get_data(img_obj_list, **patch_info)[0]
+ else:
+ # Read twice to check multiple calls
+ img, meta = reader.get_data(img_obj_list, **patch_info)
+ img2 = reader.get_data(img_obj_list, **patch_info)[0]
+ self.assertTupleEqual(img.shape, img2.shape)
+ self.assertIsNone(assert_array_equal(img, img2))
+ self.assertTupleEqual(img.shape, expected_img.shape)
+ self.assertIsNone(assert_array_equal(img, expected_img))
+ self.assertEqual(meta["backend"], self.backend)
+ self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0])))
+ self.assertEqual(meta["patch_level"][0], patch_info["level"])
+ assert_array_equal(meta["patch_size"][0], expected_img.shape[1:])
+ assert_array_equal(meta["patch_location"][0], patch_info["location"])
+
@parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1])
@skipUnless(has_tiff, "Requires tifffile.")
def test_read_rgba(self, img_expected):
@@ -201,30 +365,92 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte
data_loader = DataLoader(dataset)
data: dict = first(data_loader)
for s in data[PostFix.meta("image")]["spatial_shape"]:
- torch.testing.assert_allclose(s, expected_spatial_shape)
+ assert_allclose(s, expected_spatial_shape, type_test=False)
self.assertTupleEqual(data["image"].shape, expected_shape)
+ @parameterized.expand([TEST_CASE_TRANSFORM_0])
+ def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape):
+ train_transform = Compose(
+ [
+ LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level),
+ FromMetaTensord(keys=["image"]),
+ ToTensord(keys=["image"]),
+ ]
+ )
+ dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform)
+ batch_size = 2
+ data_loader = DataLoader(dataset, batch_size=batch_size)
+ data: dict = first(data_loader)
+ for s in data[PostFix.meta("image")]["spatial_shape"]:
+ assert_allclose(s, expected_spatial_shape, type_test=False)
+ self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:]))
+
+ @parameterized.expand([TEST_CASE_0])
+ def test_read_whole_image_multi_thread(self, file_path, level, expected_shape):
+ if self.backend == "cucim":
+ reader = WSIReader(self.backend, level=level, num_workers=4)
+ with reader.read(file_path) as img_obj:
+ img, meta = reader.get_data(img_obj)
+ self.assertTupleEqual(img.shape, expected_shape)
+ self.assertEqual(meta["backend"], self.backend)
+ self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
+ self.assertEqual(meta["patch_level"], level)
+ assert_array_equal(meta["patch_size"], expected_shape[1:])
+ assert_array_equal(meta["patch_location"], (0, 0))
+
+ @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
+ def test_read_region_multi_thread(self, file_path, kwargs, patch_info, expected_img):
+ if self.backend == "cucim":
+ reader = WSIReader(backend=self.backend, num_workers=2, **kwargs)
+ with reader.read(file_path) as img_obj:
+ # Read twice to check multiple calls
+ img, meta = reader.get_data(img_obj, **patch_info)
+ img2 = reader.get_data(img_obj, **patch_info)[0]
+ self.assertTupleEqual(img.shape, img2.shape)
+ self.assertIsNone(assert_array_equal(img, img2))
+ self.assertTupleEqual(img.shape, expected_img.shape)
+ self.assertIsNone(assert_array_equal(img, expected_img))
+ self.assertEqual(meta["backend"], self.backend)
+ self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
+ self.assertEqual(meta["patch_level"], patch_info["level"])
+ assert_array_equal(meta["patch_size"], patch_info["size"])
+ assert_array_equal(meta["patch_location"], patch_info["location"])
+
@skipUnless(has_cucim, "Requires cucim")
-class TestCuCIM(WSIReaderTests.Tests):
+class TestCuCIMDeprecated(WSIReaderDeprecatedTests.Tests):
@classmethod
def setUpClass(cls):
cls.backend = "cucim"
@skipUnless(has_osl, "Requires OpenSlide")
-class TestOpenSlide(WSIReaderTests.Tests):
+class TestOpenSlideDeprecated(WSIReaderDeprecatedTests.Tests):
@classmethod
def setUpClass(cls):
cls.backend = "openslide"
@skipUnless(has_tiff, "Requires TiffFile")
-class TestTiffFile(WSIReaderTests.Tests):
+class TestTiffFileDeprecated(WSIReaderDeprecatedTests.Tests):
@classmethod
def setUpClass(cls):
cls.backend = "tifffile"
+@skipUnless(has_cucim, "Requires cucim")
+class TestCuCIM(WSIReaderTests.Tests):
+ @classmethod
+ def setUpClass(cls):
+ cls.backend = "cucim"
+
+
+@skipUnless(has_osl, "Requires openslide")
+class TestOpenSlide(WSIReaderTests.Tests):
+ @classmethod
+ def setUpClass(cls):
+ cls.backend = "openslide"
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py
deleted file mode 100644
index 0d5e5892e6..0000000000
--- a/tests/test_wsireader_new.py
+++ /dev/null
@@ -1,277 +0,0 @@
-# Copyright (c) MONAI Consortium
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-# http://www.apache.org/licenses/LICENSE-2.0
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import os
-import unittest
-from unittest import skipUnless
-
-import numpy as np
-from numpy.testing import assert_array_equal
-from parameterized import parameterized
-
-from monai.data import DataLoader, Dataset
-from monai.data.wsi_reader import WSIReader
-from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord
-from monai.utils import first, optional_import
-from monai.utils.enums import PostFix
-from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config
-
-cucim, has_cucim = optional_import("cucim")
-has_cucim = has_cucim and hasattr(cucim, "CuImage")
-openslide, has_osl = optional_import("openslide")
-imwrite, has_tiff = optional_import("tifffile", name="imwrite")
-_, has_codec = optional_import("imagecodecs")
-has_tiff = has_tiff and has_codec
-
-FILE_KEY = "wsi_img"
-FILE_URL = testing_data_config("images", FILE_KEY, "url")
-base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff"
-FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension)
-
-HEIGHT = 32914
-WIDTH = 46000
-
-TEST_CASE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)]
-
-TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)]
-
-TEST_CASE_1 = [
- FILE_PATH,
- {},
- {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0},
- np.array([[[246], [246]], [[246], [246]], [[246], [246]]]),
-]
-
-TEST_CASE_2 = [
- FILE_PATH,
- {},
- {"location": (0, 0), "size": (2, 1), "level": 2},
- np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
-]
-
-TEST_CASE_3 = [
- FILE_PATH,
- {"channel_dim": -1},
- {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0},
- np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1),
-]
-
-TEST_CASE_4 = [
- FILE_PATH,
- {"channel_dim": 2},
- {"location": (0, 0), "size": (2, 1), "level": 2},
- np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1),
-]
-
-TEST_CASE_MULTI_WSI = [
- [FILE_PATH, FILE_PATH],
- {"location": (0, 0), "size": (2, 1), "level": 2},
- np.concatenate(
- [
- np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
- np.array([[[239], [239]], [[239], [239]], [[239], [239]]]),
- ],
- axis=0,
- ),
-]
-
-
-TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW
-
-TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW
-
-TEST_CASE_ERROR_0C = [np.ones((16, 16), dtype=np.uint8)] # no color channel
-TEST_CASE_ERROR_1C = [np.ones((16, 16, 1), dtype=np.uint8)] # one color channel
-TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels
-TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color
-
-
-def save_rgba_tiff(array: np.ndarray, filename: str, mode: str):
- """
- Save numpy array into a TIFF RGB/RGBA file
-
- Args:
- array: numpy ndarray with the shape of CxHxW and C==3 representing a RGB image
- filename: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename.
- mode: RGB or RGBA
- """
- if mode == "RGBA":
- array = np.concatenate([array, 255 * np.ones_like(array[0])[np.newaxis]]).astype(np.uint8)
-
- img_rgb = array.transpose(1, 2, 0)
- imwrite(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16))
-
- return filename
-
-
-def save_gray_tiff(array: np.ndarray, filename: str):
- """
- Save numpy array into a TIFF file
-
- Args:
- array: numpy ndarray with any shape
- filename: the filename to be used for the tiff file.
- """
- img_gray = array
- imwrite(filename, img_gray, shape=img_gray.shape)
-
- return filename
-
-
-@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!")
-def setUpModule():
- hash_type = testing_data_config("images", FILE_KEY, "hash_type")
- hash_val = testing_data_config("images", FILE_KEY, "hash_val")
- download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val)
-
-
-class WSIReaderTests:
- class Tests(unittest.TestCase):
- backend = None
-
- @parameterized.expand([TEST_CASE_0])
- def test_read_whole_image(self, file_path, level, expected_shape):
- reader = WSIReader(self.backend, level=level)
- with reader.read(file_path) as img_obj:
- img, meta = reader.get_data(img_obj)
- self.assertTupleEqual(img.shape, expected_shape)
- self.assertEqual(meta["backend"], self.backend)
- self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
- self.assertEqual(meta["patch_level"], level)
- assert_array_equal(meta["patch_size"], expected_shape[1:])
- assert_array_equal(meta["patch_location"], (0, 0))
-
- @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
- def test_read_region(self, file_path, kwargs, patch_info, expected_img):
- reader = WSIReader(self.backend, **kwargs)
- with reader.read(file_path) as img_obj:
- if self.backend == "tifffile":
- with self.assertRaises(ValueError):
- reader.get_data(img_obj, **patch_info)[0]
- else:
- # Read twice to check multiple calls
- img, meta = reader.get_data(img_obj, **patch_info)
- img2 = reader.get_data(img_obj, **patch_info)[0]
- self.assertTupleEqual(img.shape, img2.shape)
- self.assertIsNone(assert_array_equal(img, img2))
- self.assertTupleEqual(img.shape, expected_img.shape)
- self.assertIsNone(assert_array_equal(img, expected_img))
- self.assertEqual(meta["backend"], self.backend)
- self.assertEqual(meta["path"], str(os.path.abspath(file_path)))
- self.assertEqual(meta["patch_level"], patch_info["level"])
- assert_array_equal(meta["patch_size"], patch_info["size"])
- assert_array_equal(meta["patch_location"], patch_info["location"])
-
- @parameterized.expand([TEST_CASE_MULTI_WSI])
- def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img):
- kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {}
- reader = WSIReader(self.backend, **kwargs)
- img_obj_list = reader.read(file_path_list, **kwargs)
- if self.backend == "tifffile":
- with self.assertRaises(ValueError):
- reader.get_data(img_obj_list, **patch_info)[0]
- else:
- # Read twice to check multiple calls
- img, meta = reader.get_data(img_obj_list, **patch_info)
- img2 = reader.get_data(img_obj_list, **patch_info)[0]
- self.assertTupleEqual(img.shape, img2.shape)
- self.assertIsNone(assert_array_equal(img, img2))
- self.assertTupleEqual(img.shape, expected_img.shape)
- self.assertIsNone(assert_array_equal(img, expected_img))
- self.assertEqual(meta["backend"], self.backend)
- self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0])))
- self.assertEqual(meta["patch_level"][0], patch_info["level"])
- assert_array_equal(meta["patch_size"][0], expected_img.shape[1:])
- assert_array_equal(meta["patch_location"][0], patch_info["location"])
-
- @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1])
- @skipUnless(has_tiff, "Requires tifffile.")
- def test_read_rgba(self, img_expected):
- # skip for OpenSlide since not working with images without tiles
- if self.backend == "openslide":
- return
- image = {}
- reader = WSIReader(self.backend)
- for mode in ["RGB", "RGBA"]:
- file_path = save_rgba_tiff(
- img_expected,
- os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"),
- mode=mode,
- )
- with reader.read(file_path) as img_obj:
- image[mode], _ = reader.get_data(img_obj)
-
- self.assertIsNone(assert_array_equal(image["RGB"], img_expected))
- self.assertIsNone(assert_array_equal(image["RGBA"], img_expected))
-
- @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D])
- @skipUnless(has_tiff, "Requires tifffile.")
- def test_read_malformats(self, img_expected):
- if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1):
- # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230
- return
- reader = WSIReader(self.backend)
- file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff")
- imwrite(file_path, img_expected, shape=img_expected.shape)
- with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)):
- with reader.read(file_path) as img_obj:
- reader.get_data(img_obj)
-
- @parameterized.expand([TEST_CASE_TRANSFORM_0])
- def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape):
- train_transform = Compose(
- [
- LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level),
- FromMetaTensord(keys=["image"]),
- ToTensord(keys=["image"]),
- ]
- )
- dataset = Dataset([{"image": file_path}], transform=train_transform)
- data_loader = DataLoader(dataset)
- data: dict = first(data_loader)
- for s in data[PostFix.meta("image")]["spatial_shape"]:
- assert_allclose(s, expected_spatial_shape, type_test=False)
- self.assertTupleEqual(data["image"].shape, expected_shape)
-
- @parameterized.expand([TEST_CASE_TRANSFORM_0])
- def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape):
- train_transform = Compose(
- [
- LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level),
- FromMetaTensord(keys=["image"]),
- ToTensord(keys=["image"]),
- ]
- )
- dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform)
- batch_size = 2
- data_loader = DataLoader(dataset, batch_size=batch_size)
- data: dict = first(data_loader)
- for s in data[PostFix.meta("image")]["spatial_shape"]:
- assert_allclose(s, expected_spatial_shape, type_test=False)
- self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:]))
-
-
-@skipUnless(has_cucim, "Requires cucim")
-class TestCuCIM(WSIReaderTests.Tests):
- @classmethod
- def setUpClass(cls):
- cls.backend = "cucim"
-
-
-@skipUnless(has_osl, "Requires openslide")
-class TestOpenSlide(WSIReaderTests.Tests):
- @classmethod
- def setUpClass(cls):
- cls.backend = "openslide"
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/testing_data/config_fl_evaluate.json b/tests/testing_data/config_fl_evaluate.json
new file mode 100644
index 0000000000..84ab7988f6
--- /dev/null
+++ b/tests/testing_data/config_fl_evaluate.json
@@ -0,0 +1,87 @@
+{
+ "bundle_root": "tests/testing_data",
+ "dataset_dir": "tests/testing_data",
+ "imports": [
+ "$import os"
+ ],
+ "device": "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')",
+ "network_def": {
+ "_target_": "DenseNet121",
+ "spatial_dims": 2,
+ "in_channels": 1,
+ "out_channels": 6
+ },
+ "network": "$@network_def.to(@device)",
+ "validate": {
+ "val_transforms": [
+ {
+ "_target_": "LoadImaged",
+ "keys": [
+ "image"
+ ],
+ "image_only": true
+ },
+ {
+ "_target_": "AddChanneld",
+ "keys": [
+ "image"
+ ]
+ },
+ {
+ "_target_": "ScaleIntensityd",
+ "keys": [
+ "image"
+ ]
+ },
+ {
+ "_target_": "ToTensord",
+ "keys": [
+ "image",
+ "label"
+ ]
+ }
+ ],
+ "preprocessing": {
+ "_target_": "Compose",
+ "transforms": "$@validate#val_transforms"
+ },
+ "dataset": {
+ "_target_": "Dataset",
+ "data": [
+ {
+ "image": "$os.path.join(@dataset_dir, 'image0.jpeg')",
+ "label": 0
+ },
+ {
+ "image": "$os.path.join(@dataset_dir, 'image1.jpeg')",
+ "label": 1
+ }
+ ],
+ "transform": "@validate#preprocessing"
+ },
+ "dataloader": {
+ "_target_": "DataLoader",
+ "dataset": "@validate#dataset",
+ "batch_size": 128,
+ "shuffle": false,
+ "num_workers": 4
+ },
+ "inferer": {
+ "_target_": "SimpleInferer"
+ },
+ "key_metric": {
+ "accuracy": {
+ "_target_": "ignite.metrics.Accuracy",
+ "output_transform": "$monai.handlers.from_engine(['pred', 'label'])"
+ }
+ },
+ "evaluator": {
+ "_target_": "SupervisedEvaluator",
+ "device": "@device",
+ "val_data_loader": "@validate#dataloader",
+ "network": "@network",
+ "inferer": "@validate#inferer",
+ "key_val_metric": "@validate#key_metric"
+ }
+ }
+}
diff --git a/tests/testing_data/config_fl_filters.json b/tests/testing_data/config_fl_filters.json
new file mode 100644
index 0000000000..5ccafa334c
--- /dev/null
+++ b/tests/testing_data/config_fl_filters.json
@@ -0,0 +1,13 @@
+{
+ "pre_filters": [
+ {
+ "_target_": "monai.fl.utils.filters.SummaryFilter"
+ }
+ ],
+ "post_weight_filters": [
+ {
+ "_target_": "monai.fl.utils.filters.SummaryFilter"
+ }
+ ],
+ "post_evaluate_filters": []
+}
diff --git a/tests/testing_data/config_fl_train.json b/tests/testing_data/config_fl_train.json
new file mode 100644
index 0000000000..5954d2cfbc
--- /dev/null
+++ b/tests/testing_data/config_fl_train.json
@@ -0,0 +1,125 @@
+{
+ "bundle_root": "tests/testing_data",
+ "dataset_dir": "tests/testing_data",
+ "imports": [
+ "$import os"
+ ],
+ "device": "$torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')",
+ "network_def": {
+ "_target_": "DenseNet121",
+ "spatial_dims": 2,
+ "in_channels": 1,
+ "out_channels": 6
+ },
+ "network": "$@network_def.to(@device)",
+ "loss": {
+ "_target_": "torch.nn.CrossEntropyLoss"
+ },
+ "optimizer": {
+ "_target_": "torch.optim.Adam",
+ "params": "$@network.parameters()",
+ "lr": 0.0001
+ },
+ "train": {
+ "training_transforms": [
+ {
+ "_target_": "LoadImaged",
+ "keys": [
+ "image"
+ ],
+ "image_only": true
+ },
+ {
+ "_target_": "AddChanneld",
+ "keys": [
+ "image"
+ ]
+ },
+ {
+ "_target_": "ScaleIntensityd",
+ "keys": [
+ "image"
+ ]
+ },
+ {
+ "_target_": "RandRotated",
+ "keys": [
+ "image"
+ ],
+ "range_x": 15,
+ "prob": 0.5,
+ "keep_size": true
+ },
+ {
+ "_target_": "RandFlipd",
+ "keys": [
+ "image"
+ ],
+ "spatial_axis": 0,
+ "prob": 0.5
+ },
+ {
+ "_target_": "RandZoomd",
+ "keys": [
+ "image"
+ ],
+ "min_zoom": 0.9,
+ "max_zoom": 1.1,
+ "prob": 0.5
+ },
+ {
+ "_target_": "ToTensord",
+ "keys": [
+ "image",
+ "label"
+ ]
+ }
+ ],
+ "preprocessing": {
+ "_target_": "Compose",
+ "transforms": "$@train#training_transforms"
+ },
+ "dataset": {
+ "_target_": "Dataset",
+ "data": [
+ {
+ "image": "$os.path.join(@dataset_dir, 'image0.jpeg')",
+ "label": 0
+ },
+ {
+ "image": "$os.path.join(@dataset_dir, 'image1.jpeg')",
+ "label": 1
+ }
+ ],
+ "transform": "@train#preprocessing"
+ },
+ "dataloader": {
+ "_target_": "DataLoader",
+ "dataset": "@train#dataset",
+ "batch_size": 128,
+ "shuffle": true,
+ "num_workers": 2
+ },
+ "inferer": {
+ "_target_": "SimpleInferer"
+ },
+ "handlers": [
+ {
+ "_target_": "StatsHandler",
+ "tag_name": "train_loss",
+ "output_transform": "$monai.handlers.from_engine(['loss'], first=True)"
+ }
+ ],
+ "trainer": {
+ "_target_": "SupervisedTrainer",
+ "max_epochs": 2,
+ "device": "@device",
+ "train_data_loader": "@train#dataloader",
+ "network": "@network",
+ "loss_function": "@loss",
+ "optimizer": "@optimizer",
+ "inferer": "@train#inferer",
+ "train_handlers": "@train#handlers"
+ }
+ }
+}
diff --git a/tests/testing_data/data_config.json b/tests/testing_data/data_config.json
index 254314d1b8..30b2592cfc 100644
--- a/tests/testing_data/data_config.json
+++ b/tests/testing_data/data_config.json
@@ -56,6 +56,18 @@
"hash_val": "eb4f1e596ca85aadaefc359d409fb9a3e27d733e6def04b996953b7c54bc26d4"
}
},
+ "videos": {
+ "endovis": {
+ "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/d1_im.mp4",
+ "hash_type": "md5",
+ "hash_val": "9b103c07326439b0ea376018d7189384"
+ },
+ "ultrasound": {
+ "url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/example_data_Ultrasound_Q000_04_tu_segmented_ultrasound_256.avi",
+ "hash_type": "md5",
+ "hash_val": "f0755960cc4a08a958561cda9a79a157"
+ }
+ },
"models": {
"senet154-c7b49a05": {
"url": "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/senet154-c7b49a05.pth",
diff --git a/tests/testing_data/image0.jpeg b/tests/testing_data/image0.jpeg
new file mode 100644
index 0000000000..5025e5f992
Binary files /dev/null and b/tests/testing_data/image0.jpeg differ
diff --git a/tests/testing_data/image1.jpeg b/tests/testing_data/image1.jpeg
new file mode 100644
index 0000000000..462db4bbc2
Binary files /dev/null and b/tests/testing_data/image1.jpeg differ
diff --git a/tests/utils.py b/tests/utils.py
index e3e77a9c32..992ff7c5ce 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -17,6 +17,8 @@
import operator
import os
import queue
+import ssl
+import subprocess
import sys
import tempfile
import time
@@ -123,6 +125,9 @@ def skip_if_downloading_fails():
yield
except (ContentTooShortError, HTTPError, ConnectionError) as e:
raise unittest.SkipTest(f"error while downloading: {e}") from e
+ except ssl.SSLError as ssl_e:
+ if "decryption failed" in str(ssl_e):
+ raise unittest.SkipTest(f"SSL error while downloading: {ssl_e}") from ssl_e
except RuntimeError as rt_e:
if "unexpected EOF" in str(rt_e):
raise unittest.SkipTest(f"error while downloading: {rt_e}") from rt_e # incomplete download
@@ -208,25 +213,32 @@ def __call__(self, obj):
def skip_if_no_cpp_extension(obj):
"""
- Skip the unit tests if the cpp extension is not available
+ Skip the unit tests if the cpp extension is not available.
"""
return unittest.skipUnless(USE_COMPILED, "Skipping cpp extension tests")(obj)
def skip_if_no_cuda(obj):
"""
- Skip the unit tests if torch.cuda.is_available is False
+ Skip the unit tests if torch.cuda.is_available is False.
"""
return unittest.skipUnless(torch.cuda.is_available(), "Skipping CUDA-based tests")(obj)
def skip_if_windows(obj):
"""
- Skip the unit tests if platform is win32
+ Skip the unit tests if platform is win32.
"""
return unittest.skipIf(sys.platform == "win32", "Skipping tests on Windows")(obj)
+def skip_if_darwin(obj):
+ """
+ Skip the unit tests if platform is macOS (Darwin).
+ """
+ return unittest.skipIf(sys.platform == "darwin", "Skipping tests on macOS/Darwin")(obj)
+
+
class SkipIfBeforePyTorchVersion:
"""Decorator to be used if test should be skipped
with PyTorch versions older than that given."""
@@ -722,19 +734,32 @@ def test_local_inversion(invertible_xform, to_invert, im, dict_key=None):
im_item = im if dict_key is None else im[dict_key]
if not isinstance(im_item, MetaTensor):
return
+ im_ref = copy.deepcopy(im)
im_inv = invertible_xform.inverse(to_invert)
if dict_key:
im_inv = im_inv[dict_key]
- im = im[dict_key]
+ im_ref = im_ref[dict_key]
np.testing.assert_array_equal(im_inv.applied_operations, [])
- assert_allclose(im_inv.shape, im.shape)
- assert_allclose(im_inv.affine, im.affine, atol=1e-3, rtol=1e-3)
+ assert_allclose(im_inv.shape, im_ref.shape)
+ assert_allclose(im_inv.affine, im_ref.affine, atol=1e-3, rtol=1e-3)
+
+
+def command_line_tests(cmd, copy_env=True):
+ test_env = os.environ.copy() if copy_env else os.environ
+ print(f"CUDA_VISIBLE_DEVICES in {__file__}", test_env.get("CUDA_VISIBLE_DEVICES"))
+ try:
+ normal_out = subprocess.run(cmd, env=test_env, check=True, capture_output=True)
+ print(repr(normal_out).replace("\\n", "\n").replace("\\t", "\t"))
+ except subprocess.CalledProcessError as e:
+ output = repr(e.stdout).replace("\\n", "\n").replace("\\t", "\t")
+ errors = repr(e.stderr).replace("\\n", "\n").replace("\\t", "\t")
+ raise RuntimeError(f"subprocess call error {e.returncode}: {errors}, {output}") from e
-TEST_TORCH_TENSORS: Tuple[Callable] = (torch.as_tensor,)
+TEST_TORCH_TENSORS: Tuple = (torch.as_tensor,)
if torch.cuda.is_available():
gpu_tensor: Callable = partial(torch.as_tensor, device="cuda")
- TEST_NDARRAYS = TEST_TORCH_TENSORS + (gpu_tensor,)
+ TEST_TORCH_TENSORS = TEST_TORCH_TENSORS + (gpu_tensor,)
DEFAULT_TEST_AFFINE = torch.tensor(
[[2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0], [0.0, 0.0, 0.0, 1.0]]