Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/PIL/ImageDraw.py: 25%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#
2# The Python Imaging Library
3# $Id$
4#
5# drawing interface operations
6#
7# History:
8# 1996-04-13 fl Created (experimental)
9# 1996-08-07 fl Filled polygons, ellipses.
10# 1996-08-13 fl Added text support
11# 1998-06-28 fl Handle I and F images
12# 1998-12-29 fl Added arc; use arc primitive to draw ellipses
13# 1999-01-10 fl Added shape stuff (experimental)
14# 1999-02-06 fl Added bitmap support
15# 1999-02-11 fl Changed all primitives to take options
16# 1999-02-20 fl Fixed backwards compatibility
17# 2000-10-12 fl Copy on write, when necessary
18# 2001-02-18 fl Use default ink for bitmap/text also in fill mode
19# 2002-10-24 fl Added support for CSS-style color strings
20# 2002-12-10 fl Added experimental support for RGBA-on-RGB drawing
21# 2002-12-11 fl Refactored low-level drawing API (work in progress)
22# 2004-08-26 fl Made Draw() a factory function, added getdraw() support
23# 2004-09-04 fl Added width support to line primitive
24# 2004-09-10 fl Added font mode handling
25# 2006-06-19 fl Added font bearing support (getmask2)
26#
27# Copyright (c) 1997-2006 by Secret Labs AB
28# Copyright (c) 1996-2006 by Fredrik Lundh
29#
30# See the README file for information on usage and redistribution.
31#
32from __future__ import annotations
34__lazy_modules__ = {"math", "struct"}
36import math
37import struct
38from typing import cast
40from . import Image, ImageColor, ImageFont, ImageText
42TYPE_CHECKING = False
43if TYPE_CHECKING:
44 from collections.abc import Callable, Sequence
45 from types import ModuleType
46 from typing import Any, AnyStr
48 from . import ImageDraw2
49 from ._typing import Coords, _Ink
51# experimental access to the outline API
52Outline: Callable[[], Image.core._Outline] = Image.core.outline
54"""
55A simple 2D drawing interface for PIL images.
56<p>
57Application code should use the <b>Draw</b> factory, instead of
58directly.
59"""
62class ImageDraw:
63 font: ImageFont.BaseImageFont | None = None
65 def __init__(self, im: Image.Image, mode: str | None = None) -> None:
66 """
67 Create a drawing instance.
69 :param im: The image to draw in.
70 :param mode: Optional mode to use for color values. For RGB
71 images, this argument can be RGB or RGBA (to blend the
72 drawing into the image). For all other modes, this argument
73 must be the same as the image mode. If omitted, the mode
74 defaults to the mode of the image.
75 """
76 im._ensure_mutable()
77 blend = 0
78 if mode is None:
79 mode = im.mode
80 if mode != im.mode:
81 if mode == "RGBA" and im.mode == "RGB":
82 blend = 1
83 else:
84 msg = "mode mismatch"
85 raise ValueError(msg)
86 if mode in {"P", "PA"}:
87 self.palette = im.palette
88 else:
89 self.palette = None
90 self._image = im
91 self.im = im.im
92 self.draw = Image.core.draw(self.im, blend)
93 self.mode = mode
94 if mode in ("I", "F"):
95 self.ink = self.draw.draw_ink(1)
96 else:
97 self.ink = self.draw.draw_ink(-1)
98 if mode in ("1", "P", "I", "F"):
99 # FIXME: fix Fill2 to properly support matte for I+F images
100 self.fontmode = "1"
101 else:
102 self.fontmode = "L" # aliasing is okay for other modes
103 self.fill = False
105 def getfont(
106 self,
107 ) -> ImageFont.BaseImageFont:
108 """
109 Get the current default font.
111 To set the default font for this ImageDraw instance::
113 from PIL import ImageDraw, ImageFont
114 draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf")
116 To set the default font for all future ImageDraw instances::
118 from PIL import ImageDraw, ImageFont
119 ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf")
121 If the current default font is ``None``,
122 it is initialized with ``ImageFont.load_default()``.
124 :returns: An image font."""
125 if not self.font:
126 # FIXME: should add a font repository
127 self.font = ImageFont.load_default()
128 return self.font
130 def _getfont(self, font_size: float | None) -> ImageFont.BaseImageFont:
131 if font_size is not None:
132 return ImageFont.load_default(font_size)
133 else:
134 return self.getfont()
136 def _getink(
137 self, ink: _Ink | None, fill: _Ink | None = None
138 ) -> tuple[int | None, int | None]:
139 result_ink = None
140 result_fill = None
141 if ink is None and fill is None:
142 if self.fill:
143 result_fill = self.ink
144 else:
145 result_ink = self.ink
146 else:
147 if ink is not None:
148 if isinstance(ink, str):
149 ink = ImageColor.getcolor(ink, self.mode)
150 if self.mode in {"P", "PA"} and isinstance(ink, tuple):
151 ink = self._image._get_palette_color(ink)
152 if isinstance(ink, tuple) and len(ink) == 4 and ink[3] == 0:
153 # Do not use transparent ink
154 result_ink = None
155 else:
156 result_ink = self.draw.draw_ink(ink)
157 if fill is not None:
158 if isinstance(fill, str):
159 fill = ImageColor.getcolor(fill, self.mode)
160 if self.mode in {"P", "PA"} and isinstance(fill, tuple):
161 fill = self._image._get_palette_color(fill)
162 result_fill = self.draw.draw_ink(fill)
163 return result_ink, result_fill
165 def arc(
166 self,
167 xy: Coords,
168 start: float,
169 end: float,
170 fill: _Ink | None = None,
171 width: int = 1,
172 ) -> None:
173 """Draw an arc."""
174 ink, fill = self._getink(fill)
175 if ink is not None and width != 0:
176 self.draw.draw_arc(xy, start, end, ink, width)
178 def bitmap(
179 self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None
180 ) -> None:
181 """Draw a bitmap."""
182 bitmap.load()
183 ink, fill = self._getink(fill)
184 if ink is None:
185 ink = fill
186 if ink is not None:
187 self.draw.draw_bitmap(xy, bitmap.im, ink)
189 def chord(
190 self,
191 xy: Coords,
192 start: float,
193 end: float,
194 fill: _Ink | None = None,
195 outline: _Ink | None = None,
196 width: int = 1,
197 ) -> None:
198 """Draw a chord."""
199 ink, fill_ink = self._getink(outline, fill)
200 if fill_ink is not None:
201 self.draw.draw_chord(xy, start, end, fill_ink, 1)
202 if ink is not None and ink != fill_ink and width != 0:
203 self.draw.draw_chord(xy, start, end, ink, 0, width)
205 def ellipse(
206 self,
207 xy: Coords,
208 fill: _Ink | None = None,
209 outline: _Ink | None = None,
210 width: int = 1,
211 ) -> None:
212 """Draw an ellipse."""
213 ink, fill_ink = self._getink(outline, fill)
214 if fill_ink is not None:
215 self.draw.draw_ellipse(xy, fill_ink, 1)
216 if ink is not None and ink != fill_ink and width != 0:
217 self.draw.draw_ellipse(xy, ink, 0, width)
219 def circle(
220 self,
221 xy: Sequence[float],
222 radius: float,
223 fill: _Ink | None = None,
224 outline: _Ink | None = None,
225 width: int = 1,
226 ) -> None:
227 """Draw a circle given center coordinates and a radius."""
228 ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
229 self.ellipse(ellipse_xy, fill, outline, width)
231 def _normalize_coords(self, xy: Coords) -> Sequence[Sequence[float]]:
232 """Normalize 1 or 2 dimensional coord sequence into 2d sequence."""
233 if isinstance(xy[0], (list, tuple)):
234 return cast("Sequence[Sequence[float]]", xy)
235 else:
236 return [
237 cast("Sequence[float]", tuple(xy[i : i + 2]))
238 for i in range(0, len(xy), 2)
239 ]
241 def line(
242 self,
243 xy: Coords,
244 fill: _Ink | None = None,
245 width: int = 1,
246 joint: str | None = None,
247 ) -> None:
248 """Draw a line, or a connected sequence of line segments."""
249 ink = self._getink(fill)[0]
250 if ink is not None and width != 0:
251 self.draw.draw_lines(xy, ink, width)
252 if joint == "curve" and width > 4:
253 points = self._normalize_coords(xy)
254 for i in range(1, len(points) - 1):
255 point = points[i]
256 angles = [
257 math.degrees(math.atan2(end[0] - start[0], start[1] - end[1]))
258 % 360
259 for start, end in (
260 (points[i - 1], point),
261 (point, points[i + 1]),
262 )
263 ]
264 if angles[0] == angles[1]:
265 # This is a straight line, so no joint is required
266 continue
268 def coord_at_angle(
269 coord: Sequence[float], angle: float
270 ) -> tuple[float, ...]:
271 x, y = coord
272 angle -= 90
273 distance = width / 2 - 1
274 return tuple(
275 p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d))
276 for p, p_d in (
277 (x, distance * math.cos(math.radians(angle))),
278 (y, distance * math.sin(math.radians(angle))),
279 )
280 )
282 flipped = (
283 angles[1] > angles[0] and angles[1] - 180 > angles[0]
284 ) or (angles[1] < angles[0] and angles[1] + 180 > angles[0])
285 coords = [
286 (point[0] - width / 2 + 1, point[1] - width / 2 + 1),
287 (point[0] + width / 2 - 1, point[1] + width / 2 - 1),
288 ]
289 if flipped:
290 start, end = (angles[1] + 90, angles[0] + 90)
291 else:
292 start, end = (angles[0] - 90, angles[1] - 90)
293 self.pieslice(coords, start - 90, end - 90, fill)
295 if width > 8:
296 # Cover potential gaps between the line and the joint
297 if flipped:
298 gap_coords = [
299 coord_at_angle(point, angles[0] + 90),
300 point,
301 coord_at_angle(point, angles[1] + 90),
302 ]
303 else:
304 gap_coords = [
305 coord_at_angle(point, angles[0] - 90),
306 point,
307 coord_at_angle(point, angles[1] - 90),
308 ]
309 self.line(gap_coords, fill, width=3)
311 def shape(
312 self,
313 shape: Image.core._Outline,
314 fill: _Ink | None = None,
315 outline: _Ink | None = None,
316 ) -> None:
317 """(Experimental) Draw a shape."""
318 shape.close()
319 ink, fill_ink = self._getink(outline, fill)
320 if fill_ink is not None:
321 self.draw.draw_outline(shape, fill_ink, 1)
322 if ink is not None and ink != fill_ink:
323 self.draw.draw_outline(shape, ink, 0)
325 def pieslice(
326 self,
327 xy: Coords,
328 start: float,
329 end: float,
330 fill: _Ink | None = None,
331 outline: _Ink | None = None,
332 width: int = 1,
333 ) -> None:
334 """Draw a pieslice."""
335 ink, fill_ink = self._getink(outline, fill)
336 if fill_ink is not None:
337 self.draw.draw_pieslice(xy, start, end, fill_ink, 1)
338 if ink is not None and ink != fill_ink and width != 0:
339 self.draw.draw_pieslice(xy, start, end, ink, 0, width)
341 def point(self, xy: Coords, fill: _Ink | None = None) -> None:
342 """Draw one or more individual pixels."""
343 ink, fill = self._getink(fill)
344 if ink is not None:
345 self.draw.draw_points(xy, ink)
347 def polygon(
348 self,
349 xy: Coords,
350 fill: _Ink | None = None,
351 outline: _Ink | None = None,
352 width: int = 1,
353 ) -> None:
354 """Draw a polygon."""
355 ink, fill_ink = self._getink(outline, fill)
356 if fill_ink is not None:
357 self.draw.draw_polygon(xy, fill_ink, 1)
358 if ink is not None and ink != fill_ink and width != 0:
359 if width == 1:
360 self.draw.draw_polygon(xy, ink, 0, width)
361 elif self.im is not None:
362 # To avoid expanding the polygon outwards,
363 # use the fill as a mask
364 mask = Image.new("1", self.im.size)
365 mask_ink = self._getink(1)[0]
366 draw = Draw(mask)
367 draw.draw.draw_polygon(xy, mask_ink, 1)
369 self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im)
371 def regular_polygon(
372 self,
373 bounding_circle: Sequence[Sequence[float] | float],
374 n_sides: int,
375 rotation: float = 0,
376 fill: _Ink | None = None,
377 outline: _Ink | None = None,
378 width: int = 1,
379 ) -> None:
380 """Draw a regular polygon."""
381 xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation)
382 self.polygon(xy, fill, outline, width)
384 def rectangle(
385 self,
386 xy: Coords,
387 fill: _Ink | None = None,
388 outline: _Ink | None = None,
389 width: int = 1,
390 ) -> None:
391 """Draw a rectangle."""
392 ink, fill_ink = self._getink(outline, fill)
393 if fill_ink is not None:
394 self.draw.draw_rectangle(xy, fill_ink, 1)
395 if ink is not None and ink != fill_ink and width != 0:
396 self.draw.draw_rectangle(xy, ink, 0, width)
398 def rounded_rectangle(
399 self,
400 xy: Coords,
401 radius: float = 0,
402 fill: _Ink | None = None,
403 outline: _Ink | None = None,
404 width: int = 1,
405 *,
406 corners: tuple[bool, bool, bool, bool] | None = None,
407 ) -> None:
408 """Draw a rounded rectangle."""
409 (x0, y0), (x1, y1) = self._normalize_coords(xy)
410 if x1 < x0:
411 msg = "x1 must be greater than or equal to x0"
412 raise ValueError(msg)
413 if y1 < y0:
414 msg = "y1 must be greater than or equal to y0"
415 raise ValueError(msg)
416 if corners is None:
417 corners = (True, True, True, True)
419 d = min(x1 - x0, y1 - y0, radius * 2)
421 x0 = round(x0)
422 y0 = round(y0)
423 x1 = round(x1)
424 y1 = round(y1)
425 full_x, full_y = False, False
426 if all(corners):
427 full_x = d >= x1 - x0 - 1
428 if full_x:
429 # The two left and two right corners are joined
430 d = x1 - x0
431 full_y = d >= y1 - y0 - 1
432 if full_y:
433 # The two top and two bottom corners are joined
434 d = y1 - y0
435 if full_x and full_y:
436 # If all corners are joined, that is a circle
437 return self.ellipse(xy, fill, outline, width)
439 if d == 0 or not any(corners):
440 # If the corners have no curve,
441 # or there are no corners,
442 # that is a rectangle
443 return self.rectangle(xy, fill, outline, width)
445 r = int(d // 2)
446 ink, fill_ink = self._getink(outline, fill)
448 def draw_corners(pieslice: bool) -> None:
449 parts: tuple[tuple[tuple[float, float, float, float], int, int], ...]
450 if full_x:
451 # Draw top and bottom halves
452 parts = (
453 ((x0, y0, x0 + d, y0 + d), 180, 360),
454 ((x0, y1 - d, x0 + d, y1), 0, 180),
455 )
456 elif full_y:
457 # Draw left and right halves
458 parts = (
459 ((x0, y0, x0 + d, y0 + d), 90, 270),
460 ((x1 - d, y0, x1, y0 + d), 270, 90),
461 )
462 else:
463 # Draw four separate corners
464 parts = tuple(
465 part
466 for i, part in enumerate(
467 (
468 ((x0, y0, x0 + d, y0 + d), 180, 270),
469 ((x1 - d, y0, x1, y0 + d), 270, 360),
470 ((x1 - d, y1 - d, x1, y1), 0, 90),
471 ((x0, y1 - d, x0 + d, y1), 90, 180),
472 )
473 )
474 if corners[i]
475 )
476 for part in parts:
477 if pieslice:
478 self.draw.draw_pieslice(*(part + (fill_ink, 1)))
479 else:
480 self.draw.draw_arc(*(part + (ink, width)))
482 if fill_ink is not None:
483 draw_corners(True)
485 if full_x:
486 self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1)
487 elif x1 - r - 1 >= x0 + r + 1:
488 self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1)
489 if not full_x and not full_y:
490 left = [x0, y0, x0 + r, y1]
491 if corners[0]:
492 left[1] += r + 1
493 if corners[3]:
494 left[3] -= r + 1
495 self.draw.draw_rectangle(left, fill_ink, 1)
497 right = [x1 - r, y0, x1, y1]
498 if corners[1]:
499 right[1] += r + 1
500 if corners[2]:
501 right[3] -= r + 1
502 self.draw.draw_rectangle(right, fill_ink, 1)
503 if ink is not None and ink != fill_ink and width != 0:
504 draw_corners(False)
506 if not full_x:
507 top = [x0, y0, x1, y0 + width - 1]
508 if corners[0]:
509 top[0] += r + 1
510 if corners[1]:
511 top[2] -= r + 1
512 self.draw.draw_rectangle(top, ink, 1)
514 bottom = [x0, y1 - width + 1, x1, y1]
515 if corners[3]:
516 bottom[0] += r + 1
517 if corners[2]:
518 bottom[2] -= r + 1
519 self.draw.draw_rectangle(bottom, ink, 1)
520 if not full_y:
521 left = [x0, y0, x0 + width - 1, y1]
522 if corners[0]:
523 left[1] += r + 1
524 if corners[3]:
525 left[3] -= r + 1
526 self.draw.draw_rectangle(left, ink, 1)
528 right = [x1 - width + 1, y0, x1, y1]
529 if corners[1]:
530 right[1] += r + 1
531 if corners[2]:
532 right[3] -= r + 1
533 self.draw.draw_rectangle(right, ink, 1)
535 def text(
536 self,
537 xy: tuple[float, float],
538 text: AnyStr | ImageText.Text[AnyStr],
539 fill: _Ink | None = None,
540 font: ImageFont.BaseImageFont | None = None,
541 anchor: str | None = None,
542 spacing: float = 4,
543 align: str = "left",
544 direction: str | None = None,
545 features: list[str] | None = None,
546 language: str | None = None,
547 stroke_width: float = 0,
548 stroke_fill: _Ink | None = None,
549 embedded_color: bool = False,
550 *args: Any,
551 **kwargs: Any,
552 ) -> None:
553 """Draw text."""
554 if isinstance(text, ImageText.Text):
555 image_text = text
556 else:
557 if font is None:
558 font = self._getfont(kwargs.get("font_size"))
559 image_text = ImageText.Text(
560 text, font, self.mode, spacing, direction, features, language
561 )
562 if embedded_color:
563 image_text.embed_color()
564 if stroke_width:
565 image_text.stroke(stroke_width, stroke_fill)
567 def getink(fill: _Ink | None) -> int:
568 ink, fill_ink = self._getink(fill)
569 if ink is None:
570 assert fill_ink is not None
571 return fill_ink
572 return ink
574 ink = getink(fill)
575 if ink is None:
576 return
578 stroke_ink = None
579 if image_text.stroke_width:
580 stroke_ink = (
581 getink(image_text.stroke_fill)
582 if image_text.stroke_fill is not None
583 else ink
584 )
586 for line in image_text._split(xy, anchor, align):
588 def draw_text(ink: int, stroke_width: float = 0) -> None:
589 mode = self.fontmode
590 if stroke_width == 0 and image_text.embedded_color:
591 mode = "RGBA"
592 x = int(line.x)
593 y = int(line.y)
594 start = (math.modf(line.x)[0], math.modf(line.y)[0])
595 if isinstance(image_text.font, ImageFont.FreeTypeFont):
596 mask, offset = image_text.font.getmask2(
597 line.text,
598 mode,
599 direction,
600 features,
601 language,
602 stroke_width,
603 line.anchor,
604 ink,
605 start,
606 stroke_filled=True,
607 *args,
608 **kwargs,
609 )
610 x += offset[0]
611 y += offset[1]
612 else:
613 try:
614 mask = image_text.font.getmask(
615 line.text,
616 mode,
617 direction,
618 features,
619 language,
620 stroke_width,
621 line.anchor,
622 ink,
623 start=start,
624 *args,
625 **kwargs,
626 )
627 except TypeError:
628 mask = image_text.font.getmask(line.text)
629 if mode == "RGBA":
630 # image_text.font.getmask2(mode="RGBA")
631 # returns color in RGB bands and mask in A
632 # extract mask and set text alpha
633 color, mask = mask, mask.getband(3)
634 ink_alpha = struct.pack("i", ink)[3]
635 color.fillband(3, ink_alpha)
636 if self.im is not None:
637 self.im.paste(
638 color, (x, y, x + mask.size[0], y + mask.size[1]), mask
639 )
640 else:
641 self.draw.draw_bitmap((x, y), mask, ink)
643 if stroke_ink is not None:
644 # Draw stroked text
645 draw_text(stroke_ink, image_text.stroke_width)
647 # Draw normal text
648 if ink != stroke_ink:
649 draw_text(ink)
650 else:
651 # Only draw normal text
652 draw_text(ink)
654 def multiline_text(
655 self,
656 xy: tuple[float, float],
657 text: AnyStr,
658 fill: _Ink | None = None,
659 font: ImageFont.BaseImageFont | None = None,
660 anchor: str | None = None,
661 spacing: float = 4,
662 align: str = "left",
663 direction: str | None = None,
664 features: list[str] | None = None,
665 language: str | None = None,
666 stroke_width: float = 0,
667 stroke_fill: _Ink | None = None,
668 embedded_color: bool = False,
669 *,
670 font_size: float | None = None,
671 ) -> None:
672 return self.text(
673 xy,
674 text,
675 fill,
676 font,
677 anchor,
678 spacing,
679 align,
680 direction,
681 features,
682 language,
683 stroke_width,
684 stroke_fill,
685 embedded_color,
686 font_size=font_size,
687 )
689 def textlength(
690 self,
691 text: AnyStr,
692 font: ImageFont.BaseImageFont | None = None,
693 direction: str | None = None,
694 features: list[str] | None = None,
695 language: str | None = None,
696 embedded_color: bool = False,
697 *,
698 font_size: float | None = None,
699 ) -> float:
700 """Get the length of a given string, in pixels with 1/64 precision."""
701 if font is None:
702 font = self._getfont(font_size)
703 image_text = ImageText.Text(
704 text,
705 font,
706 self.mode,
707 direction=direction,
708 features=features,
709 language=language,
710 )
711 if embedded_color:
712 image_text.embed_color()
713 return image_text.get_length()
715 def textbbox(
716 self,
717 xy: tuple[float, float],
718 text: AnyStr,
719 font: ImageFont.BaseImageFont | None = None,
720 anchor: str | None = None,
721 spacing: float = 4,
722 align: str = "left",
723 direction: str | None = None,
724 features: list[str] | None = None,
725 language: str | None = None,
726 stroke_width: float = 0,
727 embedded_color: bool = False,
728 *,
729 font_size: float | None = None,
730 ) -> tuple[float, float, float, float]:
731 """Get the bounding box of a given string, in pixels."""
732 if font is None:
733 font = self._getfont(font_size)
734 image_text = ImageText.Text(
735 text, font, self.mode, spacing, direction, features, language
736 )
737 if embedded_color:
738 image_text.embed_color()
739 if stroke_width:
740 image_text.stroke(stroke_width)
741 return image_text.get_bbox(xy, anchor, align)
743 def multiline_textbbox(
744 self,
745 xy: tuple[float, float],
746 text: AnyStr,
747 font: ImageFont.BaseImageFont | None = None,
748 anchor: str | None = None,
749 spacing: float = 4,
750 align: str = "left",
751 direction: str | None = None,
752 features: list[str] | None = None,
753 language: str | None = None,
754 stroke_width: float = 0,
755 embedded_color: bool = False,
756 *,
757 font_size: float | None = None,
758 ) -> tuple[float, float, float, float]:
759 return self.textbbox(
760 xy,
761 text,
762 font,
763 anchor,
764 spacing,
765 align,
766 direction,
767 features,
768 language,
769 stroke_width,
770 embedded_color,
771 font_size=font_size,
772 )
775def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw:
776 """
777 A simple 2D drawing interface for PIL images.
779 :param im: The image to draw in.
780 :param mode: Optional mode to use for color values. For RGB
781 images, this argument can be RGB or RGBA (to blend the
782 drawing into the image). For all other modes, this argument
783 must be the same as the image mode. If omitted, the mode
784 defaults to the mode of the image.
785 """
786 try:
787 return getattr(im, "getdraw")(mode)
788 except AttributeError:
789 return ImageDraw(im, mode)
792def getdraw(im: Image.Image | None = None) -> tuple[ImageDraw2.Draw | None, ModuleType]:
793 """
794 :param im: The image to draw in.
795 :returns: A (drawing context, drawing resource factory) tuple.
796 """
797 from . import ImageDraw2
799 draw = ImageDraw2.Draw(im) if im is not None else None
800 return draw, ImageDraw2
803def floodfill(
804 image: Image.Image,
805 xy: tuple[int, int],
806 value: float | tuple[int, ...],
807 border: float | tuple[int, ...] | None = None,
808 thresh: float = 0,
809) -> None:
810 """
811 .. warning:: This method is experimental.
813 Fills a bounded region with a given color.
815 :param image: Target image.
816 :param xy: Seed position (a 2-item coordinate tuple). See
817 :ref:`coordinate-system`.
818 :param value: Fill color.
819 :param border: Optional border value. If given, the region consists of
820 pixels with a color different from the border color. If not given,
821 the region consists of pixels having the same color as the seed
822 pixel.
823 :param thresh: Optional threshold value which specifies a maximum
824 tolerable difference of a pixel value from the 'background' in
825 order for it to be replaced. Useful for filling regions of
826 non-homogeneous, but similar, colors.
827 """
828 # based on an implementation by Eric S. Raymond
829 # amended by yo1995 @20180806
830 pixel = image.load()
831 assert pixel is not None
832 x, y = xy
833 try:
834 background = pixel[x, y]
835 except IndexError:
836 return # seed point outside image
838 def _update_color(color: float | tuple[int, ...]) -> float | tuple[int, ...]:
839 if image.mode == "RGBA" and isinstance(color, tuple) and len(color) == 3:
840 color += (255,)
841 return color
843 value = _update_color(value)
844 if _color_diff(value, background) <= thresh:
845 return # seed point already has fill color
847 if border is not None:
848 border = _update_color(border)
849 pixel[x, y] = value
850 edge = {(x, y)}
851 # use a set to keep record of current and previous edge pixels
852 # to reduce memory consumption
853 full_edge = set()
854 while edge:
855 new_edge = set()
856 for x, y in edge: # 4 adjacent method
857 for s, t in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
858 # If already processed, or if a coordinate is negative, skip
859 if (s, t) in full_edge or s < 0 or t < 0:
860 continue
861 try:
862 p = pixel[s, t]
863 except (ValueError, IndexError):
864 pass
865 else:
866 full_edge.add((s, t))
867 if border is None:
868 fill = _color_diff(p, background) <= thresh
869 else:
870 fill = p not in (value, border)
871 if fill:
872 pixel[s, t] = value
873 new_edge.add((s, t))
874 full_edge = edge # discard pixels processed
875 edge = new_edge
878def _compute_regular_polygon_vertices(
879 bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float
880) -> list[tuple[float, float]]:
881 """
882 Generate a list of vertices for a 2D regular polygon.
884 :param bounding_circle: The bounding circle is a sequence defined
885 by a point and radius. The polygon is inscribed in this circle.
886 (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``)
887 :param n_sides: Number of sides
888 (e.g. ``n_sides=3`` for a triangle, ``6`` for a hexagon)
889 :param rotation: Apply an arbitrary rotation to the polygon
890 (e.g. ``rotation=90``, applies a 90 degree rotation)
891 :return: List of regular polygon vertices
892 (e.g. ``[(25, 50), (50, 50), (50, 25), (25, 25)]``)
894 How are the vertices computed?
895 1. Compute the following variables
896 - theta: Angle between the apothem & the nearest polygon vertex
897 - side_length: Length of each polygon edge
898 - centroid: Center of bounding circle (1st, 2nd elements of bounding_circle)
899 - polygon_radius: Polygon radius (last element of bounding_circle)
900 - angles: Location of each polygon vertex in polar grid
901 (e.g. A square with 0 degree rotation => [225.0, 315.0, 45.0, 135.0])
903 2. For each angle in angles, get the polygon vertex at that angle
904 The vertex is computed using the equation below.
905 X= xcos(φ) + ysin(φ)
906 Y= −xsin(φ) + ycos(φ)
908 Note:
909 φ = angle in degrees
910 x = 0
911 y = polygon_radius
913 The formula above assumes rotation around the origin.
914 In our case, we are rotating around the centroid.
915 To account for this, we use the formula below
916 X = xcos(φ) + ysin(φ) + centroid_x
917 Y = −xsin(φ) + ycos(φ) + centroid_y
918 """
919 # 1. Error Handling
920 # 1.1 Check `n_sides` has an appropriate value
921 if not isinstance(n_sides, int):
922 msg = "n_sides should be an int" # type: ignore[unreachable]
923 raise TypeError(msg)
924 if n_sides < 3:
925 msg = "n_sides should be an int > 2"
926 raise ValueError(msg)
928 # 1.2 Check `bounding_circle` has an appropriate value
929 if not isinstance(bounding_circle, (list, tuple)):
930 msg = "bounding_circle should be a sequence"
931 raise TypeError(msg)
933 if len(bounding_circle) == 3:
934 if not all(isinstance(i, (int, float)) for i in bounding_circle):
935 msg = "bounding_circle should only contain numeric data"
936 raise ValueError(msg)
938 *centroid, polygon_radius = cast("list[float]", list(bounding_circle))
939 elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)):
940 if not all(
941 isinstance(i, (int, float)) for i in bounding_circle[0]
942 ) or not isinstance(bounding_circle[1], (int, float)):
943 msg = "bounding_circle should only contain numeric data"
944 raise ValueError(msg)
946 if len(bounding_circle[0]) != 2:
947 msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))"
948 raise ValueError(msg)
950 centroid = cast("list[float]", list(bounding_circle[0]))
951 polygon_radius = cast("float", bounding_circle[1])
952 else:
953 msg = (
954 "bounding_circle should contain 2D coordinates "
955 "and a radius (e.g. (x, y, r) or ((x, y), r) )"
956 )
957 raise ValueError(msg)
959 if polygon_radius <= 0:
960 msg = "bounding_circle radius should be > 0"
961 raise ValueError(msg)
963 # 1.3 Check `rotation` has an appropriate value
964 if not isinstance(rotation, (int, float)):
965 msg = "rotation should be an int or float" # type: ignore[unreachable]
966 raise ValueError(msg)
968 # 2. Define Helper Functions
969 def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]:
970 return (
971 round(
972 point[0] * math.cos(math.radians(360 - degrees))
973 - point[1] * math.sin(math.radians(360 - degrees))
974 + centroid[0],
975 2,
976 ),
977 round(
978 point[1] * math.cos(math.radians(360 - degrees))
979 + point[0] * math.sin(math.radians(360 - degrees))
980 + centroid[1],
981 2,
982 ),
983 )
985 def _compute_polygon_vertex(angle: float) -> tuple[float, float]:
986 start_point = [polygon_radius, 0]
987 return _apply_rotation(start_point, angle)
989 def _get_angles(n_sides: int, rotation: float) -> list[float]:
990 angles = []
991 degrees = 360 / n_sides
992 # Start with the bottom left polygon vertex
993 current_angle = (270 - 0.5 * degrees) + rotation
994 for _ in range(n_sides):
995 angles.append(current_angle)
996 current_angle += degrees
997 if current_angle > 360:
998 current_angle -= 360
999 return angles
1001 # 3. Variable Declarations
1002 angles = _get_angles(n_sides, rotation)
1004 # 4. Compute Vertices
1005 return [_compute_polygon_vertex(angle) for angle in angles]
1008def _color_diff(
1009 color1: float | tuple[int, ...], color2: float | tuple[int, ...]
1010) -> float:
1011 """
1012 Uses 1-norm distance to calculate difference between two values.
1013 """
1014 first = color1 if isinstance(color1, tuple) else (color1,)
1015 second = color2 if isinstance(color2, tuple) else (color2,)
1017 return sum(abs(first[i] - second[i]) for i in range(len(second)))