Replace bbox with b_circle, kwargs with args + minor tweaks

Summary of changes:
 - `ImageDraw.regular_polygon` now accepts a bounding circle which
inscribes the polygon. A bounding circle is defined by a center point
(x0, y0) and a radius. A bounding box is no longer accepted.
 - All keyword args have been replaced with positional args.

Misc
- Test image file renaming, minor variable name changes
This commit is contained in:
Tommy C 2020-08-20 20:46:11 +01:00
parent ac7d41545d
commit df9329f9f0
9 changed files with 69 additions and 128 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

View File

@ -1095,107 +1095,84 @@ def test_same_color_outline():
@pytest.mark.parametrize( @pytest.mark.parametrize(
"n_polygon_sides, rotation, polygon_name", "n_sides, rotation, polygon_name",
[(4, 0, "square"), (8, 0, "octagon"), (4, 45, "square")], [(4, 0, "square"), (8, 0, "regular_octagon"), (4, 45, "square")],
) )
def test_draw_regular_polygon(n_polygon_sides, rotation, polygon_name): def test_draw_regular_polygon(n_sides, rotation, polygon_name):
im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0)) im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0))
filename_base = f"Tests/images/imagedraw_{polygon_name}"
filename = ( filename = (
f"Tests/images/imagedraw_regular_polygon__{polygon_name}" f"{filename_base}.png"
f"_with_{rotation}_degree_rotation.png" if rotation == 0
else f"{filename_base}_rotate_{rotation}.png"
) )
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
draw.regular_polygon( draw.regular_polygon([W // 2, H // 2, 25], n_sides, rotation=rotation, fill="red")
[(0, 0), (W, H)], n_polygon_sides, rotation=rotation, fill="red"
)
assert_image_equal(im, Image.open(filename)) assert_image_equal(im, Image.open(filename))
@pytest.mark.parametrize( @pytest.mark.parametrize(
"n_polygon_sides, expected_vertices", "n_sides, expected_vertices",
[ [
(3, [(6.7, 75.0), (93.3, 75.0), (50.0, 0.0)]), (3, [(28.35, 62.5), (71.65, 62.5), (50.0, 25.0)]),
(4, [(14.64, 85.36), (85.36, 85.36), (85.36, 14.64), (14.64, 14.64)]), (4, [(32.32, 67.68), (67.68, 67.68), (67.68, 32.32), (32.32, 32.32)]),
( (
5, 5,
[ [
(20.61, 90.45), (35.31, 70.23),
(79.39, 90.45), (64.69, 70.23),
(97.55, 34.55), (73.78, 42.27),
(50.0, 0.0), (50.0, 25.0),
(2.45, 34.55), (26.22, 42.27),
], ],
), ),
( (
6, 6,
[ [
(25.0, 93.3), (37.5, 71.65),
(75.0, 93.3), (62.5, 71.65),
(100.0, 50.0), (75.0, 50.0),
(75.0, 6.7), (62.5, 28.35),
(25.0, 6.7), (37.5, 28.35),
(0.0, 50.0), (25.0, 50.0),
], ],
), ),
], ],
) )
def test_compute_regular_polygon_vertices(n_polygon_sides, expected_vertices): def test_compute_regular_polygon_vertices(n_sides, expected_vertices):
vertices = ImageDraw._compute_regular_polygon_vertices( vertices = ImageDraw._compute_regular_polygon_vertices(
n_sides=n_polygon_sides, bbox=[(0, 0), (100, 100)], rotation=0 [W // 2, H // 2, 25], n_sides, 0
) )
assert vertices == expected_vertices assert vertices == expected_vertices
@pytest.mark.parametrize( @pytest.mark.parametrize(
"n_polygon_sides, bounding_box, rotation, expected_error, error_message", "n_sides, b_circle, rotation, expected_error, error_message",
[ [
(None, [(0, 0), (100, 100)], 0, TypeError, "n_sides should be an int"), (None, [50, 50, 25], 0, TypeError, "n_sides should be an int"),
(1, [(0, 0), (100, 100)], 0, ValueError, "n_sides should be an int > 2"), (1, [50, 50, 25], 0, ValueError, "n_sides should be an int > 2"),
(3, 100, 0, TypeError, "bbox should be a list/tuple"), (3, 50, 0, TypeError, "b_circle should be a list/tuple"),
( (
3, 3,
[(0, 0), (100,), (100,)], [50, 50, 100, 100],
0, 0,
ValueError, ValueError,
"bbox should have the following format " "b_circle should contain 2D coordinates and a radius (e.g. [x0, y0, r])",
"[(x0, y0), (x1, y1)] or [x0, y0, x1, y1]",
), ),
( (
3, 3,
[(50, 50), (100,)], [50, 50, "25"],
0, 0,
ValueError, ValueError,
"bbox should contain top-left and bottom-right coordinates (2D)", "b_circle should only contain numeric data",
),
(
3,
[(50, 50), (0, None)],
0,
ValueError,
"bbox should only contain numeric data",
),
(
3,
[(50, 50), (0, 0)],
0,
ValueError,
"bbox: Bottom-right coordinate should be larger than top-left coordinate",
),
(3, [(0, 0), (100, 90)], 0, ValueError, "bbox should be a square",),
(
3,
[(0, 0), (100, 100)],
"0",
ValueError,
"rotation should be an int or float",
), ),
(3, [50, 50, 0], 0, ValueError, "b_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( def test_compute_regular_polygon_vertices_input_error_handling(
n_polygon_sides, bounding_box, rotation, expected_error, error_message n_sides, b_circle, rotation, expected_error, error_message
): ):
with pytest.raises(expected_error) as e: with pytest.raises(expected_error) as e:
ImageDraw._compute_regular_polygon_vertices( ImageDraw._compute_regular_polygon_vertices(b_circle, n_sides, rotation)
n_sides=n_polygon_sides, bbox=bounding_box, rotation=rotation
)
assert str(e.value) == error_message assert str(e.value) == error_message

View File

@ -255,19 +255,19 @@ Methods
:param fill: Color to use for the fill. :param fill: Color to use for the fill.
.. py:method:: ImageDraw.regular_polygon(bbox, n_sides, rotation=0, fill=None, outline=None) .. py:method:: ImageDraw.regular_polygon(b_circle, n_sides, rotation=0, fill=None, outline=None)
Draws a regular polygon inscribed in ``bbox``, Draws a regular polygon inscribed in ``b_circle``,
with ``n_sides``, and rotation of ``rotation`` degrees with ``n_sides``, and rotation of ``rotation`` degrees.
:param bbox: A bounding box which inscribes the polygon :param b_circle: A bounding circle which inscribes the polygon
(e.g. bbox = [(50, 50), (150, 150)]) (e.g. b_circle=[50, 50, 25]).
:param n_sides: Number of sides :param n_sides: Number of sides
(e.g. n_sides=3 for a triangle, 6 for a hexagon, etc..) (e.g. n_sides=3 for a triangle, 6 for a hexagon).
:param rotation: Apply an arbitrary rotation to the polygon :param rotation: Apply an arbitrary rotation to the polygon
(e.g. rotation=90, applies a 90 degree rotation) (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. :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) .. py:method:: ImageDraw.rectangle(xy, fill=None, outline=None, width=1)

View File

@ -242,12 +242,10 @@ class ImageDraw:
if ink is not None and ink != fill: if ink is not None and ink != fill:
self.draw.draw_polygon(xy, ink, 0) self.draw.draw_polygon(xy, ink, 0)
def regular_polygon(self, bbox, n_sides, rotation=0, fill=None, outline=None): def regular_polygon(self, b_circle, n_sides, rotation=0, fill=None, outline=None):
"""Draw a regular polygon.""" """Draw a regular polygon."""
xy = _compute_regular_polygon_vertices( xy = _compute_regular_polygon_vertices(b_circle, n_sides, rotation)
n_sides=n_sides, bbox=bbox, rotation=rotation self.polygon(xy, fill, outline)
)
self.polygon(xy, fill=fill, outline=outline)
def rectangle(self, xy, fill=None, outline=None, width=1): def rectangle(self, xy, fill=None, outline=None, width=1):
"""Draw a rectangle.""" """Draw a rectangle."""
@ -562,14 +560,14 @@ def floodfill(image, xy, value, border=None, thresh=0):
edge = new_edge edge = new_edge
def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation): def _compute_regular_polygon_vertices(b_circle, n_sides, rotation):
""" """
Generate a list of vertices for a 2D regular polygon. Generate a list of vertices for a 2D regular polygon.
:param b_circle: A bounding circle which inscribes the polygon
(e.g. b_circle = [x0, y0, r])
:param n_sides: Number of sides :param n_sides: Number of sides
(e.g. n_sides = 3 for a triangle, 6 for a hexagon, etc..) (e.g. n_sides = 3 for a triangle, 6 for a hexagon)
:param bbox: A bounding box (square) which inscribes the polygon
(e.g. bbox = [(50, 50), (150, 150)] or [50, 50, 150, 150])
:param rotation: Apply an arbitrary rotation to the polygon :param rotation: Apply an arbitrary rotation to the polygon
(e.g. rotation=90, applies a 90 degree rotation) (e.g. rotation=90, applies a 90 degree rotation)
:return: List of regular polygon vertices :return: List of regular polygon vertices
@ -579,8 +577,8 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation):
1. Compute the following variables 1. Compute the following variables
- theta: Angle between the apothem & the nearest polygon vertex - theta: Angle between the apothem & the nearest polygon vertex
- side_length: Length of each polygon edge - side_length: Length of each polygon edge
- centroid: Center of bounding box - centroid: Center of bounding circle (1st, 2nd elements of b_circle)
- polygon_radius: Distance between centroid and each polygon vertex - polygon_radius: Polygon radius (3rd element of b_circle)
- angles: Location of each polygon vertex in polar grid - 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]) (e.g. A square with 0 degree rotation => [225.0, 315.0, 45.0, 135.0])
@ -607,44 +605,27 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation):
if n_sides < 3: if n_sides < 3:
raise ValueError("n_sides should be an int > 2") raise ValueError("n_sides should be an int > 2")
# 1.2 Check `bbox` has an appropriate value # 1.2 Check `b_circle` has an appropriate value
if not isinstance(bbox, (list, tuple)): if not isinstance(b_circle, (list, tuple)):
raise TypeError("bbox should be a list/tuple") raise TypeError("b_circle should be a list/tuple")
if not len(bbox) == 2 and not len(bbox) == 4: if not len(b_circle) == 3:
raise ValueError( raise ValueError(
"bbox should have the following format " "b_circle should contain 2D coordinates and a radius (e.g. [x0, y0, r])"
"[(x0, y0), (x1, y1)] or [x0, y0, x1, y1]"
)
# Flatten bbox if [(x0, y0), (x1, y1)] format used.
if len(bbox) == 2:
bbox = [pt for corner in bbox for pt in corner]
if not len(bbox) == 4:
raise ValueError(
"bbox should contain top-left and bottom-right coordinates (2D)"
) )
if not all(isinstance(i, (int, float)) for i in bbox): if not all(isinstance(i, (int, float)) for i in b_circle):
raise ValueError("bbox should only contain numeric data") raise ValueError("b_circle should only contain numeric data")
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: if b_circle[-1] <= 0:
raise ValueError( raise ValueError("b_circle radius should be > 0")
"bbox: Bottom-right coordinate should be larger than top-left coordinate"
)
if not bbox[2] - bbox[0] == bbox[3] - bbox[1]:
raise ValueError("bbox should be a square")
# 1.3 Check `rotation` has an appropriate value # 1.3 Check `rotation` has an appropriate value
if not isinstance(rotation, (int, float)): if not isinstance(rotation, (int, float)):
raise ValueError("rotation should be an int or float") raise ValueError("rotation should be an int or float")
# 2. Define Helper Functions # 2. Define Helper Functions
def _get_centroid(*, bbox): def _apply_rotation(point, degrees, centroid):
return (bbox[2] + bbox[0]) * 0.5, (bbox[3] + bbox[1]) * 0.5
def _apply_rotation(*, point, degrees, centroid):
return ( return (
round( round(
point[0] * math.cos(math.radians(360 - degrees)) point[0] * math.cos(math.radians(360 - degrees))
@ -660,21 +641,11 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation):
), ),
) )
def _get_theta(*, n_sides): def _compute_polygon_vertex(centroid, angle, polygon_radius):
return 0.5 * (360 / n_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] start_point = [polygon_radius, 0]
return _apply_rotation(point=start_point, degrees=angle, centroid=centroid) return _apply_rotation(start_point, angle, centroid)
def _get_side_length(*, bbox, theta): def _get_angles(n_sides, rotation):
h = bbox[3] - bbox[1]
return h * math.sin(math.radians(theta))
def _get_angles(*, n_sides, rotation):
angles = [] angles = []
degrees = 360 / n_sides degrees = 360 / n_sides
# Start with the bottom left polygon vertex # Start with the bottom left polygon vertex
@ -688,19 +659,12 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation):
# 3. Variable Declarations # 3. Variable Declarations
vertices = [] vertices = []
theta = _get_theta(n_sides=n_sides) *centroid, polygon_radius = b_circle
side_length = _get_side_length(theta=theta, bbox=bbox) angles = _get_angles(n_sides, rotation)
centroid = _get_centroid(bbox=bbox)
polygon_radius = _get_polygon_radius(side_length=side_length, theta=theta)
angles = _get_angles(n_sides=n_sides, rotation=rotation)
# 4. Compute Vertices # 4. Compute Vertices
for angle in angles: for angle in angles:
vertices.append( vertices.append(_compute_polygon_vertex(centroid, angle, polygon_radius))
_compute_polygon_vertex(
centroid=centroid, angle=angle, polygon_radius=polygon_radius
)
)
return vertices return vertices