Repository: Project-MONAI/MONAI

Issue:
CoordConv transform
**Is your feature request related to a problem? Please describe.**
CoordConv gives networks an idea of where they are in an input by adding additional channels to the input that simply contain the spatial coordinate system. It has been shown to improve performance in various computer vision tasks, [paper here](https://arxiv.org/abs/1807.03247).

**Describe the solution you'd like**
This could be simply implemented as a transform. If you think this would be useful I can make a PR to add.

**Describe alternatives you've considered**



Discussion:

Resolving patch:
diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py
--- a/monai/transforms/__init__.py
+++ b/monai/transforms/__init__.py
@@ -211,6 +211,7 @@
     VoteEnsembleDict,
 )
 from .spatial.array import (
+    AddCoordinateChannels,
     Affine,
     AffineGrid,
     Flip,
@@ -233,6 +234,9 @@
     Zoom,
 )
 from .spatial.dictionary import (
+    AddCoordinateChannelsd,
+    AddCoordinateChannelsD,
+    AddCoordinateChannelsDict,
     Affined,
     AffineD,
     AffineDict,
diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py
--- a/monai/transforms/spatial/array.py
+++ b/monai/transforms/spatial/array.py
@@ -69,6 +69,7 @@
     "RandAffine",
     "Rand2DElastic",
     "Rand3DElastic",
+    "AddCoordinateChannels",
 ]
 
 RandRange = Optional[Union[Sequence[Union[Tuple[float, float], float]], float]]
