Repository: devrimcavusoglu/pybboxes

Issue:
Feature Request: support multiple boxes or array shape in `from_array()`
Currently `BoundingBox.from_array()` takes only list of length 1. It can be improved s.t multiple boxes (box values) can be given as array, and it can return an array of `BoundingBox` objects.

```python
from pybboxes import CocoBoundingBox

boxes = [[0,0,10,25], [5,3,16,24]]
list_of_bboxes = CocoBoundingBox.from_array(boxes)
# list_of_bboxes = [<CocoBoundingBox object>, <CocoBoundingBox object>]
```

Discussion:
devrimcavusoglu: > @devrimcavusoglu buradaki multiple_yolo_bboxes nasil bir sey? turu formati nasil anlayamadim
> 
> https://github.com/devrimcavusoglu/pybboxes/blob/15b6c4087c3244be273bd323cebbac239cff5f55/tests/pybboxes/boxes/test_yolo_bounding_box.py#L60

https://github.com/devrimcavusoglu/pybboxes/blob/15b6c4087c3244be273bd323cebbac239cff5f55/tests/pybboxes/conftest.py#L118-L122



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 945978b..4927ff4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,8 @@ jobs:
     strategy:
       matrix:
         operating-system: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: [3.7, 3.8, 3.9]
+        # for Python 3.10, ref https://github.com/actions/setup-python/issues/160#issuecomment-724485470
+        python-version: [3.8, 3.9, '3.10', '3.11']
       fail-fast: false
 
     steps:
diff --git a/pybboxes/__init__.py b/pybboxes/__init__.py
index 20748d7..c9d6c74 100644
--- a/pybboxes/__init__.py
+++ b/pybboxes/__init__.py
@@ -8,4 +8,4 @@ from pybboxes.boxes import (
 )
 from pybboxes.functional import convert_bbox  # Backwards compatibility
 
-__version__ = "0.1.6"
+__version__ = "0.2.0"
diff --git a/pybboxes/boxes/base.py b/pybboxes/boxes/base.py
index 93876ee..00079a5 100644
--- a/pybboxes/boxes/base.py
+++ b/pybboxes/boxes/base.py
@@ -199,17 +199,35 @@ class BaseBoundingBox(Box, ABC):
         pass
 
     @classmethod
-    def from_array(cls, ar: Union[Tuple, List, np.ndarray], **kwargs):
+    def from_array_vectorize(cls, ar: np.ndarray):
+        constructor = cls.from_array
+        vconstructor = np.vectorize(constructor)
+        return vconstructor(ar)
+
+    @classmethod
+    def from_array(cls, ar: Union[Tuple, List, np.ndarray], **kwargs) -> Union[np.ndarray, "BaseBoundingBox"]:
         """
-        This method is intended to be "final", and should not be overridden in child classes.
+        Takes input values containing at least a single bbox values. Input can be multidimensional
+        array as long as the last dimension (-1) has length of 4, i.e for any array as input, the shape
+        should look like (x,y,z,4) and the output is of shape (x,y,z).
+
+        Args:
+            ar: Input values as a tuple or array. If the input is an array, the dimension is preserved as is
+                and each bounding box values is converted to the `BoundingBox` object.
+            **kwargs: Additional keyword arguments for construction, see :py:meth:`BoundingBox.__init__`
+
+        Notes:
+            This method is intended to be "final", and should not be overridden in child classes.
+
+        Returns:
+            Either a `BoundingBox` object constructed from input values or list of `BoundingBox` objects
+            as an array.
         """
