Repository: albumentations-team/albumentations

Issue:
Passing an empty list of masks result Error
## Describe the bug

When we give albumentation a list of masks empty, it generates an error.

### To Reproduce

```python
import albumentations as A
import numpy as np


transform = A.Compose(
    [
        A.Resize(288, 384),
        A.ToFloat(max_value=255),
    ])

transform(image=np.zeros((288, 384, 3), dtype=np.uint8),masks=[])
```
# returns 
```bash
  File "/Users/fponchon/.miniforge3/envs/bibcvdatasets_dev/lib/python3.10/site-packages/albumentations/core/composition.py", line 630, in preprocess
    shape = self._get_data_shape(data_name, internal_name, data_value)
  File "/Users/fponchon/.miniforge3/envs/bibcvdatasets_dev/lib/python3.10/site-packages/albumentations/core/composition.py", line 899, in _get_data_shape
    return self._check_masks_data(data_name, data)
  File "/Users/fponchon/.miniforge3/envs/bibcvdatasets_dev/lib/python3.10/site-packages/albumentations/core/composition.py", line 796, in _check_masks_data
    raise ValueError(f"{data_name} cannot be empty")
ValueError: masks cannot be empty
```

Steps to reproduce the behavior:

1. Python 3.10 -> Albumentation
2. Above example
3. Any error messages or incorrect outputs.

### Expected behavior

It should pass and return an empty list.

### Actual behavior

It crashes

### Screenshots


### Additional context

Sometimes, we have datarows with masks and some other without. Especially when we do instance segmentation for example.
This case should be handled correctly to avoid such issue.


Discussion:
@FrsECM Added this functionality



Patch:
diff --git a/albumentations/core/composition.py b/albumentations/core/composition.py
index 2efb4b7c8..5155f74f2 100644
--- a/albumentations/core/composition.py
+++ b/albumentations/core/composition.py
@@ -685,6 +685,9 @@ def _preprocess_images(self, data: dict[str, Any]) -> None:
 
         if isinstance(data["images"], (list, tuple)):
             self._images_was_list = True
+            # Skip stacking for empty lists
+            if not data["images"]:
+                return
             data["images"] = np.stack(data["images"])
         else:
             self._images_was_list = False
@@ -696,6 +699,9 @@ def _preprocess_masks(self, data: dict[str, Any]) -> None:
 
         if isinstance(data["masks"], (list, tuple)):
             self._masks_was_list = True
+            # Skip stacking for empty lists
+            if not data["masks"]:
+                return
             data["masks"] = np.stack(data["masks"])
         else:
             self._masks_was_list = False
@@ -771,7 +777,7 @@ def _check_single_data(data_name: str, data: Any) -> tuple[int, int]:
         return data.shape[:2]
 
     @staticmethod
