Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ tiled = imgviz.tile(
<td><pre><a href="examples/resize.py">examples/resize.py</a></pre></td>
<td><img src="examples/assets/resize.jpg" width="52.21052631578947%" /></td>
</tr>
<tr>
<td><pre><a href="examples/rotated_rectangle.py">examples/rotated_rectangle.py</a></pre></td>
<td><img src="examples/assets/rotated_rectangle.jpg" width="20.0%" /></td>
</tr>
<tr>
<td><pre><a href="examples/rounded_rectangle.py">examples/rounded_rectangle.py</a></pre></td>
<td><img src="examples/assets/rounded_rectangle.jpg" width="20.0%" /></td>
Expand Down
Binary file added examples/assets/rotated_rectangle.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions examples/rotated_rectangle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python

import matplotlib.pyplot as plt

import imgviz


def rotated_rectangle() -> None:
img = imgviz.data.lena()
height, width = img.shape[:2]
colors = imgviz.label_colormap()[1:]

angles = [0, 30, 60]
box_height = height * 0.5
box_width = width * 0.22

viz = img
for i, angle in enumerate(angles):
cy = height / 2
cx = width * (i + 1) / (len(angles) + 1)
color = colors[i]
viz = imgviz.draw.rotated_rectangle(
viz,
center=(cy, cx),
size=(box_height, box_width),
angle=angle,
outline=(int(color[0]), int(color[1]), int(color[2])),
width=5,
)
viz = imgviz.draw.text_in_rectangle(
viz,
loc="lt+",
text=f"angle={angle}",
size=18,
background=(int(color[0]), int(color[1]), int(color[2])),
yx1=(cy - box_height / 2, cx - box_width / 2),
yx2=(cy + box_height / 2, cx + box_width / 2),
)

plt.figure(dpi=200)
plt.imshow(viz)
plt.axis("off")


if __name__ == "__main__":
from _base import run_example

run_example(rotated_rectangle)
2 changes: 2 additions & 0 deletions imgviz/draw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from ._polygon import polygon_
from ._rectangle import rectangle
from ._rectangle import rectangle_
from ._rotated_rectangle import rotated_rectangle
from ._rotated_rectangle import rotated_rectangle_
from ._rounded_rectangle import rounded_rectangle
from ._rounded_rectangle import rounded_rectangle_
from ._star import star
Expand Down
89 changes: 89 additions & 0 deletions imgviz/draw/_rotated_rectangle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import numpy as np
import PIL.Image
from numpy.typing import NDArray

from .. import _utils
from ._ink import Ink
from ._ink import require_fill_or_outline
from ._polygon import polygon_


def rotated_rectangle(
image: NDArray[np.uint8],
center: tuple[float, float] | NDArray[np.floating],
size: tuple[float, float] | NDArray[np.floating],
angle: float,
fill: Ink | None = None,
outline: Ink | None = None,
width: int = 1,
) -> NDArray[np.uint8]:
"""Draw rotated rectangle on numpy array with Pillow.

Args:
image: Input image.
center: Center point (cy, cx).
size: Rectangle size (height, width) before rotation.
angle: Rotation angle in degrees. Positive rotates clockwise because the
image y-axis points down.
fill: RGB color to fill the mark. None for no fill.
outline: RGB color to draw the outline.
width: Outline width.

Returns:
Output image.
"""
dst = _utils.numpy_to_pillow(image)
rotated_rectangle_(
image=dst,
center=center,
size=size,
angle=angle,
fill=fill,
outline=outline,
width=width,
)
return _utils.pillow_to_numpy(dst)


def rotated_rectangle_(
image: PIL.Image.Image,
center: tuple[float, float] | NDArray[np.floating],
size: tuple[float, float] | NDArray[np.floating],
angle: float,
fill: Ink | None = None,
outline: Ink | None = None,
width: int = 1,
) -> None:
"""Draw rotated rectangle on PIL image in-place.

Args:
image: PIL image to draw on (modified in-place).
center: Center point (cy, cx).
size: Rectangle size (height, width) before rotation.
angle: Rotation angle in degrees. Positive rotates clockwise because the
image y-axis points down.
fill: RGB color to fill the mark. None for no fill.
outline: RGB color to draw the outline.
width: Outline width.
"""
require_fill_or_outline(fill, outline)

cy, cx = center
size_h, size_w = size

corners = np.array(
[
[-size_w / 2, -size_h / 2],
[size_w / 2, -size_h / 2],
[size_w / 2, size_h / 2],
[-size_w / 2, size_h / 2],
]
)
theta = np.deg2rad(angle)
cos = np.cos(theta)
sin = np.sin(theta)
rotation = np.array([[cos, -sin], [sin, cos]])

xy = corners @ rotation.T + (cx, cy)
yx = xy[:, ::-1]
polygon_(image, yx=yx, fill=fill, outline=outline, width=width)
134 changes: 134 additions & 0 deletions tests/unit/draw/_rotated_rectangle_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import numpy as np
import PIL.Image
import pytest
from numpy.typing import NDArray

import imgviz


@pytest.fixture
def black_image() -> NDArray[np.uint8]:
return np.zeros((100, 100, 3), dtype=np.uint8)


def _filled_bbox(image: NDArray[np.uint8]) -> tuple[int, int, int, int]:
ys, xs = np.where(image[:, :, 0] > 0)
return int(ys.min()), int(ys.max()), int(xs.min()), int(xs.max())


def test_rotated_rectangle(black_image: NDArray[np.uint8]) -> None:
res = imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(40, 20), angle=30, fill=(255, 255, 255)
)
assert res.shape == black_image.shape
assert res.dtype == black_image.dtype
assert not np.array_equal(res, black_image)


