Repository: Project-MONAI/MONAI

Issue:
add dictionary level LabelToContourd transform
**Is your feature request related to a problem? Please describe.**
As we already have array level LabelToContour transform, need to update the document and add dictionary level version.


Comments:

Patch:
diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst
index b02db767..1c8b8415 100644
--- a/docs/source/transforms.rst
+++ b/docs/source/transforms.rst
@@ -373,6 +373,12 @@ Vanilla Transforms
     :members:
     :special-members: __call__
 
+`LabelToContour`
+~~~~~~~~~~~~~~~~
+.. autoclass:: LabelToContour
+    :members:
+    :special-members: __call__
+
 
 Dictionary-based Transforms
 ---------------------------
@@ -701,6 +707,12 @@ Dictionary-based Transforms
     :members:
     :special-members: __call__
 
+`LabelToContourd`
+~~~~~~~~~~~~~~~~~
+.. autoclass:: LabelToContourd
+    :members:
+    :special-members: __call__
+
 `Lambdad`
 ~~~~~~~~~
 .. autoclass:: Lambdad
diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py
index c50833e4..582fb3de 100644
--- a/monai/transforms/post/array.py
+++ b/monai/transforms/post/array.py
@@ -16,6 +16,7 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design
 from typing import Optional, Callable
 
 import torch
+import torch.nn.functional as F
 from monai.transforms.compose import Transform
 from monai.networks.utils import one_hot
 from monai.transforms.utils import get_largest_connected_component_mask
@@ -296,56 +297,44 @@ class KeepLargestConnectedComponent(Transform):
 
 class LabelToContour(Transform):
     """
-    Return the contour flag of objects in mask images that only compose of 0 and 1, with Laplace kernel
-        set as default for edge detection.
+    Return the contour of binary input images that only compose of 0 and 1, with Laplace kernel
+    set as default for edge detection. Typical usage is to plot the edge of label or segmentation output.
 
     Args:
-        kernel_type: the method applied to do edge detection.
+        kernel_type: the method applied to do edge detection, default is "Laplace".
+
     """
 
-    def __init__(self, kernel_type="Laplace"):
+    def __init__(self, kernel_type: str = "Laplace"):
+        if kernel_type != "Laplace":
+            raise NotImplementedError("currently, LabelToContour only supports Laplace kernel.")
         self.kernel_type = kernel_type
 
-    def __find_img_contour(self, img):
-        channels = img.shape[1]
-        conv = torch.nn.Conv2d(channels, channels, kernel_size=3, stride=1, padding=1, bias=False, groups=channels)
-        kernel = torch.tensor([[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]], dtype=torch.float32, device=img.device)
-        kernel = kernel.repeat(channels, 1, 1, 1)
-        conv.weight = torch.nn.Parameter(kernel, requires_grad=False)
-
-        contour_img = conv(img)
-        torch.clamp_(contour_img, min=0.0, max=1.0)
-        return contour_img
-
     def __call__(self, img):
         """
         Args:
-            img: torch tensor of the img that you want to find the contour of, with shape being
-                 (batch_size, channels, width, height[, depth])
+            img: torch tensor data to extract the contour, with shape: [batch_size, channels, height, width[, depth]]
 
         Returns:
             A torch tensor with the same shape as img, note:
-                1. It's the binary classification result of whether a pixel is edge or not.
-                2. In order to keep the original shape of mask image, we use padding as default.
-                3. The edge detection is just approximate due to
-                    a) defects inherent to Laplace kernel, ideally the edge should be thin enough, but now it has a thickness.
-                    b) need to search the optimal/better thresold for classification
-        """
-        if self.kernel_type != "Laplace":
-            raise NotImplementedError
-        if img.ndim != 4 and img.ndim != 5:
-            raise RuntimeError("img.ndim should be 4 or 5")
-        if img.ndim == 4:
-            return self.__find_img_contour(img)
+                1. it's the binary classification result of whether a pixel is edge or not.
+                2. in order to keep the original shape of mask image, we use padding as default.
+                3. the edge detection is just approximate because it defects inherent to Laplace kernel,
+                   ideally the edge should be thin enough, but now it has a thickness.
 
