import inspect as insp
import logging
from typing import List, Literal
import numpy as np
import numpy.typing as npt
import scipy.ndimage as spim
import scipy.spatial as sptl
import scipy.stats as spst
from numba import njit
from porespy import metrics, settings
from porespy.filters import chunked_func
from porespy.tools import (
_insert_disk_at_points,
_insert_disk_at_points_parallel,
all_to_uniform,
extract_subsection,
get_border,
get_tqdm,
insert_sphere,
ps_ball,
ps_disk,
)
try:
from pyedt import edt
except ModuleNotFoundError:
from edt import edt
__all__ = [
"blobs",
"bundle_of_tubes",
"cylinders",
"cylindrical_plug",
"elevation",
"insert_shape",
"lattice_spheres",
"line_segment",
"overlapping_spheres",
"polydisperse_spheres",
"ramp",
"random_spheres",
"voronoi_edges",
"_get_Voronoi_edges",
]
tqdm = get_tqdm()
logger = logging.getLogger(__name__)
def elevation(
shape: List,
voxel_size: float,
axis: int = 0,
):
r"""
Generates a image of distances from given axis
Parameters
----------
shape : ndarray or list
This dictates the shape of the output image. If an image is supplied, then
it's shape is used. Otherwise, the shape should be supplied as a N-D long
list of the shape for each axis (i.e. `[200, 200]` or `[300, 300, 300]`).
voxel_size : scalar
The size of the voxels in physical units (i.e. `100e-6` would be 100 um per
voxel side).
axis : int, optional, default is 0
The direction along which the height is calculated. The default is 0, which
is the 'x-axis'.
Returns
-------
elevation : ndarray
A numpy array of the specified shape with the values in each voxel indicating
the height of that voxel from the beginning of the specified axis.
See Also
--------
ramp
Examples
--------
# TODO: Create a notebook example for this function
"""
im = np.zeros(shape, dtype=bool)
im = np.swapaxes(im, 0, axis)
a = np.arange(0, im.shape[0])
b = np.reshape(a, [im.shape[0], 1, 1])
c = np.tile(b, (1, *im.shape[1:]))
c = c*voxel_size
h = np.swapaxes(c, 0, axis)
return h
def ramp(
shape: List,
inlet: float = 1.0,
outlet: float = 0.0,
axis: int = 0,
):
r"""
Generates an array containing a linear ramp of greyscale values along the given
axis.
This is useful for de-trending at concentration gradient, or for computing
the elevation of each voxel for use in the capillary transform.
Parameter
---------
shape : list
The [X, Y, Z] dimension of the desired image. Z is optional.
inlet : scalar
The values to place the beginning of the specified axis. The default is 1.0.
outlet : scalar
The values to place the end of the specified axis. The default is 0.0.
axis : scalar
The axis along which the ramp should be directed. The default is 0,
corresponding to the x-axis.
Returns
-------
ramp : ndarray
An array of the requested shape with values changing linearly from inlet
to outlet in the direction specified.
See Also
--------
elevation
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/ramp.html>`_
to view online example.
"""
shape = np.array(shape)
vals = np.linspace(inlet, outlet, shape[axis])
vals = np.reshape(vals, [shape[axis]]+[1]*len(shape[1:]))
vals = np.swapaxes(vals, 0, axis)
shape[axis] = 1
ramp = np.tile(vals, shape)
return ramp
[docs]
def cylindrical_plug(shape, r=None, axis=2):
r"""
Generates a cylindrical plug suitable for use as a mask on a tomogram
Parameters
----------
shape : array_like
The shape of the image to create. Can be 3D, or 2D in which case
a circle is returned.
r : int (optional)
The diameter of the cylinder to create. If not provided then the
largest possible radius is used to fit within the image.
axis : int
The direction along with the cylinder's axis of rotation should be
oriented. The default is 2, which is the z-direction.
Returns
-------
cylinder : ndarray
A boolean image of the size given by ``shape`` with ``True``'s
inside the cylinder and ``False``'s outside.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/cylindrical_plug.html>`_
to view online example.
"""
shape = np.array(shape, dtype=int)
axes = np.array(list(set([0, 1, 2]).difference(set([axis]))), dtype=int)
if len(shape) == 3:
im2d = np.ones(shape=shape[axes])
im2d[int(shape[axes[0]]/2), int(shape[axes[1]]/2)] = 0
dt = edt(im2d)
if r is None:
r = int(min(shape[axes])/2)
circ = dt < r
tile_ax = [1, 1, 1]
tile_ax[axis] = shape[axis]
circ = np.expand_dims(circ, axis)
cyl = np.tile(circ, tile_ax)
if len(shape) == 2:
im2d = np.ones(shape=shape)
im2d[int(shape[0]/2), int(shape[1]/2)] = 0
dt = edt(im2d)
if r is None:
r = int(min(shape[axes])/2)
cyl = dt < r
return cyl
[docs]
def insert_shape(im, element, center=None, corner=None, value=1, mode="overwrite"):
r"""
Inserts sub-image into a larger image at the specified location.
If the inserted image extends beyond the boundaries of the image it will
be cropped accordingly.
Parameters
----------
im : ndarray
The image into which the sub-image will be inserted
element : ndarray
The sub-image to insert
center : tuple
Coordinates indicating the position in the main image where the
inserted imaged will be centered. If ``center`` is given then
``corner`` cannot be specified. Note that ``center`` can only be
used if all dimensions of ``element`` are odd, otherwise the meaning
of center is not defined.
corner : tuple
Coordinates indicating the position in the main image where the
lower corner (i.e. [0, 0, 0]) of the inserted image should be anchored.
If ``center`` is given then ``corner`` cannot be specified.
value : scalar
A scalar value to apply to the sub-image. The default is 1.
mode : string
If 'overwrite' (default) the inserted image replaces the values in the
main image. If 'overlay' the inserted image is added to the main
image. In both cases the inserted image is multiplied by ``value``
first.
Returns
-------
im : ndarray
A copy of ``im`` with the supplied element inserted.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/insert_shape.html>`_
to view online example.
"""
im = im.copy()
if im.ndim != element.ndim:
msg = f"Image shape {im.shape} and element shape {element.shape} do not match"
raise Exception(msg)
s_im = []
s_el = []
if (center is not None) and (corner is None):
for dim in range(im.ndim):
r, d = np.divmod(element.shape[dim], 2)
if d == 0:
raise Exception(
"Cannot specify center point when element "
+ "has one or more even dimension"
)
lower_im = np.amax((center[dim] - r, 0))
upper_im = np.amin((center[dim] + r + 1, im.shape[dim]))
s_im.append(slice(lower_im, upper_im))
lower_el = np.amax((lower_im - center[dim] + r, 0))
upper_el = np.amin((upper_im - center[dim] + r, element.shape[dim]))
s_el.append(slice(lower_el, upper_el))
elif (corner is not None) and (center is None):
for dim in range(im.ndim):
L = int(element.shape[dim])
lower_im = np.amax((corner[dim], 0))
upper_im = np.amin((corner[dim] + L, im.shape[dim]))
s_im.append(slice(lower_im, upper_im))
lower_el = np.amax((lower_im - corner[dim], 0))
upper_el = np.amin((upper_im - corner[dim], element.shape[dim]))
s_el.append(slice(min(lower_el, upper_el), upper_el))
else:
raise Exception("Cannot specify both corner and center")
if mode == "overlay":
im[tuple(s_im)] = im[tuple(s_im)] + element[tuple(s_el)] * value
elif mode == "overwrite":
im[tuple(s_im)] = element[tuple(s_el)] * value
else:
raise Exception("Invalid mode " + mode)
return im
[docs]
def random_spheres(
shape: List = None,
im: npt.ArrayLike = None,
r: int = 5,
clearance: int = 0,
protrusion: int = 0,
maxiter: int = 100000,
phi: float = 1.0,
edges: Literal['contained', 'extended'] = 'contained',
smooth: bool = True,
seed: int = None,
value: int = True,
):
r"""
Generates a sphere or disk packing using random sequential addition as
described by Torquato [1]_.
Parameters
----------
shape : list
The shape of the image to create. This is equivalent to passing an array
of `False` values of the desired size to `im`.
im : ndarray
Image with `False` indicating the voxels where spheres should be
inserted. This can be used to insert spheres into an image that already
has some features (e.g. half filled with larger spheres, or a cylindrical
plug).
r : int
The radius of the disk or sphere to insert. The default is 5.
phi : scalar (default is 1.0)
The fraction of the image that should be filled with spheres. The
spheres are added as ``True``'s, so each sphere addition increases the
``volume_fraction`` until the specified limit is reached. Note that if
``n_max`` is reached first, then ``volume_fraction`` will not be
achieved. Also, ``volume_fraction`` is not counted correctly if the
``mode`` is ``'extended'``.
clearance : int (optional, default = 0)
The amount of space to put between each sphere. Negative values are
acceptable to create overlaps, so long as ``abs(clearance) < r``.
protrusion : int (optional, default = 0)
The amount by which inserted spheres are allowed to protrude outside of
the given forground. If set to 0 (the default) then all spheres will
be fully inside the region marked ``False`` in the input image.
maxiter : int (default is 100,000)
The maximum number of spheres to add. Using a low value may halt
the addition process prior to reaching the specified ``phi``. If
``None`` is given, then no limit is used.
edges : string (default is 'contained')
Controls how the edges of the image are handled. Options are:
============ ===============================================================
Edge Mode Description
============ ===============================================================
'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 than
requested since some spheres extend beyond the image, but their
entire volume is counted as added for computational efficiency.
============ ===============================================================
smooth : bool
Indicates whether balls should have smooth faces (``True``) or should
include the bumps on the extremities (``False``).
seed : int
The seed to supply to the random number generators. Because this function
uses ``numba`` for speed, calling the normal ``numpy.random.seed(<seed>)``
has no effect. To get a repeatable image, the seed must be passed to the
function so it can be initialized the way ``numba`` requires. The default
is ``None``, which means each call will produce a new realization.
value : scalar
The value to set the inserted spheres to. Using `value > 1` is a handy
way to repeatedly insert different sphere sizes into the same image while
making them easy to identify.
Returns
-------
image : ndarray
An image with spheres of specified radius *added* to the background.
See Also
--------
pseudo_gravity_packing
pseudo_electrostatic_packing
Notes
-----
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
by repeatedly passing in the result of previous calls to the function.
References
----------
.. [1] Random Heterogeneous Materials, S. Torquato (2001)
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/random_spheres.html>`_
to view online example.
"""
logger.debug(f"random_spheres: Adding spheres of size {r}")
if smooth:
r = r + 1
if (im is None) and (shape is not None): # If shape was given, generate empty im
if np.ndim(shape) > 1:
raise Exception('shape must be a list like [Nx, Ny] or [Nx, Ny, Nz]')
im = np.zeros(shape, dtype=type(value))
options_im = np.ones_like(im, dtype=bool)
elif (shape is None) and (im is not None): # Otherwise use im
options_im = edt(im == 0) >= (r - protrusion)
im = np.copy(im).astype(type(value))
else:
raise Exception('Must specify either im or shape')
if seed is not None: # Initialize rng so numba sees it
_set_seed(seed)
# Depending on mode, adjust options_im to remove options around edge
if edges == "contained":
border = get_border(im.shape, thickness=r, mode="faces")
options_im[border] = False
elif edges == "extended":
pass
else:
raise Exception("Unrecognized mode: ", edges)
# Compute maxiter if a phi was specified
if phi < 1.0:
Vsph = 4/3*np.pi*(r**3) if im.ndim == 3 else np.pi*(r**2)
Vbulk = np.prod(im.shape)
maxiter = min(int(np.round(phi*Vbulk/Vsph)), maxiter)
# Begin inserting the spheres
free_sites = np.flatnonzero(options_im)
spheres = np.zeros_like(im, dtype=bool)
for i in range(maxiter):
if len(free_sites) <= 0:
break
# Choose a random site from free_sites
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
logger.debug(f"Regenerating `free_sites` after {i} iterations")
free_sites = np.flatnonzero(options_im)
if all(np.array(c) == -1):
break
spheres = _insert_disk_at_points(im=spheres,
coords=np.vstack(c),
r=r,
v=True,
smooth=smooth)
options_im = _insert_disk_at_points(im=options_im,
coords=np.vstack(c),
r=2*r + clearance,
v=False,
smooth=smooth,
overwrite=True)
logger.info(f"Number of spheres inserted: {i}")
im[spheres] = value
return im
@njit
def _set_seed(a): # pragma: no cover
np.random.seed(a)
@njit
def _get_rand_float(*args): # pragma: no cover
return np.random.rand(*args)
@njit
def _get_rand_int(*args): # pragma: no cover
return np.random.randint(*args)
@njit
def _make_choice(options_im, free_sites): # pragma: no cover
r"""
This function is called by _begin_inserting to find valid insertion
points.
Parameters
----------
options_im : ndarray
An array with ``True`` at all valid locations and ``False`` at all
locations where a sphere already exists PLUS a region of radius R
around each sphere since these points are also invalid insertion
points.
free_sites : array_like
A 1D array containing valid insertion indices. This list is used
to select insertion points from a limited which occasionally gets
smaller.
Returns
-------
coords : list
The XY or XYZ coordinates of the next insertion point
count : int
The number of attempts that were needed to find valid point. If
this value gets too high, a short list of ``free_sites`` should be
generated in the calling function.
"""
choice = False
count = 0
upper_limit = len(free_sites)
maxiter = upper_limit * 20
if options_im.ndim == 2:
coords = [-1, -1]
Nx, Ny = options_im.shape
while not choice:
if count >= maxiter:
coords = [-1, -1]
break
ind = _get_rand_int(0, upper_limit)
# This numpy function is not supported by numba yet
# c1, c2 = np.unravel_index(free_sites[ind], options_im.shape)
# So using manual unraveling
coords[1] = free_sites[ind] % Ny
coords[0] = (free_sites[ind] // Ny) % Nx
choice = options_im[coords[0], coords[1]]
count += 1
if options_im.ndim == 3:
coords = [-1, -1, -1]
Nx, Ny, Nz = options_im.shape
while not choice:
if count >= maxiter:
coords = [-1, -1, -1]
break
ind = _get_rand_int(0, upper_limit)
# This numpy function is not supported by numba yet
# c1, c2, c3 = np.unravel_index(free_sites[ind], options_im.shape)
# So using manual unraveling
coords[2] = free_sites[ind] % Nz
coords[1] = (free_sites[ind] // Nz) % Ny
coords[0] = (free_sites[ind] // (Nz * Ny)) % Nx
choice = options_im[coords[0], coords[1], coords[2]]
count += 1
return coords, count
[docs]
def bundle_of_tubes(
shape: List[int],
spacing: int,
distribution=None,
smooth: bool = True,
seed: int = None,
):
r"""
Create a 3D image of a bundle of tubes, in the form of a rectangular
plate with randomly sized holes through it.
Parameters
----------
shape : list
The size the image, with the 3rd dimension indicating the plate
thickness. If the 3rd dimension is not given then a thickness of
1 voxel is assumed.
spacing : int
The center to center distance of the holes. The hole sizes will
be distributed between this values down to 3 voxels.
distribution : scipy.stats object
A handle to a scipy stats object with the desired parameters.
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/bundle_of_tubes.html>`_
to view online example.
"""
if seed is not None:
np.random.seed(seed)
shape = np.array(shape)
if len(shape) == 2:
shape = np.hstack((shape, [1]))
shape2 = shape[shape > 1]
im = ~lattice_spheres(shape=shape2,
r=1,
offset=0.5*spacing,
spacing=spacing,
lattice='sc')
N = im.sum(dtype=np.int64)
if distribution is None:
# +1 below is because randint 4.X gives a max of 3
distribution = spst.randint(low=3, high=int(spacing/2 + 1))
Rs = distribution.rvs(N)
else:
Rs = distribution.rvs(N)
Rs = np.around(np.clip(Rs, a_min=1, a_max=spacing/2), decimals=0).astype(int)
temp = np.zeros_like(im)
inds = np.where(im)
for i in range(len(inds[0])):
c = np.hstack([j[i] for j in inds])
temp = insert_sphere(im=temp, c=c, r=Rs[i])
# Add 3rd dimension back
temp = np.tile(np.atleast_3d(temp), [1, 1, shape[2]])
return temp
[docs]
def polydisperse_spheres(
shape: List,
porosity: float,
dist,
nbins: int = 5,
r_min: int = 5,
seed=None):
r"""
Create an image of randomly placed, overlapping spheres with a
distribution of radii.
Parameters
----------
shape : list
The size of the image to generate in [Nx, Ny, Nz] where Ni is the
number of voxels in each direction. If shape is only 2D, then an
image of polydisperse disks is returns
porosity : float
The porosity of the image, defined as the number of void voxels
divided by the number of voxels in the image. The specified value
is only matched approximately, so it's suggested to check this
value after the image is generated.
dist : scipy.stats distribution object
This should be an initialized distribution chosen from the large
number of options in the ``scipy.stats`` submodule. For instance,
a normal distribution with a mean of 20 and a standard deviation
of 10 can be obtained with
``dist = scipy.stats.norm(loc=20, scale=10)``
nbins : int
The number of discrete sphere sizes that will be used to generate
the image. This function generates ``nbins`` images of
monodisperse spheres that span 0.05 and 0.95 of the possible
values produced by the provided distribution, then overlays them
to get polydispersivity.
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/polydisperse_spheres.html>`_
to view online example.
"""
if seed is not None:
np.random.seed(seed)
shape = np.array(shape)
if np.size(shape) == 1:
shape = np.full((3,), int(shape))
Rs = dist.interval(np.linspace(0.05, 0.95, nbins))
Rs = np.vstack(Rs).T
Rs = (Rs[:-1] + Rs[1:]) / 2
Rs = np.clip(Rs.flatten(), a_min=r_min, a_max=None)
phi_desired = 1 - (1 - porosity) / (len(Rs))
im = np.ones(shape, dtype=bool)
for r in Rs:
phi_im = im.sum(dtype=np.int64) / np.prod(shape)
phi_corrected = 1 - (1 - phi_desired) / phi_im
temp = overlapping_spheres(shape=shape, r=r, porosity=phi_corrected)
im = im * temp
return im
[docs]
def voronoi_edges(
shape: List[int],
ncells: int,
r: int = 0,
flat_faces: bool = True,
seed: int = None,
**kwargs,
):
r"""
Create an image from the edges of a Voronoi tessellation.
Parameters
----------
shape : array_like
The size of the image to generate in [Nx, Ny, <Nz>] where Ni is the
number of voxels in each direction. If Nz is not given a 2D image
is returned.
ncells : int
The number of Voronoi cells to include in the tesselation.
r : int, optional
The radius to which Voronoi edges should be dilated in the final
image. If not given then edges are a single pixel/voxel thick.
flat_faces : bool
Whether the Voronoi edges should lie on the boundary of the
image (``True``), or if edges outside the image should be removed
(``False``).
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/voronoi_edges.html>`_
to view online example.
"""
if 'radius' in kwargs.keys():
r = kwargs['radius']
print('radius keyword is deprecated in favor of just r')
if seed is not None:
np.random.seed(seed)
logger.info(f"Generating {ncells} cells")
shape = np.array(shape)
if np.size(shape) == 1:
shape = np.full((3,), int(shape))
im = np.zeros(shape, dtype=bool)
base_pts = tuple(np.around(np.random.rand(ncells, im.ndim) * (shape-1),
decimals=0).T.astype(int))
im[tuple(base_pts)] = True
pw = [(s, s) for s in im.shape]
if flat_faces:
im = np.pad(im, pad_width=pw, mode='symmetric')
else:
im = np.pad(im, pad_width=pw, mode='constant', constant_values=0)
base_pts = np.where(im)
vor = sptl.Voronoi(points=np.vstack(base_pts).T)
vor.vertices = np.around(vor.vertices)
vor.vertices *= (np.array(im.shape) - 1) / np.array(im.shape)
vor.edges = _get_Voronoi_edges(vor)
im = np.zeros_like(im)
for row in vor.edges:
pts = np.around(vor.vertices[row], decimals=0).astype(int)
if np.all(pts >= 0) and np.all(pts < im.shape):
line_pts = line_segment(pts[0], pts[1])
im[tuple(line_pts)] = True
im = extract_subsection(im=im, shape=shape)
im = edt(~im) > r
return im
def _get_Voronoi_edges(vor):
r"""
Given a Voronoi object as produced by the scipy.spatial.Voronoi class,
this function calculates the start and end points of each edge in the
Voronoi diagram, in terms of the vertex indices used by the received
Voronoi object.
Parameters
----------
vor : scipy.spatial.Voronoi object
Returns
-------
A 2-by-N array of vertex indices, indicating the start and end points
of each vertex in the Voronoi diagram. These vertex indices can be
used to index straight into the ``vor.vertices`` array to get spatial
positions.
"""
edges = [[], []]
for facet in vor.ridge_vertices:
# Create a closed cycle of vertices that define the facet
edges[0].extend(facet[:-1] + [facet[-1]])
edges[1].extend(facet[1:] + [facet[0]])
edges = np.vstack(edges).T # Convert to scipy-friendly format
mask = np.any(edges == -1, axis=1) # Identify edges at infinity
edges = edges[~mask] # Remove edges at infinity
edges = np.sort(edges, axis=1) # Move all points to upper triangle
# Remove duplicate pairs
edges = edges[:, 0] + 1j * edges[:, 1] # Convert to imaginary
edges = np.unique(edges) # Remove duplicates
edges = np.vstack((np.real(edges), np.imag(edges))).T # Back to real
edges = np.array(edges, dtype=int)
return edges
[docs]
def lattice_spheres(
shape: List,
r: int = 5,
spacing: int = None,
offset: int = None,
smooth: bool = True,
lattice: Literal['sc', 'tri', 'fcc', 'bcc'] = "sc",
):
r"""
Generate a cubic packing of spheres in a specified lattice arrangement.
Parameters
----------
shape : list
The size of the image to generate in [Nx, Ny, Nz] where N is
the number of voxels in each direction. For a 2D image, use
[Nx, Ny].
radius : int
The radius of spheres (circles) in the packing.
spacing : int or List[int]
The spacing between unit cells. If the spacing is too small then
spheres may overlap. If an ``int`` is given it will be applied
in all directions, while a list of ``int`` will be interpreted
to apply along each axis.
offset : int or List[int]
The amount offset to add between sphere centers and the edges of
the image. A single ``int`` will be applied in all directions,
while a list of ``int`` will be interpreted to apply along each
axis.
smooth : bool, default=True
If ``True`` (default) the outer extremities of the sphere will
not have the little bumps on each face.
lattice : str
Specifies the type of lattice to create. Options are:
======= ===============================================================
option description
======= ===============================================================
'sc' Simple cubic (default), works in both 2D and 3D.
'tri' Triangular, only works in 2D
'fcc' Face centered cubic, only works in 3D
'bcc' Body centered cubic, only works on 3D
======= ===============================================================
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space.
Notes
-----
For 2D images, 'sc' gives a square lattice and both 'fcc' and
'bcc' give a triangular lattice.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/lattice_spheres.html>`_
to view online example.
"""
logger.debug(f"Generating {lattice} lattice")
shape = np.array(shape)
im = np.zeros(shape, dtype=bool)
# Parse lattice type
lattice = lattice.lower()
if im.ndim == 2:
if lattice in ['sc', 'square', 'cubic', 'simple cubic']:
lattice = 'sq'
elif lattice in ['tri', 'triangular']:
lattice = 'tri'
else:
raise Exception(f'Unrecognized mode: {lattice}')
else:
if lattice in ['sc', 'cubic', 'simple cubic']:
lattice = 'sc'
elif lattice in ['bcc', 'body centered cubic']:
lattice = 'bcc'
elif lattice in ['fcc', 'face centered cubic']:
lattice = 'fcc'
else:
raise Exception(f'Unrecognized mode: {lattice}')
# Parse offset and spacing args
if spacing is None:
spacing = 2*r
if isinstance(spacing, (int, float)):
spacing = int(spacing)
spacing = [spacing]*im.ndim
spacing = np.array(spacing, dtype=int)
if offset is None:
offset = r
if isinstance(offset, (int, float)):
offset = int(offset)
offset = [offset]*im.ndim
offset = np.array(offset, dtype=int)
if lattice == 'sq':
im[offset[0]::spacing[0],
offset[1]::spacing[1]] = True
elif lattice == 'tri':
im[offset[0]::spacing[0],
offset[1]::spacing[1]] = True
im[offset[0]+int(spacing[0]/2)::spacing[0],
offset[1]+int(spacing[1]/2)::spacing[1]] = True
elif lattice == 'sc':
im[offset[0]::spacing[0],
offset[1]::spacing[1],
offset[2]::spacing[2]] = True
elif lattice == 'bcc':
im[offset[0]::spacing[0],
offset[1]::spacing[1],
offset[2]::spacing[2]] = True
im[offset[0]+int(spacing[0]/2)::spacing[0],
offset[1]+int(spacing[1]/2)::spacing[1],
offset[2]+int(spacing[2]/2)::spacing[2]] = True
elif lattice == 'fcc':
im[offset[0]::spacing[0],
offset[1]::spacing[1],
offset[2]::spacing[2]] = True
# xy-plane
im[offset[0]+int(spacing[0]/2)::spacing[0],
offset[1]+int(spacing[1]/2)::spacing[1],
offset[2]::spacing[2]] = True
# xz-plane
im[offset[0]+int(spacing[0]/2)::spacing[0],
offset[1]::spacing[1],
offset[2]+int(spacing[2]/2)::spacing[2]] = True
# yz-plane
im[offset[0]::spacing[0],
offset[1]+int(spacing[1]/2)::spacing[1],
offset[2]+int(spacing[2]/2)::spacing[2]] = True
if smooth:
im = ~(edt(~im) < r)
else:
im = ~(edt(~im) <= r)
return im
[docs]
def overlapping_spheres(
shape: List[int],
r: int = 5,
porosity: float = 0.5,
maxiter: int = 10,
tol: float = 0.01,
seed: int = None,
):
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.
r : scalar
The radius of spheres in the packing.
porosity : scalar
The porosity of the final image, accurate to the given tolerance.
maxiter : 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.
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
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.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/overlapping_spheres.html>`_
to view online example.
"""
if seed is not None:
np.random.seed(seed)
shape = np.array(shape)
if np.size(shape) == 1:
shape = np.full((3, ), int(shape))
ndim = (shape != 1).sum(dtype=np.int64)
s_vol = ps_disk(r).sum(dtype=np.int64) if ndim == 2 \
else ps_ball(r).sum(dtype=np.int64)
bulk_vol = np.prod(shape)
N = int(np.ceil((1 - porosity) * bulk_vol / s_vol))
im = np.random.random(size=shape)
# Helper functions for calculating porosity: phi = g(f(N))
def f(N):
return edt(im > N / bulk_vol) < r
def g(im):
r"""Returns fraction of 0s, given a binary image"""
return 1 - im.sum(dtype=np.int64) / np.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(maxiter):
# 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(maxiter):
N = np.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)
[docs]
def blobs(
shape: List[int],
porosity: float = 0.5,
blobiness: int = 1,
divs: int = 1,
seed=None,
):
"""
Generates an image containing amorphous blobs
Parameters
----------
shape : list
The size of the image to generate in [Nx, Ny, Nz] where N is the
number of voxels
porosity : float
If specified, this will threshold the image to the specified value
prior to returning. If ``None`` is specified, then the scalar
noise field is converted to a uniform distribution and returned
without thresholding.
blobiness : int or list of ints(default = 1)
Controls the morphology of the blobs. A higher number results in
a larger number of small blobs. If a list is supplied then the
blobs are anisotropic.
divs : int or array_like
The number of times to divide the image for parallel processing.
If ``1`` then parallel processing does not occur. ``2`` is
equivalent to ``[2, 2, 2]`` for a 3D image. The number of cores
used is specified in ``porespy.settings.ncores`` and defaults to
all cores.
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space
See Also
--------
all_to_uniform
Notes
-----
This function generates random noise, the applies a gaussian blur to
the noise with a sigma controlled by the blobiness argument as:
$$ np.mean(shape) / (40 * blobiness) $$
The value of 40 was chosen so that a ``blobiness`` of 1 gave a
reasonable result.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/blobs.html>`_
to view online example.
"""
if seed is not None:
np.random.seed(seed)
if isinstance(shape, int):
shape = [shape]*3
if len(shape) == 1:
shape = [shape[0]]*3
shape = np.array(shape)
if isinstance(blobiness, int):
blobiness = [blobiness]*len(shape)
blobiness = np.array(blobiness)
parallel = False
if isinstance(divs, int):
divs = [divs]*len(shape)
if max(divs) > 1:
parallel = True
logger.info(f'Performing {insp.currentframe().f_code.co_name} in parallel')
sigma = np.mean(shape) / (40 * blobiness)
im = np.random.random(shape)
if parallel:
overlap = max([int(s*4) for s in np.array(sigma, ndmin=1)])
im = chunked_func(func=spim.gaussian_filter,
input=im, sigma=sigma,
divs=divs, overlap=overlap)
else:
im = spim.gaussian_filter(im, sigma=sigma)
im = all_to_uniform(im, scale=[0, 1])
if porosity:
im = im < porosity
return im
def _cylinders(
shape: List[int],
r: int,
ncylinders: int,
phi_max: float = 0,
theta_max: float = 90,
length: float = None,
verbose: bool = True,
seed=None,
):
r"""
Generates a binary image of overlapping cylinders.
This is a good approximation of a fibrous mat.
Parameters
----------
shape : list
The size of the image to generate in [Nx, Ny, Nz] where N is the
number of voxels. 2D images are not permitted.
r : int
The radius of the cylinders in voxels
ncylinders : int
The number of cylinders to add to the domain. Adjust this value to
control the final porosity, which is not easily specified since
cylinders overlap and intersect different fractions of the domain.
phi_max : int
A value between 0 and 90 that controls the amount that the
cylinders lie *out of* the XY plane, with 0 meaning all cylinders
lie in the XY plane, and 90 meaning that cylinders are randomly
oriented out of the plane by as much as +/- 90 degrees.
theta_max : int
A value between 0 and 90 that controls the amount of rotation
*in the* XY plane, with 0 meaning all cylinders point in the
X-direction, and 90 meaning they are randomly rotated about the Z
axis by as much as +/- 90 degrees.
length : int
The length of the cylinders to add. If ``None`` (default) then
the cylinders will extend beyond the domain in both directions so
no ends will exist. If a scalar value is given it will be
interpreted as the Euclidean distance between the two ends of the
cylinder. Note that one or both of the ends *may* still lie
outside the domain, depending on the randomly chosen center point
of the cylinder.
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space
"""
if seed is not None:
np.random.seed(seed)
shape = np.array(shape)
if np.size(shape) == 1:
shape = np.full((3, ), int(shape))
elif np.size(shape) == 2:
raise Exception("2D cylinders don't make sense")
# Find hypotenuse of domain from [0,0,0] to [Nx,Ny,Nz]
H = np.sqrt(np.sum(np.square(shape), dtype=np.int64)).astype(int)
if length is None: # Assume cylinders span domain if length not given
length = 2 * H
R = min(int(length / 2), 2 * H) # Trim given length to 2H if too long
# Adjust max angles to be between 0 and 90
if (phi_max > 90) or (phi_max < 0):
raise Exception('phi_max must be betwen 0 and 90')
if (theta_max > 90) or (theta_max < 0):
raise Exception('theta_max must be betwen 0 and 90')
# Create empty image for inserting into
im = np.zeros(shape, dtype=bool)
n = 0
L = min(H, R)
# Disable tqdm if called from another tqdm to prevent double pbars
tqdm_settings = settings.tqdm.copy()
if not settings.tqdm["disable"]:
tqdm_settings = {**settings.tqdm, **{'disable': not verbose}}
with tqdm(ncylinders, **tqdm_settings) as pbar:
while n < ncylinders:
# Choose a random starting point in domain
x = np.random.rand(3) * (shape + 2 * L)
# Chose a random phi and theta within given ranges
phi = (np.pi / 2 - np.pi * np.random.rand()) * phi_max / 90
theta = (np.pi / 2 - np.pi * np.random.rand()) * theta_max / 90
X0 = R * np.array([np.cos(phi) * np.cos(theta),
np.cos(phi) * np.sin(theta),
np.sin(phi)])
[X0, X1] = [x + X0, x - X0]
crds = line_segment(X0, X1)
lower = ~np.any(np.vstack(crds).T < [L, L, L], axis=1)
upper = ~np.any(np.vstack(crds).T >= shape + L, axis=1)
valid = upper * lower
if np.any(valid):
coords = np.vstack(crds).T[valid] - L
_insert_disk_at_points_parallel(im, coords=coords.T, r=r, v=1,
smooth=True, overwrite=False)
n += 1
pbar.update()
return ~im
[docs]
def cylinders(
shape: List[int],
r: int,
ncylinders: int = None,
porosity: float = None,
phi_max: float = 0,
theta_max: float = 90,
length: float = None,
maxiter: int = 3,
seed=None,
):
r"""
Generates a binary image of overlapping cylinders given porosity OR
number of cylinders.
This is a good approximation of a fibrous mat.
Parameters
----------
shape : list
The size of the image to generate in [Nx, Ny, Nz] where N is the
number of voxels. 2D images are not permitted.
r : scalar
The radius of the cylinders in voxels
ncylinders : int
The number of cylinders to add to the domain. Adjust this value to
control the final porosity, which is not easily specified since
cylinders overlap and intersect different fractions of the domain.
porosity : float
The targeted value for the porosity of the generated mat. The
function uses an algorithm for predicted the number of required
number of cylinder, and refines this over a certain number of
fractional insertions (according to the 'iterations' input).
phi_max : int
A value between 0 and 90 that controls the amount that the
cylinders lie *out of* the XY plane, with 0 meaning all cylinders
lie in the XY plane, and 90 meaning that cylinders are randomly
oriented out of the plane by as much as +/- 90 degrees.
theta_max : int
A value between 0 and 90 that controls the amount of rotation
*in the* XY plane, with 0 meaning all cylinders point in the
X-direction, and 90 meaning they are randomly rotated about the Z
axis by as much as +/- 90 degrees.
length : int
The length of the cylinders to add. If ``None`` (default) then
the cylinders will extend beyond the domain in both directions so
no ends will exist. If a scalar value is given it will be
interpreted as the Euclidean distance between the two ends of the
cylinder. Note that one or both of the ends *may* still lie
outside the domain, depending on the randomly chosen center point
of the cylinder.
maxiter : int
The number of fractional fiber insertions used to target the
requested porosity. By default a value of 3 is used (and this is
typically effective in getting very close to the targeted
porosity), but a greater number can be input to improve the
achieved porosity.
seed : int, optional, default = `None`
Initializes numpy's random number generator to the specified state. If not
provided, the current global value is used. This means calls to
``np.random.state(seed)`` prior to calling this function will be respected.
Returns
-------
image : ndarray
A boolean array with ``True`` values denoting the pore space
Notes
-----
The cylinders_porosity function works by estimating the number of
cylinders needed to be inserted into the domain by estimating
cylinder length, and exploiting the fact that, when inserting any
potentially overlapping objects randomly into a volume v_total (which
has units of pixels and is equal to dimx x dimy x dimz, for example),
such that the total volume of objects added to the volume is v_added
(and includes any volume that was inserted but overlapped with already
occupied space), the resulting porosity will be equal to
exp(-v_added/v_total).
After intially estimating the cylinder number and inserting a small
fraction of the estimated number, the true cylinder volume is
calculated, the estimate refined, and a larger fraction of cylinders
inserted. This is repeated a number of times according to the
``maxiter`` argument, yielding an image with a porosity close to
the goal.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/cylinders.html>`_
to view online example.
"""
if seed is not None:
np.random.seed(seed)
if ncylinders is not None:
im = _cylinders(
shape=shape,
r=r,
ncylinders=ncylinders,
phi_max=phi_max,
theta_max=theta_max,
length=length,
)
return im
if porosity is None:
raise Exception("'ncylinders' and 'porosity' can't be both None")
# if maxiter < 3:
# raise Exception("Iterations must be greater than or equal to 3")
vol_total = float(np.prod(shape))
def get_num_pixels(porosity):
r"""
Helper method to calculate number of pixels given a porosity
"""
return -np.log(porosity) * vol_total
# Crudely estimate fiber length as cube root of product of dims
length_estimate = vol_total ** (1 / 3) if length is None else length
# Rough fiber volume estimate
vol_fiber = length_estimate * np.pi * r * r
n_pixels_to_add = get_num_pixels(porosity)
# Rough estimate of n_fibers
n_fibers_added = 0
# Calculate fraction of fibers to be added in each iteration.
subdif = 0.8 / np.sum(np.arange(1, maxiter) ** 2, dtype=np.int64)
fractions = [0.2]
for i in range(1, maxiter):
fractions.append(fractions[i - 1] + (maxiter - i) ** 2 * subdif)
im = np.ones(shape, dtype=bool)
for frac in tqdm(fractions, **settings.tqdm):
n_fibers_total = n_pixels_to_add / vol_fiber
n_fibers = int(np.ceil(frac * n_fibers_total) - n_fibers_added)
if n_fibers > 0:
tmp = _cylinders(shape, r, n_fibers,
phi_max, theta_max, length,
verbose=False)
im = im * tmp
n_fibers_added += n_fibers
# Update parameters for next iteration
eps = metrics.porosity(im)
vol_added = get_num_pixels(eps)
vol_fiber = vol_added / n_fibers_added
logger.debug(f"{n_fibers_added} fibers added to reach target porosity.")
return im
[docs]
def line_segment(X0, X1):
r"""
Calculate the voxel coordinates of a straight line between the two
given end points
Parameters
----------
X0 and X1 : array_like
The [x, y] or [x, y, z] coordinates of the start and end points of
the line.
Returns
-------
coords : list of lists
A list of lists containing the X, Y, and Z coordinates of all
voxels that should be drawn between the start and end points to
create a solid line.
Examples
--------
`Click here
<https://porespy.org/examples/generators/reference/line_segment.html>`_
to view online example.
"""
X0 = np.around(X0).astype(int)
X1 = np.around(X1).astype(int)
if len(X0) == 3:
L = np.amax(
np.absolute([[X1[0] - X0[0]], [X1[1] - X0[1]], [X1[2] - X0[2]]])
) + 1
x = np.rint(np.linspace(X0[0], X1[0], L)).astype(int)
y = np.rint(np.linspace(X0[1], X1[1], L)).astype(int)
z = np.rint(np.linspace(X0[2], X1[2], L)).astype(int)
return [x, y, z]
else:
L = np.amax(np.absolute([[X1[0] - X0[0]], [X1[1] - X0[1]]])) + 1
x = np.rint(np.linspace(X0[0], X1[0], L)).astype(int)
y = np.rint(np.linspace(X0[1], X1[1], L)).astype(int)
return [x, y]