Repository: albumentations-team/albumentations

Issue:
`A.Compose`, `A.Sequential` components run multiple times inside of `A.OneOf`
## 🐛 Bug

Hi, I used `A.OneOf` method on my `A.Compose` objects,
but when I run entire pipeline, all components of `A.Compose` objects are called altogether.


## To Reproduce

Steps to reproduce the behavior:


1. define augmentation

```python3
class Augmentation():

    def __init__(self, 
                 dataset_w:int=None, 
                 dataset_h:int=None, 
                 final_return_w:int=None, 
                 final_return_h:int=None):
        
        self.train_augmentation = self.set_train_augmentation(
            dataset_w=dataset_w,
            dataset_h=dataset_h,
            final_return_w=final_return_w,
            final_return_h=final_return_h,
        )

    def get_train_augmentation(self):
        return lambda image, mask: list(self.train_augmentation(image=image, mask=mask).values())
    
    def set_train_augmentation(self, **kwargs):
        ret = self.augmentation_method(**kwargs)
        return ret

    def _slightly_smaller(self,
        target_h:int,
        target_w:int, 
        original_h:int,
        original_w:int,
        ratio:float=0.5
    )->tuple:
        assert 0. < ratio and ratio < 1, f'0 < ratio {repr(ratio)} < 1'
        dw = int((original_w - target_w) * ratio)
        dh = int((original_h - target_h) * ratio)
        return (target_h + dh, target_w + dw)

    def _get_crop(self,
        target_h:int,
        target_w:int,
    )->list:
        tf = []
        tf += [A.RandomCrop(target_h, target_w, p=1.0)]
        return tf

    def _get_resize(self,
        target_h:int,
        target_w:int
    )->list:
        tf = []
        tf += [A.Resize(target_h, target_w, p=1.0)]
        return tf

    def _get_pad(self,
        target_h:int,
        target_w:int
    )->list:
        longer_side = target_h if target_h > target_w else target_w
        tf = []
        tf += [A.PadIfNeeded(
            min_height=longer_side,
            min_width=longer_side,
            p=1.0,
            border_mode=4)]
        return tf

    def _get_resize_crop(self,
        target_h:int,
        target_w:int,
        original_h:int,
        original_w:int,
    )->list:
        tf = []
        tf += self._get_resize(*self._slightly_smaller(
            target_h, target_w, 
            original_h, original_w)
        )
        tf += self._get_crop(target_h, target_w)
        return tf

    def _get_crop_resize(self,
        target_h:int,
        target_w:int,
        original_h:int,
        original_w:int,
    )->list:
        tf = []
        tf += self._get_crop(*self._slightly_smaller(
            target_h, target_w, 
            original_h, original_w)
        )
        tf += self._get_resize(target_h, target_w)
        return tf

    def _get_pad_resize(self,
        target_h:int,
        target_w:int,
        original_h:int,
        original_w:int,
    )->list:
        tf = []
        tf += self._get_pad(original_h, original_w)
        tf += self._get_reszie(target_h, target_w)
        return tf

    def _get_pad_crop(self,
        target_h:int,
        target_w:int,
        original_h:int,
        original_w:int,
    )->list:
        tf = []
        tf += self._get_pad(original_h, original_w)
        tf += self._get_crop(target_h, target_w)
        return tf

    def _get_pad_resize_crop(self,
        target_h:int,
        target_w:int,
        original_h:int,
        original_w:int,
    )->list:
        tf = []
        tf += self._get_pad(original_h, original_w)
        tf += self._get_resize(*self._slightly_smaller(
            target_h, target_w, 
            original_h, original_w)
        )
        tf += self._get_crop(target_h, target_w)
        return tf

    def _get_pad_crop_resize(self,
        target_h:int,
        target_w:int,
        original_h:int,
        original_w:int,
    )->list:
        tf = []
        tf += self._get_pad(original_h, original_w)
        tf += self._get_crop(*self._slightly_smaller(
            target_h, target_w, 
            original_h, original_w)
        )
        tf += self._get_resize(target_h, target_w)
        return tf

    def augmentation_method(self,
        dataset_w=None, 
        dataset_h=None,
        final_return_w=None,
        final_return_h=None,
        ):
        kwargs = {'target_h':final_return_h,
                  'target_w':final_return_w,
                  'original_h':dataset_h,
                  'original_w':dataset_w}

        print(self._get_resize_crop(**kwargs))
        print(self._get_crop_resize(**kwargs))
        print(self._get_pad_crop_resize(**kwargs))
        print(self._get_pad_resize_crop(**kwargs))
        tf = A.OneOf([
                 #A.Compose(self._get_resize_crop(**kwargs), p=1.0), # <-- this line makes error either.
                 A.Compose(self._get_crop_resize(**kwargs), p=1.0),
                 A.Compose(self._get_pad_crop_resize(**kwargs), p=1.0),
                 A.Compose(self._get_pad_resize_crop(**kwargs), p=1.0),
                ], p=1.0)
        return A.Compose(tf, p=1.0)
```


