perf(annotators): skip corner circles when drawing square label backgrounds (#2346)
Both rounded-rectangle helpers (LabelAnnotator.draw_rounded_rectangle and the public draw_rounded_rectangle in draw/utils) always drew two rectangles plus four corner circles, even when border_radius is 0, which is the default for LabelAnnotator and VertexLabelAnnotator. With a zero radius that is six cv2 calls per label per frame (the four circles are zero-radius no-ops) where one fill rectangle does the same thing. Add a square-corner fast path to both helpers. Output is pixel identical; only the redundant calls go away. On a 1080p frame with 100 labels LabelAnnotator drops from ~2.1 ms to ~1.3 ms (about 1.6x), and the rounded-rectangle call itself is ~2.8x faster at radius 0. The radius > 0 path is unchanged. Adds tests pinning square output to a plain rectangle for both helpers (the public draw/utils function had no tests before). - Rename LabelAnnotator.draw_rounded_rectangle to _draw_rounded_rectangle (accidentally public static method — now signals internal) - Expand draw/utils.py border_radius docstring: document <= 0 and clamp-to-zero fast-path behaviour - Add crash-era comment to both test files: border_radius < 0 previously raised cv2.error; fast path silently draws square corners instead - Add clamped-to-zero test in both test files: positive radius on a 1px-wide box clamps to 0 and triggers the fast path - Strengthen positive-radius assertion: full center-row check + all four corners unpainted (replaces two-pixel spot check) - Add pytest.param(id=) slugs to all parametrize decorators per CONTRIBUTING.md convention - Add Google-style docstring with Args, Returns, Example to `LabelAnnotator.draw_rounded_rectangle` (was undocumented @staticmethod) - Rename `testdraw_*` → `test_draw_*` in `TestLabelAnnotator` to restore consistent test naming broken by the earlier private-rename commit - Expand `draw/utils.draw_rounded_rectangle` border_radius docstring: note that negative values previously raised `cv2.error` and now draw square corners silently; drop "as a fast path" implementation detail - Add `Example:` block to `draw/utils.draw_rounded_rectangle` --------- Co-authored-by: jirka <6035284+Borda@users.noreply.github.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
parent
e1b7a16101
commit
97f5c0ca53
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue