def overlapping_spheres(shape: List[int], radius: int, porosity: float, iter_max: int = 10, tol: float = 0.01): r""" Generate a packing of overlapping mono-disperse spheres Parameters ---------- shape : list The size of the image to generate in [Nx, Ny, Nz] where Ni is the number of voxels in the i-th direction. radius : scalar The radius of spheres in the packing. porosity : scalar The porosity of the final image, accurate to the given tolerance. iter_max : int Maximum number of iterations for the iterative algorithm that improves the porosity of the final image to match the given value. tol : float Tolerance for porosity of the final image compared to the given value. Returns ------- image : ND-array A boolean array with ``True`` values denoting the pore space Notes ----- This method can also be used to generate a dispersion of hollows by treating ``porosity`` as solid volume fraction and inverting the returned image. """ shape = sp.array(shape) if sp.size(shape) == 1: shape = sp.full((3, ), int(shape)) ndim = (shape != 1).sum() s_vol = ps_disk(radius).sum() if ndim == 2 else ps_ball(radius).sum() bulk_vol = sp.prod(shape) N = int(sp.ceil((1 - porosity)*bulk_vol/s_vol)) im = sp.random.random(size=shape) # Helper functions for calculating porosity: phi = g(f(N)) f = lambda N: spim.distance_transform_edt(im > N/bulk_vol) < radius g = lambda im: 1 - im.sum() / sp.prod(shape) # # Newton's method for getting image porosity match the given # w = 1.0 # Damping factor # dN = 5 if ndim == 2 else 25 # Perturbation # for i in range(iter_max): # err = g(f(N)) - porosity # d_err = (g(f(N+dN)) - g(f(N))) / dN # if d_err == 0: # break # if abs(err) <= tol: # break # N2 = N - int(err/d_err) # xnew = xold - f/df # N = w * N2 + (1-w) * N # Bisection search: N is always undershoot (bc. of overlaps) N_low, N_high = N, 4*N for i in range(iter_max): N = sp.mean([N_high, N_low], dtype=int) err = g(f(N)) - porosity if err > 0: N_low = N else: N_high = N if abs(err) <= tol: break return ~f(N)
def RSA(im: array, radius: int, volume_fraction: int = 1, mode: str = 'extended'): r""" Generates a sphere or disk packing using Random Sequential Addition This which ensures that spheres do not overlap but does not guarantee they are tightly packed. Parameters ---------- im : ND-array The image into which the spheres should be inserted. By accepting an image rather than a shape, it allows users to insert spheres into an already existing image. To begin the process, start with an array of zero such as ``im = np.zeros([200, 200], dtype=bool)``. radius : int The radius of the disk or sphere to insert. volume_fraction : scalar The fraction of the image that should be filled with spheres. The spheres are addeds 1's, so each sphere addition increases the ``volume_fraction`` until the specified limit is reach. mode : string Controls how the edges of the image are handled. Options are: 'extended' - Spheres are allowed to extend beyond the edge of the image 'contained' - Spheres are all completely within the image 'periodic' - The portion of a sphere that extends beyond the image is inserted into the opposite edge of the image (Not Implemented Yet!) Returns ------- image : ND-array A copy of ``im`` with spheres of specified radius *added* to the background. Notes ----- Each sphere is filled with 1's, but the center is marked with a 2. This allows easy boolean masking to extract only the centers, which can be converted to coordinates using ``scipy.where`` and used for other purposes. The obtain only the spheres, use``im = im == 1``. This function adds spheres to the background of the received ``im``, which allows iteratively adding spheres of different radii to the unfilled space. References ---------- [1] Random Heterogeneous Materials, S. Torquato (2001) """ # Note: The 2D vs 3D splitting of this just me being lazy...I can't be # bothered to figure it out programmatically right now # TODO: Ideally the spheres should be added periodically print(78*'―') print('RSA: Adding spheres of size ' + str(radius)) d2 = len(im.shape) == 2 mrad = 2*radius if d2: im_strel = ps_disk(radius) mask_strel = ps_disk(mrad) else: im_strel = ps_ball(radius) mask_strel = ps_ball(mrad) if sp.any(im > 0): # Dilate existing objects by im_strel to remove pixels near them # from consideration for sphere placement mask = ps.tools.fftmorphology(im > 0, im_strel > 0, mode='dilate') mask = mask.astype(int) else: mask = sp.zeros_like(im) if mode == 'contained': mask = _remove_edge(mask, radius) elif mode == 'extended': pass elif mode == 'periodic': raise Exception('Periodic edges are not implemented yet') else: raise Exception('Unrecognized mode: ' + mode) vf = im.sum()/im.size free_spots = sp.argwhere(mask == 0) i = 0 while vf <= volume_fraction and len(free_spots) > 0: choice = sp.random.randint(0, len(free_spots), size=1) if d2: [x, y] = free_spots[choice].flatten() im = _fit_strel_to_im_2d(im, im_strel, radius, x, y) mask = _fit_strel_to_im_2d(mask, mask_strel, mrad, x, y) im[x, y] = 2 else: [x, y, z] = free_spots[choice].flatten() im = _fit_strel_to_im_3d(im, im_strel, radius, x, y, z) mask = _fit_strel_to_im_3d(mask, mask_strel, mrad, x, y, z) im[x, y, z] = 2 free_spots = sp.argwhere(mask == 0) vf = im.sum()/im.size i += 1 if vf > volume_fraction: print('Volume Fraction', volume_fraction, 'reached') if len(free_spots) == 0: print('No more free spots', 'Volume Fraction', vf) return im
def RSA(im: array, radius: int, volume_fraction: int = 1, n_max: int = None, mode: str = 'contained'): r""" Generates a sphere or disk packing using Random Sequential Addition This algorithm ensures that spheres do not overlap but does not guarantee they are tightly packed. This function adds spheres to the background of the received ``im``, which allows iteratively adding spheres of different radii to the unfilled space, be repeatedly passing in the result of previous calls to RSA. Parameters ---------- im : ND-array The image into which the spheres should be inserted. By accepting an image rather than a shape, it allows users to insert spheres into an already existing image. To begin the process, start with an array of zeros such as ``im = np.zeros([200, 200, 200], dtype=bool)``. radius : int The radius of the disk or sphere to insert. volume_fraction : scalar (default is 1.0) The fraction of the image that should be filled with spheres. The spheres are added as 1's, so each sphere addition increases the ``volume_fraction`` until the specified limit is reach. Note that if ``n_max`` is reached first, then ``volume_fraction`` will not be acheived. n_max : int (default is 10,000) The maximum number of spheres to add. By default the value of ``n_max`` is high so that the addition of spheres will go indefinately until ``volume_fraction`` is met, but specifying a smaller value will halt addition after the given number of spheres are added. mode : string (default is 'contained') Controls how the edges of the image are handled. Options are: 'contained' - Spheres are all completely within the image 'extended' - Spheres are allowed to extend beyond the edge of the image. In this mode the volume fraction will be less that requested since some spheres extend beyond the image, but their entire volume is counted as added for computational efficiency. Returns ------- image : ND-array A handle to the input ``im`` with spheres of specified radius *added* to the background. Notes ----- This function uses Numba to speed up the search for valid sphere insertion points. It seems that Numba does not look at the state of the scipy random number generator, so setting the seed to a known value has no effect on the output of this function. Each call to this function will produce a unique image. If you wish to use the same realization multiple times you must save the array (e.g. ``numpy.save``). References ---------- [1] Random Heterogeneous Materials, S. Torquato (2001) """ print(80*'-') print(f'RSA: Adding spheres of size {radius}') im = im.astype(bool) if n_max is None: n_max = 10000 vf_final = volume_fraction vf_start = im.sum()/im.size print('Initial volume fraction:', vf_start) if im.ndim == 2: template_lg = ps_disk(radius*2) template_sm = ps_disk(radius) else: template_lg = ps_ball(radius*2) template_sm = ps_ball(radius) vf_template = template_sm.sum()/im.size # Pad image by the radius of large template to enable insertion near edges im = np.pad(im, pad_width=2*radius, mode='edge') # Depending on mode, adjust mask to remove options around edge if mode == 'contained': border = get_border(im.shape, thickness=2*radius, mode='faces') elif mode == 'extended': border = get_border(im.shape, thickness=radius+1, mode='faces') else: raise Exception('Unrecognized mode: ', mode) # Remove border pixels im[border] = True # Dilate existing objects by strel to remove pixels near them # from consideration for sphere placement print('Dilating foreground features by sphere radius') dt = edt(im == 0) options_im = (dt >= radius) # ------------------------------------------------------------------------ # Begin inserting the spheres vf = vf_start free_sites = np.flatnonzero(options_im) i = 0 while (vf <= vf_final) and (i < n_max) and (len(free_sites) > 0): c, count = _make_choice(options_im, free_sites=free_sites) # The 100 below is arbitrary and may change performance if count > 100: # Regenerate list of free_sites print('Regenerating free_sites after', i, 'iterations') free_sites = np.flatnonzero(options_im) if all(np.array(c) == -1): break s_sm = tuple([slice(x - radius, x + radius + 1, None) for x in c]) s_lg = tuple([slice(x - 2*radius, x + 2*radius + 1, None) for x in c]) im[s_sm] += template_sm # Add ball to image options_im[s_lg][template_lg] = False # Update extended region vf += vf_template i += 1 print('Number of spheres inserted:', i) # ------------------------------------------------------------------------ # Get slice into returned image to retain original size s = tuple([slice(2*radius, d-2*radius, None) for d in im.shape]) im = im[s] vf = im.sum()/im.size print('Final volume fraction:', vf) return im