2. new instance

```python3
aug = Augmentation(DATASET_WIDTH, 
                   DATASET_HEIGHT,
                   DATASET_TRAINING_TARGET_W,
                   DATASET_TRAINING_TARGET_H)    
aug_fn_for_train = aug.get_train_augmentation()
```


3. run pipeline

```python3
image_train_debug, mask_train_debug = aug_fn_for_train(image, mask)
```

## Expected behavior

Doing augmentation from randomly selected one of the composed pipeline.

```python3
tf = A.OneOf([
         A.Compose(self._get_resize_crop(**kwargs), p=1.0), # 25%
         A.Compose(self._get_crop_resize(**kwargs), p=1.0), # 25%
         A.Compose(self._get_pad_crop_resize(**kwargs), p=1.0), # 25%
         A.Compose(self._get_pad_resize_crop(**kwargs), p=1.0), # 25%
        ], p=1.0)
```



## Environment

 - Albumentations version (e.g., 0.1.8): 0.1.12
 - Python version (e.g., 3.7): python3.8
 - OS (e.g., Linux): Google COLAB COLAB
 - How you installed albumentations (`conda`, `pip`, source): colab default installed
 - Any other relevant information:

## Additional context

![image](https://user-images.githubusercontent.com/46595649/140026181-b83675c0-e0aa-473f-9a7f-b75440508adb.png)

<!-- Add any other context about the problem here. -->


Discussion:

Patch:
diff --git a/albumentations/core/composition.py b/albumentations/core/composition.py
index 7a78e43..d9f53c4 100644
--- a/albumentations/core/composition.py
+++ b/albumentations/core/composition.py
@@ -48,6 +48,12 @@ def get_always_apply(transforms: typing.Union["BaseCompose", TransformsSeqType])
 
 class BaseCompose(metaclass=SerializableMeta):
     def __init__(self, transforms: TransformsSeqType, p: float):
+        if isinstance(transforms, (BaseCompose, BasicTransform)):
+            warnings.warn(
+                "transforms is single transform, but a sequence is expected! Transform will be wrapped into list."
+            )
+            transforms = [transforms]
+
         self.transforms = transforms
         self.p = p
 
@@ -276,7 +282,7 @@ class OneOf(BaseCompose):
 
     def __init__(self, transforms: TransformsSeqType, p: float = 0.5):
         super(OneOf, self).__init__(transforms, p)
-        transforms_ps = [t.p for t in transforms]
+        transforms_ps = [t.p for t in self.transforms]
         s = sum(transforms_ps)
         self.transforms_ps = [t / s for t in transforms_ps]
 
@@ -308,7 +314,7 @@ class SomeOf(BaseCompose):
         super(SomeOf, self).__init__(transforms, p)
         self.n = n
         self.replace = replace
-        transforms_ps = [t.p for t in transforms]
+        transforms_ps = [t.p for t in self.transforms]
         s = sum(transforms_ps)
         self.transforms_ps = [t / s for t in transforms_ps]
 
@@ -347,9 +353,9 @@ class OneOrOther(BaseCompose):
             if first is None or second is None:
                 raise ValueError("You must set both first and second or set transforms argument.")
             transforms = [first, second]
-        elif len(transforms) != 2:
-            warnings.warn("Length of transforms is not equal to 2.")
         super(OneOrOther, self).__init__(transforms, p)
+        if len(self.transforms) != 2:
+            warnings.warn("Length of transforms is not equal to 2.")
 
     def __call__(self, *args, force_apply: bool = False, **data) -> typing.Dict[str, typing.Any]:
         if self.replay_mode:


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 18e677b..5d69d29 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,26 +1,43 @@
 from __future__ import absolute_import
 
+import typing
 from unittest import mock
-from unittest.mock import Mock, MagicMock, call
+from unittest.mock import MagicMock, Mock, call
 
 import cv2
 import numpy as np
 import pytest
 
-from albumentations.core.transforms_interface import to_tuple, ImageOnlyTransform, DualTransform
+from albumentations import (
+    BasicTransform,
+    Blur,
+    Crop,
+    HorizontalFlip,
+    MedianBlur,
+    Normalize,
+    PadIfNeeded,
+    Resize,
+    Rotate,
+)
 from albumentations.augmentations.bbox_utils import check_bboxes
 from albumentations.core.composition import (
-    OneOrOther,
+    BaseCompose,
+    BboxParams,
     Compose,
+    KeypointParams,
     OneOf,
+    OneOrOther,
     SomeOf,
     PerChannel,
     ReplayCompose,
-    KeypointParams,
-    BboxParams,
     Sequential,
 )
-from albumentations import HorizontalFlip, Rotate, Blur, MedianBlur, PadIfNeeded, Crop
+from albumentations.core.transforms_interface import (
+    DualTransform,
+    ImageOnlyTransform,
+    to_tuple,
+)
+from .utils import get_filtered_transforms
 
 
 def test_one_or_other():
@@ -332,3 +349,24 @@ def test_bbox_params_is_not_set(image, bboxes):
     with pytest.raises(ValueError) as exc_info:
         t(image=image, bboxes=bboxes)
     assert str(exc_info.value) == "bbox_params must be specified for bbox transformations"
+
+
+@pytest.mark.parametrize(
+    "compose_transform", get_filtered_transforms((BaseCompose,), custom_arguments={SomeOf: {"n": 1}})
+)
+@pytest.mark.parametrize(
+    "inner_transform",
+    [(Normalize, {}), (Resize, {"height": 100, "width": 100})]
+    + get_filtered_transforms((BaseCompose,), custom_arguments={SomeOf: {"n": 1}}),  # type: ignore
+)
+def test_single_transform_compose(
+    compose_transform: typing.Tuple[typing.Type[BaseCompose], dict],
+    inner_transform: typing.Tuple[typing.Union[typing.Type[BaseCompose], typing.Type[BasicTransform]], dict],
+):
+    compose_cls, compose_kwargs = compose_transform
+    cls, kwargs = inner_transform
+    transform = cls(transforms=[], **kwargs) if issubclass(cls, BaseCompose) else cls(**kwargs)
+
+    with pytest.warns(UserWarning):
+        res_transform = compose_cls(transforms=transform, **compose_kwargs)  # type: ignore
+    assert isinstance(res_transform.transforms, list)
diff --git a/tests/utils.py b/tests/utils.py
index b5dea11..13d1fb8 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,6 +1,6 @@
+import inspect
 import random
 import typing
-import inspect
 import numpy as np
 
 from io import StringIO
@@ -73,7 +73,9 @@ def get_filtered_transforms(
     result = []
 
     for name, cls in inspect.getmembers(albumentations):
-        if not inspect.isclass(cls) or not issubclass(cls, albumentations.BasicTransform):
+        if not inspect.isclass(cls) or not issubclass(
+            cls, (albumentations.BasicTransform, albumentations.BaseCompose)
+        ):
             continue
 
         if "DeprecationWarning" in inspect.getsource(cls) or "FutureWarning" in inspect.getsource(cls):