Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added regular_polygon draw method #4846

Merged
merged 4 commits into from
Sep 4, 2020
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
Binary file added Tests/images/imagedraw_regular_octagon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/imagedraw_square.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/imagedraw_square_rotate_45.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 92 additions & 0 deletions Tests/test_imagedraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,3 +1092,95 @@ def test_same_color_outline():
operation, mode
)
assert_image_similar_tofile(im, expected, 1)


@pytest.mark.parametrize(
"n_sides, rotation, polygon_name",
[(4, 0, "square"), (8, 0, "regular_octagon"), (4, 45, "square")],
)
def test_draw_regular_polygon(n_sides, rotation, polygon_name):
im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0))
filename_base = f"Tests/images/imagedraw_{polygon_name}"
filename = (
f"{filename_base}.png"
if rotation == 0
else f"{filename_base}_rotate_{rotation}.png"
)
draw = ImageDraw.Draw(im)
bounding_circle = ((W // 2, H // 2), 25)
draw.regular_polygon(bounding_circle, n_sides, rotation=rotation, fill="red")
assert_image_equal(im, Image.open(filename))


@pytest.mark.parametrize(
"n_sides, expected_vertices",
[
(3, [(28.35, 62.5), (71.65, 62.5), (50.0, 25.0)]),
(4, [(32.32, 67.68), (67.68, 67.68), (67.68, 32.32), (32.32, 32.32)]),
(
5,
[
(35.31, 70.23),
(64.69, 70.23),
(73.78, 42.27),
(50.0, 25.0),
(26.22, 42.27),
],
),
(
6,
[
(37.5, 71.65),
(62.5, 71.65),
(75.0, 50.0),
(62.5, 28.35),
(37.5, 28.35),
(25.0, 50.0),
],
),
],
)
def test_compute_regular_polygon_vertices(n_sides, expected_vertices):
bounding_circle = (W // 2, H // 2, 25)
vertices = ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, 0)
assert vertices == expected_vertices


@pytest.mark.parametrize(
"n_sides, bounding_circle, rotation, expected_error, error_message",
[
(None, (50, 50, 25), 0, TypeError, "n_sides should be an int"),
(1, (50, 50, 25), 0, ValueError, "n_sides should be an int > 2"),
(3, 50, 0, TypeError, "bounding_circle should be a tuple"),
(
3,
(50, 50, 100, 100),
0,
ValueError,
"bounding_circle should contain 2D coordinates "
"and a radius (e.g. (x, y, r) or ((x, y), r) )",
),
(
3,
(50, 50, "25"),
0,
ValueError,
"bounding_circle should only contain numeric data",
),
(
3,
((50, 50, 50), 25),
0,
ValueError,
"bounding_circle centre should contain 2D coordinates (e.g. (x, y))",
),
(3, (50, 50, 0), 0, ValueError, "bounding_circle radius should be > 0",),
(3, (50, 50, 25), "0", ValueError, "rotation should be an int or float",),
],
)
def test_compute_regular_polygon_vertices_input_error_handling(
n_sides, bounding_circle, rotation, expected_error, error_message
):
with pytest.raises(expected_error) as e:
ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation)
assert str(e.value) == error_message
18 changes: 18 additions & 0 deletions docs/reference/ImageDraw.rst
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,24 @@ Methods
:param outline: Color to use for the outline.
:param fill: Color to use for the fill.


.. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None)

Draws a regular polygon inscribed in ``bounding_circle``,
with ``n_sides``, and rotation of ``rotation`` degrees.

:param bounding_circle: The bounding circle is a tuple defined
by a point and radius.
(e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``).
(e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``).
The polygon is inscribed in this circle.

Docs for ImageDraw.regular_polygon should probably also mention this, not just the _compute_regular_polygon_vertices function (which is not shown in the docs and can only be accessed with the __docs__ attribute).

The polygon is inscribed in this circle.
:param n_sides: Number of sides
(e.g. ``n_sides=3`` for a triangle, ``6`` for a hexagon).
:param rotation: Apply an arbitrary rotation to the polygon
(e.g. ``rotation=90``, applies a 90 degree rotation).
:param fill: Color to use for the fill.
:param outline: Color to use for the outline.


.. py:method:: ImageDraw.rectangle(xy, fill=None, outline=None, width=1)

Draws a rectangle.
Expand Down
124 changes: 124 additions & 0 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ def polygon(self, xy, fill=None, outline=None):
if ink is not None and ink != fill:
self.draw.draw_polygon(xy, ink, 0)

