diff --git a/Tests/images/imagedraw_regular_octagon.png b/Tests/images/imagedraw_regular_octagon.png new file mode 100644 index 000000000..7f215dc08 Binary files /dev/null and b/Tests/images/imagedraw_regular_octagon.png differ diff --git a/Tests/images/imagedraw_square.png b/Tests/images/imagedraw_square.png new file mode 100644 index 000000000..fd75f2f3b Binary files /dev/null and b/Tests/images/imagedraw_square.png differ diff --git a/Tests/images/imagedraw_square_rotate_45.png b/Tests/images/imagedraw_square_rotate_45.png new file mode 100644 index 000000000..8ab0e3c18 Binary files /dev/null and b/Tests/images/imagedraw_square_rotate_45.png differ diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 271a1629d..1cd878e1c 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1093,3 +1093,95 @@ def test_same_color_outline(): # Assert expected = f"Tests/images/imagedraw_outline_{operation}_{mode}.png" 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 diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 3faa0d3e0..547172c39 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -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)``). + 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. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 6775ba43e..47d970aef 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -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, 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) @@ -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.