Add ImageDraw.regular_polygon

This commit is contained in:
Tommy C 2020-08-09 21:43:37 +01:00
parent 33dca341a9
commit 0ed01dd964
6 changed files with 251 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

View File

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

View File

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

View File

@ -242,6 +242,13 @@ class ImageDraw:
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)
@ -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.