From 46c1a4cb7834d3fde81ab29cbdbf287047365a74 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 24 Feb 2021 15:02:32 +0000 Subject: [PATCH 01/85] Enhance csrc/resample (#1471) * update openmp flag Signed-off-by: Wenqi Li * improves boundtype docs Signed-off-by: Wenqi Li * update setup.py Signed-off-by: Wenqi Li * input validation 1d Signed-off-by: Wenqi Li * fixes typos Signed-off-by: Wenqi Li * fixes typos Signed-off-by: Wenqi Li * merge upstream changes Signed-off-by: Wenqi Li * tests enums Signed-off-by: Wenqi Li * init. test grid pull Signed-off-by: Wenqi Li * update Signed-off-by: Wenqi Li * test grid_pull Signed-off-by: Wenqi Li * fixes min test Signed-off-by: Wenqi Li * adds device tests Signed-off-by: Wenqi Li * bwd tests Signed-off-by: Wenqi Li --- monai/csrc/ext.cpp | 22 +- monai/csrc/resample/pushpull.h | 22 +- monai/csrc/resample/pushpull_cpu.cpp | 1220 ++++++++++++------ monai/csrc/resample/pushpull_cuda.cu | 1222 +++++++++++++------ monai/csrc/utils/common_utils.h | 33 +- monai/csrc/utils/resample_utils.h | 2 + monai/networks/layers/spatial_transforms.py | 193 +-- runtests.sh | 1 + setup.py | 4 +- tests/test_enum_bound_interp.py | 73 ++ tests/test_grid_pull.py | 94 ++ tests/testing_data/1D_BP_bwd.txt | 224 ++++ tests/testing_data/1D_BP_fwd.txt | 56 + tests/testing_data/cpp_resample_answers.py | 41 + 14 files changed, 2355 insertions(+), 852 deletions(-) create mode 100644 tests/test_enum_bound_interp.py create mode 100644 tests/test_grid_pull.py create mode 100644 tests/testing_data/1D_BP_bwd.txt create mode 100644 tests/testing_data/1D_BP_fwd.txt create mode 100644 tests/testing_data/cpp_resample_answers.py 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/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/runtests.sh b/runtests.sh index 76692e731b..1395ccdcfd 100755 --- a/runtests.sh +++ b/runtests.sh @@ -159,6 +159,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 { 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/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_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/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") From 3a1c459c0df5d39fa24832ab1d31267d6a03c035 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 24 Feb 2021 18:11:46 +0000 Subject: [PATCH 02/85] move transforms out of compose file (#1623) move transforms out of compose file. this is backwards compatible. --- monai/apps/deepgrow/transforms.py | 2 +- monai/transforms/__init__.py | 3 +- monai/transforms/compose.py | 192 +-------------------- monai/transforms/croppad/array.py | 2 +- monai/transforms/croppad/dictionary.py | 2 +- monai/transforms/intensity/array.py | 2 +- monai/transforms/intensity/dictionary.py | 2 +- monai/transforms/io/array.py | 2 +- monai/transforms/io/dictionary.py | 2 +- monai/transforms/post/array.py | 2 +- monai/transforms/post/dictionary.py | 2 +- monai/transforms/spatial/array.py | 2 +- monai/transforms/spatial/dictionary.py | 2 +- monai/transforms/transform.py | 206 +++++++++++++++++++++++ monai/transforms/utility/array.py | 2 +- monai/transforms/utility/dictionary.py | 2 +- tests/test_compose.py | 3 + 17 files changed, 229 insertions(+), 201 deletions(-) create mode 100644 monai/transforms/transform.py diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index f178360031..80b0d1648d 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -17,7 +17,7 @@ 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.transform import MapTransform, Randomizable, Transform from monai.transforms.utils import generate_spatial_bounding_box from monai.utils import min_version, optional_import diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 6f7c2a4f61..357e00c6dd 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, @@ -234,6 +234,7 @@ ZoomD, ZoomDict, ) +from .transform import MapTransform, Randomizable, Transform from .utility.array import ( AddChannel, AddExtremePointsChannel, diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index 2d1fe4eccd..2d612ad2e3 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -13,135 +13,17 @@ """ 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 +# For backwards compatiblity (so this still works: from monai.transforms.compose import MapTransform) +from monai.transforms.transform import MapTransform # noqa: F401 +from monai.transforms.transform import Randomizable, Transform 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]``. - - 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. - - """ - raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") +__all__ = ["Compose"] class Compose(Randomizable, Transform): @@ -255,67 +137,3 @@ def __call__(self, input_): for _transform in self.transforms: input_ = apply_transform(_transform, input_) return input_ - - -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.") diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index b4444803a4..ef5e0019bd 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, Transform from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 1faed25605..20ae6ac1ed 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -21,7 +21,6 @@ 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 +30,7 @@ SpatialCrop, SpatialPad, ) +from monai.transforms.transform import MapTransform, Randomizable from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 87091f6237..40bef064eb 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 Randomizable, Transform from monai.transforms.utils import rescale_array from monai.utils import PT_BEFORE_1_7, InvalidPyTorchVersionError, dtype_torch_to_numpy, ensure_tuple_size diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 1c9b31c120..54a85a57b0 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, @@ -35,6 +34,7 @@ ShiftIntensity, ThresholdIntensity, ) +from monai.transforms.transform import MapTransform, Randomizable from monai.utils import dtype_torch_to_numpy, ensure_tuple_rep, ensure_tuple_size __all__ = [ diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 9c14f7a689..855621e432 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 diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index d3220aa682..55707f750e 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__ = [ 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..aff4ae3572 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -21,7 +21,6 @@ import torch from monai.config import KeysCollection -from monai.transforms.compose import MapTransform from monai.transforms.post.array import ( Activations, AsDiscrete, @@ -30,6 +29,7 @@ MeanEnsemble, VoteEnsemble, ) +from monai.transforms.transform import MapTransform from monai.utils import ensure_tuple_rep __all__ = [ diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index df10480188..0610982847 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 Randomizable, Transform from monai.transforms.utils import ( create_control_grid, create_grid, diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index e612a25ef8..8b546e5e97 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -22,7 +22,6 @@ from monai.config import DtypeLike, KeysCollection from monai.networks.layers.simplelayers import GaussianFilter -from monai.transforms.compose import MapTransform, Randomizable from monai.transforms.croppad.array import CenterSpatialCrop from monai.transforms.spatial.array import ( Flip, @@ -36,6 +35,7 @@ Spacing, Zoom, ) +from monai.transforms.transform import MapTransform, Randomizable from monai.transforms.utils import create_grid from monai.utils import ( GridSampleMode, diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py new file mode 100644 index 0000000000..e5841cbe97 --- /dev/null +++ b/monai/transforms/transform.py @@ -0,0 +1,206 @@ +# 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, Hashable, Optional, Tuple + +import numpy as np + +from monai.config import KeysCollection +from monai.utils import MAX_SEED, ensure_tuple + +__all__ = ["Randomizable", "Transform", "MapTransform"] + + +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]``. + + 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. + + """ + 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 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.") diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index c0ae40de59..8b161a9223 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -21,7 +21,7 @@ import torch from monai.config import DtypeLike, NdarrayTensor -from monai.transforms.compose import Randomizable, Transform +from monai.transforms.transform import Randomizable, Transform from monai.transforms.utils import extreme_points_to_image, get_extreme_points, map_binary_to_indices from monai.utils import ensure_tuple, min_version, optional_import diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index f374b82d76..c4bd7d4cba 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -23,7 +23,7 @@ import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor -from monai.transforms.compose import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, Randomizable from monai.transforms.utility.array import ( AddChannel, AsChannelFirst, diff --git a/tests/test_compose.py b/tests/test_compose.py index c049044a97..3a0a6ea5bb 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -167,6 +167,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, Randomizable, Transform # noqa: F401 + if __name__ == "__main__": unittest.main() From c98fe05e8afaee4477f705bff149af31eb4d66a4 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 24 Feb 2021 19:46:35 +0000 Subject: [PATCH 03/85] pad_list_data_collate (#1626) * pad_collation Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * increase number of test cases to ensure required testing errors Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * determinism in setUp Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * pad collate for list of lists Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * allow padding options Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/data/__init__.py | 1 + monai/data/utils.py | 80 ++++++++++++++++++++++++++++++- tests/test_pad_collation.py | 95 +++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/test_pad_collation.py diff --git a/monai/data/__init__.py b/monai/data/__init__.py index e0db1e17ae..99990d7f53 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -46,6 +46,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/utils.py b/monai/data/utils.py index acc6d2e97a..c42e1abefa 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -36,6 +36,7 @@ first, optional_import, ) +from monai.utils.enums import Method nib, _ = optional_import("nibabel") @@ -63,6 +64,7 @@ "json_hashing", "pickle_hashing", "sorted_dict", + "pad_list_data_collate", ] @@ -240,7 +242,83 @@ 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 "stack expects each tensor to be 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 pad_list_data_collate( + batch: Sequence, + method: Union[Method, str] = Method.SYMMETRIC, + mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, +): + """ + Same as MONAI's ``list_data_collate``, except any tensors are centrally padded to match the shape of the biggest + tensor in each dimension. + + Note: + Need to use this collate if apply some transforms that can generate batch data. + + 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`) + """ + list_of_dicts = isinstance(batch[0], dict) + for key_or_idx in batch[0].keys() if list_of_dicts else range(len(batch[0])): + 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 + 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: Union[SpatialPadd, SpatialPad] + if list_of_dicts: + from monai.transforms.croppad.dictionary import SpatialPadd # needs to be here to avoid circular import + + padder = SpatialPadd(key_or_idx, max_shape, method, mode) # type: ignore + + else: + from monai.transforms.croppad.array import SpatialPad # needs to be here to avoid circular import + + padder = SpatialPad(max_shape, method, mode) # type: ignore + + for idx in range(len(batch)): + padded = padder(batch[idx])[key_or_idx] if list_of_dicts else padder(batch[idx][key_or_idx]) + # since tuple is immutable we'll have to recreate + if isinstance(batch[idx], tuple): + batch[idx] = list(batch[idx]) # type: ignore + batch[idx][key_or_idx] = padded + batch[idx] = tuple(batch[idx]) # type: ignore + # else, replace + else: + batch[idx][key_or_idx] = padder(batch[idx])[key_or_idx] + + if output_to_tensor: + batch[idx][key_or_idx] = torch.Tensor(batch[idx][key_or_idx]) + + # After padding, use default list collator + return list_data_collate(batch) def worker_init_fn(worker_id: int) -> None: diff --git a/tests/test_pad_collation.py b/tests/test_pad_collation.py new file mode 100644 index 0000000000..156d2649e0 --- /dev/null +++ b/tests/test_pad_collation.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 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 pad_list_data_collate +from monai.transforms import ( + RandRotate, + RandRotate90, + RandRotate90d, + RandRotated, + RandSpatialCrop, + RandSpatialCropd, + RandZoom, + RandZoomd, +) +from monai.utils import set_determinism + +TESTS: List[Tuple] = [] + + +TESTS.append((dict, RandSpatialCropd("image", roi_size=[8, 7], random_size=True))) +TESTS.append((dict, RandRotated("image", prob=1, range_x=np.pi, keep_size=False))) +TESTS.append((dict, RandZoomd("image", prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) +TESTS.append((dict, RandRotate90d("image", prob=1, max_k=2))) + +TESTS.append((list, RandSpatialCrop(roi_size=[8, 7], random_size=True))) +TESTS.append((list, RandRotate(prob=1, range_x=np.pi, keep_size=False))) +TESTS.append((list, RandZoom(prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) +TESTS.append((list, 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, 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=2, collate_fn=pad_list_data_collate) + for _ in loader: + pass + + +if __name__ == "__main__": + unittest.main() From 69d9fcc311d405bb1eeccca42b4de03d48ccb3a5 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 25 Feb 2021 09:26:13 +0000 Subject: [PATCH 04/85] decollate batch (#1624) decollate batch --- monai/data/__init__.py | 1 + monai/data/utils.py | 69 +++++++++++++++++++++- monai/transforms/__init__.py | 3 + monai/transforms/post/dictionary.py | 23 ++++++++ tests/test_decollate.py | 88 +++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/test_decollate.py diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 99990d7f53..3dd0a980ef 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -38,6 +38,7 @@ compute_shape_offset, correct_nifti_header_if_necessary, create_file_basename, + decollate_batch, dense_patch_slices, get_random_patch, get_valid_patch_size, diff --git a/monai/data/utils.py b/monai/data/utils.py index c42e1abefa..7717ddf3aa 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -18,7 +18,7 @@ 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 @@ -37,6 +37,7 @@ optional_import, ) from monai.utils.enums import Method +from monai.utils.misc import issequenceiterable nib, _ = optional_import("nibabel") @@ -64,6 +65,7 @@ "json_hashing", "pickle_hashing", "sorted_dict", + "decollate_batch", "pad_list_data_collate", ] @@ -255,6 +257,71 @@ def list_data_collate(batch: Sequence): 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, diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 357e00c6dd..5578b93077 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -155,6 +155,9 @@ AsDiscreted, AsDiscreteD, AsDiscreteDict, + Decollated, + DecollateD, + DecollateDict, Ensembled, KeepLargestConnectedComponentd, KeepLargestConnectedComponentD, diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index aff4ae3572..85abdac0ac 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -20,6 +20,7 @@ import numpy as np import torch +import monai.data from monai.config import KeysCollection from monai.transforms.post.array import ( Activations, @@ -52,6 +53,9 @@ "MeanEnsembleDict", "VoteEnsembleD", "VoteEnsembleDict", + "DecollateD", + "DecollateDict", + "Decollated", ] @@ -306,9 +310,28 @@ 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: + 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/tests/test_decollate.py b/tests/test_decollate.py new file mode 100644 index 0000000000..5c6f04b48e --- /dev/null +++ b/tests/test_decollate.py @@ -0,0 +1,88 @@ +# 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.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.utils import optional_import, set_determinism +from tests.utils import make_nifti_image + +_, has_nib = optional_import("nibabel") + +IM_2D = create_test_image_2d(100, 101)[0] +DATA_2D = {"image": make_nifti_image(IM_2D) if has_nib else IM_2D} + +TESTS = [] +TESTS.append( + ( + "2D", + [DATA_2D for _ in range(6)], + ) +) + + +class TestDeCollate(unittest.TestCase): + def setUp(self) -> None: + set_determinism(seed=0) + + def tearDown(self) -> None: + set_determinism(None) + + def check_match(self, in1, in2): + if isinstance(in1, dict): + self.assertTrue(isinstance(in2, dict)) + self.check_match(list(in1.keys()), list(in2.keys())) + self.check_match(list(in1.values()), list(in2.values())) + elif any(isinstance(in1, i) for i in [list, tuple]): + for l1, l2 in zip(in1, in2): + self.check_match(l1, l2) + elif any(isinstance(in1, i) for i in [str, int]): + self.assertEqual(in1, in2) + elif any(isinstance(in1, i) for i in [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, _, data, batch_size=2, num_workers=2): + transforms = Compose( + [ + AddChanneld("image"), + SpatialPadd("image", 150), + RandFlipd("image", prob=1.0, spatial_axis=1), + ToTensord("image"), + ] + ) + # If nibabel present, read from disk + if has_nib: + transforms = Compose([LoadImaged("image"), transforms]) + + dataset = CacheDataset(data, transforms, progress=False) + loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, 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() From b2f1cf4cdfa712546d3db2a31f13d3e9b330f9cf Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 25 Feb 2021 11:45:40 +0000 Subject: [PATCH 05/85] enhance random range parameters (#1635) enhance random range parameters 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. Backwards compatibility is ensured because the old behaviour used to do `uniform[-rotate_range[i], rotate_range[i])`, and this PR just adds the possibility to do `uniform[-rotate_range[i][0], rotate_range[i][1])`. --- monai/transforms/spatial/array.py | 158 ++++++++++++------------- monai/transforms/spatial/dictionary.py | 97 +++++++-------- 2 files changed, 121 insertions(+), 134 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 0610982847..d6dbe56f01 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -42,6 +42,7 @@ ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, + issequenceiterable, optional_import, ) @@ -69,6 +70,8 @@ "Rand3DElastic", ] +RandRange = Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] + class Spacing(Transform): """ @@ -965,30 +968,25 @@ class RandAffineGrid(Randomizable, Transform): 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. @@ -1012,15 +1010,22 @@ def __init__( self.as_tensor_output = as_tensor_output self.device = device + 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 @@ -1282,11 +1287,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 +1301,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`. @@ -1404,11 +1404,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 +1421,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`. @@ -1532,11 +1531,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 +1550,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`. diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 8b546e5e97..2c66cd5f50 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -374,10 +374,10 @@ 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, @@ -394,21 +394,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 @@ -476,12 +471,12 @@ 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, @@ -502,17 +497,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 @@ -594,12 +588,12 @@ 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, @@ -621,19 +615,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 From 3c45f183ae5b80d052496a12751d794d1ba6b760 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Thu, 25 Feb 2021 17:23:36 +0000 Subject: [PATCH 06/85] Loss script fixes (#1645) * Fixing issues preventing loss functions from being compatible with Torchscript Signed-off-by: Eric Kerfoot * Updates Signed-off-by: Eric Kerfoot * Updates Signed-off-by: Eric Kerfoot * [MONAI] python code formatting Signed-off-by: monai-bot * Updates Signed-off-by: Eric Kerfoot * Adding conditional skip to Torchscript tests Signed-off-by: Eric Kerfoot Co-authored-by: monai-bot --- monai/losses/dice.py | 22 +++--- monai/losses/focal_loss.py | 4 +- monai/losses/tversky.py | 4 +- monai/networks/layers/simplelayers.py | 76 +++++++++++-------- monai/networks/utils.py | 9 +-- tests/test_dice_ce_loss.py | 7 ++ tests/test_dice_loss.py | 7 ++ tests/test_focal_loss.py | 7 ++ tests/test_generalized_dice_loss.py | 7 ++ .../test_generalized_wasserstein_dice_loss.py | 13 ++++ ...local_normalized_cross_correlation_loss.py | 6 ++ tests/test_multi_scale.py | 7 ++ tests/test_tversky_loss.py | 7 ++ 13 files changed, 126 insertions(+), 50 deletions(-) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index c284660cc6..24bd038b68 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 @@ -139,7 +139,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: # reducing spatial dimensions and batch reduce_axis = [0] + reduce_axis @@ -268,23 +268,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 +329,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..920661f76f 100644 --- a/monai/losses/focal_loss.py +++ b/monai/losses/focal_loss.py @@ -80,8 +80,8 @@ def forward(self, logits: torch.Tensor, target: torch.Tensor) -> torch.Tensor: i = logits t = target - if i.ndimension() != t.ndimension(): - raise ValueError(f"logits and target ndim must match, got logits={i.ndimension()} target={t.ndimension()}.") + if i.ndim != t.ndim: + raise ValueError(f"logits and target ndim must match, got logits={i.ndim} target={t.ndim}.") if t.shape[1] != 1 and t.shape[1] != i.shape[1]: raise ValueError( 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/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/utils.py b/monai/networks/utils.py index 847bfc97c2..48efe3934e 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -19,8 +19,6 @@ 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) 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_focal_loss.py b/tests/test_focal_loss.py index d06e2b4c36..2d1df602c7 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -16,6 +16,7 @@ import torch.nn.functional as F from monai.losses import FocalLoss +from tests.utils import SkipIfBeforePyTorchVersion, test_script_save class TestFocalLoss(unittest.TestCase): @@ -164,6 +165,12 @@ def test_ill_shape(self): 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__": unittest.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_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_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_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() From 9ae3005abd2ff73c8f9b04871994bae11b600d2f Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 26 Feb 2021 02:53:16 +0800 Subject: [PATCH 07/85] 1633 fix string length > 1024 issue in all_gather (#1634) * [DLMED] fix length > 1024 issue in string list all gather Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] add more test Signed-off-by: Nic Ma Co-authored-by: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> --- monai/handlers/utils.py | 28 +++++++++++++++++------- tests/test_handler_metrics_saver_dist.py | 15 +++++++++---- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index 3e36af0652..a0717169aa 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -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).item() + # 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( diff --git a/tests/test_handler_metrics_saver_dist.py b/tests/test_handler_metrics_saver_dist.py index 1b17d0adb4..dfdaa16526 100644 --- a/tests/test_handler_metrics_saver_dist.py +++ b/tests/test_handler_metrics_saver_dist.py @@ -12,6 +12,7 @@ import csv import os +import random import tempfile import unittest @@ -44,8 +45,13 @@ def _val_func(engine, batch): engine = Engine(_val_func) + # test the case that all_gather with string length > 1024 chars + filename_postfix = "abcdefghigklmnopqrstuvwxyz" + for _ in range(1100): + filename_postfix += filename_postfix[random.randint(0, 26)] + if dist.get_rank() == 0: - data = [{"image_meta_dict": {"filename_or_obj": ["filepath1"]}}] + data = [{"image_meta_dict": {"filename_or_obj": [f"1{filename_postfix}"]}}] @engine.on(Events.EPOCH_COMPLETED) def _save_metrics0(engine): @@ -58,8 +64,8 @@ def _save_metrics0(engine): 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"]}}, + {"image_meta_dict": {"filename_or_obj": [f"2{filename_postfix}"]}}, + {"image_meta_dict": {"filename_or_obj": [f"3{filename_postfix}"]}}, ] @engine.on(Events.EPOCH_COMPLETED) @@ -86,7 +92,8 @@ def _save_metrics1(engine): 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}"]) + expected = [f"{i}{filename_postfix[0: 1023]}\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: From f83e30019e97aa92d21b42af9df4af2049d3e125 Mon Sep 17 00:00:00 2001 From: leotam Date: Thu, 25 Feb 2021 12:46:07 -0800 Subject: [PATCH 08/85] 1567 add RemoveRepeatedChannel (#1569) * DeleteChannel array, dictionary, tests Signed-off-by: Leo Tam * formatting Signed-off-by: Leo Tam * DeleteChannel array, dictionary, tests Signed-off-by: Leo Tam * Register in transform init Signed-off-by: Leo Tam * clean comment Signed-off-by: Leo Tam * Dict init Signed-off-by: Leo Tam * Register D and Dict Signed-off-by: Leo Tam * Style fix Signed-off-by: Leo Tam * Safety cast Signed-off-by: Leo Tam * Changinging names from DeleteChannel to RemoveRepeatedChannel Signed-off-by: Leo Tam * Lint fixes Signed-off-by: Leo Tam * Test import fix, autofix again Signed-off-by: Leo Tam --- monai/transforms/__init__.py | 4 +++ monai/transforms/utility/array.py | 27 ++++++++++++++++++++ monai/transforms/utility/dictionary.py | 27 ++++++++++++++++++++ tests/test_remove_repeated_channel.py | 30 +++++++++++++++++++++++ tests/test_remove_repeated_channeld.py | 34 ++++++++++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 tests/test_remove_repeated_channel.py create mode 100644 tests/test_remove_repeated_channeld.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 5578b93077..3499afcf95 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -250,6 +250,7 @@ Identity, LabelToMask, Lambda, + RemoveRepeatedChannel, RepeatChannel, SimulateDelay, SplitChannel, @@ -305,6 +306,9 @@ RandLambdad, RandLambdaD, RandLambdaDict, + RemoveRepeatedChanneld, + RemoveRepeatedChannelD, + RemoveRepeatedChannelDict, RepeatChanneld, RepeatChannelD, RepeatChannelDict, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 8b161a9223..fb9ae3c089 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -31,6 +31,7 @@ "AsChannelLast", "AddChannel", "RepeatChannel", + "RemoveRepeatedChannel", "SplitChannel", "CastToType", "ToTensor", @@ -161,6 +162,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. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index c4bd7d4cba..83426734eb 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -35,6 +35,7 @@ Identity, LabelToMask, Lambda, + RemoveRepeatedChannel, RepeatChannel, SimulateDelay, SplitChannel, @@ -52,6 +53,7 @@ "AsChannelLastd", "AddChanneld", "RepeatChanneld", + "RemoveRepeatedChanneld", "SplitChanneld", "CastToTyped", "ToTensord", @@ -82,6 +84,8 @@ "RandLambdaDict", "RepeatChannelD", "RepeatChannelDict", + "RemoveRepeatedChannelD", + "RemoveRepeatedChannelDict", "SplitChannelD", "SplitChannelDict", "CastToTypeD", @@ -226,6 +230,28 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d +class RemoveRepeatedChanneld(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.RemoveRepeatedChannel`. + """ + + def __init__(self, keys: KeysCollection, repeats: int) -> 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. + """ + super().__init__(keys) + self.repeater = RemoveRepeatedChannel(repeats) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.keys: + d[key] = self.repeater(d[key]) + return d + + class SplitChanneld(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.SplitChannel`. @@ -836,6 +862,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc AsChannelFirstD = AsChannelFirstDict = AsChannelFirstd AsChannelLastD = AsChannelLastDict = AsChannelLastd AddChannelD = AddChannelDict = AddChanneld +RemoveRepeatedChannelD = RemoveRepeatedChannelDict = RemoveRepeatedChanneld RepeatChannelD = RepeatChannelDict = RepeatChanneld SplitChannelD = SplitChannelDict = SplitChanneld CastToTypeD = CastToTypeDict = CastToTyped 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() From c68d25e7cb964a54db49835c697e042231dfc3c2 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 26 Feb 2021 06:04:53 +0800 Subject: [PATCH 09/85] [DLMED] add progress arg (#1647) Signed-off-by: Nic Ma Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/data/dataset.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/monai/data/dataset.py b/monai/data/dataset.py index b93f03151f..db1ecc2b1f 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -591,6 +591,7 @@ def __init__( cache_rate: float = 1.0, num_init_workers: Optional[int] = None, num_replace_workers: int = 0, + progress: bool = True, ) -> None: """ Args: @@ -605,8 +606,10 @@ def __init__( 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. + progress: whether to display a progress bar. + """ - 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): From 5875ae6f80f56ee18ac14c51530b38081c1fe2e3 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 25 Feb 2021 23:58:30 +0000 Subject: [PATCH 10/85] 1650-update distcall (#1652) * update distcall Signed-off-by: Wenqi Li * update final closing Signed-off-by: Wenqi Li Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/handlers/utils.py | 1 - tests/test_distcall.py | 29 +++++ tests/test_handler_metrics_saver_dist.py | 154 +++++++++++------------ tests/utils.py | 17 ++- 4 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 tests/test_distcall.py diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index a0717169aa..9ed13d292c 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -163,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/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_handler_metrics_saver_dist.py b/tests/test_handler_metrics_saver_dist.py index dfdaa16526..0868ec5ff3 100644 --- a/tests/test_handler_metrics_saver_dist.py +++ b/tests/test_handler_metrics_saver_dist.py @@ -12,7 +12,6 @@ import csv import os -import random import tempfile import unittest @@ -28,85 +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) - - # test the case that all_gather with string length > 1024 chars - filename_postfix = "abcdefghigklmnopqrstuvwxyz" - for _ in range(1100): - filename_postfix += filename_postfix[random.randint(0, 26)] - - if dist.get_rank() == 0: - data = [{"image_meta_dict": {"filename_or_obj": [f"1{filename_postfix}"]}}] - - @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": [f"2{filename_postfix}"]}}, - {"image_meta_dict": {"filename_or_obj": [f"3{filename_postfix}"]}}, - ] - - @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"{i}{filename_postfix[0: 1023]}\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"))) + 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/utils.py b/tests/utils.py index 4597a18fbd..8b367158b2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -237,7 +237,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 @@ -286,11 +290,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) From 56c54025541a6f73f3f70df70da2022dd5da4c9c Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Thu, 25 Feb 2021 20:04:23 -0500 Subject: [PATCH 11/85] Fix ToPIL (#1648) * Add ToPIL transformation Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add ToPILd, ToPILD, ToPILDict Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove has_pil Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Include ToPIL, ToPILd, ToPILD, and ToPILDict Also include ToNumpyD, ToNumpyDict, TorchVisionD, and TorchVisionDict Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix a typing issue Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix PIL optional import Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add unittests for ToPIL and ToPILD Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix formatting Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix formatting Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add PILImage.Image as the input for ToNumpy and ToTensor Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix type checking issue for PIL.Image.Image Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Change PILImage_fromarray to lower case Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/transforms/__init__.py | 8 ++++ monai/transforms/utility/array.py | 31 ++++++++++-- monai/transforms/utility/dictionary.py | 46 +++++++++++++++--- tests/test_to_pil.py | 64 +++++++++++++++++++++++++ tests/test_to_pild.py | 65 ++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 tests/test_to_pil.py create mode 100644 tests/test_to_pild.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 3499afcf95..f5c7c826e9 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -256,6 +256,7 @@ SplitChannel, SqueezeDim, ToNumpy, + ToPIL, TorchVision, ToTensor, Transpose, @@ -323,7 +324,14 @@ SqueezeDimD, SqueezeDimDict, ToNumpyd, + ToNumpyD, + ToNumpyDict, + ToPILd, + ToPILD, + ToPILDict, TorchVisiond, + TorchVisionD, + TorchVisionDict, ToTensord, ToTensorD, ToTensorDict, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index fb9ae3c089..0ee88e1a6c 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -15,7 +15,7 @@ import logging import time -from typing import Callable, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -25,6 +25,15 @@ 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", @@ -265,7 +274,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. """ @@ -279,7 +288,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. """ @@ -288,6 +297,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. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 83426734eb..f9612c2408 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -17,7 +17,7 @@ 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 @@ -41,11 +41,19 @@ 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", @@ -58,6 +66,7 @@ "CastToTyped", "ToTensord", "ToNumpyd", + "ToPILd", "DeleteItemsd", "SelectItemsd", "SqueezeDimd", @@ -348,8 +357,8 @@ def __init__(self, keys: KeysCollection) -> None: 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: d[key] = self.converter(d[key]) @@ -371,8 +380,31 @@ def __init__(self, keys: KeysCollection) -> None: 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: + 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) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + """ + super().__init__(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.keys: d[key] = self.converter(d[key]) @@ -867,6 +899,8 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc 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/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() From 1df63523934ce42704f2fd305d17c7045f3efd7f Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 26 Feb 2021 13:07:48 +0000 Subject: [PATCH 12/85] Randomizable constructor (#1639) add RandomizableTransform --- docs/source/transforms.rst | 5 ++ monai/apps/deepgrow/transforms.py | 6 +- monai/data/dataset.py | 13 ++-- monai/data/image_dataset.py | 5 +- monai/transforms/__init__.py | 2 +- monai/transforms/compose.py | 9 ++- monai/transforms/croppad/array.py | 10 +-- monai/transforms/croppad/dictionary.py | 22 ++++--- monai/transforms/intensity/array.py | 55 +++++++--------- monai/transforms/intensity/dictionary.py | 67 +++++++++---------- monai/transforms/spatial/array.py | 67 ++++++++----------- monai/transforms/spatial/dictionary.py | 83 ++++++++++++------------ monai/transforms/transform.py | 50 +++++++++----- monai/transforms/utility/array.py | 4 +- monai/transforms/utility/dictionary.py | 10 +-- tests/test_compose.py | 14 ++-- tests/test_image_dataset.py | 4 +- tests/test_rand_lambdad.py | 4 +- tests/test_randomizable.py | 2 +- 19 files changed, 217 insertions(+), 215 deletions(-) diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 5b550f7885..a144c8c138 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -27,6 +27,11 @@ Generic Interfaces .. autoclass:: Randomizable :members: +`RandomizableTransform` +^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: RandomizableTransform + :members: + `Compose` ^^^^^^^^^ .. autoclass:: Compose diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index 80b0d1648d..3f4031fade 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -17,7 +17,7 @@ from monai.config import IndexSelection, KeysCollection from monai.networks.layers import GaussianFilter from monai.transforms import SpatialCrop -from monai.transforms.transform import MapTransform, Randomizable, Transform +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 @@ -62,7 +62,7 @@ def __call__(self, data): return d -class AddInitialSeedPointd(Randomizable, Transform): +class AddInitialSeedPointd(RandomizableTransform): """ Add random guidance as initial seed point for a given label. @@ -279,7 +279,7 @@ 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. diff --git a/monai/data/dataset.py b/monai/data/dataset.py index db1ecc2b1f..bb5a98ba1e 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -26,6 +26,7 @@ from monai.data.utils import 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: @@ -161,7 +162,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 +184,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 @@ -522,7 +523,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 +540,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 @@ -924,9 +925,9 @@ 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] 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/transforms/__init__.py b/monai/transforms/__init__.py index f5c7c826e9..8b30d76bec 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -237,7 +237,7 @@ ZoomD, ZoomDict, ) -from .transform import MapTransform, Randomizable, Transform +from .transform import MapTransform, Randomizable, RandomizableTransform, Transform from .utility.array import ( AddChannel, AddExtremePointsChannel, diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index 2d612ad2e3..a9f66b12a0 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -18,15 +18,14 @@ import numpy as np # For backwards compatiblity (so this still works: from monai.transforms.compose import MapTransform) -from monai.transforms.transform import MapTransform # noqa: F401 -from monai.transforms.transform import Randomizable, Transform +from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform, Transform # noqa: F401 from monai.transforms.utils import apply_transform from monai.utils import MAX_SEED, ensure_tuple, get_seed __all__ = ["Compose"] -class Compose(Randomizable, Transform): +class Compose(RandomizableTransform): """ ``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 @@ -96,14 +95,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) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index ef5e0019bd..a3d36ad903 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -20,7 +20,7 @@ from monai.config import IndexSelection from monai.data.utils import get_random_patch, get_valid_patch_size -from monai.transforms.transform import Randomizable, Transform +from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, @@ -276,7 +276,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 +321,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 +429,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 +481,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/dictionary.py b/monai/transforms/croppad/dictionary.py index 20ae6ac1ed..9739c6322f 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -30,7 +30,7 @@ SpatialCrop, SpatialPad, ) -from monai.transforms.transform import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform from monai.transforms.utils import ( generate_pos_neg_label_crop_centers, generate_spatial_bounding_box, @@ -258,7 +258,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandSpatialCropd(Randomizable, MapTransform): +class RandSpatialCropd(RandomizableTransform, MapTransform): """ 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 @@ -283,7 +283,8 @@ def __init__( random_center: bool = True, random_size: bool = True, ) -> None: - super().__init__(keys) + RandomizableTransform.__init__(self) + MapTransform.__init__(self, keys) self.roi_size = roi_size self.random_center = random_center self.random_size = random_size @@ -312,7 +313,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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. @@ -344,7 +345,8 @@ def __init__( random_center: bool = True, random_size: bool = True, ) -> None: - super().__init__(keys) + RandomizableTransform.__init__(self) + MapTransform.__init__(self, keys) if num_samples < 1: raise ValueError(f"num_samples must be positive, got {num_samples}.") self.num_samples = num_samples @@ -420,7 +422,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandWeightedCropd(Randomizable, MapTransform): +class RandWeightedCropd(RandomizableTransform, MapTransform): """ Samples a list of `num_samples` image patches according to the provided `weight_map`. @@ -446,7 +448,8 @@ def __init__( num_samples: int = 1, center_coord_key: Optional[str] = None, ): - super().__init__(keys) + RandomizableTransform.__init__(self) + MapTransform.__init__(self, keys) self.spatial_size = ensure_tuple(spatial_size) self.w_key = w_key self.num_samples = int(num_samples) @@ -484,7 +487,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n 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 @@ -534,7 +537,8 @@ def __init__( fg_indices_key: Optional[str] = None, bg_indices_key: Optional[str] = None, ) -> None: - super().__init__(keys) + RandomizableTransform.__init__(self) + MapTransform.__init__(self, keys) self.label_key = label_key self.spatial_size: Union[Tuple[int, ...], Sequence[int], int] = spatial_size if pos < 0 or neg < 0: diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 40bef064eb..1bddc0137d 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.transform 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 @@ -49,7 +49,7 @@ ] -class RandGaussianNoise(Randomizable, Transform): +class RandGaussianNoise(RandomizableTransform): """ Add Gaussian noise to image. @@ -60,14 +60,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 +100,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 +112,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 +120,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: """ @@ -172,7 +169,7 @@ 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]). @@ -186,6 +183,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 +191,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 +238,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 +365,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 +378,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 +391,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 +651,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 +673,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 +741,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 +775,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 +784,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 +807,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 +819,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 +831,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 54a85a57b0..7d0d66d2ba 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -34,7 +34,7 @@ ShiftIntensity, ThresholdIntensity, ) -from monai.transforms.transform import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, RandomizableTransform from monai.utils import dtype_torch_to_numpy, ensure_tuple_rep, ensure_tuple_size __all__ = [ @@ -92,7 +92,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. @@ -108,15 +108,14 @@ class RandGaussianNoised(Randomizable, MapTransform): def __init__( self, keys: KeysCollection, prob: float = 0.1, mean: Union[Sequence[float], float] = 0.0, std: float = 0.1 ) -> None: - super().__init__(keys) - self.prob = prob + MapTransform.__init__(self, 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)) @@ -158,7 +157,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandShiftIntensityd(Randomizable, MapTransform): +class RandShiftIntensityd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandShiftIntensity`. """ @@ -173,7 +172,8 @@ def __init__(self, keys: KeysCollection, offsets: Union[Tuple[float, float], flo prob: probability of rotating. (Default 0.1, with 10% probability it returns a rotated array.) """ - super().__init__(keys) + MapTransform.__init__(self, keys) + RandomizableTransform.__init__(self, prob) if isinstance(offsets, (int, float)): self.offsets = (min(-offsets, offsets), max(-offsets, offsets)) @@ -182,12 +182,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) @@ -229,7 +226,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandScaleIntensityd(Randomizable, MapTransform): +class RandScaleIntensityd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandScaleIntensity`. """ @@ -245,7 +242,8 @@ def __init__(self, keys: KeysCollection, factors: Union[Tuple[float, float], flo (Default 0.1, with 10% probability it returns a rotated array.) """ - super().__init__(keys) + MapTransform.__init__(self, keys) + RandomizableTransform.__init__(self, prob) if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) @@ -254,12 +252,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) @@ -382,7 +377,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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: @@ -400,8 +395,8 @@ class RandAdjustContrastd(Randomizable, MapTransform): def __init__( self, keys: KeysCollection, prob: float = 0.1, gamma: Union[Tuple[float, float], float] = (0.5, 4.5) ) -> None: - super().__init__(keys) - self.prob: float = prob + MapTransform.__init__(self, keys) + RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): if gamma <= 0.5: @@ -414,11 +409,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]: @@ -529,7 +523,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandGaussianSmoothd(Randomizable, MapTransform): +class RandGaussianSmoothd(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.GaussianSmooth`. @@ -554,16 +548,15 @@ def __init__( approx: str = "erf", prob: float = 0.1, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, 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]) @@ -616,7 +609,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandGaussianSharpend(Randomizable, MapTransform): +class RandGaussianSharpend(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.GaussianSharpen`. @@ -652,7 +645,8 @@ def __init__( approx: str = "erf", prob: float = 0.1, ): - super().__init__(keys) + MapTransform.__init__(self, keys) + RandomizableTransform.__init__(self, prob) self.sigma1_x = sigma1_x self.sigma1_y = sigma1_y self.sigma1_z = sigma1_z @@ -661,11 +655,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]) @@ -689,7 +681,7 @@ def __call__(self, data): 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. @@ -706,7 +698,8 @@ class RandHistogramShiftd(Randomizable, MapTransform): def __init__( self, keys: KeysCollection, num_control_points: Union[Tuple[int, int], int] = 10, prob: float = 0.1 ) -> None: - super().__init__(keys) + MapTransform.__init__(self, 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 +710,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) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index d6dbe56f01..3559d0eb3c 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -23,7 +23,7 @@ from monai.data.utils import compute_shape_offset, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.transforms.croppad.array import CenterSpatialCrop -from monai.transforms.transform import Randomizable, Transform +from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import ( create_control_grid, create_grid, @@ -604,7 +604,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`. @@ -619,16 +619,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, 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, img: np.ndarray) -> np.ndarray: """ @@ -642,7 +641,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return rotator(img) -class RandRotate(Randomizable, Transform): +class RandRotate(RandomizableTransform): """ Randomly rotate the input arrays. @@ -682,6 +681,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]])) @@ -692,20 +692,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]) @@ -747,7 +745,7 @@ def __call__( return rotator(img) -class RandFlip(Randomizable, Transform): +class RandFlip(RandomizableTransform): """ Randomly flips the image along axes. Preserves shape. See numpy.flip for additional details. @@ -759,25 +757,21 @@ 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, min(max(prob, 0.0), 1.0)) 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 RandZoom(RandomizableTransform): """ Randomly zooms input arrays with given probability within given zoom range. @@ -816,21 +810,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__( @@ -961,7 +954,7 @@ def __call__( return np.asarray(grid.cpu().numpy()) -class RandAffineGrid(Randomizable, Transform): +class RandAffineGrid(RandomizableTransform): """ Generate randomised affine grid. """ @@ -1050,7 +1043,7 @@ def __call__( return affine_grid(spatial_size, grid) -class RandDeformGrid(Randomizable, Transform): +class RandDeformGrid(RandomizableTransform): """ Generate random deformation grid. """ @@ -1279,7 +1272,7 @@ def __call__( ) -class RandAffine(Randomizable, Transform): +class RandAffine(RandomizableTransform): """ Random affine transform. """ @@ -1331,6 +1324,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 +1340,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 +1348,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 +1376,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 +1385,7 @@ def __call__( ) -class Rand2DElastic(Randomizable, Transform): +class Rand2DElastic(RandomizableTransform): """ Random elastic deformation and affine in 2D """ @@ -1451,6 +1442,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 ) @@ -1467,8 +1459,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 @@ -1479,7 +1469,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() @@ -1505,7 +1495,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 @@ -1521,7 +1511,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 """ @@ -1580,6 +1570,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) @@ -1590,8 +1581,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 @@ -1604,8 +1593,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]) @@ -1634,7 +1623,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 2c66cd5f50..6693d75bcd 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -35,7 +35,7 @@ Spacing, Zoom, ) -from monai.transforms.transform import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import create_grid from monai.utils import ( GridSampleMode, @@ -274,7 +274,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandRotate90d(Randomizable, MapTransform): +class RandRotate90d(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRotate90`. With probability `prob`, input arrays are rotated by 90 degrees @@ -299,28 +299,26 @@ def __init__( 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. """ - super().__init__(keys) + MapTransform.__init__(self, 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]) + if self._do_transform: + d[key] = rotator(d[key]) return d @@ -364,7 +362,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandAffined(Randomizable, MapTransform): +class RandAffined(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.RandAffine`. """ @@ -420,9 +418,10 @@ def __init__( - :py:class:`monai.transforms.compose.MapTransform` - :py:class:`RandAffineGrid` for the random affine parameters configurations. """ - super().__init__(keys) + MapTransform.__init__(self, 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, @@ -442,6 +441,7 @@ def set_random_state( return self def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) self.rand_affine.randomize() def __call__( @@ -451,7 +451,7 @@ 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) else: grid = create_grid(spatial_size=sp_size) @@ -461,7 +461,7 @@ def __call__( return d -class Rand2DElasticd(Randomizable, MapTransform): +class Rand2DElasticd(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rand2DElastic`. """ @@ -523,11 +523,12 @@ def __init__( - :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) + 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, @@ -547,6 +548,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__( @@ -557,7 +559,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 @@ -578,7 +580,7 @@ def __call__( return d -class Rand3DElasticd(Randomizable, MapTransform): +class Rand3DElasticd(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rand3DElastic`. """ @@ -641,11 +643,12 @@ def __init__( - :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) + 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, @@ -665,6 +668,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__( @@ -675,7 +679,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) @@ -713,7 +717,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandFlipd(Randomizable, MapTransform): +class RandFlipd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandFlip`. @@ -732,23 +736,18 @@ def __init__( prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int], int]] = None, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, 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() + self.randomize(None) d = dict(data) - if not self._do_transform: - return d for key in self.keys: - d[key] = self.flipper(d[key]) + if self._do_transform: + d[key] = self.flipper(d[key]) return d @@ -810,7 +809,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandRotated(Randomizable, MapTransform): +class RandRotated(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRotate` Randomly rotates the input arrays. @@ -857,7 +856,8 @@ def __init__( align_corners: Union[Sequence[bool], bool] = False, dtype: Union[Sequence[DtypeLike], DtypeLike] = np.float64, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, 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]])) @@ -868,20 +868,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]) @@ -957,7 +955,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class RandZoomd(Randomizable, MapTransform): +class RandZoomd(RandomizableTransform, MapTransform): """ Dict-based version :py:class:`monai.transforms.RandZoom`. @@ -1000,23 +998,22 @@ def __init__( align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, ) -> None: - super().__init__(keys) + MapTransform.__init__(self, 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]: diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index e5841cbe97..9c9729d250 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -20,25 +20,13 @@ from monai.config import KeysCollection from monai.utils import MAX_SEED, ensure_tuple -__all__ = ["Randomizable", "Transform", "MapTransform"] +__all__ = ["Randomizable", "RandomizableTransform", "Transform", "MapTransform"] 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() @@ -77,7 +65,6 @@ def set_random_state( 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. @@ -89,7 +76,6 @@ def randomize(self, data: Any) -> None: Raises: NotImplementedError: When the subclass does not override this method. - """ raise NotImplementedError(f"Subclass {self.__class__.__name__} must implement this method.") @@ -142,6 +128,40 @@ def __call__(self, data: Any): 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=1.0, do_transform=False): + 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 diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 0ee88e1a6c..24d2feb781 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -21,7 +21,7 @@ import torch from monai.config import DtypeLike, NdarrayTensor -from monai.transforms.transform 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 @@ -631,7 +631,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return np.stack(result, axis=0).astype(np.float32) -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 f9612c2408..e9d923d0fd 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -23,7 +23,7 @@ import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor -from monai.transforms.transform import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utility.array import ( AddChannel, AsChannelFirst, @@ -682,9 +682,9 @@ def __call__(self, data): 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: @@ -800,7 +800,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class AddExtremePointsChanneld(Randomizable, MapTransform): +class AddExtremePointsChanneld(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AddExtremePointsChannel`. @@ -828,7 +828,7 @@ def __init__( rescale_min: float = -1.0, rescale_max: float = 1.0, ): - super().__init__(keys) + MapTransform.__init__(self, keys) self.background = background self.pert = pert self.points: List[Tuple[int, ...]] = [] diff --git a/tests/test_compose.py b/tests/test_compose.py index 3a0a6ea5bb..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() @@ -168,7 +172,7 @@ def test_flatten_and_len(self): self.assertEqual(len(t1), 8) def test_backwards_compatible_imports(self): - from monai.transforms.compose import MapTransform, Randomizable, Transform # noqa: F401 + from monai.transforms.compose import MapTransform, RandomizableTransform, Transform # noqa: F401 if __name__ == "__main__": 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_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_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): From 4b8819aa2d26f96dc2d23d3159e56cc413d3e846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuan-Ting=20Hsieh=20=28=E8=AC=9D=E6=B2=85=E5=BB=B7=29?= Date: Fri, 26 Feb 2021 16:13:21 -0800 Subject: [PATCH 13/85] Add more tests to test_deepgrow_dataset (#1654) * Add more tests to test_deepgrow_dataset Signed-off-by: YuanTingHsieh --- monai/apps/deepgrow/dataset.py | 6 +- tests/test_deepgrow_dataset.py | 109 ++++++++++++++++++++++++--------- 2 files changed, 82 insertions(+), 33 deletions(-) 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/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__": From 39fd45544673f06a5752980c08f0680e723a50f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuan-Ting=20Hsieh=20=28=E8=AC=9D=E6=B2=85=E5=BB=B7=29?= Date: Mon, 1 Mar 2021 08:07:49 -0800 Subject: [PATCH 14/85] Add Deepgrow inference transforms (#1653) * Add inference transforms Signed-off-by: YuanTingHsieh * Remove unused import Signed-off-by: YuanTingHsieh * Fix review comments Signed-off-by: YuanTingHsieh Co-authored-by: SACHIDANAND ALLE --- docs/source/apps.rst | 10 + monai/apps/deepgrow/transforms.py | 494 ++++++++++++++++++++++++++++-- tests/test_deepgrow_transforms.py | 234 ++++++++++++++ 3 files changed, 715 insertions(+), 23 deletions(-) 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/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index 3f4031fade..cc01a717ad 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 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!") @@ -67,7 +66,8 @@ 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) @@ -283,7 +287,7 @@ 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(RandomizableTransform): 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. @@ -457,8 +472,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) @@ -477,3 +492,436 @@ def __call__(self, data): 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. + """ + + 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", + ) -> None: + super().__init__(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.keys: + 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 + 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 + + 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. + """ + + 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", + ) -> None: + super().__init__(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 idx, key in enumerate(self.keys): + 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=self.mode[idx]) + image = resizer(image, mode=self.mode[idx], align_corners=self.align_corners[idx]) + + # 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=self.mode[idx]) + result = resizer(result, mode=self.mode[idx], align_corners=self.align_corners[idx]) + + # 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. + """ + + def __init__(self, keys, guidance="guidance", axis: int = 0, meta_key_postfix: str = "meta_dict"): + super().__init__(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.keys: + 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/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() From f5ccdc6bcd9696da9c4763910143f12835dca954 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 2 Mar 2021 02:40:59 +0800 Subject: [PATCH 15/85] 1668 Add RandAxisFlip transforms (#1670) * [DLMED] add RandAxisFlip transforms Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issues Signed-off-by: Nic Ma Co-authored-by: monai-bot --- docs/source/transforms.rst | 12 +++++++++ monai/transforms/__init__.py | 4 +++ monai/transforms/spatial/array.py | 32 ++++++++++++++++++++++ monai/transforms/spatial/dictionary.py | 37 ++++++++++++++++++++++++++ tests/test_rand_axis_flip.py | 32 ++++++++++++++++++++++ tests/test_rand_axis_flipd.py | 32 ++++++++++++++++++++++ 6 files changed, 149 insertions(+) create mode 100644 tests/test_rand_axis_flip.py create mode 100644 tests/test_rand_axis_flipd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index a144c8c138..00d8cb9053 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -314,6 +314,12 @@ Spatial :members: :special-members: __call__ +`RandAxisFlip` +"""""""""""""" +.. autoclass:: RandAxisFlip + :members: + :special-members: __call__ + `RandZoom` """""""""" .. autoclass:: RandZoom @@ -791,6 +797,12 @@ Spatial (Dict) :members: :special-members: __call__ +`RandAxisFlipd` +""""""""""""""" +.. autoclass:: RandAxisFlipd + :members: + :special-members: __call__ + `Rotated` """"""""" .. autoclass:: Rotated diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 8b30d76bec..cd5b195bd3 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -181,6 +181,7 @@ Rand3DElastic, RandAffine, RandAffineGrid, + RandAxisFlip, RandDeformGrid, RandFlip, RandRotate, @@ -209,6 +210,9 @@ RandAffined, RandAffineD, RandAffineDict, + RandAxisFlipd, + RandAxisFlipD, + RandAxisFlipDict, RandFlipd, RandFlipD, RandFlipDict, diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 3559d0eb3c..2867361b8e 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -59,6 +59,7 @@ "RandRotate90", "RandRotate", "RandFlip", + "RandAxisFlip", "RandZoom", "AffineGrid", "RandAffineGrid", @@ -771,6 +772,37 @@ def __call__(self, img: np.ndarray) -> np.ndarray: return self.flipper(img) +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. diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 6693d75bcd..f29258bf28 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -58,6 +58,7 @@ "Rand3DElasticd", "Flipd", "RandFlipd", + "RandAxisFlipd", "Rotated", "RandRotated", "Zoomd", @@ -82,6 +83,8 @@ "FlipDict", "RandFlipD", "RandFlipDict", + "RandAxisFlipD", + "RandAxisFlipDict", "RotateD", "RotateDict", "RandRotateD", @@ -751,6 +754,39 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d +class RandAxisFlipd(RandomizableTransform, MapTransform): + """ + 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. + + """ + + def __init__(self, keys: KeysCollection, prob: float = 0.1) -> None: + MapTransform.__init__(self, 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(data=data[self.keys[0]]) + flipper = Flip(spatial_axis=self._axis) + + d = dict(data) + for key in self.keys: + if self._do_transform: + d[key] = flipper(d[key]) + return d + + class Rotated(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rotate`. @@ -1051,6 +1087,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda Rand3DElasticD = Rand3DElasticDict = Rand3DElasticd FlipD = FlipDict = Flipd RandFlipD = RandFlipDict = RandFlipd +RandAxisFlipD = RandAxisFlipDict = RandAxisFlipd RotateD = RotateDict = Rotated RandRotateD = RandRotateDict = RandRotated ZoomD = ZoomDict = Zoomd 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() From 20e17b2fcec0a878a51c5b8e947413a699c4da61 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 2 Mar 2021 12:05:41 +0000 Subject: [PATCH 16/85] allow dictionary image only and test endianness (#1669) fix big endianness problem for dictionary reading. Also allow dictionary image_only --- monai/transforms/io/array.py | 24 ++++++++++++++++ monai/transforms/io/dictionary.py | 28 +++++++++++------- tests/test_nifti_endianness.py | 48 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 tests/test_nifti_endianness.py diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 855621e432..9c4f631699 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -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 = (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)): + pass + else: + raise AssertionError() + return data + + class LoadImage(Transform): """ Load image file or files from provided path based on reader. @@ -132,6 +153,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 diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 55707f750e..d9b6b5e6ab 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -59,6 +59,7 @@ def __init__( dtype: DtypeLike = np.float32, meta_key_postfix: str = "meta_dict", overwriting: bool = False, + image_only: bool = False, *args, **kwargs, ) -> None: @@ -76,11 +77,13 @@ def __init__( 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. 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) + 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 @@ -98,15 +101,20 @@ def __call__(self, data, reader: Optional[ImageReader] = None): d = dict(data) for key in self.keys: 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 diff --git a/tests/test_nifti_endianness.py b/tests/test_nifti_endianness.py new file mode 100644 index 0000000000..14317c0832 --- /dev/null +++ b/tests/test_nifti_endianness.py @@ -0,0 +1,48 @@ +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.transforms import LoadImage, LoadImaged +from monai.utils.module import optional_import + +if TYPE_CHECKING: + import nibabel as nib + + has_nib = True +else: + nib, has_nib = optional_import("nibabel") + +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)) + + +if __name__ == "__main__": + unittest.main() From 9898a89d24364a9be3525d066a7492adf00b9e6b Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 3 Mar 2021 00:34:55 +0800 Subject: [PATCH 17/85] 1673 Add AutoAdjustChannel transform (#1675) * [DLMED] add AutoAdjustChannel transform Signed-off-by: Nic Ma * [DLMED] add dict version transform Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix doc-build issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] update according to Wenqi's comments Signed-off-by: Nic Ma * [DLMED] update doc-strings Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- docs/source/transforms.rst | 12 ++++ monai/data/image_reader.py | 39 ++++++++---- monai/transforms/__init__.py | 4 ++ monai/transforms/utility/array.py | 29 ++++++++- monai/transforms/utility/dictionary.py | 31 ++++++++++ tests/min_tests.py | 2 + tests/test_ensure_channel_first.py | 86 ++++++++++++++++++++++++++ tests/test_ensure_channel_firstd.py | 62 +++++++++++++++++++ 8 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 tests/test_ensure_channel_first.py create mode 100644 tests/test_ensure_channel_firstd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 00d8cb9053..dd10176de9 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -437,6 +437,12 @@ Utility :members: :special-members: __call__ +`EnsureChannelFirst` +"""""""""""""""""""" +.. autoclass:: EnsureChannelFirst + :members: + :special-members: __call__ + `RepeatChannel` """"""""""""""" .. autoclass:: RepeatChannel @@ -890,6 +896,12 @@ Utility (Dict) :members: :special-members: __call__ +`EnsureChannelFirstd` +""""""""""""""""""""" +.. autoclass:: EnsureChannelFirstd + :members: + :special-members: __call__ + `RepeatChanneld` """""""""""""""" .. autoclass:: RepeatChanneld diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index e458833979..dfbdaf5b41 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -109,6 +109,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 +211,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: """ @@ -265,6 +277,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 +384,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 +422,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 +519,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 +597,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 +624,5 @@ 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)) diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index cd5b195bd3..a8d647b657 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -250,6 +250,7 @@ CastToType, ConvertToMultiChannelBasedOnBratsClasses, DataStats, + EnsureChannelFirst, FgBgToIndices, Identity, LabelToMask, @@ -296,6 +297,9 @@ DeleteItemsd, DeleteItemsD, DeleteItemsDict, + EnsureChannelFirstd, + EnsureChannelFirstD, + EnsureChannelFirstDict, FgBgToIndicesd, FgBgToIndicesD, FgBgToIndicesDict, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 24d2feb781..62daf9309c 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -15,7 +15,7 @@ import logging import time -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -39,6 +39,7 @@ "AsChannelFirst", "AsChannelLast", "AddChannel", + "EnsureChannelFirst", "RepeatChannel", "RemoveRepeatedChannel", "SplitChannel", @@ -149,6 +150,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. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index e9d923d0fd..4a0808fdbb 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -31,6 +31,7 @@ CastToType, ConvertToMultiChannelBasedOnBratsClasses, DataStats, + EnsureChannelFirst, FgBgToIndices, Identity, LabelToMask, @@ -60,6 +61,7 @@ "AsChannelFirstd", "AsChannelLastd", "AddChanneld", + "EnsureChannelFirstd", "RepeatChanneld", "RemoveRepeatedChanneld", "SplitChanneld", @@ -89,6 +91,8 @@ "AsChannelLastDict", "AddChannelD", "AddChannelDict", + "EnsureChannelFirstD", + "EnsureChannelFirstDict", "RandLambdaD", "RandLambdaDict", "RepeatChannelD", @@ -217,6 +221,32 @@ def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, Nda 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`. @@ -894,6 +924,7 @@ 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 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/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() From 421c0a9c3453672c608a4dc06ddbfe3d7b939c1a Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 3 Mar 2021 12:44:36 +0000 Subject: [PATCH 18/85] [1662] Allow missing keys (#1676) Allow missing keys --- monai/apps/deepgrow/transforms.py | 40 +++-- monai/transforms/croppad/dictionary.py | 131 +++++++++------ monai/transforms/intensity/dictionary.py | 163 +++++++++++++----- monai/transforms/io/dictionary.py | 12 +- monai/transforms/post/dictionary.py | 45 +++-- monai/transforms/spatial/dictionary.py | 158 +++++++++++------- monai/transforms/transform.py | 33 +++- monai/transforms/utility/dictionary.py | 202 +++++++++++++---------- 8 files changed, 510 insertions(+), 274 deletions(-) diff --git a/monai/apps/deepgrow/transforms.py b/monai/apps/deepgrow/transforms.py index cc01a717ad..644507092d 100644 --- a/monai/apps/deepgrow/transforms.py +++ b/monai/apps/deepgrow/transforms.py @@ -437,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__( @@ -452,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) @@ -482,7 +484,7 @@ 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 @@ -629,6 +631,7 @@ class SpatialCropGuidanced(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__( @@ -642,8 +645,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.guidance = guidance self.spatial_size = list(spatial_size) @@ -697,7 +701,7 @@ def __call__(self, data): cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) box_start, box_end = cropper.roi_start, cropper.roi_end - for key in self.keys: + 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}" @@ -804,6 +808,7 @@ class RestoreLabeld(MapTransform): 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__( @@ -818,8 +823,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.ref_image = ref_image self.slice_only = slice_only self.mode = ensure_tuple_rep(mode, len(self.keys)) @@ -834,15 +840,15 @@ def __call__(self, data): d = dict(data) meta_dict: Dict = d[f"{self.ref_image}_{self.meta_key_postfix}"] - for idx, key in enumerate(self.keys): + 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=self.mode[idx]) - image = resizer(image, mode=self.mode[idx], align_corners=self.align_corners[idx]) + 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] @@ -862,8 +868,8 @@ def __call__(self, data): spatial_size = spatial_shape[-len(current_size) :] if np.any(np.not_equal(current_size, spatial_size)): - resizer = Resize(spatial_size=spatial_size, mode=self.mode[idx]) - result = resizer(result, mode=self.mode[idx], align_corners=self.align_corners[idx]) + 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") @@ -898,10 +904,18 @@ class Fetch2DSliced(MapTransform): 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"): - super().__init__(keys) + 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 @@ -920,7 +934,7 @@ def __call__(self, data): guidance = d[self.guidance] if len(guidance) < 3: raise RuntimeError("Guidance does not container slice_idx!") - for key in self.keys: + 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 diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 9739c6322f..823b2dd3f4 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -94,6 +94,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,15 +109,16 @@ 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): d[key] = self.padder(d[key], mode=m) return d @@ -132,6 +134,7 @@ def __init__( keys: KeysCollection, spatial_border: Union[Sequence[int], int], mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -153,15 +156,16 @@ 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): d[key] = self.padder(d[key], mode=m) return d @@ -173,7 +177,11 @@ class DivisiblePadd(MapTransform): """ 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,17 +195,18 @@ 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): d[key] = self.padder(d[key], mode=m) return d @@ -216,6 +225,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,13 +235,14 @@ 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): d[key] = self.cropper(d[key]) return d @@ -245,15 +256,18 @@ 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): d[key] = self.cropper(d[key]) return d @@ -274,6 +288,7 @@ class RandSpatialCropd(RandomizableTransform, MapTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. + allow_missing_keys: don't raise exception if key is missing. """ def __init__( @@ -282,9 +297,10 @@ def __init__( roi_size: Union[Sequence[int], int], random_center: bool = True, random_size: bool = True, + allow_missing_keys: bool = False, ) -> None: RandomizableTransform.__init__(self) - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) self.roi_size = roi_size self.random_center = random_center self.random_size = random_size @@ -304,7 +320,7 @@ 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: d[key] = d[key][self._slices] else: @@ -331,6 +347,7 @@ class RandSpatialCropSamplesd(RandomizableTransform, MapTransform): random_center: crop at random position as center or the image center. random_size: crop with random size or specific size ROI. The actual size is sampled from `randint(roi_size, img_size)`. + allow_missing_keys: don't raise exception if key is missing. Raises: ValueError: When ``num_samples`` is nonpositive. @@ -344,13 +361,14 @@ def __init__( num_samples: int, random_center: bool = True, random_size: bool = True, + allow_missing_keys: bool = False, ) -> None: RandomizableTransform.__init__(self) - MapTransform.__init__(self, keys) + 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 @@ -388,6 +406,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: @@ -400,8 +419,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 @@ -417,7 +437,7 @@ 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): d[key] = cropper(d[key]) return d @@ -435,6 +455,7 @@ class RandWeightedCropd(RandomizableTransform, 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` @@ -447,9 +468,10 @@ def __init__( spatial_size: Union[Sequence[int], int], num_samples: int = 1, center_coord_key: Optional[str] = None, + allow_missing_keys: bool = False, ): RandomizableTransform.__init__(self) - MapTransform.__init__(self, keys) + 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) @@ -467,22 +489,22 @@ 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 @@ -517,6 +539,7 @@ class RandCropByPosNegLabeld(RandomizableTransform, MapTransform): `image_threshold`, and randomly select crop centers based on them, need to provide `fg_indices_key` and `bg_indices_key` together, expect to be 1 dim array of spatial indices after flattening. a typical usage is to call `FgBgToIndicesd` transform first and cache the results. + allow_missing_keys: don't raise exception if key is missing. Raises: ValueError: When ``pos`` or ``neg`` are negative. @@ -536,9 +559,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: RandomizableTransform.__init__(self) - MapTransform.__init__(self, keys) + 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: @@ -583,15 +607,15 @@ 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 @@ -609,6 +633,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. """ @@ -617,13 +642,14 @@ 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): d[key] = self.padcropper(d[key]) return d @@ -638,10 +664,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 @@ -650,7 +683,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/dictionary.py b/monai/transforms/intensity/dictionary.py index 7d0d66d2ba..4602d59379 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -103,12 +103,18 @@ class RandGaussianNoised(RandomizableTransform, 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: - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.mean = ensure_tuple_rep(mean, len(self.keys)) self.std = std @@ -129,7 +135,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 @@ -140,19 +146,20 @@ 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 @@ -162,7 +169,13 @@ 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. @@ -171,8 +184,9 @@ 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. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) if isinstance(offsets, (int, float)): @@ -192,7 +206,7 @@ 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 @@ -205,7 +219,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: float = 0.0, + maxv: float = 1.0, + factor: Optional[float] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -214,14 +233,15 @@ def __init__( minv: minimum value of output data. maxv: maximum value of output data. factor: factor scale by ``v = v * (1 + factor)``. + 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 @@ -231,7 +251,13 @@ 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. @@ -240,9 +266,10 @@ 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. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) if isinstance(factors, (int, float)): @@ -262,7 +289,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 @@ -282,6 +309,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__( @@ -292,13 +320,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 @@ -313,15 +342,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 @@ -338,17 +375,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 @@ -364,15 +409,16 @@ 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 @@ -390,12 +436,17 @@ class RandAdjustContrastd(RandomizableTransform, 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: - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): @@ -423,7 +474,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 @@ -441,6 +492,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__( @@ -452,13 +504,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 @@ -477,6 +530,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. """ @@ -485,14 +539,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 @@ -509,16 +564,23 @@ 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 @@ -536,6 +598,7 @@ class RandGaussianSmoothd(RandomizableTransform, 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. """ @@ -547,8 +610,9 @@ def __init__( sigma_z: Tuple[float, float] = (0.25, 1.5), approx: str = "erf", prob: float = 0.1, + allow_missing_keys: bool = False, ) -> None: - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.sigma_x = sigma_x self.sigma_y = sigma_y @@ -566,7 +630,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 @@ -588,6 +652,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. """ @@ -598,13 +663,14 @@ 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 @@ -629,6 +695,7 @@ class RandGaussianSharpend(RandomizableTransform, 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. """ @@ -644,8 +711,9 @@ def __init__( alpha: Tuple[float, float] = (10.0, 30.0), approx: str = "erf", prob: float = 0.1, + allow_missing_keys: bool = False, ): - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.sigma1_x = sigma1_x self.sigma1_y = sigma1_y @@ -674,7 +742,7 @@ 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]) @@ -693,12 +761,17 @@ class RandHistogramShiftd(RandomizableTransform, 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: - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) if isinstance(num_control_points, int): if num_control_points <= 2: @@ -726,7 +799,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 diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index d9b6b5e6ab..ea965255d5 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -60,6 +60,7 @@ def __init__( meta_key_postfix: str = "meta_dict", overwriting: bool = False, image_only: bool = False, + allow_missing_keys: bool = False, *args, **kwargs, ) -> None: @@ -79,10 +80,11 @@ def __init__( 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) + 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__}.") @@ -99,7 +101,7 @@ 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 self._loader.image_only: if not isinstance(data, np.ndarray): @@ -163,6 +165,7 @@ 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. """ @@ -180,8 +183,9 @@ def __init__( dtype: DtypeLike = np.float64, output_dtype: DtypeLike = np.float32, save_batch: bool = False, + allow_missing_keys: bool = False, ) -> None: - super().__init__(keys) + super().__init__(keys, allow_missing_keys) self.meta_key_postfix = meta_key_postfix self._saver = SaveImage( output_dir=output_dir, @@ -198,7 +202,7 @@ def __init__( 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/dictionary.py b/monai/transforms/post/dictionary.py index 85abdac0ac..42796e2412 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -71,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: @@ -83,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)) @@ -93,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 @@ -111,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: @@ -126,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)) @@ -138,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 @@ -161,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: @@ -175,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 @@ -192,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 @@ -221,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: @@ -229,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 @@ -249,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 diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index f29258bf28..a81aeb432b 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -125,6 +125,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: @@ -160,12 +161,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)) @@ -179,17 +181,19 @@ 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): + 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 = d[f"{key}_{self.meta_key_postfix}"] # resample array of each corresponding key # using affine fetched from d[affine_key] d[key], _, new_affine = self.spacing_transform( data_array=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, ) # set the 'affine' key meta_data["affine"] = new_affine @@ -214,6 +218,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: @@ -230,6 +235,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``. @@ -238,7 +244,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__}.") @@ -248,7 +254,7 @@ 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: + for key in self.key_iterator(d): meta_data = d[f"{key}_{self.meta_key_postfix}"] d[key], _, new_affine = self.ornt_transform(d[key], affine=meta_data["affine"]) meta_data["affine"] = new_affine @@ -260,19 +266,22 @@ class Rotate90d(MapTransform): 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): d[key] = self.rotator(d[key]) return d @@ -290,6 +299,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: @@ -301,8 +311,9 @@ 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. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.max_k = max_k @@ -319,7 +330,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Mapping[Hashable, np. d = dict(data) rotator = Rotate90(self._rand_k, self.spatial_axes) - for key in self.keys: + for key in self.key_iterator(d): if self._do_transform: d[key] = rotator(d[key]) return d @@ -344,6 +355,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__( @@ -352,16 +364,17 @@ 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): + d[key] = self.resizer(d[key], mode=mode, align_corners=align_corners) return d @@ -383,6 +396,7 @@ def __init__( padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, as_tensor_output: bool = True, device: Optional[torch.device] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -416,12 +430,13 @@ 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. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.rand_affine = RandAffine( prob=1.0, # because probability handled in this class @@ -459,8 +474,8 @@ def __call__( else: grid = create_grid(spatial_size=sp_size) - 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): + d[key] = self.rand_affine.resampler(d[key], grid, mode=mode, padding_mode=padding_mode) return d @@ -484,6 +499,7 @@ def __init__( padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -521,12 +537,13 @@ 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. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.rand_2d_elastic = Rand2DElastic( spacing=spacing, @@ -576,10 +593,8 @@ 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 @@ -603,6 +618,7 @@ def __init__( padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION, as_tensor_output: bool = False, device: Optional[torch.device] = None, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -641,12 +657,13 @@ 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. """ - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.rand_3d_elastic = Rand3DElastic( sigma_range=sigma_range, @@ -690,10 +707,8 @@ 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 @@ -707,15 +722,21 @@ 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): d[key] = self.flipper(d[key]) return d @@ -731,6 +752,7 @@ class RandFlipd(RandomizableTransform, 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__( @@ -738,8 +760,9 @@ def __init__( keys: KeysCollection, prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int], int]] = None, + allow_missing_keys: bool = False, ) -> None: - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self.spatial_axis = spatial_axis @@ -748,7 +771,7 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: self.randomize(None) d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): if self._do_transform: d[key] = self.flipper(d[key]) return d @@ -764,11 +787,12 @@ class RandAxisFlipd(RandomizableTransform, MapTransform): 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) -> None: - MapTransform.__init__(self, keys) + 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 @@ -781,7 +805,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda flipper = Flip(spatial_axis=self._axis) d = dict(data) - for key in self.keys: + for key in self.key_iterator(d): if self._do_transform: d[key] = flipper(d[key]) return d @@ -812,6 +836,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__( @@ -823,8 +848,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)) @@ -834,13 +860,15 @@ 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 + ): 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, ) return d @@ -877,6 +905,7 @@ class RandRotated(RandomizableTransform, 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__( @@ -891,8 +920,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: - MapTransform.__init__(self, 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: @@ -929,13 +959,15 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda angle=self.x if d[self.keys[0]].ndim == 3 else (self.x, self.y, self.z), 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 + ): 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, ) return d @@ -962,6 +994,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__( @@ -972,8 +1005,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)) @@ -981,12 +1015,14 @@ 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 + ): 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 @@ -1021,6 +1057,7 @@ class RandZoomd(RandomizableTransform, 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__( @@ -1033,8 +1070,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: - MapTransform.__init__(self, 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) @@ -1067,12 +1105,14 @@ 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): + for key, mode, padding_mode, align_corners in self.key_iterator( + d, self.mode, self.padding_mode, self.align_corners + ): d[key] = 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 diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 9c9729d250..7a09efa6d5 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -13,7 +13,7 @@ """ from abc import ABC, abstractmethod -from typing import Any, Hashable, Optional, Tuple +from typing import Any, Dict, Generator, Hashable, Iterable, List, Optional, Tuple import numpy as np @@ -178,7 +178,7 @@ def __call__(self, data): if key in data: # update output data with some_transform_function(data[key]). else: - # do nothing or some exceptions handling. + # raise exception unless allow_missing_keys==True. return data Raises: @@ -187,8 +187,9 @@ def __call__(self, data): """ - def __init__(self, keys: KeysCollection) -> None: + 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: @@ -224,3 +225,29 @@ def __call__(self, data): """ 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/dictionary.py b/monai/transforms/utility/dictionary.py index 4a0808fdbb..14f34fb663 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -137,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 @@ -161,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 @@ -183,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 @@ -205,18 +208,19 @@ 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 @@ -252,19 +256,20 @@ 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 @@ -274,19 +279,20 @@ class RemoveRepeatedChanneld(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.RemoveRepeatedChannel`. """ - 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 = RemoveRepeatedChannel(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 @@ -303,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: @@ -316,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) @@ -326,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): @@ -348,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: @@ -356,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() @@ -366,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 @@ -377,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, 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 @@ -400,20 +411,21 @@ 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, 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 @@ -423,20 +435,21 @@ class ToPILd(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 = 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.keys: + for key in self.key_iterator(d): d[key] = self.converter(d[key]) return d @@ -448,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): @@ -458,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 @@ -467,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 @@ -498,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: @@ -517,9 +532,10 @@ 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 + 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)) @@ -530,14 +546,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 @@ -547,23 +565,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 @@ -574,7 +595,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. @@ -584,13 +607,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 @@ -609,13 +633,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 @@ -626,19 +651,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 @@ -654,7 +680,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): @@ -690,6 +716,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__( @@ -697,17 +724,18 @@ 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 @@ -745,6 +773,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. """ @@ -753,13 +782,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 @@ -781,6 +811,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. """ @@ -792,8 +823,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 @@ -802,7 +834,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 @@ -819,13 +851,13 @@ 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 @@ -845,6 +877,7 @@ class AddExtremePointsChanneld(RandomizableTransform, 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. """ @@ -857,8 +890,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, ): - MapTransform.__init__(self, keys) + MapTransform.__init__(self, keys, allow_missing_keys) self.background = background self.pert = pert self.points: List[Tuple[int, ...]] = [] @@ -879,17 +913,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 @@ -900,22 +933,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 From 27543c883c97d1a35f5a3d035ba1fd608b51f6a0 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 3 Mar 2021 14:06:30 +0000 Subject: [PATCH 19/85] nifti saver squeeze dims (#1680) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/data/nifti_saver.py | 14 ++++++++++++++ monai/transforms/io/array.py | 8 ++++++++ monai/transforms/io/dictionary.py | 8 ++++++++ tests/test_nifti_saver.py | 24 ++++++++++++++++++++++++ 4 files changed, 54 insertions(+) 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/transforms/io/array.py b/monai/transforms/io/array.py index 9c4f631699..4ede04cf69 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -165,6 +165,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`. @@ -200,6 +202,10 @@ 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). """ @@ -215,6 +221,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"): @@ -227,6 +234,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 ea965255d5..8a428e1118 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -124,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` @@ -166,6 +168,10 @@ class SaveImaged(MapTransform): 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). """ @@ -184,6 +190,7 @@ def __init__( output_dtype: DtypeLike = np.float32, save_batch: bool = False, allow_missing_keys: bool = False, + squeeze_end_dims: bool = True, ) -> None: super().__init__(keys, allow_missing_keys) self.meta_key_postfix = meta_key_postfix @@ -198,6 +205,7 @@ def __init__( dtype=dtype, output_dtype=output_dtype, save_batch=save_batch, + squeeze_end_dims=squeeze_end_dims, ) def __call__(self, data): 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() From b533afdb5c9b401cf860ec4b110fd58c11e4d851 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 3 Mar 2021 15:21:21 +0000 Subject: [PATCH 20/85] improve gradcam doc (#1682) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/visualize/class_activation_maps.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index a917bcf800..624d054500 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -212,6 +212,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` @@ -307,6 +313,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` From 7cfb52970f5b4a2ddfdf81a2d517e1d5ca90309a Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 3 Mar 2021 16:50:46 +0000 Subject: [PATCH 21/85] fixes integration tests (#1683) Signed-off-by: Wenqi Li --- tests/test_integration_sliding_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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__": From 4bd9cf34df514515a7c93c0a29685c681409f3a6 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 4 Mar 2021 03:58:13 +0800 Subject: [PATCH 22/85] Update load pretrain for densenet (#1684) * Update load pretrain for densenet Signed-off-by: Yiheng Wang * Fix isort issue Signed-off-by: Yiheng Wang --- monai/networks/nets/densenet.py | 4 ++-- tests/test_densenet.py | 41 ++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/monai/networks/nets/densenet.py b/monai/networks/nets/densenet.py index ad1d1d6e5f..a59ab99e68 100644 --- a/monai/networks/nets/densenet.py +++ b/monai/networks/nets/densenet.py @@ -210,14 +210,14 @@ def _load_state_dict(model, model_url, progress): `_ """ 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] diff --git a/tests/test_densenet.py b/tests/test_densenet.py index 876689314a..41b5fbf7d6 100644 --- a/tests/test_densenet.py +++ b/tests/test_densenet.py @@ -10,14 +10,25 @@ # 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 monai.utils import optional_import from tests.utils import skip_if_quick, test_pretrained_networks, test_script_save +if TYPE_CHECKING: + import torchvision + + has_torchvision = True +else: + torchvision, has_torchvision = optional_import("torchvision") + + device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_1 = [ # 4-channel 3D, batch 2 @@ -50,27 +61,45 @@ TEST_PRETRAINED_2D_CASE_1 = [ # 4-channel 2D, batch 2 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), + {"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): + def test_121_2d_shape_pretrain(self, model, input_param, input_shape, expected_shape): net = test_pretrained_networks(model, input_param, 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 = test_pretrained_networks(model, input_param, 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) From 8cc200fcd3650cee92740c4d6cd16afddfb71ded Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 3 Mar 2021 21:08:48 +0000 Subject: [PATCH 23/85] revise the dtype according to the discussion (#1671) Signed-off-by: Wenqi Li --- monai/transforms/utility/array.py | 2 +- tests/test_convert_to_multi_channel.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 62daf9309c..8776238711 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -655,7 +655,7 @@ 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(RandomizableTransform): 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__": From ac9f953c9f69b106dbc09e9d7214dd13d8ecc822 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 4 Mar 2021 20:35:54 +0800 Subject: [PATCH 24/85] Fix senet pretrained weights issue (#1689) Signed-off-by: Yiheng Wang --- monai/networks/nets/senet.py | 4 ++-- tests/test_senet.py | 32 +++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py index 655ff203c7..ef67f853d6 100644 --- a/monai/networks/nets/senet.py +++ b/monai/networks/nets/senet.py @@ -275,7 +275,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 +285,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] diff --git a/tests/test_senet.py b/tests/test_senet.py index 883d75d62d..c1327ceb7d 100644 --- a/tests/test_senet.py +++ b/tests/test_senet.py @@ -10,6 +10,8 @@ # limitations under the License. import unittest +from typing import TYPE_CHECKING +from unittest import skipUnless import torch from parameterized import parameterized @@ -23,8 +25,17 @@ se_resnext101_32x4d, senet154, ) +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} @@ -56,11 +67,7 @@ def test_script(self, net, net_args): class TestPretrainedSENET(unittest.TestCase): - @parameterized.expand( - [ - TEST_CASE_PRETRAINED, - ] - ) + @parameterized.expand([TEST_CASE_PRETRAINED]) 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 +77,21 @@ def test_senet_shape(self, model, input_param): result = net(input_data) self.assertEqual(result.shape, expected_shape) + @parameterized.expand([TEST_CASE_PRETRAINED]) + @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() From 380f042dec9c55ba1a5ab241ae6b1a7b3e2b07fb Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 4 Mar 2021 21:54:36 +0800 Subject: [PATCH 25/85] 1680 Add squeeze SegmentationSaver handler (#1686) * [DLMED] add suqeeze to handler Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/handlers/segmentation_saver.py | 7 +++++++ monai/transforms/io/array.py | 1 + monai/transforms/io/dictionary.py | 1 + 3 files changed, 9 insertions(+) diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index a46918b893..56370fd41c 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -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/transforms/io/array.py b/monai/transforms/io/array.py index 4ede04cf69..a256a16ec8 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -206,6 +206,7 @@ class SaveImage(Transform): 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. """ diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 8a428e1118..50ab8f9868 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -172,6 +172,7 @@ class SaveImaged(MapTransform): 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. """ From 114faf079cd119de868c4419deeebd618b259c91 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 4 Mar 2021 16:07:41 +0000 Subject: [PATCH 26/85] 1285 adds module list (#1690) * fixes #1285 Signed-off-by: Wenqi Li * adds test Signed-off-by: Wenqi Li --- monai/__init__.py | 16 ++++++++++++++++ tests/test_module_list.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 tests/test_module_list.py 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/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() From 0bd86b1424e9ef2d8584812ba52e13d88ebf2dde Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 4 Mar 2021 23:41:54 +0000 Subject: [PATCH 27/85] 1685-upgrade base image to 21.02 (#1687) * fixes #1685 Signed-off-by: Wenqi Li * add temp test Signed-off-by: Wenqi Li * adds docstring Signed-off-by: Wenqi Li * fixes dist sampler Signed-off-by: Wenqi Li * remove temp tests Signed-off-by: Wenqi Li * fixes type hint issue Signed-off-by: Wenqi Li --- .github/workflows/cron.yml | 2 +- Dockerfile | 5 +++-- monai/data/utils.py | 4 ---- monai/networks/utils.py | 6 +++--- tests/test_distributed_sampler.py | 2 ++ tests/utils.py | 7 +++++++ 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index e568ba9e15..98834dbc8f 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -56,7 +56,7 @@ 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: diff --git a/Dockerfile b/Dockerfile index 47976b97b1..c90558c970 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" diff --git a/monai/data/utils.py b/monai/data/utils.py index 7717ddf3aa..60250af441 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -922,10 +922,6 @@ class DistributedSampler(_TorchDistributedSampler): """ 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: diff --git a/monai/networks/utils.py b/monai/networks/utils.py index 48efe3934e..bd25e358f6 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -14,7 +14,7 @@ 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 @@ -86,10 +86,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/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/utils.py b/tests/utils.py index 8b367158b2..3636cbe974 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 @@ -273,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( @@ -283,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) From 75b577224ab7843c31e2012b8da1aa275e34d4b4 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 5 Mar 2021 00:52:22 +0000 Subject: [PATCH 28/85] fixes data type in switching (#1694) * fixes data type in switching Signed-off-by: Wenqi Li * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/transforms/io/array.py | 2 +- tests/test_nifti_endianness.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index a256a16ec8..de9a8800a2 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -42,7 +42,7 @@ def switch_endianness(data, old, new): if data.dtype.byteorder == old: data = data.newbyteorder(new) elif isinstance(data, tuple): - data = (switch_endianness(x, old, new) for x in data) + 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): diff --git a/tests/test_nifti_endianness.py b/tests/test_nifti_endianness.py index 14317c0832..d8adb1efb2 100644 --- a/tests/test_nifti_endianness.py +++ b/tests/test_nifti_endianness.py @@ -8,6 +8,7 @@ from monai.data import DataLoader, Dataset, create_test_image_2d from monai.transforms import LoadImage, LoadImaged +from monai.transforms.io.array import switch_endianness from monai.utils.module import optional_import if TYPE_CHECKING: @@ -43,6 +44,11 @@ def test_endianness(self, endianness, use_array, image_only): 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)) + if __name__ == "__main__": unittest.main() From 889c9f941270915b0594701c58020c7a92c3b057 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 5 Mar 2021 00:08:28 -0500 Subject: [PATCH 29/85] WSI reader (#1548) * Implement CuImageReader and OpenSlideReader Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add unittests for CuImageReader Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add unittests for OpenSlideReader Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Sort imports Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add correct boundaries Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add test cases for reading patches on a grid for CuImage Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add patch whole slide imaging dataset for pathology Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add test case for read patches for OpenSlide Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * flake8 and few minor changes Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * black Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * flake8 Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add kwargs to CuImageReader and OpenSlideReader's read method Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Change the type hint from np.dtype to DTypeLike Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix a bug Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Implement WSIReader and unittests Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Minor updates Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix few typing issues Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Revert datasets Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add shape property to openslide image object Reverse size to be compatible with output size (hxw) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add untittest for loading the whole image Reverse the size accroding to the WSIReader Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update the whole image size Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove optional size Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove optional dtype Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove _get_spatial_shape return type Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Reverse the orders of dimensions of `location` to be compatible with image shape Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Change test cases to use smaller image and revese location's dimensions Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Replace the test TIFF and some upgrades Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update dependencies for OpenSlide Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update unittests for OpenSlide and CuImage Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix openslide dependency Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix doc dependencies Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Minor changes Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Few variable name changes Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add EnsureChannelFirst Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add metadata to WSIReader Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- docs/requirements.txt | 1 + docs/source/data.rst | 4 + monai/data/__init__.py | 2 +- monai/data/image_reader.py | 161 ++++++++++++++++++++++++++++++++- requirements-dev.txt | 1 + setup.cfg | 3 + tests/test_cuimage_reader.py | 103 +++++++++++++++++++++ tests/test_openslide_reader.py | 103 +++++++++++++++++++++ 8 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 tests/test_cuimage_reader.py create mode 100644 tests/test_openslide_reader.py diff --git a/docs/requirements.txt b/docs/requirements.txt index d046bc53cf..cd06166359 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,7 @@ pytorch-ignite==0.4.2 numpy>=1.17 itk>=5.0 nibabel +openslide-python==1.1.2 parameterized scikit-image>=0.14.2 tensorboard diff --git a/docs/source/data.rst b/docs/source/data.rst index 11609964c3..eed4b30ded 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -105,6 +105,10 @@ PILReader .. autoclass:: PILReader :members: +WSIReader +~~~~~~~~~ +.. autoclass:: WSIReader + :members: Nifti format handling --------------------- diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 3dd0a980ef..54ee7908f4 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -24,7 +24,7 @@ from .decathlon_datalist import load_decathlon_datalist, load_decathlon_properties from .grid_dataset import GridPatchDataset, PatchDataset 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 .iterable_dataset import IterableDataset from .nifti_saver import NiftiSaver from .nifti_writer import write_nifti diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index dfbdaf5b41..76bf1817dc 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 cuimage 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_cux = 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") + cuimage, has_cux = optional_import("cuimage") + openslide, has_osl = optional_import("openslide") -__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader"] +__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader"] class ImageReader(ABC): @@ -264,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: """ @@ -626,3 +631,151 @@ def _get_spatial_shape(self, img) -> np.ndarray: """ # 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 + + """ + + def __init__(self, reader_lib: str = "cuClaraImage"): + super().__init__() + self.reader_lib = reader_lib.lower() + if self.reader_lib == "openslide": + self.wsi_reader = openslide.OpenSlide + print("> OpenSlide is being used.") + elif self.reader_lib == "cuclaraimage": + self.wsi_reader = cuimage.CuImage + print("> CuImage is being used.") + else: + raise ValueError('`reader_lib` should be either "cuClaraImage" 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. + + """ + 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/requirements-dev.txt b/requirements-dev.txt index 2a43e63d73..3eeab474b6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -30,3 +30,4 @@ Sphinx==3.3.0 recommonmark==0.6.0 sphinx-autodoc-typehints==1.11.1 sphinx-rtd-theme==0.5.0 +openslide-python==1.1.2 diff --git a/setup.cfg b/setup.cfg index ea61eadd92..f18b4610fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ all = torchvision itk>=5.0 tqdm>=4.47.0 + openslide-python==1.1.2 nibabel = nibabel skimage = @@ -54,6 +55,8 @@ lmdb = lmdb psutil = psutil +openslide = + openslide-python==1.1.2 [flake8] select = B,C,E,F,N,P,T4,W,B9 diff --git a/tests/test_cuimage_reader.py b/tests/test_cuimage_reader.py new file mode 100644 index 0000000000..7cdf692a30 --- /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_cui = optional_import("cuimage") + + +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 TestCuClaraImageReader(unittest.TestCase): + @parameterized.expand([TEST_CASE_0]) + @skipUnless(has_cui, "Requires CuClaraImage") + def test_read_whole_image(self, file_url, expected_shape): + filename = self.camelyon_data_download(file_url) + reader = WSIReader("CuClaraImage") + 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_cui, "Requires CuClaraImage") + def test_read_region(self, file_url, patch_info, expected_img): + filename = self.camelyon_data_download(file_url) + reader = WSIReader("CuClaraImage") + 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_cui, "Requires CuClaraImage") + def test_read_patches(self, file_url, patch_info, expected_img): + filename = self.camelyon_data_download(file_url) + reader = WSIReader("CuClaraImage") + 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_openslide_reader.py b/tests/test_openslide_reader.py new file mode 100644 index 0000000000..e1f9187937 --- /dev/null +++ b/tests/test_openslide_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_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]]]]), +] + + +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 = self.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 = self.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 = self.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)) + + 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() From 232e8a524c7074ceae4c45b756bfddc67772c05f Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 5 Mar 2021 21:47:56 +0800 Subject: [PATCH 30/85] [DLMED] add WSIReader to LoadImage (#1695) Signed-off-by: Nic Ma --- monai/data/image_reader.py | 10 ++++++---- monai/transforms/io/array.py | 7 ++++--- monai/transforms/io/dictionary.py | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 76bf1817dc..16e8514f48 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -643,11 +643,13 @@ def __init__(self, reader_lib: str = "cuClaraImage"): super().__init__() self.reader_lib = reader_lib.lower() if self.reader_lib == "openslide": - self.wsi_reader = openslide.OpenSlide - print("> OpenSlide is being used.") + if has_osl: + self.wsi_reader = openslide.OpenSlide + print("> OpenSlide is being used.") elif self.reader_lib == "cuclaraimage": - self.wsi_reader = cuimage.CuImage - print("> CuImage is being used.") + if has_cux: + self.wsi_reader = cuimage.CuImage + print("> CuImage is being used.") else: raise ValueError('`reader_lib` should be either "cuClaraImage" or "OpenSlide"') diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index de9a8800a2..002bfd8242 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -19,7 +19,7 @@ import torch from monai.config import DtypeLike -from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader +from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader, WSIReader from monai.data.nifti_saver import NiftiSaver from monai.data.png_saver import PNGSaver from monai.transforms.transform import Transform @@ -78,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", "WSIReader". 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. @@ -90,7 +90,7 @@ def __init__( """ # set predefined readers as default - self.readers: List[ImageReader] = [ITKReader(), NumpyReader(), PILReader(), NibabelReader()] + self.readers: List[ImageReader] = [ITKReader(), NumpyReader(), PILReader(), NibabelReader(), WSIReader()] if reader is not None: if isinstance(reader, str): supported_readers = { @@ -98,6 +98,7 @@ def __init__( "pilreader": PILReader, "itkreader": ITKReader, "numpyreader": NumpyReader, + "wsireader": WSIReader, } reader = reader.lower() if reader not in supported_readers: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 50ab8f9868..7f663ea303 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -71,7 +71,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", "WSIReader". 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. From 66811fee151224996ac1500ef7cc91e389d7ef70 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Sun, 7 Mar 2021 10:19:12 -0500 Subject: [PATCH 31/85] Raise import error for openslide and cuimage (#1699) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/data/image_reader.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 16e8514f48..39fc1f46b4 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -639,7 +639,7 @@ class WSIReader(ImageReader): """ - def __init__(self, reader_lib: str = "cuClaraImage"): + def __init__(self, reader_lib: str = "OpenSlide"): super().__init__() self.reader_lib = reader_lib.lower() if self.reader_lib == "openslide": @@ -672,6 +672,11 @@ def read(self, data: Union[Sequence[str], str, np.ndarray], **kwargs): 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 == "cuclaraimage") and (not has_cux): + raise ImportError("No module named 'cuimage'") + img_: List = [] filenames: Sequence[str] = ensure_tuple(data) From b25b3d3c924d9286af2dec941b8e99b433413b53 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 8 Mar 2021 06:15:16 +0000 Subject: [PATCH 32/85] 1604 Revise docstring cam (#1705) * update docstring Signed-off-by: Wenqi Li * revise docstring Signed-off-by: Wenqi Li --- monai/visualize/class_activation_maps.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index 624d054500..c6051caa61 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -173,6 +173,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,6 +202,10 @@ 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 @@ -255,9 +270,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: @@ -326,9 +338,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 @@ -368,9 +377,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 From ce6628a202fd04f8029db6345217ebda9edb8151 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 8 Mar 2021 16:57:52 +0800 Subject: [PATCH 33/85] 1704 Add Affined transform (#1709) * [DLMED] add Affined transform Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- docs/source/transforms.rst | 6 ++ monai/transforms/__init__.py | 3 + monai/transforms/spatial/dictionary.py | 78 +++++++++++++++++++ tests/test_affined.py | 104 +++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 tests/test_affined.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index dd10176de9..3bc8d0899a 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -851,6 +851,12 @@ Spatial (Dict) :members: :special-members: __call__ +`Affined` +""""""""" +.. autoclass:: Affined + :members: + :special-members: __call__ + `RandAffined` """"""""""""" .. autoclass:: RandAffined diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index a8d647b657..14fe71728b 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -195,6 +195,9 @@ Zoom, ) from .spatial.dictionary import ( + Affined, + AffineD, + AffineDict, Flipd, FlipD, FlipDict, diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index a81aeb432b..d9d38242fb 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -24,6 +24,7 @@ from monai.networks.layers.simplelayers import GaussianFilter from monai.transforms.croppad.array import CenterSpatialCrop from monai.transforms.spatial.array import ( + Affine, Flip, Orientation, Rand2DElastic, @@ -53,6 +54,7 @@ "Rotate90d", "RandRotate90d", "Resized", + "Affined", "RandAffined", "Rand2DElasticd", "Rand3DElasticd", @@ -73,6 +75,8 @@ "RandRotate90Dict", "ResizeD", "ResizeDict", + "AffineD", + "AffineDict", "RandAffineD", "RandAffineDict", "Rand2DElasticD", @@ -378,6 +382,79 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d +class Affined(RandomizableTransform, MapTransform): + """ + 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): + d[key] = self.affine(d[key], mode=mode, padding_mode=padding_mode) + return d + + class RandAffined(RandomizableTransform, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.RandAffine`. @@ -1122,6 +1199,7 @@ 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 diff --git a/tests/test_affined.py b/tests/test_affined.py new file mode 100644 index 0000000000..96e6d72fe5 --- /dev/null +++ b/tests/test_affined.py @@ -0,0 +1,104 @@ +# 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)) + 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) + + +if __name__ == "__main__": + unittest.main() From 133f42aca81d7781e03140af072a4ec0fc799917 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 8 Mar 2021 10:12:44 +0000 Subject: [PATCH 34/85] 1693 update for pytorch 1.8 (#1700) * update for torch 1.8.0 Signed-off-by: Wenqi Li * mute codecov upload error Signed-off-by: Wenqi Li --- .github/workflows/cron.yml | 13 +++++------ .github/workflows/integration.yml | 6 +++--- .github/workflows/pythonapp.yml | 36 +++++++++++++++++++------------ .github/workflows/setupapp.yml | 6 +++--- tests/utils.py | 4 ++-- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 98834dbc8f..96368ba6d4 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 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 003a746de4..66f6c2956d 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: | diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 8e92ea0ed7..227d55a082 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: @@ -80,13 +80,12 @@ 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()" - 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 +133,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 +155,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 +176,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 +191,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 +224,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 +239,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 + 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..e40660c213 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 @@ -51,7 +51,7 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v1 with: - fail_ci_if_error: true + fail_ci_if_error: false file: ./coverage.xml test-py3x: diff --git a/tests/utils.py b/tests/utils.py index 3636cbe974..20f94cd1eb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -569,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 [] From 634b32d5c02af3db08c2b5fbe55ed773d9eb31c1 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 8 Mar 2021 20:10:54 +0800 Subject: [PATCH 35/85] [DLMED] update datasets (#1712) Signed-off-by: Nic Ma --- monai/data/dataset.py | 36 ++++++++++++++++----------------- tests/test_smartcachedataset.py | 8 +++++--- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/monai/data/dataset.py b/monai/data/dataset.py index bb5a98ba1e..c032e65af6 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -52,16 +52,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) @@ -118,7 +117,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: @@ -133,11 +131,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: @@ -350,7 +348,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" @@ -490,7 +489,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: @@ -591,7 +591,7 @@ 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: """ @@ -606,8 +606,8 @@ 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. - progress: whether to display a progress bar. + 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, progress) @@ -617,7 +617,10 @@ def __init__( 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) @@ -747,12 +750,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): 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: From 08af0ccfd2badda1eb09fa64e5c72ba2d8151132 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 8 Mar 2021 13:48:17 +0000 Subject: [PATCH 36/85] with allow_missing_keys_mode (#1714) with allow_missing_keys_mode --- monai/transforms/__init__.py | 4 +- monai/transforms/compose.py | 9 +++- monai/transforms/transform.py | 29 +++++++++- monai/transforms/utils.py | 78 ++++++++++++++++++--------- tests/test_with_allow_missing_keys.py | 73 +++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 32 deletions(-) create mode 100644 tests/test_with_allow_missing_keys.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 14fe71728b..796804df24 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -244,7 +244,7 @@ ZoomD, ZoomDict, ) -from .transform import MapTransform, Randomizable, RandomizableTransform, Transform +from .transform import MapTransform, Randomizable, RandomizableTransform, Transform, apply_transform from .utility.array import ( AddChannel, AddExtremePointsChannel, @@ -348,7 +348,7 @@ 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 a9f66b12a0..21e7da068c 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -18,8 +18,13 @@ import numpy as np # For backwards compatiblity (so this still works: from monai.transforms.compose import MapTransform) -from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform, Transform # noqa: F401 -from monai.transforms.utils import apply_transform +from monai.transforms.transform import ( # noqa: F401 + MapTransform, + Randomizable, + RandomizableTransform, + Transform, + apply_transform, +) from monai.utils import MAX_SEED, ensure_tuple, get_seed __all__ = ["Compose"] diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 7a09efa6d5..2a79b2edf2 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -13,14 +13,39 @@ """ from abc import ABC, abstractmethod -from typing import Any, Dict, Generator, Hashable, Iterable, List, Optional, Tuple +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__ = ["Randomizable", "RandomizableTransform", "Transform", "MapTransform"] +__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): 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/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() From f4560a183a09336bf6285d4d9f5d882715cde3e2 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 8 Mar 2021 15:20:51 +0000 Subject: [PATCH 37/85] update links (#1717) Signed-off-by: Wenqi Li --- README.md | 3 +++ docs/source/index.rst | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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 From cbddce9f3e6a666e9099d835db31bcf2fd94cba2 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 8 Mar 2021 17:39:12 +0000 Subject: [PATCH 38/85] 1500-update grid dataset to support patch level transforms (#1679) * fixes #1500 update grid dataset to support patch level transforms Signed-off-by: Wenqi Li --- docs/source/data.rst | 5 ++ monai/data/__init__.py | 2 +- monai/data/grid_dataset.py | 137 ++++++++++++++++++++++++++++++------- monai/data/utils.py | 17 ++++- tests/test_grid_dataset.py | 83 ++++++++++++++++++++++ 5 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 tests/test_grid_dataset.py diff --git a/docs/source/data.rst b/docs/source/data.rst index eed4b30ded..3dffeb8977 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -77,6 +77,11 @@ Patch-based dataset .. autoclass:: GridPatchDataset :members: +`PatchIter` +~~~~~~~~~~~ +.. autoclass:: PatchIter + :members: + `PatchDataset` ~~~~~~~~~~~~~~ .. autoclass:: PatchDataset diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 54ee7908f4..9fa5c935e2 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -22,7 +22,7 @@ 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, WSIReader from .iterable_dataset import IterableDataset 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/utils.py b/monai/data/utils.py index 60250af441..2e2f8c00cb 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -174,7 +174,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 @@ -194,6 +194,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) @@ -210,7 +219,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: @@ -411,6 +422,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 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() From 0751ca053dc1e2d3a4f12ba28c0240ad69684300 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 9 Mar 2021 20:41:35 +0800 Subject: [PATCH 39/85] Update DataLoader arg type of workflow (#1719) * [DLMED] update type of arg in workflows Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/engines/evaluator.py | 14 +++++++------- monai/engines/trainer.py | 11 +++++++---- monai/engines/workflow.py | 28 +++++++++++++++++----------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index 0b7167fb3a..b8977a3652 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence, Tuple +from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union import torch from torch.utils.data import DataLoader @@ -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/trainer.py b/monai/engines/trainer.py index efb2ab12fa..c3d471e261 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union import torch from torch.optim.optimizer import Optimizer @@ -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/workflow.py b/monai/engines/workflow.py index d6415c1966..b50d58f1a2 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 @@ -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={}, From 78ec66f3a275d596ee0e310c10d23c673fa31a04 Mon Sep 17 00:00:00 2001 From: Yiwen Li <44606435+kate-sann5100@users.noreply.github.com> Date: Tue, 9 Mar 2021 15:31:38 +0000 Subject: [PATCH 40/85] 1651 implement RegUNet (#1658) * 1651 implement RegUNet Signed-off-by: kate-sann5100 --- docs/source/networks.rst | 20 ++ monai/networks/blocks/__init__.py | 1 + monai/networks/blocks/regunet_block.py | 270 +++++++++++++++++++++++++ monai/networks/nets/__init__.py | 1 + monai/networks/nets/regunet.py | 249 +++++++++++++++++++++++ tests/test_regunet.py | 87 ++++++++ tests/test_regunet_block.py | 97 +++++++++ 7 files changed, 725 insertions(+) create mode 100644 monai/networks/blocks/regunet_block.py create mode 100644 monai/networks/nets/regunet.py create mode 100644 tests/test_regunet.py create mode 100644 tests/test_regunet_block.py diff --git a/docs/source/networks.rst b/docs/source/networks.rst index e0ac0f2d75..5688f4b143 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 @@ -330,6 +345,11 @@ Nets .. autoclass:: VNet :members: +`RegUNet` +~~~~~~~~~~ +.. autoclass:: RegUNet + :members: + `LocalNet` ~~~~~~~~~~~ .. autoclass:: LocalNet 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/nets/__init__.py b/monai/networks/nets/__init__.py index a9308de9d7..db4590cf40 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -20,6 +20,7 @@ from .highresnet import HighResBlock, HighResNet from .localnet import LocalNet from .regressor import Regressor +from .regunet import RegUNet from .segresnet import SegResNet, SegResNetVAE from .senet import SENet, se_resnet50, se_resnet101, se_resnet152, se_resnext50_32x4d, se_resnext101_32x4d, senet154 from .unet import UNet, Unet, unet diff --git a/monai/networks/nets/regunet.py b/monai/networks/nets/regunet.py new file mode 100644 index 0000000000..9499fa06fa --- /dev/null +++ b/monai/networks/nets/regunet.py @@ -0,0 +1,249 @@ +# 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 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 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() From ea1d04ba48a8b9aa85ee8baf5a592be1e40d8d43 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 9 Mar 2021 21:08:05 +0000 Subject: [PATCH 41/85] remove print msg (#1730) Signed-off-by: Wenqi Li --- monai/data/image_reader.py | 2 -- tests/test_rotated.py | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 39fc1f46b4..08432e53da 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -645,11 +645,9 @@ def __init__(self, reader_lib: str = "OpenSlide"): if self.reader_lib == "openslide": if has_osl: self.wsi_reader = openslide.OpenSlide - print("> OpenSlide is being used.") elif self.reader_lib == "cuclaraimage": if has_cux: self.wsi_reader = cuimage.CuImage - print("> CuImage is being used.") else: raise ValueError('`reader_lib` should be either "cuClaraImage" or "OpenSlide"') diff --git a/tests/test_rotated.py b/tests/test_rotated.py index 3353ae9fba..779fb2054d 100644 --- a/tests/test_rotated.py +++ b/tests/test_rotated.py @@ -104,7 +104,8 @@ 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 From a462714314057b33c84766aeae90dbb710e38fe6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Tue, 9 Mar 2021 18:13:25 -0500 Subject: [PATCH 42/85] Fix MONAI docker dependency (#1731) * Update MONAI Docker with OpenSlide dependencies Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update cron job to install apt dependencies Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- .github/workflows/cron.yml | 6 +++++- Dockerfile | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 96368ba6d4..681331aae1 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -58,7 +58,11 @@ jobs: 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 diff --git a/Dockerfile b/Dockerfile index c90558c970..57ea567869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,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 From d4c201e1624c1bd91f8127f4d53e5d854c79e0f9 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 10 Mar 2021 10:57:30 +0000 Subject: [PATCH 43/85] [1710] Gradcam eval (#1734) * use eval for getting class_idx for gradcam --- monai/visualize/class_activation_maps.py | 35 +++++++++++------------- tests/test_vis_gradcam.py | 5 ++++ 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/monai/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index c6051caa61..6e93225af3 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): diff --git a/tests/test_vis_gradcam.py b/tests/test_vis_gradcam.py index 2a7de0e70c..df47c4920e 100644 --- a/tests/test_vis_gradcam.py +++ b/tests/test_vis_gradcam.py @@ -11,6 +11,7 @@ import unittest +import numpy as np import torch from parameterized import parameterized @@ -79,9 +80,13 @@ def test_shape(self, input_data, expected_shape): 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__": From c362beb49750008c833268a12c0e3d3d016fb318 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 10 Mar 2021 12:58:57 +0000 Subject: [PATCH 44/85] fixes unit test (#1735) * fixes unit test Signed-off-by: Wenqi Li * fixes timed call tests Signed-off-by: Wenqi Li --- tests/test_rotate.py | 3 ++- tests/test_rotated.py | 3 ++- tests/test_timedcall.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_rotate.py b/tests/test_rotate.py index 6e43ab90e7..a8dca07069 100644 --- a/tests/test_rotate.py +++ b/tests/test_rotate.py @@ -102,7 +102,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 779fb2054d..82bc4aed40 100644 --- a/tests/test_rotated.py +++ b/tests/test_rotated.py @@ -52,7 +52,8 @@ 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 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 From 0b1fc4e3120806fd4c480ab90e9242d86f6bdf90 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 10 Mar 2021 15:02:30 +0000 Subject: [PATCH 45/85] basic inverse API (#1720) basic inverse API --- docs/source/transforms.rst | 6 + monai/transforms/__init__.py | 1 + monai/transforms/compose.py | 14 +- monai/transforms/croppad/dictionary.py | 27 ++- monai/transforms/inverse.py | 113 +++++++++++++ monai/utils/__init__.py | 1 + monai/utils/enums.py | 12 ++ tests/test_decollate.py | 19 ++- tests/test_inverse.py | 218 +++++++++++++++++++++++++ 9 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 monai/transforms/inverse.py create mode 100644 tests/test_inverse.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 3bc8d0899a..dcdeab1ac8 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -38,6 +38,12 @@ Generic Interfaces :members: :special-members: __call__ +`InvertibleTransform` +^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: InvertibleTransform + :members: + + Vanilla Transforms ------------------ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 796804df24..5b12da4d21 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -138,6 +138,7 @@ 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 ( diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index 21e7da068c..d509ea33a1 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -17,6 +17,8 @@ import numpy as np +from monai.transforms.inverse import InvertibleTransform + # For backwards compatiblity (so this still works: from monai.transforms.compose import MapTransform) from monai.transforms.transform import ( # noqa: F401 MapTransform, @@ -30,7 +32,7 @@ __all__ = ["Compose"] -class Compose(RandomizableTransform): +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 @@ -141,3 +143,13 @@ def __call__(self, input_): for _transform in self.transforms: 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") + + # loop backwards over transforms + for t in reversed(invertible_transforms): + data = apply_transform(t.inverse, data) + return data diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 823b2dd3f4..667fb7a821 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -15,6 +15,8 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ +from copy import deepcopy +from math import floor from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np @@ -30,6 +32,7 @@ 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, @@ -38,6 +41,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 +86,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. @@ -119,9 +123,30 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, m in self.key_iterator(d, self.mode): + self.push_transform(d, key) 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.value] + 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): """ diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py new file mode 100644 index 0000000000..f9de8746ca --- /dev/null +++ b/monai/transforms/inverse.py @@ -0,0 +1,113 @@ +# 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 + +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.value + info = { + InverseKeys.CLASS_NAME.value: self.__class__.__name__, + InverseKeys.ID.value: id(self), + InverseKeys.ORIG_SIZE.value: orig_size or data[key].shape[1:], + } + if extra_info is not None: + info[InverseKeys.EXTRA_INFO.value] = 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.value] = 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.value] != id(self): + 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.value][-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.value].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/utils/__init__.py b/monai/utils/__init__.py index 1e17d44029..3c1e7efe24 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -20,6 +20,7 @@ GridSampleMode, GridSamplePadMode, InterpolateMode, + InverseKeys, LossReduction, Method, MetricReduction, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index d1d2d3bcce..d661781616 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -28,6 +28,7 @@ "ChannelMatching", "SkipMode", "Method", + "InverseKeys", ] @@ -214,3 +215,14 @@ class Method(Enum): SYMMETRIC = "symmetric" END = "end" + + +class InverseKeys(Enum): + """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" diff --git a/tests/test_decollate.py b/tests/test_decollate.py index 5c6f04b48e..24a34482b5 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -9,7 +9,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys import unittest +from enum import Enum import numpy as np import torch @@ -20,6 +22,7 @@ from monai.transforms import AddChanneld, Compose, LoadImaged, RandFlipd, SpatialPadd, ToTensord from monai.transforms.post.dictionary import Decollated 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") @@ -46,14 +49,20 @@ def tearDown(self) -> None: def check_match(self, in1, in2): if isinstance(in1, dict): self.assertTrue(isinstance(in2, dict)) - self.check_match(list(in1.keys()), list(in2.keys())) - self.check_match(list(in1.values()), list(in2.values())) - elif any(isinstance(in1, i) for i in [list, tuple]): + 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.value 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 any(isinstance(in1, i) for i in [str, int]): + elif isinstance(in1, (str, int)): self.assertEqual(in1, in2) - elif any(isinstance(in1, i) for i in [torch.Tensor, np.ndarray]): + 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)}") diff --git a/tests/test_inverse.py b/tests/test_inverse.py new file mode 100644 index 0000000000..46729c7bc6 --- /dev/null +++ b/tests/test_inverse.py @@ -0,0 +1,218 @@ +# 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, List, Tuple + +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.utils import decollate_batch +from monai.networks.nets import UNet +from monai.transforms import ( + AddChannel, + AddChanneld, + Compose, + InvertibleTransform, + LoadImaged, + ResizeWithPadOrCrop, + SpatialPadd, + allow_missing_keys_mode, +) +from monai.utils import first, 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] = [] + +TESTS.append( + ( + "SpatialPadd (x2) 2d", + "2D", + 0.0, + SpatialPadd(KEYS, spatial_size=[111, 113], method="end"), + SpatialPadd(KEYS, spatial_size=[118, 117]), + ) +) + +TESTS.append( + ( + "SpatialPadd 3d", + "3D", + 0.0, + SpatialPadd(KEYS, spatial_size=[112, 113, 116]), + ) +) + +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 + + +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 + + im_1d = AddChannel()(np.arange(0, 10)) + self.all_data["1D"] = {"image": im_1d, "label": im_1d, "other": im_1d} + + 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}" + ) + 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: + forwards.append(t(forwards[-1])) + + # Check that error is thrown when inverse are used out of order. + t = SpatialPadd("image", [10, 5]) + with self.assertRaises(RuntimeError): + t.inverse(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) + + 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))]) + 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.value + 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) + + +if __name__ == "__main__": + unittest.main() From c3e5a692aa2e24a07d8025a535e4d396b8902b01 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 10 Mar 2021 20:28:32 +0000 Subject: [PATCH 46/85] improve decollate test for set_determinism (#1736) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- tests/test_decollate.py | 21 ++++++--------------- tests/test_thread_buffer.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/tests/test_decollate.py b/tests/test_decollate.py index 24a34482b5..4ed8de6bbb 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -15,7 +15,6 @@ 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 @@ -27,17 +26,6 @@ _, has_nib = optional_import("nibabel") -IM_2D = create_test_image_2d(100, 101)[0] -DATA_2D = {"image": make_nifti_image(IM_2D) if has_nib else IM_2D} - -TESTS = [] -TESTS.append( - ( - "2D", - [DATA_2D for _ in range(6)], - ) -) - class TestDeCollate(unittest.TestCase): def setUp(self) -> None: @@ -67,8 +55,11 @@ def check_match(self, 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, _, data, batch_size=2, num_workers=2): + def test_decollation(self, batch_size=2, num_workers=2): + + im = create_test_image_2d(100, 101)[0] + data = [{"image": make_nifti_image(im) if has_nib else im} for _ in range(6)] + transforms = Compose( [ AddChanneld("image"), @@ -82,7 +73,7 @@ def test_decollation(self, _, data, batch_size=2, num_workers=2): transforms = Compose([LoadImaged("image"), transforms]) dataset = CacheDataset(data, transforms, progress=False) - loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers) + 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) diff --git a/tests/test_thread_buffer.py b/tests/test_thread_buffer.py index 07e5a779ca..d139b44c85 100644 --- a/tests/test_thread_buffer.py +++ b/tests/test_thread_buffer.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys import time import unittest @@ -57,11 +58,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__": From 6d3bf2fb7910afcb656fed91eb1d337c61c40ac4 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 11 Mar 2021 03:59:45 +0000 Subject: [PATCH 47/85] 1236 integration test memory (#1738) * addresses testing instance memory errors Signed-off-by: Wenqi Li --- .github/pull_request_template.md | 4 ++-- .github/workflows/cron.yml | 27 +++++++++++++++++---- .github/workflows/integration.yml | 2 +- .github/workflows/pythonapp.yml | 4 ++-- .github/workflows/setupapp.yml | 16 ++++++++++--- CONTRIBUTING.md | 8 +++---- runtests.sh | 39 +++++++++++++++++-------------- tests/runner.py | 1 + 8 files changed, 66 insertions(+), 35 deletions(-) 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 681331aae1..90abb5d7e4 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -40,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 @@ -76,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 @@ -104,7 +118,10 @@ 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 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 66f6c2956d..ac3efbb751 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 227d55a082..8ed1f6d21e 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -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 }} @@ -241,7 +241,7 @@ 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")))' python -c "import monai; monai.config.print_config()" - BUILD_MONAI=1 ./runtests.sh --quick + 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 diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index e40660c213..e5cb9a7cf1 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -41,12 +41,22 @@ 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 @@ -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/CONTRIBUTING.md b/CONTRIBUTING.md index 01a4773b5a..325f81b127 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,10 +52,10 @@ Before submitting a pull request, we recommend that all linting should pass, by ```bash pip install -U -r requirements-dev.txt # install the latest tools -./runtests.sh --codeformat --nounittests # runs the linting tools only +./runtests.sh --codeformat # runs the linting tools # 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: @@ -98,12 +98,12 @@ 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 --codeformat --coverage --unittests ``` 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 diff --git a/runtests.sh b/runtests.sh index 1395ccdcfd..0d3551291a 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\"." @@ -220,8 +219,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 @@ -268,6 +267,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 @@ -511,7 +514,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]*py" fi # network training/inference/eval integration tests diff --git a/tests/runner.py b/tests/runner.py index b5d1de5fc1..f7be96cfb3 100644 --- a/tests/runner.py +++ b/tests/runner.py @@ -126,6 +126,7 @@ def get_default_pattern(loader): tests = loader.discover(args.path, args.pattern) discovery_time = pc.total_time print(f"time to discover tests: {discovery_time}s") + print(tests) test_runner = unittest.runner.TextTestRunner( resultclass=TimeLoggingTestResult, verbosity=args.verbosity, failfast=args.failfast From 401ea299bf094c10725d9a0566379b69b7410735 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 11 Mar 2021 22:42:52 +0000 Subject: [PATCH 48/85] 1541 update coverage report config (#1746) * update coverage config Signed-off-by: Wenqi Li * temp tests Signed-off-by: Wenqi Li * fixes https://github.com/Project-MONAI/MONAI/runs/2083800079?check_suite_focus=true#step:5:13886 Signed-off-by: Wenqi Li * test cases matching in runner Signed-off-by: Wenqi Li * fixes openslide tests Signed-off-by: Wenqi Li * fixes https://github.com/Project-MONAI/MONAI/runs/2086767998?check_suite_focus=true#step:7:5955 Signed-off-by: Wenqi Li * fixes print stats Signed-off-by: Wenqi Li * remove temp tests Signed-off-by: Wenqi Li * remove unused Signed-off-by: Wenqi Li * remove global logging config Signed-off-by: Wenqi Li * omit setup.py Signed-off-by: Wenqi Li --- .dockerignore | 3 +++ .gitignore | 2 ++ monai/transforms/utility/array.py | 9 +++++++-- monai/transforms/utility/dictionary.py | 1 + requirements-min.txt | 2 +- runtests.sh | 15 +++++++++------ setup.cfg | 24 +++++++++++++++++++++--- tests/runner.py | 24 +++++++++++++++--------- tests/test_affine.py | 5 +---- tests/test_affined.py | 5 +---- tests/test_data_stats.py | 6 ++++-- tests/test_data_statsd.py | 7 +++++-- tests/test_handler_stats.py | 26 ++++++++++++++++---------- tests/test_openslide_reader.py | 22 ++++++++++++---------- tests/test_rand_rotate.py | 3 ++- tests/test_rand_rotated.py | 3 ++- tests/test_rotate.py | 3 ++- 17 files changed, 104 insertions(+), 56 deletions(-) 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/.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/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 8776238711..41804d5c1d 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -14,6 +14,7 @@ """ import logging +import sys import time from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence, Tuple, Union @@ -409,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]``. @@ -424,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) @@ -459,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 diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 14f34fb663..a05a5fc904 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -532,6 +532,7 @@ 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. """ 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 0d3551291a..85ede904f6 100755 --- a/runtests.sh +++ b/runtests.sh @@ -138,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 @@ -149,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 "{}" + @@ -496,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 @@ -514,7 +516,7 @@ if [ $doUnitTests = true ] then echo "${separator}${blue}unittests${noColor}" torch_validate - ${cmdPrefix}${cmd} ./tests/runner.py -p "test_[!integration]*py" + ${cmdPrefix}${cmd} ./tests/runner.py -p "test_((?!integration).)" fi # network training/inference/eval integration tests @@ -540,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 f18b4610fd..bbdcdf805d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -55,19 +55,19 @@ lmdb = lmdb psutil = psutil -openslide = +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] @@ -148,3 +148,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/tests/runner.py b/tests/runner.py index f7be96cfb3..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,10 +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(tests) + 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 index 96e6d72fe5..850f12905d 100644 --- a/tests/test_affined.py +++ b/tests/test_affined.py @@ -94,10 +94,7 @@ 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)) - 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_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_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_openslide_reader.py b/tests/test_openslide_reader.py index e1f9187937..67a6683be3 100644 --- a/tests/test_openslide_reader.py +++ b/tests/test_openslide_reader.py @@ -61,11 +61,20 @@ ] +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 = self.camelyon_data_download(file_url) + filename = camelyon_data_download(file_url) reader = WSIReader("OpenSlide") img_obj = reader.read(filename) img = reader.get_data(img_obj)[0] @@ -74,7 +83,7 @@ def test_read_whole_image(self, file_url, 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 = self.camelyon_data_download(file_url) + filename = camelyon_data_download(file_url) reader = WSIReader("OpenSlide") img_obj = reader.read(filename) img = reader.get_data(img_obj, **patch_info)[0] @@ -84,20 +93,13 @@ def test_read_region(self, file_url, patch_info, 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 = self.camelyon_data_download(file_url) + 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)) - 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_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_rotate.py b/tests/test_rotate.py index a8dca07069..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): From ce8f92a565acdd39d2e41566f199758fdf6d2d0f Mon Sep 17 00:00:00 2001 From: Yiwen Li <44606435+kate-sann5100@users.noreply.github.com> Date: Fri, 12 Mar 2021 11:21:01 +0000 Subject: [PATCH 49/85] 1665 adjust localnet (#1752) * 1651 implement RegUNet Signed-off-by: kate-sann5100 --- monai/networks/nets/__init__.py | 3 +- monai/networks/nets/localnet.py | 129 -------------------------------- monai/networks/nets/regunet.py | 91 ++++++++++++++++++++++ tests/test_localnet.py | 58 ++++++-------- 4 files changed, 116 insertions(+), 165 deletions(-) delete mode 100644 monai/networks/nets/localnet.py diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index db4590cf40..7a39872525 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -18,9 +18,8 @@ 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 RegUNet +from .regunet import 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 .unet import UNet, Unet, unet 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 index 9499fa06fa..3263a6b5bc 100644 --- a/monai/networks/nets/regunet.py +++ b/monai/networks/nets/regunet.py @@ -12,6 +12,7 @@ import torch from torch import nn +from torch.nn import functional as F from monai.networks.blocks.regunet_block import ( RegistrationDownSampleBlock, @@ -247,3 +248,93 @@ def forward(self, x): out = self.output_block(outs, image_size=image_size) return out + + +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/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) From abf8a9992c04d715eaba0bb9877090167f29a35d Mon Sep 17 00:00:00 2001 From: Yiwen Li <44606435+kate-sann5100@users.noreply.github.com> Date: Fri, 12 Mar 2021 12:41:37 +0000 Subject: [PATCH 50/85] 1651 globalnet (#1729) * 1651 implement RegUNet Signed-off-by: kate-sann5100 * 1651 reformat code Signed-off-by: kate-sann5100 * 1651 reformat code Signed-off-by: kate-sann5100 * 1651 add globalnet Signed-off-by: kate-sann5100 * 1651 reformat code Signed-off-by: kate-sann5100 * 1651 reformat code Signed-off-by: kate-sann5100 * 1651 reformat code Signed-off-by: kate-sann5100 * 1651 reformat code Signed-off-by: kate-sann5100 --- docs/source/networks.rst | 5 ++ monai/networks/nets/__init__.py | 2 +- monai/networks/nets/regunet.py | 104 ++++++++++++++++++++++++++++++++ tests/test_globalnet.py | 79 ++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/test_globalnet.py diff --git a/docs/source/networks.rst b/docs/source/networks.rst index 5688f4b143..036ba2aff7 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -350,6 +350,11 @@ Nets .. autoclass:: RegUNet :members: +`GlobalNet` +~~~~~~~~~~~~ +.. autoclass:: GlobalNet + :members: + `LocalNet` ~~~~~~~~~~~ .. autoclass:: LocalNet diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index 7a39872525..f3def30736 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -19,7 +19,7 @@ from .generator import Generator from .highresnet import HighResBlock, HighResNet from .regressor import Regressor -from .regunet import LocalNet, RegUNet +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 .unet import UNet, Unet, unet diff --git a/monai/networks/nets/regunet.py b/monai/networks/nets/regunet.py index 3263a6b5bc..25455c2df7 100644 --- a/monai/networks/nets/regunet.py +++ b/monai/networks/nets/regunet.py @@ -250,6 +250,110 @@ def forward(self, x): 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, 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() From e001d2a492a71fe96cc7cd6f8fe0287fb063c4d0 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 13 Mar 2021 00:22:34 +0800 Subject: [PATCH 51/85] [DLMED] update WSIReader for typos and docs (#1758) Signed-off-by: Nic Ma --- monai/data/image_reader.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 08432e53da..ddb2c8c913 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -635,7 +635,11 @@ def _get_spatial_shape(self, img) -> np.ndarray: class WSIReader(ImageReader): """ - Read whole slide imaging and extract patches + Read whole slide imaging and extract patches. + + Args: + reader_lib: backend library to load the images, available options: "OpenSlide" or "cuClaraImage". + TODO: `cuClaraImage` package is unavailable so far, will enable the support later. """ From 21a3c16fcba5f99a797ddc6e6595cdf2f18cdd57 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 13 Mar 2021 02:58:50 +0800 Subject: [PATCH 52/85] 1742 Add DistributedWeightedRandomSampler (#1745) * [DLMED] add DistributedWeightedRandomSampler Signed-off-by: Nic Ma --- docs/source/data.rst | 3 + monai/data/__init__.py | 2 +- monai/data/samplers.py | 122 ++++++++++++++++++ monai/data/utils.py | 26 ---- ...est_distributed_weighted_random_sampler.py | 64 +++++++++ tests/test_rotated.py | 3 +- 6 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 monai/data/samplers.py create mode 100644 tests/test_distributed_weighted_random_sampler.py diff --git a/docs/source/data.rst b/docs/source/data.rst index 3dffeb8977..c95659bc6e 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -160,6 +160,9 @@ DistributedSampler ~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.DistributedSampler +DistributedWeightedRandomSampler +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.DistributedWeightedRandomSampler Decathlon Datalist ~~~~~~~~~~~~~~~~~~ diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 9fa5c935e2..54beb53e3f 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -30,10 +30,10 @@ 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 .utils import ( - DistributedSampler, compute_importance_map, compute_shape_offset, correct_nifti_header_if_necessary, diff --git a/monai/data/samplers.py b/monai/data/samplers.py new file mode 100644 index 0000000000..5fea6959de --- /dev/null +++ b/monai/data/samplers.py @@ -0,0 +1,122 @@ +# 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. + replacement: if ``True``, samples are drawn with replacement, otherwise, they are + drawn without replacement, which means that when a sample index is drawn for a row, + it cannot be drawn again for that row, default to True. + 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, + replacement: bool = True, + 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 + self.replacement = replacement + self.generator = generator + + def __iter__(self): + indices = list(super().__iter__()) + num_samples = self.num_samples_per_rank if self.num_samples_per_rank is not None else self.num_samples + 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, num_samples, self.replacement, generator=self.generator) + + for i in rand_tensor: + yield indices[i] diff --git a/monai/data/utils.py b/monai/data/utils.py index 2e2f8c00cb..1db2f6676f 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -22,7 +22,6 @@ 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 @@ -61,7 +60,6 @@ "partition_dataset", "partition_dataset_classes", "select_cross_validation_folds", - "DistributedSampler", "json_hashing", "pickle_hashing", "sorted_dict", @@ -921,30 +919,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): - 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/tests/test_distributed_weighted_random_sampler.py b/tests/test_distributed_weighted_random_sampler.py new file mode 100644 index 0000000000..6e27e78d4c --- /dev/null +++ b/tests/test_distributed_weighted_random_sampler.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 + +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_replacement(self): + data = [1, 2, 3, 4, 5] + weights = [1, 2, 3, 4, 5] + sampler = DistributedWeightedRandomSampler( + weights=weights, + replacement=True, + 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, + replacement=True, + 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_rotated.py b/tests/test_rotated.py index 82bc4aed40..e0c1a27e98 100644 --- a/tests/test_rotated.py +++ b/tests/test_rotated.py @@ -79,7 +79,8 @@ 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 From f56058de7740f83bb0c44f5bee24387f4350b349 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Sat, 13 Mar 2021 12:05:16 +0000 Subject: [PATCH 53/85] Croppad inverse transform (#1737) * croppad inverse Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/croppad/array.py | 15 +- monai/transforms/croppad/dictionary.py | 194 ++++++++++++++++++++++++- tests/test_inverse.py | 163 ++++++++++++++++++++- 3 files changed, 353 insertions(+), 19 deletions(-) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index a3d36ad903..6174378e3b 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -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)]) diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 667fb7a821..02ddf723be 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -16,6 +16,7 @@ """ 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 @@ -148,7 +149,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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`. @@ -191,11 +192,36 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, m in self.key_iterator(d, self.mode): + self.push_transform(d, key) 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.value]) + 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.value]) + 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) + + return d + -class DivisiblePadd(MapTransform): +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`. @@ -232,11 +258,30 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, m in self.key_iterator(d, self.mode): + self.push_transform(d, key) 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.value]) + 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) + + return d + -class SpatialCropd(MapTransform): +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 @@ -268,11 +313,31 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) 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.value] + 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`. @@ -293,11 +358,34 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) 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)) -class RandSpatialCropd(RandomizableTransform, MapTransform): + 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.value]) + 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(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 @@ -326,6 +414,7 @@ def __init__( ) -> None: RandomizableTransform.__init__(self) MapTransform.__init__(self, keys, allow_missing_keys) + self._do_transform = True self.roi_size = roi_size self.random_center = random_center self.random_size = random_size @@ -347,12 +436,46 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda raise AssertionError 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.value] + 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.value]["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(RandomizableTransform, MapTransform): """ @@ -409,7 +532,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. @@ -463,9 +586,29 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda d[self.end_coord_key] = np.asarray(box_end) cropper = SpatialCrop(roi_start=box_start, roi_end=box_end) 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.value]) + extra_info = transform[InverseKeys.EXTRA_INFO.value] + 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) + + return d + class RandWeightedCropd(RandomizableTransform, MapTransform): """ @@ -645,7 +788,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n return results -class ResizeWithPadOrCropd(MapTransform): +class ResizeWithPadOrCropd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.ResizeWithPadOrCrop`. @@ -675,7 +818,44 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) 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.value]) + 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 diff --git a/tests/test_inverse.py b/tests/test_inverse.py index 46729c7bc6..6635a4126f 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -11,6 +11,7 @@ import sys import unittest +from functools import partial from typing import TYPE_CHECKING, List, Tuple import numpy as np @@ -21,12 +22,18 @@ from monai.data.utils import decollate_batch from monai.networks.nets import UNet from monai.transforms import ( - AddChannel, AddChanneld, + BorderPadd, + CenterSpatialCropd, Compose, + CropForegroundd, + DivisiblePadd, InvertibleTransform, LoadImaged, + RandSpatialCropd, ResizeWithPadOrCrop, + ResizeWithPadOrCropd, + SpatialCropd, SpatialPadd, allow_missing_keys_mode, ) @@ -44,11 +51,44 @@ 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.0, + 0, SpatialPadd(KEYS, spatial_size=[111, 113], method="end"), SpatialPadd(KEYS, spatial_size=[118, 117]), ) @@ -58,11 +98,114 @@ ( "SpatialPadd 3d", "3D", - 0.0, + 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_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore @@ -118,8 +261,11 @@ def setUp(self): affine = make_rand_affine() affine[0] *= 2 - im_1d = AddChannel()(np.arange(0, 10)) - self.all_data["1D"] = {"image": im_1d, "label": im_1d, "other": im_1d} + 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": im_1d, "label": im_1d, "other": im_1d} 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)] @@ -147,6 +293,10 @@ def check_inverse(self, name, keys, orig_d, fwd_bck_d, unmodified_d, acceptable_ 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) @@ -183,7 +333,7 @@ def test_inverse_inferred_seg(self): 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))]) + 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) @@ -203,6 +353,7 @@ def test_inverse_inferred_seg(self): segs = model(labels).detach().cpu() label_transform_key = "label" + InverseKeys.KEY_SUFFIX.value segs_dict = {"label": segs, label_transform_key: data[label_transform_key]} + segs_dict_decollated = decollate_batch(segs_dict) # inverse of individual segmentation From e17e1586947930ac10f8dbc7c1e811e5862cd41f Mon Sep 17 00:00:00 2001 From: adam aji <3487395+adamaji@users.noreply.github.com> Date: Sat, 13 Mar 2021 18:33:47 -0500 Subject: [PATCH 54/85] TEST: Fix failing pytype check for predict_segmentation (#1762) --- monai/networks/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/monai/networks/utils.py b/monai/networks/utils.py index bd25e358f6..c5989f174b 100644 --- a/monai/networks/utils.py +++ b/monai/networks/utils.py @@ -71,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, From 42e963f8826349f525967ae6db542a0002d9e636 Mon Sep 17 00:00:00 2001 From: adam aji <3487395+adamaji@users.noreply.github.com> Date: Sun, 14 Mar 2021 10:47:29 -0400 Subject: [PATCH 55/85] BUG: MedNISTDataset returns differently sized datasets for different seeds (#1761) * TEST: Check for identical MedNISTDataset length for different seeds Signed-off-by: Adam Aji <3487395+adamaji@users.noreply.github.com> * BUG: MedNISTDataset returns differently sized datasets for diff seeds Signed-off-by: Adam Aji <3487395+adamaji@users.noreply.github.com> --- monai/apps/datasets.py | 40 ++++++++++++++++++++---------------- tests/test_mednistdataset.py | 7 ++++++- 2 files changed, 28 insertions(+), 19 deletions(-) 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/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) From 4670fd2dedcca715365cca0928b24f90a3f8f4e2 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 15 Mar 2021 19:57:15 +0800 Subject: [PATCH 56/85] Add __len__ for DistributedWeightedRandomSampler (#1766) * [DLMED] add length API Signed-off-by: Nic Ma * [DLMED] remove replacement arg Signed-off-by: Nic Ma --- monai/data/samplers.py | 13 +++++-------- tests/test_distributed_weighted_random_sampler.py | 4 +--- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/monai/data/samplers.py b/monai/data/samplers.py index 5fea6959de..8bba79c9b0 100644 --- a/monai/data/samplers.py +++ b/monai/data/samplers.py @@ -70,9 +70,6 @@ class DistributedWeightedRandomSampler(DistributedSampler): 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. - replacement: if ``True``, samples are drawn with replacement, otherwise, they are - drawn without replacement, which means that when a sample index is drawn for a row, - it cannot be drawn again for that row, default to True. 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].' @@ -90,7 +87,6 @@ def __init__( dataset: Dataset, weights: Sequence[float], num_samples_per_rank: Optional[int] = None, - replacement: bool = True, generator: Optional[torch.Generator] = None, even_divisible: bool = True, num_replicas: Optional[int] = None, @@ -107,16 +103,17 @@ def __init__( **kwargs, ) self.weights = weights - self.num_samples_per_rank = num_samples_per_rank - self.replacement = replacement + 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__()) - num_samples = self.num_samples_per_rank if self.num_samples_per_rank is not None else self.num_samples 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, num_samples, self.replacement, generator=self.generator) + 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/tests/test_distributed_weighted_random_sampler.py b/tests/test_distributed_weighted_random_sampler.py index 6e27e78d4c..b8e088fdcf 100644 --- a/tests/test_distributed_weighted_random_sampler.py +++ b/tests/test_distributed_weighted_random_sampler.py @@ -21,12 +21,11 @@ class DistributedWeightedRandomSamplerTest(DistTestCase): @DistCall(nnodes=1, nproc_per_node=2) - def test_replacement(self): + def test_sampling(self): data = [1, 2, 3, 4, 5] weights = [1, 2, 3, 4, 5] sampler = DistributedWeightedRandomSampler( weights=weights, - replacement=True, dataset=data, shuffle=False, generator=torch.Generator().manual_seed(0), @@ -46,7 +45,6 @@ def test_num_samples(self): sampler = DistributedWeightedRandomSampler( weights=weights, num_samples_per_rank=5, - replacement=True, dataset=data, shuffle=False, generator=torch.Generator().manual_seed(123), From 1badb41c06a513367627cb5ab5a9aa138a500adb Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 15 Mar 2021 16:04:11 +0000 Subject: [PATCH 57/85] reduce randomly cropped amount to appease CI (#1769) * reduce randomly cropped amount to appease CI Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fixes rand inverse Signed-off-by: Wenqi Li --- monai/transforms/croppad/dictionary.py | 7 +++---- monai/transforms/transform.py | 2 +- tests/test_inverse.py | 11 +++++++++-- tests/test_rotated.py | 6 +++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 02ddf723be..822db28467 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -412,9 +412,8 @@ def __init__( random_size: bool = True, allow_missing_keys: bool = False, ) -> None: - RandomizableTransform.__init__(self) + RandomizableTransform.__init__(self, prob=1.0, do_transform=True) MapTransform.__init__(self, keys, allow_missing_keys) - self._do_transform = True self.roi_size = roi_size self.random_center = random_center self.random_size = random_size @@ -511,7 +510,7 @@ def __init__( random_size: bool = True, allow_missing_keys: bool = False, ) -> None: - RandomizableTransform.__init__(self) + 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}.") @@ -638,7 +637,7 @@ def __init__( center_coord_key: Optional[str] = None, allow_missing_keys: bool = False, ): - RandomizableTransform.__init__(self) + 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 diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 2a79b2edf2..fea46aafa3 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -171,7 +171,7 @@ def __call__(self, img): """ - def __init__(self, prob=1.0, do_transform=False): + 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) diff --git a/tests/test_inverse.py b/tests/test_inverse.py index 6635a4126f..bb2d997eb5 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -30,6 +30,7 @@ DivisiblePadd, InvertibleTransform, LoadImaged, + Randomizable, RandSpatialCropd, ResizeWithPadOrCrop, ResizeWithPadOrCropd, @@ -37,7 +38,7 @@ SpatialPadd, allow_missing_keys_mode, ) -from monai.utils import first, optional_import, set_determinism +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 @@ -265,7 +266,11 @@ def setUp(self): # 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": im_1d, "label": im_1d, "other": im_1d} + 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)] @@ -309,6 +314,8 @@ def test_inverse(self, _, data_name, acceptable_diff, *transforms): # Apply forwards for t in transforms: + if isinstance(t, Randomizable): + t.set_random_state(seed=get_seed()) forwards.append(t(forwards[-1])) # Check that error is thrown when inverse are used out of order. diff --git a/tests/test_rotated.py b/tests/test_rotated.py index e0c1a27e98..dd57786fff 100644 --- a/tests/test_rotated.py +++ b/tests/test_rotated.py @@ -59,7 +59,7 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne 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): @@ -86,7 +86,7 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne 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): @@ -113,7 +113,7 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne 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__": From e5726d14f79e0b058fdf2c32e715450a04d01e0a Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 16 Mar 2021 03:40:03 +0800 Subject: [PATCH 58/85] 1756 update ignite version to 0.4.4 (#1759) * [DLMED] update ignite to 0.4.4 Signed-off-by: Nic Ma * [DLMED] fix ignite compatible issues Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- docs/requirements.txt | 2 +- monai/engines/evaluator.py | 4 ++-- monai/engines/multi_gpu_supervised_trainer.py | 10 +++++----- monai/engines/trainer.py | 4 ++-- monai/engines/utils.py | 2 +- monai/engines/workflow.py | 10 +++++----- monai/handlers/checkpoint_loader.py | 6 +++--- monai/handlers/checkpoint_saver.py | 10 +++++----- monai/handlers/classification_saver.py | 6 +++--- monai/handlers/confusion_matrix.py | 4 ++-- monai/handlers/hausdorff_distance.py | 4 ++-- monai/handlers/iteration_metric.py | 14 +++++++------- monai/handlers/lr_schedule_handler.py | 4 ++-- monai/handlers/mean_dice.py | 4 ++-- monai/handlers/metric_logger.py | 4 ++-- monai/handlers/metrics_saver.py | 6 +++--- monai/handlers/roc_auc.py | 6 +++--- monai/handlers/segmentation_saver.py | 4 ++-- monai/handlers/smartcache_handler.py | 4 ++-- monai/handlers/stats_handler.py | 4 ++-- monai/handlers/surface_distance.py | 4 ++-- monai/handlers/tensorboard_handlers.py | 4 ++-- monai/handlers/utils.py | 8 ++++---- monai/handlers/validation_handler.py | 4 ++-- requirements-dev.txt | 2 +- setup.cfg | 4 ++-- 26 files changed, 69 insertions(+), 69 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index cd06166359..22fd2589f0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -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 diff --git a/monai/engines/evaluator.py b/monai/engines/evaluator.py index b8977a3652..0afa3747a4 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -26,8 +26,8 @@ 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"] 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 c3d471e261..5b996eafe1 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -26,8 +26,8 @@ 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"] diff --git a/monai/engines/utils.py b/monai/engines/utils.py index 8f5899f2a5..b0b1e44f71 100644 --- a/monai/engines/utils.py +++ b/monai/engines/utils.py @@ -18,7 +18,7 @@ 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", diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index b50d58f1a2..61b92ac5dd 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -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 diff --git a/monai/handlers/checkpoint_loader.py b/monai/handlers/checkpoint_loader.py index 648cc8360a..e6319a3c64 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: diff --git a/monai/handlers/checkpoint_saver.py b/monai/handlers/checkpoint_saver.py index 1808e6b251..0c65b8cd4b 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: 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..758276d03d 100644 --- a/monai/handlers/metric_logger.py +++ b/monai/handlers/metric_logger.py @@ -14,11 +14,11 @@ 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 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 56370fd41c..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: 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 9ed13d292c..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) @@ -103,7 +103,7 @@ def string_list_all_gather(strings: List[str]) -> List[str]: # get length of strings length = len(strings) all_lens = idist.all_gather(length) - max_len = max(all_lens).item() + 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)] 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/requirements-dev.txt b/requirements-dev.txt index 3eeab474b6..1508eae4fe 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 diff --git a/setup.cfg b/setup.cfg index bbdcdf805d..9dd9fa106b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,7 @@ 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 @@ -44,7 +44,7 @@ tensorboard = gdown = gdown>=3.6.4 ignite = - pytorch-ignite==0.4.2 + pytorch-ignite==0.4.4 torchvision = torchvision itk = From 3ea9ed4397cfdf134cb49a0808b6fada332ed56a Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 16 Mar 2021 02:53:02 +0000 Subject: [PATCH 59/85] update contrib guide to include some recent changes in dev tools (#1763) Signed-off-by: Wenqi Li --- CONTRIBUTING.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325f81b127..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,8 +51,12 @@ 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 # runs the linting tools +# 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 @@ -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,7 +102,7 @@ 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 --unittests +./runtests.sh -f -u --net --coverage ``` or (for new features that would not break existing functionality): @@ -107,7 +111,7 @@ or (for new features that would not break existing functionality): ``` 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]. From 13a2c59c55863f4a2a968d7cb5629857e7011259 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 16 Mar 2021 17:40:15 +0800 Subject: [PATCH 60/85] 1768 Update docs based on feedback (#1774) * [DLMED] fix docs Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- docs/source/highlights.md | 6 +++--- monai/transforms/io/array.py | 7 ++++++- runtests.sh | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) 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/monai/transforms/io/array.py b/monai/transforms/io/array.py index 002bfd8242..60437307be 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -145,7 +145,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) diff --git a/runtests.sh b/runtests.sh index 85ede904f6..0e87a3a4e5 100755 --- a/runtests.sh +++ b/runtests.sh @@ -175,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() { From 3406398d37926923da9253e018c933c8b92468cc Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 16 Mar 2021 11:19:21 +0000 Subject: [PATCH 61/85] lossless inverse (#1767) lossless inverse transforms (non-croppad) --- monai/transforms/spatial/array.py | 8 +- monai/transforms/spatial/dictionary.py | 134 +++++++++++++++++++++++-- tests/test_inverse.py | 79 +++++++++++++++ 3 files changed, 209 insertions(+), 12 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 2867361b8e..33b8da3ebb 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -281,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 @@ -590,7 +590,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_ @@ -620,7 +620,7 @@ 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. """ - RandomizableTransform.__init__(self, min(max(prob, 0.0), 1.0)) + RandomizableTransform.__init__(self, prob) self.max_k = max_k self.spatial_axes = spatial_axes @@ -758,7 +758,7 @@ class RandFlip(RandomizableTransform): """ def __init__(self, prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: - RandomizableTransform.__init__(self, min(max(prob, 0.0), 1.0)) + RandomizableTransform.__init__(self, prob) self.flipper = Flip(spatial_axis=spatial_axis) def __call__(self, img: np.ndarray) -> np.ndarray: diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d9d38242fb..170006ed2b 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -15,6 +15,7 @@ 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 @@ -23,6 +24,7 @@ from monai.config import DtypeLike, KeysCollection from monai.networks.layers.simplelayers import GaussianFilter from monai.transforms.croppad.array import CenterSpatialCrop +from monai.transforms.inverse import InvertibleTransform from monai.transforms.spatial.array import ( Affine, Flip, @@ -47,6 +49,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", @@ -204,7 +210,7 @@ def __call__( return d -class Orientationd(MapTransform): +class Orientationd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Orientation`. @@ -259,13 +265,36 @@ def __call__( ) -> Dict[Union[Hashable, str], Union[np.ndarray, Dict[str, np.ndarray]]]: d: Dict = dict(data) for key in self.key_iterator(d): - meta_data = d[f"{key}_{self.meta_key_postfix}"] - d[key], _, new_affine = self.ornt_transform(d[key], affine=meta_data["affine"]) + 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.value]["meta_data_key"]] + orig_affine = transform[InverseKeys.EXTRA_INFO.value]["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`. """ @@ -286,11 +315,31 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) 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(RandomizableTransform, MapTransform): +class RandRotate90d(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRotate90`. With probability `prob`, input arrays are rotated by 90 degrees @@ -337,6 +386,27 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Mapping[Hashable, np. 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.value]: + # Create inverse transform + num_times_rotated = transform[InverseKeys.EXTRA_INFO.value]["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) + return d @@ -789,7 +859,7 @@ def __call__( return d -class Flipd(MapTransform): +class Flipd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Flip`. @@ -814,11 +884,26 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) 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) -class RandFlipd(RandomizableTransform, MapTransform): + return d + + +class RandFlipd(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandFlip`. @@ -851,10 +936,26 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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.value]: + # 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): +class RandAxisFlipd(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandAxisFlip`. @@ -885,6 +986,23 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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.value]: + flipper = Flip(spatial_axis=transform[InverseKeys.EXTRA_INFO.value]["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 diff --git a/tests/test_inverse.py b/tests/test_inverse.py index bb2d997eb5..0c29ea7b08 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -28,12 +28,18 @@ Compose, CropForegroundd, DivisiblePadd, + Flipd, InvertibleTransform, LoadImaged, + Orientationd, + RandAxisFlipd, + RandFlipd, Randomizable, + RandRotate90d, RandSpatialCropd, ResizeWithPadOrCrop, ResizeWithPadOrCropd, + Rotate90d, SpatialCropd, SpatialPadd, allow_missing_keys_mode, @@ -207,6 +213,79 @@ 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_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore From 43d9582cbe16f699c4c89d448be3c095af04e01d Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 17 Mar 2021 00:26:51 +0800 Subject: [PATCH 62/85] 1776 Add support for new arg `greater_or_equal` from ignite 0.4.4 (#1777) * [DLMED] add greater_or_equal Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/handlers/checkpoint_saver.py | 4 ++ tests/test_handler_checkpoint_saver.py | 55 +++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/monai/handlers/checkpoint_saver.py b/monai/handlers/checkpoint_saver.py index 0c65b8cd4b..9c67992b36 100644 --- a/monai/handlers/checkpoint_saver.py +++ b/monai/handlers/checkpoint_saver.py @@ -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/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, From 050efb7cc13237c716cc9d4d7751cd33805d1056 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 17 Mar 2021 03:58:23 +0800 Subject: [PATCH 63/85] 1757 Enhance CheckpointLoader to restore max_epochs (#1775) * [DLMED] enhance CheckpointLoader Signed-off-by: Nic Ma * [DLMED] add more unit tests Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] fix flake8 issue Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/handlers/checkpoint_loader.py | 11 +++++++++ tests/test_handler_checkpoint_loader.py | 31 ++++++++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/monai/handlers/checkpoint_loader.py b/monai/handlers/checkpoint_loader.py index e6319a3c64..bb67428bef 100644 --- a/monai/handlers/checkpoint_loader.py +++ b/monai/handlers/checkpoint_loader.py @@ -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/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() From bcbd6859ef0652b6dce491f372edeb8e9fc43c73 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Tue, 16 Mar 2021 18:09:36 -0400 Subject: [PATCH 64/85] Update WSIReader with cuCIM (#1773) * Update WSIReader with cuCIM Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update unittests for cuCIM Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- .github/workflows/pythonapp.yml | 4 ++++ docs/requirements.txt | 1 + monai/data/image_reader.py | 21 ++++++++++----------- requirements-dev.txt | 1 + setup.cfg | 3 +++ tests/test_cuimage_reader.py | 16 ++++++++-------- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 8ed1f6d21e..83d01ff5e0 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -83,6 +83,10 @@ jobs: 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.8.0 torchvision==0.9.0 diff --git a/docs/requirements.txt b/docs/requirements.txt index 22fd2589f0..ae3ced2c05 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,7 @@ pytorch-ignite==0.4.4 numpy>=1.17 itk>=5.0 nibabel +cucim==0.18.0 openslide-python==1.1.2 parameterized scikit-image>=0.14.2 diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index ddb2c8c913..67425c0f47 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -25,7 +25,7 @@ from .utils import is_supported_format if TYPE_CHECKING: - import cuimage + import cucim import itk # type: ignore import nibabel as nib import openslide @@ -33,14 +33,14 @@ from nibabel.nifti1 import Nifti1Image from PIL import Image as PILImage - has_itk = has_nib = has_pil = has_cux = has_osl = 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") - cuimage, has_cux = optional_import("cuimage") + cucim, has_cim = optional_import("cucim") openslide, has_osl = optional_import("openslide") __all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader"] @@ -638,8 +638,7 @@ class WSIReader(ImageReader): Read whole slide imaging and extract patches. Args: - reader_lib: backend library to load the images, available options: "OpenSlide" or "cuClaraImage". - TODO: `cuClaraImage` package is unavailable so far, will enable the support later. + reader_lib: backend library to load the images, available options: "OpenSlide" or "cuCIM". """ @@ -649,11 +648,11 @@ def __init__(self, reader_lib: str = "OpenSlide"): if self.reader_lib == "openslide": if has_osl: self.wsi_reader = openslide.OpenSlide - elif self.reader_lib == "cuclaraimage": - if has_cux: - self.wsi_reader = cuimage.CuImage + elif self.reader_lib == "cucim": + if has_cim: + self.wsi_reader = cucim.CuImage else: - raise ValueError('`reader_lib` should be either "cuClaraImage" or "OpenSlide"') + raise ValueError('`reader_lib` should be either "cuCIM" or "OpenSlide"') def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: """ @@ -676,8 +675,8 @@ def read(self, data: Union[Sequence[str], str, np.ndarray], **kwargs): """ if (self.reader_lib == "openslide") and (not has_osl): raise ImportError("No module named 'openslide'") - elif (self.reader_lib == "cuclaraimage") and (not has_cux): - raise ImportError("No module named 'cuimage'") + elif (self.reader_lib == "cucim") and (not has_cim): + raise ImportError("No module named 'cucim'") img_: List = [] diff --git a/requirements-dev.txt b/requirements-dev.txt index 1508eae4fe..82dd695d47 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -30,4 +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.0 openslide-python==1.1.2 diff --git a/setup.cfg b/setup.cfg index 9dd9fa106b..1222761801 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ all = torchvision itk>=5.0 tqdm>=4.47.0 + cucim==0.18.0 openslide-python==1.1.2 nibabel = nibabel @@ -55,6 +56,8 @@ lmdb = lmdb psutil = psutil +cucim = + cucim==0.18.0 openslide = openslide-python==1.1.2 diff --git a/tests/test_cuimage_reader.py b/tests/test_cuimage_reader.py index 7cdf692a30..221a458ca8 100644 --- a/tests/test_cuimage_reader.py +++ b/tests/test_cuimage_reader.py @@ -10,7 +10,7 @@ from monai.data.image_reader import WSIReader from monai.utils import optional_import -_, has_cui = optional_import("cuimage") +_, has_cim = optional_import("cucim") FILE_URL = "http://openslide.cs.cmu.edu/download/openslide-testdata/Generic-TIFF/CMU-1.tiff" @@ -61,31 +61,31 @@ ] -class TestCuClaraImageReader(unittest.TestCase): +class TestCuCIMReader(unittest.TestCase): @parameterized.expand([TEST_CASE_0]) - @skipUnless(has_cui, "Requires CuClaraImage") + @skipUnless(has_cim, "Requires CuCIM") def test_read_whole_image(self, file_url, expected_shape): filename = self.camelyon_data_download(file_url) - reader = WSIReader("CuClaraImage") + 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_cui, "Requires CuClaraImage") + @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("CuClaraImage") + 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_cui, "Requires CuClaraImage") + @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("CuClaraImage") + reader = WSIReader("cuCIM") img_obj = reader.read(filename) img = reader.get_data(img_obj, **patch_info)[0] self.assertTupleEqual(img.shape, expected_img.shape) From 55885283d5055f673fdca5f83156044649e5560c Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 16 Mar 2021 23:59:34 +0000 Subject: [PATCH 65/85] Inverse Spacingd (#1779) --- monai/transforms/spatial/array.py | 2 +- monai/transforms/spatial/dictionary.py | 39 ++++++++++++++++++++++++-- tests/test_inverse.py | 3 ++ tests/test_spacingd.py | 14 ++++++--- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 33b8da3ebb..8dd2692c2d 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -151,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 diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 170006ed2b..dd385bfc6e 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -111,7 +111,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`. @@ -194,10 +194,11 @@ def __call__( 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 = d[f"{key}_{self.meta_key_postfix}"] + 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=mode, @@ -205,10 +206,42 @@ def __call__( align_corners=align_corners, dtype=dtype, ) + self.push_transform(d, key, extra_info={"meta_data_key": meta_data_key, "old_affine": old_affine}) # 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.value]["meta_data_key"]] + old_affine = np.array(transform[InverseKeys.EXTRA_INFO.value]["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, InvertibleTransform): """ diff --git a/tests/test_inverse.py b/tests/test_inverse.py index 0c29ea7b08..204e2da723 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -40,6 +40,7 @@ ResizeWithPadOrCrop, ResizeWithPadOrCropd, Rotate90d, + Spacingd, SpatialCropd, SpatialPadd, allow_missing_keys_mode, @@ -286,6 +287,8 @@ ) ) +TESTS.append(("Spacingd 3d", "3D", 3e-2, Spacingd(KEYS, [0.5, 0.7, 0.9], diagonal=False))) + 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 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))) From b868087f1c0ec7709e20d9630ff1b189c691dd78 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Wed, 17 Mar 2021 16:46:14 +0800 Subject: [PATCH 66/85] 1755 improve focal loss (#1765) * Add to_onehot_y and include_background Signed-off-by: Yiheng Wang * Add parameters to test seg loss Signed-off-by: Yiheng Wang * modify log softmax Signed-off-by: Yiheng Wang * Modify weight format Signed-off-by: Yiheng Wang --- monai/losses/dice.py | 10 ++- monai/losses/focal_loss.py | 113 +++++++++++++++-------------- tests/test_focal_loss.py | 58 ++++++++++----- tests/test_seg_loss_integration.py | 17 ++++- 4 files changed, 118 insertions(+), 80 deletions(-) diff --git a/monai/losses/dice.py b/monai/losses/dice.py index 24bd038b68..47a1605fdc 100644 --- a/monai/losses/dice.py +++ b/monai/losses/dice.py @@ -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,7 +138,7 @@ 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[int] = torch.arange(2, len(input.shape)).tolist() diff --git a/monai/losses/focal_loss.py b/monai/losses/focal_loss.py index 920661f76f..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.ndim != t.ndim: - raise ValueError(f"logits and target ndim must match, got logits={i.ndim} target={t.ndim}.") - - 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/tests/test_focal_loss.py b/tests/test_focal_loss.py index 2d1df602c7..4512dac4b9 100644 --- a/tests/test_focal_loss.py +++ b/tests/test_focal_loss.py @@ -16,13 +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 @@ -36,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: @@ -45,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 @@ -60,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: @@ -75,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) @@ -91,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) @@ -106,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 @@ -115,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): @@ -137,33 +162,30 @@ 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): 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() From 46154ec78fb41680561fe275068c52dcd073faa5 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 17 Mar 2021 10:38:44 +0000 Subject: [PATCH 67/85] Inverse Resized (#1780) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/spatial/array.py | 2 +- monai/transforms/spatial/dictionary.py | 17 ++++++++++++++++- tests/test_inverse.py | 5 +++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 8dd2692c2d..891c811186 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -317,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: diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index dd385bfc6e..9b76b4d18d 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -443,7 +443,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar return d -class Resized(MapTransform): +class Resized(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Resize`. @@ -481,9 +481,24 @@ def __init__( def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) for key, mode, align_corners in self.key_iterator(d, self.mode, self.align_corners): + self.push_transform(d, key) 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.value] + # 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 Affined(RandomizableTransform, MapTransform): """ diff --git a/tests/test_inverse.py b/tests/test_inverse.py index 204e2da723..4a43cd729b 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -37,6 +37,7 @@ Randomizable, RandRotate90d, RandSpatialCropd, + Resized, ResizeWithPadOrCrop, ResizeWithPadOrCropd, Rotate90d, @@ -289,6 +290,10 @@ 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_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore From cf3bd1a82869dfcde8db9c8f7be99a50abd81811 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 17 Mar 2021 12:43:10 +0000 Subject: [PATCH 68/85] Inverse Zoomd and RandZoomd (#1778) Zoomd and RandZoomd --- monai/transforms/spatial/dictionary.py | 58 ++++++++++++++++++++++++-- tests/test_inverse.py | 32 ++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 9b76b4d18d..d9eb98302c 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -23,7 +23,7 @@ from monai.config import DtypeLike, KeysCollection from monai.networks.layers.simplelayers import GaussianFilter -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, @@ -1215,7 +1215,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d -class Zoomd(MapTransform): +class Zoomd(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Zoom`. @@ -1261,6 +1261,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): + self.push_transform(d, key) d[key] = self.zoomer( d[key], mode=mode, @@ -1269,8 +1270,31 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda ) 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.value])(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d -class RandZoomd(RandomizableTransform, MapTransform): + +class RandZoomd(RandomizableTransform, MapTransform, InvertibleTransform): """ Dict-based version :py:class:`monai.transforms.RandZoom`. @@ -1338,6 +1362,8 @@ 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={"zoom": self._zoom}) return d img_dims = data[self.keys[0]].ndim @@ -1351,6 +1377,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): + self.push_transform(d, key, extra_info={"zoom": self._zoom}) d[key] = zoomer( d[key], mode=mode, @@ -1359,6 +1386,31 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda ) 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.value]: + # Create inverse transform + zoom = np.array(transform[InverseKeys.EXTRA_INFO.value]["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.value])(d[key]) + # Remove the applied transform + self.pop_transform(d, key) + + return d + SpacingD = SpacingDict = Spacingd OrientationD = OrientationDict = Orientationd diff --git a/tests/test_inverse.py b/tests/test_inverse.py index 4a43cd729b..b0f2b8187a 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -37,6 +37,7 @@ Randomizable, RandRotate90d, RandSpatialCropd, + RandZoomd, Resized, ResizeWithPadOrCrop, ResizeWithPadOrCropd, @@ -44,6 +45,7 @@ Spacingd, SpatialCropd, SpatialPadd, + Zoomd, allow_missing_keys_mode, ) from monai.utils import first, get_seed, optional_import, set_determinism @@ -294,6 +296,36 @@ 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_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore From 592806a763f64dd67c05797288f9f0949276853c Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 17 Mar 2021 15:35:32 +0000 Subject: [PATCH 69/85] Inverse Rotated (#1782) Inverse Rotated and RandRotated --- monai/transforms/spatial/array.py | 8 ++- monai/transforms/spatial/dictionary.py | 74 ++++++++++++++++++++++++-- tests/test_inverse.py | 39 ++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 891c811186..17626e4582 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -421,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, @@ -482,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): """ @@ -743,7 +749,7 @@ 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(RandomizableTransform): diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d9eb98302c..1c6b6a14bc 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -22,6 +22,7 @@ import torch from monai.config import DtypeLike, KeysCollection +from monai.networks.layers import AffineTransform from monai.networks.layers.simplelayers import GaussianFilter from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad from monai.transforms.inverse import InvertibleTransform @@ -1054,7 +1055,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar return d -class Rotated(MapTransform): +class Rotated(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Rotate`. @@ -1106,6 +1107,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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=mode, @@ -1113,10 +1115,40 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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.value]["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.value], + ) + d[key] = np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) + # Remove the applied transform + self.pop_transform(d, key) -class RandRotated(RandomizableTransform, MapTransform): + return d + + +class RandRotated(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRotate` Randomly rotates the input arrays. @@ -1197,14 +1229,18 @@ 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 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=mode, @@ -1212,6 +1248,38 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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.value]: + # Create inverse transform + fwd_rot_mat = transform[InverseKeys.EXTRA_INFO.value]["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.value], + ) + d[key] = np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) + # Remove the applied transform + self.pop_transform(d, key) + return d diff --git a/tests/test_inverse.py b/tests/test_inverse.py index b0f2b8187a..03e1270ea3 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import random import sys import unittest from functools import partial @@ -36,12 +37,14 @@ RandFlipd, Randomizable, RandRotate90d, + RandRotated, RandSpatialCropd, RandZoomd, Resized, ResizeWithPadOrCrop, ResizeWithPadOrCropd, Rotate90d, + Rotated, Spacingd, SpatialCropd, SpatialPadd, @@ -326,6 +329,42 @@ 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_COMPOSE_X2 = [(t[0] + " Compose", t[1], t[2], Compose(Compose(t[3:]))) for t in TESTS] TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore From 7f0e789514554928c664cd02a64f7cef8fa901e5 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 17 Mar 2021 19:26:12 +0000 Subject: [PATCH 70/85] Inverse Affined and RandAffined (#1781) * inverse RandAffined Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/spatial/array.py | 58 +++++++++++++++++-------- monai/transforms/spatial/dictionary.py | 59 +++++++++++++++++++++++++- tests/test_inverse.py | 36 ++++++++++++++++ tests/test_rand_affined.py | 2 + 4 files changed, 135 insertions(+), 20 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 17626e4582..de9bba8e95 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -931,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. """ @@ -942,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 @@ -951,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: @@ -969,27 +977,32 @@ 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(RandomizableTransform): @@ -1040,6 +1053,7 @@ 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 = [] @@ -1059,7 +1073,9 @@ def randomize(self, data: Optional[Any] = None) -> None: 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: @@ -1078,7 +1094,13 @@ 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(RandomizableTransform): diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 1c6b6a14bc..caa1a34e08 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -28,6 +28,7 @@ from monai.transforms.inverse import InvertibleTransform from monai.transforms.spatial.array import ( Affine, + AffineGrid, Flip, Orientation, Rand2DElastic, @@ -501,7 +502,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar return d -class Affined(RandomizableTransform, MapTransform): +class Affined(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Affine`. """ @@ -570,11 +571,38 @@ def __call__( ) -> 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.value] + # Create inverse transform + fwd_affine = transform[InverseKeys.EXTRA_INFO.value]["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): +class RandAffined(RandomizableTransform, MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.RandAffine`. """ @@ -667,13 +695,40 @@ def __call__( sp_size = fall_back_tuple(self.rand_affine.spatial_size, data[self.keys[0]].shape[1:]) 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 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.value] + # Create inverse transform + fwd_affine = transform[InverseKeys.EXTRA_INFO.value]["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.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): """ diff --git a/tests/test_inverse.py b/tests/test_inverse.py index 03e1270ea3..c1225ea11c 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -24,6 +24,7 @@ from monai.networks.nets import UNet from monai.transforms import ( AddChanneld, + Affined, BorderPadd, CenterSpatialCropd, Compose, @@ -33,6 +34,7 @@ InvertibleTransform, LoadImaged, Orientationd, + RandAffined, RandAxisFlipd, RandFlipd, Randomizable, @@ -365,6 +367,40 @@ ) ) +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 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): From 053d0c8aab5bf038b65300bf2632fc0be7a10991 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 18 Mar 2021 05:10:07 +0800 Subject: [PATCH 71/85] Fix type error (#1792) Signed-off-by: Yiheng Wang --- monai/transforms/intensity/array.py | 3 ++- monai/transforms/intensity/dictionary.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 1bddc0137d..91407323b9 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -148,7 +148,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 diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 4602d59379..7d4319eab0 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -221,8 +221,8 @@ class ScaleIntensityd(MapTransform): def __init__( self, keys: KeysCollection, - minv: float = 0.0, - maxv: float = 1.0, + minv: Optional[float] = 0.0, + maxv: Optional[float] = 1.0, factor: Optional[float] = None, allow_missing_keys: bool = False, ) -> None: @@ -232,7 +232,8 @@ 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. """ From f063a4791bd5cab9a9bf36f677e770dbf80cc6d0 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Wed, 17 Mar 2021 20:13:04 -0400 Subject: [PATCH 72/85] Update cucim to remove warning message (#1799) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- docs/requirements.txt | 2 +- requirements-dev.txt | 2 +- setup.cfg | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index ae3ced2c05..f05bc5b9ca 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,7 @@ pytorch-ignite==0.4.4 numpy>=1.17 itk>=5.0 nibabel -cucim==0.18.0 +cucim==0.18.1 openslide-python==1.1.2 parameterized scikit-image>=0.14.2 diff --git a/requirements-dev.txt b/requirements-dev.txt index 82dd695d47..dc4181b310 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -30,5 +30,5 @@ Sphinx==3.3.0 recommonmark==0.6.0 sphinx-autodoc-typehints==1.11.1 sphinx-rtd-theme==0.5.0 -cucim==0.18.0 +cucim==0.18.1 openslide-python==1.1.2 diff --git a/setup.cfg b/setup.cfg index 1222761801..15e6a6d127 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,7 @@ all = torchvision itk>=5.0 tqdm>=4.47.0 - cucim==0.18.0 + cucim==0.18.1 openslide-python==1.1.2 nibabel = nibabel @@ -57,7 +57,7 @@ lmdb = psutil = psutil cucim = - cucim==0.18.0 + cucim==0.18.1 openslide = openslide-python==1.1.2 From 466a0bf629cbf98920935dd6cf4512dc5c908a8f Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Fri, 19 Mar 2021 01:14:51 +0800 Subject: [PATCH 73/85] Implement FROC metric (#1509) * Implement FROC metric Signed-off-by: yiheng-wang-nv * Fix doc error Signed-off-by: yiheng-wang-nv * Change param lowercase Signed-off-by: yiheng-wang-nv * Update calculate functions Signed-off-by: yiheng-wang-nv * fix mypy error Signed-off-by: yiheng-wang-nv * Update parameter names Signed-off-by: Yiheng Wang --- docs/source/metrics.rst | 4 ++ monai/metrics/__init__.py | 1 + monai/metrics/froc.py | 137 +++++++++++++++++++++++++++++++++++++ tests/test_compute_froc.py | 101 +++++++++++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 monai/metrics/froc.py create mode 100644 tests/test_compute_froc.py 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/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/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() From 86cbf05a19271a0c320030435a90d7c6d23a8b06 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 18 Mar 2021 19:21:17 +0000 Subject: [PATCH 74/85] [1798] fix RandZoomd collation (#1801) * fix RandZoomd collation Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * dont check class ID on windows Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * if elif change Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code review Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * remove extra import Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * win32 change Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * changes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * update RandAxisFlipd Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * help message more general Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * more changes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * use torch Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * wyli changes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * more changes Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * skip test if win32 Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * get default mp Signed-off-by: Wenqi Li * dedicated fail test Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * requires reason Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> Co-authored-by: Wenqi Li --- monai/data/utils.py | 2 +- monai/transforms/inverse.py | 12 +++- monai/transforms/spatial/dictionary.py | 19 +++--- tests/test_inverse.py | 18 +++-- tests/test_inverse_collation.py | 93 ++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 tests/test_inverse_collation.py diff --git a/monai/data/utils.py b/monai/data/utils.py index 1db2f6676f..ae0180f4b5 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -257,7 +257,7 @@ def list_data_collate(batch: Sequence): return default_collate(data) except RuntimeError as re: re_str = str(re) - if "stack expects each tensor to be equal size" in re_str: + 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 " diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index f9de8746ca..9708f103e6 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -12,6 +12,7 @@ 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 @@ -89,8 +90,15 @@ def push_transform( def check_transforms_match(self, transform: dict) -> None: """Check transforms are of same instance.""" - if transform[InverseKeys.ID.value] != id(self): - raise RuntimeError("Should inverse most recently applied invertible transform first") + if transform[InverseKeys.ID.value] == 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.value] == 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.""" diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index caa1a34e08..32327ec302 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -1090,7 +1090,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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}) + self.push_transform(d, key, extra_info={"axis": self._axis}) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: @@ -1484,10 +1484,6 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda # match the spatial dim of first item self.randomize() d = dict(data) - if not self._do_transform: - for key in self.keys: - self.push_transform(d, key, extra_info={"zoom": self._zoom}) - return d img_dims = data[self.keys[0]].ndim if len(self._zoom) == 1: @@ -1501,12 +1497,13 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda d, self.mode, self.padding_mode, self.align_corners ): self.push_transform(d, key, extra_info={"zoom": self._zoom}) - d[key] = zoomer( - d[key], - mode=mode, - padding_mode=padding_mode, - align_corners=align_corners, - ) + 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]: diff --git a/tests/test_inverse.py b/tests/test_inverse.py index c1225ea11c..8ce4e3bbf3 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -14,6 +14,7 @@ import unittest from functools import partial from typing import TYPE_CHECKING, List, Tuple +from unittest.case import skipUnless import numpy as np import torch @@ -512,11 +513,6 @@ def test_inverse(self, _, data_name, acceptable_diff, *transforms): t.set_random_state(seed=get_seed()) forwards.append(t(forwards[-1])) - # Check that error is thrown when inverse are used out of order. - t = SpatialPadd("image", [10, 5]) - with self.assertRaises(RuntimeError): - t.inverse(forwards[-1]) - # Apply inverses fwd_bck = forwards[-1].copy() for i, t in enumerate(reversed(transforms)): @@ -524,6 +520,18 @@ def test_inverse(self, _, data_name, acceptable_diff, *transforms): 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 = [] 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() From 4411758c4eacb3d017f156969870166024934e1c Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Thu, 18 Mar 2021 18:18:53 -0400 Subject: [PATCH 75/85] Remove WSIReader from LoadImage (#1796) * Remove WSIReader from LoadImage Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove WSIReader from LoadImaged Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/transforms/io/array.py | 7 +++---- monai/transforms/io/dictionary.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 60437307be..164c7b0e76 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -19,7 +19,7 @@ import torch from monai.config import DtypeLike -from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NumpyReader, PILReader, WSIReader +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.transform import Transform @@ -78,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", "WSIReader". + "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. @@ -90,7 +90,7 @@ def __init__( """ # set predefined readers as default - self.readers: List[ImageReader] = [ITKReader(), NumpyReader(), PILReader(), NibabelReader(), WSIReader()] + self.readers: List[ImageReader] = [ITKReader(), NumpyReader(), PILReader(), NibabelReader()] if reader is not None: if isinstance(reader, str): supported_readers = { @@ -98,7 +98,6 @@ def __init__( "pilreader": PILReader, "itkreader": ITKReader, "numpyreader": NumpyReader, - "wsireader": WSIReader, } reader = reader.lower() if reader not in supported_readers: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 7f663ea303..79f8561d5e 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -71,7 +71,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", "WSIReader". + "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. From 7d257434f6164e267bd03eddc45e9415d35e3f40 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Fri, 19 Mar 2021 17:26:17 +0800 Subject: [PATCH 76/85] 1804 add pretrain options (#1805) * add pretrain options Signed-off-by: Yiheng Wang --- monai/networks/nets/densenet.py | 113 ++++++++++++++--------- monai/networks/nets/senet.py | 153 ++++++++++++++++++-------------- tests/test_densenet.py | 18 +++- tests/test_senet.py | 26 +++++- 4 files changed, 195 insertions(+), 115 deletions(-) diff --git a/monai/networks/nets/densenet.py b/monai/networks/nets/densenet.py index a59ab99e68..4b4f2cc6a4 100644 --- a/monai/networks/nets/densenet.py +++ b/monai/networks/nets/densenet.py @@ -115,6 +115,11 @@ class DenseNet(nn.Module): bn_size: multiplicative factor for number of bottle neck layers. (i.e. bn_size * k features in the bottleneck layer) dropout_prob: dropout rate after each dense layer. + pretrained: whether to load ImageNet pretrained weights when `spatial_dims == 2`. + In order to load weights correctly, Please ensure that the `block_config` + is consistent with the corresponding arch. + pretrained_arch: the arch name for pretrained weights. + progress: If True, displays a progress bar of the download to stderr. """ def __init__( @@ -127,6 +132,9 @@ def __init__( block_config: Sequence[int] = (6, 12, 24, 16), bn_size: int = 4, dropout_prob: float = 0.0, + pretrained: bool = False, + pretrained_arch: str = "densenet121", + progress: bool = True, ) -> None: super(DenseNet, self).__init__() @@ -190,43 +198,49 @@ def __init__( elif isinstance(m, nn.Linear): nn.init.constant_(torch.as_tensor(m.bias), 0) + if pretrained: + self._load_state_dict(pretrained_arch, progress) + def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.features(x) x = self.class_layers(x) return x + def _load_state_dict(self, 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.keys(): + 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))$" + ) -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): - """ - This function is used to load pretrained models. - Adapted from `PyTorch Hub 2D version - `_ - """ - pattern = re.compile( - 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) + ".layers" + res.group(2) + res.group(3) - state_dict[new_key] = state_dict[key] - del state_dict[key] + 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) + ".layers" + res.group(2) + res.group(3) + state_dict[new_key] = state_dict[key] + del state_dict[key] - model_dict = model.state_dict() - state_dict = { - k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) - } - model_dict.update(state_dict) - model.load_state_dict(model_dict) + model_dict = self.state_dict() + state_dict = { + k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) + } + model_dict.update(state_dict) + self.load_state_dict(model_dict) def densenet121(pretrained: bool = False, progress: bool = True, **kwargs) -> DenseNet: @@ -235,10 +249,15 @@ def densenet121(pretrained: bool = False, progress: bool = True, **kwargs) -> De 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) + model = DenseNet( + init_features=64, + growth_rate=32, + block_config=(6, 12, 24, 16), + pretrained=pretrained, + pretrained_arch="densenet121", + progress=progress, + **kwargs, + ) return model @@ -248,10 +267,15 @@ def densenet169(pretrained: bool = False, progress: bool = True, **kwargs) -> De 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) + model = DenseNet( + init_features=64, + growth_rate=32, + block_config=(6, 12, 32, 32), + pretrained=pretrained, + pretrained_arch="densenet169", + progress=progress, + **kwargs, + ) return model @@ -261,10 +285,15 @@ def densenet201(pretrained: bool = False, progress: bool = True, **kwargs) -> De 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) + model = DenseNet( + init_features=64, + growth_rate=32, + block_config=(6, 12, 48, 32), + pretrained=pretrained, + pretrained_arch="densenet201", + progress=progress, + **kwargs, + ) return model diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py index ef67f853d6..333a3b1159 100644 --- a/monai/networks/nets/senet.py +++ b/monai/networks/nets/senet.py @@ -66,7 +66,11 @@ class SENet(nn.Module): - For SE-ResNeXt models: False num_classes: number of outputs in `last_linear` layer. for all models: 1000 - + pretrained: whether to load ImageNet pretrained weights when `spatial_dims == 2`. + In order to load weights correctly, Please ensure that the `block_config` + is consistent with the corresponding arch. + pretrained_arch: the arch name for pretrained weights. + progress: If True, displays a progress bar of the download to stderr. """ def __init__( @@ -83,6 +87,9 @@ def __init__( downsample_kernel_size: int = 3, input_3x3: bool = True, num_classes: int = 1000, + pretrained: bool = False, + pretrained_arch: str = "se_resnet50", + progress: bool = True, ) -> None: super(SENet, self).__init__() @@ -176,6 +183,64 @@ def __init__( elif isinstance(m, nn.Linear): nn.init.constant_(torch.as_tensor(m.bias), 0) + if pretrained: + self._load_state_dict(pretrained_arch, progress) + + def _load_state_dict(self, 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.keys(): + 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*)$") + pattern_se2 = re.compile(r"^(layer[1-4]\.\d\.)(?:se_module.fc2.)(\w*)$") + pattern_down_conv = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.0.)(\w*)$") + pattern_down_bn = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.1.)(\w*)$") + + state_dict = load_state_dict_from_url(model_url, progress=progress) + for key in list(state_dict.keys()): + new_key = None + 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\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) + elif pattern_se2.match(key): + state_dict[key] = state_dict[key].squeeze() + new_key = re.sub(pattern_se2, r"\1se_layer.fc.2.\2", key) + 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.adn.N.\2", key) + if new_key: + state_dict[new_key] = state_dict[key] + del state_dict[key] + + model_dict = self.state_dict() + state_dict = { + k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) + } + model_dict.update(state_dict) + self.load_state_dict(model_dict) + def _make_layer( self, block: Type[Union[SEBottleneck, SEResNetBottleneck, SEResNeXtBottleneck]], @@ -248,56 +313,6 @@ 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): - """ - This function is used to load pretrained models. - """ - 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*)$") - pattern_se2 = re.compile(r"^(layer[1-4]\.\d\.)(?:se_module.fc2.)(\w*)$") - pattern_down_conv = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.0.)(\w*)$") - pattern_down_bn = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.1.)(\w*)$") - - state_dict = load_state_dict_from_url(model_url, progress=progress) - for key in list(state_dict.keys()): - new_key = None - 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\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) - elif pattern_se2.match(key): - state_dict[key] = state_dict[key].squeeze() - new_key = re.sub(pattern_se2, r"\1se_layer.fc.2.\2", key) - 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.adn.N.\2", key) - if new_key: - state_dict[new_key] = state_dict[key] - del state_dict[key] - - model_dict = model.state_dict() - state_dict = { - k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) - } - model_dict.update(state_dict) - model.load_state_dict(model_dict) - - def senet154( spatial_dims: int, in_channels: int, @@ -320,10 +335,10 @@ def senet154( dropout_prob=0.2, dropout_dim=1, num_classes=num_classes, + pretrained=pretrained, + pretrained_arch="senet154", + progress=progress, ) - if pretrained: - arch = "senet154" - _load_state_dict(model, model_urls[arch], progress) return model @@ -347,10 +362,10 @@ def se_resnet50( input_3x3=False, downsample_kernel_size=1, num_classes=num_classes, + pretrained=pretrained, + pretrained_arch="se_resnet50", + progress=progress, ) - if pretrained: - arch = "se_resnet50" - _load_state_dict(model, model_urls[arch], progress) return model @@ -375,10 +390,10 @@ def se_resnet101( input_3x3=False, downsample_kernel_size=1, num_classes=num_classes, + pretrained=pretrained, + pretrained_arch="se_resnet101", + progress=progress, ) - if pretrained: - arch = "se_resnet101" - _load_state_dict(model, model_urls[arch], progress) return model @@ -403,10 +418,10 @@ def se_resnet152( input_3x3=False, downsample_kernel_size=1, num_classes=num_classes, + pretrained=pretrained, + pretrained_arch="se_resnet152", + progress=progress, ) - if pretrained: - arch = "se_resnet152" - _load_state_dict(model, model_urls[arch], progress) return model @@ -430,10 +445,10 @@ def se_resnext50_32x4d( input_3x3=False, downsample_kernel_size=1, num_classes=num_classes, + pretrained=pretrained, + pretrained_arch="se_resnext50_32x4d", + progress=progress, ) - if pretrained: - arch = "se_resnext50_32x4d" - _load_state_dict(model, model_urls[arch], progress) return model @@ -457,8 +472,8 @@ def se_resnext101_32x4d( input_3x3=False, downsample_kernel_size=1, num_classes=num_classes, + pretrained=pretrained, + pretrained_arch="se_resnext101_32x4d", + progress=progress, ) - if pretrained: - arch = "se_resnext101_32x4d" - _load_state_dict(model, model_urls[arch], progress) return model diff --git a/tests/test_densenet.py b/tests/test_densenet.py index 41b5fbf7d6..5ead5f5818 100644 --- a/tests/test_densenet.py +++ b/tests/test_densenet.py @@ -17,7 +17,7 @@ from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.nets import densenet121, densenet169, densenet201, densenet264 +from monai.networks.nets import DenseNet, densenet121, densenet169, densenet201, densenet264 from monai.utils import optional_import from tests.utils import skip_if_quick, test_pretrained_networks, test_script_save @@ -78,6 +78,17 @@ (1, 3, 32, 32), ] +TEST_PRETRAINED_2D_CASE_4 = [ + { + "pretrained": True, + "pretrained_arch": "densenet264", + "progress": False, + "spatial_dims": 2, + "in_channels": 3, + "out_channels": 1, + }, +] + class TestPretrainedDENSENET(unittest.TestCase): @parameterized.expand([TEST_PRETRAINED_2D_CASE_1, TEST_PRETRAINED_2D_CASE_2]) @@ -100,6 +111,11 @@ def test_pretrain_consistency(self, model, input_param, input_shape): expected_result = torchvision_net.features.forward(example) self.assertTrue(torch.all(result == expected_result)) + @parameterized.expand([TEST_PRETRAINED_2D_CASE_4]) + def test_ill_pretrain(self, input_param): + with self.assertRaisesRegex(ValueError, ""): + net = DenseNet(**input_param) + class TestDENSENET(unittest.TestCase): @parameterized.expand(TEST_CASES) diff --git a/tests/test_senet.py b/tests/test_senet.py index c1327ceb7d..a2d96e1f18 100644 --- a/tests/test_senet.py +++ b/tests/test_senet.py @@ -17,7 +17,9 @@ from parameterized import parameterized from monai.networks import eval_mode +from monai.networks.blocks.squeeze_and_excitation import SEBottleneck from monai.networks.nets import ( + SENet, se_resnet50, se_resnet101, se_resnet152, @@ -46,7 +48,20 @@ TEST_CASE_5 = [se_resnext50_32x4d, NET_ARGS] TEST_CASE_6 = [se_resnext101_32x4d, NET_ARGS] -TEST_CASE_PRETRAINED = [se_resnet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}] +TEST_CASE_PRETRAINED_1 = [se_resnet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}] +TEST_CASE_PRETRAINED_2 = [ + { + "spatial_dims": 2, + "in_channels": 3, + "block": SEBottleneck, + "layers": [3, 8, 36, 3], + "groups": 64, + "reduction": 16, + "num_classes": 2, + "pretrained": True, + "pretrained_arch": "resnet50", + } +] class TestSENET(unittest.TestCase): @@ -67,7 +82,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) @@ -77,7 +92,7 @@ def test_senet_shape(self, model, input_param): result = net(input_data) self.assertEqual(result.shape, expected_shape) - @parameterized.expand([TEST_CASE_PRETRAINED]) + @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) @@ -92,6 +107,11 @@ def test_pretrain_consistency(self, model, input_param): # 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)) + @parameterized.expand([TEST_CASE_PRETRAINED_2]) + def test_ill_pretrain(self, input_param): + with self.assertRaisesRegex(ValueError, ""): + net = SENet(**input_param) + if __name__ == "__main__": unittest.main() From 70388fa298384f698dfb3ba790e3bedb1ffcb07b Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Sat, 20 Mar 2021 02:06:17 +0800 Subject: [PATCH 77/85] 1809 add std shift intensity transform (#1810) * add pretrain options Signed-off-by: Yiheng Wang --- docs/source/transforms.rst | 24 +++++++ monai/transforms/__init__.py | 8 +++ monai/transforms/intensity/array.py | 92 +++++++++++++++++++++++- monai/transforms/intensity/dictionary.py | 88 +++++++++++++++++++++++ tests/test_normalize_intensity.py | 2 +- tests/test_rand_std_shift_intensity.py | 32 +++++++++ tests/test_rand_std_shift_intensityd.py | 33 +++++++++ tests/test_std_shift_intensity.py | 57 +++++++++++++++ tests/test_std_shift_intensityd.py | 61 ++++++++++++++++ 9 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 tests/test_rand_std_shift_intensity.py create mode 100644 tests/test_rand_std_shift_intensityd.py create mode 100644 tests/test_std_shift_intensity.py create mode 100644 tests/test_std_shift_intensityd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index dcdeab1ac8..768c0665a2 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -143,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 @@ -638,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 diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 5b12da4d21..0ce09e69d2 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -78,11 +78,13 @@ RandHistogramShift, RandScaleIntensity, RandShiftIntensity, + RandStdShiftIntensity, SavitzkyGolaySmooth, ScaleIntensity, ScaleIntensityRange, ScaleIntensityRangePercentiles, ShiftIntensity, + StdShiftIntensity, ThresholdIntensity, ) from .intensity.dictionary import ( @@ -122,6 +124,9 @@ RandShiftIntensityd, RandShiftIntensityD, RandShiftIntensityDict, + RandStdShiftIntensityd, + RandStdShiftIntensityD, + RandStdShiftIntensityDict, ScaleIntensityd, ScaleIntensityD, ScaleIntensityDict, @@ -134,6 +139,9 @@ ShiftIntensityd, ShiftIntensityD, ShiftIntensityDict, + StdShiftIntensityd, + StdShiftIntensityD, + StdShiftIntensityDict, ThresholdIntensityd, ThresholdIntensityD, ThresholdIntensityDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 91407323b9..abd7de151f 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -30,6 +30,8 @@ "RandGaussianNoise", "ShiftIntensity", "RandShiftIntensity", + "StdShiftIntensity", + "RandStdShiftIntensity", "ScaleIntensity", "RandScaleIntensity", "NormalizeIntensity", @@ -135,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). @@ -173,7 +263,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: 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: diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 7d4319eab0..881a0d3dc9 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -32,6 +32,7 @@ ScaleIntensityRange, ScaleIntensityRangePercentiles, ShiftIntensity, + StdShiftIntensity, ThresholdIntensity, ) from monai.transforms.transform import MapTransform, RandomizableTransform @@ -211,6 +212,91 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda 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 + + class ScaleIntensityd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.ScaleIntensity`. @@ -812,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/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_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_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() From 3e313d242f7b534c9f683a6cfb37e4930039ddfb Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 19 Mar 2021 21:44:34 +0000 Subject: [PATCH 78/85] PadListDataCollate transform (#1813) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/data/utils.py | 59 +++---------- monai/transforms/__init__.py | 1 + monai/transforms/croppad/batch.py | 129 ++++++++++++++++++++++++++++ monai/transforms/post/dictionary.py | 1 + tests/test_decollate.py | 35 +++++--- tests/test_pad_collation.py | 33 ++++--- 6 files changed, 181 insertions(+), 77 deletions(-) create mode 100644 monai/transforms/croppad/batch.py diff --git a/monai/data/utils.py b/monai/data/utils.py index ae0180f4b5..bdbfa5c636 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -337,64 +337,25 @@ def pad_list_data_collate( 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. + tensor in each dimension. This transform is useful if some of the applied transforms generate batch data of + different sizes. - Note: - Need to use this collate if apply some transforms that can generate batch data. + 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`) """ - list_of_dicts = isinstance(batch[0], dict) - for key_or_idx in batch[0].keys() if list_of_dicts else range(len(batch[0])): - 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 - 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: Union[SpatialPadd, SpatialPad] - if list_of_dicts: - from monai.transforms.croppad.dictionary import SpatialPadd # needs to be here to avoid circular import + from monai.transforms.croppad.batch import PadListDataCollate # needs to be here to avoid circular import - padder = SpatialPadd(key_or_idx, max_shape, method, mode) # type: ignore - - else: - from monai.transforms.croppad.array import SpatialPad # needs to be here to avoid circular import - - padder = SpatialPad(max_shape, method, mode) # type: ignore - - for idx in range(len(batch)): - padded = padder(batch[idx])[key_or_idx] if list_of_dicts else padder(batch[idx][key_or_idx]) - # since tuple is immutable we'll have to recreate - if isinstance(batch[idx], tuple): - batch[idx] = list(batch[idx]) # type: ignore - batch[idx][key_or_idx] = padded - batch[idx] = tuple(batch[idx]) # type: ignore - # else, replace - else: - batch[idx][key_or_idx] = padder(batch[idx])[key_or_idx] - - if output_to_tensor: - batch[idx][key_or_idx] = torch.Tensor(batch[idx][key_or_idx]) - - # After padding, use default list collator - return list_data_collate(batch) + return PadListDataCollate(method, mode)(batch) def worker_init_fn(worker_id: int) -> None: diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 0ce09e69d2..22311cdca6 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -25,6 +25,7 @@ SpatialCrop, SpatialPad, ) +from .croppad.batch import PadListDataCollate from .croppad.dictionary import ( BorderPadd, BorderPadD, diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py new file mode 100644 index 0000000000..7cbf39597c --- /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.value + if transform_key in d.keys(): + transform = d[transform_key][-1] + if transform[InverseKeys.CLASS_NAME.value] == PadListDataCollate.__name__: + d[key] = CenterSpatialCrop(transform["orig_size"])(d[key]) + # remove transform + d[transform_key].pop() + return d diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 42796e2412..6d28f780d4 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -333,6 +333,7 @@ class Decollated(MapTransform): """ def __init__(self, batch_size: Optional[int] = None) -> None: + super().__init__(None) self.batch_size = batch_size def __call__(self, data: dict) -> List[dict]: diff --git a/tests/test_decollate.py b/tests/test_decollate.py index 4ed8de6bbb..4dc5a217a7 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -12,25 +12,38 @@ 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) @@ -55,24 +68,18 @@ def check_match(self, in1, in2): else: raise RuntimeError(f"Not sure how to compare types. type(in1): {type(in1)}, type(in2): {type(in2)}") - def test_decollation(self, batch_size=2, num_workers=2): + @parameterized.expand(TESTS) + def test_decollation(self, *transforms): - im = create_test_image_2d(100, 101)[0] - data = [{"image": make_nifti_image(im) if has_nib else im} for _ in range(6)] - - transforms = Compose( - [ - AddChanneld("image"), - SpatialPadd("image", 150), - RandFlipd("image", prob=1.0, spatial_axis=1), - ToTensord("image"), - ] - ) + batch_size = 2 + num_workers = 2 + + t_compose = Compose([AddChanneld(KEYS), Compose(transforms), ToTensord(KEYS)]) # If nibabel present, read from disk if has_nib: - transforms = Compose([LoadImaged("image"), transforms]) + t_compose = Compose([LoadImaged("image"), t_compose]) - dataset = CacheDataset(data, transforms, progress=False) + 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): diff --git a/tests/test_pad_collation.py b/tests/test_pad_collation.py index 156d2649e0..3835dc8895 100644 --- a/tests/test_pad_collation.py +++ b/tests/test_pad_collation.py @@ -18,8 +18,9 @@ from parameterized import parameterized from monai.data import CacheDataset, DataLoader -from monai.data.utils import pad_list_data_collate +from monai.data.utils import decollate_batch, pad_list_data_collate from monai.transforms import ( + PadListDataCollate, RandRotate, RandRotate90, RandRotate90d, @@ -33,16 +34,16 @@ 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((dict, RandSpatialCropd("image", roi_size=[8, 7], random_size=True))) -TESTS.append((dict, RandRotated("image", prob=1, range_x=np.pi, keep_size=False))) -TESTS.append((dict, RandZoomd("image", prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) -TESTS.append((dict, RandRotate90d("image", prob=1, max_k=2))) - -TESTS.append((list, RandSpatialCrop(roi_size=[8, 7], random_size=True))) -TESTS.append((list, RandRotate(prob=1, range_x=np.pi, keep_size=False))) -TESTS.append((list, RandZoom(prob=1, min_zoom=1.1, max_zoom=2.0, keep_size=False))) -TESTS.append((list, RandRotate90(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): @@ -72,7 +73,7 @@ def tearDown(self) -> None: set_determinism(None) @parameterized.expand(TESTS) - def test_pad_collation(self, t_type, transform): + def test_pad_collation(self, t_type, collate_method, transform): if t_type == dict: dataset = CacheDataset(self.dict_data, transform, progress=False) @@ -86,9 +87,13 @@ def test_pad_collation(self, t_type, transform): pass # Padded collation shouldn't - loader = DataLoader(dataset, batch_size=2, collate_fn=pad_list_data_collate) - for _ in loader: - pass + 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__": From fdf26fbd08da035d1aab0c0eaf5a8f3713d1ee94 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Fri, 19 Mar 2021 23:40:32 +0000 Subject: [PATCH 79/85] Jupyter Utilities (#1797) * Jupyter and other additions Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Jupyter utilities update Signed-off-by: Eric Kerfoot * Update Signed-off-by: Eric Kerfoot Co-authored-by: Nic Ma Co-authored-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- docs/source/data.rst | 6 + monai/data/__init__.py | 3 +- monai/data/dataset.py | 55 ++++- monai/data/thread_buffer.py | 19 ++ monai/handlers/__init__.py | 2 +- monai/handlers/metric_logger.py | 79 ++++++- monai/utils/__init__.py | 1 + monai/utils/jupyter_utils.py | 351 +++++++++++++++++++++++++++++++ tests/test_npzdictitemdataset.py | 55 +++++ tests/test_thread_buffer.py | 12 +- tests/test_threadcontainer.py | 58 +++++ 11 files changed, 626 insertions(+), 15 deletions(-) create mode 100644 monai/utils/jupyter_utils.py create mode 100644 tests/test_npzdictitemdataset.py create mode 100644 tests/test_threadcontainer.py diff --git a/docs/source/data.rst b/docs/source/data.rst index c95659bc6e..6ed6be9702 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 ------------------- diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 54beb53e3f..2a7647e527 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -17,6 +17,7 @@ CacheNTransDataset, Dataset, LMDBDataset, + NPZDictItemDataset, PersistentDataset, SmartCacheDataset, ZipDataset, @@ -32,7 +33,7 @@ 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 ( compute_importance_map, compute_shape_offset, diff --git a/monai/data/dataset.py b/monai/data/dataset.py index c032e65af6..c10c500bf8 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -19,12 +19,13 @@ 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 @@ -931,3 +932,53 @@ def __getitem__(self, index: int): 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/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/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/metric_logger.py b/monai/handlers/metric_logger.py index 758276d03d..c749d4bbab 100644 --- a/monai/handlers/metric_logger.py +++ b/monai/handlers/metric_logger.py @@ -10,8 +10,11 @@ # 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.engines.utils import CommonKeys from monai.utils import exact_version, optional_import Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") @@ -21,12 +24,43 @@ 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/utils/__init__.py b/monai/utils/__init__.py index 3c1e7efe24..4d272ac6ff 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -31,6 +31,7 @@ UpsampleMode, Weight, ) +from .jupyter_utils import StatusMembers, ThreadContainer from .misc import ( MAX_SEED, ImageMetaKey, diff --git a/monai/utils/jupyter_utils.py b/monai/utils/jupyter_utils.py new file mode 100644 index 0000000000..a7e712619e --- /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/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_thread_buffer.py b/tests/test_thread_buffer.py index d139b44c85..1b3ebb910d 100644 --- a/tests/test_thread_buffer.py +++ b/tests/test_thread_buffer.py @@ -13,7 +13,7 @@ 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 @@ -41,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) diff --git a/tests/test_threadcontainer.py b/tests/test_threadcontainer.py new file mode 100644 index 0000000000..92a50a15aa --- /dev/null +++ b/tests/test_threadcontainer.py @@ -0,0 +1,58 @@ +# 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 + +try: + _, has_ignite = optional_import("ignite") + + from monai.engines import CommonKeys, 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() From a2aeb4040bd4b8c927b04550eef40425a651e126 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Sat, 20 Mar 2021 09:51:35 +0000 Subject: [PATCH 80/85] Batch inverse (#1795) batch inverse --- docs/source/data.rst | 5 ++ monai/data/__init__.py | 1 + monai/data/inverse_batch_transform.py | 84 +++++++++++++++++++++++++++ tests/test_inverse.py | 11 ++++ 4 files changed, 101 insertions(+) create mode 100644 monai/data/inverse_batch_transform.py diff --git a/docs/source/data.rst b/docs/source/data.rst index 6ed6be9702..8071bb1585 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -183,3 +183,8 @@ DataLoader ThreadBuffer ~~~~~~~~~~~~ .. autoclass:: monai.data.ThreadBuffer + + +BatchInverseTransform +~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.BatchInverseTransform diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 2a7647e527..2001ccfc8f 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -26,6 +26,7 @@ from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter from .image_dataset import ImageDataset 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 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/tests/test_inverse.py b/tests/test_inverse.py index 8ce4e3bbf3..f548b53f11 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -21,6 +21,7 @@ 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 ( @@ -407,6 +408,10 @@ TESTS = TESTS + TESTS_COMPOSE_X2 # type: ignore +def no_collation(x): + return x + + class TestInverse(unittest.TestCase): """Test inverse methods. @@ -573,6 +578,12 @@ def test_inverse_inferred_seg(self): 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() From b6f213e57cea3e7b335b3a1f4f214cb43fc6d087 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Mon, 22 Mar 2021 14:58:37 +0800 Subject: [PATCH 81/85] 1804 add pretrain for densenet senet by using subclass (#1812) * add pretrain options Signed-off-by: Yiheng Wang * rewrite error message add test cases Signed-off-by: Yiheng Wang --- docs/source/networks.rst | 10 - monai/networks/nets/__init__.py | 4 +- monai/networks/nets/densenet.py | 198 +++++---- monai/networks/nets/senet.py | 452 ++++++++++---------- monai/visualize/class_activation_maps.py | 8 +- monai/visualize/occlusion_sensitivity.py | 4 +- tests/test_densenet.py | 34 +- tests/test_integration_classification_2d.py | 6 +- tests/test_occlusion_sensitivity.py | 4 +- tests/test_senet.py | 45 +- tests/test_vis_cam.py | 8 +- tests/test_vis_gradcam.py | 8 +- tests/test_vis_gradcampp.py | 8 +- 13 files changed, 370 insertions(+), 419 deletions(-) diff --git a/docs/source/networks.rst b/docs/source/networks.rst index 036ba2aff7..f5d498a363 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -286,10 +286,6 @@ Nets ~~~~~~~~~~ .. autoclass:: DenseNet :members: -.. autofunction:: densenet121 -.. autofunction:: densenet169 -.. autofunction:: densenet201 -.. autofunction:: densenet264 `SegResNet` ~~~~~~~~~~~ @@ -305,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` ~~~~~~~~~~~~ diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index f3def30736..cd00ea1aa1 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -13,7 +13,7 @@ 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 @@ -21,7 +21,7 @@ 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 4b4f2cc6a4..280bc6b0cb 100644 --- a/monai/networks/nets/densenet.py +++ b/monai/networks/nets/densenet.py @@ -115,11 +115,6 @@ class DenseNet(nn.Module): bn_size: multiplicative factor for number of bottle neck layers. (i.e. bn_size * k features in the bottleneck layer) dropout_prob: dropout rate after each dense layer. - pretrained: whether to load ImageNet pretrained weights when `spatial_dims == 2`. - In order to load weights correctly, Please ensure that the `block_config` - is consistent with the corresponding arch. - pretrained_arch: the arch name for pretrained weights. - progress: If True, displays a progress bar of the download to stderr. """ def __init__( @@ -132,9 +127,6 @@ def __init__( block_config: Sequence[int] = (6, 12, 24, 16), bn_size: int = 4, dropout_prob: float = 0.0, - pretrained: bool = False, - pretrained_arch: str = "densenet121", - progress: bool = True, ) -> None: super(DenseNet, self).__init__() @@ -198,107 +190,127 @@ def __init__( elif isinstance(m, nn.Linear): nn.init.constant_(torch.as_tensor(m.bias), 0) - if pretrained: - self._load_state_dict(pretrained_arch, progress) - def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.features(x) x = self.class_layers(x) return x - def _load_state_dict(self, 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.keys(): - 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))$" + +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))$" + ) - 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) + ".layers" + res.group(2) + res.group(3) - state_dict[new_key] = state_dict[key] - del state_dict[key] + 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) + ".layers" + res.group(2) + res.group(3) + state_dict[new_key] = state_dict[key] + del state_dict[key] - model_dict = self.state_dict() - state_dict = { - k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) - } - model_dict.update(state_dict) - self.load_state_dict(model_dict) + model_dict = model.state_dict() + state_dict = { + k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) + } + model_dict.update(state_dict) + 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), - pretrained=pretrained, - pretrained_arch="densenet121", - progress=progress, +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, - ) - return model + ) -> 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), - pretrained=pretrained, - pretrained_arch="densenet169", - progress=progress, +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, - ) - return model + ) -> 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), - pretrained=pretrained, - pretrained_arch="densenet201", - progress=progress, +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, - ) - return model + ) -> 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) -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 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/senet.py b/monai/networks/nets/senet.py index 333a3b1159..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,11 +66,6 @@ class SENet(nn.Module): - For SE-ResNeXt models: False num_classes: number of outputs in `last_linear` layer. for all models: 1000 - pretrained: whether to load ImageNet pretrained weights when `spatial_dims == 2`. - In order to load weights correctly, Please ensure that the `block_config` - is consistent with the corresponding arch. - pretrained_arch: the arch name for pretrained weights. - progress: If True, displays a progress bar of the download to stderr. """ def __init__( @@ -78,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, @@ -87,9 +82,6 @@ def __init__( downsample_kernel_size: int = 3, input_3x3: bool = True, num_classes: int = 1000, - pretrained: bool = False, - pretrained_arch: str = "se_resnet50", - progress: bool = True, ) -> None: super(SENet, self).__init__() @@ -183,64 +175,6 @@ def __init__( elif isinstance(m, nn.Linear): nn.init.constant_(torch.as_tensor(m.bias), 0) - if pretrained: - self._load_state_dict(pretrained_arch, progress) - - def _load_state_dict(self, 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.keys(): - 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*)$") - pattern_se2 = re.compile(r"^(layer[1-4]\.\d\.)(?:se_module.fc2.)(\w*)$") - pattern_down_conv = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.0.)(\w*)$") - pattern_down_bn = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.1.)(\w*)$") - - state_dict = load_state_dict_from_url(model_url, progress=progress) - for key in list(state_dict.keys()): - new_key = None - 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\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) - elif pattern_se2.match(key): - state_dict[key] = state_dict[key].squeeze() - new_key = re.sub(pattern_se2, r"\1se_layer.fc.2.\2", key) - 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.adn.N.\2", key) - if new_key: - state_dict[new_key] = state_dict[key] - del state_dict[key] - - model_dict = self.state_dict() - state_dict = { - k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) - } - model_dict.update(state_dict) - self.load_state_dict(model_dict) - def _make_layer( self, block: Type[Union[SEBottleneck, SEResNetBottleneck, SEResNeXtBottleneck]], @@ -313,167 +247,225 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -def senet154( - spatial_dims: int, - in_channels: int, - num_classes: int, - pretrained: bool = False, - progress: bool = True, -) -> SENet: +def _load_state_dict(model, arch, progress): """ - 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, - pretrained=pretrained, - pretrained_arch="senet154", - progress=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, - pretrained=pretrained, - pretrained_arch="se_resnet50", - progress=progress, - ) - return model - - -def se_resnet101( - spatial_dims: int, in_channels: int, num_classes: int, pretrained: bool = False, progress: bool = True -) -> SENet: + This function is used to load pretrained models. """ - 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, - pretrained=pretrained, - pretrained_arch="se_resnet101", - progress=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, - pretrained=pretrained, - pretrained_arch="se_resnet152", - progress=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, - pretrained=pretrained, - pretrained_arch="se_resnext50_32x4d", - progress=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, - pretrained=pretrained, - pretrained_arch="se_resnext101_32x4d", - progress=progress, - ) - return model + 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*)$") + pattern_se2 = re.compile(r"^(layer[1-4]\.\d\.)(?:se_module.fc2.)(\w*)$") + pattern_down_conv = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.0.)(\w*)$") + pattern_down_bn = re.compile(r"^(layer[1-4]\.\d\.)(?:downsample.1.)(\w*)$") + + state_dict = load_state_dict_from_url(model_url, progress=progress) + for key in list(state_dict.keys()): + new_key = None + 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\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) + elif pattern_se2.match(key): + state_dict[key] = state_dict[key].squeeze() + new_key = re.sub(pattern_se2, r"\1se_layer.fc.2.\2", key) + 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.adn.N.\2", key) + if new_key: + state_dict[new_key] = state_dict[key] + del state_dict[key] + + model_dict = model.state_dict() + state_dict = { + k: v for k, v in state_dict.items() if (k in model_dict) and (model_dict[k].shape == state_dict[k].shape) + } + model_dict.update(state_dict) + model.load_state_dict(model_dict) + + +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/visualize/class_activation_maps.py b/monai/visualize/class_activation_maps.py index 6e93225af3..b310ec0834 100644 --- a/monai/visualize/class_activation_maps.py +++ b/monai/visualize/class_activation_maps.py @@ -209,10 +209,10 @@ class CAM(CAMBase): .. 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))) @@ -307,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))) 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/tests/test_densenet.py b/tests/test_densenet.py index 5ead5f5818..c934841598 100644 --- a/tests/test_densenet.py +++ b/tests/test_densenet.py @@ -17,9 +17,9 @@ from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.nets import DenseNet, densenet121, densenet169, densenet201, densenet264 +from monai.networks.nets import DenseNet121, DenseNet169, DenseNet201, DenseNet264 from monai.utils import optional_import -from tests.utils import skip_if_quick, test_pretrained_networks, test_script_save +from tests.utils import skip_if_quick, test_script_save if TYPE_CHECKING: import torchvision @@ -51,50 +51,39 @@ 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}, (1, 2, 32, 64), (1, 3), ] TEST_PRETRAINED_2D_CASE_2 = [ # 4-channel 2D, batch 2 - densenet121, + 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, + DenseNet121, {"pretrained": True, "progress": False, "spatial_dims": 2, "in_channels": 3, "out_channels": 1}, (1, 3, 32, 32), ] -TEST_PRETRAINED_2D_CASE_4 = [ - { - "pretrained": True, - "pretrained_arch": "densenet264", - "progress": False, - "spatial_dims": 2, - "in_channels": 3, - "out_channels": 1, - }, -] - class TestPretrainedDENSENET(unittest.TestCase): @parameterized.expand([TEST_PRETRAINED_2D_CASE_1, TEST_PRETRAINED_2D_CASE_2]) @skip_if_quick def test_121_2d_shape_pretrain(self, model, input_param, input_shape, expected_shape): - net = test_pretrained_networks(model, input_param, device) + 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) @@ -103,7 +92,7 @@ def test_121_2d_shape_pretrain(self, model, input_param, input_shape, expected_s @skipUnless(has_torchvision, "Requires `torchvision` package.") def test_pretrain_consistency(self, model, input_param, input_shape): example = torch.randn(input_shape).to(device) - net = test_pretrained_networks(model, input_param, 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) @@ -111,11 +100,6 @@ def test_pretrain_consistency(self, model, input_param, input_shape): expected_result = torchvision_net.features.forward(example) self.assertTrue(torch.all(result == expected_result)) - @parameterized.expand([TEST_PRETRAINED_2D_CASE_4]) - def test_ill_pretrain(self, input_param): - with self.assertRaisesRegex(ValueError, ""): - net = DenseNet(**input_param) - class TestDENSENET(unittest.TestCase): @parameterized.expand(TEST_CASES) 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_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_senet.py b/tests/test_senet.py index a2d96e1f18..1c6222d6a0 100644 --- a/tests/test_senet.py +++ b/tests/test_senet.py @@ -17,16 +17,7 @@ from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.blocks.squeeze_and_excitation import SEBottleneck -from monai.networks.nets import ( - SENet, - 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 @@ -41,27 +32,14 @@ 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_PRETRAINED_1 = [se_resnet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}] -TEST_CASE_PRETRAINED_2 = [ - { - "spatial_dims": 2, - "in_channels": 3, - "block": SEBottleneck, - "layers": [3, 8, 36, 3], - "groups": 64, - "reduction": 16, - "num_classes": 2, - "pretrained": True, - "pretrained_arch": "resnet50", - } -] +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_1 = [SEResNet50, {"spatial_dims": 2, "in_channels": 3, "num_classes": 2, "pretrained": True}] class TestSENET(unittest.TestCase): @@ -107,11 +85,6 @@ def test_pretrain_consistency(self, model, input_param): # 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)) - @parameterized.expand([TEST_CASE_PRETRAINED_2]) - def test_ill_pretrain(self, input_param): - with self.assertRaisesRegex(ValueError, ""): - net = SENet(**input_param) - 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 df47c4920e..f8e49f486f 100644 --- a/tests/test_vis_gradcam.py +++ b/tests/test_vis_gradcam.py @@ -15,7 +15,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 GradCAM # 2D @@ -65,15 +65,15 @@ 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() 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() From 5151b15aaadc90bf578729d85e8011faae5d1836 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 22 Mar 2021 11:17:28 +0000 Subject: [PATCH 82/85] remove .value from InverseKeys enum (#1817) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/croppad/batch.py | 4 +-- monai/transforms/croppad/dictionary.py | 22 ++++++------ monai/transforms/inverse.py | 20 +++++------ monai/transforms/spatial/dictionary.py | 46 +++++++++++++------------- monai/utils/enums.py | 2 +- tests/test_decollate.py | 2 +- tests/test_inverse.py | 2 +- 7 files changed, 49 insertions(+), 49 deletions(-) diff --git a/monai/transforms/croppad/batch.py b/monai/transforms/croppad/batch.py index 7cbf39597c..37ff8618fa 100644 --- a/monai/transforms/croppad/batch.py +++ b/monai/transforms/croppad/batch.py @@ -119,10 +119,10 @@ def inverse(data: dict) -> Dict[Hashable, np.ndarray]: d = deepcopy(data) for key in d.keys(): - transform_key = str(key) + InverseKeys.KEY_SUFFIX.value + transform_key = str(key) + InverseKeys.KEY_SUFFIX if transform_key in d.keys(): transform = d[transform_key][-1] - if transform[InverseKeys.CLASS_NAME.value] == PadListDataCollate.__name__: + if transform[InverseKeys.CLASS_NAME] == PadListDataCollate.__name__: d[key] = CenterSpatialCrop(transform["orig_size"])(d[key]) # remove transform d[transform_key].pop() diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 822db28467..c3523f3993 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -133,7 +133,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Create inverse transform - orig_size = transform[InverseKeys.ORIG_SIZE.value] + 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)] @@ -202,7 +202,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]) + 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: @@ -210,7 +210,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar # 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.value]) + roi_start + roi_end = np.array(transform[InverseKeys.ORIG_SIZE]) + roi_start inverse_transform = SpatialCrop(roi_start=roi_start, roi_end=roi_end) # Apply inverse transform @@ -268,7 +268,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]) + 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 @@ -323,7 +323,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Create inverse transform - orig_size = transform[InverseKeys.ORIG_SIZE.value] + 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 @@ -369,7 +369,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]) + 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 @@ -449,12 +449,12 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Create inverse transform - orig_size = transform[InverseKeys.ORIG_SIZE.value] + 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.value]["slices"]): + 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: @@ -594,8 +594,8 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]) - extra_info = transform[InverseKeys.EXTRA_INFO.value] + 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 @@ -827,7 +827,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]) + 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. diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index 9708f103e6..3e5b68e8e4 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -72,17 +72,17 @@ def push_transform( orig_size: Optional[Tuple] = None, ) -> None: """Append to list of applied transforms for that key.""" - key_transform = str(key) + InverseKeys.KEY_SUFFIX.value + key_transform = str(key) + InverseKeys.KEY_SUFFIX info = { - InverseKeys.CLASS_NAME.value: self.__class__.__name__, - InverseKeys.ID.value: id(self), - InverseKeys.ORIG_SIZE.value: orig_size or data[key].shape[1:], + 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.value] = extra_info + 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.value] = self._do_transform + info[InverseKeys.DO_TRANSFORM] = self._do_transform # If this is the first, create list if key_transform not in data: data[key_transform] = [] @@ -90,25 +90,25 @@ def push_transform( def check_transforms_match(self, transform: dict) -> None: """Check transforms are of same instance.""" - if transform[InverseKeys.ID.value] == id(self): + 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.value] == self.__class__.__name__ + 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.value][-1]) + 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.value].pop() + data[str(key) + InverseKeys.KEY_SUFFIX].pop() def inverse(self, data: dict) -> Dict[Hashable, np.ndarray]: """ diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 32327ec302..0d5b3436fd 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -225,8 +225,8 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar + "Please raise a github issue if you need this feature" ) # Create inverse transform - meta_data = d[transform[InverseKeys.EXTRA_INFO.value]["meta_data_key"]] - old_affine = np.array(transform[InverseKeys.EXTRA_INFO.value]["old_affine"]) + 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 @@ -312,8 +312,8 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]["meta_data_key"]] - orig_affine = transform[InverseKeys.EXTRA_INFO.value]["old_affine"] + 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, @@ -429,9 +429,9 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]: + if transform[InverseKeys.DO_TRANSFORM]: # Create inverse transform - num_times_rotated = transform[InverseKeys.EXTRA_INFO.value]["rand_k"] + 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 @@ -491,7 +491,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value] + orig_size = transform[InverseKeys.ORIG_SIZE] # Create inverse transform inverse_transform = Resize(orig_size, mode, align_corners) # Apply inverse transform @@ -582,9 +582,9 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value] + orig_size = transform[InverseKeys.ORIG_SIZE] # Create inverse transform - fwd_affine = transform[InverseKeys.EXTRA_INFO.value]["affine"] + fwd_affine = transform[InverseKeys.EXTRA_INFO]["affine"] inv_affine = np.linalg.inv(fwd_affine) affine_grid = AffineGrid(affine=inv_affine) @@ -710,9 +710,9 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value] + orig_size = transform[InverseKeys.ORIG_SIZE] # Create inverse transform - fwd_affine = transform[InverseKeys.EXTRA_INFO.value]["affine"] + fwd_affine = transform[InverseKeys.EXTRA_INFO]["affine"] inv_affine = np.linalg.inv(fwd_affine) affine_grid = AffineGrid(affine=inv_affine) @@ -1048,7 +1048,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]: + 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() @@ -1098,8 +1098,8 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value]: - flipper = Flip(spatial_axis=transform[InverseKeys.EXTRA_INFO.value]["axis"]) + 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() @@ -1181,7 +1181,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar ): transform = self.get_most_recent_transform(d, key) # Create inverse transform - fwd_rot_mat = transform[InverseKeys.EXTRA_INFO.value]["rot_mat"] + fwd_rot_mat = transform[InverseKeys.EXTRA_INFO]["rot_mat"] inv_rot_mat = np.linalg.inv(fwd_rot_mat) xform = AffineTransform( @@ -1194,7 +1194,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value], + spatial_size=transform[InverseKeys.ORIG_SIZE], ) d[key] = np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) # Remove the applied transform @@ -1314,9 +1314,9 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar ): transform = self.get_most_recent_transform(d, key) # Check if random transform was actually performed (based on `prob`) - if transform[InverseKeys.DO_TRANSFORM.value]: + if transform[InverseKeys.DO_TRANSFORM]: # Create inverse transform - fwd_rot_mat = transform[InverseKeys.EXTRA_INFO.value]["rot_mat"] + fwd_rot_mat = transform[InverseKeys.EXTRA_INFO]["rot_mat"] inv_rot_mat = np.linalg.inv(fwd_rot_mat) xform = AffineTransform( @@ -1329,7 +1329,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar 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.value], + spatial_size=transform[InverseKeys.ORIG_SIZE], ) d[key] = np.asarray(output.squeeze(0).detach().cpu().numpy(), dtype=np.float32) # Remove the applied transform @@ -1410,7 +1410,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar align_corners=align_corners, ) # Size might be out by 1 voxel so pad - d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE.value])(d[key]) + d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE])(d[key]) # Remove the applied transform self.pop_transform(d, key) @@ -1513,9 +1513,9 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar ): transform = self.get_most_recent_transform(d, key) # Check if random transform was actually performed (based on `prob`) - if transform[InverseKeys.DO_TRANSFORM.value]: + if transform[InverseKeys.DO_TRANSFORM]: # Create inverse transform - zoom = np.array(transform[InverseKeys.EXTRA_INFO.value]["zoom"]) + 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( @@ -1525,7 +1525,7 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar align_corners=align_corners, ) # Size might be out by 1 voxel so pad - d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE.value])(d[key]) + d[key] = SpatialPad(transform[InverseKeys.ORIG_SIZE])(d[key]) # Remove the applied transform self.pop_transform(d, key) diff --git a/monai/utils/enums.py b/monai/utils/enums.py index d661781616..63d65329af 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -217,7 +217,7 @@ class Method(Enum): END = "end" -class InverseKeys(Enum): +class InverseKeys: """Extra meta data keys used for inverse transforms.""" CLASS_NAME = "class" diff --git a/tests/test_decollate.py b/tests/test_decollate.py index 4dc5a217a7..5b78bbbcf6 100644 --- a/tests/test_decollate.py +++ b/tests/test_decollate.py @@ -55,7 +55,7 @@ def check_match(self, in1, in2): 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.value and sys.platform in ["darwin", "win32"]: + if k1 == InverseKeys.ID and sys.platform in ["darwin", "win32"]: continue self.check_match(v1, v2) elif isinstance(in1, (list, tuple)): diff --git a/tests/test_inverse.py b/tests/test_inverse.py index f548b53f11..d54855d7c1 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -565,7 +565,7 @@ def test_inverse_inferred_seg(self): data = first(loader) labels = data["label"].to(device) segs = model(labels).detach().cpu() - label_transform_key = "label" + InverseKeys.KEY_SUFFIX.value + 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) From 06e1e990b03a763ac0a1da0e37917f991e3901cf Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 22 Mar 2021 16:30:38 +0000 Subject: [PATCH 83/85] cron tutorial notebooks (#1783) * cron tutorial notebooks Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> Co-authored-by: Wenqi Li --- .github/workflows/cron.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 90abb5d7e4..761b1f7ebc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -128,3 +128,36 @@ jobs: 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 From a8f83e17ffee12bd1b8f9f6d7711a26ba6ff69ff Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 22 Mar 2021 20:05:26 +0000 Subject: [PATCH 84/85] refactor CommonKeys (#1825) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/apps/deepgrow/interaction.py | 2 +- monai/engines/__init__.py | 2 +- monai/engines/evaluator.py | 2 +- monai/engines/trainer.py | 2 +- monai/engines/utils.py | 19 +------------------ monai/handlers/metric_logger.py | 2 +- monai/utils/__init__.py | 1 + monai/utils/enums.py | 18 ++++++++++++++++++ tests/test_threadcontainer.py | 3 ++- 9 files changed, 27 insertions(+), 24 deletions(-) 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/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 0afa3747a4..2c237f5245 100644 --- a/monai/engines/evaluator.py +++ b/monai/engines/evaluator.py @@ -14,13 +14,13 @@ 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 diff --git a/monai/engines/trainer.py b/monai/engines/trainer.py index 5b996eafe1..a7b1943211 100644 --- a/monai/engines/trainer.py +++ b/monai/engines/trainer.py @@ -15,12 +15,12 @@ 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 diff --git a/monai/engines/utils.py b/monai/engines/utils.py index b0b1e44f71..04237d0f4a 100644 --- a/monai/engines/utils.py +++ b/monai/engines/utils.py @@ -14,6 +14,7 @@ import torch from monai.utils import exact_version, optional_import +from monai.utils.enums import CommonKeys if TYPE_CHECKING: from ignite.engine import EventEnum @@ -22,7 +23,6 @@ __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/handlers/metric_logger.py b/monai/handlers/metric_logger.py index c749d4bbab..0cfefb715a 100644 --- a/monai/handlers/metric_logger.py +++ b/monai/handlers/metric_logger.py @@ -14,8 +14,8 @@ from threading import RLock from typing import TYPE_CHECKING, Callable, DefaultDict, List, Optional -from monai.engines.utils import CommonKeys from monai.utils import exact_version, optional_import +from monai.utils.enums import CommonKeys Events, _ = optional_import("ignite.engine", "0.4.4", exact_version, "Events") if TYPE_CHECKING: diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 4d272ac6ff..6a76c96d0c 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -17,6 +17,7 @@ Average, BlendMode, ChannelMatching, + CommonKeys, GridSampleMode, GridSamplePadMode, InterpolateMode, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 63d65329af..9920aefe0e 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -29,6 +29,7 @@ "SkipMode", "Method", "InverseKeys", + "CommonKeys", ] @@ -226,3 +227,20 @@ class InverseKeys: 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/tests/test_threadcontainer.py b/tests/test_threadcontainer.py index 92a50a15aa..13608e166c 100644 --- a/tests/test_threadcontainer.py +++ b/tests/test_threadcontainer.py @@ -15,11 +15,12 @@ import torch from monai.utils import optional_import +from monai.utils.enums import CommonKeys try: _, has_ignite = optional_import("ignite") - from monai.engines import CommonKeys, SupervisedTrainer + from monai.engines import SupervisedTrainer from monai.utils import ThreadContainer except ImportError: has_ignite = False From db2f47de9df9418f57ee3573fab32b69c1f42d31 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 22 Mar 2021 21:42:42 +0000 Subject: [PATCH 85/85] [1823] allow None for switch endianness (#1824) * allow None for switch endianness Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * add PIL testing Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/transforms/io/array.py | 4 ++-- monai/utils/jupyter_utils.py | 4 ++-- tests/test_nifti_endianness.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 164c7b0e76..9c2727ffc3 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -47,10 +47,10 @@ def switch_endianness(data, old, new): 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)): + elif isinstance(data, (bool, str, float, int, type(None))): pass else: - raise AssertionError() + raise AssertionError(f"Unknown type: {type(data).__name__}") return data diff --git a/monai/utils/jupyter_utils.py b/monai/utils/jupyter_utils.py index a7e712619e..726a11731c 100644 --- a/monai/utils/jupyter_utils.py +++ b/monai/utils/jupyter_utils.py @@ -10,8 +10,8 @@ # 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. +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 diff --git a/tests/test_nifti_endianness.py b/tests/test_nifti_endianness.py index d8adb1efb2..b725e2462c 100644 --- a/tests/test_nifti_endianness.py +++ b/tests/test_nifti_endianness.py @@ -1,3 +1,4 @@ +import os import tempfile import unittest from typing import TYPE_CHECKING, List, Tuple @@ -7,16 +8,20 @@ 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 ["<", ">"]: @@ -49,6 +54,16 @@ def test_switch(self): # verify data types 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()