def test_rotated_rectangle_rejects_missing_fill_and_outline(
black_image: NDArray[np.uint8],
) -> None:
with pytest.raises(ValueError, match="at least one of `fill` or `outline`"):
imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(40, 20), angle=30
)


def test_rotated_rectangle_zero_angle_is_axis_aligned(
black_image: NDArray[np.uint8],
) -> None:
res = imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(40, 20), angle=0, fill=(255, 255, 255)
)

y1, y2, x1, x2 = _filled_bbox(res)
# size=(height=40, width=20) centered at (50, 50)
assert abs(y1 - 30) <= 1 and abs(y2 - 70) <= 1
assert abs(x1 - 40) <= 1 and abs(x2 - 60) <= 1


def test_rotated_rectangle_ninety_degrees_swaps_extent(
black_image: NDArray[np.uint8],
) -> None:
res = imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(40, 20), angle=90, fill=(255, 255, 255)
)

y1, y2, x1, x2 = _filled_bbox(res)
# a 90-degree turn swaps the height and width extents
assert abs((y2 - y1) - 20) <= 1
assert abs((x2 - x1) - 40) <= 1


def test_rotated_rectangle_positive_angle_rotates_clockwise(
black_image: NDArray[np.uint8],
) -> None:
# a thin vertical bar; clockwise (image y-axis down) swings its top to the
# right and its bottom to the left
res = imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(60, 6), angle=30, fill=(255, 255, 255)
)

ys, xs = np.where(res[:, :, 0] > 0)
top_x_mean = xs[ys == ys.min()].mean()
bottom_x_mean = xs[ys == ys.max()].mean()
assert top_x_mean > 50
assert bottom_x_mean < 50


def test_rotated_rectangle_outline_only(black_image: NDArray[np.uint8]) -> None:
res = imgviz.draw.rotated_rectangle(
black_image,
center=(50, 50),
size=(40, 20),
angle=30,
outline=(255, 255, 255),
width=2,
)

assert not np.array_equal(res, black_image)
# outline only leaves the interior unfilled
np.testing.assert_array_equal(res[50, 50], [0, 0, 0])


def test_rotated_rectangle_fill_and_outline(black_image: NDArray[np.uint8]) -> None:
res = imgviz.draw.rotated_rectangle(
black_image,
center=(50, 50),
size=(40, 20),
angle=30,
fill=(0, 0, 255),
outline=(255, 0, 0),
width=3,
)

np.testing.assert_array_equal(res[50, 50], [0, 0, 255])
colors = res[res.any(axis=2)].reshape(-1, 3)
assert (colors == [255, 0, 0]).all(axis=1).any()


def test_rotated_rectangle_preserves_area_under_rotation(
black_image: NDArray[np.uint8],
) -> None:
upright = imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(40, 20), angle=0, fill=(255, 255, 255)
)
tilted = imgviz.draw.rotated_rectangle(
black_image, center=(50, 50), size=(40, 20), angle=45, fill=(255, 255, 255)
)

area_upright = int((upright[:, :, 0] > 0).sum())
area_tilted = int((tilted[:, :, 0] > 0).sum())
assert not np.array_equal(upright, tilted)
assert abs(area_upright - area_tilted) / area_upright < 0.05


def test_rotated_rectangle_in_place_mutates_pil_image() -> None:
image = PIL.Image.new("RGB", (100, 100), (0, 0, 0))
before = np.asarray(image).copy()

imgviz.draw.rotated_rectangle_(
image, center=(50, 50), size=(40, 20), angle=30, fill=(255, 255, 255)
)

assert not np.array_equal(before, np.asarray(image))