From 0ed01dd964665cdda08069a4ec531b32efb1c191 Mon Sep 17 00:00:00 2001 From: Tommy C Date: Sun, 9 Aug 2020 21:43:37 +0100 Subject: [PATCH] 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.