Repository: Project-MONAI/MONAI

Issue:
Missing `wrap_sequence` in `ToNumpy` and `EnsureType`
These two transforms (also for their dict versions) miss the parameter `wrap_sequence`.


Discussion:

Patch:
diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py
--- a/monai/transforms/utility/array.py
+++ b/monai/transforms/utility/array.py
@@ -340,33 +340,48 @@ def __call__(self, img: NdarrayOrTensor, dtype: Optional[Union[DtypeLike, torch.
 class ToTensor(Transform):
     """
     Converts the input image to a tensor without applying any other transformations.
+    Input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc.
+    Will convert Tensor, Numpy array, float, int, bool to Tensor, strings and objects keep the original.
+    For dictionary, list or tuple, convert every item to a Tensor if applicable and `wrap_sequence=False`.
+
+    Args:
+        dtype: target data type to when converting to Tensor.
+        device: target device to put the converted Tensor data.
+        wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+            E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
+
     """
 
     backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
 
-    def __init__(self, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None) -> None:
+    def __init__(
+        self, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, wrap_sequence: bool = True
+    ) -> None:
         super().__init__()
         self.dtype = dtype
         self.device = device
+        self.wrap_sequence = wrap_sequence
 
-    def __call__(self, img: NdarrayOrTensor) -> torch.Tensor:
+    def __call__(self, img: NdarrayOrTensor):
         """
         Apply the transform to `img` and make it contiguous.
         """
-        return convert_to_tensor(img, dtype=self.dtype, device=self.device, wrap_sequence=True)  # type: ignore
+        return convert_to_tensor(img, dtype=self.dtype, device=self.device, wrap_sequence=self.wrap_sequence)
 
 
 class EnsureType(Transform):
     """
     Ensure the input data to be a PyTorch Tensor or numpy array, support: `numpy array`, `PyTorch Tensor`,
     `float`, `int`, `bool`, `string` and `object` keep the original.
-    If passing a dictionary, list or tuple, still return dictionary, list or tuple and recursively convert
-    every item to the expected data type.
+    If passing a dictionary, list or tuple, still return dictionary, list or tuple will recursively convert
+    every item to the expected data type if `wrap_sequence=False`.
 
     Args:
         data_type: target data type to convert, should be "tensor" or "numpy".
         dtype: target data content type to convert, for example: np.float32, torch.float, etc.
         device: for Tensor data type, specify the target device.
+        wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+            E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
 
     """
 
@@ -377,10 +392,12 @@ def __init__(
         data_type: str = "tensor",
         dtype: Optional[Union[DtypeLike, torch.dtype]] = None,
         device: Optional[torch.device] = None,
+        wrap_sequence: bool = True,
     ) -> None:
         self.data_type = look_up_option(data_type.lower(), {"tensor", "numpy"})
         self.dtype = dtype
         self.device = device
+        self.wrap_sequence = wrap_sequence
 
     def __call__(self, data: NdarrayOrTensor):
         """
@@ -388,30 +405,39 @@ def __call__(self, data: NdarrayOrTensor):
             data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc.
                 will ensure Tensor, Numpy array, float, int, bool as Tensors or numpy arrays, strings and
                 objects keep the original. for dictionary, list or tuple, ensure every item as expected type
-                if applicable.
+                if applicable and `wrap_sequence=False`.
 
         """
         output_type = torch.Tensor if self.data_type == "tensor" else np.ndarray
-        out, *_ = convert_data_type(data, output_type=output_type, dtype=self.dtype, device=self.device)
+        out, *_ = convert_data_type(
+            data=data, output_type=output_type, dtype=self.dtype, device=self.device, wrap_sequence=self.wrap_sequence
+        )
         return out
 
 
 class ToNumpy(Transform):
     """
     Converts the input data to numpy array, can support list or tuple of numbers and PyTorch Tensor.
+
+    Args:
+        dtype: target data type when converting to numpy array.
+        wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+            E.g., if `False`, `[1, 2]` -> `[array(1), array(2)]`, if `True`, then `[1, 2]` -> `array([1, 2])`.
+
     """
 
     backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
 
-    def __init__(self, dtype: DtypeLike = None) -> None:
+    def __init__(self, dtype: DtypeLike = None, wrap_sequence: bool = True) -> None:
         super().__init__()
         self.dtype = dtype
+        self.wrap_sequence = wrap_sequence
 
-    def __call__(self, img: NdarrayOrTensor) -> np.ndarray:
+    def __call__(self, img: NdarrayOrTensor):
         """
         Apply the transform to `img` and make it contiguous.
         """
-        return convert_to_numpy(img, dtype=self.dtype)  # type: ignore
+        return convert_to_numpy(img, dtype=self.dtype, wrap_sequence=self.wrap_sequence)
 
 
 class ToCupy(Transform):
@@ -420,19 +446,23 @@ class ToCupy(Transform):
 
     Args:
         dtype: data type specifier. It is inferred from the input by default.
+        wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+            E.g., if `False`, `[1, 2]` -> `[array(1), array(2)]`, if `True`, then `[1, 2]` -> `array([1, 2])`.
+
     """
 
     backend = [TransformBackends.TORCH, TransformBackends.NUMPY]
 
-    def __init__(self, dtype=None) -> None:
+    def __init__(self, dtype=None, wrap_sequence: bool = True) -> None:
         super().__init__()
         self.dtype = dtype
+        self.wrap_sequence = wrap_sequence
 
     def __call__(self, data: NdarrayOrTensor):
         """
         Create a CuPy array from `data` and make it contiguous
         """
-        return convert_to_cupy(data, self.dtype)
+        return convert_to_cupy(data, dtype=self.dtype, wrap_sequence=self.wrap_sequence)
 
 
 class ToPIL(Transform):
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
@@ -460,6 +460,7 @@ def __init__(
         keys: KeysCollection,
         dtype: Optional[torch.dtype] = None,
         device: Optional[torch.device] = None,
+        wrap_sequence: bool = True,
         allow_missing_keys: bool = False,
     ) -> None:
         """
@@ -468,10 +469,12 @@ def __init__(
                 See also: :py:class:`monai.transforms.compose.MapTransform`
             dtype: target data content type to convert, for example: torch.float, etc.
             device: specify the target device to put the Tensor data.
+            wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+                E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
             allow_missing_keys: don't raise exception if key is missing.
         """
         super().__init__(keys, allow_missing_keys)
-        self.converter = ToTensor(dtype=dtype, device=device)
+        self.converter = ToTensor(dtype=dtype, device=device, wrap_sequence=wrap_sequence)
 
     def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = dict(data)
@@ -499,7 +502,7 @@ class EnsureTyped(MapTransform, InvertibleTransform):
     Ensure the input data to be a PyTorch Tensor or numpy array, support: `numpy array`, `PyTorch Tensor`,
     `float`, `int`, `bool`, `string` and `object` keep the original.
     If passing a dictionary, list or tuple, still return dictionary, list or tuple and recursively convert
-    every item to the expected data type.
+    every item to the expected data type if `wrap_sequence=False`.
 
     Note: Currently, we only convert tensor data to numpy array or scalar number in the inverse operation.
 
@@ -513,6 +516,7 @@ def __init__(
         data_type: str = "tensor",
         dtype: Optional[Union[DtypeLike, torch.dtype]] = None,
         device: Optional[torch.device] = None,
+        wrap_sequence: bool = True,
         allow_missing_keys: bool = False,
     ) -> None:
         """
@@ -522,10 +526,12 @@ def __init__(
             data_type: target data type to convert, should be "tensor" or "numpy".
             dtype: target data content type to convert, for example: np.float32, torch.float, etc.
             device: for Tensor data type, specify the target device.
+            wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+                E.g., if `False`, `[1, 2]` -> `[tensor(1), tensor(2)]`, if `True`, then `[1, 2]` -> `tensor([1, 2])`.
             allow_missing_keys: don't raise exception if key is missing.
         """
         super().__init__(keys, allow_missing_keys)
-        self.converter = EnsureType(data_type=data_type, dtype=dtype, device=device)
+        self.converter = EnsureType(data_type=data_type, dtype=dtype, device=device, wrap_sequence=wrap_sequence)
 
     def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = dict(data)
@@ -552,16 +558,24 @@ class ToNumpyd(MapTransform):
 
     backend = ToNumpy.backend
 
-    def __init__(self, keys: KeysCollection, dtype: DtypeLike = None, allow_missing_keys: bool = False) -> None:
+    def __init__(
+        self,
+        keys: KeysCollection,
+        dtype: DtypeLike = None,
+        wrap_sequence: bool = True,
+        allow_missing_keys: bool = False,
+    ) -> None:
         """
         Args:
             keys: keys of the corresponding items to be transformed.
                 See also: :py:class:`monai.transforms.compose.MapTransform`
             dtype: target data type when converting to numpy array.
+            wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+                E.g., if `False`, `[1, 2]` -> `[array(1), array(2)]`, if `True`, then `[1, 2]` -> `array([1, 2])`.
             allow_missing_keys: don't raise exception if key is missing.
         """
         super().__init__(keys, allow_missing_keys)
-        self.converter = ToNumpy(dtype=dtype)
+        self.converter = ToNumpy(dtype=dtype, wrap_sequence=wrap_sequence)
 
     def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]:
         d = dict(data)
@@ -578,14 +592,18 @@ class ToCupyd(MapTransform):
         keys: keys of the corresponding items to be transformed.
             See also: :py:class:`monai.transforms.compose.MapTransform`
         dtype: data type specifier. It is inferred from the input by default.
+        wrap_sequence: if `False`, then lists will recursively call this function, default to `True`.
+            E.g., if `False`, `[1, 2]` -> `[array(1), array(2)]`, if `True`, then `[1, 2]` -> `array([1, 2])`.
         allow_missing_keys: don't raise exception if key is missing.
     """
 
     backend = ToCupy.backend
 
-    def __init__(self, keys: KeysCollection, dtype=None, allow_missing_keys: bool = False) -> None:
+    def __init__(
+        self, keys: KeysCollection, dtype=None, wrap_sequence: bool = True, allow_missing_keys: bool = False
+    ) -> None:
         super().__init__(keys, allow_missing_keys)
-        self.converter = ToCupy(dtype=dtype)
+        self.converter = ToCupy(dtype=dtype, wrap_sequence=wrap_sequence)
 
     def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]:
         d = dict(data)
diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py
--- a/monai/utils/type_conversion.py
+++ b/monai/utils/type_conversion.py
@@ -108,7 +108,7 @@ def convert_to_tensor(
 
     Args:
         data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc.
-            will convert Tensor, Numpy array, float, int, bool to Tensors, strings and objects keep the original.
+            will convert Tensor, Numpy array, float, int, bool to Tensor, strings and objects keep the original.
             for dictionary, list or tuple, convert every item to a Tensor if applicable.
         dtype: target data type to when converting to Tensor.
         device: target device to put the converted Tensor data.
@@ -175,7 +175,7 @@ def convert_to_numpy(data, dtype: DtypeLike = None, wrap_sequence: bool = False)
     return data
 
 
-def convert_to_cupy(data, dtype, wrap_sequence: bool = True):
+def convert_to_cupy(data, dtype, wrap_sequence: bool = False):
     """
     Utility to convert the input data to a cupy array. If passing a dictionary, list or tuple,
     recursively check every item and convert it to cupy array.


Test patch:
diff --git a/tests/test_ensure_type.py b/tests/test_ensure_type.py
--- a/tests/test_ensure_type.py
+++ b/tests/test_ensure_type.py
@@ -59,12 +59,12 @@ def test_string(self):
 
     def test_list_tuple(self):
         for dtype in ("tensor", "numpy"):
-            result = EnsureType(data_type=dtype)([[1, 2], [3, 4]])
+            result = EnsureType(data_type=dtype, wrap_sequence=False)([[1, 2], [3, 4]])
             self.assertTrue(isinstance(result, list))
             self.assertTrue(isinstance(result[0][1], torch.Tensor if dtype == "tensor" else np.ndarray))
             torch.testing.assert_allclose(result[1][0], torch.as_tensor(3))
             # tuple of numpy arrays
-            result = EnsureType(data_type=dtype)((np.array([1, 2]), np.array([3, 4])))
+            result = EnsureType(data_type=dtype, wrap_sequence=False)((np.array([1, 2]), np.array([3, 4])))
             self.assertTrue(isinstance(result, tuple))
             self.assertTrue(isinstance(result[0], torch.Tensor if dtype == "tensor" else np.ndarray))
             torch.testing.assert_allclose(result[1], torch.as_tensor([3, 4]))
diff --git a/tests/test_ensure_typed.py b/tests/test_ensure_typed.py
--- a/tests/test_ensure_typed.py
+++ b/tests/test_ensure_typed.py
@@ -61,12 +61,14 @@ def test_string(self):
 
     def test_list_tuple(self):
         for dtype in ("tensor", "numpy"):
-            result = EnsureTyped(keys="data", data_type=dtype)({"data": [[1, 2], [3, 4]]})["data"]
+            result = EnsureTyped(keys="data", data_type=dtype, wrap_sequence=False)({"data": [[1, 2], [3, 4]]})["data"]
             self.assertTrue(isinstance(result, list))
             self.assertTrue(isinstance(result[0][1], torch.Tensor if dtype == "tensor" else np.ndarray))
             torch.testing.assert_allclose(result[1][0], torch.as_tensor(3))
             # tuple of numpy arrays
-            result = EnsureTyped(keys="data", data_type=dtype)({"data": (np.array([1, 2]), np.array([3, 4]))})["data"]
+            result = EnsureTyped(keys="data", data_type=dtype, wrap_sequence=False)(
+                {"data": (np.array([1, 2]), np.array([3, 4]))}
+            )["data"]
             self.assertTrue(isinstance(result, tuple))
             self.assertTrue(isinstance(result[0], torch.Tensor if dtype == "tensor" else np.ndarray))
             torch.testing.assert_allclose(result[1], torch.as_tensor([3, 4]))
diff --git a/tests/test_to_cupy.py b/tests/test_to_cupy.py
--- a/tests/test_to_cupy.py
+++ b/tests/test_to_cupy.py
@@ -99,10 +99,10 @@ def test_tensor_cuda_input_dtype(self):
 
     def test_list_tuple(self):
         test_data = [[1, 2], [3, 4]]
-        result = ToCupy()(test_data)
+        result = ToCupy(wrap_sequence=True)(test_data)
         cp.testing.assert_allclose(result, cp.asarray(test_data))
         test_data = ((1, 2), (3, 4))
-        result = ToCupy()(test_data)
+        result = ToCupy(wrap_sequence=True)(test_data)
         cp.testing.assert_allclose(result, cp.asarray(test_data))
 
 
diff --git a/tests/test_to_cupyd.py b/tests/test_to_cupyd.py
--- a/tests/test_to_cupyd.py
+++ b/tests/test_to_cupyd.py
@@ -67,10 +67,10 @@ def test_tensor_cuda_input(self):
     @skipUnless(has_cp, "CuPy is required.")
     def test_list_tuple(self):
         test_data = [[1, 2], [3, 4]]
-        result = ToCupyd(keys="img")({"img": test_data})["img"]
+        result = ToCupyd(keys="img", wrap_sequence=True)({"img": test_data})["img"]
         cp.testing.assert_allclose(result, cp.asarray(test_data))
         test_data = ((1, 2), (3, 4))
-        result = ToCupyd(keys="img")({"img": test_data})["img"]
+        result = ToCupyd(keys="img", wrap_sequence=True)({"img": test_data})["img"]
         cp.testing.assert_allclose(result, cp.asarray(test_data))
 
 
diff --git a/tests/test_to_numpy.py b/tests/test_to_numpy.py
--- a/tests/test_to_numpy.py
+++ b/tests/test_to_numpy.py
@@ -67,8 +67,9 @@ def test_list_tuple(self):
         result = ToNumpy()(test_data)
         assert_allclose(result, np.asarray(test_data), type_test=False)
         test_data = ((1, 2), (3, 4))
-        result = ToNumpy()(test_data)
-        assert_allclose(result, np.asarray(test_data), type_test=False)
+        result = ToNumpy(wrap_sequence=False)(test_data)
+        self.assertTrue(type(result), tuple)
+        assert_allclose(result, ((np.asarray(1), np.asarray(2)), (np.asarray(3), np.asarray(4))))
 
     def test_single_value(self):
         for test_data in [5, np.array(5), torch.tensor(5)]:
diff --git a/tests/test_to_tensor.py b/tests/test_to_tensor.py
--- a/tests/test_to_tensor.py
+++ b/tests/test_to_tensor.py
@@ -35,7 +35,7 @@
 class TestToTensor(unittest.TestCase):
     @parameterized.expand(TESTS)
     def test_array_input(self, test_data, expected_shape):
-        result = ToTensor(dtype=torch.float32, device="cpu")(test_data)
+        result = ToTensor(dtype=torch.float32, device="cpu", wrap_sequence=True)(test_data)
         self.assertTrue(isinstance(result, torch.Tensor))
         assert_allclose(result, test_data, type_test=False)
         self.assertTupleEqual(result.shape, expected_shape)