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 1 commit
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
98 changes: 98 additions & 0 deletions Tests/test_imagedraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,3 +1092,101 @@ def test_same_color_outline():
operation, mode
)
assert_image_similar_tofile(im, expected, 1)


@pytest.mark.parametrize(
"nb_polygon_sides, rotation, polygon_name",
[(4, 0, "square"), (8, 0, "octagon"), (4, 45, "square")],
)
def test_draw_regular_polygon(nb_polygon_sides, rotation, polygon_name):
im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0))
filename = (
f"Tests/images/imagedraw_regular_polygon__{polygon_name}"
f"_with_{rotation}_degree_rotation.png"
)
draw = ImageDraw.Draw(im)
draw.regular_polygon(
b_box=[(0, 0), (W, H)], nb_sides=nb_polygon_sides, rotation=rotation, fill="red"
)
assert_image_equal(im, Image.open(filename))


@pytest.mark.parametrize(
"nb_polygon_sides, expected_vertices",
[
(3, [(6.7, 75.0), (93.3, 75.0), (50.0, 0.0)]),
(4, [(14.64, 85.36), (85.36, 85.36), (85.36, 14.64), (14.64, 14.64)]),
(
5,
[
(20.61, 90.45),
(79.39, 90.45),
(97.55, 34.55),
(50.0, 0.0),
(2.45, 34.55),
],
),
(
6,
[
(25.0, 93.3),
(75.0, 93.3),
(100.0, 50.0),
(75.0, 6.7),
(25.0, 6.7),
(0.0, 50.0),
],
),
],
)
def test_compute_regular_polygon_vertices(nb_polygon_sides, expected_vertices):
vertices = ImageDraw._compute_regular_polygon_vertices(
nb_sides=nb_polygon_sides, b_box=[(0, 0), (100, 100)], rotation=0
)
assert vertices == expected_vertices


@pytest.mark.parametrize(
"nb_polygon_sides, bounding_box, rotation, expected_error, error_message",
[
(None, [(0, 0), (100, 100)], 0, TypeError, "nb_sides should be an int"),
(1, [(0, 0), (100, 100)], 0, ValueError, "nb_sides should be an int > 2"),
(3, 100, 0, TypeError, "b_box should be a list/tuple"),
(
3,
[(0, 0), (50, 50), (100, 100)],
0,
ValueError,
"b_box should have 2 items (top-left & bottom-right coordinates)",
),
(
3,
[(50, 50), (0, None)],
0,
ValueError,
"b_box should only contain numeric data",
),
(
3,
[(50, 50), (0, 0)],
0,
ValueError,
"b_box: Bottom-right coordinate should be larger than top-left coordinate",
),
(
3,
[(0, 0), (100, 100)],
"0",
ValueError,
"rotation should be an int or float",
),
],
)
def test_compute_regular_polygon_vertices_input_error_handling(
nb_polygon_sides, bounding_box, rotation, expected_error, error_message
):
with pytest.raises(expected_error) as e:
ImageDraw._compute_regular_polygon_vertices(
nb_sides=nb_polygon_sides, b_box=bounding_box, rotation=rotation
)
assert str(e.value) == error_message
16 changes: 16 additions & 0 deletions docs/reference/ImageDraw.rst
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,22 @@ Methods
:param outline: Color to use for the outline.
:param fill: Color to use for the fill.


.. py:method:: ImageDraw.regular_polygon(*, b_box, nb_sides, rotation=0, fill=None, outline=None)
Copy link
Member

Choose a reason for hiding this comment

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

Nits:

Let's call it bbox throughout, that's what the rest of the codebase uses.

Also, n_things is used quite a lot, and there's very little nb_things:

Suggested change
.. py:method:: ImageDraw.regular_polygon(*, b_box, nb_sides, rotation=0, fill=None, outline=None)
.. py:method:: ImageDraw.regular_polygon(*, bbox, n_sides, rotation=0, fill=None, outline=None)