@@ -1700,3 +1701,46 @@ def __call__(
             grid[:3] += gaussian(offset)[0] * self.magnitude
             grid = self.rand_affine_grid(grid=grid)
         return self.resampler(img, grid, mode=mode or self.mode, padding_mode=padding_mode or self.padding_mode)
+
+
+class AddCoordinateChannels(Transform):
+    """
+    Appends additional channels encoding coordinates of the input. Useful when e.g. training using patch-based sampling,
+    to allow feeding of the patch's location into the network.
+
+    This can be seen as a input-only version of CoordConv:
+
+    Liu, R. et al. An Intriguing Failing of Convolutional Neural Networks and the CoordConv Solution, NeurIPS 2018.
+    """
+
+    def __init__(
+        self,
+        spatial_channels: Sequence[int],
+    ) -> None:
+        """
+        Args:
+            spatial_channels: the spatial dimensions that are to have their coordinates encoded in a channel and
+                appended to the input. E.g., `(1,2,3)` will append three channels to the input, encoding the
+                coordinates of the input's three spatial dimensions (0 is reserved for the channel dimension).
+        """
+        self.spatial_channels = spatial_channels
+
+    def __call__(self, img: Union[np.ndarray, torch.Tensor]):
+        """
+        Args:
+            img: data to be transformed, assuming `img` is channel first.
+        """
+        if max(self.spatial_channels) > img.ndim - 1:
+            raise ValueError(
+                f"input has {img.ndim-1} spatial dimensions, cannot add AddCoordinateChannels channel for "
+                f"dim {max(self.spatial_channels)}."
+            )
+        if 0 in self.spatial_channels:
+            raise ValueError("cannot add AddCoordinateChannels channel for dimension 0, as 0 is channel dim.")
+
+        spatial_dims = img.shape[1:]
+        coord_channels = np.array(np.meshgrid(*tuple(np.linspace(-0.5, 0.5, s) for s in spatial_dims), indexing="ij"))
+        # only keep required dimensions. need to subtract 1 since im will be 0-based
+        # but user input is 1-based (because channel dim is 0)
+        coord_channels = coord_channels[[s - 1 for s in self.spatial_channels]]
+        return np.concatenate((img, coord_channels), axis=0)
diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py
--- a/monai/transforms/spatial/dictionary.py
+++ b/monai/transforms/spatial/dictionary.py
@@ -28,6 +28,7 @@
 from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad
 from monai.transforms.inverse import InvertibleTransform
 from monai.transforms.spatial.array import (
+    AddCoordinateChannels,
     Affine,
     AffineGrid,
     Flip,
@@ -106,6 +107,8 @@
     "ZoomDict",
     "RandZoomD",
     "RandZoomDict",
+    "AddCoordinateChannelsD",
+    "AddCoordinateChannelsDict",
 ]
 
 GridSampleModeSequence = Union[Sequence[Union[GridSampleMode, str]], GridSampleMode, str]
@@ -1639,6 +1642,34 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar
         return d
 
 
+class AddCoordinateChannelsd(MapTransform):
+    """
+    Dictionary-based wrapper of :py:class:`monai.transforms.AddCoordinateChannels`.
+    """
+
+    def __init__(self, keys: KeysCollection, spatial_channels: Sequence[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`
+            allow_missing_keys: don't raise exception if key is missing.
+            spatial_channels: the spatial dimensions that are to have their coordinates encoded in a channel and
+                appended to the input. E.g., `(1,2,3)` will append three channels to the input, encoding the
+                coordinates of the input's three spatial dimensions. It is assumed dimension 0 is the channel.
+
+        """
+        super().__init__(keys, allow_missing_keys)
+        self.add_coordinate_channels = AddCoordinateChannels(spatial_channels)
+
+    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.key_iterator(d):
+            d[key] = self.add_coordinate_channels(d[key])
+        return d
+
+
 SpacingD = SpacingDict = Spacingd
 OrientationD = OrientationDict = Orientationd
 Rotate90D = Rotate90Dict = Rotate90d
@@ -1655,3 +1686,4 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar
 RandRotateD = RandRotateDict = RandRotated
 ZoomD = ZoomDict = Zoomd
 RandZoomD = RandZoomDict = RandZoomd
+AddCoordinateChannelsD = AddCoordinateChannelsDict = AddCoordinateChannelsd


Test patch:
diff --git a/tests/test_add_coordinate_channels.py b/tests/test_add_coordinate_channels.py
new file mode 100644
--- /dev/null
+++ b/tests/test_add_coordinate_channels.py
@@ -0,0 +1,47 @@
+# 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 AddCoordinateChannels
+
+TEST_CASE_1 = [{"spatial_channels": (1, 2, 3)}, np.random.randint(0, 2, size=(1, 3, 3, 3)), (4, 3, 3, 3)]
+
+TEST_CASE_2 = [{"spatial_channels": (1,)}, np.random.randint(0, 2, size=(1, 3, 3, 3)), (2, 3, 3, 3)]
+
+TEST_CASE_ERROR_3 = [{"spatial_channels": (3,)}, np.random.randint(0, 2, size=(1, 3, 3))]
+
+TEST_CASE_ERROR_4 = [{"spatial_channels": (0, 1, 2)}, np.random.randint(0, 2, size=(1, 3, 3))]
+
+
+class TestAddCoordinateChannels(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
+    def test_shape(self, input_param, input, expected_shape):
+        result = AddCoordinateChannels(**input_param)(input)
+        self.assertEqual(list(result.shape), list(expected_shape))
+        np.testing.assert_array_equal(input[0, ...], result[0, ...])
+
+    @parameterized.expand([TEST_CASE_ERROR_3])
+    def test_max_channel(self, input_param, input):
+        with self.assertRaises(ValueError):
+            AddCoordinateChannels(**input_param)(input)
+
+    @parameterized.expand([TEST_CASE_ERROR_4])
+    def test_channel_dim(self, input_param, input):
+        with self.assertRaises(ValueError):
+            AddCoordinateChannels(**input_param)(input)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_add_coordinate_channelsd.py b/tests/test_add_coordinate_channelsd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_add_coordinate_channelsd.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 unittest
+
+import numpy as np
+from parameterized import parameterized
+
+from monai.transforms import AddCoordinateChannelsd
+
+TEST_CASE_1 = [
+    {"spatial_channels": (1, 2, 3), "keys": ["img"]},
+    {"img": np.random.randint(0, 2, size=(1, 3, 3, 3))},
+    (4, 3, 3, 3),
+]
+
+TEST_CASE_2 = [
+    {"spatial_channels": (1,), "keys": ["img"]},
+    {"img": np.random.randint(0, 2, size=(1, 3, 3, 3))},
+    (2, 3, 3, 3),
+]
+
+TEST_CASE_ERROR_3 = [{"spatial_channels": (3,), "keys": ["img"]}, {"img": np.random.randint(0, 2, size=(1, 3, 3))}]
+
+TEST_CASE_ERROR_4 = [{"spatial_channels": (0, 1, 2), "keys": ["img"]}, {"img": np.random.randint(0, 2, size=(1, 3, 3))}]
+
+
+class TestAddCoordinateChannels(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
+    def test_shape(self, input_param, input, expected_shape):
+        result = AddCoordinateChannelsd(**input_param)(input)
+        self.assertEqual(list(result["img"].shape), list(expected_shape))
+        np.testing.assert_array_equal(input["img"][0, ...], result["img"][0, ...])
+
+    @parameterized.expand([TEST_CASE_ERROR_3])
+    def test_max_channel(self, input_param, input):
+        with self.assertRaises(ValueError):
+            AddCoordinateChannelsd(**input_param)(input)
+
+    @parameterized.expand([TEST_CASE_ERROR_4])
+    def test_channel_dim(self, input_param, input):
+        with self.assertRaises(ValueError):
+            AddCoordinateChannelsd(**input_param)(input)
+
+
+if __name__ == "__main__":
+    unittest.main()