Repository: albumentations-team/albumentations (Python)

Issue:
RandomRotate90 + ToTensorV2 fail when used with masks in albumentations >= 1.4.11
## Describe the bug

A refactoring of `RandomRotate90` in albumentations 1.4.11 introduced a runtime error when using the transform before `albumentations.pytorch.ToTensorV2` with masks.

The refactor done in PR #1823 removed an explicit call to `np.ascontiguousarray` from [`albumentations.augmentations.geometric.functional.rot90`](https://github.com/albumentations-team/albumentations/blob/27fee7d97e3d1d0cedcd1957a5a500f98d85d725/albumentations/augmentations/geometric/functional.py#L1340).

Code before the PR:
```python
def rot90(img: np.ndarray, factor: int) -> np.ndarray:
    img = np.rot90(img, factor)
    return np.ascontiguousarray(img)
```

After PR:
```python
def rot90(img: np.ndarray, factor: int) -> np.ndarray:
    return np.rot90(img, factor)
```

The `ToTensorV2` transform, however, requires the input array to be contiguous.

This does not cause any issues when transforming only images, because this case is handled in [`BasicTransform.apply_with_params`](https://github.com/albumentations-team/albumentations/blob/27fee7d97e3d1d0cedcd1957a5a500f98d85d725/albumentations/core/transforms_interface.py#L136):
```python
if isinstance(arg, np.ndarray):
    result = target_function(np.require(arg, requirements=["C_CONTIGUOUS"]), **params)
    if isinstance(result, np.ndarray):
        res[key] = np.require(result, requirements=["C_CONTIGUOUS"])
    else:
        res[key] = result
else:
    res[key] = target_function(arg, **params)
```

Masks, however, are passed as a *list* of numpy arrays to `apply_to_masks` and hence not covered by this conversion.

### To Reproduce

#### Environment (e.g., OS, Python version, Albumentations version, etc.)

```
albumentations==1.4.17
numpy==1.26.4
torch==2.4.1
```

#### Sample code that produces the bug.

```python
import torch
import numpy as np
import albumentations as A
from albumentations.pytorch import ToTensorV2

transforms = A.Compose([
    A.RandomRotate90(p=1.0),
    ToTensorV2(),
])

img = np.random.randint(0, 256, (640, 480, 3)).astype(np.uint8)
masks = [np.random.randint(0, 2, (640, 480)).astype(np.uint8) for _ in range(4)]

for _ in range(10):  # make sure we hit an actual rotation
    transformed = transforms(image=img, masks=masks)
```

### Expected behavior

Images and masks are transformed without throwing an exception.

### Actual behavior

Exception is raised:

```python-traceback
ValueError                                Traceback (most recent call last)
Cell In[63], line 1
----> 1 transforms(image=img, masks=masks)

File ~/software/anaconda3/envs/InstanceSeg3D/lib/python3.11/site-packages/albumentations/core/composition.py:348, in Compose.__call__(self, force_apply, *args, **data)
    345 self.preprocess(data)
    347 for t in self.transforms:
--> 348     data = t(**data)
    349     data = self.check_data_post_transform(data)
    351 return self.postprocess(data)

File ~/software/anaconda3/envs/InstanceSeg3D/lib/python3.11/site-packages/albumentations/core/transforms_interface.py:124, in BasicTransform.__call__(self, force_apply, *args, **kwargs)
    122     if self.deterministic:
    123         kwargs[self.save_key][id(self)] = deepcopy(params)
--> 124     return self.apply_with_params(params, **kwargs)
    126 return kwargs

File ~/software/anaconda3/envs/InstanceSeg3D/lib/python3.11/site-packages/albumentations/core/transforms_interface.py:149, in BasicTransform.apply_with_params(self, params, *args, **kwargs)
    147             res[key] = result
    148     else:
--> 149         res[key] = target_function(arg, **params)
    150 else:
    151     res[key] = arg

File ~/software/anaconda3/envs/InstanceSeg3D/lib/python3.11/site-packages/albumentations/pytorch/transforms.py:52, in ToTensorV2.apply_to_masks(self, masks, **params)
     51 def apply_to_masks(self, masks: list[np.ndarray], **params: Any) -> list[torch.Tensor]:
---> 52     return [self.apply_to_mask(mask, **params) for mask in masks]

File ~/software/anaconda3/envs/InstanceSeg3D/lib/python3.11/site-packages/albumentations/pytorch/transforms.py:52, in <listcomp>(.0)
     51 def apply_to_masks(self, masks: list[np.ndarray], **params: Any) -> list[torch.Tensor]:
---> 52     return [self.apply_to_mask(mask, **params) for mask in masks]

File ~/software/anaconda3/envs/InstanceSeg3D/lib/python3.11/site-packages/albumentations/pytorch/transforms.py:49, in ToTensorV2.apply_to_mask(self, mask, **params)
     47 if self.transpose_mask and mask.ndim == NUM_MULTI_CHANNEL_DIMENSIONS:
     48     mask = mask.transpose(2, 0, 1)
---> 49 return torch.from_numpy(mask)

ValueError: At least one stride in the given numpy array is negative, and tensors with negative strides are not currently supported. (You can probably work around this by making a copy of your array  with array.copy().) 
```

### Additional context

#### Solution Ideas

1. Add back explicit call to `np.ascontiguousarray` to `albumentations.augmentations.geometric.functional.rot90`.
2. Modify `BasicTransform.apply_with_params` to not only convert arguments that are numpy arrays but also lists of numpy arrays.

Is there any preference or alternative solution to this issue?



Discussion:
It was already fixed.

Added test just in case in https://github.com/albumentations-team/albumentations/pull/1975
@ternaus Thanks for the quick response!
Where was this fixed? I tried installing albumentations from the current main branch and the issue still persists.
I cannot reproduce it an added test based on your example at 

https://github.com/albumentations-team/albumentations/blob/main/tests/test_pytorch.py#L252C1-L275C55

When you call `apply_to_masks` on `list[np.ndarray]` it calls `apply_to_mask` on each and it checks that inputs and outputs are contiguous arrays. 

https://github.com/albumentations-team/albumentations/blob/main/albumentations/core/transforms_interface.py#L136-L153

---
Can you confirm that tests including pass? 
The issue still persists even though the test passes.
I debugged into it and it seems that there is some fixed seed, so that the random rotation always picks a rotation around 0° when running the test. Hence, the image is not actually rotated and the error does not occur.

When running `transformed = transforms(image=img, masks=masks)` in a loop as in the example above, the test fails.

As far as I can see, the `apply_with_params` method you linked to only ensures inputs to be contiguous that are numpy arrays. A list of masks will be passed to `apply_to_masks` without checking input and output format.
I proposed a fix for both the test and the actual issue in #1986.



Patch:
diff --git a/albumentations/core/transforms_interface.py b/albumentations/core/transforms_interface.py
index db18bb081..0edfed826 100644
--- a/albumentations/core/transforms_interface.py
+++ b/albumentations/core/transforms_interface.py
@@ -21,7 +21,7 @@
     ColorType,
     Targets,
 )
