Repository: Project-MONAI/MONAI

Issue:
Compute horizontal vertical maps
HoVerNet pipeline requires to compute horizontal and vertical maps from instance segmentations, which are based on the horizontal and vertical distances of each pixel in a nucleus to the center of mass of each nucleus.


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
@@ -89,6 +89,7 @@
 )
 from .intensity.array import (
     AdjustContrast,
+    ComputeHoVerMaps,
     DetectEnvelope,
     ForegroundMask,
     GaussianSharpen,
@@ -127,6 +128,9 @@
     AdjustContrastd,
     AdjustContrastD,
     AdjustContrastDict,
+    ComputeHoVerMapsd,
+    ComputeHoVerMapsD,
+    ComputeHoVerMapsDict,
     ForegroundMaskd,
     ForegroundMaskD,
     ForegroundMaskDict,
diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py
--- a/monai/transforms/intensity/array.py
+++ b/monai/transforms/intensity/array.py
@@ -73,6 +73,7 @@
     "IntensityRemap",
     "RandIntensityRemap",
     "ForegroundMask",
+    "ComputeHoVerMaps",
 ]
 
 
@@ -2301,3 +2302,42 @@ def __call__(self, image: NdarrayOrTensor):
 
         mask = np.stack(foregrounds).all(axis=0)
         return convert_to_dst_type(src=mask, dst=image)[0]
+
+
+class ComputeHoVerMaps(Transform):
+    """Compute horizontal and vertical maps from an instance mask
+    It generates normalized horizontal and vertical distances to the center of mass of each region.
+
+    Args:
+        dtype: the data type of output Tensor. Defaults to `"float32"`.
+
+    Return:
+        A torch.Tensor with the size of [2xHxW[xD]], which is stack horizontal and vertical maps
+
+    """
+
+    def __init__(self, dtype: DtypeLike = "float32") -> None:
+        super().__init__()
+        self.dtype = dtype
+
+    def __call__(self, mask: NdarrayOrTensor):
+        instance_mask = convert_data_type(mask, np.ndarray)[0]
+
+        h_map = instance_mask.astype(self.dtype, copy=True)
+        v_map = instance_mask.astype(self.dtype, copy=True)
+
+        for region in skimage.measure.regionprops(instance_mask):
+            v_dist = region.coords[:, 0] - region.centroid[0]
+            h_dist = region.coords[:, 1] - region.centroid[1]
+
+            h_dist[h_dist < 0] /= -np.amin(h_dist)
+            h_dist[h_dist > 0] /= np.amax(h_dist)
+
+            v_dist[v_dist < 0] /= -np.amin(v_dist)
+            v_dist[v_dist > 0] /= np.amax(v_dist)
+
+            h_map[h_map == region.label] = h_dist
+            v_map[v_map == region.label] = v_dist
+
+        hv_maps = convert_to_tensor(np.stack([h_map, v_map]), track_meta=get_track_meta())
+        return hv_maps
diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py
--- a/monai/transforms/intensity/dictionary.py
+++ b/monai/transforms/intensity/dictionary.py
@@ -24,6 +24,7 @@
 from monai.data.meta_obj import get_track_meta
 from monai.transforms.intensity.array import (
     AdjustContrast,
+    ComputeHoVerMaps,
     ForegroundMask,
     GaussianSharpen,
     GaussianSmooth,
@@ -91,6 +92,7 @@
     "RandCoarseShuffled",
     "HistogramNormalized",
     "ForegroundMaskd",
+    "ComputeHoVerMapsd",
     "RandGaussianNoiseD",
     "RandGaussianNoiseDict",
     "ShiftIntensityD",
@@ -151,6 +153,8 @@
     "RandKSpaceSpikeNoiseDict",
     "ForegroundMaskD",
     "ForegroundMaskDict",
+    "ComputeHoVerMapsD",
+    "ComputeHoVerMapsDict",
 ]
 
 DEFAULT_POST_FIX = PostFix.meta()
@@ -1741,6 +1745,39 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
         return d
 
 