def regular_polygon(
self, bounding_circle, n_sides, rotation=0, fill=None, outline=None
):
"""Draw a regular polygon."""
xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation)
self.polygon(xy, fill, outline)

def rectangle(self, xy, fill=None, outline=None, width=1):
"""Draw a rectangle."""
ink, fill = self._getink(outline, fill)
Expand Down Expand Up @@ -555,6 +562,123 @@ def floodfill(image, xy, value, border=None, thresh=0):
edge = new_edge


def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation):
"""
Generate a list of vertices for a 2D regular polygon.

:param bounding_circle: The bounding circle is a tuple defined
by a point and radius. The polygon is inscribed in this circle.
(e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``)
:param n_sides: Number of sides
(e.g. ``n_sides=3`` for a triangle, ``6`` for a hexagon)
:param rotation: Apply an arbitrary rotation to the polygon
(e.g. ``rotation=90``, applies a 90 degree rotation)
:return: List of regular polygon vertices
(e.g. ``[(25, 50), (50, 50), (50, 25), (25, 25)]``)

How are the vertices computed?
1. Compute the following variables
- theta: Angle between the apothem & the nearest polygon vertex
- side_length: Length of each polygon edge
- centroid: Center of bounding circle (1st, 2nd elements of bounding_circle)
- polygon_radius: Polygon radius (last element of bounding_circle)
- angles: Location of each polygon vertex in polar grid
(e.g. A square with 0 degree rotation => [225.0, 315.0, 45.0, 135.0])

2. For each angle in angles, get the polygon vertex at that angle
The vertex is computed using the equation below.
X= xcos(φ) + ysin(φ)
Y= −xsin(φ) + ycos(φ)

Note:
φ = angle in degrees
x = 0
y = polygon_radius

The formula above assumes rotation around the origin.
In our case, we are rotating around the centroid.
To account for this, we use the formula below
X = xcos(φ) + ysin(φ) + centroid_x
Y = −xsin(φ) + ycos(φ) + centroid_y
"""
# 1. Error Handling
# 1.1 Check `n_sides` has an appropriate value
if not isinstance(n_sides, int):
raise TypeError("n_sides should be an int")
if n_sides < 3:
raise ValueError("n_sides should be an int > 2")

# 1.2 Check `bounding_circle` has an appropriate value
if not isinstance(bounding_circle, (list, tuple)):
raise TypeError("bounding_circle should be a tuple")

if len(bounding_circle) == 3:
*centroid, polygon_radius = bounding_circle
elif len(bounding_circle) == 2:
centroid, polygon_radius = bounding_circle
else:
raise ValueError(
"bounding_circle should contain 2D coordinates "
"and a radius (e.g. (x, y, r) or ((x, y), r) )"
)

if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)):
raise ValueError("bounding_circle should only contain numeric data")

if not len(centroid) == 2:
raise ValueError(
"bounding_circle centre should contain 2D coordinates (e.g. (x, y))"
)

if polygon_radius <= 0:
raise ValueError("bounding_circle radius should be > 0")

# 1.3 Check `rotation` has an appropriate value
if not isinstance(rotation, (int, float)):
raise ValueError("rotation should be an int or float")

# 2. Define Helper Functions
def _apply_rotation(point, degrees, centroid):
return (
round(
point[0] * math.cos(math.radians(360 - degrees))
- point[1] * math.sin(math.radians(360 - degrees))
+ centroid[0],
2,
),
round(
point[1] * math.cos(math.radians(360 - degrees))
+ point[0] * math.sin(math.radians(360 - degrees))
+ centroid[1],
2,
),
)

def _compute_polygon_vertex(centroid, polygon_radius, angle):
start_point = [polygon_radius, 0]
return _apply_rotation(start_point, angle, centroid)

def _get_angles(n_sides, rotation):
angles = []
degrees = 360 / n_sides
# Start with the bottom left polygon vertex
current_angle = (270 - 0.5 * degrees) + rotation
for _ in range(0, n_sides):
angles.append(current_angle)
current_angle += degrees
if current_angle > 360:
current_angle -= 360
return angles

# 3. Variable Declarations
angles = _get_angles(n_sides, rotation)

# 4. Compute Vertices
return [
_compute_polygon_vertex(centroid, polygon_radius, angle) for angle in angles
]


def _color_diff(color1, color2):
"""
Uses 1-norm distance to calculate difference between two values.
Expand Down