diff --git a/.dockerignore b/.dockerignore index 549e63bad5..262da4d0dd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,9 @@ __pycache__/ docs/ .coverage +.coverage.* +.coverage/ +coverage.xml .readthedocs.yml *.md *.toml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 61b8814857..f7024f1a08 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,7 @@ A few sentences describing the changes proposed in this pull request. - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. -- [ ] Integration tests passed locally by running `./runtests.sh --codeformat --coverage`. -- [ ] Quick tests passed locally by running `./runtests.sh --quick`. +- [ ] Integration tests passed locally by running `./runtests.sh -f -u --net --coverage`. +- [ ] Quick tests passed locally by running `./runtests.sh --quick --unittests`. - [ ] In-line docstrings updated. - [ ] Documentation updated, tested `make html` command in the `docs/` folder. diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index e568ba9e15..761b1f7ebc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -13,7 +13,7 @@ jobs: runs-on: [self-hosted, linux, x64, common] strategy: matrix: - pytorch-version: [1.5.0, 1.5.1, 1.6.0, latest] + pytorch-version: [1.5.1, 1.6.0, 1.7.1, latest] steps: - uses: actions/checkout@v2 - name: Install the dependencies @@ -23,15 +23,12 @@ jobs: python -m pip uninstall -y torch torchvision if [ ${{ matrix.pytorch-version }} == "latest" ]; then python -m pip install torch torchvision - elif [ ${{ matrix.pytorch-version }} == "1.5.0" ]; then - python -m pip install torch==1.5.0 - python -m pip install torchvision==0.6.0 elif [ ${{ matrix.pytorch-version }} == "1.5.1" ]; then - python -m pip install torch==1.5.1 - python -m pip install torchvision==0.6.1 + python -m pip install torch==1.5.1 torchvision==0.6.1 elif [ ${{ matrix.pytorch-version }} == "1.6.0" ]; then - python -m pip install torch==1.6.0 - python -m pip install torchvision==0.7.0 + python -m pip install torch==1.6.0 torchvision==0.7.0 + elif [ ${{ matrix.pytorch-version }} == "1.7.1" ]; then + python -m pip install torch==1.7.1 torchvision==0.8.2 fi python -m pip install -r requirements-dev.txt python -m pip list @@ -43,9 +40,16 @@ jobs: nvidia-smi export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) echo $CUDA_VISIBLE_DEVICES + stop_time=$((LAUNCH_DELAY + $(date +%s))) + while [ $(date +%s) -lt $stop_time ]; do + python -c 'import torch; torch.rand(5, 3, device=torch.device("cuda:0"))'; + done python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" - python -c 'import torch; print(torch.rand(5,3, device=torch.device("cuda:0")))' - BUILD_MONAI=1 ./runtests.sh --coverage + python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' + BUILD_MONAI=1 ./runtests.sh --coverage --unittests # unit tests with coverage report + export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) + echo $CUDA_VISIBLE_DEVICES + BUILD_MONAI=1 ./runtests.sh --coverage --net # integration tests with coverage report coverage xml - name: Upload coverage uses: codecov/codecov-action@v1 @@ -56,12 +60,16 @@ jobs: cron-pt-image: if: github.repository == 'Project-MONAI/MONAI' container: - image: nvcr.io/nvidia/pytorch:20.12-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:21.02-py3 # testing with the latest pytorch base image options: "--gpus all" runs-on: [self-hosted, linux, x64, common] steps: - uses: actions/checkout@v2 - - name: Install the dependencies + - name: Install APT dependencies + run: | + apt-get update + DEBIAN_FRONTEND="noninteractive" apt-get install -y libopenslide0 + - name: Install Python dependencies run: | which python python -m pip install --upgrade pip wheel @@ -75,9 +83,16 @@ jobs: nvidia-smi export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) echo $CUDA_VISIBLE_DEVICES + stop_time=$((LAUNCH_DELAY + $(date +%s))) + while [ $(date +%s) -lt $stop_time ]; do + python -c 'import torch; torch.rand(5, 3, device=torch.device("cuda:0"))'; + done python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" - python -c 'import torch; print(torch.rand(5,3, device=torch.device("cuda:0")))' - BUILD_MONAI=1 ./runtests.sh --coverage + python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' + BUILD_MONAI=1 ./runtests.sh --coverage --unittests # unit tests with coverage report + export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) + echo $CUDA_VISIBLE_DEVICES + BUILD_MONAI=1 ./runtests.sh --coverage --net # integration tests with coverage report coverage xml - name: Upload coverage uses: codecov/codecov-action@v1 @@ -103,10 +118,46 @@ jobs: python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" python -c 'import torch; print(torch.rand(5,3, device=torch.device("cuda:0")))' ngc --version - BUILD_MONAI=1 ./runtests.sh --coverage --pytype + BUILD_MONAI=1 ./runtests.sh --coverage --pytype --unittests # unit tests with pytype checks, coverage report + export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) + echo $CUDA_VISIBLE_DEVICES + BUILD_MONAI=1 ./runtests.sh --coverage --net # integration tests with coverage report coverage xml - name: Upload coverage uses: codecov/codecov-action@v1 with: fail_ci_if_error: false file: ./coverage.xml + + cron-tutorial-notebooks: + if: github.repository == 'Project-MONAI/MONAI' + needs: cron-gpu # so that monai itself is verified first + container: + image: nvcr.io/nvidia/pytorch:21.02-py3 # testing with the latest pytorch base image + options: "--gpus all --ipc=host" + runs-on: [self-hosted, linux, x64, common] + steps: + - uses: actions/checkout@v2 + - name: Install MONAI + id: monai-install + run: | + which python + python -m pip install --upgrade pip wheel + python -m pip install -r requirements-dev.txt + BUILD_MONAI=0 python setup.py develop # install monai + nvidia-smi + export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) + echo $CUDA_VISIBLE_DEVICES + echo "::set-output name=devices::$CUDA_VISIBLE_DEVICES" + - name: Checkout tutorials and install their requirements + run: | + cd /opt + git clone --depth 1 --branch master --single-branch https://github.com/Project-MONAI/tutorials.git # latest commit of master branch + cd tutorials + python -m pip install -r requirements.txt + - name: Run tutorial notebooks + run: | + export CUDA_VISIBLE_DEVICES=${{ steps.monai-install.outputs.devices }} + echo $CUDA_VISIBLE_DEVICES + cd /opt/tutorials + $(pwd)/runner.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 003a746de4..ac3efbb751 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -7,7 +7,7 @@ on: jobs: integration-py3: container: - image: nvcr.io/nvidia/pytorch:20.03-py3 # CUDA 10.2 + image: nvcr.io/nvidia/pytorch:20.12-py3 # CUDA 11.1, default for PT 1.8.0 options: --gpus all runs-on: [self-hosted, linux, x64, common] steps: @@ -28,13 +28,13 @@ jobs: path: | ~/.cache/pip ~/.cache/torch - key: docker-20-03-py3-pip-${{ steps.pip-cache.outputs.datew }} + key: docker-py3-pip-${{ steps.pip-cache.outputs.datew }} - name: Install the dependencies run: | which python python -m pip install --upgrade pip wheel python -m pip uninstall -y torch torchvision - python -m pip install torch==1.7.1 torchvision==0.8.2 + python -m pip install torch==1.8.0 torchvision==0.9.0 python -m pip install -r requirements-dev.txt - name: Run integration tests run: | @@ -44,7 +44,7 @@ jobs: echo $CUDA_VISIBLE_DEVICES python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" python -c 'import torch; print(torch.rand(5,3, device=torch.device("cuda:0")))' - BUILD_MONAI=1 ./runtests.sh --net + BUILD_MONAI=1 ./runtests.sh --unittests --net - name: Add reaction uses: peter-evans/create-or-update-comment@v1 with: diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 8e92ea0ed7..83d01ff5e0 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -9,7 +9,7 @@ on: jobs: # caching of these jobs: - # - docker-20-03-py3-pip- (shared) + # - docker-py3-pip- (shared) # - ubuntu py37 pip- # - os-latest-pip- (shared) flake8-py3: @@ -39,7 +39,7 @@ jobs: # clean up temporary files $(pwd)/runtests.sh --clean # Git hub actions have 2 cores, so parallize pytype - $(pwd)/runtests.sh --nounittests --codeformat -j 2 + $(pwd)/runtests.sh --codeformat -j 2 quick-py3: # full dependencies installed runs-on: ${{ matrix.os }} @@ -80,13 +80,16 @@ jobs: - if: runner.os == 'windows' name: Install torch cpu from pytorch.org (Windows only) run: | - python -m pip install torch==1.7.1+cpu torchvision==0.8.2+cpu -f https://download.pytorch.org/whl/torch_stable.html + python -m pip install torch==1.8.0+cpu torchvision==0.9.0+cpu -f https://download.pytorch.org/whl/torch_stable.html # min. requirements for windows instances python -c "f=open('requirements-dev.txt', 'r'); txt=f.readlines(); f.close(); print(txt); f=open('requirements-dev.txt', 'w'); f.writelines(txt[1:12]); f.close()" + - if: runner.os == 'macos' + name: Remove cucim installation (Mac only) + run: | + python -c "f=open('requirements-dev.txt', 'r'); txt=f.readlines(); f.close(); print(txt); f=open('requirements-dev.txt', 'w'); f.writelines([t for t in txt if not t.startswith('cucim')]); f.close()" - name: Install the dependencies run: | - python -m pip install torch==1.7.1 - python -m pip install torchvision==0.8.2 + python -m pip install torch==1.8.0 torchvision==0.9.0 cat "requirements-dev.txt" python -m pip install -r requirements-dev.txt python -m pip list @@ -134,11 +137,11 @@ jobs: - if: runner.os == 'windows' name: Install torch cpu from pytorch.org (Windows only) run: | - python -m pip install torch==1.7.1+cpu -f https://download.pytorch.org/whl/torch_stable.html + python -m pip install torch==1.8.0+cpu -f https://download.pytorch.org/whl/torch_stable.html - name: Install the dependencies run: | # min. requirements - python -m pip install torch==1.7.1 + python -m pip install torch==1.8.0 python -m pip install -r requirements-min.txt python -m pip list BUILD_MONAI=0 python setup.py develop # no compile of extensions @@ -156,15 +159,13 @@ jobs: strategy: matrix: environment: - - "PT15+CUDA101" - "PT16+CUDA102" - "PT16+CUDA110" - "PT17+CUDA102" - "PT17+CUDA110" + - "PT18+CUDA102" + - "PT18+CUDA112" include: - - environment: PT15+CUDA101 - pytorch: "torch==1.5.0+cu101 torchvision==0.6.0+cu101 -f https://download.pytorch.org/whl/torch_stable.html" - base: "nvcr.io/nvidia/cuda:10.1-devel-ubuntu18.04" - environment: PT16+CUDA102 pytorch: "torch==1.6.0 torchvision==0.7.0" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" @@ -179,6 +180,13 @@ jobs: # we explicitly set pytorch to -h to avoid pip install error pytorch: "-h" base: "nvcr.io/nvidia/pytorch:20.09-py3" + - environment: PT18+CUDA102 + pytorch: "torch==1.8.0 torchvision==0.9.0" + base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" + - environment: PT18+CUDA112 + # we explicitly set pytorch to -h to avoid pip install error + pytorch: "-h" + base: "nvcr.io/nvidia/pytorch:21.02-py3" container: image: ${{ matrix.base }} options: --gpus all @@ -187,7 +195,10 @@ jobs: - uses: actions/checkout@v2 - name: apt install run: | - if [ ${{ matrix.environment }} != "PT16+CUDA110" ]; then \ + if [ ${{ matrix.environment }} = "PT16+CUDA102" ] || \ + [ ${{ matrix.environment }} = "PT17+CUDA102" ] || \ + [ ${{ matrix.environment }} = "PT18+CUDA102" ] + then PYVER=3.6 PYSFX=3 DISTUTILS=python3-distutils && \ apt-get update && apt-get install -y --no-install-recommends \ curl \ @@ -217,7 +228,8 @@ jobs: ln -s /usr/bin/python$PYVER /usr/bin/python`echo $PYVER | cut -c1-1` && curl -O https://bootstrap.pypa.io/get-pip.py && \ python get-pip.py && \ - rm get-pip.py ; fi + rm get-pip.py; + fi - name: Install dependencies run: | which python @@ -231,10 +243,10 @@ jobs: export CUDA_VISIBLE_DEVICES=$(coverage run -m tests.utils) echo $CUDA_VISIBLE_DEVICES python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" - python -c 'import torch; print(torch.rand(5,3, device=torch.device("cuda:0")))' + python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' python -c "import monai; monai.config.print_config()" - BUILD_MONAI=1 ./runtests.sh --quick - if [ ${{ matrix.environment }} == "PT16+CUDA110" ]; then + BUILD_MONAI=1 ./runtests.sh --quick --unittests + if [ ${{ matrix.environment }} == "PT18+CUDA112" ]; then # test the clang-format tool downloading once coverage run -m tests.clang_format_utils fi diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 7656eb4828..e5cb9a7cf1 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -8,7 +8,7 @@ on: jobs: # caching of these jobs: - # - docker-20-03-py3-pip- (shared) + # - docker-py3-pip- (shared) # - ubuntu py36 37 38-pip- # - os-latest-pip (shared) coverage-py3: @@ -30,7 +30,7 @@ jobs: path: | ~/.cache/pip ~/.cache/torch - key: docker-20-03-py3-pip-${{ steps.pip-cache.outputs.datew }} + key: docker-py3-pip-${{ steps.pip-cache.outputs.datew }} - name: Install the dependencies run: | which python @@ -41,17 +41,27 @@ jobs: - name: Run unit tests report coverage run: | python -m pip list + export LAUNCH_DELAY=$[ $RANDOM % 16 * 60 ] + echo "Sleep $LAUNCH_DELAY" + sleep $LAUNCH_DELAY nvidia-smi export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) echo $CUDA_VISIBLE_DEVICES + stop_time=$((LAUNCH_DELAY + $(date +%s))) + while [ $(date +%s) -lt $stop_time ]; do + python -c 'import torch; torch.rand(5, 3, device=torch.device("cuda:0"))'; + done python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" - python -c 'import torch; print(torch.rand(5,3, device=torch.device("cuda:0")))' - BUILD_MONAI=1 ./runtests.sh --coverage + python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' + BUILD_MONAI=1 ./runtests.sh --coverage --unittests # unit tests with coverage report + export CUDA_VISIBLE_DEVICES=$(python -m tests.utils) + echo $CUDA_VISIBLE_DEVICES + BUILD_MONAI=1 ./runtests.sh --coverage --net # integration tests with coverage report coverage xml - name: Upload coverage uses: codecov/codecov-action@v1 with: - fail_ci_if_error: true + fail_ci_if_error: false file: ./coverage.xml test-py3x: @@ -88,7 +98,7 @@ jobs: run: | python -m pip list python -c 'import torch; print(torch.__version__); print(torch.rand(5,3))' - BUILD_MONAI=1 ./runtests.sh --quick + BUILD_MONAI=1 ./runtests.sh --quick --unittests coverage xml - name: Upload coverage uses: codecov/codecov-action@v1 diff --git a/.gitignore b/.gitignore index 0d1455d70d..f60641d6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ htmlcov/ .tox/ .coverage .coverage.* +.coverage/ .cache nosetests.xml coverage.xml @@ -124,6 +125,7 @@ temp/ # temporary testing data MedNIST tests/testing_data/MedNIST* tests/testing_data/*Hippocampus* +tests/testing_data/CMU-1.tiff # clang format tool .clang-format-bin/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01a4773b5a..79cbbfabac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ We are happy to talk with you about your needs for MONAI and your ideas for cont ### Does it belong in PyTorch instead of MONAI? -MONAI is based on the PyTorch and Numpy libraries. These libraries implement what we consider to be best practice for general scientific computing and deep learning functionality. MONAI builds on these with a strong focus on medical applications. As such, it is a good idea to consider whether your functionality is medical-application specific or not. General deep learning functionality may be better off in PyTorch; you can find their contribution guidelines [here](https://pytorch.org/docs/stable/community/contribution_guide.html). +MONAI is part of [PyTorch Ecosystem](https://pytorch.org/ecosystem/), and mainly based on the PyTorch and Numpy libraries. These libraries implement what we consider to be best practice for general scientific computing and deep learning functionality. MONAI builds on these with a strong focus on medical applications. As such, it is a good idea to consider whether your functionality is medical-application specific or not. General deep learning functionality may be better off in PyTorch; you can find their contribution guidelines [here](https://pytorch.org/docs/stable/community/contribution_guide.html). ## The contribution process @@ -51,11 +51,15 @@ Coding style is checked and enforced by flake8, black, and isort, using [a flake Before submitting a pull request, we recommend that all linting should pass, by running the following command locally: ```bash -pip install -U -r requirements-dev.txt # install the latest tools -./runtests.sh --codeformat --nounittests # runs the linting tools only +# optionally update the dependencies and dev tools +python -m pip install -U pip +python -m pip install -U -r requirements-dev.txt + +# run the linting and type checking tools +./runtests.sh --codeformat # try to fix the coding style errors automatically -./runtests.sh --autofix --nounittests +./runtests.sh --autofix ``` License information: all source code files should start with this paragraph: @@ -86,7 +90,7 @@ MONAI tests are located under `tests/`. - The unit test's file name follows `test_[module_name].py`. - The integration test's file name follows `test_integration_[workflow_name].py`. -A bash script (`runtests.sh`) is provided to run all tests locally +A bash script (`runtests.sh`) is provided to run all tests locally. Please run ``./runtests.sh -h`` to see all options. To run a particular test, for example `tests/test_dice_loss.py`: @@ -98,16 +102,16 @@ Before submitting a pull request, we recommend that all linting and unit tests should pass, by running the following command locally: ```bash -./runtests.sh --codeformat --coverage +./runtests.sh -f -u --net --coverage ``` or (for new features that would not break existing functionality): ```bash -./runtests.sh --quick +./runtests.sh --quick --unittests ``` It is recommended that the new test `test_[module_name].py` is constructed by using only -python 3.6+ build-in functions, `torch`, `numpy`, and `parameterized` packages. +python 3.6+ build-in functions, `torch`, `numpy`, `coverage` (for reporting code coverages) and `parameterized` (for organising test cases) packages. If it requires any other external packages, please make sure: - the packages are listed in [`requirements-dev.txt`](requirements-dev.txt) - the new test `test_[module_name].py` is added to the `exclude_cases` in [`./tests/min_tests.py`](./tests/min_tests.py) so that @@ -141,7 +145,7 @@ Before submitting a pull request, it is recommended to: - check the auto-generated documentation (by browsing `./docs/build/html/index.html` with a web browser) - type `make clean` in `docs/` folder to remove the current build files. -Please type `make help` for all supported format options. +Please type `make help` in `docs/` folder for all supported format options. #### Automatic code formatting MONAI provides support of automatic Python code formatting via [a customised GitHub action](https://github.com/Project-MONAI/monai-code-formatter). @@ -251,6 +255,7 @@ All code review comments should be specific, constructive, and actionable. 1. Read carefully the descriptions of the pull request and the files changed, write comments if needed. 1. Make in-line comments to specific code segments, [request for changes](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-request-reviews) if needed. 1. Review any further code changes until all comments addressed by the contributors. +1. Comment to trigger `/black` and/or `/integration-test` for optional auto code formatting and [integration tests](.github/workflows/integration.yml). 1. Merge the pull request to the master branch. 1. Close the corresponding task ticket on [the issue list][monai issue list]. diff --git a/Dockerfile b/Dockerfile index 47976b97b1..57ea567869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:20.12-py3 - +# To build with a different base image +# please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.02-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" @@ -43,6 +44,9 @@ RUN wget -q ${NGC_CLI_URI} && \ unzip ngccli_cat_linux.zip && chmod u+x ngc && \ md5sum -c ngc.md5 && \ rm -rf ngccli_cat_linux.zip ngc.md5 +RUN apt-get update \ + && DEBIAN_FRONTEND="noninteractive" apt-get install -y libopenslide0 \ + && rm -rf /var/lib/apt/lists/* # append /opt/tools to runtime path for NGC CLI to be accessible from all file system locations ENV PATH=${PATH}:/opt/tools WORKDIR /opt/monai diff --git a/README.md b/README.md index f06a2d146f..7a4a69e1e9 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,6 @@ Ask and answer questions over on [MONAI's GitHub Discussions tab](https://github - Issue tracker: https://github.com/Project-MONAI/MONAI/issues - Wiki: https://github.com/Project-MONAI/MONAI/wiki - Test status: https://github.com/Project-MONAI/MONAI/actions +- PyPI package: https://pypi.org/project/monai/ +- Weekly previews: https://pypi.org/project/monai-weekly/ +- Docker Hub: https://hub.docker.com/r/projectmonai/monai diff --git a/docs/requirements.txt b/docs/requirements.txt index d046bc53cf..f05bc5b9ca 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,9 +1,11 @@ -f https://download.pytorch.org/whl/cpu/torch-1.6.0%2Bcpu-cp37-cp37m-linux_x86_64.whl torch>=1.5 -pytorch-ignite==0.4.2 +pytorch-ignite==0.4.4 numpy>=1.17 itk>=5.0 nibabel +cucim==0.18.1 +openslide-python==1.1.2 parameterized scikit-image>=0.14.2 tensorboard diff --git a/docs/source/apps.rst b/docs/source/apps.rst index b8c8b4d341..1c4f4c3dfb 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -46,9 +46,19 @@ Applications :members: .. autoclass:: AddRandomGuidanced :members: +.. autoclass:: AddGuidanceFromPointsd + :members: .. autoclass:: SpatialCropForegroundd :members: +.. autoclass:: SpatialCropGuidanced + :members: +.. autoclass:: RestoreLabeld + :members: +.. autoclass:: ResizeGuidanced + :members: .. autoclass:: FindDiscrepancyRegionsd :members: .. autoclass:: FindAllValidSlicesd :members: +.. autoclass:: Fetch2DSliced + :members: diff --git a/docs/source/data.rst b/docs/source/data.rst index 11609964c3..8071bb1585 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -68,6 +68,12 @@ Generic Interfaces .. autoclass:: ImageDataset :members: :special-members: __getitem__ + +`NPZDictItemDataset` +~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: NPZDictItemDataset + :members: + :special-members: __getitem__ Patch-based dataset ------------------- @@ -77,6 +83,11 @@ Patch-based dataset .. autoclass:: GridPatchDataset :members: +`PatchIter` +~~~~~~~~~~~ +.. autoclass:: PatchIter + :members: + `PatchDataset` ~~~~~~~~~~~~~~ .. autoclass:: PatchDataset @@ -105,6 +116,10 @@ PILReader .. autoclass:: PILReader :members: +WSIReader +~~~~~~~~~ +.. autoclass:: WSIReader + :members: Nifti format handling --------------------- @@ -151,6 +166,9 @@ DistributedSampler ~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.DistributedSampler +DistributedWeightedRandomSampler +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.DistributedWeightedRandomSampler Decathlon Datalist ~~~~~~~~~~~~~~~~~~ @@ -165,3 +183,8 @@ DataLoader ThreadBuffer ~~~~~~~~~~~~ .. autoclass:: monai.data.ThreadBuffer + + +BatchInverseTransform +~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.BatchInverseTransform diff --git a/docs/source/highlights.md b/docs/source/highlights.md index 29302bda77..5baaa75f4c 100644 --- a/docs/source/highlights.md +++ b/docs/source/highlights.md @@ -299,9 +299,9 @@ In 2017, NVIDIA researchers developed a methodology for mixed-precision training For the PyTorch 1.6 release, developers at NVIDIA and Facebook moved mixed precision functionality into PyTorch core as the AMP package, `torch.cuda.amp`. -MONAI workflows can easily set `amp=True/False` in `SupervisedTrainer` or `SupervisedEvaluator` during training or evaluation to enable/disable AMP. And we tried to compare the training speed if AMP ON/OFF on Tesla V100 GPU with CUDA 11 and PyTorch 1.6, got some benchmark for reference: +MONAI workflows can easily set `amp=True/False` in `SupervisedTrainer` or `SupervisedEvaluator` during training or evaluation to enable/disable AMP. And we tried to compare the training speed if AMP ON/OFF on NVIDIA V100 GPU with CUDA 11 and PyTorch 1.6, got some benchmark for reference: ![image](../images/amp_training_v100.png) -We also executed the same test program on Testa A100 GPU with the same software environment, got much faster benchmark for reference: +We also executed the same test program on NVIDIA A100 GPU with the same software environment, got much faster benchmark for reference: ![image](../images/amp_training_a100.png) More details is available at [AMP training tutorial](https://github.com/Project-MONAI/tutorials/blob/master/acceleration/automatic_mixed_precision.ipynb). We also tried to combine AMP with `CacheDataset` and `Novograd` optimizer to achieve the fast training in MONAI, able to obtain approximately 12x speedup compared with a Pytorch native implementation when the training converges at a validation mean dice of 0.93. Benchmark for reference: @@ -310,7 +310,7 @@ More details is available at [Fast training tutorial](https://github.com/Project ### 2. Distributed data parallel Distributed data parallel is an important feature of PyTorch to connect multiple GPU devices on single or multiple nodes to train or evaluate models. MONAI provides demos for reference: train/evaluate with PyTorch DDP, train/evaluate with Horovod, train/evaluate with Ignite DDP, partition dataset and train with SmartCacheDataset, as well as a real world training example based on Decathlon challenge Task01 - Brain Tumor segmentation. -The demo contains distributed caching, training, and validation. We tried to train this example on NVIDIA NGC server, got some performance benchmarks for reference(PyTorch 1.6, CUDA 11, Tesla V100 GPUs): +The demo contains distributed caching, training, and validation. We tried to train this example on NVIDIA NGC server, got some performance benchmarks for reference(PyTorch 1.6, CUDA 11, NVIDIA V100 GPUs): ![image](../images/distributed_training.png) ### 3. C++/CUDA optimized modules diff --git a/docs/source/index.rst b/docs/source/index.rst index ea21428e6e..23146ae69e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -91,9 +91,8 @@ Links - FAQ: https://github.com/Project-MONAI/MONAI/wiki/Frequently-asked-questions-and-answers - Test status: https://github.com/Project-MONAI/MONAI/actions - PyPI package: https://pypi.org/project/monai/ +- Weekly previews: https://pypi.org/project/monai-weekly/ - Docker Hub: https://hub.docker.com/r/projectmonai/monai -- Google Group: https://groups.google.com/forum/#!forum/project-monai -- Reddit: https://www.reddit.com/r/projectmonai/ Indices and tables diff --git a/docs/source/metrics.rst b/docs/source/metrics.rst index 32a3faf380..7cfd10f196 100644 --- a/docs/source/metrics.rst +++ b/docs/source/metrics.rst @@ -6,6 +6,10 @@ Metrics ======= .. currentmodule:: monai.metrics +`FROC` +------ +.. autofunction:: compute_froc_score + `Mean Dice` ----------- .. autofunction:: compute_meandice diff --git a/docs/source/networks.rst b/docs/source/networks.rst index e0ac0f2d75..f5d498a363 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -119,6 +119,21 @@ Blocks .. autoclass:: Subpixelupsample .. autoclass:: SubpixelUpSample +`Registration Residual Conv Block` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: RegistrationResidualConvBlock + :members: + +`Registration Down Sample Block` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: RegistrationDownSampleBlock + :members: + +`Registration Extraction Block` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: RegistrationExtractionBlock + :members: + `LocalNet DownSample Block` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: LocalNetDownSampleBlock @@ -271,10 +286,6 @@ Nets ~~~~~~~~~~ .. autoclass:: DenseNet :members: -.. autofunction:: densenet121 -.. autofunction:: densenet169 -.. autofunction:: densenet201 -.. autofunction:: densenet264 `SegResNet` ~~~~~~~~~~~ @@ -290,12 +301,6 @@ Nets ~~~~~~~ .. autoclass:: SENet :members: -.. autofunction:: senet154 -.. autofunction:: se_resnet50 -.. autofunction:: se_resnet101 -.. autofunction:: se_resnet152 -.. autofunction:: se_resnext50_32x4d -.. autofunction:: se_resnext101_32x4d `HighResNet` ~~~~~~~~~~~~ @@ -330,6 +335,16 @@ Nets .. autoclass:: VNet :members: +`RegUNet` +~~~~~~~~~~ +.. autoclass:: RegUNet + :members: + +`GlobalNet` +~~~~~~~~~~~~ +.. autoclass:: GlobalNet + :members: + `LocalNet` ~~~~~~~~~~~ .. autoclass:: LocalNet diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 5b550f7885..768c0665a2 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -27,12 +27,23 @@ Generic Interfaces .. autoclass:: Randomizable :members: +`RandomizableTransform` +^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: RandomizableTransform + :members: + `Compose` ^^^^^^^^^ .. autoclass:: Compose :members: :special-members: __call__ +`InvertibleTransform` +^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: InvertibleTransform + :members: + + Vanilla Transforms ------------------ @@ -132,6 +143,18 @@ Intensity :members: :special-members: __call__ +`StdShiftIntensity` +""""""""""""""""""" +.. autoclass:: StdShiftIntensity + :members: + :special-members: __call__ + +`RandStdShiftIntensity` +""""""""""""""""""""""" +.. autoclass:: RandStdShiftIntensity + :members: + :special-members: __call__ + `ScaleIntensity` """""""""""""""" .. autoclass:: ScaleIntensity @@ -309,6 +332,12 @@ Spatial :members: :special-members: __call__ +`RandAxisFlip` +"""""""""""""" +.. autoclass:: RandAxisFlip + :members: + :special-members: __call__ + `RandZoom` """""""""" .. autoclass:: RandZoom @@ -426,6 +455,12 @@ Utility :members: :special-members: __call__ +`EnsureChannelFirst` +"""""""""""""""""""" +.. autoclass:: EnsureChannelFirst + :members: + :special-members: __call__ + `RepeatChannel` """"""""""""""" .. autoclass:: RepeatChannel @@ -615,6 +650,18 @@ Instensity (Dict) :members: :special-members: __call__ +`StdShiftIntensityd` +"""""""""""""""""""" +.. autoclass:: StdShiftIntensityd + :members: + :special-members: __call__ + +`RandStdShiftIntensityd` +"""""""""""""""""""""""" +.. autoclass:: RandStdShiftIntensityd + :members: + :special-members: __call__ + `ScaleIntensityd` """"""""""""""""" .. autoclass:: ScaleIntensityd @@ -786,6 +833,12 @@ Spatial (Dict) :members: :special-members: __call__ +`RandAxisFlipd` +""""""""""""""" +.. autoclass:: RandAxisFlipd + :members: + :special-members: __call__ + `Rotated` """"""""" .. autoclass:: Rotated @@ -828,6 +881,12 @@ Spatial (Dict) :members: :special-members: __call__ +`Affined` +""""""""" +.. autoclass:: Affined + :members: + :special-members: __call__ + `RandAffined` """"""""""""" .. autoclass:: RandAffined @@ -873,6 +932,12 @@ Utility (Dict) :members: :special-members: __call__ +`EnsureChannelFirstd` +""""""""""""""""""""" +.. autoclass:: EnsureChannelFirstd + :members: + :special-members: __call__ + `RepeatChanneld` """""""""""""""" .. autoclass:: RepeatChanneld diff --git a/monai/__init__.py b/monai/__init__.py index 910698ee14..b9b010f0fb 100644 --- a/monai/__init__.py +++ b/monai/__init__.py @@ -44,3 +44,19 @@ # load all modules, this will trigger all export decorations load_submodules(sys.modules[__name__], True, exclude_pattern=excludes) + +__all__ = [ + "apps", + "config", + "data", + "engines", + "handlers", + "inferers", + "losses", + "metrics", + "networks", + "optimizers", + "transforms", + "utils", + "visualize", +] diff --git a/monai/apps/datasets.py b/monai/apps/datasets.py index f0416b8c4f..a193b7391e 100644 --- a/monai/apps/datasets.py +++ b/monai/apps/datasets.py @@ -11,7 +11,7 @@ import os import sys -from typing import Any, Callable, Dict, List, Optional, Sequence, Union +from typing import Callable, Dict, List, Optional, Sequence, Union import numpy as np @@ -98,8 +98,8 @@ def __init__( self, data, transform, cache_num=cache_num, cache_rate=cache_rate, num_workers=num_workers ) - def randomize(self, data: Optional[Any] = None) -> None: - self.rann = self.R.random() + def randomize(self, data: List[int]) -> None: + self.R.shuffle(data) def get_num_classes(self) -> int: """Get number of classes.""" @@ -132,22 +132,26 @@ def _generate_data_list(self, dataset_dir: str) -> List[Dict]: data = [] - for i in range(num_total): - self.randomize() - if self.section == "training": - if self.rann < self.val_frac + self.test_frac: - continue - elif self.section == "validation": - if self.rann >= self.val_frac: - continue - elif self.section == "test": - if self.rann < self.val_frac or self.rann >= self.val_frac + self.test_frac: - continue - else: - raise ValueError( - f'Unsupported section: {self.section}, available options are ["training", "validation", "test"].' - ) + length = len(image_files_list) + indices = np.arange(length) + self.randomize(indices) + + test_length = int(length * self.test_frac) + val_length = int(length * self.val_frac) + if self.section == "test": + section_indices = indices[:test_length] + elif self.section == "validation": + section_indices = indices[test_length : test_length + val_length] + elif self.section == "training": + section_indices = indices[test_length + val_length :] + else: + raise ValueError( + f'Unsupported section: {self.section}, available options are ["training", "validation", "test"].' + ) + + for i in section_indices: data.append({"image": image_files_list[i], "label": image_class[i], "class_name": class_name[i]}) + return data diff --git a/monai/apps/deepgrow/dataset.py b/monai/apps/deepgrow/dataset.py index 45cfbde6ea..acaeba0bc3 100644 --- a/monai/apps/deepgrow/dataset.py +++ b/monai/apps/deepgrow/dataset.py @@ -22,7 +22,7 @@ def create_dataset( datalist, output_dir: str, - dimension, + dimension: int, pixdim, image_key: str = "image", label_key: str = "label", @@ -138,7 +138,7 @@ def _save_data_2d(vol_idx, vol_image, vol_label, dataset_dir, relative_path): if len(vol_image.shape) == 4: logging.info( "4D-Image, pick only first series; Image: {}; Label: {}".format( - vol_image.shape, vol_label.shape if vol_label else None + vol_image.shape, vol_label.shape if vol_label is not None else None ) ) vol_image = vol_image[0] @@ -216,7 +216,7 @@ def _save_data_3d(vol_idx, vol_image, vol_label, dataset_dir, relative_path): if len(vol_image.shape) == 4: logging.info( "4D-Image, pick only first series; Image: {}; Label: {}".format( - vol_image.shape, vol_label.shape if vol_label else None + vol_image.shape, vol_label.shape if vol_label is not None else None ) ) vol_image = vol_image[0] diff --git a/monai/apps/deepgrow/interaction.py b/monai/apps/deepgrow/interaction.py index 77e271a9eb..8a64ad7cf9 100644 --- a/monai/apps/deepgrow/interaction.py +++ b/monai/apps/deepgrow/interaction.py @@ -13,9 +13,9 @@ import torch from monai.engines import SupervisedEvaluator, SupervisedTrainer -from monai.engines.utils import CommonKeys from monai.engines.workflow import Events from monai.transforms import Compose +from monai.utils.enums import CommonKeys class Interaction: diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index f178360031..644507092d 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -8,18 +8,17 @@ # 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, Optional, Sequence, Union +from typing import Callable, Dict, Optional, Sequence, Union import numpy as np import torch from monai.config import IndexSelection, KeysCollection from monai.networks.layers import GaussianFilter -from monai.transforms import SpatialCrop -from monai.transforms.compose import MapTransform, Randomizable, Transform +from monai.transforms import Resize, SpatialCrop +from monai.transforms.transform import MapTransform, RandomizableTransform, Transform from monai.transforms.utils import generate_spatial_bounding_box -from monai.utils import min_version, optional_import +from monai.utils import InterpolateMode, ensure_tuple_rep, min_version, optional_import measure, _ = optional_import("skimage.measure", "0.14.2", min_version) distance_transform_cdt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_cdt") @@ -48,7 +47,7 @@ def _apply(self, label): return np.asarray(sids) def __call__(self, data): - d = dict(data) + d: Dict = dict(data) label = d[self.label] if label.shape[0] != 1: raise ValueError("Only supports single channel labels!") @@ -62,12 +61,13 @@ def __call__(self, data): return d -class AddInitialSeedPointd(Randomizable, Transform): +class AddInitialSeedPointd(RandomizableTransform): """ Add random guidance as initial seed point for a given label. Note that the label is of size (C, D, H, W) or (C, H, W) - The guidance is of size (2, N, # of dims) where N is number of guidance added + + The guidance is of size (2, N, # of dims) where N is number of guidance added. # of dims = 4 when C, D, H, W; # of dims = 3 when (C, H, W) Args: @@ -86,14 +86,23 @@ def __init__( sid: str = "sid", connected_regions: int = 5, ): + super().__init__(prob=1.0, do_transform=True) self.label = label - self.sids = sids - self.sid = sid + self.sids_key = sids + self.sid_key = sid + self.sid = None self.guidance = guidance self.connected_regions = connected_regions - def randomize(self, data=None): - pass + def randomize(self, data): + sid = data.get(self.sid_key, None) + sids = data.get(self.sids_key, None) + if sids is not None: + if sid is None or sid not in sids: + sid = self.R.choice(sids, replace=False) + else: + sid = None + self.sid = sid def _apply(self, label, sid): dimensions = 3 if len(label.shape) > 3 else 2 @@ -134,14 +143,8 @@ def _apply(self, label, sid): def __call__(self, data): d = dict(data) - sid = d.get(self.sid, None) - sids = d.get(self.sids, None) - if sids is not None: - if sid is None or sid not in sids: - sid = self.R.choice(sids, replace=False) - else: - sid = None - d[self.guidance] = self._apply(d[self.label], sid) + self.randomize(data) + d[self.guidance] = self._apply(d[self.label], self.sid) return d @@ -232,6 +235,7 @@ class FindDiscrepancyRegionsd(Transform): Find discrepancy between prediction and actual during click interactions during training. If batched is true: + label is in shape (B, C, D, H, W) or (B, C, H, W) pred has same shape as label discrepancy will have shape (B, 2, C, D, H, W) or (B, 2, C, H, W) @@ -279,11 +283,11 @@ def __call__(self, data): return d -class AddRandomGuidanced(Randomizable, Transform): +class AddRandomGuidanced(RandomizableTransform): """ Add random guidance based on discrepancies that were found between label and prediction. - If batched is True: + If batched is True, input shape is as below: Guidance is of shape (B, 2, N, # of dim) where B is batch size, 2 means positive and negative, N means how many guidance points, # of dim is the total number of dimensions of the image @@ -291,7 +295,15 @@ class AddRandomGuidanced(Randomizable, Transform): Discrepancy is of shape (B, 2, C, D, H, W) or (B, 2, C, H, W) - Probability is of shape (B,) + Probability is of shape (B, 1) + + else: + + Guidance is of shape (2, N, # of dim) + + Discrepancy is of shape (2, C, D, H, W) or (2, C, H, W) + + Probability is of shape (1) Args: guidance: key to guidance source. @@ -307,6 +319,7 @@ def __init__( probability: str = "probability", batched: bool = True, ): + super().__init__(prob=1.0, do_transform=True) self.guidance = guidance self.discrepancy = discrepancy self.probability = probability @@ -389,7 +402,7 @@ class SpatialCropForegroundd(MapTransform): """ Crop only the foreground object of the expected images. - Difference VS CropForegroundd: + Difference VS :py:class:`monai.transforms.CropForegroundd`: 1. If the bounding box is smaller than spatial size in all dimensions then this transform will crop the object using box's center and spatial_size. @@ -399,9 +412,11 @@ class SpatialCropForegroundd(MapTransform): The typical usage is to help training and evaluation if the valid part is small in the whole medical image. The valid part can be determined by any field in the data with `source_key`, for example: + - Select values > 0 in image field as the foreground and crop on all fields specified by `keys`. - Select label = 3 in label field as the foreground to crop on all fields specified by `keys`. - Select label > 0 in the third channel of a One-Hot label field as the foreground to crop all `keys` fields. + Users can define arbitrary function to select expected foreground from the whole source image or specified channels. And it can also add margin to every dim of the bounding box of foreground object. @@ -422,6 +437,7 @@ class SpatialCropForegroundd(MapTransform): end_coord_key: key to record the end coordinate of spatial bounding box for foreground. original_shape_key: key to record original shape for foreground. cropped_shape_key: key to record cropped shape for foreground. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -437,8 +453,9 @@ def __init__( end_coord_key: str = "foreground_end_coord", original_shape_key: str = "foreground_original_shape", cropped_shape_key: str = "foreground_cropped_shape", + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.source_key = source_key self.spatial_size = list(spatial_size) @@ -457,8 +474,8 @@ def __call__(self, data): d[self.source_key], self.select_fn, self.channel_indices, self.margin ) - center = np.mean([box_start, box_end], axis=0).astype(int).tolist() - current_size = np.subtract(box_end, box_start).astype(int).tolist() + center = list(np.mean([box_start, box_end], axis=0).astype(int)) + current_size = list(np.subtract(box_end, box_start).astype(int)) if np.all(np.less(current_size, self.spatial_size)): cropper = SpatialCrop(roi_center=center, roi_size=self.spatial_size) @@ -467,7 +484,226 @@ def __call__(self, data): else: cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) - for key in self.keys: + for key in self.key_iterator(d): + meta_key = f"{key}_{self.meta_key_postfix}" + d[meta_key][self.start_coord_key] = box_start + d[meta_key][self.end_coord_key] = box_end + d[meta_key][self.original_shape_key] = d[key].shape + + image = cropper(d[key]) + d[meta_key][self.cropped_shape_key] = image.shape + d[key] = image + return d + + +# Transforms to support Inference for Deepgrow models +class AddGuidanceFromPointsd(Transform): + """ + Add guidance based on user clicks. + + We assume the input is loaded by LoadImaged and has the shape of (H, W, D) originally. + Clicks always specify the coordinates in (H, W, D) + + If depth_first is True: + + Input is now of shape (D, H, W), will return guidance that specifies the coordinates in (D, H, W) + + else: + + Input is now of shape (H, W, D), will return guidance that specifies the coordinates in (H, W, D) + + Args: + ref_image: key to reference image to fetch current and original image details. + guidance: output key to store guidance. + foreground: key that represents user foreground (+ve) clicks. + background: key that represents user background (-ve) clicks. + axis: axis that represents slices in 3D volume. (axis to Depth) + depth_first: if depth (slices) is positioned at first dimension. + dimensions: dimensions based on model used for deepgrow (2D vs 3D). + slice_key: key that represents applicable slice to add guidance. + meta_key_postfix: use `{ref_image}_{postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + """ + + def __init__( + self, + ref_image, + guidance: str = "guidance", + foreground: str = "foreground", + background: str = "background", + axis: int = 0, + depth_first: bool = True, + dimensions: int = 2, + slice_key: str = "slice", + meta_key_postfix: str = "meta_dict", + ): + self.ref_image = ref_image + self.guidance = guidance + self.foreground = foreground + self.background = background + self.axis = axis + self.depth_first = depth_first + self.dimensions = dimensions + self.slice = slice_key + self.meta_key_postfix = meta_key_postfix + + def _apply(self, pos_clicks, neg_clicks, factor, slice_num): + pos = neg = [] + + if self.dimensions == 2: + points = list(pos_clicks) + points.extend(neg_clicks) + points = np.array(points) + + slices = list(np.unique(points[:, self.axis])) + slice_idx = slices[0] if slice_num is None else next(x for x in slices if x == slice_num) + + if len(pos_clicks): + pos_clicks = np.array(pos_clicks) + pos = (pos_clicks[np.where(pos_clicks[:, self.axis] == slice_idx)] * factor)[:, 1:].astype(int).tolist() + if len(neg_clicks): + neg_clicks = np.array(neg_clicks) + neg = (neg_clicks[np.where(neg_clicks[:, self.axis] == slice_idx)] * factor)[:, 1:].astype(int).tolist() + + guidance = [pos, neg, slice_idx] + else: + if len(pos_clicks): + pos = np.multiply(pos_clicks, factor).astype(int).tolist() + if len(neg_clicks): + neg = np.multiply(neg_clicks, factor).astype(int).tolist() + guidance = [pos, neg] + return guidance + + def __call__(self, data): + d = dict(data) + meta_dict_key = f"{self.ref_image}_{self.meta_key_postfix}" + if meta_dict_key not in d: + raise RuntimeError(f"Missing meta_dict {meta_dict_key} in data!") + if "spatial_shape" not in d[meta_dict_key]: + raise RuntimeError('Missing "spatial_shape" in meta_dict!') + original_shape = d[meta_dict_key]["spatial_shape"] + current_shape = list(d[self.ref_image].shape) + + if self.depth_first: + if self.axis != 0: + raise RuntimeError("Depth first means the depth axis should be 0.") + # in here we assume the depth dimension was in the last dimension of "original_shape" + original_shape = np.roll(original_shape, 1) + + factor = np.array(current_shape) / original_shape + + fg_bg_clicks = [] + for key in [self.foreground, self.background]: + clicks = d[key] + clicks = list(np.array(clicks).astype(int)) + if self.depth_first: + for i in range(len(clicks)): + clicks[i] = list(np.roll(clicks[i], 1)) + fg_bg_clicks.append(clicks) + d[self.guidance] = self._apply(fg_bg_clicks[0], fg_bg_clicks[1], factor, d.get(self.slice, None)) + return d + + +class SpatialCropGuidanced(MapTransform): + """ + Crop image based on guidance with minimal spatial size. + + - If the bounding box is smaller than spatial size in all dimensions then this transform will crop the + object using box's center and spatial_size. + + - This transform will set "start_coord_key", "end_coord_key", "original_shape_key" and "cropped_shape_key" + in data[{key}_{meta_key_postfix}] + + Input data is of shape (C, spatial_1, [spatial_2, ...]) + + Args: + keys: keys of the corresponding items to be transformed. + guidance: key to the guidance. It is used to generate the bounding box of foreground + spatial_size: minimal spatial size of the image patch e.g. [128, 128, 128] to fit in. + margin: add margin value to spatial dims of the bounding box, if only 1 value provided, use it for all dims. + meta_key_postfix: use `key_{postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + start_coord_key: key to record the start coordinate of spatial bounding box for foreground. + end_coord_key: key to record the end coordinate of spatial bounding box for foreground. + original_shape_key: key to record original shape for foreground. + cropped_shape_key: key to record cropped shape for foreground. + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__( + self, + keys: KeysCollection, + guidance: str, + spatial_size, + margin=20, + meta_key_postfix="meta_dict", + start_coord_key: str = "foreground_start_coord", + end_coord_key: str = "foreground_end_coord", + original_shape_key: str = "foreground_original_shape", + cropped_shape_key: str = "foreground_cropped_shape", + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + + self.guidance = guidance + self.spatial_size = list(spatial_size) + self.margin = margin + self.meta_key_postfix = meta_key_postfix + self.start_coord_key = start_coord_key + self.end_coord_key = end_coord_key + self.original_shape_key = original_shape_key + self.cropped_shape_key = cropped_shape_key + + def bounding_box(self, points, img_shape): + ndim = len(img_shape) + margin = ensure_tuple_rep(self.margin, ndim) + for m in margin: + if m < 0: + raise ValueError("margin value should not be negative number.") + + box_start = [0] * ndim + box_end = [0] * ndim + + for di in range(ndim): + dt = points[..., di] + min_d = max(min(dt - margin[di]), 0) + max_d = min(img_shape[di], max(dt + margin[di] + 1)) + box_start[di], box_end[di] = min_d, max_d + return box_start, box_end + + def __call__(self, data): + d: Dict = dict(data) + guidance = d[self.guidance] + original_spatial_shape = d[self.keys[0]].shape[1:] + box_start, box_end = self.bounding_box(np.array(guidance[0] + guidance[1]), original_spatial_shape) + center = list(np.mean([box_start, box_end], axis=0).astype(int)) + spatial_size = self.spatial_size + + box_size = list(np.subtract(box_end, box_start).astype(int)) + spatial_size = spatial_size[-len(box_size) :] + + if len(spatial_size) < len(box_size): + # If the data is in 3D and spatial_size is specified as 2D [256,256] + # Then we will get all slices in such case + diff = len(box_size) - len(spatial_size) + spatial_size = list(original_spatial_shape[1 : (1 + diff)]) + spatial_size + + if np.all(np.less(box_size, spatial_size)): + if len(center) == 3: + # 3D Deepgrow: set center to be middle of the depth dimension (D) + center[0] = spatial_size[0] // 2 + cropper = SpatialCrop(roi_center=center, roi_size=spatial_size) + else: + cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) + box_start, box_end = cropper.roi_start, cropper.roi_end + + for key in self.key_iterator(d): + if not np.array_equal(d[key].shape[1:], original_spatial_shape): + raise RuntimeError("All the image specified in keys should have same spatial shape") meta_key = f"{key}_{self.meta_key_postfix}" d[meta_key][self.start_coord_key] = box_start d[meta_key][self.end_coord_key] = box_end @@ -476,4 +712,230 @@ def __call__(self, data): image = cropper(d[key]) d[meta_key][self.cropped_shape_key] = image.shape d[key] = image + + pos_clicks, neg_clicks = guidance[0], guidance[1] + pos = np.subtract(pos_clicks, box_start).tolist() if len(pos_clicks) else [] + neg = np.subtract(neg_clicks, box_start).tolist() if len(neg_clicks) else [] + + d[self.guidance] = [pos, neg] + return d + + +class ResizeGuidanced(Transform): + """ + Resize the guidance based on cropped vs resized image. + + This transform assumes that the images have been cropped and resized. And the shape after cropped is store inside + the meta dict of ref image. + + Args: + guidance: key to guidance + ref_image: key to reference image to fetch current and original image details + meta_key_postfix: use `{ref_image}_{postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + cropped_shape_key: key that records cropped shape for foreground. + """ + + def __init__( + self, + guidance: str, + ref_image: str, + meta_key_postfix="meta_dict", + cropped_shape_key: str = "foreground_cropped_shape", + ) -> None: + self.guidance = guidance + self.ref_image = ref_image + self.meta_key_postfix = meta_key_postfix + self.cropped_shape_key = cropped_shape_key + + def __call__(self, data): + d = dict(data) + guidance = d[self.guidance] + meta_dict: Dict = d[f"{self.ref_image}_{self.meta_key_postfix}"] + current_shape = d[self.ref_image].shape[1:] + cropped_shape = meta_dict[self.cropped_shape_key][1:] + factor = np.divide(current_shape, cropped_shape) + + pos_clicks, neg_clicks = guidance[0], guidance[1] + pos = np.multiply(pos_clicks, factor).astype(int).tolist() if len(pos_clicks) else [] + neg = np.multiply(neg_clicks, factor).astype(int).tolist() if len(neg_clicks) else [] + + d[self.guidance] = [pos, neg] + return d + + +class RestoreLabeld(MapTransform): + """ + Restores label based on the ref image. + + The ref_image is assumed that it went through the following transforms: + + 1. Fetch2DSliced (If 2D) + 2. Spacingd + 3. SpatialCropGuidanced + 4. Resized + + And its shape is assumed to be (C, D, H, W) + + This transform tries to undo these operation so that the result label can be overlapped with original volume. + It does the following operation: + + 1. Undo Resized + 2. Undo SpatialCropGuidanced + 3. Undo Spacingd + 4. Undo Fetch2DSliced + + The resulting label is of shape (D, H, W) + + Args: + keys: keys of the corresponding items to be transformed. + ref_image: reference image to fetch current and original image details + slice_only: apply only to an applicable slice, in case of 2D model/prediction + mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, + ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} + One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + 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``. + meta_key_postfix: use `{ref_image}_{meta_key_postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + start_coord_key: key that records the start coordinate of spatial bounding box for foreground. + end_coord_key: key that records the end coordinate of spatial bounding box for foreground. + original_shape_key: key that records original shape for foreground. + cropped_shape_key: key that records cropped shape for foreground. + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__( + self, + keys: KeysCollection, + ref_image: str, + slice_only: bool = False, + mode: Union[Sequence[Union[InterpolateMode, str]], InterpolateMode, str] = InterpolateMode.NEAREST, + align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, + meta_key_postfix: str = "meta_dict", + start_coord_key: str = "foreground_start_coord", + end_coord_key: str = "foreground_end_coord", + original_shape_key: str = "foreground_original_shape", + cropped_shape_key: str = "foreground_cropped_shape", + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.ref_image = ref_image + self.slice_only = slice_only + self.mode = ensure_tuple_rep(mode, len(self.keys)) + self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) + self.meta_key_postfix = meta_key_postfix + self.start_coord_key = start_coord_key + self.end_coord_key = end_coord_key + self.original_shape_key = original_shape_key + self.cropped_shape_key = cropped_shape_key + + def __call__(self, data): + d = dict(data) + meta_dict: Dict = d[f"{self.ref_image}_{self.meta_key_postfix}"] + + for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): + image = d[key] + + # Undo Resize + current_shape = image.shape + cropped_shape = meta_dict[self.cropped_shape_key] + if np.any(np.not_equal(current_shape, cropped_shape)): + resizer = Resize(spatial_size=cropped_shape[1:], mode=mode) + image = resizer(image, mode=mode, align_corners=align_corners) + + # Undo Crop + original_shape = meta_dict[self.original_shape_key] + result = np.zeros(original_shape, dtype=np.float32) + box_start = meta_dict[self.start_coord_key] + box_end = meta_dict[self.end_coord_key] + + spatial_dims = min(len(box_start), len(image.shape[1:])) + slices = [slice(None)] + [slice(s, e) for s, e in zip(box_start[:spatial_dims], box_end[:spatial_dims])] + slices = tuple(slices) + result[slices] = image + + # Undo Spacing + current_size = result.shape[1:] + # change spatial_shape from HWD to DHW + spatial_shape = list(np.roll(meta_dict["spatial_shape"], 1)) + spatial_size = spatial_shape[-len(current_size) :] + + if np.any(np.not_equal(current_size, spatial_size)): + resizer = Resize(spatial_size=spatial_size, mode=mode) + result = resizer(result, mode=mode, align_corners=align_corners) + + # Undo Slicing + slice_idx = meta_dict.get("slice_idx") + if slice_idx is None or self.slice_only: + final_result = result if len(result.shape) <= 3 else result[0] + else: + slice_idx = meta_dict["slice_idx"][0] + final_result = np.zeros(tuple(spatial_shape)) + final_result[slice_idx] = result + d[key] = final_result + + meta = d.get(f"{key}_{self.meta_key_postfix}") + if meta is None: + meta = dict() + d[f"{key}_{self.meta_key_postfix}"] = meta + meta["slice_idx"] = slice_idx + meta["affine"] = meta_dict["original_affine"] + return d + + +class Fetch2DSliced(MapTransform): + """ + Fetch one slice in case of a 3D volume. + + The volume only contains spatial coordinates. + + Args: + keys: keys of the corresponding items to be transformed. + guidance: key that represents guidance. + axis: axis that represents slice in 3D volume. + meta_key_postfix: use `key_{meta_key_postfix}` to to fetch the meta data according to the key data, + default is `meta_dict`, the meta data is a dictionary object. + For example, to handle key `image`, read/write affine matrices from the + metadata `image_meta_dict` dictionary's `affine` field. + allow_missing_keys: don't raise exception if key is missing. + """ + + def __init__( + self, + keys, + guidance="guidance", + axis: int = 0, + meta_key_postfix: str = "meta_dict", + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.guidance = guidance + self.axis = axis + self.meta_key_postfix = meta_key_postfix + + def _apply(self, image, guidance): + slice_idx = guidance[2] # (pos, neg, slice_idx) + idx = [] + for i in range(len(image.shape)): + idx.append(slice_idx) if i == self.axis else idx.append(slice(0, image.shape[i])) + + idx = tuple(idx) + return image[idx], idx + + def __call__(self, data): + d = dict(data) + guidance = d[self.guidance] + if len(guidance) < 3: + raise RuntimeError("Guidance does not container slice_idx!") + for key in self.key_iterator(d): + img_slice, idx = self._apply(d[key], guidance) + d[key] = img_slice + d[f"{key}_{self.meta_key_postfix}"]["slice_idx"] = idx return d diff --git a/monai/csrc/ext.cpp b/monai/csrc/ext.cpp index 2e0644bc78..b4bb0f2c04 100644 --- a/monai/csrc/ext.cpp +++ b/monai/csrc/ext.cpp @@ -29,14 +29,20 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { // resample bound mode py::enum_(m, "BoundType") - .value("replicate", monai::BoundType::Replicate) - .value("dct1", monai::BoundType::DCT1) - .value("dct2", monai::BoundType::DCT2) - .value("dst1", monai::BoundType::DST1) - .value("dst2", monai::BoundType::DST2) - .value("dft", monai::BoundType::DFT) - .value("sliding", monai::BoundType::Sliding) - .value("zero", monai::BoundType::Zero) + .value("replicate", monai::BoundType::Replicate, "a a a | a b c d | d d d") + .value("nearest", monai::BoundType::Replicate, "a a a | a b c d | d d d") + .value("dct1", monai::BoundType::DCT1, "d c b | a b c d | c b a") + .value("mirror", monai::BoundType::DCT1, "d c b | a b c d | c b a") + .value("dct2", monai::BoundType::DCT2, "c b a | a b c d | d c b") + .value("reflect", monai::BoundType::DCT2, "c b a | a b c d | d c b") + .value("dst1", monai::BoundType::DST1, "-b -a 0 | a b c d | 0 -d -c") + .value("antimirror", monai::BoundType::DST1, "-b -a 0 | a b c d | 0 -d -c") + .value("dst2", monai::BoundType::DST2, "-c -b -a | a b c d | -d -c -b") + .value("antireflect", monai::BoundType::DST2, "-c -b -a | a b c d | -d -c -b") + .value("dft", monai::BoundType::DFT, "b c d | a b c d | a b c") + .value("wrap", monai::BoundType::DFT, "b c d | a b c d | a b c") + // .value("sliding", monai::BoundType::Sliding) + .value("zero", monai::BoundType::Zero, "0 0 0 | a b c d | 0 0 0") .export_values(); // resample interpolation mode diff --git a/monai/csrc/resample/pushpull.h b/monai/csrc/resample/pushpull.h index 45fd5ce564..1c20cc0114 100644 --- a/monai/csrc/resample/pushpull.h +++ b/monai/csrc/resample/pushpull.h @@ -69,8 +69,8 @@ at::Tensor grid_pull( CHECK_STRIDED(grid_opt) CHECK_SAME_DEVICE(input_opt, grid_opt) CHECK_SAME_DTYPE(input_opt, grid_opt) - CHECK_SPATIAL_2D_OR_3D(input) - CHECK_SPATIAL_2D_OR_3D(grid) + CHECK_SPATIAL_1D_2D_OR_3D(input) + CHECK_SPATIAL_1D_2D_OR_3D(grid) CHECK_GRID_COMPONENT(grid, grid.dim()) CHECK_SPATIAL_NOT_EMPTY(input) CHECK_SPATIAL_NOT_EMPTY(grid) @@ -165,8 +165,8 @@ at::Tensor grid_push( CHECK_STRIDED(grid_opt) CHECK_SAME_DEVICE(input_opt, grid_opt) CHECK_SAME_DTYPE(input_opt, grid_opt) - CHECK_SPATIAL_2D_OR_3D(input) - CHECK_SPATIAL_2D_OR_3D(grid) + CHECK_SPATIAL_1D_2D_OR_3D(input) + CHECK_SPATIAL_1D_2D_OR_3D(grid) CHECK_GRID_COMPONENT(grid, grid.dim()) CHECK_SPATIAL_NOT_EMPTY(input) CHECK_SPATIAL_NOT_EMPTY(grid) @@ -175,7 +175,10 @@ at::Tensor grid_push( CHECK_VEC_NOT_EMPTY(interpolation_mode); if (source_size.empty()) { - auto size = c10::IntArrayRef({input.size(2), input.size(3), input.dim() == 5 ? input.size(4) : 1}); + auto size = c10::IntArrayRef( + {input.dim() >= 3 ? input.size(2) : 1, + input.dim() >= 4 ? input.size(3) : 1, + input.dim() >= 5 ? input.size(4) : 1}); if (input.is_cuda()) #ifdef WITH_CUDA return cuda::pushpull( @@ -295,14 +298,15 @@ at::Tensor grid_count( CHECK_DEFINED(grid) auto grid_opt = grid.options(); CHECK_STRIDED(grid_opt) - CHECK_SPATIAL_2D_OR_3D(grid) + CHECK_SPATIAL_1D_2D_OR_3D(grid) CHECK_GRID_COMPONENT(grid, grid.dim()) CHECK_SPATIAL_NOT_EMPTY(grid) CHECK_VEC_NOT_EMPTY(bound_mode); CHECK_VEC_NOT_EMPTY(interpolation_mode); if (source_size.empty()) { - auto size = c10::IntArrayRef({grid.size(1), grid.size(2), grid.dim() == 5 ? grid.size(3) : 1}); + auto size = c10::IntArrayRef( + {grid.dim() >= 3 ? grid.size(2) : 1, grid.dim() >= 4 ? grid.size(3) : 1, grid.dim() >= 5 ? grid.size(4) : 1}); if (grid.is_cuda()) #ifdef WITH_CUDA return cuda::pushpull( @@ -422,8 +426,8 @@ at::Tensor grid_grad( CHECK_STRIDED(grid_opt) CHECK_SAME_DEVICE(input_opt, grid_opt) CHECK_SAME_DTYPE(input_opt, grid_opt) - CHECK_SPATIAL_2D_OR_3D(input) - CHECK_SPATIAL_2D_OR_3D(grid) + CHECK_SPATIAL_1D_2D_OR_3D(input) + CHECK_SPATIAL_1D_2D_OR_3D(grid) CHECK_GRID_COMPONENT(grid, grid.dim()) CHECK_SPATIAL_NOT_EMPTY(input) CHECK_SPATIAL_NOT_EMPTY(grid) diff --git a/monai/csrc/resample/pushpull_cpu.cpp b/monai/csrc/resample/pushpull_cpu.cpp index 40743a6cf1..dd10dd76ee 100644 --- a/monai/csrc/resample/pushpull_cpu.cpp +++ b/monai/csrc/resample/pushpull_cpu.cpp @@ -18,13 +18,14 @@ limitations under the License. // It handles boundary conditions and interpolation orders defined in // `utils/resample_utils.h` and `utils/resample_utils.h`. // These parameters can be specified per dimension. -// Isotorpic 0-th and 1-st order interpolation have their own (faster) +// Isotropic 0-th and 1-st order interpolation have their own (faster) // implementations. Sliding boundary conditions are also implemented // separately. // TODO: // . [DONE] generic 3d // . [DONE] generic 2d +// . [DONE] generic 1d // . sliding nearest 3d // . sliding nearest 2d // . sliding linear 3d @@ -37,6 +38,7 @@ limitations under the License. // . input bound/inter are always vectors -> clean unused constructors #include +#include #include #include "bounds_common.h" #include "interpolation_common.h" @@ -44,7 +46,7 @@ limitations under the License. //#include // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -// CPU/GPU -specific parameters +// CPU-specific parameters #include namespace { // This parameter specifies the minimum number of voxels that should be @@ -74,18 +76,27 @@ MONAI_NAMESPACE_DEVICE { // cpu namespace { // anonymous namespace > everything inside has internal linkage // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // GENERIC PUSHPULL CLASS + // INDEXING UTILS // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // This class implements the bulk of the code. - // /!\ No type and shape checking is performed here. - template - class PushPullImpl { + // This class reads and sets all the parameters that will later be used + // by the algorithm in PushPullImpl. All of this is done outside of the + // implementation class so that we do not depend on generic types. The + // point is to pre-allocate all necessary tensors so that we can check + // if they're all compatible with 32 bit math. If it's the case, we can + // dispatch to a 32b cuda implementation, which might increase + // performance. Else, we use 64 bit math to compute offsets. + // (On CPU, we always use 64 bit offsets because it doesn't make a huge + // difference. It would be different if we had a vectorized + // implementation as in PyTorch). + class PushPullAllocator { public: + static constexpr int64_t max_int32 = std::numeric_limits::max(); + // ~~~ CONSTRUCTORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MONAI_HOST - PushPullImpl( + PushPullAllocator( int dim, BoundVectorRef bound, InterpolationVectorRef interpolation, @@ -125,101 +136,418 @@ MONAI_NAMESPACE_DEVICE { // cpu iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; } - MONAI_HOST - PushPullImpl( - int dim, - BoundType bound, - InterpolationVectorRef interpolation, - bool extrapolate, - bool do_pull, - bool do_push, - bool do_count, - bool do_grad, - bool do_sgrad) - : dim(dim), - bound0(bound), - bound1(bound), - bound2(bound), - interpolation0(interpolation.size() > 0 ? interpolation[0] : InterpolationType::Linear), - interpolation1( - interpolation.size() > 1 ? interpolation[1] - : interpolation.size() > 0 ? interpolation[0] - : InterpolationType::Linear), - interpolation2( - interpolation.size() > 2 ? interpolation[2] - : interpolation.size() > 1 ? interpolation[1] - : interpolation.size() > 0 ? interpolation[0] - : InterpolationType::Linear), - extrapolate(extrapolate), - do_pull(do_pull), - do_push(do_push), - do_count(do_count), - do_grad(do_grad), - do_sgrad(do_sgrad) { - iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; + // ~~~ FUNCTORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + // Usually used for pull: + // - do_pull -> return source[grid] + // - do_push -> fails + // - do_grad -> return J(source)[grid] + // - do_sgrad -> return H(source)[grid] + MONAI_HOST void ioset(const Tensor& source, const Tensor& grid) { + init_all(); + init_source(source); + init_grid(grid); + init_output(); } - MONAI_HOST - PushPullImpl( - int dim, - BoundVectorRef bound, - InterpolationType interpolation, - bool extrapolate, - bool do_pull, - bool do_push, - bool do_count, - bool do_grad, - bool do_sgrad) - : dim(dim), - bound0(bound.size() > 0 ? bound[0] : BoundType::Replicate), - bound1( - bound.size() > 1 ? bound[1] - : bound.size() > 0 ? bound[0] - : BoundType::Replicate), - bound2( - bound.size() > 2 ? bound[2] - : bound.size() > 1 ? bound[1] - : bound.size() > 0 ? bound[0] - : BoundType::Replicate), - interpolation0(interpolation), - interpolation1(interpolation), - interpolation2(interpolation), - extrapolate(extrapolate), - do_pull(do_pull), - do_push(do_push), - do_count(do_count), - do_grad(do_grad), - do_sgrad(do_sgrad) { - iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; + // Usually used for pull_backward: + // - do_pull -> return source[grid] + // - do_push -> return push(target, grid, source.shape) + // - do_grad -> return J(source)[grid] + // - do_sgrad -> return H(source)[grid] + MONAI_HOST void ioset(const Tensor& source, const Tensor& grid, const Tensor& target) { + init_all(); + init_source(source); + init_grid(grid); + init_target(target); + init_output(); } - MONAI_HOST - PushPullImpl( - int dim, - BoundType bound, - InterpolationType interpolation, - bool extrapolate, - bool do_pull, - bool do_push, - bool do_count, - bool do_grad, - bool do_sgrad) - : dim(dim), - bound0(bound), - bound1(bound), - bound2(bound), - interpolation0(interpolation), - interpolation1(interpolation), - interpolation2(interpolation), - extrapolate(extrapolate), - do_pull(do_pull), - do_push(do_push), - do_count(do_count), - do_grad(do_grad), - do_sgrad(do_sgrad) { - iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; + // Usually used for push: + // - do_pull -> fails + // - do_push -> return push(target, grid, source_size) + // - do_grad -> fails + // - do_sgrad -> fails + MONAI_HOST void ioset(IntArrayRef source_size, const Tensor& grid, const Tensor& target) { + init_all(); + init_source(source_size); + init_grid(grid); + init_target(target); + init_output(); } + // Usually used for count: + // - do_pull -> fails + // - do_push -> return push(ones, grid, source_size) + // - do_grad -> fails + // - do_sgrad -> fails + MONAI_HOST void ioset(IntArrayRef source_size, const Tensor& grid) { + init_all(); + init_source(source_size); + init_grid(grid); + init_output(); + } + + // We just check that all tensors that we own are compatible with 32b math + bool canUse32BitIndexMath(int64_t max_elem = max_int32) const { + return src_32b_ok && trgt_32b_ok && grid_32b_ok && grad_32b_ok && out_32b_ok; + } + + private: + // Copied from aten/src/ATen/native/IndexingUtils.cpp in PyTorch 1.6. + // It is used to decide to which pointer type we should dispatch to. + // Basically, we need to make sure that the "furthest" element we need + // to reach is less than max_elem away. + static bool tensorCanUse32BitIndexMath(const Tensor& t, int64_t max_elem = max_int32) { + int64_t elements = t.numel(); + if (elements >= max_elem) { + return false; + } + if (elements == 0) { + return max_elem > 0; + } + + int64_t offset = 0; + int64_t linearId = elements - 1; + + // NOTE: Assumes all strides are positive, which is true for now + for (int i = t.dim() - 1; i >= 0; --i) { + int64_t curDimIndex = linearId % t.size(i); + int64_t curDimOffset = curDimIndex * t.stride(i); + offset += curDimOffset; + linearId /= t.size(i); + } + + if (offset >= max_elem) { + return false; + } + + return true; + } + + // ~~~ COMPONENTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + MONAI_HOST void init_all(); + MONAI_HOST void init_source(const Tensor& source); + MONAI_HOST void init_source(IntArrayRef source_size); + MONAI_HOST void init_grid(const Tensor& grid); + MONAI_HOST void init_target(const Tensor& target); + MONAI_HOST void init_output(); + + // ~~~ OPTIONS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + int dim; // dimensionality (2 or 3) + BoundType bound0; // boundary condition // x|W + BoundType bound1; // boundary condition // y|H + BoundType bound2; // boundary condition // z|D + InterpolationType interpolation0; // interpolation order // x|W + InterpolationType interpolation1; // interpolation order // y|H + InterpolationType interpolation2; // interpolation order // z|D + bool iso; // isotropic interpolation? + bool extrapolate; // compute out-of-bound values + bool do_pull; // sample a volume + bool do_push; // splat a volume + bool do_count; // splatting weights (= jacobian determinant) + bool do_grad; // backprop: gradient of grid // pull + bool do_sgrad; // sample spatial gradients + + // ~~~ NAVIGATORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + std::deque output; + TensorOptions src_opt; + TensorOptions grid_opt; + TensorOptions trgt_opt; + int64_t N; + int64_t C; + int64_t src_X; + int64_t src_Y; + int64_t src_Z; + int64_t trgt_X; + int64_t trgt_Y; + int64_t trgt_Z; + int64_t trgt_K; + int64_t src_sN; + int64_t src_sC; + int64_t src_sX; + int64_t src_sY; + int64_t src_sZ; + bool src_32b_ok; + void* src_ptr; + int64_t trgt_sN; + int64_t trgt_sC; + int64_t trgt_sX; + int64_t trgt_sY; + int64_t trgt_sZ; + int64_t trgt_sK; + bool trgt_32b_ok; + void* trgt_ptr; + int64_t grid_sN; + int64_t grid_sC; + int64_t grid_sX; + int64_t grid_sY; + int64_t grid_sZ; + bool grid_32b_ok; + void* grid_ptr; + int64_t out_sN; + int64_t out_sC; + int64_t out_sX; + int64_t out_sY; + int64_t out_sZ; + int64_t out_sK; // gradient dimension + bool out_32b_ok; + void* out_ptr; + int64_t grad_sN; + int64_t grad_sC; + int64_t grad_sX; + int64_t grad_sY; + int64_t grad_sZ; + bool grad_32b_ok; + void* grad_ptr; + + // Allow PushPullImpl's constructor to access PushPullAllocator's + // private members. + template + friend class PushPullImpl; + }; + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // INITIALISATION + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + MONAI_HOST + void PushPullAllocator::init_all() { + src_opt = grid_opt = trgt_opt = TensorOptions(); + N = C = 1L; + src_X = src_Y = src_Z = 1L; + trgt_X = trgt_Y = trgt_Z = 1L; + trgt_K = 0L; + src_sN = src_sC = src_sX = src_sY = src_sZ = 0L; + grid_sN = grid_sC = grid_sX = grid_sY = grid_sZ = 0L; + grad_sN = grad_sC = grad_sX = grad_sY = grad_sZ = 0L; + trgt_sN = trgt_sC = trgt_sX = trgt_sY = trgt_sZ = trgt_sK = 0L; + out_sN = out_sC = out_sX = out_sY = out_sZ = out_sK = 0L; + src_ptr = trgt_ptr = grid_ptr = out_ptr = grad_ptr = static_cast(0); + src_32b_ok = trgt_32b_ok = grid_32b_ok = out_32b_ok = grad_32b_ok = true; + } + + MONAI_HOST + void PushPullAllocator::init_source(const Tensor& source) { + N = source.size(0); + C = source.size(1); + src_X = source.size(2); + src_Y = dim < 2 ? 1L : source.size(3); + src_Z = dim < 3 ? 1L : source.size(4); + src_sN = source.stride(0); + src_sC = source.stride(1); + src_sX = source.stride(2); + src_sY = dim < 2 ? 0L : source.stride(3); + src_sZ = dim < 3 ? 0L : source.stride(4); + src_ptr = source.data_ptr(); + src_opt = source.options(); + src_32b_ok = tensorCanUse32BitIndexMath(source); + } + + MONAI_HOST + void PushPullAllocator::init_source(IntArrayRef source_size) { + src_X = source_size[0]; + src_Y = dim < 2 ? 1L : source_size[1]; + src_Z = dim < 3 ? 1L : source_size[2]; + } + + MONAI_HOST + void PushPullAllocator::init_grid(const Tensor& grid) { + N = grid.size(0); + trgt_X = grid.size(1); + trgt_Y = dim < 2 ? 1L : grid.size(2); + trgt_Z = dim < 3 ? 1L : grid.size(3); + grid_sN = grid.stride(0); + grid_sX = grid.stride(1); + grid_sY = dim < 2 ? 0L : grid.stride(2); + grid_sZ = dim < 3 ? 0L : grid.stride(3); + grid_sC = grid.stride(dim == 1 ? 2 : dim == 2 ? 3 : 4); + grid_ptr = grid.data_ptr(); + grid_opt = grid.options(); + grid_32b_ok = tensorCanUse32BitIndexMath(grid); + } + + MONAI_HOST + void PushPullAllocator::init_target(const Tensor& target) { + N = target.size(0); + C = target.size(1); + trgt_X = target.size(2); + trgt_Y = dim < 2 ? 1L : target.size(3); + trgt_Z = dim < 3 ? 1L : target.size(4); + trgt_K = target.dim() == dim + 3 ? target.size(dim == 1 ? 3 : dim == 2 ? 4 : 5) : 0L; + trgt_sN = target.stride(0); + trgt_sC = target.stride(1); + trgt_sX = target.stride(2); + trgt_sY = dim < 2 ? 0L : target.stride(3); + trgt_sZ = dim < 3 ? 0L : target.stride(4); + trgt_sK = target.dim() == dim + 3 ? target.stride(dim == 1 ? 3 : dim == 2 ? 4 : 5) : 0L; + trgt_ptr = target.data_ptr(); + trgt_opt = target.options(); + trgt_32b_ok = tensorCanUse32BitIndexMath(target); + } + + MONAI_HOST + void PushPullAllocator::init_output() { + output.clear(); + if (do_pull) { + if (dim == 1) + output.push_back(at::empty({N, C, trgt_X}, src_opt)); + else if (dim == 2) + output.push_back(at::empty({N, C, trgt_X, trgt_Y}, src_opt)); + else + output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z}, src_opt)); + auto pull = output.back(); + out_sN = pull.stride(0); + out_sC = pull.stride(1); + out_sX = pull.stride(2); + out_sY = dim < 2 ? 0L : pull.stride(3); + out_sZ = dim < 3 ? 0L : pull.stride(4); + out_sK = 0L; + out_ptr = pull.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(pull); + } else if (do_sgrad) { + if (dim == 1) + output.push_back(at::empty({N, C, trgt_X, 1}, src_opt)); + else if (dim == 2) + output.push_back(at::empty({N, C, trgt_X, trgt_Y, 2}, src_opt)); + else + output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z, 3}, src_opt)); + auto sgrad = output.back(); + out_sN = sgrad.stride(0); + out_sC = sgrad.stride(1); + out_sX = sgrad.stride(2); + out_sY = dim < 2 ? 0L : sgrad.stride(3); + out_sZ = dim < 3 ? 0L : sgrad.stride(4); + out_sK = sgrad.stride(dim == 1 ? 3 : dim == 2 ? 4 : 5); + out_ptr = sgrad.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(sgrad); + + if (iso && interpolation0 == InterpolationType::Nearest) + sgrad.zero_(); + if (iso && interpolation0 == InterpolationType::Linear && dim == 1) + sgrad.zero_(); + } else if (do_push) { + if (dim == 1) + output.push_back(at::zeros({N, C, src_X}, trgt_opt)); + else if (dim == 2) + output.push_back(at::zeros({N, C, src_X, src_Y}, trgt_opt)); + else + output.push_back(at::zeros({N, C, src_X, src_Y, src_Z}, trgt_opt)); + auto push = output.back(); + out_sN = push.stride(0); + out_sC = push.stride(1); + out_sX = push.stride(2); + out_sY = dim < 2 ? 0L : push.stride(3); + out_sZ = dim < 3 ? 0L : push.stride(4); + out_sK = 0L; + out_ptr = push.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(push); + } else if (do_count) { + if (dim == 1) + output.push_back(at::zeros({N, 1, src_X}, grid_opt)); + else if (dim == 2) + output.push_back(at::zeros({N, 1, src_X, src_Y}, grid_opt)); + else + output.push_back(at::zeros({N, 1, src_X, src_Y, src_Z}, grid_opt)); + auto count = output.back(); + out_sN = count.stride(0); + out_sC = count.stride(1); + out_sX = count.stride(2); + out_sY = dim < 2 ? 0L : count.stride(3); + out_sZ = dim < 3 ? 0L : count.stride(4); + out_sK = 0L; + out_ptr = count.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(count); + } + if (do_grad) { + if (dim == 1) + output.push_back(at::zeros({N, trgt_X, 1}, grid_opt)); + else if (dim == 2) + output.push_back(at::zeros({N, trgt_X, trgt_Y, 2}, grid_opt)); + else + output.push_back(at::zeros({N, trgt_X, trgt_Y, trgt_Z, 3}, grid_opt)); + auto grad = output.back(); + grad_sN = grad.stride(0); + grad_sX = grad.stride(1); + grad_sY = dim < 2 ? 0L : grad.stride(2); + grad_sZ = dim < 3 ? 0L : grad.stride(3); + grad_sC = grad.stride(dim == 1 ? 2 : dim == 2 ? 3 : 4); + grad_ptr = grad.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(grad); + + if (iso && interpolation0 == InterpolationType::Nearest) + grad.zero_(); + } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // GENERIC PUSHPULL CLASS + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // This class implements the bulk of the code. + // /!\ No type and shape checking is performed here. + + template + class PushPullImpl { + public: + // ~~~ CONSTRUCTOR ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + PushPullImpl(const PushPullAllocator& info) + : output(info.output), + dim(info.dim), + bound0(info.bound0), + bound1(info.bound1), + bound2(info.bound2), + interpolation0(info.interpolation0), + interpolation1(info.interpolation1), + interpolation2(info.interpolation1), + iso(info.iso), + extrapolate(info.extrapolate), + do_pull(info.do_pull), + do_push(info.do_push), + do_count(info.do_count), + do_grad(info.do_grad), + do_sgrad(info.do_sgrad), + N(static_cast(info.N)), + C(static_cast(info.C)), + src_X(static_cast(info.src_X)), + src_Y(static_cast(info.src_Y)), + src_Z(static_cast(info.src_Z)), + trgt_X(static_cast(info.trgt_X)), + trgt_Y(static_cast(info.trgt_Y)), + trgt_Z(static_cast(info.trgt_Z)), + trgt_K(static_cast(info.trgt_K)), + src_sN(static_cast(info.src_sN)), + src_sC(static_cast(info.src_sC)), + src_sX(static_cast(info.src_sX)), + src_sY(static_cast(info.src_sY)), + src_sZ(static_cast(info.src_sZ)), + src_ptr(static_cast(info.src_ptr)), + trgt_sN(static_cast(info.trgt_sN)), + trgt_sC(static_cast(info.trgt_sC)), + trgt_sX(static_cast(info.trgt_sX)), + trgt_sY(static_cast(info.trgt_sY)), + trgt_sZ(static_cast(info.trgt_sZ)), + trgt_sK(static_cast(info.trgt_sK)), + trgt_ptr(static_cast(info.trgt_ptr)), + grid_sN(static_cast(info.grid_sN)), + grid_sC(static_cast(info.grid_sC)), + grid_sX(static_cast(info.grid_sX)), + grid_sY(static_cast(info.grid_sY)), + grid_sZ(static_cast(info.grid_sZ)), + grid_ptr(static_cast(info.grid_ptr)), + out_sN(static_cast(info.out_sN)), + out_sC(static_cast(info.out_sC)), + out_sX(static_cast(info.out_sX)), + out_sY(static_cast(info.out_sY)), + out_sZ(static_cast(info.out_sZ)), + out_sK(static_cast(info.out_sK)), + out_ptr(static_cast(info.out_ptr)), + grad_sN(static_cast(info.grad_sN)), + grad_sC(static_cast(info.grad_sC)), + grad_sX(static_cast(info.grad_sX)), + grad_sY(static_cast(info.grad_sY)), + grad_sZ(static_cast(info.grad_sZ)), + grad_ptr(static_cast(info.grad_ptr)) {} + // ~~~ PUBLIC VALUE ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ std::deque output; @@ -247,39 +575,8 @@ MONAI_NAMESPACE_DEVICE { // cpu // } // ~~~ FUNCTORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - MONAI_HOST void ioset // Pull - (const Tensor& source, const Tensor& grid) { - init_all(); - init_source(source); - init_grid(grid); - init_output(); - } - - MONAI_HOST void ioset(const Tensor& source, const Tensor& grid, const Tensor& target) { - init_all(); - init_source(source); - init_grid(grid); - init_target(target); - init_output(); - } - - MONAI_HOST void ioset // Push - (IntArrayRef source_size, const Tensor& grid, const Tensor& target) { - init_all(); - init_source(source_size); - init_grid(grid); - init_target(target); - init_output(); - } - - MONAI_HOST void ioset // Count - (IntArrayRef source_size, const Tensor& grid) { - init_all(); - init_source(source_size); - init_grid(grid); - init_output(); - } + // Loop over all voxels void loop() const; MONAI_HOST MONAI_DEVICE int64_t voxcount() const { @@ -288,14 +585,18 @@ MONAI_NAMESPACE_DEVICE { // cpu private: // ~~~ COMPONENTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - MONAI_HOST void init_all(); - MONAI_HOST void init_source(const Tensor& source); - MONAI_HOST void init_source(IntArrayRef source_size); - MONAI_HOST void init_grid(const Tensor& grid); - MONAI_HOST void init_target(const Tensor& target); - MONAI_HOST void init_output(); + MONAI_DEVICE void check1d(offset_t w, offset_t n) const; MONAI_DEVICE void check2d(offset_t w, offset_t h, offset_t n) const; MONAI_DEVICE void check3d(offset_t w, offset_t h, offset_t d, offset_t n) const; + MONAI_DEVICE void interpolate1d(scalar_t x, offset_t w, offset_t n) const; + MONAI_DEVICE void interpolate1d_nearest(scalar_t x, offset_t w, offset_t n) const; + MONAI_DEVICE void interpolate1d_linear(scalar_t x, offset_t w, offset_t n) const; + MONAI_DEVICE void interpolate1d_sliding(scalar_t x, offset_t w, offset_t n) const { /*TODO*/ + } + MONAI_DEVICE void interpolate1d_sliding_nearest(scalar_t x, offset_t w, offset_t n) const { /*TODO*/ + } + MONAI_DEVICE void interpolate1d_sliding_linear(scalar_t x, offset_t w, offset_t n) const { /*TODO*/ + } MONAI_DEVICE void interpolate2d(scalar_t x, scalar_t y, offset_t w, offset_t h, offset_t n) const; MONAI_DEVICE void interpolate2d_nearest(scalar_t x, scalar_t y, offset_t w, offset_t h, offset_t n) const; MONAI_DEVICE void interpolate2d_bilinear(scalar_t x, scalar_t y, offset_t w, offset_t h, offset_t n) const; @@ -370,9 +671,6 @@ MONAI_NAMESPACE_DEVICE { // cpu bool do_sgrad; // sample spatial gradients // ~~~ NAVIGATORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - TensorOptions src_opt; - TensorOptions grid_opt; - TensorOptions trgt_opt; offset_t N; offset_t C; offset_t src_X; @@ -402,174 +700,24 @@ MONAI_NAMESPACE_DEVICE { // cpu offset_t grid_sZ; scalar_t* grid_ptr; offset_t out_sN; - offset_t out_sC; - offset_t out_sX; - offset_t out_sY; - offset_t out_sZ; - offset_t out_sK; // gradient dimension - scalar_t* out_ptr; - offset_t grad_sN; - offset_t grad_sC; - offset_t grad_sX; - offset_t grad_sY; - offset_t grad_sZ; - scalar_t* grad_ptr; - }; - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // INITIALISATION - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - template - void PushPullImpl::init_all() { - src_opt = grid_opt = trgt_opt = TensorOptions(); - N = C = static_cast(1); - src_X = src_Y = src_Z = static_cast(1); - trgt_X = trgt_Y = trgt_Z = trgt_K = static_cast(1); - src_sN = src_sC = src_sX = src_sY = src_sZ = static_cast(0); - grid_sN = grid_sC = grid_sX = grid_sY = grid_sZ = static_cast(0); - grad_sN = grad_sC = grad_sX = grad_sY = grad_sZ = static_cast(0); - trgt_sN = trgt_sC = trgt_sX = trgt_sY = trgt_sZ = trgt_sK = static_cast(0); - out_sN = out_sC = out_sX = out_sY = out_sZ = out_sK = static_cast(0); - src_ptr = trgt_ptr = grid_ptr = out_ptr = grad_ptr = static_cast(0); - } - - template - MONAI_HOST void PushPullImpl::init_source(const Tensor& source) { - N = source.size(0); - C = source.size(1); - src_X = source.size(2); - src_Y = source.size(3); - src_Z = dim == 2 ? static_cast(1) : source.size(4); - src_sN = source.stride(0); - src_sC = source.stride(1); - src_sX = source.stride(2); - src_sY = source.stride(3); - src_sZ = dim == 2 ? static_cast(0) : source.stride(4); - src_ptr = source.data_ptr(); - src_opt = source.options(); - } - - template - MONAI_HOST void PushPullImpl::init_source(IntArrayRef source_size) { - src_X = source_size[0]; - src_Y = source_size[1]; - src_Z = dim == 2 ? static_cast(1) : source_size[2]; - } - - template - MONAI_HOST void PushPullImpl::init_grid(const Tensor& grid) { - N = grid.size(0); - trgt_X = grid.size(1); - trgt_Y = grid.size(2); - trgt_Z = dim == 2 ? static_cast(1) : grid.size(3); - grid_sN = grid.stride(0); - grid_sX = grid.stride(1); - grid_sY = grid.stride(2); - grid_sZ = dim == 2 ? static_cast(0) : grid.stride(3); - grid_sC = grid.stride(dim == 2 ? 3 : 4); - grid_ptr = grid.data_ptr(); - grid_opt = grid.options(); - } - - template - MONAI_HOST void PushPullImpl::init_target(const Tensor& target) { - N = target.size(0); - C = target.size(1); - trgt_X = target.size(2); - trgt_Y = target.size(3); - trgt_Z = dim == 2 ? static_cast(1) : target.size(4); - trgt_K = target.dim() == dim + 3 ? target.size(dim == 2 ? 4 : 5) : static_cast(1); - trgt_sN = target.stride(0); - trgt_sC = target.stride(1); - trgt_sX = target.stride(2); - trgt_sY = target.stride(3); - trgt_sZ = dim == 2 ? static_cast(0) : target.stride(4); - trgt_sK = target.dim() == dim + 3 ? target.stride(dim == 2 ? 4 : 5) : static_cast(0); - trgt_ptr = target.data_ptr(); - trgt_opt = target.options(); - } - - template - MONAI_HOST void PushPullImpl::init_output() { - output.clear(); - if (do_pull) { - if (dim == 2) - output.push_back(at::empty({N, C, trgt_X, trgt_Y}, src_opt)); - else - output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z}, src_opt)); - auto pull = output.back(); - out_sN = pull.stride(0); - out_sC = pull.stride(1); - out_sX = pull.stride(2); - out_sY = pull.stride(3); - out_sZ = dim == 2 ? static_cast(0) : pull.stride(4); - out_sK = static_cast(0); - out_ptr = pull.template data_ptr(); - } else if (do_sgrad) { - if (dim == 2) - output.push_back(at::empty({N, C, trgt_X, trgt_Y, 2}, src_opt)); - else - output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z, 3}, src_opt)); - auto sgrad = output.back(); - out_sN = sgrad.stride(0); - out_sC = sgrad.stride(1); - out_sX = sgrad.stride(2); - out_sY = sgrad.stride(3); - out_sZ = dim == 2 ? static_cast(0) : sgrad.stride(4); - out_sK = sgrad.stride(dim == 2 ? 4 : 5); - out_ptr = sgrad.template data_ptr(); - - if (iso && interpolation0 == InterpolationType::Nearest) - sgrad.zero_(); - } else if (do_push) { - if (dim == 2) - output.push_back(at::zeros({N, C, src_X, src_Y}, trgt_opt)); - else - output.push_back(at::zeros({N, C, src_X, src_Y, src_Z}, trgt_opt)); - auto push = output.back(); - out_sN = push.stride(0); - out_sC = push.stride(1); - out_sX = push.stride(2); - out_sY = push.stride(3); - out_sZ = dim == 2 ? static_cast(0) : push.stride(4); - out_sK = static_cast(0); - out_ptr = push.template data_ptr(); - } else if (do_count) { - if (dim == 2) - output.push_back(at::zeros({N, 1, src_X, src_Y}, grid_opt)); - else - output.push_back(at::zeros({N, 1, src_X, src_Y, src_Z}, grid_opt)); - auto count = output.back(); - out_sN = count.stride(0); - out_sC = count.stride(1); - out_sX = count.stride(2); - out_sY = count.stride(3); - out_sZ = dim == 2 ? static_cast(0) : count.stride(4); - out_sK = static_cast(0); - out_ptr = count.template data_ptr(); - } - if (do_grad) { - if (dim == 2) - output.push_back(at::zeros({N, src_X, src_Y, 2}, grid_opt)); - else - output.push_back(at::zeros({N, src_X, src_Y, src_Z, 3}, grid_opt)); - auto grad = output.back(); - grad_sN = grad.stride(0); - grad_sX = grad.stride(1); - grad_sY = grad.stride(2); - grad_sZ = dim == 2 ? static_cast(0) : grad.stride(3); - grad_sC = grad.stride(dim == 2 ? 3 : 4); - grad_ptr = grad.template data_ptr(); - - if (iso && interpolation0 == InterpolationType::Nearest) - grad.zero_(); - } - } + offset_t out_sC; + offset_t out_sX; + offset_t out_sY; + offset_t out_sZ; + offset_t out_sK; // gradient dimension + scalar_t* out_ptr; + offset_t grad_sN; + offset_t grad_sC; + offset_t grad_sX; + offset_t grad_sY; + offset_t grad_sZ; + scalar_t* grad_ptr; + }; // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // LOOP // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // This bit loops over all target voxels. We therefore need to // convert linear indices to multivariate indices. The way I do it // might not be optimal. @@ -586,7 +734,10 @@ MONAI_NAMESPACE_DEVICE { // cpu // parallelize across voxels. at::parallel_for(0, N, 0, [&](offset_t start, offset_t end) { for (offset_t n = start; n < end; ++n) { - if (dim == 2) { + if (dim == 1) { + for (offset_t w = 0; w < trgt_X; ++w) + check1d(w, n); + } else if (dim == 2) { for (offset_t h = 0; h < trgt_Y; ++h) for (offset_t w = 0; w < trgt_X; ++w) check2d(w, h, n); @@ -600,8 +751,8 @@ MONAI_NAMESPACE_DEVICE { // cpu }); return; } -#endif +#endif // Parallelize across voxels offset_t trgt_NXYZ = trgt_Z * trgt_Y * trgt_X * N; offset_t trgt_XYZ = trgt_Z * trgt_Y * trgt_X; @@ -615,7 +766,9 @@ MONAI_NAMESPACE_DEVICE { // cpu h = (i / trgt_Z) % trgt_Y; d = i % trgt_Z; - if (dim == 2) + if (dim == 1) + check1d(w, n); + else if (dim == 2) check2d(w, h, n); else check3d(w, h, d, n); @@ -631,6 +784,59 @@ MONAI_NAMESPACE_DEVICE { // cpu // 1) read the [x,y,z] source coordinate for the current target voxel // 3) check if the source coordinate is in bounds + template + MONAI_DEVICE void PushPullImpl::check3d(offset_t w, offset_t h, offset_t d, offset_t n) const { + // get the corresponding input x, y, z co-ordinates from grid + scalar_t* grid_ptr_NXYZ = grid_ptr + n * grid_sN + w * grid_sX + h * grid_sY + d * grid_sZ; + scalar_t x = *grid_ptr_NXYZ; + scalar_t y = grid_ptr_NXYZ[grid_sC]; + scalar_t z = grid_ptr_NXYZ[grid_sC * 2]; + + // Check if out-of-bound + if (!(extrapolate || + (inbounds(x, src_X, static_cast(TINY)) && inbounds(y, src_Y, static_cast(TINY)) && + inbounds(z, src_Z, static_cast(TINY))))) { + if (do_pull || do_sgrad) { + scalar_t* out_ptr_NCXYZ = out_ptr + n * out_sN + w * out_sX + h * out_sY + d * out_sZ; + for (offset_t c = 0; c < C; ++c, out_ptr_NCXYZ += out_sC) { + *out_ptr_NCXYZ = static_cast(0); + if (do_sgrad) { + out_ptr_NCXYZ[out_sK] = static_cast(0); + out_ptr_NCXYZ[out_sK * 2] = static_cast(0); + } + } + } + if (do_grad) { + scalar_t* grad_ptr_NXYZ = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY + d * grad_sZ; + (*grad_ptr_NXYZ) = static_cast(0); + grad_ptr_NXYZ[grad_sC] = static_cast(0); + grad_ptr_NXYZ[grad_sC * 2] = static_cast(0); + } + return; + } + + // Next step + if (bound0 == BoundType::Sliding) { + if (iso) + switch (static_cast(interpolation0)) { + case 0: + return interpolate3d_sliding_nearest(x, y, z, w, h, d, n); + case 1: + return interpolate3d_sliding_trilinear(x, y, z, w, h, d, n); + } + return interpolate3d_sliding(x, y, z, w, h, d, n); + } else { + if (iso) + switch (static_cast(interpolation0)) { + case 0: + return interpolate3d_nearest(x, y, z, w, h, d, n); + case 1: + return interpolate3d_trilinear(x, y, z, w, h, d, n); + } + return interpolate3d(x, y, z, w, h, d, n); + } + } + template MONAI_DEVICE void PushPullImpl::check2d(offset_t w, offset_t h, offset_t n) const { // get the corresponding input x, y, z co-ordinates from grid @@ -642,7 +848,7 @@ MONAI_NAMESPACE_DEVICE { // cpu if (!(extrapolate || (inbounds(x, src_X, static_cast(TINY)) && inbounds(y, src_Y, static_cast(TINY))))) { if (do_pull || do_sgrad) { - scalar_t* out_ptr_NCXY = out_ptr + n * out_sN + w * out_sZ + h * out_sY; + scalar_t* out_ptr_NCXY = out_ptr + n * out_sN + w * out_sX + h * out_sY; for (offset_t c = 0; c < C; ++c, out_ptr_NCXY += out_sC) { *out_ptr_NCXY = static_cast(0); if (do_sgrad) @@ -680,32 +886,25 @@ MONAI_NAMESPACE_DEVICE { // cpu } template - MONAI_DEVICE void PushPullImpl::check3d(offset_t w, offset_t h, offset_t d, offset_t n) const { + MONAI_DEVICE void PushPullImpl::check1d(offset_t w, offset_t n) const { // get the corresponding input x, y, z co-ordinates from grid - scalar_t* grid_ptr_NXYZ = grid_ptr + n * grid_sN + w * grid_sX + h * grid_sY + d * grid_sZ; - scalar_t x = *grid_ptr_NXYZ; - scalar_t y = grid_ptr_NXYZ[grid_sC]; - scalar_t z = grid_ptr_NXYZ[grid_sC * 2]; + scalar_t* grid_ptr_NX = grid_ptr + n * grid_sN + w * grid_sX; + scalar_t x = *grid_ptr_NX; // Check if out-of-bound - if (!(extrapolate || - (inbounds(x, src_X, static_cast(TINY)) && inbounds(y, src_Y, static_cast(TINY)) && - inbounds(z, src_Z, static_cast(TINY))))) { + if (!(extrapolate || inbounds(x, src_X, static_cast(TINY)))) { if (do_pull || do_sgrad) { - scalar_t* out_ptr_NCXYZ = out_ptr + n * out_sN + w * out_sX + h * out_sY + d * out_sZ; - for (offset_t c = 0; c < C; ++c, out_ptr_NCXYZ += out_sC) { - *out_ptr_NCXYZ = static_cast(0); - if (do_sgrad) { - out_ptr_NCXYZ[out_sK] = static_cast(0); - out_ptr_NCXYZ[out_sK * 2] = static_cast(0); - } + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC) { + *out_ptr_NCX = static_cast(0); + if (do_sgrad) + out_ptr_NCX[out_sK] = static_cast(0); } } if (do_grad) { - scalar_t* grad_ptr_NXYZ = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY + d * grad_sZ; - (*grad_ptr_NXYZ) = static_cast(0); - grad_ptr_NXYZ[grad_sC] = static_cast(0); - grad_ptr_NXYZ[grad_sC * 2] = static_cast(0); + scalar_t* grad_ptr_NX = grad_ptr + n * grad_sN + w * grad_sX; + (*grad_ptr_NX) = static_cast(0); + grad_ptr_NX[grad_sC] = static_cast(0); } return; } @@ -715,20 +914,20 @@ MONAI_NAMESPACE_DEVICE { // cpu if (iso) switch (static_cast(interpolation0)) { case 0: - return interpolate3d_sliding_nearest(x, y, z, w, h, d, n); + return interpolate1d_sliding_nearest(x, w, n); case 1: - return interpolate3d_sliding_trilinear(x, y, z, w, h, d, n); + return interpolate1d_sliding_linear(x, w, n); } - return interpolate3d_sliding(x, y, z, w, h, d, n); + return interpolate1d_sliding(x, w, n); } else { if (iso) switch (static_cast(interpolation0)) { case 0: - return interpolate3d_nearest(x, y, z, w, h, d, n); + return interpolate1d_nearest(x, w, n); case 1: - return interpolate3d_trilinear(x, y, z, w, h, d, n); + return interpolate1d_linear(x, w, n); } - return interpolate3d(x, y, z, w, h, d, n); + return interpolate1d(x, w, n); } } @@ -763,7 +962,7 @@ MONAI_NAMESPACE_DEVICE { // cpu if (trgt_ptr && (do_push || do_grad)) for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXYZ += trgt_sC) { target[c] = *trgt_ptr_NCXYZ; - if (trgt_K > 1) { + if (trgt_K > 0) { target[c + C] = trgt_ptr_NCXYZ[trgt_sK]; target[c + C * 2] = trgt_ptr_NCXYZ[trgt_sK * 2]; } @@ -881,7 +1080,7 @@ MONAI_NAMESPACE_DEVICE { // cpu // ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ else if (do_push) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull scalar_t* out_ptr_NC = out_ptr_NC0; for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) @@ -904,7 +1103,7 @@ MONAI_NAMESPACE_DEVICE { // cpu // ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ if (do_grad) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. pull/push scalar_t* src_ptr_NC = src_ptr_NC0; scalar_t dot = static_cast(0); @@ -973,7 +1172,7 @@ MONAI_NAMESPACE_DEVICE { // cpu if (trgt_ptr && (do_push || do_grad)) for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXY += trgt_sC) { target[c] = *trgt_ptr_NCXY; - if (trgt_K > 1) { + if (trgt_K > 0) { target[c + C] = trgt_ptr_NCXY[trgt_sK]; } } @@ -1066,7 +1265,7 @@ MONAI_NAMESPACE_DEVICE { // cpu // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ else if (do_push) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull scalar_t* out_ptr_NC = out_ptr_NC0; for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) @@ -1088,7 +1287,7 @@ MONAI_NAMESPACE_DEVICE { // cpu // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ if (do_grad) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. pull/push scalar_t* src_ptr_NC = src_ptr_NC0; scalar_t dot = static_cast(0); @@ -1125,6 +1324,150 @@ MONAI_NAMESPACE_DEVICE { // cpu } } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // GENERIC INTERPOLATION 1D + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + template + MONAI_DEVICE void PushPullImpl::interpolate1d(scalar_t x, offset_t w, offset_t n) const { + // Get corner pixel values from (x, y) + offset_t bx0, bx1; + interpolation::bounds(interpolation0, x, bx0, bx1); + offset_t dbx = bx1 - bx0; + + // Pre-compute offsets and target value + scalar_t* src_ptr_NC0 = src_ptr + n * src_sN; + scalar_t* out_ptr_NC0 = out_ptr + n * out_sN; + scalar_t* out_ptr_NCX0 = out_ptr + n * out_sN + w * out_sX; + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t target[2 * MONAI_MAX_NUM_CHANNELS]; + if (trgt_ptr && (do_push || do_grad)) + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC) { + target[c] = *trgt_ptr_NCX; + if (trgt_K > 0) { + target[c + C] = trgt_ptr_NCX[trgt_sK]; + } + } + + // Initialize output + scalar_t* out_ptr_NCX = out_ptr_NCX0; + if (do_pull || do_sgrad) { + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC) { + *out_ptr_NCX = static_cast(0); + if (do_sgrad) { + out_ptr_NCX[out_sK] = static_cast(0); + } + } + } + + // Pre-compute indices/weights/grad + scalar_t wx[8]; // B-spline weights + scalar_t gx[8]; // B-spline derivatives + scalar_t hx[8]; // B-spline 2nd derivatives + offset_t ix[8]; // Warped indices + uint8_t sx[8]; // Warped indices + + { + scalar_t *owx = static_cast(wx), *ogx = static_cast(gx), *ohx = static_cast(hx); + offset_t* oix = static_cast(ix); + uint8_t* osx = static_cast(sx); + for (offset_t bx = bx0; bx <= bx1; ++bx) { + scalar_t dx = x - bx; + *(owx++) = interpolation::fastweight(interpolation0, dx); + if (do_grad || do_sgrad) + *(ogx++) = interpolation::fastgrad(interpolation0, dx); + if (do_grad && trgt_sK > 1) + *(ohx++) = interpolation::fasthess(interpolation0, dx); + *(osx++) = bound::sign(bound0, bx, src_X); + *(oix++) = bound::index(bound0, bx, src_X); + } + } + + // Convolve coefficients with basis functions + scalar_t ogx; + ogx = static_cast(0); + for (offset_t i = 0; i <= dbx; ++i) { + offset_t oox = ix[i] * out_sX; + offset_t osx = ix[i] * src_sX; + uint8_t sxx = sx[i]; + scalar_t wxx = wx[i]; + scalar_t gxx = gx[i]; + scalar_t hxx = hx[i]; + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pull ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_pull) { + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t* out_ptr_NCX = out_ptr_NCX0; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) + *out_ptr_NCX += bound::get(src_ptr_NC, osx, sxx) * wxx; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SGrad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_sgrad) { + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t* out_ptr_NCX = out_ptr_NCX0; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) { + scalar_t src = bound::get(src_ptr_NC, osx, sxx); + *out_ptr_NCX += src * gxx; + } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_push) { + if (trgt_K == 0) { + // Diff w.r.t. push/pull + scalar_t* out_ptr_NC = out_ptr_NC0; + for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) + bound::add(out_ptr_NC, oox, wxx * target[c], sxx); + } else { + // Diff w.r.t. sgrad + scalar_t* out_ptr_NC = out_ptr_NC0; + for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) { + scalar_t val = gxx * target[c]; + bound::add(out_ptr_NC, oox, val, sxx); + } + } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Count ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_count) { + bound::add(out_ptr_NC0, oox, wxx, sxx); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_grad) { + if (trgt_K == 0) { + // Diff w.r.t. pull/push + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t dot = static_cast(0); + for (offset_t c = 0; c < C; ++c, src_ptr_NC += src_sC) { + scalar_t src = bound::get(src_ptr_NC, osx, sxx); + dot += (trgt_ptr ? src * target[c] : src); + // trgt_ptr == 0 in the backward pass of 'count' + } + ogx += gxx * dot; + } else { + // Diff w.r.t. sgrad + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t dot; + dot = static_cast(0); + for (offset_t c = 0; c < C; ++c, src_ptr_NC += src_sC) { + scalar_t src = bound::get(src_ptr_NC, osx, sxx); + dot += src * target[c]; + } + ogx += hxx * dot; + } + } + + } // x + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_grad) { + scalar_t* grad_ptr_NX = grad_ptr + n * grad_sN + w * grad_sX; + (*grad_ptr_NX) = ogx; + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // LINEAR INTERPOLATION 3D // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1214,7 +1557,7 @@ MONAI_NAMESPACE_DEVICE { // cpu scalar_t* trgt_ptr_NCXYZ = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY + d * trgt_sZ; scalar_t* src_ptr_NC = src_ptr + n * src_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // backward w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXYZ += trgt_sC, src_ptr_NC += src_sC) { scalar_t src; @@ -1376,7 +1719,7 @@ MONAI_NAMESPACE_DEVICE { // cpu o111 = ix1 * out_sX + iy1 * out_sY + iz1 * out_sZ; scalar_t* trgt_ptr_NCXYZ = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY + d * trgt_sZ; scalar_t* out_ptr_NC = out_ptr + n * out_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXYZ += trgt_sC, out_ptr_NC += out_sC) { scalar_t trgt = *trgt_ptr_NCXYZ; @@ -1461,7 +1804,6 @@ MONAI_NAMESPACE_DEVICE { // cpu scalar_t w10 = dx1 * dy0; scalar_t w01 = dx0 * dy1; scalar_t w11 = dx1 * dy1; - ; // Sign (/!\ compute sign before warping indices) int8_t sx1 = bound::sign(bound0, ix0 + 1, src_X); @@ -1500,7 +1842,7 @@ MONAI_NAMESPACE_DEVICE { // cpu scalar_t* trgt_ptr_NCXY = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY; scalar_t* src_ptr_NC = src_ptr + n * src_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // backward w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXY += trgt_sC, src_ptr_NC += src_sC) { scalar_t src; @@ -1547,9 +1889,9 @@ MONAI_NAMESPACE_DEVICE { // cpu } } - scalar_t* grad_ptr_NXYZ = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY; - (*grad_ptr_NXYZ) = gx; - grad_ptr_NXYZ[grad_sC] = gy; + scalar_t* grad_ptr_NXY = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY; + (*grad_ptr_NXY) = gx; + grad_ptr_NXY[grad_sC] = gy; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pull ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ if (do_pull) { @@ -1591,7 +1933,7 @@ MONAI_NAMESPACE_DEVICE { // cpu o11 = ix1 * out_sX + iy1 * out_sY; scalar_t* trgt_ptr_NCXY = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY; scalar_t* out_ptr_NC = out_ptr + n * out_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXY += trgt_sC, out_ptr_NC += out_sC) { scalar_t trgt = *trgt_ptr_NCXY; @@ -1632,6 +1974,123 @@ MONAI_NAMESPACE_DEVICE { // cpu } } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // LINEAR INTERPOLATION 1D + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + template + MONAI_DEVICE void PushPullImpl::interpolate1d_linear(scalar_t x, offset_t w, offset_t n) const { + // Get corner pixel values from (x) + offset_t ix0 = static_cast(std::floor(x)); + + // Interpolation weights (inversely proportional to distance) + scalar_t w1 = x - ix0; + scalar_t w0 = 1. - w1; + + // Sign (/!\ compute sign before warping indices) + int8_t s1 = bound::sign(bound0, ix0 + 1, src_X); + int8_t s0 = bound::sign(bound0, ix0, src_X); + + // Warp indices + offset_t ix1; + ix1 = bound::index(bound0, ix0 + 1, src_X); + ix0 = bound::index(bound0, ix0, src_X); + + // Offsets into source volume + offset_t o0, o1; + if (do_pull || do_grad || do_sgrad) { + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~ Grid gradient ~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_grad) { + if (trgt_K == 0) { + // backward w.r.t. push/pull + + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + scalar_t gx = static_cast(0); + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, src_ptr_NC += src_sC) { + scalar_t src; + scalar_t trgt = trgt_ptr ? *trgt_ptr_NCX : static_cast(1); + // ^ trgt_ptr == 0 during the backward pass of count + src = bound::get(src_ptr_NC, o0, s0); + if (trgt_ptr) + src *= trgt; + gx -= src; + src = bound::get(src_ptr_NC, o1, s1); + if (trgt_ptr) + src *= trgt; + gx += src; + } + + scalar_t* grad_ptr_NX = grad_ptr + n * grad_sN + w * grad_sX; + (*grad_ptr_NX) = gx; + } else { + // backward w.r.t. sgrad + // -> zero (make sure this is done at initialization) + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pull ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_pull) { + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) { + *out_ptr_NCX = bound::get(src_ptr_NC, o0, s0) * w0 + bound::get(src_ptr_NC, o1, s1) * w1; + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SGrad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_sgrad) { + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) { + *out_ptr_NCX = bound::get(src_ptr_NC, o1, s1) - bound::get(src_ptr_NC, o0, s0); + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_push) { + // Offsets into 'push' volume + o0 = ix0 * out_sX; + o1 = ix1 * out_sX; + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t* out_ptr_NC = out_ptr + n * out_sN; + if (trgt_K == 0) { + // Diff w.r.t. push/pull + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, out_ptr_NC += out_sC) { + scalar_t trgt = *trgt_ptr_NCX; + bound::add(out_ptr_NC, o0, w0 * trgt, s0); + bound::add(out_ptr_NC, o1, w1 * trgt, s1); + } + } else { + // Diff w.r.t. sgrad + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, out_ptr_NC += out_sC) { + scalar_t trgt0 = *trgt_ptr_NCX; + bound::add(out_ptr_NC, o0, -trgt0, s0); + bound::add(out_ptr_NC, o1, trgt0, s1); + } + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_count) { + // Offsets into 'push' volume + o0 = ix0 * out_sX; + o1 = ix1 * out_sX; + + scalar_t* out_ptr_N = out_ptr + n * out_sN; + bound::add(out_ptr_N, o0, w0, s0); + bound::add(out_ptr_N, o1, w1, s1); + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // NEAREST NEIGHBOR INTERPOLATION 3D // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1666,7 +2125,7 @@ MONAI_NAMESPACE_DEVICE { // cpu scalar_t* src_ptr_NC = src_ptr + n * src_sN; for (offset_t c = 0; c < C; ++c, out_ptr_NCXYZ += out_sC, src_ptr_NC += src_sC) *out_ptr_NCXYZ = bound::get(src_ptr_NC, o, s); - } else if (do_push && trgt_K == 1) { + } else if (do_push && trgt_K == 0) { offset_t o = iz * out_sZ + iy * out_sY + ix * out_sX; scalar_t* trgt_ptr_NCXYZ = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY + d * trgt_sZ; scalar_t* out_ptr_NC = out_ptr + n * out_sN; @@ -1709,7 +2168,7 @@ MONAI_NAMESPACE_DEVICE { // cpu scalar_t* src_ptr_NC = src_ptr + n * src_sN; for (offset_t c = 0; c < C; ++c, out_ptr_NCXY += out_sC, src_ptr_NC += src_sC) *out_ptr_NCXY = bound::get(src_ptr_NC, o, s); - } else if (do_push && trgt_K == 1) { + } else if (do_push && trgt_K == 0) { offset_t o = iy * out_sY + ix * out_sX; scalar_t* trgt_ptr_NCXY = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY; scalar_t* out_ptr_NC = out_ptr + n * out_sN; @@ -1722,10 +2181,48 @@ MONAI_NAMESPACE_DEVICE { // cpu bound::add(out_ptr_NC, o, static_cast(1), s); } } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // NEAREST NEIGHBOR INTERPOLATION 1D + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + template + MONAI_DEVICE void PushPullImpl::interpolate1d_nearest(scalar_t x, offset_t w, offset_t n) const { + offset_t i = static_cast(std::round(x)); + + // Boundary condition (/!\ compute sign before warping indices) + int8_t s = bound::sign(bound0, i, src_X); + i = bound::index(bound0, i, src_X); + + if (do_pull) { + offset_t o = i * src_sX; + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) + *out_ptr_NCX = bound::get(src_ptr_NC, o, s); + } else if (do_push && trgt_K == 0) { + offset_t o = i * out_sX; + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t* out_ptr_NC = out_ptr + n * out_sN; + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, out_ptr_NC += out_sC) + bound::add(out_ptr_NC, o, *trgt_ptr_NCX, s); + } else if (do_count) { + offset_t o = i * out_sX; + scalar_t* out_ptr_NC = out_ptr + n * out_sN; + for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) + bound::add(out_ptr_NC, o, static_cast(1), s); + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // LINEAR INTERPOLATION 3D + SLIDING BOUNDARY // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // TODO + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // CUDA KERNEL (MUST BE OUT OF CLASS) + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + } // namespace // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1757,8 +2254,6 @@ MONAI_NAMESPACE_DEVICE { // cpu PUSHPULL_INSTANTIATE1(BoundType); \ PUSHPULL_INSTANTIATE1(BoundVectorRef) - // ~~~ CPU ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // Two arguments (source, grid) // > `bound` and `interpolation` can be single arguments or vectors. template @@ -1773,12 +2268,14 @@ MONAI_NAMESPACE_DEVICE { // cpu bool do_count, bool do_grad, bool do_sgrad) { + PushPullAllocator info( + grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); + info.ioset(source, grid); + return AT_DISPATCH_FLOATING_TYPES(grid.scalar_type(), "pushpull", [&] { - PushPullImpl f( - grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); - f.ioset(source, grid); - f.loop(); - return f.output; + PushPullImpl algo(info); + algo.loop(); + return algo.output; }); } @@ -1798,17 +2295,18 @@ MONAI_NAMESPACE_DEVICE { // cpu bool do_count, bool do_grad, bool do_sgrad) { + PushPullAllocator info( + grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); + info.ioset(source, grid, target); + return AT_DISPATCH_FLOATING_TYPES(grid.scalar_type(), "pushpull", [&] { - PushPullImpl f( - grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); - f.ioset(source, grid, target); - f.loop(); - return f.output; + PushPullImpl algo(info); + algo.loop(); + return algo.output; }); } PUSHPULL_INSTANTIATE; -} // namespace - +} // namespace cpu } // namespace monai diff --git a/monai/csrc/resample/pushpull_cuda.cu b/monai/csrc/resample/pushpull_cuda.cu index ecfeb562ab..38d34ffe98 100644 --- a/monai/csrc/resample/pushpull_cuda.cu +++ b/monai/csrc/resample/pushpull_cuda.cu @@ -25,6 +25,7 @@ limitations under the License. // TODO: // . [DONE] generic 3d // . [DONE] generic 2d +// . [DONE] generic 1d // . sliding nearest 3d // . sliding nearest 2d // . sliding linear 3d @@ -37,6 +38,7 @@ limitations under the License. // . input bound/inter are always vectors -> clean unused constructors #include +#include #include #include "bounds_common.h" #include "interpolation_common.h" @@ -71,18 +73,27 @@ MONAI_NAMESPACE_DEVICE { // cuda namespace { // anonymous namespace > everything inside has internal linkage // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // GENERIC PUSHPULL CLASS + // INDEXING UTILS // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // This class implements the bulk of the code. - // /!\ No type and shape checking is performed here. - template - class PushPullImpl { + // This class reads and sets all the parameters that will later be used + // by the algorithm in PushPullImpl. All of this is done outside of the + // implementation class so that we do not depend on generic types. The + // point is to pre-allocate all necessary tensors so that we can check + // if they're all compatible with 32 bit math. If it's the case, we can + // dispatch to a 32b cuda implementation, which might increase + // performance. Else, we use 64 bit math to compute offsets. + // (On CPU, we always use 64 bit offsets because it doesn't make a huge + // difference. It would be different if we had a vectorized + // implementation as in PyTorch). + class PushPullAllocator { public: + static constexpr int64_t max_int32 = std::numeric_limits::max(); + // ~~~ CONSTRUCTORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MONAI_HOST - PushPullImpl( + PushPullAllocator( int dim, BoundVectorRef bound, InterpolationVectorRef interpolation, @@ -122,100 +133,417 @@ MONAI_NAMESPACE_DEVICE { // cuda iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; } - MONAI_HOST - PushPullImpl( - int dim, - BoundType bound, - InterpolationVectorRef interpolation, - bool extrapolate, - bool do_pull, - bool do_push, - bool do_count, - bool do_grad, - bool do_sgrad) - : dim(dim), - bound0(bound), - bound1(bound), - bound2(bound), - interpolation0(interpolation.size() > 0 ? interpolation[0] : InterpolationType::Linear), - interpolation1( - interpolation.size() > 1 ? interpolation[1] - : interpolation.size() > 0 ? interpolation[0] - : InterpolationType::Linear), - interpolation2( - interpolation.size() > 2 ? interpolation[2] - : interpolation.size() > 1 ? interpolation[1] - : interpolation.size() > 0 ? interpolation[0] - : InterpolationType::Linear), - extrapolate(extrapolate), - do_pull(do_pull), - do_push(do_push), - do_count(do_count), - do_grad(do_grad), - do_sgrad(do_sgrad) { - iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; + // ~~~ FUNCTORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + // Usually used for pull: + // - do_pull -> return source[grid] + // - do_push -> fails + // - do_grad -> return J(source)[grid] + // - do_sgrad -> return H(source)[grid] + MONAI_HOST void ioset(const Tensor& source, const Tensor& grid) { + init_all(); + init_source(source); + init_grid(grid); + init_output(); } - MONAI_HOST - PushPullImpl( - int dim, - BoundVectorRef bound, - InterpolationType interpolation, - bool extrapolate, - bool do_pull, - bool do_push, - bool do_count, - bool do_grad, - bool do_sgrad) - : dim(dim), - bound0(bound.size() > 0 ? bound[0] : BoundType::Replicate), - bound1( - bound.size() > 1 ? bound[1] - : bound.size() > 0 ? bound[0] - : BoundType::Replicate), - bound2( - bound.size() > 2 ? bound[2] - : bound.size() > 1 ? bound[1] - : bound.size() > 0 ? bound[0] - : BoundType::Replicate), - interpolation0(interpolation), - interpolation1(interpolation), - interpolation2(interpolation), - extrapolate(extrapolate), - do_pull(do_pull), - do_push(do_push), - do_count(do_count), - do_grad(do_grad), - do_sgrad(do_sgrad) { - iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; + // Usually used for pull_backward: + // - do_pull -> return source[grid] + // - do_push -> return push(target, grid, source.shape) + // - do_grad -> return J(source)[grid] + // - do_sgrad -> return H(source)[grid] + MONAI_HOST void ioset(const Tensor& source, const Tensor& grid, const Tensor& target) { + init_all(); + init_source(source); + init_grid(grid); + init_target(target); + init_output(); } - MONAI_HOST - PushPullImpl( - int dim, - BoundType bound, - InterpolationType interpolation, - bool extrapolate, - bool do_pull, - bool do_push, - bool do_count, - bool do_grad, - bool do_sgrad) - : dim(dim), - bound0(bound), - bound1(bound), - bound2(bound), - interpolation0(interpolation), - interpolation1(interpolation), - interpolation2(interpolation), - extrapolate(extrapolate), - do_pull(do_pull), - do_push(do_push), - do_count(do_count), - do_grad(do_grad), - do_sgrad(do_sgrad) { - iso = interpolation0 == interpolation1 && interpolation0 == interpolation2; + // Usually used for push: + // - do_pull -> fails + // - do_push -> return push(target, grid, source_size) + // - do_grad -> fails + // - do_sgrad -> fails + MONAI_HOST void ioset(IntArrayRef source_size, const Tensor& grid, const Tensor& target) { + init_all(); + init_source(source_size); + init_grid(grid); + init_target(target); + init_output(); + } + + // Usually used for count: + // - do_pull -> fails + // - do_push -> return push(ones, grid, source_size) + // - do_grad -> fails + // - do_sgrad -> fails + MONAI_HOST void ioset(IntArrayRef source_size, const Tensor& grid) { + init_all(); + init_source(source_size); + init_grid(grid); + init_output(); + } + + // We just check that all tensors that we own are compatible with 32b math + bool canUse32BitIndexMath(int64_t max_elem = max_int32) const { + return src_32b_ok && trgt_32b_ok && grid_32b_ok && grad_32b_ok && out_32b_ok; + } + + private: + // Copied from aten/src/ATen/native/IndexingUtils.cpp in PyTorch 1.6. + // It is used to decide to which pointer type we should dispatch to. + // Basically, we need to make sure that the "furthest" element we need + // to reach is less than max_elem away. + static bool tensorCanUse32BitIndexMath(const Tensor& t, int64_t max_elem = max_int32) { + int64_t elements = t.numel(); + if (elements >= max_elem) { + return false; + } + if (elements == 0) { + return max_elem > 0; + } + + int64_t offset = 0; + int64_t linearId = elements - 1; + + // NOTE: Assumes all strides are positive, which is true for now + for (int i = t.dim() - 1; i >= 0; --i) { + int64_t curDimIndex = linearId % t.size(i); + int64_t curDimOffset = curDimIndex * t.stride(i); + offset += curDimOffset; + linearId /= t.size(i); + } + + if (offset >= max_elem) { + return false; + } + + return true; + } + + // ~~~ COMPONENTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + MONAI_HOST void init_all(); + MONAI_HOST void init_source(const Tensor& source); + MONAI_HOST void init_source(IntArrayRef source_size); + MONAI_HOST void init_grid(const Tensor& grid); + MONAI_HOST void init_target(const Tensor& target); + MONAI_HOST void init_output(); + + // ~~~ OPTIONS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + int dim; // dimensionality (2 or 3) + BoundType bound0; // boundary condition // x|W + BoundType bound1; // boundary condition // y|H + BoundType bound2; // boundary condition // z|D + InterpolationType interpolation0; // interpolation order // x|W + InterpolationType interpolation1; // interpolation order // y|H + InterpolationType interpolation2; // interpolation order // z|D + bool iso; // isotropic interpolation? + bool extrapolate; // compute out-of-bound values + bool do_pull; // sample a volume + bool do_push; // splat a volume + bool do_count; // splatting weights (= jacobian determinant) + bool do_grad; // backprop: gradient of grid // pull + bool do_sgrad; // sample spatial gradients + + // ~~~ NAVIGATORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + std::deque output; + TensorOptions src_opt; + TensorOptions grid_opt; + TensorOptions trgt_opt; + int64_t N; + int64_t C; + int64_t src_X; + int64_t src_Y; + int64_t src_Z; + int64_t trgt_X; + int64_t trgt_Y; + int64_t trgt_Z; + int64_t trgt_K; + int64_t src_sN; + int64_t src_sC; + int64_t src_sX; + int64_t src_sY; + int64_t src_sZ; + bool src_32b_ok; + void* src_ptr; + int64_t trgt_sN; + int64_t trgt_sC; + int64_t trgt_sX; + int64_t trgt_sY; + int64_t trgt_sZ; + int64_t trgt_sK; + bool trgt_32b_ok; + void* trgt_ptr; + int64_t grid_sN; + int64_t grid_sC; + int64_t grid_sX; + int64_t grid_sY; + int64_t grid_sZ; + bool grid_32b_ok; + void* grid_ptr; + int64_t out_sN; + int64_t out_sC; + int64_t out_sX; + int64_t out_sY; + int64_t out_sZ; + int64_t out_sK; // gradient dimension + bool out_32b_ok; + void* out_ptr; + int64_t grad_sN; + int64_t grad_sC; + int64_t grad_sX; + int64_t grad_sY; + int64_t grad_sZ; + bool grad_32b_ok; + void* grad_ptr; + + // Allow PushPullImpl's constructor to access PushPullAllocator's + // private members. + template + friend class PushPullImpl; + }; + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // INITIALISATION + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + MONAI_HOST + void PushPullAllocator::init_all() { + src_opt = grid_opt = trgt_opt = TensorOptions(); + N = C = 1L; + src_X = src_Y = src_Z = 1L; + trgt_X = trgt_Y = trgt_Z = 1L; + trgt_K = 0L; + src_sN = src_sC = src_sX = src_sY = src_sZ = 0L; + grid_sN = grid_sC = grid_sX = grid_sY = grid_sZ = 0L; + grad_sN = grad_sC = grad_sX = grad_sY = grad_sZ = 0L; + trgt_sN = trgt_sC = trgt_sX = trgt_sY = trgt_sZ = trgt_sK = 0L; + out_sN = out_sC = out_sX = out_sY = out_sZ = out_sK = 0L; + src_ptr = trgt_ptr = grid_ptr = out_ptr = grad_ptr = static_cast(0); + src_32b_ok = trgt_32b_ok = grid_32b_ok = out_32b_ok = grad_32b_ok = true; + } + + MONAI_HOST + void PushPullAllocator::init_source(const Tensor& source) { + N = source.size(0); + C = source.size(1); + src_X = source.size(2); + src_Y = dim < 2 ? 1L : source.size(3); + src_Z = dim < 3 ? 1L : source.size(4); + src_sN = source.stride(0); + src_sC = source.stride(1); + src_sX = source.stride(2); + src_sY = dim < 2 ? 0L : source.stride(3); + src_sZ = dim < 3 ? 0L : source.stride(4); + src_ptr = source.data_ptr(); + src_opt = source.options(); + src_32b_ok = tensorCanUse32BitIndexMath(source); + } + + MONAI_HOST + void PushPullAllocator::init_source(IntArrayRef source_size) { + src_X = source_size[0]; + src_Y = dim < 2 ? 1L : source_size[1]; + src_Z = dim < 3 ? 1L : source_size[2]; + } + + MONAI_HOST + void PushPullAllocator::init_grid(const Tensor& grid) { + N = grid.size(0); + trgt_X = grid.size(1); + trgt_Y = dim < 2 ? 1L : grid.size(2); + trgt_Z = dim < 3 ? 1L : grid.size(3); + grid_sN = grid.stride(0); + grid_sX = grid.stride(1); + grid_sY = dim < 2 ? 0L : grid.stride(2); + grid_sZ = dim < 3 ? 0L : grid.stride(3); + grid_sC = grid.stride(dim == 1 ? 2 : dim == 2 ? 3 : 4); + grid_ptr = grid.data_ptr(); + grid_opt = grid.options(); + grid_32b_ok = tensorCanUse32BitIndexMath(grid); + } + + MONAI_HOST + void PushPullAllocator::init_target(const Tensor& target) { + N = target.size(0); + C = target.size(1); + trgt_X = target.size(2); + trgt_Y = dim < 2 ? 1L : target.size(3); + trgt_Z = dim < 3 ? 1L : target.size(4); + trgt_K = target.dim() == dim + 3 ? target.size(dim == 1 ? 3 : dim == 2 ? 4 : 5) : 0L; + trgt_sN = target.stride(0); + trgt_sC = target.stride(1); + trgt_sX = target.stride(2); + trgt_sY = dim < 2 ? 0L : target.stride(3); + trgt_sZ = dim < 3 ? 0L : target.stride(4); + trgt_sK = target.dim() == dim + 3 ? target.stride(dim == 1 ? 3 : dim == 2 ? 4 : 5) : 0L; + trgt_ptr = target.data_ptr(); + trgt_opt = target.options(); + trgt_32b_ok = tensorCanUse32BitIndexMath(target); + } + + MONAI_HOST + void PushPullAllocator::init_output() { + output.clear(); + if (do_pull) { + if (dim == 1) + output.push_back(at::empty({N, C, trgt_X}, src_opt)); + else if (dim == 2) + output.push_back(at::empty({N, C, trgt_X, trgt_Y}, src_opt)); + else + output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z}, src_opt)); + auto pull = output.back(); + out_sN = pull.stride(0); + out_sC = pull.stride(1); + out_sX = pull.stride(2); + out_sY = dim < 2 ? 0L : pull.stride(3); + out_sZ = dim < 3 ? 0L : pull.stride(4); + out_sK = 0L; + out_ptr = pull.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(pull); + } else if (do_sgrad) { + if (dim == 1) + output.push_back(at::empty({N, C, trgt_X, 1}, src_opt)); + else if (dim == 2) + output.push_back(at::empty({N, C, trgt_X, trgt_Y, 2}, src_opt)); + else + output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z, 3}, src_opt)); + auto sgrad = output.back(); + out_sN = sgrad.stride(0); + out_sC = sgrad.stride(1); + out_sX = sgrad.stride(2); + out_sY = dim < 2 ? 0L : sgrad.stride(3); + out_sZ = dim < 3 ? 0L : sgrad.stride(4); + out_sK = sgrad.stride(dim == 1 ? 3 : dim == 2 ? 4 : 5); + out_ptr = sgrad.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(sgrad); + + if (iso && interpolation0 == InterpolationType::Nearest) + sgrad.zero_(); + if (iso && interpolation0 == InterpolationType::Linear && dim == 1) + sgrad.zero_(); + } else if (do_push) { + if (dim == 1) + output.push_back(at::zeros({N, C, src_X}, trgt_opt)); + else if (dim == 2) + output.push_back(at::zeros({N, C, src_X, src_Y}, trgt_opt)); + else + output.push_back(at::zeros({N, C, src_X, src_Y, src_Z}, trgt_opt)); + auto push = output.back(); + out_sN = push.stride(0); + out_sC = push.stride(1); + out_sX = push.stride(2); + out_sY = dim < 2 ? 0L : push.stride(3); + out_sZ = dim < 3 ? 0L : push.stride(4); + out_sK = 0L; + out_ptr = push.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(push); + } else if (do_count) { + if (dim == 1) + output.push_back(at::zeros({N, 1, src_X}, grid_opt)); + else if (dim == 2) + output.push_back(at::zeros({N, 1, src_X, src_Y}, grid_opt)); + else + output.push_back(at::zeros({N, 1, src_X, src_Y, src_Z}, grid_opt)); + auto count = output.back(); + out_sN = count.stride(0); + out_sC = count.stride(1); + out_sX = count.stride(2); + out_sY = dim < 2 ? 0L : count.stride(3); + out_sZ = dim < 3 ? 0L : count.stride(4); + out_sK = 0L; + out_ptr = count.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(count); + } + if (do_grad) { + if (dim == 1) + output.push_back(at::zeros({N, trgt_X, 1}, grid_opt)); + else if (dim == 2) + output.push_back(at::zeros({N, trgt_X, trgt_Y, 2}, grid_opt)); + else + output.push_back(at::zeros({N, trgt_X, trgt_Y, trgt_Z, 3}, grid_opt)); + auto grad = output.back(); + grad_sN = grad.stride(0); + grad_sX = grad.stride(1); + grad_sY = dim < 2 ? 0L : grad.stride(2); + grad_sZ = dim < 3 ? 0L : grad.stride(3); + grad_sC = grad.stride(dim == 1 ? 2 : dim == 2 ? 3 : 4); + grad_ptr = grad.data_ptr(); + out_32b_ok = tensorCanUse32BitIndexMath(grad); + + if (iso && interpolation0 == InterpolationType::Nearest) + grad.zero_(); } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // GENERIC PUSHPULL CLASS + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // This class implements the bulk of the code. + // /!\ No type and shape checking is performed here. + + template + class PushPullImpl { + public: + // ~~~ CONSTRUCTOR ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + PushPullImpl(const PushPullAllocator& info) + : output(info.output), + dim(info.dim), + bound0(info.bound0), + bound1(info.bound1), + bound2(info.bound2), + interpolation0(info.interpolation0), + interpolation1(info.interpolation1), + interpolation2(info.interpolation1), + iso(info.iso), + extrapolate(info.extrapolate), + do_pull(info.do_pull), + do_push(info.do_push), + do_count(info.do_count), + do_grad(info.do_grad), + do_sgrad(info.do_sgrad), + N(static_cast(info.N)), + C(static_cast(info.C)), + src_X(static_cast(info.src_X)), + src_Y(static_cast(info.src_Y)), + src_Z(static_cast(info.src_Z)), + trgt_X(static_cast(info.trgt_X)), + trgt_Y(static_cast(info.trgt_Y)), + trgt_Z(static_cast(info.trgt_Z)), + trgt_K(static_cast(info.trgt_K)), + src_sN(static_cast(info.src_sN)), + src_sC(static_cast(info.src_sC)), + src_sX(static_cast(info.src_sX)), + src_sY(static_cast(info.src_sY)), + src_sZ(static_cast(info.src_sZ)), + src_ptr(static_cast(info.src_ptr)), + trgt_sN(static_cast(info.trgt_sN)), + trgt_sC(static_cast(info.trgt_sC)), + trgt_sX(static_cast(info.trgt_sX)), + trgt_sY(static_cast(info.trgt_sY)), + trgt_sZ(static_cast(info.trgt_sZ)), + trgt_sK(static_cast(info.trgt_sK)), + trgt_ptr(static_cast(info.trgt_ptr)), + grid_sN(static_cast(info.grid_sN)), + grid_sC(static_cast(info.grid_sC)), + grid_sX(static_cast(info.grid_sX)), + grid_sY(static_cast(info.grid_sY)), + grid_sZ(static_cast(info.grid_sZ)), + grid_ptr(static_cast(info.grid_ptr)), + out_sN(static_cast(info.out_sN)), + out_sC(static_cast(info.out_sC)), + out_sX(static_cast(info.out_sX)), + out_sY(static_cast(info.out_sY)), + out_sZ(static_cast(info.out_sZ)), + out_sK(static_cast(info.out_sK)), + out_ptr(static_cast(info.out_ptr)), + grad_sN(static_cast(info.grad_sN)), + grad_sC(static_cast(info.grad_sC)), + grad_sX(static_cast(info.grad_sX)), + grad_sY(static_cast(info.grad_sY)), + grad_sZ(static_cast(info.grad_sZ)), + grad_ptr(static_cast(info.grad_ptr)) {} // ~~~ PUBLIC VALUE ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -244,39 +572,9 @@ MONAI_NAMESPACE_DEVICE { // cuda // } // ~~~ FUNCTORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - MONAI_HOST void ioset // Pull - (const Tensor& source, const Tensor& grid) { - init_all(); - init_source(source); - init_grid(grid); - init_output(); - } - - MONAI_HOST void ioset(const Tensor& source, const Tensor& grid, const Tensor& target) { - init_all(); - init_source(source); - init_grid(grid); - init_target(target); - init_output(); - } - - MONAI_HOST void ioset // Push - (IntArrayRef source_size, const Tensor& grid, const Tensor& target) { - init_all(); - init_source(source_size); - init_grid(grid); - init_target(target); - init_output(); - } - - MONAI_HOST void ioset // Count - (IntArrayRef source_size, const Tensor& grid) { - init_all(); - init_source(source_size); - init_grid(grid); - init_output(); - } + // Loop over voxels that belong to one CUDA block + // This function is called by the CUDA kernel MONAI_DEVICE void loop(int threadIdx, int blockIdx, int blockDim, int gridDim) const; MONAI_HOST MONAI_DEVICE int64_t voxcount() const { @@ -285,14 +583,18 @@ MONAI_NAMESPACE_DEVICE { // cuda private: // ~~~ COMPONENTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - MONAI_HOST void init_all(); - MONAI_HOST void init_source(const Tensor& source); - MONAI_HOST void init_source(IntArrayRef source_size); - MONAI_HOST void init_grid(const Tensor& grid); - MONAI_HOST void init_target(const Tensor& target); - MONAI_HOST void init_output(); + MONAI_DEVICE void check1d(offset_t w, offset_t n) const; MONAI_DEVICE void check2d(offset_t w, offset_t h, offset_t n) const; MONAI_DEVICE void check3d(offset_t w, offset_t h, offset_t d, offset_t n) const; + MONAI_DEVICE void interpolate1d(scalar_t x, offset_t w, offset_t n) const; + MONAI_DEVICE void interpolate1d_nearest(scalar_t x, offset_t w, offset_t n) const; + MONAI_DEVICE void interpolate1d_linear(scalar_t x, offset_t w, offset_t n) const; + MONAI_DEVICE void interpolate1d_sliding(scalar_t x, offset_t w, offset_t n) const { /*TODO*/ + } + MONAI_DEVICE void interpolate1d_sliding_nearest(scalar_t x, offset_t w, offset_t n) const { /*TODO*/ + } + MONAI_DEVICE void interpolate1d_sliding_linear(scalar_t x, offset_t w, offset_t n) const { /*TODO*/ + } MONAI_DEVICE void interpolate2d(scalar_t x, scalar_t y, offset_t w, offset_t h, offset_t n) const; MONAI_DEVICE void interpolate2d_nearest(scalar_t x, scalar_t y, offset_t w, offset_t h, offset_t n) const; MONAI_DEVICE void interpolate2d_bilinear(scalar_t x, scalar_t y, offset_t w, offset_t h, offset_t n) const; @@ -367,9 +669,6 @@ MONAI_NAMESPACE_DEVICE { // cuda bool do_sgrad; // sample spatial gradients // ~~~ NAVIGATORS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - TensorOptions src_opt; - TensorOptions grid_opt; - TensorOptions trgt_opt; offset_t N; offset_t C; offset_t src_X; @@ -396,173 +695,22 @@ MONAI_NAMESPACE_DEVICE { // cuda offset_t grid_sC; offset_t grid_sX; offset_t grid_sY; - offset_t grid_sZ; - scalar_t* grid_ptr; - offset_t out_sN; - offset_t out_sC; - offset_t out_sX; - offset_t out_sY; - offset_t out_sZ; - offset_t out_sK; // gradient dimension - scalar_t* out_ptr; - offset_t grad_sN; - offset_t grad_sC; - offset_t grad_sX; - offset_t grad_sY; - offset_t grad_sZ; - scalar_t* grad_ptr; - }; - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // INITIALISATION - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - template - void PushPullImpl::init_all() { - src_opt = grid_opt = trgt_opt = TensorOptions(); - N = C = static_cast(1); - src_X = src_Y = src_Z = static_cast(1); - trgt_X = trgt_Y = trgt_Z = trgt_K = static_cast(1); - src_sN = src_sC = src_sX = src_sY = src_sZ = static_cast(0); - grid_sN = grid_sC = grid_sX = grid_sY = grid_sZ = static_cast(0); - grad_sN = grad_sC = grad_sX = grad_sY = grad_sZ = static_cast(0); - trgt_sN = trgt_sC = trgt_sX = trgt_sY = trgt_sZ = trgt_sK = static_cast(0); - out_sN = out_sC = out_sX = out_sY = out_sZ = out_sK = static_cast(0); - src_ptr = trgt_ptr = grid_ptr = out_ptr = grad_ptr = static_cast(0); - } - - template - MONAI_HOST void PushPullImpl::init_source(const Tensor& source) { - N = source.size(0); - C = source.size(1); - src_X = source.size(2); - src_Y = source.size(3); - src_Z = dim == 2 ? static_cast(1) : source.size(4); - src_sN = source.stride(0); - src_sC = source.stride(1); - src_sX = source.stride(2); - src_sY = source.stride(3); - src_sZ = dim == 2 ? static_cast(0) : source.stride(4); - src_ptr = source.data_ptr(); - src_opt = source.options(); - } - - template - MONAI_HOST void PushPullImpl::init_source(IntArrayRef source_size) { - src_X = source_size[0]; - src_Y = source_size[1]; - src_Z = dim == 2 ? static_cast(1) : source_size[2]; - } - - template - MONAI_HOST void PushPullImpl::init_grid(const Tensor& grid) { - N = grid.size(0); - trgt_X = grid.size(1); - trgt_Y = grid.size(2); - trgt_Z = dim == 2 ? static_cast(1) : grid.size(3); - grid_sN = grid.stride(0); - grid_sX = grid.stride(1); - grid_sY = grid.stride(2); - grid_sZ = dim == 2 ? static_cast(0) : grid.stride(3); - grid_sC = grid.stride(dim == 2 ? 3 : 4); - grid_ptr = grid.data_ptr(); - grid_opt = grid.options(); - } - - template - MONAI_HOST void PushPullImpl::init_target(const Tensor& target) { - N = target.size(0); - C = target.size(1); - trgt_X = target.size(2); - trgt_Y = target.size(3); - trgt_Z = dim == 2 ? static_cast(1) : target.size(4); - trgt_K = target.dim() == dim + 3 ? target.size(dim == 2 ? 4 : 5) : static_cast(1); - trgt_sN = target.stride(0); - trgt_sC = target.stride(1); - trgt_sX = target.stride(2); - trgt_sY = target.stride(3); - trgt_sZ = dim == 2 ? static_cast(0) : target.stride(4); - trgt_sK = target.dim() == dim + 3 ? target.stride(dim == 2 ? 4 : 5) : static_cast(0); - trgt_ptr = target.data_ptr(); - trgt_opt = target.options(); - } - - template - MONAI_HOST void PushPullImpl::init_output() { - output.clear(); - if (do_pull) { - if (dim == 2) - output.push_back(at::empty({N, C, trgt_X, trgt_Y}, src_opt)); - else - output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z}, src_opt)); - auto pull = output.back(); - out_sN = pull.stride(0); - out_sC = pull.stride(1); - out_sX = pull.stride(2); - out_sY = pull.stride(3); - out_sZ = dim == 2 ? static_cast(0) : pull.stride(4); - out_sK = static_cast(0); - out_ptr = pull.template data_ptr(); - } else if (do_sgrad) { - if (dim == 2) - output.push_back(at::empty({N, C, trgt_X, trgt_Y, 2}, src_opt)); - else - output.push_back(at::empty({N, C, trgt_X, trgt_Y, trgt_Z, 3}, src_opt)); - auto sgrad = output.back(); - out_sN = sgrad.stride(0); - out_sC = sgrad.stride(1); - out_sX = sgrad.stride(2); - out_sY = sgrad.stride(3); - out_sZ = dim == 2 ? static_cast(0) : sgrad.stride(4); - out_sK = sgrad.stride(dim == 2 ? 4 : 5); - out_ptr = sgrad.template data_ptr(); - - if (iso && interpolation0 == InterpolationType::Nearest) - sgrad.zero_(); - } else if (do_push) { - if (dim == 2) - output.push_back(at::zeros({N, C, src_X, src_Y}, trgt_opt)); - else - output.push_back(at::zeros({N, C, src_X, src_Y, src_Z}, trgt_opt)); - auto push = output.back(); - out_sN = push.stride(0); - out_sC = push.stride(1); - out_sX = push.stride(2); - out_sY = push.stride(3); - out_sZ = dim == 2 ? static_cast(0) : push.stride(4); - out_sK = static_cast(0); - out_ptr = push.template data_ptr(); - } else if (do_count) { - if (dim == 2) - output.push_back(at::zeros({N, 1, src_X, src_Y}, grid_opt)); - else - output.push_back(at::zeros({N, 1, src_X, src_Y, src_Z}, grid_opt)); - auto count = output.back(); - out_sN = count.stride(0); - out_sC = count.stride(1); - out_sX = count.stride(2); - out_sY = count.stride(3); - out_sZ = dim == 2 ? static_cast(0) : count.stride(4); - out_sK = static_cast(0); - out_ptr = count.template data_ptr(); - } - if (do_grad) { - if (dim == 2) - output.push_back(at::zeros({N, src_X, src_Y, 2}, grid_opt)); - else - output.push_back(at::zeros({N, src_X, src_Y, src_Z, 3}, grid_opt)); - auto grad = output.back(); - grad_sN = grad.stride(0); - grad_sX = grad.stride(1); - grad_sY = grad.stride(2); - grad_sZ = dim == 2 ? static_cast(0) : grad.stride(3); - grad_sC = grad.stride(dim == 2 ? 3 : 4); - grad_ptr = grad.template data_ptr(); - - if (iso && interpolation0 == InterpolationType::Nearest) - grad.zero_(); - } - } + offset_t grid_sZ; + scalar_t* grid_ptr; + offset_t out_sN; + offset_t out_sC; + offset_t out_sX; + offset_t out_sY; + offset_t out_sZ; + offset_t out_sK; // gradient dimension + scalar_t* out_ptr; + offset_t grad_sN; + offset_t grad_sC; + offset_t grad_sX; + offset_t grad_sY; + offset_t grad_sZ; + scalar_t* grad_ptr; + }; // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // LOOP @@ -583,7 +731,9 @@ MONAI_NAMESPACE_DEVICE { // cuda h = (i / trgt_Z) % trgt_Y; d = i % trgt_Z; - if (dim == 2) + if (dim == 1) + check1d(w, n); + else if (dim == 2) check2d(w, h, n); else check3d(w, h, d, n); @@ -598,6 +748,59 @@ MONAI_NAMESPACE_DEVICE { // cuda // 1) read the [x,y,z] source coordinate for the current target voxel // 3) check if the source coordinate is in bounds + template + MONAI_DEVICE void PushPullImpl::check3d(offset_t w, offset_t h, offset_t d, offset_t n) const { + // get the corresponding input x, y, z co-ordinates from grid + scalar_t* grid_ptr_NXYZ = grid_ptr + n * grid_sN + w * grid_sX + h * grid_sY + d * grid_sZ; + scalar_t x = *grid_ptr_NXYZ; + scalar_t y = grid_ptr_NXYZ[grid_sC]; + scalar_t z = grid_ptr_NXYZ[grid_sC * 2]; + + // Check if out-of-bound + if (!(extrapolate || + (inbounds(x, src_X, static_cast(TINY)) && inbounds(y, src_Y, static_cast(TINY)) && + inbounds(z, src_Z, static_cast(TINY))))) { + if (do_pull || do_sgrad) { + scalar_t* out_ptr_NCXYZ = out_ptr + n * out_sN + w * out_sX + h * out_sY + d * out_sZ; + for (offset_t c = 0; c < C; ++c, out_ptr_NCXYZ += out_sC) { + *out_ptr_NCXYZ = static_cast(0); + if (do_sgrad) { + out_ptr_NCXYZ[out_sK] = static_cast(0); + out_ptr_NCXYZ[out_sK * 2] = static_cast(0); + } + } + } + if (do_grad) { + scalar_t* grad_ptr_NXYZ = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY + d * grad_sZ; + (*grad_ptr_NXYZ) = static_cast(0); + grad_ptr_NXYZ[grad_sC] = static_cast(0); + grad_ptr_NXYZ[grad_sC * 2] = static_cast(0); + } + return; + } + + // Next step + if (bound0 == BoundType::Sliding) { + if (iso) + switch (static_cast(interpolation0)) { + case 0: + return interpolate3d_sliding_nearest(x, y, z, w, h, d, n); + case 1: + return interpolate3d_sliding_trilinear(x, y, z, w, h, d, n); + } + return interpolate3d_sliding(x, y, z, w, h, d, n); + } else { + if (iso) + switch (static_cast(interpolation0)) { + case 0: + return interpolate3d_nearest(x, y, z, w, h, d, n); + case 1: + return interpolate3d_trilinear(x, y, z, w, h, d, n); + } + return interpolate3d(x, y, z, w, h, d, n); + } + } + template MONAI_DEVICE void PushPullImpl::check2d(offset_t w, offset_t h, offset_t n) const { // get the corresponding input x, y, z co-ordinates from grid @@ -609,7 +812,7 @@ MONAI_NAMESPACE_DEVICE { // cuda if (!(extrapolate || (inbounds(x, src_X, static_cast(TINY)) && inbounds(y, src_Y, static_cast(TINY))))) { if (do_pull || do_sgrad) { - scalar_t* out_ptr_NCXY = out_ptr + n * out_sN + w * out_sZ + h * out_sY; + scalar_t* out_ptr_NCXY = out_ptr + n * out_sN + w * out_sX + h * out_sY; for (offset_t c = 0; c < C; ++c, out_ptr_NCXY += out_sC) { *out_ptr_NCXY = static_cast(0); if (do_sgrad) @@ -647,32 +850,25 @@ MONAI_NAMESPACE_DEVICE { // cuda } template - MONAI_DEVICE void PushPullImpl::check3d(offset_t w, offset_t h, offset_t d, offset_t n) const { + MONAI_DEVICE void PushPullImpl::check1d(offset_t w, offset_t n) const { // get the corresponding input x, y, z co-ordinates from grid - scalar_t* grid_ptr_NXYZ = grid_ptr + n * grid_sN + w * grid_sX + h * grid_sY + d * grid_sZ; - scalar_t x = *grid_ptr_NXYZ; - scalar_t y = grid_ptr_NXYZ[grid_sC]; - scalar_t z = grid_ptr_NXYZ[grid_sC * 2]; + scalar_t* grid_ptr_NX = grid_ptr + n * grid_sN + w * grid_sX; + scalar_t x = *grid_ptr_NX; // Check if out-of-bound - if (!(extrapolate || - (inbounds(x, src_X, static_cast(TINY)) && inbounds(y, src_Y, static_cast(TINY)) && - inbounds(z, src_Z, static_cast(TINY))))) { + if (!(extrapolate || inbounds(x, src_X, static_cast(TINY)))) { if (do_pull || do_sgrad) { - scalar_t* out_ptr_NCXYZ = out_ptr + n * out_sN + w * out_sX + h * out_sY + d * out_sZ; - for (offset_t c = 0; c < C; ++c, out_ptr_NCXYZ += out_sC) { - *out_ptr_NCXYZ = static_cast(0); - if (do_sgrad) { - out_ptr_NCXYZ[out_sK] = static_cast(0); - out_ptr_NCXYZ[out_sK * 2] = static_cast(0); - } + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC) { + *out_ptr_NCX = static_cast(0); + if (do_sgrad) + out_ptr_NCX[out_sK] = static_cast(0); } } if (do_grad) { - scalar_t* grad_ptr_NXYZ = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY + d * grad_sZ; - (*grad_ptr_NXYZ) = static_cast(0); - grad_ptr_NXYZ[grad_sC] = static_cast(0); - grad_ptr_NXYZ[grad_sC * 2] = static_cast(0); + scalar_t* grad_ptr_NX = grad_ptr + n * grad_sN + w * grad_sX; + (*grad_ptr_NX) = static_cast(0); + grad_ptr_NX[grad_sC] = static_cast(0); } return; } @@ -682,20 +878,20 @@ MONAI_NAMESPACE_DEVICE { // cuda if (iso) switch (static_cast(interpolation0)) { case 0: - return interpolate3d_sliding_nearest(x, y, z, w, h, d, n); + return interpolate1d_sliding_nearest(x, w, n); case 1: - return interpolate3d_sliding_trilinear(x, y, z, w, h, d, n); + return interpolate1d_sliding_linear(x, w, n); } - return interpolate3d_sliding(x, y, z, w, h, d, n); + return interpolate1d_sliding(x, w, n); } else { if (iso) switch (static_cast(interpolation0)) { case 0: - return interpolate3d_nearest(x, y, z, w, h, d, n); + return interpolate1d_nearest(x, w, n); case 1: - return interpolate3d_trilinear(x, y, z, w, h, d, n); + return interpolate1d_linear(x, w, n); } - return interpolate3d(x, y, z, w, h, d, n); + return interpolate1d(x, w, n); } } @@ -730,7 +926,7 @@ MONAI_NAMESPACE_DEVICE { // cuda if (trgt_ptr && (do_push || do_grad)) for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXYZ += trgt_sC) { target[c] = *trgt_ptr_NCXYZ; - if (trgt_K > 1) { + if (trgt_K > 0) { target[c + C] = trgt_ptr_NCXYZ[trgt_sK]; target[c + C * 2] = trgt_ptr_NCXYZ[trgt_sK * 2]; } @@ -848,7 +1044,7 @@ MONAI_NAMESPACE_DEVICE { // cuda // ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ else if (do_push) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull scalar_t* out_ptr_NC = out_ptr_NC0; for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) @@ -871,7 +1067,7 @@ MONAI_NAMESPACE_DEVICE { // cuda // ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ if (do_grad) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. pull/push scalar_t* src_ptr_NC = src_ptr_NC0; scalar_t dot = static_cast(0); @@ -940,7 +1136,7 @@ MONAI_NAMESPACE_DEVICE { // cuda if (trgt_ptr && (do_push || do_grad)) for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXY += trgt_sC) { target[c] = *trgt_ptr_NCXY; - if (trgt_K > 1) { + if (trgt_K > 0) { target[c + C] = trgt_ptr_NCXY[trgt_sK]; } } @@ -1033,7 +1229,7 @@ MONAI_NAMESPACE_DEVICE { // cuda // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ else if (do_push) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull scalar_t* out_ptr_NC = out_ptr_NC0; for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) @@ -1055,7 +1251,7 @@ MONAI_NAMESPACE_DEVICE { // cuda // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ if (do_grad) { - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. pull/push scalar_t* src_ptr_NC = src_ptr_NC0; scalar_t dot = static_cast(0); @@ -1092,6 +1288,150 @@ MONAI_NAMESPACE_DEVICE { // cuda } } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // GENERIC INTERPOLATION 1D + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + template + MONAI_DEVICE void PushPullImpl::interpolate1d(scalar_t x, offset_t w, offset_t n) const { + // Get corner pixel values from (x, y) + offset_t bx0, bx1; + interpolation::bounds(interpolation0, x, bx0, bx1); + offset_t dbx = bx1 - bx0; + + // Pre-compute offsets and target value + scalar_t* src_ptr_NC0 = src_ptr + n * src_sN; + scalar_t* out_ptr_NC0 = out_ptr + n * out_sN; + scalar_t* out_ptr_NCX0 = out_ptr + n * out_sN + w * out_sX; + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t target[2 * MONAI_MAX_NUM_CHANNELS]; + if (trgt_ptr && (do_push || do_grad)) + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC) { + target[c] = *trgt_ptr_NCX; + if (trgt_K > 0) { + target[c + C] = trgt_ptr_NCX[trgt_sK]; + } + } + + // Initialize output + scalar_t* out_ptr_NCX = out_ptr_NCX0; + if (do_pull || do_sgrad) { + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC) { + *out_ptr_NCX = static_cast(0); + if (do_sgrad) { + out_ptr_NCX[out_sK] = static_cast(0); + } + } + } + + // Pre-compute indices/weights/grad + scalar_t wx[8]; // B-spline weights + scalar_t gx[8]; // B-spline derivatives + scalar_t hx[8]; // B-spline 2nd derivatives + offset_t ix[8]; // Warped indices + uint8_t sx[8]; // Warped indices + + { + scalar_t *owx = static_cast(wx), *ogx = static_cast(gx), *ohx = static_cast(hx); + offset_t* oix = static_cast(ix); + uint8_t* osx = static_cast(sx); + for (offset_t bx = bx0; bx <= bx1; ++bx) { + scalar_t dx = x - bx; + *(owx++) = interpolation::fastweight(interpolation0, dx); + if (do_grad || do_sgrad) + *(ogx++) = interpolation::fastgrad(interpolation0, dx); + if (do_grad && trgt_sK > 1) + *(ohx++) = interpolation::fasthess(interpolation0, dx); + *(osx++) = bound::sign(bound0, bx, src_X); + *(oix++) = bound::index(bound0, bx, src_X); + } + } + + // Convolve coefficients with basis functions + scalar_t ogx; + ogx = static_cast(0); + for (offset_t i = 0; i <= dbx; ++i) { + offset_t oox = ix[i] * out_sX; + offset_t osx = ix[i] * src_sX; + uint8_t sxx = sx[i]; + scalar_t wxx = wx[i]; + scalar_t gxx = gx[i]; + scalar_t hxx = hx[i]; + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pull ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_pull) { + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t* out_ptr_NCX = out_ptr_NCX0; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) + *out_ptr_NCX += bound::get(src_ptr_NC, osx, sxx) * wxx; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SGrad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_sgrad) { + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t* out_ptr_NCX = out_ptr_NCX0; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) { + scalar_t src = bound::get(src_ptr_NC, osx, sxx); + *out_ptr_NCX += src * gxx; + } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_push) { + if (trgt_K == 0) { + // Diff w.r.t. push/pull + scalar_t* out_ptr_NC = out_ptr_NC0; + for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) + bound::add(out_ptr_NC, oox, wxx * target[c], sxx); + } else { + // Diff w.r.t. sgrad + scalar_t* out_ptr_NC = out_ptr_NC0; + for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) { + scalar_t val = gxx * target[c]; + bound::add(out_ptr_NC, oox, val, sxx); + } + } + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Count ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_count) { + bound::add(out_ptr_NC0, oox, wxx, sxx); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_grad) { + if (trgt_K == 0) { + // Diff w.r.t. pull/push + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t dot = static_cast(0); + for (offset_t c = 0; c < C; ++c, src_ptr_NC += src_sC) { + scalar_t src = bound::get(src_ptr_NC, osx, sxx); + dot += (trgt_ptr ? src * target[c] : src); + // trgt_ptr == 0 in the backward pass of 'count' + } + ogx += gxx * dot; + } else { + // Diff w.r.t. sgrad + scalar_t* src_ptr_NC = src_ptr_NC0; + scalar_t dot; + dot = static_cast(0); + for (offset_t c = 0; c < C; ++c, src_ptr_NC += src_sC) { + scalar_t src = bound::get(src_ptr_NC, osx, sxx); + dot += src * target[c]; + } + ogx += hxx * dot; + } + } + + } // x + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Grad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_grad) { + scalar_t* grad_ptr_NX = grad_ptr + n * grad_sN + w * grad_sX; + (*grad_ptr_NX) = ogx; + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // LINEAR INTERPOLATION 3D // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1181,7 +1521,7 @@ MONAI_NAMESPACE_DEVICE { // cuda scalar_t* trgt_ptr_NCXYZ = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY + d * trgt_sZ; scalar_t* src_ptr_NC = src_ptr + n * src_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // backward w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXYZ += trgt_sC, src_ptr_NC += src_sC) { scalar_t src; @@ -1343,7 +1683,7 @@ MONAI_NAMESPACE_DEVICE { // cuda o111 = ix1 * out_sX + iy1 * out_sY + iz1 * out_sZ; scalar_t* trgt_ptr_NCXYZ = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY + d * trgt_sZ; scalar_t* out_ptr_NC = out_ptr + n * out_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXYZ += trgt_sC, out_ptr_NC += out_sC) { scalar_t trgt = *trgt_ptr_NCXYZ; @@ -1428,7 +1768,6 @@ MONAI_NAMESPACE_DEVICE { // cuda scalar_t w10 = dx1 * dy0; scalar_t w01 = dx0 * dy1; scalar_t w11 = dx1 * dy1; - ; // Sign (/!\ compute sign before warping indices) int8_t sx1 = bound::sign(bound0, ix0 + 1, src_X); @@ -1467,7 +1806,7 @@ MONAI_NAMESPACE_DEVICE { // cuda scalar_t* trgt_ptr_NCXY = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY; scalar_t* src_ptr_NC = src_ptr + n * src_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // backward w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXY += trgt_sC, src_ptr_NC += src_sC) { scalar_t src; @@ -1514,9 +1853,9 @@ MONAI_NAMESPACE_DEVICE { // cuda } } - scalar_t* grad_ptr_NXYZ = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY; - (*grad_ptr_NXYZ) = gx; - grad_ptr_NXYZ[grad_sC] = gy; + scalar_t* grad_ptr_NXY = grad_ptr + n * grad_sN + w * grad_sX + h * grad_sY; + (*grad_ptr_NXY) = gx; + grad_ptr_NXY[grad_sC] = gy; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pull ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ if (do_pull) { @@ -1558,7 +1897,7 @@ MONAI_NAMESPACE_DEVICE { // cuda o11 = ix1 * out_sX + iy1 * out_sY; scalar_t* trgt_ptr_NCXY = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY; scalar_t* out_ptr_NC = out_ptr + n * out_sN; - if (trgt_K == 1) { + if (trgt_K == 0) { // Diff w.r.t. push/pull for (offset_t c = 0; c < C; ++c, trgt_ptr_NCXY += trgt_sC, out_ptr_NC += out_sC) { scalar_t trgt = *trgt_ptr_NCXY; @@ -1599,6 +1938,123 @@ MONAI_NAMESPACE_DEVICE { // cuda } } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // LINEAR INTERPOLATION 1D + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + template + MONAI_DEVICE void PushPullImpl::interpolate1d_linear(scalar_t x, offset_t w, offset_t n) const { + // Get corner pixel values from (x) + offset_t ix0 = static_cast(std::floor(x)); + + // Interpolation weights (inversely proportional to distance) + scalar_t w1 = x - ix0; + scalar_t w0 = 1. - w1; + + // Sign (/!\ compute sign before warping indices) + int8_t s1 = bound::sign(bound0, ix0 + 1, src_X); + int8_t s0 = bound::sign(bound0, ix0, src_X); + + // Warp indices + offset_t ix1; + ix1 = bound::index(bound0, ix0 + 1, src_X); + ix0 = bound::index(bound0, ix0, src_X); + + // Offsets into source volume + offset_t o0, o1; + if (do_pull || do_grad || do_sgrad) { + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~ Grid gradient ~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_grad) { + if (trgt_K == 0) { + // backward w.r.t. push/pull + + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + scalar_t gx = static_cast(0); + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, src_ptr_NC += src_sC) { + scalar_t src; + scalar_t trgt = trgt_ptr ? *trgt_ptr_NCX : static_cast(1); + // ^ trgt_ptr == 0 during the backward pass of count + src = bound::get(src_ptr_NC, o0, s0); + if (trgt_ptr) + src *= trgt; + gx -= src; + src = bound::get(src_ptr_NC, o1, s1); + if (trgt_ptr) + src *= trgt; + gx += src; + } + + scalar_t* grad_ptr_NX = grad_ptr + n * grad_sN + w * grad_sX; + (*grad_ptr_NX) = gx; + } else { + // backward w.r.t. sgrad + // -> zero (make sure this is done at initialization) + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pull ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if (do_pull) { + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) { + *out_ptr_NCX = bound::get(src_ptr_NC, o0, s0) * w0 + bound::get(src_ptr_NC, o1, s1) * w1; + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ SGrad ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_sgrad) { + o0 = ix0 * src_sX; + o1 = ix1 * src_sX; + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) { + *out_ptr_NCX = bound::get(src_ptr_NC, o1, s1) - bound::get(src_ptr_NC, o0, s0); + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_push) { + // Offsets into 'push' volume + o0 = ix0 * out_sX; + o1 = ix1 * out_sX; + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t* out_ptr_NC = out_ptr + n * out_sN; + if (trgt_K == 0) { + // Diff w.r.t. push/pull + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, out_ptr_NC += out_sC) { + scalar_t trgt = *trgt_ptr_NCX; + bound::add(out_ptr_NC, o0, w0 * trgt, s0); + bound::add(out_ptr_NC, o1, w1 * trgt, s1); + } + } else { + // Diff w.r.t. sgrad + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, out_ptr_NC += out_sC) { + scalar_t trgt0 = *trgt_ptr_NCX; + bound::add(out_ptr_NC, o0, -trgt0, s0); + bound::add(out_ptr_NC, o1, trgt0, s1); + } + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Push ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + else if (do_count) { + // Offsets into 'push' volume + o0 = ix0 * out_sX; + o1 = ix1 * out_sX; + + scalar_t* out_ptr_N = out_ptr + n * out_sN; + bound::add(out_ptr_N, o0, w0, s0); + bound::add(out_ptr_N, o1, w1, s1); + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // NEAREST NEIGHBOR INTERPOLATION 3D // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1633,7 +2089,7 @@ MONAI_NAMESPACE_DEVICE { // cuda scalar_t* src_ptr_NC = src_ptr + n * src_sN; for (offset_t c = 0; c < C; ++c, out_ptr_NCXYZ += out_sC, src_ptr_NC += src_sC) *out_ptr_NCXYZ = bound::get(src_ptr_NC, o, s); - } else if (do_push && trgt_K == 1) { + } else if (do_push && trgt_K == 0) { offset_t o = iz * out_sZ + iy * out_sY + ix * out_sX; scalar_t* trgt_ptr_NCXYZ = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY + d * trgt_sZ; scalar_t* out_ptr_NC = out_ptr + n * out_sN; @@ -1676,7 +2132,7 @@ MONAI_NAMESPACE_DEVICE { // cuda scalar_t* src_ptr_NC = src_ptr + n * src_sN; for (offset_t c = 0; c < C; ++c, out_ptr_NCXY += out_sC, src_ptr_NC += src_sC) *out_ptr_NCXY = bound::get(src_ptr_NC, o, s); - } else if (do_push && trgt_K == 1) { + } else if (do_push && trgt_K == 0) { offset_t o = iy * out_sY + ix * out_sX; scalar_t* trgt_ptr_NCXY = trgt_ptr + n * trgt_sN + w * trgt_sX + h * trgt_sY; scalar_t* out_ptr_NC = out_ptr + n * out_sN; @@ -1689,6 +2145,39 @@ MONAI_NAMESPACE_DEVICE { // cuda bound::add(out_ptr_NC, o, static_cast(1), s); } } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // NEAREST NEIGHBOR INTERPOLATION 1D + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + template + MONAI_DEVICE void PushPullImpl::interpolate1d_nearest(scalar_t x, offset_t w, offset_t n) const { + offset_t i = static_cast(std::round(x)); + + // Boundary condition (/!\ compute sign before warping indices) + int8_t s = bound::sign(bound0, i, src_X); + i = bound::index(bound0, i, src_X); + + if (do_pull) { + offset_t o = i * src_sX; + scalar_t* out_ptr_NCX = out_ptr + n * out_sN + w * out_sX; + scalar_t* src_ptr_NC = src_ptr + n * src_sN; + for (offset_t c = 0; c < C; ++c, out_ptr_NCX += out_sC, src_ptr_NC += src_sC) + *out_ptr_NCX = bound::get(src_ptr_NC, o, s); + } else if (do_push && trgt_K == 0) { + offset_t o = i * out_sX; + scalar_t* trgt_ptr_NCX = trgt_ptr + n * trgt_sN + w * trgt_sX; + scalar_t* out_ptr_NC = out_ptr + n * out_sN; + for (offset_t c = 0; c < C; ++c, trgt_ptr_NCX += trgt_sC, out_ptr_NC += out_sC) + bound::add(out_ptr_NC, o, *trgt_ptr_NCX, s); + } else if (do_count) { + offset_t o = i * out_sX; + scalar_t* out_ptr_NC = out_ptr + n * out_sN; + for (offset_t c = 0; c < C; ++c, out_ptr_NC += out_sC) + bound::add(out_ptr_NC, o, static_cast(1), s); + } + } + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // LINEAR INTERPOLATION 3D + SLIDING BOUNDARY // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1736,8 +2225,6 @@ MONAI_NAMESPACE_DEVICE { // cuda PUSHPULL_INSTANTIATE1(BoundType); \ PUSHPULL_INSTANTIATE1(BoundVectorRef) - // ~~~ CUDA ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // Two arguments (source, grid) // > `bound` and `interpolation` can be single arguments or vectors. template @@ -1752,12 +2239,20 @@ MONAI_NAMESPACE_DEVICE { // cuda bool do_count, bool do_grad, bool do_sgrad) { + PushPullAllocator info( + grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); + info.ioset(source, grid); + return AT_DISPATCH_FLOATING_TYPES_AND_HALF(grid.scalar_type(), "pushpull", [&] { - PushPullImpl f( - grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); - f.ioset(source, grid); - pushpull_kernel<<>>(f); - return f.output; + if (info.canUse32BitIndexMath()) { + PushPullImpl algo(info); + pushpull_kernel<<>>(algo); + return algo.output; + } else { + PushPullImpl algo(info); + pushpull_kernel<<>>(algo); + return algo.output; + } }); } @@ -1777,17 +2272,24 @@ MONAI_NAMESPACE_DEVICE { // cuda bool do_count, bool do_grad, bool do_sgrad) { + PushPullAllocator info( + grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); + info.ioset(source, grid, target); + return AT_DISPATCH_FLOATING_TYPES_AND_HALF(grid.scalar_type(), "pushpull", [&] { - PushPullImpl f( - grid.dim() - 2, bound, interpolation, extrapolate, do_pull, do_push, do_count, do_grad, do_sgrad); - f.ioset(source, grid, target); - pushpull_kernel<<>>(f); - return f.output; + if (info.canUse32BitIndexMath()) { + PushPullImpl algo(info); + pushpull_kernel<<>>(algo); + return algo.output; + } else { + PushPullImpl algo(info); + pushpull_kernel<<>>(algo); + return algo.output; + } }); } PUSHPULL_INSTANTIATE; -} // namespace - +} // namespace gpu } // namespace monai diff --git a/monai/csrc/utils/common_utils.h b/monai/csrc/utils/common_utils.h index 882312acb3..4d09377e65 100644 --- a/monai/csrc/utils/common_utils.h +++ b/monai/csrc/utils/common_utils.h @@ -26,10 +26,10 @@ limitations under the License. value.layout() == at::kStrided, \ "(): expected " #value "to have torch.strided layout, but it has ", \ value.layout()); -#define CHECK_SPATIAL_2D_OR_3D(value) \ - TORCH_CHECK( \ - (value.dim() == 4 || value.dim() == 5), \ - "(): expected 4D or 5D " #value " but got input with sizes ", \ +#define CHECK_SPATIAL_1D_2D_OR_3D(value) \ + TORCH_CHECK( \ + (value.dim() == 3 || value.dim() == 4 || value.dim() == 5), \ + "(): expected 3D, 4D or 5D " #value " but got input with sizes ", \ value.sizes()); #define CHECK_GRID_COMPONENT(value, dim) \ TORCH_CHECK( \ @@ -42,18 +42,18 @@ limitations under the License. #define CHECK_SAME_DEVICE(value1, value2) \ TORCH_CHECK( \ value1.device() == value2.device(), \ - "(): expected " #value2 " and " #value2 \ + "(): expected " #value1 " and " #value2 \ " to be on same device, " \ - "but " #value2 " is on ", \ + "but " #value1 " is on ", \ value1.device(), \ " and " #value2 " is on ", \ value2.device()); #define CHECK_SAME_DTYPE(value1, value2) \ TORCH_CHECK( \ value1.dtype() == value2.dtype(), \ - "(): expected " #value2 " and " #value2 \ + "(): expected " #value1 " and " #value2 \ " to have the same dtype, " \ - "but " #value2 " has ", \ + "but " #value1 " has ", \ value1.dtype(), \ " and " #value2 " has ", \ value2.dtype()); @@ -67,14 +67,15 @@ limitations under the License. i, \ " being empty"); \ } -#define CHECK_GRID_TARGET_COMPAT(value1, value2) \ - TORCH_CHECK( \ - value2.size(0) == value1.size(0) && value2.size(2) == value1.size(1) && value2.size(3) == value1.size(2) && \ - (value2.dim() == 4 || value2.size(4) == value1.size(3)), \ - "(): expected " #value2 " and " #value1 \ - " to have same batch, width, height and (optionally) depth sizes, but got " #value2 " with sizes ", \ - value2.sizes(), \ - " and " #value1 " with sizes ", \ +#define CHECK_GRID_TARGET_COMPAT(value1, value2) \ + TORCH_CHECK( \ + value2.size(0) == value1.size(0) && (value2.dim() <= 2 || value2.size(2) == value1.size(1)) && \ + (value2.dim() <= 3 || value2.size(3) == value1.size(2)) && \ + (value2.dim() <= 4 || value2.size(4) == value1.size(3)), \ + "(): expected " #value2 " and " #value1 \ + " to have same batch, width, height and (optionally) depth sizes, but got " #value2 " with sizes ", \ + value2.sizes(), \ + " and " #value1 " with sizes ", \ value1.sizes()); #define CHECK_SPATIAL_LENGTH(value, dim) \ TORCH_CHECK(((int64_t)(value.size()) == dim - 2), "(): expected ", dim, #value " elements but got ", value.size()); diff --git a/monai/csrc/utils/resample_utils.h b/monai/csrc/utils/resample_utils.h index 4735d13ca1..bbdf258b4c 100644 --- a/monai/csrc/utils/resample_utils.h +++ b/monai/csrc/utils/resample_utils.h @@ -62,7 +62,9 @@ namespace monai { template static inline void cpuAtomicAdd(scalar_t* ptr, offset_t offset, scalar_t value) { #if AT_PARALLEL_OPENMP +#if _OPENMP #pragma omp atomic +#endif #endif ptr[offset] += value; } diff --git a/monai/data/__init__.py b/monai/data/__init__.py index e0db1e17ae..2001ccfc8f 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -17,27 +17,30 @@ CacheNTransDataset, Dataset, LMDBDataset, + NPZDictItemDataset, PersistentDataset, SmartCacheDataset, ZipDataset, ) from .decathlon_datalist import load_decathlon_datalist, load_decathlon_properties -from .grid_dataset import GridPatchDataset, PatchDataset +from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter from .image_dataset import ImageDataset -from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader +from .image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader, WSIReader +from .inverse_batch_transform import BatchInverseTransform from .iterable_dataset import IterableDataset from .nifti_saver import NiftiSaver from .nifti_writer import write_nifti from .png_saver import PNGSaver from .png_writer import write_png +from .samplers import DistributedSampler, DistributedWeightedRandomSampler from .synthetic import create_test_image_2d, create_test_image_3d -from .thread_buffer import ThreadBuffer +from .thread_buffer import ThreadBuffer, ThreadDataLoader from .utils import ( - DistributedSampler, compute_importance_map, compute_shape_offset, correct_nifti_header_if_necessary, create_file_basename, + decollate_batch, dense_patch_slices, get_random_patch, get_valid_patch_size, @@ -46,6 +49,7 @@ iter_patch_slices, json_hashing, list_data_collate, + pad_list_data_collate, partition_dataset, partition_dataset_classes, pickle_hashing, diff --git a/monai/data/dataset.py b/monai/data/dataset.py index b93f03151f..c10c500bf8 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -19,13 +19,15 @@ from copy import deepcopy from multiprocessing.pool import ThreadPool from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Union +from typing import IO, TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Union +import numpy as np import torch from torch.utils.data import Dataset as _TorchDataset -from monai.data.utils import pickle_hashing +from monai.data.utils import first, pickle_hashing from monai.transforms import Compose, Randomizable, Transform, apply_transform +from monai.transforms.transform import RandomizableTransform from monai.utils import MAX_SEED, get_seed, min_version, optional_import if TYPE_CHECKING: @@ -51,16 +53,15 @@ class Dataset(_TorchDataset): }, }, }] """ - def __init__(self, data: Sequence, transform: Optional[Callable] = None, progress: bool = True) -> None: + def __init__(self, data: Sequence, transform: Optional[Callable] = None) -> None: """ Args: data: input data to load and transform to generate dataset for model. transform: a callable data transform on input data. - progress: whether to display a progress bar. + """ self.data = data self.transform = transform - self.progress = progress def __len__(self) -> int: return len(self.data) @@ -117,7 +118,6 @@ def __init__( transform: Union[Sequence[Callable], Callable], cache_dir: Optional[Union[Path, str]] = None, hash_func: Callable[..., bytes] = pickle_hashing, - progress: bool = True, ) -> None: """ Args: @@ -132,11 +132,11 @@ def __init__( If the cache_dir doesn't exist, will automatically create it. hash_func: a callable to compute hash from data items to be cached. defaults to `monai.data.utils.pickle_hashing`. - progress: whether to display a progress bar. + """ if not isinstance(transform, Compose): transform = Compose(transform) - super().__init__(data=data, transform=transform, progress=progress) + super().__init__(data=data, transform=transform) self.cache_dir = Path(cache_dir) if cache_dir is not None else None self.hash_func = hash_func if self.cache_dir is not None: @@ -161,7 +161,7 @@ def _pre_transform(self, item_transformed): raise ValueError("transform must be an instance of monai.transforms.Compose.") for _transform in self.transform.transforms: # execute all the deterministic transforms - if isinstance(_transform, Randomizable) or not isinstance(_transform, Transform): + if isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform): break item_transformed = apply_transform(_transform, item_transformed) return item_transformed @@ -183,7 +183,7 @@ def _post_transform(self, item_transformed): for _transform in self.transform.transforms: if ( start_post_randomize_run - or isinstance(_transform, Randomizable) + or isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform) ): start_post_randomize_run = True @@ -349,7 +349,8 @@ def __init__( 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, progress=progress) + super().__init__(data=data, transform=transform, cache_dir=cache_dir, hash_func=hash_func) + self.progress = progress if not self.cache_dir: raise ValueError("cache_dir must be specified.") self.db_file = self.cache_dir / f"{db_name}.lmdb" @@ -489,7 +490,8 @@ def __init__( """ if not isinstance(transform, Compose): transform = Compose(transform) - super().__init__(data=data, transform=transform, progress=progress) + super().__init__(data=data, transform=transform) + self.progress = progress self.cache_num = min(int(cache_num), int(len(data) * cache_rate), len(data)) self.num_workers = num_workers if self.num_workers is not None: @@ -522,7 +524,7 @@ def _load_cache_item(self, idx: int): raise ValueError("transform must be an instance of monai.transforms.Compose.") for _transform in self.transform.transforms: # execute all the deterministic transforms - if isinstance(_transform, Randomizable) or not isinstance(_transform, Transform): + if isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform): break item = apply_transform(_transform, item) return item @@ -539,7 +541,7 @@ def __getitem__(self, index): if not isinstance(self.transform, Compose): raise ValueError("transform must be an instance of monai.transforms.Compose.") for _transform in self.transform.transforms: - if start_run or isinstance(_transform, Randomizable) or not isinstance(_transform, Transform): + if start_run or isinstance(_transform, RandomizableTransform) or not isinstance(_transform, Transform): start_run = True data = apply_transform(_transform, data) return data @@ -590,7 +592,8 @@ def __init__( cache_num: int = sys.maxsize, cache_rate: float = 1.0, num_init_workers: Optional[int] = None, - num_replace_workers: int = 0, + num_replace_workers: Optional[int] = None, + progress: bool = True, ) -> None: """ Args: @@ -604,16 +607,21 @@ def __init__( num_init_workers: the number of worker threads to initialize the cache for first epoch. If num_init_workers is None then the number returned by os.cpu_count() is used. num_replace_workers: the number of worker threads to prepare the replacement cache for every epoch. - if 0, run in main thread, no separate thread will open. + If num_replace_workers is None then the number returned by os.cpu_count() is used. + progress: whether to display a progress bar when caching for the first epoch. + """ - super().__init__(data, transform, cache_num, cache_rate, num_init_workers) + super().__init__(data, transform, cache_num, cache_rate, num_init_workers, progress) if self._cache is None: self._cache = self._fill_cache() if self.cache_num >= len(data): warnings.warn("cache_num is greater or equal than dataset length, fall back to regular CacheDataset.") if replace_rate <= 0: raise ValueError("replace_rate must be greater than 0, otherwise, please use CacheDataset.") - self.num_replace_workers: int = num_replace_workers + + self.num_replace_workers: Optional[int] = num_replace_workers + if self.num_replace_workers is not None: + self.num_replace_workers = max(int(self.num_replace_workers), 1) self._total_num: int = len(data) self._replace_num: int = min(math.ceil(self.cache_num * replace_rate), len(data) - self.cache_num) @@ -743,12 +751,9 @@ def _compute_replacements(self): It can support multi-threads to accelerate the computation progress. """ - if self.num_replace_workers > 0: - with ThreadPool(self.num_replace_workers) as p: - p.map(self._replace_cache_thread, list(range(self._replace_num))) - else: - for i in range(self._replace_num): - self._replace_cache_thread(i) + with ThreadPool(self.num_replace_workers) as p: + p.map(self._replace_cache_thread, list(range(self._replace_num))) + self._replace_done = True def _try_manage_replacement(self, check_round): @@ -921,9 +926,59 @@ def __getitem__(self, index: int): # set transforms of each zip component for dataset in self.dataset.data: transform = getattr(dataset, "transform", None) - if isinstance(transform, Randomizable): + if isinstance(transform, RandomizableTransform): transform.set_random_state(seed=self._seed) transform = getattr(self.dataset, "transform", None) - if isinstance(transform, Randomizable): + if isinstance(transform, RandomizableTransform): transform.set_random_state(seed=self._seed) return self.dataset[index] + + +class NPZDictItemDataset(Dataset): + """ + Represents a dataset from a loaded NPZ file. The members of the file to load are named in the keys of `keys` and + stored under the keyed name. All loaded arrays must have the same 0-dimension (batch) size. Items are always dicts + mapping names to an item extracted from the loaded arrays. + + Args: + npzfile: Path to .npz file or stream containing .npz file data + keys: Maps keys to load from file to name to store in dataset + transform: Transform to apply to batch dict + other_keys: secondary data to load from file and store in dict `other_keys`, not returned by __getitem__ + """ + + def __init__( + self, + npzfile: Union[str, IO], + keys: Dict[str, str], + transform: Optional[Callable] = None, + other_keys: Optional[Sequence[str]] = (), + ): + self.npzfile: Union[str, IO] = npzfile if isinstance(npzfile, str) else "STREAM" + self.keys: Dict[str, str] = dict(keys) + dat = np.load(npzfile) + + self.arrays = {storedk: dat[datak] for datak, storedk in self.keys.items()} + self.length = self.arrays[first(self.keys.values())].shape[0] + + self.other_keys = {} if other_keys is None else {k: dat[k] for k in other_keys} + + for k, v in self.arrays.items(): + if v.shape[0] != self.length: + raise ValueError( + "All loaded arrays must have the same first dimension " + f"size {self.length}, array `{k}` has size {v.shape[0]}" + ) + + super().__init__([], transform) + + def __len__(self): + return self.length + + def __getitem__(self, index: int): + data = {k: v[index] for k, v in self.arrays.items()} + + if self.transform is not None: + data = apply_transform(self.transform, data) + + return data diff --git a/monai/data/grid_dataset.py b/monai/data/grid_dataset.py index f85569d88a..3f373491ed 100644 --- a/monai/data/grid_dataset.py +++ b/monai/data/grid_dataset.py @@ -9,9 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import math from typing import Callable, Dict, Optional, Sequence, Union +import numpy as np import torch from torch.utils.data import IterableDataset @@ -20,31 +20,25 @@ from monai.transforms import apply_transform from monai.utils import NumpyPadMode, ensure_tuple -__all__ = ["PatchDataset", "GridPatchDataset"] +__all__ = ["PatchDataset", "GridPatchDataset", "PatchIter"] -class GridPatchDataset(IterableDataset): +class PatchIter: """ - Yields patches from arrays read from an input dataset. The patches are chosen in a contiguous grid sampling scheme. + A class to return a patch generator with predefined properties such as `patch_size`. + Typically used with :py:class:`monai.data.GridPatchDataset`. """ def __init__( self, - dataset: Sequence, patch_size: Sequence[int], start_pos: Sequence[int] = (), mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, **pad_opts: Dict, - ) -> None: + ): """ - Initializes this dataset in terms of the input dataset and patch size. The `patch_size` is the size of the - patch to sample from the input arrays. It is assumed the arrays first dimension is the channel dimension which - will be yielded in its entirety so this should not be specified in `patch_size`. For example, for an input 3D - array with 1 channel of size (1, 20, 20, 20) a regular grid sampling of eight patches (1, 10, 10, 10) would be - specified by a `patch_size` of (10, 10, 10). Args: - dataset: the dataset to read array data from patch_size: size of patches to generate slices for, 0/None selects whole dimension start_pos: starting position in the array, default is 0 for each dimension mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, @@ -52,32 +46,123 @@ def __init__( One of the listed string values or a user supplied function. Defaults to ``"wrap"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html pad_opts: padding options, see numpy.pad - """ - self.dataset = dataset + Note: + The `patch_size` is the size of the + patch to sample from the input arrays. It is assumed the arrays first dimension is the channel dimension which + will be yielded in its entirety so this should not be specified in `patch_size`. For example, for an input 3D + array with 1 channel of size (1, 20, 20, 20) a regular grid sampling of eight patches (1, 10, 10, 10) would be + specified by a `patch_size` of (10, 10, 10). + + """ self.patch_size = (None,) + tuple(patch_size) self.start_pos = ensure_tuple(start_pos) self.mode: NumpyPadMode = NumpyPadMode(mode) self.pad_opts = pad_opts + def __call__(self, array): + """ + Args: + array: the image to generate patches from. + """ + yield from iter_patch( + array, + patch_size=self.patch_size, # expand to have the channel dim + start_pos=self.start_pos, + copy_back=False, + mode=self.mode, + **self.pad_opts, + ) + + +class GridPatchDataset(IterableDataset): + """ + Yields patches from images read from an image dataset. + Typically used with `PatchIter` so that the patches are chosen in a contiguous grid sampling scheme. + + .. code-block:: python + + import numpy as np + + from monai.data import GridPatchDataset, DataLoader, PatchIter + from monai.transforms import RandShiftIntensity + + # image-level dataset + images = [np.arange(16, dtype=float).reshape(1, 4, 4), + np.arange(16, dtype=float).reshape(1, 4, 4)] + # image-level patch generator, "grid sampling" + patch_iter = PatchIter(patch_size=(2, 2), start_pos=(0, 0)) + # patch-level intensity shifts + patch_intensity = RandShiftIntensity(offsets=1.0, prob=1.0) + + # construct the dataset + ds = GridPatchDataset(dataset=images, + patch_iter=patch_iter, + transform=patch_intensity) + # use the grid patch dataset + for item in DataLoader(ds, batch_size=2, num_workers=2): + print("patch size:", item[0].shape) + print("coordinates:", item[1]) + + # >>> patch size: torch.Size([2, 1, 2, 2]) + # coordinates: tensor([[[0, 1], [0, 2], [0, 2]], + # [[0, 1], [2, 4], [0, 2]]]) + + """ + + def __init__( + self, + dataset: Sequence, + patch_iter: Callable, + transform: Optional[Callable] = None, + with_coordinates: bool = True, + ) -> None: + """ + Initializes this dataset in terms of the image dataset, patch generator, and an optional transform. + + Args: + dataset: the dataset to read image data from. + patch_iter: converts an input image (item from dataset) into a iterable of image patches. + `patch_iter(dataset[idx])` must yield a tuple: (patches, coordinates). + see also: :py:class:`monai.data.PatchIter`. + transform: a callable data transform operates on the patches. + with_coordinates: whether to yield the coordinates of each patch, default to `True`. + + """ + + self.dataset = dataset + self.patch_iter = patch_iter + self.transform = transform + self.with_coordinates = with_coordinates + def __iter__(self): worker_info = torch.utils.data.get_worker_info() - iter_start = 0 - iter_end = len(self.dataset) + iter_start, iter_end = 0, 1 + try: + iter_end = len(self.dataset) # TODO: support iterable self.dataset + except TypeError: + raise NotImplementedError("image dataset must implement `len()`.") if worker_info is not None: # split workload - per_worker = int(math.ceil((iter_end - iter_start) / float(worker_info.num_workers))) - worker_id = worker_info.id - iter_start = iter_start + worker_id * per_worker + per_worker = int(np.ceil((iter_end - iter_start) / float(worker_info.num_workers))) + iter_start = iter_start + worker_info.id * per_worker iter_end = min(iter_start + per_worker, iter_end) for index in range(iter_start, iter_end): - arrays = self.dataset[index] - - iters = [iter_patch(a, self.patch_size, self.start_pos, False, self.mode, **self.pad_opts) for a in arrays] - - yield from zip(*iters) + image = self.dataset[index] + if not self.with_coordinates: + for patch, *_ in self.patch_iter(image): # patch_iter to yield at least 1 item: patch + out_patch = ( + patch if self.transform is None else apply_transform(self.transform, patch, map_items=False) + ) + yield out_patch + else: + for patch, slices, *_ in self.patch_iter(image): # patch_iter to yield at least 2 items: patch, coords + out_patch = ( + patch if self.transform is None else apply_transform(self.transform, patch, map_items=False) + ) + yield out_patch, slices class PatchDataset(Dataset): @@ -95,8 +180,8 @@ class PatchDataset(Dataset): from monai.transforms import RandSpatialCropSamples, RandShiftIntensity # image dataset - images = [np.arange(16, dtype=np.float).reshape(1, 4, 4), - np.arange(16, dtype=np.float).reshape(1, 4, 4)] + images = [np.arange(16, dtype=float).reshape(1, 4, 4), + np.arange(16, dtype=float).reshape(1, 4, 4)] # image patch sampler n_samples = 5 sampler = RandSpatialCropSamples(roi_size=(3, 3), num_samples=n_samples, diff --git a/monai/data/image_dataset.py b/monai/data/image_dataset.py index 1568e082ee..1074105508 100644 --- a/monai/data/image_dataset.py +++ b/monai/data/image_dataset.py @@ -17,6 +17,7 @@ from monai.config import DtypeLike from monai.data.image_reader import ImageReader from monai.transforms import LoadImage, Randomizable, apply_transform +from monai.transforms.transform import RandomizableTransform from monai.utils import MAX_SEED, get_seed @@ -106,14 +107,14 @@ def __getitem__(self, index: int): label = self.labels[index] if self.transform is not None: - if isinstance(self.transform, Randomizable): + if isinstance(self.transform, RandomizableTransform): self.transform.set_random_state(seed=self._seed) img = apply_transform(self.transform, img) data = [img] if self.seg_transform is not None: - if isinstance(self.seg_transform, Randomizable): + if isinstance(self.seg_transform, RandomizableTransform): self.seg_transform.set_random_state(seed=self._seed) seg = apply_transform(self.seg_transform, seg) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index e458833979..67425c0f47 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -19,26 +19,31 @@ from monai.config import DtypeLike, KeysCollection from monai.data.utils import correct_nifti_header_if_necessary +from monai.transforms.utility.array import EnsureChannelFirst from monai.utils import ensure_tuple, optional_import from .utils import is_supported_format if TYPE_CHECKING: + import cucim import itk # type: ignore import nibabel as nib + import openslide from itk import Image # type: ignore from nibabel.nifti1 import Nifti1Image from PIL import Image as PILImage - has_itk = has_nib = has_pil = True + has_itk = has_nib = has_pil = has_cim = has_osl = True else: itk, has_itk = optional_import("itk", allow_namespace_pkg=True) Image, _ = optional_import("itk", allow_namespace_pkg=True, name="Image") nib, has_nib = optional_import("nibabel") Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image") PILImage, has_pil = optional_import("PIL.Image") + cucim, has_cim = optional_import("cucim") + openslide, has_osl = optional_import("openslide") -__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader"] +__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader"] class ImageReader(ABC): @@ -109,6 +114,17 @@ def _copy_compatible_dict(from_dict: Dict, to_dict: Dict): ) +def _stack_images(image_list: List, meta_dict: Dict): + if len(image_list) > 1: + if meta_dict.get("original_channel_dim", None) not in ("no_channel", None): + raise RuntimeError("can not read a list of images which already have channel dimension.") + meta_dict["original_channel_dim"] = 0 + img_array = np.stack(image_list, axis=0) + else: + img_array = image_list[0] + return img_array + + class ITKReader(ImageReader): """ Load medical images based on ITK library. @@ -200,11 +216,12 @@ def get_data(self, img): header["original_affine"] = self._get_affine(i) header["affine"] = header["original_affine"].copy() header["spatial_shape"] = self._get_spatial_shape(i) - img_array.append(self._get_array_data(i)) + data = self._get_array_data(i) + img_array.append(data) + header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1 _copy_compatible_dict(header, compatible_meta) - img_array_ = np.stack(img_array, axis=0) if len(img_array) > 1 else img_array[0] - return img_array_, compatible_meta + return _stack_images(img_array, compatible_meta), compatible_meta def _get_meta_dict(self, img) -> Dict: """ @@ -252,10 +269,10 @@ def _get_affine(self, img) -> np.ndarray: origin = np.asarray(img.GetOrigin()) direction = np.asarray(direction) - affine = np.eye(direction.shape[0] + 1) + affine: np.ndarray = np.eye(direction.shape[0] + 1) affine[(slice(-1), slice(-1))] = direction @ np.diag(spacing) affine[(slice(-1), -1)] = origin - return np.asarray(affine) + return affine def _get_spatial_shape(self, img) -> np.ndarray: """ @@ -265,6 +282,7 @@ def _get_spatial_shape(self, img) -> np.ndarray: img: a ITK image object loaded from a image file. """ + # the img data should have no channel dim or the last dim is channel shape = list(itk.size(img)) shape.reverse() return np.asarray(shape) @@ -371,11 +389,12 @@ def get_data(self, img): i = nib.as_closest_canonical(i) header["affine"] = self._get_affine(i) header["spatial_shape"] = self._get_spatial_shape(i) - img_array.append(self._get_array_data(i)) + data = self._get_array_data(i) + img_array.append(data) + header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1 _copy_compatible_dict(header, compatible_meta) - img_array_ = np.stack(img_array, axis=0) if len(img_array) > 1 else img_array[0] - return img_array_, compatible_meta + return _stack_images(img_array, compatible_meta), compatible_meta def _get_meta_dict(self, img) -> Dict: """ @@ -408,6 +427,7 @@ def _get_spatial_shape(self, img) -> np.ndarray: """ ndim = img.header["dim"][0] spatial_rank = min(ndim, 3) + # the img data should have no channel dim or the last dim is channel return np.asarray(img.header["dim"][1 : spatial_rank + 1]) def _get_array_data(self, img) -> np.ndarray: @@ -504,12 +524,12 @@ def get_data(self, img): for i in ensure_tuple(img): header = {} if isinstance(i, np.ndarray): + # can not detect the channel dim of numpy array, use all the dims as spatial_shape header["spatial_shape"] = i.shape img_array.append(i) _copy_compatible_dict(header, compatible_meta) - img_array_ = np.stack(img_array, axis=0) if len(img_array) > 1 else img_array[0] - return img_array_, compatible_meta + return _stack_images(img_array, compatible_meta), compatible_meta class PILReader(ImageReader): @@ -582,11 +602,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) - img_array.append(np.asarray(i)) + data = np.asarray(i) + img_array.append(data) + header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1 _copy_compatible_dict(header, compatible_meta) - img_array_ = np.stack(img_array, axis=0) if len(img_array) > 1 else img_array[0] - return img_array_, compatible_meta + return _stack_images(img_array, compatible_meta), compatible_meta def _get_meta_dict(self, img) -> Dict: """ @@ -608,4 +629,161 @@ def _get_spatial_shape(self, img) -> np.ndarray: Args: img: a PIL Image object loaded from a image file. """ + # the img data should have no channel dim or the last dim is channel return np.asarray((img.width, img.height)) + + +class WSIReader(ImageReader): + """ + Read whole slide imaging and extract patches. + + Args: + reader_lib: backend library to load the images, available options: "OpenSlide" or "cuCIM". + + """ + + def __init__(self, reader_lib: str = "OpenSlide"): + super().__init__() + self.reader_lib = reader_lib.lower() + if self.reader_lib == "openslide": + if has_osl: + self.wsi_reader = openslide.OpenSlide + elif self.reader_lib == "cucim": + if has_cim: + self.wsi_reader = cucim.CuImage + else: + raise ValueError('`reader_lib` should be either "cuCIM" or "OpenSlide"') + + def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: + """ + Verify whether the specified file or files format is supported by WSI reader. + + Args: + filename: file name or a list of file names to read. + if a list of files, verify all the suffixes. + """ + return is_supported_format(filename, ["tif", "tiff"]) + + def read(self, data: Union[Sequence[str], str, np.ndarray], **kwargs): + """ + Read image data from specified file or files. + Note that the returned object is CuImage or list of CuImage objects. + + Args: + data: file name or a list of file names to read. + + """ + if (self.reader_lib == "openslide") and (not has_osl): + raise ImportError("No module named 'openslide'") + elif (self.reader_lib == "cucim") and (not has_cim): + raise ImportError("No module named 'cucim'") + + img_: List = [] + + filenames: Sequence[str] = ensure_tuple(data) + for name in filenames: + img = self.wsi_reader(name) + if self.reader_lib == "openslide": + img.shape = (img.dimensions[1], img.dimensions[0], 3) + img_.append(img) + + return img_ if len(filenames) > 1 else img_[0] + + def get_data( + self, + img, + location: Tuple[int, int] = (0, 0), + size: Optional[Tuple[int, int]] = None, + level: int = 0, + dtype: DtypeLike = np.uint8, + grid_shape: Tuple[int, int] = (1, 1), + patch_size: Optional[int] = None, + ): + """ + Extract regions as numpy array from WSI image and return them. + + Args: + img: a WSIReader image object loaded from a file, or list of CuImage objects + location: (x_min, y_min) tuple giving the top left pixel in the level 0 reference frame, + or list of tuples (default=(0, 0)) + size: (height, width) tuple giving the region size, or list of tuples (default to full image size) + This is the size of image at the given level (`level`) + level: the level number, or list of level numbers (default=0) + dtype: the data type of output image + grid_shape: (row, columns) tuple define a grid to extract patches on that + patch_size: (heigsht, width) the size of extracted patches at the given level + """ + if size is None: + if location == (0, 0): + # the maximum size is set to WxH + size = (img.shape[0] // (2 ** level), img.shape[1] // (2 ** level)) + print(f"Reading the whole image at level={level} with shape={size}") + else: + raise ValueError("Size need to be provided to extract the region!") + + region = self._extract_region(img, location=location, size=size, level=level, dtype=dtype) + + metadata: Dict = {} + metadata["spatial_shape"] = size + metadata["original_channel_dim"] = -1 + region = EnsureChannelFirst()(region, metadata) + + if patch_size is None: + patches = region + else: + patches = self._extract_patches( + region, patch_size=(patch_size, patch_size), grid_shape=grid_shape, dtype=dtype + ) + + return patches, metadata + + def _extract_region( + self, + img_obj, + size: Tuple[int, int], + location: Tuple[int, int] = (0, 0), + level: int = 0, + dtype: DtypeLike = np.uint8, + ): + # reverse the order of dimensions for size and location to be compatible with image shape + size = size[::-1] + location = location[::-1] + region = img_obj.read_region(location=location, size=size, level=level) + if self.reader_lib == "openslide": + region = region.convert("RGB") + # convert to numpy + region = np.asarray(region, dtype=dtype) + + return region + + def _extract_patches( + self, + region: np.ndarray, + grid_shape: Tuple[int, int] = (1, 1), + patch_size: Optional[Tuple[int, int]] = None, + dtype: DtypeLike = np.uint8, + ): + if patch_size is None and grid_shape == (1, 1): + return region + + n_patches = grid_shape[0] * grid_shape[1] + region_size = region.shape[1:] + + if patch_size is None: + patch_size = (region_size[0] // grid_shape[0], region_size[1] // grid_shape[1]) + + # split the region into patches on the grid and center crop them to patch size + flat_patch_grid = np.zeros((n_patches, 3, patch_size[0], patch_size[1]), dtype=dtype) + start_points = [ + np.round(region_size[i] * (0.5 + np.arange(grid_shape[i])) / grid_shape[i] - patch_size[i] / 2).astype(int) + for i in range(2) + ] + idx = 0 + for y_start in start_points[1]: + for x_start in start_points[0]: + x_end = x_start + patch_size[0] + y_end = y_start + patch_size[1] + flat_patch_grid[idx] = region[:, x_start:x_end, y_start:y_end] + idx += 1 + + return flat_patch_grid diff --git a/monai/data/inverse_batch_transform.py b/monai/data/inverse_batch_transform.py new file mode 100644 index 0000000000..fbc42c6ce1 --- /dev/null +++ b/monai/data/inverse_batch_transform.py @@ -0,0 +1,84 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Callable, Dict, Hashable, Optional, Sequence + +import numpy as np +from torch.utils.data.dataloader import DataLoader as TorchDataLoader + +from monai.data.dataloader import DataLoader +from monai.data.dataset import Dataset +from monai.data.utils import decollate_batch, pad_list_data_collate +from monai.transforms.croppad.batch import PadListDataCollate +from monai.transforms.inverse import InvertibleTransform +from monai.transforms.transform import Transform +from monai.utils import first + +__all__ = ["BatchInverseTransform"] + + +class _BatchInverseDataset(Dataset): + def __init__( + self, + data: Sequence[Any], + transform: InvertibleTransform, + pad_collation_used: bool, + ) -> None: + super().__init__(data, transform) + self.invertible_transform = transform + self.pad_collation_used = pad_collation_used + + def __getitem__(self, index: int) -> Dict[Hashable, np.ndarray]: + data = dict(self.data[index]) + # If pad collation was used, then we need to undo this first + if self.pad_collation_used: + data = PadListDataCollate.inverse(data) + + return self.invertible_transform.inverse(data) + + +def no_collation(x): + return x + + +class BatchInverseTransform(Transform): + """Perform inverse on a batch of data. This is useful if you have inferred a batch of images and want to invert them all.""" + + def __init__( + self, transform: InvertibleTransform, loader: TorchDataLoader, collate_fn: Optional[Callable] = no_collation + ) -> None: + """ + Args: + transform: a callable data transform on input data. + loader: data loader used to generate the batch of data. + collate_fn: how to collate data after inverse transformations. Default won't do any collation, so the output will be a + list of size batch size. + """ + self.transform = transform + self.batch_size = loader.batch_size + self.num_workers = loader.num_workers + self.collate_fn = collate_fn + self.pad_collation_used = loader.collate_fn == pad_list_data_collate + + def __call__(self, data: Dict[str, Any]) -> Any: + + decollated_data = decollate_batch(data) + inv_ds = _BatchInverseDataset(decollated_data, self.transform, self.pad_collation_used) + inv_loader = DataLoader( + inv_ds, batch_size=self.batch_size, num_workers=self.num_workers, collate_fn=self.collate_fn + ) + try: + return first(inv_loader) + except RuntimeError as re: + re_str = str(re) + if "equal size" in re_str: + re_str += "\nMONAI hint: try creating `BatchInverseTransform` with `collate_fn=lambda x: x`." + raise RuntimeError(re_str) diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index 01e701b1a6..016b06fda5 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -27,6 +27,8 @@ class NiftiSaver: Typically, the data can be segmentation predictions, call `save` for single data or call `save_batch` to save a batch of data together. If no meta data provided, use index from 0 as the filename prefix. + + NB: image should include channel dimension: [B],C,H,W,[D]. """ def __init__( @@ -40,6 +42,7 @@ def __init__( align_corners: bool = False, dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, + squeeze_end_dims: bool = True, ) -> None: """ Args: @@ -60,6 +63,10 @@ def __init__( dtype: data type for resampling computation. Defaults to ``np.float64`` for best precision. If None, use the data type of input data. output_dtype: data type for saving data. Defaults to ``np.float32``. + squeeze_end_dims: if True, any trailing singleton dimensions will be removed (after the channel + has been moved to the end). So if input is (C,H,W,D), this will be altered to (H,W,D,C), and + then if C==1, it will be saved as (H,W,D). If D also ==1, it will be saved as (H,W). If false, + image will always be saved as (H,W,D,C). """ self.output_dir = output_dir self.output_postfix = output_postfix @@ -71,6 +78,7 @@ def __init__( self.dtype = dtype self.output_dtype = output_dtype self._data_index = 0 + self.squeeze_end_dims = squeeze_end_dims def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] = None) -> None: """ @@ -111,6 +119,12 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] data = np.expand_dims(data, -1) # change data to "channel last" format and write to nifti format file data = np.moveaxis(np.asarray(data), 0, -1) + + # if desired, remove trailing singleton dimensions + if self.squeeze_end_dims: + while data.shape[-1] == 1: + data = np.squeeze(data, -1) + write_nifti( data, file_name=filename, diff --git a/monai/data/samplers.py b/monai/data/samplers.py new file mode 100644 index 0000000000..8bba79c9b0 --- /dev/null +++ b/monai/data/samplers.py @@ -0,0 +1,119 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional, Sequence + +import torch +from torch.utils.data import Dataset +from torch.utils.data import DistributedSampler as _TorchDistributedSampler + +__all__ = ["DistributedSampler", "DistributedWeightedRandomSampler"] + + +class DistributedSampler(_TorchDistributedSampler): + """ + Enhance PyTorch DistributedSampler to support non-evenly divisible sampling. + + Args: + dataset: Dataset used for sampling. + even_divisible: if False, different ranks can have different data length. + for example, input data: [1, 2, 3, 4, 5], rank 0: [1, 3, 5], rank 1: [2, 4]. + num_replicas: number of processes participating in distributed training. + by default, `world_size` is retrieved from the current distributed group. + rank: rank of the current process within `num_replicas`. by default, + `rank` is retrieved from the current distributed group. + shuffle: if `True`, sampler will shuffle the indices, default to True. + kwargs: additional arguments for `DistributedSampler` super class, can be `seed` and `drop_last`. + + More information about DistributedSampler, please check: + https://github.com/pytorch/pytorch/blob/master/torch/utils/data/distributed.py + + """ + + def __init__( + self, + dataset: Dataset, + even_divisible: bool = True, + num_replicas: Optional[int] = None, + rank: Optional[int] = None, + shuffle: bool = True, + **kwargs, + ): + super().__init__(dataset=dataset, num_replicas=num_replicas, rank=rank, shuffle=shuffle, **kwargs) + + if not even_divisible: + data_len = len(dataset) # type: ignore + extra_size = self.total_size - data_len + if self.rank + extra_size >= self.num_replicas: + self.num_samples -= 1 + self.total_size = data_len + + +class DistributedWeightedRandomSampler(DistributedSampler): + """ + Extend the `DistributedSampler` to support weighted sampling. + Refer to `torch.utils.data.WeightedRandomSampler`, for more details please check: + https://github.com/pytorch/pytorch/blob/master/torch/utils/data/sampler.py#L150 + + Args: + dataset: Dataset used for sampling. + weights: a sequence of weights, not necessary summing up to one, length should exactly + match the full dataset. + num_samples_per_rank: number of samples to draw for every rank, sample from + the distributed subset of dataset. + if None, default to the length of dataset split by DistributedSampler. + generator: PyTorch Generator used in sampling. + even_divisible: if False, different ranks can have different data length. + for example, input data: [1, 2, 3, 4, 5], rank 0: [1, 3, 5], rank 1: [2, 4].' + num_replicas: number of processes participating in distributed training. + by default, `world_size` is retrieved from the current distributed group. + rank: rank of the current process within `num_replicas`. by default, + `rank` is retrieved from the current distributed group. + shuffle: if `True`, sampler will shuffle the indices, default to True. + kwargs: additional arguments for `DistributedSampler` super class, can be `seed` and `drop_last`. + + """ + + def __init__( + self, + dataset: Dataset, + weights: Sequence[float], + num_samples_per_rank: Optional[int] = None, + generator: Optional[torch.Generator] = None, + even_divisible: bool = True, + num_replicas: Optional[int] = None, + rank: Optional[int] = None, + shuffle: bool = True, + **kwargs, + ): + super().__init__( + dataset=dataset, + even_divisible=even_divisible, + num_replicas=num_replicas, + rank=rank, + shuffle=shuffle, + **kwargs, + ) + self.weights = weights + self.num_samples_per_rank = num_samples_per_rank if num_samples_per_rank is not None else self.num_samples + self.generator = generator + + def __iter__(self): + indices = list(super().__iter__()) + weights = torch.as_tensor([self.weights[i] for i in indices], dtype=torch.double) + # sample based on the provided weights + rand_tensor = torch.multinomial(weights, self.num_samples_per_rank, True, generator=self.generator) + + for i in rand_tensor: + yield indices[i] + + def __len__(self): + return self.num_samples_per_rank diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index 252fdd6a21..da5f864900 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -13,6 +13,8 @@ from queue import Empty, Full, Queue from threading import Thread +from monai.data import DataLoader, Dataset + class ThreadBuffer: """ @@ -73,3 +75,20 @@ def __iter__(self): pass # queue was empty this time, try again finally: self.stop() # ensure thread completion + + +class ThreadDataLoader(DataLoader): + """ + Subclass of `DataLoader` using a `ThreadBuffer` object to implement `__iter__` method asynchronously. This will + iterate over data from the loader as expected however the data is generated on a separate thread. Use this class + where a `DataLoader` instance is required and not just an iterable object. + """ + + def __init__(self, dataset: Dataset, num_workers: int = 0, **kwargs): + super().__init__(dataset, num_workers, **kwargs) + + # ThreadBuffer will use the inherited __iter__ instead of the one defined below + self.buffer = ThreadBuffer(super()) + + def __iter__(self): + yield from self.buffer diff --git a/monai/data/utils.py b/monai/data/utils.py index acc6d2e97a..bdbfa5c636 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -18,11 +18,10 @@ from collections import defaultdict from itertools import product, starmap from pathlib import PurePath -from typing import Dict, Generator, Iterable, List, Optional, Sequence, Tuple, Union +from typing import Any, Dict, Generator, Iterable, List, Optional, Sequence, Tuple, Union import numpy as np import torch -from torch.utils.data import DistributedSampler as _TorchDistributedSampler from torch.utils.data._utils.collate import default_collate from monai.networks.layers.simplelayers import GaussianFilter @@ -36,6 +35,8 @@ first, optional_import, ) +from monai.utils.enums import Method +from monai.utils.misc import issequenceiterable nib, _ = optional_import("nibabel") @@ -59,10 +60,11 @@ "partition_dataset", "partition_dataset_classes", "select_cross_validation_folds", - "DistributedSampler", "json_hashing", "pickle_hashing", "sorted_dict", + "decollate_batch", + "pad_list_data_collate", ] @@ -170,7 +172,7 @@ def iter_patch( copy_back: bool = True, mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, **pad_opts: Dict, -) -> Generator[np.ndarray, None, None]: +): """ Yield successive patches from `arr` of size `patch_size`. The iteration can start from position `start_pos` in `arr` but drawing from a padded array extended by the `patch_size` in each dimension (so these coordinates can be negative @@ -190,6 +192,15 @@ def iter_patch( Yields: Patches of array data from `arr` which are views into a padded array which can be modified, if `copy_back` is True these changes will be reflected in `arr` once the iteration completes. + + Note: + coordinate format is: + + [1st_dim_start, 1st_dim_end, + 2nd_dim_start, 2nd_dim_end, + ..., + Nth_dim_start, Nth_dim_end]] + """ # ensure patchSize and startPos are the right length patch_size_ = get_valid_patch_size(arr.shape, patch_size) @@ -206,7 +217,9 @@ def iter_patch( iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded): - yield arrpad[slices] + # compensate original image padding + coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + yield arrpad[slices], np.asarray(coords_no_pad) # data and coords (in numpy; works with torch loader) # copy back data from the padded image if required if copy_back: @@ -240,7 +253,109 @@ def list_data_collate(batch: Sequence): """ elem = batch[0] data = [i for k in batch for i in k] if isinstance(elem, list) else batch - return default_collate(data) + try: + return default_collate(data) + except RuntimeError as re: + re_str = str(re) + if "equal size" in re_str: + re_str += ( + "\nMONAI hint: if your transforms intentionally create images of different shapes, creating your " + + "`DataLoader` with `collate_fn=pad_list_data_collate` might solve this problem (check its " + + "documentation)." + ) + raise RuntimeError(re_str) + + +def decollate_batch(data: dict, batch_size: Optional[int] = None) -> List[dict]: + """De-collate a batch of data (for example, as produced by a `DataLoader`). + + Returns a list of dictionaries. Each dictionary will only contain the data for a given batch. + + Images originally stored as (B,C,H,W,[D]) will be returned as (C,H,W,[D]). Other information, + such as metadata, may have been stored in a list (or a list inside nested dictionaries). In + this case we return the element of the list corresponding to the batch idx. + + Return types aren't guaranteed to be the same as the original, since numpy arrays will have been + converted to torch.Tensor, and tuples/lists may have been converted to lists of tensors + + For example: + + .. code-block:: python + + batch_data = { + "image": torch.rand((2,1,10,10)), + "image_meta_dict": {"scl_slope": torch.Tensor([0.0, 0.0])} + } + out = decollate_batch(batch_data) + print(len(out)) + >>> 2 + + print(out[0]) + >>> {'image': tensor([[[4.3549e-01...43e-01]]]), 'image_meta_dict': {'scl_slope': 0.0}} + + Args: + data: data to be de-collated. + batch_size: number of batches in data. If `None` is passed, try to figure out batch size. + """ + if not isinstance(data, dict): + raise RuntimeError("Only currently implemented for dictionary data (might be trivial to adapt).") + if batch_size is None: + for v in data.values(): + if isinstance(v, torch.Tensor): + batch_size = v.shape[0] + break + if batch_size is None: + raise RuntimeError("Couldn't determine batch size, please specify as argument.") + + def torch_to_single(d: torch.Tensor): + """If input is a torch.Tensor with only 1 element, return just the element.""" + return d if d.numel() > 1 else d.item() + + def decollate(data: Any, idx: int): + """Recursively de-collate.""" + if isinstance(data, dict): + return {k: decollate(v, idx) for k, v in data.items()} + if isinstance(data, torch.Tensor): + out = data[idx] + return torch_to_single(out) + elif isinstance(data, list): + if len(data) == 0: + return data + if isinstance(data[0], torch.Tensor): + return [torch_to_single(d[idx]) for d in data] + if issequenceiterable(data[0]): + return [decollate(d, idx) for d in data] + return data[idx] + raise TypeError(f"Not sure how to de-collate type: {type(data)}") + + return [{key: decollate(data[key], idx) for key in data.keys()} for idx in range(batch_size)] + + +def pad_list_data_collate( + batch: Sequence, + method: Union[Method, str] = Method.SYMMETRIC, + mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, +): + """ + Function version of :py:class:`monai.transforms.croppad.batch.PadListDataCollate`. + + Same as MONAI's ``list_data_collate``, except any tensors are centrally padded to match the shape of the biggest + tensor in each dimension. This transform is useful if some of the applied transforms generate batch data of + different sizes. + + This can be used on both list and dictionary data. In the case of the dictionary data, this transform will be added + to the list of invertible transforms. + + The inverse can be called using the static method: `monai.transforms.croppad.batch.PadListDataCollate.inverse`. + + Args: + batch: batch of data to pad-collate + method: padding method (see :py:class:`monai.transforms.SpatialPad`) + mode: padding mode (see :py:class:`monai.transforms.SpatialPad`) + """ + from monai.transforms.croppad.batch import PadListDataCollate # needs to be here to avoid circular import + + return PadListDataCollate(method, mode)(batch) def worker_init_fn(worker_id: int) -> None: @@ -266,6 +381,8 @@ def set_rnd(obj, seed: int) -> int: obj.set_random_state(seed=seed % MAX_SEED) return seed + 1 # a different seed for the next component for key in obj.__dict__: + if key.startswith("__"): # skip the private methods + continue seed = set_rnd(obj.__dict__[key], seed=seed) return seed @@ -763,34 +880,6 @@ def select_cross_validation_folds(partitions: Sequence[Iterable], folds: Union[S return [data_item for fold_id in ensure_tuple(folds) for data_item in partitions[fold_id]] -class DistributedSampler(_TorchDistributedSampler): - """ - Enhance PyTorch DistributedSampler to support non-evenly divisible sampling. - - Args: - even_divisible: if False, different ranks can have different data length. - for example, input data: [1, 2, 3, 4, 5], rank 0: [1, 3, 5], rank 1: [2, 4]. - - More information about DistributedSampler, please check: - https://github.com/pytorch/pytorch/blob/master/torch/utils/data/distributed.py - - """ - - def __init__(self, even_divisible: bool = True, *args, **kwargs): - self.total_size: int = 0 - self.rank: int = 0 - self.num_samples: int = 0 - self.num_replicas: int = 0 - super().__init__(*args, **kwargs) - - if not even_divisible: - data_len = len(kwargs["dataset"]) - extra_size = self.total_size - data_len - if self.rank + extra_size >= self.num_replicas: - self.num_samples -= 1 - self.total_size = data_len - - def json_hashing(item) -> bytes: """ diff --git a/monai/engines/__init__.py b/monai/engines/__init__.py index 8256680735..d3a14f6104 100644 --- a/monai/engines/__init__.py +++ b/monai/engines/__init__.py @@ -12,4 +12,4 @@ from .evaluator import EnsembleEvaluator, Evaluator, SupervisedEvaluator from .multi_gpu_supervised_trainer import create_multigpu_supervised_evaluator, create_multigpu_supervised_trainer from .trainer import GanTrainer, SupervisedTrainer, Trainer -from .utils import CommonKeys, GanKeys, IterationEvents, default_make_latent, default_prepare_batch, get_devices_spec +from .utils import GanKeys, IterationEvents, default_make_latent, default_prepare_batch, get_devices_spec diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index 0b7167fb3a..2c237f5245 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -9,25 +9,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence, Tuple +from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union import torch from torch.utils.data import DataLoader -from monai.engines.utils import CommonKeys as Keys from monai.engines.utils import IterationEvents, default_prepare_batch from monai.engines.workflow import Workflow from monai.inferers import Inferer, SimpleInferer from monai.networks.utils import eval_mode from monai.transforms import Transform from monai.utils import ensure_tuple, exact_version, optional_import +from monai.utils.enums import CommonKeys as Keys if TYPE_CHECKING: from ignite.engine import Engine from ignite.metrics import Metric else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") - Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") __all__ = ["Evaluator", "SupervisedEvaluator", "EnsembleEvaluator"] @@ -38,7 +38,7 @@ class Evaluator(Workflow): Args: device: an object representing the device on which to run. - val_data_loader: Ignite engine use data_loader to run, must be torch.DataLoader. + val_data_loader: Ignite engine use data_loader to run, must be Iterable or torch.DataLoader. epoch_length: number of iterations for one epoch, default to `len(val_data_loader)`. 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. @@ -60,7 +60,7 @@ class Evaluator(Workflow): def __init__( self, device: torch.device, - val_data_loader: DataLoader, + val_data_loader: Union[Iterable, DataLoader], epoch_length: Optional[int] = None, non_blocking: bool = False, prepare_batch: Callable = default_prepare_batch, @@ -110,7 +110,7 @@ class SupervisedEvaluator(Evaluator): Args: device: an object representing the device on which to run. - val_data_loader: Ignite engine use data_loader to run, must be torch.DataLoader. + val_data_loader: Ignite engine use data_loader to run, must be Iterable, typically be torch.DataLoader. network: use the network to run model forward. epoch_length: number of iterations for one epoch, default to `len(val_data_loader)`. non_blocking: if True and this copy is between CPU and GPU, the copy may occur asynchronously @@ -134,7 +134,7 @@ class SupervisedEvaluator(Evaluator): def __init__( self, device: torch.device, - val_data_loader: DataLoader, + val_data_loader: Union[Iterable, DataLoader], network: torch.nn.Module, epoch_length: Optional[int] = None, non_blocking: bool = False, @@ -215,7 +215,7 @@ class EnsembleEvaluator(Evaluator): Args: device: an object representing the device on which to run. - val_data_loader: Ignite engine use data_loader to run, must be torch.DataLoader. + val_data_loader: Ignite engine use data_loader to run, must be Iterable, typically be torch.DataLoader. epoch_length: number of iterations for one epoch, default to `len(val_data_loader)`. networks: use the networks to run model forward in order. pred_keys: the keys to store every prediction data. @@ -241,7 +241,7 @@ class EnsembleEvaluator(Evaluator): def __init__( self, device: torch.device, - val_data_loader: DataLoader, + val_data_loader: Union[Iterable, DataLoader], networks: Sequence[torch.nn.Module], pred_keys: Sequence[str], epoch_length: Optional[int] = None, diff --git a/monai/engines/multi_gpu_supervised_trainer.py b/monai/engines/multi_gpu_supervised_trainer.py index d12e012a56..d0e09443fa 100644 --- a/monai/engines/multi_gpu_supervised_trainer.py +++ b/monai/engines/multi_gpu_supervised_trainer.py @@ -19,15 +19,15 @@ from monai.engines.utils import get_devices_spec from monai.utils import exact_version, optional_import -create_supervised_trainer, _ = optional_import("ignite.engine", "0.4.2", exact_version, "create_supervised_trainer") -create_supervised_evaluator, _ = optional_import("ignite.engine", "0.4.2", exact_version, "create_supervised_evaluator") -_prepare_batch, _ = optional_import("ignite.engine", "0.4.2", exact_version, "_prepare_batch") +create_supervised_trainer, _ = optional_import("ignite.engine", "0.4.4", exact_version, "create_supervised_trainer") +create_supervised_evaluator, _ = optional_import("ignite.engine", "0.4.4", exact_version, "create_supervised_evaluator") +_prepare_batch, _ = optional_import("ignite.engine", "0.4.4", exact_version, "_prepare_batch") if TYPE_CHECKING: from ignite.engine import Engine from ignite.metrics import Metric else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") - Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") __all__ = [ "create_multigpu_supervised_trainer", diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index efb2ab12fa..a7b1943211 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -9,25 +9,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union import torch from torch.optim.optimizer import Optimizer from torch.utils.data import DataLoader -from monai.engines.utils import CommonKeys as Keys from monai.engines.utils import GanKeys, IterationEvents, default_make_latent, default_prepare_batch from monai.engines.workflow import Workflow from monai.inferers import Inferer, SimpleInferer from monai.transforms import Transform from monai.utils import exact_version, optional_import +from monai.utils.enums import CommonKeys as Keys if TYPE_CHECKING: from ignite.engine import Engine from ignite.metrics import Metric else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") - Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") __all__ = ["Trainer", "SupervisedTrainer", "GanTrainer"] @@ -58,7 +58,7 @@ class SupervisedTrainer(Trainer): Args: device: an object representing the device on which to run. max_epochs: the total epoch number for trainer to run. - train_data_loader: Ignite engine use data_loader to run, must be torch.DataLoader. + train_data_loader: Ignite engine use data_loader to run, must be Iterable or torch.DataLoader. network: to train with this network. optimizer: the optimizer associated to the network. loss_function: the loss function associated to the optimizer. @@ -85,7 +85,7 @@ def __init__( self, device: torch.device, max_epochs: int, - train_data_loader: DataLoader, + train_data_loader: Union[Iterable, DataLoader], network: torch.nn.Module, optimizer: Optimizer, loss_function: Callable, @@ -251,6 +251,9 @@ def __init__( additional_metrics: Optional[Dict[str, Metric]] = None, train_handlers: Optional[Sequence] = None, ): + if not isinstance(train_data_loader, DataLoader): + raise ValueError("train_data_loader must be PyTorch DataLoader.") + # set up Ignite engine and environments super().__init__( device=device, @@ -296,7 +299,7 @@ def _iteration( raise ValueError("must provide batch data for current iteration.") d_input = self.prepare_batch(batchdata, engine.state.device, engine.non_blocking) - batch_size = self.data_loader.batch_size + batch_size = self.data_loader.batch_size # type: ignore g_input = self.g_prepare_batch(batch_size, self.latent_shape, engine.state.device, engine.non_blocking) g_output = self.g_inferer(g_input, self.g_network) diff --git a/monai/engines/utils.py b/monai/engines/utils.py index 8f5899f2a5..04237d0f4a 100644 --- a/monai/engines/utils.py +++ b/monai/engines/utils.py @@ -14,15 +14,15 @@ import torch from monai.utils import exact_version, optional_import +from monai.utils.enums import CommonKeys if TYPE_CHECKING: from ignite.engine import EventEnum else: - EventEnum, _ = optional_import("ignite.engine", "0.4.2", exact_version, "EventEnum") + EventEnum, _ = optional_import("ignite.engine", "0.4.4", exact_version, "EventEnum") __all__ = [ "IterationEvents", - "CommonKeys", "GanKeys", "get_devices_spec", "default_prepare_batch", @@ -47,23 +47,6 @@ class IterationEvents(EventEnum): OPTIMIZER_COMPLETED = "optimizer_completed" -class CommonKeys: - """ - A set of common keys for dictionary based supervised training process. - `IMAGE` is the input image data. - `LABEL` is the training or evaluation label of segmentation or classification task. - `PRED` is the prediction data of model output. - `LOSS` is the loss value of current iteration. - `INFO` is some useful information during training or evaluation, like loss value, etc. - - """ - - IMAGE = "image" - LABEL = "label" - PRED = "pred" - LOSS = "loss" - - class GanKeys: """ A set of common keys for generative adversarial networks. diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index d6415c1966..61b92ac5dd 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence +from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Union import torch import torch.distributed as dist @@ -20,15 +20,15 @@ from monai.transforms import apply_transform from monai.utils import ensure_tuple, exact_version, optional_import -IgniteEngine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") -State, _ = optional_import("ignite.engine", "0.4.2", exact_version, "State") -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +IgniteEngine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") +State, _ = optional_import("ignite.engine", "0.4.4", exact_version, "State") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine from ignite.metrics import Metric else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") - Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optional_import @@ -44,7 +44,7 @@ class Workflow(IgniteEngine): # type: ignore[valid-type, misc] # due to optiona Args: device: an object representing the device on which to run. max_epochs: the total epoch number for engine to run, validator and evaluator have only 1 epoch. - data_loader: Ignite engine use data_loader to run, must be torch.DataLoader. + data_loader: Ignite engine use data_loader to run, must be Iterable or torch.DataLoader. epoch_length: number of iterations for one epoch, default to `len(data_loader)`. 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. @@ -73,7 +73,7 @@ def __init__( self, device: torch.device, max_epochs: int, - data_loader: DataLoader, + data_loader: Union[Iterable, DataLoader], epoch_length: Optional[int] = None, non_blocking: bool = False, prepare_batch: Callable = default_prepare_batch, @@ -90,14 +90,20 @@ def __init__( super().__init__(self._iteration) if not isinstance(device, torch.device): raise TypeError(f"device must be a torch.device but is {type(device).__name__}.") - if not isinstance(data_loader, DataLoader): - raise TypeError(f"data_loader must be a torch.utils.data.DataLoader but is {type(data_loader).__name__}.") - sampler = data_loader.__dict__["sampler"] - if isinstance(sampler, DistributedSampler): - @self.on(Events.EPOCH_STARTED) - def set_sampler_epoch(engine: Engine): - sampler.set_epoch(engine.state.epoch) + if isinstance(data_loader, DataLoader): + sampler = data_loader.__dict__["sampler"] + if isinstance(sampler, DistributedSampler): + + @self.on(Events.EPOCH_STARTED) + def set_sampler_epoch(engine: Engine): + sampler.set_epoch(engine.state.epoch) + + if epoch_length is None: + 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.") # set all sharable data for the workflow based on Ignite engine.state self.state = State( @@ -106,7 +112,7 @@ def set_sampler_epoch(engine: Engine): iteration=0, epoch=0, max_epochs=max_epochs, - epoch_length=len(data_loader) if epoch_length is None else epoch_length, + epoch_length=epoch_length, output=None, batch=None, metrics={}, diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 8f73f7f2fd..5669e8a9ee 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -17,7 +17,7 @@ from .iteration_metric import IterationMetric from .lr_schedule_handler import LrScheduleHandler from .mean_dice import MeanDice -from .metric_logger import MetricLogger +from .metric_logger import MetricLogger, MetricLoggerKeys from .metrics_saver import MetricsSaver from .roc_auc import ROCAUC from .segmentation_saver import SegmentationSaver diff --git a/monai/handlers/checkpoint_loader.py b/monai/handlers/checkpoint_loader.py index 648cc8360a..bb67428bef 100644 --- a/monai/handlers/checkpoint_loader.py +++ b/monai/handlers/checkpoint_loader.py @@ -16,12 +16,12 @@ from monai.utils import exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") -Checkpoint, _ = optional_import("ignite.handlers", "0.4.2", exact_version, "Checkpoint") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") +Checkpoint, _ = optional_import("ignite.handlers", "0.4.4", exact_version, "Checkpoint") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class CheckpointLoader: @@ -80,5 +80,16 @@ def __call__(self, engine: Engine) -> None: """ checkpoint = torch.load(self.load_path, map_location=self.map_location) + # save current max epochs setting in the engine, don't overwrite it if larger than max_epochs in checkpoint + prior_max_epochs = engine.state.max_epochs Checkpoint.load_objects(to_load=self.load_dict, checkpoint=checkpoint) + if engine.state.epoch > prior_max_epochs: + raise ValueError( + f"Epoch count ({engine.state.epoch}) in checkpoint is larger than " + f"the `engine.state.max_epochs` ({prior_max_epochs}) of engine. To further train from checkpoint, " + "construct trainer with `max_epochs` larger than checkpoint's epoch count. " + "To use checkpoint for inference, no need to load state_dict for the engine." + ) + engine.state.max_epochs = prior_max_epochs + self.logger.info(f"Restored all variables from {self.load_path}") diff --git a/monai/handlers/checkpoint_saver.py b/monai/handlers/checkpoint_saver.py index 1808e6b251..9c67992b36 100644 --- a/monai/handlers/checkpoint_saver.py +++ b/monai/handlers/checkpoint_saver.py @@ -15,16 +15,16 @@ from monai.utils import exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") -Checkpoint, _ = optional_import("ignite.handlers", "0.4.2", exact_version, "Checkpoint") -BaseSaveHandler, _ = optional_import("ignite.handlers.checkpoint", "0.4.2", exact_version, "BaseSaveHandler") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") +Checkpoint, _ = optional_import("ignite.handlers", "0.4.4", exact_version, "Checkpoint") +BaseSaveHandler, _ = optional_import("ignite.handlers.checkpoint", "0.4.4", exact_version, "BaseSaveHandler") if TYPE_CHECKING: from ignite.engine import Engine from ignite.handlers import DiskSaver else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") - DiskSaver, _ = optional_import("ignite.handlers", "0.4.2", exact_version, "DiskSaver") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + DiskSaver, _ = optional_import("ignite.handlers", "0.4.4", exact_version, "DiskSaver") class CheckpointSaver: @@ -60,6 +60,8 @@ class CheckpointSaver: if `True`, then will save an object in the checkpoint file with key `checkpointer` to be consistent with ignite: https://github.com/pytorch/ignite/blob/master/ignite/handlers/checkpoint.py#L99. typically, it's used to resume training and compare current metric with previous N values. + key_metric_greater_or_equal: if `True`, the latest equally scored model is stored. Otherwise, + save the the first equally scored model. default to `False`. epoch_level: save checkpoint during training for every N epochs or every N iterations. `True` is epoch level, `False` is iteration level. save_interval: save checkpoint every N epochs, default is 0 to save no checkpoint. @@ -90,6 +92,7 @@ def __init__( key_metric_n_saved: int = 1, key_metric_filename: Optional[str] = None, key_metric_save_state: bool = False, + key_metric_greater_or_equal: bool = False, epoch_level: bool = True, save_interval: int = 0, n_saved: Optional[int] = None, @@ -163,6 +166,7 @@ def _score_func(engine: Engine): score_name="key_metric", n_saved=key_metric_n_saved, include_self=key_metric_save_state, + greater_or_equal=key_metric_greater_or_equal, ) if save_interval > 0: diff --git a/monai/handlers/classification_saver.py b/monai/handlers/classification_saver.py index 33ce7c7ec8..98f917330f 100644 --- a/monai/handlers/classification_saver.py +++ b/monai/handlers/classification_saver.py @@ -17,12 +17,12 @@ from monai.utils import ImageMetaKey as Key from monai.utils import exact_version, optional_import -idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +idist, _ = optional_import("ignite", "0.4.4", exact_version, "distributed") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class ClassificationSaver: diff --git a/monai/handlers/confusion_matrix.py b/monai/handlers/confusion_matrix.py index 1741aa305a..551fd29199 100644 --- a/monai/handlers/confusion_matrix.py +++ b/monai/handlers/confusion_matrix.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Callable, Optional +from typing import Any, Callable, Union import torch @@ -28,7 +28,7 @@ def __init__( include_background: bool = True, metric_name: str = "hit_rate", output_transform: Callable = lambda x: x, - device: Optional[torch.device] = None, + device: Union[str, torch.device] = "cpu", save_details: bool = True, ) -> None: """ diff --git a/monai/handlers/hausdorff_distance.py b/monai/handlers/hausdorff_distance.py index 7ac52d642a..042a587852 100644 --- a/monai/handlers/hausdorff_distance.py +++ b/monai/handlers/hausdorff_distance.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional +from typing import Callable, Optional, Union import torch @@ -30,7 +30,7 @@ def __init__( percentile: Optional[float] = None, directed: bool = False, output_transform: Callable = lambda x: x, - device: Optional[torch.device] = None, + device: Union[str, torch.device] = "cpu", save_details: bool = True, ) -> None: """ diff --git a/monai/handlers/iteration_metric.py b/monai/handlers/iteration_metric.py index 641efad243..f49c799a21 100644 --- a/monai/handlers/iteration_metric.py +++ b/monai/handlers/iteration_metric.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Union import torch @@ -17,13 +17,13 @@ from monai.metrics import do_metric_reduction from monai.utils import MetricReduction, exact_version, optional_import -idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") -Metric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "Metric") -reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.2", exact_version, "reinit__is_reduced") +idist, _ = optional_import("ignite", "0.4.4", exact_version, "distributed") +Metric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "Metric") +reinit__is_reduced, _ = optional_import("ignite.metrics.metric", "0.4.4", exact_version, "reinit__is_reduced") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class IterationMetric(Metric): # type: ignore[valid-type, misc] # due to optional_import @@ -46,7 +46,7 @@ def __init__( self, metric_fn: Callable, output_transform: Callable = lambda x: x, - device: Optional[torch.device] = None, + device: Union[str, torch.device] = "cpu", save_details: bool = True, ) -> None: self._is_reduced: bool = False @@ -77,7 +77,7 @@ def update(self, output: Sequence[torch.Tensor]) -> None: score = self.metric_fn(y_pred, y) if isinstance(score, (tuple, list)): score = score[0] - self._scores.append(score) + self._scores.append(score.to(self._device)) def compute(self) -> Any: """ diff --git a/monai/handlers/lr_schedule_handler.py b/monai/handlers/lr_schedule_handler.py index e5593f07ff..3b300537b2 100644 --- a/monai/handlers/lr_schedule_handler.py +++ b/monai/handlers/lr_schedule_handler.py @@ -16,11 +16,11 @@ from monai.utils import ensure_tuple, exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class LrScheduleHandler: diff --git a/monai/handlers/mean_dice.py b/monai/handlers/mean_dice.py index 7decc3ab9b..6d51c534cf 100644 --- a/monai/handlers/mean_dice.py +++ b/monai/handlers/mean_dice.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional +from typing import Callable, Union import torch @@ -27,7 +27,7 @@ def __init__( self, include_background: bool = True, output_transform: Callable = lambda x: x, - device: Optional[torch.device] = None, + device: Union[str, torch.device] = "cpu", save_details: bool = True, ) -> None: """ diff --git a/monai/handlers/metric_logger.py b/monai/handlers/metric_logger.py index fdd60da57c..0cfefb715a 100644 --- a/monai/handlers/metric_logger.py +++ b/monai/handlers/metric_logger.py @@ -10,23 +10,57 @@ # limitations under the License. from collections import defaultdict -from typing import TYPE_CHECKING, Callable, DefaultDict, List +from enum import Enum +from threading import RLock +from typing import TYPE_CHECKING, Callable, DefaultDict, List, Optional from monai.utils import exact_version, optional_import +from monai.utils.enums import CommonKeys -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") + + +def _get_loss_from_output(output, loss_key: str = CommonKeys.LOSS): + return output[loss_key].item() + + +class MetricLoggerKeys(Enum): + METRICS = "Metrics" + LOSS = "Loss" class MetricLogger: - def __init__(self, loss_transform: Callable = lambda x: x, metric_transform: Callable = lambda x: x) -> None: + """ + Collect per-iteration metrics and loss value from the attached trainer. This will also collect metric values from + a given evaluator object which is expected to perform evaluation at the end of training epochs. This class is + useful for collecting loss and metric values in one place for storage with checkpoint savers (`state_dict` and + `load_state_dict` methods provided as expected by Pytorch and Ignite) and for graphing during training. + + Args: + loss_transform: Converts the `output` value from the trainer's state into a loss value + metric_transform: Converts the metric value coming from the trainer/evaluator's state into a storable value + evaluator: Optional evaluator to consume metric results from at the end of its evaluation run + """ + + def __init__( + self, + loss_transform: Callable = _get_loss_from_output, + metric_transform: Callable = lambda x: x, + evaluator: Optional[Engine] = None, + ) -> None: self.loss_transform = loss_transform self.metric_transform = metric_transform self.loss: List = [] self.metrics: DefaultDict = defaultdict(list) + self.iteration = 0 + self.lock = RLock() + + if evaluator is not None: + self.attach_evaluator(evaluator) def attach(self, engine: Engine) -> None: """ @@ -35,21 +69,46 @@ def attach(self, engine: Engine) -> None: """ engine.add_event_handler(Events.ITERATION_COMPLETED, self) + def attach_evaluator(self, evaluator: Engine) -> None: + """ + Attach event handlers to the given evaluator to log metric values from it. + + Args: + evaluator: Ignite Engine implementing network evaluation + """ + evaluator.add_event_handler(Events.COMPLETED, self.log_metrics) + def __call__(self, engine: Engine) -> None: """ Args: engine: Ignite Engine, it can be a trainer, validator or evaluator. """ - self.loss.append(self.loss_transform(engine.state.output)) + with self.lock: + self.iteration = engine.state.iteration + lossval = self.loss_transform(engine.state.output) + + self.loss.append((self.iteration, lossval)) + self.log_metrics(engine) + + def log_metrics(self, engine: Engine) -> None: + """ + Log metrics from the given Engine's state member. + + Args: + engine: Ignite Engine to log from + """ + with self.lock: + for m, v in engine.state.metrics.items(): + v = self.metric_transform(v) + self.metrics[m].append((self.iteration, v)) - for m, v in engine.state.metrics.items(): - v = self.metric_transform(v) - # # metrics may not be added on the first timestep, pad the list if this is the case - # # so that each metric list is the same length as self.loss - # if len(self.metrics[m])==0: - # self.metrics[m].append([v[0]]*len(self.loss)) + def state_dict(self): + return {MetricLoggerKeys.LOSS: self.loss, MetricLoggerKeys.METRICS: self.metrics} - self.metrics[m].append(v) + def load_state_dict(self, state_dict): + self.loss[:] = state_dict[MetricLoggerKeys.LOSS] + self.metrics.clear() + self.metrics.update(state_dict[MetricLoggerKeys.METRICS]) metriclogger = MetricLogger diff --git a/monai/handlers/metrics_saver.py b/monai/handlers/metrics_saver.py index 87d7223c96..082c370e48 100644 --- a/monai/handlers/metrics_saver.py +++ b/monai/handlers/metrics_saver.py @@ -15,12 +15,12 @@ from monai.utils import ImageMetaKey as Key from monai.utils import ensure_tuple, exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") -idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") +idist, _ = optional_import("ignite", "0.4.4", exact_version, "distributed") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class MetricsSaver: diff --git a/monai/handlers/roc_auc.py b/monai/handlers/roc_auc.py index 2273b9ee89..9a9af601f9 100644 --- a/monai/handlers/roc_auc.py +++ b/monai/handlers/roc_auc.py @@ -17,8 +17,8 @@ from monai.metrics import compute_roc_auc from monai.utils import Average, exact_version, optional_import -idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") -EpochMetric, _ = optional_import("ignite.metrics", "0.4.2", exact_version, "EpochMetric") +idist, _ = optional_import("ignite", "0.4.4", exact_version, "distributed") +EpochMetric, _ = optional_import("ignite.metrics", "0.4.4", exact_version, "EpochMetric") class ROCAUC(EpochMetric): # type: ignore[valid-type, misc] # due to optional_import @@ -61,7 +61,7 @@ def __init__( other_act: Optional[Callable] = None, average: Union[Average, str] = Average.MACRO, output_transform: Callable = lambda x: x, - device: Optional[torch.device] = None, + device: Union[str, torch.device] = "cpu", ) -> None: def _compute_fn(pred, label): return compute_roc_auc( diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index a46918b893..25238ea442 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -18,11 +18,11 @@ from monai.transforms import SaveImage from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode, exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class SegmentationSaver: @@ -41,6 +41,7 @@ def __init__( scale: Optional[int] = None, dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, + squeeze_end_dims: bool = True, batch_transform: Callable = lambda x: x, output_transform: Callable = lambda x: x, name: Optional[str] = None, @@ -77,6 +78,11 @@ def __init__( If None, use the data type of input data. It's used for Nifti format only. output_dtype: data type for saving data. Defaults to ``np.float32``, it's used for Nifti format only. + squeeze_end_dims: if True, any trailing singleton dimensions will be removed (after the channel + has been moved to the end). So if input is (C,H,W,D), this will be altered to (H,W,D,C), and + then if C==1, it will be saved as (H,W,D). If D also ==1, it will be saved as (H,W). If false, + image will always be saved as (H,W,D,C). + it's used for NIfTI format only. batch_transform: a callable that is used to transform the ignite.engine.batch into expected format to extract the meta_data dictionary. output_transform: a callable that is used to transform the @@ -96,6 +102,7 @@ def __init__( scale=scale, dtype=dtype, output_dtype=output_dtype, + squeeze_end_dims=squeeze_end_dims, save_batch=True, ) self.batch_transform = batch_transform diff --git a/monai/handlers/smartcache_handler.py b/monai/handlers/smartcache_handler.py index 423d87c22a..821f883d91 100644 --- a/monai/handlers/smartcache_handler.py +++ b/monai/handlers/smartcache_handler.py @@ -14,11 +14,11 @@ from monai.data import SmartCacheDataset from monai.utils import exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class SmartCacheHandler: diff --git a/monai/handlers/stats_handler.py b/monai/handlers/stats_handler.py index 24d844569f..6d4a4e958b 100644 --- a/monai/handlers/stats_handler.py +++ b/monai/handlers/stats_handler.py @@ -17,11 +17,11 @@ from monai.utils import exact_version, is_scalar, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") DEFAULT_KEY_VAL_FORMAT = "{}: {:.4f} " DEFAULT_TAG = "Loss" diff --git a/monai/handlers/surface_distance.py b/monai/handlers/surface_distance.py index d3fa69bfce..7c2322354a 100644 --- a/monai/handlers/surface_distance.py +++ b/monai/handlers/surface_distance.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable, Optional +from typing import Callable, Union import torch @@ -29,7 +29,7 @@ def __init__( symmetric: bool = False, distance_metric: str = "euclidean", output_transform: Callable = lambda x: x, - device: Optional[torch.device] = None, + device: Union[str, torch.device] = "cpu", save_details: bool = True, ) -> None: """ diff --git a/monai/handlers/tensorboard_handlers.py b/monai/handlers/tensorboard_handlers.py index 4ee88bcfc9..9ad1fe6353 100644 --- a/monai/handlers/tensorboard_handlers.py +++ b/monai/handlers/tensorboard_handlers.py @@ -18,12 +18,12 @@ from monai.utils import exact_version, is_scalar, optional_import from monai.visualize import plot_2d_or_3d_image -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine from torch.utils.tensorboard import SummaryWriter else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") SummaryWriter, _ = optional_import("torch.utils.tensorboard", name="SummaryWriter") DEFAULT_TAG = "Loss" diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index 3e36af0652..2eaf3ab932 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -18,11 +18,11 @@ from monai.utils import ensure_tuple, exact_version, get_torch_version_tuple, optional_import -idist, _ = optional_import("ignite", "0.4.2", exact_version, "distributed") +idist, _ = optional_import("ignite", "0.4.4", exact_version, "distributed") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") __all__ = [ "stopping_fn_from_metric", @@ -75,7 +75,7 @@ def evenly_divisible_all_gather(data: torch.Tensor) -> torch.Tensor: # make sure the data is evenly-divisible on multi-GPUs length = data.shape[0] all_lens = idist.all_gather(length) - max_len = max(all_lens).item() + max_len = max(all_lens) if length < max_len: size = [max_len - length] + list(data.shape[1:]) data = torch.cat([data, data.new_full(size, 0)], dim=0) @@ -85,27 +85,39 @@ def evenly_divisible_all_gather(data: torch.Tensor) -> torch.Tensor: return torch.cat([data[i * max_len : i * max_len + l, ...] for i, l in enumerate(all_lens)], dim=0) -def string_list_all_gather(strings: List[str], delimiter: str = "\t") -> List[str]: +def string_list_all_gather(strings: List[str]) -> List[str]: """ Utility function for distributed data parallel to all gather a list of strings. + Note that if the item in `strings` is longer than 1024 chars, it will be truncated to 1024: + https://github.com/pytorch/ignite/blob/master/ignite/distributed/comp_models/base.py#L92 Args: strings: a list of strings to all gather. - delimiter: use the delimiter to join the string list to be a long string, - then all gather across ranks and split to a list. default to "\t". """ - if idist.get_world_size() <= 1: + world_size = idist.get_world_size() + if world_size <= 1: return strings - _joined = delimiter.join(strings) + result: List[List[str]] = [[] for _ in range(world_size)] + # get length of strings + length = len(strings) + all_lens = idist.all_gather(length) + max_len = max(all_lens) + # pad the item to make sure the same length + if length < max_len: + strings = strings + ["" for _ in range(max_len - length)] + if get_torch_version_tuple() > (1, 6, 0): - # all gather across all ranks - _joined = delimiter.join(idist.all_gather(_joined)) + for s in strings: + gathered = idist.all_gather(s) + for i, g in enumerate(gathered): + if len(g) > 0: + result[i].append(g) else: raise RuntimeError("string all_gather can not be supported in PyTorch < 1.7.0.") - return _joined.split(delimiter) + return [i for k in result for i in k] def write_metrics_reports( @@ -151,7 +163,6 @@ def write_metrics_reports( with open(os.path.join(save_dir, "metrics.csv"), "w") as f: for k, v in metrics.items(): f.write(f"{k}{deli}{str(v)}\n") - if metric_details is not None and len(metric_details) > 0: for k, v in metric_details.items(): if isinstance(v, torch.Tensor): diff --git a/monai/handlers/validation_handler.py b/monai/handlers/validation_handler.py index 9cc2e926f4..4458a17380 100644 --- a/monai/handlers/validation_handler.py +++ b/monai/handlers/validation_handler.py @@ -14,11 +14,11 @@ from monai.engines.evaluator import Evaluator from monai.utils import exact_version, optional_import -Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events") +Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: from ignite.engine import Engine else: - Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine") + Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") class ValidationHandler: diff --git a/monai/losses/dice.py b/monai/losses/dice.py index c284660cc6..47a1605fdc 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -10,7 +10,7 @@ # limitations under the License. import warnings -from typing import Callable, Optional, Union +from typing import Callable, List, Optional, Union import numpy as np import torch @@ -54,7 +54,7 @@ def __init__( ) -> None: """ Args: - include_background: if False channel index 0 (background category) is excluded from the calculation. + include_background: if False, channel index 0 (background category) is excluded from the calculation. to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. sigmoid: if True, apply a sigmoid function to the prediction. softmax: if True, apply a softmax function to the prediction. @@ -101,10 +101,12 @@ def __init__( def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """ Args: - input: the shape should be BNH[WD]. - target: the shape should be BNH[WD]. + input: the shape should be BNH[WD], where N is the number of classes. + target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes. Raises: + AssertionError: When input and target (after one hot transform if setted) + have different shapes. ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"]. """ @@ -136,10 +138,10 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: input = input[:, 1:] if target.shape != input.shape: - raise AssertionError(f"ground truth has differing shape ({target.shape}) from input ({input.shape})") + raise AssertionError(f"ground truth has different shape ({target.shape}) from input ({input.shape})") # reducing only spatial dimensions (not batch nor channels) - reduce_axis = list(range(2, len(input.shape))) + reduce_axis: List[int] = torch.arange(2, len(input.shape)).tolist() if self.batch: # reducing spatial dimensions and batch reduce_axis = [0] + reduce_axis @@ -268,23 +270,27 @@ def __init__( raise TypeError(f"other_act must be None or callable but is {type(other_act).__name__}.") if int(sigmoid) + int(softmax) + int(other_act is not None) > 1: raise ValueError("Incompatible values: more than 1 of [sigmoid=True, softmax=True, other_act is not None].") + self.include_background = include_background self.to_onehot_y = to_onehot_y self.sigmoid = sigmoid self.softmax = softmax self.other_act = other_act - w_type = Weight(w_type) - self.w_func: Callable = torch.ones_like - if w_type == Weight.SIMPLE: - self.w_func = torch.reciprocal - elif w_type == Weight.SQUARE: - self.w_func = lambda x: torch.reciprocal(x * x) + self.w_type = Weight(w_type) self.smooth_nr = float(smooth_nr) self.smooth_dr = float(smooth_dr) self.batch = batch + def w_func(self, grnd): + if self.w_type == Weight.SIMPLE: + return torch.reciprocal(grnd) + elif self.w_type == Weight.SQUARE: + return torch.reciprocal(grnd * grnd) + else: + return torch.ones_like(grnd) + def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """ Args: @@ -325,7 +331,7 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: raise AssertionError(f"ground truth has differing shape ({target.shape}) from input ({input.shape})") # reducing only spatial dimensions (not batch nor channels) - reduce_axis = list(range(2, len(input.shape))) + reduce_axis: List[int] = torch.arange(2, len(input.shape)).tolist() if self.batch: reduce_axis = [0] + reduce_axis intersection = torch.sum(target * input, reduce_axis) diff --git a/monai/losses/focal_loss.py b/monai/losses/focal_loss.py index da7c63e571..664e7673a4 100644 --- a/monai/losses/focal_loss.py +++ b/monai/losses/focal_loss.py @@ -9,16 +9,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional, Union +import warnings +from typing import Optional, Sequence, Union import torch import torch.nn.functional as F -from torch.nn.modules.loss import _WeightedLoss +from torch.nn.modules.loss import _Loss +from monai.networks import one_hot from monai.utils import LossReduction -class FocalLoss(_WeightedLoss): +class FocalLoss(_Loss): """ Reimplementation of the Focal Loss described in: @@ -29,15 +31,21 @@ class FocalLoss(_WeightedLoss): def __init__( self, + include_background: bool = True, + to_onehot_y: bool = False, gamma: float = 2.0, - weight: Optional[torch.Tensor] = None, + weight: Optional[Union[Sequence[float], float, int, torch.Tensor]] = None, reduction: Union[LossReduction, str] = LossReduction.MEAN, ) -> None: """ Args: + include_background: if False, channel index 0 (background category) is excluded from the calculation. + to_onehot_y: whether to convert `y` into the one-hot format. Defaults to False. gamma: value of the exponent gamma in the definition of the Focal loss. weight: weights to apply to the voxels of each class. If None no weights are applied. This corresponds to the weights `\alpha` in [1]. + The input can be a single value (same weight for all classes), a sequence of values (the length + of the sequence should be the same as the number of classes). reduction: {``"none"``, ``"mean"``, ``"sum"``} Specifies the reduction to apply to the output. Defaults to ``"mean"``. @@ -57,80 +65,75 @@ def __init__( fl(pred, grnd) """ - super(FocalLoss, self).__init__(weight=weight, reduction=LossReduction(reduction).value) + super(FocalLoss, self).__init__(reduction=LossReduction(reduction).value) + self.include_background = include_background + self.to_onehot_y = to_onehot_y self.gamma = gamma - self.weight: Optional[torch.Tensor] = None + self.weight: Optional[Union[Sequence[float], float, int, torch.Tensor]] = weight - def forward(self, logits: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """ Args: - logits: the shape should be BCH[WD]. - where C (greater than 1) is the number of classes. - Softmax over the logits is integrated in this module for improved numerical stability. - target: the shape should be B1H[WD] or BCH[WD]. - If the target's shape is B1H[WD], the target that this loss expects should be a class index - in the range [0, C-1] where C is the number of classes. + input: the shape should be BNH[WD], where N is the number of classes. + The input should be the original logits since it will be transferred by + `F.log_softmax` in the forward function. + target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes. Raises: - ValueError: When ``target`` ndim differs from ``logits``. - ValueError: When ``target`` channel is not 1 and ``target`` shape differs from ``logits``. + AssertionError: When input and target (after one hot transform if setted) + have different shapes. ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"]. """ - i = logits + n_pred_ch = input.shape[1] + + if self.to_onehot_y: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `to_onehot_y=True` ignored.") + else: + target = one_hot(target, num_classes=n_pred_ch) + + if not self.include_background: + if n_pred_ch == 1: + warnings.warn("single channel prediction, `include_background=False` ignored.") + else: + # if skipping background, removing first channel + target = target[:, 1:] + input = input[:, 1:] + + if target.shape != input.shape: + raise AssertionError(f"ground truth has different shape ({target.shape}) from input ({input.shape})") + + i = input t = target - if i.ndimension() != t.ndimension(): - raise ValueError(f"logits and target ndim must match, got logits={i.ndimension()} target={t.ndimension()}.") - - if t.shape[1] != 1 and t.shape[1] != i.shape[1]: - raise ValueError( - "target must have one channel or have the same shape as the logits. " - "If it has one channel, it should be a class index in the range [0, C-1] " - f"where C is the number of classes inferred from 'logits': C={i.shape[1]}. " - ) - if i.shape[1] == 1: - raise NotImplementedError("Single-channel predictions not supported.") - - # Change the shape of logits and target to - # num_batch x num_class x num_voxels. - if i.dim() > 2: - i = i.view(i.size(0), i.size(1), -1) # N,C,H,W => N,C,H*W - t = t.view(t.size(0), t.size(1), -1) # N,1,H,W => N,1,H*W or N,C,H*W - else: # Compatibility with classification. - i = i.unsqueeze(2) # N,C => N,C,1 - t = t.unsqueeze(2) # N,1 => N,1,1 or N,C,1 - - # Compute the log proba (more stable numerically than softmax). - logpt = F.log_softmax(i, dim=1) # N,C,H*W - # Keep only log proba values of the ground truth class for each voxel. - if target.shape[1] == 1: - logpt = logpt.gather(1, t.long()) # N,C,H*W => N,1,H*W - logpt = torch.squeeze(logpt, dim=1) # N,1,H*W => N,H*W + # Change the shape of input and target to B x N x num_voxels. + i = i.view(i.size(0), i.size(1), -1) + t = t.view(t.size(0), t.size(1), -1) + # Compute the log proba. + logpt = F.log_softmax(i, dim=1) # Get the proba - pt = torch.exp(logpt) # N,H*W or N,C,H*W + pt = torch.exp(logpt) # B,H*W or B,N,H*W if self.weight is not None: - self.weight = self.weight.to(i) + class_weight: Optional[torch.Tensor] = None + if isinstance(self.weight, (float, int)): + class_weight = torch.as_tensor([self.weight] * i.size(1)) + else: + class_weight = torch.as_tensor(self.weight) + class_weight = class_weight.to(i) # Convert the weight to a map in which each voxel # has the weight associated with the ground-truth label # associated with this voxel in target. - at = self.weight[None, :, None] # C => 1,C,1 - at = at.expand((t.size(0), -1, t.size(2))) # 1,C,1 => N,C,H*W - if target.shape[1] == 1: - at = at.gather(1, t.long()) # selection of the weights => N,1,H*W - at = torch.squeeze(at, dim=1) # N,1,H*W => N,H*W + at = class_weight[None, :, None] # N => 1,N,1 + at = at.expand((t.size(0), -1, t.size(2))) # 1,N,1 => B,N,H*W # Multiply the log proba by their weights. logpt = logpt * at # Compute the loss mini-batch. weight = torch.pow(-pt + 1.0, self.gamma) - if target.shape[1] == 1: - loss = torch.mean(-weight * logpt, dim=1) # N - else: - loss = torch.mean(-weight * t * logpt, dim=-1) # N,C - + loss = torch.mean(-weight * t * logpt, dim=-1) if self.reduction == LossReduction.SUM.value: return loss.sum() if self.reduction == LossReduction.NONE.value: diff --git a/monai/losses/tversky.py b/monai/losses/tversky.py index b1c45a74a2..1d75b9e8cc 100644 --- a/monai/losses/tversky.py +++ b/monai/losses/tversky.py @@ -10,7 +10,7 @@ # limitations under the License. import warnings -from typing import Callable, Optional, Union +from typing import Callable, List, Optional, Union import torch from torch.nn.modules.loss import _Loss @@ -139,7 +139,7 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: g1 = 1 - g0 # reducing only spatial dimensions (not batch nor channels) - reduce_axis = list(range(2, len(input.shape))) + reduce_axis: List[int] = torch.arange(2, len(input.shape)).tolist() if self.batch: # reducing spatial dimensions and batch reduce_axis = [0] + reduce_axis diff --git a/monai/metrics/__init__.py b/monai/metrics/__init__.py index 818413c30d..35dea5f387 100644 --- a/monai/metrics/__init__.py +++ b/monai/metrics/__init__.py @@ -10,6 +10,7 @@ # limitations under the License. from .confusion_matrix import ConfusionMatrixMetric, compute_confusion_matrix_metric, get_confusion_matrix +from .froc import compute_fp_tp_probs, compute_froc_curve_data, compute_froc_score from .hausdorff_distance import HausdorffDistanceMetric, compute_hausdorff_distance, compute_percent_hausdorff_distance from .meandice import DiceMetric, compute_meandice from .rocauc import compute_roc_auc diff --git a/monai/metrics/froc.py b/monai/metrics/froc.py new file mode 100644 index 0000000000..ec349967c6 --- /dev/null +++ b/monai/metrics/froc.py @@ -0,0 +1,137 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from typing import List, Optional, Tuple, Union + +import numpy as np +import torch + + +def compute_fp_tp_probs( + probs: Union[np.ndarray, torch.Tensor], + y_coord: Union[np.ndarray, torch.Tensor], + x_coord: Union[np.ndarray, torch.Tensor], + evaluation_mask: Union[np.ndarray, torch.Tensor], + labels_to_exclude: Optional[List] = None, + resolution_level: int = 0, +): + """ + This function is modified from the official evaluation code of + `CAMELYON 16 Challenge `_, and used to distinguish + true positive and false positive predictions. A true positive prediction is defined when + the detection point is within the annotated ground truth region. + + Args: + probs: an array with shape (n,) that represents the probabilities of the detections. + Where, n is the number of predicted detections. + y_coord: an array with shape (n,) that represents the Y-coordinates of the detections. + x_coord: an array with shape (n,) that represents the X-coordinates of the detections. + evaluation_mask: the ground truth mask for evaluation. + labels_to_exclude: labels in this list will not be counted for metric calculation. + resolution_level: the level at which the evaluation mask is made. + + Returns: + fp_probs: an array that contains the probabilities of the false positive detections. + tp_probs: an array that contains the probabilities of the True positive detections. + num_targets: the total number of targets (excluding `labels_to_exclude`) for all images under evaluation. + + """ + assert ( + probs.shape == y_coord.shape == x_coord.shape + ), "the shapes for coordinates and probabilities should be the same." + + if isinstance(probs, torch.Tensor): + probs = probs.detach().cpu().numpy() + if isinstance(y_coord, torch.Tensor): + y_coord = y_coord.detach().cpu().numpy() + if isinstance(x_coord, torch.Tensor): + x_coord = x_coord.detach().cpu().numpy() + if isinstance(evaluation_mask, torch.Tensor): + evaluation_mask = evaluation_mask.detach().cpu().numpy() + + if labels_to_exclude is None: + labels_to_exclude = [] + + max_label = np.max(evaluation_mask) + tp_probs = np.zeros((max_label,), dtype=np.float32) + + y_coord = (y_coord / pow(2, resolution_level)).astype(int) + x_coord = (x_coord / pow(2, resolution_level)).astype(int) + + hittedlabel = evaluation_mask[y_coord, x_coord] + fp_probs = probs[np.where(hittedlabel == 0)] + for i in range(1, max_label + 1): + if i not in labels_to_exclude and i in hittedlabel: + tp_probs[i - 1] = probs[np.where(hittedlabel == i)].max() + + num_targets = max_label - len(labels_to_exclude) + return fp_probs, tp_probs, num_targets + + +def compute_froc_curve_data( + fp_probs: Union[np.ndarray, torch.Tensor], + tp_probs: Union[np.ndarray, torch.Tensor], + num_targets: int, + num_images: int, +): + """ + This function is modified from the official evaluation code of + `CAMELYON 16 Challenge `_, and used to compute + the required data for plotting the Free Response Operating Characteristic (FROC) curve. + + Args: + fp_probs: an array that contains the probabilities of the false positive detections for all + images under evaluation. + tp_probs: an array that contains the probabilities of the True positive detections for all + images under evaluation. + num_targets: the total number of targets (excluding `labels_to_exclude`) for all images under evaluation. + num_images: the number of images under evaluation. + + """ + assert type(fp_probs) == type(tp_probs), "fp and tp probs should have same type." + if isinstance(fp_probs, torch.Tensor): + fp_probs = fp_probs.detach().cpu().numpy() + if isinstance(tp_probs, torch.Tensor): + tp_probs = tp_probs.detach().cpu().numpy() + + total_fps, total_tps = [], [] + all_probs = sorted(set(list(fp_probs) + list(tp_probs))) + for thresh in all_probs[1:]: + total_fps.append((fp_probs >= thresh).sum()) + total_tps.append((tp_probs >= thresh).sum()) + total_fps.append(0) + total_tps.append(0) + fps_per_image = np.asarray(total_fps) / float(num_images) + total_sensitivity = np.asarray(total_tps) / float(num_targets) + return fps_per_image, total_sensitivity + + +def compute_froc_score( + fps_per_image: np.ndarray, + total_sensitivity: np.ndarray, + eval_thresholds: Tuple = (0.25, 0.5, 1, 2, 4, 8), +): + """ + This function is modified from the official evaluation code of + `CAMELYON 16 Challenge `_, and used to compute + the challenge's second evaluation metric, which is defined as the average sensitivity at + the predefined false positive rates per whole slide image. + + Args: + fps_per_image: the average number of false positives per image for different thresholds. + total_sensitivity: sensitivities (true positive rates) for different thresholds. + eval_thresholds: the false positive rates for calculating the average sensitivity. Defaults + to (0.25, 0.5, 1, 2, 4, 8) which is the same as the CAMELYON 16 Challenge. + + """ + interp_sens = np.interp(eval_thresholds, fps_per_image[::-1], total_sensitivity[::-1]) + return np.mean(interp_sens) diff --git a/monai/networks/blocks/__init__.py b/monai/networks/blocks/__init__.py index 4a2e31928e..4639630c36 100644 --- a/monai/networks/blocks/__init__.py +++ b/monai/networks/blocks/__init__.py @@ -17,6 +17,7 @@ from .dynunet_block import UnetBasicBlock, UnetOutBlock, UnetResBlock, UnetUpBlock, get_output_padding, get_padding from .fcn import FCN, GCN, MCFCN, Refine from .localnet_block import LocalNetDownSampleBlock, LocalNetFeatureExtractorBlock, LocalNetUpSampleBlock +from .regunet_block import RegistrationDownSampleBlock, RegistrationExtractionBlock, RegistrationResidualConvBlock from .segresnet_block import ResBlock from .squeeze_and_excitation import ( ChannelSELayer, diff --git a/monai/networks/blocks/regunet_block.py b/monai/networks/blocks/regunet_block.py new file mode 100644 index 0000000000..f4c2c1f3a7 --- /dev/null +++ b/monai/networks/blocks/regunet_block.py @@ -0,0 +1,270 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import List, Optional, Sequence, Tuple, Type, Union + +import torch +from torch import nn +from torch.nn import functional as F + +from monai.networks.blocks import Convolution +from monai.networks.layers import Conv, Norm, Pool, same_padding + + +def get_conv_block( + spatial_dims: int, + in_channels: int, + out_channels: int, + kernel_size: Union[Sequence[int], int] = 3, + strides: int = 1, + padding: Optional[Union[Tuple[int, ...], int]] = None, + act: Optional[Union[Tuple, str]] = "RELU", + norm: Optional[Union[Tuple, str]] = "BATCH", + initializer: Optional[str] = "kaiming_uniform", +) -> nn.Module: + if padding is None: + padding = same_padding(kernel_size) + conv_block = Convolution( + spatial_dims, + in_channels, + out_channels, + kernel_size=kernel_size, + strides=strides, + act=act, + norm=norm, + bias=False, + conv_only=False, + padding=padding, + ) + conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv[Conv.CONV, spatial_dims] + for m in conv_block.modules(): + if isinstance(m, conv_type): + if initializer == "kaiming_uniform": + nn.init.kaiming_normal_(torch.as_tensor(m.weight)) + elif initializer == "zeros": + nn.init.zeros_(torch.as_tensor(m.weight)) + else: + raise ValueError( + f"initializer {initializer} is not supported, " "currently supporting kaiming_uniform and zeros" + ) + return conv_block + + +def get_conv_layer( + spatial_dims: int, + in_channels: int, + out_channels: int, + kernel_size: Union[Sequence[int], int] = 3, +) -> nn.Module: + padding = same_padding(kernel_size) + return Convolution( + spatial_dims, + in_channels, + out_channels, + kernel_size=kernel_size, + bias=False, + conv_only=True, + padding=padding, + ) + + +class RegistrationResidualConvBlock(nn.Module): + """ + A block with skip links and layer - norm - activation. + Only changes the number of channels, the spatial size is kept same. + """ + + def __init__( + self, + spatial_dims: int, + in_channels: int, + out_channels: int, + num_layers: int = 2, + kernel_size: int = 3, + ): + """ + + Args: + spatial_dims: number of spatial dimensions + in_channels: number of input channels + out_channels: number of output channels + num_layers: number of layers inside the block + kernel_size: kernel_size + """ + super(RegistrationResidualConvBlock, self).__init__() + self.num_layers = num_layers + self.layers = nn.ModuleList( + [ + get_conv_layer( + spatial_dims=spatial_dims, + in_channels=in_channels if i == 0 else out_channels, + out_channels=out_channels, + kernel_size=kernel_size, + ) + for i in range(num_layers) + ] + ) + self.norms = nn.ModuleList([Norm[Norm.BATCH, spatial_dims](out_channels) for _ in range(num_layers)]) + self.acts = nn.ModuleList([nn.ReLU() for _ in range(num_layers)]) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + + Args: + x: Tensor in shape (batch, ``in_channels``, insize_1, insize_2, [insize_3]) + + Returns: + Tensor in shape (batch, ``out_channels``, insize_1, insize_2, [insize_3]), + with the same spatial size as ``x`` + """ + skip = x + for i, (conv, norm, act) in enumerate(zip(self.layers, self.norms, self.acts)): + x = conv(x) + x = norm(x) + if i == self.num_layers - 1: + # last block + x = x + skip + x = act(x) + return x + + +class RegistrationDownSampleBlock(nn.Module): + """ + A down-sample module used in RegUNet to half the spatial size. + The number of channels is kept same. + + Adapted from: + DeepReg (https://github.com/DeepRegNet/DeepReg) + """ + + def __init__( + self, + spatial_dims: int, + channels: int, + pooling: bool, + ) -> None: + """ + Args: + spatial_dims: number of spatial dimensions. + channels: channels + pooling: use MaxPool if True, strided conv if False + """ + super(RegistrationDownSampleBlock, self).__init__() + if pooling: + self.layer = Pool[Pool.MAX, spatial_dims](kernel_size=2) + else: + self.layer = get_conv_block( + spatial_dims=spatial_dims, + in_channels=channels, + out_channels=channels, + kernel_size=2, + strides=2, + padding=0, + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Halves the spatial dimensions and keeps the same channel. + output in shape (batch, ``channels``, insize_1 / 2, insize_2 / 2, [insize_3 / 2]), + + Args: + x: Tensor in shape (batch, ``channels``, insize_1, insize_2, [insize_3]) + + Raises: + ValueError: when input spatial dimensions are not even. + """ + for i in x.shape[2:]: + if i % 2 != 0: + raise ValueError("expecting x spatial dimensions be even, " f"got x of shape {x.shape}") + out: torch.Tensor = self.layer(x) + return out + + +def get_deconv_block( + spatial_dims: int, + in_channels: int, + out_channels: int, +) -> nn.Module: + return Convolution( + dimensions=spatial_dims, + in_channels=in_channels, + out_channels=out_channels, + strides=2, + act="RELU", + norm="BATCH", + bias=False, + is_transposed=True, + padding=1, + output_padding=1, + ) + + +class RegistrationExtractionBlock(nn.Module): + """ + The Extraction Block used in RegUNet. + Extracts feature from each ``extract_levels`` and takes the average. + """ + + def __init__( + self, + spatial_dims: int, + extract_levels: Tuple[int], + num_channels: Union[Tuple[int], List[int]], + out_channels: int, + kernel_initializer: Optional[str] = "kaiming_uniform", + activation: Optional[str] = None, + ): + """ + + Args: + spatial_dims: number of spatial dimensions + extract_levels: spatial levels to extract feature from, 0 refers to the input scale + num_channels: number of channels at each scale level, + List or Tuple of lenth equals to `depth` of the RegNet + out_channels: number of output channels + kernel_initializer: kernel initializer + activation: kernel activation function + """ + super(RegistrationExtractionBlock, self).__init__() + self.extract_levels = extract_levels + self.max_level = max(extract_levels) + self.layers = nn.ModuleList( + [ + get_conv_block( + spatial_dims=spatial_dims, + in_channels=num_channels[d], + out_channels=out_channels, + norm=None, + act=activation, + initializer=kernel_initializer, + ) + for d in extract_levels + ] + ) + + def forward(self, x: List[torch.Tensor], image_size: List[int]) -> torch.Tensor: + """ + + Args: + x: Decoded feature at different spatial levels, sorted from deep to shallow + image_size: output image size + + Returns: + Tensor of shape (batch, `out_channels`, size1, size2, size3), where (size1, size2, size3) = ``image_size`` + """ + feature_list = [ + F.interpolate( + layer(x[self.max_level - level]), + size=image_size, + ) + for layer, level in zip(self.layers, self.extract_levels) + ] + out: torch.Tensor = torch.mean(torch.stack(feature_list, dim=0), dim=0) + return out diff --git a/monai/networks/layers/simplelayers.py b/monai/networks/layers/simplelayers.py index f560526db8..b2af4fcbcd 100644 --- a/monai/networks/layers/simplelayers.py +++ b/monai/networks/layers/simplelayers.py @@ -10,14 +10,14 @@ # limitations under the License. import math -from typing import Sequence, Union, cast +from typing import List, Sequence, Union import torch import torch.nn.functional as F from torch import nn from torch.autograd import Function -from monai.networks.layers.convutils import gaussian_1d, same_padding +from monai.networks.layers.convutils import gaussian_1d from monai.networks.layers.factories import Conv from monai.utils import ( PT_BEFORE_1_7, @@ -164,9 +164,45 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x.reshape(shape) -def separable_filtering( - x: torch.Tensor, kernels: Union[Sequence[torch.Tensor], torch.Tensor], mode: str = "zeros" +def _separable_filtering_conv( + input_: torch.Tensor, + kernels: List[torch.Tensor], + pad_mode: str, + d: int, + spatial_dims: int, + paddings: List[int], + num_channels: int, ) -> torch.Tensor: + + if d < 0: + return input_ + + s = [1] * len(input_.shape) + s[d + 2] = -1 + _kernel = kernels[d].reshape(s) + + # if filter kernel is unity, don't convolve + if _kernel.numel() == 1 and _kernel[0] == 1: + return _separable_filtering_conv(input_, kernels, pad_mode, d - 1, spatial_dims, paddings, num_channels) + + _kernel = _kernel.repeat([num_channels, 1] + [1] * spatial_dims) + _padding = [0] * spatial_dims + _padding[d] = paddings[d] + conv_type = [F.conv1d, F.conv2d, F.conv3d][spatial_dims - 1] + + # translate padding for input to torch.nn.functional.pad + _reversed_padding_repeated_twice: List[List[int]] = [[p, p] for p in reversed(_padding)] + _sum_reversed_padding_repeated_twice: List[int] = sum(_reversed_padding_repeated_twice, []) + padded_input = F.pad(input_, _sum_reversed_padding_repeated_twice, mode=pad_mode) + + return conv_type( + input=_separable_filtering_conv(padded_input, kernels, pad_mode, d - 1, spatial_dims, paddings, num_channels), + weight=_kernel, + groups=num_channels, + ) + + +def separable_filtering(x: torch.Tensor, kernels: List[torch.Tensor], mode: str = "zeros") -> torch.Tensor: """ Apply 1-D convolutions along each spatial dimension of `x`. @@ -186,36 +222,12 @@ def separable_filtering( raise TypeError(f"x must be a torch.Tensor but is {type(x).__name__}.") spatial_dims = len(x.shape) - 2 - _kernels = [ - torch.as_tensor(s, dtype=torch.float, device=s.device if isinstance(s, torch.Tensor) else None) - for s in ensure_tuple_rep(kernels, spatial_dims) - ] - _paddings = [cast(int, (same_padding(k.shape[0]))) for k in _kernels] + _kernels = [s.float() for s in kernels] + _paddings = [(k.shape[0] - 1) // 2 for k in _kernels] n_chs = x.shape[1] + pad_mode = "constant" if mode == "zeros" else mode - def _conv(input_: torch.Tensor, d: int) -> torch.Tensor: - if d < 0: - return input_ - s = [1] * len(input_.shape) - s[d + 2] = -1 - _kernel = kernels[d].reshape(s) - # if filter kernel is unity, don't convolve - if _kernel.numel() == 1 and _kernel[0] == 1: - return _conv(input_, d - 1) - _kernel = _kernel.repeat([n_chs, 1] + [1] * spatial_dims) - _padding = [0] * spatial_dims - _padding[d] = _paddings[d] - conv_type = [F.conv1d, F.conv2d, F.conv3d][spatial_dims - 1] - # translate padding for input to torch.nn.functional.pad - _reversed_padding_repeated_twice = [p for p in reversed(_padding) for _ in range(2)] - pad_mode = "constant" if mode == "zeros" else mode - return conv_type( - input=_conv(F.pad(input_, _reversed_padding_repeated_twice, mode=pad_mode), d - 1), - weight=_kernel, - groups=n_chs, - ) - - return _conv(x, spatial_dims - 1) + return _separable_filtering_conv(x, kernels, pad_mode, spatial_dims - 1, spatial_dims, _paddings, n_chs) class SavitzkyGolayFilter(nn.Module): diff --git a/monai/networks/layers/spatial_transforms.py b/monai/networks/layers/spatial_transforms.py index 175fd05694..03031f3340 100644 --- a/monai/networks/layers/spatial_transforms.py +++ b/monai/networks/layers/spatial_transforms.py @@ -35,17 +35,15 @@ def forward(ctx, input, grid, interpolation, bound, extrapolate): @staticmethod def backward(ctx, grad): - var = ctx.saved_variables + if not (ctx.needs_input_grad[0] or ctx.needs_input_grad[1]): + return None, None, None, None, None + var = ctx.saved_tensors opt = ctx.opt - grad_input = grad_grid = None grads = _C.grid_pull_backward(grad, *var, *opt) if ctx.needs_input_grad[0]: - grad_input = grads[0] - if ctx.needs_input_grad[1]: - grad_grid = grads[1] - elif ctx.needs_input_grad[1]: - grad_grid = grads[0] - return grad_input, grad_grid, None, None, None + return grads[0], grads[1] if ctx.needs_input_grad[1] else None, None, None, None + if ctx.needs_input_grad[1]: + return None, grads[0], None, None, None def grid_pull(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", bound="zero", extrapolate: bool = True): @@ -60,7 +58,9 @@ def grid_pull(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b - 2 or 'quadratic' or InterpolationType.quadratic - 3 or 'cubic' or InterpolationType.cubic - 4 or 'fourth' or InterpolationType.fourth - - etc. + - 5 or 'fifth' or InterpolationType.fifth + - 6 or 'sixth' or InterpolationType.sixth + - 7 or 'seventh' or InterpolationType.seventh A list of values can be provided, in the order [W, H, D], to specify dimension-specific interpolation orders. @@ -68,14 +68,13 @@ def grid_pull(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b `bound` can be an int, a string or a BoundType. Possible values are:: - - 0 or 'replicate' or BoundType.replicate - - 1 or 'dct1' or BoundType.dct1 - - 2 or 'dct2' or BoundType.dct2 - - 3 or 'dst1' or BoundType.dst1 - - 4 or 'dst2' or BoundType.dst2 - - 5 or 'dft' or BoundType.dft - - 6 or 'sliding' or BoundType.sliding [not implemented] - - 7 or 'zero' or BoundType.zero + - 0 or 'replicate' or 'nearest' or BoundType.replicate + - 1 or 'dct1' or 'mirror' or BoundType.dct1 + - 2 or 'dct2' or 'reflect' or BoundType.dct2 + - 3 or 'dst1' or 'antimirror' or BoundType.dst1 + - 4 or 'dst2' or 'antireflect' or BoundType.dst2 + - 5 or 'dft' or 'wrap' or BoundType.dft + - 7 or 'zero' or BoundType.zero A list of values can be provided, in the order [W, H, D], to specify dimension-specific boundary conditions. @@ -87,15 +86,17 @@ def grid_pull(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b - `dct2` corresponds to Neumann boundary conditions (symmetric) - `dst2` corresponds to Dirichlet boundary conditions (antisymmetric) - See: - https://en.wikipedia.org/wiki/Discrete_cosine_transform - https://en.wikipedia.org/wiki/Discrete_sine_transform + See Also: + - https://en.wikipedia.org/wiki/Discrete_cosine_transform + - https://en.wikipedia.org/wiki/Discrete_sine_transform + - ``help(monai._C.BoundType)`` + - ``help(monai._C.InterpolationType)`` Args: input: Input image. `(B, C, Wi, Hi, Di)`. - grid: Deformation field. `(B, Wo, Ho, Do, 2|3)`. + grid: Deformation field. `(B, Wo, Ho, Do, 1|2|3)`. interpolation (int or list[int] , optional): Interpolation order. - Defaults to `1`. + Defaults to `'linear'`. bound (BoundType, or list[BoundType], optional): Boundary conditions. Defaults to `'zero'`. extrapolate: Extrapolate out-of-bound data. @@ -106,11 +107,10 @@ def grid_pull(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b """ # Convert parameters - bound = ensure_tuple(bound) - interpolation = ensure_tuple(interpolation) - bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in bound] + bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in ensure_tuple(bound)] interpolation = [ - _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) for i in interpolation + _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) + for i in ensure_tuple(interpolation) ] return _GridPull.apply(input, grid, interpolation, bound, extrapolate) @@ -129,17 +129,15 @@ def forward(ctx, input, grid, shape, interpolation, bound, extrapolate): @staticmethod def backward(ctx, grad): - var = ctx.saved_variables + if not (ctx.needs_input_grad[0] or ctx.needs_input_grad[1]): + return None, None, None, None, None, None + var = ctx.saved_tensors opt = ctx.opt - grad_input = grad_grid = None grads = _C.grid_push_backward(grad, *var, *opt) if ctx.needs_input_grad[0]: - grad_input = grads[0] - if ctx.needs_input_grad[1]: - grad_grid = grads[1] - elif ctx.needs_input_grad[1]: - grad_grid = grads[0] - return grad_input, grad_grid, None, None, None, None + return grads[0], grads[1] if ctx.needs_input_grad[1] else None, None, None, None, None + if ctx.needs_input_grad[1]: + return None, grads[0], None, None, None, None def grid_push( @@ -156,7 +154,9 @@ def grid_push( - 2 or 'quadratic' or InterpolationType.quadratic - 3 or 'cubic' or InterpolationType.cubic - 4 or 'fourth' or InterpolationType.fourth - - etc. + - 5 or 'fifth' or InterpolationType.fifth + - 6 or 'sixth' or InterpolationType.sixth + - 7 or 'seventh' or InterpolationType.seventh A list of values can be provided, in the order `[W, H, D]`, to specify dimension-specific interpolation orders. @@ -164,14 +164,13 @@ def grid_push( `bound` can be an int, a string or a BoundType. Possible values are:: - - 0 or 'replicate' or BoundType.replicate - - 1 or 'dct1' or BoundType.dct1 - - 2 or 'dct2' or BoundType.dct2 - - 3 or 'dst1' or BoundType.dst1 - - 4 or 'dst2' or BoundType.dst2 - - 5 or 'dft' or BoundType.dft - - 6 or 'sliding' or BoundType.sliding [not implemented] - - 7 or 'zero' or BoundType.zero + - 0 or 'replicate' or 'nearest' or BoundType.replicate + - 1 or 'dct1' or 'mirror' or BoundType.dct1 + - 2 or 'dct2' or 'reflect' or BoundType.dct2 + - 3 or 'dst1' or 'antimirror' or BoundType.dst1 + - 4 or 'dst2' or 'antireflect' or BoundType.dst2 + - 5 or 'dft' or 'wrap' or BoundType.dft + - 7 or 'zero' or BoundType.zero A list of values can be provided, in the order `[W, H, D]`, to specify dimension-specific boundary conditions. @@ -183,17 +182,19 @@ def grid_push( - `dct2` corresponds to Neumann boundary conditions (symmetric) - `dst2` corresponds to Dirichlet boundary conditions (antisymmetric) - See also: + See Also: - https://en.wikipedia.org/wiki/Discrete_cosine_transform - https://en.wikipedia.org/wiki/Discrete_sine_transform + - ``help(monai._C.BoundType)`` + - ``help(monai._C.InterpolationType)`` Args: input: Input image `(B, C, Wi, Hi, Di)`. - grid: Deformation field `(B, Wi, Hi, Di, 2|3)`. + grid: Deformation field `(B, Wi, Hi, Di, 1|2|3)`. shape: Shape of the source image. interpolation (int or list[int] , optional): Interpolation order. - Defaults to `1`. + Defaults to `'linear'`. bound (BoundType, or list[BoundType], optional): Boundary conditions. Defaults to `'zero'`. extrapolate: Extrapolate out-of-bound data. @@ -204,11 +205,10 @@ def grid_push( """ # Convert parameters - bound = ensure_tuple(bound) - interpolation = ensure_tuple(interpolation) - bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in bound] + bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in ensure_tuple(bound)] interpolation = [ - _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) for i in interpolation + _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) + for i in ensure_tuple(interpolation) ] if shape is None: @@ -230,12 +230,11 @@ def forward(ctx, grid, shape, interpolation, bound, extrapolate): @staticmethod def backward(ctx, grad): - var = ctx.saved_variables - opt = ctx.opt - grad_grid = None if ctx.needs_input_grad[0]: - grad_grid = _C.grid_count_backward(grad, *var, *opt) - return grad_grid, None, None, None, None + var = ctx.saved_tensors + opt = ctx.opt + return _C.grid_count_backward(grad, *var, *opt), None, None, None, None + return None, None, None, None, None def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="zero", extrapolate: bool = True): @@ -252,7 +251,9 @@ def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="ze - 2 or 'quadratic' or InterpolationType.quadratic - 3 or 'cubic' or InterpolationType.cubic - 4 or 'fourth' or InterpolationType.fourth - - etc. + - 5 or 'fifth' or InterpolationType.fifth + - 6 or 'sixth' or InterpolationType.sixth + - 7 or 'seventh' or InterpolationType.seventh A list of values can be provided, in the order [W, H, D], to specify dimension-specific interpolation orders. @@ -260,14 +261,13 @@ def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="ze `bound` can be an int, a string or a BoundType. Possible values are:: - - 0 or 'replicate' or BoundType.replicate - - 1 or 'dct1' or BoundType.dct1 - - 2 or 'dct2' or BoundType.dct2 - - 3 or 'dst1' or BoundType.dst1 - - 4 or 'dst2' or BoundType.dst2 - - 5 or 'dft' or BoundType.dft - - 6 or 'sliding' or BoundType.sliding [not implemented] - - 7 or 'zero' or BoundType.zero + - 0 or 'replicate' or 'nearest' or BoundType.replicate + - 1 or 'dct1' or 'mirror' or BoundType.dct1 + - 2 or 'dct2' or 'reflect' or BoundType.dct2 + - 3 or 'dst1' or 'antimirror' or BoundType.dst1 + - 4 or 'dst2' or 'antireflect' or BoundType.dst2 + - 5 or 'dft' or 'wrap' or BoundType.dft + - 7 or 'zero' or BoundType.zero A list of values can be provided, in the order [W, H, D], to specify dimension-specific boundary conditions. @@ -283,12 +283,14 @@ def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="ze - https://en.wikipedia.org/wiki/Discrete_cosine_transform - https://en.wikipedia.org/wiki/Discrete_sine_transform + - ``help(monai._C.BoundType)`` + - ``help(monai._C.InterpolationType)`` Args: grid: Deformation field `(B, Wi, Hi, Di, 2|3)`. shape: shape of the source image. interpolation (int or list[int] , optional): Interpolation order. - Defaults to `1`. + Defaults to `'linear'`. bound (BoundType, or list[BoundType], optional): Boundary conditions. Defaults to `'zero'`. extrapolate (bool, optional): Extrapolate out-of-bound data. @@ -299,11 +301,10 @@ def grid_count(grid: torch.Tensor, shape=None, interpolation="linear", bound="ze """ # Convert parameters - bound = ensure_tuple(bound) - interpolation = ensure_tuple(interpolation) - bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in bound] + bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in ensure_tuple(bound)] interpolation = [ - _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) for i in interpolation + _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) + for i in ensure_tuple(interpolation) ] if shape is None: @@ -325,18 +326,15 @@ def forward(ctx, input, grid, interpolation, bound, extrapolate): @staticmethod def backward(ctx, grad): - var = ctx.saved_variables + if not (ctx.needs_input_grad[0] or ctx.needs_input_grad[1]): + return None, None, None, None, None + var = ctx.saved_tensors opt = ctx.opt - grad_input = grad_grid = None - if ctx.needs_input_grad[0] or ctx.needs_input_grad[1]: - grads = _C.grid_grad_backward(grad, *var, *opt) - if ctx.needs_input_grad[0]: - grad_input = grads[0] - if ctx.needs_input_grad[1]: - grad_grid = grads[1] - elif ctx.needs_input_grad[1]: - grad_grid = grads[0] - return grad_input, grad_grid, None, None, None + grads = _C.grid_grad_backward(grad, *var, *opt) + if ctx.needs_input_grad[0]: + return grads[0], grads[1] if ctx.needs_input_grad[1] else None, None, None, None + if ctx.needs_input_grad[1]: + return None, grads[0], None, None, None def grid_grad(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", bound="zero", extrapolate: bool = True): @@ -351,7 +349,9 @@ def grid_grad(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b - 2 or 'quadratic' or InterpolationType.quadratic - 3 or 'cubic' or InterpolationType.cubic - 4 or 'fourth' or InterpolationType.fourth - - etc. + - 5 or 'fifth' or InterpolationType.fifth + - 6 or 'sixth' or InterpolationType.sixth + - 7 or 'seventh' or InterpolationType.seventh A list of values can be provided, in the order [W, H, D], to specify dimension-specific interpolation orders. @@ -359,14 +359,13 @@ def grid_grad(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b `bound` can be an int, a string or a BoundType. Possible values are:: - - 0 or 'replicate' or BoundType.replicate - - 1 or 'dct1' or BoundType.dct1 - - 2 or 'dct2' or BoundType.dct2 - - 3 or 'dst1' or BoundType.dst1 - - 4 or 'dst2' or BoundType.dst2 - - 5 or 'dft' or BoundType.dft - - 6 or 'sliding' or BoundType.sliding [not implemented] - - 7 or 'zero' or BoundType.zero + - 0 or 'replicate' or 'nearest' or BoundType.replicate + - 1 or 'dct1' or 'mirror' or BoundType.dct1 + - 2 or 'dct2' or 'reflect' or BoundType.dct2 + - 3 or 'dst1' or 'antimirror' or BoundType.dst1 + - 4 or 'dst2' or 'antireflect' or BoundType.dst2 + - 5 or 'dft' or 'wrap' or BoundType.dft + - 7 or 'zero' or BoundType.zero A list of values can be provided, in the order [W, H, D], to specify dimension-specific boundary conditions. @@ -378,30 +377,32 @@ def grid_grad(input: torch.Tensor, grid: torch.Tensor, interpolation="linear", b - `dct2` corresponds to Neumann boundary conditions (symmetric) - `dst2` corresponds to Dirichlet boundary conditions (antisymmetric) - See also: + See Also: - https://en.wikipedia.org/wiki/Discrete_cosine_transform - https://en.wikipedia.org/wiki/Discrete_sine_transform + - ``help(monai._C.BoundType)`` + - ``help(monai._C.InterpolationType)`` + Args: input: Input image. `(B, C, Wi, Hi, Di)`. grid: Deformation field. `(B, Wo, Ho, Do, 2|3)`. interpolation (int or list[int] , optional): Interpolation order. - Defaults to `1`. + Defaults to `'linear'`. bound (BoundType, or list[BoundType], optional): Boundary conditions. Defaults to `'zero'`. extrapolate: Extrapolate out-of-bound data. Defaults to `True`. Returns: - output (torch.Tensor): Sampled gradients (B, C, Wo, Ho, Do, 2|3). + output (torch.Tensor): Sampled gradients (B, C, Wo, Ho, Do, 1|2|3). """ # Convert parameters - bound = ensure_tuple(bound) - interpolation = ensure_tuple(interpolation) - bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in bound] + bound = [_C.BoundType.__members__[b] if isinstance(b, str) else _C.BoundType(b) for b in ensure_tuple(bound)] interpolation = [ - _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) for i in interpolation + _C.InterpolationType.__members__[i] if isinstance(i, str) else _C.InterpolationType(i) + for i in ensure_tuple(interpolation) ] return _GridGrad.apply(input, grid, interpolation, bound, extrapolate) diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index a9308de9d7..cd00ea1aa1 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -13,15 +13,15 @@ from .autoencoder import AutoEncoder from .basic_unet import BasicUNet, BasicUnet, Basicunet from .classifier import Classifier, Critic, Discriminator -from .densenet import DenseNet, densenet121, densenet169, densenet201, densenet264 +from .densenet import DenseNet, DenseNet121, DenseNet169, DenseNet201, DenseNet264 from .dynunet import DynUNet, DynUnet, Dynunet from .fullyconnectednet import FullyConnectedNet, VarFullyConnectedNet from .generator import Generator from .highresnet import HighResBlock, HighResNet -from .localnet import LocalNet from .regressor import Regressor +from .regunet import GlobalNet, LocalNet, RegUNet from .segresnet import SegResNet, SegResNetVAE -from .senet import SENet, se_resnet50, se_resnet101, se_resnet152, se_resnext50_32x4d, se_resnext101_32x4d, senet154 +from .senet import SENet, SENet154, SEResNet50, SEResNet101, SEResNet152, SEResNext50, SEResNext101 from .unet import UNet, Unet, unet from .varautoencoder import VarAutoEncoder from .vnet import VNet diff --git a/monai/networks/nets/densenet.py b/monai/networks/nets/densenet.py index ad1d1d6e5f..280bc6b0cb 100644 --- a/monai/networks/nets/densenet.py +++ b/monai/networks/nets/densenet.py @@ -196,28 +196,32 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -model_urls = { - "densenet121": "https://download.pytorch.org/models/densenet121-a639ec97.pth", - "densenet169": "https://download.pytorch.org/models/densenet169-b2777c0a.pth", - "densenet201": "https://download.pytorch.org/models/densenet201-c1103571.pth", -} - - -def _load_state_dict(model, model_url, progress): +def _load_state_dict(model, arch, progress): """ This function is used to load pretrained models. Adapted from `PyTorch Hub 2D version `_ """ + model_urls = { + "densenet121": "https://download.pytorch.org/models/densenet121-a639ec97.pth", + "densenet169": "https://download.pytorch.org/models/densenet169-b2777c0a.pth", + "densenet201": "https://download.pytorch.org/models/densenet201-c1103571.pth", + } + if arch in model_urls: + model_url = model_urls[arch] + else: + raise ValueError( + "only 'densenet121', 'densenet169' and 'densenet201' are supported to load pretrained weights." + ) pattern = re.compile( - r"^(.*denselayer\d+\.(?:norm|relu|conv))\.((?:[12])\.(?:weight|bias|running_mean|running_var))$" + r"^(.*denselayer\d+)(\.(?:norm|relu|conv))\.((?:[12])\.(?:weight|bias|running_mean|running_var))$" ) state_dict = load_state_dict_from_url(model_url, progress=progress) for key in list(state_dict.keys()): res = pattern.match(key) if res: - new_key = res.group(1) + res.group(2) + new_key = res.group(1) + ".layers" + res.group(2) + res.group(3) state_dict[new_key] = state_dict[key] del state_dict[key] @@ -229,47 +233,84 @@ def _load_state_dict(model, model_url, progress): model.load_state_dict(model_dict) -def densenet121(pretrained: bool = False, progress: bool = True, **kwargs) -> DenseNet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `PyTorch Hub 2D version - `_ - """ - model = DenseNet(init_features=64, growth_rate=32, block_config=(6, 12, 24, 16), **kwargs) - if pretrained: - arch = "densenet121" - _load_state_dict(model, model_urls[arch], progress) - return model +class DenseNet121(DenseNet): + def __init__( + self, + init_features: int = 64, + growth_rate: int = 32, + block_config: Sequence[int] = (6, 12, 24, 16), + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(DenseNet121, self).__init__( + init_features=init_features, + growth_rate=growth_rate, + block_config=block_config, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "densenet121", progress) -def densenet169(pretrained: bool = False, progress: bool = True, **kwargs) -> DenseNet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `PyTorch Hub 2D version - `_ - """ - model = DenseNet(init_features=64, growth_rate=32, block_config=(6, 12, 32, 32), **kwargs) - if pretrained: - arch = "densenet169" - _load_state_dict(model, model_urls[arch], progress) - return model +class DenseNet169(DenseNet): + def __init__( + self, + init_features: int = 64, + growth_rate: int = 32, + block_config: Sequence[int] = (6, 12, 32, 32), + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(DenseNet169, self).__init__( + init_features=init_features, + growth_rate=growth_rate, + block_config=block_config, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "densenet169", progress) -def densenet201(pretrained: bool = False, progress: bool = True, **kwargs) -> DenseNet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `PyTorch Hub 2D version - `_ - """ - model = DenseNet(init_features=64, growth_rate=32, block_config=(6, 12, 48, 32), **kwargs) - if pretrained: - arch = "densenet201" - _load_state_dict(model, model_urls[arch], progress) - return model - - -def densenet264(pretrained: bool = False, progress: bool = True, **kwargs) -> DenseNet: - model = DenseNet(init_features=64, growth_rate=32, block_config=(6, 12, 64, 48), **kwargs) - if pretrained: - print("Currently PyTorch Hub does not provide densenet264 pretrained models.") - return model +class DenseNet201(DenseNet): + def __init__( + self, + init_features: int = 64, + growth_rate: int = 32, + block_config: Sequence[int] = (6, 12, 48, 32), + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(DenseNet201, self).__init__( + init_features=init_features, + growth_rate=growth_rate, + block_config=block_config, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "densenet201", progress) + + +class DenseNet264(DenseNet): + def __init__( + self, + init_features: int = 64, + growth_rate: int = 32, + block_config: Sequence[int] = (6, 12, 48, 32), + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(DenseNet264, self).__init__( + init_features=init_features, + growth_rate=growth_rate, + block_config=block_config, + **kwargs, + ) + if pretrained: + print("Currently PyTorch Hub does not provide densenet264 pretrained models.") diff --git a/monai/networks/nets/localnet.py b/monai/networks/nets/localnet.py deleted file mode 100644 index e9df68104d..0000000000 --- a/monai/networks/nets/localnet.py +++ /dev/null @@ -1,129 +0,0 @@ -from typing import List, Optional, Tuple, Union - -import torch -from torch import nn -from torch.nn import functional as F - -from monai.networks.blocks.localnet_block import ( - LocalNetDownSampleBlock, - LocalNetFeatureExtractorBlock, - LocalNetUpSampleBlock, - get_conv_block, -) - - -class LocalNet(nn.Module): - """ - Reimplementation of LocalNet, based on: - `Weakly-supervised convolutional neural networks for multimodal image registration - `_. - `Label-driven weakly-supervised learning for multimodal deformable image registration - `_. - - Adapted from: - DeepReg (https://github.com/DeepRegNet/DeepReg) - """ - - def __init__( - self, - spatial_dims: int, - in_channels: int, - out_channels: int, - num_channel_initial: int, - extract_levels: List[int], - out_activation: Optional[Union[Tuple, str]], - out_initializer: str = "kaiming_uniform", - ) -> None: - """ - Args: - spatial_dims: number of spatial dimensions. - in_channels: number of input channels. - out_channels: number of output channels. - num_channel_initial: number of initial channels. - extract_levels: number of extraction levels. - out_activation: activation to use at end layer. - out_initializer: initializer for extraction layers. - """ - super(LocalNet, self).__init__() - self.extract_levels = extract_levels - self.extract_max_level = max(self.extract_levels) # E - self.extract_min_level = min(self.extract_levels) # D - - num_channels = [ - num_channel_initial * (2 ** level) for level in range(self.extract_max_level + 1) - ] # level 0 to E - - self.downsample_blocks = nn.ModuleList( - [ - LocalNetDownSampleBlock( - spatial_dims=spatial_dims, - in_channels=in_channels if i == 0 else num_channels[i - 1], - out_channels=num_channels[i], - kernel_size=7 if i == 0 else 3, - ) - for i in range(self.extract_max_level) - ] - ) # level 0 to self.extract_max_level - 1 - self.conv3d_block = get_conv_block( - spatial_dims=spatial_dims, in_channels=num_channels[-2], out_channels=num_channels[-1] - ) # self.extract_max_level - - self.upsample_blocks = nn.ModuleList( - [ - LocalNetUpSampleBlock( - spatial_dims=spatial_dims, - in_channels=num_channels[level + 1], - out_channels=num_channels[level], - ) - for level in range(self.extract_max_level - 1, self.extract_min_level - 1, -1) - ] - ) # self.extract_max_level - 1 to self.extract_min_level - - self.extract_layers = nn.ModuleList( - [ - # if kernels are not initialized by zeros, with init NN, extract may be too large - LocalNetFeatureExtractorBlock( - spatial_dims=spatial_dims, - in_channels=num_channels[level], - out_channels=out_channels, - act=out_activation, - initializer=out_initializer, - ) - for level in self.extract_levels - ] - ) - - def forward(self, x) -> torch.Tensor: - image_size = x.shape[2:] - for size in image_size: - if size % (2 ** self.extract_max_level) != 0: - raise ValueError( - f"given extract_max_level {self.extract_max_level}, " - f"all input spatial dimension must be divisible by {2 ** self.extract_max_level}, " - f"got input of size {image_size}" - ) - mid_features = [] # 0 -> self.extract_max_level - 1 - for downsample_block in self.downsample_blocks: - x, mid = downsample_block(x) - mid_features.append(mid) - x = self.conv3d_block(x) # self.extract_max_level - - decoded_features = [x] - for idx, upsample_block in enumerate(self.upsample_blocks): - x = upsample_block(x, mid_features[-idx - 1]) - decoded_features.append(x) # self.extract_max_level -> self.extract_min_level - - output = torch.mean( - torch.stack( - [ - F.interpolate( - extract_layer(decoded_features[self.extract_max_level - self.extract_levels[idx]]), - size=image_size, - ) - for idx, extract_layer in enumerate(self.extract_layers) - ], - dim=-1, - ), - dim=-1, - ) - return output diff --git a/monai/networks/nets/regunet.py b/monai/networks/nets/regunet.py new file mode 100644 index 0000000000..25455c2df7 --- /dev/null +++ b/monai/networks/nets/regunet.py @@ -0,0 +1,444 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import List, Optional, Tuple, Union + +import torch +from torch import nn +from torch.nn import functional as F + +from monai.networks.blocks.regunet_block import ( + RegistrationDownSampleBlock, + RegistrationExtractionBlock, + RegistrationResidualConvBlock, + get_conv_block, + get_deconv_block, +) + + +class RegUNet(nn.Module): + """ + Class that implements an adapted UNet. This class also serve as the parent class of LocalNet and GlobalNet + + Reference: + O. Ronneberger, P. Fischer, and T. Brox, + “U-net: Convolutional networks for biomedical image segmentation,”, + Lecture Notes in Computer Science, 2015, vol. 9351, pp. 234–241. + https://arxiv.org/abs/1505.04597 + + Adapted from: + DeepReg (https://github.com/DeepRegNet/DeepReg) + """ + + def __init__( + self, + spatial_dims: int, + in_channels: int, + num_channel_initial: int, + depth: int, + out_kernel_initializer: Optional[str] = "kaiming_uniform", + out_activation: Optional[str] = None, + out_channels: int = 3, + extract_levels: Optional[Tuple[int]] = None, + pooling: bool = True, + concat_skip: bool = False, + encode_kernel_sizes: Union[int, List[int]] = 3, + ): + """ + Args: + spatial_dims: number of spatial dims + in_channels: number of input channels + num_channel_initial: number of initial channels + depth: input is at level 0, bottom is at level depth. + out_kernel_initializer: kernel initializer for the last layer + out_activation: activation at the last layer + out_channels: number of channels for the output + extract_levels: list, which levels from net to extract. The maximum level must equal to ``depth`` + pooling: for down-sampling, use non-parameterized pooling if true, otherwise use conv3d + concat_skip: when up-sampling, concatenate skipped tensor if true, otherwise use addition + encode_kernel_sizes: kernel size for down-sampling + """ + super(RegUNet, self).__init__() + if not extract_levels: + extract_levels = (depth,) + assert max(extract_levels) == depth + + # save parameters + self.spatial_dims = spatial_dims + self.in_channels = in_channels + self.num_channel_initial = num_channel_initial + self.depth = depth + self.out_kernel_initializer = out_kernel_initializer + self.out_activation = out_activation + self.out_channels = out_channels + self.extract_levels = extract_levels + self.pooling = pooling + self.concat_skip = concat_skip + + if isinstance(encode_kernel_sizes, int): + encode_kernel_sizes = [encode_kernel_sizes] * (self.depth + 1) + assert len(encode_kernel_sizes) == self.depth + 1 + self.encode_kernel_sizes: List[int] = encode_kernel_sizes + + self.num_channels = [self.num_channel_initial * (2 ** d) for d in range(self.depth + 1)] + self.min_extract_level = min(self.extract_levels) + + # init layers + # all lists start with d = 0 + self.encode_convs = None + self.encode_pools = None + self.bottom_block = None + self.decode_deconvs = None + self.decode_convs = None + self.output_block = None + + # build layers + self.build_layers() + + def build_layers( + self, + ): + self.build_encode_layers() + self.build_decode_layers() + + def build_encode_layers(self): + # encoding / down-sampling + self.encode_convs = nn.ModuleList( + [ + self.build_conv_block( + in_channels=self.in_channels if d == 0 else self.num_channels[d - 1], + out_channels=self.num_channels[d], + kernel_size=self.encode_kernel_sizes[d], + ) + for d in range(self.depth) + ] + ) + self.encode_pools = nn.ModuleList( + [ + self.build_down_sampling_block( + channels=self.num_channels[d], + ) + for d in range(self.depth) + ] + ) + self.bottom_block = self.build_bottom_block( + in_channels=self.num_channels[-2], out_channels=self.num_channels[-1] + ) + + def build_conv_block( + self, + in_channels, + out_channels, + kernel_size, + ): + return nn.Sequential( + get_conv_block( + spatial_dims=self.spatial_dims, + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + ), + RegistrationResidualConvBlock( + spatial_dims=self.spatial_dims, + in_channels=out_channels, + out_channels=out_channels, + kernel_size=kernel_size, + ), + ) + + def build_down_sampling_block( + self, + channels: int, + ): + return RegistrationDownSampleBlock(spatial_dims=self.spatial_dims, channels=channels, pooling=self.pooling) + + def build_bottom_block(self, in_channels: int, out_channels: int): + kernel_size = self.encode_kernel_sizes[self.depth] + return nn.Sequential( + get_conv_block( + spatial_dims=self.spatial_dims, + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + ), + RegistrationResidualConvBlock( + spatial_dims=self.spatial_dims, + in_channels=out_channels, + out_channels=out_channels, + kernel_size=kernel_size, + ), + ) + + def build_decode_layers(self): + # decoding / up-sampling + # [depth - 1, depth - 2, ..., min_extract_level] + self.decode_deconvs = nn.ModuleList( + [ + self.build_up_sampling_block(in_channels=self.num_channels[d + 1], out_channels=self.num_channels[d]) + for d in range(self.depth - 1, self.min_extract_level - 1, -1) + ] + ) + self.decode_convs = nn.ModuleList( + [ + self.build_conv_block( + in_channels=(2 * self.num_channels[d] if self.concat_skip else self.num_channels[d]), + out_channels=self.num_channels[d], + kernel_size=3, + ) + for d in range(self.depth - 1, self.min_extract_level - 1, -1) + ] + ) + + # extraction + self.output_block = self.build_output_block() + + def build_up_sampling_block( + self, + in_channels: int, + out_channels: int, + ) -> nn.Module: + return get_deconv_block(spatial_dims=self.spatial_dims, in_channels=in_channels, out_channels=out_channels) + + def build_output_block(self) -> nn.Module: + return RegistrationExtractionBlock( + spatial_dims=self.spatial_dims, + extract_levels=self.extract_levels, + num_channels=self.num_channels, + out_channels=self.out_channels, + kernel_initializer=self.out_kernel_initializer, + activation=self.out_activation, + ) + + def forward(self, x): + """ + Args: + x: Tensor in shape (batch, ``in_channels``, insize_1, insize_2, [insize_3]) + + Returns: + Tensor in shape (batch, ``out_channels``, insize_1, insize_2, [insize_3]), with the same spatial size as ``x`` + """ + image_size = x.shape[2:] + skips = [] # [0, ..., depth - 1] + encoded = x + for encode_conv, encode_pool in zip(self.encode_convs, self.encode_pools): + skip = encode_conv(encoded) + encoded = encode_pool(skip) + skips.append(skip) + decoded = self.bottom_block(encoded) + + outs = [decoded] + + # [depth - 1, ..., min_extract_level] + for i, (decode_deconv, decode_conv) in enumerate(zip(self.decode_deconvs, self.decode_convs)): + # [depth - 1, depth - 2, ..., min_extract_level] + decoded = decode_deconv(decoded) + if self.concat_skip: + decoded = torch.cat([decoded, skips[-i - 1]], dim=1) + else: + decoded = decoded + skips[-i - 1] + decoded = decode_conv(decoded) + outs.append(decoded) + + out = self.output_block(outs, image_size=image_size) + return out + + +class AffineHead(nn.Module): + def __init__( + self, + spatial_dims: int, + image_size: List[int], + decode_size: List[int], + in_channels: int, + ): + super(AffineHead, self).__init__() + self.spatial_dims = spatial_dims + if spatial_dims == 2: + in_features = in_channels * decode_size[0] * decode_size[1] + out_features = 6 + elif spatial_dims == 3: + in_features = in_channels * decode_size[0] * decode_size[1] * decode_size[2] + out_features = 12 + else: + raise ValueError(f"only support 2D/3D operation, got spatial_dims={spatial_dims}") + + self.fc = nn.Linear(in_features=in_features, out_features=out_features) + self.grid = self.get_reference_grid(image_size) # (spatial_dims, ...) + + @staticmethod + def get_reference_grid(image_size: Union[Tuple[int], List[int]]) -> torch.Tensor: + mesh_points = [torch.arange(0, dim) for dim in image_size] + grid = torch.stack(torch.meshgrid(*mesh_points), dim=0) # (spatial_dims, ...) + return grid.to(dtype=torch.float) + + def affine_transform(self, theta: torch.Tensor): + # (spatial_dims, ...) -> (spatial_dims + 1, ...) + grid_padded = torch.cat([self.grid, torch.ones_like(self.grid[:1])]) + + # grid_warped[b,p,...] = sum_over_q(grid_padded[q,...] * theta[b,p,q] + if self.spatial_dims == 2: + grid_warped = torch.einsum("qij,bpq->bpij", grid_padded, theta.reshape(-1, 2, 3)) + elif self.spatial_dims == 3: + grid_warped = torch.einsum("qijk,bpq->bpijk", grid_padded, theta.reshape(-1, 3, 4)) + else: + raise ValueError(f"do not support spatial_dims={self.spatial_dims}") + return grid_warped + + def forward(self, x: List[torch.Tensor], image_size: List[int]) -> torch.Tensor: + f = x[0] + self.grid = self.grid.to(device=f.device) + theta = self.fc(f.reshape(f.shape[0], -1)) + out: torch.Tensor = self.affine_transform(theta) - self.grid + return out + + +class GlobalNet(RegUNet): + """ + Build GlobalNet for image registration. + + Reference: + Hu, Yipeng, et al. + "Label-driven weakly-supervised learning + for multimodal deformable image registration," + https://arxiv.org/abs/1711.01666 + """ + + def __init__( + self, + image_size: List[int], + spatial_dims: int, + in_channels: int, + num_channel_initial: int, + depth: int, + out_kernel_initializer: Optional[str] = "kaiming_uniform", + out_activation: Optional[str] = None, + pooling: bool = True, + concat_skip: bool = False, + encode_kernel_sizes: Union[int, List[int]] = 3, + ): + for size in image_size: + if size % (2 ** depth) != 0: + raise ValueError( + f"given depth {depth}, " + f"all input spatial dimension must be divisible by {2 ** depth}, " + f"got input of size {image_size}" + ) + self.image_size = image_size + self.decode_size = [size // (2 ** depth) for size in image_size] + super().__init__( + spatial_dims=spatial_dims, + in_channels=in_channels, + num_channel_initial=num_channel_initial, + depth=depth, + out_kernel_initializer=out_kernel_initializer, + out_activation=out_activation, + out_channels=spatial_dims, + pooling=pooling, + concat_skip=concat_skip, + encode_kernel_sizes=encode_kernel_sizes, + ) + + def build_output_block(self): + return AffineHead( + spatial_dims=self.spatial_dims, + image_size=self.image_size, + decode_size=self.decode_size, + in_channels=self.num_channels[-1], + ) + + +class AdditiveUpSampleBlock(nn.Module): + def __init__( + self, + spatial_dims: int, + in_channels: int, + out_channels: int, + ): + super(AdditiveUpSampleBlock, self).__init__() + self.deconv = get_deconv_block(spatial_dims=spatial_dims, in_channels=in_channels, out_channels=out_channels) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + output_size = (size * 2 for size in x.shape[2:]) + deconved = self.deconv(x) + resized = F.interpolate(x, output_size) + resized = torch.sum(torch.stack(resized.split(split_size=resized.shape[1] // 2, dim=1), dim=-1), dim=-1) + out: torch.Tensor = deconved + resized + return out + + +class LocalNet(RegUNet): + """ + Reimplementation of LocalNet, based on: + `Weakly-supervised convolutional neural networks for multimodal image registration + `_. + `Label-driven weakly-supervised learning for multimodal deformable image registration + `_. + + Adapted from: + DeepReg (https://github.com/DeepRegNet/DeepReg) + """ + + def __init__( + self, + spatial_dims: int, + in_channels: int, + num_channel_initial: int, + extract_levels: Tuple[int], + out_kernel_initializer: Optional[str] = "kaiming_uniform", + out_activation: Optional[str] = None, + out_channels: int = 3, + pooling: bool = True, + concat_skip: bool = False, + ): + """ + Args: + spatial_dims: number of spatial dims + in_channels: number of input channels + num_channel_initial: number of initial channels + out_kernel_initializer: kernel initializer for the last layer + out_activation: activation at the last layer + out_channels: number of channels for the output + extract_levels: list, which levels from net to extract. The maximum level must equal to ``depth`` + pooling: for down-sampling, use non-parameterized pooling if true, otherwise use conv3d + concat_skip: when up-sampling, concatenate skipped tensor if true, otherwise use addition + """ + super().__init__( + spatial_dims=spatial_dims, + in_channels=in_channels, + num_channel_initial=num_channel_initial, + depth=max(extract_levels), + out_kernel_initializer=out_kernel_initializer, + out_activation=out_activation, + out_channels=out_channels, + pooling=pooling, + concat_skip=concat_skip, + encode_kernel_sizes=[7] + [3] * max(extract_levels), + ) + + def build_bottom_block(self, in_channels: int, out_channels: int): + kernel_size = self.encode_kernel_sizes[self.depth] + return get_conv_block( + spatial_dims=self.spatial_dims, + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + ) + + def build_up_sampling_block( + self, + in_channels: int, + out_channels: int, + ) -> nn.Module: + if self._use_additive_upsampling: + return AdditiveUpSampleBlock( + spatial_dims=self.spatial_dims, in_channels=in_channels, out_channels=out_channels + ) + + return get_deconv_block(spatial_dims=self.spatial_dims, in_channels=in_channels, out_channels=out_channels) diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py index 655ff203c7..f5738edeeb 100644 --- a/monai/networks/nets/senet.py +++ b/monai/networks/nets/senet.py @@ -11,7 +11,7 @@ import re from collections import OrderedDict -from typing import Any, List, Optional, Tuple, Type, Union +from typing import Any, List, Optional, Sequence, Tuple, Type, Union import torch import torch.nn as nn @@ -66,7 +66,6 @@ class SENet(nn.Module): - For SE-ResNeXt models: False num_classes: number of outputs in `last_linear` layer. for all models: 1000 - """ def __init__( @@ -74,7 +73,7 @@ def __init__( spatial_dims: int, in_channels: int, block: Type[Union[SEBottleneck, SEResNetBottleneck, SEResNeXtBottleneck]], - layers: List[int], + layers: Sequence[int], groups: int, reduction: int, dropout_prob: Optional[float] = 0.2, @@ -248,20 +247,26 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -model_urls = { - "senet154": "http://data.lip6.fr/cadene/pretrainedmodels/senet154-c7b49a05.pth", - "se_resnet50": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet50-ce0d4300.pth", - "se_resnet101": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet101-7e38fcc6.pth", - "se_resnet152": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet152-d17c99b7.pth", - "se_resnext50_32x4d": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnext50_32x4d-a260b3a4.pth", - "se_resnext101_32x4d": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnext101_32x4d-3b2fe3d8.pth", -} - - -def _load_state_dict(model, model_url, progress): +def _load_state_dict(model, arch, progress): """ This function is used to load pretrained models. """ + model_urls = { + "senet154": "http://data.lip6.fr/cadene/pretrainedmodels/senet154-c7b49a05.pth", + "se_resnet50": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet50-ce0d4300.pth", + "se_resnet101": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet101-7e38fcc6.pth", + "se_resnet152": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnet152-d17c99b7.pth", + "se_resnext50_32x4d": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnext50_32x4d-a260b3a4.pth", + "se_resnext101_32x4d": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnext101_32x4d-3b2fe3d8.pth", + } + if arch in model_urls: + model_url = model_urls[arch] + else: + raise ValueError( + "only 'senet154', 'se_resnet50', 'se_resnet101', 'se_resnet152', 'se_resnext50_32x4d', \ + and se_resnext101_32x4d are supported to load pretrained weights." + ) + pattern_conv = re.compile(r"^(layer[1-4]\.\d\.(?:conv)\d\.)(\w*)$") pattern_bn = re.compile(r"^(layer[1-4]\.\d\.)(?:bn)(\d\.)(\w*)$") pattern_se = re.compile(r"^(layer[1-4]\.\d\.)(?:se_module.fc1.)(\w*)$") @@ -275,7 +280,7 @@ def _load_state_dict(model, model_url, progress): if pattern_conv.match(key): new_key = re.sub(pattern_conv, r"\1conv.\2", key) elif pattern_bn.match(key): - new_key = re.sub(pattern_bn, r"\1conv\2norm.\3", key) + new_key = re.sub(pattern_bn, r"\1conv\2adn.N.\3", key) elif pattern_se.match(key): state_dict[key] = state_dict[key].squeeze() new_key = re.sub(pattern_se, r"\1se_layer.fc.0.\2", key) @@ -285,7 +290,7 @@ def _load_state_dict(model, model_url, progress): elif pattern_down_conv.match(key): new_key = re.sub(pattern_down_conv, r"\1project.conv.\2", key) elif pattern_down_bn.match(key): - new_key = re.sub(pattern_down_bn, r"\1project.norm.\2", key) + new_key = re.sub(pattern_down_bn, r"\1project.adn.N.\2", key) if new_key: state_dict[new_key] = state_dict[key] del state_dict[key] @@ -298,167 +303,169 @@ def _load_state_dict(model, model_url, progress): model.load_state_dict(model_dict) -def senet154( - spatial_dims: int, - in_channels: int, - num_classes: int, - pretrained: bool = False, - progress: bool = True, -) -> SENet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `Cadene Hub 2D version - `_. - """ - model = SENet( - spatial_dims=spatial_dims, - in_channels=in_channels, - block=SEBottleneck, - layers=[3, 8, 36, 3], - groups=64, - reduction=16, - dropout_prob=0.2, - dropout_dim=1, - num_classes=num_classes, - ) - if pretrained: - arch = "senet154" - _load_state_dict(model, model_urls[arch], progress) - return model - - -def se_resnet50( - spatial_dims: int, in_channels: int, num_classes: int, pretrained: bool = False, progress: bool = True -) -> SENet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `Cadene Hub 2D version - `_. - """ - model = SENet( - spatial_dims=spatial_dims, - in_channels=in_channels, - block=SEResNetBottleneck, - layers=[3, 4, 6, 3], - groups=1, - reduction=16, - dropout_prob=None, - inplanes=64, - input_3x3=False, - downsample_kernel_size=1, - num_classes=num_classes, - ) - if pretrained: - arch = "se_resnet50" - _load_state_dict(model, model_urls[arch], progress) - return model - - -def se_resnet101( - spatial_dims: int, in_channels: int, num_classes: int, pretrained: bool = False, progress: bool = True -) -> SENet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `Cadene Hub 2D version - `_. - """ - model = SENet( - spatial_dims=spatial_dims, - in_channels=in_channels, - block=SEResNetBottleneck, - layers=[3, 4, 23, 3], - groups=1, - reduction=16, - dropout_prob=0.2, - dropout_dim=1, - inplanes=64, - input_3x3=False, - downsample_kernel_size=1, - num_classes=num_classes, - ) - if pretrained: - arch = "se_resnet101" - _load_state_dict(model, model_urls[arch], progress) - return model - - -def se_resnet152( - spatial_dims: int, in_channels: int, num_classes: int, pretrained: bool = False, progress: bool = True -) -> SENet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `Cadene Hub 2D version - `_. - """ - model = SENet( - spatial_dims=spatial_dims, - in_channels=in_channels, - block=SEResNetBottleneck, - layers=[3, 8, 36, 3], - groups=1, - reduction=16, - dropout_prob=0.2, - dropout_dim=1, - inplanes=64, - input_3x3=False, - downsample_kernel_size=1, - num_classes=num_classes, - ) - if pretrained: - arch = "se_resnet152" - _load_state_dict(model, model_urls[arch], progress) - return model - - -def se_resnext50_32x4d( - spatial_dims: int, in_channels: int, num_classes: int, pretrained: bool = False, progress: bool = True -) -> SENet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `Cadene Hub 2D version - `_. - """ - model = SENet( - spatial_dims=spatial_dims, - in_channels=in_channels, - block=SEResNeXtBottleneck, - layers=[3, 4, 6, 3], - groups=32, - reduction=16, - dropout_prob=None, - inplanes=64, - input_3x3=False, - downsample_kernel_size=1, - num_classes=num_classes, - ) - if pretrained: - arch = "se_resnext50_32x4d" - _load_state_dict(model, model_urls[arch], progress) - return model - - -def se_resnext101_32x4d( - spatial_dims: int, in_channels: int, num_classes: int, pretrained: bool = False, progress: bool = True -) -> SENet: - """ - when `spatial_dims = 2`, specify `pretrained = True` can load Imagenet pretrained weights achieved - from `Cadene Hub 2D version - `_. - """ - model = SENet( - spatial_dims=spatial_dims, - in_channels=in_channels, - block=SEResNeXtBottleneck, - layers=[3, 4, 23, 3], - groups=32, - reduction=16, - dropout_prob=None, - inplanes=64, - input_3x3=False, - downsample_kernel_size=1, - num_classes=num_classes, - ) - if pretrained: - arch = "se_resnext101_32x4d" - _load_state_dict(model, model_urls[arch], progress) - return model +class SENet154(SENet): + def __init__( + self, + layers: Sequence[int] = (3, 8, 36, 3), + groups: int = 64, + reduction: int = 16, + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(SENet154, self).__init__( + block=SEBottleneck, + layers=layers, + groups=groups, + reduction=reduction, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "senet154", progress) + + +class SEResNet50(SENet): + def __init__( + self, + layers: Sequence[int] = (3, 4, 6, 3), + groups: int = 1, + reduction: int = 16, + dropout_prob: Optional[float] = None, + inplanes: int = 64, + downsample_kernel_size: int = 1, + input_3x3: bool = False, + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(SEResNet50, self).__init__( + block=SEResNetBottleneck, + layers=layers, + groups=groups, + reduction=reduction, + dropout_prob=dropout_prob, + inplanes=inplanes, + downsample_kernel_size=downsample_kernel_size, + input_3x3=input_3x3, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "se_resnet50", progress) + + +class SEResNet101(SENet): + def __init__( + self, + layers: Sequence[int] = (3, 4, 23, 3), + groups: int = 1, + reduction: int = 16, + inplanes: int = 64, + downsample_kernel_size: int = 1, + input_3x3: bool = False, + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(SEResNet101, self).__init__( + block=SEResNetBottleneck, + layers=layers, + groups=groups, + reduction=reduction, + inplanes=inplanes, + downsample_kernel_size=downsample_kernel_size, + input_3x3=input_3x3, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "se_resnet101", progress) + + +class SEResNet152(SENet): + def __init__( + self, + layers: Sequence[int] = (3, 8, 36, 3), + groups: int = 1, + reduction: int = 16, + inplanes: int = 64, + downsample_kernel_size: int = 1, + input_3x3: bool = False, + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(SEResNet152, self).__init__( + block=SEResNetBottleneck, + layers=layers, + groups=groups, + reduction=reduction, + inplanes=inplanes, + downsample_kernel_size=downsample_kernel_size, + input_3x3=input_3x3, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "se_resnet152", progress) + + +class SEResNext50(SENet): + def __init__( + self, + layers: Sequence[int] = (3, 4, 6, 3), + groups: int = 32, + reduction: int = 16, + dropout_prob: Optional[float] = None, + inplanes: int = 64, + downsample_kernel_size: int = 1, + input_3x3: bool = False, + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(SEResNext50, self).__init__( + block=SEResNeXtBottleneck, + layers=layers, + groups=groups, + dropout_prob=dropout_prob, + reduction=reduction, + inplanes=inplanes, + downsample_kernel_size=downsample_kernel_size, + input_3x3=input_3x3, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "se_resnext50_32x4d", progress) + + +class SEResNext101(SENet): + def __init__( + self, + layers: Sequence[int] = (3, 4, 23, 3), + groups: int = 32, + reduction: int = 16, + dropout_prob: Optional[float] = None, + inplanes: int = 64, + downsample_kernel_size: int = 1, + input_3x3: bool = False, + pretrained: bool = False, + progress: bool = True, + **kwargs, + ) -> None: + super(SEResNext101, self).__init__( + block=SEResNeXtBottleneck, + layers=layers, + groups=groups, + dropout_prob=dropout_prob, + reduction=reduction, + inplanes=inplanes, + downsample_kernel_size=downsample_kernel_size, + input_3x3=input_3x3, + **kwargs, + ) + if pretrained: + # it only worked when `spatial_dims` is 2 + _load_state_dict(self, "se_resnext101_32x4d", progress) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 847bfc97c2..c5989f174b 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -14,13 +14,11 @@ import warnings from contextlib import contextmanager -from typing import Any, Callable, Optional, Sequence, cast +from typing import Any, Callable, Optional, Sequence import torch import torch.nn as nn -from monai.utils import ensure_tuple_size - __all__ = [ "one_hot", "slice_channels", @@ -50,13 +48,14 @@ def one_hot(labels: torch.Tensor, num_classes: int, dtype: torch.dtype = torch.f # if `dim` is bigger, add singleton dim at the end if labels.ndim < dim + 1: - shape = ensure_tuple_size(labels.shape, dim + 1, 1) - labels = labels.reshape(*shape) + shape = list(labels.shape) + [1] * (dim + 1 - len(labels.shape)) + labels = torch.reshape(labels, shape) sh = list(labels.shape) if sh[dim] != 1: - raise AssertionError("labels should have a channel with length equals to one.") + raise AssertionError("labels should have a channel with length equal to one.") + sh[dim] = num_classes o = torch.zeros(size=sh, dtype=dtype, device=labels.device) @@ -72,9 +71,7 @@ def slice_channels(tensor: torch.Tensor, *slicevals: Optional[int]) -> torch.Ten return tensor[slices] -def predict_segmentation( - logits: torch.Tensor, mutually_exclusive: bool = False, threshold: float = 0.0 -) -> torch.Tensor: +def predict_segmentation(logits: torch.Tensor, mutually_exclusive: bool = False, threshold: float = 0.0) -> Any: """ Given the logits from a network, computing the segmentation by thresholding all values above 0 if multi-labels task, computing the `argmax` along the channel axis if multi-classes task, @@ -87,10 +84,10 @@ def predict_segmentation( threshold: thresholding the prediction values if multi-labels task. """ if not mutually_exclusive: - return (cast(torch.Tensor, logits >= threshold)).int() + return (logits >= threshold).int() if logits.shape[1] == 1: warnings.warn("single channel prediction, `mutually_exclusive=True` ignored, use threshold instead.") - return (cast(torch.Tensor, logits >= threshold)).int() + return (logits >= threshold).int() return logits.argmax(1, keepdim=True) diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 6f7c2a4f61..22311cdca6 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -10,7 +10,7 @@ # limitations under the License. from .adaptors import FunctionSignature, adaptor, apply_alias, to_kwargs -from .compose import Compose, MapTransform, Randomizable, Transform +from .compose import Compose from .croppad.array import ( BorderPad, BoundingRect, @@ -25,6 +25,7 @@ SpatialCrop, SpatialPad, ) +from .croppad.batch import PadListDataCollate from .croppad.dictionary import ( BorderPadd, BorderPadD, @@ -78,11 +79,13 @@ RandHistogramShift, RandScaleIntensity, RandShiftIntensity, + RandStdShiftIntensity, SavitzkyGolaySmooth, ScaleIntensity, ScaleIntensityRange, ScaleIntensityRangePercentiles, ShiftIntensity, + StdShiftIntensity, ThresholdIntensity, ) from .intensity.dictionary import ( @@ -122,6 +125,9 @@ RandShiftIntensityd, RandShiftIntensityD, RandShiftIntensityDict, + RandStdShiftIntensityd, + RandStdShiftIntensityD, + RandStdShiftIntensityDict, ScaleIntensityd, ScaleIntensityD, ScaleIntensityDict, @@ -134,10 +140,14 @@ ShiftIntensityd, ShiftIntensityD, ShiftIntensityDict, + StdShiftIntensityd, + StdShiftIntensityD, + StdShiftIntensityDict, ThresholdIntensityd, ThresholdIntensityD, ThresholdIntensityDict, ) +from .inverse import InvertibleTransform from .io.array import LoadImage, SaveImage from .io.dictionary import LoadImaged, LoadImageD, LoadImageDict, SaveImaged, SaveImageD, SaveImageDict from .post.array import ( @@ -155,6 +165,9 @@ AsDiscreted, AsDiscreteD, AsDiscreteDict, + Decollated, + DecollateD, + DecollateDict, Ensembled, KeepLargestConnectedComponentd, KeepLargestConnectedComponentD, @@ -178,6 +191,7 @@ Rand3DElastic, RandAffine, RandAffineGrid, + RandAxisFlip, RandDeformGrid, RandFlip, RandRotate, @@ -191,6 +205,9 @@ Zoom, ) from .spatial.dictionary import ( + Affined, + AffineD, + AffineDict, Flipd, FlipD, FlipDict, @@ -206,6 +223,9 @@ RandAffined, RandAffineD, RandAffineDict, + RandAxisFlipd, + RandAxisFlipD, + RandAxisFlipDict, RandFlipd, RandFlipD, RandFlipDict, @@ -234,6 +254,7 @@ ZoomD, ZoomDict, ) +from .transform import MapTransform, Randomizable, RandomizableTransform, Transform, apply_transform from .utility.array import ( AddChannel, AddExtremePointsChannel, @@ -242,15 +263,18 @@ CastToType, ConvertToMultiChannelBasedOnBratsClasses, DataStats, + EnsureChannelFirst, FgBgToIndices, Identity, LabelToMask, Lambda, + RemoveRepeatedChannel, RepeatChannel, SimulateDelay, SplitChannel, SqueezeDim, ToNumpy, + ToPIL, TorchVision, ToTensor, Transpose, @@ -286,6 +310,9 @@ DeleteItemsd, DeleteItemsD, DeleteItemsDict, + EnsureChannelFirstd, + EnsureChannelFirstD, + EnsureChannelFirstDict, FgBgToIndicesd, FgBgToIndicesD, FgBgToIndicesDict, @@ -301,6 +328,9 @@ RandLambdad, RandLambdaD, RandLambdaDict, + RemoveRepeatedChanneld, + RemoveRepeatedChannelD, + RemoveRepeatedChannelDict, RepeatChanneld, RepeatChannelD, RepeatChannelDict, @@ -315,13 +345,20 @@ SqueezeDimD, SqueezeDimDict, ToNumpyd, + ToNumpyD, + ToNumpyDict, + ToPILd, + ToPILD, + ToPILDict, TorchVisiond, + TorchVisionD, + TorchVisionDict, ToTensord, ToTensorD, ToTensorDict, ) from .utils import ( - apply_transform, + allow_missing_keys_mode, copypaste_arrays, create_control_grid, create_grid, diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index 2d1fe4eccd..d509ea33a1 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -13,138 +13,26 @@ """ import warnings -from abc import ABC, abstractmethod -from typing import Any, Callable, Hashable, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Optional, Sequence, Union import numpy as np -from monai.config import KeysCollection -from monai.transforms.utils import apply_transform -from monai.utils import MAX_SEED, ensure_tuple, get_seed - -__all__ = ["Transform", "Randomizable", "Compose", "MapTransform"] - - -class Transform(ABC): - """ - An abstract class of a ``Transform``. - A transform is callable that processes ``data``. - - It could be stateful and may modify ``data`` in place, - the implementation should be aware of: - - #. thread safety when mutating its own states. - When used from a multi-process context, transform's instance variables are read-only. - #. ``data`` content unused by this transform may still be used in the - subsequent transforms in a composed transform. - #. storing too much information in ``data`` may not scale. - - See Also - - :py:class:`monai.transforms.Compose` - """ - - @abstractmethod - def __call__(self, data: Any): - """ - ``data`` is an element which often comes from an iteration over an - iterable, such as :py:class:`torch.utils.data.Dataset`. This method should - return an updated version of ``data``. - To simplify the input validations, most of the transforms assume that - - - ``data`` is a Numpy ndarray, PyTorch Tensor or string - - the data shape can be: - - #. string data without shape, `LoadImage` transform expects file paths - #. most of the pre-processing transforms expect: ``(num_channels, spatial_dim_1[, spatial_dim_2, ...])``, - except that `AddChannel` expects (spatial_dim_1[, spatial_dim_2, ...]) and - `AsChannelFirst` expects (spatial_dim_1[, spatial_dim_2, ...], num_channels) - #. most of the post-processing transforms expect - ``(batch_size, num_channels, spatial_dim_1[, spatial_dim_2, ...])`` - - - the channel dimension is not omitted even if number of channels is one - - This method can optionally take additional arguments to help execute transformation operation. - - Raises: - NotImplementedError: When the subclass does not override this method. - - """ - raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") - - -class Randomizable(ABC): - """ - An interface for handling random state locally, currently based on a class variable `R`, - which is an instance of `np.random.RandomState`. - This is mainly for randomized data augmentation transforms. For example:: - - class RandShiftIntensity(Randomizable): - def randomize(): - self._offset = self.R.uniform(low=0, high=100) - def __call__(self, img): - self.randomize() - return img + self._offset - - transform = RandShiftIntensity() - transform.set_random_state(seed=0) - - """ - - R: np.random.RandomState = np.random.RandomState() - - def set_random_state( - self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None - ) -> "Randomizable": - """ - Set the random state locally, to control the randomness, the derived - classes should use :py:attr:`self.R` instead of `np.random` to introduce random - factors. - - Args: - seed: set the random state with an integer seed. - state: set the random state with a `np.random.RandomState` object. - - Raises: - TypeError: When ``state`` is not an ``Optional[np.random.RandomState]``. +from monai.transforms.inverse import InvertibleTransform - Returns: - a Randomizable instance. - - """ - if seed is not None: - _seed = id(seed) if not isinstance(seed, (int, np.integer)) else seed - _seed = _seed % MAX_SEED - self.R = np.random.RandomState(_seed) - return self - - if state is not None: - if not isinstance(state, np.random.RandomState): - raise TypeError(f"state must be None or a np.random.RandomState but is {type(state).__name__}.") - self.R = state - return self - - self.R = np.random.RandomState() - return self - - @abstractmethod - def randomize(self, data: Any) -> None: - """ - Within this method, :py:attr:`self.R` should be used, instead of `np.random`, to introduce random factors. - - all :py:attr:`self.R` calls happen here so that we have a better chance to - identify errors of sync the random state. - - This method can generate the random factors based on properties of the input data. - - Raises: - NotImplementedError: When the subclass does not override this method. +# For backwards compatiblity (so this still works: from monai.transforms.compose import MapTransform) +from monai.transforms.transform import ( # noqa: F401 + MapTransform, + Randomizable, + RandomizableTransform, + Transform, + apply_transform, +) +from monai.utils import MAX_SEED, ensure_tuple, get_seed - """ - raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") +__all__ = ["Compose"] -class Compose(Randomizable, Transform): +class Compose(RandomizableTransform, InvertibleTransform): """ ``Compose`` provides the ability to chain a series of calls together in a sequence. Each transform in the sequence must take a single argument and @@ -214,14 +102,14 @@ def __init__(self, transforms: Optional[Union[Sequence[Callable], Callable]] = N def set_random_state(self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None) -> "Compose": super().set_random_state(seed=seed, state=state) for _transform in self.transforms: - if not isinstance(_transform, Randomizable): + if not isinstance(_transform, RandomizableTransform): continue _transform.set_random_state(seed=self.R.randint(MAX_SEED, dtype="uint32")) return self def randomize(self, data: Optional[Any] = None) -> None: for _transform in self.transforms: - if not isinstance(_transform, Randomizable): + if not isinstance(_transform, RandomizableTransform): continue try: _transform.randomize(data) @@ -256,66 +144,12 @@ def __call__(self, input_): input_ = apply_transform(_transform, input_) return input_ + def inverse(self, data): + invertible_transforms = [t for t in self.flatten().transforms if isinstance(t, InvertibleTransform)] + if len(invertible_transforms) == 0: + warnings.warn("inverse has been called but no invertible transforms have been supplied") -class MapTransform(Transform): - """ - A subclass of :py:class:`monai.transforms.Transform` with an assumption - that the ``data`` input of ``self.__call__`` is a MutableMapping such as ``dict``. - - The ``keys`` parameter will be used to get and set the actual data - item to transform. That is, the callable of this transform should - follow the pattern: - - .. code-block:: python - - def __call__(self, data): - for key in self.keys: - if key in data: - # update output data with some_transform_function(data[key]). - else: - # do nothing or some exceptions handling. - return data - - Raises: - ValueError: When ``keys`` is an empty iterable. - TypeError: When ``keys`` type is not in ``Union[Hashable, Iterable[Hashable]]``. - - """ - - def __init__(self, keys: KeysCollection) -> None: - self.keys: Tuple[Hashable, ...] = ensure_tuple(keys) - if not self.keys: - raise ValueError("keys must be non empty.") - for key in self.keys: - if not isinstance(key, Hashable): - raise TypeError(f"keys must be one of (Hashable, Iterable[Hashable]) but is {type(keys).__name__}.") - - @abstractmethod - def __call__(self, data): - """ - ``data`` often comes from an iteration over an iterable, - such as :py:class:`torch.utils.data.Dataset`. - - To simplify the input validations, this method assumes: - - - ``data`` is a Python dictionary - - ``data[key]`` is a Numpy ndarray, PyTorch Tensor or string, where ``key`` is an element - of ``self.keys``, the data shape can be: - - #. string data without shape, `LoadImaged` transform expects file paths - #. most of the pre-processing transforms expect: ``(num_channels, spatial_dim_1[, spatial_dim_2, ...])``, - except that `AddChanneld` expects (spatial_dim_1[, spatial_dim_2, ...]) and - `AsChannelFirstd` expects (spatial_dim_1[, spatial_dim_2, ...], num_channels) - #. most of the post-processing transforms expect - ``(batch_size, num_channels, spatial_dim_1[, spatial_dim_2, ...])`` - - - the channel dimension is not omitted even if number of channels is one - - Raises: - NotImplementedError: When the subclass does not override this method. - - returns: - An updated dictionary version of ``data`` by applying the transform. - - """ - raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + # loop backwards over transforms + for t in reversed(invertible_transforms): + data = apply_transform(t.inverse, data) + return data diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index b4444803a4..6174378e3b 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -20,7 +20,7 @@ from monai.config import IndexSelection from monai.data.utils import get_random_patch, get_valid_patch_size -from monai.transforms.compose import Randomizable, Transform +from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, @@ -106,7 +106,7 @@ class BorderPad(Transform): Pad the input data by adding specified borders to every dimension. Args: - spatial_border: specified size for every spatial border. it can be 3 shapes: + spatial_border: specified size for every spatial border. Any -ve values will be set to 0. It can be 3 shapes: - single int number, pad all the borders with the same size. - length equals the length of image shape, pad every spatial dimension separately. @@ -140,16 +140,16 @@ def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = N See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html Raises: - ValueError: When ``self.spatial_border`` contains a nonnegative int. + ValueError: When ``self.spatial_border`` does not contain ints. ValueError: When ``self.spatial_border`` length is not one of [1, len(spatial_shape), 2*len(spatial_shape)]. """ spatial_shape = img.shape[1:] spatial_border = ensure_tuple(self.spatial_border) - for b in spatial_border: - if not isinstance(b, int) or b < 0: - raise ValueError(f"self.spatial_border must contain only nonnegative ints, got {spatial_border}.") + if not all(isinstance(b, int) for b in spatial_border): + raise ValueError(f"self.spatial_border must contain only ints, got {spatial_border}.") + spatial_border = tuple(max(0, b) for b in spatial_border) if len(spatial_border) == 1: data_pad_width = [(spatial_border[0], spatial_border[0]) for _ in range(len(spatial_shape))] @@ -242,13 +242,16 @@ def __init__( raise ValueError("Please specify either roi_center, roi_size or roi_start, roi_end.") self.roi_start = np.maximum(np.asarray(roi_start, dtype=np.int16), 0) self.roi_end = np.maximum(np.asarray(roi_end, dtype=np.int16), self.roi_start) + # Allow for 1D by converting back to np.array (since np.maximum will convert to int) + self.roi_start = self.roi_start if isinstance(self.roi_start, np.ndarray) else np.array([self.roi_start]) + self.roi_end = self.roi_end if isinstance(self.roi_end, np.ndarray) else np.array([self.roi_end]) def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> np.ndarray: """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't apply to the channel dim. """ - sd = min(len(self.roi_start), len(self.roi_end), len(img.shape[1:])) # spatial dims + sd = min(self.roi_start.size, self.roi_end.size, len(img.shape[1:])) # spatial dims slices = [slice(None)] + [slice(s, e) for s, e in zip(self.roi_start[:sd], self.roi_end[:sd])] return np.asarray(img[tuple(slices)]) @@ -276,7 +279,7 @@ def __call__(self, img: np.ndarray): return cropper(img) -class RandSpatialCrop(Randomizable, Transform): +class RandSpatialCrop(RandomizableTransform): """ Crop image with random size or specific size ROI. It can crop at a random position as center or at the image center. And allows to set the minimum size to limit the randomly generated ROI. @@ -321,7 +324,7 @@ def __call__(self, img: np.ndarray): return cropper(img) -class RandSpatialCropSamples(Randomizable, Transform): +class RandSpatialCropSamples(RandomizableTransform): """ Crop image with random size or specific size ROI to generate a list of N samples. It can crop at a random position as center or at the image center. And allows to set @@ -429,7 +432,7 @@ def __call__(self, img: np.ndarray): return cropped -class RandWeightedCrop(Randomizable, Transform): +class RandWeightedCrop(RandomizableTransform): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -481,7 +484,7 @@ def __call__(self, img: np.ndarray, weight_map: Optional[np.ndarray] = None) -> return results -class RandCropByPosNegLabel(Randomizable, Transform): +class RandCropByPosNegLabel(RandomizableTransform): """ Crop random fixed sized regions with the center being a foreground or background voxel based on the Pos Neg Ratio. diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py new file mode 100644 index 0000000000..37ff8618fa --- /dev/null +++ b/monai/transforms/croppad/batch.py @@ -0,0 +1,129 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A collection of "vanilla" transforms for crop and pad operations acting on batches of data +https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design +""" + +from copy import deepcopy +from typing import Any, Dict, Hashable, Union + +import numpy as np +import torch + +from monai.data.utils import list_data_collate +from monai.transforms.compose import Compose +from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad +from monai.transforms.inverse import InvertibleTransform +from monai.transforms.utility.array import ToTensor +from monai.utils.enums import InverseKeys, Method, NumpyPadMode + +__all__ = [ + "PadListDataCollate", +] + + +def replace_element(to_replace, batch, idx, key_or_idx): + # since tuple is immutable we'll have to recreate + if isinstance(batch[idx], tuple): + batch_idx_list = list(batch[idx]) + batch_idx_list[key_or_idx] = to_replace + batch[idx] = tuple(batch_idx_list) + # else, replace + else: + batch[idx][key_or_idx] = to_replace + return batch + + +class PadListDataCollate(InvertibleTransform): + """ + Same as MONAI's ``list_data_collate``, except any tensors are centrally padded to match the shape of the biggest + tensor in each dimension. This transform is useful if some of the applied transforms generate batch data of + different sizes. + + This can be used on both list and dictionary data. In the case of the dictionary data, this transform will be added + to the list of invertible transforms. + + Note that normally, a user won't explicitly use the `__call__` method. Rather this would be passed to the `DataLoader`. + This means that `__call__` handles data as it comes out of a `DataLoader`, containing batch dimension. However, the + `inverse` operates on dictionaries containing images of shape `C,H,W,[D]`. This asymmetry is necessary so that we can + pass the inverse through multiprocessing. + + Args: + batch: batch of data to pad-collate + method: padding method (see :py:class:`monai.transforms.SpatialPad`) + mode: padding mode (see :py:class:`monai.transforms.SpatialPad`) + """ + + def __init__( + self, + method: Union[Method, str] = Method.SYMMETRIC, + mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, + ) -> None: + self.method = method + self.mode = mode + + def __call__(self, batch: Any): + # data is either list of dicts or list of lists + is_list_of_dicts = isinstance(batch[0], dict) + # loop over items inside of each element in a batch + for key_or_idx in batch[0].keys() if is_list_of_dicts else range(len(batch[0])): + # calculate max size of each dimension + max_shapes = [] + for elem in batch: + if not isinstance(elem[key_or_idx], (torch.Tensor, np.ndarray)): + break + max_shapes.append(elem[key_or_idx].shape[1:]) + # len > 0 if objects were arrays, else skip as no padding to be done + if len(max_shapes) == 0: + continue + max_shape = np.array(max_shapes).max(axis=0) + # If all same size, skip + if np.all(np.array(max_shapes).min(axis=0) == max_shape): + continue + # Do we need to convert output to Tensor? + output_to_tensor = isinstance(batch[0][key_or_idx], torch.Tensor) + + # Use `SpatialPadd` or `SpatialPad` to match sizes + # Default params are central padding, padding with 0's + # If input is dictionary, use the dictionary version so that the transformation is recorded + + padder = SpatialPad(max_shape, self.method, self.mode) # type: ignore + transform = padder if not output_to_tensor else Compose([padder, ToTensor()]) + + for idx in range(len(batch)): + im = batch[idx][key_or_idx] + orig_size = im.shape[1:] + padded = transform(batch[idx][key_or_idx]) + batch = replace_element(padded, batch, idx, key_or_idx) + + # If we have a dictionary of data, append to list + if is_list_of_dicts: + self.push_transform(batch[idx], key_or_idx, orig_size=orig_size) + + # 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): + raise RuntimeError("Inverse can only currently be applied on dictionaries.") + + d = deepcopy(data) + for key in d.keys(): + transform_key = str(key) + InverseKeys.KEY_SUFFIX + if transform_key in d.keys(): + transform = d[transform_key][-1] + if transform[InverseKeys.CLASS_NAME] == PadListDataCollate.__name__: + d[key] = CenterSpatialCrop(transform["orig_size"])(d[key]) + # remove transform + d[transform_key].pop() + return d diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 1faed25605..c3523f3993 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -15,13 +15,15 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ +from copy import deepcopy +from itertools import chain +from math import floor from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np from monai.config import IndexSelection, KeysCollection from monai.data.utils import get_random_patch, get_valid_patch_size -from monai.transforms.compose import MapTransform, Randomizable from monai.transforms.croppad.array import ( BorderPad, BoundingRect, @@ -31,6 +33,8 @@ SpatialCrop, SpatialPad, ) +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, generate_spatial_bounding_box, @@ -38,6 +42,7 @@ weighted_patch_samples, ) from monai.utils import Method, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple +from monai.utils.enums import InverseKeys __all__ = [ "NumpyPadModeSequence", @@ -82,7 +87,7 @@ NumpyPadModeSequence = Union[Sequence[Union[NumpyPadMode, str]], NumpyPadMode, str] -class SpatialPadd(MapTransform): +class SpatialPadd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.SpatialPad`. Performs padding to the data, symmetric for all sides or all on one side for each dimension. @@ -94,6 +99,7 @@ def __init__( spatial_size: Union[Sequence[int], int], method: Union[Method, str] = Method.SYMMETRIC, mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -108,20 +114,42 @@ def __init__( One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html It also can be a sequence of string, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padder = SpatialPad(spatial_size, method) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key, m in zip(self.keys, self.mode): + for key, m in self.key_iterator(d, self.mode): + self.push_transform(d, key) d[key] = self.padder(d[key], mode=m) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = transform[InverseKeys.ORIG_SIZE] + if self.padder.method == Method.SYMMETRIC: + current_size = d[key].shape[1:] + roi_center = [floor(i / 2) if r % 2 == 0 else (i - 1) // 2 for r, i in zip(orig_size, current_size)] + else: + roi_center = [floor(r / 2) if r % 2 == 0 else (r - 1) // 2 for r in orig_size] + + inverse_transform = SpatialCrop(roi_center, orig_size) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d + -class BorderPadd(MapTransform): +class BorderPadd(MapTransform, InvertibleTransform): """ Pad the input data by adding specified borders to every dimension. Dictionary-based wrapper of :py:class:`monai.transforms.BorderPad`. @@ -132,6 +160,7 @@ def __init__( keys: KeysCollection, spatial_border: Union[Sequence[int], int], mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -153,27 +182,57 @@ def __init__( One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html It also can be a sequence of string, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padder = BorderPad(spatial_border=spatial_border) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key, m in zip(self.keys, self.mode): + for key, m in self.key_iterator(d, self.mode): + self.push_transform(d, key) d[key] = self.padder(d[key], mode=m) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = np.array(transform[InverseKeys.ORIG_SIZE]) + roi_start = np.array(self.padder.spatial_border) + # Need to convert single value to [min1,min2,...] + if roi_start.size == 1: + roi_start = np.full((len(orig_size)), roi_start) + # need to convert [min1,max1,min2,...] to [min1,min2,...] + elif roi_start.size == 2 * orig_size.size: + roi_start = roi_start[::2] + roi_end = np.array(transform[InverseKeys.ORIG_SIZE]) + roi_start + + inverse_transform = SpatialCrop(roi_start=roi_start, roi_end=roi_end) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) -class DivisiblePadd(MapTransform): + return d + + +class DivisiblePadd(MapTransform, InvertibleTransform): """ Pad the input data, so that the spatial sizes are divisible by `k`. Dictionary-based wrapper of :py:class:`monai.transforms.DivisiblePad`. """ def __init__( - self, keys: KeysCollection, k: Union[Sequence[int], int], mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT + self, + keys: KeysCollection, + k: Union[Sequence[int], int], + mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -187,22 +246,42 @@ def __init__( One of the listed string values or a user supplied function. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html It also can be a sequence of string, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. See also :py:class:`monai.transforms.SpatialPad` """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padder = DivisiblePad(k=k) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key, m in zip(self.keys, self.mode): + for key, m in self.key_iterator(d, self.mode): + self.push_transform(d, key) d[key] = self.padder(d[key], mode=m) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = np.array(transform[InverseKeys.ORIG_SIZE]) + current_size = np.array(d[key].shape[1:]) + roi_start = np.floor((current_size - orig_size) / 2) + roi_end = orig_size + roi_start + inverse_transform = SpatialCrop(roi_start=roi_start, roi_end=roi_end) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) -class SpatialCropd(MapTransform): + return d + + +class SpatialCropd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.SpatialCrop`. Either a spatial center and size must be provided, or alternatively if center and size @@ -216,6 +295,7 @@ def __init__( roi_size: Optional[Sequence[int]] = None, roi_start: Optional[Sequence[int]] = None, roi_end: Optional[Sequence[int]] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -225,18 +305,39 @@ def __init__( roi_size: size of the crop ROI. roi_start: voxel coordinates for start of the crop ROI. roi_end: voxel coordinates for end of the crop ROI. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.cropper = SpatialCrop(roi_center, roi_size, roi_start, roi_end) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + self.push_transform(d, key) d[key] = self.cropper(d[key]) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = transform[InverseKeys.ORIG_SIZE] + pad_to_start = np.array(self.cropper.roi_start) + pad_to_end = orig_size - self.cropper.roi_end + # interleave mins and maxes + pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) + inverse_transform = BorderPad(pad) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d + -class CenterSpatialCropd(MapTransform): +class CenterSpatialCropd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.CenterSpatialCrop`. @@ -245,20 +346,46 @@ class CenterSpatialCropd(MapTransform): See also: monai.transforms.MapTransform roi_size: the size of the crop region e.g. [224,224,128] If its components have non-positive values, the corresponding size of input image will be used. + allow_missing_keys: don't raise exception if key is missing. """ - def __init__(self, keys: KeysCollection, roi_size: Union[Sequence[int], int]) -> None: - super().__init__(keys) + def __init__( + self, keys: KeysCollection, roi_size: Union[Sequence[int], int], allow_missing_keys: bool = False + ) -> None: + super().__init__(keys, allow_missing_keys) self.cropper = CenterSpatialCrop(roi_size) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + orig_size = d[key].shape[1:] d[key] = self.cropper(d[key]) + self.push_transform(d, key, orig_size=orig_size) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = np.array(transform[InverseKeys.ORIG_SIZE]) + current_size = np.array(d[key].shape[1:]) + pad_to_start = np.floor((orig_size - current_size) / 2).astype(int) + # in each direction, if original size is even and current size is odd, += 1 + pad_to_start[np.logical_and(orig_size % 2 == 0, current_size % 2 == 1)] += 1 + pad_to_end = orig_size - current_size - pad_to_start + pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) + inverse_transform = BorderPad(pad) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d -class RandSpatialCropd(Randomizable, MapTransform): + +class RandSpatialCropd(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandSpatialCrop`. Crop image with random size or specific size ROI. It can crop at a random position as @@ -274,6 +401,7 @@ class RandSpatialCropd(Randomizable, MapTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -282,8 +410,10 @@ def __init__( roi_size: Union[Sequence[int], int], random_center: bool = True, random_size: bool = True, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + RandomizableTransform.__init__(self, prob=1.0, do_transform=True) + MapTransform.__init__(self, keys, allow_missing_keys) self.roi_size = roi_size self.random_center = random_center self.random_size = random_size @@ -303,16 +433,50 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda self.randomize(d[self.keys[0]].shape[1:]) # image shape from the first data key if self._size is None: raise AssertionError - for key in self.keys: + for key in self.key_iterator(d): if self.random_center: + self.push_transform(d, key, {"slices": [(i.start, i.stop) for i in self._slices[1:]]}) # type: ignore d[key] = d[key][self._slices] else: + self.push_transform(d, key) cropper = CenterSpatialCrop(self._size) d[key] = cropper(d[key]) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = transform[InverseKeys.ORIG_SIZE] + random_center = self.random_center + pad_to_start = np.empty((len(orig_size)), dtype=np.int32) + pad_to_end = np.empty((len(orig_size)), dtype=np.int32) + if random_center: + for i, _slice in enumerate(transform[InverseKeys.EXTRA_INFO]["slices"]): + pad_to_start[i] = _slice[0] + pad_to_end[i] = orig_size[i] - _slice[1] + else: + current_size = d[key].shape[1:] + for i, (o_s, c_s) in enumerate(zip(orig_size, current_size)): + pad_to_start[i] = pad_to_end[i] = (o_s - c_s) / 2 + if o_s % 2 == 0 and c_s % 2 == 1: + pad_to_start[i] += 1 + elif o_s % 2 == 1 and c_s % 2 == 0: + pad_to_end[i] += 1 + # interleave mins and maxes + pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) + inverse_transform = BorderPad(pad) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d + -class RandSpatialCropSamplesd(Randomizable, MapTransform): +class RandSpatialCropSamplesd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandSpatialCropSamples`. Crop image with random size or specific size ROI to generate a list of N samples. @@ -330,6 +494,7 @@ class RandSpatialCropSamplesd(Randomizable, MapTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. + allow_missing_keys: don't raise exception if key is missing. Raises: ValueError: When ``num_samples`` is nonpositive. @@ -343,12 +508,14 @@ def __init__( num_samples: int, random_center: bool = True, random_size: bool = True, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + RandomizableTransform.__init__(self, prob=1.0, do_transform=True) + MapTransform.__init__(self, keys, allow_missing_keys) if num_samples < 1: raise ValueError(f"num_samples must be positive, got {num_samples}.") self.num_samples = num_samples - self.cropper = RandSpatialCropd(keys, roi_size, random_center, random_size) + self.cropper = RandSpatialCropd(keys, roi_size, random_center, random_size, allow_missing_keys) def set_random_state( self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None @@ -364,7 +531,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n return [self.cropper(data) for _ in range(self.num_samples)] -class CropForegroundd(MapTransform): +class CropForegroundd(MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.CropForeground`. Crop only the foreground object of the expected images. @@ -386,6 +553,7 @@ def __init__( margin: int = 0, start_coord_key: str = "foreground_start_coord", end_coord_key: str = "foreground_end_coord", + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -398,8 +566,9 @@ def __init__( margin: add margin value to spatial dims of the bounding box, if only 1 value provided, use it for all dims. start_coord_key: key to record the start coordinate of spatial bounding box for foreground. end_coord_key: key to record the end coordinate of spatial bounding box for foreground. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.source_key = source_key self.select_fn = select_fn self.channel_indices = ensure_tuple(channel_indices) if channel_indices is not None else None @@ -415,12 +584,32 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda d[self.start_coord_key] = np.asarray(box_start) d[self.end_coord_key] = np.asarray(box_end) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) - for key in self.keys: + for key in self.key_iterator(d): + self.push_transform(d, key, extra_info={"box_start": box_start, "box_end": box_end}) d[key] = cropper(d[key]) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = np.array(transform[InverseKeys.ORIG_SIZE]) + extra_info = transform[InverseKeys.EXTRA_INFO] + pad_to_start = np.array(extra_info["box_start"]) + pad_to_end = orig_size - np.array(extra_info["box_end"]) + # interleave mins and maxes + pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) + inverse_transform = BorderPad(pad) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) -class RandWeightedCropd(Randomizable, MapTransform): + return d + + +class RandWeightedCropd(RandomizableTransform, MapTransform): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -433,6 +622,7 @@ class RandWeightedCropd(Randomizable, MapTransform): If its components have non-positive values, the corresponding size of `img` will be used. num_samples: number of samples (image patches) to take in the returned list. center_coord_key: if specified, the actual sampling location will be stored with the corresponding key. + allow_missing_keys: don't raise exception if key is missing. See Also: :py:class:`monai.transforms.RandWeightedCrop` @@ -445,8 +635,10 @@ def __init__( spatial_size: Union[Sequence[int], int], num_samples: int = 1, center_coord_key: Optional[str] = None, + allow_missing_keys: bool = False, ): - super().__init__(keys) + RandomizableTransform.__init__(self, prob=1.0, do_transform=True) + MapTransform.__init__(self, keys, allow_missing_keys) self.spatial_size = ensure_tuple(spatial_size) self.w_key = w_key self.num_samples = int(num_samples) @@ -464,27 +656,27 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n _spatial_size = fall_back_tuple(self.spatial_size, d[self.w_key].shape[1:]) results: List[Dict[Hashable, np.ndarray]] = [{} for _ in range(self.num_samples)] - for key in data.keys(): - if key in self.keys: - img = d[key] - if img.shape[1:] != d[self.w_key].shape[1:]: - raise ValueError( - f"data {key} and weight map {self.w_key} spatial shape mismatch: " - f"{img.shape[1:]} vs {d[self.w_key].shape[1:]}." - ) - for i, center in enumerate(self.centers): - cropper = SpatialCrop(roi_center=center, roi_size=_spatial_size) - results[i][key] = cropper(img) - if self.center_coord_key: - results[i][self.center_coord_key] = center - else: - for i in range(self.num_samples): - results[i][key] = data[key] + for key in self.key_iterator(d): + img = d[key] + if img.shape[1:] != d[self.w_key].shape[1:]: + raise ValueError( + f"data {key} and weight map {self.w_key} spatial shape mismatch: " + f"{img.shape[1:]} vs {d[self.w_key].shape[1:]}." + ) + for i, center in enumerate(self.centers): + cropper = SpatialCrop(roi_center=center, roi_size=_spatial_size) + results[i][key] = cropper(img) + if self.center_coord_key: + results[i][self.center_coord_key] = center + # fill in the extra keys with unmodified data + for key in set(data.keys()).difference(set(self.keys)): + for i in range(self.num_samples): + results[i][key] = data[key] return results -class RandCropByPosNegLabeld(Randomizable, MapTransform): +class RandCropByPosNegLabeld(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandCropByPosNegLabel`. Crop random fixed sized regions with the center being a foreground or background voxel @@ -514,6 +706,7 @@ class RandCropByPosNegLabeld(Randomizable, MapTransform): `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `FgBgToIndicesd` transform first and cache the results. + allow_missing_keys: don't raise exception if key is missing. Raises: ValueError: When ``pos`` or ``neg`` are negative. @@ -533,8 +726,10 @@ def __init__( image_threshold: float = 0.0, fg_indices_key: Optional[str] = None, bg_indices_key: Optional[str] = None, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + RandomizableTransform.__init__(self) + MapTransform.__init__(self, keys, allow_missing_keys) self.label_key = label_key self.spatial_size: Union[Tuple[int, ...], Sequence[int], int] = spatial_size if pos < 0 or neg < 0: @@ -579,20 +774,20 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n if self.centers is None: raise AssertionError results: List[Dict[Hashable, np.ndarray]] = [{} for _ in range(self.num_samples)] - for key in data.keys(): - if key in self.keys: + + for i, center in enumerate(self.centers): + for key in self.key_iterator(d): img = d[key] - for i, center in enumerate(self.centers): - cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore - results[i][key] = cropper(img) - else: - for i in range(self.num_samples): - results[i][key] = data[key] + cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore + results[i][key] = cropper(img) + # fill in the extra keys with unmodified data + for key in set(data.keys()).difference(set(self.keys)): + results[i][key] = data[key] return results -class ResizeWithPadOrCropd(MapTransform): +class ResizeWithPadOrCropd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.ResizeWithPadOrCrop`. @@ -605,6 +800,7 @@ class ResizeWithPadOrCropd(MapTransform): ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``. See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + allow_missing_keys: don't raise exception if key is missing. """ @@ -613,14 +809,52 @@ def __init__( keys: KeysCollection, spatial_size: Union[Sequence[int], int], mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, mode=mode) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + orig_size = d[key].shape[1:] d[key] = self.padcropper(d[key]) + self.push_transform(d, key, orig_size=orig_size) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + orig_size = np.array(transform[InverseKeys.ORIG_SIZE]) + current_size = np.array(d[key].shape[1:]) + # Unfortunately, we can't just use ResizeWithPadOrCrop with original size because of odd/even rounding. + # Instead, we first pad any smaller dimensions, and then we crop any larger dimensions. + + # First, do pad + if np.any((orig_size - current_size) > 0): + pad_to_start = np.floor((orig_size - current_size) / 2).astype(int) + # in each direction, if original size is even and current size is odd, += 1 + pad_to_start[np.logical_and(orig_size % 2 == 0, current_size % 2 == 1)] += 1 + pad_to_start[pad_to_start < 0] = 0 + pad_to_end = orig_size - current_size - pad_to_start + pad_to_end[pad_to_end < 0] = 0 + pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) + d[key] = BorderPad(pad)(d[key]) + + # Next crop + if np.any((orig_size - current_size) < 0): + if self.padcropper.padder.method == Method.SYMMETRIC: + roi_center = [floor(i / 2) if r % 2 == 0 else (i - 1) // 2 for r, i in zip(orig_size, current_size)] + else: + roi_center = [floor(r / 2) if r % 2 == 0 else (r - 1) // 2 for r in orig_size] + + d[key] = SpatialCrop(roi_center, orig_size)(d[key]) + + # Remove the applied transform + self.pop_transform(d, key) + return d @@ -634,10 +868,17 @@ class BoundingRectd(MapTransform): bbox_key_postfix: the output bounding box coordinates will be written to the value of `{key}_{bbox_key_postfix}`. select_fn: function to select expected foreground, default is to select values > 0. + allow_missing_keys: don't raise exception if key is missing. """ - def __init__(self, keys: KeysCollection, bbox_key_postfix: str = "bbox", select_fn: Callable = lambda x: x > 0): - super().__init__(keys=keys) + def __init__( + self, + keys: KeysCollection, + bbox_key_postfix: str = "bbox", + select_fn: Callable = lambda x: x > 0, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) self.bbox = BoundingRect(select_fn=select_fn) self.bbox_key_postfix = bbox_key_postfix @@ -646,7 +887,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda See also: :py:class:`monai.transforms.utils.generate_spatial_bounding_box`. """ d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): bbox = self.bbox(d[key]) key_to_add = f"{key}_{self.bbox_key_postfix}" if key_to_add in d: diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 87091f6237..abd7de151f 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -22,7 +22,7 @@ from monai.config import DtypeLike from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter -from monai.transforms.compose import Randomizable, Transform +from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import rescale_array from monai.utils import PT_BEFORE_1_7, InvalidPyTorchVersionError, dtype_torch_to_numpy, ensure_tuple_size @@ -30,6 +30,8 @@ "RandGaussianNoise", "ShiftIntensity", "RandShiftIntensity", + "StdShiftIntensity", + "RandStdShiftIntensity", "ScaleIntensity", "RandScaleIntensity", "NormalizeIntensity", @@ -49,7 +51,7 @@ ] -class RandGaussianNoise(Randomizable, Transform): +class RandGaussianNoise(RandomizableTransform): """ Add Gaussian noise to image. @@ -60,14 +62,13 @@ class RandGaussianNoise(Randomizable, Transform): """ def __init__(self, prob: float = 0.1, mean: Union[Sequence[float], float] = 0.0, std: float = 0.1) -> None: - self.prob = prob + RandomizableTransform.__init__(self, prob) self.mean = mean self.std = std - self._do_transform = False self._noise = None def randomize(self, im_shape: Sequence[int]) -> None: - self._do_transform = self.R.random() < self.prob + super().randomize(None) self._noise = self.R.normal(self.mean, self.R.uniform(0, self.std), size=im_shape) def __call__(self, img: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, np.ndarray]: @@ -101,7 +102,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return np.asarray((img + self.offset), dtype=img.dtype) -class RandShiftIntensity(Randomizable, Transform): +class RandShiftIntensity(RandomizableTransform): """ Randomly shift intensity with randomly picked offset. """ @@ -113,6 +114,7 @@ def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1 if single number, offset value is picked from (-offsets, offsets). prob: probability of shift. """ + RandomizableTransform.__init__(self, prob) if isinstance(offsets, (int, float)): self.offsets = (min(-offsets, offsets), max(-offsets, offsets)) else: @@ -120,12 +122,9 @@ def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1 raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) - self.prob = prob - self._do_transform = False - def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) - self._do_transform = self.R.random() < self.prob + super().randomize(None) def __call__(self, img: np.ndarray) -> np.ndarray: """ @@ -138,6 +137,94 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return shifter(img) +class StdShiftIntensity(Transform): + """ + Shift intensity for the image with a factor and the standard deviation of the image + by: ``v = v + factor * std(v)``. + This transform can focus on only non-zero values or the entire image, + and can also calculate the std on each channel separately. + + Args: + factor: factor shift by ``v = v + factor * std(v)``. + nonzero: whether only count non-zero values. + channel_wise: if True, calculate on each channel separately. Please ensure + that the first dimension represents the channel of the image if True. + """ + + def __init__(self, factor: float, nonzero: bool = False, channel_wise: bool = False) -> None: + self.factor = factor + self.nonzero = nonzero + self.channel_wise = channel_wise + + def _stdshift(self, img: np.ndarray) -> np.ndarray: + slices = (img != 0) if self.nonzero else np.ones(img.shape, dtype=bool) + if not np.any(slices): + return img + offset = self.factor * np.std(img[slices]) + img[slices] = img[slices] + offset + return img + + def __call__(self, img: np.ndarray) -> np.ndarray: + """ + Apply the transform to `img`. + """ + if img.dtype != float: + img = img.astype(float) + if self.channel_wise: + for i, d in enumerate(img): + img[i] = self._stdshift(d) + else: + img = self._stdshift(img) + return img + + +class RandStdShiftIntensity(RandomizableTransform): + """ + Shift intensity for the image with a factor and the standard deviation of the image + by: ``v = v + factor * std(v)`` where the `factor` is randomly picked. + """ + + def __init__( + self, + factors: Union[Tuple[float, float], float], + prob: float = 0.1, + nonzero: bool = False, + channel_wise: bool = False, + ) -> None: + """ + Args: + factors: if tuple, the randomly picked range is (min(factors), max(factors)). + If single number, the range is (-factors, factors). + prob: probability of std shift. + nonzero: whether only count non-zero values. + channel_wise: if True, calculate on each channel separately. + + """ + RandomizableTransform.__init__(self, prob) + if isinstance(factors, (int, float)): + self.factors = (min(-factors, factors), max(-factors, factors)) + else: + if len(factors) != 2: + raise AssertionError("factors should be a number or pair of numbers.") + self.factors = (min(factors), max(factors)) + self.nonzero = nonzero + self.channel_wise = channel_wise + + def randomize(self, data: Optional[Any] = None) -> None: + self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) + super().randomize(None) + + def __call__(self, img: np.ndarray) -> np.ndarray: + """ + Apply the transform to `img`. + """ + self.randomize() + if not self._do_transform: + return img + shifter = StdShiftIntensity(factor=self.factor, nonzero=self.nonzero, channel_wise=self.channel_wise) + return shifter(img) + + class ScaleIntensity(Transform): """ Scale the intensity of input image to the given value range (minv, maxv). @@ -151,7 +238,8 @@ def __init__( Args: minv: minimum value of output data. maxv: maximum value of output data. - factor: factor scale by ``v = v * (1 + factor)``. + factor: factor scale by ``v = v * (1 + factor)``. In order to use + this parameter, please set `minv` and `maxv` into None. """ self.minv = minv self.maxv = maxv @@ -172,10 +260,10 @@ def __call__(self, img: np.ndarray) -> np.ndarray: raise ValueError("Incompatible values: minv=None or maxv=None and factor=None.") -class RandScaleIntensity(Randomizable, Transform): +class RandScaleIntensity(RandomizableTransform): """ Randomly scale the intensity of input image by ``v = v * (1 + factor)`` where the `factor` - is randomly picked from (-factors[0], factors[0]). + is randomly picked. """ def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1) -> None: @@ -186,6 +274,7 @@ def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1 prob: probability of scale. """ + RandomizableTransform.__init__(self, prob) if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) else: @@ -193,12 +282,9 @@ def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1 raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) - self.prob = prob - self._do_transform = False - def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) - self._do_transform = self.R.random() < self.prob + super().randomize(None) def __call__(self, img: np.ndarray) -> np.ndarray: """ @@ -243,7 +329,7 @@ def __init__( self.dtype = dtype def _normalize(self, img: np.ndarray, sub=None, div=None) -> np.ndarray: - slices = (img != 0) if self.nonzero else np.ones(img.shape, dtype=np.bool_) + slices = (img != 0) if self.nonzero else np.ones(img.shape, dtype=bool) if not np.any(slices): return img @@ -370,7 +456,7 @@ def __call__(self, img: np.ndarray): return np.power(((img - img_min) / float(img_range + epsilon)), self.gamma) * img_range + img_min -class RandAdjustContrast(Randomizable, Transform): +class RandAdjustContrast(RandomizableTransform): """ Randomly changes image intensity by gamma. Each pixel/voxel intensity is updated as:: @@ -383,7 +469,7 @@ class RandAdjustContrast(Randomizable, Transform): """ def __init__(self, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0.5, 4.5)) -> None: - self.prob = prob + RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): if gamma <= 0.5: @@ -396,11 +482,10 @@ def __init__(self, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0. raise AssertionError("gamma should be a number or pair of numbers.") self.gamma = (min(gamma), max(gamma)) - self._do_transform = False self.gamma_value = None def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.gamma_value = self.R.uniform(low=self.gamma[0], high=self.gamma[1]) def __call__(self, img: np.ndarray) -> np.ndarray: @@ -657,7 +742,7 @@ def __call__(self, img: np.ndarray): return gaussian_filter(input_data).squeeze(0).detach().numpy() -class RandGaussianSmooth(Randomizable, Transform): +class RandGaussianSmooth(RandomizableTransform): """ Apply Gaussian smooth to the input data based on randomly selected `sigma` parameters. @@ -679,15 +764,14 @@ def __init__( prob: float = 0.1, approx: str = "erf", ) -> None: + RandomizableTransform.__init__(self, prob) self.sigma_x = sigma_x self.sigma_y = sigma_y self.sigma_z = sigma_z - self.prob = prob self.approx = approx - self._do_transform = False def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.x = self.R.uniform(low=self.sigma_x[0], high=self.sigma_x[1]) self.y = self.R.uniform(low=self.sigma_y[0], high=self.sigma_y[1]) self.z = self.R.uniform(low=self.sigma_z[0], high=self.sigma_z[1]) @@ -748,7 +832,7 @@ def __call__(self, img: np.ndarray): return (blurred_f + self.alpha * (blurred_f - filter_blurred_f)).squeeze(0).detach().numpy() -class RandGaussianSharpen(Randomizable, Transform): +class RandGaussianSharpen(RandomizableTransform): """ Sharpen images using the Gaussian Blur filter based on randomly selected `sigma1`, `sigma2` and `alpha`. The algorithm is :py:class:`monai.transforms.GaussianSharpen`. @@ -782,6 +866,7 @@ def __init__( approx: str = "erf", prob: float = 0.1, ) -> None: + RandomizableTransform.__init__(self, prob) self.sigma1_x = sigma1_x self.sigma1_y = sigma1_y self.sigma1_z = sigma1_z @@ -790,11 +875,9 @@ def __init__( self.sigma2_z = sigma2_z self.alpha = alpha self.approx = approx - self.prob = prob - self._do_transform = False def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.x1 = self.R.uniform(low=self.sigma1_x[0], high=self.sigma1_x[1]) self.y1 = self.R.uniform(low=self.sigma1_y[0], high=self.sigma1_y[1]) self.z1 = self.R.uniform(low=self.sigma1_z[0], high=self.sigma1_z[1]) @@ -815,7 +898,7 @@ def __call__(self, img: np.ndarray): return GaussianSharpen(sigma1=sigma1, sigma2=sigma2, alpha=self.a, approx=self.approx)(img) -class RandHistogramShift(Randomizable, Transform): +class RandHistogramShift(RandomizableTransform): """ Apply random nonlinear transform to the image's intensity histogram. @@ -827,6 +910,7 @@ class RandHistogramShift(Randomizable, Transform): """ def __init__(self, num_control_points: Union[Tuple[int, int], int] = 10, prob: float = 0.1) -> None: + RandomizableTransform.__init__(self, prob) if isinstance(num_control_points, int): if num_control_points <= 2: @@ -838,11 +922,9 @@ def __init__(self, num_control_points: Union[Tuple[int, int], int] = 10, prob: f if min(num_control_points) <= 2: raise AssertionError("num_control_points should be greater than or equal to 3") self.num_control_points = (min(num_control_points), max(num_control_points)) - self.prob = prob - self._do_transform = False def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random() < self.prob + super().randomize(None) num_control_point = self.R.randint(self.num_control_points[0], self.num_control_points[1] + 1) self.reference_control_points = np.linspace(0, 1, num_control_point) self.floating_control_points = np.copy(self.reference_control_points) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 1c9b31c120..881a0d3dc9 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -22,7 +22,6 @@ import torch from monai.config import DtypeLike, KeysCollection -from monai.transforms.compose import MapTransform, Randomizable from monai.transforms.intensity.array import ( AdjustContrast, GaussianSharpen, @@ -33,8 +32,10 @@ ScaleIntensityRange, ScaleIntensityRangePercentiles, ShiftIntensity, + StdShiftIntensity, ThresholdIntensity, ) +from monai.transforms.transform import MapTransform, RandomizableTransform from monai.utils import dtype_torch_to_numpy, ensure_tuple_rep, ensure_tuple_size __all__ = [ @@ -92,7 +93,7 @@ ] -class RandGaussianNoised(Randomizable, MapTransform): +class RandGaussianNoised(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandGaussianNoise`. Add Gaussian noise to image. This transform assumes all the expected fields have same shape. @@ -103,20 +104,25 @@ class RandGaussianNoised(Randomizable, MapTransform): prob: Probability to add Gaussian noise. mean: Mean or “centre” of the distribution. std: Standard deviation (spread) of distribution. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( - self, keys: KeysCollection, prob: float = 0.1, mean: Union[Sequence[float], float] = 0.0, std: float = 0.1 + self, + keys: KeysCollection, + prob: float = 0.1, + mean: Union[Sequence[float], float] = 0.0, + std: float = 0.1, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) - self.prob = prob + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.mean = ensure_tuple_rep(mean, len(self.keys)) self.std = std - self._do_transform = False self._noise: List[np.ndarray] = [] def randomize(self, im_shape: Sequence[int]) -> None: - self._do_transform = self.R.random() < self.prob + super().randomize(None) self._noise.clear() for m in self.mean: self._noise.append(self.R.normal(m, self.R.uniform(0, self.std), size=im_shape)) @@ -130,7 +136,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda raise AssertionError if not self._do_transform: return d - for noise, key in zip(self._noise, self.keys): + for key, noise in self.key_iterator(d, self._noise): dtype = dtype_torch_to_numpy(d[key].dtype) if isinstance(d[key], torch.Tensor) else d[key].dtype d[key] = d[key] + noise.astype(dtype) return d @@ -141,29 +147,36 @@ class ShiftIntensityd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ShiftIntensity`. """ - def __init__(self, keys: KeysCollection, offset: float) -> None: + def __init__(self, keys: KeysCollection, offset: float, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` offset: offset value to shift the intensity of image. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.shifter = ShiftIntensity(offset) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.shifter(d[key]) return d -class RandShiftIntensityd(Randomizable, MapTransform): +class RandShiftIntensityd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandShiftIntensity`. """ - def __init__(self, keys: KeysCollection, offsets: Union[Tuple[float, float], float], prob: float = 0.1) -> None: + def __init__( + self, + keys: KeysCollection, + offsets: Union[Tuple[float, float], float], + prob: float = 0.1, + allow_missing_keys: bool = False, + ) -> None: """ Args: keys: keys of the corresponding items to be transformed. @@ -172,8 +185,10 @@ def __init__(self, keys: KeysCollection, offsets: Union[Tuple[float, float], flo if single number, offset value is picked from (-offsets, offsets). prob: probability of rotating. (Default 0.1, with 10% probability it returns a rotated array.) + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) if isinstance(offsets, (int, float)): self.offsets = (min(-offsets, offsets), max(-offsets, offsets)) @@ -182,12 +197,9 @@ def __init__(self, keys: KeysCollection, offsets: Union[Tuple[float, float], flo raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) - self.prob = prob - self._do_transform = False - def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) - self._do_transform = self.R.random() < self.prob + super().randomize(None) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) @@ -195,7 +207,92 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda if not self._do_transform: return d shifter = ShiftIntensity(self._offset) - for key in self.keys: + for key in self.key_iterator(d): + d[key] = shifter(d[key]) + return d + + +class StdShiftIntensityd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.StdShiftIntensity`. + """ + + def __init__( + self, + keys: KeysCollection, + factor: float, + nonzero: bool = False, + channel_wise: bool = False, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + factor: factor shift by ``v = v + factor * std(v)``. + nonzero: whether only count non-zero values. + channel_wise: if True, calculate on each channel separately. Please ensure + that the first dimension represents the channel of the image if True. + allow_missing_keys: don't raise exception if key is missing. + """ + super().__init__(keys, allow_missing_keys) + self.shifter = StdShiftIntensity(factor, nonzero, channel_wise) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.shifter(d[key]) + return d + + +class RandStdShiftIntensityd(RandomizableTransform, MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.RandStdShiftIntensity`. + """ + + def __init__( + self, + keys: KeysCollection, + factors: Union[Tuple[float, float], float], + prob: float = 0.1, + nonzero: bool = False, + channel_wise: bool = False, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + factors: if tuple, the randomly picked range is (min(factors), max(factors)). + If single number, the range is (-factors, factors). + prob: probability of std shift. + nonzero: whether only count non-zero values. + channel_wise: if True, calculate on each channel separately. + allow_missing_keys: don't raise exception if key is missing. + """ + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + + if isinstance(factors, (int, float)): + self.factors = (min(-factors, factors), max(-factors, factors)) + else: + if len(factors) != 2: + raise AssertionError("factors should be a number or pair of numbers.") + self.factors = (min(factors), max(factors)) + self.nonzero = nonzero + self.channel_wise = channel_wise + + def randomize(self, data: Optional[Any] = None) -> None: + self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) + super().randomize(None) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + self.randomize() + if not self._do_transform: + return d + shifter = StdShiftIntensity(self.factor, self.nonzero, self.channel_wise) + for key in self.key_iterator(d): d[key] = shifter(d[key]) return d @@ -208,7 +305,12 @@ class ScaleIntensityd(MapTransform): """ def __init__( - self, keys: KeysCollection, minv: float = 0.0, maxv: float = 1.0, factor: Optional[float] = None + self, + keys: KeysCollection, + minv: Optional[float] = 0.0, + maxv: Optional[float] = 1.0, + factor: Optional[float] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -216,25 +318,33 @@ def __init__( See also: :py:class:`monai.transforms.compose.MapTransform` minv: minimum value of output data. maxv: maximum value of output data. - factor: factor scale by ``v = v * (1 + factor)``. + factor: factor scale by ``v = v * (1 + factor)``. In order to use + this parameter, please set `minv` and `maxv` into None. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.scaler = ScaleIntensity(minv, maxv, factor) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.scaler(d[key]) return d -class RandScaleIntensityd(Randomizable, MapTransform): +class RandScaleIntensityd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandScaleIntensity`. """ - def __init__(self, keys: KeysCollection, factors: Union[Tuple[float, float], float], prob: float = 0.1) -> None: + def __init__( + self, + keys: KeysCollection, + factors: Union[Tuple[float, float], float], + prob: float = 0.1, + allow_missing_keys: bool = False, + ) -> None: """ Args: keys: keys of the corresponding items to be transformed. @@ -243,9 +353,11 @@ def __init__(self, keys: KeysCollection, factors: Union[Tuple[float, float], flo if single number, factor value is picked from (-factors, factors). prob: probability of rotating. (Default 0.1, with 10% probability it returns a rotated array.) + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) @@ -254,12 +366,9 @@ def __init__(self, keys: KeysCollection, factors: Union[Tuple[float, float], flo raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) - self.prob = prob - self._do_transform = False - def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) - self._do_transform = self.R.random() < self.prob + super().randomize(None) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) @@ -267,7 +376,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda if not self._do_transform: return d scaler = ScaleIntensity(minv=None, maxv=None, factor=self.factor) - for key in self.keys: + for key in self.key_iterator(d): d[key] = scaler(d[key]) return d @@ -287,6 +396,7 @@ class NormalizeIntensityd(MapTransform): channel_wise: if using calculated mean and std, calculate on each channel separately or calculate on the entire image directly. dtype: output data type, defaut to float32. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -297,13 +407,14 @@ def __init__( nonzero: bool = False, channel_wise: bool = False, dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.normalizer = NormalizeIntensity(subtrahend, divisor, nonzero, channel_wise, dtype) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.normalizer(d[key]) return d @@ -318,15 +429,23 @@ class ThresholdIntensityd(MapTransform): threshold: the threshold to filter intensity values. above: filter values above the threshold or below the threshold, default is True. cval: value to fill the remaining parts of the image, default is 0. + allow_missing_keys: don't raise exception if key is missing. """ - def __init__(self, keys: KeysCollection, threshold: float, above: bool = True, cval: float = 0.0) -> None: - super().__init__(keys) + def __init__( + self, + keys: KeysCollection, + threshold: float, + above: bool = True, + cval: float = 0.0, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) self.filter = ThresholdIntensity(threshold, above, cval) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.filter(d[key]) return d @@ -343,17 +462,25 @@ class ScaleIntensityRanged(MapTransform): b_min: intensity target range min. b_max: intensity target range max. clip: whether to perform clip after scaling. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( - self, keys: KeysCollection, a_min: float, a_max: float, b_min: float, b_max: float, clip: bool = False + self, + keys: KeysCollection, + a_min: float, + a_max: float, + b_min: float, + b_max: float, + clip: bool = False, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.scaler = ScaleIntensityRange(a_min, a_max, b_min, b_max, clip) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.scaler(d[key]) return d @@ -369,20 +496,21 @@ class AdjustContrastd(MapTransform): keys: keys of the corresponding items to be transformed. See also: monai.transforms.MapTransform gamma: gamma value to adjust the contrast as function. + allow_missing_keys: don't raise exception if key is missing. """ - def __init__(self, keys: KeysCollection, gamma: float) -> None: - super().__init__(keys) + def __init__(self, keys: KeysCollection, gamma: float, allow_missing_keys: bool = False) -> None: + super().__init__(keys, allow_missing_keys) self.adjuster = AdjustContrast(gamma) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.adjuster(d[key]) return d -class RandAdjustContrastd(Randomizable, MapTransform): +class RandAdjustContrastd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandAdjustContrast`. Randomly changes image intensity by gamma. Each pixel/voxel intensity is updated as: @@ -395,13 +523,18 @@ class RandAdjustContrastd(Randomizable, MapTransform): prob: Probability of adjustment. gamma: Range of gamma values. If single number, value is picked from (0.5, gamma), default is (0.5, 4.5). + allow_missing_keys: don't raise exception if key is missing. """ def __init__( - self, keys: KeysCollection, prob: float = 0.1, gamma: Union[Tuple[float, float], float] = (0.5, 4.5) + self, + keys: KeysCollection, + prob: float = 0.1, + gamma: Union[Tuple[float, float], float] = (0.5, 4.5), + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) - self.prob: float = prob + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): if gamma <= 0.5: @@ -414,11 +547,10 @@ def __init__( raise AssertionError("gamma should be a number or pair of numbers.") self.gamma = (min(gamma), max(gamma)) - self._do_transform = False self.gamma_value: Optional[float] = None def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.gamma_value = self.R.uniform(low=self.gamma[0], high=self.gamma[1]) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: @@ -429,7 +561,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda if not self._do_transform: return d adjuster = AdjustContrast(self.gamma_value) - for key in self.keys: + for key in self.key_iterator(d): d[key] = adjuster(d[key]) return d @@ -447,6 +579,7 @@ class ScaleIntensityRangePercentilesd(MapTransform): b_max: intensity target range max. clip: whether to perform clip after scaling. relative: whether to scale to the corresponding percentiles of [b_min, b_max] + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -458,13 +591,14 @@ def __init__( b_max: float, clip: bool = False, relative: bool = False, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.scaler = ScaleIntensityRangePercentiles(lower, upper, b_min, b_max, clip, relative) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.scaler(d[key]) return d @@ -483,6 +617,7 @@ class MaskIntensityd(MapTransform): if None, will extract the mask data from input data based on `mask_key`. mask_key: the key to extract mask data from input dictionary, only works when `mask_data` is None. + allow_missing_keys: don't raise exception if key is missing. """ @@ -491,14 +626,15 @@ def __init__( keys: KeysCollection, mask_data: Optional[np.ndarray] = None, mask_key: Optional[str] = None, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = MaskIntensity(mask_data) self.mask_key = mask_key if mask_data is None else None def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key], d[self.mask_key]) if self.mask_key is not None else self.converter(d[key]) return d @@ -515,21 +651,28 @@ class GaussianSmoothd(MapTransform): use it for all spatial dimensions. approx: discrete Gaussian kernel type, available options are "erf", "sampled", and "scalespace". see also :py:meth:`monai.networks.layers.GaussianFilter`. + allow_missing_keys: don't raise exception if key is missing. """ - def __init__(self, keys: KeysCollection, sigma: Union[Sequence[float], float], approx: str = "erf") -> None: - super().__init__(keys) + def __init__( + self, + keys: KeysCollection, + sigma: Union[Sequence[float], float], + approx: str = "erf", + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) self.converter = GaussianSmooth(sigma, approx=approx) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d -class RandGaussianSmoothd(Randomizable, MapTransform): +class RandGaussianSmoothd(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.GaussianSmooth`. @@ -542,6 +685,7 @@ class RandGaussianSmoothd(Randomizable, MapTransform): approx: discrete Gaussian kernel type, available options are "erf", "sampled", and "scalespace". see also :py:meth:`monai.networks.layers.GaussianFilter`. prob: probability of Gaussian smooth. + allow_missing_keys: don't raise exception if key is missing. """ @@ -553,17 +697,17 @@ def __init__( sigma_z: Tuple[float, float] = (0.25, 1.5), approx: str = "erf", prob: float = 0.1, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.sigma_x = sigma_x self.sigma_y = sigma_y self.sigma_z = sigma_z self.approx = approx - self.prob = prob - self._do_transform = False def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.x = self.R.uniform(low=self.sigma_x[0], high=self.sigma_x[1]) self.y = self.R.uniform(low=self.sigma_y[0], high=self.sigma_y[1]) self.z = self.R.uniform(low=self.sigma_z[0], high=self.sigma_z[1]) @@ -573,7 +717,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda self.randomize() if not self._do_transform: return d - for key in self.keys: + for key in self.key_iterator(d): sigma = ensure_tuple_size(tup=(self.x, self.y, self.z), dim=d[key].ndim - 1) d[key] = GaussianSmooth(sigma=sigma, approx=self.approx)(d[key]) return d @@ -595,6 +739,7 @@ class GaussianSharpend(MapTransform): alpha: weight parameter to compute the final result. approx: discrete Gaussian kernel type, available options are "erf", "sampled", and "scalespace". see also :py:meth:`monai.networks.layers.GaussianFilter`. + allow_missing_keys: don't raise exception if key is missing. """ @@ -605,18 +750,19 @@ def __init__( sigma2: Union[Sequence[float], float] = 1.0, alpha: float = 30.0, approx: str = "erf", + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = GaussianSharpen(sigma1, sigma2, alpha, approx=approx) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d -class RandGaussianSharpend(Randomizable, MapTransform): +class RandGaussianSharpend(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.GaussianSharpen`. @@ -636,6 +782,7 @@ class RandGaussianSharpend(Randomizable, MapTransform): approx: discrete Gaussian kernel type, available options are "erf", "sampled", and "scalespace". see also :py:meth:`monai.networks.layers.GaussianFilter`. prob: probability of Gaussian sharpen. + allow_missing_keys: don't raise exception if key is missing. """ @@ -651,8 +798,10 @@ def __init__( alpha: Tuple[float, float] = (10.0, 30.0), approx: str = "erf", prob: float = 0.1, + allow_missing_keys: bool = False, ): - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.sigma1_x = sigma1_x self.sigma1_y = sigma1_y self.sigma1_z = sigma1_z @@ -661,11 +810,9 @@ def __init__( self.sigma2_z = sigma2_z self.alpha = alpha self.approx = approx - self.prob = prob - self._do_transform = False def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.x1 = self.R.uniform(low=self.sigma1_x[0], high=self.sigma1_x[1]) self.y1 = self.R.uniform(low=self.sigma1_y[0], high=self.sigma1_y[1]) self.z1 = self.R.uniform(low=self.sigma1_z[0], high=self.sigma1_z[1]) @@ -682,14 +829,14 @@ def __call__(self, data): self.randomize() if not self._do_transform: return d - for key in self.keys: + for key in self.key_iterator(d): sigma1 = ensure_tuple_size(tup=(self.x1, self.y1, self.z1), dim=d[key].ndim - 1) sigma2 = ensure_tuple_size(tup=(self.x2, self.y2, self.z2), dim=d[key].ndim - 1) d[key] = GaussianSharpen(sigma1=sigma1, sigma2=sigma2, alpha=self.a, approx=self.approx)(d[key]) return d -class RandHistogramShiftd(Randomizable, MapTransform): +class RandHistogramShiftd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandHistogramShift`. Apply random nonlinear transform the the image's intensity histogram. @@ -701,12 +848,18 @@ class RandHistogramShiftd(Randomizable, MapTransform): a smaller number of control points allows for larger intensity shifts. if two values provided, number of control points selecting from range (min_value, max_value). prob: probability of histogram shift. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( - self, keys: KeysCollection, num_control_points: Union[Tuple[int, int], int] = 10, prob: float = 0.1 + self, + keys: KeysCollection, + num_control_points: Union[Tuple[int, int], int] = 10, + prob: float = 0.1, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) if isinstance(num_control_points, int): if num_control_points <= 2: raise AssertionError("num_control_points should be greater than or equal to 3") @@ -717,11 +870,9 @@ def __init__( if min(num_control_points) <= 2: raise AssertionError("num_control_points should be greater than or equal to 3") self.num_control_points = (min(num_control_points), max(num_control_points)) - self.prob = prob - self._do_transform = False def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random() < self.prob + super().randomize(None) num_control_point = self.R.randint(self.num_control_points[0], self.num_control_points[1] + 1) self.reference_control_points = np.linspace(0, 1, num_control_point) self.floating_control_points = np.copy(self.reference_control_points) @@ -735,7 +886,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda self.randomize() if not self._do_transform: return d - for key in self.keys: + for key in self.key_iterator(d): img_min, img_max = d[key].min(), d[key].max() reference_control_points_scaled = self.reference_control_points * (img_max - img_min) + img_min floating_control_points_scaled = self.floating_control_points * (img_max - img_min) + img_min @@ -747,6 +898,8 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd RandShiftIntensityD = RandShiftIntensityDict = RandShiftIntensityd +StdShiftIntensityD = StdShiftIntensityDict = StdShiftIntensityd +RandStdShiftIntensityD = RandStdShiftIntensityDict = RandStdShiftIntensityd ScaleIntensityD = ScaleIntensityDict = ScaleIntensityd RandScaleIntensityD = RandScaleIntensityDict = RandScaleIntensityd NormalizeIntensityD = NormalizeIntensityDict = NormalizeIntensityd diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py new file mode 100644 index 0000000000..3e5b68e8e4 --- /dev/null +++ b/monai/transforms/inverse.py @@ -0,0 +1,121 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Dict, Hashable, Optional, Tuple + +import numpy as np +import torch + +from monai.transforms.transform import RandomizableTransform, Transform +from monai.utils.enums import InverseKeys + +__all__ = ["InvertibleTransform"] + + +class InvertibleTransform(Transform): + """Classes for invertible transforms. + + This class exists so that an ``invert`` method can be implemented. This allows, for + example, images to be cropped, rotated, padded, etc., during training and inference, + and after be returned to their original size before saving to file for comparison in + an external viewer. + + When the ``__call__`` method is called, the transformation information for each key is + stored. If the transforms were applied to keys "image" and "label", there will be two + extra keys in the dictionary: "image_transforms" and "label_transforms". Each list + contains a list of the transforms applied to that key. When the ``inverse`` method is + called, the inverse is called on each key individually, which allows for different + parameters being passed to each label (e.g., different interpolation for image and + label). + + When the ``inverse`` method is called, the inverse transforms are applied in a last- + in-first-out order. As the inverse is applied, its entry is removed from the list + detailing the applied transformations. That is to say that during the forward pass, + the list of applied transforms grows, and then during the inverse it shrinks back + down to an empty list. + + The information in ``data[key_transform]`` will be compatible with the default collate + since it only stores strings, numbers and arrays. + + We currently check that the ``id()`` of the transform is the same in the forward and + inverse directions. This is a useful check to ensure that the inverses are being + processed in the correct order. However, this may cause issues if the ``id()`` of the + object changes (such as multiprocessing on Windows). If you feel this issue affects + you, please raise a GitHub issue. + + Note to developers: When converting a transform to an invertible transform, you need to: + + #. Inherit from this class. + #. In ``__call__``, add a call to ``push_transform``. + #. Any extra information that might be needed for the inverse can be included with the + dictionary ``extra_info``. This dictionary should have the same keys regardless of + whether ``do_transform`` was `True` or `False` and can only contain objects that are + accepted in pytorch data loader's collate function (e.g., `None` is not allowed). + #. Implement an ``inverse`` method. Make sure that after performing the inverse, + ``pop_transform`` is called. + + """ + + def push_transform( + self, + data: dict, + key: Hashable, + extra_info: Optional[dict] = None, + orig_size: Optional[Tuple] = None, + ) -> None: + """Append to list of applied transforms for that key.""" + key_transform = str(key) + InverseKeys.KEY_SUFFIX + info = { + InverseKeys.CLASS_NAME: self.__class__.__name__, + InverseKeys.ID: id(self), + InverseKeys.ORIG_SIZE: orig_size or data[key].shape[1:], + } + if extra_info is not None: + info[InverseKeys.EXTRA_INFO] = extra_info + # If class is randomizable transform, store whether the transform was actually performed (based on `prob`) + if isinstance(self, RandomizableTransform): + info[InverseKeys.DO_TRANSFORM] = self._do_transform + # If this is the first, create list + if key_transform not in data: + data[key_transform] = [] + data[key_transform].append(info) + + def check_transforms_match(self, transform: dict) -> None: + """Check transforms are of same instance.""" + if transform[InverseKeys.ID] == id(self): + return + # basic check if multiprocessing uses 'spawn' (objects get recreated so don't have same ID) + if ( + torch.multiprocessing.get_start_method(allow_none=False) == "spawn" + and transform[InverseKeys.CLASS_NAME] == self.__class__.__name__ + ): + return + raise RuntimeError("Should inverse most recently applied invertible transform first") + + def get_most_recent_transform(self, data: dict, key: Hashable) -> dict: + """Get most recent transform.""" + transform = dict(data[str(key) + InverseKeys.KEY_SUFFIX][-1]) + self.check_transforms_match(transform) + return transform + + def pop_transform(self, data: dict, key: Hashable) -> None: + """Remove most recent transform.""" + data[str(key) + InverseKeys.KEY_SUFFIX].pop() + + def inverse(self, data: dict) -> Dict[Hashable, np.ndarray]: + """ + Inverse of ``__call__``. + + Raises: + NotImplementedError: When the subclass does not override this method. + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 9c14f7a689..9c2727ffc3 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -22,7 +22,7 @@ from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader from monai.data.nifti_saver import NiftiSaver from monai.data.png_saver import PNGSaver -from monai.transforms.compose import Transform +from monai.transforms.transform import Transform from monai.utils import GridSampleMode, GridSamplePadMode from monai.utils import ImageMetaKey as Key from monai.utils import InterpolateMode, ensure_tuple, optional_import @@ -33,6 +33,27 @@ __all__ = ["LoadImage", "SaveImage"] +def switch_endianness(data, old, new): + """ + If any numpy arrays have `old` (e.g., ">"), + replace with `new` (e.g., "<"). + """ + if isinstance(data, np.ndarray): + if data.dtype.byteorder == old: + data = data.newbyteorder(new) + elif isinstance(data, tuple): + data = tuple(switch_endianness(x, old, new) for x in data) + elif isinstance(data, list): + data = [switch_endianness(x, old, new) for x in data] + elif isinstance(data, dict): + data = {k: switch_endianness(v, old, new) for k, v in data.items()} + elif isinstance(data, (bool, str, float, int, type(None))): + pass + else: + raise AssertionError(f"Unknown type: {type(data).__name__}") + return data + + class LoadImage(Transform): """ Load image file or files from provided path based on reader. @@ -57,7 +78,7 @@ def __init__( reader: register reader to load image file and meta data, if None, still can register readers at runtime or use the default readers. If a string of reader name provided, will construct a reader object with the `*args` and `**kwargs` parameters, supported reader name: "NibabelReader", - "PILReader", "ITKReader", "NumpyReader" + "PILReader", "ITKReader", "NumpyReader". image_only: if True return only the image volume, otherwise return image data array and header dict. dtype: if not None convert the loaded image to this data type. args: additional parameters for reader if providing a reader name. @@ -123,7 +144,12 @@ def __call__( break if reader is None: - raise RuntimeError(f"can not find suitable reader for this file: {filename}.") + raise RuntimeError( + f"can not find suitable reader for this file: {filename}. \ + Please install dependency libraries: (nii, nii.gz) -> Nibabel, (png, jpg, bmp) -> PIL, \ + (npz, npy) -> Numpy, others -> ITK. Refer to the installation instruction: \ + https://docs.monai.io/en/latest/installation.html#installing-the-recommended-dependencies." + ) img = reader.read(filename) img_array, meta_data = reader.get_data(img) @@ -132,6 +158,9 @@ def __call__( if self.image_only: return img_array meta_data[Key.FILENAME_OR_OBJ] = ensure_tuple(filename)[0] + # make sure all elements in metadata are little endian + meta_data = switch_endianness(meta_data, ">", "<") + return img_array, meta_data @@ -141,6 +170,8 @@ class SaveImage(Transform): It can work for both numpy array and PyTorch Tensor in both pre-transform chain and post transform chain. + NB: image should include channel dimension: [B],C,H,W,[D]. + Args: output_dir: output image directory. output_postfix: a string appended to all output file names, default to `trans`. @@ -176,6 +207,11 @@ class SaveImage(Transform): it's used for NIfTI format only. save_batch: whether the import image is a batch data, default to `False`. usually pre-transforms run for channel first data, while post-transforms run for batch data. + squeeze_end_dims: if True, any trailing singleton dimensions will be removed (after the channel + has been moved to the end). So if input is (C,H,W,D), this will be altered to (H,W,D,C), and + then if C==1, it will be saved as (H,W,D). If D also ==1, it will be saved as (H,W). If false, + image will always be saved as (H,W,D,C). + it's used for NIfTI format only. """ @@ -191,6 +227,7 @@ def __init__( dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, save_batch: bool = False, + squeeze_end_dims: bool = True, ) -> None: self.saver: Union[NiftiSaver, PNGSaver] if output_ext in (".nii.gz", ".nii"): @@ -203,6 +240,7 @@ def __init__( padding_mode=padding_mode, dtype=dtype, output_dtype=output_dtype, + squeeze_end_dims=squeeze_end_dims, ) elif output_ext == ".png": self.saver = PNGSaver( diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index d3220aa682..79f8561d5e 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -21,8 +21,8 @@ from monai.config import DtypeLike, KeysCollection from monai.data.image_reader import ImageReader -from monai.transforms.compose import MapTransform from monai.transforms.io.array import LoadImage, SaveImage +from monai.transforms.transform import MapTransform from monai.utils import GridSampleMode, GridSamplePadMode, InterpolateMode __all__ = [ @@ -59,6 +59,8 @@ def __init__( dtype: DtypeLike = np.float32, meta_key_postfix: str = "meta_dict", overwriting: bool = False, + image_only: bool = False, + allow_missing_keys: bool = False, *args, **kwargs, ) -> None: @@ -69,18 +71,21 @@ def __init__( reader: register reader to load image file and meta data, if None, still can register readers at runtime or use the default readers. If a string of reader name provided, will construct a reader object with the `*args` and `**kwargs` parameters, supported reader name: "NibabelReader", - "PILReader", "ITKReader", "NumpyReader" + "PILReader", "ITKReader", "NumpyReader". dtype: if not None convert the loaded image data to this data type. meta_key_postfix: use `key_{postfix}` to store the metadata of the nifti image, default is `meta_dict`. The meta data is a dictionary object. For example, load nifti file for `image`, store the metadata into `image_meta_dict`. overwriting: whether allow to overwrite existing meta data of same key. default is False, which will raise exception if encountering existing key. + image_only: if True return dictionary containing just only the image volumes, otherwise return + dictionary containing image data array and header dict per input key. + 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) - self._loader = LoadImage(reader, False, dtype, *args, **kwargs) + super().__init__(keys, allow_missing_keys) + self._loader = LoadImage(reader, image_only, dtype, *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_key_postfix = meta_key_postfix @@ -96,17 +101,22 @@ def __call__(self, data, reader: Optional[ImageReader] = None): """ d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): data = self._loader(d[key], reader) - if not isinstance(data, (tuple, list)): - raise ValueError("loader must return a tuple or list.") - d[key] = data[0] - if not isinstance(data[1], dict): - raise ValueError("metadata must be a dict.") - key_to_add = f"{key}_{self.meta_key_postfix}" - if key_to_add in d and not self.overwriting: - raise KeyError(f"Meta data with key {key_to_add} already exists and overwriting=False.") - d[key_to_add] = data[1] + if self._loader.image_only: + if not isinstance(data, np.ndarray): + raise ValueError("loader must return a numpy array (because image_only=True was used).") + d[key] = data + else: + if not isinstance(data, (tuple, list)): + raise ValueError("loader must return a tuple or list (because image_only=False was used).") + d[key] = data[0] + if not isinstance(data[1], dict): + raise ValueError("metadata must be a dict.") + key_to_add = f"{key}_{self.meta_key_postfix}" + if key_to_add in d and not self.overwriting: + raise KeyError(f"Meta data with key {key_to_add} already exists and overwriting=False.") + d[key_to_add] = data[1] return d @@ -114,6 +124,8 @@ class SaveImaged(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.SaveImage`. + NB: image should include channel dimension: [B],C,H,W,[D]. + Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` @@ -155,6 +167,12 @@ class SaveImaged(MapTransform): it's used for NIfTI format only. save_batch: whether the import image is a batch data, default to `False`. usually pre-transforms run for channel first data, while post-transforms run for batch data. + allow_missing_keys: don't raise exception if key is missing. + squeeze_end_dims: if True, any trailing singleton dimensions will be removed (after the channel + has been moved to the end). So if input is (C,H,W,D), this will be altered to (H,W,D,C), and + then if C==1, it will be saved as (H,W,D). If D also ==1, it will be saved as (H,W). If false, + image will always be saved as (H,W,D,C). + it's used for NIfTI format only. """ @@ -172,8 +190,10 @@ def __init__( dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, save_batch: bool = False, + allow_missing_keys: bool = False, + squeeze_end_dims: bool = True, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.meta_key_postfix = meta_key_postfix self._saver = SaveImage( output_dir=output_dir, @@ -186,11 +206,12 @@ def __init__( dtype=dtype, output_dtype=output_dtype, save_batch=save_batch, + squeeze_end_dims=squeeze_end_dims, ) def __call__(self, data): d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): meta_data = d[f"{key}_{self.meta_key_postfix}"] if self.meta_key_postfix is not None else None self._saver(img=d[key], meta_data=meta_data) return d diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 0c60b0cc89..8b4f71093b 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -21,7 +21,7 @@ import torch.nn.functional as F from monai.networks import one_hot -from monai.transforms.compose import Transform +from monai.transforms.transform import Transform from monai.transforms.utils import get_largest_connected_component_mask from monai.utils import ensure_tuple diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 60cda11a91..6d28f780d4 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -20,8 +20,8 @@ import numpy as np import torch +import monai.data from monai.config import KeysCollection -from monai.transforms.compose import MapTransform from monai.transforms.post.array import ( Activations, AsDiscrete, @@ -30,6 +30,7 @@ MeanEnsemble, VoteEnsemble, ) +from monai.transforms.transform import MapTransform from monai.utils import ensure_tuple_rep __all__ = [ @@ -52,6 +53,9 @@ "MeanEnsembleDict", "VoteEnsembleD", "VoteEnsembleDict", + "DecollateD", + "DecollateDict", + "Decollated", ] @@ -67,6 +71,7 @@ def __init__( sigmoid: Union[Sequence[bool], bool] = False, softmax: Union[Sequence[bool], bool] = False, other: Optional[Union[Sequence[Callable], Callable]] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -79,9 +84,10 @@ def __init__( other: callable function to execute other activation layers, for example: `other = lambda x: torch.tanh(x)`. it also can be a sequence of Callable, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.sigmoid = ensure_tuple_rep(sigmoid, len(self.keys)) self.softmax = ensure_tuple_rep(softmax, len(self.keys)) self.other = ensure_tuple_rep(other, len(self.keys)) @@ -89,8 +95,8 @@ def __init__( def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for idx, key in enumerate(self.keys): - d[key] = self.converter(d[key], self.sigmoid[idx], self.softmax[idx], self.other[idx]) + for key, sigmoid, softmax, other in self.key_iterator(d, self.sigmoid, self.softmax, self.other): + d[key] = self.converter(d[key], sigmoid, softmax, other) return d @@ -107,6 +113,7 @@ def __init__( n_classes: Optional[Union[Sequence[int], int]] = None, threshold_values: Union[Sequence[bool], bool] = False, logit_thresh: Union[Sequence[float], float] = 0.5, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -122,9 +129,10 @@ def __init__( it also can be a sequence of bool, each element corresponds to a key in ``keys``. logit_thresh: the threshold value for thresholding operation, default is 0.5. it also can be a sequence of float, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.argmax = ensure_tuple_rep(argmax, len(self.keys)) self.to_onehot = ensure_tuple_rep(to_onehot, len(self.keys)) self.n_classes = ensure_tuple_rep(n_classes, len(self.keys)) @@ -134,14 +142,16 @@ def __init__( def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for idx, key in enumerate(self.keys): + for key, argmax, to_onehot, n_classes, threshold_values, logit_thresh in self.key_iterator( + d, self.argmax, self.to_onehot, self.n_classes, self.threshold_values, self.logit_thresh + ): d[key] = self.converter( d[key], - self.argmax[idx], - self.to_onehot[idx], - self.n_classes[idx], - self.threshold_values[idx], - self.logit_thresh[idx], + argmax, + to_onehot, + n_classes, + threshold_values, + logit_thresh, ) return d @@ -157,6 +167,7 @@ def __init__( applied_labels: Union[Sequence[int], int], independent: bool = True, connectivity: Optional[int] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -171,14 +182,15 @@ def __init__( connectivity: Maximum number of orthogonal hops to consider a pixel/voxel as a neighbor. Accepted values are ranging from 1 to input.ndim. If ``None``, a full connectivity of ``input.ndim`` is used. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = KeepLargestConnectedComponent(applied_labels, independent, connectivity) def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -188,20 +200,21 @@ class LabelToContourd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.LabelToContour`. """ - def __init__(self, keys: KeysCollection, kernel_type: str = "Laplace") -> None: + def __init__(self, keys: KeysCollection, kernel_type: str = "Laplace", allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` kernel_type: the method applied to do edge detection, default is "Laplace". + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = LabelToContour(kernel_type=kernel_type) def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -217,6 +230,7 @@ def __init__( keys: KeysCollection, ensemble: Callable[[Union[Sequence[torch.Tensor], torch.Tensor]], torch.Tensor], output_key: Optional[str] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -225,13 +239,14 @@ def __init__( output_key: the key to store ensemble result in the dictionary. ensemble: callable method to execute ensemble on specified data. if only 1 key provided in `keys`, `output_key` can be None and use `keys` as default. + allow_missing_keys: don't raise exception if key is missing. Raises: TypeError: When ``ensemble`` is not ``callable``. ValueError: When ``len(keys) > 1`` and ``output_key=None``. Incompatible values. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) if not callable(ensemble): raise TypeError(f"ensemble must be callable but is {type(ensemble).__name__}.") self.ensemble = ensemble @@ -245,7 +260,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc if len(self.keys) == 1: items = d[self.keys[0]] else: - items = [d[key] for key in self.keys] + items = [d[key] for key in self.key_iterator(d)] d[self.output_key] = self.ensemble(items) return d @@ -306,9 +321,29 @@ def __init__( super().__init__(keys, ensemble, output_key) +class Decollated(MapTransform): + """ + Decollate a batch of data. + + Note that unlike most MapTransforms, this will decollate all data, so keys are not needed. + + Args: + batch_size: if not supplied, we try to determine it based on array lengths. Will raise an error if + it fails to determine it automatically. + """ + + def __init__(self, batch_size: Optional[int] = None) -> None: + super().__init__(None) + self.batch_size = batch_size + + def __call__(self, data: dict) -> List[dict]: + return monai.data.decollate_batch(data, self.batch_size) + + ActivationsD = ActivationsDict = Activationsd AsDiscreteD = AsDiscreteDict = AsDiscreted KeepLargestConnectedComponentD = KeepLargestConnectedComponentDict = KeepLargestConnectedComponentd LabelToContourD = LabelToContourDict = LabelToContourd MeanEnsembleD = MeanEnsembleDict = MeanEnsembled VoteEnsembleD = VoteEnsembleDict = VoteEnsembled +DecollateD = DecollateDict = Decollated diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index df10480188..de9bba8e95 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -22,8 +22,8 @@ from monai.config import USE_COMPILED, DtypeLike from monai.data.utils import compute_shape_offset, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull -from monai.transforms.compose import Randomizable, Transform from monai.transforms.croppad.array import CenterSpatialCrop +from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import ( create_control_grid, create_grid, @@ -42,6 +42,7 @@ ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, + issequenceiterable, optional_import, ) @@ -58,6 +59,7 @@ "RandRotate90", "RandRotate", "RandFlip", + "RandAxisFlip", "RandZoom", "AffineGrid", "RandAffineGrid", @@ -69,6 +71,8 @@ "Rand3DElastic", ] +RandRange = Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] + class Spacing(Transform): """ @@ -147,7 +151,7 @@ def __call__( ValueError: When ``pixdim`` is nonpositive. Returns: - data_array (resampled into `self.pixdim`), original pixdim, current pixdim. + data_array (resampled into `self.pixdim`), original affine, current affine. """ _dtype = dtype or self.dtype or data_array.dtype @@ -277,7 +281,7 @@ def __call__( ornt[:, 0] += 1 # skip channel dim ornt = np.concatenate([np.array([[0, 1]]), ornt]) shape = data_array.shape[1:] - data_array = nib.orientations.apply_orientation(data_array, ornt) + data_array = np.ascontiguousarray(nib.orientations.apply_orientation(data_array, ornt)) new_affine = affine_ @ nib.orientations.inv_ornt_aff(spatial_ornt, shape) new_affine = to_affine_nd(affine, new_affine) return data_array, affine, new_affine @@ -313,7 +317,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: class Resize(Transform): """ - Resize the input image to given spatial size. + Resize the input image to given spatial size (with scaling, not cropping/padding). Implemented using :py:class:`torch.nn.functional.interpolate`. Args: @@ -417,6 +421,7 @@ def __init__( self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) self.align_corners = align_corners self.dtype = dtype + self.rotation_matrix: Optional[np.ndarray] = None def __call__( self, @@ -478,8 +483,13 @@ def __call__( torch.as_tensor(np.ascontiguousarray(transform).astype(_dtype)), spatial_size=output_shape, ) + self.rotation_matrix = transform return np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) + def get_rotation_matrix(self) -> Optional[np.ndarray]: + """Get the most recently applied rotation matrix""" + return self.rotation_matrix + class Zoom(Transform): """ @@ -586,7 +596,7 @@ def __init__(self, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: If axis is negative it counts from the last to the first axis. """ self.k = k - spatial_axes_ = ensure_tuple(spatial_axes) + spatial_axes_: Tuple[int, int] = ensure_tuple(spatial_axes) # type: ignore if len(spatial_axes_) != 2: raise ValueError("spatial_axes must be 2 int numbers to indicate the axes to rotate 90 degrees.") self.spatial_axes = spatial_axes_ @@ -601,7 +611,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return result.astype(img.dtype) -class RandRotate90(Randomizable, Transform): +class RandRotate90(RandomizableTransform): """ With probability `prob`, input arrays are rotated by 90 degrees in the plane specified by `spatial_axes`. @@ -616,16 +626,15 @@ def __init__(self, prob: float = 0.1, max_k: int = 3, spatial_axes: Tuple[int, i spatial_axes: 2 int numbers, defines the plane to rotate with 2 spatial axes. Default: (0, 1), this is the first two axis in spatial dimensions. """ - self.prob = min(max(prob, 0.0), 1.0) + RandomizableTransform.__init__(self, prob) self.max_k = max_k self.spatial_axes = spatial_axes - self._do_transform = False self._rand_k = 0 def randomize(self, data: Optional[Any] = None) -> None: self._rand_k = self.R.randint(self.max_k) + 1 - self._do_transform = self.R.random() < self.prob + super().randomize(None) def __call__(self, img: np.ndarray) -> np.ndarray: """ @@ -639,7 +648,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return rotator(img) -class RandRotate(Randomizable, Transform): +class RandRotate(RandomizableTransform): """ Randomly rotate the input arrays. @@ -679,6 +688,7 @@ def __init__( align_corners: bool = False, dtype: DtypeLike = np.float64, ) -> None: + RandomizableTransform.__init__(self, prob) self.range_x = ensure_tuple(range_x) if len(self.range_x) == 1: self.range_x = tuple(sorted([-self.range_x[0], self.range_x[0]])) @@ -689,20 +699,18 @@ def __init__( if len(self.range_z) == 1: self.range_z = tuple(sorted([-self.range_z[0], self.range_z[0]])) - self.prob = prob self.keep_size = keep_size self.mode: GridSampleMode = GridSampleMode(mode) self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) self.align_corners = align_corners self.dtype = dtype - self._do_transform = False self.x = 0.0 self.y = 0.0 self.z = 0.0 def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.x = self.R.uniform(low=self.range_x[0], high=self.range_x[1]) self.y = self.R.uniform(low=self.range_y[0], high=self.range_y[1]) self.z = self.R.uniform(low=self.range_z[0], high=self.range_z[1]) @@ -741,10 +749,10 @@ def __call__( align_corners=self.align_corners if align_corners is None else align_corners, dtype=dtype or self.dtype or img.dtype, ) - return rotator(img) + return np.array(rotator(img)) -class RandFlip(Randomizable, Transform): +class RandFlip(RandomizableTransform): """ Randomly flips the image along axes. Preserves shape. See numpy.flip for additional details. @@ -756,25 +764,52 @@ class RandFlip(Randomizable, Transform): """ def __init__(self, prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: - self.prob = prob + RandomizableTransform.__init__(self, prob) self.flipper = Flip(spatial_axis=spatial_axis) - self._do_transform = False - - def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob def __call__(self, img: np.ndarray) -> np.ndarray: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), """ - self.randomize() + self.randomize(None) if not self._do_transform: return img return self.flipper(img) -class RandZoom(Randomizable, Transform): +class RandAxisFlip(RandomizableTransform): + """ + Randomly select a spatial axis and flip along it. + See numpy.flip for additional details. + https://docs.scipy.org/doc/numpy/reference/generated/numpy.flip.html + + Args: + prob: Probability of flipping. + + """ + + def __init__(self, prob: float = 0.1) -> None: + RandomizableTransform.__init__(self, min(max(prob, 0.0), 1.0)) + self._axis: Optional[int] = None + + def randomize(self, data: np.ndarray) -> None: + super().randomize(None) + self._axis = self.R.randint(data.ndim - 1) + + def __call__(self, img: np.ndarray) -> np.ndarray: + """ + Args: + img: channel first array, must have shape: (num_channels, H[, W, ..., ]), + """ + self.randomize(data=img) + if not self._do_transform: + return img + flipper = Flip(spatial_axis=self._axis) + return flipper(img) + + +class RandZoom(RandomizableTransform): """ Randomly zooms input arrays with given probability within given zoom range. @@ -813,21 +848,20 @@ def __init__( align_corners: Optional[bool] = None, keep_size: bool = True, ) -> None: + RandomizableTransform.__init__(self, prob) self.min_zoom = ensure_tuple(min_zoom) self.max_zoom = ensure_tuple(max_zoom) if len(self.min_zoom) != len(self.max_zoom): raise AssertionError("min_zoom and max_zoom must have same length.") - self.prob = prob self.mode: InterpolateMode = InterpolateMode(mode) self.padding_mode: NumpyPadMode = NumpyPadMode(padding_mode) self.align_corners = align_corners self.keep_size = keep_size - self._do_transform = False self._zoom: Sequence[float] = [1.0] def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self._zoom = [self.R.uniform(l, h) for l, h in zip(self.min_zoom, self.max_zoom)] def __call__( @@ -897,6 +931,9 @@ class AffineGrid(Transform): as_tensor_output: whether to output tensor instead of numpy array. defaults to True. device: device to store the output grid data. + affine: If applied, ignore the params (`rotate_params`, etc.) and use the + supplied matrix. Should be square with each side = num of image spatial + dimensions + 1. """ @@ -908,6 +945,7 @@ def __init__( scale_params: Optional[Union[Sequence[float], float]] = None, as_tensor_output: bool = True, device: Optional[torch.device] = None, + affine: Optional[Union[np.ndarray, torch.Tensor]] = None, ) -> None: self.rotate_params = rotate_params self.shear_params = shear_params @@ -917,8 +955,12 @@ def __init__( self.as_tensor_output = as_tensor_output self.device = device + self.affine = affine + def __call__( - self, spatial_size: Optional[Sequence[int]] = None, grid: Optional[Union[np.ndarray, torch.Tensor]] = None + self, + spatial_size: Optional[Sequence[int]] = None, + grid: Optional[Union[np.ndarray, torch.Tensor]] = None, ) -> Union[np.ndarray, torch.Tensor]: """ Args: @@ -935,60 +977,60 @@ def __call__( else: raise ValueError("Incompatible values: grid=None and spatial_size=None.") - spatial_dims = len(grid.shape) - 1 - affine = np.eye(spatial_dims + 1) - if self.rotate_params: - affine = affine @ create_rotate(spatial_dims, self.rotate_params) - if self.shear_params: - affine = affine @ create_shear(spatial_dims, self.shear_params) - if self.translate_params: - affine = affine @ create_translate(spatial_dims, self.translate_params) - if self.scale_params: - affine = affine @ create_scale(spatial_dims, self.scale_params) - affine = torch.as_tensor(np.ascontiguousarray(affine), device=self.device) + if self.affine is None: + spatial_dims = len(grid.shape) - 1 + affine = np.eye(spatial_dims + 1) + if self.rotate_params: + affine = affine @ create_rotate(spatial_dims, self.rotate_params) + if self.shear_params: + affine = affine @ create_shear(spatial_dims, self.shear_params) + if self.translate_params: + affine = affine @ create_translate(spatial_dims, self.translate_params) + if self.scale_params: + affine = affine @ create_scale(spatial_dims, self.scale_params) + self.affine = affine + + self.affine = torch.as_tensor(np.ascontiguousarray(self.affine), device=self.device) grid = torch.tensor(grid) if not isinstance(grid, torch.Tensor) else grid.detach().clone() if self.device: grid = grid.to(self.device) - grid = (affine.float() @ grid.reshape((grid.shape[0], -1)).float()).reshape([-1] + list(grid.shape[1:])) + grid = (self.affine.float() @ grid.reshape((grid.shape[0], -1)).float()).reshape([-1] + list(grid.shape[1:])) if grid is None or not isinstance(grid, torch.Tensor): raise ValueError("Unknown grid.") - if self.as_tensor_output: - return grid - return np.asarray(grid.cpu().numpy()) + return grid if self.as_tensor_output else np.asarray(grid.cpu().numpy()) + def get_transformation_matrix(self) -> Optional[Union[np.ndarray, torch.Tensor]]: + """Get the most recently applied transformation matrix""" + return self.affine -class RandAffineGrid(Randomizable, Transform): + +class RandAffineGrid(RandomizableTransform): """ Generate randomised affine grid. """ def __init__( self, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, + rotate_range: RandRange = None, + shear_range: RandRange = None, + translate_range: RandRange = None, + scale_range: RandRange = None, as_tensor_output: bool = True, device: Optional[torch.device] = None, ) -> None: """ Args: - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. Similarly, `rotate_range[1]` and - `rotate_range[2]` are used in 3D affine for the range of 2nd and 3rd axes. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` to - `shear_range[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` - to `translate_range[N]` controls the range of the uniform distribution used to generate - the 2nd to N-th parameter. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` to - `scale_range[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). as_tensor_output: whether to output tensor instead of numpy array. defaults to True. device: device to store the output grid data. @@ -1011,19 +1053,29 @@ def __init__( self.as_tensor_output = as_tensor_output self.device = device + self.affine: Optional[Union[np.ndarray, torch.Tensor]] = None + + def _get_rand_param(self, param_range, add_scalar: float = 0.0): + out_param = [] + for f in param_range: + if issequenceiterable(f): + if len(f) != 2: + raise ValueError("If giving range as [min,max], should only have two elements per dim.") + out_param.append(self.R.uniform(f[0], f[1]) + add_scalar) + elif f is not None: + out_param.append(self.R.uniform(-f, f) + add_scalar) + return out_param def randomize(self, data: Optional[Any] = None) -> None: - if self.rotate_range: - self.rotate_params = [self.R.uniform(-f, f) for f in self.rotate_range if f is not None] - if self.shear_range: - self.shear_params = [self.R.uniform(-f, f) for f in self.shear_range if f is not None] - if self.translate_range: - self.translate_params = [self.R.uniform(-f, f) for f in self.translate_range if f is not None] - if self.scale_range: - self.scale_params = [self.R.uniform(-f, f) + 1.0 for f in self.scale_range if f is not None] + self.rotate_params = self._get_rand_param(self.rotate_range) + self.shear_params = self._get_rand_param(self.shear_range) + self.translate_params = self._get_rand_param(self.translate_range) + self.scale_params = self._get_rand_param(self.scale_range, 1.0) def __call__( - self, spatial_size: Optional[Sequence[int]] = None, grid: Optional[Union[np.ndarray, torch.Tensor]] = None + self, + spatial_size: Optional[Sequence[int]] = None, + grid: Optional[Union[np.ndarray, torch.Tensor]] = None, ) -> Union[np.ndarray, torch.Tensor]: """ Args: @@ -1042,10 +1094,16 @@ def __call__( as_tensor_output=self.as_tensor_output, device=self.device, ) - return affine_grid(spatial_size, grid) + grid = affine_grid(spatial_size, grid) + self.affine = affine_grid.get_transformation_matrix() + return grid + def get_transformation_matrix(self) -> Optional[Union[np.ndarray, torch.Tensor]]: + """Get the most recently applied transformation matrix""" + return self.affine -class RandDeformGrid(Randomizable, Transform): + +class RandDeformGrid(RandomizableTransform): """ Generate random deformation grid. """ @@ -1274,7 +1332,7 @@ def __call__( ) -class RandAffine(Randomizable, Transform): +class RandAffine(RandomizableTransform): """ Random affine transform. """ @@ -1282,11 +1340,11 @@ class RandAffine(Randomizable, Transform): def __init__( self, prob: float = 0.1, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, - spatial_size: Optional[Union[Sequence[float], float]] = None, + rotate_range: RandRange = None, + shear_range: RandRange = None, + translate_range: RandRange = None, + scale_range: RandRange = None, + spatial_size: Optional[Union[Sequence[int], int]] = None, mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, as_tensor_output: bool = True, @@ -1296,21 +1354,16 @@ def __init__( Args: prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid. - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. Similarly, `rotate_range[1]` and - `rotate_range[2]` are used in 3D affine for the range of 2nd and 3rd axes. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` to - `shear_range[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` - to `translate_range[N]` controls the range of the uniform distribution used to generate - the 2nd to N-th parameter. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` to - `scale_range[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. @@ -1331,6 +1384,7 @@ def __init__( - :py:class:`RandAffineGrid` for the random affine parameters configurations. - :py:class:`Affine` for the affine transformation parameters configurations. """ + RandomizableTransform.__init__(self, prob) self.rand_affine_grid = RandAffineGrid( rotate_range=rotate_range, @@ -1346,9 +1400,6 @@ def __init__( self.mode: GridSampleMode = GridSampleMode(mode) self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) - self.do_transform = False - self.prob = prob - def set_random_state( self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None ) -> "RandAffine": @@ -1357,7 +1408,7 @@ def set_random_state( return self def randomize(self, data: Optional[Any] = None) -> None: - self.do_transform = self.R.rand() < self.prob + super().randomize(None) self.rand_affine_grid.randomize() def __call__( @@ -1385,7 +1436,7 @@ def __call__( self.randomize() sp_size = fall_back_tuple(spatial_size or self.spatial_size, img.shape[1:]) - if self.do_transform: + if self._do_transform: grid = self.rand_affine_grid(spatial_size=sp_size) else: grid = create_grid(spatial_size=sp_size) @@ -1394,7 +1445,7 @@ def __call__( ) -class Rand2DElastic(Randomizable, Transform): +class Rand2DElastic(RandomizableTransform): """ Random elastic deformation and affine in 2D """ @@ -1404,11 +1455,11 @@ def __init__( spacing: Union[Tuple[float, float], float], magnitude_range: Tuple[float, float], prob: float = 0.1, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, - spatial_size: Optional[Union[Sequence[int], int]] = None, + rotate_range: RandRange = None, + shear_range: RandRange = None, + translate_range: RandRange = None, + scale_range: RandRange = None, + spatial_size: Optional[Union[Tuple[int, int], int]] = None, mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, @@ -1421,17 +1472,16 @@ def __init__( prob: probability of returning a randomized elastic transform. defaults to 0.1, with 10% chance returns a randomized elastic transform, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` controls - the range of the uniform distribution used to generate the 2nd parameter. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` controls - the range of the uniform distribution used to generate the 2nd parameter. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` controls - the range of the uniform distribution used to generate the 2nd parameter. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). 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`. @@ -1452,6 +1502,7 @@ def __init__( - :py:class:`RandAffineGrid` for the random affine parameters configurations. - :py:class:`Affine` for the affine transformation parameters configurations. """ + RandomizableTransform.__init__(self, prob) self.deform_grid = RandDeformGrid( spacing=spacing, magnitude_range=magnitude_range, as_tensor_output=True, device=device ) @@ -1468,8 +1519,6 @@ def __init__( self.spatial_size = spatial_size self.mode: GridSampleMode = GridSampleMode(mode) self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) - self.prob = prob - self.do_transform = False def set_random_state( self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None @@ -1480,7 +1529,7 @@ def set_random_state( return self def randomize(self, spatial_size: Sequence[int]) -> None: - self.do_transform = self.R.rand() < self.prob + super().randomize(None) self.deform_grid.randomize(spatial_size) self.rand_affine_grid.randomize() @@ -1506,7 +1555,7 @@ def __call__( """ sp_size = fall_back_tuple(spatial_size or self.spatial_size, img.shape[1:]) self.randomize(spatial_size=sp_size) - if self.do_transform: + if self._do_transform: grid = self.deform_grid(spatial_size=sp_size) grid = self.rand_affine_grid(grid=grid) grid = torch.nn.functional.interpolate( # type: ignore @@ -1522,7 +1571,7 @@ def __call__( return self.resampler(img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode) -class Rand3DElastic(Randomizable, Transform): +class Rand3DElastic(RandomizableTransform): """ Random elastic deformation and affine in 3D """ @@ -1532,11 +1581,11 @@ def __init__( sigma_range: Tuple[float, float], magnitude_range: Tuple[float, float], prob: float = 0.1, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, - spatial_size: Optional[Union[Sequence[int], int]] = None, + rotate_range: RandRange = None, + shear_range: RandRange = None, + translate_range: RandRange = None, + scale_range: RandRange = None, + spatial_size: Optional[Union[Tuple[int, int, int], int]] = None, mode: Union[GridSampleMode, str] = GridSampleMode.BILINEAR, padding_mode: Union[GridSamplePadMode, str] = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, @@ -1551,19 +1600,16 @@ def __init__( prob: probability of returning a randomized elastic transform. defaults to 0.1, with 10% chance returns a randomized elastic transform, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. Similarly, `rotate_range[1]` and - `rotate_range[2]` are used in 3D affine for the range of 2nd and 3rd axes. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` and `shear_range[2]` - controls the range of the uniform distribution used to generate the 2nd and 3rd parameters. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` and - `translate_range[2]` controls the range of the uniform distribution used to generate - the 2nd and 3rd parameters. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` and `scale_range[2]` - controls the range of the uniform distribution used to generate the 2nd and 3rd parameters. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). spatial_size: specifying 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`. @@ -1584,6 +1630,7 @@ def __init__( - :py:class:`RandAffineGrid` for the random affine parameters configurations. - :py:class:`Affine` for the affine transformation parameters configurations. """ + RandomizableTransform.__init__(self, prob) self.rand_affine_grid = RandAffineGrid(rotate_range, shear_range, translate_range, scale_range, True, device) self.resampler = Resample(as_tensor_output=as_tensor_output, device=device) @@ -1594,8 +1641,6 @@ def __init__( self.padding_mode: GridSamplePadMode = GridSamplePadMode(padding_mode) self.device = device - self.prob = prob - self.do_transform = False self.rand_offset = None self.magnitude = 1.0 self.sigma = 1.0 @@ -1608,8 +1653,8 @@ def set_random_state( return self def randomize(self, grid_size: Sequence[int]) -> None: - self.do_transform = self.R.rand() < self.prob - if self.do_transform: + super().randomize(None) + if self._do_transform: self.rand_offset = self.R.uniform(-1.0, 1.0, [3] + list(grid_size)).astype(np.float32) self.magnitude = self.R.uniform(self.magnitude_range[0], self.magnitude_range[1]) self.sigma = self.R.uniform(self.sigma_range[0], self.sigma_range[1]) @@ -1638,7 +1683,7 @@ def __call__( sp_size = fall_back_tuple(spatial_size or self.spatial_size, img.shape[1:]) self.randomize(grid_size=sp_size) grid = create_grid(spatial_size=sp_size) - if self.do_transform: + if self._do_transform: if self.rand_offset is None: raise AssertionError grid = torch.as_tensor(np.ascontiguousarray(grid), device=self.device) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index e612a25ef8..0d5b3436fd 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -15,16 +15,20 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ +from copy import deepcopy from typing import Any, Dict, Hashable, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch from monai.config import DtypeLike, KeysCollection +from monai.networks.layers import AffineTransform from monai.networks.layers.simplelayers import GaussianFilter -from monai.transforms.compose import MapTransform, Randomizable -from monai.transforms.croppad.array import CenterSpatialCrop +from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad +from monai.transforms.inverse import InvertibleTransform from monai.transforms.spatial.array import ( + Affine, + AffineGrid, Flip, Orientation, Rand2DElastic, @@ -36,6 +40,7 @@ Spacing, Zoom, ) +from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import create_grid from monai.utils import ( GridSampleMode, @@ -46,6 +51,10 @@ ensure_tuple_rep, fall_back_tuple, ) +from monai.utils.enums import InverseKeys +from monai.utils.module import optional_import + +nib, _ = optional_import("nibabel") __all__ = [ "Spacingd", @@ -53,11 +62,13 @@ "Rotate90d", "RandRotate90d", "Resized", + "Affined", "RandAffined", "Rand2DElasticd", "Rand3DElasticd", "Flipd", "RandFlipd", + "RandAxisFlipd", "Rotated", "RandRotated", "Zoomd", @@ -72,6 +83,8 @@ "RandRotate90Dict", "ResizeD", "ResizeDict", + "AffineD", + "AffineDict", "RandAffineD", "RandAffineDict", "Rand2DElasticD", @@ -82,6 +95,8 @@ "FlipDict", "RandFlipD", "RandFlipDict", + "RandAxisFlipD", + "RandAxisFlipDict", "RotateD", "RotateDict", "RandRotateD", @@ -98,7 +113,7 @@ NumpyPadModeSequence = Union[Sequence[Union[NumpyPadMode, str]], NumpyPadMode, str] -class Spacingd(MapTransform): +class Spacingd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Spacing`. @@ -122,6 +137,7 @@ def __init__( align_corners: Union[Sequence[bool], bool] = False, dtype: Optional[Union[Sequence[DtypeLike], DtypeLike]] = np.float64, meta_key_postfix: str = "meta_dict", + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -157,12 +173,13 @@ def __init__( default is `meta_dict`, the meta data is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. + allow_missing_keys: don't raise exception if key is missing. Raises: TypeError: When ``meta_key_postfix`` is not a ``str``. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.spacing_transform = Spacing(pixdim, diagonal=diagonal) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) @@ -176,24 +193,59 @@ def __call__( self, data: Mapping[Union[Hashable, str], Dict[str, np.ndarray]] ) -> Dict[Union[Hashable, str], Union[np.ndarray, Dict[str, np.ndarray]]]: d: Dict = dict(data) - for idx, key in enumerate(self.keys): - meta_data = d[f"{key}_{self.meta_key_postfix}"] + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype + ): + meta_data_key = f"{key}_{self.meta_key_postfix}" + meta_data = d[meta_data_key] # resample array of each corresponding key # using affine fetched from d[affine_key] - d[key], _, new_affine = self.spacing_transform( + d[key], old_affine, new_affine = self.spacing_transform( data_array=np.asarray(d[key]), affine=meta_data["affine"], - mode=self.mode[idx], - padding_mode=self.padding_mode[idx], - align_corners=self.align_corners[idx], - dtype=self.dtype[idx], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + dtype=dtype, ) + self.push_transform(d, key, extra_info={"meta_data_key": meta_data_key, "old_affine": old_affine}) # set the 'affine' key meta_data["affine"] = new_affine return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype + ): + transform = self.get_most_recent_transform(d, key) + if self.spacing_transform.diagonal: + raise RuntimeError( + "Spacingd:inverse not yet implemented for diagonal=True. " + + "Please raise a github issue if you need this feature" + ) + # Create inverse transform + meta_data = d[transform[InverseKeys.EXTRA_INFO]["meta_data_key"]] + old_affine = np.array(transform[InverseKeys.EXTRA_INFO]["old_affine"]) + orig_pixdim = np.sqrt(np.sum(np.square(old_affine), 0))[:-1] + inverse_transform = Spacing(orig_pixdim, diagonal=self.spacing_transform.diagonal) + # Apply inverse + d[key], _, new_affine = inverse_transform( + data_array=np.asarray(d[key]), + affine=meta_data["affine"], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + dtype=dtype, + ) + meta_data["affine"] = new_affine + # Remove the applied transform + self.pop_transform(d, key) + + return d + -class Orientationd(MapTransform): +class Orientationd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Orientation`. @@ -211,6 +263,7 @@ def __init__( as_closest_canonical: bool = False, labels: Optional[Sequence[Tuple[str, str]]] = tuple(zip("LPI", "RAS")), meta_key_postfix: str = "meta_dict", + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -227,6 +280,7 @@ def __init__( default is `meta_dict`, the meta data is a dictionary object. For example, to handle key `image`, read/write affine matrices from the metadata `image_meta_dict` dictionary's `affine` field. + allow_missing_keys: don't raise exception if key is missing. Raises: TypeError: When ``meta_key_postfix`` is not a ``str``. @@ -235,7 +289,7 @@ def __init__( `nibabel.orientations.ornt2axcodes`. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.ornt_transform = Orientation(axcodes=axcodes, as_closest_canonical=as_closest_canonical, labels=labels) if not isinstance(meta_key_postfix, str): raise TypeError(f"meta_key_postfix must be a str but is {type(meta_key_postfix).__name__}.") @@ -245,36 +299,82 @@ def __call__( self, data: Mapping[Union[Hashable, str], Dict[str, np.ndarray]] ) -> Dict[Union[Hashable, str], Union[np.ndarray, Dict[str, np.ndarray]]]: d: Dict = dict(data) - for key in self.keys: - meta_data = d[f"{key}_{self.meta_key_postfix}"] - d[key], _, new_affine = self.ornt_transform(d[key], affine=meta_data["affine"]) + for key in self.key_iterator(d): + meta_data_key = f"{key}_{self.meta_key_postfix}" + meta_data = d[meta_data_key] + d[key], old_affine, new_affine = self.ornt_transform(d[key], affine=meta_data["affine"]) + self.push_transform(d, key, extra_info={"meta_data_key": meta_data_key, "old_affine": old_affine}) + d[meta_data_key]["affine"] = new_affine + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + meta_data = d[transform[InverseKeys.EXTRA_INFO]["meta_data_key"]] + orig_affine = transform[InverseKeys.EXTRA_INFO]["old_affine"] + orig_axcodes = nib.orientations.aff2axcodes(orig_affine) + inverse_transform = Orientation( + axcodes=orig_axcodes, + as_closest_canonical=False, + labels=self.ornt_transform.labels, + ) + # Apply inverse + d[key], _, new_affine = inverse_transform(d[key], affine=meta_data["affine"]) meta_data["affine"] = new_affine + # Remove the applied transform + self.pop_transform(d, key) + return d -class Rotate90d(MapTransform): +class Rotate90d(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rotate90`. """ - def __init__(self, keys: KeysCollection, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1)) -> None: + def __init__( + self, keys: KeysCollection, k: int = 1, spatial_axes: Tuple[int, int] = (0, 1), allow_missing_keys: bool = False + ) -> None: """ Args: k: number of times to rotate by 90 degrees. spatial_axes: 2 int numbers, defines the plane to rotate with 2 spatial axes. Default: (0, 1), this is the first two axis in spatial dimensions. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.rotator = Rotate90(k, spatial_axes) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + self.push_transform(d, key) d[key] = self.rotator(d[key]) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + _ = self.get_most_recent_transform(d, key) + # Create inverse transform + spatial_axes = self.rotator.spatial_axes + num_times_rotated = self.rotator.k + num_times_to_rotate = 4 - num_times_rotated + inverse_transform = Rotate90(num_times_to_rotate, spatial_axes) + # Might need to convert to numpy + if isinstance(d[key], torch.Tensor): + d[key] = torch.Tensor(d[key]).cpu().numpy() + # Apply inverse + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d + -class RandRotate90d(Randomizable, MapTransform): +class RandRotate90d(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRotate90`. With probability `prob`, input arrays are rotated by 90 degrees @@ -287,6 +387,7 @@ def __init__( prob: float = 0.1, max_k: int = 3, spatial_axes: Tuple[int, int] = (0, 1), + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -298,33 +399,53 @@ def __init__( (Default 3) spatial_axes: 2 int numbers, defines the plane to rotate with 2 spatial axes. Default: (0, 1), this is the first two axis in spatial dimensions. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) - self.prob = min(max(prob, 0.0), 1.0) self.max_k = max_k self.spatial_axes = spatial_axes - self._do_transform = False self._rand_k = 0 def randomize(self, data: Optional[Any] = None) -> None: self._rand_k = self.R.randint(self.max_k) + 1 - self._do_transform = self.R.random() < self.prob + super().randomize(None) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Mapping[Hashable, np.ndarray]: self.randomize() - if not self._do_transform: - return data + d = dict(data) rotator = Rotate90(self._rand_k, self.spatial_axes) - d = dict(data) - for key in self.keys: - d[key] = rotator(d[key]) + for key in self.key_iterator(d): + if self._do_transform: + d[key] = rotator(d[key]) + self.push_transform(d, key, extra_info={"rand_k": self._rand_k}) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Check if random transform was actually performed (based on `prob`) + if transform[InverseKeys.DO_TRANSFORM]: + # Create inverse transform + num_times_rotated = transform[InverseKeys.EXTRA_INFO]["rand_k"] + num_times_to_rotate = 4 - num_times_rotated + inverse_transform = Rotate90(num_times_to_rotate, self.spatial_axes) + # Might need to convert to numpy + if isinstance(d[key], torch.Tensor): + d[key] = torch.Tensor(d[key]).cpu().numpy() + # Apply inverse + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) -class Resized(MapTransform): + return d + + +class Resized(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Resize`. @@ -343,6 +464,7 @@ class Resized(MapTransform): 'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None. See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -351,20 +473,136 @@ def __init__( spatial_size: Union[Sequence[int], int], mode: InterpolateModeSequence = InterpolateMode.AREA, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.resizer = Resize(spatial_size=spatial_size) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for idx, key in enumerate(self.keys): - d[key] = self.resizer(d[key], mode=self.mode[idx], align_corners=self.align_corners[idx]) + for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): + self.push_transform(d, key) + d[key] = self.resizer(d[key], mode=mode, align_corners=align_corners) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): + transform = self.get_most_recent_transform(d, key) + orig_size = transform[InverseKeys.ORIG_SIZE] + # Create inverse transform + inverse_transform = Resize(orig_size, mode, align_corners) + # Apply inverse transform + d[key] = inverse_transform(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d -class RandAffined(Randomizable, MapTransform): +class Affined(MapTransform, InvertibleTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.Affine`. + """ + + def __init__( + self, + keys: KeysCollection, + rotate_params: Optional[Union[Sequence[float], float]] = None, + shear_params: Optional[Union[Sequence[float], float]] = None, + translate_params: Optional[Union[Sequence[float], float]] = None, + scale_params: Optional[Union[Sequence[float], float]] = None, + spatial_size: Optional[Union[Sequence[int], int]] = None, + mode: GridSampleModeSequence = GridSampleMode.BILINEAR, + padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, + as_tensor_output: bool = False, + device: Optional[torch.device] = None, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + rotate_params: a rotation angle in radians, a scalar for 2D image, a tuple of 3 floats for 3D. + Defaults to no rotation. + shear_params: a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing. + translate_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Translation is in + pixel/voxel relative to the center of the input image. Defaults to no translation. + scale_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Defaults to no scaling. + spatial_size: output image spatial size. + if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, + the transform will use the spatial size of `img`. + if the 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"``. + 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``. + padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``} + Padding mode for outside grid values. Defaults to ``"reflection"``. + 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``. + as_tensor_output: the computation is implemented using pytorch tensors, this option specifies + whether to convert it back to numpy arrays. + device: device on which the tensor will be allocated. + allow_missing_keys: don't raise exception if key is missing. + + See also: + - :py:class:`monai.transforms.compose.MapTransform` + - :py:class:`RandAffineGrid` for the random affine parameters configurations. + """ + MapTransform.__init__(self, keys, allow_missing_keys) + self.affine = Affine( + rotate_params=rotate_params, + shear_params=shear_params, + translate_params=translate_params, + scale_params=scale_params, + spatial_size=spatial_size, + as_tensor_output=as_tensor_output, + device=device, + ) + self.mode = ensure_tuple_rep(mode, len(self.keys)) + self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) + + def __call__( + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + d = dict(data) + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + orig_size = d[key].shape[1:] + d[key] = self.affine(d[key], mode=mode, padding_mode=padding_mode) + affine = self.affine.affine_grid.get_transformation_matrix() + self.push_transform(d, key, orig_size=orig_size, extra_info={"affine": affine}) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + transform = self.get_most_recent_transform(d, key) + orig_size = transform[InverseKeys.ORIG_SIZE] + # Create inverse transform + fwd_affine = transform[InverseKeys.EXTRA_INFO]["affine"] + inv_affine = np.linalg.inv(fwd_affine) + + affine_grid = AffineGrid(affine=inv_affine) + grid: torch.Tensor = affine_grid(orig_size) # type: ignore + + # Apply inverse transform + out = self.affine.resampler(d[key], grid, mode, padding_mode) + + # Convert to numpy + d[key] = out if isinstance(out, np.ndarray) else out.cpu().numpy() + + # Remove the applied transform + self.pop_transform(d, key) + + return d + + +class RandAffined(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.RandAffine`. """ @@ -374,14 +612,15 @@ def __init__( keys: KeysCollection, spatial_size: Optional[Union[Sequence[int], int]] = None, prob: float = 0.1, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, + rotate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + shear_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + translate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + scale_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, mode: GridSampleModeSequence = GridSampleMode.BILINEAR, padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, as_tensor_output: bool = True, device: Optional[torch.device] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -394,21 +633,16 @@ def __init__( to `(32, 64)` if the second spatial dimension size of img is `64`. prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid. - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. Similarly, `rotate_range[1]` and - `rotate_range[2]` are used in 3D affine for the range of 2nd and 3rd axes. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` to - `shear_range[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` - to `translate_range[N]` controls the range of the uniform distribution used to generate - the 2nd to N-th parameter. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` to - `scale_range[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -420,14 +654,16 @@ def __init__( as_tensor_output: the computation is implemented using pytorch tensors, this option specifies whether to convert it back to numpy arrays. device: device on which the tensor will be allocated. + allow_missing_keys: don't raise exception if key is missing. See also: - :py:class:`monai.transforms.compose.MapTransform` - :py:class:`RandAffineGrid` for the random affine parameters configurations. """ - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.rand_affine = RandAffine( - prob=prob, + prob=1.0, # because probability handled in this class rotate_range=rotate_range, shear_range=shear_range, translate_range=translate_range, @@ -447,6 +683,7 @@ def set_random_state( return self def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) self.rand_affine.randomize() def __call__( @@ -456,17 +693,44 @@ def __call__( self.randomize() sp_size = fall_back_tuple(self.rand_affine.spatial_size, data[self.keys[0]].shape[1:]) - if self.rand_affine.do_transform: + if self._do_transform: grid = self.rand_affine.rand_affine_grid(spatial_size=sp_size) + affine = self.rand_affine.rand_affine_grid.get_transformation_matrix() else: grid = create_grid(spatial_size=sp_size) + affine = np.eye(len(sp_size) + 1) - for idx, key in enumerate(self.keys): - d[key] = self.rand_affine.resampler(d[key], grid, mode=self.mode[idx], padding_mode=self.padding_mode[idx]) + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + self.push_transform(d, key, extra_info={"affine": affine}) + d[key] = self.rand_affine.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + transform = self.get_most_recent_transform(d, key) + orig_size = transform[InverseKeys.ORIG_SIZE] + # Create inverse transform + fwd_affine = transform[InverseKeys.EXTRA_INFO]["affine"] + inv_affine = np.linalg.inv(fwd_affine) + + affine_grid = AffineGrid(affine=inv_affine) + grid: torch.Tensor = affine_grid(orig_size) # type: ignore -class Rand2DElasticd(Randomizable, MapTransform): + # Apply inverse transform + out = self.rand_affine.resampler(d[key], grid, mode, padding_mode) + + # Convert to numpy + d[key] = out if isinstance(out, np.ndarray) else out.cpu().numpy() + + # Remove the applied transform + self.pop_transform(d, key) + + return d + + +class Rand2DElasticd(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rand2DElastic`. """ @@ -476,16 +740,17 @@ def __init__( keys: KeysCollection, spacing: Union[Tuple[float, float], float], magnitude_range: Tuple[float, float], - spatial_size: Optional[Union[Sequence[int], int]] = None, + spatial_size: Optional[Union[Tuple[int, int], int]] = None, prob: float = 0.1, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, + rotate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + shear_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + translate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + scale_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, mode: GridSampleModeSequence = GridSampleMode.BILINEAR, padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -502,17 +767,16 @@ def __init__( prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` controls - the range of the uniform distribution used to generate the 2nd parameter. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` controls - the range of the uniform distribution used to generate the 2nd parameter. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` controls - the range of the uniform distribution used to generate the 2nd parameter. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -524,16 +788,18 @@ def __init__( as_tensor_output: the computation is implemented using pytorch tensors, this option specifies whether to convert it back to numpy arrays. device: device on which the tensor will be allocated. + allow_missing_keys: don't raise exception if key is missing. See also: - :py:class:`RandAffineGrid` for the random affine parameters configurations. - :py:class:`Affine` for the affine transformation parameters configurations. """ - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.rand_2d_elastic = Rand2DElastic( spacing=spacing, magnitude_range=magnitude_range, - prob=prob, + prob=1.0, # because probability controlled by this class rotate_range=rotate_range, shear_range=shear_range, translate_range=translate_range, @@ -553,6 +819,7 @@ def set_random_state( return self def randomize(self, spatial_size: Sequence[int]) -> None: + super().randomize(None) self.rand_2d_elastic.randomize(spatial_size) def __call__( @@ -563,7 +830,7 @@ def __call__( sp_size = fall_back_tuple(self.rand_2d_elastic.spatial_size, data[self.keys[0]].shape[1:]) self.randomize(spatial_size=sp_size) - if self.rand_2d_elastic.do_transform: + if self._do_transform: grid = self.rand_2d_elastic.deform_grid(spatial_size=sp_size) grid = self.rand_2d_elastic.rand_affine_grid(grid=grid) grid = torch.nn.functional.interpolate( # type: ignore @@ -577,14 +844,12 @@ def __call__( else: grid = create_grid(spatial_size=sp_size) - for idx, key in enumerate(self.keys): - d[key] = self.rand_2d_elastic.resampler( - d[key], grid, mode=self.mode[idx], padding_mode=self.padding_mode[idx] - ) + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + d[key] = self.rand_2d_elastic.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) return d -class Rand3DElasticd(Randomizable, MapTransform): +class Rand3DElasticd(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rand3DElastic`. """ @@ -594,16 +859,17 @@ def __init__( keys: KeysCollection, sigma_range: Tuple[float, float], magnitude_range: Tuple[float, float], - spatial_size: Optional[Union[Sequence[int], int]] = None, + spatial_size: Optional[Union[Tuple[int, int, int], int]] = None, prob: float = 0.1, - rotate_range: Optional[Union[Sequence[float], float]] = None, - shear_range: Optional[Union[Sequence[float], float]] = None, - translate_range: Optional[Union[Sequence[float], float]] = None, - scale_range: Optional[Union[Sequence[float], float]] = None, + rotate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + shear_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + translate_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, + scale_range: Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] = None, mode: GridSampleModeSequence = GridSampleMode.BILINEAR, padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -621,19 +887,16 @@ def __init__( prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. rotate_range[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_range[0], rotate_range[0])`. Similarly, `rotate_range[1]` and - `rotate_range[2]` are used in 3D affine for the range of 2nd and 3rd axes. - shear_range: shear_range[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_range[0], shear_range[0])`. Similarly, `shear_range[1]` and `shear_range[2]` - controls the range of the uniform distribution used to generate the 2nd and 3rd parameters. - translate_range : translate_range[0] with be used to generate the 1st shift parameter from - `uniform[-translate_range[0], translate_range[0])`. Similarly, `translate_range[1]` and - `translate_range[2]` controls the range of the uniform distribution used to generate - the 2nd and 3rd parameters. - scale_range: scaling_range[0] with be used to generate the 1st scaling factor from - `uniform[-scale_range[0], scale_range[0]) + 1.0`. Similarly, `scale_range[1]` and `scale_range[2]` - controls the range of the uniform distribution used to generate the 2nd and 3rd parameters. + rotate_range: angle range in radians. If element `i` is iterable, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can + be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range + `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 + and nothing for the remaining dimensions. + shear_range: shear_range with format matching `rotate_range`. + translate_range: translate_range with format matching `rotate_range`. + scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1). mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -645,16 +908,18 @@ def __init__( as_tensor_output: the computation is implemented using pytorch tensors, this option specifies whether to convert it back to numpy arrays. device: device on which the tensor will be allocated. + allow_missing_keys: don't raise exception if key is missing. See also: - :py:class:`RandAffineGrid` for the random affine parameters configurations. - :py:class:`Affine` for the affine transformation parameters configurations. """ - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.rand_3d_elastic = Rand3DElastic( sigma_range=sigma_range, magnitude_range=magnitude_range, - prob=prob, + prob=1.0, # because probability controlled by this class rotate_range=rotate_range, shear_range=shear_range, translate_range=translate_range, @@ -674,6 +939,7 @@ def set_random_state( return self def randomize(self, grid_size: Sequence[int]) -> None: + super().randomize(None) self.rand_3d_elastic.randomize(grid_size) def __call__( @@ -684,7 +950,7 @@ def __call__( self.randomize(grid_size=sp_size) grid = create_grid(spatial_size=sp_size) - if self.rand_3d_elastic.do_transform: + if self._do_transform: device = self.rand_3d_elastic.device grid = torch.tensor(grid).to(device) gaussian = GaussianFilter(spatial_dims=3, sigma=self.rand_3d_elastic.sigma, truncated=3.0).to(device) @@ -692,14 +958,12 @@ def __call__( grid[:3] += gaussian(offset)[0] * self.rand_3d_elastic.magnitude grid = self.rand_3d_elastic.rand_affine_grid(grid=grid) - for idx, key in enumerate(self.keys): - d[key] = self.rand_3d_elastic.resampler( - d[key], grid, mode=self.mode[idx], padding_mode=self.padding_mode[idx] - ) + for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): + d[key] = self.rand_3d_elastic.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) return d -class Flipd(MapTransform): +class Flipd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Flip`. @@ -709,20 +973,41 @@ class Flipd(MapTransform): Args: keys: Keys to pick data for transformation. spatial_axis: Spatial axes along which to flip over. Default is None. + allow_missing_keys: don't raise exception if key is missing. """ - def __init__(self, keys: KeysCollection, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: - super().__init__(keys) + def __init__( + self, + keys: KeysCollection, + spatial_axis: Optional[Union[Sequence[int], int]] = None, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) self.flipper = Flip(spatial_axis=spatial_axis) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + self.push_transform(d, key) d[key] = self.flipper(d[key]) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + _ = self.get_most_recent_transform(d, key) + # Might need to convert to numpy + if isinstance(d[key], torch.Tensor): + d[key] = torch.Tensor(d[key]).cpu().numpy() + # Inverse is same as forward + d[key] = self.flipper(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d + -class RandFlipd(Randomizable, MapTransform): +class RandFlipd(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandFlip`. @@ -733,6 +1018,7 @@ class RandFlipd(Randomizable, MapTransform): keys: Keys to pick data for transformation. prob: Probability of flipping. spatial_axis: Spatial axes along which to flip over. Default is None. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -740,28 +1026,91 @@ def __init__( keys: KeysCollection, prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int], int]] = None, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.spatial_axis = spatial_axis - self.prob = prob - self._do_transform = False self.flipper = Flip(spatial_axis=spatial_axis) - def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + self.randomize(None) + d = dict(data) + for key in self.key_iterator(d): + if self._do_transform: + d[key] = self.flipper(d[key]) + self.push_transform(d, key) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Check if random transform was actually performed (based on `prob`) + if transform[InverseKeys.DO_TRANSFORM]: + # Might need to convert to numpy + if isinstance(d[key], torch.Tensor): + d[key] = torch.Tensor(d[key]).cpu().numpy() + # Inverse is same as forward + d[key] = self.flipper(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d + + +class RandAxisFlipd(RandomizableTransform, MapTransform, InvertibleTransform): + """ + Dictionary-based version :py:class:`monai.transforms.RandAxisFlip`. + + See `numpy.flip` for additional details. + https://docs.scipy.org/doc/numpy/reference/generated/numpy.flip.html + + Args: + keys: Keys to pick data for transformation. + prob: Probability of flipping. + allow_missing_keys: don't raise exception if key is missing. + + """ + + def __init__(self, keys: KeysCollection, prob: float = 0.1, allow_missing_keys: bool = False) -> None: + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + self._axis: Optional[int] = None + + def randomize(self, data: np.ndarray) -> None: + super().randomize(None) + self._axis = self.R.randint(data.ndim - 1) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: - self.randomize() + self.randomize(data=data[self.keys[0]]) + flipper = Flip(spatial_axis=self._axis) + d = dict(data) - if not self._do_transform: - return d - for key in self.keys: - d[key] = self.flipper(d[key]) + for key in self.key_iterator(d): + if self._do_transform: + d[key] = flipper(d[key]) + self.push_transform(d, key, extra_info={"axis": self._axis}) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + transform = self.get_most_recent_transform(d, key) + # Check if random transform was actually performed (based on `prob`) + if transform[InverseKeys.DO_TRANSFORM]: + flipper = Flip(spatial_axis=transform[InverseKeys.EXTRA_INFO]["axis"]) + # Might need to convert to numpy + if isinstance(d[key], torch.Tensor): + d[key] = torch.Tensor(d[key]).cpu().numpy() + # Inverse is same as forward + d[key] = flipper(d[key]) + # Remove the applied transform + self.pop_transform(d, key) return d -class Rotated(MapTransform): +class Rotated(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rotate`. @@ -786,6 +1135,7 @@ class Rotated(MapTransform): If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. It also can be a sequence of dtype or None, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -797,8 +1147,9 @@ def __init__( padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.rotator = Rotate(angle=angle, keep_size=keep_size) self.mode = ensure_tuple_rep(mode, len(self.keys)) @@ -808,18 +1159,51 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for idx, key in enumerate(self.keys): + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype + ): + orig_size = d[key].shape[1:] d[key] = self.rotator( d[key], - mode=self.mode[idx], - padding_mode=self.padding_mode[idx], - align_corners=self.align_corners[idx], - dtype=self.dtype[idx], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + dtype=dtype, + ) + rot_mat = self.rotator.get_rotation_matrix() + self.push_transform(d, key, orig_size=orig_size, extra_info={"rot_mat": rot_mat}) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype + ): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + fwd_rot_mat = transform[InverseKeys.EXTRA_INFO]["rot_mat"] + inv_rot_mat = np.linalg.inv(fwd_rot_mat) + + xform = AffineTransform( + normalized=False, + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + reverse_indexing=True, + ) + output = xform( + torch.as_tensor(np.ascontiguousarray(d[key]).astype(dtype)).unsqueeze(0), + torch.as_tensor(np.ascontiguousarray(inv_rot_mat).astype(dtype)), + spatial_size=transform[InverseKeys.ORIG_SIZE], ) + d[key] = np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) + # Remove the applied transform + self.pop_transform(d, key) + return d -class RandRotated(Randomizable, MapTransform): +class RandRotated(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRotate` Randomly rotates the input arrays. @@ -851,6 +1235,7 @@ class RandRotated(Randomizable, MapTransform): If None, use the data type of input data. To be compatible with other modules, the output data type is always ``np.float32``. It also can be a sequence of dtype or None, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -865,8 +1250,10 @@ def __init__( padding_mode: GridSamplePadModeSequence = GridSamplePadMode.BORDER, align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.range_x = ensure_tuple(range_x) if len(self.range_x) == 1: self.range_x = tuple(sorted([-self.range_x[0], self.range_x[0]])) @@ -877,20 +1264,18 @@ def __init__( if len(self.range_z) == 1: self.range_z = tuple(sorted([-self.range_z[0], self.range_z[0]])) - self.prob = prob self.keep_size = keep_size self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.dtype = ensure_tuple_rep(dtype, len(self.keys)) - self._do_transform = False self.x = 0.0 self.y = 0.0 self.z = 0.0 def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self.x = self.R.uniform(low=self.range_x[0], high=self.range_x[1]) self.y = self.R.uniform(low=self.range_y[0], high=self.range_y[1]) self.z = self.R.uniform(low=self.range_z[0], high=self.range_z[1]) @@ -899,23 +1284,61 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda self.randomize() d = dict(data) if not self._do_transform: + for key in self.keys: + self.push_transform(d, key, extra_info={"rot_mat": np.eye(4)}) return d + angle: Union[Sequence[float], float] = self.x if d[self.keys[0]].ndim == 3 else (self.x, self.y, self.z) rotator = Rotate( - angle=self.x if d[self.keys[0]].ndim == 3 else (self.x, self.y, self.z), + angle=angle, keep_size=self.keep_size, ) - for idx, key in enumerate(self.keys): + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype + ): + orig_size = d[key].shape[1:] d[key] = rotator( d[key], - mode=self.mode[idx], - padding_mode=self.padding_mode[idx], - align_corners=self.align_corners[idx], - dtype=self.dtype[idx], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + dtype=dtype, ) + rot_mat = rotator.get_rotation_matrix() + self.push_transform(d, key, orig_size=orig_size, extra_info={"rot_mat": rot_mat}) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key, mode, padding_mode, align_corners, dtype in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners, self.dtype + ): + transform = self.get_most_recent_transform(d, key) + # Check if random transform was actually performed (based on `prob`) + if transform[InverseKeys.DO_TRANSFORM]: + # Create inverse transform + fwd_rot_mat = transform[InverseKeys.EXTRA_INFO]["rot_mat"] + inv_rot_mat = np.linalg.inv(fwd_rot_mat) + + xform = AffineTransform( + normalized=False, + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + reverse_indexing=True, + ) + output = xform( + torch.as_tensor(np.ascontiguousarray(d[key]).astype(dtype)).unsqueeze(0), + torch.as_tensor(np.ascontiguousarray(inv_rot_mat).astype(dtype)), + spatial_size=transform[InverseKeys.ORIG_SIZE], + ) + d[key] = np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) + # Remove the applied transform + self.pop_transform(d, key) + return d -class Zoomd(MapTransform): +class Zoomd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Zoom`. @@ -937,6 +1360,7 @@ class Zoomd(MapTransform): See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. keep_size: Should keep original size (pad if needed), default is True. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -947,8 +1371,9 @@ def __init__( padding_mode: NumpyPadModeSequence = NumpyPadMode.EDGE, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) @@ -956,17 +1381,43 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for idx, key in enumerate(self.keys): + for key, mode, padding_mode, align_corners in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners + ): + self.push_transform(d, key) d[key] = self.zoomer( d[key], - mode=self.mode[idx], - padding_mode=self.padding_mode[idx], - align_corners=self.align_corners[idx], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, ) return d + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key, mode, padding_mode, align_corners in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners + ): + transform = self.get_most_recent_transform(d, key) + # Create inverse transform + zoom = np.array(self.zoomer.zoom) + inverse_transform = Zoom(zoom=1 / zoom, keep_size=self.zoomer.keep_size) + # Apply inverse + d[key] = inverse_transform( + d[key], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + ) + # Size might be out by 1 voxel so pad + d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE])(d[key]) + # Remove the applied transform + self.pop_transform(d, key) -class RandZoomd(Randomizable, MapTransform): + return d + + +class RandZoomd(RandomizableTransform, MapTransform, InvertibleTransform): """ Dict-based version :py:class:`monai.transforms.RandZoom`. @@ -996,6 +1447,7 @@ class RandZoomd(Randomizable, MapTransform): See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. keep_size: Should keep original size (pad if needed), default is True. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -1008,32 +1460,30 @@ def __init__( padding_mode: NumpyPadModeSequence = NumpyPadMode.EDGE, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) self.min_zoom = ensure_tuple(min_zoom) self.max_zoom = ensure_tuple(max_zoom) if len(self.min_zoom) != len(self.max_zoom): raise AssertionError("min_zoom and max_zoom must have same length.") - self.prob = prob self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.keep_size = keep_size - self._do_transform = False self._zoom: Sequence[float] = [1.0] def randomize(self, data: Optional[Any] = None) -> None: - self._do_transform = self.R.random_sample() < self.prob + super().randomize(None) self._zoom = [self.R.uniform(l, h) for l, h in zip(self.min_zoom, self.max_zoom)] def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: # match the spatial dim of first item self.randomize() d = dict(data) - if not self._do_transform: - return d img_dims = data[self.keys[0]].ndim if len(self._zoom) == 1: @@ -1043,13 +1493,42 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda # if 2 zoom factors provided for 3D data, use the first factor for H and W dims, second factor for D dim self._zoom = ensure_tuple_rep(self._zoom[0], img_dims - 2) + ensure_tuple(self._zoom[-1]) zoomer = Zoom(self._zoom, keep_size=self.keep_size) - for idx, key in enumerate(self.keys): - d[key] = zoomer( - d[key], - mode=self.mode[idx], - padding_mode=self.padding_mode[idx], - align_corners=self.align_corners[idx], - ) + for key, mode, padding_mode, align_corners in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners + ): + self.push_transform(d, key, extra_info={"zoom": self._zoom}) + if self._do_transform: + d[key] = zoomer( + d[key], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + ) + return d + + def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = deepcopy(dict(data)) + for key, mode, padding_mode, align_corners in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners + ): + transform = self.get_most_recent_transform(d, key) + # Check if random transform was actually performed (based on `prob`) + if transform[InverseKeys.DO_TRANSFORM]: + # Create inverse transform + zoom = np.array(transform[InverseKeys.EXTRA_INFO]["zoom"]) + inverse_transform = Zoom(zoom=1 / zoom, keep_size=self.keep_size) + # Apply inverse + d[key] = inverse_transform( + d[key], + mode=mode, + padding_mode=padding_mode, + align_corners=align_corners, + ) + # Size might be out by 1 voxel so pad + d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE])(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + return d @@ -1058,11 +1537,13 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda Rotate90D = Rotate90Dict = Rotate90d RandRotate90D = RandRotate90Dict = RandRotate90d ResizeD = ResizeDict = Resized +AffineD = AffineDict = Affined RandAffineD = RandAffineDict = RandAffined Rand2DElasticD = Rand2DElasticDict = Rand2DElasticd Rand3DElasticD = Rand3DElasticDict = Rand3DElasticd FlipD = FlipDict = Flipd RandFlipD = RandFlipDict = RandFlipd +RandAxisFlipD = RandAxisFlipDict = RandAxisFlipd RotateD = RotateDict = Rotated RandRotateD = RandRotateDict = RandRotated ZoomD = ZoomDict = Zoomd diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py new file mode 100644 index 0000000000..fea46aafa3 --- /dev/null +++ b/monai/transforms/transform.py @@ -0,0 +1,278 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A collection of generic interfaces for MONAI transforms. +""" + +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, Generator, Hashable, Iterable, List, Optional, Tuple + +import numpy as np + +from monai.config import KeysCollection +from monai.utils import MAX_SEED, ensure_tuple + +__all__ = ["apply_transform", "Randomizable", "RandomizableTransform", "Transform", "MapTransform"] + + +def apply_transform(transform: Callable, data, map_items: bool = True): + """ + Transform `data` with `transform`. + If `data` is a list or tuple and `map_data` is True, each item of `data` will be transformed + and this method returns a list of outcomes. + otherwise transform will be applied once with `data` as the argument. + + Args: + transform: a callable to be used to transform `data` + data: an object to be transformed. + map_items: whether to apply transform to each item in `data`, + if `data` is a list or tuple. Defaults to True. + + Raises: + Exception: When ``transform`` raises an exception. + + """ + try: + if isinstance(data, (list, tuple)) and map_items: + return [transform(item) for item in data] + return transform(data) + except Exception as e: + raise RuntimeError(f"applying transform {transform}") from e + + +class Randomizable(ABC): + """ + An interface for handling random state locally, currently based on a class variable `R`, + which is an instance of `np.random.RandomState`. + """ + + R: np.random.RandomState = np.random.RandomState() + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "Randomizable": + """ + Set the random state locally, to control the randomness, the derived + classes should use :py:attr:`self.R` instead of `np.random` to introduce random + factors. + + Args: + seed: set the random state with an integer seed. + state: set the random state with a `np.random.RandomState` object. + + Raises: + TypeError: When ``state`` is not an ``Optional[np.random.RandomState]``. + + Returns: + a Randomizable instance. + + """ + if seed is not None: + _seed = id(seed) if not isinstance(seed, (int, np.integer)) else seed + _seed = _seed % MAX_SEED + self.R = np.random.RandomState(_seed) + return self + + if state is not None: + if not isinstance(state, np.random.RandomState): + raise TypeError(f"state must be None or a np.random.RandomState but is {type(state).__name__}.") + self.R = state + return self + + self.R = np.random.RandomState() + return self + + def randomize(self, data: Any) -> None: + """ + Within this method, :py:attr:`self.R` should be used, instead of `np.random`, to introduce random factors. + + all :py:attr:`self.R` calls happen here so that we have a better chance to + identify errors of sync the random state. + + This method can generate the random factors based on properties of the input data. + + Raises: + NotImplementedError: When the subclass does not override this method. + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + +class Transform(ABC): + """ + An abstract class of a ``Transform``. + A transform is callable that processes ``data``. + + It could be stateful and may modify ``data`` in place, + the implementation should be aware of: + + #. thread safety when mutating its own states. + When used from a multi-process context, transform's instance variables are read-only. + #. ``data`` content unused by this transform may still be used in the + subsequent transforms in a composed transform. + #. storing too much information in ``data`` may not scale. + + See Also + + :py:class:`monai.transforms.Compose` + """ + + @abstractmethod + def __call__(self, data: Any): + """ + ``data`` is an element which often comes from an iteration over an + iterable, such as :py:class:`torch.utils.data.Dataset`. This method should + return an updated version of ``data``. + To simplify the input validations, most of the transforms assume that + + - ``data`` is a Numpy ndarray, PyTorch Tensor or string + - the data shape can be: + + #. string data without shape, `LoadImage` transform expects file paths + #. most of the pre-processing transforms expect: ``(num_channels, spatial_dim_1[, spatial_dim_2, ...])``, + except that `AddChannel` expects (spatial_dim_1[, spatial_dim_2, ...]) and + `AsChannelFirst` expects (spatial_dim_1[, spatial_dim_2, ...], num_channels) + #. most of the post-processing transforms expect + ``(batch_size, num_channels, spatial_dim_1[, spatial_dim_2, ...])`` + + - the channel dimension is not omitted even if number of channels is one + + This method can optionally take additional arguments to help execute transformation operation. + + Raises: + NotImplementedError: When the subclass does not override this method. + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + +class RandomizableTransform(Randomizable, Transform): + """ + An interface for handling random state locally, currently based on a class variable `R`, + which is an instance of `np.random.RandomState`. + This is mainly for randomized data augmentation transforms. For example:: + + class RandShiftIntensity(RandomizableTransform): + def randomize(): + self._offset = self.R.uniform(low=0, high=100) + def __call__(self, img): + self.randomize() + return img + self._offset + + transform = RandShiftIntensity() + transform.set_random_state(seed=0) + + """ + + def __init__(self, prob: float = 1.0, do_transform: bool = True): + self._do_transform = do_transform + self.prob = min(max(prob, 0.0), 1.0) + + def randomize(self, data: Any) -> None: + """ + Within this method, :py:attr:`self.R` should be used, instead of `np.random`, to introduce random factors. + + all :py:attr:`self.R` calls happen here so that we have a better chance to + identify errors of sync the random state. + + This method can generate the random factors based on properties of the input data. + """ + self._do_transform = self.R.rand() < self.prob + + +class MapTransform(Transform): + """ + A subclass of :py:class:`monai.transforms.Transform` with an assumption + that the ``data`` input of ``self.__call__`` is a MutableMapping such as ``dict``. + + The ``keys`` parameter will be used to get and set the actual data + item to transform. That is, the callable of this transform should + follow the pattern: + + .. code-block:: python + + def __call__(self, data): + for key in self.keys: + if key in data: + # update output data with some_transform_function(data[key]). + else: + # raise exception unless allow_missing_keys==True. + return data + + Raises: + ValueError: When ``keys`` is an empty iterable. + TypeError: When ``keys`` type is not in ``Union[Hashable, Iterable[Hashable]]``. + + """ + + 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 + if not self.keys: + raise ValueError("keys must be non empty.") + for key in self.keys: + if not isinstance(key, Hashable): + raise TypeError(f"keys must be one of (Hashable, Iterable[Hashable]) but is {type(keys).__name__}.") + + @abstractmethod + def __call__(self, data): + """ + ``data`` often comes from an iteration over an iterable, + such as :py:class:`torch.utils.data.Dataset`. + + To simplify the input validations, this method assumes: + + - ``data`` is a Python dictionary + - ``data[key]`` is a Numpy ndarray, PyTorch Tensor or string, where ``key`` is an element + of ``self.keys``, the data shape can be: + + #. string data without shape, `LoadImaged` transform expects file paths + #. most of the pre-processing transforms expect: ``(num_channels, spatial_dim_1[, spatial_dim_2, ...])``, + except that `AddChanneld` expects (spatial_dim_1[, spatial_dim_2, ...]) and + `AsChannelFirstd` expects (spatial_dim_1[, spatial_dim_2, ...], num_channels) + #. most of the post-processing transforms expect + ``(batch_size, num_channels, spatial_dim_1[, spatial_dim_2, ...])`` + + - the channel dimension is not omitted even if number of channels is one + + Raises: + NotImplementedError: When the subclass does not override this method. + + returns: + An updated dictionary version of ``data`` by applying the transform. + + """ + raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") + + def key_iterator( + self, + data: Dict[Hashable, Any], + *extra_iterables: Optional[Iterable], + ) -> Generator: + """ + Iterate across keys and optionally extra iterables. If key is missing, exception is raised if + `allow_missing_keys==False` (default). If `allow_missing_keys==True`, key is skipped. + + Args: + data: data that the transform will be applied to + extra_iterables: anything else to be iterated through + """ + # if no extra iterables given, create a dummy list of Nones + ex_iters = extra_iterables if extra_iterables else [[None] * len(self.keys)] + + # loop over keys and any extra iterables + _ex_iters: List[Any] + for key, *_ex_iters in zip(self.keys, *ex_iters): + # all normal, yield (what we yield depends on whether extra iterables were given) + if key in data.keys(): + yield (key,) + tuple(_ex_iters) if extra_iterables else key + # if missing keys not allowed, raise + elif not self.allow_missing_keys: + raise KeyError(f"Key was missing ({key}) and allow_missing_keys==False") diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index c0ae40de59..41804d5c1d 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -14,23 +14,35 @@ """ import logging +import sys import time -from typing import Callable, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import torch from monai.config import DtypeLike, NdarrayTensor -from monai.transforms.compose import Randomizable, Transform +from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import extreme_points_to_image, get_extreme_points, map_binary_to_indices from monai.utils import ensure_tuple, min_version, optional_import +if TYPE_CHECKING: + from PIL.Image import Image as PILImageImage + from PIL.Image import fromarray as pil_image_fromarray + + has_pil = True +else: + PILImageImage, has_pil = optional_import("PIL.Image", name="Image") + pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") + __all__ = [ "Identity", "AsChannelFirst", "AsChannelLast", "AddChannel", + "EnsureChannelFirst", "RepeatChannel", + "RemoveRepeatedChannel", "SplitChannel", "CastToType", "ToTensor", @@ -139,6 +151,32 @@ def __call__(self, img: NdarrayTensor): return img[None] +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. + + """ + + def __call__(self, img: np.ndarray, meta_dict: Optional[Dict] = None): + """ + Apply the transform to `img`. + """ + if not isinstance(meta_dict, dict): + raise ValueError("meta_dict must be a dictionay data.") + + channel_dim = meta_dict.get("original_channel_dim", None) + + if channel_dim is None: + raise ValueError("meta_dict must contain `original_channel_dim` information.") + elif channel_dim == "no_channel": + return AddChannel()(img) + else: + return AsChannelFirst(channel_dim=channel_dim)(img) + + class RepeatChannel(Transform): """ Repeat channel data to construct expected input shape for models. @@ -161,6 +199,32 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return np.repeat(img, self.repeats, 0) +class RemoveRepeatedChannel(Transform): + """ + RemoveRepeatedChannel data to undo RepeatChannel + The `repeats` count specifies the deletion of the origin data, for example: + ``RemoveRepeatedChannel(repeats=2)([[1, 2], [1, 2], [3, 4], [3, 4]])`` generates: ``[[1, 2], [3, 4]]`` + + Args: + repeats: the number of repetitions to be deleted for each element. + """ + + def __init__(self, repeats: int) -> None: + if repeats <= 0: + raise AssertionError("repeats count must be greater than 0.") + + self.repeats = repeats + + def __call__(self, img: np.ndarray) -> np.ndarray: + """ + Apply the transform to `img`, assuming `img` is a "channel-first" array. + """ + if np.shape(img)[0] < 2: + raise AssertionError("Image must have more than one channel") + + return np.array(img[:: self.repeats, :]) + + class SplitChannel(Transform): """ Split Numpy array or PyTorch Tensor data according to the channel dim. @@ -238,7 +302,7 @@ class ToTensor(Transform): Converts the input image to a tensor without applying any other transformations. """ - def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> torch.Tensor: + def __call__(self, img: Union[np.ndarray, torch.Tensor, PILImageImage]) -> torch.Tensor: """ Apply the transform to `img` and make it contiguous. """ @@ -252,7 +316,7 @@ class ToNumpy(Transform): Converts the input data to numpy array, can support list or tuple of numbers and PyTorch Tensor. """ - def __call__(self, img: Union[List, Tuple, np.ndarray, torch.Tensor]) -> np.ndarray: + def __call__(self, img: Union[List, Tuple, np.ndarray, torch.Tensor, PILImageImage]) -> np.ndarray: """ Apply the transform to `img` and make it contiguous. """ @@ -261,6 +325,22 @@ def __call__(self, img: Union[List, Tuple, np.ndarray, torch.Tensor]) -> np.ndar return np.ascontiguousarray(img) +class ToPIL(Transform): + """ + Converts the input image (in the form of NumPy array or PyTorch Tensor) to PIL image + """ + + def __call__(self, img: Union[np.ndarray, torch.Tensor, PILImageImage]) -> PILImageImage: + """ + Apply the transform to `img` and make it contiguous. + """ + if isinstance(img, PILImageImage): + return img + if isinstance(img, torch.Tensor): + img = img.detach().cpu().numpy() + return pil_image_fromarray(img) + + class Transpose(Transform): """ Transposes the input image based on the given `indices` dimension ordering. @@ -330,6 +410,7 @@ def __init__( additional_info: user can define callable function to extract additional info from input data. logger_handler: add additional handler to output data: save to file, etc. add existing python logging handlers: https://docs.python.org/3/library/logging.handlers.html + the handler should have a logging level of at least `INFO`. Raises: TypeError: When ``additional_info`` is not an ``Optional[Callable]``. @@ -345,8 +426,11 @@ def __init__( raise TypeError(f"additional_info must be None or callable but is {type(additional_info).__name__}.") self.additional_info = additional_info self.output: Optional[str] = None - logging.basicConfig(level=logging.NOTSET) self._logger = logging.getLogger("DataStats") + self._logger.setLevel(logging.INFO) + console = logging.StreamHandler(sys.stdout) # always stdout + console.setLevel(logging.INFO) + self._logger.addHandler(console) if logger_handler is not None: self._logger.addHandler(logger_handler) @@ -380,7 +464,7 @@ def __call__( lines.append(f"Additional info: {additional_info(img)}") separator = "\n" self.output = f"{separator.join(lines)}" - self._logger.debug(self.output) + self._logger.info(self.output) return img @@ -576,10 +660,10 @@ def __call__(self, img: np.ndarray) -> np.ndarray: result.append(np.logical_or(np.logical_or(img == 1, img == 4), img == 2)) # label 4 is ET result.append(img == 4) - return np.stack(result, axis=0).astype(np.float32) + return np.stack(result, axis=0) -class AddExtremePointsChannel(Transform, Randomizable): +class AddExtremePointsChannel(RandomizableTransform): """ Add extreme points of label to the image as a new channel. This transform generates extreme point from label and applies a gaussian filter. The pixel values in points image are rescaled diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index f374b82d76..a05a5fc904 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -17,13 +17,13 @@ import copy import logging -from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor -from monai.transforms.compose import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utility.array import ( AddChannel, AsChannelFirst, @@ -31,31 +31,44 @@ CastToType, ConvertToMultiChannelBasedOnBratsClasses, DataStats, + EnsureChannelFirst, FgBgToIndices, Identity, LabelToMask, Lambda, + RemoveRepeatedChannel, RepeatChannel, SimulateDelay, SplitChannel, SqueezeDim, ToNumpy, + ToPIL, TorchVision, ToTensor, ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points -from monai.utils import ensure_tuple, ensure_tuple_rep +from monai.utils import ensure_tuple, ensure_tuple_rep, optional_import + +if TYPE_CHECKING: + from PIL.Image import Image as PILImageImage + + has_pil = True +else: + PILImageImage, has_pil = optional_import("PIL.Image", name="Image") __all__ = [ "Identityd", "AsChannelFirstd", "AsChannelLastd", "AddChanneld", + "EnsureChannelFirstd", "RepeatChanneld", + "RemoveRepeatedChanneld", "SplitChanneld", "CastToTyped", "ToTensord", "ToNumpyd", + "ToPILd", "DeleteItemsd", "SelectItemsd", "SqueezeDimd", @@ -78,10 +91,14 @@ "AsChannelLastDict", "AddChannelD", "AddChannelDict", + "EnsureChannelFirstD", + "EnsureChannelFirstDict", "RandLambdaD", "RandLambdaDict", "RepeatChannelD", "RepeatChannelDict", + "RemoveRepeatedChannelD", + "RemoveRepeatedChannelDict", "SplitChannelD", "SplitChannelDict", "CastToTypeD", @@ -120,21 +137,22 @@ class Identityd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.Identity`. """ - def __init__(self, keys: KeysCollection) -> None: + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.identity = Identity() def __call__( self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.identity(d[key]) return d @@ -144,19 +162,20 @@ class AsChannelFirstd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AsChannelFirst`. """ - def __init__(self, keys: KeysCollection, channel_dim: int = -1) -> None: + def __init__(self, keys: KeysCollection, channel_dim: int = -1, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` channel_dim: which dimension of input image is the channel, default is the last dimension. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = AsChannelFirst(channel_dim=channel_dim) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -166,19 +185,20 @@ class AsChannelLastd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AsChannelLast`. """ - def __init__(self, keys: KeysCollection, channel_dim: int = 0) -> None: + def __init__(self, keys: KeysCollection, channel_dim: int = 0, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` channel_dim: which dimension of input image is the channel, default is the first dimension. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = AsChannelLast(channel_dim=channel_dim) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -188,40 +208,91 @@ class AddChanneld(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AddChannel`. """ - def __init__(self, keys: KeysCollection) -> None: + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.adder = AddChannel() def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.adder(d[key]) return d +class EnsureChannelFirstd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.EnsureChannelFirst`. + """ + + def __init__(self, keys: KeysCollection, meta_key_postfix: str = "meta_dict") -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + meta_key_postfix: `key_{postfix}` was used to store the metadata in `LoadImaged`. + So need the key to extract metadata for channel dim information, default is `meta_dict`. + For example, for data with key `image`, metadata by default is in `image_meta_dict`. + + """ + super().__init__(keys) + self.adjuster = EnsureChannelFirst() + self.meta_key_postfix = meta_key_postfix + + def __call__(self, data) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.keys: + d[key] = self.adjuster(d[key], d[f"{key}_{self.meta_key_postfix}"]) + return d + + class RepeatChanneld(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.RepeatChannel`. """ - def __init__(self, keys: KeysCollection, repeats: int) -> None: + def __init__(self, keys: KeysCollection, repeats: int, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` repeats: the number of repetitions for each element. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.repeater = RepeatChannel(repeats) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + d[key] = self.repeater(d[key]) + return d + + +class RemoveRepeatedChanneld(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.RemoveRepeatedChannel`. + """ + + def __init__(self, keys: KeysCollection, repeats: int, allow_missing_keys: bool = False) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + repeats: the number of repetitions for each element. + allow_missing_keys: don't raise exception if key is missing. + """ + super().__init__(keys, allow_missing_keys) + self.repeater = RemoveRepeatedChannel(repeats) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.key_iterator(d): d[key] = self.repeater(d[key]) return d @@ -238,6 +309,7 @@ def __init__( keys: KeysCollection, output_postfixes: Optional[Sequence[str]] = None, channel_dim: Optional[int] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -251,9 +323,10 @@ def __init__( to automatically select: if data is numpy array, channel_dim is 0 as `numpy array` is used in the pre transforms, if PyTorch Tensor, channel_dim is 1 as in most of the cases `Tensor` is uses in the post transforms. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.output_postfixes = output_postfixes self.splitter = SplitChannel(channel_dim=channel_dim) @@ -261,7 +334,7 @@ def __call__( self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): rets = self.splitter(d[key]) postfixes: Sequence = list(range(len(rets))) if self.output_postfixes is None else self.output_postfixes if len(postfixes) != len(rets): @@ -283,6 +356,7 @@ def __init__( self, keys: KeysCollection, dtype: Union[Sequence[Union[DtypeLike, torch.dtype]], DtypeLike, torch.dtype] = np.float32, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -291,9 +365,10 @@ def __init__( dtype: convert image to this data type, default is `np.float32`. it also can be a sequence of dtypes or torch.dtype, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) self.dtype = ensure_tuple_rep(dtype, len(self.keys)) self.converter = CastToType() @@ -301,8 +376,8 @@ def __call__( self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: d = dict(data) - for idx, key in enumerate(self.keys): - d[key] = self.converter(d[key], dtype=self.dtype[idx]) + for key, dtype in self.key_iterator(d, self.dtype): + d[key] = self.converter(d[key], dtype=dtype) return d @@ -312,20 +387,21 @@ class ToTensord(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ToTensor`. """ - def __init__(self, keys: KeysCollection) -> None: + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = ToTensor() def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -335,20 +411,45 @@ class ToNumpyd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ToNumpy`. """ - def __init__(self, keys: KeysCollection) -> None: + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = ToNumpy() def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): + d[key] = self.converter(d[key]) + return d + + +class ToPILd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.ToNumpy`. + """ + + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + allow_missing_keys: don't raise exception if key is missing. + """ + super().__init__(keys, allow_missing_keys) + self.converter = ToPIL() + + def __call__( + self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]] + ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor, PILImageImage]]: + d = dict(data) + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -360,7 +461,7 @@ class DeleteItemsd(MapTransform): """ def __call__(self, data): - return {key: val for key, val in data.items() if key not in self.keys} + return {key: val for key, val in data.items() if key not in self.key_iterator(data)} class SelectItemsd(MapTransform): @@ -370,7 +471,7 @@ class SelectItemsd(MapTransform): """ def __call__(self, data): - result = {key: val for key, val in data.items() if key in self.keys} + result = {key: data[key] for key in self.key_iterator(data)} return result @@ -379,19 +480,20 @@ class SqueezeDimd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.SqueezeDim`. """ - def __init__(self, keys: KeysCollection, dim: int = 0) -> None: + def __init__(self, keys: KeysCollection, dim: int = 0, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` dim: dimension to be squeezed. Default: 0 (the first dimension) + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = SqueezeDim(dim=dim) def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -410,6 +512,7 @@ def __init__( data_value: Union[Sequence[bool], bool] = False, additional_info: Optional[Union[Sequence[Callable], Callable]] = None, logger_handler: Optional[logging.Handler] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -429,9 +532,11 @@ def __init__( corresponds to a key in ``keys``. logger_handler: add additional handler to output data: save to file, etc. add existing python logging handlers: https://docs.python.org/3/library/logging.handlers.html + the handler should have a logging level of at least `INFO`. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.prefix = ensure_tuple_rep(prefix, len(self.keys)) self.data_shape = ensure_tuple_rep(data_shape, len(self.keys)) self.value_range = ensure_tuple_rep(value_range, len(self.keys)) @@ -442,14 +547,16 @@ def __init__( def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) - for idx, key in enumerate(self.keys): + for key, prefix, data_shape, value_range, data_value, additional_info in self.key_iterator( + d, self.prefix, self.data_shape, self.value_range, self.data_value, self.additional_info + ): d[key] = self.printer( d[key], - self.prefix[idx], - self.data_shape[idx], - self.value_range[idx], - self.data_value[idx], - self.additional_info[idx], + prefix, + data_shape, + value_range, + data_value, + additional_info, ) return d @@ -459,23 +566,26 @@ class SimulateDelayd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.SimulateDelay`. """ - def __init__(self, keys: KeysCollection, delay_time: Union[Sequence[float], float] = 0.0) -> None: + def __init__( + self, keys: KeysCollection, delay_time: Union[Sequence[float], float] = 0.0, allow_missing_keys: bool = False + ) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` delay_time: The minimum amount of time, in fractions of seconds, to accomplish this identity task. It also can be a sequence of string, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.delay_time = ensure_tuple_rep(delay_time, len(self.keys)) self.delayer = SimulateDelay() def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) - for idx, key in enumerate(self.keys): - d[key] = self.delayer(d[key], delay_time=self.delay_time[idx]) + for key, delay_time in self.key_iterator(d, self.delay_time): + d[key] = self.delayer(d[key], delay_time=delay_time) return d @@ -486,7 +596,9 @@ class CopyItemsd(MapTransform): """ - def __init__(self, keys: KeysCollection, times: int, names: KeysCollection) -> None: + def __init__( + self, keys: KeysCollection, times: int, names: KeysCollection, allow_missing_keys: bool = False + ) -> None: """ Args: keys: keys of the corresponding items to be transformed. @@ -496,13 +608,14 @@ def __init__(self, keys: KeysCollection, times: int, names: KeysCollection) -> N names: the names corresponding to the newly copied data, the length should match `len(keys) x times`. for example, if keys is ["img", "seg"] and times is 2, names can be: ["img_1", "seg_1", "img_2", "seg_2"]. + allow_missing_keys: don't raise exception if key is missing. Raises: ValueError: When ``times`` is nonpositive. ValueError: When ``len(names)`` is not ``len(keys) * times``. Incompatible values. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) if times < 1: raise ValueError(f"times must be positive, got {times}.") self.times = times @@ -521,13 +634,14 @@ def __call__(self, data): """ d = dict(data) - for key, new_key in zip(self.keys * self.times, self.names): + for new_key in self.names: if new_key in d: raise KeyError(f"Key {new_key} already exists in data.") - if isinstance(d[key], torch.Tensor): - d[new_key] = d[key].detach().clone() - else: - d[new_key] = copy.deepcopy(d[key]) + for key in self.key_iterator(d): + if isinstance(d[key], torch.Tensor): + d[new_key] = d[key].detach().clone() + else: + d[new_key] = copy.deepcopy(d[key]) return d @@ -538,19 +652,20 @@ class ConcatItemsd(MapTransform): """ - def __init__(self, keys: KeysCollection, name: str, dim: int = 0) -> None: + def __init__(self, keys: KeysCollection, name: str, dim: int = 0, allow_missing_keys: bool = False) -> None: """ Args: keys: keys of the corresponding items to be concatenated together. See also: :py:class:`monai.transforms.compose.MapTransform` name: the name corresponding to the key to store the concatenated data. dim: on which dimension to concatenate the items, default is 0. + allow_missing_keys: don't raise exception if key is missing. Raises: ValueError: When insufficient keys are given (``len(self.keys) < 2``). """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) if len(self.keys) < 2: raise ValueError("Concatenation requires at least 2 keys.") self.name = name @@ -566,7 +681,7 @@ def __call__(self, data): d = dict(data) output = [] data_type = None - for key in self.keys: + for key in self.key_iterator(d): if data_type is None: data_type = type(d[key]) elif not isinstance(d[key], data_type): @@ -602,6 +717,7 @@ class Lambdad(MapTransform): each element corresponds to a key in ``keys``. overwrite: whether to overwrite the original data in the input dictionary with lamdbda function output. default to True. it also can be a sequence of bool, each element corresponds to a key in ``keys``. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -609,24 +725,25 @@ def __init__( keys: KeysCollection, func: Union[Sequence[Callable], Callable], overwrite: Union[Sequence[bool], bool] = True, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.func = ensure_tuple_rep(func, len(self.keys)) self.overwrite = ensure_tuple_rep(overwrite, len(self.keys)) self._lambd = Lambda() def __call__(self, data): d = dict(data) - for idx, key in enumerate(self.keys): - ret = self._lambd(d[key], func=self.func[idx]) - if self.overwrite[idx]: + for key, func, overwrite in self.key_iterator(d, self.func, self.overwrite): + ret = self._lambd(d[key], func=func) + if overwrite: d[key] = ret return d -class RandLambdad(Lambdad, Randomizable): +class RandLambdad(Lambdad, RandomizableTransform): """ - Randomizable version :py:class:`monai.transforms.Lambdad`, the input `func` contains random logic. + RandomizableTransform version :py:class:`monai.transforms.Lambdad`, the input `func` contains random logic. It's a randomizable transform so `CacheDataset` will not execute it and cache the results. Args: @@ -657,6 +774,7 @@ class LabelToMaskd(MapTransform): `select_labels` is the expected channel indices. merge_channels: whether to use `np.any()` to merge the result on channel dim. if yes, will return a single channel mask with binary data. + allow_missing_keys: don't raise exception if key is missing. """ @@ -665,13 +783,14 @@ def __init__( # pytype: disable=annotation-type-mismatch keys: KeysCollection, select_labels: Union[Sequence[int], int], merge_channels: bool = False, + allow_missing_keys: bool = False, ) -> None: # pytype: disable=annotation-type-mismatch - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.converter = LabelToMask(select_labels=select_labels, merge_channels=merge_channels) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -693,6 +812,7 @@ class FgBgToIndicesd(MapTransform): image_threshold: if enabled image_key, use ``image > image_threshold`` to determine the valid image content area and select background only in this area. output_shape: expected shape of output indices. if not None, unravel indices to specified shape. + allow_missing_keys: don't raise exception if key is missing. """ @@ -704,8 +824,9 @@ def __init__( image_key: Optional[str] = None, image_threshold: float = 0.0, output_shape: Optional[Sequence[int]] = None, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.fg_postfix = fg_postfix self.bg_postfix = bg_postfix self.image_key = image_key @@ -714,7 +835,7 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) image = d[self.image_key] if self.image_key else None - for key in self.keys: + for key in self.key_iterator(d): d[str(key) + self.fg_postfix], d[str(key) + self.bg_postfix] = self.converter(d[key], image) return d @@ -731,18 +852,18 @@ class ConvertToMultiChannelBasedOnBratsClassesd(MapTransform): and ET (Enhancing tumor). """ - def __init__(self, keys: KeysCollection): - super().__init__(keys) + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False): + super().__init__(keys, allow_missing_keys) self.converter = ConvertToMultiChannelBasedOnBratsClasses() def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d -class AddExtremePointsChanneld(Randomizable, MapTransform): +class AddExtremePointsChanneld(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AddExtremePointsChannel`. @@ -757,6 +878,7 @@ class AddExtremePointsChanneld(Randomizable, MapTransform): use it for all spatial dimensions. rescale_min: minimum value of output data. rescale_max: maximum value of output data. + allow_missing_keys: don't raise exception if key is missing. """ @@ -769,8 +891,9 @@ def __init__( sigma: Union[Sequence[float], float, Sequence[torch.Tensor], torch.Tensor] = 3.0, rescale_min: float = -1.0, rescale_max: float = 1.0, + allow_missing_keys: bool = False, ): - super().__init__(keys) + MapTransform.__init__(self, keys, allow_missing_keys) self.background = background self.pert = pert self.points: List[Tuple[int, ...]] = [] @@ -791,17 +914,16 @@ def __call__(self, data): # Generate extreme points self.randomize(label[0, :]) - for key in data.keys(): - if key in self.keys: - img = d[key] - points_image = extreme_points_to_image( - points=self.points, - label=label, - sigma=self.sigma, - rescale_min=self.rescale_min, - rescale_max=self.rescale_max, - ) - d[key] = np.concatenate([img, points_image], axis=0) + for key in self.key_iterator(d): + img = d[key] + points_image = extreme_points_to_image( + points=self.points, + label=label, + sigma=self.sigma, + rescale_min=self.rescale_min, + rescale_max=self.rescale_max, + ) + d[key] = np.concatenate([img, points_image], axis=0) return d @@ -812,22 +934,23 @@ class TorchVisiond(MapTransform): data to be dict of PyTorch Tensors, users can easily call `ToTensord` transform to convert Numpy to Tensor. """ - def __init__(self, keys: KeysCollection, name: str, *args, **kwargs) -> None: + def __init__(self, keys: KeysCollection, name: str, allow_missing_keys: bool = False, *args, **kwargs) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` name: The transform name in TorchVision package. + allow_missing_keys: don't raise exception if key is missing. args: parameters for the TorchVision transform. kwargs: parameters for the TorchVision transform. """ - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.trans = TorchVision(name, *args, **kwargs) def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): d[key] = self.trans(d[key]) return d @@ -836,10 +959,14 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc AsChannelFirstD = AsChannelFirstDict = AsChannelFirstd AsChannelLastD = AsChannelLastDict = AsChannelLastd AddChannelD = AddChannelDict = AddChanneld +EnsureChannelFirstD = EnsureChannelFirstDict = EnsureChannelFirstd +RemoveRepeatedChannelD = RemoveRepeatedChannelDict = RemoveRepeatedChanneld RepeatChannelD = RepeatChannelDict = RepeatChanneld SplitChannelD = SplitChannelDict = SplitChanneld CastToTypeD = CastToTypeDict = CastToTyped ToTensorD = ToTensorDict = ToTensord +ToNumpyD = ToNumpyDict = ToNumpyd +ToPILD = ToPILDict = ToPILd DeleteItemsD = DeleteItemsDict = DeleteItemsd SqueezeDimD = SqueezeDimDict = SqueezeDimd DataStatsD = DataStatsDict = DataStatsd diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 9a84eb00d9..eb1b194c96 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -12,6 +12,7 @@ import itertools import random import warnings +from contextlib import contextmanager from typing import Callable, List, Optional, Sequence, Tuple, Union import numpy as np @@ -19,7 +20,10 @@ from monai.config import DtypeLike, IndexSelection from monai.networks.layers import GaussianFilter +from monai.transforms.compose import Compose +from monai.transforms.transform import MapTransform from monai.utils import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, min_version, optional_import +from monai.utils.misc import issequenceiterable measure, _ = optional_import("skimage.measure", "0.14.2", min_version) @@ -37,7 +41,6 @@ "map_binary_to_indices", "weighted_patch_samples", "generate_pos_neg_label_crop_centers", - "apply_transform", "create_grid", "create_control_grid", "create_rotate", @@ -49,6 +52,7 @@ "get_extreme_points", "extreme_points_to_image", "map_spatial_axes", + "allow_missing_keys_mode", ] @@ -363,31 +367,6 @@ def _correct_centers( return centers -def apply_transform(transform: Callable, data, map_items: bool = True): - """ - Transform `data` with `transform`. - If `data` is a list or tuple and `map_data` is True, each item of `data` will be transformed - and this method returns a list of outcomes. - otherwise transform will be applied once with `data` as the argument. - - Args: - transform: a callable to be used to transform `data` - data: an object to be transformed. - map_items: whether to apply transform to each item in `data`, - if `data` is a list or tuple. Defaults to True. - - Raises: - Exception: When ``transform`` raises an exception. - - """ - try: - if isinstance(data, (list, tuple)) and map_items: - return [transform(item) for item in data] - return transform(data) - except Exception as e: - raise RuntimeError(f"applying transform {transform}") from e - - def create_grid( spatial_size: Sequence[int], spacing: Optional[Sequence[float]] = None, @@ -730,3 +709,50 @@ def map_spatial_axes( spatial_axes_.append(a - 1 if a < 0 else a) return spatial_axes_ + + +@contextmanager +def allow_missing_keys_mode(transform: Union[MapTransform, Compose, Tuple[MapTransform], Tuple[Compose]]): + """Temporarily set all MapTransforms to not throw an error if keys are missing. After, revert to original states. + + Args: + transform: either MapTransform or a Compose + + Example: + + .. code-block:: python + + data = {"image": np.arange(16, dtype=float).reshape(1, 4, 4)} + t = SpatialPadd(["image", "label"], 10, allow_missing_keys=False) + _ = t(data) # would raise exception + with allow_missing_keys_mode(t): + _ = t(data) # OK! + """ + # If given a sequence of transforms, Compose them to get a single list + if issequenceiterable(transform): + transform = Compose(transform) + + # Get list of MapTransforms + transforms = [] + if isinstance(transform, MapTransform): + transforms = [transform] + elif isinstance(transform, Compose): + # Only keep contained MapTransforms + transforms = [t for t in transform.flatten().transforms if isinstance(t, MapTransform)] + if len(transforms) == 0: + raise TypeError( + "allow_missing_keys_mode expects either MapTransform(s) or Compose(s) containing MapTransform(s)" + ) + + # Get the state of each `allow_missing_keys` + orig_states = [t.allow_missing_keys for t in transforms] + + try: + # Set all to True + for t in transforms: + t.allow_missing_keys = True + yield + finally: + # Revert + for t, o_s in zip(transforms, orig_states): + t.allow_missing_keys = o_s diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 1e17d44029..6a76c96d0c 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -17,9 +17,11 @@ Average, BlendMode, ChannelMatching, + CommonKeys, GridSampleMode, GridSamplePadMode, InterpolateMode, + InverseKeys, LossReduction, Method, MetricReduction, @@ -30,6 +32,7 @@ UpsampleMode, Weight, ) +from .jupyter_utils import StatusMembers, ThreadContainer from .misc import ( MAX_SEED, ImageMetaKey, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index d1d2d3bcce..9920aefe0e 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -28,6 +28,8 @@ "ChannelMatching", "SkipMode", "Method", + "InverseKeys", + "CommonKeys", ] @@ -214,3 +216,31 @@ class Method(Enum): SYMMETRIC = "symmetric" END = "end" + + +class InverseKeys: + """Extra meta data keys used for inverse transforms.""" + + CLASS_NAME = "class" + ID = "id" + ORIG_SIZE = "orig_size" + EXTRA_INFO = "extra_info" + DO_TRANSFORM = "do_transforms" + KEY_SUFFIX = "_transforms" + + +class CommonKeys: + """ + A set of common keys for dictionary based supervised training process. + `IMAGE` is the input image data. + `LABEL` is the training or evaluation label of segmentation or classification task. + `PRED` is the prediction data of model output. + `LOSS` is the loss value of current iteration. + `INFO` is some useful information during training or evaluation, like loss value, etc. + + """ + + IMAGE = "image" + LABEL = "label" + PRED = "pred" + LOSS = "loss" diff --git a/monai/utils/jupyter_utils.py b/monai/utils/jupyter_utils.py new file mode 100644 index 0000000000..726a11731c --- /dev/null +++ b/monai/utils/jupyter_utils.py @@ -0,0 +1,351 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This set of utility function is meant to make using Jupyter notebooks easier with MONAI. Plotting functions using +Matplotlib produce common plots for metrics and images. +""" + +from enum import Enum +from threading import RLock, Thread +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import numpy as np +import torch + +# from monai.utils import exact_version, optional_import + +# if TYPE_CHECKING: +# import matplotlib.pyplot as plt +# from ignite.engine import Engine, Events + +# Figure = plt.Figure +# Axes = plt.Axes +# has_matplotlib = True +# else: +# Engine, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Engine") +# Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") +# plt, has_matplotlib = optional_import("matplotlib.pyplot") +# Figure, _ = optional_import("matplotlib.pyplot", name="Figure") +# Axes, _ = optional_import("matplotlib.pyplot", name="Axes") + +try: + import matplotlib.pyplot as plt + + has_matplotlib = True +except ImportError: + has_matplotlib = False + +try: + from ignite.engine import Engine, Events + + has_ignite = True +except ImportError: + Engine = object + Events = object + has_ignite = False + +LOSS_NAME = "loss" + + +def plot_metric_graph( + ax, + title: str, + graphmap: Dict[str, Union[List[float], Tuple[List[float], List[float]]]], + yscale: str = "log", + avg_keys: Tuple[str] = (LOSS_NAME,), + window_fraction: int = 20, +): + """ + Plot metrics on a single graph with running averages plotted for selected keys. The values in `graphmap` + should be lists of (timepoint, value) pairs as stored in MetricLogger objects. + + Args: + ax: Axes object to plot into + title: graph title + graphmap: dictionary of named graph values, which are lists of values or (index, value) pairs + yscale: scale for y-axis compatible with `Axes.set_yscale` + avg_keys: tuple of keys in `graphmap` to provide running average plots for + window_fraction: what fraction of the graph value length to use as the running average window + """ + from matplotlib.ticker import MaxNLocator + + for n, v in graphmap.items(): + if len(v) > 0: + if isinstance(v[0], (tuple, list)): # values are (x,y) pairs + inds, vals = zip(*v) # separate values into list of indices in X dimension and values + else: + inds, vals = tuple(range(len(v))), tuple(v) # values are without indices, make indices for them + + ax.plot(inds, vals, label=f"{n} = {vals[-1]:.5g}") + + # if requested compute and plot a running average for the values using a fractional window size + if n in avg_keys and len(v) > window_fraction: + window = len(v) // window_fraction + kernel = np.ones((window,)) / window + ra = np.convolve((vals[0],) * (window - 1) + vals, kernel, mode="valid") + + ax.plot(inds, ra, label=f"{n} Avg = {ra[-1]:.5g}") + + ax.set_title(title) + ax.set_yscale(yscale) + ax.axis("on") + ax.legend(bbox_to_anchor=(1, 1), loc=1, borderaxespad=0.0) + ax.grid(True, "both", "both") + ax.xaxis.set_major_locator(MaxNLocator(integer=True)) + + +def plot_metric_images( + fig, + title: str, + graphmap: Dict[str, Union[List[float], Tuple[List[float], List[float]]]], + imagemap: Dict[str, np.ndarray], + yscale: str = "log", + avg_keys: Tuple[str] = (LOSS_NAME,), + window_fraction: int = 20, +) -> List: + """ + Plot metric graph data with images below into figure `fig`. The intended use is for the graph data to be + metrics from a training run and the images to be the batch and output from the last iteration. This uses + `plot_metric_graph` to plot the metric graph. + + Args: + fig: Figure object to plot into, reuse from previous plotting for flicker-free refreshing + title: graph title + graphmap: dictionary of named graph values, which are lists of values or (index, value) pairs + imagemap: dictionary of named images to show with metric plot + yscale: for metric plot, scale for y-axis compatible with `Axes.set_yscale` + avg_keys: for metric plot, tuple of keys in `graphmap` to provide running average plots for + window_fraction: for metric plot, what fraction of the graph value length to use as the running average window + + Returns: + list of Axes objects for graph followed by images + """ + gridshape = (4, max(1, len(imagemap))) + + graph = plt.subplot2grid(gridshape, (0, 0), colspan=gridshape[1], fig=fig) + + plot_metric_graph(graph, title, graphmap, yscale, avg_keys, window_fraction) + + axes = [graph] + for i, n in enumerate(imagemap): + im = plt.subplot2grid(gridshape, (1, i), rowspan=2, fig=fig) + + if imagemap[n].shape[0] == 3: + im.imshow(imagemap[n].transpose([1, 2, 0])) + else: + im.imshow(np.squeeze(imagemap[n]), cmap="gray") + + im.set_title("%s\n%.3g -> %.3g" % (n, imagemap[n].min(), imagemap[n].max())) + im.axis("off") + axes.append(im) + + return axes + + +def tensor_to_images(name: str, tensor: torch.Tensor): + """ + Return an tuple of images derived from the given tensor. The `name` value indices which key from the + output or batch value the tensor was stored as, or is "Batch" or "Output" if these were single tensors + instead of dictionaries. Returns a tuple of 2D images of shape HW, or 3D images of shape CHW where C is + color channels RGB or RGBA. This allows multiple images to be created from a single tensor, ie. to show + each channel separately. + """ + if tensor.ndim == 4 and tensor.shape[2] > 2 and tensor.shape[3] > 2: + return tuple(tensor[0].cpu().data.numpy()) + elif tensor.ndim == 5 and tensor.shape[3] > 2 and tensor.shape[4] > 2: + dmid = tensor.shape[2] // 2 + return tuple(tensor[0, :, dmid].cpu().data.numpy()) + + return () + + +def plot_engine_status( + engine: Engine, + logger, + title: str = "Training Log", + yscale: str = "log", + avg_keys: Tuple[str] = (LOSS_NAME,), + window_fraction: int = 20, + image_fn: Optional[Callable] = tensor_to_images, + fig=None, +) -> Tuple: + """ + Plot the status of the given Engine with its logger. The plot will consist of a graph of loss values and metrics + taken from the logger, and images taken from the `output` and `batch` members of `engine.state`. The images are + converted to Numpy arrays suitable for input to `Axes.imshow` using `image_fn`, if this is None then no image + plotting is done. + + Args: + engine: Engine to extract images from + logger: MetricLogger to extract loss and metric data from + title: graph title + yscale: for metric plot, scale for y-axis compatible with `Axes.set_yscale` + avg_keys: for metric plot, tuple of keys in `graphmap` to provide running average plots for + window_fraction: for metric plot, what fraction of the graph value length to use as the running average window + image_fn: callable converting tensors keyed to a name in the Engine to a tuple of images to plot + fig: Figure object to plot into, reuse from previous plotting for flicker-free refreshing + + Returns: + Figure object (or `fig` if given), list of Axes objects for graph and images + """ + if fig is not None: + fig.clf() + else: + fig = plt.Figure(figsize=(20, 10), tight_layout=True, facecolor="white") + + graphmap = {LOSS_NAME: logger.loss} + graphmap.update(logger.metrics) + + imagemap = {} + + if image_fn is not None and engine.state is not None and engine.state.batch is not None: + for src in (engine.state.batch, engine.state.output): + if isinstance(src, dict): + for k, v in src.items(): + images = image_fn(k, v) + + for i, im in enumerate(images): + imagemap[f"{k}_{i}"] = im + else: + label = "Batch" if src is engine.state.batch else "Output" + images = image_fn(label, src) + + for i, im in enumerate(images): + imagemap[f"{label}_{i}"] = im + + axes = plot_metric_images(fig, title, graphmap, imagemap, yscale, avg_keys, window_fraction) + + axes[0].axhline(logger.loss[-1][1], c="k", ls=":") # draw dotted horizontal line at last loss value + + return fig, axes + + +def _get_loss_from_output(output: Union[Dict[str, torch.Tensor], torch.Tensor]) -> float: + """Returns a single value from the network output, which is a dict or tensor.""" + if isinstance(output, dict): + return output["loss"].item() + else: + return output.item() + + +class StatusMembers(Enum): + """ + Named members of the status dictionary, others may be present for named metric values. + """ + + STATUS = "Status" + EPOCHS = "Epochs" + ITERS = "Iters" + LOSS = "Loss" + + +class ThreadContainer(Thread): + """ + Contains a running `Engine` object within a separate thread from main thread in a Jupyter notebook. This + allows an engine to begin a run in the background and allow the starting notebook cell to complete. A + user can thus start a run and then navigate away from the notebook without concern for loosing connection + with the running cell. All output is acquired through methods which synchronize with the running engine + using an internal `lock` member, acquiring this lock allows the engine to be inspected while it's prevented + from starting the next iteration. + + Args: + engine: wrapped `Engine` object, when the container is started its `run` method is called + loss_transform: callable to convert an output dict into a single numeric value + metric_transform: callable to convert a named metric value into a single numeric value + """ + + def __init__( + self, + engine: Engine, + loss_transform: Callable = _get_loss_from_output, + metric_transform: Callable = lambda name, value: value, + ): + super().__init__() + self.lock = RLock() + self.engine = engine + self._status_dict: Dict[str, Any] = {} + self.loss_transform = loss_transform + self.metric_transform = metric_transform + self.fig = None + + self.engine.add_event_handler(Events.ITERATION_COMPLETED, self._update_status) + + def run(self): + """Calls the `run` method of the wrapped engine.""" + self.engine.run() + + def stop(self): + """Stop the engine and join the thread.""" + self.engine.terminate() + self.join() + + def _update_status(self): + """Called as an event, updates the internal status dict at the end of iterations.""" + with self.lock: + state = self.engine.state + stats = { + StatusMembers.EPOCHS.value: 0, + StatusMembers.ITERS.value: 0, + StatusMembers.LOSS.value: float("nan"), + } + + if state is not None: + if state.max_epochs >= 1: + epoch = f"{state.epoch}/{state.max_epochs}" + else: + epoch = str(state.epoch) + + if state.epoch_length is not None: + iters = f"{state.iteration % state.epoch_length}/{state.epoch_length}" + else: + iters = str(state.iteration) + + stats[StatusMembers.EPOCHS.value] = epoch + stats[StatusMembers.ITERS.value] = iters + stats[StatusMembers.LOSS.value] = self.loss_transform(state.output) + + metrics = state.metrics or {} + for m, v in metrics.items(): + v = self.metric_transform(m, v) + if v is not None: + stats[m].append(v) + + self._status_dict.update(stats) + + @property + def status_dict(self) -> Dict[str, str]: + """A dictionary containing status information, current loss, and current metric values.""" + with self.lock: + stats = {StatusMembers.STATUS.value: "Running" if self.is_alive else "Stopped"} + stats.update(self._status_dict) + return stats + + def status(self) -> str: + """Returns a status string for the current state of the engine.""" + stats = self.status_dict + + msgs = [stats.pop(StatusMembers.STATUS.value), "Iters: " + str(stats.pop(StatusMembers.ITERS.value))] + msgs += ["%s: %s" % kv for kv in stats.items()] + + return ", ".join(msgs) + + def plot_status(self, logger, plot_func: Callable = plot_engine_status): + """ + Generate a plot of the current status of the contained engine whose loss and metrics were tracked by `logger`. + The function `plot_func` must accept arguments `title`, `engine`, `logger`, and `fig` which are the plot title, + `self.engine`, `logger`, and `self.fig` respectively. The return value must be a figure object (stored in + `self.fig`) and a list of Axes objects for the plots in the figure. Only the figure is returned by this method, + which holds the internal lock during the plot generation. + """ + with self.lock: + self.fig, axes = plot_func(title=self.status(), engine=self.engine, logger=logger, fig=self.fig) + return self.fig diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index a917bcf800..b310ec0834 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -17,7 +17,6 @@ import torch.nn as nn import torch.nn.functional as F -from monai.networks.utils import eval_mode, train_mode from monai.transforms import ScaleIntensity from monai.utils import ensure_tuple from monai.visualize.visualizer import default_upsampler @@ -110,26 +109,24 @@ def get_layer(self, layer_id: Union[str, Callable]): return mod raise NotImplementedError(f"Could not find {layer_id}.") - def class_score(self, logits, class_idx=None): - if class_idx is not None: - return logits[:, class_idx].squeeze(), class_idx - class_idx = logits.max(1)[-1] - return logits[:, class_idx].squeeze(), class_idx + def class_score(self, logits, class_idx): + return logits[:, class_idx].squeeze() def __call__(self, x, class_idx=None, retain_graph=False): - # Use train_mode if grad is required, else eval_mode - mode = train_mode if self.register_backward else eval_mode - with mode(self.model): - logits = self.model(x) - acti, grad = None, None - if self.register_forward: - acti = tuple(self.activations[layer] for layer in self.target_layers) - if self.register_backward: - score, class_idx = self.class_score(logits, class_idx) - self.model.zero_grad() - self.score, self.class_idx = score, class_idx - score.sum().backward(retain_graph=retain_graph) - grad = tuple(self.gradients[layer] for layer in self.target_layers) + train = self.model.training + self.model.eval() + logits = self.model(x) + self.class_idx = logits.max(1)[-1] if class_idx is None else class_idx + acti, grad = None, None + if self.register_forward: + acti = tuple(self.activations[layer] for layer in self.target_layers) + if self.register_backward: + self.score = self.class_score(logits, self.class_idx) + self.model.zero_grad() + self.score.sum().backward(retain_graph=retain_graph) + grad = tuple(self.gradients[layer] for layer in self.target_layers) + if train: + self.model.train() return logits, acti, grad def get_wrapped_net(self): @@ -173,6 +170,17 @@ def feature_map_size(self, input_size, device="cpu", layer_idx=-1): return self.compute_map(torch.zeros(*input_size, device=device), layer_idx=layer_idx).shape def compute_map(self, x, class_idx=None, layer_idx=-1): + """ + Compute the actual feature map with input tensor `x`. + + Args: + x: input to `nn_module`. + class_idx: index of the class to be visualized. Default to `None` (computing `class_idx` from `argmax`) + layer_idx: index of the target layer if there are multiple target layers. Defaults to -1. + + Returns: + activation maps (raw outputs without upsampling/post-processing.) + """ raise NotImplementedError() def _upsample_and_post_process(self, acti_map, x): @@ -191,16 +199,20 @@ def __call__(self): class CAM(CAMBase): """ Compute class activation map from the last fully-connected layers before the spatial pooling. + This implementation is based on: + + Zhou et al., Learning Deep Features for Discriminative Localization. CVPR '16, + https://arxiv.org/abs/1512.04150 Examples .. code-block:: python # densenet 2d - from monai.networks.nets import densenet121 + from monai.networks.nets import DenseNet121 from monai.visualize import CAM - model_2d = densenet121(spatial_dims=2, in_channels=1, out_channels=3) + model_2d = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) cam = CAM(nn_module=model_2d, target_layers="class_layers.relu", fc_layers="class_layers.out") result = cam(x=torch.rand((1, 1, 48, 64))) @@ -212,6 +224,12 @@ class CAM(CAMBase): cam = CAM(nn_module=model_2d, target_layers="layer4", fc_layers="last_linear") result = cam(x=torch.rand((2, 3, 48, 64))) + N.B.: To help select the target layer, it may be useful to list all layers: + + .. code-block:: python + + for name, _ in model.named_modules(): print(name) + See Also: - :py:class:`monai.visualize.class_activation_maps.GradCAM` @@ -249,9 +267,6 @@ def __init__( self.fc_layers = fc_layers def compute_map(self, x, class_idx=None, layer_idx=-1): - """ - Compute the actual feature map with input tensor `x`. - """ logits, acti, _ = self.nn_module(x) acti = acti[layer_idx] if class_idx is None: @@ -292,10 +307,10 @@ class GradCAM(CAMBase): .. code-block:: python # densenet 2d - from monai.networks.nets import densenet121 + from monai.networks.nets import DenseNet121 from monai.visualize import GradCAM - model_2d = densenet121(spatial_dims=2, in_channels=1, out_channels=3) + model_2d = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) cam = GradCAM(nn_module=model_2d, target_layers="class_layers.relu") result = cam(x=torch.rand((1, 1, 48, 64))) @@ -307,6 +322,12 @@ class GradCAM(CAMBase): cam = GradCAM(nn_module=model_2d, target_layers="layer4") result = cam(x=torch.rand((2, 3, 48, 64))) + N.B.: To help select the target layer, it may be useful to list all layers: + + .. code-block:: python + + for name, _ in model.named_modules(): print(name) + See Also: - :py:class:`monai.visualize.class_activation_maps.CAM` @@ -314,9 +335,6 @@ class GradCAM(CAMBase): """ def compute_map(self, x, class_idx=None, retain_graph=False, layer_idx=-1): - """ - Compute the actual feature map with input tensor `x`. - """ _, acti, grad = self.nn_module(x, class_idx=class_idx, retain_graph=retain_graph) acti, grad = acti[layer_idx], grad[layer_idx] b, c, *spatial = grad.shape @@ -356,9 +374,6 @@ class GradCAMpp(GradCAM): """ def compute_map(self, x, class_idx=None, retain_graph=False, layer_idx=-1): - """ - Compute the actual feature map with input tensor `x`. - """ _, acti, grad = self.nn_module(x, class_idx=class_idx, retain_graph=retain_graph) acti, grad = acti[layer_idx], grad[layer_idx] b, c, *spatial = grad.shape diff --git a/monai/visualize/occlusion_sensitivity.py b/monai/visualize/occlusion_sensitivity.py index 5863614965..ee9a967da1 100644 --- a/monai/visualize/occlusion_sensitivity.py +++ b/monai/visualize/occlusion_sensitivity.py @@ -122,10 +122,10 @@ class OcclusionSensitivity: .. code-block:: python # densenet 2d - from monai.networks.nets import densenet121 + from monai.networks.nets import DenseNet121 from monai.visualize import OcclusionSensitivity - model_2d = densenet121(spatial_dims=2, in_channels=1, out_channels=3) + model_2d = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) occ_sens = OcclusionSensitivity(nn_module=model_2d) occ_map, most_probable_class = occ_sens(x=torch.rand((1, 1, 48, 64)), class_idx=None, b_box=[-1, -1, 2, 40, 1, 62]) diff --git a/requirements-dev.txt b/requirements-dev.txt index 2a43e63d73..dc4181b310 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ # Full requirements for developments -r requirements-min.txt -pytorch-ignite==0.4.2 +pytorch-ignite==0.4.4 gdown>=3.6.4 scipy itk>=5.0 @@ -30,3 +30,5 @@ Sphinx==3.3.0 recommonmark==0.6.0 sphinx-autodoc-typehints==1.11.1 sphinx-rtd-theme==0.5.0 +cucim==0.18.1 +openslide-python==1.1.2 diff --git a/requirements-min.txt b/requirements-min.txt index 3a5585de8d..5db219c840 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -1,5 +1,5 @@ # Requirements for minimal tests -r requirements.txt setuptools>=50.3.0 -coverage +coverage>=5.5 parameterized diff --git a/runtests.sh b/runtests.sh index 76692e731b..0e87a3a4e5 100755 --- a/runtests.sh +++ b/runtests.sh @@ -36,9 +36,7 @@ doQuickTests=false doNetTests=false doDryRun=false doZooTests=false - -doUnitTests=true - +doUnitTests=false doBlackFormat=false doBlackFix=false doIsortFormat=false @@ -55,16 +53,17 @@ PY_EXE=${MONAI_PY_EXE:-$(which python)} function print_usage { echo "runtests.sh [--codeformat] [--autofix] [--black] [--isort] [--flake8] [--clangformat] [--pytype] [--mypy]" - echo " [--nounittests] [--coverage] [--quick] [--net] [--dryrun] [-j number] [--clean] [--help] [--version]" + echo " [--unittests] [--coverage] [--quick] [--net] [--dryrun] [-j number] [--clean] [--help] [--version]" echo "" echo "MONAI unit testing utilities." echo "" echo "Examples:" - echo "./runtests.sh --codeformat --coverage # run full tests (${green}recommended before making pull requests${noColor})." - echo "./runtests.sh --codeformat --nounittests # run coding style and static type checking." - echo "./runtests.sh --quick # run minimal unit tests, for quick verification during code developments." - echo "./runtests.sh --autofix --nounittests # run automatic code formatting using \"isort\" and \"black\"." - echo "./runtests.sh --clean # clean up temporary files and run \"${PY_EXE} setup.py develop --uninstall\"." + echo "./runtests.sh -f -u --net --coverage # run style checks, full tests, print code coverage (${green}recommended for pull requests${noColor})." + echo "./runtests.sh -f -u # run style checks and unit tests." + echo "./runtests.sh -f # run coding style and static type checking." + echo "./runtests.sh --quick --unittests # run minimal unit tests, for quick verification during code developments." + echo "./runtests.sh --autofix # run automatic code formatting using \"isort\" and \"black\"." + echo "./runtests.sh --clean # clean up temporary files and run \"${PY_EXE} setup.py develop --uninstall\"." echo "" echo "Code style check options:" echo " --black : perform \"black\" code format checks" @@ -79,11 +78,11 @@ function print_usage { echo " -j, --jobs : number of parallel jobs to run \"pytype\" (default $NUM_PARALLEL)" echo "" echo "MONAI unit testing options:" - echo " --nounittests : skip doing unit testing (i.e. only format lint testers)" - echo " --coverage : peforms coverage analysis of code for tests run" - echo " -q, --quick : disable long running tests" - echo " --net : perform training/inference/eval integration testing" - echo " --list_tests : list tests and exit" + echo " -u, --unittests : perform unit testing" + echo " --coverage : report testing code coverage, to be used with \"--net\", \"--unittests\"" + echo " -q, --quick : skip long running unit tests and integration tests" + echo " --net : perform integration testing" + echo " --list_tests : list unit tests and exit" echo "" echo "Misc. options:" echo " --dryrun : display the commands to the screen without running" @@ -92,7 +91,7 @@ function print_usage { echo " -h, --help : show this help message and exit" echo " -v, --version : show MONAI and system version information and exit" echo "" - echo "${separator}For bug reports, questions, and discussions, please file an issue at:" + echo "${separator}For bug reports and feature requests, please file an issue at:" echo " https://github.com/Project-MONAI/MONAI/issues/new/choose" echo "" echo "To choose an alternative python executable, set the environmental variable, \"MONAI_PY_EXE\"." @@ -139,6 +138,9 @@ function clang_format { } function clean_py { + # remove coverage history + ${cmdPrefix}${PY_EXE} -m coverage erase + # uninstall the development package echo "Uninstalling MONAI development files..." ${cmdPrefix}${PY_EXE} setup.py develop --user --uninstall @@ -150,7 +152,7 @@ function clean_py { find ${TO_CLEAN}/monai -type f -name "*.py[co]" -delete find ${TO_CLEAN}/monai -type f -name "*.so" -delete find ${TO_CLEAN}/monai -type d -name "__pycache__" -delete - find ${TO_CLEAN} -maxdepth 1 -type f -name ".coverage" -delete + find ${TO_CLEAN} -maxdepth 1 -type f -name ".coverage.*" -delete find ${TO_CLEAN} -depth -maxdepth 1 -type d -name ".eggs" -exec rm -r "{}" + find ${TO_CLEAN} -depth -maxdepth 1 -type d -name "monai.egg-info" -exec rm -r "{}" + @@ -159,6 +161,7 @@ function clean_py { find ${TO_CLEAN} -depth -maxdepth 1 -type d -name ".mypy_cache" -exec rm -r "{}" + find ${TO_CLEAN} -depth -maxdepth 1 -type d -name ".pytype" -exec rm -r "{}" + find ${TO_CLEAN} -depth -maxdepth 1 -type d -name ".coverage" -exec rm -r "{}" + + find ${TO_CLEAN} -depth -maxdepth 1 -type d -name "__pycache__" -exec rm -r "{}" + } function torch_validate { @@ -172,7 +175,7 @@ function print_error_msg() { function print_style_fail_msg() { echo "${red}Check failed!${noColor}" - echo "Please run auto style fixes: ${green}./runtests.sh --autofix --nounittests${noColor}" + echo "Please run auto style fixes: ${green}./runtests.sh --autofix${noColor}" } function is_pip_installed() { @@ -219,8 +222,8 @@ do --dryrun) doDryRun=true ;; - --nou*) # allow --nounittest | --nounittests | --nounittesting etc. - doUnitTests=false + -u|--u*) # allow --unittest | --unittests | --unittesting etc. + doUnitTests=true ;; -f|--codeformat) doBlackFormat=true @@ -267,6 +270,10 @@ do print_version exit 1 ;; + --nou*) # allow --nounittest | --nounittests | --nounittesting etc. + print_error_msg "nounittest option is deprecated, no unit tests is the default setting" + print_usage + ;; *) print_error_msg "Incorrect commandline provided, invalid key: $key" print_usage @@ -492,12 +499,11 @@ then export QUICKTEST=True fi -# set command and clear previous coverage data +# set coverage command if [ $doCoverage = true ] then echo "${separator}${blue}coverage${noColor}" - cmd="${PY_EXE} -m coverage run -a --source ." - ${cmdPrefix}${PY_EXE} -m coverage erase + cmd="${PY_EXE} -m coverage run --append" fi # # download test data if needed @@ -510,7 +516,7 @@ if [ $doUnitTests = true ] then echo "${separator}${blue}unittests${noColor}" torch_validate - ${cmdPrefix}${cmd} ./tests/runner.py + ${cmdPrefix}${cmd} ./tests/runner.py -p "test_((?!integration).)" fi # network training/inference/eval integration tests @@ -536,5 +542,6 @@ fi if [ $doCoverage = true ] then echo "${separator}${blue}coverage${noColor}" - ${cmdPrefix}${PY_EXE} -m coverage report --skip-covered -m + ${cmdPrefix}${PY_EXE} -m coverage combine --append .coverage/ + ${cmdPrefix}${PY_EXE} -m coverage report fi diff --git a/setup.cfg b/setup.cfg index ea61eadd92..15e6a6d127 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,11 +27,13 @@ all = scikit-image>=0.14.2 pillow tensorboard - pytorch-ignite==0.4.2 + pytorch-ignite==0.4.4 gdown>=3.6.4 torchvision itk>=5.0 tqdm>=4.47.0 + cucim==0.18.1 + openslide-python==1.1.2 nibabel = nibabel skimage = @@ -43,7 +45,7 @@ tensorboard = gdown = gdown>=3.6.4 ignite = - pytorch-ignite==0.4.2 + pytorch-ignite==0.4.4 torchvision = torchvision itk = @@ -54,17 +56,21 @@ lmdb = lmdb psutil = psutil +cucim = + cucim==0.18.1 +openslide = + openslide-python==1.1.2 [flake8] select = B,C,E,F,N,P,T4,W,B9 -max-line-length = 120 +max_line_length = 120 # C408 ignored because we like the dict keyword argument syntax # E501 is not flexible enough, we're using B950 instead ignore = E203,E305,E402,E501,E721,E741,F821,F841,F999,W503,W504,C408,E302,W291,E303, # N812 lowercase 'torch.nn.functional' imported as non lowercase 'F' N812 -per-file-ignores = __init__.py: F401 +per_file_ignores = __init__.py: F401 exclude = *.pyi,.git,.eggs,monai/_version.py,versioneer.py,venv,.venv,_version.py [isort] @@ -145,3 +151,21 @@ precise_return = True protocols = True # Experimental: Only load submodules that are explicitly imported. strict_import = False + +[coverage:run] +concurrency = multiprocessing +source = . +data_file = .coverage/.coverage +omit = setup.py + +[coverage:report] +exclude_lines = + pragma: no cover + # Don't complain if tests don't hit code: + raise NotImplementedError + if __name__ == .__main__.: +show_missing = True +skip_covered = True + +[coverage:xml] +output = coverage.xml diff --git a/setup.py b/setup.py index 9b20df845a..426866428c 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ FORCE_CUDA = os.getenv("FORCE_CUDA", "0") == "1" # flag ignored if BUILD_MONAI is False BUILD_CPP = BUILD_CUDA = False +TORCH_VERSION = 0 try: import torch @@ -35,14 +36,13 @@ BUILD_CPP = True from torch.utils.cpp_extension import CUDA_HOME, CUDAExtension - BUILD_CUDA = (torch.cuda.is_available() and (CUDA_HOME is not None)) or FORCE_CUDA + BUILD_CUDA = (CUDA_HOME is not None) if torch.cuda.is_available() else FORCE_CUDA _pt_version = pkg_resources.parse_version(torch.__version__).release # type: ignore[attr-defined] if _pt_version is None or len(_pt_version) < 3: raise AssertionError("unknown torch version") TORCH_VERSION = int(_pt_version[0]) * 10000 + int(_pt_version[1]) * 100 + int(_pt_version[2]) except (ImportError, TypeError, AssertionError, AttributeError) as e: - TORCH_VERSION = 0 warnings.warn(f"extension build skipped: {e}") finally: if not RUN_BUILD: diff --git a/tests/min_tests.py b/tests/min_tests.py index 999a1aeaa0..83c1ceea9f 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -109,6 +109,8 @@ def run_testsuit(): "test_deepgrow_dataset", "test_save_image", "test_save_imaged", + "test_ensure_channel_first", + "test_ensure_channel_firstd", ] assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}" diff --git a/tests/runner.py b/tests/runner.py index b5d1de5fc1..b340d60719 100644 --- a/tests/runner.py +++ b/tests/runner.py @@ -10,8 +10,10 @@ # limitations under the License. import argparse +import glob import inspect import os +import re import sys import time import unittest @@ -62,7 +64,7 @@ def print_results(results, discovery_time, thresh, status): print("Remember to check above times for any errors!") -def parse_args(default_pattern): +def parse_args(): parser = argparse.ArgumentParser(description="Runner for MONAI unittests with timing.") parser.add_argument( "-s", action="store", dest="path", default=".", help="Directory to start discovery (default: '%(default)s')" @@ -71,7 +73,7 @@ def parse_args(default_pattern): "-p", action="store", dest="pattern", - default=default_pattern, + default="test_*.py", help="Pattern to match tests (default: '%(default)s')", ) parser.add_argument( @@ -111,11 +113,8 @@ def get_default_pattern(loader): if __name__ == "__main__": - loader = unittest.TestLoader() - default_pattern = get_default_pattern(loader) - # Parse input arguments - args = parse_args(default_pattern) + args = parse_args() # If quick is desired, set environment variable if args.quick: @@ -123,9 +122,17 @@ def get_default_pattern(loader): # Get all test names (optionally from some path with some pattern) with PerfContext() as pc: - tests = loader.discover(args.path, args.pattern) + # the files are searched from `tests/` folder, starting with `test_` + files = glob.glob(os.path.join(os.path.dirname(__file__), "test_*.py")) + cases = [] + for test_module in {os.path.basename(f)[:-3] for f in files}: + if re.match(args.pattern, test_module): + cases.append(f"tests.{test_module}") + else: + print(f"monai test runner: excluding tests.{test_module}") + tests = unittest.TestLoader().loadTestsFromNames(cases) discovery_time = pc.total_time - print(f"time to discover tests: {discovery_time}s") + print(f"time to discover tests: {discovery_time}s, total cases: {tests.countTestCases()}.") test_runner = unittest.runner.TextTestRunner( resultclass=TimeLoggingTestResult, verbosity=args.verbosity, failfast=args.failfast diff --git a/tests/test_affine.py b/tests/test_affine.py index 934473fc5c..ea146e0fbd 100644 --- a/tests/test_affine.py +++ b/tests/test_affine.py @@ -80,10 +80,7 @@ def test_affine(self, input_param, input_data, expected_val): g = Affine(**input_param) result = g(**input_data) self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) - if isinstance(result, torch.Tensor): - np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) - else: - np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) if __name__ == "__main__": diff --git a/tests/test_affined.py b/tests/test_affined.py new file mode 100644 index 0000000000..850f12905d --- /dev/null +++ b/tests/test_affined.py @@ -0,0 +1,101 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.transforms import Affined + +TEST_CASES = [ + [ + dict(keys="img", padding_mode="zeros", as_tensor_output=False, spatial_size=(-1, 0), device=None), + {"img": np.arange(9).reshape((1, 3, 3))}, + np.arange(9).reshape(1, 3, 3), + ], + [ + dict(keys="img", padding_mode="zeros", as_tensor_output=False, device=None), + {"img": np.arange(4).reshape((1, 2, 2))}, + np.arange(4).reshape(1, 2, 2), + ], + [ + dict(keys="img", padding_mode="zeros", spatial_size=(4, 4), as_tensor_output=False, device=None), + {"img": np.arange(4).reshape((1, 2, 2))}, + np.array([[[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 2.0, 3.0, 0.0], [0.0, 0.0, 0.0, 0.0]]]), + ], + [ + dict( + keys="img", + rotate_params=[np.pi / 2], + padding_mode="zeros", + spatial_size=(4, 4), + as_tensor_output=False, + device=None, + ), + {"img": np.arange(4).reshape((1, 2, 2))}, + np.array([[[0.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 3.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0]]]), + ], + [ + dict(keys="img", padding_mode="zeros", spatial_size=(-1, 0, 0), as_tensor_output=False, device=None), + {"img": np.arange(27).reshape((1, 3, 3, 3))}, + np.arange(27).reshape(1, 3, 3, 3), + ], + [ + dict(keys="img", padding_mode="zeros", spatial_size=(4, 4, 4), as_tensor_output=False, device=None), + {"img": np.arange(8).reshape((1, 2, 2, 2))}, + np.array( + [ + [ + [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 2.0, 3.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0], [0.0, 4.0, 5.0, 0.0], [0.0, 6.0, 7.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + ] + ] + ), + ], + [ + dict( + keys="img", + rotate_params=[np.pi / 2], + padding_mode="zeros", + spatial_size=(4, 4, 4), + as_tensor_output=False, + device=None, + ), + {"img": np.arange(8).reshape((1, 2, 2, 2))}, + np.array( + [ + [ + [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 3.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0], [0.0, 6.0, 4.0, 0.0], [0.0, 7.0, 5.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]], + ] + ] + ), + ], +] + + +class TestAffined(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_affine(self, input_param, input_data, expected_val): + g = Affined(**input_param) + result = g(input_data)["img"] + self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor)) + np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_compose.py b/tests/test_compose.py index c049044a97..bb8a5f08c5 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -13,11 +13,12 @@ import unittest from monai.data import DataLoader, Dataset -from monai.transforms import AddChannel, Compose, Randomizable +from monai.transforms import AddChannel, Compose +from monai.transforms.transform import RandomizableTransform from monai.utils import set_determinism -class _RandXform(Randomizable): +class _RandXform(RandomizableTransform): def randomize(self): self.val = self.R.random_sample() @@ -79,7 +80,7 @@ def c(d): # transform to handle dict data self.assertDictEqual(item, {"a": 2, "b": 1, "c": 2}) def test_random_compose(self): - class _Acc(Randomizable): + class _Acc(RandomizableTransform): self.rand = 0.0 def randomize(self, data=None): @@ -98,10 +99,13 @@ def __call__(self, data): self.assertAlmostEqual(c(1), 1.90734751) def test_randomize_warn(self): - class _RandomClass(Randomizable): + class _RandomClass(RandomizableTransform): def randomize(self, foo1, foo2): pass + def __call__(self, data): + pass + c = Compose([_RandomClass(), _RandomClass()]) with self.assertWarns(Warning): c.randomize() @@ -167,6 +171,9 @@ def test_flatten_and_len(self): # test len self.assertEqual(len(t1), 8) + def test_backwards_compatible_imports(self): + from monai.transforms.compose import MapTransform, RandomizableTransform, Transform # noqa: F401 + if __name__ == "__main__": unittest.main() diff --git a/tests/test_compute_froc.py b/tests/test_compute_froc.py new file mode 100644 index 0000000000..70de836dd9 --- /dev/null +++ b/tests/test_compute_froc.py @@ -0,0 +1,101 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.metrics import compute_fp_tp_probs, compute_froc_curve_data, compute_froc_score + +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]), + "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, + }, + np.array([0.6]), + np.array([1, 0, 0.8]), + 2, +] + +TEST_CASE_2 = [ + { + "probs": torch.tensor([1, 0.6, 0.8]), + "y_coord": torch.tensor([0, 2, 3]), + "x_coord": torch.tensor([3, 0, 1]), + "evaluation_mask": np.array([[0, 0, 1, 1], [2, 2, 0, 0], [0, 3, 3, 0], [0, 3, 3, 3]]), + "resolution_level": 0, + }, + np.array([0.6]), + np.array([1, 0, 0.8]), + 3, +] + +TEST_CASE_3 = [ + { + "probs": torch.tensor([1, 0.6, 0.8]), + "y_coord": torch.tensor([0, 4, 6]), + "x_coord": torch.tensor([6, 0, 2]), + "evaluation_mask": np.array([[0, 0, 1, 1], [2, 2, 0, 0], [0, 3, 3, 0], [0, 3, 3, 3]]), + "resolution_level": 1, + }, + np.array([0.6]), + np.array([1, 0, 0.8]), + 3, +] + +TEST_CASE_4 = [ + { + "fp_probs": np.array([0.8, 0.6]), + "tp_probs": np.array([1, 1, 0, 0, 0.8, 0.8, 0]), + "num_targets": 4, + "num_images": 2, + }, + (0.25, 0.5, 1, 2, 4, 8), + 0.95833333, +] + +TEST_CASE_5 = [ + { + "fp_probs": torch.tensor([0.8, 0.6]), + "tp_probs": torch.tensor([1, 1, 0, 0, 0.8, 0.8, 0]), + "num_targets": 4, + "num_images": 2, + }, + (0.25), + 0.75, +] + + +class TestComputeFpTp(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_value(self, input_data, expected_fp, expected_tp, expected_num): + fp_probs, tp_probs, num_tumors = compute_fp_tp_probs(**input_data) + np.testing.assert_allclose(fp_probs, expected_fp, rtol=1e-5) + np.testing.assert_allclose(tp_probs, expected_tp, rtol=1e-5) + np.testing.assert_equal(num_tumors, expected_num) + + +class TestComputeFrocScore(unittest.TestCase): + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_value(self, input_data, thresholds, expected_score): + fps_per_image, total_sensitivity = compute_froc_curve_data(**input_data) + score = compute_froc_score(fps_per_image, total_sensitivity, thresholds) + np.testing.assert_allclose(score, expected_score, rtol=1e-5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_convert_to_multi_channel.py b/tests/test_convert_to_multi_channel.py index ea27371ac7..03510ad38c 100644 --- a/tests/test_convert_to_multi_channel.py +++ b/tests/test_convert_to_multi_channel.py @@ -27,6 +27,7 @@ class TestConvertToMultiChannel(unittest.TestCase): def test_type_shape(self, data, expected_result): result = ConvertToMultiChannelBasedOnBratsClasses()(data) np.testing.assert_equal(result, expected_result) + self.assertEqual(f"{result.dtype}", "bool") if __name__ == "__main__": diff --git a/tests/test_cuimage_reader.py b/tests/test_cuimage_reader.py new file mode 100644 index 0000000000..221a458ca8 --- /dev/null +++ b/tests/test_cuimage_reader.py @@ -0,0 +1,103 @@ +import os +import unittest +from unittest import skipUnless +from urllib import request + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data.image_reader import WSIReader +from monai.utils import optional_import + +_, has_cim = optional_import("cucim") + + +FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" +HEIGHT = 32914 +WIDTH = 46000 + +TEST_CASE_0 = [FILE_URL, (3, HEIGHT, WIDTH)] + +TEST_CASE_1 = [ + FILE_URL, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), +] + +TEST_CASE_2 = [ + FILE_URL, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), +] + +TEST_CASE_3 = [ + FILE_URL, + { + "location": (0, 0), + "size": (8, 8), + "level": 2, + "grid_shape": (2, 1), + "patch_size": 2, + }, + np.array( + [ + [[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[239, 239], [239, 239]]], + [[[242, 242], [242, 243]], [[242, 242], [242, 243]], [[242, 242], [242, 243]]], + ] + ), +] + +TEST_CASE_4 = [ + FILE_URL, + { + "location": (0, 0), + "size": (8, 8), + "level": 2, + "grid_shape": (2, 1), + "patch_size": 1, + }, + np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), +] + + +class TestCuCIMReader(unittest.TestCase): + @parameterized.expand([TEST_CASE_0]) + @skipUnless(has_cim, "Requires CuCIM") + def test_read_whole_image(self, file_url, expected_shape): + filename = self.camelyon_data_download(file_url) + reader = WSIReader("cuCIM") + img_obj = reader.read(filename) + img = reader.get_data(img_obj)[0] + self.assertTupleEqual(img.shape, expected_shape) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) + @skipUnless(has_cim, "Requires cuCIM") + def test_read_region(self, file_url, patch_info, expected_img): + filename = self.camelyon_data_download(file_url) + reader = WSIReader("cuCIM") + img_obj = reader.read(filename) + img = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + + @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) + @skipUnless(has_cim, "Requires cuCIM") + def test_read_patches(self, file_url, patch_info, expected_img): + filename = self.camelyon_data_download(file_url) + reader = WSIReader("cuCIM") + img_obj = reader.read(filename) + img = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + + def camelyon_data_download(self, file_url): + filename = os.path.basename(file_url) + if not os.path.exists(filename): + print(f"Test image [{filename}] does not exist. Downloading...") + request.urlretrieve(file_url, filename) + return filename + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_data_stats.py b/tests/test_data_stats.py index e7334eb52c..877da52263 100644 --- a/tests/test_data_stats.py +++ b/tests/test_data_stats.py @@ -119,6 +119,7 @@ def test_file(self, input_data, expected_print): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_data_stats.log") handler = logging.FileHandler(filename, mode="w") + handler.setLevel(logging.INFO) input_param = { "prefix": "test data", "data_shape": True, @@ -129,8 +130,9 @@ def test_file(self, input_data, expected_print): } transform = DataStats(**input_param) _ = transform(input_data) - handler.stream.close() - transform._logger.removeHandler(handler) + for h in transform._logger.handlers[:]: + h.close() + transform._logger.removeHandler(h) with open(filename, "r") as f: content = f.read() self.assertEqual(content, expected_print) diff --git a/tests/test_data_statsd.py b/tests/test_data_statsd.py index a5fae3d66d..bacd70194a 100644 --- a/tests/test_data_statsd.py +++ b/tests/test_data_statsd.py @@ -132,6 +132,7 @@ def test_file(self, input_data, expected_print): with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_stats.log") handler = logging.FileHandler(filename, mode="w") + handler.setLevel(logging.INFO) input_param = { "keys": "img", "prefix": "test data", @@ -143,8 +144,10 @@ def test_file(self, input_data, expected_print): } transform = DataStatsd(**input_param) _ = transform(input_data) - handler.stream.close() - transform.printer._logger.removeHandler(handler) + for h in transform.printer._logger.handlers[:]: + h.close() + transform.printer._logger.removeHandler(h) + del handler with open(filename, "r") as f: content = f.read() self.assertEqual(content, expected_print) diff --git a/tests/test_decollate.py b/tests/test_decollate.py new file mode 100644 index 0000000000..5b78bbbcf6 --- /dev/null +++ b/tests/test_decollate.py @@ -0,0 +1,95 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import unittest +from enum import Enum +from typing import List, Tuple + +import numpy as np +import torch +from parameterized import parameterized + +from monai.data import CacheDataset, DataLoader, create_test_image_2d +from monai.data.utils import decollate_batch +from monai.transforms import AddChanneld, Compose, LoadImaged, RandFlipd, SpatialPadd, ToTensord +from monai.transforms.post.dictionary import Decollated +from monai.transforms.spatial.dictionary import RandAffined, RandRotate90d +from monai.utils import optional_import, set_determinism +from monai.utils.enums import InverseKeys +from tests.utils import make_nifti_image + +_, has_nib = optional_import("nibabel") + +KEYS = ["image"] + +TESTS: List[Tuple] = [] +TESTS.append((SpatialPadd(KEYS, 150), RandFlipd(KEYS, prob=1.0, spatial_axis=1))) +TESTS.append((RandRotate90d(KEYS, prob=0.0, max_k=1),)) +TESTS.append((RandAffined(KEYS, prob=0.0, translate_range=10),)) + + +class TestDeCollate(unittest.TestCase): + def setUp(self) -> None: + set_determinism(seed=0) + + im = create_test_image_2d(100, 101)[0] + self.data = [{"image": make_nifti_image(im) if has_nib else im} for _ in range(6)] + + def tearDown(self) -> None: + set_determinism(None) + + def check_match(self, in1, in2): + if isinstance(in1, dict): + self.assertTrue(isinstance(in2, dict)) + for (k1, v1), (k2, v2) in zip(in1.items(), in2.items()): + if isinstance(k1, Enum) and isinstance(k2, Enum): + k1, k2 = k1.value, k2.value + self.check_match(k1, k2) + # Transform ids won't match for windows with multiprocessing, so don't check values + if k1 == InverseKeys.ID and sys.platform in ["darwin", "win32"]: + continue + self.check_match(v1, v2) + elif isinstance(in1, (list, tuple)): + for l1, l2 in zip(in1, in2): + self.check_match(l1, l2) + elif isinstance(in1, (str, int)): + self.assertEqual(in1, in2) + elif isinstance(in1, (torch.Tensor, np.ndarray)): + np.testing.assert_array_equal(in1, in2) + else: + raise RuntimeError(f"Not sure how to compare types. type(in1): {type(in1)}, type(in2): {type(in2)}") + + @parameterized.expand(TESTS) + def test_decollation(self, *transforms): + + batch_size = 2 + num_workers = 2 + + t_compose = Compose([AddChanneld(KEYS), Compose(transforms), ToTensord(KEYS)]) + # If nibabel present, read from disk + if has_nib: + t_compose = Compose([LoadImaged("image"), t_compose]) + + dataset = CacheDataset(self.data, t_compose, progress=False) + loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers) + + for b, batch_data in enumerate(loader): + decollated_1 = decollate_batch(batch_data) + decollated_2 = Decollated()(batch_data) + + for decollated in [decollated_1, decollated_2]: + for i, d in enumerate(decollated): + self.check_match(dataset[b * batch_size + i], d) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deepgrow_dataset.py b/tests/test_deepgrow_dataset.py index e871c328a6..147d8e7099 100644 --- a/tests/test_deepgrow_dataset.py +++ b/tests/test_deepgrow_dataset.py @@ -10,47 +10,96 @@ # limitations under the License. import os +import shutil import tempfile import unittest import nibabel as nib import numpy as np +from parameterized import parameterized from monai.apps.deepgrow.dataset import create_dataset +from monai.utils import set_determinism + +TEST_CASE_1 = [{"dimension": 2, "pixdim": (1, 1)}, {"length": 3}, 9, 1] + +TEST_CASE_2 = [{"dimension": 2, "pixdim": (1, 1), "limit": 1}, {"length": 3}, 3, 1] + +TEST_CASE_3 = [{"dimension": 2, "pixdim": (1, 1)}, {"length": 1}, 3, 1] + +TEST_CASE_4 = [{"dimension": 3, "pixdim": (1, 1, 1)}, {"length": 1}, 1, 1] + +TEST_CASE_5 = [{"dimension": 3, "pixdim": (1, 1, 1)}, {"length": 1, "image_channel": 4}, 1, 1] + +TEST_CASE_6 = [{"dimension": 2, "pixdim": (1, 1)}, {"length": 1, "image_channel": 4}, 3, 1] + +TEST_CASE_7 = [ + {"dimension": 2, "pixdim": (1, 1), "label_key": None}, + {"length": 1, "image_channel": 4, "with_label": False}, + 40, + None, +] + +TEST_CASE_8 = [ + {"dimension": 3, "pixdim": (1, 1, 1), "label_key": None}, + {"length": 1, "image_channel": 4, "with_label": False}, + 1, + None, +] class TestCreateDataset(unittest.TestCase): - def _create_data(self, tempdir): + def setUp(self): + set_determinism(1) + self.tempdir = tempfile.mkdtemp() + + def _create_data(self, length=1, image_channel=1, with_label=True): affine = np.eye(4) - image = np.random.randint(0, 2, size=(128, 128, 40)) - image_file = os.path.join(tempdir, "image1.nii.gz") - nib.save(nib.Nifti1Image(image, affine), image_file) - - label = np.zeros((128, 128, 40)) - label[0][1][0] = 1 - label[0][1][1] = 1 - label[0][0][2] = 1 - label[0][1][2] = 1 - label_file = os.path.join(tempdir, "label1.nii.gz") - nib.save(nib.Nifti1Image(label, affine), label_file) - - return [{"image": image_file, "label": label_file}] - - def test_create_dataset_2d(self): - with tempfile.TemporaryDirectory() as tempdir: - datalist = self._create_data(tempdir) - output_dir = os.path.join(tempdir, "2d") - deepgrow_datalist = create_dataset(datalist=datalist, output_dir=output_dir, dimension=2, pixdim=(1, 1)) - self.assertEqual(len(deepgrow_datalist), 3) - self.assertEqual(deepgrow_datalist[0]["region"], 1) - - def test_create_dataset_3d(self): - with tempfile.TemporaryDirectory() as tempdir: - datalist = self._create_data(tempdir) - output_dir = os.path.join(tempdir, "3d") - deepgrow_datalist = create_dataset(datalist=datalist, output_dir=output_dir, dimension=3, pixdim=(1, 1, 1)) - self.assertEqual(len(deepgrow_datalist), 1) - self.assertEqual(deepgrow_datalist[0]["region"], 1) + datalist = [] + for i in range(length): + if image_channel == 1: + image = np.random.randint(0, 2, size=(128, 128, 40)) + else: + image = np.random.randint(0, 2, size=(128, 128, 40, image_channel)) + image_file = os.path.join(self.tempdir, f"image{i}.nii.gz") + nib.save(nib.Nifti1Image(image, affine), image_file) + + if with_label: + # 3 slices has label + label = np.zeros((128, 128, 40)) + label[0][1][0] = 1 + label[0][1][1] = 1 + label[0][0][2] = 1 + label[0][1][2] = 1 + label_file = os.path.join(self.tempdir, f"label{i}.nii.gz") + nib.save(nib.Nifti1Image(label, affine), label_file) + datalist.append({"image": image_file, "label": label_file}) + else: + datalist.append({"image": image_file}) + + return datalist + + @parameterized.expand( + [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7, TEST_CASE_8] + ) + def test_create_dataset(self, args, data_args, expected_length, expected_region): + datalist = self._create_data(**data_args) + deepgrow_datalist = create_dataset(datalist=datalist, output_dir=self.tempdir, **args) + self.assertEqual(len(deepgrow_datalist), expected_length) + if expected_region is not None: + self.assertEqual(deepgrow_datalist[0]["region"], expected_region) + + def test_invalid_dim(self): + with self.assertRaises(ValueError): + create_dataset(datalist=self._create_data(), output_dir=self.tempdir, dimension=4, pixdim=(1, 1, 1, 1)) + + def test_empty_datalist(self): + with self.assertRaises(ValueError): + create_dataset(datalist=[], output_dir=self.tempdir, dimension=3, pixdim=(1, 1, 1)) + + def tearDown(self): + shutil.rmtree(self.tempdir) + set_determinism(None) if __name__ == "__main__": diff --git a/tests/test_deepgrow_transforms.py b/tests/test_deepgrow_transforms.py index f534813832..2d57ed9325 100644 --- a/tests/test_deepgrow_transforms.py +++ b/tests/test_deepgrow_transforms.py @@ -15,12 +15,17 @@ from parameterized import parameterized from monai.apps.deepgrow.transforms import ( + AddGuidanceFromPointsd, AddGuidanceSignald, AddInitialSeedPointd, AddRandomGuidanced, + Fetch2DSliced, FindAllValidSlicesd, FindDiscrepancyRegionsd, + ResizeGuidanced, + RestoreLabeld, SpatialCropForegroundd, + SpatialCropGuidanced, ) IMAGE = np.array([[[[1, 0, 2, 0, 1], [0, 1, 2, 1, 0], [2, 2, 3, 2, 2], [0, 1, 2, 1, 0], [1, 0, 2, 0, 1]]]]) @@ -76,6 +81,76 @@ "probability": [1.0], } +DATA_5 = { + "image": np.arange(25).reshape((1, 5, 5)), + "image_meta_dict": {"spatial_shape": [5, 5, 1]}, + "foreground": [[2, 2, 0]], + "background": [], +} + +DATA_6 = { + "image": np.arange(25).reshape((1, 5, 5)), + "image_meta_dict": {"spatial_shape": [5, 2, 1]}, + "foreground": [[2, 1, 0]], + "background": [[1, 0, 0]], +} + +DATA_7 = { + "image": np.arange(500).reshape((5, 10, 10)), + "image_meta_dict": {"spatial_shape": [20, 20, 10]}, + "foreground": [[10, 14, 6], [10, 14, 8]], + "background": [[10, 16, 8]], + "slice": 6, +} + +DATA_8 = { + "image": np.arange(500).reshape((1, 5, 10, 10)), + "image_meta_dict": {"spatial_shape": [20, 20, 10]}, + "guidance": [[[3, 5, 7], [4, 5, 7]], [[4, 5, 8]]], +} + +DATA_9 = { + "image": np.arange(1000).reshape((1, 5, 10, 20)), + "image_meta_dict": {"foreground_cropped_shape": (1, 10, 20, 40)}, + "guidance": [[[6, 10, 14], [8, 10, 14]], [[8, 10, 16]]], +} + +DATA_10 = { + "image": np.arange(9).reshape((1, 1, 3, 3)), + "image_meta_dict": { + "spatial_shape": [3, 3, 1], + "foreground_start_coord": np.array([0, 0, 0]), + "foreground_end_coord": np.array([1, 3, 3]), + "foreground_original_shape": (1, 1, 3, 3), + "foreground_cropped_shape": (1, 1, 3, 3), + "original_affine": np.array( + [[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0]]] + ), + }, + "pred": np.array([[[[1, 2, 3], [4, 5, 6], [7, 8, 9]]]]), +} + +DATA_11 = { + "image": np.arange(500).reshape((1, 5, 10, 10)), + "image_meta_dict": { + "spatial_shape": [20, 20, 10], + "foreground_start_coord": np.array([2, 2, 2]), + "foreground_end_coord": np.array([4, 4, 4]), + "foreground_original_shape": (1, 5, 10, 10), + "foreground_cropped_shape": (1, 2, 2, 2), + "original_affine": np.array( + [[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0]]] + ), + }, + "pred": np.array([[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]]), +} + +DATA_12 = { + "image": np.arange(27).reshape(3, 3, 3), + "image_meta_dict": {}, + "guidance": [[0, 0, 0], [0, 1, 1], 1], +} + FIND_SLICE_TEST_CASE_1 = [ {"label": "label", "sids": "sids"}, DATA_1, @@ -159,6 +234,118 @@ np.array([[[[1, 0, 2, 2], [1, 0, 1, 3]], [[-1, -1, -1, -1], [-1, -1, -1, -1]]]]), ] +ADD_GUIDANCE_FROM_POINTS_TEST_CASE_1 = [ + {"ref_image": "image", "dimensions": 3, "guidance": "guidance", "depth_first": True}, + DATA_5, + [[0, 2, 2]], + [], +] + +ADD_GUIDANCE_FROM_POINTS_TEST_CASE_2 = [ + {"ref_image": "image", "dimensions": 3, "guidance": "guidance", "depth_first": True}, + DATA_6, + [[0, 2, 2]], + [[0, 1, 0]], +] + +ADD_GUIDANCE_FROM_POINTS_TEST_CASE_3 = [ + {"ref_image": "image", "dimensions": 3, "guidance": "guidance", "depth_first": True}, + DATA_7, + [[3, 5, 7], [4, 5, 7]], + [[4, 5, 8]], +] + +ADD_GUIDANCE_FROM_POINTS_TEST_CASE_4 = [ + {"ref_image": "image", "dimensions": 2, "guidance": "guidance", "depth_first": True}, + DATA_6, + [[2, 2]], + [[1, 0]], +] + +ADD_GUIDANCE_FROM_POINTS_TEST_CASE_5 = [ + {"ref_image": "image", "dimensions": 2, "guidance": "guidance", "depth_first": True, "slice_key": "slice"}, + DATA_7, + [[5, 7]], + [], +] + +ADD_GUIDANCE_FROM_POINTS_TEST_CASE_6 = [ + {"ref_image": "image", "dimensions": 2, "guidance": "guidance", "depth_first": True}, + DATA_5, + [[2, 2]], + [], +] + +SPATIAL_CROP_GUIDANCE_TEST_CASE_1 = [ + {"keys": ["image"], "guidance": "guidance", "spatial_size": [1, 4, 4], "margin": 0}, + DATA_8, + np.array([[[[357, 358]], [[457, 458]]]]), +] + +SPATIAL_CROP_GUIDANCE_TEST_CASE_2 = [ + {"keys": ["image"], "guidance": "guidance", "spatial_size": [2, 2], "margin": 1}, + DATA_8, + np.array( + [ + [ + [[246, 247, 248, 249], [256, 257, 258, 259], [266, 267, 268, 269]], + [[346, 347, 348, 349], [356, 357, 358, 359], [366, 367, 368, 369]], + [[446, 447, 448, 449], [456, 457, 458, 459], [466, 467, 468, 469]], + ] + ] + ), +] + +SPATIAL_CROP_GUIDANCE_TEST_CASE_3 = [ + {"keys": ["image"], "guidance": "guidance", "spatial_size": [3, 3], "margin": 0}, + DATA_8, + np.array( + [ + [ + [[47, 48, 49], [57, 58, 59], [67, 68, 69]], + [[147, 148, 149], [157, 158, 159], [167, 168, 169]], + [[247, 248, 249], [257, 258, 259], [267, 268, 269]], + [[347, 348, 349], [357, 358, 359], [367, 368, 369]], + [[447, 448, 449], [457, 458, 459], [467, 468, 469]], + ] + ] + ), +] + +RESIZE_GUIDANCE_TEST_CASE_1 = [ + {"ref_image": "image", "guidance": "guidance"}, + DATA_9, + [[[3, 5, 7], [4, 5, 7]], [[4, 5, 8]]], +] + +RESTORE_LABEL_TEST_CASE_1 = [ + {"keys": ["pred"], "ref_image": "image", "mode": "nearest"}, + DATA_10, + np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]]), +] + +RESULT = np.zeros((10, 20, 20)) +RESULT[4:8, 4:8, 4:8] = np.array( + [ + [[1.0, 1.0, 2.0, 2.0], [1.0, 1.0, 2.0, 2.0], [3.0, 3.0, 4.0, 4.0], [3.0, 3.0, 4.0, 4.0]], + [[1.0, 1.0, 2.0, 2.0], [1.0, 1.0, 2.0, 2.0], [3.0, 3.0, 4.0, 4.0], [3.0, 3.0, 4.0, 4.0]], + [[5.0, 5.0, 6.0, 6.0], [5.0, 5.0, 6.0, 6.0], [7.0, 7.0, 8.0, 8.0], [7.0, 7.0, 8.0, 8.0]], + [[5.0, 5.0, 6.0, 6.0], [5.0, 5.0, 6.0, 6.0], [7.0, 7.0, 8.0, 8.0], [7.0, 7.0, 8.0, 8.0]], + ], +) + +RESTORE_LABEL_TEST_CASE_2 = [ + {"keys": ["pred"], "ref_image": "image", "mode": "nearest"}, + DATA_11, + RESULT, +] + +FETCH_2D_SLICE_TEST_CASE_1 = [ + {"keys": ["image"], "guidance": "guidance"}, + DATA_12, + np.array([[9, 10, 11], [12, 13, 14], [15, 16, 17]]), +] + class TestFindAllValidSlicesd(unittest.TestCase): @parameterized.expand([FIND_SLICE_TEST_CASE_1, FIND_SLICE_TEST_CASE_2]) @@ -220,5 +407,52 @@ def test_correct_results(self, arguments, input_data, expected_result): np.testing.assert_allclose(result[arguments["guidance"]], expected_result, rtol=1e-5) +class TestAddGuidanceFromPointsd(unittest.TestCase): + @parameterized.expand( + [ + ADD_GUIDANCE_FROM_POINTS_TEST_CASE_1, + ADD_GUIDANCE_FROM_POINTS_TEST_CASE_2, + ADD_GUIDANCE_FROM_POINTS_TEST_CASE_3, + ADD_GUIDANCE_FROM_POINTS_TEST_CASE_4, + ADD_GUIDANCE_FROM_POINTS_TEST_CASE_5, + ADD_GUIDANCE_FROM_POINTS_TEST_CASE_6, + ] + ) + def test_correct_results(self, arguments, input_data, expected_pos, expected_neg): + result = AddGuidanceFromPointsd(**arguments)(input_data) + self.assertEqual(result[arguments["guidance"]][0], expected_pos) + self.assertEqual(result[arguments["guidance"]][1], expected_neg) + + +class TestSpatialCropGuidanced(unittest.TestCase): + @parameterized.expand( + [SPATIAL_CROP_GUIDANCE_TEST_CASE_1, SPATIAL_CROP_GUIDANCE_TEST_CASE_2, SPATIAL_CROP_GUIDANCE_TEST_CASE_3] + ) + def test_correct_results(self, arguments, input_data, expected_result): + result = SpatialCropGuidanced(**arguments)(input_data) + np.testing.assert_allclose(result["image"], expected_result) + + +class TestResizeGuidanced(unittest.TestCase): + @parameterized.expand([RESIZE_GUIDANCE_TEST_CASE_1]) + def test_correct_results(self, arguments, input_data, expected_result): + result = ResizeGuidanced(**arguments)(input_data) + self.assertEqual(result[arguments["guidance"]], expected_result) + + +class TestRestoreLabeld(unittest.TestCase): + @parameterized.expand([RESTORE_LABEL_TEST_CASE_1, RESTORE_LABEL_TEST_CASE_2]) + def test_correct_results(self, arguments, input_data, expected_result): + result = RestoreLabeld(**arguments)(input_data) + np.testing.assert_allclose(result["pred"], expected_result) + + +class TestFetch2DSliced(unittest.TestCase): + @parameterized.expand([FETCH_2D_SLICE_TEST_CASE_1]) + def test_correct_results(self, arguments, input_data, expected_result): + result = Fetch2DSliced(**arguments)(input_data) + np.testing.assert_allclose(result["image"], expected_result) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_densenet.py b/tests/test_densenet.py index 876689314a..c934841598 100644 --- a/tests/test_densenet.py +++ b/tests/test_densenet.py @@ -10,13 +10,24 @@ # limitations under the License. import unittest +from typing import TYPE_CHECKING +from unittest import skipUnless import torch from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.nets import densenet121, densenet169, densenet201, densenet264 -from tests.utils import skip_if_quick, test_pretrained_networks, test_script_save +from monai.networks.nets import DenseNet121, DenseNet169, DenseNet201, DenseNet264 +from monai.utils import optional_import +from tests.utils import skip_if_quick, test_script_save + +if TYPE_CHECKING: + import torchvision + + has_torchvision = True +else: + torchvision, has_torchvision = optional_import("torchvision") + device = "cuda" if torch.cuda.is_available() else "cpu" @@ -40,37 +51,55 @@ TEST_CASES = [] for case in [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]: - for model in [densenet121, densenet169, densenet201, densenet264]: + for model in [DenseNet121, DenseNet169, DenseNet201, DenseNet264]: TEST_CASES.append([model, *case]) -TEST_SCRIPT_CASES = [[model, *TEST_CASE_1] for model in [densenet121, densenet169, densenet201, densenet264]] +TEST_SCRIPT_CASES = [[model, *TEST_CASE_1] for model in [DenseNet121, DenseNet169, DenseNet201, DenseNet264]] TEST_PRETRAINED_2D_CASE_1 = [ # 4-channel 2D, batch 2 - densenet121, + DenseNet121, {"pretrained": True, "progress": True, "spatial_dims": 2, "in_channels": 2, "out_channels": 3}, - (2, 2, 32, 64), - (2, 3), + (1, 2, 32, 64), + (1, 3), ] TEST_PRETRAINED_2D_CASE_2 = [ # 4-channel 2D, batch 2 - densenet121, - {"pretrained": True, "progress": False, "spatial_dims": 2, "in_channels": 2, "out_channels": 3}, - (2, 2, 32, 64), - (2, 3), + DenseNet121, + {"pretrained": True, "progress": False, "spatial_dims": 2, "in_channels": 2, "out_channels": 1}, + (1, 2, 32, 64), + (1, 1), +] + +TEST_PRETRAINED_2D_CASE_3 = [ + DenseNet121, + {"pretrained": True, "progress": False, "spatial_dims": 2, "in_channels": 3, "out_channels": 1}, + (1, 3, 32, 32), ] class TestPretrainedDENSENET(unittest.TestCase): @parameterized.expand([TEST_PRETRAINED_2D_CASE_1, TEST_PRETRAINED_2D_CASE_2]) @skip_if_quick - def test_121_3d_shape_pretrain(self, model, input_param, input_shape, expected_shape): - net = test_pretrained_networks(model, input_param, device) + def test_121_2d_shape_pretrain(self, model, input_param, input_shape, expected_shape): + net = model(**input_param).to(device) with eval_mode(net): result = net.forward(torch.randn(input_shape).to(device)) self.assertEqual(result.shape, expected_shape) + @parameterized.expand([TEST_PRETRAINED_2D_CASE_3]) + @skipUnless(has_torchvision, "Requires `torchvision` package.") + def test_pretrain_consistency(self, model, input_param, input_shape): + example = torch.randn(input_shape).to(device) + net = model(**input_param).to(device) + with eval_mode(net): + result = net.features.forward(example) + torchvision_net = torchvision.models.densenet121(pretrained=True).to(device) + with eval_mode(torchvision_net): + expected_result = torchvision_net.features.forward(example) + self.assertTrue(torch.all(result == expected_result)) + class TestDENSENET(unittest.TestCase): @parameterized.expand(TEST_CASES) diff --git a/tests/test_dice_ce_loss.py b/tests/test_dice_ce_loss.py index 443d9a9baf..8627c6d130 100644 --- a/tests/test_dice_ce_loss.py +++ b/tests/test_dice_ce_loss.py @@ -16,6 +16,7 @@ from parameterized import parameterized from monai.losses import DiceCELoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save TEST_CASES = [ [ # shape: (2, 2, 3), (2, 1, 3) @@ -64,6 +65,12 @@ def test_ill_shape(self): with self.assertRaisesRegex(ValueError, ""): loss(torch.ones((1, 2, 3)), torch.ones((1, 1, 2, 3))) + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + loss = DiceCELoss() + test_input = torch.ones(2, 1, 8, 8) + test_script_save(loss, test_input, test_input) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_dice_loss.py b/tests/test_dice_loss.py index aa4a7cbc34..ef0a51eb15 100644 --- a/tests/test_dice_loss.py +++ b/tests/test_dice_loss.py @@ -16,6 +16,7 @@ from parameterized import parameterized from monai.losses import DiceLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save TEST_CASES = [ [ # shape: (1, 1, 2, 2), (1, 1, 2, 2) @@ -195,6 +196,12 @@ def test_input_warnings(self): loss = DiceLoss(to_onehot_y=True) loss.forward(chn_input, chn_target) + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + loss = DiceLoss() + test_input = torch.ones(2, 1, 8, 8) + test_script_save(loss, test_input, test_input) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_distcall.py b/tests/test_distcall.py new file mode 100644 index 0000000000..1830a85654 --- /dev/null +++ b/tests/test_distcall.py @@ -0,0 +1,29 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from tests.utils import DistCall, DistTestCase + + +class DistributedCallTest(DistTestCase): + def test_constructor(self): + with self.assertRaises(ValueError): + DistCall(nnodes=1, nproc_per_node=0) + with self.assertRaises(ValueError): + DistCall(nnodes=0, nproc_per_node=0) + with self.assertRaises(ValueError): + DistCall(nnodes=0, nproc_per_node=1) + _ = DistCall(nnodes=1, nproc_per_node=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_distributed_sampler.py b/tests/test_distributed_sampler.py index d0054885eb..0a439874bd 100644 --- a/tests/test_distributed_sampler.py +++ b/tests/test_distributed_sampler.py @@ -24,6 +24,7 @@ def test_even(self): data = [1, 2, 3, 4, 5] sampler = DistributedSampler(dataset=data, shuffle=False) samples = np.array([data[i] for i in list(sampler)]) + self.assertEqual(dist.get_rank(), sampler.rank) if dist.get_rank() == 0: np.testing.assert_allclose(samples, np.array([1, 3, 5])) @@ -35,6 +36,7 @@ def test_uneven(self): data = [1, 2, 3, 4, 5] sampler = DistributedSampler(dataset=data, shuffle=False, even_divisible=False) samples = np.array([data[i] for i in list(sampler)]) + self.assertEqual(dist.get_rank(), sampler.rank) if dist.get_rank() == 0: np.testing.assert_allclose(samples, np.array([1, 3, 5])) diff --git a/tests/test_distributed_weighted_random_sampler.py b/tests/test_distributed_weighted_random_sampler.py new file mode 100644 index 0000000000..b8e088fdcf --- /dev/null +++ b/tests/test_distributed_weighted_random_sampler.py @@ -0,0 +1,62 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +import torch.distributed as dist + +from monai.data import DistributedWeightedRandomSampler +from tests.utils import DistCall, DistTestCase + + +class DistributedWeightedRandomSamplerTest(DistTestCase): + @DistCall(nnodes=1, nproc_per_node=2) + def test_sampling(self): + data = [1, 2, 3, 4, 5] + weights = [1, 2, 3, 4, 5] + sampler = DistributedWeightedRandomSampler( + weights=weights, + dataset=data, + shuffle=False, + generator=torch.Generator().manual_seed(0), + ) + samples = np.array([data[i] for i in list(sampler)]) + + if dist.get_rank() == 0: + np.testing.assert_allclose(samples, np.array([5, 5, 5])) + + if dist.get_rank() == 1: + np.testing.assert_allclose(samples, np.array([1, 4, 4])) + + @DistCall(nnodes=1, nproc_per_node=2) + def test_num_samples(self): + data = [1, 2, 3, 4, 5] + weights = [1, 2, 3, 4, 5] + sampler = DistributedWeightedRandomSampler( + weights=weights, + num_samples_per_rank=5, + dataset=data, + shuffle=False, + generator=torch.Generator().manual_seed(123), + ) + samples = np.array([data[i] for i in list(sampler)]) + + if dist.get_rank() == 0: + np.testing.assert_allclose(samples, np.array([3, 1, 5, 1, 5])) + + if dist.get_rank() == 1: + np.testing.assert_allclose(samples, np.array([4, 2, 4, 2, 4])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ensure_channel_first.py b/tests/test_ensure_channel_first.py new file mode 100644 index 0000000000..ff656f2e24 --- /dev/null +++ b/tests/test_ensure_channel_first.py @@ -0,0 +1,86 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tempfile +import unittest + +import itk +import nibabel as nib +import numpy as np +from parameterized import parameterized +from PIL import Image + +from monai.data import ITKReader +from monai.transforms import EnsureChannelFirst, LoadImage + +TEST_CASE_1 = [{"image_only": False}, ["test_image.nii.gz"], None] + +TEST_CASE_2 = [{"image_only": False}, ["test_image.nii.gz"], -1] + +TEST_CASE_3 = [ + {"image_only": False}, + ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], + None, +] + +TEST_CASE_4 = [{"reader": ITKReader(), "image_only": False}, ["test_image.nii.gz"], None] + +TEST_CASE_5 = [{"reader": ITKReader(), "image_only": False}, ["test_image.nii.gz"], -1] + +TEST_CASE_6 = [ + {"reader": ITKReader(), "image_only": False}, + ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], + None, +] + +TEST_CASE_7 = [ + {"image_only": False, "reader": ITKReader(pixel_type=itk.UC)}, + "tests/testing_data/CT_DICOM", + None, +] + + +class TestEnsureChannelFirst(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) + 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) + elif original_channel_dim == -1: + test_image = np.random.rand(128, 128, 128, 1) + + with tempfile.TemporaryDirectory() as tempdir: + for i, name in enumerate(filenames): + filenames[i] = os.path.join(tempdir, name) + nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) + result, header = LoadImage(**input_param)(filenames) + result = EnsureChannelFirst()(result, header) + self.assertEqual(result.shape[0], len(filenames)) + + @parameterized.expand([TEST_CASE_7]) + def test_itk_dicom_series_reader(self, input_param, filenames, original_channel_dim): + result, header = LoadImage(**input_param)(filenames) + result = EnsureChannelFirst()(result, header) + 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) + with tempfile.TemporaryDirectory() as tempdir: + filename = os.path.join(tempdir, "test_image.png") + Image.fromarray(test_image.astype("uint8")).save(filename) + result, header = LoadImage(image_only=False)(filename) + result = EnsureChannelFirst()(result, header) + self.assertEqual(result.shape[0], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ensure_channel_firstd.py b/tests/test_ensure_channel_firstd.py new file mode 100644 index 0000000000..a5298f4453 --- /dev/null +++ b/tests/test_ensure_channel_firstd.py @@ -0,0 +1,62 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tempfile +import unittest + +import nibabel as nib +import numpy as np +from parameterized import parameterized +from PIL import Image + +from monai.transforms import EnsureChannelFirstd, LoadImaged + +TEST_CASE_1 = [{"keys": "img"}, ["test_image.nii.gz"], None] + +TEST_CASE_2 = [{"keys": "img"}, ["test_image.nii.gz"], -1] + +TEST_CASE_3 = [ + {"keys": "img"}, + ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], + None, +] + + +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) + elif original_channel_dim == -1: + test_image = np.random.rand(128, 128, 128, 1) + + with tempfile.TemporaryDirectory() as tempdir: + for i, name in enumerate(filenames): + filenames[i] = os.path.join(tempdir, name) + nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) + result = LoadImaged(**input_param)({"img": filenames}) + result = EnsureChannelFirstd(**input_param)(result) + self.assertEqual(result["img"].shape[0], len(filenames)) + + def test_load_png(self): + spatial_size = (256, 256, 3) + test_image = np.random.randint(0, 256, size=spatial_size) + with tempfile.TemporaryDirectory() as tempdir: + filename = os.path.join(tempdir, "test_image.png") + Image.fromarray(test_image.astype("uint8")).save(filename) + result = LoadImaged(keys="img")({"img": filename}) + result = EnsureChannelFirstd(keys="img")(result) + self.assertEqual(result["img"].shape[0], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_enum_bound_interp.py b/tests/test_enum_bound_interp.py new file mode 100644 index 0000000000..f788f8ba17 --- /dev/null +++ b/tests/test_enum_bound_interp.py @@ -0,0 +1,73 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from monai.utils import optional_import +from tests.utils import skip_if_no_cpp_extension + +b, _ = optional_import("monai._C", name="BoundType") +p, _ = optional_import("monai._C", name="InterpolationType") + + +@skip_if_no_cpp_extension +class TestEnumBoundInterp(unittest.TestCase): + def test_bound(self): + self.assertEqual(str(b.replicate), "BoundType.replicate") + self.assertEqual(str(b.nearest), "BoundType.replicate") + self.assertEqual(str(b.dct1), "BoundType.dct1") + self.assertEqual(str(b.mirror), "BoundType.dct1") + self.assertEqual(str(b.dct2), "BoundType.dct2") + self.assertEqual(str(b.reflect), "BoundType.dct2") + self.assertEqual(str(b.dst1), "BoundType.dst1") + self.assertEqual(str(b.antimirror), "BoundType.dst1") + self.assertEqual(str(b.dst2), "BoundType.dst2") + self.assertEqual(str(b.antireflect), "BoundType.dst2") + self.assertEqual(str(b.dft), "BoundType.dft") + self.assertEqual(str(b.wrap), "BoundType.dft") + self.assertEqual(str(b.zero), "BoundType.zero") + + self.assertEqual(int(b.replicate), 0) + self.assertEqual(int(b.nearest), 0) + self.assertEqual(int(b.dct1), 1) + self.assertEqual(int(b.mirror), 1) + self.assertEqual(int(b.dct2), 2) + self.assertEqual(int(b.reflect), 2) + self.assertEqual(int(b.dst1), 3) + self.assertEqual(int(b.antimirror), 3) + self.assertEqual(int(b.dst2), 4) + self.assertEqual(int(b.antireflect), 4) + self.assertEqual(int(b.dft), 5) + self.assertEqual(int(b.wrap), 5) + self.assertEqual(int(b.zero), 7) + + def test_interp(self): + self.assertEqual(str(p.nearest), "InterpolationType.nearest") + self.assertEqual(str(p.linear), "InterpolationType.linear") + self.assertEqual(str(p.quadratic), "InterpolationType.quadratic") + self.assertEqual(str(p.cubic), "InterpolationType.cubic") + self.assertEqual(str(p.fourth), "InterpolationType.fourth") + self.assertEqual(str(p.fifth), "InterpolationType.fifth") + self.assertEqual(str(p.sixth), "InterpolationType.sixth") + self.assertEqual(str(p.seventh), "InterpolationType.seventh") + + self.assertEqual(int(p.nearest), 0) + self.assertEqual(int(p.linear), 1) + self.assertEqual(int(p.quadratic), 2) + self.assertEqual(int(p.cubic), 3) + self.assertEqual(int(p.fourth), 4) + self.assertEqual(int(p.fifth), 5) + self.assertEqual(int(p.sixth), 6) + self.assertEqual(int(p.seventh), 7) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_focal_loss.py b/tests/test_focal_loss.py index d06e2b4c36..4512dac4b9 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -16,12 +16,14 @@ import torch.nn.functional as F from monai.losses import FocalLoss +from monai.networks import one_hot +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save class TestFocalLoss(unittest.TestCase): def test_consistency_with_cross_entropy_2d(self): # For gamma=0 the focal loss reduces to the cross entropy loss - focal_loss = FocalLoss(gamma=0.0, reduction="mean") + focal_loss = FocalLoss(to_onehot_y=True, gamma=0.0, reduction="mean", weight=1.0) ce = nn.CrossEntropyLoss(reduction="mean") max_error = 0 class_num = 10 @@ -35,7 +37,30 @@ def test_consistency_with_cross_entropy_2d(self): x = x.cuda() l = l.cuda() output0 = focal_loss(x, l) - output1 = ce(x, l[:, 0]) + output1 = ce(x, l[:, 0]) / class_num + a = float(output0.cpu().detach()) + b = float(output1.cpu().detach()) + if abs(a - b) > max_error: + max_error = abs(a - b) + self.assertAlmostEqual(max_error, 0.0, places=3) + + def test_consistency_with_cross_entropy_2d_onehot_label(self): + # For gamma=0 the focal loss reduces to the cross entropy loss + focal_loss = FocalLoss(to_onehot_y=False, gamma=0.0, reduction="mean") + ce = nn.CrossEntropyLoss(reduction="mean") + max_error = 0 + class_num = 10 + batch_size = 128 + for _ in range(100): + # Create a random tensor of shape (batch_size, class_num, 8, 4) + x = torch.rand(batch_size, class_num, 8, 4, requires_grad=True) + # Create a random batch of classes + l = torch.randint(low=0, high=class_num, size=(batch_size, 1, 8, 4)) + if torch.cuda.is_available(): + x = x.cuda() + l = l.cuda() + output0 = focal_loss(x, one_hot(l, num_classes=class_num)) + output1 = ce(x, l[:, 0]) / class_num a = float(output0.cpu().detach()) b = float(output1.cpu().detach()) if abs(a - b) > max_error: @@ -44,7 +69,7 @@ def test_consistency_with_cross_entropy_2d(self): def test_consistency_with_cross_entropy_classification(self): # for gamma=0 the focal loss reduces to the cross entropy loss - focal_loss = FocalLoss(gamma=0.0, reduction="mean") + focal_loss = FocalLoss(to_onehot_y=True, gamma=0.0, reduction="mean") ce = nn.CrossEntropyLoss(reduction="mean") max_error = 0 class_num = 10 @@ -59,7 +84,7 @@ def test_consistency_with_cross_entropy_classification(self): x = x.cuda() l = l.cuda() output0 = focal_loss(x, l) - output1 = ce(x, l[:, 0]) + output1 = ce(x, l[:, 0]) / class_num a = float(output0.cpu().detach()) b = float(output1.cpu().detach()) if abs(a - b) > max_error: @@ -74,7 +99,7 @@ def test_bin_seg_2d(self): pred_very_good = 1000 * F.one_hot(target, num_classes=2).permute(0, 3, 1, 2).float() # initialize the mean dice loss - loss = FocalLoss() + loss = FocalLoss(to_onehot_y=True) # focal loss for pred_very_good should be close to 0 target = target.unsqueeze(1) # shape (1, 1, H, W) @@ -90,7 +115,7 @@ def test_empty_class_2d(self): pred_very_good = 1000 * F.one_hot(target, num_classes=num_classes).permute(0, 3, 1, 2).float() # initialize the mean dice loss - loss = FocalLoss() + loss = FocalLoss(to_onehot_y=True) # focal loss for pred_very_good should be close to 0 target = target.unsqueeze(1) # shape (1, 1, H, W) @@ -105,7 +130,8 @@ def test_multi_class_seg_2d(self): target = target.unsqueeze(0) # shape (1, H, W) pred_very_good = 1000 * F.one_hot(target, num_classes=num_classes).permute(0, 3, 1, 2).float() # initialize the mean dice loss - loss = FocalLoss() + loss = FocalLoss(to_onehot_y=True) + loss_onehot = FocalLoss(to_onehot_y=False) # focal loss for pred_very_good should be close to 0 target_one_hot = F.one_hot(target, num_classes=num_classes).permute(0, 3, 1, 2) # test one hot @@ -114,7 +140,7 @@ def test_multi_class_seg_2d(self): focal_loss_good = float(loss(pred_very_good, target).cpu()) self.assertAlmostEqual(focal_loss_good, 0.0, places=3) - focal_loss_good = float(loss(pred_very_good, target_one_hot).cpu()) + focal_loss_good = float(loss_onehot(pred_very_good, target_one_hot).cpu()) self.assertAlmostEqual(focal_loss_good, 0.0, places=3) def test_bin_seg_3d(self): @@ -136,33 +162,36 @@ def test_bin_seg_3d(self): pred_very_good = 1000 * F.one_hot(target, num_classes=num_classes).permute(0, 4, 1, 2, 3).float() # initialize the mean dice loss - loss = FocalLoss() + loss = FocalLoss(to_onehot_y=True) + loss_onehot = FocalLoss(to_onehot_y=False) # focal loss for pred_very_good should be close to 0 target = target.unsqueeze(1) # shape (1, 1, H, W) focal_loss_good = float(loss(pred_very_good, target).cpu()) self.assertAlmostEqual(focal_loss_good, 0.0, places=3) - focal_loss_good = float(loss(pred_very_good, target_one_hot).cpu()) + focal_loss_good = float(loss_onehot(pred_very_good, target_one_hot).cpu()) self.assertAlmostEqual(focal_loss_good, 0.0, places=3) def test_ill_opts(self): chn_input = torch.ones((1, 2, 3)) - chn_target = torch.ones((1, 1, 3)) + chn_target = torch.ones((1, 2, 3)) with self.assertRaisesRegex(ValueError, ""): FocalLoss(reduction="unknown")(chn_input, chn_target) - with self.assertRaisesRegex(ValueError, ""): - FocalLoss(reduction=None)(chn_input, chn_target) + with self.assertRaisesRegex(TypeError, ""): + FocalLoss(other_act="tanh")(chn_input, chn_target) def test_ill_shape(self): chn_input = torch.ones((1, 2, 3)) chn_target = torch.ones((1, 3)) - with self.assertRaisesRegex(ValueError, ""): + with self.assertRaisesRegex(AssertionError, ""): FocalLoss(reduction="mean")(chn_input, chn_target) - chn_input = torch.ones((1, 1, 30)) - chn_target = torch.ones((1, 1, 30)) - with self.assertRaisesRegex(NotImplementedError, ""): - FocalLoss()(chn_input, chn_target) + + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + loss = FocalLoss() + test_input = torch.ones(2, 2, 8, 8) + test_script_save(loss, test_input, test_input) if __name__ == "__main__": diff --git a/tests/test_generalized_dice_loss.py b/tests/test_generalized_dice_loss.py index e88253ccba..06446204fb 100644 --- a/tests/test_generalized_dice_loss.py +++ b/tests/test_generalized_dice_loss.py @@ -16,6 +16,7 @@ from parameterized import parameterized from monai.losses import GeneralizedDiceLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save TEST_CASES = [ [ # shape: (1, 1, 2, 2), (1, 1, 2, 2) @@ -178,6 +179,12 @@ def test_input_warnings(self): loss = GeneralizedDiceLoss(to_onehot_y=True) loss.forward(chn_input, chn_target) + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + loss = GeneralizedDiceLoss() + test_input = torch.ones(2, 1, 8, 8) + test_script_save(loss, test_input, test_input) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_generalized_wasserstein_dice_loss.py b/tests/test_generalized_wasserstein_dice_loss.py index 6865b53027..295a4a6d70 100644 --- a/tests/test_generalized_wasserstein_dice_loss.py +++ b/tests/test_generalized_wasserstein_dice_loss.py @@ -18,6 +18,7 @@ import torch.optim as optim from monai.losses import GeneralizedWassersteinDiceLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save class TestGeneralizedWassersteinDiceLoss(unittest.TestCase): @@ -215,6 +216,18 @@ def forward(self, x): # check that the predicted segmentation has improved self.assertGreater(diff_start, diff_end) + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + target = torch.tensor([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]]) + + # add another dimension corresponding to the batch (batch size = 1 here) + target = target.unsqueeze(0) + pred_very_good = 1000 * F.one_hot(target, num_classes=2).permute(0, 3, 1, 2).float() + + loss = GeneralizedWassersteinDiceLoss(dist_matrix=np.array([[0.0, 1.0], [1.0, 0.0]]), weighting_mode="default") + + test_script_save(loss, pred_very_good, target) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_globalnet.py b/tests/test_globalnet.py new file mode 100644 index 0000000000..19e9db9137 --- /dev/null +++ b/tests/test_globalnet.py @@ -0,0 +1,79 @@ +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.networks import eval_mode +from monai.networks.nets import GlobalNet +from monai.networks.nets.regunet import AffineHead +from tests.utils import test_script_save + +TEST_CASES_AFFINE_TRANSFORM = [ + [ + {"spatial_dims": 3, "image_size": (2, 2, 2), "decode_size": (2, 2, 2), "in_channels": 1}, + torch.ones(2, 12), + torch.tensor([[[1, 2], [2, 3]], [[2, 3], [3, 4]]]).unsqueeze(0).unsqueeze(0).expand(2, 3, 2, 2, 2), + ], + [ + {"spatial_dims": 3, "image_size": (2, 2, 2), "decode_size": (2, 2, 2), "in_channels": 1}, + torch.arange(1, 13).reshape(1, 12).to(torch.float), + torch.tensor( + [ + [[[4.0, 7.0], [6.0, 9.0]], [[5.0, 8.0], [7.0, 10.0]]], + [[[8.0, 15.0], [14.0, 21.0]], [[13.0, 20.0], [19.0, 26.0]]], + [[[12.0, 23.0], [22.0, 33.0]], [[21.0, 32.0], [31.0, 42.0]]], + ] + ).unsqueeze(0), + ], +] + + +TEST_CASES_GLOBAL_NET = [ + [ + { + "image_size": (16, 16), + "spatial_dims": 2, + "in_channels": 1, + "num_channel_initial": 16, + "depth": 1, + "out_kernel_initializer": "kaiming_uniform", + "out_activation": None, + "pooling": True, + "concat_skip": True, + "encode_kernel_sizes": 3, + }, + (1, 1, 16, 16), + (1, 2, 16, 16), + ] +] + + +class TestAffineHead(unittest.TestCase): + @parameterized.expand(TEST_CASES_AFFINE_TRANSFORM) + def test_shape(self, input_param, theta, expected_val): + layer = AffineHead(**input_param) + result = layer.affine_transform(theta) + np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4) + + +device = "cuda" if torch.cuda.is_available() else "cpu" + + +class TestGlobalNet(unittest.TestCase): + @parameterized.expand(TEST_CASES_GLOBAL_NET) + def test_shape(self, input_param, input_shape, expected_shape): + net = GlobalNet(**input_param).to(device) + with eval_mode(net): + result = net(torch.randn(input_shape).to(device)) + self.assertEqual(result.shape, expected_shape) + + def test_script(self): + input_param, input_shape, _ = TEST_CASES_GLOBAL_NET[0] + net = GlobalNet(**input_param) + test_data = torch.randn(input_shape) + test_script_save(net, test_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_grid_dataset.py b/tests/test_grid_dataset.py new file mode 100644 index 0000000000..6e0aa4023e --- /dev/null +++ b/tests/test_grid_dataset.py @@ -0,0 +1,83 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import unittest + +import numpy as np + +from monai.data import DataLoader, GridPatchDataset, PatchIter +from monai.transforms import RandShiftIntensity +from monai.utils import set_determinism + + +def identity_generator(x): + # simple transform that returns the input itself + for idx, item in enumerate(x): + yield item, idx + + +class TestGridPatchDataset(unittest.TestCase): + def setUp(self): + set_determinism(seed=1234) + + def tearDown(self): + set_determinism(None) + + def test_shape(self): + test_dataset = ["vwxyz", "helloworld", "worldfoobar"] + result = GridPatchDataset(dataset=test_dataset, patch_iter=identity_generator, with_coordinates=False) + output = [] + n_workers = 0 if sys.platform == "win32" else 2 + for item in DataLoader(result, batch_size=3, num_workers=n_workers): + output.append("".join(item)) + expected = ["vwx", "wor", "yzh", "ldf", "ell", "oob", "owo", "ar", "rld"] + self.assertEqual(sorted(output), sorted(expected)) + self.assertEqual(len("".join(expected)), len("".join(test_dataset))) + + def test_loading_array(self): + set_determinism(seed=1234) + # image dataset + images = [np.arange(16, dtype=float).reshape(1, 4, 4), np.arange(16, dtype=float).reshape(1, 4, 4)] + # image level + patch_intensity = RandShiftIntensity(offsets=1.0, prob=1.0) + patch_iter = PatchIter(patch_size=(2, 2), start_pos=(0, 0)) + ds = GridPatchDataset(dataset=images, patch_iter=patch_iter, transform=patch_intensity) + # use the grid patch dataset + for item in DataLoader(ds, batch_size=2, shuffle=False, num_workers=0): + np.testing.assert_equal(tuple(item[0].shape), (2, 1, 2, 2)) + np.testing.assert_allclose( + item[0], + np.array([[[[1.7413, 2.7413], [5.7413, 6.7413]]], [[[9.1419, 10.1419], [13.1419, 14.1419]]]]), + rtol=1e-5, + ) + np.testing.assert_allclose( + item[1], + np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), + rtol=1e-5, + ) + if sys.platform != "win32": + for item in DataLoader(ds, batch_size=2, shuffle=False, num_workers=2): + np.testing.assert_equal(tuple(item[0].shape), (2, 1, 2, 2)) + np.testing.assert_allclose( + item[0], + np.array([[[[2.3944, 3.3944], [6.3944, 7.3944]]], [[[10.6551, 11.6551], [14.6551, 15.6551]]]]), + rtol=1e-3, + ) + np.testing.assert_allclose( + item[1], + np.array([[[0, 1], [0, 2], [2, 4]], [[0, 1], [2, 4], [2, 4]]]), + rtol=1e-5, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_grid_pull.py b/tests/test_grid_pull.py new file mode 100644 index 0000000000..9e4d2e8237 --- /dev/null +++ b/tests/test_grid_pull.py @@ -0,0 +1,94 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +import torch +from parameterized import parameterized + +from monai.networks.layers import grid_pull +from monai.utils import optional_import +from tests.testing_data.cpp_resample_answers import Expected_1D_GP_bwd, Expected_1D_GP_fwd +from tests.utils import skip_if_no_cpp_extension + +BType, has_b_type = optional_import("monai._C", name="BoundType") +PType, has_p_type = optional_import("monai._C", name="InterpolationType") + + +def make_grid(shape, dtype=None, device=None, requires_grad=True): + ranges = [torch.arange(float(s), dtype=dtype, device=device, requires_grad=requires_grad) for s in shape] + grid = torch.stack(torch.meshgrid(*ranges), dim=-1) + return grid[None] + + +# 1D combinations of bounds/interpolations +bounds = set(BType.__members__.values()) if has_b_type else [] +interps = set(PType.__members__.values()) if has_p_type else [] +device = "cuda" if torch.cuda.is_available() else "cpu" +TEST_1D_GP = [] +for bound in bounds: + for interp in interps: + if not Expected_1D_GP_fwd or not Expected_1D_GP_bwd: + break # skip if the testing data are unavailable + expected_val = Expected_1D_GP_fwd.pop(0) + + for input_g in (True, False): + for grid_g in (True, False): + expected_grad = Expected_1D_GP_bwd.pop(0) + test_case = [ + { + "input": torch.arange(10, dtype=torch.float, requires_grad=input_g, device=device).reshape( + (1, 1, 10) + ), + "grid": make_grid((20,), dtype=torch.float, device=device, requires_grad=grid_g) + 0.5, + "interpolation": interp, + "bound": bound, + }, + { + "val": torch.tensor([[expected_val]]), + "device": device, + "grad": torch.tensor(expected_grad), + }, + ] + TEST_1D_GP.append(test_case) + + +@skip_if_no_cpp_extension +class TestGridPull(unittest.TestCase): + @parameterized.expand(TEST_1D_GP, skip_on_empty=True) + def test_grid_pull(self, input_param, expected): + result = grid_pull(**input_param) + if input_param["input"].requires_grad: + input_param["input"].retain_grad() + if input_param["grid"].requires_grad: + input_param["grid"].retain_grad() + if input_param["input"].requires_grad or input_param["grid"].requires_grad: + result.sum().backward() + + grads = [] + if input_param["input"].requires_grad: + grads.append(input_param["input"].grad.view(-1)) + if input_param["grid"].requires_grad: + grads.append(input_param["grid"].grad.view(-1)) + if not grads: + grads = torch.tensor(0.0, device=result.device) + elif len(grads) == 1: + grads = grads[0] + else: + grads = torch.cat(grads, dim=0) + self.assertTrue("{}".format(result.device).startswith(expected["device"])) + np.testing.assert_allclose(result.detach().cpu().numpy(), expected["val"].cpu().numpy(), rtol=1e-4, atol=1e-4) + np.testing.assert_allclose(grads.detach().cpu().numpy(), expected["grad"].cpu().numpy(), rtol=1e-4, atol=1e-4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_handler_checkpoint_loader.py b/tests/test_handler_checkpoint_loader.py index 8b0f752ff4..838cc3f4dd 100644 --- a/tests/test_handler_checkpoint_loader.py +++ b/tests/test_handler_checkpoint_loader.py @@ -16,7 +16,7 @@ import torch import torch.optim as optim -from ignite.engine import Engine +from ignite.engine import Engine, Events from monai.handlers import CheckpointLoader, CheckpointSaver @@ -33,15 +33,30 @@ def test_one_save_one_load(self): data2["weight"] = torch.tensor([0.2]) net2.load_state_dict(data2) with tempfile.TemporaryDirectory() as tempdir: - engine = Engine(lambda e, b: None) - CheckpointSaver(save_dir=tempdir, save_dict={"net": net1}, save_final=True).attach(engine) - engine.run([0] * 8, max_epochs=5) - path = tempdir + "/net_final_iteration=40.pt" - engine = Engine(lambda e, b: None) - CheckpointLoader(load_path=path, load_dict={"net": net2}).attach(engine) - engine.run([0] * 8, max_epochs=1) + engine1 = Engine(lambda e, b: None) + CheckpointSaver(save_dir=tempdir, save_dict={"net": net1, "eng": engine1}, save_final=True).attach(engine1) + engine1.run([0] * 8, max_epochs=5) + path = tempdir + "/checkpoint_final_iteration=40.pt" + engine2 = Engine(lambda e, b: None) + CheckpointLoader(load_path=path, load_dict={"net": net2, "eng": engine2}).attach(engine2) + + @engine2.on(Events.STARTED) + def check_epoch(engine: Engine): + self.assertEqual(engine.state.epoch, 5) + + engine2.run([0] * 8, max_epochs=8) torch.testing.assert_allclose(net2.state_dict()["weight"], torch.tensor([0.1])) + # test bad case with max_epochs smaller than current epoch + engine3 = Engine(lambda e, b: None) + CheckpointLoader(load_path=path, load_dict={"net": net2, "eng": engine3}).attach(engine3) + + try: + engine3.run([0] * 8, max_epochs=3) + except ValueError: + self.assertEqual(engine3.state.epoch, 5) + self.assertEqual(engine3.state.max_epochs, 5) + def test_two_save_one_load(self): logging.basicConfig(stream=sys.stdout, level=logging.INFO) net1 = torch.nn.PReLU() diff --git a/tests/test_handler_checkpoint_saver.py b/tests/test_handler_checkpoint_saver.py index 5c2b750a57..14474054df 100644 --- a/tests/test_handler_checkpoint_saver.py +++ b/tests/test_handler_checkpoint_saver.py @@ -22,7 +22,20 @@ from monai.handlers import CheckpointLoader, CheckpointSaver -TEST_CASE_1 = [True, None, False, None, 1, None, False, True, 0, None, ["test_checkpoint_final_iteration=40.pt"]] +TEST_CASE_1 = [ + True, + None, + False, + None, + 1, + None, + False, + False, + True, + 0, + None, + ["test_checkpoint_final_iteration=40.pt"], +] TEST_CASE_2 = [ False, @@ -33,6 +46,7 @@ None, False, True, + False, 0, None, ["test_checkpoint_key_metric=32.pt", "test_checkpoint_key_metric=40.pt"], @@ -47,6 +61,7 @@ None, False, True, + True, 2, 2, ["test_checkpoint_epoch=2.pt", "test_checkpoint_epoch=4.pt"], @@ -61,20 +76,48 @@ None, False, False, + False, 10, 2, ["test_checkpoint_iteration=30.pt", "test_checkpoint_iteration=40.pt"], ] -TEST_CASE_5 = [True, None, False, None, 1, None, False, True, 0, None, ["test_checkpoint_final_iteration=40.pt"], True] +TEST_CASE_5 = [ + True, + None, + False, + None, + 1, + None, + False, + False, + True, + 0, + None, + ["test_checkpoint_final_iteration=40.pt"], + True, +] + +TEST_CASE_6 = [True, "final_model.pt", False, None, 1, None, False, False, True, 0, None, ["final_model.pt"]] -TEST_CASE_6 = [True, "final_model.pt", False, None, 1, None, False, True, 0, None, ["final_model.pt"]] +TEST_CASE_7 = [False, None, True, "val_loss", 1, "model.pt", False, False, True, 0, None, ["model.pt"]] -TEST_CASE_7 = [False, None, True, "val_loss", 1, "model.pt", False, True, 0, None, ["model.pt"]] +TEST_CASE_8 = [False, None, True, "val_loss", 1, "model.pt", False, True, True, 0, None, ["model.pt"]] class TestHandlerCheckpointSaver(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7]) + @parameterized.expand( + [ + TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, + TEST_CASE_4, + TEST_CASE_5, + TEST_CASE_6, + TEST_CASE_7, + TEST_CASE_8, + ] + ) def test_file( self, save_final, @@ -84,6 +127,7 @@ def test_file( key_metric_n_saved, key_metric_filename, key_metric_save_state, + key_metric_greater_or_equal, epoch_level, save_interval, n_saved, @@ -117,6 +161,7 @@ def _train_func(engine, batch): key_metric_n_saved, key_metric_filename, key_metric_save_state, + key_metric_greater_or_equal, epoch_level, save_interval, n_saved, diff --git a/tests/test_handler_metrics_saver_dist.py b/tests/test_handler_metrics_saver_dist.py index 1b17d0adb4..0868ec5ff3 100644 --- a/tests/test_handler_metrics_saver_dist.py +++ b/tests/test_handler_metrics_saver_dist.py @@ -27,79 +27,82 @@ class DistributedMetricsSaver(DistTestCase): @DistCall(nnodes=1, nproc_per_node=2) def test_content(self): - self._run() - - def _run(self): with tempfile.TemporaryDirectory() as tempdir: - metrics_saver = MetricsSaver( - save_dir=tempdir, - metrics=["metric1", "metric2"], - metric_details=["metric3", "metric4"], - batch_transform=lambda x: x["image_meta_dict"], - summary_ops="*", - ) - - def _val_func(engine, batch): - pass - - engine = Engine(_val_func) - - if dist.get_rank() == 0: - data = [{"image_meta_dict": {"filename_or_obj": ["filepath1"]}}] - - @engine.on(Events.EPOCH_COMPLETED) - def _save_metrics0(engine): - engine.state.metrics = {"metric1": 1, "metric2": 2} - engine.state.metric_details = { - "metric3": torch.tensor([[1, 2]]), - "metric4": torch.tensor([[5, 6]]), - } - - if dist.get_rank() == 1: - # different ranks have different data length - data = [ - {"image_meta_dict": {"filename_or_obj": ["filepath2"]}}, - {"image_meta_dict": {"filename_or_obj": ["filepath3"]}}, - ] - - @engine.on(Events.EPOCH_COMPLETED) - def _save_metrics1(engine): - engine.state.metrics = {"metric1": 1, "metric2": 2} - engine.state.metric_details = { - "metric3": torch.tensor([[2, 3], [3, 4]]), - "metric4": torch.tensor([[6, 7], [7, 8]]), - } - - metrics_saver.attach(engine) - engine.run(data, max_epochs=1) - - if dist.get_rank() == 0: - # check the metrics.csv and content - self.assertTrue(os.path.exists(os.path.join(tempdir, "metrics.csv"))) - with open(os.path.join(tempdir, "metrics.csv")) as f: - f_csv = csv.reader(f) - for i, row in enumerate(f_csv): - self.assertEqual(row, [f"metric{i + 1}\t{i + 1}"]) - self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_raw.csv"))) - # check the metric_raw.csv and content - with open(os.path.join(tempdir, "metric3_raw.csv")) as f: - f_csv = csv.reader(f) - for i, row in enumerate(f_csv): - if i > 0: - self.assertEqual(row, [f"filepath{i}\t{float(i)}\t{float(i + 1)}\t{i + 0.5}"]) - self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_summary.csv"))) - # check the metric_summary.csv and content - with open(os.path.join(tempdir, "metric3_summary.csv")) as f: - f_csv = csv.reader(f) - for i, row in enumerate(f_csv): - if i == 1: - self.assertEqual(row, ["class0\t1.0000\t1.0000\t1.0000\t1.0000\t1.0000\t0.0000"]) - elif i == 2: - self.assertEqual(row, ["class1\t2.0000\t2.0000\t2.0000\t2.0000\t2.0000\t0.0000"]) - elif i == 3: - self.assertEqual(row, ["mean\t1.5000\t1.5000\t1.5000\t1.5000\t1.5000\t0.0000"]) - self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_raw.csv"))) - self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_summary.csv"))) + self._run(tempdir) + + def _run(self, tempdir): + fnames = ["aaa" * 300, "bbb" * 301, "ccc" * 302] + + metrics_saver = MetricsSaver( + save_dir=tempdir, + metrics=["metric1", "metric2"], + metric_details=["metric3", "metric4"], + batch_transform=lambda x: x["image_meta_dict"], + summary_ops="*", + ) + + def _val_func(engine, batch): + pass + + engine = Engine(_val_func) + + if dist.get_rank() == 0: + data = [{"image_meta_dict": {"filename_or_obj": [fnames[0]]}}] + + @engine.on(Events.EPOCH_COMPLETED) + def _save_metrics0(engine): + engine.state.metrics = {"metric1": 1, "metric2": 2} + engine.state.metric_details = { + "metric3": torch.tensor([[1, 2]]), + "metric4": torch.tensor([[5, 6]]), + } + + if dist.get_rank() == 1: + # different ranks have different data length + data = [ + {"image_meta_dict": {"filename_or_obj": [fnames[1]]}}, + {"image_meta_dict": {"filename_or_obj": [fnames[2]]}}, + ] + + @engine.on(Events.EPOCH_COMPLETED) + def _save_metrics1(engine): + engine.state.metrics = {"metric1": 1, "metric2": 2} + engine.state.metric_details = { + "metric3": torch.tensor([[2, 3], [3, 4]]), + "metric4": torch.tensor([[6, 7], [7, 8]]), + } + + metrics_saver.attach(engine) + engine.run(data, max_epochs=1) + + if dist.get_rank() == 0: + # check the metrics.csv and content + self.assertTrue(os.path.exists(os.path.join(tempdir, "metrics.csv"))) + with open(os.path.join(tempdir, "metrics.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + self.assertEqual(row, [f"metric{i + 1}\t{i + 1}"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_raw.csv"))) + # check the metric_raw.csv and content + with open(os.path.join(tempdir, "metric3_raw.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i > 0: + expected = [f"{fnames[i-1]}\t{float(i)}\t{float(i + 1)}\t{i + 0.5}"] + self.assertEqual(row, expected) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric3_summary.csv"))) + # check the metric_summary.csv and content + with open(os.path.join(tempdir, "metric3_summary.csv")) as f: + f_csv = csv.reader(f) + for i, row in enumerate(f_csv): + if i == 1: + self.assertEqual(row, ["class0\t1.0000\t1.0000\t1.0000\t1.0000\t1.0000\t0.0000"]) + elif i == 2: + self.assertEqual(row, ["class1\t2.0000\t2.0000\t2.0000\t2.0000\t2.0000\t0.0000"]) + elif i == 3: + self.assertEqual(row, ["mean\t1.5000\t1.5000\t1.5000\t1.5000\t1.5000\t0.0000"]) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_raw.csv"))) + self.assertTrue(os.path.exists(os.path.join(tempdir, "metric4_summary.csv"))) if __name__ == "__main__": diff --git a/tests/test_handler_stats.py b/tests/test_handler_stats.py index d1602f802a..248be9f329 100644 --- a/tests/test_handler_stats.py +++ b/tests/test_handler_stats.py @@ -25,7 +25,8 @@ class TestHandlerStats(unittest.TestCase): def test_metrics_print(self): log_stream = StringIO() - logging.basicConfig(stream=log_stream, level=logging.INFO) + log_handler = logging.StreamHandler(log_stream) + log_handler.setLevel(logging.INFO) key_to_handler = "test_logging" key_to_print = "testing_metric" @@ -42,13 +43,14 @@ def _update_metric(engine): engine.state.metrics[key_to_print] = current_metric + 0.1 # set up testing handler - stats_handler = StatsHandler(name=key_to_handler) + stats_handler = StatsHandler(name=key_to_handler, logger_handler=log_handler) stats_handler.attach(engine) engine.run(range(3), max_epochs=2) # check logging output output_str = log_stream.getvalue() + log_handler.close() grep = re.compile(f".*{key_to_handler}.*") has_key_word = re.compile(f".*{key_to_print}.*") for idx, line in enumerate(output_str.split("\n")): @@ -58,7 +60,8 @@ def _update_metric(engine): def test_loss_print(self): log_stream = StringIO() - logging.basicConfig(stream=log_stream, level=logging.INFO) + log_handler = logging.StreamHandler(log_stream) + log_handler.setLevel(logging.INFO) key_to_handler = "test_logging" key_to_print = "myLoss" @@ -69,13 +72,14 @@ def _train_func(engine, batch): engine = Engine(_train_func) # set up testing handler - stats_handler = StatsHandler(name=key_to_handler, tag_name=key_to_print) + stats_handler = StatsHandler(name=key_to_handler, tag_name=key_to_print, logger_handler=log_handler) stats_handler.attach(engine) engine.run(range(3), max_epochs=2) # check logging output output_str = log_stream.getvalue() + log_handler.close() grep = re.compile(f".*{key_to_handler}.*") has_key_word = re.compile(f".*{key_to_print}.*") for idx, line in enumerate(output_str.split("\n")): @@ -85,7 +89,8 @@ def _train_func(engine, batch): def test_loss_dict(self): log_stream = StringIO() - logging.basicConfig(stream=log_stream, level=logging.INFO) + log_handler = logging.StreamHandler(log_stream) + log_handler.setLevel(logging.INFO) key_to_handler = "test_logging" key_to_print = "myLoss1" @@ -96,13 +101,16 @@ def _train_func(engine, batch): engine = Engine(_train_func) # set up testing handler - stats_handler = StatsHandler(name=key_to_handler, output_transform=lambda x: {key_to_print: x}) + stats_handler = StatsHandler( + name=key_to_handler, output_transform=lambda x: {key_to_print: x}, logger_handler=log_handler + ) stats_handler.attach(engine) engine.run(range(3), max_epochs=2) # check logging output output_str = log_stream.getvalue() + log_handler.close() grep = re.compile(f".*{key_to_handler}.*") has_key_word = re.compile(f".*{key_to_print}.*") for idx, line in enumerate(output_str.split("\n")): @@ -111,13 +119,13 @@ def _train_func(engine, batch): self.assertTrue(has_key_word.match(line)) def test_loss_file(self): - logging.basicConfig(level=logging.INFO) key_to_handler = "test_logging" key_to_print = "myLoss" with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_loss_stats.log") handler = logging.FileHandler(filename, mode="w") + handler.setLevel(logging.INFO) # set up engine def _train_func(engine, batch): @@ -130,7 +138,7 @@ def _train_func(engine, batch): stats_handler.attach(engine) engine.run(range(3), max_epochs=2) - handler.stream.close() + handler.close() stats_handler.logger.removeHandler(handler) with open(filename, "r") as f: output_str = f.read() @@ -142,8 +150,6 @@ def _train_func(engine, batch): self.assertTrue(has_key_word.match(line)) def test_exception(self): - logging.basicConfig(level=logging.INFO) - # set up engine def _train_func(engine, batch): raise RuntimeError("test exception.") diff --git a/tests/test_image_dataset.py b/tests/test_image_dataset.py index d79a7d884c..ec2cf77cd8 100644 --- a/tests/test_image_dataset.py +++ b/tests/test_image_dataset.py @@ -17,12 +17,12 @@ import numpy as np from monai.data import ImageDataset -from monai.transforms import Randomizable +from monai.transforms.transform import RandomizableTransform FILENAMES = ["test1.nii.gz", "test2.nii", "test3.nii.gz"] -class RandTest(Randomizable): +class RandTest(RandomizableTransform): """ randomisable transform for testing. """ diff --git a/tests/test_integration_classification_2d.py b/tests/test_integration_classification_2d.py index 4be59cba41..6f8c949d78 100644 --- a/tests/test_integration_classification_2d.py +++ b/tests/test_integration_classification_2d.py @@ -22,7 +22,7 @@ from monai.apps import download_and_extract from monai.metrics import compute_roc_auc from monai.networks import eval_mode -from monai.networks.nets import densenet121 +from monai.networks.nets import DenseNet121 from monai.transforms import AddChannel, Compose, LoadImage, RandFlip, RandRotate, RandZoom, ScaleIntensity, ToTensor from monai.utils import set_determinism from tests.testing_data.integration_answers import test_integration_value @@ -71,7 +71,7 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", val_ds = MedNISTDataset(val_x, val_y, val_transforms) val_loader = DataLoader(val_ds, batch_size=300, num_workers=num_workers) - model = densenet121(spatial_dims=2, in_channels=1, out_channels=len(np.unique(train_y))).to(device) + model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=len(np.unique(train_y))).to(device) loss_function = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), 1e-5) epoch_num = 4 @@ -133,7 +133,7 @@ def run_inference_test(root_dir, test_x, test_y, device="cuda:0", num_workers=10 val_ds = MedNISTDataset(test_x, test_y, val_transforms) val_loader = DataLoader(val_ds, batch_size=300, num_workers=num_workers) - model = densenet121(spatial_dims=2, in_channels=1, out_channels=len(np.unique(test_y))).to(device) + model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=len(np.unique(test_y))).to(device) model_filename = os.path.join(root_dir, "best_metric_model.pth") model.load_state_dict(torch.load(model_filename)) diff --git a/tests/test_integration_sliding_window.py b/tests/test_integration_sliding_window.py index c4d020276e..faec377586 100644 --- a/tests/test_integration_sliding_window.py +++ b/tests/test_integration_sliding_window.py @@ -84,7 +84,7 @@ def test_training(self): ) output_image = nib.load(output_file).get_fdata() np.testing.assert_allclose(np.sum(output_image), 33621) - np.testing.assert_allclose(output_image.shape, (28, 25, 63, 1)) + np.testing.assert_allclose(output_image.shape, (28, 25, 63)) if __name__ == "__main__": diff --git a/tests/test_inverse.py b/tests/test_inverse.py new file mode 100644 index 0000000000..d54855d7c1 --- /dev/null +++ b/tests/test_inverse.py @@ -0,0 +1,589 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import sys +import unittest +from functools import partial +from typing import TYPE_CHECKING, List, Tuple +from unittest.case import skipUnless + +import numpy as np +import torch +from parameterized import parameterized + +from monai.data import CacheDataset, DataLoader, create_test_image_2d, create_test_image_3d +from monai.data.inverse_batch_transform import BatchInverseTransform +from monai.data.utils import decollate_batch +from monai.networks.nets import UNet +from monai.transforms import ( + AddChanneld, + Affined, + BorderPadd, + CenterSpatialCropd, + Compose, + CropForegroundd, + DivisiblePadd, + Flipd, + InvertibleTransform, + LoadImaged, + Orientationd, + RandAffined, + RandAxisFlipd, + RandFlipd, + Randomizable, + RandRotate90d, + RandRotated, + RandSpatialCropd, + RandZoomd, + Resized, + ResizeWithPadOrCrop, + ResizeWithPadOrCropd, + Rotate90d, + Rotated, + Spacingd, + SpatialCropd, + SpatialPadd, + Zoomd, + allow_missing_keys_mode, +) +from monai.utils import first, get_seed, optional_import, set_determinism +from monai.utils.enums import InverseKeys +from tests.utils import make_nifti_image, make_rand_affine + +if TYPE_CHECKING: + + has_nib = True +else: + _, has_nib = optional_import("nibabel") + +KEYS = ["image", "label"] + +TESTS: List[Tuple] = [] + +# For pad, start with odd/even images and add odd/even amounts +for name in ("1D even", "1D odd"): + for val in (3, 4): + for t in ( + partial(SpatialPadd, spatial_size=val, method="symmetric"), + partial(SpatialPadd, spatial_size=val, method="end"), + partial(BorderPadd, spatial_border=[val, val + 1]), + partial(DivisiblePadd, k=val), + partial(ResizeWithPadOrCropd, spatial_size=20 + val), + partial(CenterSpatialCropd, roi_size=10 + val), + partial(CropForegroundd, source_key="label"), + partial(SpatialCropd, roi_center=10, roi_size=10 + val), + partial(SpatialCropd, roi_center=11, roi_size=10 + val), + partial(SpatialCropd, roi_start=val, roi_end=17), + partial(SpatialCropd, roi_start=val, roi_end=16), + partial(RandSpatialCropd, roi_size=12 + val), + partial(ResizeWithPadOrCropd, spatial_size=21 - val), + ): + TESTS.append((t.func.__name__ + name, name, 0, t(KEYS))) # type: ignore + +# non-sensical tests: crop bigger or pad smaller or -ve values +for t in ( + partial(DivisiblePadd, k=-3), + partial(CenterSpatialCropd, roi_size=-3), + partial(RandSpatialCropd, roi_size=-3), + partial(SpatialPadd, spatial_size=15), + partial(BorderPadd, spatial_border=[15, 16]), + partial(CenterSpatialCropd, roi_size=30), + partial(SpatialCropd, roi_center=10, roi_size=100), + partial(SpatialCropd, roi_start=3, roi_end=100), +): + TESTS.append((t.func.__name__ + "bad 1D even", "1D even", 0, t(KEYS))) # type: ignore + +TESTS.append( + ( + "SpatialPadd (x2) 2d", + "2D", + 0, + SpatialPadd(KEYS, spatial_size=[111, 113], method="end"), + SpatialPadd(KEYS, spatial_size=[118, 117]), + ) +) + +TESTS.append( + ( + "SpatialPadd 3d", + "3D", + 0, + SpatialPadd(KEYS, spatial_size=[112, 113, 116]), + ) +) + + +TESTS.append( + ( + "SpatialCropd 2d", + "2D", + 0, + SpatialCropd(KEYS, [49, 51], [90, 89]), + ) +) + +TESTS.append( + ( + "SpatialCropd 2d", + "2D", + 0, + SpatialCropd(KEYS, [49, 51], [390, 89]), + ) +) + +TESTS.append( + ( + "SpatialCropd 3d", + "3D", + 0, + SpatialCropd(KEYS, [49, 51, 44], [90, 89, 93]), + ) +) + +TESTS.append(("RandSpatialCropd 2d", "2D", 0, RandSpatialCropd(KEYS, [96, 93], True, False))) + +TESTS.append(("RandSpatialCropd 3d", "3D", 0, RandSpatialCropd(KEYS, [96, 93, 92], False, False))) + +TESTS.append( + ( + "BorderPadd 2d", + "2D", + 0, + BorderPadd(KEYS, [3, 7, 2, 5]), + ) +) + +TESTS.append( + ( + "BorderPadd 2d", + "2D", + 0, + BorderPadd(KEYS, [3, 7]), + ) +) + +TESTS.append( + ( + "BorderPadd 3d", + "3D", + 0, + BorderPadd(KEYS, [4]), + ) +) + +TESTS.append( + ( + "DivisiblePadd 2d", + "2D", + 0, + DivisiblePadd(KEYS, k=4), + ) +) + +TESTS.append( + ( + "DivisiblePadd 3d", + "3D", + 0, + DivisiblePadd(KEYS, k=[4, 8, 11]), + ) +) + + +TESTS.append( + ( + "CenterSpatialCropd 2d", + "2D", + 0, + CenterSpatialCropd(KEYS, roi_size=95), + ) +) + +TESTS.append( + ( + "CenterSpatialCropd 3d", + "3D", + 0, + CenterSpatialCropd(KEYS, roi_size=[95, 97, 98]), + ) +) + +TESTS.append(("CropForegroundd 2d", "2D", 0, CropForegroundd(KEYS, source_key="label", margin=2))) + +TESTS.append(("CropForegroundd 3d", "3D", 0, CropForegroundd(KEYS, source_key="label"))) + + +TESTS.append(("ResizeWithPadOrCropd 3d", "3D", 0, ResizeWithPadOrCropd(KEYS, [201, 150, 105]))) + +TESTS.append( + ( + "Flipd 3d", + "3D", + 0, + Flipd(KEYS, [1, 2]), + ) +) + +TESTS.append( + ( + "Flipd 3d", + "3D", + 0, + Flipd(KEYS, [1, 2]), + ) +) + +TESTS.append( + ( + "RandFlipd 3d", + "3D", + 0, + RandFlipd(KEYS, 1, [1, 2]), + ) +) + +TESTS.append( + ( + "RandAxisFlipd 3d", + "3D", + 0, + RandAxisFlipd(KEYS, 1), + ) +) + +for acc in [True, False]: + TESTS.append( + ( + "Orientationd 3d", + "3D", + 0, + Orientationd(KEYS, "RAS", as_closest_canonical=acc), + ) + ) + +TESTS.append( + ( + "Rotate90d 2d", + "2D", + 0, + Rotate90d(KEYS), + ) +) + +TESTS.append( + ( + "Rotate90d 3d", + "3D", + 0, + Rotate90d(KEYS, k=2, spatial_axes=(1, 2)), + ) +) + +TESTS.append( + ( + "RandRotate90d 3d", + "3D", + 0, + RandRotate90d(KEYS, prob=1, spatial_axes=(1, 2)), + ) +) + +TESTS.append(("Spacingd 3d", "3D", 3e-2, Spacingd(KEYS, [0.5, 0.7, 0.9], diagonal=False))) + +TESTS.append(("Resized 2d", "2D", 2e-1, Resized(KEYS, [50, 47]))) + +TESTS.append(("Resized 3d", "3D", 5e-2, Resized(KEYS, [201, 150, 78]))) + + +TESTS.append( + ( + "Zoomd 1d", + "1D odd", + 0, + Zoomd(KEYS, zoom=2, keep_size=False), + ) +) + +TESTS.append( + ( + "Zoomd 2d", + "2D", + 2e-1, + Zoomd(KEYS, zoom=0.9), + ) +) + +TESTS.append( + ( + "Zoomd 3d", + "3D", + 3e-2, + Zoomd(KEYS, zoom=[2.5, 1, 3], keep_size=False), + ) +) + +TESTS.append(("RandZoom 3d", "3D", 9e-2, RandZoomd(KEYS, 1, [0.5, 0.6, 0.9], [1.1, 1, 1.05], keep_size=True))) + +TESTS.append( + ( + "RandRotated, prob 0", + "2D", + 0, + RandRotated(KEYS, prob=0), + ) +) + +TESTS.append( + ( + "Rotated 2d", + "2D", + 8e-2, + Rotated(KEYS, random.uniform(np.pi / 6, np.pi), keep_size=True, align_corners=False), + ) +) + +TESTS.append( + ( + "Rotated 3d", + "3D", + 1e-1, + Rotated(KEYS, [random.uniform(np.pi / 6, np.pi) for _ in range(3)], True), # type: ignore + ) +) + +TESTS.append( + ( + "RandRotated 3d", + "3D", + 1e-1, + RandRotated(KEYS, *[random.uniform(np.pi / 6, np.pi) for _ in range(3)], 1), # type: ignore + ) +) + +TESTS.append( + ( + "Affine 3d", + "3D", + 1e-1, + Affined( + KEYS, + spatial_size=[155, 179, 192], + rotate_params=[np.pi / 6, -np.pi / 5, np.pi / 7], + shear_params=[0.5, 0.5], + translate_params=[10, 5, -4], + scale_params=[0.8, 1.3], + ), + ) +) + +TESTS.append( + ( + "RandAffine 3d", + "3D", + 1e-1, + RandAffined( + KEYS, + [155, 179, 192], + prob=1, + padding_mode="zeros", + rotate_range=[np.pi / 6, -np.pi / 5, np.pi / 7], + shear_range=[(0.5, 0.5)], + translate_range=[10, 5, -4], + scale_range=[(0.8, 1.2), (0.9, 1.3)], + ), + ) +) + +TESTS_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] + +TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore + + +def no_collation(x): + return x + + +class TestInverse(unittest.TestCase): + """Test inverse methods. + + If tests are failing, the following function might be useful for displaying + `x`, `fx`, `f⁻¹fx` and `x - f⁻¹fx`. + + .. code-block:: python + + def plot_im(orig, fwd_bck, fwd): + import matplotlib.pyplot as plt + diff_orig_fwd_bck = orig - fwd_bck + ims_to_show = [orig, fwd, fwd_bck, diff_orig_fwd_bck] + titles = ["x", "fx", "f⁻¹fx", "x - f⁻¹fx"] + fig, axes = plt.subplots(1, 4, gridspec_kw={"width_ratios": [i.shape[1] for i in ims_to_show]}) + vmin = min(np.array(i).min() for i in [orig, fwd_bck, fwd]) + vmax = max(np.array(i).max() for i in [orig, fwd_bck, fwd]) + for im, title, ax in zip(ims_to_show, titles, axes): + _vmin, _vmax = (vmin, vmax) if id(im) != id(diff_orig_fwd_bck) else (None, None) + im = np.squeeze(np.array(im)) + while im.ndim > 2: + im = im[..., im.shape[-1] // 2] + im_show = ax.imshow(np.squeeze(im), vmin=_vmin, vmax=_vmax) + ax.set_title(title, fontsize=25) + ax.axis("off") + fig.colorbar(im_show, ax=ax) + plt.show() + + This can then be added to the exception: + + .. code-block:: python + + except AssertionError: + print( + f"Failed: {name}. Mean diff = {mean_diff} (expected <= {acceptable_diff}), unmodified diff: {unmodded_diff}" + ) + if orig[0].ndim > 1: + plot_im(orig, fwd_bck, unmodified) + """ + + def setUp(self): + if not has_nib: + self.skipTest("nibabel required for test_inverse") + + set_determinism(seed=0) + + self.all_data = {} + + affine = make_rand_affine() + affine[0] *= 2 + + for size in [10, 11]: + # pad 5 onto both ends so that cropping can be lossless + im_1d = np.pad(np.arange(size), 5)[None] + name = "1D even" if size % 2 == 0 else "1D odd" + self.all_data[name] = { + "image": np.array(im_1d, copy=True), + "label": np.array(im_1d, copy=True), + "other": np.array(im_1d, copy=True), + } + + im_2d_fname, seg_2d_fname = [make_nifti_image(i) for i in create_test_image_2d(101, 100)] + im_3d_fname, seg_3d_fname = [make_nifti_image(i, affine) for i in create_test_image_3d(100, 101, 107)] + + load_ims = Compose([LoadImaged(KEYS), AddChanneld(KEYS)]) + self.all_data["2D"] = load_ims({"image": im_2d_fname, "label": seg_2d_fname}) + self.all_data["3D"] = load_ims({"image": im_3d_fname, "label": seg_3d_fname}) + + def tearDown(self): + set_determinism(seed=None) + + def check_inverse(self, name, keys, orig_d, fwd_bck_d, unmodified_d, acceptable_diff): + for key in keys: + orig = orig_d[key] + fwd_bck = fwd_bck_d[key] + if isinstance(fwd_bck, torch.Tensor): + fwd_bck = fwd_bck.cpu().numpy() + unmodified = unmodified_d[key] + if isinstance(orig, np.ndarray): + mean_diff = np.mean(np.abs(orig - fwd_bck)) + unmodded_diff = np.mean(np.abs(orig - ResizeWithPadOrCrop(orig.shape[1:])(unmodified))) + try: + self.assertLessEqual(mean_diff, acceptable_diff) + except AssertionError: + print( + f"Failed: {name}. Mean diff = {mean_diff} (expected <= {acceptable_diff}), unmodified diff: {unmodded_diff}" + ) + if orig[0].ndim == 1: + print("orig", orig[0]) + print("fwd_bck", fwd_bck[0]) + print("unmod", unmodified[0]) + raise + + @parameterized.expand(TESTS) + def test_inverse(self, _, data_name, acceptable_diff, *transforms): + name = _ + + data = self.all_data[data_name] + + forwards = [data.copy()] + + # Apply forwards + for t in transforms: + if isinstance(t, Randomizable): + t.set_random_state(seed=get_seed()) + forwards.append(t(forwards[-1])) + + # Apply inverses + fwd_bck = forwards[-1].copy() + for i, t in enumerate(reversed(transforms)): + if isinstance(t, InvertibleTransform): + fwd_bck = t.inverse(fwd_bck) + self.check_inverse(name, data.keys(), forwards[-i - 2], fwd_bck, forwards[-1], acceptable_diff) + + # skip this test if multiprocessing uses 'spawn', as the check is only basic anyway + @skipUnless(torch.multiprocessing.get_start_method(allow_none=False) == "spawn", "requires spawn") + def test_fail(self): + + t1 = SpatialPadd("image", [10, 5]) + data = t1(self.all_data["2D"]) + + # Check that error is thrown when inverse are used out of order. + t2 = ResizeWithPadOrCropd("image", [10, 5]) + with self.assertRaises(RuntimeError): + t2.inverse(data) + + def test_inverse_inferred_seg(self): + + test_data = [] + for _ in range(20): + image, label = create_test_image_2d(100, 101) + test_data.append({"image": image, "label": label.astype(np.float32)}) + + batch_size = 10 + # num workers = 0 for mac + num_workers = 2 if sys.platform != "darwin" else 0 + transforms = Compose([AddChanneld(KEYS), SpatialPadd(KEYS, (150, 153)), CenterSpatialCropd(KEYS, (110, 99))]) + num_invertible_transforms = sum(1 for i in transforms.transforms if isinstance(i, InvertibleTransform)) + + dataset = CacheDataset(test_data, transform=transforms, progress=False) + loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers) + + device = "cuda" if torch.cuda.is_available() else "cpu" + model = UNet( + dimensions=2, + in_channels=1, + out_channels=1, + channels=(2, 4), + strides=(2,), + ).to(device) + + data = first(loader) + labels = data["label"].to(device) + segs = model(labels).detach().cpu() + label_transform_key = "label" + InverseKeys.KEY_SUFFIX + segs_dict = {"label": segs, label_transform_key: data[label_transform_key]} + + segs_dict_decollated = decollate_batch(segs_dict) + + # inverse of individual segmentation + seg_dict = first(segs_dict_decollated) + with allow_missing_keys_mode(transforms): + inv_seg = transforms.inverse(seg_dict)["label"] + self.assertEqual(len(data["label_transforms"]), num_invertible_transforms) + self.assertEqual(len(seg_dict["label_transforms"]), num_invertible_transforms) + self.assertEqual(inv_seg.shape[1:], test_data[0]["label"].shape) + + # Inverse of batch + batch_inverter = BatchInverseTransform(transforms, loader, collate_fn=no_collation) + with allow_missing_keys_mode(transforms): + inv_batch = batch_inverter(segs_dict) + self.assertEqual(inv_batch[0]["label"].shape[1:], test_data[0]["label"].shape) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_inverse_collation.py b/tests/test_inverse_collation.py new file mode 100644 index 0000000000..c5d77fb8f2 --- /dev/null +++ b/tests/test_inverse_collation.py @@ -0,0 +1,93 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import unittest +from typing import TYPE_CHECKING + +import numpy as np +from parameterized import parameterized + +from monai.data import CacheDataset, DataLoader, create_test_image_3d, pad_list_data_collate +from monai.transforms import ( + AddChanneld, + Compose, + LoadImaged, + RandAffined, + RandAxisFlipd, + RandFlipd, + RandRotate90d, + RandRotated, + RandZoomd, + ResizeWithPadOrCropd, +) +from monai.utils import optional_import, set_determinism +from tests.utils import make_nifti_image + +if TYPE_CHECKING: + + has_nib = True +else: + _, has_nib = optional_import("nibabel") + +KEYS = ["image", "label"] + +TESTS = [ + (t.__class__.__name__ + (" pad_list_data_collate" if collate_fn else " default_collate"), t, collate_fn) + for collate_fn in [None, pad_list_data_collate] + for t in [ + RandFlipd(keys=KEYS, spatial_axis=[1, 2]), + RandAxisFlipd(keys=KEYS), + RandRotate90d(keys=KEYS, spatial_axes=(1, 2)), + RandZoomd(keys=KEYS, prob=0.5, min_zoom=0.5, max_zoom=1.1, keep_size=True), + RandRotated(keys=KEYS, range_x=np.pi), + RandAffined(keys=KEYS, rotate_range=np.pi), + ] +] + + +class TestInverseCollation(unittest.TestCase): + """Test collation for of random transformations with prob == 0 and 1.""" + + def setUp(self): + if not has_nib: + self.skipTest("nibabel required for test_inverse") + + set_determinism(seed=0) + + im_fname, seg_fname = [make_nifti_image(i) for i in create_test_image_3d(101, 100, 107)] + load_ims = Compose([LoadImaged(KEYS), AddChanneld(KEYS)]) + self.batch_size = 10 + self.data = [load_ims({"image": im_fname, "label": seg_fname}) for _ in range(self.batch_size)] + + def tearDown(self): + set_determinism(seed=None) + + @parameterized.expand(TESTS) + def test_collation(self, _, transform, collate_fn): + + if collate_fn: + modified_transform = transform + else: + modified_transform = Compose([transform, ResizeWithPadOrCropd(KEYS, [100, 100, 100])]) + + # num workers = 0 for mac + num_workers = 2 if sys.platform != "darwin" else 0 + + dataset = CacheDataset(self.data, transform=modified_transform, progress=False) + loader = DataLoader(dataset, num_workers, batch_size=self.batch_size, collate_fn=collate_fn) + + for _ in loader: + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local_normalized_cross_correlation_loss.py b/tests/test_local_normalized_cross_correlation_loss.py index cf8566a559..8e9482596f 100644 --- a/tests/test_local_normalized_cross_correlation_loss.py +++ b/tests/test_local_normalized_cross_correlation_loss.py @@ -110,5 +110,11 @@ def test_ill_opts(self): LocalNormalizedCrossCorrelationLoss(in_channels=3, reduction=None)(pred, target) +# def test_script(self): +# input_param, input_data, _ = TEST_CASES[0] +# loss = LocalNormalizedCrossCorrelationLoss(**input_param) +# test_script_save(loss, input_data["pred"], input_data["target"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_localnet.py b/tests/test_localnet.py index 97a10d0c83..df1d9f61cb 100644 --- a/tests/test_localnet.py +++ b/tests/test_localnet.py @@ -4,7 +4,7 @@ from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.nets.localnet import LocalNet +from monai.networks.nets.regunet import LocalNet from tests.utils import test_script_save device = "cuda" if torch.cuda.is_available() else "cpu" @@ -15,39 +15,36 @@ { "spatial_dims": 2, "in_channels": 2, - "out_channels": 2, "num_channel_initial": 16, - "extract_levels": [0, 1, 2], - "out_activation": act, + "out_kernel_initializer": "kaiming_uniform", + "out_activation": None, + "out_channels": 2, + "extract_levels": (0, 1), + "pooling": False, + "concat_skip": True, }, (1, 2, 16, 16), (1, 2, 16, 16), ] - for act in ["sigmoid", None] ] -TEST_CASE_LOCALNET_3D = [] -for in_channels in [2, 3]: - for out_channels in [1, 3]: - for num_channel_initial in [4, 16, 32]: - for extract_levels in [[0, 1, 2], [0, 1, 2, 3], [0, 1, 2, 3, 4]]: - for out_activation in ["sigmoid", None]: - for out_initializer in ["kaiming_uniform", "zeros"]: - TEST_CASE_LOCALNET_3D.append( - [ - { - "spatial_dims": 3, - "in_channels": in_channels, - "out_channels": out_channels, - "num_channel_initial": num_channel_initial, - "extract_levels": extract_levels, - "out_activation": out_activation, - "out_initializer": out_initializer, - }, - (1, in_channels, 16, 16, 16), - (1, out_channels, 16, 16, 16), - ] - ) +TEST_CASE_LOCALNET_3D = [ + [ + { + "spatial_dims": 3, + "in_channels": 2, + "num_channel_initial": 16, + "out_kernel_initializer": "zeros", + "out_activation": "sigmoid", + "out_channels": 2, + "extract_levels": (0, 1, 2, 3), + "pooling": True, + "concat_skip": False, + }, + (1, 2, 16, 16, 16), + (1, 2, 16, 16, 16), + ] +] class TestLocalNet(unittest.TestCase): @@ -58,13 +55,6 @@ def test_shape(self, input_param, input_shape, expected_shape): result = net(torch.randn(input_shape).to(device)) self.assertEqual(result.shape, expected_shape) - def test_ill_shape(self): - with self.assertRaisesRegex(ValueError, ""): - input_param, _, _ = TEST_CASE_LOCALNET_2D[0] - input_shape = (1, input_param["in_channels"], 17, 17) - net = LocalNet(**input_param).to(device) - net.forward(torch.randn(input_shape).to(device)) - def test_script(self): input_param, input_shape, _ = TEST_CASE_LOCALNET_2D[0] net = LocalNet(**input_param) diff --git a/tests/test_mednistdataset.py b/tests/test_mednistdataset.py index 0887734a7c..2e27f4ba95 100644 --- a/tests/test_mednistdataset.py +++ b/tests/test_mednistdataset.py @@ -18,6 +18,8 @@ from monai.transforms import AddChanneld, Compose, LoadImaged, ScaleIntensityd, ToTensord from tests.utils import skip_if_quick +MEDNIST_FULL_DATASET_LENGTH = 58954 + class TestMedNISTDataset(unittest.TestCase): @skip_if_quick @@ -33,7 +35,7 @@ def test_values(self): ) def _test_dataset(dataset): - self.assertEqual(len(dataset), 5986) + self.assertEqual(len(dataset), int(MEDNIST_FULL_DATASET_LENGTH * dataset.test_frac)) self.assertTrue("image" in dataset[0]) self.assertTrue("label" in dataset[0]) self.assertTrue("image_meta_dict" in dataset[0]) @@ -56,6 +58,9 @@ def _test_dataset(dataset): _test_dataset(data) data = MedNISTDataset(root_dir=testing_dir, section="test", download=False) self.assertTupleEqual(data[0]["image"].shape, (64, 64)) + # test same dataset length with different random seed + data = MedNISTDataset(root_dir=testing_dir, transform=transform, section="test", download=False, seed=42) + _test_dataset(data) shutil.rmtree(os.path.join(testing_dir, "MedNIST")) try: data = MedNISTDataset(root_dir=testing_dir, transform=transform, section="test", download=False) diff --git a/tests/test_module_list.py b/tests/test_module_list.py new file mode 100644 index 0000000000..25e1dfbd6f --- /dev/null +++ b/tests/test_module_list.py @@ -0,0 +1,38 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import glob +import os +import unittest + +import monai + + +class TestAllImport(unittest.TestCase): + def test_public_api(self): + """ + This is to check "monai.__all__" should be consistent with + the top-level folders except for "__pycache__" and "csrc" (cpp/cuda src) + """ + base_folder = os.path.dirname(monai.__file__) + to_search = os.path.join(base_folder, "*", "") + subfolders = [os.path.basename(x[:-1]) for x in glob.glob(to_search)] + to_exclude = ("__pycache__", "csrc") + mod = [] + for code_folder in subfolders: + if code_folder in to_exclude: + continue + mod.append(code_folder) + self.assertEqual(sorted(monai.__all__), sorted(mod)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_multi_scale.py b/tests/test_multi_scale.py index 722ae7cfce..9ce1734e28 100644 --- a/tests/test_multi_scale.py +++ b/tests/test_multi_scale.py @@ -16,6 +16,7 @@ from monai.losses import DiceLoss from monai.losses.multi_scale import MultiScaleLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save dice_loss = DiceLoss(include_background=True, sigmoid=True, smooth_nr=1e-5, smooth_dr=1e-5) @@ -55,6 +56,12 @@ def test_ill_opts(self): with self.assertRaisesRegex(ValueError, ""): MultiScaleLoss(loss=dice_loss, scales=[-1], reduction="none")(torch.ones((1, 1, 3)), torch.ones((1, 1, 3))) + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + input_param, input_data, expected_val = TEST_CASES[0] + loss = MultiScaleLoss(**input_param) + test_script_save(loss, input_data["y_pred"], input_data["y_true"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_nifti_endianness.py b/tests/test_nifti_endianness.py new file mode 100644 index 0000000000..b725e2462c --- /dev/null +++ b/tests/test_nifti_endianness.py @@ -0,0 +1,69 @@ +import os +import tempfile +import unittest +from typing import TYPE_CHECKING, List, Tuple +from unittest.case import skipUnless + +import numpy as np +from parameterized import parameterized + +from monai.data import DataLoader, Dataset, create_test_image_2d +from monai.data.image_reader import PILReader +from monai.transforms import LoadImage, LoadImaged +from monai.transforms.io.array import switch_endianness +from monai.utils.module import optional_import + +if TYPE_CHECKING: + import nibabel as nib + from PIL import Image as PILImage + + has_nib = True + has_pil = True +else: + nib, has_nib = optional_import("nibabel") + PILImage, has_pil = optional_import("PIL.Image") + +TESTS: List[Tuple] = [] +for endianness in ["<", ">"]: + for use_array in [True, False]: + for image_only in [True, False]: + TESTS.append((endianness, use_array, image_only)) + + +class TestNiftiEndianness(unittest.TestCase): + def setUp(self): + self.im, _ = create_test_image_2d(100, 100) + self.fname = tempfile.NamedTemporaryFile(suffix=".nii.gz").name + + @parameterized.expand(TESTS) + @skipUnless(has_nib, "Requires NiBabel") + def test_endianness(self, endianness, use_array, image_only): + + hdr = nib.Nifti1Header(endianness=endianness) + nii = nib.Nifti1Image(self.im, np.eye(4), header=hdr) + nib.save(nii, self.fname) + + data = [self.fname] if use_array else [{"image": self.fname}] + tr = LoadImage(image_only=image_only) if use_array else LoadImaged("image", image_only=image_only) + check_ds = Dataset(data, tr) + check_loader = DataLoader(check_ds, batch_size=1) + _ = next(iter(check_loader)) + + def test_switch(self): # verify data types + for data in (np.zeros((2, 1)), ("test",), [24, 42], {"foo": "bar"}, True, 42): + output = switch_endianness(data, ">", "<") + self.assertEqual(type(data), type(output)) + + @skipUnless(has_pil, "Requires PIL") + def test_pil(self): + tempdir = tempfile.mkdtemp() + test_image = np.random.randint(0, 256, size=[128, 256]) + filename = os.path.join(tempdir, "test_image.png") + PILImage.fromarray(test_image.astype("uint8")).save(filename) + + loader = LoadImage(PILReader(converter=lambda image: image.convert("LA"))) + _ = loader(filename) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nifti_saver.py b/tests/test_nifti_saver.py index 2e2bfd4254..f48374a61c 100644 --- a/tests/test_nifti_saver.py +++ b/tests/test_nifti_saver.py @@ -17,6 +17,7 @@ import torch from monai.data import NiftiSaver +from monai.transforms import LoadImage class TestNiftiSaver(unittest.TestCase): @@ -72,6 +73,29 @@ def test_saved_3d_resize_content(self): filepath = os.path.join("testfile" + str(i), "testfile" + str(i) + "_seg.nii.gz") self.assertTrue(os.path.exists(os.path.join(tempdir, filepath))) + def test_squeeze_end_dims(self): + with tempfile.TemporaryDirectory() as tempdir: + + for squeeze_end_dims in [False, True]: + + saver = NiftiSaver( + output_dir=tempdir, + output_postfix="", + output_ext=".nii.gz", + dtype=np.float32, + squeeze_end_dims=squeeze_end_dims, + ) + + fname = "testfile_squeeze" + meta_data = {"filename_or_obj": fname} + + # 2d image w channel + saver.save(torch.randint(0, 255, (1, 2, 2)), meta_data) + + im, meta = LoadImage()(os.path.join(tempdir, fname, fname + ".nii.gz")) + self.assertTrue(im.ndim == 2 if squeeze_end_dims else 4) + self.assertTrue(meta["dim"][0] == im.ndim) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_normalize_intensity.py b/tests/test_normalize_intensity.py index ecf162e12f..dfb0de18fa 100644 --- a/tests/test_normalize_intensity.py +++ b/tests/test_normalize_intensity.py @@ -58,7 +58,7 @@ class TestNormalizeIntensity(NumpyImageTestCase2D): def test_default(self): normalizer = NormalizeIntensity() - normalized = normalizer(self.imt) + normalized = normalizer(self.imt.copy()) self.assertTrue(normalized.dtype == np.float32) expected = (self.imt - np.mean(self.imt)) / np.std(self.imt) np.testing.assert_allclose(normalized, expected, rtol=1e-5) diff --git a/tests/test_npzdictitemdataset.py b/tests/test_npzdictitemdataset.py new file mode 100644 index 0000000000..5ec52f45a2 --- /dev/null +++ b/tests/test_npzdictitemdataset.py @@ -0,0 +1,55 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import tempfile +import unittest +from io import BytesIO + +import numpy as np + +from monai.data import NPZDictItemDataset + + +class TestNPZDictItemDataset(unittest.TestCase): + def test_load_stream(self): + dat0 = np.random.rand(10, 1, 4, 4) + dat1 = np.random.rand(10, 1, 4, 4) + + npzfile = BytesIO() + npz = np.savez_compressed(npzfile, dat0=dat0, dat1=dat1) + npzfile.seek(0) + + npzds = NPZDictItemDataset(npzfile, {"dat0": "images", "dat1": "seg"}) + + item = npzds[0] + + np.testing.assert_allclose(item["images"].shape, (1, 4, 4)) + np.testing.assert_allclose(item["seg"].shape, (1, 4, 4)) + + def test_load_file(self): + dat0 = np.random.rand(10, 1, 4, 4) + dat1 = np.random.rand(10, 1, 4, 4) + + with tempfile.TemporaryDirectory() as tempdir: + npzfile = f"{tempdir}/test.npz" + + npz = np.savez_compressed(npzfile, dat0=dat0, dat1=dat1) + + npzds = NPZDictItemDataset(npzfile, {"dat0": "images", "dat1": "seg"}) + + item = npzds[0] + + np.testing.assert_allclose(item["images"].shape, (1, 4, 4)) + np.testing.assert_allclose(item["seg"].shape, (1, 4, 4)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_occlusion_sensitivity.py b/tests/test_occlusion_sensitivity.py index 47a13d01e1..d58359a598 100644 --- a/tests/test_occlusion_sensitivity.py +++ b/tests/test_occlusion_sensitivity.py @@ -14,13 +14,13 @@ import torch from parameterized import parameterized -from monai.networks.nets import DenseNet, densenet121 +from monai.networks.nets import DenseNet, DenseNet121 from monai.visualize import OcclusionSensitivity device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") out_channels_2d = 4 out_channels_3d = 3 -model_2d = densenet121(spatial_dims=2, in_channels=1, out_channels=out_channels_2d).to(device) +model_2d = DenseNet121(spatial_dims=2, in_channels=1, out_channels=out_channels_2d).to(device) model_3d = DenseNet( spatial_dims=3, in_channels=1, out_channels=out_channels_3d, init_features=2, growth_rate=2, block_config=(6,) ).to(device) diff --git a/tests/test_openslide_reader.py b/tests/test_openslide_reader.py new file mode 100644 index 0000000000..67a6683be3 --- /dev/null +++ b/tests/test_openslide_reader.py @@ -0,0 +1,105 @@ +import os +import unittest +from unittest import skipUnless +from urllib import request + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data.image_reader import WSIReader +from monai.utils import optional_import + +_, has_osl = optional_import("openslide") + + +FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" +HEIGHT = 32914 +WIDTH = 46000 + +TEST_CASE_0 = [FILE_URL, (3, HEIGHT, WIDTH)] + +TEST_CASE_1 = [ + FILE_URL, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), +] + +TEST_CASE_2 = [ + FILE_URL, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), +] + +TEST_CASE_3 = [ + FILE_URL, + { + "location": (0, 0), + "size": (8, 8), + "level": 2, + "grid_shape": (2, 1), + "patch_size": 2, + }, + np.array( + [ + [[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[239, 239], [239, 239]]], + [[[242, 242], [242, 243]], [[242, 242], [242, 243]], [[242, 242], [242, 243]]], + ] + ), +] + +TEST_CASE_4 = [ + FILE_URL, + { + "location": (0, 0), + "size": (8, 8), + "level": 2, + "grid_shape": (2, 1), + "patch_size": 1, + }, + np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), +] + + +def camelyon_data_download(file_url): + filename = os.path.basename(file_url) + fullname = os.path.join("tests", "testing_data", filename) + if not os.path.exists(fullname): + print(f"Test image [{fullname}] does not exist. Downloading...") + request.urlretrieve(file_url, fullname) + return fullname + + +class TestOpenSlideReader(unittest.TestCase): + @parameterized.expand([TEST_CASE_0]) + @skipUnless(has_osl, "Requires OpenSlide") + def test_read_whole_image(self, file_url, expected_shape): + filename = camelyon_data_download(file_url) + reader = WSIReader("OpenSlide") + img_obj = reader.read(filename) + img = reader.get_data(img_obj)[0] + self.assertTupleEqual(img.shape, expected_shape) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) + @skipUnless(has_osl, "Requires OpenSlide") + def test_read_region(self, file_url, patch_info, expected_img): + filename = camelyon_data_download(file_url) + reader = WSIReader("OpenSlide") + img_obj = reader.read(filename) + img = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + + @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) + @skipUnless(has_osl, "Requires OpenSlide") + def test_read_patches(self, file_url, patch_info, expected_img): + filename = camelyon_data_download(file_url) + reader = WSIReader("OpenSlide") + img_obj = reader.read(filename) + img = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pad_collation.py b/tests/test_pad_collation.py new file mode 100644 index 0000000000..3835dc8895 --- /dev/null +++ b/tests/test_pad_collation.py @@ -0,0 +1,100 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import unittest +from typing import List, Tuple + +import numpy as np +import torch +from parameterized import parameterized + +from monai.data import CacheDataset, DataLoader +from monai.data.utils import decollate_batch, pad_list_data_collate +from monai.transforms import ( + PadListDataCollate, + RandRotate, + RandRotate90, + RandRotate90d, + RandRotated, + RandSpatialCrop, + RandSpatialCropd, + RandZoom, + RandZoomd, +) +from monai.utils import set_determinism + +TESTS: List[Tuple] = [] + +for pad_collate in [pad_list_data_collate, PadListDataCollate()]: + 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))) + TESTS.append((dict, pad_collate, RandZoomd("image", prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) + TESTS.append((dict, pad_collate, RandRotate90d("image", prob=1, max_k=2))) + + TESTS.append((list, pad_collate, RandSpatialCrop(roi_size=[8, 7], random_size=True))) + TESTS.append((list, pad_collate, RandRotate(prob=1, range_x=np.pi, keep_size=False))) + TESTS.append((list, pad_collate, RandZoom(prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) + TESTS.append((list, pad_collate, RandRotate90(prob=1, max_k=2))) + + +class _Dataset(torch.utils.data.Dataset): + def __init__(self, images, labels, transforms): + self.images = images + self.labels = labels + self.transforms = transforms + + def __len__(self): + return len(self.images) + + def __getitem__(self, index): + return self.transforms(self.images[index]), self.labels[index] + + +class TestPadCollation(unittest.TestCase): + def setUp(self) -> None: + set_determinism(seed=0) + # image is non square to throw rotation errors + im = np.arange(0, 10 * 9).reshape(1, 10, 9) + num_elements = 20 + self.dict_data = [{"image": im} for _ in range(num_elements)] + self.list_data = [im for _ in range(num_elements)] + self.list_labels = [random.randint(0, 1) for _ in range(num_elements)] + + def tearDown(self) -> None: + set_determinism(None) + + @parameterized.expand(TESTS) + def test_pad_collation(self, t_type, collate_method, transform): + + if t_type == dict: + dataset = CacheDataset(self.dict_data, transform, progress=False) + else: + dataset = _Dataset(self.list_data, self.list_labels, transform) + + # Default collation should raise an error + loader_fail = DataLoader(dataset, batch_size=10) + with self.assertRaises(RuntimeError): + for _ in loader_fail: + pass + + # Padded collation shouldn't + loader = DataLoader(dataset, batch_size=10, collate_fn=collate_method) + # check collation in forward direction + for data in loader: + if t_type == dict: + decollated_data = decollate_batch(data) + for d in decollated_data: + PadListDataCollate.inverse(d) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index 54d71ad8f7..ae2adbe3b3 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -145,6 +145,8 @@ def test_rand_affined(self, input_param, input_data, expected_val): res = g(input_data) for key in res: result = res[key] + if "_transforms" in key: + continue expected = expected_val[key] if isinstance(expected_val, dict) else expected_val self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected, torch.Tensor)) if isinstance(result, torch.Tensor): diff --git a/tests/test_rand_axis_flip.py b/tests/test_rand_axis_flip.py new file mode 100644 index 0000000000..0bc2eb130e --- /dev/null +++ b/tests/test_rand_axis_flip.py @@ -0,0 +1,32 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import RandAxisFlip +from tests.utils import NumpyImageTestCase2D + + +class TestRandAxisFlip(NumpyImageTestCase2D): + def test_correct_results(self): + flip = RandAxisFlip(prob=1.0) + result = flip(self.imt[0]) + + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, flip._axis)) + self.assertTrue(np.allclose(np.stack(expected), result)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_axis_flipd.py b/tests/test_rand_axis_flipd.py new file mode 100644 index 0000000000..154d7813cb --- /dev/null +++ b/tests/test_rand_axis_flipd.py @@ -0,0 +1,32 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import RandAxisFlipd +from tests.utils import NumpyImageTestCase3D + + +class TestRandAxisFlip(NumpyImageTestCase3D): + def test_correct_results(self): + flip = RandAxisFlipd(keys="img", prob=1.0) + result = flip({"img": self.imt[0]}) + + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, flip._axis)) + self.assertTrue(np.allclose(np.stack(expected), result["img"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_lambdad.py b/tests/test_rand_lambdad.py index 359da8857a..2ddfeefae0 100644 --- a/tests/test_rand_lambdad.py +++ b/tests/test_rand_lambdad.py @@ -13,11 +13,11 @@ import numpy as np -from monai.transforms import Randomizable +from monai.transforms.transform import RandomizableTransform from monai.transforms.utility.dictionary import RandLambdad -class RandTest(Randomizable): +class RandTest(RandomizableTransform): """ randomisable transform for testing. """ diff --git a/tests/test_rand_rotate.py b/tests/test_rand_rotate.py index 79f3036454..0ff8508a0f 100644 --- a/tests/test_rand_rotate.py +++ b/tests/test_rand_rotate.py @@ -52,7 +52,8 @@ def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_cor self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(np.float32) - np.testing.assert_allclose(expected, rotated[0]) + good = np.sum(np.isclose(expected, rotated[0], atol=1e-3)) + self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 pixels") class TestRandRotate3D(NumpyImageTestCase3D): diff --git a/tests/test_rand_rotated.py b/tests/test_rand_rotated.py index 962ac5fc51..47b4b7107e 100644 --- a/tests/test_rand_rotated.py +++ b/tests/test_rand_rotated.py @@ -54,7 +54,8 @@ def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_cor self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(np.float32) - self.assertTrue(np.allclose(expected, rotated["img"][0])) + good = np.sum(np.isclose(expected, rotated["img"][0], atol=1e-3)) + self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 pixels") class TestRandRotated3D(NumpyImageTestCase3D): diff --git a/tests/test_rand_std_shift_intensity.py b/tests/test_rand_std_shift_intensity.py new file mode 100644 index 0000000000..9aff50ab66 --- /dev/null +++ b/tests/test_rand_std_shift_intensity.py @@ -0,0 +1,32 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import RandStdShiftIntensity +from tests.utils import NumpyImageTestCase2D + + +class TestRandStdShiftIntensity(NumpyImageTestCase2D): + def test_value(self): + shifter = RandStdShiftIntensity(factors=1.0, prob=1.0) + shifter.set_random_state(seed=0) + result = shifter(self.imt) + np.random.seed(0) + factor = np.random.uniform(low=-1.0, high=1.0) + expected = self.imt + factor * np.std(self.imt) + np.testing.assert_allclose(result, expected, rtol=1e-5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_std_shift_intensityd.py b/tests/test_rand_std_shift_intensityd.py new file mode 100644 index 0000000000..0cb6bd66be --- /dev/null +++ b/tests/test_rand_std_shift_intensityd.py @@ -0,0 +1,33 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import RandStdShiftIntensityd +from tests.utils import NumpyImageTestCase2D + + +class TestRandStdShiftIntensityd(NumpyImageTestCase2D): + def test_value(self): + key = "img" + shifter = RandStdShiftIntensityd(keys=[key], factors=1.0, prob=1.0) + shifter.set_random_state(seed=0) + result = shifter({key: self.imt}) + np.random.seed(0) + factor = np.random.uniform(low=-1.0, high=1.0) + expected = self.imt + factor * np.std(self.imt) + np.testing.assert_allclose(result[key], expected, rtol=1e-5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_randomizable.py b/tests/test_randomizable.py index a7a30124df..9972bded0f 100644 --- a/tests/test_randomizable.py +++ b/tests/test_randomizable.py @@ -13,7 +13,7 @@ import numpy as np -from monai.transforms import Randomizable +from monai.transforms.transform import Randomizable class RandTest(Randomizable): diff --git a/tests/test_regunet.py b/tests/test_regunet.py new file mode 100644 index 0000000000..4dd968a1cf --- /dev/null +++ b/tests/test_regunet.py @@ -0,0 +1,87 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.networks import eval_mode +from monai.networks.nets.regunet import RegUNet +from tests.utils import test_script_save + +device = "cuda" if torch.cuda.is_available() else "cpu" + + +TEST_CASE_REGUNET_2D = [ + [ + { + "spatial_dims": 2, + "in_channels": 2, + "num_channel_initial": 16, + "depth": 3, + "out_kernel_initializer": "kaiming_uniform", + "out_activation": None, + "out_channels": 2, + "pooling": False, + "concat_skip": True, + "encode_kernel_sizes": 3, + }, + (1, 2, 16, 16), + (1, 2, 16, 16), + ] +] + +TEST_CASE_REGUNET_3D = [ + [ + { + "spatial_dims": 3, + "in_channels": 2, + "num_channel_initial": 16, + "depth": 3, + "out_kernel_initializer": "kaiming_uniform", + "out_activation": "sigmoid", + "out_channels": 2, + "extract_levels": (0, 1, 2, 3), + "pooling": True, + "concat_skip": False, + "encode_kernel_sizes": (3, 3, 3, 7), + }, + (1, 2, 16, 16, 16), + (1, 2, 16, 16, 16), + ] +] + + +class TestREGUNET(unittest.TestCase): + @parameterized.expand(TEST_CASE_REGUNET_2D + TEST_CASE_REGUNET_3D) + def test_shape(self, input_param, input_shape, expected_shape): + net = RegUNet(**input_param).to(device) + with eval_mode(net): + result = net(torch.randn(input_shape).to(device)) + self.assertEqual(result.shape, expected_shape) + + def test_ill_shape(self): + with self.assertRaisesRegex(ValueError, ""): + input_param, _, _ = TEST_CASE_REGUNET_2D[0] + input_shape = (1, input_param["in_channels"], 17, 17) + net = RegUNet(**input_param).to(device) + net.forward(torch.randn(input_shape).to(device)) + + def test_script(self): + input_param, input_shape, _ = TEST_CASE_REGUNET_2D[0] + net = RegUNet(**input_param) + test_data = torch.randn(input_shape) + test_script_save(net, test_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_regunet_block.py b/tests/test_regunet_block.py new file mode 100644 index 0000000000..9b96875432 --- /dev/null +++ b/tests/test_regunet_block.py @@ -0,0 +1,97 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import torch +from parameterized import parameterized + +from monai.networks import eval_mode +from monai.networks.blocks.regunet_block import ( + RegistrationDownSampleBlock, + RegistrationExtractionBlock, + RegistrationResidualConvBlock, +) + +TEST_CASE_RESIDUAL = [ + [{"spatial_dims": 2, "in_channels": 1, "out_channels": 2, "num_layers": 1}, (1, 1, 5, 5), (1, 2, 5, 5)], + [{"spatial_dims": 3, "in_channels": 2, "out_channels": 2, "num_layers": 2}, (1, 2, 5, 5, 5), (1, 2, 5, 5, 5)], +] + +TEST_CASE_DOWN_SAMPLE = [ + [{"spatial_dims": 2, "channels": 1, "pooling": False}, (1, 1, 4, 4), (1, 1, 2, 2)], + [{"spatial_dims": 3, "channels": 2, "pooling": True}, (1, 2, 4, 4, 4), (1, 2, 2, 2, 2)], +] + +TEST_CASE_EXTRACTION = [ + [ + { + "spatial_dims": 2, + "extract_levels": (0,), + "num_channels": [1], + "out_channels": 1, + "kernel_initializer": "kaiming_uniform", + "activation": None, + }, + [(1, 1, 2, 2)], + (3, 3), + (1, 1, 3, 3), + ], + [ + { + "spatial_dims": 3, + "extract_levels": (1, 2), + "num_channels": [1, 2, 3], + "out_channels": 1, + "kernel_initializer": "zeros", + "activation": "sigmoid", + }, + [(1, 3, 2, 2, 2), (1, 2, 4, 4, 4), (1, 1, 8, 8, 8)], + (3, 3, 3), + (1, 1, 3, 3, 3), + ], +] + + +class TestRegistrationResidualConvBlock(unittest.TestCase): + @parameterized.expand(TEST_CASE_RESIDUAL) + def test_shape(self, input_param, input_shape, expected_shape): + net = RegistrationResidualConvBlock(**input_param) + with eval_mode(net): + x = net(torch.randn(input_shape)) + self.assertEqual(x.shape, expected_shape) + + +class TestRegistrationDownSampleBlock(unittest.TestCase): + @parameterized.expand(TEST_CASE_DOWN_SAMPLE) + def test_shape(self, input_param, input_shape, expected_shape): + net = RegistrationDownSampleBlock(**input_param) + with eval_mode(net): + x = net(torch.rand(input_shape)) + self.assertEqual(x.shape, expected_shape) + + def test_ill_shape(self): + net = RegistrationDownSampleBlock(spatial_dims=2, channels=2, pooling=True) + with self.assertRaises(ValueError): + net(torch.rand((1, 2, 3, 3))) + + +class TestRegistrationExtractionBlock(unittest.TestCase): + @parameterized.expand(TEST_CASE_EXTRACTION) + def test_shape(self, input_param, input_shapes, image_size, expected_shape): + net = RegistrationExtractionBlock(**input_param) + with eval_mode(net): + x = net([torch.rand(input_shape) for input_shape in input_shapes], image_size) + self.assertEqual(x.shape, expected_shape) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_remove_repeated_channel.py b/tests/test_remove_repeated_channel.py new file mode 100644 index 0000000000..070e0e2b8d --- /dev/null +++ b/tests/test_remove_repeated_channel.py @@ -0,0 +1,30 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RemoveRepeatedChannel + +TEST_CASE_1 = [{"repeats": 2}, np.array([[1, 2], [1, 2], [3, 4], [3, 4]]), (2, 2)] + + +class TestRemoveRepeatedChannel(unittest.TestCase): + @parameterized.expand([TEST_CASE_1]) + def test_shape(self, input_param, input_data, expected_shape): + result = RemoveRepeatedChannel(**input_param)(input_data) + self.assertEqual(result.shape, expected_shape) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_remove_repeated_channeld.py b/tests/test_remove_repeated_channeld.py new file mode 100644 index 0000000000..46c68bbdc2 --- /dev/null +++ b/tests/test_remove_repeated_channeld.py @@ -0,0 +1,34 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RemoveRepeatedChanneld + +TEST_CASE_1 = [ + {"keys": ["img"], "repeats": 2}, + {"img": np.array([[1, 2], [1, 2], [3, 4], [3, 4]]), "seg": np.array([[1, 2], [1, 2], [3, 4], [3, 4]])}, + (2, 2), +] + + +class TestRemoveRepeatedChanneld(unittest.TestCase): + @parameterized.expand([TEST_CASE_1]) + def test_shape(self, input_param, input_data, expected_shape): + result = RemoveRepeatedChanneld(**input_param)(input_data) + self.assertEqual(result["img"].shape, expected_shape) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rotate.py b/tests/test_rotate.py index 6e43ab90e7..436c952d4b 100644 --- a/tests/test_rotate.py +++ b/tests/test_rotate.py @@ -70,7 +70,8 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne ) ) expected = np.stack(expected).astype(np.float32) - np.testing.assert_allclose(expected, rotated, atol=1e-1) + good = np.sum(np.isclose(expected, rotated, atol=1e-3)) + self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 pixels") class TestRotate3D(NumpyImageTestCase3D): @@ -102,7 +103,8 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne ) ) expected = np.stack(expected).astype(np.float32) - np.testing.assert_allclose(expected, rotated, atol=1e-1) + n_good = np.sum(np.isclose(expected, rotated, atol=1e-3)) + self.assertLessEqual(expected.size - n_good, 5, "diff at most 5 pixels") @parameterized.expand(TEST_CASES_SHAPE_3D) def test_correct_shape(self, angle, mode, padding_mode, align_corners): diff --git a/tests/test_rotated.py b/tests/test_rotated.py index 3353ae9fba..dd57786fff 100644 --- a/tests/test_rotated.py +++ b/tests/test_rotated.py @@ -52,13 +52,14 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne expected = scipy.ndimage.rotate( self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False ) - np.testing.assert_allclose(expected, rotated["img"][0], atol=1e-3) + good = np.sum(np.isclose(expected, rotated["img"][0], atol=1e-3)) + self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 pixels") expected = scipy.ndimage.rotate( self.segn[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=0, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(int) - self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 20) + self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 30) class TestRotated3D(NumpyImageTestCase3D): @@ -78,13 +79,14 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne expected = scipy.ndimage.rotate( self.imt[0, 0], np.rad2deg(angle), (0, 2), not keep_size, order=_order, mode=_mode, prefilter=False ) - np.testing.assert_allclose(expected.astype(np.float32), rotated["img"][0], atol=1e-3) + good = np.sum(np.isclose(expected.astype(np.float32), rotated["img"][0], atol=1e-3)) + self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 voxels.") expected = scipy.ndimage.rotate( self.segn[0, 0], np.rad2deg(angle), (0, 2), not keep_size, order=0, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(int) - self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 105) + self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 110) class TestRotated3DXY(NumpyImageTestCase3D): @@ -104,13 +106,14 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne expected = scipy.ndimage.rotate( self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False ) - np.testing.assert_allclose(expected, rotated["img"][0], atol=1e-3) + good = np.sum(np.isclose(expected, rotated["img"][0], atol=1e-3)) + self.assertLessEqual(np.abs(good - expected.size), 5, "diff at most 5 voxels") expected = scipy.ndimage.rotate( self.segn[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=0, mode=_mode, prefilter=False ) expected = np.stack(expected).astype(int) - self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 100) + self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 110) if __name__ == "__main__": diff --git a/tests/test_seg_loss_integration.py b/tests/test_seg_loss_integration.py index 2103119342..d2f991f160 100644 --- a/tests/test_seg_loss_integration.py +++ b/tests/test_seg_loss_integration.py @@ -18,23 +18,29 @@ from parameterized import parameterized from monai.losses import DiceLoss, FocalLoss, GeneralizedDiceLoss, TverskyLoss +from monai.networks import one_hot TEST_CASES = [ [DiceLoss, {"to_onehot_y": True, "squared_pred": True, "smooth_nr": 1e-4, "smooth_dr": 1e-4}, {}], [DiceLoss, {"to_onehot_y": True, "squared_pred": True, "smooth_nr": 0, "smooth_dr": 1e-3}, {}], + [DiceLoss, {"to_onehot_y": False, "squared_pred": True, "smooth_nr": 0, "smooth_dr": 1e-3}, {}], [DiceLoss, {"to_onehot_y": True, "squared_pred": True, "batch": True}, {}], [DiceLoss, {"to_onehot_y": True, "sigmoid": True}, {}], [DiceLoss, {"to_onehot_y": True, "softmax": True}, {}], - [FocalLoss, {"gamma": 1.5, "weight": torch.tensor([1, 2])}, {}], - [FocalLoss, {"gamma": 1.5}, {}], + [FocalLoss, {"to_onehot_y": True, "gamma": 1.5, "weight": torch.tensor([1, 2])}, {}], + [FocalLoss, {"to_onehot_y": False, "gamma": 1.5, "weight": [1, 2]}, {}], + [FocalLoss, {"to_onehot_y": False, "gamma": 1.5, "weight": 1.0}, {}], + [FocalLoss, {"to_onehot_y": True, "gamma": 1.5}, {}], [GeneralizedDiceLoss, {"to_onehot_y": True, "softmax": True}, {}], [GeneralizedDiceLoss, {"to_onehot_y": True, "sigmoid": True}, {}], [GeneralizedDiceLoss, {"to_onehot_y": True, "sigmoid": True, "w_type": "simple"}, {}], [GeneralizedDiceLoss, {"to_onehot_y": True, "sigmoid": True, "w_type": "uniform"}, {}], [GeneralizedDiceLoss, {"to_onehot_y": True, "sigmoid": True, "w_type": "uniform", "batch": True}, {}], + [GeneralizedDiceLoss, {"to_onehot_y": False, "sigmoid": True, "w_type": "uniform", "batch": True}, {}], [TverskyLoss, {"to_onehot_y": True, "softmax": True, "alpha": 0.8, "beta": 0.2}, {}], [TverskyLoss, {"to_onehot_y": True, "softmax": True, "alpha": 0.8, "beta": 0.2, "batch": True}, {}], [TverskyLoss, {"to_onehot_y": True, "softmax": True, "alpha": 1.0, "beta": 0.0}, {}], + [TverskyLoss, {"to_onehot_y": False, "softmax": True, "alpha": 1.0, "beta": 0.0}, {}], ] @@ -80,6 +86,8 @@ def test_convergence(self, loss_type, loss_args, forward_args): num_classes = 2 num_voxels = 3 * 4 * 4 + target_onehot = one_hot(target_seg, num_classes=num_classes) + # define a one layer model class OnelayerNet(nn.Module): def __init__(self): @@ -118,7 +126,10 @@ def forward(self, x): if init_output is None: init_output = torch.argmax(output, 1).detach().cpu().numpy() - loss_val = loss(output, target_seg, **forward_args) + if loss_args["to_onehot_y"] is False: + loss_val = loss(output, target_onehot, **forward_args) + else: + loss_val = loss(output, target_seg, **forward_args) if iter_i % 10 == 0: pred = torch.argmax(output, 1).detach().cpu().numpy() diff --git a/tests/test_senet.py b/tests/test_senet.py index 883d75d62d..1c6222d6a0 100644 --- a/tests/test_senet.py +++ b/tests/test_senet.py @@ -10,32 +10,36 @@ # limitations under the License. import unittest +from typing import TYPE_CHECKING +from unittest import skipUnless import torch from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.nets import ( - se_resnet50, - se_resnet101, - se_resnet152, - se_resnext50_32x4d, - se_resnext101_32x4d, - senet154, -) +from monai.networks.nets import SENet154, SEResNet50, SEResNet101, SEResNet152, SEResNext50, SEResNext101 +from monai.utils import optional_import from tests.utils import test_pretrained_networks, test_script_save +if TYPE_CHECKING: + import pretrainedmodels + + has_cadene_pretrain = True +else: + pretrainedmodels, has_cadene_pretrain = optional_import("pretrainedmodels") + + device = "cuda" if torch.cuda.is_available() else "cpu" NET_ARGS = {"spatial_dims": 3, "in_channels": 2, "num_classes": 2} -TEST_CASE_1 = [senet154, NET_ARGS] -TEST_CASE_2 = [se_resnet50, NET_ARGS] -TEST_CASE_3 = [se_resnet101, NET_ARGS] -TEST_CASE_4 = [se_resnet152, NET_ARGS] -TEST_CASE_5 = [se_resnext50_32x4d, NET_ARGS] -TEST_CASE_6 = [se_resnext101_32x4d, NET_ARGS] +TEST_CASE_1 = [SENet154, NET_ARGS] +TEST_CASE_2 = [SEResNet50, NET_ARGS] +TEST_CASE_3 = [SEResNet101, NET_ARGS] +TEST_CASE_4 = [SEResNet152, NET_ARGS] +TEST_CASE_5 = [SEResNext50, NET_ARGS] +TEST_CASE_6 = [SEResNext101, NET_ARGS] -TEST_CASE_PRETRAINED = [se_resnet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}] +TEST_CASE_PRETRAINED_1 = [SEResNet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}] class TestSENET(unittest.TestCase): @@ -56,11 +60,7 @@ def test_script(self, net, net_args): class TestPretrainedSENET(unittest.TestCase): - @parameterized.expand( - [ - TEST_CASE_PRETRAINED, - ] - ) + @parameterized.expand([TEST_CASE_PRETRAINED_1]) def test_senet_shape(self, model, input_param): net = test_pretrained_networks(model, input_param, device) input_data = torch.randn(3, 3, 64, 64).to(device) @@ -70,6 +70,21 @@ def test_senet_shape(self, model, input_param): result = net(input_data) self.assertEqual(result.shape, expected_shape) + @parameterized.expand([TEST_CASE_PRETRAINED_1]) + @skipUnless(has_cadene_pretrain, "Requires `pretrainedmodels` package.") + def test_pretrain_consistency(self, model, input_param): + input_data = torch.randn(1, 3, 64, 64).to(device) + net = test_pretrained_networks(model, input_param, device) + with eval_mode(net): + result = net.features(input_data) + cadene_net = pretrainedmodels.se_resnet50().to(device) + with eval_mode(cadene_net): + expected_result = cadene_net.features(input_data) + # The difference between Cadene's senet and our version is that + # we use nn.Linear as the FC layer, but Cadene's version uses + # a conv layer with kernel size equals to 1. It may bring a little difference. + self.assertTrue(torch.allclose(result, expected_result, rtol=1e-5, atol=1e-5)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_smartcachedataset.py b/tests/test_smartcachedataset.py index 3d1a051a83..7ebb2858d2 100644 --- a/tests/test_smartcachedataset.py +++ b/tests/test_smartcachedataset.py @@ -24,13 +24,15 @@ TEST_CASE_2 = [0.1, 4, Compose([LoadImaged(keys=["image", "label", "extra"])])] -TEST_CASE_3 = [0.1, 4, None] +TEST_CASE_3 = [0.1, None, Compose([LoadImaged(keys=["image", "label", "extra"])])] -TEST_CASE_4 = [0.5, 2, Compose([LoadImaged(keys=["image", "label", "extra"])])] +TEST_CASE_4 = [0.1, 4, None] + +TEST_CASE_5 = [0.5, 2, Compose([LoadImaged(keys=["image", "label", "extra"])])] class TestSmartCacheDataset(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) def test_shape(self, replace_rate, num_replace_workers, transform): test_image = nib.Nifti1Image(np.random.randint(0, 2, size=[8, 8, 8]), np.eye(4)) with tempfile.TemporaryDirectory() as tempdir: diff --git a/tests/test_spacingd.py b/tests/test_spacingd.py index ec32563543..e4efe4241d 100644 --- a/tests/test_spacingd.py +++ b/tests/test_spacingd.py @@ -21,7 +21,7 @@ def test_spacingd_3d(self): data = {"image": np.ones((2, 10, 15, 20)), "image_meta_dict": {"affine": np.eye(4)}} spacing = Spacingd(keys="image", pixdim=(1, 2, 1.4)) res = spacing(data) - self.assertEqual(("image", "image_meta_dict"), tuple(sorted(res))) + self.assertEqual(("image", "image_meta_dict", "image_transforms"), tuple(sorted(res))) np.testing.assert_allclose(res["image"].shape, (2, 10, 8, 15)) np.testing.assert_allclose(res["image_meta_dict"]["affine"], np.diag([1, 2, 1.4, 1.0])) @@ -29,7 +29,7 @@ def test_spacingd_2d(self): data = {"image": np.ones((2, 10, 20)), "image_meta_dict": {"affine": np.eye(3)}} spacing = Spacingd(keys="image", pixdim=(1, 2, 1.4)) res = spacing(data) - self.assertEqual(("image", "image_meta_dict"), tuple(sorted(res))) + self.assertEqual(("image", "image_meta_dict", "image_transforms"), tuple(sorted(res))) np.testing.assert_allclose(res["image"].shape, (2, 10, 10)) np.testing.assert_allclose(res["image_meta_dict"]["affine"], np.diag((1, 2, 1))) @@ -49,7 +49,10 @@ def test_interp_all(self): ), ) res = spacing(data) - self.assertEqual(("image", "image_meta_dict", "seg", "seg_meta_dict"), tuple(sorted(res))) + self.assertEqual( + ("image", "image_meta_dict", "image_transforms", "seg", "seg_meta_dict", "seg_transforms"), + tuple(sorted(res)), + ) np.testing.assert_allclose(res["image"].shape, (2, 1, 46)) np.testing.assert_allclose(res["image_meta_dict"]["affine"], np.diag((1, 0.2, 1, 1))) @@ -69,7 +72,10 @@ def test_interp_sep(self): ), ) res = spacing(data) - self.assertEqual(("image", "image_meta_dict", "seg", "seg_meta_dict"), tuple(sorted(res))) + self.assertEqual( + ("image", "image_meta_dict", "image_transforms", "seg", "seg_meta_dict", "seg_transforms"), + tuple(sorted(res)), + ) np.testing.assert_allclose(res["image"].shape, (2, 1, 46)) np.testing.assert_allclose(res["image_meta_dict"]["affine"], np.diag((1, 0.2, 1, 1))) diff --git a/tests/test_std_shift_intensity.py b/tests/test_std_shift_intensity.py new file mode 100644 index 0000000000..a0a3b3ff0f --- /dev/null +++ b/tests/test_std_shift_intensity.py @@ -0,0 +1,57 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import ShiftIntensity, StdShiftIntensity +from tests.utils import NumpyImageTestCase2D + + +class TestStdShiftIntensity(NumpyImageTestCase2D): + def test_value(self): + factor = np.random.rand() + offset = np.std(self.imt) * factor + shifter = ShiftIntensity(offset=offset) + expected = shifter(self.imt) + std_shifter = StdShiftIntensity(factor=factor) + result = std_shifter(self.imt) + np.testing.assert_allclose(result, expected, rtol=1e-5) + + def test_zerostd(self): + image = np.ones([2, 3, 3]) + for nonzero in [True, False]: + for channel_wise in [True, False]: + factor = np.random.rand() + std_shifter = StdShiftIntensity(factor=factor, nonzero=nonzero, channel_wise=channel_wise) + result = std_shifter(image) + np.testing.assert_equal(result, image) + + def test_nonzero(self): + image = np.asarray([[4.0, 0.0, 2.0], [0, 2, 4]]) # std = 1 + factor = np.random.rand() + std_shifter = StdShiftIntensity(factor=factor, nonzero=True) + result = std_shifter(image) + expected = np.asarray([[4 + factor, 0, 2 + factor], [0, 2 + factor, 4 + factor]]) + np.testing.assert_equal(result, expected) + + def test_channel_wise(self): + image = np.stack((np.asarray([1.0, 2.0]), np.asarray([1.0, 1.0]))) # std: 0.5, 0 + factor = np.random.rand() + std_shifter = StdShiftIntensity(factor=factor, channel_wise=True) + result = std_shifter(image) + expected = np.stack((np.asarray([1 + 0.5 * factor, 2 + 0.5 * factor]), np.asarray([1, 1]))) + np.testing.assert_equal(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_std_shift_intensityd.py b/tests/test_std_shift_intensityd.py new file mode 100644 index 0000000000..f5c2dd650c --- /dev/null +++ b/tests/test_std_shift_intensityd.py @@ -0,0 +1,61 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import ShiftIntensityd, StdShiftIntensityd +from tests.utils import NumpyImageTestCase2D + + +class TestStdShiftIntensityd(NumpyImageTestCase2D): + def test_value(self): + key = "img" + factor = np.random.rand() + offset = np.std(self.imt) * factor + shifter = ShiftIntensityd(keys=[key], offset=offset) + expected = shifter({key: self.imt}) + std_shifter = StdShiftIntensityd(keys=[key], factor=factor) + result = std_shifter({key: self.imt}) + np.testing.assert_allclose(result[key], expected[key], rtol=1e-5) + + def test_zerostd(self): + key = "img" + image = np.ones([2, 3, 3]) + for nonzero in [True, False]: + for channel_wise in [True, False]: + factor = np.random.rand() + std_shifter = StdShiftIntensityd(keys=[key], factor=factor, nonzero=nonzero, channel_wise=channel_wise) + result = std_shifter({key: image}) + np.testing.assert_equal(result[key], image) + + def test_nonzero(self): + key = "img" + image = np.asarray([[4.0, 0.0, 2.0], [0, 2, 4]]) # std = 1 + factor = np.random.rand() + std_shifter = StdShiftIntensityd(keys=[key], factor=factor, nonzero=True) + result = std_shifter({key: image}) + expected = np.asarray([[4 + factor, 0, 2 + factor], [0, 2 + factor, 4 + factor]]) + np.testing.assert_equal(result[key], expected) + + def test_channel_wise(self): + key = "img" + image = np.stack((np.asarray([1.0, 2.0]), np.asarray([1.0, 1.0]))) # std: 0.5, 0 + factor = np.random.rand() + std_shifter = StdShiftIntensityd(keys=[key], factor=factor, channel_wise=True) + result = std_shifter({key: image}) + expected = np.stack((np.asarray([1 + 0.5 * factor, 2 + 0.5 * factor]), np.asarray([1, 1]))) + np.testing.assert_equal(result[key], expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_thread_buffer.py b/tests/test_thread_buffer.py index 07e5a779ca..1b3ebb910d 100644 --- a/tests/test_thread_buffer.py +++ b/tests/test_thread_buffer.py @@ -9,10 +9,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys import time import unittest -from monai.data import DataLoader, Dataset, ThreadBuffer +from monai.data import DataLoader, Dataset, ThreadBuffer, ThreadDataLoader from monai.transforms import Compose, SimulateDelayd from monai.utils import PerfContext @@ -40,6 +41,16 @@ def test_values(self): self.assertEqual(d["label"][0], "spleen_label_19.nii.gz") self.assertEqual(d["label"][1], "spleen_label_31.nii.gz") + def test_dataloader(self): + dataset = Dataset(data=self.datalist, transform=self.transform) + dataloader = ThreadDataLoader(dataset=dataset, batch_size=2, num_workers=0) + + for d in dataloader: + self.assertEqual(d["image"][0], "spleen_19.nii.gz") + self.assertEqual(d["image"][1], "spleen_31.nii.gz") + self.assertEqual(d["label"][0], "spleen_label_19.nii.gz") + self.assertEqual(d["label"][1], "spleen_label_31.nii.gz") + def test_time(self): dataset = Dataset(data=self.datalist * 2, transform=self.transform) # contains data for 2 batches dataloader = DataLoader(dataset=dataset, batch_size=2, num_workers=0) @@ -57,11 +68,13 @@ def test_time(self): time.sleep(0.5) # while "computation" is happening the next batch is being generated, saving 0.4 s buffered_time = pc.total_time - - self.assertTrue( - buffered_time < unbuffered_time, - f"Buffered time {buffered_time} should be less than unbuffered time {unbuffered_time}", - ) + if sys.platform == "darwin": # skip macOS measure + print(f"darwin: Buffered time {buffered_time} vs unbuffered time {unbuffered_time}") + else: + self.assertTrue( + buffered_time < unbuffered_time, + f"Buffered time {buffered_time} should be less than unbuffered time {unbuffered_time}", + ) if __name__ == "__main__": diff --git a/tests/test_threadcontainer.py b/tests/test_threadcontainer.py new file mode 100644 index 0000000000..13608e166c --- /dev/null +++ b/tests/test_threadcontainer.py @@ -0,0 +1,59 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +import unittest + +import torch + +from monai.utils import optional_import +from monai.utils.enums import CommonKeys + +try: + _, has_ignite = optional_import("ignite") + + from monai.engines import SupervisedTrainer + from monai.utils import ThreadContainer +except ImportError: + has_ignite = False + +from monai.data import DataLoader + + +class TestThreadContainer(unittest.TestCase): + @unittest.skipIf(not has_ignite, "Ignite needed for this test") + def test_container(self): + net = torch.nn.Conv2d(1, 1, 3, padding=1) + + opt = torch.optim.Adam(net.parameters()) + + img = torch.rand(1, 16, 16) + data = {CommonKeys.IMAGE: img, CommonKeys.LABEL: img} + loader = DataLoader([data for _ in range(10)]) + + trainer = SupervisedTrainer( + device=torch.device("cpu"), + max_epochs=1, + train_data_loader=loader, + network=net, + optimizer=opt, + loss_function=torch.nn.L1Loss(), + ) + + con = ThreadContainer(trainer) + con.start() + time.sleep(1) # wait for trainer to start + + self.assertTrue(con.is_alive) + self.assertIsNotNone(con.status()) + self.assertTrue(len(con.status_dict) > 0) + + con.join() diff --git a/tests/test_timedcall.py b/tests/test_timedcall.py index e87d160743..de10abb8f7 100644 --- a/tests/test_timedcall.py +++ b/tests/test_timedcall.py @@ -10,13 +10,14 @@ # limitations under the License. import multiprocessing +import sys import time import unittest from tests.utils import TimedCall -@TimedCall(seconds=10, force_quit=False) +@TimedCall(seconds=10 if sys.platform == "linux" else 60, force_quit=False) def case_1_seconds(arg=None): time.sleep(1) return "good" if not arg else arg diff --git a/tests/test_to_pil.py b/tests/test_to_pil.py new file mode 100644 index 0000000000..ec63750ce4 --- /dev/null +++ b/tests/test_to_pil.py @@ -0,0 +1,64 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from typing import TYPE_CHECKING +from unittest import skipUnless + +import numpy as np +import torch +from parameterized import parameterized + +from monai.transforms import ToPIL +from monai.utils import optional_import + +if TYPE_CHECKING: + from PIL.Image import Image as PILImageImage + from PIL.Image import fromarray as pil_image_fromarray + + has_pil = True +else: + pil_image_fromarray, has_pil = optional_import("PIL.Image", name="fromarray") + PILImageImage, _ = optional_import("PIL.Image", name="Image") + +TEST_CASE_ARRAY_1 = [np.array([[1.0, 2.0], [3.0, 4.0]])] +TEST_CASE_TENSOR_1 = [torch.tensor([[1.0, 2.0], [3.0, 4.0]])] + + +class TestToPIL(unittest.TestCase): + @parameterized.expand([TEST_CASE_ARRAY_1]) + @skipUnless(has_pil, "Requires `pillow` package.") + def test_numpy_input(self, test_data): + self.assertTrue(isinstance(test_data, np.ndarray)) + result = ToPIL()(test_data) + self.assertTrue(isinstance(result, PILImageImage)) + np.testing.assert_allclose(np.array(result), test_data) + + @parameterized.expand([TEST_CASE_TENSOR_1]) + @skipUnless(has_pil, "Requires `pillow` package.") + def test_tensor_input(self, test_data): + self.assertTrue(isinstance(test_data, torch.Tensor)) + result = ToPIL()(test_data) + self.assertTrue(isinstance(result, PILImageImage)) + np.testing.assert_allclose(np.array(result), test_data.numpy()) + + @parameterized.expand([TEST_CASE_ARRAY_1]) + @skipUnless(has_pil, "Requires `pillow` package.") + def test_pil_input(self, test_data): + test_data_pil = pil_image_fromarray(test_data) + self.assertTrue(isinstance(test_data_pil, PILImageImage)) + result = ToPIL()(test_data_pil) + self.assertTrue(isinstance(result, PILImageImage)) + np.testing.assert_allclose(np.array(result), test_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_to_pild.py b/tests/test_to_pild.py new file mode 100644 index 0000000000..43778022ee --- /dev/null +++ b/tests/test_to_pild.py @@ -0,0 +1,65 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from typing import TYPE_CHECKING +from unittest import skipUnless + +import numpy as np +import torch +from parameterized import parameterized + +from monai.transforms import ToPILd +from monai.utils import optional_import + +if TYPE_CHECKING: + from PIL.Image import Image as PILImageImage + from PIL.Image import fromarray as pil_image_fromarray + + has_pil = True +else: + pil_image_fromarray, has_pil = optional_import("PIL.Image", name="fromarray") + PILImageImage, _ = optional_import("PIL.Image", name="Image") + +TEST_CASE_ARRAY_1 = [{"keys": "image"}, {"image": np.array([[1.0, 2.0], [3.0, 4.0]])}] +TEST_CASE__TENSOR_1 = [{"keys": "image"}, {"image": torch.tensor([[1.0, 2.0], [3.0, 4.0]])}] + + +class TestToPIL(unittest.TestCase): + @parameterized.expand([TEST_CASE_ARRAY_1]) + @skipUnless(has_pil, "Requires `pillow` package.") + def test_numpy_input(self, input_param, test_data): + self.assertTrue(isinstance(test_data[input_param["keys"]], np.ndarray)) + result = ToPILd(**input_param)(test_data)[input_param["keys"]] + self.assertTrue(isinstance(result, PILImageImage)) + np.testing.assert_allclose(np.array(result), test_data[input_param["keys"]]) + + @parameterized.expand([TEST_CASE__TENSOR_1]) + @skipUnless(has_pil, "Requires `pillow` package.") + def test_tensor_input(self, input_param, test_data): + self.assertTrue(isinstance(test_data[input_param["keys"]], torch.Tensor)) + result = ToPILd(**input_param)(test_data)[input_param["keys"]] + self.assertTrue(isinstance(result, PILImageImage)) + np.testing.assert_allclose(np.array(result), test_data[input_param["keys"]].numpy()) + + @parameterized.expand([TEST_CASE_ARRAY_1]) + @skipUnless(has_pil, "Requires `pillow` package.") + def test_pil_input(self, input_param, test_data): + input_array = test_data[input_param["keys"]] + test_data[input_param["keys"]] = pil_image_fromarray(input_array) + self.assertTrue(isinstance(test_data[input_param["keys"]], PILImageImage)) + result = ToPILd(**input_param)(test_data)[input_param["keys"]] + self.assertTrue(isinstance(result, PILImageImage)) + np.testing.assert_allclose(np.array(result), test_data[input_param["keys"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tversky_loss.py b/tests/test_tversky_loss.py index a1befa062d..0bc2ca2e70 100644 --- a/tests/test_tversky_loss.py +++ b/tests/test_tversky_loss.py @@ -16,6 +16,7 @@ from parameterized import parameterized from monai.losses import TverskyLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save TEST_CASES = [ [ # shape: (1, 1, 2, 2), (1, 1, 2, 2) @@ -183,6 +184,12 @@ def test_input_warnings(self): loss = TverskyLoss(to_onehot_y=True) loss.forward(chn_input, chn_target) + @SkipIfBeforePyTorchVersion((1, 7, 0)) + def test_script(self): + loss = TverskyLoss() + test_input = torch.ones(2, 1, 8, 8) + test_script_save(loss, test_input, test_input) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vis_cam.py b/tests/test_vis_cam.py index d400c27f02..47c116cd5d 100644 --- a/tests/test_vis_cam.py +++ b/tests/test_vis_cam.py @@ -14,7 +14,7 @@ import torch from parameterized import parameterized -from monai.networks.nets import DenseNet, densenet121, se_resnet50 +from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 from monai.visualize import CAM # 2D @@ -68,15 +68,15 @@ class TestClassActivationMap(unittest.TestCase): @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_shape(self, input_data, expected_shape): if input_data["model"] == "densenet2d": - model = densenet121(spatial_dims=2, in_channels=1, out_channels=3) + model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) if input_data["model"] == "densenet3d": model = DenseNet( spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,) ) if input_data["model"] == "senet2d": - model = se_resnet50(spatial_dims=2, in_channels=3, num_classes=4) + model = SEResNet50(spatial_dims=2, in_channels=3, num_classes=4) if input_data["model"] == "senet3d": - model = se_resnet50(spatial_dims=3, in_channels=3, num_classes=4) + model = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) device = "cuda:0" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() diff --git a/tests/test_vis_gradcam.py b/tests/test_vis_gradcam.py index 2a7de0e70c..f8e49f486f 100644 --- a/tests/test_vis_gradcam.py +++ b/tests/test_vis_gradcam.py @@ -11,10 +11,11 @@ import unittest +import numpy as np import torch from parameterized import parameterized -from monai.networks.nets import DenseNet, densenet121, se_resnet50 +from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 from monai.visualize import GradCAM # 2D @@ -64,24 +65,28 @@ class TestGradientClassActivationMap(unittest.TestCase): @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_shape(self, input_data, expected_shape): if input_data["model"] == "densenet2d": - model = densenet121(spatial_dims=2, in_channels=1, out_channels=3) + model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) if input_data["model"] == "densenet3d": model = DenseNet( spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,) ) if input_data["model"] == "senet2d": - model = se_resnet50(spatial_dims=2, in_channels=3, num_classes=4) + model = SEResNet50(spatial_dims=2, in_channels=3, num_classes=4) if input_data["model"] == "senet3d": - model = se_resnet50(spatial_dims=3, in_channels=3, num_classes=4) + model = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) device = "cuda:0" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() cam = GradCAM(nn_module=model, target_layers=input_data["target_layers"]) image = torch.rand(input_data["shape"], device=device) result = cam(x=image, layer_idx=-1) + np.testing.assert_array_equal(cam.nn_module.class_idx.cpu(), model(image).max(1)[-1].cpu()) fea_shape = cam.feature_map_size(input_data["shape"], device=device) self.assertTupleEqual(fea_shape, input_data["feature_shape"]) self.assertTupleEqual(result.shape, expected_shape) + # check result is same whether class_idx=None is used or not + result2 = cam(x=image, layer_idx=-1, class_idx=model(image).max(1)[-1].cpu()) + np.testing.assert_array_almost_equal(result, result2) if __name__ == "__main__": diff --git a/tests/test_vis_gradcampp.py b/tests/test_vis_gradcampp.py index fce68ccde0..92a4b2ac7b 100644 --- a/tests/test_vis_gradcampp.py +++ b/tests/test_vis_gradcampp.py @@ -14,7 +14,7 @@ import torch from parameterized import parameterized -from monai.networks.nets import DenseNet, densenet121, se_resnet50 +from monai.networks.nets import DenseNet, DenseNet121, SEResNet50 from monai.visualize import GradCAMpp # 2D @@ -64,15 +64,15 @@ class TestGradientClassActivationMapPP(unittest.TestCase): @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_shape(self, input_data, expected_shape): if input_data["model"] == "densenet2d": - model = densenet121(spatial_dims=2, in_channels=1, out_channels=3) + model = DenseNet121(spatial_dims=2, in_channels=1, out_channels=3) if input_data["model"] == "densenet3d": model = DenseNet( spatial_dims=3, in_channels=1, out_channels=3, init_features=2, growth_rate=2, block_config=(6,) ) if input_data["model"] == "senet2d": - model = se_resnet50(spatial_dims=2, in_channels=3, num_classes=4) + model = SEResNet50(spatial_dims=2, in_channels=3, num_classes=4) if input_data["model"] == "senet3d": - model = se_resnet50(spatial_dims=3, in_channels=3, num_classes=4) + model = SEResNet50(spatial_dims=3, in_channels=3, num_classes=4) device = "cuda:0" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() diff --git a/tests/test_with_allow_missing_keys.py b/tests/test_with_allow_missing_keys.py new file mode 100644 index 0000000000..68c5ad30c4 --- /dev/null +++ b/tests/test_with_allow_missing_keys.py @@ -0,0 +1,73 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np + +from monai.transforms import Compose, SpatialPad, SpatialPadd, allow_missing_keys_mode + + +class TestWithAllowMissingKeysMode(unittest.TestCase): + def setUp(self): + self.data = {"image": np.arange(16, dtype=float).reshape(1, 4, 4)} + + def test_map_transform(self): + for amk in [True, False]: + t = SpatialPadd(["image", "label"], 10, allow_missing_keys=amk) + with allow_missing_keys_mode(t): + # check state is True + self.assertTrue(t.allow_missing_keys) + # and that transform works even though key is missing + _ = t(self.data) + # check it has returned to original state + self.assertEqual(t.allow_missing_keys, amk) + if not amk: + # should fail because amks==False and key is missing + with self.assertRaises(KeyError): + _ = t(self.data) + + def test_compose(self): + amks = [True, False, True] + t = Compose([SpatialPadd(["image", "label"], 10, allow_missing_keys=amk) for amk in amks]) + with allow_missing_keys_mode(t): + # check states are all True + for _t in t.transforms: + self.assertTrue(_t.allow_missing_keys) + # and that transform works even though key is missing + _ = t(self.data) + # check they've returned to original state + for _t, amk in zip(t.transforms, amks): + self.assertEqual(_t.allow_missing_keys, amk) + # should fail because not all amks==True and key is missing + with self.assertRaises((KeyError, RuntimeError)): + _ = t(self.data) + + def test_array_transform(self): + for t in [SpatialPad(10), Compose([SpatialPad(10)])]: + with self.assertRaises(TypeError): + with allow_missing_keys_mode(t): + pass + + def test_multiple(self): + orig_states = [True, False] + ts = [SpatialPadd(["image", "label"], 10, allow_missing_keys=i) for i in orig_states] + with allow_missing_keys_mode(ts): + for t in ts: + self.assertTrue(t.allow_missing_keys) + # and that transform works even though key is missing + _ = t(self.data) + for t, o_s in zip(ts, orig_states): + self.assertEqual(t.allow_missing_keys, o_s) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/testing_data/1D_BP_bwd.txt b/tests/testing_data/1D_BP_bwd.txt new file mode 100644 index 0000000000..de43270e94 --- /dev/null +++ b/tests/testing_data/1D_BP_bwd.txt @@ -0,0 +1,224 @@ +0., 1., 1., 1., 1., 1., 1., 1., 1.,12., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., # InterpolationType.nearest BoundType.replicate +0., 1., 1., 1., 1., 1., 1., 1., 1.,12., # InterpolationType.nearest BoundType.replicate +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.replicate +0., # InterpolationType.nearest BoundType.replicate +0.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,11.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.linear BoundType.replicate +0.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,11.5, # InterpolationType.linear BoundType.replicate +1.,1.,1.,1.,1.,1.,1.,1.,1.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.linear BoundType.replicate +0., # InterpolationType.linear BoundType.replicate +0.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,11.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.quadratic BoundType.replicate +0.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,11.5, # InterpolationType.quadratic BoundType.replicate +1.,1.,1.,1.,1.,1.,1.,1.,1.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.quadratic BoundType.replicate +0., # InterpolationType.quadratic BoundType.replicate +0.5208333 , 0.9791666 , 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994,11.5 , 0.875 , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.875 , 0.125 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.cubic BoundType.replicate +0.5208333 , 0.9791666 , 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994,11.5 , # InterpolationType.cubic BoundType.replicate +0.875,1. ,1. ,1. ,1. ,1. ,1. ,1. ,0.875,0.125,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. , # InterpolationType.cubic BoundType.replicate +0., # InterpolationType.cubic BoundType.replicate +0.5416667 , 0.9583334 , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,11.5 , 0.8333334 , 1. , 1. , 1. , 1. , 1. , 0.9999999 , 1. , 0.833333 , 0.16666651, 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.fourth BoundType.replicate +0.5416667, 0.9583334, 1. , 1. , 1. , 1. , 1. , 1. , 1. ,11.5 , # InterpolationType.fourth BoundType.replicate +0.8333334 ,1. ,1. ,1. ,1. ,1. ,0.9999999 ,1. ,0.833333 ,0.16666651,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. , # InterpolationType.fourth BoundType.replicate +0., # InterpolationType.fourth BoundType.replicate +5.6223959e-01,9.3802083e-01,9.9973959e-01,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.1499999e+01,7.9947913e-01,9.9739581e-01,1.0000000e+00,1.0000000e+00,9.9999994e-01,1.0000001e+00,9.9999976e-01,9.9739575e-01,7.9947948e-01,2.0052099e-01,2.6040077e-03,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.fifth BoundType.replicate +0.5622396, 0.9380208, 0.9997396, 1. , 1. , 1. , 1. , 1. , 1. ,11.499999 , # InterpolationType.fifth BoundType.replicate +0.7994791 ,0.9973958 ,1. ,1. ,0.99999994,1.0000001 ,0.99999976,0.99739575,0.7994795 ,0.20052099,0.00260401,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. ,0. , # InterpolationType.fifth BoundType.replicate +0., # InterpolationType.fifth BoundType.replicate +5.8194447e-01,9.1944444e-01,9.9861109e-01,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.1499997e+01,7.7499998e-01,9.9166673e-01,1.0000000e+00,1.0000000e+00,1.0000000e+00,9.9999982e-01,1.0000004e+00,9.9166673e-01,7.7499980e-01,2.2500010e-01,8.3333999e-03,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07, # InterpolationType.sixth BoundType.replicate +0.58194447, 0.91944444, 0.9986111 , 1. , 1. , 1. , 1. , 1. , 1. ,11.499997 , # InterpolationType.sixth BoundType.replicate +7.7499998e-01,9.9166673e-01,1.0000000e+00,1.0000000e+00,1.0000000e+00,9.9999982e-01,1.0000004e+00,9.9166673e-01,7.7499980e-01,2.2500010e-01,8.3333999e-03,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07,1.9371510e-07, # InterpolationType.sixth BoundType.replicate +0., # InterpolationType.sixth BoundType.replicate +6.0078436e-01,9.0259641e-01,9.9662077e-01,9.9999845e-01,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.0000000e+00,1.1500004e+01,7.5551212e-01,9.8430985e-01,9.9997836e-01,9.9999994e-01,1.0000000e+00,1.0000001e+00,9.9997842e-01,9.8431003e-01,7.5551212e-01,2.4448761e-01,1.5690181e-02,2.1788481e-05,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07, # InterpolationType.seventh BoundType.replicate +0.60078436, 0.9025964 , 0.9966208 , 0.99999845, 1. , 1. , 1. , 1. , 1. ,11.500004 , # InterpolationType.seventh BoundType.replicate +7.5551212e-01,9.8430985e-01,9.9997836e-01,9.9999994e-01,1.0000000e+00,1.0000001e+00,9.9997842e-01,9.8431003e-01,7.5551212e-01,2.4448761e-01,1.5690181e-02,2.1788481e-05,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07,3.3080869e-07, # InterpolationType.seventh BoundType.replicate +0., # InterpolationType.seventh BoundType.replicate +1.,3.,3.,2.,2.,2.,2.,2.,2.,1.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dct1 +1.,3.,3.,2.,2.,2.,2.,2.,2.,1., # InterpolationType.nearest BoundType.dct1 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dct1 +0., # InterpolationType.nearest BoundType.dct1 +1.5, 3. , 2.5, 2. , 2. , 2. , 2. , 2. , 2. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. , 1. , 1. , # InterpolationType.linear BoundType.dct1 +1.5,3. ,2.5,2. ,2. ,2. ,2. ,2. ,2. ,1. , # InterpolationType.linear BoundType.dct1 +1., 1., 1., 1., 1., 1., 1., 1., 1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1., 1., 1., # InterpolationType.linear BoundType.dct1 +0., # InterpolationType.linear BoundType.dct1 +1.5, 3. , 2.5, 2. , 2. , 2. , 2. , 2. , 2. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. , 1. , 1. , # InterpolationType.quadratic BoundType.dct1 +1.5,3. ,2.5,2. ,2. ,2. ,2. ,2. ,2. ,1. , # InterpolationType.quadratic BoundType.dct1 +1., 1., 1., 1., 1., 1., 1., 1., 1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1., 1., 1., # InterpolationType.quadratic BoundType.dct1 +0., # InterpolationType.quadratic BoundType.dct1 +1.5 , 2.9791667 , 2.5 , 2.0208333 , 1.9999999 , 1.9999999 , 1.9999999 , 1.9999999 , 1.9999999 , 0.99999994, 0.75 , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.75 ,-0.75 ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-0.75 , 0.75 , 1. , # InterpolationType.cubic BoundType.dct1 +1.5 ,2.9791667 ,2.5 ,2.0208333 ,1.9999999 ,1.9999999 ,1.9999999 ,1.9999999 ,1.9999999 ,0.99999994, # InterpolationType.cubic BoundType.dct1 +0.75, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.75,-0.75,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-0.75, 0.75, 1. , # InterpolationType.cubic BoundType.dct1 +0., # InterpolationType.cubic BoundType.dct1 +1.5 , 2.9583333 , 2.5 , 2.0416667 , 2. , 2. , 2. , 2. , 2. , 1. , 0.6666666 , 1. , 1. , 1. , 1. , 1. , 0.9999999 , 1. , 0.6666664 ,-0.66666675,-1. ,-1.0000001 ,-1.0000002 ,-1. ,-1.0000001 ,-1.0000001 ,-1. ,-0.6666667 , 0.6666666 , 1. , # InterpolationType.fourth BoundType.dct1 +1.5 ,2.9583333,2.5 ,2.0416667,2. ,2. ,2. ,2. ,2. ,1. , # InterpolationType.fourth BoundType.dct1 +0.6666666 , 1. , 1. , 1. , 1. , 1. , 0.9999999 , 1. , 0.6666664 ,-0.66666675,-1. ,-1.0000001 ,-1.0000002 ,-1. ,-1.0000001 ,-1.0000001 ,-1. ,-0.6666667 , 0.6666666 , 1. , # InterpolationType.fourth BoundType.dct1 +0., # InterpolationType.fourth BoundType.dct1 +1.4997395 , 2.9380207 , 2.5 , 2.061979 , 2.0002604 , 2. , 2. , 2. , 2. , 1. , 0.5989583 , 0.9947917 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.99479157, 0.5989587 ,-0.59895825,-0.9947917 ,-0.9999998 ,-1.0000002 ,-1. ,-0.9999998 ,-1. ,-0.9947917 ,-0.5989583 , 0.5989583 , 0.9947917 , # InterpolationType.fifth BoundType.dct1 +1.4997395,2.9380207,2.5 ,2.061979 ,2.0002604,2. ,2. ,2. ,2. ,1. , # InterpolationType.fifth BoundType.dct1 +0.5989583 , 0.9947917 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.99479157, 0.5989587 ,-0.59895825,-0.9947917 ,-0.9999998 ,-1.0000002 ,-1. ,-0.9999998 ,-1. ,-0.9947917 ,-0.5989583 , 0.5989583 , 0.9947917 , # InterpolationType.fifth BoundType.dct1 +0., # InterpolationType.fifth BoundType.dct1 +1.498611 , 2.919444 , 2.5 , 2.0805554 , 2.0013888 , 2. , 2. , 2. , 2. , 1. , 0.54999995, 0.9833334 , 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.9833334 , 0.5499998 ,-0.5499999 ,-0.9833334 ,-1.0000004 ,-1.0000001 ,-1.0000001 ,-1. ,-0.99999994,-0.98333335,-0.55 , 0.54999995, 0.9833334 , # InterpolationType.sixth BoundType.dct1 +1.498611 ,2.919444 ,2.5 ,2.0805554,2.0013888,2. ,2. ,2. ,2. ,1. , # InterpolationType.sixth BoundType.dct1 +0.54999995, 0.9833334 , 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.9833334 , 0.5499998 ,-0.5499999 ,-0.9833334 ,-1.0000004 ,-1.0000001 ,-1.0000001 ,-1. ,-0.99999994,-0.98333335,-0.55 , 0.54999995, 0.9833334 , # InterpolationType.sixth BoundType.dct1 +0., # InterpolationType.sixth BoundType.dct1 +1.4966209 , 2.9025953 , 2.5000002 , 2.097404 , 2.0033796 , 2.000002 , 2.0000002 , 2.0000002 , 2.0000002 , 1. , 0.5110243 , 0.9686197 , 0.99995667, 0.99999994, 1. , 1.0000001 , 0.9999567 , 0.96861994, 0.51102436,-0.5110245 ,-0.9686197 ,-0.99995685,-1. ,-1. ,-1.0000001 ,-0.99995655,-0.9686198 ,-0.5110243 , 0.5110243 , 0.9686197 , # InterpolationType.seventh BoundType.dct1 +1.4966209,2.9025953,2.5000002,2.097404 ,2.0033796,2.000002 ,2.0000002,2.0000002,2.0000002,1. , # InterpolationType.seventh BoundType.dct1 +0.5110243 , 0.9686197 , 0.99995667, 0.99999994, 1. , 1.0000001 , 0.9999567 , 0.96861994, 0.51102436,-0.5110245 ,-0.9686197 ,-0.99995685,-1. ,-1. ,-1.0000001 ,-0.99995655,-0.9686198 ,-0.5110243 , 0.5110243 , 0.9686197 , # InterpolationType.seventh BoundType.dct1 +0., # InterpolationType.seventh BoundType.dct1 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dct2 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.nearest BoundType.dct2 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dct2 +0., # InterpolationType.nearest BoundType.dct2 +2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1., 0., # InterpolationType.linear BoundType.dct2 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.linear BoundType.dct2 +1., 1., 1., 1., 1., 1., 1., 1., 1., 0.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1., 0., # InterpolationType.linear BoundType.dct2 +0., # InterpolationType.linear BoundType.dct2 +2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1., 0., # InterpolationType.quadratic BoundType.dct2 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.quadratic BoundType.dct2 +1., 1., 1., 1., 1., 1., 1., 1., 1., 0.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1.,-1., 0., # InterpolationType.quadratic BoundType.dct2 +0., # InterpolationType.quadratic BoundType.dct2 +1.9999999, 2. , 1.9999999, 1.9999999, 1.9999999, 1.9999999, 1.9999999, 1.9999999, 1.9999999, 2. , 0.875 , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.875 , 0. ,-0.875 ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-0.875 , 0. , # InterpolationType.cubic BoundType.dct2 +1.9999999,2. ,1.9999999,1.9999999,1.9999999,1.9999999,1.9999999,1.9999999,1.9999999,2. , # InterpolationType.cubic BoundType.dct2 +0.875, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.875, 0. ,-0.875,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-1. ,-0.875, 0. , # InterpolationType.cubic BoundType.dct2 +0., # InterpolationType.cubic BoundType.dct2 +2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 8.3333337e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999988e-01, 1.0000000e+00, 8.3333302e-01,-1.1920929e-07,-8.3333325e-01,-1.0000000e+00,-1.0000001e+00,-1.0000002e+00,-1.0000000e+00,-1.0000001e+00,-1.0000001e+00,-1.0000000e+00,-8.3333337e-01, 0., # InterpolationType.fourth BoundType.dct2 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.fourth BoundType.dct2 +8.3333337e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999988e-01, 1.0000000e+00, 8.3333302e-01,-1.1920929e-07,-8.3333325e-01,-1.0000000e+00,-1.0000001e+00,-1.0000002e+00,-1.0000000e+00,-1.0000001e+00,-1.0000001e+00,-1.0000000e+00,-8.3333337e-01, 0., # InterpolationType.fourth BoundType.dct2 +0., # InterpolationType.fourth BoundType.dct2 +2.0000000e+00, 1.9999999e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 7.9687500e-01, 9.9739581e-01, 1.0000000e+00, 1.0000000e+00, 9.9999994e-01, 1.0000001e+00, 9.9999976e-01, 9.9739575e-01, 7.9687530e-01, 1.6018748e-07,-7.9687524e-01,-9.9739569e-01,-9.9999982e-01,-1.0000002e+00,-1.0000000e+00,-9.9999982e-01,-1.0000000e+00,-9.9739587e-01,-7.9687494e-01, 5.1222742e-09, # InterpolationType.fifth BoundType.dct2 +2. ,1.9999999,2. ,2. ,2. ,2. ,2. ,2. ,2. ,2. , # InterpolationType.fifth BoundType.dct2 +7.9687500e-01, 9.9739581e-01, 1.0000000e+00, 1.0000000e+00, 9.9999994e-01, 1.0000001e+00, 9.9999976e-01, 9.9739575e-01, 7.9687530e-01, 1.6018748e-07,-7.9687524e-01,-9.9739569e-01,-9.9999982e-01,-1.0000002e+00,-1.0000000e+00,-9.9999982e-01,-1.0000000e+00,-9.9739587e-01,-7.9687494e-01, 5.1222742e-09, # InterpolationType.fifth BoundType.dct2 +0., # InterpolationType.fifth BoundType.dct2 +2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 2.0000000e+00, 7.6666665e-01, 9.9166673e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999982e-01, 1.0000004e+00, 9.9166673e-01, 7.6666647e-01, 5.9604645e-08,-7.6666659e-01,-9.9166662e-01,-1.0000004e+00,-1.0000001e+00,-1.0000001e+00,-1.0000000e+00,-9.9999994e-01,-9.9166667e-01,-7.6666665e-01, 1.8626451e-09, # InterpolationType.sixth BoundType.dct2 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.sixth BoundType.dct2 +7.6666665e-01, 9.9166673e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999982e-01, 1.0000004e+00, 9.9166673e-01, 7.6666647e-01, 5.9604645e-08,-7.6666659e-01,-9.9166662e-01,-1.0000004e+00,-1.0000001e+00,-1.0000001e+00,-1.0000000e+00,-9.9999994e-01,-9.9166667e-01,-7.6666665e-01, 1.8626451e-09, # InterpolationType.sixth BoundType.dct2 +0., # InterpolationType.sixth BoundType.dct2 +2.0000002e+00, 2.0000000e+00, 2.0000000e+00, 2.0000002e+00, 2.0000002e+00, 2.0000002e+00, 2.0000002e+00, 2.0000002e+00, 2.0000002e+00, 2.0000002e+00, 7.3982203e-01, 9.8428816e-01, 9.9997836e-01, 9.9999994e-01, 1.0000000e+00, 1.0000001e+00, 9.9997842e-01, 9.8428833e-01, 7.3982203e-01,-1.6936974e-07,-7.3982191e-01,-9.8428810e-01,-9.9997830e-01,-1.0000000e+00,-1.0000000e+00,-1.0000001e+00,-9.9997824e-01,-9.8428822e-01,-7.3982203e-01,-2.7284841e-09, # InterpolationType.seventh BoundType.dct2 +2.0000002,2. ,2. ,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002, # InterpolationType.seventh BoundType.dct2 +7.3982203e-01, 9.8428816e-01, 9.9997836e-01, 9.9999994e-01, 1.0000000e+00, 1.0000001e+00, 9.9997842e-01, 9.8428833e-01, 7.3982203e-01,-1.6936974e-07,-7.3982191e-01,-9.8428810e-01,-9.9997830e-01,-1.0000000e+00,-1.0000000e+00,-1.0000001e+00,-9.9997824e-01,-9.8428822e-01,-7.3982203e-01,-2.7284841e-09, # InterpolationType.seventh BoundType.dct2 +0., # InterpolationType.seventh BoundType.dct2 +-1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., # InterpolationType.nearest BoundType.dst1 +-1., 0., 0., 0., 0., 0., 0., 0., 0., 0., # InterpolationType.nearest BoundType.dst1 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dst1 +0., # InterpolationType.nearest BoundType.dst1 +0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1., # InterpolationType.linear BoundType.dst1 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.linear BoundType.dst1 +1., 1., 1., 1., 1., 1., 1., 1., 1.,-9.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1., # InterpolationType.linear BoundType.dst1 +0., # InterpolationType.linear BoundType.dst1 +0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1., # InterpolationType.quadratic BoundType.dst1 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.quadratic BoundType.dst1 +1., 1., 1., 1., 1., 1., 1., 1., 1.,-9.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1., # InterpolationType.quadratic BoundType.dst1 +0., # InterpolationType.quadratic BoundType.dst1 +0., 0.,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08, 8.7500000e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00,-2.5000000e-01,-7.7500000e+00,-7.7500000e+00,-2.5000000e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 8.7500000e-01, # InterpolationType.cubic BoundType.dst1 +0., 0.,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08, # InterpolationType.cubic BoundType.dst1 +0.875, 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25 ,-7.75 ,-7.75 ,-0.25 , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.875, # InterpolationType.cubic BoundType.dst1 +0., # InterpolationType.cubic BoundType.dst1 +0., 0.,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-1.1175871e-08, 8.3333337e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999988e-01, 1.0000000e+00,-6.6666698e-01,-7.3333335e+00,-7.3333335e+00,-6.6666675e-01, 1.0000000e+00, 1.0000001e+00, 1.0000002e+00, 1.0000000e+00, 1.0000001e+00, 1.0000001e+00, 1.0000000e+00, 8.3333337e-01, # InterpolationType.fourth BoundType.dst1 +0., 0.,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-1.1175871e-08, # InterpolationType.fourth BoundType.dst1 +0.8333334 , 1. , 1. , 1. , 1. , 1. , 0.9999999 , 1. ,-0.666667 ,-7.3333335 ,-7.3333335 ,-0.66666675, 1. , 1.0000001 , 1.0000002 , 1. , 1.0000001 , 1.0000001 , 1. , 0.8333334 , # InterpolationType.fourth BoundType.dst1 +0., # InterpolationType.fourth BoundType.dst1 +3.9872248e-09, 0., 1.1175871e-08, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.9947913e-01, 9.9739581e-01, 1.0000000e+00, 1.0000000e+00, 9.9999994e-01, 1.0000001e+00, 9.9999976e-01, 9.7395825e-01,-1.0052080e+00,-6.9687500e+00,-6.9687500e+00,-1.0052083e+00, 9.7395819e-01, 9.9999982e-01, 1.0000002e+00, 1.0000000e+00, 9.9999982e-01, 1.0000000e+00, 9.9739587e-01, 7.9947913e-01, # InterpolationType.fifth BoundType.dst1 +3.9872248e-09,0.,1.1175871e-08,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09, # InterpolationType.fifth BoundType.dst1 +0.7994791 , 0.9973958 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-1.005208 ,-6.96875 ,-6.96875 ,-1.0052083 , 0.9739582 , 0.9999998 , 1.0000002 , 1. , 0.9999998 , 1. , 0.9973959 , 0.7994791 , # InterpolationType.fifth BoundType.dst1 +0., # InterpolationType.fifth BoundType.dst1 +4.1094609e-08, 0.,-1.4901161e-08, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09,-2.6193447e-08, 7.7499998e-01, 9.9166673e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999982e-01, 1.0000004e+00, 9.1666675e-01,-1.2500002e+00,-6.6666665e+00,-6.6666665e+00,-1.2500000e+00, 9.1666681e-01, 1.0000004e+00, 1.0000001e+00, 1.0000001e+00, 1.0000000e+00, 9.9999994e-01, 9.9166667e-01, 7.7499998e-01, # InterpolationType.sixth BoundType.dst1 +4.1094609e-08, 0.,-1.4901161e-08, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09,-2.6193447e-08, # InterpolationType.sixth BoundType.dst1 +0.775 , 0.99166673, 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.2500002 ,-6.6666665 ,-6.6666665 ,-1.25 , 0.9166668 , 1.0000004 , 1.0000001 , 1.0000001 , 1. , 0.99999994, 0.9916667 , 0.775 , # InterpolationType.sixth BoundType.dst1 +0., # InterpolationType.sixth BoundType.dst1 +-9.7788870e-09, 3.7846348e-10,-7.4505806e-09, 2.3283064e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 7.5553381e-01, 9.8430985e-01, 9.9997836e-01, 9.9999994e-01, 1.0000000e+00, 1.0000001e+00, 9.9978310e-01, 8.4309906e-01,-1.4446614e+00,-6.3982205e+00,-6.3982205e+00,-1.4446614e+00, 8.4309900e-01, 9.9978304e-01, 1.0000000e+00, 1.0000000e+00, 1.0000001e+00, 9.9997824e-01, 9.8430991e-01, 7.5553381e-01, # InterpolationType.seventh BoundType.dst1 +-9.7788870e-09, 3.7846348e-10,-7.4505806e-09, 2.3283064e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, # InterpolationType.seventh BoundType.dst1 +0.7555338 , 0.98430985, 0.99997836, 0.99999994, 1. , 1.0000001 , 0.9997831 , 0.84309906,-1.4446614 ,-6.3982205 ,-6.3982205 ,-1.4446614 , 0.843099 , 0.99978304, 1. , 1. , 1.0000001 , 0.99997824, 0.9843099 , 0.7555338 , # InterpolationType.seventh BoundType.dst1 +0., # InterpolationType.seventh BoundType.dst1 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dst2 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dst2 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dst2 +0., # InterpolationType.nearest BoundType.dst2 + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-18., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., # InterpolationType.linear BoundType.dst2 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.linear BoundType.dst2 + 1., 1., 1., 1., 1., 1., 1., 1., 1.,-18., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., # InterpolationType.linear BoundType.dst2 +0., # InterpolationType.linear BoundType.dst2 + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-18., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., # InterpolationType.quadratic BoundType.dst2 +0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.quadratic BoundType.dst2 + 1., 1., 1., 1., 1., 1., 1., 1., 1.,-18., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., # InterpolationType.quadratic BoundType.dst2 +0., # InterpolationType.quadratic BoundType.dst2 +0., 0.,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08, 9.3132257e-09, 8.7500000e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00,-1.3750000e+00,-1.3250000e+01,-1.3750000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 8.7500000e-01, 2.5000000e-01, # InterpolationType.cubic BoundType.dst2 +0., 0.,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08,-2.0489097e-08, 9.3132257e-09, # InterpolationType.cubic BoundType.dst2 + 0.875, 1. , 1. , 1. , 1. , 1. , 1. , 1. , -1.375,-13.25 , -1.375, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.875, 0.25 , # InterpolationType.cubic BoundType.dst2 +0., # InterpolationType.cubic BoundType.dst2 +0., 0.,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-1.1175871e-08, 8.3333337e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999988e-01, 1.0000000e+00,-2.1666670e+00,-1.1666667e+01,-2.1666667e+00, 1.0000000e+00, 1.0000001e+00, 1.0000002e+00, 1.0000000e+00, 1.0000001e+00, 1.0000001e+00, 1.0000000e+00, 8.3333337e-01, 3.3333334e-01, # InterpolationType.fourth BoundType.dst2 +0., 0.,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-4.0978193e-08,-1.1175871e-08, # InterpolationType.fourth BoundType.dst2 + 0.8333334 , 1. , 1. , 1. , 1. , 1. , 0.9999999 , 1. , -2.166667 ,-11.666667 , -2.1666667 , 1. , 1.0000001 , 1.0000002 , 1. , 1.0000001 , 1.0000001 , 1. , 0.8333334 , 0.33333334, # InterpolationType.fourth BoundType.dst2 +0., # InterpolationType.fourth BoundType.dst2 +0., 3.7252903e-09, 1.1175871e-08, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 7.1886461e-09, 1.0913936e-08, 8.0208331e-01, 9.9739581e-01, 1.0000000e+00, 1.0000000e+00, 9.9999994e-01, 1.0000001e+00, 9.9999976e-01, 9.5052075e-01,-2.7604163e+00,-1.0380208e+01,-2.7604165e+00, 9.5052069e-01, 9.9999982e-01, 1.0000002e+00, 1.0000000e+00, 9.9999982e-01, 1.0000000e+00, 9.9739587e-01, 8.0208331e-01, 4.0104166e-01, # InterpolationType.fifth BoundType.dst2 +0.,3.7252903e-09,1.1175871e-08,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09,7.1886461e-09,1.0913936e-08, # InterpolationType.fifth BoundType.dst2 + 0.8020833 , 0.9973958 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.95052075, -2.7604163 ,-10.380208 , -2.7604165 , 0.9505207 , 0.9999998 , 1.0000002 , 1. , 0.9999998 , 1. , 0.9973959 , 0.8020833 , 0.40104166, # InterpolationType.fifth BoundType.dst2 +0., # InterpolationType.fifth BoundType.dst2 +5.9604645e-08,-1.4901161e-08,-1.4901161e-08, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09,-1.1292286e-08, 7.8333330e-01, 9.9166673e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 9.9999982e-01, 1.0000004e+00, 8.4166676e-01,-3.1166668e+00,-9.4499998e+00,-3.1166666e+00, 8.4166652e-01, 1.0000004e+00, 1.0000001e+00, 1.0000001e+00, 1.0000000e+00, 9.9999994e-01, 9.9166667e-01, 7.8333330e-01, 4.5000002e-01, # InterpolationType.sixth BoundType.dst2 +5.9604645e-08,-1.4901161e-08,-1.4901161e-08, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09, 3.6088750e-09,-1.1292286e-08, # InterpolationType.sixth BoundType.dst2 +0.7833333 , 0.99166673, 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.84166676,-3.1166668 ,-9.45 ,-3.1166666 , 0.8416665 , 1.0000004 , 1.0000001 , 1.0000001 , 1. , 0.99999994, 0.9916667 , 0.7833333 , 0.45000002, # InterpolationType.sixth BoundType.dst2 +0., # InterpolationType.sixth BoundType.dst2 +0.,-7.4505806e-09,-6.9849193e-09, 2.3283064e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09,-5.0350764e-09, 7.7120221e-01, 9.8433155e-01, 9.9997836e-01, 9.9999994e-01, 1.0000000e+00, 1.0000001e+00, 9.9958777e-01, 7.0230043e-01,-3.3471570e+00,-8.7094622e+00,-3.3471570e+00, 7.0230043e-01, 9.9958777e-01, 1.0000000e+00, 1.0000000e+00, 1.0000001e+00, 9.9997824e-01, 9.8433161e-01, 7.7120221e-01, 4.8897570e-01, # InterpolationType.seventh BoundType.dst2 +0.,-7.4505806e-09,-6.9849193e-09, 2.3283064e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09, 1.9498430e-09,-5.0350764e-09, # InterpolationType.seventh BoundType.dst2 +0.7712022 , 0.98433155, 0.99997836, 0.99999994, 1. , 1.0000001 , 0.9995878 , 0.7023004 ,-3.347157 ,-8.709462 ,-3.347157 , 0.7023004 , 0.9995878 , 1. , 1. , 1.0000001 , 0.99997824, 0.9843316 , 0.7712022 , 0.4889757 , # InterpolationType.seventh BoundType.dst2 +0., # InterpolationType.seventh BoundType.dst2 +2.,2.,2.,2.,2.,2.,2.,2.,2.,2.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dft +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.nearest BoundType.dft +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.dft +0., # InterpolationType.nearest BoundType.dft +2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., # InterpolationType.linear BoundType.dft +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.linear BoundType.dft +1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., # InterpolationType.linear BoundType.dft +0., # InterpolationType.linear BoundType.dft +2., 2., 2., 2., 2., 2., 2., 2., 2., 2., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., # InterpolationType.quadratic BoundType.dft +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.quadratic BoundType.dft +1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., 1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., # InterpolationType.quadratic BoundType.dft +0., # InterpolationType.quadratic BoundType.dft +2. , 2. , 1.9999999, 1.9999999, 1.9999999, 1.9999999, 1.9999999, 1.9999999, 1.9999999, 2. ,-0.25 , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25 ,-6.5 ,-0.25 , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25 ,-6.5 , # InterpolationType.cubic BoundType.dft +2. ,2. ,1.9999999,1.9999999,1.9999999,1.9999999,1.9999999,1.9999999,1.9999999,2. , # InterpolationType.cubic BoundType.dft +-0.25, 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25,-6.5 ,-0.25, 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25,-6.5 , # InterpolationType.cubic BoundType.dft +0., # InterpolationType.cubic BoundType.dft +2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. ,-0.6666666, 1. , 1. , 1. , 1. , 1. , 0.9999999, 1. ,-0.666667 ,-5.666667 ,-0.6666666, 1. , 1. , 1. , 1. , 1. , 0.9999999, 1. ,-0.666667 ,-5.666667 , # InterpolationType.fourth BoundType.dft +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.fourth BoundType.dft +-0.6666666, 1. , 1. , 1. , 1. , 1. , 0.9999999, 1. ,-0.666667 ,-5.666667 ,-0.6666666, 1. , 1. , 1. , 1. , 1. , 0.9999999, 1. ,-0.666667 ,-5.666667 , # InterpolationType.fourth BoundType.dft +0., # InterpolationType.fourth BoundType.dft +2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. ,-0.97916675, 0.9739583 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-0.9791663 ,-4.989583 ,-0.97916675, 0.9739583 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-0.9791663 ,-4.989583 , # InterpolationType.fifth BoundType.dft +2.,2.,2.,2.,2.,2.,2.,2.,2.,2., # InterpolationType.fifth BoundType.dft +-0.97916675, 0.9739583 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-0.9791663 ,-4.989583 ,-0.97916675, 0.9739583 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-0.9791663 ,-4.989583 , # InterpolationType.fifth BoundType.dft +0., # InterpolationType.fifth BoundType.dft +1.9999999 , 2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. , 2. ,-1.1666667 , 0.9166667 , 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.1666669 ,-4.4999995 ,-1.1666667 , 0.9166667 , 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.1666669 ,-4.4999995 , # InterpolationType.sixth BoundType.dft +1.9999999,2. ,2. ,2. ,2. ,2. ,2. ,2. ,2. ,2. , # InterpolationType.sixth BoundType.dft +-1.1666667 , 0.9166667 , 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.1666669 ,-4.4999995 ,-1.1666667 , 0.9166667 , 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.1666669 ,-4.4999995 , # InterpolationType.sixth BoundType.dft +0., # InterpolationType.sixth BoundType.dft +2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 , 2.0000002 ,-1.2879773 , 0.84331596, 0.999783 , 0.99999994, 1. , 1.0000001 , 0.9997831 , 0.8433161 ,-1.2879775 ,-4.110243 ,-1.2879773 , 0.84331596, 0.999783 , 0.99999994, 1. , 1.0000001 , 0.9997831 , 0.8433161 ,-1.2879775 ,-4.110243 , # InterpolationType.seventh BoundType.dft +2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002,2.0000002, # InterpolationType.seventh BoundType.dft +-1.2879773 , 0.84331596, 0.999783 , 0.99999994, 1. , 1.0000001 , 0.9997831 , 0.8433161 ,-1.2879775 ,-4.110243 ,-1.2879773 , 0.84331596, 0.999783 , 0.99999994, 1. , 1.0000001 , 0.9997831 , 0.8433161 ,-1.2879775 ,-4.110243 , # InterpolationType.seventh BoundType.dft +0., # InterpolationType.seventh BoundType.dft +0.,1.,1.,1.,1.,1.,1.,1.,1.,1.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.zero +0.,1.,1.,1.,1.,1.,1.,1.,1.,1., # InterpolationType.nearest BoundType.zero +0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0., # InterpolationType.nearest BoundType.zero +0., # InterpolationType.nearest BoundType.zero +0.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-9. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.linear BoundType.zero +0.5,1. ,1. ,1. ,1. ,1. ,1. ,1. ,1. ,1. , # InterpolationType.linear BoundType.zero +1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., # InterpolationType.linear BoundType.zero +0., # InterpolationType.linear BoundType.zero +0.5, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-9. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.quadratic BoundType.zero +0.5,1. ,1. ,1. ,1. ,1. ,1. ,1. ,1. ,1. , # InterpolationType.quadratic BoundType.zero +1., 1., 1., 1., 1., 1., 1., 1., 1.,-9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., # InterpolationType.quadratic BoundType.zero +0., # InterpolationType.quadratic BoundType.zero +0.5 , 0.9791666 , 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.99999994, 0.875 , 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25 ,-6.625 ,-1.125 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.cubic BoundType.zero +0.5 ,0.9791666 ,0.99999994,0.99999994,0.99999994,0.99999994,0.99999994,0.99999994,0.99999994,0.99999994, # InterpolationType.cubic BoundType.zero +0.875, 1. , 1. , 1. , 1. , 1. , 1. , 1. ,-0.25 ,-6.625,-1.125, 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.cubic BoundType.zero +0., # InterpolationType.cubic BoundType.zero +0.5 , 0.9583334, 1. , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.8333334, 1. , 1. , 1. , 1. , 1. , 0.9999999, 1. ,-0.666667 ,-5.8333335,-1.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.fourth BoundType.zero +0.5 ,0.9583334,1. ,1. ,1. ,1. ,1. ,1. ,1. ,1. , # InterpolationType.fourth BoundType.zero +0.8333334, 1. , 1. , 1. , 1. , 1. , 0.9999999, 1. ,-0.666667 ,-5.8333335,-1.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.fourth BoundType.zero +0., # InterpolationType.fourth BoundType.zero +0.5 , 0.9380208 , 0.9997396 , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.7994791 , 0.9973958 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-0.9817705 ,-5.190104 ,-1.7786459 ,-0.0234375 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.fifth BoundType.zero +0.5 ,0.9380208,0.9997396,1. ,1. ,1. ,1. ,1. ,1. ,1. , # InterpolationType.fifth BoundType.zero +0.7994791 , 0.9973958 , 1. , 1. , 0.99999994, 1.0000001 , 0.99999976, 0.97395825,-0.9817705 ,-5.190104 ,-1.7786459 ,-0.0234375 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.fifth BoundType.zero +0., # InterpolationType.fifth BoundType.zero +0.49999997, 0.91944444, 0.9986111 , 1. , 1. , 1. , 1. , 1. , 1. , 1. , 0.775 , 0.99166673, 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.1750002 ,-4.725 ,-1.9416667 ,-0.075 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.sixth BoundType.zero +0.49999997,0.91944444,0.9986111 ,1. ,1. ,1. ,1. ,1. ,1. ,1. , # InterpolationType.sixth BoundType.zero +0.775 , 0.99166673, 1. , 1. , 1. , 0.9999998 , 1.0000004 , 0.91666675,-1.1750002 ,-4.725 ,-1.9416667 ,-0.075 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , # InterpolationType.sixth BoundType.zero +0., # InterpolationType.sixth BoundType.zero +5.0000000e-01, 9.0259641e-01, 9.9662077e-01, 9.9999845e-01, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 1.0000000e+00, 7.5551212e-01, 9.8430985e-01, 9.9997836e-01, 9.9999994e-01, 1.0000000e+00, 1.0000001e+00, 9.9978310e-01, 8.4329438e-01,-1.3036675e+00,-4.3547311e+00,-2.0434895e+00,-1.4099392e-01,-1.9531250e-04, 0., 0., 0., 0., 0., 0., 0., # InterpolationType.seventh BoundType.zero +0.5 ,0.9025964 ,0.9966208 ,0.99999845,1. ,1. ,1. ,1. ,1. ,1. , # InterpolationType.seventh BoundType.zero +7.5551212e-01, 9.8430985e-01, 9.9997836e-01, 9.9999994e-01, 1.0000000e+00, 1.0000001e+00, 9.9978310e-01, 8.4329438e-01,-1.3036675e+00,-4.3547311e+00,-2.0434895e+00,-1.4099392e-01,-1.9531250e-04, 0., 0., 0., 0., 0., 0., 0., # InterpolationType.seventh BoundType.zero +0., # InterpolationType.seventh BoundType.zero diff --git a/tests/testing_data/1D_BP_fwd.txt b/tests/testing_data/1D_BP_fwd.txt new file mode 100644 index 0000000000..a620d59dff --- /dev/null +++ b/tests/testing_data/1D_BP_fwd.txt @@ -0,0 +1,56 @@ +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.nearest BoundType.replicate +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.linear BoundType.replicate +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.quadratic BoundType.replicate +0.5208, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.4792, 8.9792, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.cubic BoundType.replicate +0.5417, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.4583, 8.9583, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.fourth BoundType.replicate +0.5622, 1.5003, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4997, 8.4378, 8.9378, 8.9997, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.fifth BoundType.replicate +0.5819, 1.5014, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4986, 8.4181, 8.9181, 8.9986, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.sixth BoundType.replicate +0.6008, 1.5034, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4966, 8.3992, 8.8992, 8.9966, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, # InterpolationType.seventh BoundType.replicate +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0, 1.0, 2.0, # InterpolationType.nearest BoundType.dct1 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 8.5, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5, 0.5, 1.5, # InterpolationType.linear BoundType.dct1 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 8.5, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5, 0.5, 1.5, # InterpolationType.quadratic BoundType.dct1 +0.5417, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.4583, 8.4583, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5417, 0.5417, 1.5, # InterpolationType.cubic BoundType.dct1 +0.5833, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.4167, 8.4167, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5833, 0.5833, 1.5, # InterpolationType.fourth BoundType.dct1 +0.6245, 1.5005, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4995, 8.3755, 8.3755, 7.4995, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5005, 0.6245, 0.6245, 1.5005, # InterpolationType.fifth BoundType.dct1 +0.6639, 1.5028, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4972, 8.3361, 8.3361, 7.4972, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5028, 0.6639, 0.6639, 1.5028, # InterpolationType.sixth BoundType.dct1 +0.7016, 1.5068, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4932, 8.2984, 8.2984, 7.4932, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5068, 0.7016, 0.7016, 1.5068, # InterpolationType.seventh BoundType.dct1 +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0, 0.0, # InterpolationType.nearest BoundType.dct2 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.0, 8.5, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5, 0.0, # InterpolationType.linear BoundType.dct2 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.0, 8.5, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5, 0.0, # InterpolationType.quadratic BoundType.dct2 +0.5208, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.4792, 8.9583, 8.4792, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5208, 0.0417, # InterpolationType.cubic BoundType.dct2 +0.5417, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.4583, 8.9167, 8.4583, 7.5, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5, 0.5417, 0.0833, # InterpolationType.fourth BoundType.dct2 +0.5625, 1.5003, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4997, 8.4375, 8.8755, 8.4375, 7.4997, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5003, 0.5625, 0.1245, # InterpolationType.fifth BoundType.dct2 +0.5833, 1.5014, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4986, 8.4167, 8.8361, 8.4167, 7.4986, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5014, 0.5833, 0.1639, # InterpolationType.sixth BoundType.dct2 +0.6042, 1.5034, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4966, 8.3958, 8.7984, 8.3958, 7.4966, 6.5, 5.5, 4.5, 3.5, 2.5, 1.5034, 0.6042, 0.2016, # InterpolationType.seventh BoundType.dct2 +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 0.0, -9.0, -8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -1.0, -0.0, # InterpolationType.nearest BoundType.dst1 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, -4.5, -8.5, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -0.5, # InterpolationType.linear BoundType.dst1 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, -4.5, -8.5, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -0.5, # InterpolationType.quadratic BoundType.dst1 +0.5208, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.2917, 4.2917, -4.2917, -8.2917, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -0.5208, # InterpolationType.cubic BoundType.dst1 +0.5417, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.0833, 4.0833, -4.0833, -8.0833, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -0.5417, # InterpolationType.fourth BoundType.dst1 +0.5622, 1.5003, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4974, 7.8776, 3.8802, -3.8802, -7.8776, -7.4974, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5003, -0.5622, # InterpolationType.fifth BoundType.dst1 +0.5819, 1.5014, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4861, 7.6806, 3.6944, -3.6944, -7.6806, -7.4861, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5014, -0.5819, # InterpolationType.sixth BoundType.dst1 +0.6008, 1.5034, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4662, 7.4922, 3.5260, -3.5260, -7.4922, -7.4662, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5034, -0.6008, # InterpolationType.seventh BoundType.dst1 +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, -9.0, -8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -1.0, -0.0, 0.0, # InterpolationType.nearest BoundType.dst2 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 0.0, -8.5, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -0.5, 0.0, # InterpolationType.linear BoundType.dst2 +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 0.0, -8.5, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -0.5, 0.0, # InterpolationType.quadratic BoundType.dst2 +5.2083e-01, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.1042, -1.6391e-07, -8.1042, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -5.2083e-01, 0.0, # InterpolationType.cubic BoundType.dst2 +5.4167e-01, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 7.7083, 1.4901e-07, -7.7083, -7.5, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5, -5.4167e-01, 0.0, # InterpolationType.fourth BoundType.dst2 +5.6198e-01, 1.5003, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4951, 7.3224, 1.2107e-07, -7.3224, -7.4951, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5003, -5.6198e-01, 5.2387e-10, # InterpolationType.fifth BoundType.dst2 +5.8056e-01, 1.5014, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4736, 6.9694, -1.0896e-07, -6.9694, -7.4736, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5014, -5.8056e-01, 2.3283e-10, # InterpolationType.sixth BoundType.dst2 +0.59740, 1.5034, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4358, 6.6493, 0.0, -6.6493, -7.4358, -6.5, -5.5, -4.5, -3.5, -2.5, -1.5034, -0.59740, 0.0, # InterpolationType.seventh BoundType.dst2 +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 0.0, # InterpolationType.nearest BoundType.dft +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, # InterpolationType.linear BoundType.dft +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, # InterpolationType.quadratic BoundType.dft +0.7083, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.2917, 4.5, 0.7083, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.2917, 4.5, # InterpolationType.cubic BoundType.dft +0.9167, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.0833, 4.5, 0.9167, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.0833, 4.5, # InterpolationType.fourth BoundType.dft +1.1198, 1.5026, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4974, 7.8802, 4.5, 1.1198, 1.5026, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4974, 7.8802, 4.5, # InterpolationType.fifth BoundType.dft +1.3056, 1.5139, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4861, 7.6944, 4.5, 1.3056, 1.5139, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4861, 7.6944, 4.5, # InterpolationType.sixth BoundType.dft +1.4740, 1.5338, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4662, 7.5260, 4.5, 1.4740, 1.5338, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4662, 7.5260, 4.5, # InterpolationType.seventh BoundType.dft +1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.nearest BoundType.zero +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.linear BoundType.zero +0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 4.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.quadratic BoundType.zero +0.5208, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.2917, 4.4792, 0.1875, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.cubic BoundType.zero +0.5417, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.0833, 4.4583, 0.3750, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.fourth BoundType.zero +5.6224e-01, 1.5003, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4974, 7.8799, 4.4378, 5.5755e-01, 2.3438e-03, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.fifth BoundType.zero +0.5819, 1.5014, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4861, 7.6931, 4.4181, 0.7236, 0.0125, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.sixth BoundType.zero +6.0078e-01, 1.5034, 2.5, 3.5, 4.5, 5.5, 6.5, 7.4662, 7.5226, 4.3992, 8.7325e-01, 3.0411e-02, 1.3951e-05, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # InterpolationType.seventh BoundType.zero diff --git a/tests/testing_data/cpp_resample_answers.py b/tests/testing_data/cpp_resample_answers.py new file mode 100644 index 0000000000..51ac6ccda9 --- /dev/null +++ b/tests/testing_data/cpp_resample_answers.py @@ -0,0 +1,41 @@ +# Copyright 2020 - 2021 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import csv +import os +import warnings +from typing import List, Optional + + +def _read_testing_data_answers(fname: Optional[str] = None, delimiter=",") -> List: + answers: List = [] + if not fname: + return answers + # read answers from directory of the current file + pwd = os.path.dirname(os.path.abspath(__file__)) + filename = os.path.join(pwd, fname) + if not os.path.isfile(filename): + warnings.warn("test data {} not found.".format(filename)) + return answers + with open(filename) as f: + res_reader = csv.reader(f, delimiter=delimiter) + for r in res_reader: + res_row = [] + for item in r: + if item.strip().startswith("#"): + continue # allow for some simple comments in the file + res_row.append(float(item)) + answers.append(res_row) + return answers + + +Expected_1D_GP_fwd: List = _read_testing_data_answers(fname="1D_BP_fwd.txt") +Expected_1D_GP_bwd: List = _read_testing_data_answers(fname="1D_BP_bwd.txt") diff --git a/tests/utils.py b/tests/utils.py index 4597a18fbd..20f94cd1eb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -16,6 +16,7 @@ import queue import sys import tempfile +import time import traceback import unittest import warnings @@ -237,7 +238,11 @@ def __init__( """ self.nnodes = int(nnodes) self.nproc_per_node = int(nproc_per_node) - self.node_rank = int(os.environ.get("NODE_RANK", "0")) if node_rank is None else node_rank + if self.nnodes < 1 or self.nproc_per_node < 1: + raise ValueError( + f"number of nodes and processes per node must be >= 1, got {self.nnodes} and {self.nproc_per_node}" + ) + self.node_rank = int(os.environ.get("NODE_RANK", "0")) if node_rank is None else int(node_rank) self.master_addr = master_addr self.master_port = np.random.randint(10000, 20000) if master_port is None else master_port @@ -269,6 +274,7 @@ def run_process(self, func, local_rank, args, kwargs, results): os.environ["RANK"] = str(self.nproc_per_node * self.node_rank + local_rank) if torch.cuda.is_available(): + os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" torch.cuda.set_device(int(local_rank)) dist.init_process_group( @@ -279,6 +285,11 @@ def run_process(self, func, local_rank, args, kwargs, results): rank=int(os.environ["RANK"]), ) func(*args, **kwargs) + # the primary node lives longer to + # avoid _store_based_barrier, RuntimeError: Broken pipe + # as the TCP store daemon is on the rank 0 + if int(os.environ["RANK"]) == 0: + time.sleep(0.1) results.put(True) except Exception as e: results.put(False) @@ -286,11 +297,20 @@ def run_process(self, func, local_rank, args, kwargs, results): finally: os.environ.clear() os.environ.update(_env) - dist.destroy_process_group() + try: + dist.destroy_process_group() + except RuntimeError as e: + warnings.warn(f"While closing process group: {e}.") def __call__(self, obj): if not torch.distributed.is_available(): return unittest.skipIf(True, "Skipping distributed tests because not torch.distributed.is_available()")(obj) + if torch.cuda.is_available() and torch.cuda.device_count() < self.nproc_per_node: + return unittest.skipIf( + True, + f"Skipping distributed tests because it requires {self.nnodes} devices " + f"but got {torch.cuda.device_count()}", + )(obj) _cache_original_func(obj) @@ -549,13 +569,13 @@ def query_memory(n=2): """ Find best n idle devices and return a string of device ids. """ - bash_string = "nvidia-smi --query-gpu=utilization.gpu,temperature.gpu,memory.used --format=csv,noheader,nounits" + bash_string = "nvidia-smi --query-gpu=utilization.gpu,power.draw,memory.used --format=csv,noheader,nounits" try: p1 = Popen(bash_string.split(), stdout=PIPE) output, error = p1.communicate() free_memory = [x.split(",") for x in output.decode("utf-8").split("\n")[:-1]] - free_memory = np.asarray(free_memory, dtype=np.float).T + free_memory = np.asarray(free_memory, dtype=float).T ids = np.lexsort(free_memory)[:n] except (FileNotFoundError, TypeError, IndexError): ids = range(n) if isinstance(n, int) else []