draw.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """Functions for drawing text"""
  2. from PIL import Image, ImageDraw, ImageFont
  3. import numpy as np
  4. from string import ascii_letters
  5. from scipy.signal import convolve2d
  6. def text_array(text, font='arial.ttf', size=50, colour=(255,255,255), bg=(0,0,0), border=(0,0,0,0), crop_x='text', crop_y='text', crop_chars=ascii_letters, fontcrop_n=None, align_x='centre', greyscale=True, method=np.round, rotate=0, fliplr=False, flipud=False, outline=False, return_img=False):
  7. """Return an `np.array` (default) or PIL image of the specified text, cropped to its boundaries.
  8. Paramaters
  9. ----------
  10. text : str
  11. The text to draw.
  12. font : str
  13. The .ttf font file to use.
  14. size : int
  15. The font size in number of pixels (passed to `PIL.ImageFont.truetype()`)
  16. colour : tuple
  17. A tuple of length 3, specifying the font colour (0 to 255) in in the form (R,G,B)
  18. bg : tuple
  19. A tuple specifying the background colour (0 to 255) in in the form (R,G,B)
  20. border : tuple
  21. A tuple of pixel sizes for a border in the form (left_x, right_x, top_y, bottom_y), surrounding the crop
  22. crop_x : str
  23. Should be one of the following, specifiying whether the output's width be cropped to the maximum boundaries of the current text or the font:
  24. 'text': the width limits of the rendered text
  25. 'font': the width limits of the font's ascii characters given the text's length, with centred alignment
  26. crop_y : str
  27. Should be one of the following, specifiying whether the output's height be cropped to the maximum boundaries of the current text or the font:
  28. 'text': the height limits of the rendered text
  29. 'font': the height limits of the font's ascii characters given the text's length
  30. crop_chars : list
  31. A list of characters that should be used to find the font extremities when cropping. Default is `string.ascii_letters`.
  32. fontcrop_n : int
  33. If crop_x or crop_y are 'font', how many characters should the crop assume are available to the font? If `None` (default) will use `len(text)`.
  34. align_x : str
  35. How to horizontally align the text (if any white space):
  36. 'left': align to the left
  37. 'centre': align to the centre
  38. 'right': align to the right
  39. greyscale : bool
  40. Should the resulting image be converted to greyscale?
  41. method : function, optional
  42. Function to apply to the array at the end. Default is `np.round` to binarise.
  43. rotate : float
  44. Degrees by which the text should be rotated.
  45. fliplr : bool
  46. Should the array be mirrored horizontally?
  47. flipud : bool
  48. Should the array be mirrored vertically?
  49. outline : bool
  50. Should the array be outlined using `outline_shape()`?
  51. return_img : bool
  52. Should an image be returned, rather than an array?
  53. """
  54. # get font info
  55. pil_font = ImageFont.truetype(font, size=size, encoding="unic")
  56. pil_fontsize = pil_font.getbbox(text)[-2:]
  57. # draw image
  58. im = Image.new('RGB', pil_fontsize, bg)
  59. im_draw = ImageDraw.Draw(im)
  60. im_draw.text((0,0), text, font=pil_font, fill=colour)
  61. # find which pixels could be filled by text
  62. if crop_x=='font' or crop_y=='font':
  63. if fontcrop_n is None:
  64. crop_nchar = len(text)
  65. else:
  66. crop_nchar = fontcrop_n
  67. letter_fontsizes = [pil_font.getbbox(letter*crop_nchar)[-2:] for letter in set(crop_chars)]
  68. canvas_max_size = (max([tup[0] for tup in letter_fontsizes]), max([tup[1] for tup in letter_fontsizes]))
  69. im_lims = Image.new('RGB', canvas_max_size, bg)
  70. im_lims_draw = ImageDraw.Draw(im_lims)
  71. for letter in crop_chars:
  72. im_lims_draw.text((0,0), letter*crop_nchar, font=pil_font, fill=colour)
  73. im_lims_arr = np.array(im_lims)
  74. text_lims_px = np.where(np.sum(im_lims_arr==bg, 2)!=im_lims_arr.shape[2])
  75. # find which pixels are filled with text
  76. im_arr = np.array(im)
  77. text_px = np.where(np.sum(im_arr==bg, 2)!=im_arr.shape[2]) # find non-background pixels
  78. # get x crop from either font or text limits
  79. if crop_x=='font':
  80. min_x = np.min(text_lims_px[1])
  81. max_x = np.max(text_lims_px[1])+1
  82. elif crop_x=='text':
  83. min_x = np.min(text_px[1])
  84. max_x = np.max(text_px[1])+1
  85. # get y crop from either font or text limits
  86. if crop_y=='font':
  87. min_y = np.min(text_lims_px[0])
  88. max_y = np.max(text_lims_px[0])+1
  89. elif crop_y=='text':
  90. min_y = np.min(text_px[0])
  91. max_y = np.max(text_px[0])+1
  92. min_x = int(min_x)
  93. max_x = int(max_x)
  94. min_y = int(min_y)
  95. max_y = int(max_y)
  96. # get the horizontal and vertical text size info (for alignment & drawing location)
  97. min_x_text = np.min(text_px[1])
  98. max_x_text = np.max(text_px[1])+1
  99. # min_y_text = np.min(text_px[0])
  100. # max_y_text = np.max(text_px[0])+1
  101. text_width = max_x_text - min_x_text
  102. # apply crop (create as new image to avoid default black background)
  103. im_out = Image.new('RGB', (border[0] + border[1] + max_x - min_x, border[2] + border[3] + max_y - min_y), bg)
  104. im_out_draw = ImageDraw.Draw(im_out)
  105. # get the text position adjustment (dictated by alignment)
  106. if align_x=='centre':
  107. align_adjust = round((max_x - min_x) * 0.5 - text_width * 0.5)
  108. elif align_x=='left':
  109. align_adjust = 0
  110. elif align_x=='right':
  111. align_adjust = max_x - text_width
  112. # draw the text in the necessary position
  113. im_out_draw.text((-min_x_text + border[0] + align_adjust, -min_y + border[2]), text, font=pil_font, fill=colour)
  114. # convert to greyscale if necessary
  115. if greyscale:
  116. im_out=im_out.convert('L')
  117. # rotate if necessary
  118. if rotate!=0:
  119. im_out=im_out.rotate(rotate, expand=True)
  120. # return the PIL image if requested
  121. if return_img and method is None and not outline:
  122. return(im_out)
  123. # convert to array
  124. arr = np.asarray(im_out)
  125. arr = arr / np.max(arr)
  126. if fliplr:
  127. arr = np.fliplr(arr)
  128. if flipud:
  129. arr = np.flipud(arr)
  130. # apply function if required
  131. if method is not None:
  132. arr = method(arr)
  133. # outline the shape if requested
  134. if outline:
  135. arr = outline_shape(arr)
  136. # return the PIL image if requested
  137. if return_img:
  138. im_out = Image.fromarray(np.uint8(arr*255))
  139. return(im_out)
  140. return(arr)
  141. def outline_shape(arr, binarise=True, thresh=0):
  142. """Keep only the outline of non-zero elements of a shape. The outline is located using convolution with kernels for finding the vertical and horizontal edges. The output will be binary.
  143. Paramaters
  144. ----------
  145. arr : np.ndarray
  146. Array to outline - should be scaled between 0 and 1.
  147. binarise : bool
  148. Should the array be binarised before finding the outline.
  149. thresh : float
  150. Threshold (from 0 to 1) for binarisation.
  151. """
  152. arr_bin = np.zeros(arr.shape)
  153. arr_bin[arr>thresh] = 1
  154. edge_kernels = [
  155. # vertical edges
  156. [[-1, 1, 0]],
  157. [[0, 1, -1]],
  158. # horizontal edges
  159. [[-1], [1], [0]],
  160. [[0], [1], [-1]]
  161. ]
  162. edges = np.zeros(arr.shape)
  163. for k in edge_kernels:
  164. c = convolve2d(arr_bin, k, mode='same')
  165. c[c<0] = 0
  166. edges += c
  167. edges[edges>1] = 1 # account for some pixels being edges with multiple sides facing zeroes
  168. if not binarise:
  169. # if binarise is False, return the original values in the locations of the edges (useful for methods using weighting)
  170. edges[edges==1] = arr[edges==1]
  171. return(edges)