-        warnings.warn(
-            "The functionality of the `from_array()` method is changed from only supporting a single box values to "
-            "support (arbitrary) n-dimensional array of box values starting from 0.2 onward "
-            "requiring Python3.8 or higher.",
-            FutureWarning,
-            stacklevel=find_stack_level(),
-        )
-        if len(ar) != 4:
-            raise ValueError(f"Given array must be length of 4, got length {len(ar)}.")
-        return cls(*ar, **kwargs)
+        if not isinstance(ar, np.ndarray):
+            ar = np.array(ar)
+        if ar.shape[-1] != 4:
+            raise ValueError(f"Given input array must have bounding box values at dim -1 as 4, got shape {ar.shape}.")
+        if ar.ndim == 1:
+            return cls(*ar, **kwargs)
+        vf = np.vectorize(cls.from_array, signature="(n) -> ()", excluded={"image_size", "strict"})
+        return vf(ar, **kwargs)
diff --git a/requirements.txt b/requirements.txt
index 296d654..6ea33a7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +1,1 @@
-numpy
\ No newline at end of file
+numpy==1.24.2
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 9c240b4..5ef1447 100644
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,7 @@ setuptools.setup(
     long_description_content_type="text/markdown",
     url="https://github.com/devrimcavusoglu/pybboxes",
     packages=setuptools.find_packages(exclude=["tests"]),
-    python_requires=">=3.7",
+    python_requires=">=3.8",
     install_requires=get_requirements(),
     extras_require=extras,
     include_package_data=True,
@@ -60,9 +60,10 @@ setuptools.setup(
         "Intended Audience :: Developers",
         "Intended Audience :: Science/Research",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
         "Topic :: Software Development :: Libraries",
         "Topic :: Software Development :: Libraries :: Python Modules",
         "Topic :: Education",


Test patch:
diff --git a/tests/pybboxes/boxes/test_albumentations_bounding_box.py b/tests/pybboxes/boxes/test_albumentations_bounding_box.py
index f592b35..eacd09d 100644
--- a/tests/pybboxes/boxes/test_albumentations_bounding_box.py
+++ b/tests/pybboxes/boxes/test_albumentations_bounding_box.py
@@ -15,6 +15,11 @@ def albumentations_oob_bounding_box():
     return [0.15625, 0.21875, 0.875, 1.1041666666666667]
 
 
+@pytest.fixture
+def albumentations_multi_array_zeroth():
+    return 0.18727005942368125, 0.4753571532049581, 0.5619230133529087, 0.5352678187964783
+
+
 @pytest.fixture(scope="module")
 def albumentations_bounding_box2(albumentations_bbox, image_size):
     np.random.seed(42)
@@ -58,11 +63,14 @@ def test_area_computations(
     assert_almost_equal(actual=actual_output, desired=albumentations_area_computations_expected_output)
 
 
-def test_from_array(albumentations_bbox, image_size):
-    with pytest.warns(FutureWarning):
-        alb_box = AlbumentationsBoundingBox.from_array(albumentations_bbox, image_size=image_size)
-
-    assert alb_box.is_oob is False
+def test_from_array(
+    multiple_albumentations_bboxes, image_size, expected_multiple_bbox_shape, albumentations_multi_array_zeroth
+):
+    alb_boxes = AlbumentationsBoundingBox.from_array(multiple_albumentations_bboxes, image_size=image_size)
+    assert_almost_equal(actual=alb_boxes.shape, desired=expected_multiple_bbox_shape)
+    assert_almost_equal(
+        alb_boxes.flatten()[0].values, albumentations_multi_array_zeroth, ignore_numeric_type_changes=True
+    )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/pybboxes/boxes/test_coco_bounding_box.py b/tests/pybboxes/boxes/test_coco_bounding_box.py
index 2a2c961..eb55671 100644
--- a/tests/pybboxes/boxes/test_coco_bounding_box.py
+++ b/tests/pybboxes/boxes/test_coco_bounding_box.py
@@ -22,6 +22,11 @@ def coco_bounding_box2(coco_bbox, image_size):
     return BoundingBox.from_coco(*coco_bbox2, image_size=image_size)
 
 
+@pytest.fixture
+def coco_multi_array_zeroth():
+    return 102, 435, 20, 18
+
+
 @pytest.fixture()
 def scaled_coco_box():
     return 145, 362, 228, 83
@@ -51,11 +56,12 @@ def test_area_computations(coco_bounding_box, coco_bounding_box2, coco_area_comp
     assert_almost_equal(actual=actual_output, desired=coco_area_computations_expected_output)
 
 
-def test_from_array(coco_bbox, image_size):
-    with pytest.warns(FutureWarning):
-        coco_box = CocoBoundingBox.from_array(coco_bbox, image_size=image_size)
-
-    assert coco_box.is_oob is False
+def test_from_array(multiple_coco_bboxes, image_size, expected_multiple_bbox_shape, coco_multi_array_zeroth):
+    coco_boxes = CocoBoundingBox.from_array(multiple_coco_bboxes, image_size=image_size)
+    assert_almost_equal(actual=coco_boxes.shape, desired=expected_multiple_bbox_shape)
+    assert_almost_equal(
+        actual=coco_boxes.flatten()[0].values, desired=coco_multi_array_zeroth, ignore_numeric_type_changes=True
+    )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/pybboxes/boxes/test_fiftyone_bounding_box.py b/tests/pybboxes/boxes/test_fiftyone_bounding_box.py
index 863ba55..172aec0 100644
--- a/tests/pybboxes/boxes/test_fiftyone_bounding_box.py
+++ b/tests/pybboxes/boxes/test_fiftyone_bounding_box.py
@@ -15,6 +15,11 @@ def fiftyone_oob_bounding_box():
     return [0.15625, 0.21875, 0.71875, 0.8854166666666666]
 
 
+@pytest.fixture
+def fiftyone_multi_array_zeroth():
+    return 0.29963209507789, 0.760571445127933, 0.024769205341163492, 0.014107127518591313
+
+
 @pytest.fixture(scope="module")
 def fiftyone_bounding_box2(fiftyone_bbox, image_size):
     np.random.seed(42)
@@ -51,11 +56,12 @@ def test_area_computations(fiftyone_bounding_box, fiftyone_bounding_box2, fiftyo
     assert_almost_equal(actual=actual_output, desired=fiftyone_area_computations_expected_output)
 
 
-def test_from_array(fiftyone_bbox, image_size):
-    with pytest.warns(FutureWarning):
-        fo_box = FiftyoneBoundingBox.from_array(fiftyone_bbox, image_size=image_size)
-
-    assert fo_box.is_oob is False
+def test_from_array(multiple_fiftyone_bboxes, image_size, expected_multiple_bbox_shape, fiftyone_multi_array_zeroth):
+    fo_boxes = FiftyoneBoundingBox.from_array(multiple_fiftyone_bboxes, image_size=image_size)
+    assert_almost_equal(actual=fo_boxes.shape, desired=expected_multiple_bbox_shape)
+    assert_almost_equal(
+        actual=fo_boxes.flatten()[0].values, desired=fiftyone_multi_array_zeroth, ignore_numeric_type_changes=True
+    )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/pybboxes/boxes/test_voc_bounding_box.py b/tests/pybboxes/boxes/test_voc_bounding_box.py
index 5e31166..64701f9 100644
--- a/tests/pybboxes/boxes/test_voc_bounding_box.py
+++ b/tests/pybboxes/boxes/test_voc_bounding_box.py
@@ -15,6 +15,11 @@ def voc_oob_bounding_box():
     return [100, 105, 560, 530]
 
 
+@pytest.fixture()
+def voc_multi_array_zeroth():
+    return 102, 179, 433, 457
+
+
 @pytest.fixture(scope="module")
 def voc_bounding_box2(voc_bbox, image_size):
     np.random.seed(42)
@@ -51,11 +56,12 @@ def test_area_computations(voc_bounding_box, voc_bounding_box2, voc_area_computa
     assert_almost_equal(actual=actual_output, desired=voc_area_computations_expected_output)
 
 
-def test_from_array(voc_bbox, image_size):
-    with pytest.warns(FutureWarning):
-        voc_box = VocBoundingBox.from_array(voc_bbox, image_size=image_size)
-
-    assert voc_box.is_oob is False
+def test_from_array(multiple_voc_bboxes, image_size, expected_multiple_bbox_shape, voc_multi_array_zeroth):
+    voc_boxes = VocBoundingBox.from_array(multiple_voc_bboxes, image_size=image_size)
+    assert_almost_equal(actual=voc_boxes.shape, desired=expected_multiple_bbox_shape)
+    assert_almost_equal(
+        actual=voc_boxes.flatten()[0].values, desired=voc_multi_array_zeroth, ignore_numeric_type_changes=True
+    )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/pybboxes/boxes/test_yolo_bounding_box.py b/tests/pybboxes/boxes/test_yolo_bounding_box.py
index 4af9920..3a28c26 100644
--- a/tests/pybboxes/boxes/test_yolo_bounding_box.py
+++ b/tests/pybboxes/boxes/test_yolo_bounding_box.py
@@ -27,6 +27,11 @@ def scaled_yolo_box():
     return 0.4046875, 0.840625, 0.3557630992844818, 0.17235727791422098
 
 
+@pytest.fixture()
+def yolo_multi_array_zeroth():
+    return 0.22472407130841748, 0.5704285838459496, 0.024769205341163492, 0.014107127518591313
+
+
 @pytest.fixture(scope="function")
 def yolo_area_computations_expected_output():
     return {
@@ -51,11 +56,12 @@ def test_area_computations(yolo_bounding_box, yolo_bounding_box2, yolo_area_comp
     assert_almost_equal(actual=actual_output, desired=yolo_area_computations_expected_output)
 
 
-def test_from_array(yolo_bbox, image_size):
-    with pytest.warns(FutureWarning):
-        yolo_box = YoloBoundingBox.from_array(yolo_bbox, image_size=image_size)
-
-    assert yolo_box.is_oob is False
+def test_from_array(multiple_yolo_bboxes, image_size, expected_multiple_bbox_shape, yolo_multi_array_zeroth):
+    yolo_boxes = YoloBoundingBox.from_array(multiple_yolo_bboxes, image_size=image_size)
+    assert_almost_equal(actual=yolo_boxes.shape, desired=expected_multiple_bbox_shape)
+    assert_almost_equal(
+        actual=yolo_boxes.flatten()[0].values, desired=yolo_multi_array_zeroth, ignore_numeric_type_changes=True
+    )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/pybboxes/conftest.py b/tests/pybboxes/conftest.py
index c334aa8..73833ef 100644
--- a/tests/pybboxes/conftest.py
+++ b/tests/pybboxes/conftest.py
@@ -7,12 +7,18 @@ import inspect
 import os
 from typing import Optional
 
+import numpy as np
 import pytest
 
 from tests.pybboxes import EXPECTED_OUTPUTS
 from tests.utils import load_json
 
 
+@pytest.fixture
+def seed():
+    return 42
+
+
 @pytest.fixture(scope="package")
 def image_size():
     return 640, 480
@@ -63,6 +69,59 @@ def yolo_bbox():
     return [0.4046875, 0.840625, 0.503125, 0.24375]
 
 
+@pytest.fixture
+def multiple_bbox_shape():
+    return 8, 3, 100, 2
+
+
+@pytest.fixture
+def expected_multiple_bbox_shape():
+    return 8, 3, 100
+
+
+@pytest.fixture
+def multiple_albumentations_bboxes(multiple_bbox_shape, seed):
+    np.random.seed(seed)
+    a = np.random.uniform(0, 0.5, size=multiple_bbox_shape)
+    b = np.random.uniform(0.5, 1, size=multiple_bbox_shape)
+    return np.concatenate([a, b], -1)
+
+
+@pytest.fixture
+def multiple_coco_bboxes(multiple_bbox_shape, image_size, seed):
+    np.random.seed(seed)
+    w, h = image_size
+    a = np.random.randint(0, min(w, h) - 30, size=multiple_bbox_shape)
+    b = np.random.randint(1, 30, size=multiple_bbox_shape)
+    return np.concatenate([a, b], -1)
+
+
+@pytest.fixture
+def multiple_fiftyone_bboxes(multiple_bbox_shape, seed):
+    np.random.seed(seed)
+    a = np.random.uniform(0, 0.8, size=multiple_bbox_shape)
+    b = np.random.uniform(0, 0.2, size=multiple_bbox_shape)
+    return np.concatenate([a, b], -1)
+
+
+@pytest.fixture
+def multiple_voc_bboxes(multiple_bbox_shape, image_size, seed):
+    np.random.seed(seed)
+    w, h = image_size
+    cut = min(w, h) // 2
+    a = np.random.randint(0, cut, size=multiple_bbox_shape)
+    b = np.random.randint(cut, min(w, h), size=multiple_bbox_shape)
+    return np.concatenate([a, b], -1)
+
+
+@pytest.fixture
+def multiple_yolo_bboxes(multiple_bbox_shape, seed):
+    np.random.seed(seed)
+    a = np.random.uniform(0, 0.6, size=multiple_bbox_shape)
+    b = np.random.uniform(0, 0.2, size=multiple_bbox_shape)
+    return np.concatenate([a, b], -1)
+
+
 def get_expected_output(prefix: Optional[str] = None):
     def wrapper(fn, *args, **kwargs):
         module_name = os.path.basename(inspect.getfile(fn)).replace(".py", "")