diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index 7ec07860..442c6eb9 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -1467,12 +1467,51 @@ class LabelAnnotator(_BaseLabelAnnotator): color: tuple[int, int, int], border_radius: int, ) -> npt.NDArray[np.uint8]: + """Draw a filled rectangle with optional rounded corners on an image. + + Args: + scene: BGR image array to draw on; modified in-place and returned. + xyxy: Bounding box as (x1, y1, x2, y2) pixel coordinates. + color: Fill color as a BGR tuple (e.g. ``(0, 0, 255)`` for red). + border_radius: Corner rounding radius in pixels. Values <= 0 + (including values clamped to 0 by a degenerate box) draw a + plain filled rectangle with square corners. + + Returns: + The annotated ``scene`` array. + + Example: + ```python + import numpy as np + import supervision as sv + + scene = np.zeros((200, 200, 3), dtype=np.uint8) + scene = sv.LabelAnnotator.draw_rounded_rectangle( + scene=scene, + xyxy=(10, 10, 100, 50), + color=(0, 255, 0), + border_radius=0, + ) + ``` + """ x1, y1, x2, y2 = xyxy width = x2 - x1 height = y2 - y1 border_radius = min(border_radius, min(width, height) // 2) + if border_radius <= 0: + # square corners: a single fill rectangle (the common default), rather + # than two rectangles plus four zero-radius corner circles + cv2.rectangle( + img=scene, + pt1=(x1, y1), + pt2=(x2, y2), + color=color, + thickness=-1, + ) + return scene + rectangle_coordinates = [ ((x1 + border_radius, y1), (x2 - border_radius, y2)), ((x1, y1 + border_radius), (x2, y2 - border_radius)), diff --git a/src/supervision/draw/utils.py b/src/supervision/draw/utils.py index 057a39af..ce30659d 100644 --- a/src/supervision/draw/utils.py +++ b/src/supervision/draw/utils.py @@ -122,15 +122,43 @@ def draw_rounded_rectangle( scene: The image on which the rounded rectangle will be drawn. rect: The rectangle to be drawn. color: The color of the rounded rectangle. - border_radius: The radius of the corner rounding. + border_radius: The radius of the corner rounding in pixels. Values <= 0 + (or values clamped to 0 when the rectangle is too small) draw a + plain filled rectangle with square corners. Note: previously, + a negative value that remained negative after clamping would raise + ``cv2.error``; it now draws square corners silently. Returns: The image with the rounded rectangle drawn on it. + + Example: + ```python + import numpy as np + from supervision.draw.utils import draw_rounded_rectangle + from supervision.draw.color import Color + from supervision.geometry.core import Rect + + scene = np.zeros((200, 300, 3), dtype=np.uint8) + rect = Rect(x=20, y=30, width=120, height=80) + scene = draw_rounded_rectangle(scene, rect, Color.RED, border_radius=0) + ``` """ x1, y1, x2, y2 = rect.as_xyxy_int_tuple() width, height = x2 - x1, y2 - y1 border_radius = min(border_radius, min(width, height) // 2) + if border_radius <= 0: + # square corners: a single fill rectangle (the common default), rather + # than two rectangles plus four zero-radius corner circles + cv2.rectangle( + img=scene, + pt1=(x1, y1), + pt2=(x2, y2), + color=color.as_bgr(), + thickness=-1, + ) + return scene + rectangle_coordinates = [ ((x1 + border_radius, y1), (x2 - border_radius, y2)), ((x1, y1 + border_radius), (x2, y2 - border_radius)), diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 005f8166..55acf3fa 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -665,6 +665,53 @@ class TestDotAnnotator: class TestLabelAnnotator: """Tests for LabelAnnotator class""" + @pytest.mark.parametrize( + "border_radius", + [ + pytest.param(0, id="radius-zero"), + pytest.param(-3, id="radius-negative"), + ], + ) + def test_draw_rounded_rectangle_square_matches_plain_rectangle( + self, border_radius: int + ) -> None: + """Non-positive radius fills the same pixels as a plain rectangle. + + For border_radius < 0: previously raised cv2.error: radius >= 0 in + function 'circle'; fast path now silently draws square corners instead. + """ + scene = np.full((100, 120, 3), 9, dtype=np.uint8) + + result = LabelAnnotator.draw_rounded_rectangle( + scene=scene.copy(), + xyxy=(10, 20, 90, 70), + color=(0, 0, 255), + border_radius=border_radius, + ) + + expected = scene.copy() + expected[20:71, 10:91] = (0, 0, 255) + assert np.array_equal(result, expected) + + def test_draw_rounded_rectangle_clamped_to_zero_acts_as_square(self) -> None: + """Positive border_radius clamped to 0 by a degenerate box draws square corners. + + 1px-wide box: min(10, 1 // 2) = min(10, 0) = 0 → fast path fires even + though the caller passed a positive radius. + """ + scene = np.full((100, 120, 3), 9, dtype=np.uint8) + + result = LabelAnnotator.draw_rounded_rectangle( + scene=scene.copy(), + xyxy=(10, 20, 11, 70), + color=(0, 0, 255), + border_radius=10, + ) + + expected = scene.copy() + expected[20:71, 10:12] = (0, 0, 255) + assert np.array_equal(result, expected) + def test_annotate_with_no_detections(self, test_image): """Test that annotate method returns unmodified image when no detections""" detections = Detections.empty() diff --git a/tests/draw/test_utils.py b/tests/draw/test_utils.py index c7733185..fb58909f 100644 --- a/tests/draw/test_utils.py +++ b/tests/draw/test_utils.py @@ -2,7 +2,8 @@ import cv2 import numpy as np import pytest -from supervision.draw.utils import draw_image +from supervision.draw.color import Color +from supervision.draw.utils import draw_image, draw_rounded_rectangle from supervision.geometry.core import Rect @@ -72,3 +73,65 @@ def test_draw_image_grayscale_array_raises_value_error() -> None: opacity=1.0, rect=rect, ) + + +@pytest.mark.parametrize( + "border_radius", + [ + pytest.param(0, id="radius-zero"), + pytest.param(-5, id="radius-negative"), + ], +) +def test_draw_rounded_rectangle_square_matches_plain_rectangle( + border_radius: int, +) -> None: + """Non-positive border_radius fills exactly the same pixels as a plain box. + + For border_radius < 0: previously raised cv2.error: radius >= 0 in + function 'circle'; fast path now silently draws square corners instead. + """ + rect = Rect(x=20, y=30, width=120, height=80) + scene = np.full((150, 200, 3), 17, dtype=np.uint8) + + result = draw_rounded_rectangle(scene.copy(), rect, Color.RED, border_radius) + + expected = scene.copy() + expected[30:111, 20:141] = Color.RED.as_bgr() + assert np.array_equal(result, expected) + + +def test_draw_rounded_rectangle_clamped_to_zero_acts_as_square() -> None: + """A positive border_radius clamped to 0 by a degenerate box draws square corners. + + 1px-wide box: min(10, 1 // 2) = min(10, 0) = 0 → fast path fires even + though the caller passed a positive radius. + """ + rect = Rect(x=10, y=10, width=1, height=20) + scene = np.full((50, 50, 3), 17, dtype=np.uint8) + + result = draw_rounded_rectangle(scene.copy(), rect, Color.RED, border_radius=10) + + expected = scene.copy() + expected[10:31, 10:12] = Color.RED.as_bgr() + assert np.array_equal(result, expected) + + +def test_draw_rounded_rectangle_positive_radius_rounds_corners() -> None: + """A positive border radius leaves the extreme corners unpainted.""" + rect = Rect(x=20, y=30, width=120, height=80) + scene = np.zeros((150, 200, 3), dtype=np.uint8) + + result = draw_rounded_rectangle(scene.copy(), rect, Color.RED, border_radius=15) + + red = np.array(Color.RED.as_bgr(), dtype=np.uint8) + bg = np.zeros(3, dtype=np.uint8) + + # center row is fully filled between the inner rectangle bounds + center_y = (30 + 110) // 2 # 70; 40px from each y edge, well past border_radius=15 + assert np.all(result[center_y, 35:126] == red) + + # all four extreme corners stay background (clipped by border_radius=15) + assert np.array_equal(result[30, 20], bg) # top-left + assert np.array_equal(result[30, 140], bg) # top-right + assert np.array_equal(result[110, 20], bg) # bottom-left + assert np.array_equal(result[110, 140], bg) # bottom-right