123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- """Functions for Creating Gaussian Bubbles images, with bubbles_mask_nonzero() specialised for letters"""
- from PIL import Image
- import numpy as np
- from scipy.stats import norm
- from scipy.ndimage import gaussian_filter
- from skimage.morphology import binary_dilation
- import warnings
- def build_mask(mu_y, mu_x, sigma, sh, scale, sum_merge):
- """Build a Bubbles mask which can be applied to an image of shape `sh`. Returns a matrix for the mask.
-
- Keyword arguments:
- mu_y -- the locations of the bubbles centres, in numpy axis 0
- mu_x -- the locations of the bubbles centres, in numpy axis 1 (should be same len as mu_y)
- sigma -- array of sigmas for the spread of the bubbles (should be same len as mu_y)
- sh -- shape (np.shape) of the desired mask (usually the shape of the respective image)
- scale -- should densities' maxima be consistently scaled across different sigma values?
- 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).
- """
- # check lengths match and are all 1d
- gauss_pars_sh = [np.shape(x) for x in [mu_y, mu_x, sigma]]
- gauss_pars_n_dims = [len(x) for x in gauss_pars_sh]
-
- if len(set(gauss_pars_sh))!=1 or any(gauss_pars_n_dims)!=1:
- ValueError('mu_y, mu_x, and sigma should all be 1-dimensional arrays of identical length')
-
- # for each distribution, generate the bubble
- dists = [
- # get the outer product of vectors for the densities of pixel indices across x and y dimensions, for each distribution (provides 2d density)
- np.outer(
- norm.pdf(np.arange(start=0, stop=sh[0]), mu_y_i, sigma_i),
- norm.pdf(np.arange(start=0, stop=sh[1]), mu_x_i, sigma_i)
- )
- for mu_x_i, mu_y_i, sigma_i in zip(mu_x, mu_y, sigma)
- ]
-
- # scale all bubbles consistently if requested
- if scale:
- dists = [x/np.max(x) for x in dists]
-
- if sum_merge:
- # sum the distributions, then threshold the maximum to the maximum peak
- mask = np.sum(dists, axis=0)
- mask[mask>np.max(dists)] = np.max(dists)
- else:
- # merge using average of densities
- mask = np.mean(dists, axis=0)
-
- # scale density to within [0, 1] (will already be scaled to [0, 1] above if scale==True)
- mask /= np.max(mask)
-
- return(mask)
- def build_conv_mask(mu_y, mu_x, sigma, sh):
- """
- Build a Bubbles mask via convolution which can be applied to an image of shape `sh`. Returns a matrix for the mask.
- Unlike build_mask(), build_conv_mask() requires that all sigma values are equal.
-
- Keyword arguments:
- mu_y -- the locations of the bubbles centres, in numpy axis 0. Must be integers (will be rounded otherwise)
- 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)
- 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)
- sh -- shape (np.shape) of the desired mask (usually the shape of the respective image)
- """
- # if sigma is given as a list, get the single value
- if isinstance(sigma, list) | isinstance(sigma, np.ndarray):
- sigma = np.unique(sigma)
-
- # if more than one sigma value, give error
- if len(sigma)>1:
- ValueError('for the convolution approach, sigma should be of length one, or else all values should be identical')
- # check lengths for mu match and are both 1d
- gauss_pars_sh = [np.shape(x) for x in [mu_y, mu_x]]
- gauss_pars_n_dims = [len(x) for x in gauss_pars_sh]
-
- if len(set(gauss_pars_sh))!=1 or any(gauss_pars_n_dims)!=1:
- ValueError('mu_y and mu_x should both be 1-dimensional arrays of identical length')
- # generate the pre-convolution mask
- mask_preconv = np.zeros(sh)
- mask_preconv[
- np.array(mu_y).astype(int),
- np.array(mu_x).astype(int)
- ] = 1
- # apply the filter via scipy.signal.gaussian_filter (uses a series of 1d convolutions)
- mask = gaussian_filter(mask_preconv, sigma=float(sigma), mode='constant', cval=0.0)
- # scale the mask
- mask /= np.max(mask)
- return(mask)
- def apply_mask(im, mask, bg=0, bg_im=None):
- """Apply a mask to image `im`. Returns a PIL image.
-
- Keyword arguments:
- im -- the original image
- mask -- the mask to apply to the image
- 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
- 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`.
- """
- if type(im) is np.ndarray:
- if im.max() <= 1:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
- im = Image.fromarray(np.uint8(im * 255))
- else:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
- im = Image.fromarray(np.uint8(im))
-
- sh = np.asarray(im).shape
-
- if len(sh)>2:
- n_col_chs = sh[2]
- else:
- n_col_chs = 1
-
- if n_col_chs > 1:
- im_out_mat = im * np.repeat(mask[:,:,np.newaxis], n_col_chs, axis=2)
- else:
- im_out_mat = im * mask
-
- if bg_im is None:
- # check bg is array
- if ~isinstance(bg, np.ndarray):
- bg = np.array(bg)
- # adjust the background
- if np.any(bg != 0):
- if n_col_chs > 1:
- im_bg_mat = bg * (1 - np.repeat(mask[:,:,np.newaxis], sh[2], axis=2))
- else:
- im_bg_mat = bg * (1 - mask)
-
- im_out_mat += im_bg_mat
- else:
- n_bg_col_chs = np.asarray(bg_im).shape[2]
- if n_bg_col_chs > 1:
- im_bg_mat = bg_im * (1 - np.repeat(mask[:,:,np.newaxis], n_bg_col_chs, axis=2))
- else:
- im_bg_mat = bg_im * (1 - mask)
-
- im_out_mat += im_bg_mat
-
- return(im_out_mat)
- 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):
- """Apply the bubbles mask to a given PIL image. Returns the edited PIL image, the generated mask, mu_y, mu_x, and sigma.
-
- Keyword arguments:
- im -- the PIL image to apply the bubbles mask to
- mu_x -- x indices (axis 1 in numpy) for bubble locations - set to None (default) for random location
- mu_y -- y indices (axis 0 in numpy) for bubble locations - set to None (default) for random location
- sigma -- array of sigmas for the spread of the bubbles. `n` is inferred from this array
- 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
- scale -- should densities' maxima be consistently scaled across different sigma values?
- 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).
- 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`.
- """
- if type(im) is np.ndarray:
- if im.max() <= 1:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
- im = Image.fromarray(np.uint8(im * 255))
- else:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
- im = Image.fromarray(np.uint8(im))
-
- n = len(sigma) # get n bubbles
- sh = np.asarray(im).shape # get shape
-
- # generate distributions' locations
- if mu_y is None:
- mu_y = np.random.uniform(low=0, high=sh[0], size=n)
-
- if mu_x is None:
- mu_x = np.random.uniform(low=0, high=sh[1], size=n)
-
- # build mask
- mask = build_mask(mu_y=mu_y, mu_x=mu_x, sigma=sigma, sh=sh, scale=scale, sum_merge=sum_merge)
-
- # apply mask
- im_out_mat = apply_mask(im=im, mask=mask, bg=bg, bg_im=bg_im)
-
- im_out = Image.fromarray(np.uint8(im_out_mat))
-
- return(im_out, mask, mu_x, mu_y, sigma)
- def bubbles_conv_mask (im, mu_x=None, mu_y=None, sigma=np.repeat(25, repeats=5), bg=0, bg_im=None):
- """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.
-
- Keyword arguments:
- im -- the PIL image to apply the bubbles mask to
- 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)
- 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)
- 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
- 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
- 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`.
- """
- if type(im) is np.ndarray:
- if im.max() <= 1:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
- im = Image.fromarray(np.uint8(im * 255))
- else:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
- im = Image.fromarray(np.uint8(im))
-
- n = len(sigma) # get n bubbles
- sh = np.asarray(im).shape # get shape
-
- # generate distributions' locations
- if mu_y is None:
- mu_y = np.random.randint(low=0, high=sh[0], size=n)
-
- if mu_x is None:
- mu_x = np.random.randint(low=0, high=sh[1], size=n)
-
- # build mask
- mask = build_conv_mask(mu_y=mu_y, mu_x=mu_x, sigma=sigma, sh=sh)
-
- # apply mask
- im_out_mat = apply_mask(im=im, mask=mask, bg=bg, bg_im=bg_im)
-
- im_out = Image.fromarray(np.uint8(im_out_mat))
-
- return(im_out, mask, mu_x, mu_y, sigma)
- 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):
- """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.
-
- Keyword arguments:
- im -- the image to apply the bubbles mask to
- 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)
- sigma -- array of sigmas for the spread of the bubbles. `n` is inferred from this array
- bg -- value for the background, from 0 to 255. Can also be an array of 3 values from 0 to 255, for RGB
- scale -- should densities' maxima be consistently scaled across different sigma values?
- 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).
- 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
- 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`.
- """
- if type(im) is np.ndarray:
- if im.max() <= 1:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 1.')
- im = Image.fromarray(np.uint8(im * 255))
- else:
- warnings.warn('Expected PIL.Image but got np.array - will try to convert assuming max value is 255.')
- im = Image.fromarray(np.uint8(im))
-
- sh = np.asarray(im).shape # get shape
-
- # if no limits, just use bubbles_mask()
- if max_sigma_from_nonzero == np.Infinity:
- return(bubbles_mask(im=im, sigma=sigma, bg=bg, scale=scale, bg_im=bg_im))
-
- # get the acceptable mu locations for each sigma value, and store in `sigma_mu_bounds`
-
- # get acceptable boundaries for each sigma
- sigma_dil_iters = [int(np.round(s * max_sigma_from_nonzero)) for s in sigma]
-
- n_iter = max(sigma_dil_iters)
-
- if ref_im is None:
- mu_bounds = np.max(np.asarray(im) > bg, axis=2)
- else:
- mu_bounds = np.max(np.asarray(ref_im) > bg, axis=2)
-
- # this will contain the desired mu bounds for each sigma
- sigma_mu_bounds = [None] * len(sigma)
-
- for i in range(n_iter):
- binary_dilation(mu_bounds, out=mu_bounds)
-
- if i+1 in sigma_dil_iters:
- matching_sigma_idx = list(np.where(np.array(sigma_dil_iters) == (i+1))[0])
- for sigma_i in matching_sigma_idx:
- sigma_mu_bounds[sigma_i] = mu_bounds.copy()
- # get possible mu locations for each sigma
- poss_mu = [np.where(idx_ok) for idx_ok in sigma_mu_bounds]
-
- # get mu locations for each bubble, as an index in the possible mu values
- mu_idx = [np.random.randint(low=0, high=len(x[0]), size=1) for x in poss_mu]
-
- # generate actual mu values as index plus uniform noise between -0.5 and 0.5 (rather than all mus being on integers)
- 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))
- 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))
-
- # build mask
- mask = build_mask(mu_y=mu_y, mu_x=mu_x, sigma=sigma, sh=sh, scale=scale, sum_merge=sum_merge)
-
- # apply mask
- im_out_mat = apply_mask(im=im, mask=mask, bg=bg, bg_im=bg_im)
- im_out = Image.fromarray(np.uint8(im_out_mat))
-
- return(im_out, mask, mu_x, mu_y, sigma)
- if __name__ == "__main__":
- from argparse import ArgumentParser
- parser = ArgumentParser()
-
- parser.add_argument('-i', '--input', help='the file path for the input image',
- action='store', required=True, type=str)
-
- parser.add_argument('-o', '--output', help='the path of the desired output file',
- action='store', required=True, type=str)
-
- parser.add_argument('-s', '--sigma', nargs='+', help='a list of sigmas for the bubbles, in space-separated format (e.g., "10 10 15")',
- action='store', required=True, type=float)
-
- 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)
-
- 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)
-
- 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',
- action='store', type=int, default=0)
-
- parser.add_argument('--unscaled', help='do not scale the densities of the bubbles to have the same maxima',
- action='store_false')
-
- 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).',
- action='store_true')
-
- parser.add_argument('--seed', help='random seed to use', action='store', type=int)
-
- args = parser.parse_args()
-
- if args.seed is not None:
- np.random.seed(args.seed)
-
- im = Image.open(args.input)
- 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]
- im_out.save(args.output)
-
-
|