Copy link
Contributor

Choose a reason for hiding this comment

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

I would go with xy here, as while most of the code base uses bbox, the ImageDraw functions use xy: https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html#PIL.ImageDraw.ImageDraw.rectangle

Suggested change
.. py:method:: ImageDraw.regular_polygon(*, b_box, nb_sides, rotation=0, fill=None, outline=None)
.. py:method:: ImageDraw.regular_polygon(*, xy, n_sides, rotation=0, fill=None, outline=None)


Draws a regular polygon inscribed in ``b_box``,
with ``nb_sides``, and rotation of ``rotation`` degrees

:param b_box: A bounding box which inscribes the polygon
(e.g. b_box = [(50, 50), (150, 150)])
:param nb_sides: Number of sides
(e.g. nb_sides=3 for a triangle, 6 for a hexagon, etc..)
:param rotation: Apply an arbitrary rotation to the polygon
(e.g. rotation=90, applies a 90 degree rotation)
:param outline: Color to use for the outline.
:param fill: Color to use for the fill.


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

Draws a rectangle.
Expand Down
137 changes: 137 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, *, nb_sides, b_box, rotation=0, fill=None, outline=None):
"""Draw a regular polygon."""
xy = _compute_regular_polygon_vertices(
nb_sides=nb_sides, b_box=b_box, rotation=rotation
)
self.polygon(xy, fill=fill, outline=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,136 @@ def floodfill(image, xy, value, border=None, thresh=0):
edge = new_edge


def _compute_regular_polygon_vertices(*, nb_sides, b_box, rotation):
"""
Generate a list of vertices for a 2D regular polygon.

:param nb_sides: Number of sides
(e.g. nb_sides = 3 for a triangle, 6 for a hexagon, etc..)
:param b_box: A bounding box which inscribes the polygon
(e.g. b_box = [(50, 50), (150, 150)])
: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 box
- polygon_radius: Distance between centroid and each polygon vertex
- 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 `nb_sides` has an appropriate value
if not isinstance(nb_sides, int):
raise TypeError("nb_sides should be an int")
if nb_sides < 3:
raise ValueError("nb_sides should be an int > 2")

# 1.2 Check `b_box` has an appropriate value
if not isinstance(b_box, (list, tuple)):
raise TypeError("b_box should be a list/tuple")
if not len(b_box) == 2:
raise ValueError(
"b_box should have 2 items (top-left & bottom-right coordinates)"
)

b_box_pts = [pt for corner in b_box for pt in corner]
if not all(isinstance(i, (int, float)) for i in b_box_pts):
raise ValueError("b_box should only contain numeric data")

if b_box[1][1] <= b_box[0][1] or b_box[1][0] <= b_box[0][0]:
raise ValueError(
"b_box: Bottom-right coordinate should be larger than top-left coordinate"
)

# 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 _get_centroid(*, b_box):
return (b_box[1][0] + b_box[0][0]) * 0.5, (b_box[1][1] + b_box[0][1]) * 0.5

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 _get_theta(*, nb_sides):
return 0.5 * (360 / nb_sides)

def _get_polygon_radius(*, side_length, theta):
return (0.5 * side_length) / math.sin(math.radians(theta))

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

def _get_side_length(*, b_box, theta):
h = b_box[1][1] - b_box[0][1]
return h * math.sin(math.radians(theta))

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

# 3. Variable Declarations
vertices = []
theta = _get_theta(nb_sides=nb_sides)
side_length = _get_side_length(theta=theta, b_box=b_box)
centroid = _get_centroid(b_box=b_box)
polygon_radius = _get_polygon_radius(side_length=side_length, theta=theta)
angles = _get_angles(nb_sides=nb_sides, rotation=rotation)

# 4. Compute Vertices
for angle in angles:
vertices.append(
_compute_polygon_vertex(
centroid=centroid, angle=angle, polygon_radius=polygon_radius
)
)
return vertices


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