-from .utils import format_args
+from .utils import ensure_contiguous_output, format_args
 
 __all__ = ["BasicTransform", "DualTransform", "ImageOnlyTransform", "NoOp"]
 
@@ -141,14 +141,9 @@ def apply_with_params(self, params: dict[str, Any], *args: Any, **kwargs: Any) -
         for key, arg in kwargs.items():
             if key in self._key2func and arg is not None:
                 target_function = self._key2func[key]
-                if isinstance(arg, np.ndarray):
-                    result = target_function(np.require(arg, requirements=["C_CONTIGUOUS"]), **params)
-                    if isinstance(result, np.ndarray):
-                        res[key] = np.require(result, requirements=["C_CONTIGUOUS"])
-                    else:
-                        res[key] = result
-                else:
-                    res[key] = target_function(arg, **params)
+                res[key] = ensure_contiguous_output(
+                    target_function(ensure_contiguous_output(arg), **params),
+                )
             else:
                 res[key] = arg
         return res
diff --git a/albumentations/core/utils.py b/albumentations/core/utils.py
index ce26408e7..3d1b05a79 100644
--- a/albumentations/core/utils.py
+++ b/albumentations/core/utils.py
@@ -310,6 +310,14 @@ def ensure_int_output(
     return (int(min_val), int(max_val)) if isinstance(param, int) else (float(min_val), float(max_val))
 
 
+def ensure_contiguous_output(arg: np.ndarray | Sequence[np.ndarray]) -> np.ndarray | list[np.ndarray]:
+    if isinstance(arg, np.ndarray):
+        arg = np.require(arg, requirements=["C_CONTIGUOUS"])
+    elif isinstance(arg, Sequence):
+        arg = list(map(ensure_contiguous_output, arg))
+    return arg
+
+
 @overload
 def to_tuple(param: ScaleIntType, low: ScaleType | None = None, bias: ScalarType | None = None) -> tuple[int, int]: ...
 


Test patch:
diff --git a/tests/test_pytorch.py b/tests/test_pytorch.py
index d09bfe512..bebcad6a0 100644
--- a/tests/test_pytorch.py
+++ b/tests/test_pytorch.py
@@ -277,6 +277,9 @@ def test_to_tensor_v2_on_non_contiguous_array_with_random_rotate90():
 
     img = np.random.randint(0, 256, (640, 480, 3)).astype(np.uint8)
     masks = [np.random.randint(0, 2, (640, 480)).astype(np.uint8) for _ in range(4)]
-    transformed = transforms(image=img, masks=masks)
-    assert transformed["image"].numpy().shape == (3, 640, 480)
-    assert transformed["masks"][0].shape == (640, 480)
+    for _ in range(10):
+        transformed = transforms(image=img, masks=masks)
+        assert isinstance(transformed["image"], torch.Tensor)
+        assert isinstance(transformed["masks"][0], torch.Tensor)
+        assert transformed["image"].numpy().shape in ((3, 640, 480), (3, 480, 640))
+        assert transformed["masks"][0].shape in ((640, 480), (480, 640))