+        """
         channels = img.shape[1]
+        if img.ndim == 4:
+            kernel = torch.tensor([[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]], dtype=torch.float32, device=img.device)
+            kernel = kernel.repeat(channels, 1, 1, 1)
+            contour_img = F.conv2d(img, kernel, bias=None, stride=1, padding=1, dilation=1, groups=channels)
+        elif img.ndim == 5:
+            kernel = -1 * torch.ones(3, 3, 3, dtype=torch.float32, device=img.device)
+            kernel[1, 1, 1] = 26
+            kernel = kernel.repeat(channels, 1, 1, 1, 1)
+            contour_img = F.conv3d(img, kernel, bias=None, stride=1, padding=1, dilation=1, groups=channels)
+        else:
+            raise RuntimeError("the dimensions of img should be 4 or 5.")
 
-        conv = torch.nn.Conv3d(channels, channels, kernel_size=3, stride=1, padding=1, bias=False, groups=channels)
-        kernel = -1 * torch.ones(3, 3, 3, dtype=torch.float32, device=img.device)
-        kernel[1, 1, 1] = 26
-        kernel = kernel.repeat(channels, 1, 1, 1, 1)
-        conv.weight = torch.nn.Parameter(kernel, requires_grad=False)
-
-        contour_img = conv(img)
         torch.clamp_(contour_img, min=0.0, max=1.0)
         return contour_img
diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py
index dbc4a26a..53c3d3fa 100644
--- a/monai/transforms/post/dictionary.py
+++ b/monai/transforms/post/dictionary.py
@@ -20,7 +20,13 @@ from typing import Optional
 from monai.config.type_definitions import KeysCollection
 from monai.utils.misc import ensure_tuple_rep
 from monai.transforms.compose import MapTransform
-from monai.transforms.post.array import SplitChannel, Activations, AsDiscrete, KeepLargestConnectedComponent
+from monai.transforms.post.array import (
+    SplitChannel,
+    Activations,
+    AsDiscrete,
+    KeepLargestConnectedComponent,
+    LabelToContour,
+)
 
 
 class SplitChanneld(MapTransform):
@@ -145,7 +151,7 @@ class AsDiscreted(MapTransform):
 
 class KeepLargestConnectedComponentd(MapTransform):
     """
