From 32dca290ef2f3f11aac832e061f8cf1c64cc48aa Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Nov 2025 13:00:11 +0100 Subject: [PATCH 1/5] `xyxy_to_mask` implementation + tests --- supervision/__init__.py | 2 + supervision/detection/utils/converters.py | 30 ++++ test/detection/utils/test_converters.py | 181 ++++++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/supervision/__init__.py b/supervision/__init__.py index 04d3fb254..17ed8c26d 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -61,6 +61,7 @@ from supervision.detection.utils.converters import ( mask_to_polygons, mask_to_xyxy, + xyxy_to_mask, polygon_to_mask, polygon_to_xyxy, xcycwh_to_xyxy, @@ -252,4 +253,5 @@ "xyxy_to_polygons", "xyxy_to_xcycarh", "xyxy_to_xywh", + "xyxy_to_mask" ] diff --git a/supervision/detection/utils/converters.py b/supervision/detection/utils/converters.py index 9e02783a0..b167828d4 100644 --- a/supervision/detection/utils/converters.py +++ b/supervision/detection/utils/converters.py @@ -229,6 +229,36 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray: return xyxy +def xyxy_to_mask(boxes: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarray: + """ + Converts a 2D `np.ndarray` of bounding boxes into a 3D `np.ndarray` of bool masks. + + Parameters: + boxes (np.ndarray): A 2D `np.ndarray` of shape `(N, 4)` + containing bounding boxes `(x_min, y_min, x_max, y_max)` + resolution_wh (Tuple[int, int]): A tuple `(width, height)` specifying + the resolution of the output masks + + Returns: + np.ndarray: A 3D `np.ndarray` of shape `(N, height, width)` + containing 2D bool masks for each bounding box + """ + width, height = resolution_wh + n = boxes.shape[0] + masks = np.zeros((n, height, width), dtype=bool) + + for i, (x_min, y_min, x_max, y_max) in enumerate(boxes): + x_min = max(0, int(x_min)) + y_min = max(0, int(y_min)) + x_max = min(width - 1, int(x_max)) + y_max = min(height - 1, int(y_max)) + + if x_max >= x_min and y_max >= y_min: + masks[i, y_min:y_max + 1, x_min:x_max + 1] = True + + return masks + + def mask_to_polygons(mask: np.ndarray) -> list[np.ndarray]: """ Converts a binary mask to a list of polygons. diff --git a/test/detection/utils/test_converters.py b/test/detection/utils/test_converters.py index e13b15004..4dae89935 100644 --- a/test/detection/utils/test_converters.py +++ b/test/detection/utils/test_converters.py @@ -8,6 +8,7 @@ xywh_to_xyxy, xyxy_to_xcycarh, xyxy_to_xywh, + xyxy_to_mask ) @@ -129,3 +130,183 @@ def test_xyxy_to_xcycarh(xyxy: np.ndarray, expected_result: np.ndarray) -> None: def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None: result = xcycwh_to_xyxy(xcycwh) np.testing.assert_array_equal(result, expected_result) + + +@pytest.mark.parametrize( + "boxes,resolution_wh,expected", + [ + # 0) Empty input + ( + np.array([], dtype=float).reshape(0, 4), + (5, 4), + np.array([], dtype=bool).reshape(0, 4, 5), + ), + + # 1) Single pixel box + ( + np.array([[2, 1, 2, 1]], dtype=float), + (5, 4), + np.array( + [ + [ + [False, False, False, False, False], + [False, False, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 2) Horizontal line, inclusive bounds + ( + np.array([[1, 2, 3, 2]], dtype=float), + (5, 4), + np.array( + [ + [ + [False, False, False, False, False], + [False, False, False, False, False], + [False, True, True, True, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 3) Vertical line, inclusive bounds + ( + np.array([[3, 0, 3, 2]], dtype=float), + (5, 4), + np.array( + [ + [ + [False, False, False, True, False], + [False, False, False, True, False], + [False, False, False, True, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 4) Proper rectangle fill + ( + np.array([[1, 1, 3, 2]], dtype=float), + (5, 4), + np.array( + [ + [ + [False, False, False, False, False], + [False, True, True, True, False], + [False, True, True, True, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 5) Negative coordinates clipped to [0, 0] + ( + np.array([[-2, -1, 1, 1]], dtype=float), + (5, 4), + np.array( + [ + [ + [ True, True, False, False, False], + [ True, True, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 6) Overflow coordinates clipped to width-1 and height-1 + ( + np.array([[3, 2, 10, 10]], dtype=float), + (5, 4), + np.array( + [ + [ + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, True, True], + [False, False, False, True, True], + ] + ], + dtype=bool, + ), + ), + + # 7) Invalid box where max < min after ints, mask stays empty + ( + np.array([[3, 2, 1, 4]], dtype=float), + (5, 4), + np.array( + [ + [ + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 8) Fractional coordinates are floored by int conversion + # (0.2,0.2)-(2.8,1.9) -> (0,0)-(2,1) + ( + np.array([[0.2, 0.2, 2.8, 1.9]], dtype=float), + (5, 4), + np.array( + [ + [ + [ True, True, True, False, False], + [ True, True, True, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ] + ], + dtype=bool, + ), + ), + + # 9) Multiple boxes, separate masks + ( + np.array([[0, 0, 1, 0], [2, 1, 4, 3]], dtype=float), + (5, 4), + np.array( + [ + # Box 0: row 0, cols 0..1 + [ + [ True, True, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + [False, False, False, False, False], + ], + # Box 1: rows 1..3, cols 2..4 + [ + [False, False, False, False, False], + [False, False, True, True, True], + [False, False, True, True, True], + [False, False, True, True, True], + ], + ], + dtype=bool, + ), + ), + ], +) +def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) -> None: + result = xyxy_to_mask(boxes, resolution_wh) + assert result.dtype == np.bool_ + assert result.shape == expected.shape + np.testing.assert_array_equal(result, expected) \ No newline at end of file From a3ea8cdb1f787bb5423e3827dfeeed50a5fdae20 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Nov 2025 13:01:30 +0100 Subject: [PATCH 2/5] bump version from `0.27.0rc1` to `0.27.0rc2` --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9bf3b24aa..a7910db36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "supervision" description = "A set of easy-to-use utils that will come in handy in any Computer Vision project" license = { text = "MIT" } -version = "0.27.0rc1" +version = "0.27.0rc2" readme = "README.md" requires-python = ">=3.9" authors = [ From 3e7e4caf3f645a2cf3b064ef970f5251ca8b5db5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:03:50 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supervision/__init__.py | 4 +- supervision/detection/utils/converters.py | 2 +- test/detection/utils/test_converters.py | 47 +++++++++-------------- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/supervision/__init__.py b/supervision/__init__.py index 17ed8c26d..a70dd20fe 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -61,11 +61,11 @@ from supervision.detection.utils.converters import ( mask_to_polygons, mask_to_xyxy, - xyxy_to_mask, polygon_to_mask, polygon_to_xyxy, xcycwh_to_xyxy, xywh_to_xyxy, + xyxy_to_mask, xyxy_to_polygons, xyxy_to_xcycarh, xyxy_to_xywh, @@ -250,8 +250,8 @@ "tint_image", "xcycwh_to_xyxy", "xywh_to_xyxy", + "xyxy_to_mask", "xyxy_to_polygons", "xyxy_to_xcycarh", "xyxy_to_xywh", - "xyxy_to_mask" ] diff --git a/supervision/detection/utils/converters.py b/supervision/detection/utils/converters.py index b167828d4..7c39ed1ef 100644 --- a/supervision/detection/utils/converters.py +++ b/supervision/detection/utils/converters.py @@ -254,7 +254,7 @@ def xyxy_to_mask(boxes: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarra y_max = min(height - 1, int(y_max)) if x_max >= x_min and y_max >= y_min: - masks[i, y_min:y_max + 1, x_min:x_max + 1] = True + masks[i, y_min : y_max + 1, x_min : x_max + 1] = True return masks diff --git a/test/detection/utils/test_converters.py b/test/detection/utils/test_converters.py index 4dae89935..52a3b5200 100644 --- a/test/detection/utils/test_converters.py +++ b/test/detection/utils/test_converters.py @@ -6,9 +6,9 @@ from supervision.detection.utils.converters import ( xcycwh_to_xyxy, xywh_to_xyxy, + xyxy_to_mask, xyxy_to_xcycarh, xyxy_to_xywh, - xyxy_to_mask ) @@ -141,7 +141,6 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None (5, 4), np.array([], dtype=bool).reshape(0, 4, 5), ), - # 1) Single pixel box ( np.array([[2, 1, 2, 1]], dtype=float), @@ -150,7 +149,7 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None [ [ [False, False, False, False, False], - [False, False, True, False, False], + [False, False, True, False, False], [False, False, False, False, False], [False, False, False, False, False], ] @@ -158,7 +157,6 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None dtype=bool, ), ), - # 2) Horizontal line, inclusive bounds ( np.array([[1, 2, 3, 2]], dtype=float), @@ -168,14 +166,13 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None [ [False, False, False, False, False], [False, False, False, False, False], - [False, True, True, True, False], + [False, True, True, True, False], [False, False, False, False, False], ] ], dtype=bool, ), ), - # 3) Vertical line, inclusive bounds ( np.array([[3, 0, 3, 2]], dtype=float), @@ -183,16 +180,15 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None np.array( [ [ - [False, False, False, True, False], - [False, False, False, True, False], - [False, False, False, True, False], + [False, False, False, True, False], + [False, False, False, True, False], + [False, False, False, True, False], [False, False, False, False, False], ] ], dtype=bool, ), ), - # 4) Proper rectangle fill ( np.array([[1, 1, 3, 2]], dtype=float), @@ -201,15 +197,14 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None [ [ [False, False, False, False, False], - [False, True, True, True, False], - [False, True, True, True, False], + [False, True, True, True, False], + [False, True, True, True, False], [False, False, False, False, False], ] ], dtype=bool, ), ), - # 5) Negative coordinates clipped to [0, 0] ( np.array([[-2, -1, 1, 1]], dtype=float), @@ -217,8 +212,8 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None np.array( [ [ - [ True, True, False, False, False], - [ True, True, False, False, False], + [True, True, False, False, False], + [True, True, False, False, False], [False, False, False, False, False], [False, False, False, False, False], ] @@ -226,7 +221,6 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None dtype=bool, ), ), - # 6) Overflow coordinates clipped to width-1 and height-1 ( np.array([[3, 2, 10, 10]], dtype=float), @@ -236,14 +230,13 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None [ [False, False, False, False, False], [False, False, False, False, False], - [False, False, False, True, True], - [False, False, False, True, True], + [False, False, False, True, True], + [False, False, False, True, True], ] ], dtype=bool, ), ), - # 7) Invalid box where max < min after ints, mask stays empty ( np.array([[3, 2, 1, 4]], dtype=float), @@ -260,7 +253,6 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None dtype=bool, ), ), - # 8) Fractional coordinates are floored by int conversion # (0.2,0.2)-(2.8,1.9) -> (0,0)-(2,1) ( @@ -269,8 +261,8 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None np.array( [ [ - [ True, True, True, False, False], - [ True, True, True, False, False], + [True, True, True, False, False], + [True, True, True, False, False], [False, False, False, False, False], [False, False, False, False, False], ] @@ -278,7 +270,6 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None dtype=bool, ), ), - # 9) Multiple boxes, separate masks ( np.array([[0, 0, 1, 0], [2, 1, 4, 3]], dtype=float), @@ -287,7 +278,7 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None [ # Box 0: row 0, cols 0..1 [ - [ True, True, False, False, False], + [True, True, False, False, False], [False, False, False, False, False], [False, False, False, False, False], [False, False, False, False, False], @@ -295,9 +286,9 @@ def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None # Box 1: rows 1..3, cols 2..4 [ [False, False, False, False, False], - [False, False, True, True, True], - [False, False, True, True, True], - [False, False, True, True, True], + [False, False, True, True, True], + [False, False, True, True, True], + [False, False, True, True, True], ], ], dtype=bool, @@ -309,4 +300,4 @@ def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) -> result = xyxy_to_mask(boxes, resolution_wh) assert result.dtype == np.bool_ assert result.shape == expected.shape - np.testing.assert_array_equal(result, expected) \ No newline at end of file + np.testing.assert_array_equal(result, expected) From 1c8398942a31ed34cc8ad6022d69f4525d615b9e Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Wed, 5 Nov 2025 13:12:49 +0100 Subject: [PATCH 4/5] docs and examples --- docs/detection/utils/converters.md | 6 ++++ supervision/detection/utils/converters.py | 34 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/detection/utils/converters.md b/docs/detection/utils/converters.md index 48bec65fe..3978f108d 100644 --- a/docs/detection/utils/converters.md +++ b/docs/detection/utils/converters.md @@ -58,3 +58,9 @@ status: new :::supervision.detection.utils.converters.polygon_to_xyxy + + + +:::supervision.detection.utils.converters.xyxy_to_mask \ No newline at end of file diff --git a/supervision/detection/utils/converters.py b/supervision/detection/utils/converters.py index 7c39ed1ef..4aef2dc87 100644 --- a/supervision/detection/utils/converters.py +++ b/supervision/detection/utils/converters.py @@ -242,6 +242,40 @@ def xyxy_to_mask(boxes: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarra Returns: np.ndarray: A 3D `np.ndarray` of shape `(N, height, width)` containing 2D bool masks for each bounding box + + Examples: + ```python + import numpy as np + import supervision as sv + + boxes = np.array([[0, 0, 2, 2]]) + + sv.xyxy_to_mask(boxes, (5, 5)) + # array([ + # [[ True, True, True, False, False], + # [ True, True, True, False, False], + # [ True, True, True, False, False], + # [False, False, False, False, False], + # [False, False, False, False, False]] + # ]) + + boxes = np.array([[0, 0, 1, 1], [3, 3, 4, 4]]) + + sv.xyxy_to_mask(boxes, (5, 5)) + # array([ + # [[ True, True, False, False, False], + # [ True, True, False, False, False], + # [False, False, False, False, False], + # [False, False, False, False, False], + # [False, False, False, False, False]], + # + # [[False, False, False, False, False], + # [False, False, False, False, False], + # [False, False, False, False, False], + # [False, False, False, True, True], + # [False, False, False, True, True]] + # ]) + ``` """ width, height = resolution_wh n = boxes.shape[0] From 89ee57f5199da87980944eb43173a4f80b0d3cb0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:14:39 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/detection/utils/converters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/detection/utils/converters.md b/docs/detection/utils/converters.md index 3978f108d..b6b1e2af6 100644 --- a/docs/detection/utils/converters.md +++ b/docs/detection/utils/converters.md @@ -63,4 +63,4 @@ status: new

xyxy_to_mask

-:::supervision.detection.utils.converters.xyxy_to_mask \ No newline at end of file +:::supervision.detection.utils.converters.xyxy_to_mask