mirror of
https://github.com/python-pillow/Pillow.git
synced 2024-12-25 17:36:18 +03:00
Merge pull request #4846 from comhar/features/compute_polygon_coordinates
This commit is contained in:
commit
3dba4ee10a
BIN
Tests/images/imagedraw_regular_octagon.png
Normal file
BIN
Tests/images/imagedraw_regular_octagon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 491 B |
BIN
Tests/images/imagedraw_square.png
Normal file
BIN
Tests/images/imagedraw_square.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 327 B |
BIN
Tests/images/imagedraw_square_rotate_45.png
Normal file
BIN
Tests/images/imagedraw_square_rotate_45.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 587 B |
|
@ -1093,3 +1093,95 @@ def test_same_color_outline():
|
||||||
# Assert
|
# Assert
|
||||||
expected = f"Tests/images/imagedraw_outline_{operation}_{mode}.png"
|
expected = f"Tests/images/imagedraw_outline_{operation}_{mode}.png"
|
||||||
assert_image_similar_tofile(im, expected, 1)
|
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
|
||||||
|
|
|
@ -254,6 +254,24 @@ Methods
|
||||||
:param outline: Color to use for the outline.
|
:param outline: Color to use for the outline.
|
||||||
:param fill: Color to use for the fill.
|
: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)
|
.. py:method:: ImageDraw.rectangle(xy, fill=None, outline=None, width=1)
|
||||||
|
|
||||||
Draws a rectangle.
|
Draws a rectangle.
|
||||||
|
|
|
@ -242,6 +242,13 @@ 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, 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):
|
def rectangle(self, xy, fill=None, outline=None, width=1):
|
||||||
"""Draw a rectangle."""
|
"""Draw a rectangle."""
|
||||||
ink, fill = self._getink(outline, fill)
|
ink, fill = self._getink(outline, fill)
|
||||||
|
@ -555,6 +562,123 @@ def floodfill(image, xy, value, border=None, thresh=0):
|
||||||
edge = new_edge
|
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):
|
def _color_diff(color1, color2):
|
||||||
"""
|
"""
|
||||||
Uses 1-norm distance to calculate difference between two values.
|
Uses 1-norm distance to calculate difference between two values.
|
||||||
|
|
Loading…
Reference in New Issue
Block a user