-    def _check_masks_data(data_name: str, data: Any) -> tuple[int, int]:
+    def _check_masks_data(data_name: str, data: Any) -> tuple[int, int] | None:
         """Check masks data format and return shape.
 
         Args:
@@ -779,9 +785,10 @@ def _check_masks_data(data_name: str, data: Any) -> tuple[int, int]:
             data (Any): Input data in one of these formats:
                 - List of numpy arrays, each of shape (H, W) or (H, W, C)
                 - Numpy array of shape (N, H, W) or (N, H, W, C)
+                - Empty list for cases where no masks are present
 
         Returns:
-            tuple[int, int]: (height, width) of the first mask
+            tuple[int, int] | None: (height, width) of the first mask, or None if masks list is empty
         Raises:
             TypeError: If data format is invalid
 
@@ -793,7 +800,8 @@ def _check_masks_data(data_name: str, data: Any) -> tuple[int, int]:
 
         if isinstance(data, (list, tuple)):
             if not data:
-                raise ValueError(f"{data_name} cannot be empty")
+                # Allow empty list/tuple of masks
+                return None
             if not all(isinstance(m, np.ndarray) for m in data):
                 raise TypeError(f"All elements in {data_name} must be numpy arrays")
             if any(m.ndim not in {2, 3} for m in data):
@@ -883,30 +891,46 @@ def _check_args(self, **kwargs: Any) -> None:
 
     def _get_data_shape(self, data_name: str, internal_name: str, data: Any) -> tuple[int, ...] | None:
         """Get shape of data based on its type."""
+        # Handle single images and masks
         if internal_name in CHECKED_SINGLE:
-            if not isinstance(data, np.ndarray):
-                raise TypeError(f"{data_name} must be numpy array type")
-            return data.shape
+            return self._get_single_data_shape(data_name, data)
 
+        # Handle volumes
         if internal_name in CHECKED_VOLUME:
             return self._check_volume_data(data_name, data)
 
+        # Handle 3D masks
         if internal_name in CHECKED_MASK3D:
             return self._check_mask3d_data(data_name, data)
 
+        # Handle multi-item data (masks, images, volumes)
         if internal_name in CHECKED_MULTI:
-            if internal_name == "masks":
-                return self._check_masks_data(data_name, data)
-            if internal_name in {"volumes", "masks3d"}:  # Group these together
-                if not isinstance(data, np.ndarray):
-                    raise TypeError(f"{data_name} must be numpy array type")
-                if data.ndim not in {4, 5}:  # (N,D,H,W) or (N,D,H,W,C)
-                    raise TypeError(f"{data_name} must be 4D or 5D array")
-                return data.shape  # Return full shape
-            return self._check_multi_data(data_name, data)
+            return self._get_multi_data_shape(data_name, internal_name, data)
 
         return None
 
+    def _get_single_data_shape(self, data_name: str, data: np.ndarray) -> tuple[int, ...]:
+        """Get shape of single image or mask."""
+        if not isinstance(data, np.ndarray):
+            raise TypeError(f"{data_name} must be numpy array type")
+        return data.shape
+
+    def _get_multi_data_shape(self, data_name: str, internal_name: str, data: Any) -> tuple[int, ...] | None:
+        """Get shape of multi-item data (masks, images, volumes)."""
+        if internal_name == "masks":
+            shape = self._check_masks_data(data_name, data)
+            # Skip empty masks lists when returning shape
+            return None if shape is None else shape
+
+        if internal_name in {"volumes", "masks3d"}:  # Group these together
+            if not isinstance(data, np.ndarray):
+                raise TypeError(f"{data_name} must be numpy array type")
+            if data.ndim not in {4, 5}:  # (N,D,H,W) or (N,D,H,W,C)
+                raise TypeError(f"{data_name} must be 4D or 5D array")
+            return data.shape  # Return full shape
+
+        return self._check_multi_data(data_name, data)
+
     def _check_shape_consistency(self, shapes: list[tuple[int, ...]], volume_shapes: list[tuple[int, ...]]) -> None:
         """Check consistency of shapes."""
         # Check H,W consistency
diff --git a/albumentations/core/transforms_interface.py b/albumentations/core/transforms_interface.py
index 7223655df..d1902c1c2 100644
--- a/albumentations/core/transforms_interface.py
+++ b/albumentations/core/transforms_interface.py
@@ -301,10 +301,14 @@ def apply_with_params(self, params: dict[str, Any], *args: Any, **kwargs: Any) -
         res = {}
         for key, arg in kwargs.items():
             if key in self._key2func and arg is not None:
-                target_function = self._key2func[key]
-                res[key] = ensure_contiguous_output(
-                    target_function(ensure_contiguous_output(arg), **params),
-                )
+                # Handle empty lists for mask-like keys
+                if key in {"masks", "masks3d"} and isinstance(arg, (list, tuple)) and not arg:
+                    res[key] = arg  # Keep empty list as is
+                else:
+                    target_function = self._key2func[key]
+                    res[key] = ensure_contiguous_output(
+                        target_function(ensure_contiguous_output(arg), **params),
+                    )
             else:
                 res[key] = arg
         return res


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 37ca5fff1..2e11f7379 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1945,3 +1945,17 @@ def test_keypoint_hflip_hflip_no_labels(kp_format: str, keypoints: list[list[flo
     transformed = aug(image=image, keypoints=original_keypoints)
 
     assert np.allclose(transformed["keypoints"], original_keypoints, atol=1e-6)
+
+
+def test_compose_with_empty_masks():
+    """Test that Compose can handle empty masks list."""
+    transform = Compose([
+        A.Resize(288, 384),
+        A.ToFloat(max_value=255)
+    ])
+    image = np.zeros((288, 384, 3), dtype=np.uint8)
+    result = transform(image=image, masks=[])
+    # Verify that the result contains an empty masks list
+    assert "masks" in result
+    assert isinstance(result["masks"], (list, tuple))
+    assert len(result["masks"]) == 0