bubbles.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. """Functions for Creating Gaussian Bubbles images, with bubbles_mask_nonzero() specialised for letters"""
  2. from PIL import Image
  3. import numpy as np
  4. from scipy.stats import norm
  5. from scipy.ndimage import gaussian_filter
  6. from skimage.morphology import binary_dilation
  7. import warnings
  8. def build_mask(mu_y, mu_x, sigma, sh, scale, sum_merge):
  9. """Build a Bubbles mask which can be applied to an image of shape `sh`. Returns a matrix for the mask.
  10. Keyword arguments:
  11. mu_y -- the locations of the bubbles centres, in numpy axis 0
  12. mu_x -- the locations of the bubbles centres, in numpy axis 1 (should be same len as mu_y)
  13. sigma -- array of sigmas for the spread of the bubbles (should be same len as mu_y)
  14. sh -- shape (np.shape) of the desired mask (usually the shape of the respective image)
  15. scale -- should densities' maxima be consistently scaled across different sigma values?
  16. sum_merge -- should merges, where bubbles overlap, be completed using a simple sum of the bubbles, thresholded to the maxima of the pre-merged bubbles? If False (the default), densities are instead averaged (mean).
  17. """
  18. # check lengths match and are all 1d
  19. gauss_pars_sh = [np.shape(x) for x in [mu_y, mu_x, sigma]]
  20. gauss_pars_n_dims = [len(x) for x in gauss_pars_sh]
  21. if len(set(gauss_pars_sh))!=1 or any(gauss_pars_n_dims)!=1:
  22. ValueError('mu_y, mu_x, and sigma should all be 1-dimensional arrays of identical length')
  23. # for each distribution, generate the bubble
  24. dists = [
  25. # get the outer product of vectors for the densities of pixel indices across x and y dimensions, for each distribution (provides 2d density)
  26. np.outer(
  27. norm.pdf(np.arange(start=0, stop=sh[0]), mu_y_i, sigma_i),
  28. norm.pdf(np.arange(start=0, stop=sh[1]), mu_x_i, sigma_i)
  29. )
  30. for mu_x_i, mu_y_i, sigma_i in zip(mu_x, mu_y, sigma)
  31. ]
  32. # scale all bubbles consistently if requested
  33. if scale:
  34. dists = [x/np.max(x) for x in dists]
  35. if sum_merge:
  36. # sum the distributions, then threshold the maximum to the maximum peak
  37. mask = np.sum(dists, axis=0)
  38. mask[mask>np.max(dists)] = np.max(dists)
  39. else:
  40. # merge using average of densities
  41. mask = np.mean(dists, axis=0)
  42. # scale density to within [0, 1] (will already be scaled to [0, 1] above if scale==True)
  43. mask /= np.max(mask)
  44. return(mask)
  45. def build_conv_mask(mu_y, mu_x, sigma, sh):
  46. """
  47. Build a Bubbles mask via convolution which can be applied to an image of shape `sh`. Returns a matrix for the mask.
  48. Unlike build_mask(), build_conv_mask() requires that all sigma values are equal.
  49. Keyword arguments:
  50. mu_y -- the locations of the bubbles centres, in numpy axis 0. Must be integers (will be rounded otherwise)
  51. mu_x -- the locations of the bubbles centres, in numpy axis 1 (should be same len as mu_y). Must be integers (will be rounded otherwise)
  52. sigma -- a single value for sigma, or else an array of sigmas for the spread of the bubbles (in which case, should be same len as mu_y, and should all be identical)
  53. sh -- shape (np.shape) of the desired mask (usually the shape of the respective image)
  54. """
  55. # if sigma is given as a list, get the single value
  56. if isinstance(sigma, list) | isinstance(sigma, np.ndarray):
  57. sigma = np.unique(sigma)
  58. # if more than one sigma value, give error
  59. if len(sigma)>1:
  60. ValueError('for the convolution approach, sigma should be of length one, or else all values should be identical')
  61. # check lengths for mu match and are both 1d
  62. gauss_pars_sh = [np.shape(x) for x in [mu_y, mu_x]]
  63. gauss_pars_n_dims = [len(x) for x in gauss_pars_sh]
  64. if len(set(gauss_pars_sh))!=1 or any(gauss_pars_n_dims)!=1:
  65. ValueError('mu_y and mu_x should both be 1-dimensional arrays of identical length')
  66. # generate the pre-convolution mask
  67. mask_preconv = np.zeros(sh)
  68. mask_preconv[
  69. np.array(mu_y).astype(int),
  70. np.array(mu_x).astype(int)
  71. ] = 1
  72. # apply the filter via scipy.signal.gaussian_filter (uses a series of 1d convolutions)
  73. mask = gaussian_filter(mask_preconv, sigma=float(sigma), mode='constant', cval=0.0)
  74. # scale the mask
  75. mask /= np.max(mask)
  76. return(mask)
  77. def apply_mask(im, mask, bg=0, bg_im=None):
  78. """Apply a mask to image `im`. Returns a PIL image.
  79. Keyword arguments:
  80. im -- the original image
  81. mask -- the mask to apply to the image
  82. bg -- value for the background, from 0 to 255. Can also be an array of 3 values from 0 to 255, for RGB, or 4 values for RGBA
  83. bg_im -- a PIL image to use as the background, rather than a flat colour. If provided, will take priority over `bg`. Should be the same shape as `im`.
  84. """
  85. if type(im) is np.ndarray:
  86. if im.max() <= 1:
  87. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
  88. im = Image.fromarray(np.uint8(im * 255))
  89. else:
  90. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
  91. im = Image.fromarray(np.uint8(im))
  92. sh = np.asarray(im).shape
  93. if len(sh)>2:
  94. n_col_chs = sh[2]
  95. else:
  96. n_col_chs = 1
  97. if n_col_chs > 1:
  98. im_out_mat = im * np.repeat(mask[:,:,np.newaxis], n_col_chs, axis=2)
  99. else:
  100. im_out_mat = im * mask
  101. if bg_im is None:
  102. # check bg is array
  103. if ~isinstance(bg, np.ndarray):
  104. bg = np.array(bg)
  105. # adjust the background
  106. if np.any(bg != 0):
  107. if n_col_chs > 1:
  108. im_bg_mat = bg * (1 - np.repeat(mask[:,:,np.newaxis], sh[2], axis=2))
  109. else:
  110. im_bg_mat = bg * (1 - mask)
  111. im_out_mat += im_bg_mat
  112. else:
  113. n_bg_col_chs = np.asarray(bg_im).shape[2]
  114. if n_bg_col_chs > 1:
  115. im_bg_mat = bg_im * (1 - np.repeat(mask[:,:,np.newaxis], n_bg_col_chs, axis=2))
  116. else:
  117. im_bg_mat = bg_im * (1 - mask)
  118. im_out_mat += im_bg_mat
  119. return(im_out_mat)
  120. def bubbles_mask(im, mu_x=None, mu_y=None, sigma=np.repeat(25, repeats=5), bg=0, scale=True, sum_merge=False, bg_im=None):
  121. """Apply the bubbles mask to a given PIL image. Returns the edited PIL image, the generated mask, mu_y, mu_x, and sigma.
  122. Keyword arguments:
  123. im -- the PIL image to apply the bubbles mask to
  124. mu_x -- x indices (axis 1 in numpy) for bubble locations - set to None (default) for random location
  125. mu_y -- y indices (axis 0 in numpy) for bubble locations - set to None (default) for random location
  126. sigma -- array of sigmas for the spread of the bubbles. `n` is inferred from this array
  127. bg -- value for the background, from 0 to 255. Can also be an array of 3 values from 0 to 255, for RGB, or 4 values, for RGBA
  128. scale -- should densities' maxima be consistently scaled across different sigma values?
  129. sum_merge -- should merges, where bubbles overlap, be completed using a simple sum of the bubbles, thresholded to the maxima of the pre-merged bubbles? If False (the default), densities are instead averaged (mean).
  130. bg_im -- a PIL image to use as the background, rather than a flat colour. If provided, will take priority over `bg`. Should be the same shape as `im`.
  131. """
  132. if type(im) is np.ndarray:
  133. if im.max() <= 1:
  134. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
  135. im = Image.fromarray(np.uint8(im * 255))
  136. else:
  137. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
  138. im = Image.fromarray(np.uint8(im))
  139. n = len(sigma) # get n bubbles
  140. sh = np.asarray(im).shape # get shape
  141. # generate distributions' locations
  142. if mu_y is None:
  143. mu_y = np.random.uniform(low=0, high=sh[0], size=n)
  144. if mu_x is None:
  145. mu_x = np.random.uniform(low=0, high=sh[1], size=n)
  146. # build mask
  147. mask = build_mask(mu_y=mu_y, mu_x=mu_x, sigma=sigma, sh=sh, scale=scale, sum_merge=sum_merge)
  148. # apply mask
  149. im_out_mat = apply_mask(im=im, mask=mask, bg=bg, bg_im=bg_im)
  150. im_out = Image.fromarray(np.uint8(im_out_mat))
  151. return(im_out, mask, mu_x, mu_y, sigma)
  152. def bubbles_conv_mask (im, mu_x=None, mu_y=None, sigma=np.repeat(25, repeats=5), bg=0, bg_im=None):
  153. """Apply a bubbles mask generated via convolution to a given PIL image. Returns the edited PIL image, the generated mask, mu_y, mu_x, and sigma.
  154. Keyword arguments:
  155. im -- the PIL image to apply the bubbles mask to
  156. mu_x -- x indices (axis 1 in numpy) for bubble locations - set to None (default) for random location. Must be integers (will be rounded otherwise)
  157. mu_y -- y indices (axis 0 in numpy) for bubble locations - set to None (default) for random location. Must be integers (will be rounded otherwise)
  158. sigma -- array of sigmas for the spread of the bubbles. `n` is inferred from this array, but all values should be identical for this method
  159. bg -- value for the background, from 0 to 255. Can also be an array of 3 values from 0 to 255, for RGB, or 4 values, for RGBA
  160. bg_im -- a PIL image to use as the background, rather than a flat colour. If provided, will take priority over `bg`. Should be the same shape as `im`.
  161. """
  162. if type(im) is np.ndarray:
  163. if im.max() <= 1:
  164. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
  165. im = Image.fromarray(np.uint8(im * 255))
  166. else:
  167. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
  168. im = Image.fromarray(np.uint8(im))
  169. n = len(sigma) # get n bubbles
  170. sh = np.asarray(im).shape # get shape
  171. # generate distributions' locations
  172. if mu_y is None:
  173. mu_y = np.random.randint(low=0, high=sh[0], size=n)
  174. if mu_x is None:
  175. mu_x = np.random.randint(low=0, high=sh[1], size=n)
  176. # build mask
  177. mask = build_conv_mask(mu_y=mu_y, mu_x=mu_x, sigma=sigma, sh=sh)
  178. # apply mask
  179. im_out_mat = apply_mask(im=im, mask=mask, bg=bg, bg_im=bg_im)
  180. im_out = Image.fromarray(np.uint8(im_out_mat))
  181. return(im_out, mask, mu_x, mu_y, sigma)
  182. def bubbles_mask_nonzero(im, ref_im=None, sigma = np.repeat(25, repeats=5), bg=0, scale=True, sum_merge=False, max_sigma_from_nonzero=np.Infinity, bg_im=None):
  183. """Apply the bubbles mask to a given PIL image, restricting the possible locations of the bubbles' centres to be within a given multiple of non-zero pixels. The image will be binarised to be im<=bg gives 0, else 1, so binary dilation can be applied. Returns the edited PIL image, the generated mask, mu_y, mu_x, and sigma.
  184. Keyword arguments:
  185. im -- the image to apply the bubbles mask to
  186. ref_im -- the image to be used as the reference image for finding the minimum (useful for finding the minimum in a pre-distorted im)
  187. sigma -- array of sigmas for the spread of the bubbles. `n` is inferred from this array
  188. bg -- value for the background, from 0 to 255. Can also be an array of 3 values from 0 to 255, for RGB
  189. scale -- should densities' maxima be consistently scaled across different sigma values?
  190. sum_merge -- should merges, where bubbles overlap, be completed using a simple sum of the bubbles, thresholded to the maxima of the pre-merged bubbles? If False (the default), densities are instead averaged (mean).
  191. max_sigma_from_nonzero -- maximum multiples of the given sigma value from the nearest nonzero (in practice, non-minimum) values that a bubble's centre can be. Can be `np.Infinity` for no restriction
  192. bg_im -- a PIL image to use as the background, rather than a flat colour. If provided, will take priority over `bg`. Should be the same shape as `im`.
  193. """
  194. if type(im) is np.ndarray:
  195. if im.max() <= 1:
  196. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
  197. im = Image.fromarray(np.uint8(im * 255))
  198. else:
  199. warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
  200. im = Image.fromarray(np.uint8(im))
  201. sh = np.asarray(im).shape # get shape
  202. # if no limits, just use bubbles_mask()
  203. if max_sigma_from_nonzero == np.Infinity:
  204. return(bubbles_mask(im=im, sigma=sigma, bg=bg, scale=scale, bg_im=bg_im))
  205. # get the acceptable mu locations for each sigma value, and store in `sigma_mu_bounds`
  206. # get acceptable boundaries for each sigma
  207. sigma_dil_iters = [int(np.round(s * max_sigma_from_nonzero)) for s in sigma]
  208. n_iter = max(sigma_dil_iters)
  209. if ref_im is None:
  210. mu_bounds = np.max(np.asarray(im) > bg, axis=2)
  211. else:
  212. mu_bounds = np.max(np.asarray(ref_im) > bg, axis=2)
  213. # this will contain the desired mu bounds for each sigma
  214. sigma_mu_bounds = [None] * len(sigma)
  215. for i in range(n_iter):
  216. binary_dilation(mu_bounds, out=mu_bounds)
  217. if i+1 in sigma_dil_iters:
  218. matching_sigma_idx = list(np.where(np.array(sigma_dil_iters) == (i+1))[0])
  219. for sigma_i in matching_sigma_idx:
  220. sigma_mu_bounds[sigma_i] = mu_bounds.copy()
  221. # get possible mu locations for each sigma
  222. poss_mu = [np.where(idx_ok) for idx_ok in sigma_mu_bounds]
  223. # get mu locations for each bubble, as an index in the possible mu values
  224. mu_idx = [np.random.randint(low=0, high=len(x[0]), size=1) for x in poss_mu]
  225. # generate actual mu values as index plus uniform noise between -0.5 and 0.5 (rather than all mus being on integers)
  226. mu_y = [int(poss_mu[i][0][mu_idx[i]]) for i in range(len(poss_mu))] + np.random.uniform(low=-0.5, high=0.5, size=len(mu_idx))
  227. mu_x = [int(poss_mu[i][1][mu_idx[i]]) for i in range(len(poss_mu))] + np.random.uniform(low=-0.5, high=0.5, size=len(mu_idx))
  228. # build mask
  229. mask = build_mask(mu_y=mu_y, mu_x=mu_x, sigma=sigma, sh=sh, scale=scale, sum_merge=sum_merge)
  230. # apply mask
  231. im_out_mat = apply_mask(im=im, mask=mask, bg=bg, bg_im=bg_im)
  232. im_out = Image.fromarray(np.uint8(im_out_mat))
  233. return(im_out, mask, mu_x, mu_y, sigma)
  234. if __name__ == "__main__":
  235. from argparse import ArgumentParser
  236. parser = ArgumentParser()
  237. parser.add_argument('-i', '--input', help='the file path for the input image',
  238. action='store', required=True, type=str)
  239. parser.add_argument('-o', '--output', help='the path of the desired output file',
  240. action='store', required=True, type=str)
  241. parser.add_argument('-s', '--sigma', nargs='+', help='a list of sigmas for the bubbles, in space-separated format (e.g., "10 10 15")',
  242. action='store', required=True, type=float)
  243. parser.add_argument('-x', '--mu_x', nargs='+', help='x indices (axis 1 in numpy) for bubble locations, in space-separated format - leave blank (default) for random location', type=float)
  244. parser.add_argument('-y', '--mu_y', nargs='+', help='y indices (axis 0 in numpy) for bubble locations, in space-separated format - leave blank (default) for random location', type=float)
  245. parser.add_argument('-b', '--background', nargs='+', help='the desired background for the image, as a single integer from 0 to 255 (default=0), or space-separated values for each channel in the image',
  246. action='store', type=int, default=0)
  247. parser.add_argument('--unscaled', help='do not scale the densities of the bubbles to have the same maxima',
  248. action='store_false')
  249. parser.add_argument('--summerge', help='sum_merge -- should merges, where bubbles overlap, be completed using a simple sum of the bubbles, thresholded to the maxima of the pre-merged bubbles? If not (the default), densities are instead averaged (mean).',
  250. action='store_true')
  251. parser.add_argument('--seed', help='random seed to use', action='store', type=int)
  252. args = parser.parse_args()
  253. if args.seed is not None:
  254. np.random.seed(args.seed)
  255. im = Image.open(args.input)
  256. im_out = bubbles_mask(im=im, mu_x=args.mu_x, mu_y=args.mu_y, sigma=args.sigma, bg=args.background, scale=args.unscaled, sum_merge=args.summerge)[0]
  257. im_out.save(args.output)