From 0ed01dd964665cdda08069a4ec531b32efb1c191 Mon Sep 17 00:00:00 2001 From: Tommy C Date: Sun, 9 Aug 2020 21:43:37 +0100 Subject: [PATCH 1/4] Add `ImageDraw.regular_polygon` --- ...olygon__octagon_with_0_degree_rotation.png | Bin 0 -> 635 bytes ...polygon__square_with_0_degree_rotation.png | Bin 0 -> 327 bytes ...olygon__square_with_45_degree_rotation.png | Bin 0 -> 844 bytes Tests/test_imagedraw.py | 98 +++++++++++++ docs/reference/ImageDraw.rst | 16 ++ src/PIL/ImageDraw.py | 137 ++++++++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 Tests/images/imagedraw_regular_polygon__octagon_with_0_degree_rotation.png create mode 100644 Tests/images/imagedraw_regular_polygon__square_with_0_degree_rotation.png create mode 100644 Tests/images/imagedraw_regular_polygon__square_with_45_degree_rotation.png diff --git a/Tests/images/imagedraw_regular_polygon__octagon_with_0_degree_rotation.png b/Tests/images/imagedraw_regular_polygon__octagon_with_0_degree_rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..6e529a1a47754d45e09064db8f369748fc0430c9 GIT binary patch literal 635 zcmeAS@N?(olHy`uVBq!ia0vp^DIm zz1;X4gCwJ}Xx+7p2@?&17N+;HND3;8mVMKhFwrnb(b%QoOvA*Y`A4Nw)Ox18_L-+( zoV4Ul+GnPjlFCnw7PFt=lFo6+)m|`X-}AH7^G&sfr0UH@Mpa! z#{(AEw@eljFnG86p06&4#QV^{>9GA`ct=I+y`hKq66_=+74 zY5zRNi;^+|C?bF@+>XCE5|! z%sr;pf8JctJ~zI$y+P=D=Jj&>O%5O57ajNC@9gmLb=mg({Z||}zAs#ES)baNbp5qn z@&7lhOZTpkomRugxD@E8sdWY{OZP0Ad$lHrU3LGX*sFF{j8FHjlHF=^n(67jS9iDA z#jre$x$LJi?_1NSm6hUQ{r4RWZ#6#sJz;9#nY8V7j$4(wUY?Q5=LvT*oPYJT=sPYA zJ>#ll8JS?~#kvF`jJ@GID;hcg@7WL+9jb1M^&_TrmE= zW2c3X*N@2RSXEiZrKcs!{~ezg7PC!^TVUfqMwYjh$Fu(g>;xt%22WQ%mvv4FO#r)+ B0Z;${ literal 0 HcmV?d00001 diff --git a/Tests/images/imagedraw_regular_polygon__square_with_0_degree_rotation.png b/Tests/images/imagedraw_regular_polygon__square_with_0_degree_rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..0c94dd42e59c11cf64810a5a54245f7e7eb48694 GIT binary patch literal 327 zcmeAS@N?(olHy`uVBq!ia0vp^DImt{`_XN;S^H#=ut3iI>Ld$SkoKX{;2$O>WMZil9bP0l+XkK9Ia@r literal 0 HcmV?d00001 diff --git a/Tests/images/imagedraw_regular_polygon__square_with_45_degree_rotation.png b/Tests/images/imagedraw_regular_polygon__square_with_45_degree_rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..aef8dba52c8da50fec657c7fda8af0b52cab710f GIT binary patch literal 844 zcmV-S1GD^zP)3U zD}a0=NTaBO?3FKzq6~6Yx+ID!NN?G4D2gDXB}<{Gfm|(D21N;Ewp0le6%ea3=}{Cw ztV^UuVF&Rlj~0a)#Je;~6jqR^vglA4L842dLSX~RDu)Jz2_(Ce?AHgt142;-2T49F zFs`M$w-J!Sm`hiGyQlhYo1KnlY)hkc%b6oyZBdp!av4Bu?_y9ZJjG5P9w3#2e& z^Vat#NMYpUulMKHH!CT50slY>gCnme3WF+7EO0E|N@+i`3WXz&|9`0$N^Ty&_n*Cm zl9RvQxBf9Kl-RuWy?5!PP-61c^U=k{Lh;RJzh{@<3&kh9z4p98RVcPO?6dbR+(NO* zX^$f}ITXrVy80Wv4X9AArMtH)H_8>tScdw#dMjz6^kulGnVYQ(~H;8^j%TR_Vze9;BF|U`+6O%aTgTxJv|Sf zSsle?Kkwt2Rz-2!E8+mN)lgjbi8|tM6%_Y9A`ih-M+uof`j}W%l(4xo4pLS_37t3V zsBsmP@HsON<7Y=nnJ@d}Dp*m{=E?>E_)t>kVE_SmP}1jM0Ri&M$bNkQTp&PRDHIq0 z9|#a%4h;st2?E5GM1=wHf&l(y(P04GAb?+Klo$X%2w-0xEe4 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 diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 3faa0d3e0..8690b4de9 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -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. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 33cb67923..b21cade7d 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, *, 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. From ac7d41545dfe9895972bda68c96729738382fd30 Mon Sep 17 00:00:00 2001 From: Tommy C Date: Sun, 16 Aug 2020 21:07:16 +0100 Subject: [PATCH 2/4] Update `regular_polygon` args + arg checks Summary of changes - Allow positional args in `regular_polygon` method - Allow multiple bounding box formats - (e.g. bbox = [(x0, y0), (x1, y1)] or [x0, y0, x1, y1]) - Check if bounding box is square - Update var names - b_box => bbox - nb_sides => n_sides --- Tests/test_imagedraw.py | 41 +++++++++++------- docs/reference/ImageDraw.rst | 14 +++--- src/PIL/ImageDraw.py | 82 +++++++++++++++++++++--------------- 3 files changed, 79 insertions(+), 58 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index b25fa3f6f..7a530aa1d 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1095,10 +1095,10 @@ def test_same_color_outline(): @pytest.mark.parametrize( - "nb_polygon_sides, rotation, polygon_name", + "n_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): +def test_draw_regular_polygon(n_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}" @@ -1106,13 +1106,13 @@ def test_draw_regular_polygon(nb_polygon_sides, rotation, polygon_name): ) draw = ImageDraw.Draw(im) draw.regular_polygon( - b_box=[(0, 0), (W, H)], nb_sides=nb_polygon_sides, rotation=rotation, fill="red" + [(0, 0), (W, H)], n_polygon_sides, rotation=rotation, fill="red" ) assert_image_equal(im, Image.open(filename)) @pytest.mark.parametrize( - "nb_polygon_sides, expected_vertices", + "n_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)]), @@ -1139,40 +1139,49 @@ def test_draw_regular_polygon(nb_polygon_sides, rotation, polygon_name): ), ], ) -def test_compute_regular_polygon_vertices(nb_polygon_sides, expected_vertices): +def test_compute_regular_polygon_vertices(n_polygon_sides, expected_vertices): vertices = ImageDraw._compute_regular_polygon_vertices( - nb_sides=nb_polygon_sides, b_box=[(0, 0), (100, 100)], rotation=0 + n_sides=n_polygon_sides, bbox=[(0, 0), (100, 100)], rotation=0 ) assert vertices == expected_vertices @pytest.mark.parametrize( - "nb_polygon_sides, bounding_box, rotation, expected_error, error_message", + "n_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"), + (None, [(0, 0), (100, 100)], 0, TypeError, "n_sides should be an int"), + (1, [(0, 0), (100, 100)], 0, ValueError, "n_sides should be an int > 2"), + (3, 100, 0, TypeError, "bbox should be a list/tuple"), ( 3, - [(0, 0), (50, 50), (100, 100)], + [(0, 0), (100,), (100,)], 0, ValueError, - "b_box should have 2 items (top-left & bottom-right coordinates)", + "bbox should have the following format " + "[(x0, y0), (x1, y1)] or [x0, y0, x1, y1]", + ), + ( + 3, + [(50, 50), (100,)], + 0, + ValueError, + "bbox should contain top-left and bottom-right coordinates (2D)", ), ( 3, [(50, 50), (0, None)], 0, ValueError, - "b_box should only contain numeric data", + "bbox should only contain numeric data", ), ( 3, [(50, 50), (0, 0)], 0, ValueError, - "b_box: Bottom-right coordinate should be larger than top-left coordinate", + "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)], @@ -1183,10 +1192,10 @@ def test_compute_regular_polygon_vertices(nb_polygon_sides, expected_vertices): ], ) def test_compute_regular_polygon_vertices_input_error_handling( - nb_polygon_sides, bounding_box, rotation, expected_error, error_message + n_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 + n_sides=n_polygon_sides, bbox=bounding_box, rotation=rotation ) assert str(e.value) == error_message diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 8690b4de9..6bfe4cdc0 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -255,15 +255,15 @@ Methods :param fill: Color to use for the fill. -.. 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) - Draws a regular polygon inscribed in ``b_box``, - with ``nb_sides``, and rotation of ``rotation`` degrees + Draws a regular polygon inscribed in ``bbox``, + with ``n_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 bbox: A bounding box which inscribes the polygon + (e.g. bbox = [(50, 50), (150, 150)]) + :param n_sides: Number of sides + (e.g. n_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. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index b21cade7d..a64325ac6 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -242,10 +242,10 @@ 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): + def regular_polygon(self, bbox, n_sides, 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 + n_sides=n_sides, bbox=bbox, rotation=rotation ) self.polygon(xy, fill=fill, outline=outline) @@ -562,14 +562,14 @@ def floodfill(image, xy, value, border=None, thresh=0): edge = new_edge -def _compute_regular_polygon_vertices(*, nb_sides, b_box, rotation): +def _compute_regular_polygon_vertices(*, n_sides, bbox, 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 n_sides: Number of sides + (e.g. n_sides = 3 for a triangle, 6 for a hexagon, etc..) + :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 (e.g. rotation=90, applies a 90 degree rotation) :return: List of regular polygon vertices @@ -601,36 +601,48 @@ def _compute_regular_polygon_vertices(*, nb_sides, b_box, rotation): 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.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 `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: + # 1.2 Check `bbox` has an appropriate value + if not isinstance(bbox, (list, tuple)): + raise TypeError("bbox should be a list/tuple") + + if not len(bbox) == 2 and not len(bbox) == 4: raise ValueError( - "b_box should have 2 items (top-left & bottom-right coordinates)" + "bbox should have the following format " + "[(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)" ) - 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 not all(isinstance(i, (int, float)) for i in bbox): + raise ValueError("bbox should only contain numeric data") - if b_box[1][1] <= b_box[0][1] or b_box[1][0] <= b_box[0][0]: + if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: raise ValueError( - "b_box: Bottom-right coordinate should be larger than top-left coordinate" + "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 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 _get_centroid(*, bbox): + return (bbox[2] + bbox[0]) * 0.5, (bbox[3] + bbox[1]) * 0.5 def _apply_rotation(*, point, degrees, centroid): return ( @@ -648,8 +660,8 @@ def _compute_regular_polygon_vertices(*, nb_sides, b_box, rotation): ), ) - def _get_theta(*, nb_sides): - return 0.5 * (360 / nb_sides) + def _get_theta(*, n_sides): + return 0.5 * (360 / n_sides) def _get_polygon_radius(*, side_length, theta): return (0.5 * side_length) / math.sin(math.radians(theta)) @@ -658,16 +670,16 @@ def _compute_regular_polygon_vertices(*, nb_sides, b_box, rotation): 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] + def _get_side_length(*, bbox, theta): + h = bbox[3] - bbox[1] return h * math.sin(math.radians(theta)) - def _get_angles(*, nb_sides, rotation): + def _get_angles(*, n_sides, rotation): angles = [] - degrees = 360 / nb_sides + degrees = 360 / n_sides # Start with the bottom left polygon vertex current_angle = (270 - 0.5 * degrees) + rotation - for _ in range(0, nb_sides): + for _ in range(0, n_sides): angles.append(current_angle) current_angle += degrees if current_angle > 360: @@ -676,11 +688,11 @@ def _compute_regular_polygon_vertices(*, nb_sides, b_box, rotation): # 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) + theta = _get_theta(n_sides=n_sides) + side_length = _get_side_length(theta=theta, bbox=bbox) + centroid = _get_centroid(bbox=bbox) polygon_radius = _get_polygon_radius(side_length=side_length, theta=theta) - angles = _get_angles(nb_sides=nb_sides, rotation=rotation) + angles = _get_angles(n_sides=n_sides, rotation=rotation) # 4. Compute Vertices for angle in angles: From df9329f9f0509f2e5544702726236b8720577c17 Mon Sep 17 00:00:00 2001 From: Tommy C Date: Thu, 20 Aug 2020 20:46:11 +0100 Subject: [PATCH 3/4] 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 --- Tests/images/imagedraw_regular_octagon.png | Bin 0 -> 491 bytes ...olygon__octagon_with_0_degree_rotation.png | Bin 635 -> 0 bytes ...polygon__square_with_0_degree_rotation.png | Bin 327 -> 0 bytes ...olygon__square_with_45_degree_rotation.png | Bin 844 -> 0 bytes Tests/images/imagedraw_square.png | Bin 0 -> 327 bytes Tests/images/imagedraw_square_rotate_45.png | Bin 0 -> 587 bytes Tests/test_imagedraw.py | 95 +++++++----------- docs/reference/ImageDraw.rst | 16 +-- src/PIL/ImageDraw.py | 86 +++++----------- 9 files changed, 69 insertions(+), 128 deletions(-) create mode 100644 Tests/images/imagedraw_regular_octagon.png delete mode 100644 Tests/images/imagedraw_regular_polygon__octagon_with_0_degree_rotation.png delete mode 100644 Tests/images/imagedraw_regular_polygon__square_with_0_degree_rotation.png delete mode 100644 Tests/images/imagedraw_regular_polygon__square_with_45_degree_rotation.png create mode 100644 Tests/images/imagedraw_square.png create mode 100644 Tests/images/imagedraw_square_rotate_45.png diff --git a/Tests/images/imagedraw_regular_octagon.png b/Tests/images/imagedraw_regular_octagon.png new file mode 100644 index 0000000000000000000000000000000000000000..7f215dc08bff7a57f70f98f2c60927133ba623e1 GIT binary patch literal 491 zcmeAS@N?(olHy`uVBq!ia0vp^DIm=oBnT%KB29mZDq^kIaE=#wJ{JwybbJXvTV!khBgL8OaiQ>6r(E2AKv zQjUT{hjY!geW}tZe;NK~lwMZr=qTK}EtlKn*p8^1I~a@DdsaV=_HVw!`S{g~XOk7m zkL~E+-L}LPLlPE_R0mfXGh4nkX%k`ab1qS$N0L9~ z*_%d<9!cSpXJ-~#^hk=QJlo2yY?ziaVdiz2zN0f{R^IuU?rU%Om-A;SF>b?DQ_H}| XCVzZFyJQnE5*a*Q{an^LB{Ts537fn> literal 0 HcmV?d00001 diff --git a/Tests/images/imagedraw_regular_polygon__octagon_with_0_degree_rotation.png b/Tests/images/imagedraw_regular_polygon__octagon_with_0_degree_rotation.png deleted file mode 100644 index 6e529a1a47754d45e09064db8f369748fc0430c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 635 zcmeAS@N?(olHy`uVBq!ia0vp^DIm zz1;X4gCwJ}Xx+7p2@?&17N+;HND3;8mVMKhFwrnb(b%QoOvA*Y`A4Nw)Ox18_L-+( zoV4Ul+GnPjlFCnw7PFt=lFo6+)m|`X-}AH7^G&sfr0UH@Mpa! z#{(AEw@eljFnG86p06&4#QV^{>9GA`ct=I+y`hKq66_=+74 zY5zRNi;^+|C?bF@+>XCE5|! z%sr;pf8JctJ~zI$y+P=D=Jj&>O%5O57ajNC@9gmLb=mg({Z||}zAs#ES)baNbp5qn z@&7lhOZTpkomRugxD@E8sdWY{OZP0Ad$lHrU3LGX*sFF{j8FHjlHF=^n(67jS9iDA z#jre$x$LJi?_1NSm6hUQ{r4RWZ#6#sJz;9#nY8V7j$4(wUY?Q5=LvT*oPYJT=sPYA zJ>#ll8JS?~#kvF`jJ@GID;hcg@7WL+9jb1M^&_TrmE= zW2c3X*N@2RSXEiZrKcs!{~ezg7PC!^TVUfqMwYjh$Fu(g>;xt%22WQ%mvv4FO#r)+ B0Z;${ diff --git a/Tests/images/imagedraw_regular_polygon__square_with_0_degree_rotation.png b/Tests/images/imagedraw_regular_polygon__square_with_0_degree_rotation.png deleted file mode 100644 index 0c94dd42e59c11cf64810a5a54245f7e7eb48694..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 327 zcmeAS@N?(olHy`uVBq!ia0vp^DImt{`_XN;S^H#=ut3iI>Ld$SkoKX{;2$O>WMZil9bP0l+XkK9Ia@r diff --git a/Tests/images/imagedraw_regular_polygon__square_with_45_degree_rotation.png b/Tests/images/imagedraw_regular_polygon__square_with_45_degree_rotation.png deleted file mode 100644 index aef8dba52c8da50fec657c7fda8af0b52cab710f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 844 zcmV-S1GD^zP)3U zD}a0=NTaBO?3FKzq6~6Yx+ID!NN?G4D2gDXB}<{Gfm|(D21N;Ewp0le6%ea3=}{Cw ztV^UuVF&Rlj~0a)#Je;~6jqR^vglA4L842dLSX~RDu)Jz2_(Ce?AHgt142;-2T49F zFs`M$w-J!Sm`hiGyQlhYo1KnlY)hkc%b6oyZBdp!av4Bu?_y9ZJjG5P9w3#2e& z^Vat#NMYpUulMKHH!CT50slY>gCnme3WF+7EO0E|N@+i`3WXz&|9`0$N^Ty&_n*Cm zl9RvQxBf9Kl-RuWy?5!PP-61c^U=k{Lh;RJzh{@<3&kh9z4p98RVcPO?6dbR+(NO* zX^$f}ITXrVy80Wv4X9AArMtH)H_8>tScdw#dMjz6^kulGnVYQ(~H;8^j%TR_Vze9;BF|U`+6O%aTgTxJv|Sf zSsle?Kkwt2Rz-2!E8+mN)lgjbi8|tM6%_Y9A`ih-M+uof`j}W%l(4xo4pLS_37t3V zsBsmP@HsON<7Y=nnJ@d}Dp*m{=E?>E_)t>kVE_SmP}1jM0Ri&M$bNkQTp&PRDHIq0 z9|#a%4h;st2?E5GM1=wHf&l(y(P04GAb?+Klo$X%2w-0xEe4Z-UbH_2FG(Z z>QDdL#-vuboYBL~#`3&NQLg@k^-T#}O`06b6lRdm*tuPNhU8n4(x^%dCvj5Ea6EP)a!g{xU7f;ro?`{-obSl`(T5_rKKH2{G*aDTtKc!bVrie^P z?_KHc$x!*NBTK~kMC{|tz%vTv{mWc7Hu>}}bvA7B>0jcsu`#E2v7=#QPXDIAH3yg% zNiZLjWt;F-P%dh3y7ZB@!mVZ3)jC8UXFc7gD`0)>>W{h73Yrq(i@*CdFG-B)y=%)k zsjJYm=)Z`E)v>E@*C%s5Nqu2iUjF;_j6YMS_8ArZ|04f2WXi_f=J&oWTiA1N_t%34 ztf_y_on7zuMnn61ZvD;-*T~)3(kwL^tG`P)9BAHD*UrlF?f8v4y9Z2-djmxz9xyll zEHsgLz|y#LVu-{8*2dq*FC{iev%Hh`?PRPIsQ6bA#dCt8@pPm96A6Q-tDnm{r-UW|^8WOV literal 0 HcmV?d00001 diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 7a530aa1d..df81b3c46 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1095,107 +1095,84 @@ def test_same_color_outline(): @pytest.mark.parametrize( - "n_polygon_sides, rotation, polygon_name", - [(4, 0, "square"), (8, 0, "octagon"), (4, 45, "square")], + "n_sides, rotation, polygon_name", + [(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)) + filename_base = f"Tests/images/imagedraw_{polygon_name}" filename = ( - f"Tests/images/imagedraw_regular_polygon__{polygon_name}" - f"_with_{rotation}_degree_rotation.png" + f"{filename_base}.png" + if rotation == 0 + else f"{filename_base}_rotate_{rotation}.png" ) draw = ImageDraw.Draw(im) - draw.regular_polygon( - [(0, 0), (W, H)], n_polygon_sides, rotation=rotation, fill="red" - ) + draw.regular_polygon([W // 2, H // 2, 25], n_sides, rotation=rotation, fill="red") assert_image_equal(im, Image.open(filename)) @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)]), - (4, [(14.64, 85.36), (85.36, 85.36), (85.36, 14.64), (14.64, 14.64)]), + (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, [ - (20.61, 90.45), - (79.39, 90.45), - (97.55, 34.55), - (50.0, 0.0), - (2.45, 34.55), + (35.31, 70.23), + (64.69, 70.23), + (73.78, 42.27), + (50.0, 25.0), + (26.22, 42.27), ], ), ( 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), + (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_polygon_sides, expected_vertices): +def test_compute_regular_polygon_vertices(n_sides, expected_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 @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"), - (1, [(0, 0), (100, 100)], 0, ValueError, "n_sides should be an int > 2"), - (3, 100, 0, TypeError, "bbox should be a list/tuple"), + (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, "b_circle should be a list/tuple"), ( 3, - [(0, 0), (100,), (100,)], + [50, 50, 100, 100], 0, ValueError, - "bbox should have the following format " - "[(x0, y0), (x1, y1)] or [x0, y0, x1, y1]", + "b_circle should contain 2D coordinates and a radius (e.g. [x0, y0, r])", ), ( 3, - [(50, 50), (100,)], + [50, 50, "25"], 0, ValueError, - "bbox should contain top-left and bottom-right coordinates (2D)", - ), - ( - 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", + "b_circle should only contain numeric data", ), + (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( - 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: - ImageDraw._compute_regular_polygon_vertices( - n_sides=n_polygon_sides, bbox=bounding_box, rotation=rotation - ) + ImageDraw._compute_regular_polygon_vertices(b_circle, n_sides, rotation) assert str(e.value) == error_message diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 6bfe4cdc0..3f740110e 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -255,19 +255,19 @@ Methods :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``, - with ``n_sides``, and rotation of ``rotation`` degrees + Draws a regular polygon inscribed in ``b_circle``, + with ``n_sides``, and rotation of ``rotation`` degrees. - :param bbox: A bounding box which inscribes the polygon - (e.g. bbox = [(50, 50), (150, 150)]) + :param b_circle: A bounding circle which inscribes the polygon + (e.g. b_circle=[50, 50, 25]). :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 - (e.g. rotation=90, applies a 90 degree rotation) - :param outline: Color to use for the outline. + (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) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index a64325ac6..1ef1fd4f2 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -242,12 +242,10 @@ class ImageDraw: if ink is not None and ink != fill: 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.""" - xy = _compute_regular_polygon_vertices( - n_sides=n_sides, bbox=bbox, rotation=rotation - ) - self.polygon(xy, fill=fill, outline=outline) + xy = _compute_regular_polygon_vertices(b_circle, n_sides, rotation) + self.polygon(xy, fill, outline) def rectangle(self, xy, fill=None, outline=None, width=1): """Draw a rectangle.""" @@ -562,14 +560,14 @@ def floodfill(image, xy, value, border=None, thresh=0): 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. + :param b_circle: A bounding circle which inscribes the polygon + (e.g. b_circle = [x0, y0, r]) :param n_sides: Number of sides - (e.g. n_sides = 3 for a triangle, 6 for a hexagon, etc..) - :param bbox: A bounding box (square) which inscribes the polygon - (e.g. bbox = [(50, 50), (150, 150)] or [50, 50, 150, 150]) + (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 @@ -579,8 +577,8 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation): 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 + - centroid: Center of bounding circle (1st, 2nd elements of b_circle) + - polygon_radius: Polygon radius (3rd element of b_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]) @@ -607,44 +605,27 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation): if n_sides < 3: raise ValueError("n_sides should be an int > 2") - # 1.2 Check `bbox` has an appropriate value - if not isinstance(bbox, (list, tuple)): - raise TypeError("bbox should be a list/tuple") + # 1.2 Check `b_circle` has an appropriate value + if not isinstance(b_circle, (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( - "bbox should have the following format " - "[(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)" + "b_circle should contain 2D coordinates and a radius (e.g. [x0, y0, r])" ) - if not all(isinstance(i, (int, float)) for i in bbox): - raise ValueError("bbox should only contain numeric data") + if not all(isinstance(i, (int, float)) for i in b_circle): + raise ValueError("b_circle should only contain numeric data") - if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: - raise ValueError( - "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") + if b_circle[-1] <= 0: + raise ValueError("b_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 _get_centroid(*, bbox): - return (bbox[2] + bbox[0]) * 0.5, (bbox[3] + bbox[1]) * 0.5 - - def _apply_rotation(*, point, degrees, centroid): + def _apply_rotation(point, degrees, centroid): return ( round( 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): - 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): + def _compute_polygon_vertex(centroid, angle, polygon_radius): 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): - h = bbox[3] - bbox[1] - return h * math.sin(math.radians(theta)) - - def _get_angles(*, n_sides, rotation): + def _get_angles(n_sides, rotation): angles = [] degrees = 360 / n_sides # Start with the bottom left polygon vertex @@ -688,19 +659,12 @@ def _compute_regular_polygon_vertices(*, n_sides, bbox, rotation): # 3. Variable Declarations vertices = [] - theta = _get_theta(n_sides=n_sides) - side_length = _get_side_length(theta=theta, bbox=bbox) - 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) + *centroid, polygon_radius = b_circle + angles = _get_angles(n_sides, rotation) # 4. Compute Vertices for angle in angles: - vertices.append( - _compute_polygon_vertex( - centroid=centroid, angle=angle, polygon_radius=polygon_radius - ) - ) + vertices.append(_compute_polygon_vertex(centroid, angle, polygon_radius)) return vertices From b142560488c77acc5319f98964224b4c576f58ef Mon Sep 17 00:00:00 2001 From: Tommy C Date: Fri, 21 Aug 2020 10:12:00 +0100 Subject: [PATCH 4/4] Rename `b_circle` and `bounding_circle` + accept ((x0, y0), r) Summary of changes - Rename `b_circle` and `bounding_circle` -`bounding_circle` now accepts both formats below: - (x0, y0, r) - ((x0, y0), r) --- Tests/test_imagedraw.py | 40 +++++++++++++---------- docs/reference/ImageDraw.rst | 14 +++++---- src/PIL/ImageDraw.py | 61 +++++++++++++++++++++--------------- 3 files changed, 68 insertions(+), 47 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index df81b3c46..78fdec9c1 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1107,7 +1107,8 @@ def test_draw_regular_polygon(n_sides, rotation, polygon_name): else f"{filename_base}_rotate_{rotation}.png" ) draw = ImageDraw.Draw(im) - draw.regular_polygon([W // 2, H // 2, 25], n_sides, rotation=rotation, fill="red") + 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)) @@ -1140,39 +1141,46 @@ def test_draw_regular_polygon(n_sides, rotation, polygon_name): ], ) def test_compute_regular_polygon_vertices(n_sides, expected_vertices): - vertices = ImageDraw._compute_regular_polygon_vertices( - [W // 2, H // 2, 25], n_sides, 0 - ) + 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, b_circle, rotation, expected_error, error_message", + "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, "b_circle should be a list/tuple"), + (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], + (50, 50, 100, 100), 0, ValueError, - "b_circle should contain 2D coordinates and a radius (e.g. [x0, y0, r])", + "bounding_circle should contain 2D coordinates " + "and a radius (e.g. (x, y, r) or ((x, y), r) )", ), ( 3, - [50, 50, "25"], + (50, 50, "25"), 0, ValueError, - "b_circle should only contain numeric data", + "bounding_circle should only contain numeric data", ), - (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",), + ( + 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, b_circle, rotation, expected_error, error_message + n_sides, bounding_circle, rotation, expected_error, error_message ): with pytest.raises(expected_error) as e: - ImageDraw._compute_regular_polygon_vertices(b_circle, n_sides, rotation) + 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 3f740110e..547172c39 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -255,17 +255,19 @@ Methods :param fill: Color to use for the fill. -.. py:method:: ImageDraw.regular_polygon(b_circle, n_sides, rotation=0, fill=None, outline=None) +.. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None) - Draws a regular polygon inscribed in ``b_circle``, + Draws a regular polygon inscribed in ``bounding_circle``, with ``n_sides``, and rotation of ``rotation`` degrees. - :param b_circle: A bounding circle which inscribes the polygon - (e.g. b_circle=[50, 50, 25]). + :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). + (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). + (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. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 1ef1fd4f2..521f6da9e 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -242,9 +242,11 @@ class ImageDraw: if ink is not None and ink != fill: self.draw.draw_polygon(xy, ink, 0) - def regular_polygon(self, b_circle, n_sides, rotation=0, fill=None, outline=None): + def regular_polygon( + self, bounding_circle, n_sides, rotation=0, fill=None, outline=None + ): """Draw a regular polygon.""" - xy = _compute_regular_polygon_vertices(b_circle, n_sides, rotation) + 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): @@ -560,25 +562,26 @@ def floodfill(image, xy, value, border=None, thresh=0): edge = new_edge -def _compute_regular_polygon_vertices(b_circle, n_sides, rotation): +def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): """ 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 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) + (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) + (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)]) + (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 b_circle) - - polygon_radius: Polygon radius (3rd element of b_circle) + - 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]) @@ -605,20 +608,30 @@ def _compute_regular_polygon_vertices(b_circle, n_sides, rotation): if n_sides < 3: raise ValueError("n_sides should be an int > 2") - # 1.2 Check `b_circle` has an appropriate value - if not isinstance(b_circle, (list, tuple)): - raise TypeError("b_circle should be a list/tuple") + # 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 not len(b_circle) == 3: + if len(bounding_circle) == 3: + *centroid, polygon_radius = bounding_circle + elif len(bounding_circle) == 2: + centroid, polygon_radius = bounding_circle + else: raise ValueError( - "b_circle should contain 2D coordinates and a radius (e.g. [x0, y0, r])" + "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 b_circle): - raise ValueError("b_circle should only contain numeric data") + if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): + raise ValueError("bounding_circle should only contain numeric data") - if b_circle[-1] <= 0: - raise ValueError("b_circle radius should be > 0") + 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)): @@ -641,7 +654,7 @@ def _compute_regular_polygon_vertices(b_circle, n_sides, rotation): ), ) - def _compute_polygon_vertex(centroid, angle, polygon_radius): + def _compute_polygon_vertex(centroid, polygon_radius, angle): start_point = [polygon_radius, 0] return _apply_rotation(start_point, angle, centroid) @@ -658,14 +671,12 @@ def _compute_regular_polygon_vertices(b_circle, n_sides, rotation): return angles # 3. Variable Declarations - vertices = [] - *centroid, polygon_radius = b_circle angles = _get_angles(n_sides, rotation) # 4. Compute Vertices - for angle in angles: - vertices.append(_compute_polygon_vertex(centroid, angle, polygon_radius)) - return vertices + return [ + _compute_polygon_vertex(centroid, polygon_radius, angle) for angle in angles + ] def _color_diff(color1, color2):