+class ComputeHoVerMapsd(MapTransform):
+    """Compute horizontal and vertical maps from an instance mask
+    It generates normalized horizontal and vertical distances to the center of mass of each region.
+
+    Args:
+        keys: keys of the corresponding items to be transformed.
+        dtype: the type of output Tensor. Defaults to `"float32"`.
+        new_key_prefix: this prefix be prepended to the key to create a new key for the output and keep the value of
+            key intact. Defaults to '"_hover", so if the input key is "mask" the output will be "hover_mask".
+        allow_missing_keys: do not raise exception if key is missing.
+
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        dtype: DtypeLike = "float32",
+        new_key_prefix: str = "hover_",
+        allow_missing_keys: bool = False,
+    ) -> None:
+        super().__init__(keys, allow_missing_keys)
+        self.transform = ComputeHoVerMaps(dtype=dtype)
+        self.new_key_prefix = new_key_prefix
+
+    def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
+        d = dict(data)
+        for key in self.key_iterator(d):
+            new_key = key if self.new_key_prefix is None else self.new_key_prefix + key
+            d[new_key] = self.transform(d[key])
+
+        return d
+
+
 RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised
 RandRicianNoiseD = RandRicianNoiseDict = RandRicianNoised
 ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd
@@ -1771,3 +1808,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N
 HistogramNormalizeD = HistogramNormalizeDict = HistogramNormalized
 RandCoarseShuffleD = RandCoarseShuffleDict = RandCoarseShuffled
 ForegroundMaskD = ForegroundMaskDict = ForegroundMaskd
+ComputeHoVerMapsD = ComputeHoVerMapsDict = ComputeHoVerMapsd


Test patch:
diff --git a/tests/min_tests.py b/tests/min_tests.py
--- a/tests/min_tests.py
+++ b/tests/min_tests.py
@@ -34,8 +34,10 @@ def run_testsuit():
         "test_cachedataset_parallel",
         "test_cachedataset_persistent_workers",
         "test_cachentransdataset",
-        "test_contrastive_loss",
         "test_check_missing_files",
+        "test_compute_ho_ver_maps",
+        "test_compute_ho_ver_maps_d",
+        "test_contrastive_loss",
         "test_csv_dataset",
         "test_csv_iterable_dataset",
         "test_cumulative_average_dist",
diff --git a/tests/test_compute_ho_ver_maps.py b/tests/test_compute_ho_ver_maps.py
new file mode 100644
--- /dev/null
+++ b/tests/test_compute_ho_ver_maps.py
@@ -0,0 +1,74 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+import torch
+from parameterized import parameterized
+
+from monai.transforms.intensity.array import ComputeHoVerMaps
+from monai.utils import min_version, optional_import
+from tests.utils import TEST_NDARRAYS, assert_allclose
+
+_, has_skimage = optional_import("skimage", "0.19.0", min_version)
+
+
+INSTANCE_MASK = np.zeros((16, 16), dtype="int16")
+INSTANCE_MASK[5:8, 4:11] = 1
+INSTANCE_MASK[3:5, 6:9] = 1
+INSTANCE_MASK[8:10, 6:9] = 1
+INSTANCE_MASK[13:, 13:] = 2
+H_MAP = torch.zeros((16, 16), dtype=torch.float32)
+H_MAP[5:8, 4] = -1.0
+H_MAP[5:8, 5] = -2.0 / 3.0
+H_MAP[3:10, 6] = -1.0 / 3.0
+H_MAP[3:10, 7] = 0.0
+H_MAP[3:10, 8] = 1.0 / 3.0
+H_MAP[5:8, 9] = 2.0 / 3.0
+H_MAP[5:8, 10] = 1.0
+H_MAP[13:, 13] = -1.0
+H_MAP[13:, 14] = 0.0
+H_MAP[13:, 15] = 1.0
+V_MAP = torch.zeros((16, 16), dtype=torch.float32)
+V_MAP[3, 6:9] = -1.0
+V_MAP[4, 6:9] = -2.0 / 3.0
+V_MAP[5, 4:11] = -1.0 / 3.0
+V_MAP[6, 4:11] = 0.0
+V_MAP[7, 4:11] = 1.0 / 3.0
+V_MAP[8, 6:9] = 2.0 / 3.0
+V_MAP[9, 6:9] = 1.0
+V_MAP[13, 13:] = -1.0
+V_MAP[14, 13:] = 0.0
+V_MAP[15, 13:] = 1.0
+HV_MAPS = torch.stack([H_MAP, V_MAP])
+TEST_CASE_0 = [{}, INSTANCE_MASK, HV_MAPS]
+TEST_CASE_1 = [{"dtype": "float64"}, INSTANCE_MASK, HV_MAPS]
+
+TESTS = []
+for p in TEST_NDARRAYS:
+    TESTS.append([p, *TEST_CASE_0])
+    TESTS.append([p, *TEST_CASE_1])
+
+
+@unittest.skipUnless(has_skimage, "Requires scikit-image library.")
+class ComputeHoVerMapsTests(unittest.TestCase):
+    @parameterized.expand(TESTS)
+    def test_horizontal_certical_maps(self, in_type, arguments, mask, hv_mask):
+        input_image = in_type(mask)
+        result = ComputeHoVerMaps(**arguments)(input_image)
+        self.assertTrue(isinstance(result, torch.Tensor))
+        self.assertTrue(str(result.dtype).split(".")[1] == arguments.get("dtype", "float32"))
+        assert_allclose(result, hv_mask, type_test="tensor")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_compute_ho_ver_maps_d.py b/tests/test_compute_ho_ver_maps_d.py
new file mode 100644
--- /dev/null
+++ b/tests/test_compute_ho_ver_maps_d.py
@@ -0,0 +1,78 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import numpy as np
+import torch
+from parameterized import parameterized
+
+from monai.transforms.intensity.dictionary import ComputeHoVerMapsd
+from monai.utils import min_version, optional_import
+from tests.utils import TEST_NDARRAYS, assert_allclose
+
+_, has_skimage = optional_import("skimage", "0.19.0", min_version)
+
+
+INSTANCE_MASK = np.zeros((16, 16), dtype="int16")
+INSTANCE_MASK[5:8, 4:11] = 1
+INSTANCE_MASK[3:5, 6:9] = 1
+INSTANCE_MASK[8:10, 6:9] = 1
+INSTANCE_MASK[13:, 13:] = 2
+H_MAP = torch.zeros((16, 16), dtype=torch.float32)
+H_MAP[5:8, 4] = -1.0
+H_MAP[5:8, 5] = -2.0 / 3.0
+H_MAP[3:10, 6] = -1.0 / 3.0
+H_MAP[3:10, 7] = 0.0
+H_MAP[3:10, 8] = 1.0 / 3.0
+H_MAP[5:8, 9] = 2.0 / 3.0
+H_MAP[5:8, 10] = 1.0
+H_MAP[13:, 13] = -1.0
+H_MAP[13:, 14] = 0.0
+H_MAP[13:, 15] = 1.0
+V_MAP = torch.zeros((16, 16), dtype=torch.float32)
+V_MAP[3, 6:9] = -1.0
+V_MAP[4, 6:9] = -2.0 / 3.0
+V_MAP[5, 4:11] = -1.0 / 3.0
+V_MAP[6, 4:11] = 0.0
+V_MAP[7, 4:11] = 1.0 / 3.0
+V_MAP[8, 6:9] = 2.0 / 3.0
+V_MAP[9, 6:9] = 1.0
+V_MAP[13, 13:] = -1.0
+V_MAP[14, 13:] = 0.0
+V_MAP[15, 13:] = 1.0
+HV_MAPS = torch.stack([H_MAP, V_MAP])
+TEST_CASE_0 = [{}, {"mask": INSTANCE_MASK}, {"hover_mask": HV_MAPS}]
+TEST_CASE_1 = [{"dtype": "float64"}, {"mask": INSTANCE_MASK}, {"hover_mask": HV_MAPS}]
+TEST_CASE_1 = [{"dtype": "float64", "new_key_prefix": ""}, {"mask": INSTANCE_MASK}, {"mask": HV_MAPS}]
+
+TESTS = []
+for p in TEST_NDARRAYS:
+    TESTS.append([p, *TEST_CASE_0])
+    TESTS.append([p, *TEST_CASE_1])
+
+
+@unittest.skipUnless(has_skimage, "Requires scikit-image library.")
+class ComputeHoVerMapsDictTests(unittest.TestCase):
+    @parameterized.expand(TESTS)
+    def test_horizontal_certical_maps(self, in_type, arguments, mask, hv_mask):
+        hv_key = list(hv_mask.keys())[0]
+        input_image = {}
+        for k in mask.keys():
+            input_image[k] = in_type(mask[k])
+        result = ComputeHoVerMapsd(keys="mask", **arguments)(input_image)[hv_key]
+        self.assertTrue(isinstance(result, torch.Tensor))
+        self.assertTrue(str(result.dtype).split(".")[1] == arguments.get("dtype", "float32"))
+        assert_allclose(result, hv_mask[hv_key], type_test="tensor")
+
+
+if __name__ == "__main__":
+    unittest.main()