9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
301 lines
9.8 KiB
Python
301 lines
9.8 KiB
Python
import cv2
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from supervision.draw.color import Color
|
|
from supervision.draw.utils import (
|
|
draw_filled_rectangle,
|
|
draw_image,
|
|
draw_line,
|
|
draw_rectangle,
|
|
draw_rounded_rectangle,
|
|
draw_text,
|
|
)
|
|
from supervision.geometry.core import Point, Rect
|
|
from supervision.utils.image import grayscale_image
|
|
|
|
|
|
def test_draw_image_invalid_path_raises_oserror(tmp_path) -> None:
|
|
"""Existing but undecodable image files raise OSError."""
|
|
invalid_image_path = tmp_path / "invalid_image.dat"
|
|
invalid_image_path.write_bytes(b"not an image")
|
|
scene = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
rect = Rect(x=0, y=0, width=100, height=100)
|
|
|
|
with pytest.raises(OSError, match="Could not decode image path"):
|
|
draw_image(
|
|
scene=scene,
|
|
image=str(invalid_image_path),
|
|
opacity=1.0,
|
|
rect=rect,
|
|
)
|
|
|
|
|
|
def test_draw_image_valid_image(tmp_path) -> None:
|
|
"""Valid image files are decoded and drawn onto the scene."""
|
|
image = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
image_path = tmp_path / "image.png"
|
|
cv2.imwrite(str(image_path), image)
|
|
|
|
scene = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
rect = Rect(x=0, y=0, width=100, height=100)
|
|
|
|
result = draw_image(
|
|
scene=scene,
|
|
image=str(image_path),
|
|
opacity=1.0,
|
|
rect=rect,
|
|
)
|
|
|
|
assert isinstance(result, np.ndarray)
|
|
|
|
|
|
def test_draw_image_grayscale_file_raises_value_error(tmp_path) -> None:
|
|
"""Grayscale image files raise ValueError before channel access."""
|
|
image = np.zeros((100, 100), dtype=np.uint8)
|
|
image_path = tmp_path / "grayscale.png"
|
|
cv2.imwrite(str(image_path), image)
|
|
|
|
scene = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
rect = Rect(x=0, y=0, width=100, height=100)
|
|
|
|
with pytest.raises(ValueError, match="3 or 4 channels"):
|
|
draw_image(
|
|
scene=scene,
|
|
image=str(image_path),
|
|
opacity=1.0,
|
|
rect=rect,
|
|
)
|
|
|
|
|
|
def test_draw_image_grayscale_array_raises_value_error() -> None:
|
|
"""Grayscale image arrays raise ValueError before channel access."""
|
|
image = np.zeros((100, 100), dtype=np.uint8)
|
|
scene = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
rect = Rect(x=0, y=0, width=100, height=100)
|
|
|
|
with pytest.raises(ValueError, match="3 or 4 channels"):
|
|
draw_image(
|
|
scene=scene,
|
|
image=image,
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# draw_line
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_draw_line_modifies_scene() -> None:
|
|
"""draw_line changes at least one pixel along the drawn path."""
|
|
scene = np.zeros((50, 50, 3), dtype=np.uint8)
|
|
before = scene.copy()
|
|
result = draw_line(
|
|
scene=scene,
|
|
start=Point(x=0, y=25),
|
|
end=Point(x=49, y=25),
|
|
color=Color.WHITE,
|
|
thickness=1,
|
|
)
|
|
assert result is scene
|
|
assert not np.array_equal(result, before)
|
|
|
|
|
|
def test_draw_line_preserves_shape_and_dtype() -> None:
|
|
"""draw_line returns the same shape and dtype as the input scene."""
|
|
scene = np.zeros((30, 30, 3), dtype=np.uint8)
|
|
result = draw_line(
|
|
scene=scene,
|
|
start=Point(x=0, y=0),
|
|
end=Point(x=29, y=29),
|
|
color=Color.WHITE,
|
|
)
|
|
assert result.shape == (30, 30, 3)
|
|
assert result.dtype == np.uint8
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# draw_rectangle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_draw_rectangle_modifies_border_pixels() -> None:
|
|
"""draw_rectangle draws on the border of the specified rect."""
|
|
scene = np.zeros((50, 50, 3), dtype=np.uint8)
|
|
before = scene.copy()
|
|
result = draw_rectangle(
|
|
scene=scene,
|
|
rect=Rect(x=5, y=5, width=20, height=20),
|
|
color=Color.WHITE,
|
|
thickness=1,
|
|
)
|
|
assert result is scene
|
|
assert not np.array_equal(result, before)
|
|
|
|
|
|
def test_draw_rectangle_interior_unchanged_for_thin_border() -> None:
|
|
"""A 1-pixel border rect leaves the interior pixel untouched."""
|
|
scene = np.zeros((50, 50, 3), dtype=np.uint8)
|
|
draw_rectangle(
|
|
scene=scene,
|
|
rect=Rect(x=10, y=10, width=20, height=20),
|
|
color=Color.WHITE,
|
|
thickness=1,
|
|
)
|
|
# centre of the rect interior — should remain black
|
|
assert scene[20, 20].tolist() == [0, 0, 0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# draw_filled_rectangle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_draw_filled_rectangle_fills_interior() -> None:
|
|
"""draw_filled_rectangle sets interior pixels to the given colour."""
|
|
scene = np.zeros((50, 50, 3), dtype=np.uint8)
|
|
draw_filled_rectangle(
|
|
scene=scene,
|
|
rect=Rect(x=10, y=10, width=20, height=20),
|
|
color=Color.WHITE,
|
|
opacity=1.0,
|
|
)
|
|
# centre should be white (BGR 255, 255, 255)
|
|
assert scene[20, 20].tolist() == [255, 255, 255]
|
|
|
|
|
|
def test_draw_filled_rectangle_opacity_blends() -> None:
|
|
"""opacity<1 blends the fill colour with the original background."""
|
|
scene = np.zeros((50, 50, 3), dtype=np.uint8)
|
|
draw_filled_rectangle(
|
|
scene=scene,
|
|
rect=Rect(x=10, y=10, width=20, height=20),
|
|
color=Color.WHITE,
|
|
opacity=0.5,
|
|
)
|
|
# blended pixel should be neither black nor white
|
|
val = scene[20, 20, 0]
|
|
assert 0 < val < 255
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# draw_text
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_draw_text_modifies_scene() -> None:
|
|
"""draw_text changes at least one pixel in the scene."""
|
|
scene = np.zeros((100, 200, 3), dtype=np.uint8)
|
|
before = scene.copy()
|
|
result = draw_text(
|
|
scene=scene,
|
|
text="Hi",
|
|
text_anchor=Point(x=100, y=50),
|
|
text_color=Color.WHITE,
|
|
)
|
|
assert result is scene
|
|
assert not np.array_equal(result, before)
|
|
|
|
|
|
def test_draw_text_preserves_shape() -> None:
|
|
"""draw_text does not change the scene dimensions."""
|
|
scene = np.zeros((100, 200, 3), dtype=np.uint8)
|
|
result = draw_text(
|
|
scene=scene,
|
|
text="Test",
|
|
text_anchor=Point(x=100, y=50),
|
|
)
|
|
assert result.shape == (100, 200, 3)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# grayscale_image
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_grayscale_image_preserves_shape() -> None:
|
|
"""grayscale_image returns a 3-channel image with the same HxW."""
|
|
image = np.random.default_rng(0).integers(0, 256, (60, 80, 3), dtype=np.uint8)
|
|
result = grayscale_image(image=image)
|
|
assert result.shape == (60, 80, 3)
|
|
assert result.dtype == np.uint8
|
|
|
|
|
|
def test_grayscale_image_channels_are_equal() -> None:
|
|
"""All three output channels contain identical luminance values."""
|
|
image = np.random.default_rng(1).integers(0, 256, (40, 40, 3), dtype=np.uint8)
|
|
result = grayscale_image(image=image)
|
|
np.testing.assert_array_equal(result[:, :, 0], result[:, :, 1])
|
|
np.testing.assert_array_equal(result[:, :, 0], result[:, :, 2])
|
|
|
|
|
|
def test_grayscale_image_uniform_color_stays_gray() -> None:
|
|
"""A uniform-colour input maps to a single uniform gray value."""
|
|
image = np.full((20, 20, 3), fill_value=[100, 150, 200], dtype=np.uint8)
|
|
result = grayscale_image(image=image)
|
|
assert result[:, :, 0].min() == result[:, :, 0].max()
|