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:
Agis Kounelis 2026-07-01 07:25:25 +08:00 committed by GitHub
parent e1b7a16101
commit 97f5c0ca53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 179 additions and 2 deletions

View File

@ -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)),

View File

@ -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)),

View File

@ -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()

View File

@ -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