Repository: Project-MONAI/MONAI

Issue:
Add CopyKeys transform
**Is your feature request related to a problem? Please describe.**
In order to support parallel transforms, users can use `CopyKeys` transform to copy the same data as input of several following transforms.



Discussion:

Resolving patch:
diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py
--- a/monai/transforms/utility/dictionary.py
+++ b/monai/transforms/utility/dictionary.py
@@ -17,12 +17,12 @@
 
 from logging import Handler
 from typing import Optional
-
+import copy
 import numpy as np
 
 from monai.config.type_definitions import KeysCollection
 from monai.transforms.compose import MapTransform
-from monai.utils.misc import ensure_tuple_rep
+from monai.utils.misc import ensure_tuple, ensure_tuple_rep
 from monai.transforms.utility.array import (
     AddChannel,
     AsChannelFirst,
@@ -210,9 +210,9 @@ def __call__(self, data):
         return d
 
 
-class DeleteKeysd(MapTransform):
+class DeleteItemsd(MapTransform):
     """
-    Delete specified keys from data dictionary to release memory.
+    Delete specified items from data dictionary to release memory.
     It will remove the key-values and copy the others to construct a new dictionary.
     """
 
@@ -327,6 +327,42 @@ def __call__(self, data):
         return d
 
 
+class CopyItemsd(MapTransform):
+    """
+    Copy specified items from data dictionary and save with different key names.
+    It can copy several items together and copy several times.
+
+    """
+
+    def __init__(self, keys: KeysCollection, times: int, names):
+        """
+        Args:
+            keys: keys of the corresponding items to be transformed.
+                See also: :py:class:`monai.transforms.compose.MapTransform`
+            times: expected copy times, for example, if keys is "img", times is 3,
+                it will add 3 copies of "img" data to the dictionary.
+            names(str, list or tuple of str): the names coresponding 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"].
+        """
+        super().__init__(keys)
+        if times < 1:
+            raise ValueError("times must be greater than 0.")
+        self.times = times
+        names = ensure_tuple(names)
+        if len(names) != (len(self.keys) * times):
+            raise ValueError("length of names does not match `len(keys) x times`.")
+        self.names = names
+
+    def __call__(self, data):
+        d = dict(data)
+        for key, new_key in zip(self.keys * self.times, self.names):
+            if new_key in d:
+                raise KeyError(f"key {new_key} already exists in dictionary.")
+            d[new_key] = copy.deepcopy(d[key])
+        return d
+
+
 IdentityD = IdentityDict = Identityd
 AsChannelFirstD = AsChannelFirstDict = AsChannelFirstd
 AsChannelLastD = AsChannelLastDict = AsChannelLastd
@@ -334,7 +370,8 @@ def __call__(self, data):
 RepeatChannelD = RepeatChannelDict = RepeatChanneld
 CastToTypeD = CastToTypeDict = CastToTyped
 ToTensorD = ToTensorDict = ToTensord
-DeleteKeysD = DeleteKeysDict = DeleteKeysd
+DeleteItemsD = DeleteItemsDict = DeleteItemsd
 SqueezeDimD = SqueezeDimDict = SqueezeDimd
 DataStatsD = DataStatsDict = DataStatsd
 SimulateDelayD = SimulateDelayDict = SimulateDelayd
+CopyItemsD = CopyItemsDict = CopyItemsd


Test patch:
diff --git a/tests/test_copy_itemsd.py b/tests/test_copy_itemsd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_copy_itemsd.py
@@ -0,0 +1,63 @@
+# 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 time
+import sys
+import torch
+import numpy as np
+from parameterized import parameterized
+from monai.transforms import CopyItemsd
+from monai.utils.misc import ensure_tuple
+
+TEST_CASE_1 = ["img", 1, "img_1"]
+
+TEST_CASE_2 = [["img", "seg"], 1, ["img_1", "seg_1"]]
+
+TEST_CASE_3 = ["img", 2, ["img_1", "img_2"]]
+
+TEST_CASE_4 = [["img", "seg"], 2, ["img_1", "seg_1", "img_2", "seg_2"]]
+
+
+class TestCopyItemsd(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
+    def test_numpy_values(self, keys, times, names):
+        input_data = {"img": np.array([[0, 1], [1, 2]]), "seg": np.array([[0, 1], [1, 2]])}
+        result = CopyItemsd(keys=keys, times=times, names=names)(input_data)
+        for name in ensure_tuple(names):
+            self.assertTrue(name in result)
+            result[name] += 1
+            np.testing.assert_allclose(result[name], np.array([[1, 2], [2, 3]]))
+        np.testing.assert_allclose(result["img"], np.array([[0, 1], [1, 2]]))
+
+    def test_tensor_values(self):
+        device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu:0")
+        input_data = {
+            "img": torch.tensor([[0, 1], [1, 2]], device=device),
+            "seg": torch.tensor([[0, 1], [1, 2]], device=device),
+        }
+        result = CopyItemsd(keys="img", times=1, names="img_1")(input_data)
+        self.assertTrue("img_1" in result)
+        result["img_1"] += 1
+        torch.testing.assert_allclose(result["img"], torch.tensor([[0, 1], [1, 2]], device=device))
+        torch.testing.assert_allclose(result["img_1"], torch.tensor([[1, 2], [2, 3]], device=device))
+
+    def test_array_values(self):
+        input_data = {"img": [[0, 1], [1, 2]], "seg": [[0, 1], [1, 2]]}
+        result = CopyItemsd(keys="img", times=1, names="img_1")(input_data)
+        self.assertTrue("img_1" in result)
+        result["img_1"][0][0] += 1
+        np.testing.assert_allclose(result["img"], [[0, 1], [1, 2]])
+        np.testing.assert_allclose(result["img_1"], [[1, 1], [1, 2]])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_delete_keys.py b/tests/test_delete_itemsd.py
similarity index 89%
rename from tests/test_delete_keys.py
rename to tests/test_delete_itemsd.py
--- a/tests/test_delete_keys.py
+++ b/tests/test_delete_itemsd.py
@@ -13,7 +13,7 @@
 import time
 import sys
 from parameterized import parameterized
-from monai.transforms import DeleteKeysd
+from monai.transforms import DeleteItemsd
 
 TEST_CASE_1 = [
     {"keys": [str(i) for i in range(30)]},
@@ -21,13 +21,13 @@
 ]
 
 
-class TestDeleteKeysd(unittest.TestCase):
+class TestDeleteItemsd(unittest.TestCase):
     @parameterized.expand([TEST_CASE_1])
     def test_memory(self, input_param, expected_key_size):
         input_data = dict()
         for i in range(50):
             input_data[str(i)] = [time.time()] * 100000
-        result = DeleteKeysd(**input_param)(input_data)
+        result = DeleteItemsd(**input_param)(input_data)
         self.assertEqual(len(result.keys()), expected_key_size)
         self.assertGreaterEqual(
             sys.getsizeof(input_data) * float(expected_key_size) / len(input_data), sys.getsizeof(result)