-    dictionary-based wrapper of :py:class:monai.transforms.utility.array.KeepLargestConnectedComponent.
+    dictionary-based wrapper of :py:class:monai.transforms.KeepLargestConnectedComponent.
     """
 
     def __init__(
@@ -176,7 +182,30 @@ class KeepLargestConnectedComponentd(MapTransform):
 
     def __call__(self, data):
         d = dict(data)
-        for idx, key in enumerate(self.keys):
+        for key in self.keys:
+            d[key] = self.converter(d[key])
+        return d
+
+
+class LabelToContourd(MapTransform):
+    """
+    dictionary-based wrapper of :py:class:monai.transforms.LabelToContour.
+    """
+
+    def __init__(self, keys: KeysCollection, kernel_type: str = "Laplace"):
+        """
+        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".
+
+        """
+        super().__init__(keys)
+        self.converter = LabelToContour(kernel_type=kernel_type)
+
+    def __call__(self, data):
+        d = dict(data)
+        for key in self.keys:
             d[key] = self.converter(d[key])
         return d
 
@@ -185,3 +214,4 @@ SplitChannelD = SplitChannelDict = SplitChanneld
 ActivationsD = ActivationsDict = Activationsd
 AsDiscreteD = AsDiscreteDict = AsDiscreted
 KeepLargestConnectedComponentD = KeepLargestConnectedComponentDict = KeepLargestConnectedComponentd
+LabelToContourD = LabelToContourDict = LabelToContourd


Test patch:
diff --git a/tests/test_label_to_contour.py b/tests/test_label_to_contour.py
index e90dcc73..cd9bf88d 100644
--- a/tests/test_label_to_contour.py
+++ b/tests/test_label_to_contour.py
@@ -171,10 +171,6 @@ class TestContour(unittest.TestCase):
         error_input = torch.rand(1, 2, 3, 4, 5, 6)
         self.assertRaises(RuntimeError, LabelToContour(**input_param), error_input)
 
-        # check invalid kernel type
-        input_param["kernel_type"] = "Sobel"
-        self.assertRaises(NotImplementedError, LabelToContour(**input_param), test_cube)
-
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_label_to_contourd.py b/tests/test_label_to_contourd.py
new file mode 100644
index 00000000..4e184c00
--- /dev/null
+++ b/tests/test_label_to_contourd.py
@@ -0,0 +1,176 @@
+# Copyright 2020 MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+import torch
+import numpy as np
+from monai.transforms import LabelToContourd
+
+expected_output_for_cube = 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, 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, 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, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+        ],
+        [
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0],
+            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.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.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.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],
+        ],
+    ]
+)
+
+
+def gen_fixed_cube():
+    scale, core_start, core_end = 8, 1, 7
+    cube = torch.zeros(scale, scale, scale)
+    cube[core_start:core_end, core_start:core_end, core_start:core_end] = torch.ones(
+        core_end - core_start, core_end - core_start, core_end - core_start
+    )
+    cube = torch.unsqueeze(cube, 0)
+
+    batch_size, channels = 10, 6
+    cube = cube.repeat(batch_size, channels, 1, 1, 1)
+    return cube, expected_output_for_cube
+
+
+def gen_fixed_img():
+    img = torch.tensor(
+        [
+            [0, 0, 0, 1, 1, 1, 1],
+            [0, 0, 0, 1, 1, 1, 1],
+            [0, 0, 1, 1, 1, 1, 1],
+            [0, 1, 1, 1, 1, 1, 1],
+            [1, 1, 1, 1, 1, 1, 1],
+        ],
+        dtype=torch.float32,
+    )
+    batch_size, channels = 10, 6
+    img = img.repeat(batch_size, channels, 1, 1)
+    expected_output_for_img = torch.tensor(
+        [
+            [0, 0, 0, 1, 1, 1, 1],
+            [0, 0, 0, 1, 0, 0, 1],
+            [0, 0, 1, 1, 0, 0, 1],
+            [0, 1, 1, 0, 0, 0, 1],
+            [1, 1, 1, 1, 1, 1, 1],
+        ],
+        dtype=torch.float32,
+    )
+    return img, expected_output_for_img
+
+
+class TestContourd(unittest.TestCase):
+    def test_contour(self):
+        input_param = {"keys": "img", "kernel_type": "Laplace"}
+
+        # check 5-dim input data
+        test_cube, expected_output = gen_fixed_cube()
+        test_result_cube = LabelToContourd(**input_param)({"img": test_cube})
+        self.assertEqual(test_result_cube["img"].shape, test_cube.shape)
+
+        test_result_np = test_result_cube["img"].data.cpu().numpy()
+        batch_size, channels = test_cube.shape[0], test_cube.shape[1]
+        for batch in range(batch_size):
+            for channel in range(channels):
+                np.testing.assert_allclose(test_result_np[batch, channel, ...], expected_output)
+
+        # check 4-dim input data
+        test_img, expected_output = gen_fixed_img()
+        batch_size, channels = test_img.shape[0], test_img.shape[1]
+        test_result_img = LabelToContourd(**input_param)({"img": test_img})
+        self.assertEqual(test_result_img["img"].shape, test_img.shape)
+
+        test_result_np = test_result_img["img"].data.cpu().numpy()
+        for batch in range(batch_size):
+            for channel in range(channels):
+                np.testing.assert_allclose(test_result_img["img"][batch, channel, ...], expected_output)
+
+        # check invalid input data
+        error_input = {"img": torch.rand(1, 2, 3)}
+        self.assertRaises(RuntimeError, LabelToContourd(**input_param), error_input)
+        error_input = {"img": torch.rand(1, 2, 3, 4, 5, 6)}
+        self.assertRaises(RuntimeError, LabelToContourd(**input_param), error_input)
+
+
+if __name__ == "__main__":
+    unittest.main()