This commit is contained in:
ton
2024-10-07 10:13:40 +07:00
parent aa1631742f
commit 3a7d696db6
9729 changed files with 1832837 additions and 161742 deletions

View File

@@ -0,0 +1,46 @@
"""Algorithms to partition images into meaningful regions or boundaries.
"""
from ._expand_labels import expand_labels
from .random_walker_segmentation import random_walker
from .active_contour_model import active_contour
from ._felzenszwalb import felzenszwalb
from .slic_superpixels import slic
from ._quickshift import quickshift
from .boundaries import find_boundaries, mark_boundaries
from ._clear_border import clear_border
from ._join import join_segmentations, relabel_sequential
from ._watershed import watershed
from ._chan_vese import chan_vese
from .morphsnakes import (
morphological_geodesic_active_contour,
morphological_chan_vese,
inverse_gaussian_gradient,
disk_level_set,
checkerboard_level_set,
)
from ..morphology import flood, flood_fill
__all__ = [
'expand_labels',
'random_walker',
'active_contour',
'felzenszwalb',
'slic',
'quickshift',
'find_boundaries',
'mark_boundaries',
'clear_border',
'join_segmentations',
'relabel_sequential',
'watershed',
'chan_vese',
'morphological_geodesic_active_contour',
'morphological_chan_vese',
'inverse_gaussian_gradient',
'disk_level_set',
'checkerboard_level_set',
'flood',
'flood_fill',
]

View File

@@ -0,0 +1,365 @@
import numpy as np
from scipy.ndimage import distance_transform_edt as distance
from .._shared.utils import _supported_float_type
def _cv_calculate_variation(image, phi, mu, lambda1, lambda2, dt):
"""Returns the variation of level set 'phi' based on algorithm parameters.
This corresponds to equation (22) of the paper by Pascal Getreuer,
which computes the next iteration of the level set based on a current
level set.
A full explanation regarding all the terms is beyond the scope of the
present description, but there is one difference of particular import.
In the original algorithm, convergence is accelerated, and required
memory is reduced, by using a single array. This array, therefore, is a
combination of non-updated and updated values. If this were to be
implemented in python, this would require a double loop, where the
benefits of having fewer iterations would be outweided by massively
increasing the time required to perform each individual iteration. A
similar approach is used by Rami Cohen, and it is from there that the
C1-4 notation is taken.
"""
eta = 1e-16
P = np.pad(phi, 1, mode='edge')
phixp = P[1:-1, 2:] - P[1:-1, 1:-1]
phixn = P[1:-1, 1:-1] - P[1:-1, :-2]
phix0 = (P[1:-1, 2:] - P[1:-1, :-2]) / 2.0
phiyp = P[2:, 1:-1] - P[1:-1, 1:-1]
phiyn = P[1:-1, 1:-1] - P[:-2, 1:-1]
phiy0 = (P[2:, 1:-1] - P[:-2, 1:-1]) / 2.0
C1 = 1.0 / np.sqrt(eta + phixp**2 + phiy0**2)
C2 = 1.0 / np.sqrt(eta + phixn**2 + phiy0**2)
C3 = 1.0 / np.sqrt(eta + phix0**2 + phiyp**2)
C4 = 1.0 / np.sqrt(eta + phix0**2 + phiyn**2)
K = P[1:-1, 2:] * C1 + P[1:-1, :-2] * C2 + P[2:, 1:-1] * C3 + P[:-2, 1:-1] * C4
Hphi = (phi > 0).astype(image.dtype)
(c1, c2) = _cv_calculate_averages(image, Hphi)
difference_from_average_term = (
-lambda1 * (image - c1) ** 2 + lambda2 * (image - c2) ** 2
)
new_phi = phi + (dt * _cv_delta(phi)) * (mu * K + difference_from_average_term)
return new_phi / (1 + mu * dt * _cv_delta(phi) * (C1 + C2 + C3 + C4))
def _cv_heavyside(x, eps=1.0):
"""Returns the result of a regularised heavyside function of the
input value(s).
"""
return 0.5 * (1.0 + (2.0 / np.pi) * np.arctan(x / eps))
def _cv_delta(x, eps=1.0):
"""Returns the result of a regularised dirac function of the
input value(s).
"""
return eps / (eps**2 + x**2)
def _cv_calculate_averages(image, Hphi):
"""Returns the average values 'inside' and 'outside'."""
H = Hphi
Hinv = 1.0 - H
Hsum = np.sum(H)
Hinvsum = np.sum(Hinv)
avg_inside = np.sum(image * H)
avg_oustide = np.sum(image * Hinv)
if Hsum != 0:
avg_inside /= Hsum
if Hinvsum != 0:
avg_oustide /= Hinvsum
return (avg_inside, avg_oustide)
def _cv_difference_from_average_term(image, Hphi, lambda_pos, lambda_neg):
"""Returns the 'energy' contribution due to the difference from
the average value within a region at each point.
"""
(c1, c2) = _cv_calculate_averages(image, Hphi)
Hinv = 1.0 - Hphi
return lambda_pos * (image - c1) ** 2 * Hphi + lambda_neg * (image - c2) ** 2 * Hinv
def _cv_edge_length_term(phi, mu):
"""Returns the 'energy' contribution due to the length of the
edge between regions at each point, multiplied by a factor 'mu'.
"""
P = np.pad(phi, 1, mode='edge')
fy = (P[2:, 1:-1] - P[:-2, 1:-1]) / 2.0
fx = (P[1:-1, 2:] - P[1:-1, :-2]) / 2.0
return mu * _cv_delta(phi) * np.sqrt(fx**2 + fy**2)
def _cv_energy(image, phi, mu, lambda1, lambda2):
"""Returns the total 'energy' of the current level set function.
This corresponds to equation (7) of the paper by Pascal Getreuer,
which is the weighted sum of the following:
(A) the length of the contour produced by the zero values of the
level set,
(B) the area of the "foreground" (area of the image where the
level set is positive),
(C) the variance of the image inside the foreground,
(D) the variance of the image outside of the foreground
Each value is computed for each pixel, and then summed. The weight
of (B) is set to 0 in this implementation.
"""
H = _cv_heavyside(phi)
avgenergy = _cv_difference_from_average_term(image, H, lambda1, lambda2)
lenenergy = _cv_edge_length_term(phi, mu)
return np.sum(avgenergy) + np.sum(lenenergy)
def _cv_reset_level_set(phi):
"""This is a placeholder function as resetting the level set is not
strictly necessary, and has not been done for this implementation.
"""
return phi
def _cv_checkerboard(image_size, square_size, dtype=np.float64):
"""Generates a checkerboard level set function.
According to Pascal Getreuer, such a level set function has fast
convergence.
"""
yv = np.arange(image_size[0], dtype=dtype).reshape(image_size[0], 1)
xv = np.arange(image_size[1], dtype=dtype)
sf = np.pi / square_size
xv *= sf
yv *= sf
return np.sin(yv) * np.sin(xv)
def _cv_large_disk(image_size):
"""Generates a disk level set function.
The disk covers the whole image along its smallest dimension.
"""
res = np.ones(image_size)
centerY = int((image_size[0] - 1) / 2)
centerX = int((image_size[1] - 1) / 2)
res[centerY, centerX] = 0.0
radius = float(min(centerX, centerY))
return (radius - distance(res)) / radius
def _cv_small_disk(image_size):
"""Generates a disk level set function.
The disk covers half of the image along its smallest dimension.
"""
res = np.ones(image_size)
centerY = int((image_size[0] - 1) / 2)
centerX = int((image_size[1] - 1) / 2)
res[centerY, centerX] = 0.0
radius = float(min(centerX, centerY)) / 2.0
return (radius - distance(res)) / (radius * 3)
def _cv_init_level_set(init_level_set, image_shape, dtype=np.float64):
"""Generates an initial level set function conditional on input arguments."""
if isinstance(init_level_set, str):
if init_level_set == 'checkerboard':
res = _cv_checkerboard(image_shape, 5, dtype)
elif init_level_set == 'disk':
res = _cv_large_disk(image_shape)
elif init_level_set == 'small disk':
res = _cv_small_disk(image_shape)
else:
raise ValueError("Incorrect name for starting level set preset.")
else:
res = init_level_set
return res.astype(dtype, copy=False)
def chan_vese(
image,
mu=0.25,
lambda1=1.0,
lambda2=1.0,
tol=1e-3,
max_num_iter=500,
dt=0.5,
init_level_set='checkerboard',
extended_output=False,
):
"""Chan-Vese segmentation algorithm.
Active contour model by evolving a level set. Can be used to
segment objects without clearly defined boundaries.
Parameters
----------
image : (M, N) ndarray
Grayscale image to be segmented.
mu : float, optional
'edge length' weight parameter. Higher `mu` values will
produce a 'round' edge, while values closer to zero will
detect smaller objects.
lambda1 : float, optional
'difference from average' weight parameter for the output
region with value 'True'. If it is lower than `lambda2`, this
region will have a larger range of values than the other.
lambda2 : float, optional
'difference from average' weight parameter for the output
region with value 'False'. If it is lower than `lambda1`, this
region will have a larger range of values than the other.
tol : float, positive, optional
Level set variation tolerance between iterations. If the
L2 norm difference between the level sets of successive
iterations normalized by the area of the image is below this
value, the algorithm will assume that the solution was
reached.
max_num_iter : uint, optional
Maximum number of iterations allowed before the algorithm
interrupts itself.
dt : float, optional
A multiplication factor applied at calculations for each step,
serves to accelerate the algorithm. While higher values may
speed up the algorithm, they may also lead to convergence
problems.
init_level_set : str or (M, N) ndarray, optional
Defines the starting level set used by the algorithm.
If a string is inputted, a level set that matches the image
size will automatically be generated. Alternatively, it is
possible to define a custom level set, which should be an
array of float values, with the same shape as 'image'.
Accepted string values are as follows.
'checkerboard'
the starting level set is defined as
sin(x/5*pi)*sin(y/5*pi), where x and y are pixel
coordinates. This level set has fast convergence, but may
fail to detect implicit edges.
'disk'
the starting level set is defined as the opposite
of the distance from the center of the image minus half of
the minimum value between image width and image height.
This is somewhat slower, but is more likely to properly
detect implicit edges.
'small disk'
the starting level set is defined as the
opposite of the distance from the center of the image
minus a quarter of the minimum value between image width
and image height.
extended_output : bool, optional
If set to True, the return value will be a tuple containing
the three return values (see below). If set to False which
is the default value, only the 'segmentation' array will be
returned.
Returns
-------
segmentation : (M, N) ndarray, bool
Segmentation produced by the algorithm.
phi : (M, N) ndarray of floats
Final level set computed by the algorithm.
energies : list of floats
Shows the evolution of the 'energy' for each step of the
algorithm. This should allow to check whether the algorithm
converged.
Notes
-----
The Chan-Vese Algorithm is designed to segment objects without
clearly defined boundaries. This algorithm is based on level sets
that are evolved iteratively to minimize an energy, which is
defined by weighted values corresponding to the sum of differences
intensity from the average value outside the segmented region, the
sum of differences from the average value inside the segmented
region, and a term which is dependent on the length of the
boundary of the segmented region.
This algorithm was first proposed by Tony Chan and Luminita Vese,
in a publication entitled "An Active Contour Model Without Edges"
[1]_.
This implementation of the algorithm is somewhat simplified in the
sense that the area factor 'nu' described in the original paper is
not implemented, and is only suitable for grayscale images.
Typical values for `lambda1` and `lambda2` are 1. If the
'background' is very different from the segmented object in terms
of distribution (for example, a uniform black image with figures
of varying intensity), then these values should be different from
each other.
Typical values for mu are between 0 and 1, though higher values
can be used when dealing with shapes with very ill-defined
contours.
The 'energy' which this algorithm tries to minimize is defined
as the sum of the differences from the average within the region
squared and weighed by the 'lambda' factors to which is added the
length of the contour multiplied by the 'mu' factor.
Supports 2D grayscale images only, and does not implement the area
term described in the original article.
References
----------
.. [1] An Active Contour Model without Edges, Tony Chan and
Luminita Vese, Scale-Space Theories in Computer Vision,
1999, :DOI:`10.1007/3-540-48236-9_13`
.. [2] Chan-Vese Segmentation, Pascal Getreuer Image Processing On
Line, 2 (2012), pp. 214-224,
:DOI:`10.5201/ipol.2012.g-cv`
.. [3] The Chan-Vese Algorithm - Project Report, Rami Cohen, 2011
:arXiv:`1107.2782`
"""
if len(image.shape) != 2:
raise ValueError("Input image should be a 2D array.")
float_dtype = _supported_float_type(image.dtype)
phi = _cv_init_level_set(init_level_set, image.shape, dtype=float_dtype)
if type(phi) != np.ndarray or phi.shape != image.shape:
raise ValueError(
"The dimensions of initial level set do not "
"match the dimensions of image."
)
image = image.astype(float_dtype, copy=False)
image = image - np.min(image)
if np.max(image) != 0:
image = image / np.max(image)
i = 0
old_energy = _cv_energy(image, phi, mu, lambda1, lambda2)
energies = []
phivar = tol + 1
segmentation = phi > 0
while phivar > tol and i < max_num_iter:
# Save old level set values
oldphi = phi
# Calculate new level set
phi = _cv_calculate_variation(image, phi, mu, lambda1, lambda2, dt)
phi = _cv_reset_level_set(phi)
phivar = np.sqrt(((phi - oldphi) ** 2).mean())
# Extract energy and compare to previous level set and
# segmentation to see if continuing is necessary
segmentation = phi > 0
new_energy = _cv_energy(image, phi, mu, lambda1, lambda2)
# Save old energy values
energies.append(old_energy)
old_energy = new_energy
i += 1
if extended_output:
return (segmentation, phi, energies)
else:
return segmentation

View File

@@ -0,0 +1,109 @@
import numpy as np
from ..measure import label
def clear_border(labels, buffer_size=0, bgval=0, mask=None, *, out=None):
"""Clear objects connected to the label image border.
Parameters
----------
labels : (M[, N[, ..., P]]) array of int or bool
Imaging data labels.
buffer_size : int, optional
The width of the border examined. By default, only objects
that touch the outside of the image are removed.
bgval : float or int, optional
Cleared objects are set to this value.
mask : ndarray of bool, same shape as `image`, optional.
Image data mask. Objects in labels image overlapping with
False pixels of mask will be removed. If defined, the
argument buffer_size will be ignored.
out : ndarray
Array of the same shape as `labels`, into which the
output is placed. By default, a new array is created.
Returns
-------
out : (M[, N[, ..., P]]) array
Imaging data labels with cleared borders
Examples
--------
>>> import numpy as np
>>> from skimage.segmentation import clear_border
>>> labels = np.array([[0, 0, 0, 0, 0, 0, 0, 1, 0],
... [1, 1, 0, 0, 1, 0, 0, 1, 0],
... [1, 1, 0, 1, 0, 1, 0, 0, 0],
... [0, 0, 0, 1, 1, 1, 1, 0, 0],
... [0, 1, 1, 1, 1, 1, 1, 1, 0],
... [0, 0, 0, 0, 0, 0, 0, 0, 0]])
>>> clear_border(labels)
array([[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0]])
>>> mask = np.array([[0, 0, 1, 1, 1, 1, 1, 1, 1],
... [0, 0, 1, 1, 1, 1, 1, 1, 1],
... [1, 1, 1, 1, 1, 1, 1, 1, 1],
... [1, 1, 1, 1, 1, 1, 1, 1, 1],
... [1, 1, 1, 1, 1, 1, 1, 1, 1],
... [1, 1, 1, 1, 1, 1, 1, 1, 1]]).astype(bool)
>>> clear_border(labels, mask=mask)
array([[0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 1, 0, 0, 1, 0],
[0, 0, 0, 1, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0]])
"""
if any(buffer_size >= s for s in labels.shape) and mask is None:
# ignore buffer_size if mask
raise ValueError("buffer size may not be greater than labels size")
if out is None:
out = labels.copy()
if mask is not None:
err_msg = (
f'labels and mask should have the same shape but '
f'are {out.shape} and {mask.shape}'
)
if out.shape != mask.shape:
raise (ValueError, err_msg)
if mask.dtype != bool:
raise TypeError("mask should be of type bool.")
borders = ~mask
else:
# create borders with buffer_size
borders = np.zeros_like(out, dtype=bool)
ext = buffer_size + 1
slstart = slice(ext)
slend = slice(-ext, None)
slices = [slice(None) for _ in out.shape]
for d in range(out.ndim):
slices[d] = slstart
borders[tuple(slices)] = True
slices[d] = slend
borders[tuple(slices)] = True
slices[d] = slice(None)
# Re-label, in case we are dealing with a binary out
# and to get consistent labeling
labels, number = label(out, background=0, return_num=True)
# determine all objects that are connected to borders
borders_indices = np.unique(labels[borders])
indices = np.arange(number + 1)
# mask all label indices that are connected to borders
label_mask = np.isin(indices, borders_indices)
# create mask for pixels to clear
mask = label_mask[labels.reshape(-1)].reshape(labels.shape)
# clear border pixels
out[mask] = bgval
return out

View File

@@ -0,0 +1,104 @@
import numpy as np
from scipy.ndimage import distance_transform_edt
def expand_labels(label_image, distance=1, spacing=1):
"""Expand labels in label image by ``distance`` pixels without overlapping.
Given a label image, ``expand_labels`` grows label regions (connected components)
outwards by up to ``distance`` units without overflowing into neighboring regions.
More specifically, each background pixel that is within Euclidean distance
of <= ``distance`` pixels of a connected component is assigned the label of that
connected component. The `spacing` parameter can be used to specify the spacing
rate of the distance transform used to calculate the Euclidean distance for anisotropic
images.
Where multiple connected components are within ``distance`` pixels of a background
pixel, the label value of the closest connected component will be assigned (see
Notes for the case of multiple labels at equal distance).
Parameters
----------
label_image : ndarray of dtype int
label image
distance : float
Euclidean distance in pixels by which to grow the labels. Default is one.
spacing : float, or sequence of float, optional
Spacing of elements along each dimension. If a sequence, must be of length
equal to the input rank; if a single number, this is used for all axes. If
not specified, a grid spacing of unity is implied.
Returns
-------
enlarged_labels : ndarray of dtype int
Labeled array, where all connected regions have been enlarged
Notes
-----
Where labels are spaced more than ``distance`` pixels are apart, this is
equivalent to a morphological dilation with a disc or hyperball of radius ``distance``.
However, in contrast to a morphological dilation, ``expand_labels`` will
not expand a label region into a neighboring region.
This implementation of ``expand_labels`` is derived from CellProfiler [1]_, where
it is known as module "IdentifySecondaryObjects (Distance-N)" [2]_.
There is an important edge case when a pixel has the same distance to
multiple regions, as it is not defined which region expands into that
space. Here, the exact behavior depends on the upstream implementation
of ``scipy.ndimage.distance_transform_edt``.
See Also
--------
:func:`skimage.measure.label`, :func:`skimage.segmentation.watershed`, :func:`skimage.morphology.dilation`
References
----------
.. [1] https://cellprofiler.org
.. [2] https://github.com/CellProfiler/CellProfiler/blob/082930ea95add7b72243a4fa3d39ae5145995e9c/cellprofiler/modules/identifysecondaryobjects.py#L559
Examples
--------
>>> labels = np.array([0, 1, 0, 0, 0, 0, 2])
>>> expand_labels(labels, distance=1)
array([1, 1, 1, 0, 0, 2, 2])
Labels will not overwrite each other:
>>> expand_labels(labels, distance=3)
array([1, 1, 1, 1, 2, 2, 2])
In case of ties, behavior is undefined, but currently resolves to the
label closest to ``(0,) * ndim`` in lexicographical order.
>>> labels_tied = np.array([0, 1, 0, 2, 0])
>>> expand_labels(labels_tied, 1)
array([1, 1, 1, 2, 2])
>>> labels2d = np.array(
... [[0, 1, 0, 0],
... [2, 0, 0, 0],
... [0, 3, 0, 0]]
... )
>>> expand_labels(labels2d, 1)
array([[2, 1, 1, 0],
[2, 2, 0, 0],
[2, 3, 3, 0]])
>>> expand_labels(labels2d, 1, spacing=[1, 0.5])
array([[1, 1, 1, 1],
[2, 2, 2, 0],
[3, 3, 3, 3]])
"""
distances, nearest_label_coords = distance_transform_edt(
label_image == 0, sampling=spacing, return_indices=True
)
labels_out = np.zeros_like(label_image)
dilate_mask = distances <= distance
# build the coordinates to find nearest labels,
# in contrast to [1] this implementation supports label arrays
# of any dimension
masked_nearest_label_coords = [
dimension_indices[dilate_mask] for dimension_indices in nearest_label_coords
]
nearest_labels = label_image[tuple(masked_nearest_label_coords)]
labels_out[dilate_mask] = nearest_labels
return labels_out

View File

@@ -0,0 +1,69 @@
import numpy as np
from ._felzenszwalb_cy import _felzenszwalb_cython
from .._shared import utils
@utils.channel_as_last_axis(multichannel_output=False)
def felzenszwalb(image, scale=1, sigma=0.8, min_size=20, *, channel_axis=-1):
"""Computes Felsenszwalb's efficient graph based image segmentation.
Produces an oversegmentation of a multichannel (i.e. RGB) image
using a fast, minimum spanning tree based clustering on the image grid.
The parameter ``scale`` sets an observation level. Higher scale means
less and larger segments. ``sigma`` is the diameter of a Gaussian kernel,
used for smoothing the image prior to segmentation.
The number of produced segments as well as their size can only be
controlled indirectly through ``scale``. Segment size within an image can
vary greatly depending on local contrast.
For RGB images, the algorithm uses the euclidean distance between pixels in
color space.
Parameters
----------
image : (M, N[, 3]) ndarray
Input image.
scale : float
Free parameter. Higher means larger clusters.
sigma : float
Width (standard deviation) of Gaussian kernel used in preprocessing.
min_size : int
Minimum component size. Enforced using postprocessing.
channel_axis : int or None, optional
If None, the image is assumed to be a grayscale (single channel) image.
Otherwise, this parameter indicates which axis of the array corresponds
to channels.
.. versionadded:: 0.19
``channel_axis`` was added in 0.19.
Returns
-------
segment_mask : (M, N) ndarray
Integer mask indicating segment labels.
References
----------
.. [1] Efficient graph-based image segmentation, Felzenszwalb, P.F. and
Huttenlocher, D.P. International Journal of Computer Vision, 2004
Notes
-----
The `k` parameter used in the original paper renamed to `scale` here.
Examples
--------
>>> from skimage.segmentation import felzenszwalb
>>> from skimage.data import coffee
>>> img = coffee()
>>> segments = felzenszwalb(img, scale=3.0, sigma=0.95, min_size=5)
"""
if channel_axis is None and image.ndim > 2:
raise ValueError(
"This algorithm works only on single or " "multi-channel 2d images. "
)
image = np.atleast_3d(image)
return _felzenszwalb_cython(image, scale=scale, sigma=sigma, min_size=min_size)

View File

@@ -0,0 +1,184 @@
import numpy as np
from ..util._map_array import map_array, ArrayMap
def join_segmentations(s1, s2, return_mapping: bool = False):
"""Return the join of the two input segmentations.
The join J of S1 and S2 is defined as the segmentation in which two
voxels are in the same segment if and only if they are in the same
segment in *both* S1 and S2.
Parameters
----------
s1, s2 : numpy arrays
s1 and s2 are label fields of the same shape.
return_mapping : bool, optional
If true, return mappings for joined segmentation labels to the original labels.
Returns
-------
j : numpy array
The join segmentation of s1 and s2.
map_j_to_s1 : ArrayMap, optional
Mapping from labels of the joined segmentation j to labels of s1.
map_j_to_s2 : ArrayMap, optional
Mapping from labels of the joined segmentation j to labels of s2.
Examples
--------
>>> from skimage.segmentation import join_segmentations
>>> s1 = np.array([[0, 0, 1, 1],
... [0, 2, 1, 1],
... [2, 2, 2, 1]])
>>> s2 = np.array([[0, 1, 1, 0],
... [0, 1, 1, 0],
... [0, 1, 1, 1]])
>>> join_segmentations(s1, s2)
array([[0, 1, 3, 2],
[0, 5, 3, 2],
[4, 5, 5, 3]])
>>> j, m1, m2 = join_segmentations(s1, s2, return_mapping=True)
>>> m1
ArrayMap(array([0, 1, 2, 3, 4, 5]), array([0, 0, 1, 1, 2, 2]))
>>> np.all(m1[j] == s1)
True
>>> np.all(m2[j] == s2)
True
"""
if s1.shape != s2.shape:
raise ValueError(
"Cannot join segmentations of different shape. "
f"s1.shape: {s1.shape}, s2.shape: {s2.shape}"
)
# Reindex input label images
s1_relabeled, _, backward_map1 = relabel_sequential(s1)
s2_relabeled, _, backward_map2 = relabel_sequential(s2)
# Create joined label image
factor = s2.max() + 1
j_initial = factor * s1_relabeled + s2_relabeled
j, _, map_j_to_j_initial = relabel_sequential(j_initial)
if not return_mapping:
return j
# Determine label mapping
labels_j = np.unique(j_initial)
labels_s1_relabeled, labels_s2_relabeled = np.divmod(labels_j, factor)
map_j_to_s1 = ArrayMap(
map_j_to_j_initial.in_values, backward_map1[labels_s1_relabeled]
)
map_j_to_s2 = ArrayMap(
map_j_to_j_initial.in_values, backward_map2[labels_s2_relabeled]
)
return j, map_j_to_s1, map_j_to_s2
def relabel_sequential(label_field, offset=1):
"""Relabel arbitrary labels to {`offset`, ... `offset` + number_of_labels}.
This function also returns the forward map (mapping the original labels to
the reduced labels) and the inverse map (mapping the reduced labels back
to the original ones).
Parameters
----------
label_field : numpy array of int, arbitrary shape
An array of labels, which must be non-negative integers.
offset : int, optional
The return labels will start at `offset`, which should be
strictly positive.
Returns
-------
relabeled : numpy array of int, same shape as `label_field`
The input label field with labels mapped to
{offset, ..., number_of_labels + offset - 1}.
The data type will be the same as `label_field`, except when
offset + number_of_labels causes overflow of the current data type.
forward_map : ArrayMap
The map from the original label space to the returned label
space. Can be used to re-apply the same mapping. See examples
for usage. The output data type will be the same as `relabeled`.
inverse_map : ArrayMap
The map from the new label space to the original space. This
can be used to reconstruct the original label field from the
relabeled one. The output data type will be the same as `label_field`.
Notes
-----
The label 0 is assumed to denote the background and is never remapped.
The forward map can be extremely big for some inputs, since its
length is given by the maximum of the label field. However, in most
situations, ``label_field.max()`` is much smaller than
``label_field.size``, and in these cases the forward map is
guaranteed to be smaller than either the input or output images.
Examples
--------
>>> from skimage.segmentation import relabel_sequential
>>> label_field = np.array([1, 1, 5, 5, 8, 99, 42])
>>> relab, fw, inv = relabel_sequential(label_field)
>>> relab
array([1, 1, 2, 2, 3, 5, 4])
>>> print(fw)
ArrayMap:
1 → 1
5 → 2
8 → 3
42 → 4
99 → 5
>>> np.array(fw)
array([0, 1, 0, 0, 0, 2, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5])
>>> np.array(inv)
array([ 0, 1, 5, 8, 42, 99])
>>> (fw[label_field] == relab).all()
True
>>> (inv[relab] == label_field).all()
True
>>> relab, fw, inv = relabel_sequential(label_field, offset=5)
>>> relab
array([5, 5, 6, 6, 7, 9, 8])
"""
if offset <= 0:
raise ValueError("Offset must be strictly positive.")
if np.min(label_field) < 0:
raise ValueError("Cannot relabel array that contains negative values.")
offset = int(offset)
in_vals = np.unique(label_field)
if in_vals[0] == 0:
# always map 0 to 0
out_vals = np.concatenate([[0], np.arange(offset, offset + len(in_vals) - 1)])
else:
out_vals = np.arange(offset, offset + len(in_vals))
input_type = label_field.dtype
if input_type.kind not in "iu":
raise TypeError("label_field must have an integer dtype")
# Some logic to determine the output type:
# - we don't want to return a smaller output type than the input type,
# ie if we get uint32 as labels input, don't return a uint8 array.
# - but, in some cases, using the input type could result in overflow. The
# input type could be a signed integer (e.g. int32) but
# `np.min_scalar_type` will always return an unsigned type. We check for
# that by casting the largest output value to the input type. If it is
# unchanged, we use the input type, else we use the unsigned minimum
# required type
required_type = np.min_scalar_type(out_vals[-1])
if input_type.itemsize < required_type.itemsize:
output_type = required_type
else:
if out_vals[-1] < np.iinfo(input_type).max:
output_type = input_type
else:
output_type = required_type
out_array = np.empty(label_field.shape, dtype=output_type)
out_vals = out_vals.astype(output_type)
map_array(label_field, in_vals, out_vals, out=out_array)
fw_map = ArrayMap(in_vals, out_vals)
inv_map = ArrayMap(out_vals, in_vals)
return out_array, fw_map, inv_map

View File

@@ -0,0 +1,104 @@
import numpy as np
from .._shared.filters import gaussian
from .._shared.utils import _supported_float_type
from ..color import rgb2lab
from ..util import img_as_float
from ._quickshift_cy import _quickshift_cython
def quickshift(
image,
ratio=1.0,
kernel_size=5,
max_dist=10,
return_tree=False,
sigma=0,
convert2lab=True,
rng=42,
*,
channel_axis=-1,
):
"""Segment image using quickshift clustering in Color-(x,y) space.
Produces an oversegmentation of the image using the quickshift mode-seeking
algorithm.
Parameters
----------
image : (M, N, C) ndarray
Input image. The axis corresponding to color channels can be specified
via the `channel_axis` argument.
ratio : float, optional, between 0 and 1
Balances color-space proximity and image-space proximity.
Higher values give more weight to color-space.
kernel_size : float, optional
Width of Gaussian kernel used in smoothing the
sample density. Higher means fewer clusters.
max_dist : float, optional
Cut-off point for data distances.
Higher means fewer clusters.
return_tree : bool, optional
Whether to return the full segmentation hierarchy tree and distances.
sigma : float, optional
Width for Gaussian smoothing as preprocessing. Zero means no smoothing.
convert2lab : bool, optional
Whether the input should be converted to Lab colorspace prior to
segmentation. For this purpose, the input is assumed to be RGB.
rng : {`numpy.random.Generator`, int}, optional
Pseudo-random number generator.
By default, a PCG64 generator is used (see :func:`numpy.random.default_rng`).
If `rng` is an int, it is used to seed the generator.
The PRNG is used to break ties, and is seeded with 42 by default.
channel_axis : int, optional
The axis of `image` corresponding to color channels. Defaults to the
last axis.
Returns
-------
segment_mask : (M, N) ndarray
Integer mask indicating segment labels.
Notes
-----
The authors advocate to convert the image to Lab color space prior to
segmentation, though this is not strictly necessary. For this to work, the
image must be given in RGB format.
References
----------
.. [1] Quick shift and kernel methods for mode seeking,
Vedaldi, A. and Soatto, S.
European Conference on Computer Vision, 2008
"""
image = img_as_float(np.atleast_3d(image))
float_dtype = _supported_float_type(image.dtype)
image = image.astype(float_dtype, copy=False)
if image.ndim > 3:
raise ValueError("Only 2D color images are supported")
# move channels to last position as expected by the Cython code
image = np.moveaxis(image, source=channel_axis, destination=-1)
if convert2lab:
if image.shape[-1] != 3:
raise ValueError("Only RGB images can be converted to Lab space.")
image = rgb2lab(image)
if kernel_size < 1:
raise ValueError("`kernel_size` should be >= 1.")
image = gaussian(image, sigma=[sigma, sigma, 0], mode='reflect', channel_axis=-1)
image = np.ascontiguousarray(image * ratio)
segment_mask = _quickshift_cython(
image,
kernel_size=kernel_size,
max_dist=max_dist,
return_tree=return_tree,
rng=rng,
)
return segment_mask

View File

@@ -0,0 +1,242 @@
"""watershed.py - watershed algorithm
This module implements a watershed algorithm that apportions pixels into
marked basins. The algorithm uses a priority queue to hold the pixels
with the metric for the priority queue being pixel value, then the time
of entry into the queue - this settles ties in favor of the closest marker.
Some ideas taken from
Soille, "Automated Basin Delineation from Digital Elevation Models Using
Mathematical Morphology", Signal Processing 20 (1990) 171-182.
The most important insight in the paper is that entry time onto the queue
solves two problems: a pixel should be assigned to the neighbor with the
largest gradient or, if there is no gradient, pixels on a plateau should
be split between markers on opposite sides.
"""
import numpy as np
from scipy import ndimage as ndi
from . import _watershed_cy
from ..morphology.extrema import local_minima
from ..morphology._util import _validate_connectivity, _offsets_to_raveled_neighbors
from ..util import crop, regular_seeds
def _validate_inputs(image, markers, mask, connectivity):
"""Ensure that all inputs to watershed have matching shapes and types.
Parameters
----------
image : array
The input image.
markers : int or array of int
The marker image.
mask : array, or None
A boolean mask, True where we want to compute the watershed.
connectivity : int in {1, ..., image.ndim}
The connectivity of the neighborhood of a pixel.
Returns
-------
image, markers, mask : arrays
The validated and formatted arrays. Image will have dtype float64,
markers int32, and mask int8. If ``None`` was given for the mask,
it is a volume of all 1s.
Raises
------
ValueError
If the shapes of the given arrays don't match.
"""
n_pixels = image.size
if mask is None:
# Use a complete `True` mask if none is provided
mask = np.ones(image.shape, bool)
else:
mask = np.asanyarray(mask, dtype=bool)
n_pixels = np.sum(mask)
if mask.shape != image.shape:
message = (
f'`mask` (shape {mask.shape}) must have same shape '
f'as `image` (shape {image.shape})'
)
raise ValueError(message)
if markers is None:
markers_bool = local_minima(image, connectivity=connectivity) * mask
footprint = ndi.generate_binary_structure(markers_bool.ndim, connectivity)
markers = ndi.label(markers_bool, structure=footprint)[0]
elif not isinstance(markers, (np.ndarray, list, tuple)):
# not array-like, assume int
# given int, assume that number of markers *within mask*.
markers = regular_seeds(image.shape, int(markers / (n_pixels / image.size)))
markers *= mask
else:
markers = np.asanyarray(markers) * mask
if markers.shape != image.shape:
message = (
f'`markers` (shape {markers.shape}) must have same '
f'shape as `image` (shape {image.shape})'
)
raise ValueError(message)
return (image.astype(np.float64), markers, mask.astype(np.int8))
def watershed(
image,
markers=None,
connectivity=1,
offset=None,
mask=None,
compactness=0,
watershed_line=False,
):
"""Find watershed basins in an image flooded from given markers.
Parameters
----------
image : (M, N[, ...]) ndarray
Data array where the lowest value points are labeled first.
markers : int, or (M, N[, ...]) ndarray of int, optional
The desired number of basins, or an array marking the basins with the
values to be assigned in the label matrix. Zero means not a marker. If
None, the (default) markers are determined as the local minima of
`image`. Specifically, the computation is equivalent to applying
:func:`skimage.morphology.local_minima` onto `image`, followed by
:func:`skimage.measure.label` onto the result (with the same given
`connectivity`). Generally speaking, users are encouraged to pass
markers explicitly.
connectivity : int or ndarray, optional
The neighborhood connectivity. An integer is interpreted as in
``scipy.ndimage.generate_binary_structure``, as the maximum number
of orthogonal steps to reach a neighbor. An array is directly
interpreted as a footprint (structuring element). Default value is 1.
In 2D, 1 gives a 4-neighborhood while 2 gives an 8-neighborhood.
offset : array_like of shape image.ndim, optional
The coordinates of the center of the footprint.
mask : (M, N[, ...]) ndarray of bools or 0's and 1's, optional
Array of same shape as `image`. Only points at which mask == True
will be labeled.
compactness : float, optional
Use compact watershed [1]_ with given compactness parameter.
Higher values result in more regularly-shaped watershed basins.
watershed_line : bool, optional
If True, a one-pixel wide line separates the regions
obtained by the watershed algorithm. The line has the label 0.
Note that the method used for adding this line expects that
marker regions are not adjacent; the watershed line may not catch
borders between adjacent marker regions.
Returns
-------
out : ndarray
A labeled matrix of the same type and shape as `markers`.
See Also
--------
skimage.segmentation.random_walker
A segmentation algorithm based on anisotropic diffusion, usually
slower than the watershed but with good results on noisy data and
boundaries with holes.
Notes
-----
This function implements a watershed algorithm [2]_ [3]_ that apportions
pixels into marked basins. The algorithm uses a priority queue to hold
the pixels with the metric for the priority queue being pixel value, then
the time of entry into the queue -- this settles ties in favor of the
closest marker.
Some ideas are taken from [4]_.
The most important insight in the paper is that entry time onto the queue
solves two problems: a pixel should be assigned to the neighbor with the
largest gradient or, if there is no gradient, pixels on a plateau should
be split between markers on opposite sides.
This implementation converts all arguments to specific, lowest common
denominator types, then passes these to a C algorithm.
Markers can be determined manually, or automatically using for example
the local minima of the gradient of the image, or the local maxima of the
distance function to the background for separating overlapping objects
(see example).
References
----------
.. [1] P. Neubert and P. Protzel, "Compact Watershed and Preemptive SLIC:
On Improving Trade-offs of Superpixel Segmentation Algorithms,"
2014 22nd International Conference on Pattern Recognition,
Stockholm, Sweden, 2014, pp. 996-1001, :DOI:`10.1109/ICPR.2014.181`
https://www.tu-chemnitz.de/etit/proaut/publications/cws_pSLIC_ICPR.pdf
.. [2] https://en.wikipedia.org/wiki/Watershed_%28image_processing%29
.. [3] http://cmm.ensmp.fr/~beucher/wtshed.html
.. [4] P. J. Soille and M. M. Ansoult, "Automated basin delineation from
digital elevation models using mathematical morphology," Signal
Processing, 20(2):171-182, :DOI:`10.1016/0165-1684(90)90127-K`
Examples
--------
The watershed algorithm is useful to separate overlapping objects.
We first generate an initial image with two overlapping circles:
>>> x, y = np.indices((80, 80))
>>> x1, y1, x2, y2 = 28, 28, 44, 52
>>> r1, r2 = 16, 20
>>> mask_circle1 = (x - x1)**2 + (y - y1)**2 < r1**2
>>> mask_circle2 = (x - x2)**2 + (y - y2)**2 < r2**2
>>> image = np.logical_or(mask_circle1, mask_circle2)
Next, we want to separate the two circles. We generate markers at the
maxima of the distance to the background:
>>> from scipy import ndimage as ndi
>>> distance = ndi.distance_transform_edt(image)
>>> from skimage.feature import peak_local_max
>>> max_coords = peak_local_max(distance, labels=image,
... footprint=np.ones((3, 3)))
>>> local_maxima = np.zeros_like(image, dtype=bool)
>>> local_maxima[tuple(max_coords.T)] = True
>>> markers = ndi.label(local_maxima)[0]
Finally, we run the watershed on the image and markers:
>>> labels = watershed(-distance, markers, mask=image)
The algorithm works also for 3D images, and can be used for example to
separate overlapping spheres.
"""
image, markers, mask = _validate_inputs(image, markers, mask, connectivity)
connectivity, offset = _validate_connectivity(image.ndim, connectivity, offset)
# pad the image, markers, and mask so that we can use the mask to
# keep from running off the edges
pad_width = [(p, p) for p in offset]
image = np.pad(image, pad_width, mode='constant')
mask = np.pad(mask, pad_width, mode='constant').ravel()
output = np.pad(markers, pad_width, mode='constant')
flat_neighborhood = _offsets_to_raveled_neighbors(
image.shape, connectivity, center=offset
)
marker_locations = np.flatnonzero(output)
image_strides = np.array(image.strides, dtype=np.intp) // image.itemsize
_watershed_cy.watershed_raveled(
image.ravel(),
marker_locations,
flat_neighborhood,
mask,
image_strides,
compactness,
output.ravel(),
watershed_line,
)
output = crop(output, pad_width, copy=True)
return output

View File

@@ -0,0 +1,250 @@
import numpy as np
from scipy.interpolate import RectBivariateSpline
from .._shared.utils import _supported_float_type
from ..util import img_as_float
from ..filters import sobel
def active_contour(
image,
snake,
alpha=0.01,
beta=0.1,
w_line=0,
w_edge=1,
gamma=0.01,
max_px_move=1.0,
max_num_iter=2500,
convergence=0.1,
*,
boundary_condition='periodic',
):
"""Active contour model.
Active contours by fitting snakes to features of images. Supports single
and multichannel 2D images. Snakes can be periodic (for segmentation) or
have fixed and/or free ends.
The output snake has the same length as the input boundary.
As the number of points is constant, make sure that the initial snake
has enough points to capture the details of the final contour.
Parameters
----------
image : (M, N) or (M, N, 3) ndarray
Input image.
snake : (K, 2) ndarray
Initial snake coordinates. For periodic boundary conditions, endpoints
must not be duplicated.
alpha : float, optional
Snake length shape parameter. Higher values makes snake contract
faster.
beta : float, optional
Snake smoothness shape parameter. Higher values makes snake smoother.
w_line : float, optional
Controls attraction to brightness. Use negative values to attract
toward dark regions.
w_edge : float, optional
Controls attraction to edges. Use negative values to repel snake from
edges.
gamma : float, optional
Explicit time stepping parameter.
max_px_move : float, optional
Maximum pixel distance to move per iteration.
max_num_iter : int, optional
Maximum iterations to optimize snake shape.
convergence : float, optional
Convergence criteria.
boundary_condition : string, optional
Boundary conditions for the contour. Can be one of 'periodic',
'free', 'fixed', 'free-fixed', or 'fixed-free'. 'periodic' attaches
the two ends of the snake, 'fixed' holds the end-points in place,
and 'free' allows free movement of the ends. 'fixed' and 'free' can
be combined by parsing 'fixed-free', 'free-fixed'. Parsing
'fixed-fixed' or 'free-free' yields same behaviour as 'fixed' and
'free', respectively.
Returns
-------
snake : (K, 2) ndarray
Optimised snake, same shape as input parameter.
References
----------
.. [1] Kass, M.; Witkin, A.; Terzopoulos, D. "Snakes: Active contour
models". International Journal of Computer Vision 1 (4): 321
(1988). :DOI:`10.1007/BF00133570`
Examples
--------
>>> from skimage.draw import circle_perimeter
>>> from skimage.filters import gaussian
Create and smooth image:
>>> img = np.zeros((100, 100))
>>> rr, cc = circle_perimeter(35, 45, 25)
>>> img[rr, cc] = 1
>>> img = gaussian(img, sigma=2, preserve_range=False)
Initialize spline:
>>> s = np.linspace(0, 2*np.pi, 100)
>>> init = 50 * np.array([np.sin(s), np.cos(s)]).T + 50
Fit spline to image:
>>> snake = active_contour(img, init, w_edge=0, w_line=1) # doctest: +SKIP
>>> dist = np.sqrt((45-snake[:, 0])**2 + (35-snake[:, 1])**2) # doctest: +SKIP
>>> int(np.mean(dist)) # doctest: +SKIP
25
"""
max_num_iter = int(max_num_iter)
if max_num_iter <= 0:
raise ValueError("max_num_iter should be >0.")
convergence_order = 10
valid_bcs = [
'periodic',
'free',
'fixed',
'free-fixed',
'fixed-free',
'fixed-fixed',
'free-free',
]
if boundary_condition not in valid_bcs:
raise ValueError(
"Invalid boundary condition.\n"
+ "Should be one of: "
+ ", ".join(valid_bcs)
+ '.'
)
img = img_as_float(image)
float_dtype = _supported_float_type(image.dtype)
img = img.astype(float_dtype, copy=False)
RGB = img.ndim == 3
# Find edges using sobel:
if w_edge != 0:
if RGB:
edge = [sobel(img[:, :, 0]), sobel(img[:, :, 1]), sobel(img[:, :, 2])]
else:
edge = [sobel(img)]
else:
edge = [0]
# Superimpose intensity and edge images:
if RGB:
img = w_line * np.sum(img, axis=2) + w_edge * sum(edge)
else:
img = w_line * img + w_edge * edge[0]
# Interpolate for smoothness:
intp = RectBivariateSpline(
np.arange(img.shape[1]), np.arange(img.shape[0]), img.T, kx=2, ky=2, s=0
)
snake_xy = snake[:, ::-1]
x = snake_xy[:, 0].astype(float_dtype)
y = snake_xy[:, 1].astype(float_dtype)
n = len(x)
xsave = np.empty((convergence_order, n), dtype=float_dtype)
ysave = np.empty((convergence_order, n), dtype=float_dtype)
# Build snake shape matrix for Euler equation in double precision
eye_n = np.eye(n, dtype=float)
a = (
np.roll(eye_n, -1, axis=0) + np.roll(eye_n, -1, axis=1) - 2 * eye_n
) # second order derivative, central difference
b = (
np.roll(eye_n, -2, axis=0)
+ np.roll(eye_n, -2, axis=1)
- 4 * np.roll(eye_n, -1, axis=0)
- 4 * np.roll(eye_n, -1, axis=1)
+ 6 * eye_n
) # fourth order derivative, central difference
A = -alpha * a + beta * b
# Impose boundary conditions different from periodic:
sfixed = False
if boundary_condition.startswith('fixed'):
A[0, :] = 0
A[1, :] = 0
A[1, :3] = [1, -2, 1]
sfixed = True
efixed = False
if boundary_condition.endswith('fixed'):
A[-1, :] = 0
A[-2, :] = 0
A[-2, -3:] = [1, -2, 1]
efixed = True
sfree = False
if boundary_condition.startswith('free'):
A[0, :] = 0
A[0, :3] = [1, -2, 1]
A[1, :] = 0
A[1, :4] = [-1, 3, -3, 1]
sfree = True
efree = False
if boundary_condition.endswith('free'):
A[-1, :] = 0
A[-1, -3:] = [1, -2, 1]
A[-2, :] = 0
A[-2, -4:] = [-1, 3, -3, 1]
efree = True
# Only one inversion is needed for implicit spline energy minimization:
inv = np.linalg.inv(A + gamma * eye_n)
# can use float_dtype once we have computed the inverse in double precision
inv = inv.astype(float_dtype, copy=False)
# Explicit time stepping for image energy minimization:
for i in range(max_num_iter):
# RectBivariateSpline always returns float64, so call astype here
fx = intp(x, y, dx=1, grid=False).astype(float_dtype, copy=False)
fy = intp(x, y, dy=1, grid=False).astype(float_dtype, copy=False)
if sfixed:
fx[0] = 0
fy[0] = 0
if efixed:
fx[-1] = 0
fy[-1] = 0
if sfree:
fx[0] *= 2
fy[0] *= 2
if efree:
fx[-1] *= 2
fy[-1] *= 2
xn = inv @ (gamma * x + fx)
yn = inv @ (gamma * y + fy)
# Movements are capped to max_px_move per iteration:
dx = max_px_move * np.tanh(xn - x)
dy = max_px_move * np.tanh(yn - y)
if sfixed:
dx[0] = 0
dy[0] = 0
if efixed:
dx[-1] = 0
dy[-1] = 0
x += dx
y += dy
# Convergence criteria needs to compare to a number of previous
# configurations since oscillations can occur.
j = i % (convergence_order + 1)
if j < convergence_order:
xsave[j, :] = x
ysave[j, :] = y
else:
dist = np.min(
np.max(np.abs(xsave - x[None, :]) + np.abs(ysave - y[None, :]), 1)
)
if dist < convergence:
break
return np.stack([y, x], axis=1)

View File

@@ -0,0 +1,240 @@
import numpy as np
from scipy import ndimage as ndi
from .._shared.utils import _supported_float_type
from ..morphology import dilation, erosion, square
from ..util import img_as_float, view_as_windows
from ..color import gray2rgb
def _find_boundaries_subpixel(label_img):
"""See ``find_boundaries(..., mode='subpixel')``.
Notes
-----
This function puts in an empty row and column between each *actual*
row and column of the image, for a corresponding shape of ``2s - 1``
for every image dimension of size ``s``. These "interstitial" rows
and columns are filled as ``True`` if they separate two labels in
`label_img`, ``False`` otherwise.
I used ``view_as_windows`` to get the neighborhood of each pixel.
Then I check whether there are two labels or more in that
neighborhood.
"""
ndim = label_img.ndim
max_label = np.iinfo(label_img.dtype).max
label_img_expanded = np.zeros(
[(2 * s - 1) for s in label_img.shape], label_img.dtype
)
pixels = (slice(None, None, 2),) * ndim
label_img_expanded[pixels] = label_img
edges = np.ones(label_img_expanded.shape, dtype=bool)
edges[pixels] = False
label_img_expanded[edges] = max_label
windows = view_as_windows(np.pad(label_img_expanded, 1, mode='edge'), (3,) * ndim)
boundaries = np.zeros_like(edges)
for index in np.ndindex(label_img_expanded.shape):
if edges[index]:
values = np.unique(windows[index].ravel())
if len(values) > 2: # single value and max_label
boundaries[index] = True
return boundaries
def find_boundaries(label_img, connectivity=1, mode='thick', background=0):
"""Return bool array where boundaries between labeled regions are True.
Parameters
----------
label_img : array of int or bool
An array in which different regions are labeled with either different
integers or boolean values.
connectivity : int in {1, ..., `label_img.ndim`}, optional
A pixel is considered a boundary pixel if any of its neighbors
has a different label. `connectivity` controls which pixels are
considered neighbors. A connectivity of 1 (default) means
pixels sharing an edge (in 2D) or a face (in 3D) will be
considered neighbors. A connectivity of `label_img.ndim` means
pixels sharing a corner will be considered neighbors.
mode : string in {'thick', 'inner', 'outer', 'subpixel'}
How to mark the boundaries:
- thick: any pixel not completely surrounded by pixels of the
same label (defined by `connectivity`) is marked as a boundary.
This results in boundaries that are 2 pixels thick.
- inner: outline the pixels *just inside* of objects, leaving
background pixels untouched.
- outer: outline pixels in the background around object
boundaries. When two objects touch, their boundary is also
marked.
- subpixel: return a doubled image, with pixels *between* the
original pixels marked as boundary where appropriate.
background : int, optional
For modes 'inner' and 'outer', a definition of a background
label is required. See `mode` for descriptions of these two.
Returns
-------
boundaries : array of bool, same shape as `label_img`
A bool image where ``True`` represents a boundary pixel. For
`mode` equal to 'subpixel', ``boundaries.shape[i]`` is equal
to ``2 * label_img.shape[i] - 1`` for all ``i`` (a pixel is
inserted in between all other pairs of pixels).
Examples
--------
>>> labels = np.array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
... [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
... [0, 0, 0, 0, 0, 5, 5, 5, 0, 0],
... [0, 0, 1, 1, 1, 5, 5, 5, 0, 0],
... [0, 0, 1, 1, 1, 5, 5, 5, 0, 0],
... [0, 0, 1, 1, 1, 5, 5, 5, 0, 0],
... [0, 0, 0, 0, 0, 5, 5, 5, 0, 0],
... [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
... [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=np.uint8)
>>> find_boundaries(labels, mode='thick').astype(np.uint8)
array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 1, 0, 1, 1, 0],
[0, 1, 1, 0, 1, 1, 0, 1, 1, 0],
[0, 1, 1, 1, 1, 1, 0, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
>>> find_boundaries(labels, mode='inner').astype(np.uint8)
array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 1, 0, 0],
[0, 0, 1, 0, 1, 1, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
>>> find_boundaries(labels, mode='outer').astype(np.uint8)
array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0, 1, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 1, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 1, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
>>> labels_small = labels[::2, ::3]
>>> labels_small
array([[0, 0, 0, 0],
[0, 0, 5, 0],
[0, 1, 5, 0],
[0, 0, 5, 0],
[0, 0, 0, 0]], dtype=uint8)
>>> find_boundaries(labels_small, mode='subpixel').astype(np.uint8)
array([[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 0],
[0, 0, 0, 1, 0, 1, 0],
[0, 1, 1, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 1, 1, 0, 1, 0],
[0, 0, 0, 1, 0, 1, 0],
[0, 0, 0, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
>>> bool_image = np.array([[False, False, False, False, False],
... [False, False, False, False, False],
... [False, False, True, True, True],
... [False, False, True, True, True],
... [False, False, True, True, True]],
... dtype=bool)
>>> find_boundaries(bool_image)
array([[False, False, False, False, False],
[False, False, True, True, True],
[False, True, True, True, True],
[False, True, True, False, False],
[False, True, True, False, False]])
"""
if label_img.dtype == 'bool':
label_img = label_img.astype(np.uint8)
ndim = label_img.ndim
footprint = ndi.generate_binary_structure(ndim, connectivity)
if mode != 'subpixel':
boundaries = dilation(label_img, footprint) != erosion(label_img, footprint)
if mode == 'inner':
foreground_image = label_img != background
boundaries &= foreground_image
elif mode == 'outer':
max_label = np.iinfo(label_img.dtype).max
background_image = label_img == background
footprint = ndi.generate_binary_structure(ndim, ndim)
inverted_background = np.array(label_img, copy=True)
inverted_background[background_image] = max_label
adjacent_objects = (
dilation(label_img, footprint)
!= erosion(inverted_background, footprint)
) & ~background_image
boundaries &= background_image | adjacent_objects
return boundaries
else:
boundaries = _find_boundaries_subpixel(label_img)
return boundaries
def mark_boundaries(
image,
label_img,
color=(1, 1, 0),
outline_color=None,
mode='outer',
background_label=0,
):
"""Return image with boundaries between labeled regions highlighted.
Parameters
----------
image : (M, N[, 3]) array
Grayscale or RGB image.
label_img : (M, N) array of int
Label array where regions are marked by different integer values.
color : length-3 sequence, optional
RGB color of boundaries in the output image.
outline_color : length-3 sequence, optional
RGB color surrounding boundaries in the output image. If None, no
outline is drawn.
mode : string in {'thick', 'inner', 'outer', 'subpixel'}, optional
The mode for finding boundaries.
background_label : int, optional
Which label to consider background (this is only useful for
modes ``inner`` and ``outer``).
Returns
-------
marked : (M, N, 3) array of float
An image in which the boundaries between labels are
superimposed on the original image.
See Also
--------
find_boundaries
"""
float_dtype = _supported_float_type(image.dtype)
marked = img_as_float(image, force_copy=True)
marked = marked.astype(float_dtype, copy=False)
if marked.ndim == 2:
marked = gray2rgb(marked)
if mode == 'subpixel':
# Here, we want to interpose an extra line of pixels between
# each original line - except for the last axis which holds
# the RGB information. ``ndi.zoom`` then performs the (cubic)
# interpolation, filling in the values of the interposed pixels
marked = ndi.zoom(
marked, [2 - 1 / s for s in marked.shape[:-1]] + [1], mode='mirror'
)
boundaries = find_boundaries(label_img, mode=mode, background=background_label)
if outline_color is not None:
outlines = dilation(boundaries, square(3))
marked[outlines] = outline_color
marked[boundaries] = color
return marked

View File

@@ -0,0 +1,449 @@
from itertools import cycle
import numpy as np
from scipy import ndimage as ndi
from .._shared.utils import check_nD
__all__ = [
'morphological_chan_vese',
'morphological_geodesic_active_contour',
'inverse_gaussian_gradient',
'disk_level_set',
'checkerboard_level_set',
]
class _fcycle:
def __init__(self, iterable):
"""Call functions from the iterable each time it is called."""
self.funcs = cycle(iterable)
def __call__(self, *args, **kwargs):
f = next(self.funcs)
return f(*args, **kwargs)
# SI and IS operators for 2D and 3D.
_P2 = [
np.eye(3),
np.array([[0, 1, 0]] * 3),
np.flipud(np.eye(3)),
np.rot90([[0, 1, 0]] * 3),
]
_P3 = [np.zeros((3, 3, 3)) for i in range(9)]
_P3[0][:, :, 1] = 1
_P3[1][:, 1, :] = 1
_P3[2][1, :, :] = 1
_P3[3][:, [0, 1, 2], [0, 1, 2]] = 1
_P3[4][:, [0, 1, 2], [2, 1, 0]] = 1
_P3[5][[0, 1, 2], :, [0, 1, 2]] = 1
_P3[6][[0, 1, 2], :, [2, 1, 0]] = 1
_P3[7][[0, 1, 2], [0, 1, 2], :] = 1
_P3[8][[0, 1, 2], [2, 1, 0], :] = 1
def sup_inf(u):
"""SI operator."""
if np.ndim(u) == 2:
P = _P2
elif np.ndim(u) == 3:
P = _P3
else:
raise ValueError("u has an invalid number of dimensions " "(should be 2 or 3)")
erosions = []
for P_i in P:
erosions.append(ndi.binary_erosion(u, P_i).astype(np.int8))
return np.stack(erosions, axis=0).max(0)
def inf_sup(u):
"""IS operator."""
if np.ndim(u) == 2:
P = _P2
elif np.ndim(u) == 3:
P = _P3
else:
raise ValueError("u has an invalid number of dimensions " "(should be 2 or 3)")
dilations = []
for P_i in P:
dilations.append(ndi.binary_dilation(u, P_i).astype(np.int8))
return np.stack(dilations, axis=0).min(0)
_curvop = _fcycle(
[lambda u: sup_inf(inf_sup(u)), lambda u: inf_sup(sup_inf(u))] # SIoIS
) # ISoSI
def _check_input(image, init_level_set):
"""Check that shapes of `image` and `init_level_set` match."""
check_nD(image, [2, 3])
if len(image.shape) != len(init_level_set.shape):
raise ValueError(
"The dimensions of the initial level set do not "
"match the dimensions of the image."
)
def _init_level_set(init_level_set, image_shape):
"""Auxiliary function for initializing level sets with a string.
If `init_level_set` is not a string, it is returned as is.
"""
if isinstance(init_level_set, str):
if init_level_set == 'checkerboard':
res = checkerboard_level_set(image_shape)
elif init_level_set == 'disk':
res = disk_level_set(image_shape)
else:
raise ValueError("`init_level_set` not in " "['checkerboard', 'disk']")
else:
res = init_level_set
return res
def disk_level_set(image_shape, *, center=None, radius=None):
"""Create a disk level set with binary values.
Parameters
----------
image_shape : tuple of positive integers
Shape of the image
center : tuple of positive integers, optional
Coordinates of the center of the disk given in (row, column). If not
given, it defaults to the center of the image.
radius : float, optional
Radius of the disk. If not given, it is set to the 75% of the
smallest image dimension.
Returns
-------
out : array with shape `image_shape`
Binary level set of the disk with the given `radius` and `center`.
See Also
--------
checkerboard_level_set
"""
if center is None:
center = tuple(i // 2 for i in image_shape)
if radius is None:
radius = min(image_shape) * 3.0 / 8.0
grid = np.mgrid[[slice(i) for i in image_shape]]
grid = (grid.T - center).T
phi = radius - np.sqrt(np.sum((grid) ** 2, 0))
res = np.int8(phi > 0)
return res
def checkerboard_level_set(image_shape, square_size=5):
"""Create a checkerboard level set with binary values.
Parameters
----------
image_shape : tuple of positive integers
Shape of the image.
square_size : int, optional
Size of the squares of the checkerboard. It defaults to 5.
Returns
-------
out : array with shape `image_shape`
Binary level set of the checkerboard.
See Also
--------
disk_level_set
"""
grid = np.mgrid[[slice(i) for i in image_shape]]
grid = grid // square_size
# Alternate 0/1 for even/odd numbers.
grid = grid & 1
checkerboard = np.bitwise_xor.reduce(grid, axis=0)
res = np.int8(checkerboard)
return res
def inverse_gaussian_gradient(image, alpha=100.0, sigma=5.0):
"""Inverse of gradient magnitude.
Compute the magnitude of the gradients in the image and then inverts the
result in the range [0, 1]. Flat areas are assigned values close to 1,
while areas close to borders are assigned values close to 0.
This function or a similar one defined by the user should be applied over
the image as a preprocessing step before calling
`morphological_geodesic_active_contour`.
Parameters
----------
image : (M, N) or (L, M, N) array
Grayscale image or volume.
alpha : float, optional
Controls the steepness of the inversion. A larger value will make the
transition between the flat areas and border areas steeper in the
resulting array.
sigma : float, optional
Standard deviation of the Gaussian filter applied over the image.
Returns
-------
gimage : (M, N) or (L, M, N) array
Preprocessed image (or volume) suitable for
`morphological_geodesic_active_contour`.
"""
gradnorm = ndi.gaussian_gradient_magnitude(image, sigma, mode='nearest')
return 1.0 / np.sqrt(1.0 + alpha * gradnorm)
def morphological_chan_vese(
image,
num_iter,
init_level_set='checkerboard',
smoothing=1,
lambda1=1,
lambda2=1,
iter_callback=lambda x: None,
):
"""Morphological Active Contours without Edges (MorphACWE)
Active contours without edges implemented with morphological operators. It
can be used to segment objects in images and volumes without well defined
borders. It is required that the inside of the object looks different on
average than the outside (i.e., the inner area of the object should be
darker or lighter than the outer area on average).
Parameters
----------
image : (M, N) or (L, M, N) array
Grayscale image or volume to be segmented.
num_iter : uint
Number of num_iter to run
init_level_set : str, (M, N) array, or (L, M, N) array
Initial level set. If an array is given, it will be binarized and used
as the initial level set. If a string is given, it defines the method
to generate a reasonable initial level set with the shape of the
`image`. Accepted values are 'checkerboard' and 'disk'. See the
documentation of `checkerboard_level_set` and `disk_level_set`
respectively for details about how these level sets are created.
smoothing : uint, optional
Number of times the smoothing operator is applied per iteration.
Reasonable values are around 1-4. Larger values lead to smoother
segmentations.
lambda1 : float, optional
Weight parameter for the outer region. If `lambda1` is larger than
`lambda2`, the outer region will contain a larger range of values than
the inner region.
lambda2 : float, optional
Weight parameter for the inner region. If `lambda2` is larger than
`lambda1`, the inner region will contain a larger range of values than
the outer region.
iter_callback : function, optional
If given, this function is called once per iteration with the current
level set as the only argument. This is useful for debugging or for
plotting intermediate results during the evolution.
Returns
-------
out : (M, N) or (L, M, N) array
Final segmentation (i.e., the final level set)
See Also
--------
disk_level_set, checkerboard_level_set
Notes
-----
This is a version of the Chan-Vese algorithm that uses morphological
operators instead of solving a partial differential equation (PDE) for the
evolution of the contour. The set of morphological operators used in this
algorithm are proved to be infinitesimally equivalent to the Chan-Vese PDE
(see [1]_). However, morphological operators are do not suffer from the
numerical stability issues typically found in PDEs (it is not necessary to
find the right time step for the evolution), and are computationally
faster.
The algorithm and its theoretical derivation are described in [1]_.
References
----------
.. [1] A Morphological Approach to Curvature-based Evolution of Curves and
Surfaces, Pablo Márquez-Neila, Luis Baumela, Luis Álvarez. In IEEE
Transactions on Pattern Analysis and Machine Intelligence (PAMI),
2014, :DOI:`10.1109/TPAMI.2013.106`
"""
init_level_set = _init_level_set(init_level_set, image.shape)
_check_input(image, init_level_set)
u = np.int8(init_level_set > 0)
iter_callback(u)
for _ in range(num_iter):
# inside = u > 0
# outside = u <= 0
c0 = (image * (1 - u)).sum() / float((1 - u).sum() + 1e-8)
c1 = (image * u).sum() / float(u.sum() + 1e-8)
# Image attachment
du = np.gradient(u)
abs_du = np.abs(du).sum(0)
aux = abs_du * (lambda1 * (image - c1) ** 2 - lambda2 * (image - c0) ** 2)
u[aux < 0] = 1
u[aux > 0] = 0
# Smoothing
for _ in range(smoothing):
u = _curvop(u)
iter_callback(u)
return u
def morphological_geodesic_active_contour(
gimage,
num_iter,
init_level_set='disk',
smoothing=1,
threshold='auto',
balloon=0,
iter_callback=lambda x: None,
):
"""Morphological Geodesic Active Contours (MorphGAC).
Geodesic active contours implemented with morphological operators. It can
be used to segment objects with visible but noisy, cluttered, broken
borders.
Parameters
----------
gimage : (M, N) or (L, M, N) array
Preprocessed image or volume to be segmented. This is very rarely the
original image. Instead, this is usually a preprocessed version of the
original image that enhances and highlights the borders (or other
structures) of the object to segment.
:func:`morphological_geodesic_active_contour` will try to stop the contour
evolution in areas where `gimage` is small. See
:func:`inverse_gaussian_gradient` as an example function to
perform this preprocessing. Note that the quality of
:func:`morphological_geodesic_active_contour` might greatly depend on this
preprocessing.
num_iter : uint
Number of num_iter to run.
init_level_set : str, (M, N) array, or (L, M, N) array
Initial level set. If an array is given, it will be binarized and used
as the initial level set. If a string is given, it defines the method
to generate a reasonable initial level set with the shape of the
`image`. Accepted values are 'checkerboard' and 'disk'. See the
documentation of `checkerboard_level_set` and `disk_level_set`
respectively for details about how these level sets are created.
smoothing : uint, optional
Number of times the smoothing operator is applied per iteration.
Reasonable values are around 1-4. Larger values lead to smoother
segmentations.
threshold : float, optional
Areas of the image with a value smaller than this threshold will be
considered borders. The evolution of the contour will stop in these
areas.
balloon : float, optional
Balloon force to guide the contour in non-informative areas of the
image, i.e., areas where the gradient of the image is too small to push
the contour towards a border. A negative value will shrink the contour,
while a positive value will expand the contour in these areas. Setting
this to zero will disable the balloon force.
iter_callback : function, optional
If given, this function is called once per iteration with the current
level set as the only argument. This is useful for debugging or for
plotting intermediate results during the evolution.
Returns
-------
out : (M, N) or (L, M, N) array
Final segmentation (i.e., the final level set)
See Also
--------
inverse_gaussian_gradient, disk_level_set, checkerboard_level_set
Notes
-----
This is a version of the Geodesic Active Contours (GAC) algorithm that uses
morphological operators instead of solving partial differential equations
(PDEs) for the evolution of the contour. The set of morphological operators
used in this algorithm are proved to be infinitesimally equivalent to the
GAC PDEs (see [1]_). However, morphological operators are do not suffer
from the numerical stability issues typically found in PDEs (e.g., it is
not necessary to find the right time step for the evolution), and are
computationally faster.
The algorithm and its theoretical derivation are described in [1]_.
References
----------
.. [1] A Morphological Approach to Curvature-based Evolution of Curves and
Surfaces, Pablo Márquez-Neila, Luis Baumela, Luis Álvarez. In IEEE
Transactions on Pattern Analysis and Machine Intelligence (PAMI),
2014, :DOI:`10.1109/TPAMI.2013.106`
"""
image = gimage
init_level_set = _init_level_set(init_level_set, image.shape)
_check_input(image, init_level_set)
if threshold == 'auto':
threshold = np.percentile(image, 40)
structure = np.ones((3,) * len(image.shape), dtype=np.int8)
dimage = np.gradient(image)
# threshold_mask = image > threshold
if balloon != 0:
threshold_mask_balloon = image > threshold / np.abs(balloon)
u = np.int8(init_level_set > 0)
iter_callback(u)
for _ in range(num_iter):
# Balloon
if balloon > 0:
aux = ndi.binary_dilation(u, structure)
elif balloon < 0:
aux = ndi.binary_erosion(u, structure)
if balloon != 0:
u[threshold_mask_balloon] = aux[threshold_mask_balloon]
# Image attachment
aux = np.zeros_like(image)
du = np.gradient(u)
for el1, el2 in zip(dimage, du):
aux += el1 * el2
u[aux > 0] = 1
u[aux < 0] = 0
# Smoothing
for _ in range(smoothing):
u = _curvop(u)
iter_callback(u)
return u

View File

@@ -0,0 +1,580 @@
"""
Random walker segmentation algorithm
from *Random walks for image segmentation*, Leo Grady, IEEE Trans
Pattern Anal Mach Intell. 2006 Nov;28(11):1768-83.
Installing pyamg and using the 'cg_mg' mode of random_walker improves
significantly the performance.
"""
import numpy as np
from scipy import sparse, ndimage as ndi
from .._shared import utils
from .._shared.utils import warn
from .._shared.compat import SCIPY_CG_TOL_PARAM_NAME
# executive summary for next code block: try to import umfpack from
# scipy, but make sure not to raise a fuss if it fails since it's only
# needed to speed up a few cases.
# See discussions at:
# https://groups.google.com/d/msg/scikit-image/FrM5IGP6wh4/1hp-FtVZmfcJ
# https://stackoverflow.com/questions/13977970/ignore-exceptions-printed-to-stderr-in-del/13977992?noredirect=1#comment28386412_13977992
try:
from scipy.sparse.linalg.dsolve.linsolve import umfpack
old_del = umfpack.UmfpackContext.__del__
def new_del(self):
try:
old_del(self)
except AttributeError:
pass
umfpack.UmfpackContext.__del__ = new_del
UmfpackContext = umfpack.UmfpackContext()
except ImportError:
UmfpackContext = None
try:
from pyamg import ruge_stuben_solver
amg_loaded = True
except ImportError:
amg_loaded = False
except AttributeError as error:
if "`np.deprecate` was removed" in error.args[0]:
warn(
"found optional dependency pyamg, which cannot (yet) be imported with "
"NumPy >=2 and will be treated as if not available",
RuntimeWarning,
)
amg_loaded = False
else:
raise error
from ..util import img_as_float
from scipy.sparse.linalg import cg, spsolve
def _make_graph_edges_3d(n_x, n_y, n_z):
"""Returns a list of edges for a 3D image.
Parameters
----------
n_x : integer
The size of the grid in the x direction.
n_y : integer
The size of the grid in the y direction
n_z : integer
The size of the grid in the z direction
Returns
-------
edges : (2, N) ndarray
with the total number of edges::
N = n_x * n_y * (nz - 1) +
n_x * (n_y - 1) * nz +
(n_x - 1) * n_y * nz
Graph edges with each column describing a node-id pair.
"""
vertices = np.arange(n_x * n_y * n_z).reshape((n_x, n_y, n_z))
edges_deep = np.vstack((vertices[..., :-1].ravel(), vertices[..., 1:].ravel()))
edges_right = np.vstack((vertices[:, :-1].ravel(), vertices[:, 1:].ravel()))
edges_down = np.vstack((vertices[:-1].ravel(), vertices[1:].ravel()))
edges = np.hstack((edges_deep, edges_right, edges_down))
return edges
def _compute_weights_3d(data, spacing, beta, eps, multichannel):
# Weight calculation is main difference in multispectral version
# Original gradient**2 replaced with sum of gradients ** 2
gradients = (
np.concatenate(
[
np.diff(data[..., 0], axis=ax).ravel() / spacing[ax]
for ax in [2, 1, 0]
if data.shape[ax] > 1
],
axis=0,
)
** 2
)
for channel in range(1, data.shape[-1]):
gradients += (
np.concatenate(
[
np.diff(data[..., channel], axis=ax).ravel() / spacing[ax]
for ax in [2, 1, 0]
if data.shape[ax] > 1
],
axis=0,
)
** 2
)
# All channels considered together in this standard deviation
scale_factor = -beta / (10 * data.std())
if multichannel:
# New final term in beta to give == results in trivial case where
# multiple identical spectra are passed.
scale_factor /= np.sqrt(data.shape[-1])
weights = np.exp(scale_factor * gradients)
weights += eps
return -weights
def _build_laplacian(data, spacing, mask, beta, multichannel):
l_x, l_y, l_z = data.shape[:3]
edges = _make_graph_edges_3d(l_x, l_y, l_z)
weights = _compute_weights_3d(
data, spacing, beta=beta, eps=1.0e-10, multichannel=multichannel
)
if mask is not None:
# Remove edges of the graph connected to masked nodes, as well
# as corresponding weights of the edges.
mask0 = np.hstack(
[mask[..., :-1].ravel(), mask[:, :-1].ravel(), mask[:-1].ravel()]
)
mask1 = np.hstack(
[mask[..., 1:].ravel(), mask[:, 1:].ravel(), mask[1:].ravel()]
)
ind_mask = np.logical_and(mask0, mask1)
edges, weights = edges[:, ind_mask], weights[ind_mask]
# Reassign edges labels to 0, 1, ... edges_number - 1
_, inv_idx = np.unique(edges, return_inverse=True)
edges = inv_idx.reshape(edges.shape)
# Build the sparse linear system
pixel_nb = l_x * l_y * l_z
i_indices = edges.ravel()
j_indices = edges[::-1].ravel()
data = np.hstack((weights, weights))
lap = sparse.coo_matrix((data, (i_indices, j_indices)), shape=(pixel_nb, pixel_nb))
lap.setdiag(-np.ravel(lap.sum(axis=0)))
return lap.tocsr()
def _build_linear_system(data, spacing, labels, nlabels, mask, beta, multichannel):
"""
Build the matrix A and rhs B of the linear system to solve.
A and B are two block of the laplacian of the image graph.
"""
if mask is None:
labels = labels.ravel()
else:
labels = labels[mask]
indices = np.arange(labels.size)
seeds_mask = labels > 0
unlabeled_indices = indices[~seeds_mask]
seeds_indices = indices[seeds_mask]
lap_sparse = _build_laplacian(
data, spacing, mask=mask, beta=beta, multichannel=multichannel
)
rows = lap_sparse[unlabeled_indices, :]
lap_sparse = rows[:, unlabeled_indices]
B = -rows[:, seeds_indices]
seeds = labels[seeds_mask]
seeds_mask = sparse.csc_matrix(
np.hstack([np.atleast_2d(seeds == lab).T for lab in range(1, nlabels + 1)])
)
rhs = B.dot(seeds_mask)
return lap_sparse, rhs
def _solve_linear_system(lap_sparse, B, tol, mode):
if mode is None:
mode = 'cg_j'
if mode == 'cg_mg' and not amg_loaded:
warn(
'"cg_mg" not available, it requires pyamg to be installed. '
'The "cg_j" mode will be used instead.',
stacklevel=2,
)
mode = 'cg_j'
if mode == 'bf':
X = spsolve(lap_sparse, B.toarray()).T
else:
maxiter = None
if mode == 'cg':
if UmfpackContext is None:
warn(
'"cg" mode may be slow because UMFPACK is not available. '
'Consider building Scipy with UMFPACK or use a '
'preconditioned version of CG ("cg_j" or "cg_mg" modes).',
stacklevel=2,
)
M = None
elif mode == 'cg_j':
M = sparse.diags(1.0 / lap_sparse.diagonal())
else:
# mode == 'cg_mg'
lap_sparse = lap_sparse.tocsr()
ml = ruge_stuben_solver(lap_sparse, coarse_solver='pinv')
M = ml.aspreconditioner(cycle='V')
maxiter = 30
rtol = {SCIPY_CG_TOL_PARAM_NAME: tol}
cg_out = [
cg(lap_sparse, B[:, i].toarray(), **rtol, atol=0, M=M, maxiter=maxiter)
for i in range(B.shape[1])
]
if np.any([info > 0 for _, info in cg_out]):
warn(
"Conjugate gradient convergence to tolerance not achieved. "
"Consider decreasing beta to improve system conditionning.",
stacklevel=2,
)
X = np.asarray([x for x, _ in cg_out])
return X
def _preprocess(labels):
label_values, inv_idx = np.unique(labels, return_inverse=True)
if max(label_values) <= 0:
raise ValueError(
'No seeds provided in label image: please ensure '
'it contains at least one positive value'
)
if not (label_values == 0).any():
warn(
'Random walker only segments unlabeled areas, where '
'labels == 0. No zero valued areas in labels were '
'found. Returning provided labels.',
stacklevel=2,
)
return labels, None, None, None, None
# If some labeled pixels are isolated inside pruned zones, prune them
# as well and keep the labels for the final output
null_mask = labels == 0
pos_mask = labels > 0
mask = labels >= 0
fill = ndi.binary_propagation(null_mask, mask=mask)
isolated = np.logical_and(pos_mask, np.logical_not(fill))
pos_mask[isolated] = False
# If the array has pruned zones, be sure that no isolated pixels
# exist between pruned zones (they could not be determined)
if label_values[0] < 0 or np.any(isolated):
isolated = np.logical_and(
np.logical_not(ndi.binary_propagation(pos_mask, mask=mask)), null_mask
)
labels[isolated] = -1
if np.all(isolated[null_mask]):
warn(
'All unlabeled pixels are isolated, they could not be '
'determined by the random walker algorithm.',
stacklevel=2,
)
return labels, None, None, None, None
mask[isolated] = False
mask = np.atleast_3d(mask)
else:
mask = None
# Reorder label values to have consecutive integers (no gaps)
zero_idx = np.searchsorted(label_values, 0)
labels = np.atleast_3d(inv_idx.reshape(labels.shape) - zero_idx)
nlabels = label_values[zero_idx + 1 :].shape[0]
inds_isolated_seeds = np.nonzero(isolated)
isolated_values = labels[inds_isolated_seeds]
return labels, nlabels, mask, inds_isolated_seeds, isolated_values
@utils.channel_as_last_axis(multichannel_output=False)
def random_walker(
data,
labels,
beta=130,
mode='cg_j',
tol=1.0e-3,
copy=True,
return_full_prob=False,
spacing=None,
*,
prob_tol=1e-3,
channel_axis=None,
):
"""Random walker algorithm for segmentation from markers.
Random walker algorithm is implemented for gray-level or multichannel
images.
Parameters
----------
data : (M, N[, P][, C]) ndarray
Image to be segmented in phases. Gray-level `data` can be two- or
three-dimensional; multichannel data can be three- or four-
dimensional with `channel_axis` specifying the dimension containing
channels. Data spacing is assumed isotropic unless the `spacing`
keyword argument is used.
labels : (M, N[, P]) array of ints
Array of seed markers labeled with different positive integers
for different phases. Zero-labeled pixels are unlabeled pixels.
Negative labels correspond to inactive pixels that are not taken
into account (they are removed from the graph). If labels are not
consecutive integers, the labels array will be transformed so that
labels are consecutive. In the multichannel case, `labels` should have
the same shape as a single channel of `data`, i.e. without the final
dimension denoting channels.
beta : float, optional
Penalization coefficient for the random walker motion
(the greater `beta`, the more difficult the diffusion).
mode : string, available options {'cg', 'cg_j', 'cg_mg', 'bf'}
Mode for solving the linear system in the random walker algorithm.
- 'bf' (brute force): an LU factorization of the Laplacian is
computed. This is fast for small images (<1024x1024), but very slow
and memory-intensive for large images (e.g., 3-D volumes).
- 'cg' (conjugate gradient): the linear system is solved iteratively
using the Conjugate Gradient method from scipy.sparse.linalg. This is
less memory-consuming than the brute force method for large images,
but it is quite slow.
- 'cg_j' (conjugate gradient with Jacobi preconditionner): the
Jacobi preconditionner is applied during the Conjugate
gradient method iterations. This may accelerate the
convergence of the 'cg' method.
- 'cg_mg' (conjugate gradient with multigrid preconditioner): a
preconditioner is computed using a multigrid solver, then the
solution is computed with the Conjugate Gradient method. This mode
requires that the pyamg module is installed.
tol : float, optional
Tolerance to achieve when solving the linear system using
the conjugate gradient based modes ('cg', 'cg_j' and 'cg_mg').
copy : bool, optional
If copy is False, the `labels` array will be overwritten with
the result of the segmentation. Use copy=False if you want to
save on memory.
return_full_prob : bool, optional
If True, the probability that a pixel belongs to each of the
labels will be returned, instead of only the most likely
label.
spacing : iterable of floats, optional
Spacing between voxels in each spatial dimension. If `None`, then
the spacing between pixels/voxels in each dimension is assumed 1.
prob_tol : float, optional
Tolerance on the resulting probability to be in the interval [0, 1].
If the tolerance is not satisfied, a warning is displayed.
channel_axis : int or None, optional
If None, the image is assumed to be a grayscale (single channel) image.
Otherwise, this parameter indicates which axis of the array corresponds
to channels.
.. versionadded:: 0.19
``channel_axis`` was added in 0.19.
Returns
-------
output : ndarray
* If `return_full_prob` is False, array of ints of same shape
and data type as `labels`, in which each pixel has been
labeled according to the marker that reached the pixel first
by anisotropic diffusion.
* If `return_full_prob` is True, array of floats of shape
`(nlabels, labels.shape)`. `output[label_nb, i, j]` is the
probability that label `label_nb` reaches the pixel `(i, j)`
first.
See Also
--------
skimage.segmentation.watershed
A segmentation algorithm based on mathematical morphology
and "flooding" of regions from markers.
Notes
-----
Multichannel inputs are scaled with all channel data combined. Ensure all
channels are separately normalized prior to running this algorithm.
The `spacing` argument is specifically for anisotropic datasets, where
data points are spaced differently in one or more spatial dimensions.
Anisotropic data is commonly encountered in medical imaging.
The algorithm was first proposed in [1]_.
The algorithm solves the diffusion equation at infinite times for
sources placed on markers of each phase in turn. A pixel is labeled with
the phase that has the greatest probability to diffuse first to the pixel.
The diffusion equation is solved by minimizing x.T L x for each phase,
where L is the Laplacian of the weighted graph of the image, and x is
the probability that a marker of the given phase arrives first at a pixel
by diffusion (x=1 on markers of the phase, x=0 on the other markers, and
the other coefficients are looked for). Each pixel is attributed the label
for which it has a maximal value of x. The Laplacian L of the image
is defined as:
- L_ii = d_i, the number of neighbors of pixel i (the degree of i)
- L_ij = -w_ij if i and j are adjacent pixels
The weight w_ij is a decreasing function of the norm of the local gradient.
This ensures that diffusion is easier between pixels of similar values.
When the Laplacian is decomposed into blocks of marked and unmarked
pixels::
L = M B.T
B A
with first indices corresponding to marked pixels, and then to unmarked
pixels, minimizing x.T L x for one phase amount to solving::
A x = - B x_m
where x_m = 1 on markers of the given phase, and 0 on other markers.
This linear system is solved in the algorithm using a direct method for
small images, and an iterative method for larger images.
References
----------
.. [1] Leo Grady, Random walks for image segmentation, IEEE Trans Pattern
Anal Mach Intell. 2006 Nov;28(11):1768-83.
:DOI:`10.1109/TPAMI.2006.233`.
Examples
--------
>>> rng = np.random.default_rng()
>>> a = np.zeros((10, 10)) + 0.2 * rng.random((10, 10))
>>> a[5:8, 5:8] += 1
>>> b = np.zeros_like(a, dtype=np.int32)
>>> b[3, 3] = 1 # Marker for first phase
>>> b[6, 6] = 2 # Marker for second phase
>>> random_walker(a, b) # doctest: +SKIP
array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 2, 2, 2, 1, 1],
[1, 1, 1, 1, 1, 2, 2, 2, 1, 1],
[1, 1, 1, 1, 1, 2, 2, 2, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], dtype=int32)
"""
# Parse input data
if mode not in ('cg_mg', 'cg', 'bf', 'cg_j', None):
raise ValueError(
f"{mode} is not a valid mode. Valid modes are 'cg_mg', "
f"'cg', 'cg_j', 'bf', and None"
)
# Spacing kwarg checks
if spacing is None:
spacing = np.ones(3)
elif len(spacing) == labels.ndim:
if len(spacing) == 2:
# Need a dummy spacing for singleton 3rd dim
spacing = np.r_[spacing, 1.0]
spacing = np.asarray(spacing)
else:
raise ValueError(
'Input argument `spacing` incorrect, should be an '
'iterable with one number per spatial dimension.'
)
# This algorithm expects 4-D arrays of floats, where the first three
# dimensions are spatial and the final denotes channels. 2-D images have
# a singleton placeholder dimension added for the third spatial dimension,
# and single channel images likewise have a singleton added for channels.
# The following block ensures valid input and coerces it to the correct
# form.
multichannel = channel_axis is not None
if not multichannel:
if data.ndim not in (2, 3):
raise ValueError(
'For non-multichannel input, data must be of ' 'dimension 2 or 3.'
)
if data.shape != labels.shape:
raise ValueError('Incompatible data and labels shapes.')
data = np.atleast_3d(img_as_float(data))[..., np.newaxis]
else:
if data.ndim not in (3, 4):
raise ValueError(
'For multichannel input, data must have 3 or 4 ' 'dimensions.'
)
if data.shape[:-1] != labels.shape:
raise ValueError('Incompatible data and labels shapes.')
data = img_as_float(data)
if data.ndim == 3: # 2D multispectral, needs singleton in 3rd axis
data = data[:, :, np.newaxis, :]
labels_shape = labels.shape
labels_dtype = labels.dtype
if copy:
labels = np.copy(labels)
(labels, nlabels, mask, inds_isolated_seeds, isolated_values) = _preprocess(labels)
if isolated_values is None:
# No non isolated zero valued areas in labels were
# found. Returning provided labels.
if return_full_prob:
# Return the concatenation of the masks of each unique label
return np.concatenate(
[np.atleast_3d(labels == lab) for lab in np.unique(labels) if lab > 0],
axis=-1,
)
return labels
# Build the linear system (lap_sparse, B)
lap_sparse, B = _build_linear_system(
data, spacing, labels, nlabels, mask, beta, multichannel
)
# Solve the linear system lap_sparse X = B
# where X[i, j] is the probability that a marker of label i arrives
# first at pixel j by anisotropic diffusion.
X = _solve_linear_system(lap_sparse, B, tol, mode)
if X.min() < -prob_tol or X.max() > 1 + prob_tol:
warn(
'The probability range is outside [0, 1] given the tolerance '
'`prob_tol`. Consider decreasing `beta` and/or decreasing '
'`tol`.'
)
# Build the output according to return_full_prob value
# Put back labels of isolated seeds
labels[inds_isolated_seeds] = isolated_values
labels = labels.reshape(labels_shape)
mask = labels == 0
mask[inds_isolated_seeds] = False
if return_full_prob:
out = np.zeros((nlabels,) + labels_shape)
for lab, (label_prob, prob) in enumerate(zip(out, X), start=1):
label_prob[mask] = prob
label_prob[labels == lab] = 1
else:
X = np.argmax(X, axis=0) + 1
out = labels.astype(labels_dtype)
out[mask] = X
return out

View File

@@ -0,0 +1,449 @@
import math
from collections.abc import Iterable
from warnings import warn
import numpy as np
from numpy import random
from scipy.cluster.vq import kmeans2
from scipy.spatial.distance import pdist, squareform
from .._shared import utils
from .._shared.filters import gaussian
from ..color import rgb2lab
from ..util import img_as_float, regular_grid
from ._slic import _enforce_label_connectivity_cython, _slic_cython
def _get_mask_centroids(mask, n_centroids, multichannel):
"""Find regularly spaced centroids on a mask.
Parameters
----------
mask : 3D ndarray
The mask within which the centroids must be positioned.
n_centroids : int
The number of centroids to be returned.
Returns
-------
centroids : 2D ndarray
The coordinates of the centroids with shape (n_centroids, 3).
steps : 1D ndarray
The approximate distance between two seeds in all dimensions.
"""
# Get tight ROI around the mask to optimize
coord = np.array(np.nonzero(mask), dtype=float).T
# Fix random seed to ensure repeatability
# Keep old-style RandomState here as expected results in tests depend on it
rng = random.RandomState(123)
# select n_centroids randomly distributed points from within the mask
idx_full = np.arange(len(coord), dtype=int)
idx = np.sort(rng.choice(idx_full, min(n_centroids, len(coord)), replace=False))
# To save time, when n_centroids << len(coords), use only a subset of the
# coordinates when calling k-means. Rather than the full set of coords,
# we will use a substantially larger subset than n_centroids. Here we
# somewhat arbitrarily choose dense_factor=10 to make the samples
# 10 times closer together along each axis than the n_centroids samples.
dense_factor = 10
ndim_spatial = mask.ndim - 1 if multichannel else mask.ndim
n_dense = int((dense_factor**ndim_spatial) * n_centroids)
if len(coord) > n_dense:
# subset of points to use for the k-means calculation
# (much denser than idx, but less than the full set)
idx_dense = np.sort(rng.choice(idx_full, n_dense, replace=False))
else:
idx_dense = Ellipsis
centroids, _ = kmeans2(coord[idx_dense], coord[idx], iter=5)
# Compute the minimum distance of each centroid to the others
dist = squareform(pdist(centroids))
np.fill_diagonal(dist, np.inf)
closest_pts = dist.argmin(-1)
steps = abs(centroids - centroids[closest_pts, :]).mean(0)
return centroids, steps
def _get_grid_centroids(image, n_centroids):
"""Find regularly spaced centroids on the image.
Parameters
----------
image : 2D, 3D or 4D ndarray
Input image, which can be 2D or 3D, and grayscale or
multichannel.
n_centroids : int
The (approximate) number of centroids to be returned.
Returns
-------
centroids : 2D ndarray
The coordinates of the centroids with shape (~n_centroids, 3).
steps : 1D ndarray
The approximate distance between two seeds in all dimensions.
"""
d, h, w = image.shape[:3]
grid_z, grid_y, grid_x = np.mgrid[:d, :h, :w]
slices = regular_grid(image.shape[:3], n_centroids)
centroids_z = grid_z[slices].ravel()[..., np.newaxis]
centroids_y = grid_y[slices].ravel()[..., np.newaxis]
centroids_x = grid_x[slices].ravel()[..., np.newaxis]
centroids = np.concatenate([centroids_z, centroids_y, centroids_x], axis=-1)
steps = np.asarray([float(s.step) if s.step is not None else 1.0 for s in slices])
return centroids, steps
@utils.channel_as_last_axis(multichannel_output=False)
def slic(
image,
n_segments=100,
compactness=10.0,
max_num_iter=10,
sigma=0,
spacing=None,
convert2lab=None,
enforce_connectivity=True,
min_size_factor=0.5,
max_size_factor=3,
slic_zero=False,
start_label=1,
mask=None,
*,
channel_axis=-1,
):
"""Segments image using k-means clustering in Color-(x,y,z) space.
Parameters
----------
image : (M, N[, P][, C]) ndarray
Input image. Can be 2D or 3D, and grayscale or multichannel
(see `channel_axis` parameter).
Input image must either be NaN-free or the NaN's must be masked out.
n_segments : int, optional
The (approximate) number of labels in the segmented output image.
compactness : float, optional
Balances color proximity and space proximity. Higher values give
more weight to space proximity, making superpixel shapes more
square/cubic. In SLICO mode, this is the initial compactness.
This parameter depends strongly on image contrast and on the
shapes of objects in the image. We recommend exploring possible
values on a log scale, e.g., 0.01, 0.1, 1, 10, 100, before
refining around a chosen value.
max_num_iter : int, optional
Maximum number of iterations of k-means.
sigma : float or array-like of floats, optional
Width of Gaussian smoothing kernel for pre-processing for each
dimension of the image. The same sigma is applied to each dimension in
case of a scalar value. Zero means no smoothing.
Note that `sigma` is automatically scaled if it is scalar and
if a manual voxel spacing is provided (see Notes section). If
sigma is array-like, its size must match ``image``'s number
of spatial dimensions.
spacing : array-like of floats, optional
The voxel spacing along each spatial dimension. By default,
`slic` assumes uniform spacing (same voxel resolution along
each spatial dimension).
This parameter controls the weights of the distances along the
spatial dimensions during k-means clustering.
convert2lab : bool, optional
Whether the input should be converted to Lab colorspace prior to
segmentation. The input image *must* be RGB. Highly recommended.
This option defaults to ``True`` when ``channel_axis` is not None *and*
``image.shape[-1] == 3``.
enforce_connectivity : bool, optional
Whether the generated segments are connected or not
min_size_factor : float, optional
Proportion of the minimum segment size to be removed with respect
to the supposed segment size ```depth*width*height/n_segments```
max_size_factor : float, optional
Proportion of the maximum connected segment size. A value of 3 works
in most of the cases.
slic_zero : bool, optional
Run SLIC-zero, the zero-parameter mode of SLIC. [2]_
start_label : int, optional
The labels' index start. Should be 0 or 1.
.. versionadded:: 0.17
``start_label`` was introduced in 0.17
mask : ndarray, optional
If provided, superpixels are computed only where mask is True,
and seed points are homogeneously distributed over the mask
using a k-means clustering strategy. Mask number of dimensions
must be equal to image number of spatial dimensions.
.. versionadded:: 0.17
``mask`` was introduced in 0.17
channel_axis : int or None, optional
If None, the image is assumed to be a grayscale (single channel) image.
Otherwise, this parameter indicates which axis of the array corresponds
to channels.
.. versionadded:: 0.19
``channel_axis`` was added in 0.19.
Returns
-------
labels : 2D or 3D array
Integer mask indicating segment labels.
Raises
------
ValueError
If ``convert2lab`` is set to ``True`` but the last array
dimension is not of length 3.
ValueError
If ``start_label`` is not 0 or 1.
ValueError
If ``image`` contains unmasked NaN values.
ValueError
If ``image`` contains unmasked infinite values.
ValueError
If ``image`` is 2D but ``channel_axis`` is -1 (the default).
Notes
-----
* If `sigma > 0`, the image is smoothed using a Gaussian kernel prior to
segmentation.
* If `sigma` is scalar and `spacing` is provided, the kernel width is
divided along each dimension by the spacing. For example, if ``sigma=1``
and ``spacing=[5, 1, 1]``, the effective `sigma` is ``[0.2, 1, 1]``. This
ensures sensible smoothing for anisotropic images.
* The image is rescaled to be in [0, 1] prior to processing (masked
values are ignored).
* Images of shape (M, N, 3) are interpreted as 2D RGB images by default. To
interpret them as 3D with the last dimension having length 3, use
`channel_axis=None`.
* `start_label` is introduced to handle the issue [4]_. Label indexing
starts at 1 by default.
References
----------
.. [1] Radhakrishna Achanta, Appu Shaji, Kevin Smith, Aurelien Lucchi,
Pascal Fua, and Sabine Süsstrunk, SLIC Superpixels Compared to
State-of-the-art Superpixel Methods, TPAMI, May 2012.
:DOI:`10.1109/TPAMI.2012.120`
.. [2] https://www.epfl.ch/labs/ivrl/research/slic-superpixels/#SLICO
.. [3] Irving, Benjamin. "maskSLIC: regional superpixel generation with
application to local pathology characterisation in medical images.",
2016, :arXiv:`1606.09518`
.. [4] https://github.com/scikit-image/scikit-image/issues/3722
Examples
--------
>>> from skimage.segmentation import slic
>>> from skimage.data import astronaut
>>> img = astronaut()
>>> segments = slic(img, n_segments=100, compactness=10)
Increasing the compactness parameter yields more square regions:
>>> segments = slic(img, n_segments=100, compactness=20)
"""
if image.ndim == 2 and channel_axis is not None:
raise ValueError(
f"channel_axis={channel_axis} indicates multichannel, which is not "
"supported for a two-dimensional image; use channel_axis=None if "
"the image is grayscale"
)
image = img_as_float(image)
float_dtype = utils._supported_float_type(image.dtype)
# copy=True so subsequent in-place operations do not modify the
# function input
image = image.astype(float_dtype, copy=True)
if mask is not None:
# Create masked_image to rescale while ignoring masked values
mask = np.ascontiguousarray(mask, dtype=bool)
if channel_axis is not None:
mask_ = np.expand_dims(mask, axis=channel_axis)
mask_ = np.broadcast_to(mask_, image.shape)
else:
mask_ = mask
image_values = image[mask_]
else:
image_values = image
# Rescale image to [0, 1] to make choice of compactness insensitive to
# input image scale.
imin = image_values.min()
imax = image_values.max()
if np.isnan(imin):
raise ValueError("unmasked NaN values in image are not supported")
if np.isinf(imin) or np.isinf(imax):
raise ValueError("unmasked infinite values in image are not supported")
image -= imin
if imax != imin:
image /= imax - imin
use_mask = mask is not None
dtype = image.dtype
is_2d = False
multichannel = channel_axis is not None
if image.ndim == 2:
# 2D grayscale image
image = image[np.newaxis, ..., np.newaxis]
is_2d = True
elif image.ndim == 3 and multichannel:
# Make 2D multichannel image 3D with depth = 1
image = image[np.newaxis, ...]
is_2d = True
elif image.ndim == 3 and not multichannel:
# Add channel as single last dimension
image = image[..., np.newaxis]
if multichannel and (convert2lab or convert2lab is None):
if image.shape[channel_axis] != 3 and convert2lab:
raise ValueError("Lab colorspace conversion requires a RGB image.")
elif image.shape[channel_axis] == 3:
image = rgb2lab(image)
if start_label not in [0, 1]:
raise ValueError("start_label should be 0 or 1.")
# initialize cluster centroids for desired number of segments
update_centroids = False
if use_mask:
mask = mask.view('uint8')
if mask.ndim == 2:
mask = np.ascontiguousarray(mask[np.newaxis, ...])
if mask.shape != image.shape[:3]:
raise ValueError("image and mask should have the same shape.")
centroids, steps = _get_mask_centroids(mask, n_segments, multichannel)
update_centroids = True
else:
centroids, steps = _get_grid_centroids(image, n_segments)
if spacing is None:
spacing = np.ones(3, dtype=dtype)
elif isinstance(spacing, Iterable):
spacing = np.asarray(spacing, dtype=dtype)
if is_2d:
if spacing.size != 2:
if spacing.size == 3:
warn(
"Input image is 2D: spacing number of "
"elements must be 2. In the future, a ValueError "
"will be raised.",
FutureWarning,
stacklevel=2,
)
else:
raise ValueError(
f"Input image is 2D, but spacing has "
f"{spacing.size} elements (expected 2)."
)
else:
spacing = np.insert(spacing, 0, 1)
elif spacing.size != 3:
raise ValueError(
f"Input image is 3D, but spacing has "
f"{spacing.size} elements (expected 3)."
)
spacing = np.ascontiguousarray(spacing, dtype=dtype)
else:
raise TypeError("spacing must be None or iterable.")
if np.isscalar(sigma):
sigma = np.array([sigma, sigma, sigma], dtype=dtype)
sigma /= spacing
elif isinstance(sigma, Iterable):
sigma = np.asarray(sigma, dtype=dtype)
if is_2d:
if sigma.size != 2:
if spacing.size == 3:
warn(
"Input image is 2D: sigma number of "
"elements must be 2. In the future, a ValueError "
"will be raised.",
FutureWarning,
stacklevel=2,
)
else:
raise ValueError(
f"Input image is 2D, but sigma has "
f"{sigma.size} elements (expected 2)."
)
else:
sigma = np.insert(sigma, 0, 0)
elif sigma.size != 3:
raise ValueError(
f"Input image is 3D, but sigma has "
f"{sigma.size} elements (expected 3)."
)
if (sigma > 0).any():
# add zero smoothing for channel dimension
sigma = list(sigma) + [0]
image = gaussian(image, sigma=sigma, mode='reflect')
n_centroids = centroids.shape[0]
segments = np.ascontiguousarray(
np.concatenate([centroids, np.zeros((n_centroids, image.shape[3]))], axis=-1),
dtype=dtype,
)
# Scaling of ratio in the same way as in the SLIC paper so the
# values have the same meaning
step = max(steps)
ratio = 1.0 / compactness
image = np.ascontiguousarray(image * ratio, dtype=dtype)
if update_centroids:
# Step 2 of the algorithm [3]_
_slic_cython(
image,
mask,
segments,
step,
max_num_iter,
spacing,
slic_zero,
ignore_color=True,
start_label=start_label,
)
labels = _slic_cython(
image,
mask,
segments,
step,
max_num_iter,
spacing,
slic_zero,
ignore_color=False,
start_label=start_label,
)
if enforce_connectivity:
if use_mask:
segment_size = mask.sum() / n_centroids
else:
segment_size = math.prod(image.shape[:3]) / n_centroids
min_size = int(min_size_factor * segment_size)
max_size = int(max_size_factor * segment_size)
labels = _enforce_label_connectivity_cython(
labels, min_size, max_size, start_label=start_label
)
if is_2d:
labels = labels[0]
return labels

View File

@@ -0,0 +1,190 @@
import numpy as np
import pytest
from numpy.testing import assert_equal, assert_allclose
from skimage import data
from skimage._shared.utils import _supported_float_type
from skimage.color import rgb2gray
from skimage.filters import gaussian
from skimage.segmentation import active_contour
@pytest.mark.parametrize('dtype', [np.float16, np.float32, np.float64])
def test_periodic_reference(dtype):
img = data.astronaut()
img = rgb2gray(img)
s = np.linspace(0, 2 * np.pi, 400)
r = 100 + 100 * np.sin(s)
c = 220 + 100 * np.cos(s)
init = np.array([r, c]).T
img_smooth = gaussian(img, sigma=3, preserve_range=False).astype(dtype, copy=False)
snake = active_contour(
img_smooth, init, alpha=0.015, beta=10, w_line=0, w_edge=1, gamma=0.001
)
assert snake.dtype == _supported_float_type(dtype)
refr = [98, 99, 100, 101, 102, 103, 104, 105, 106, 108]
refc = [299, 298, 298, 298, 298, 297, 297, 296, 296, 295]
assert_equal(np.array(snake[:10, 0], dtype=np.int32), refr)
assert_equal(np.array(snake[:10, 1], dtype=np.int32), refc)
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_fixed_reference(dtype):
img = data.text()
r = np.linspace(136, 50, 100)
c = np.linspace(5, 424, 100)
init = np.array([r, c]).T
image_smooth = gaussian(img, sigma=1, preserve_range=False).astype(
dtype, copy=False
)
snake = active_contour(
image_smooth,
init,
boundary_condition='fixed',
alpha=0.1,
beta=1.0,
w_line=-5,
w_edge=0,
gamma=0.1,
)
assert snake.dtype == _supported_float_type(dtype)
refr = [136, 135, 134, 133, 132, 131, 129, 128, 127, 125]
refc = [5, 9, 13, 17, 21, 25, 30, 34, 38, 42]
assert_equal(np.array(snake[:10, 0], dtype=np.int32), refr)
assert_equal(np.array(snake[:10, 1], dtype=np.int32), refc)
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_free_reference(dtype):
img = data.text()
r = np.linspace(70, 40, 100)
c = np.linspace(5, 424, 100)
init = np.array([r, c]).T
img_smooth = gaussian(img, sigma=3, preserve_range=False).astype(dtype, copy=False)
snake = active_contour(
img_smooth,
init,
boundary_condition='free',
alpha=0.1,
beta=1.0,
w_line=-5,
w_edge=0,
gamma=0.1,
)
assert snake.dtype == _supported_float_type(dtype)
refr = [76, 76, 75, 74, 73, 72, 71, 70, 69, 69]
refc = [10, 13, 16, 19, 23, 26, 29, 32, 36, 39]
assert_equal(np.array(snake[:10, 0], dtype=np.int32), refr)
assert_equal(np.array(snake[:10, 1], dtype=np.int32), refc)
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_RGB(dtype):
img = gaussian(data.text(), sigma=1, preserve_range=False)
imgR = np.zeros((img.shape[0], img.shape[1], 3), dtype=dtype)
imgG = np.zeros((img.shape[0], img.shape[1], 3), dtype=dtype)
imgRGB = np.zeros((img.shape[0], img.shape[1], 3), dtype=dtype)
imgR[:, :, 0] = img
imgG[:, :, 1] = img
imgRGB[:, :, :] = img[:, :, None]
r = np.linspace(136, 50, 100)
c = np.linspace(5, 424, 100)
init = np.array([r, c]).T
snake = active_contour(
imgR,
init,
boundary_condition='fixed',
alpha=0.1,
beta=1.0,
w_line=-5,
w_edge=0,
gamma=0.1,
)
float_dtype = _supported_float_type(dtype)
assert snake.dtype == float_dtype
refr = [136, 135, 134, 133, 132, 131, 129, 128, 127, 125]
refc = [5, 9, 13, 17, 21, 25, 30, 34, 38, 42]
assert_equal(np.array(snake[:10, 0], dtype=np.int32), refr)
assert_equal(np.array(snake[:10, 1], dtype=np.int32), refc)
snake = active_contour(
imgG,
init,
boundary_condition='fixed',
alpha=0.1,
beta=1.0,
w_line=-5,
w_edge=0,
gamma=0.1,
)
assert snake.dtype == float_dtype
assert_equal(np.array(snake[:10, 0], dtype=np.int32), refr)
assert_equal(np.array(snake[:10, 1], dtype=np.int32), refc)
snake = active_contour(
imgRGB,
init,
boundary_condition='fixed',
alpha=0.1,
beta=1.0,
w_line=-5 / 3.0,
w_edge=0,
gamma=0.1,
)
assert snake.dtype == float_dtype
assert_equal(np.array(snake[:10, 0], dtype=np.int32), refr)
assert_equal(np.array(snake[:10, 1], dtype=np.int32), refc)
def test_end_points():
img = data.astronaut()
img = rgb2gray(img)
s = np.linspace(0, 2 * np.pi, 400)
r = 100 + 100 * np.sin(s)
c = 220 + 100 * np.cos(s)
init = np.array([r, c]).T
snake = active_contour(
gaussian(img, sigma=3),
init,
boundary_condition='periodic',
alpha=0.015,
beta=10,
w_line=0,
w_edge=1,
gamma=0.001,
max_num_iter=100,
)
assert np.sum(np.abs(snake[0, :] - snake[-1, :])) < 2
snake = active_contour(
gaussian(img, sigma=3),
init,
boundary_condition='free',
alpha=0.015,
beta=10,
w_line=0,
w_edge=1,
gamma=0.001,
max_num_iter=100,
)
assert np.sum(np.abs(snake[0, :] - snake[-1, :])) > 2
snake = active_contour(
gaussian(img, sigma=3),
init,
boundary_condition='fixed',
alpha=0.015,
beta=10,
w_line=0,
w_edge=1,
gamma=0.001,
max_num_iter=100,
)
assert_allclose(snake[0, :], [r[0], c[0]], atol=1e-5)
def test_bad_input():
img = np.zeros((10, 10))
r = np.linspace(136, 50, 100)
c = np.linspace(5, 424, 100)
init = np.array([r, c]).T
with pytest.raises(ValueError):
active_contour(img, init, boundary_condition='wrong')
with pytest.raises(ValueError):
active_contour(img, init, max_num_iter=-15)

View File

@@ -0,0 +1,159 @@
import numpy as np
import pytest
from numpy.testing import assert_array_equal, assert_allclose
from skimage._shared.utils import _supported_float_type
from skimage.segmentation import find_boundaries, mark_boundaries
white = (1, 1, 1)
def test_find_boundaries():
image = np.zeros((10, 10), dtype=np.uint8)
image[2:7, 2:7] = 1
ref = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
result = find_boundaries(image)
assert_array_equal(result, ref)
def test_find_boundaries_bool():
image = np.zeros((5, 5), dtype=bool)
image[2:5, 2:5] = True
ref = np.array(
[
[False, False, False, False, False],
[False, False, True, True, True],
[False, True, True, True, True],
[False, True, True, False, False],
[False, True, True, False, False],
],
dtype=bool,
)
result = find_boundaries(image)
assert_array_equal(result, ref)
@pytest.mark.parametrize('dtype', [np.uint8, np.float16, np.float32, np.float64])
def test_mark_boundaries(dtype):
image = np.zeros((10, 10), dtype=dtype)
label_image = np.zeros((10, 10), dtype=np.uint8)
label_image[2:7, 2:7] = 1
ref = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
marked = mark_boundaries(image, label_image, color=white, mode='thick')
assert marked.dtype == _supported_float_type(dtype)
result = np.mean(marked, axis=-1)
assert_array_equal(result, ref)
ref = np.array(
[
[0, 2, 2, 2, 2, 2, 2, 2, 0, 0],
[2, 2, 1, 1, 1, 1, 1, 2, 2, 0],
[2, 1, 1, 1, 1, 1, 1, 1, 2, 0],
[2, 1, 1, 2, 2, 2, 1, 1, 2, 0],
[2, 1, 1, 2, 0, 2, 1, 1, 2, 0],
[2, 1, 1, 2, 2, 2, 1, 1, 2, 0],
[2, 1, 1, 1, 1, 1, 1, 1, 2, 0],
[2, 2, 1, 1, 1, 1, 1, 2, 2, 0],
[0, 2, 2, 2, 2, 2, 2, 2, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
marked = mark_boundaries(
image, label_image, color=white, outline_color=(2, 2, 2), mode='thick'
)
result = np.mean(marked, axis=-1)
assert_array_equal(result, ref)
def test_mark_boundaries_bool():
image = np.zeros((10, 10), dtype=bool)
label_image = np.zeros((10, 10), dtype=np.uint8)
label_image[2:7, 2:7] = 1
ref = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 0, 0, 0, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
marked = mark_boundaries(image, label_image, color=white, mode='thick')
result = np.mean(marked, axis=-1)
assert_array_equal(result, ref)
@pytest.mark.parametrize('dtype', [np.float16, np.float32, np.float64])
def test_mark_boundaries_subpixel(dtype):
labels = np.array(
[[0, 0, 0, 0], [0, 0, 5, 0], [0, 1, 5, 0], [0, 0, 5, 0], [0, 0, 0, 0]],
dtype=np.uint8,
)
np.random.seed(0)
image = np.round(np.random.rand(*labels.shape), 2)
image = image.astype(dtype, copy=False)
marked = mark_boundaries(image, labels, color=white, mode='subpixel')
assert marked.dtype == _supported_float_type(dtype)
marked_proj = np.round(np.mean(marked, axis=-1), 2)
ref_result = np.array(
[
[0.55, 0.63, 0.72, 0.69, 0.6, 0.55, 0.54],
[0.45, 0.58, 0.72, 1.0, 1.0, 1.0, 0.69],
[0.42, 0.54, 0.65, 1.0, 0.44, 1.0, 0.89],
[0.69, 1.0, 1.0, 1.0, 0.69, 1.0, 0.83],
[0.96, 1.0, 0.38, 1.0, 0.79, 1.0, 0.53],
[0.89, 1.0, 1.0, 1.0, 0.38, 1.0, 0.16],
[0.57, 0.78, 0.93, 1.0, 0.07, 1.0, 0.09],
[0.2, 0.52, 0.92, 1.0, 1.0, 1.0, 0.54],
[0.02, 0.35, 0.83, 0.9, 0.78, 0.81, 0.87],
]
)
assert_allclose(marked_proj, ref_result, atol=0.01)
@pytest.mark.parametrize('mode', ['thick', 'inner', 'outer', 'subpixel'])
def test_boundaries_constant_image(mode):
"""A constant-valued image has not boundaries."""
ones = np.ones((8, 8), dtype=int)
b = find_boundaries(ones, mode=mode)
assert np.all(b == 0)

View File

@@ -0,0 +1,102 @@
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from skimage._shared.utils import _supported_float_type
from skimage.segmentation import chan_vese
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_chan_vese_flat_level_set(dtype):
# because the algorithm evolves the level set around the
# zero-level, it the level-set has no zero level, the algorithm
# will not produce results in theory. However, since a continuous
# approximation of the delta function is used, the algorithm
# still affects the entirety of the level-set. Therefore with
# infinite time, the segmentation will still converge.
img = np.zeros((10, 10), dtype=dtype)
img[3:6, 3:6] = 1
ls = np.full((10, 10), 1000, dtype=dtype)
result = chan_vese(img, mu=0.0, tol=1e-3, init_level_set=ls)
assert_array_equal(result.astype(float), np.ones((10, 10)))
result = chan_vese(img, mu=0.0, tol=1e-3, init_level_set=-ls)
assert_array_equal(result.astype(float), np.zeros((10, 10)))
def test_chan_vese_small_disk_level_set():
img = np.zeros((10, 10))
img[3:6, 3:6] = 1
result = chan_vese(img, mu=0.0, tol=1e-3, init_level_set="small disk")
assert_array_equal(result.astype(float), img)
def test_chan_vese_simple_shape():
img = np.zeros((10, 10))
img[3:6, 3:6] = 1
result = chan_vese(img, mu=0.0, tol=1e-8).astype(float)
assert_array_equal(result, img)
@pytest.mark.parametrize('dtype', [np.uint8, np.float16, np.float32, np.float64])
def test_chan_vese_extended_output(dtype):
img = np.zeros((10, 10), dtype=dtype)
img[3:6, 3:6] = 1
result = chan_vese(img, mu=0.0, tol=1e-8, extended_output=True)
float_dtype = _supported_float_type(dtype)
assert result[1].dtype == float_dtype
assert all(arr.dtype == float_dtype for arr in result[2])
assert_array_equal(len(result), 3)
def test_chan_vese_remove_noise():
ref = np.zeros((10, 10))
ref[1:6, 1:6] = np.array(
[
[0, 1, 1, 1, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[0, 1, 1, 1, 0],
]
)
img = ref.copy()
img[8, 3] = 1
result = chan_vese(
img, mu=0.3, tol=1e-3, max_num_iter=100, dt=10, init_level_set="disk"
).astype(float)
assert_array_equal(result, ref)
def test_chan_vese_incorrect_image_type():
img = np.zeros((10, 10, 3))
ls = np.zeros((10, 9))
with pytest.raises(ValueError):
chan_vese(img, mu=0.0, init_level_set=ls)
def test_chan_vese_gap_closing():
ref = np.zeros((20, 20))
ref[8:15, :] = np.ones((7, 20))
img = ref.copy()
img[:, 6] = np.zeros(20)
result = chan_vese(
img, mu=0.7, tol=1e-3, max_num_iter=1000, dt=1000, init_level_set="disk"
).astype(float)
assert_array_equal(result, ref)
def test_chan_vese_incorrect_level_set():
img = np.zeros((10, 10))
ls = np.zeros((10, 9))
with pytest.raises(ValueError):
chan_vese(img, mu=0.0, init_level_set=ls)
with pytest.raises(ValueError):
chan_vese(img, mu=0.0, init_level_set="a")
def test_chan_vese_blank_image():
img = np.zeros((10, 10))
level_set = np.random.rand(10, 10)
ref = level_set > 0
result = chan_vese(img, mu=0.0, tol=0.0, init_level_set=level_set)
assert_array_equal(result, ref)

View File

@@ -0,0 +1,180 @@
import numpy as np
from skimage.segmentation import clear_border
from skimage._shared.testing import assert_array_equal, assert_
def test_clear_border():
image = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 1, 0],
[1, 1, 0, 0, 1, 0, 0, 1, 0],
[1, 1, 0, 1, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
# test default case
result = clear_border(image.copy())
ref = image.copy()
ref[1:3, 0:2] = 0
ref[0:2, -2] = 0
assert_array_equal(result, ref)
# test buffer
result = clear_border(image.copy(), 1)
assert_array_equal(result, np.zeros(result.shape))
# test background value
result = clear_border(image.copy(), buffer_size=1, bgval=2)
assert_array_equal(result, 2 * np.ones_like(image))
# test mask
mask = np.array(
[
[0, 0, 1, 1, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1],
]
).astype(bool)
result = clear_border(image.copy(), mask=mask)
ref = image.copy()
ref[1:3, 0:2] = 0
assert_array_equal(result, ref)
def test_clear_border_3d():
image = np.array(
[
[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 0]],
[[0, 0, 0, 0], [0, 1, 1, 0], [0, 0, 1, 0], [0, 0, 0, 0]],
[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
]
)
# test default case
result = clear_border(image.copy())
ref = image.copy()
ref[0, 3, 0] = 0
assert_array_equal(result, ref)
# test buffer
result = clear_border(image.copy(), 1)
assert_array_equal(result, np.zeros(result.shape))
# test background value
result = clear_border(image.copy(), buffer_size=1, bgval=2)
assert_array_equal(result, 2 * np.ones_like(image))
def test_clear_border_non_binary():
image = np.array(
[[1, 2, 3, 1, 2], [3, 3, 5, 4, 2], [3, 4, 5, 4, 2], [3, 3, 2, 1, 2]]
)
result = clear_border(image)
expected = np.array(
[[0, 0, 0, 0, 0], [0, 0, 5, 4, 0], [0, 4, 5, 4, 0], [0, 0, 0, 0, 0]]
)
assert_array_equal(result, expected)
assert_(not np.all(image == result))
def test_clear_border_non_binary_3d():
image3d = np.array(
[
[[1, 2, 3, 1, 2], [3, 3, 3, 4, 2], [3, 4, 3, 4, 2], [3, 3, 2, 1, 2]],
[[1, 2, 3, 1, 2], [3, 3, 5, 4, 2], [3, 4, 5, 4, 2], [3, 3, 2, 1, 2]],
[[1, 2, 3, 1, 2], [3, 3, 3, 4, 2], [3, 4, 3, 4, 2], [3, 3, 2, 1, 2]],
]
)
result = clear_border(image3d)
expected = np.array(
[
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0], [0, 0, 5, 0, 0], [0, 0, 5, 0, 0], [0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
]
)
assert_array_equal(result, expected)
assert_(not np.all(image3d == result))
def test_clear_border_non_binary_inplace():
image = np.array(
[[1, 2, 3, 1, 2], [3, 3, 5, 4, 2], [3, 4, 5, 4, 2], [3, 3, 2, 1, 2]]
)
result = clear_border(image, out=image)
expected = np.array(
[[0, 0, 0, 0, 0], [0, 0, 5, 4, 0], [0, 4, 5, 4, 0], [0, 0, 0, 0, 0]]
)
assert_array_equal(result, expected)
assert_array_equal(image, result)
def test_clear_border_non_binary_inplace_3d():
image3d = np.array(
[
[[1, 2, 3, 1, 2], [3, 3, 3, 4, 2], [3, 4, 3, 4, 2], [3, 3, 2, 1, 2]],
[[1, 2, 3, 1, 2], [3, 3, 5, 4, 2], [3, 4, 5, 4, 2], [3, 3, 2, 1, 2]],
[[1, 2, 3, 1, 2], [3, 3, 3, 4, 2], [3, 4, 3, 4, 2], [3, 3, 2, 1, 2]],
]
)
result = clear_border(image3d, out=image3d)
expected = np.array(
[
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0], [0, 0, 5, 0, 0], [0, 0, 5, 0, 0], [0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
]
)
assert_array_equal(result, expected)
assert_array_equal(image3d, result)
def test_clear_border_non_binary_out():
image = np.array(
[[1, 2, 3, 1, 2], [3, 3, 5, 4, 2], [3, 4, 5, 4, 2], [3, 3, 2, 1, 2]]
)
out = np.empty_like(image)
result = clear_border(image, out=out)
expected = np.array(
[[0, 0, 0, 0, 0], [0, 0, 5, 4, 0], [0, 4, 5, 4, 0], [0, 0, 0, 0, 0]]
)
assert_array_equal(result, expected)
assert_array_equal(out, result)
def test_clear_border_non_binary_out_3d():
image3d = np.array(
[
[[1, 2, 3, 1, 2], [3, 3, 3, 4, 2], [3, 4, 3, 4, 2], [3, 3, 2, 1, 2]],
[[1, 2, 3, 1, 2], [3, 3, 5, 4, 2], [3, 4, 5, 4, 2], [3, 3, 2, 1, 2]],
[[1, 2, 3, 1, 2], [3, 3, 3, 4, 2], [3, 4, 3, 4, 2], [3, 3, 2, 1, 2]],
]
)
out = np.empty_like(image3d)
result = clear_border(image3d, out=out)
expected = np.array(
[
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0], [0, 0, 5, 0, 0], [0, 0, 5, 0, 0], [0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
]
)
assert_array_equal(result, expected)
assert_array_equal(out, result)

View File

@@ -0,0 +1,182 @@
from scipy import ndimage as ndi
from skimage import data
import numpy as np
from skimage import measure
from skimage.segmentation._expand_labels import expand_labels
from skimage._shared import testing
from skimage._shared.testing import assert_array_equal
SAMPLE1D = np.array([0, 0, 4, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0])
SAMPLE1D_EXPANDED_3 = np.array([4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0])
# Some pixels are important edge cases with undefined behaviour:
# these are the pixels that are at the same distance from
# multiple labels. Ideally the label would be chosen at random
# to avoid bias, but as we are relying on the index map returned
# by the scipy.ndimage distance transform, what actually happens
# is determined by the upstream implementation of the distance
# tansform, thus we don't give any guarantees for the edge case pixels.
#
# Regardless, it seems prudent to have a test including an edge case
# so we can detect whether future upstream changes in scipy.ndimage
# modify the behaviour.
EDGECASE1D = np.array([0, 0, 4, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0])
EDGECASE1D_EXPANDED_3 = np.array([4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0])
SAMPLE2D = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 2, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
SAMPLE2D_EXPANDED_3 = np.array(
[
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 0],
[1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2],
[1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2],
[1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2],
[1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2],
[1, 1, 1, 1, 1, 0, 0, 2, 2, 2, 2],
[0, 0, 1, 0, 0, 0, 0, 2, 2, 2, 2],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0],
]
)
# non-integer expansion
SAMPLE2D_EXPANDED_1_5 = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 0, 0, 0, 2, 2, 2],
[1, 1, 1, 1, 0, 0, 0, 0, 2, 2, 2],
[0, 1, 1, 1, 0, 0, 0, 0, 2, 2, 2],
[0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
EDGECASE2D = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 2, 2, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 2, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
]
)
EDGECASE2D_EXPANDED_4 = np.array(
[
[1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 0],
[1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2],
[1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2],
[1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 0],
[1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 0],
]
)
SAMPLE3D = np.array(
[
[[0, 0, 0, 0], [0, 3, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
[[0, 0, 0, 0], [0, 3, 3, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
[[0, 0, 0, 0], [0, 3, 0, 0], [0, 0, 0, 0], [0, 0, 5, 0]],
[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 5, 0]],
]
)
SAMPLE3D_EXPANDED_2 = np.array(
[
[[3, 3, 3, 3], [3, 3, 3, 3], [3, 3, 3, 3], [0, 3, 5, 0]],
[[3, 3, 3, 3], [3, 3, 3, 3], [3, 3, 3, 3], [0, 5, 5, 5]],
[[3, 3, 3, 3], [3, 3, 3, 3], [3, 3, 5, 5], [5, 5, 5, 5]],
[[3, 3, 3, 0], [3, 3, 3, 0], [3, 3, 5, 5], [5, 5, 5, 5]],
]
)
SAMPLE3D_EXPAND_SPACING = np.array(
[
[[0, 3, 0, 0], [3, 3, 3, 0], [0, 3, 0, 0], [0, 0, 0, 0]],
[[0, 3, 3, 0], [3, 3, 3, 3], [0, 3, 3, 0], [0, 0, 0, 0]],
[[0, 3, 0, 0], [3, 3, 3, 0], [0, 3, 5, 0], [0, 5, 5, 5]],
[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 5, 0], [0, 5, 5, 5]],
]
)
SAMPLE_EDGECASE_BEHAVIOUR = np.array([[0, 1, 0, 0], [2, 0, 0, 0], [0, 3, 0, 0]])
@testing.parametrize(
"input_array, expected_output, expand_distance, spacing",
[
(SAMPLE1D, SAMPLE1D_EXPANDED_3, 3, 1),
(SAMPLE2D, SAMPLE2D_EXPANDED_3, 3, 1),
(SAMPLE2D, SAMPLE2D_EXPANDED_1_5, 1.5, 1),
(EDGECASE1D, EDGECASE1D_EXPANDED_3, 3, 1),
(EDGECASE2D, EDGECASE2D_EXPANDED_4, 4, 1),
(SAMPLE3D, SAMPLE3D_EXPANDED_2, 2, 1),
(SAMPLE3D, SAMPLE3D_EXPAND_SPACING, 1, [2, 1, 1]),
],
)
def test_expand_labels(input_array, expected_output, expand_distance, spacing):
expanded = expand_labels(input_array, expand_distance, spacing)
assert_array_equal(expanded, expected_output)
@testing.parametrize('ndim', [2, 3])
@testing.parametrize('distance', range(6))
def test_binary_blobs(ndim, distance):
"""Check some invariants with label expansion.
- New labels array should exactly contain the original labels array.
- Distance to old labels array within new labels should never exceed input
distance.
- Distance beyond the expanded labels should always exceed the input
distance.
"""
img = data.binary_blobs(length=64, blob_size_fraction=0.05, n_dim=ndim)
labels = measure.label(img)
expanded = expand_labels(labels, distance=distance)
original_mask = labels != 0
assert_array_equal(labels[original_mask], expanded[original_mask])
expanded_only_mask = (expanded - labels).astype(bool)
distance_map = ndi.distance_transform_edt(~original_mask)
expanded_distances = distance_map[expanded_only_mask]
if expanded_distances.size > 0:
assert np.all(expanded_distances <= distance)
beyond_expanded_distances = distance_map[~expanded.astype(bool)]
if beyond_expanded_distances.size > 0:
assert np.all(beyond_expanded_distances > distance)
def test_edge_case_behaviour():
"""Check edge case behavior to detect upstream changes
For edge cases where a pixel has the same distance to several regions,
lexicographical order seems to determine which region gets to expand
into this pixel given the current upstream behaviour in
scipy.ndimage.distance_map_edt.
As a result, we expect different results when transposing the array.
If this test fails, something has changed upstream.
"""
expanded = expand_labels(SAMPLE_EDGECASE_BEHAVIOUR, 1)
expanded_transpose = expand_labels(SAMPLE_EDGECASE_BEHAVIOUR.T, 1)
assert not np.all(expanded == expanded_transpose.T)

View File

@@ -0,0 +1,90 @@
import numpy as np
from skimage import data
from skimage.segmentation import felzenszwalb
from skimage._shared import testing
from skimage._shared.testing import (
assert_greater,
run_in_parallel,
assert_equal,
assert_array_equal,
assert_warns,
assert_no_warnings,
)
@run_in_parallel()
def test_grey():
# very weak tests.
img = np.zeros((20, 21))
img[:10, 10:] = 0.2
img[10:, :10] = 0.4
img[10:, 10:] = 0.6
seg = felzenszwalb(img, sigma=0)
# we expect 4 segments:
assert_equal(len(np.unique(seg)), 4)
# that mostly respect the 4 regions:
for i in range(4):
hist = np.histogram(img[seg == i], bins=[0, 0.1, 0.3, 0.5, 1])[0]
assert_greater(hist[i], 40)
def test_minsize():
# single-channel:
img = data.coins()[20:168, 0:128]
for min_size in np.arange(10, 100, 10):
segments = felzenszwalb(img, min_size=min_size, sigma=3)
counts = np.bincount(segments.ravel())
# actually want to test greater or equal.
assert_greater(counts.min() + 1, min_size)
# multi-channel:
coffee = data.coffee()[::4, ::4]
for min_size in np.arange(10, 100, 10):
segments = felzenszwalb(coffee, min_size=min_size, sigma=3)
counts = np.bincount(segments.ravel())
# actually want to test greater or equal.
assert_greater(counts.min() + 1, min_size)
@testing.parametrize('channel_axis', [0, -1])
def test_3D(channel_axis):
grey_img = np.zeros((10, 10))
rgb_img = np.zeros((10, 10, 3))
three_d_img = np.zeros((10, 10, 10))
rgb_img = np.moveaxis(rgb_img, -1, channel_axis)
with assert_no_warnings():
felzenszwalb(grey_img, channel_axis=-1)
felzenszwalb(grey_img, channel_axis=None)
felzenszwalb(rgb_img, channel_axis=channel_axis)
with assert_warns(RuntimeWarning):
felzenszwalb(three_d_img, channel_axis=channel_axis)
with testing.raises(ValueError):
felzenszwalb(rgb_img, channel_axis=None)
felzenszwalb(three_d_img, channel_axis=None)
def test_color():
# very weak tests.
img = np.zeros((20, 21, 3))
img[:10, :10, 0] = 1
img[10:, :10, 1] = 1
img[10:, 10:, 2] = 1
seg = felzenszwalb(img, sigma=0)
# we expect 4 segments:
assert_equal(len(np.unique(seg)), 4)
assert_array_equal(seg[:10, :10], 0)
assert_array_equal(seg[10:, :10], 2)
assert_array_equal(seg[:10, 10:], 1)
assert_array_equal(seg[10:, 10:], 3)
def test_merging():
# test region merging in the post-processing step
img = np.array([[0, 0.3], [0.7, 1]])
# With scale=0, only the post-processing is performed.
seg = felzenszwalb(img, scale=0, sigma=0, min_size=2)
# we expect 2 segments:
assert_equal(len(np.unique(seg)), 2)
assert_array_equal(seg[0, :], 0)
assert_array_equal(seg[1, :], 1)

View File

@@ -0,0 +1,218 @@
import numpy as np
from skimage.segmentation import join_segmentations, relabel_sequential
from skimage._shared import testing
from skimage._shared.testing import assert_array_equal
import pytest
def test_join_segmentations():
s1 = np.array([[0, 0, 1, 1], [0, 2, 1, 1], [2, 2, 2, 1]])
s2 = np.array([[0, 1, 1, 0], [0, 1, 1, 0], [0, 1, 1, 1]])
# test correct join
# NOTE: technically, equality to j_ref is not required, only that there
# is a one-to-one mapping between j and j_ref. I don't know of an easy way
# to check this (i.e. not as error-prone as the function being tested)
j = join_segmentations(s1, s2)
j_ref = np.array([[0, 1, 3, 2], [0, 5, 3, 2], [4, 5, 5, 3]])
assert_array_equal(j, j_ref)
# test correct mapping
j, m1, m2 = join_segmentations(s1, s2, return_mapping=True)
assert_array_equal(m1[j], s1)
assert_array_equal(m2[j], s2)
# test correct exception when arrays are different shapes
s3 = np.array([[0, 0, 1, 1], [0, 2, 2, 1]])
with testing.raises(ValueError):
join_segmentations(s1, s3)
def _check_maps(ar, ar_relab, fw, inv):
assert_array_equal(fw[ar], ar_relab)
assert_array_equal(inv[ar_relab], ar)
def test_relabel_sequential_offset1():
ar = np.array([1, 1, 5, 5, 8, 99, 42])
ar_relab, fw, inv = relabel_sequential(ar)
_check_maps(ar, ar_relab, fw, inv)
ar_relab_ref = np.array([1, 1, 2, 2, 3, 5, 4])
assert_array_equal(ar_relab, ar_relab_ref)
fw_ref = np.zeros(100, int)
fw_ref[1] = 1
fw_ref[5] = 2
fw_ref[8] = 3
fw_ref[42] = 4
fw_ref[99] = 5
assert_array_equal(fw, fw_ref)
inv_ref = np.array([0, 1, 5, 8, 42, 99])
assert_array_equal(inv, inv_ref)
def test_relabel_sequential_offset5():
ar = np.array([1, 1, 5, 5, 8, 99, 42])
ar_relab, fw, inv = relabel_sequential(ar, offset=5)
_check_maps(ar, ar_relab, fw, inv)
ar_relab_ref = np.array([5, 5, 6, 6, 7, 9, 8])
assert_array_equal(ar_relab, ar_relab_ref)
fw_ref = np.zeros(100, int)
fw_ref[1] = 5
fw_ref[5] = 6
fw_ref[8] = 7
fw_ref[42] = 8
fw_ref[99] = 9
assert_array_equal(fw, fw_ref)
inv_ref = np.array([0, 0, 0, 0, 0, 1, 5, 8, 42, 99])
assert_array_equal(inv, inv_ref)
def test_relabel_sequential_offset5_with0():
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0])
ar_relab, fw, inv = relabel_sequential(ar, offset=5)
_check_maps(ar, ar_relab, fw, inv)
ar_relab_ref = np.array([5, 5, 6, 6, 7, 9, 8, 0])
assert_array_equal(ar_relab, ar_relab_ref)
fw_ref = np.zeros(100, int)
fw_ref[1] = 5
fw_ref[5] = 6
fw_ref[8] = 7
fw_ref[42] = 8
fw_ref[99] = 9
assert_array_equal(fw, fw_ref)
inv_ref = np.array([0, 0, 0, 0, 0, 1, 5, 8, 42, 99])
assert_array_equal(inv, inv_ref)
def test_relabel_sequential_dtype():
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0], dtype=np.uint8)
ar_relab, fw, inv = relabel_sequential(ar, offset=5)
_check_maps(ar.astype(int), ar_relab, fw, inv)
ar_relab_ref = np.array([5, 5, 6, 6, 7, 9, 8, 0])
assert_array_equal(ar_relab, ar_relab_ref)
fw_ref = np.zeros(100, int)
fw_ref[1] = 5
fw_ref[5] = 6
fw_ref[8] = 7
fw_ref[42] = 8
fw_ref[99] = 9
assert_array_equal(fw, fw_ref)
inv_ref = np.array([0, 0, 0, 0, 0, 1, 5, 8, 42, 99])
assert_array_equal(inv, inv_ref)
def test_relabel_sequential_signed_overflow():
imax = np.iinfo(np.int32).max
labels = np.array([0, 1, 99, 42, 42], dtype=np.int32)
output, fw, inv = relabel_sequential(labels, offset=imax)
reference = np.array([0, imax, imax + 2, imax + 1, imax + 1], dtype=np.uint32)
assert_array_equal(output, reference)
assert output.dtype == reference.dtype
def test_very_large_labels():
imax = np.iinfo(np.int64).max
labels = np.array([0, 1, imax, 42, 42], dtype=np.int64)
output, fw, inv = relabel_sequential(labels, offset=imax)
assert np.max(output) == imax + 2
@pytest.mark.parametrize(
'dtype',
(
np.byte,
np.short,
np.intc,
int,
np.longlong,
np.ubyte,
np.ushort,
np.uintc,
np.uint,
np.ulonglong,
),
)
@pytest.mark.parametrize('data_already_sequential', (False, True))
def test_relabel_sequential_int_dtype_stability(data_already_sequential, dtype):
if data_already_sequential:
ar = np.array([1, 3, 0, 2, 5, 4], dtype=dtype)
else:
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0], dtype=dtype)
assert all(a.dtype == dtype for a in relabel_sequential(ar))
def test_relabel_sequential_int_dtype_overflow():
ar = np.array([1, 3, 0, 2, 5, 4], dtype=np.uint8)
offset = 254
ar_relab, fw, inv = relabel_sequential(ar, offset=offset)
_check_maps(ar, ar_relab, fw, inv)
assert all(a.dtype == np.uint16 for a in (ar_relab, fw))
assert inv.dtype == ar.dtype
ar_relab_ref = np.where(ar > 0, ar.astype(int) + offset - 1, 0)
assert_array_equal(ar_relab, ar_relab_ref)
def test_relabel_sequential_negative_values():
ar = np.array([1, 1, 5, -5, 8, 99, 42, 0])
with pytest.raises(ValueError):
relabel_sequential(ar)
@pytest.mark.parametrize('offset', (0, -3))
@pytest.mark.parametrize('data_already_sequential', (False, True))
def test_relabel_sequential_nonpositive_offset(data_already_sequential, offset):
if data_already_sequential:
ar = np.array([1, 3, 0, 2, 5, 4])
else:
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0])
with pytest.raises(ValueError):
relabel_sequential(ar, offset=offset)
@pytest.mark.parametrize('offset', (1, 5))
@pytest.mark.parametrize('with0', (False, True))
@pytest.mark.parametrize('input_starts_at_offset', (False, True))
def test_relabel_sequential_already_sequential(offset, with0, input_starts_at_offset):
if with0:
ar = np.array([1, 3, 0, 2, 5, 4])
else:
ar = np.array([1, 3, 2, 5, 4])
if input_starts_at_offset:
ar[ar > 0] += offset - 1
ar_relab, fw, inv = relabel_sequential(ar, offset=offset)
_check_maps(ar, ar_relab, fw, inv)
if input_starts_at_offset:
ar_relab_ref = ar
else:
ar_relab_ref = np.where(ar > 0, ar + offset - 1, 0)
assert_array_equal(ar_relab, ar_relab_ref)
def test_incorrect_input_dtype():
labels = np.array([0, 2, 2, 1, 1, 8], dtype=float)
with testing.raises(TypeError):
_ = relabel_sequential(labels)
def test_arraymap_call():
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0], dtype=np.intp)
relabeled, fw, inv = relabel_sequential(ar)
testing.assert_array_equal(relabeled, fw(ar))
testing.assert_array_equal(ar, inv(relabeled))
def test_arraymap_len():
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0], dtype=np.intp)
relabeled, fw, inv = relabel_sequential(ar)
assert len(fw) == 100
assert len(fw) == len(np.array(fw))
assert len(inv) == 6
assert len(inv) == len(np.array(inv))
def test_arraymap_set():
ar = np.array([1, 1, 5, 5, 8, 99, 42, 0], dtype=np.intp)
relabeled, fw, inv = relabel_sequential(ar)
fw[72] = 6
assert fw[72] == 6

View File

@@ -0,0 +1,152 @@
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from skimage.segmentation import (
disk_level_set,
inverse_gaussian_gradient,
morphological_chan_vese,
morphological_geodesic_active_contour,
)
def gaussian_blob():
coords = np.mgrid[-5:6, -5:6]
sqrdistances = (coords**2).sum(0)
return np.exp(-sqrdistances / 10)
def test_morphsnakes_incorrect_image_shape():
img = np.zeros((10, 10, 3))
ls = np.zeros((10, 9))
with pytest.raises(ValueError):
morphological_chan_vese(img, num_iter=1, init_level_set=ls)
with pytest.raises(ValueError):
morphological_geodesic_active_contour(img, num_iter=1, init_level_set=ls)
def test_morphsnakes_incorrect_ndim():
img = np.zeros((4, 4, 4, 4))
ls = np.zeros((4, 4, 4, 4))
with pytest.raises(ValueError):
morphological_chan_vese(img, num_iter=1, init_level_set=ls)
with pytest.raises(ValueError):
morphological_geodesic_active_contour(img, num_iter=1, init_level_set=ls)
def test_morphsnakes_black():
img = np.zeros((11, 11))
ls = disk_level_set(img.shape, center=(5, 5), radius=3)
ref_zeros = np.zeros(img.shape, dtype=np.int8)
ref_ones = np.ones(img.shape, dtype=np.int8)
acwe_ls = morphological_chan_vese(img, num_iter=6, init_level_set=ls)
assert_array_equal(acwe_ls, ref_zeros)
gac_ls = morphological_geodesic_active_contour(img, num_iter=6, init_level_set=ls)
assert_array_equal(gac_ls, ref_zeros)
gac_ls2 = morphological_geodesic_active_contour(
img, num_iter=6, init_level_set=ls, balloon=1, threshold=-1, smoothing=0
)
assert_array_equal(gac_ls2, ref_ones)
assert acwe_ls.dtype == gac_ls.dtype == gac_ls2.dtype == np.int8
def test_morphsnakes_simple_shape_chan_vese():
img = gaussian_blob()
ls1 = disk_level_set(img.shape, center=(5, 5), radius=3)
ls2 = disk_level_set(img.shape, center=(5, 5), radius=6)
acwe_ls1 = morphological_chan_vese(img, num_iter=10, init_level_set=ls1)
acwe_ls2 = morphological_chan_vese(img, num_iter=10, init_level_set=ls2)
assert_array_equal(acwe_ls1, acwe_ls2)
assert acwe_ls1.dtype == acwe_ls2.dtype == np.int8
def test_morphsnakes_simple_shape_geodesic_active_contour():
img = (disk_level_set((11, 11), center=(5, 5), radius=3.5)).astype(float)
gimg = inverse_gaussian_gradient(img, alpha=10.0, sigma=1.0)
ls = disk_level_set(img.shape, center=(5, 5), radius=6)
ref = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.int8,
)
gac_ls = morphological_geodesic_active_contour(
gimg, num_iter=10, init_level_set=ls, balloon=-1
)
assert_array_equal(gac_ls, ref)
assert gac_ls.dtype == np.int8
def test_init_level_sets():
image = np.zeros((6, 6))
checkerboard_ls = morphological_chan_vese(image, 0, 'checkerboard')
checkerboard_ref = np.array(
[
[0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 0],
],
dtype=np.int8,
)
disk_ls = morphological_geodesic_active_contour(image, 0, 'disk')
disk_ref = np.array(
[
[0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 0],
],
dtype=np.int8,
)
assert_array_equal(checkerboard_ls, checkerboard_ref)
assert_array_equal(disk_ls, disk_ref)
def test_morphsnakes_3d():
image = np.zeros((7, 7, 7))
evolution = []
def callback(x):
evolution.append(x.sum())
ls = morphological_chan_vese(image, 5, 'disk', iter_callback=callback)
# Check that the initial disk level set is correct
assert evolution[0] == 81
# Check that the final level set is correct
assert ls.sum() == 0
# Check that the contour is shrinking at every iteration
for v1, v2 in zip(evolution[:-1], evolution[1:]):
assert v1 >= v2

View File

@@ -0,0 +1,79 @@
import numpy as np
import pytest
from skimage.segmentation import quickshift
from skimage._shared import testing
from skimage._shared.testing import (
assert_greater,
run_in_parallel,
assert_equal,
assert_array_equal,
)
@run_in_parallel()
@testing.parametrize('dtype', [np.float32, np.float64])
def test_grey(dtype):
rng = np.random.default_rng(0)
img = np.zeros((20, 21))
img[:10, 10:] = 0.2
img[10:, :10] = 0.4
img[10:, 10:] = 0.6
img += 0.05 * rng.normal(size=img.shape)
img = img.astype(dtype, copy=False)
seg = quickshift(img, kernel_size=2, max_dist=3, rng=0, convert2lab=False, sigma=0)
quickshift(img, kernel_size=2, max_dist=3, rng=0, convert2lab=False, sigma=0)
# we expect 4 segments:
assert_equal(len(np.unique(seg)), 4)
# that mostly respect the 4 regions:
for i in range(4):
hist = np.histogram(img[seg == i], bins=[0, 0.1, 0.3, 0.5, 1])[0]
assert_greater(hist[i], 20)
@testing.parametrize('dtype', [np.float32, np.float64])
@testing.parametrize('channel_axis', [-3, -2, -1, 0, 1, 2])
def test_color(dtype, channel_axis):
rng = np.random.default_rng(583428449)
img = np.zeros((20, 21, 3))
img[:10, :10, 0] = 1
img[10:, :10, 1] = 1
img[10:, 10:, 2] = 1
img += 0.01 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
img = img.astype(dtype, copy=False)
img = np.moveaxis(img, source=-1, destination=channel_axis)
seg = quickshift(
img, rng=0, max_dist=30, kernel_size=10, sigma=0, channel_axis=channel_axis
)
# we expect 4 segments:
assert_equal(len(np.unique(seg)), 4)
assert_array_equal(seg[:10, :10], 1)
assert_array_equal(seg[10:, :10], 3)
assert_array_equal(seg[:10, 10:], 0)
assert_array_equal(seg[10:, 10:], 2)
seg2 = quickshift(
img,
kernel_size=1,
max_dist=2,
rng=0,
convert2lab=False,
sigma=0,
channel_axis=channel_axis,
)
# very oversegmented:
assert len(np.unique(seg2)) > 10
# still don't cross lines
assert (seg2[9, :] != seg2[10, :]).all()
assert (seg2[:, 9] != seg2[:, 10]).all()
def test_convert2lab_not_rgb():
img = np.zeros((20, 21, 2))
with pytest.raises(
ValueError, match="Only RGB images can be converted to Lab space"
):
quickshift(img, convert2lab=True)

View File

@@ -0,0 +1,523 @@
import numpy as np
from skimage._shared import testing
from skimage._shared._warnings import expected_warnings
from skimage._shared.testing import xfail, arch32
from skimage.segmentation import random_walker
from skimage.transform import resize
PYAMG_MISSING_WARNING = r'pyamg|\A\Z'
def make_2d_syntheticdata(lx, ly=None):
if ly is None:
ly = lx
np.random.seed(1234)
data = np.zeros((lx, ly)) + 0.1 * np.random.randn(lx, ly)
small_l = int(lx // 5)
data[
lx // 2 - small_l : lx // 2 + small_l, ly // 2 - small_l : ly // 2 + small_l
] = 1
data[
lx // 2 - small_l + 1 : lx // 2 + small_l - 1,
ly // 2 - small_l + 1 : ly // 2 + small_l - 1,
] = 0.1 * np.random.randn(2 * small_l - 2, 2 * small_l - 2)
data[lx // 2 - small_l, ly // 2 - small_l // 8 : ly // 2 + small_l // 8] = 0
seeds = np.zeros_like(data)
seeds[lx // 5, ly // 5] = 1
seeds[lx // 2 + small_l // 4, ly // 2 - small_l // 4] = 2
return data, seeds
def make_3d_syntheticdata(lx, ly=None, lz=None):
if ly is None:
ly = lx
if lz is None:
lz = lx
np.random.seed(1234)
data = np.zeros((lx, ly, lz)) + 0.1 * np.random.randn(lx, ly, lz)
small_l = int(lx // 5)
data[
lx // 2 - small_l : lx // 2 + small_l,
ly // 2 - small_l : ly // 2 + small_l,
lz // 2 - small_l : lz // 2 + small_l,
] = 1
data[
lx // 2 - small_l + 1 : lx // 2 + small_l - 1,
ly // 2 - small_l + 1 : ly // 2 + small_l - 1,
lz // 2 - small_l + 1 : lz // 2 + small_l - 1,
] = 0
# make a hole
hole_size = np.max([1, small_l // 8])
data[
lx // 2 - small_l,
ly // 2 - hole_size : ly // 2 + hole_size,
lz // 2 - hole_size : lz // 2 + hole_size,
] = 0
seeds = np.zeros_like(data)
seeds[lx // 5, ly // 5, lz // 5] = 1
seeds[lx // 2 + small_l // 4, ly // 2 - small_l // 4, lz // 2 - small_l // 4] = 2
return data, seeds
@testing.parametrize('dtype', [np.float16, np.float32, np.float64])
def test_2d_bf(dtype):
lx = 70
ly = 100
# have to use a smaller beta to avoid warning with lower precision input
beta = 90 if dtype == np.float64 else 25
data, labels = make_2d_syntheticdata(lx, ly)
data = data.astype(dtype, copy=False)
labels_bf = random_walker(data, labels, beta=beta, mode='bf')
assert (labels_bf[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
full_prob_bf = random_walker(
data, labels, beta=beta, mode='bf', return_full_prob=True
)
assert (full_prob_bf[1, 25:45, 40:60] >= full_prob_bf[0, 25:45, 40:60]).all()
assert data.shape == labels.shape
# Now test with more than two labels
labels[55, 80] = 3
full_prob_bf = random_walker(
data, labels, beta=beta, mode='bf', return_full_prob=True
)
assert (full_prob_bf[1, 25:45, 40:60] >= full_prob_bf[0, 25:45, 40:60]).all()
assert len(full_prob_bf) == 3
assert data.shape == labels.shape
@testing.parametrize('dtype', [np.float16, np.float32, np.float64])
def test_2d_cg(dtype):
lx = 70
ly = 100
data, labels = make_2d_syntheticdata(lx, ly)
data = data.astype(dtype, copy=False)
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
labels_cg = random_walker(data, labels, beta=90, mode='cg')
assert (labels_cg[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
full_prob = random_walker(
data, labels, beta=90, mode='cg', return_full_prob=True
)
assert (full_prob[1, 25:45, 40:60] >= full_prob[0, 25:45, 40:60]).all()
assert data.shape == labels.shape
@testing.parametrize('dtype', [np.float16, np.float32, np.float64])
def test_2d_cg_mg(dtype):
lx = 70
ly = 100
data, labels = make_2d_syntheticdata(lx, ly)
data = data.astype(dtype, copy=False)
anticipated_warnings = [
f'scipy.sparse.sparsetools|{PYAMG_MISSING_WARNING}|scipy.sparse.linalg.cg'
]
with expected_warnings(anticipated_warnings):
labels_cg_mg = random_walker(data, labels, beta=90, mode='cg_mg')
assert (labels_cg_mg[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
with expected_warnings(anticipated_warnings):
full_prob = random_walker(
data, labels, beta=90, mode='cg_mg', return_full_prob=True
)
assert (full_prob[1, 25:45, 40:60] >= full_prob[0, 25:45, 40:60]).all()
assert data.shape == labels.shape
@testing.parametrize('dtype', [np.float16, np.float32, np.float64])
def test_2d_cg_j(dtype):
lx = 70
ly = 100
data, labels = make_2d_syntheticdata(lx, ly)
data = data.astype(dtype, copy=False)
labels_cg = random_walker(data, labels, beta=90, mode='cg_j')
assert (labels_cg[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
full_prob = random_walker(data, labels, beta=90, mode='cg_j', return_full_prob=True)
assert (full_prob[1, 25:45, 40:60] >= full_prob[0, 25:45, 40:60]).all()
assert data.shape == labels.shape
def test_types():
lx = 70
ly = 100
data, labels = make_2d_syntheticdata(lx, ly)
data = 255 * (data - data.min()) // (data.max() - data.min())
data = data.astype(np.uint8)
with expected_warnings([f"{PYAMG_MISSING_WARNING}|scipy.sparse.linalg.cg"]):
labels_cg_mg = random_walker(data, labels, beta=90, mode='cg_mg')
assert (labels_cg_mg[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
def test_reorder_labels():
lx = 70
ly = 100
data, labels = make_2d_syntheticdata(lx, ly)
labels[labels == 2] = 4
labels_bf = random_walker(data, labels, beta=90, mode='bf')
assert (labels_bf[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
def test_2d_inactive():
lx = 70
ly = 100
data, labels = make_2d_syntheticdata(lx, ly)
labels[10:20, 10:20] = -1
labels[46:50, 33:38] = -2
labels = random_walker(data, labels, beta=90)
assert (labels.reshape((lx, ly))[25:45, 40:60] == 2).all()
assert data.shape == labels.shape
def test_2d_laplacian_size():
# test case from: https://github.com/scikit-image/scikit-image/issues/5034
# The markers here were modified from the ones in the original issue to
# avoid a singular matrix, but still reproduce the issue.
data = np.asarray(
[[12823, 12787, 12710], [12883, 13425, 12067], [11934, 11929, 12309]]
)
markers = np.asarray([[0, -1, 2], [0, -1, 0], [1, 0, -1]])
expected_labels = np.asarray([[1, -1, 2], [1, -1, 2], [1, 1, -1]])
labels = random_walker(data, markers, beta=10)
np.testing.assert_array_equal(labels, expected_labels)
@testing.parametrize('dtype', [np.float32, np.float64])
def test_3d(dtype):
n = 30
lx, ly, lz = n, n, n
data, labels = make_3d_syntheticdata(lx, ly, lz)
data = data.astype(dtype, copy=False)
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
labels = random_walker(data, labels, mode='cg')
assert (labels.reshape(data.shape)[13:17, 13:17, 13:17] == 2).all()
assert data.shape == labels.shape
def test_3d_inactive():
n = 30
lx, ly, lz = n, n, n
data, labels = make_3d_syntheticdata(lx, ly, lz)
labels[5:25, 26:29, 26:29] = -1
with expected_warnings(['"cg" mode|CObject type|scipy.sparse.linalg.cg']):
labels = random_walker(data, labels, mode='cg')
assert (labels.reshape(data.shape)[13:17, 13:17, 13:17] == 2).all()
assert data.shape == labels.shape
@testing.parametrize('channel_axis', [0, 1, -1])
@testing.parametrize('dtype', [np.float32, np.float64])
def test_multispectral_2d(dtype, channel_axis):
lx, ly = 70, 100
data, labels = make_2d_syntheticdata(lx, ly)
data = data.astype(dtype, copy=False)
data = data[..., np.newaxis].repeat(2, axis=-1) # Expect identical output
data = np.moveaxis(data, -1, channel_axis)
with expected_warnings(
['"cg" mode|scipy.sparse.linalg.cg', 'The probability range is outside']
):
multi_labels = random_walker(data, labels, mode='cg', channel_axis=channel_axis)
data = np.moveaxis(data, channel_axis, -1)
assert data[..., 0].shape == labels.shape
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
random_walker(data[..., 0], labels, mode='cg')
assert (multi_labels.reshape(labels.shape)[25:45, 40:60] == 2).all()
assert data[..., 0].shape == labels.shape
@testing.parametrize('dtype', [np.float32, np.float64])
def test_multispectral_3d(dtype):
n = 30
lx, ly, lz = n, n, n
data, labels = make_3d_syntheticdata(lx, ly, lz)
data = data.astype(dtype, copy=False)
data = data[..., np.newaxis].repeat(2, axis=-1) # Expect identical output
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
multi_labels = random_walker(data, labels, mode='cg', channel_axis=-1)
assert data[..., 0].shape == labels.shape
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
single_labels = random_walker(data[..., 0], labels, mode='cg')
assert (multi_labels.reshape(labels.shape)[13:17, 13:17, 13:17] == 2).all()
assert (single_labels.reshape(labels.shape)[13:17, 13:17, 13:17] == 2).all()
assert data[..., 0].shape == labels.shape
def test_spacing_0():
n = 30
lx, ly, lz = n, n, n
data, _ = make_3d_syntheticdata(lx, ly, lz)
# Rescale `data` along Z axis
data_aniso = np.zeros((n, n, n // 2))
for i, yz in enumerate(data):
data_aniso[i, :, :] = resize(
yz, (n, n // 2), mode='constant', anti_aliasing=False
)
# Generate new labels
small_l = int(lx // 5)
labels_aniso = np.zeros_like(data_aniso)
labels_aniso[lx // 5, ly // 5, lz // 5] = 1
labels_aniso[
lx // 2 + small_l // 4, ly // 2 - small_l // 4, lz // 4 - small_l // 8
] = 2
# Test with `spacing` kwarg
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
labels_aniso = random_walker(
data_aniso, labels_aniso, mode='cg', spacing=(1.0, 1.0, 0.5)
)
assert (labels_aniso[13:17, 13:17, 7:9] == 2).all()
@xfail(
condition=arch32,
reason=(
'Known test failure on 32-bit platforms. See links for '
'details: '
'https://github.com/scikit-image/scikit-image/issues/3091 '
'https://github.com/scikit-image/scikit-image/issues/3092'
),
)
def test_spacing_1():
n = 30
lx, ly, lz = n, n, n
data, _ = make_3d_syntheticdata(lx, ly, lz)
# Rescale `data` along Y axis
# `resize` is not yet 3D capable, so this must be done by looping in 2D.
data_aniso = np.zeros((n, n * 2, n))
for i, yz in enumerate(data):
data_aniso[i, :, :] = resize(
yz, (n * 2, n), mode='constant', anti_aliasing=False
)
# Generate new labels
small_l = int(lx // 5)
labels_aniso = np.zeros_like(data_aniso)
labels_aniso[lx // 5, ly // 5, lz // 5] = 1
labels_aniso[lx // 2 + small_l // 4, ly - small_l // 2, lz // 2 - small_l // 4] = 2
# Test with `spacing` kwarg
# First, anisotropic along Y
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
labels_aniso = random_walker(
data_aniso, labels_aniso, mode='cg', spacing=(1.0, 2.0, 1.0)
)
assert (labels_aniso[13:17, 26:34, 13:17] == 2).all()
# Rescale `data` along X axis
# `resize` is not yet 3D capable, so this must be done by looping in 2D.
data_aniso = np.zeros((n, n * 2, n))
for i in range(data.shape[1]):
data_aniso[i, :, :] = resize(
data[:, 1, :], (n * 2, n), mode='constant', anti_aliasing=False
)
# Generate new labels
small_l = int(lx // 5)
labels_aniso2 = np.zeros_like(data_aniso)
labels_aniso2[lx // 5, ly // 5, lz // 5] = 1
labels_aniso2[lx - small_l // 2, ly // 2 + small_l // 4, lz // 2 - small_l // 4] = 2
# Anisotropic along X
with expected_warnings(['"cg" mode|scipy.sparse.linalg.cg']):
labels_aniso2 = random_walker(
data_aniso, labels_aniso2, mode='cg', spacing=(2.0, 1.0, 1.0)
)
assert (labels_aniso2[26:34, 13:17, 13:17] == 2).all()
def test_trivial_cases():
# When all voxels are labeled
img = np.ones((10, 10))
labels = np.ones((10, 10))
with expected_warnings(["Returning provided labels"]):
pass_through = random_walker(img, labels)
np.testing.assert_array_equal(pass_through, labels)
# When all voxels are labeled AND return_full_prob is True
labels[:, :5] = 3
expected = np.concatenate(
((labels == 1)[..., np.newaxis], (labels == 3)[..., np.newaxis]), axis=2
)
with expected_warnings(["Returning provided labels"]):
test = random_walker(img, labels, return_full_prob=True)
np.testing.assert_array_equal(test, expected)
# Unlabeled voxels not connected to seed, so nothing can be done
img = np.full((10, 10), False)
object_A = np.array([(6, 7), (6, 8), (7, 7), (7, 8)])
object_B = np.array([(3, 1), (4, 1), (2, 2), (3, 2), (4, 2), (2, 3), (3, 3)])
for x, y in np.vstack((object_A, object_B)):
img[y][x] = True
markers = np.zeros((10, 10), dtype=np.int8)
for x, y in object_B:
markers[y][x] = 1
markers[img == 0] = -1
with expected_warnings(["All unlabeled pixels are isolated"]):
output_labels = random_walker(img, markers)
assert np.all(output_labels[markers == 1] == 1)
# Here 0-labeled pixels could not be determined (no connection to seed)
assert np.all(output_labels[markers == 0] == -1)
with expected_warnings(["All unlabeled pixels are isolated"]):
test = random_walker(img, markers, return_full_prob=True)
def test_length2_spacing():
# If this passes without raising an exception (warnings OK), the new
# spacing code is working properly.
np.random.seed(42)
img = np.ones((10, 10)) + 0.2 * np.random.normal(size=(10, 10))
labels = np.zeros((10, 10), dtype=np.uint8)
labels[2, 4] = 1
labels[6, 8] = 4
random_walker(img, labels, spacing=(1.0, 2.0))
def test_bad_inputs():
# Too few dimensions
img = np.ones(10)
labels = np.arange(10)
with testing.raises(ValueError):
random_walker(img, labels)
with testing.raises(ValueError):
random_walker(img, labels, channel_axis=-1)
# Too many dimensions
np.random.seed(42)
img = np.random.normal(size=(3, 3, 3, 3, 3))
labels = np.arange(3**5).reshape(img.shape)
with testing.raises(ValueError):
random_walker(img, labels)
with testing.raises(ValueError):
random_walker(img, labels, channel_axis=-1)
# Spacing incorrect length
img = np.random.normal(size=(10, 10))
labels = np.zeros((10, 10))
labels[2, 4] = 2
labels[6, 8] = 5
with testing.raises(ValueError):
random_walker(img, labels, spacing=(1,))
# Invalid mode
img = np.random.normal(size=(10, 10))
labels = np.zeros((10, 10))
with testing.raises(ValueError):
random_walker(img, labels, mode='bad')
def test_isolated_seeds():
np.random.seed(0)
a = np.random.random((7, 7))
mask = -np.ones(a.shape)
# This pixel is an isolated seed
mask[1, 1] = 1
# Unlabeled pixels
mask[3:, 3:] = 0
# Seeds connected to unlabeled pixels
mask[4, 4] = 2
mask[6, 6] = 1
# Test that no error is raised, and that labels of isolated seeds are OK
with expected_warnings(['The probability range is outside|scipy.sparse.linalg.cg']):
res = random_walker(a, mask)
assert res[1, 1] == 1
with expected_warnings(['The probability range is outside|scipy.sparse.linalg.cg']):
res = random_walker(a, mask, return_full_prob=True)
assert res[0, 1, 1] == 1
assert res[1, 1, 1] == 0
def test_isolated_area():
np.random.seed(0)
a = np.random.random((7, 7))
mask = -np.ones(a.shape)
# This pixel is an isolated seed
mask[1, 1] = 0
# Unlabeled pixels
mask[3:, 3:] = 0
# Seeds connected to unlabeled pixels
mask[4, 4] = 2
mask[6, 6] = 1
# Test that no error is raised, and that labels of isolated seeds are OK
with expected_warnings(['The probability range is outside|scipy.sparse.linalg.cg']):
res = random_walker(a, mask)
assert res[1, 1] == 0
with expected_warnings(['The probability range is outside|scipy.sparse.linalg.cg']):
res = random_walker(a, mask, return_full_prob=True)
assert res[0, 1, 1] == 0
assert res[1, 1, 1] == 0
def test_prob_tol():
np.random.seed(0)
a = np.random.random((7, 7))
mask = -np.ones(a.shape)
# This pixel is an isolated seed
mask[1, 1] = 1
# Unlabeled pixels
mask[3:, 3:] = 0
# Seeds connected to unlabeled pixels
mask[4, 4] = 2
mask[6, 6] = 1
with expected_warnings(['The probability range is outside|scipy.sparse.linalg.cg']):
res = random_walker(a, mask, return_full_prob=True)
# Lower beta, no warning is expected.
res = random_walker(a, mask, return_full_prob=True, beta=10)
assert res[0, 1, 1] == 1
assert res[1, 1, 1] == 0
# Being more prob_tol tolerant, no warning is expected.
res = random_walker(a, mask, return_full_prob=True, prob_tol=1e-1)
assert res[0, 1, 1] == 1
assert res[1, 1, 1] == 0
# Reduced tol, no warning is expected.
res = random_walker(a, mask, return_full_prob=True, tol=1e-9)
assert res[0, 1, 1] == 1
assert res[1, 1, 1] == 0
def test_umfpack_import():
from skimage.segmentation import random_walker_segmentation
UmfpackContext = random_walker_segmentation.UmfpackContext
try:
# when scikit-umfpack is installed UmfpackContext should not be None
import scikits.umfpack # noqa: F401
assert UmfpackContext is not None
except ImportError:
assert UmfpackContext is None
def test_empty_labels():
image = np.random.random((5, 5))
labels = np.zeros((5, 5), dtype=int)
with testing.raises(ValueError, match="No seeds provided"):
random_walker(image, labels)
labels[1, 1] = -1
with testing.raises(ValueError, match="No seeds provided"):
random_walker(image, labels)
# Once seeds are provided, it should run without error
labels[3, 3] = 1
random_walker(image, labels)

View File

@@ -0,0 +1,632 @@
from itertools import product
import numpy as np
import pytest
from numpy.testing import assert_equal
from skimage import data, filters, img_as_float
from skimage._shared.testing import run_in_parallel, expected_warnings
from skimage.segmentation import slic
@run_in_parallel()
def test_color_2d():
rng = np.random.default_rng(0)
img = np.zeros((20, 21, 3))
img[:10, :10, 0] = 1
img[10:, :10, 1] = 1
img[10:, 10:, 2] = 1
img += 0.01 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
seg = slic(img, n_segments=4, sigma=0, enforce_connectivity=False, start_label=0)
# we expect 4 segments
assert_equal(len(np.unique(seg)), 4)
assert_equal(seg.shape, img.shape[:-1])
assert_equal(seg[:10, :10], 0)
assert_equal(seg[10:, :10], 2)
assert_equal(seg[:10, 10:], 1)
assert_equal(seg[10:, 10:], 3)
def test_multichannel_2d():
rng = np.random.default_rng(0)
img = np.zeros((20, 20, 8))
img[:10, :10, 0:2] = 1
img[:10, 10:, 2:4] = 1
img[10:, :10, 4:6] = 1
img[10:, 10:, 6:8] = 1
img += 0.01 * rng.normal(size=img.shape)
img = np.clip(img, 0, 1, out=img)
seg = slic(img, n_segments=4, enforce_connectivity=False, start_label=0)
# we expect 4 segments
assert_equal(len(np.unique(seg)), 4)
assert_equal(seg.shape, img.shape[:-1])
assert_equal(seg[:10, :10], 0)
assert_equal(seg[10:, :10], 2)
assert_equal(seg[:10, 10:], 1)
assert_equal(seg[10:, 10:], 3)
def test_gray_2d():
rng = np.random.default_rng(0)
img = np.zeros((20, 21))
img[:10, :10] = 0.33
img[10:, :10] = 0.67
img[10:, 10:] = 1.00
img += 0.0033 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
seg = slic(
img,
sigma=0,
n_segments=4,
compactness=1,
channel_axis=None,
convert2lab=False,
start_label=0,
)
assert_equal(len(np.unique(seg)), 4)
assert_equal(seg.shape, img.shape)
assert_equal(seg[:10, :10], 0)
assert_equal(seg[10:, :10], 2)
assert_equal(seg[:10, 10:], 1)
assert_equal(seg[10:, 10:], 3)
def test_gray2d_default_channel_axis():
img = np.zeros((20, 21))
img[:10, :10] = 0.33
with pytest.raises(ValueError, match="channel_axis=-1 indicates multichannel"):
slic(img)
slic(img, channel_axis=None)
def _check_segment_labels(seg1, seg2, allowed_mismatch_ratio=0.1):
size = seg1.size
ndiff = np.sum(seg1 != seg2)
assert (ndiff / size) < allowed_mismatch_ratio
def test_slic_consistency_across_image_magnitude():
# verify that that images of various scales across integer and float dtypes
# give the same segmentation result
img_uint8 = data.cat()[:256, :128]
img_uint16 = 256 * img_uint8.astype(np.uint16)
img_float32 = img_as_float(img_uint8)
img_float32_norm = img_float32 / img_float32.max()
img_float32_offset = img_float32 + 1000
seg1 = slic(img_uint8)
seg2 = slic(img_uint16)
seg3 = slic(img_float32)
seg4 = slic(img_float32_norm)
seg5 = slic(img_float32_offset)
np.testing.assert_array_equal(seg1, seg2)
np.testing.assert_array_equal(seg1, seg3)
# Assert that offset has no impact on result
np.testing.assert_array_equal(seg4, seg5)
# Floating point cases can have mismatch due to floating point error
# exact match was observed on x86_64, but mismatches seen no i686.
# For now just verify that a similar number of superpixels are present in
# each case.
n_seg1 = seg1.max()
n_seg4 = seg4.max()
assert abs(n_seg1 - n_seg4) / n_seg1 < 0.5
def test_color_3d():
rng = np.random.default_rng(0)
img = np.zeros((20, 21, 22, 3))
slices = []
for dim_size in img.shape[:-1]:
midpoint = dim_size // 2
slices.append((slice(None, midpoint), slice(midpoint, None)))
slices = list(product(*slices))
colors = list(product(*(([0, 1],) * 3)))
for s, c in zip(slices, colors):
img[s] = c
img += 0.01 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
seg = slic(img, sigma=0, n_segments=8, start_label=0)
assert_equal(len(np.unique(seg)), 8)
for s, c in zip(slices, range(8)):
assert_equal(seg[s], c)
def test_gray_3d():
rng = np.random.default_rng(0)
img = np.zeros((20, 21, 22))
slices = []
for dim_size in img.shape:
midpoint = dim_size // 2
slices.append((slice(None, midpoint), slice(midpoint, None)))
slices = list(product(*slices))
shades = np.arange(0, 1.000001, 1.0 / 7)
for s, sh in zip(slices, shades):
img[s] = sh
img += 0.001 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
seg = slic(
img,
sigma=0,
n_segments=8,
compactness=1,
channel_axis=None,
convert2lab=False,
start_label=0,
)
assert_equal(len(np.unique(seg)), 8)
for s, c in zip(slices, range(8)):
assert_equal(seg[s], c)
def test_list_sigma():
rng = np.random.default_rng(0)
img = np.array([[1, 1, 1, 0, 0, 0], [0, 0, 0, 1, 1, 1]], float)
img += 0.1 * rng.normal(size=img.shape)
result_sigma = np.array([[0, 0, 0, 1, 1, 1], [0, 0, 0, 1, 1, 1]], int)
with expected_warnings(
["Input image is 2D: sigma number of " "elements must be 2"]
):
seg_sigma = slic(
img, n_segments=2, sigma=[1, 50, 1], channel_axis=None, start_label=0
)
assert_equal(seg_sigma, result_sigma)
def test_spacing():
rng = np.random.default_rng(0)
img = np.array([[1, 1, 1, 0, 0], [1, 1, 0, 0, 0]], float)
result_non_spaced = np.array([[0, 0, 0, 1, 1], [0, 0, 1, 1, 1]], int)
result_spaced = np.array([[0, 0, 0, 0, 0], [1, 1, 1, 1, 1]], int)
img += 0.1 * rng.normal(size=img.shape)
seg_non_spaced = slic(
img, n_segments=2, sigma=0, channel_axis=None, compactness=1.0, start_label=0
)
seg_spaced = slic(
img,
n_segments=2,
sigma=0,
spacing=[500, 1],
compactness=1.0,
channel_axis=None,
start_label=0,
)
assert_equal(seg_non_spaced, result_non_spaced)
assert_equal(seg_spaced, result_spaced)
def test_invalid_lab_conversion():
img = np.array([[1, 1, 1, 0, 0], [1, 1, 0, 0, 0]], float) + 1
with pytest.raises(ValueError):
slic(img, channel_axis=-1, convert2lab=True, start_label=0)
def test_enforce_connectivity():
img = np.array([[0, 0, 0, 1, 1, 1], [1, 0, 0, 1, 1, 0], [0, 0, 0, 1, 1, 0]], float)
segments_connected = slic(
img,
2,
compactness=0.0001,
enforce_connectivity=True,
convert2lab=False,
start_label=0,
channel_axis=None,
)
segments_disconnected = slic(
img,
2,
compactness=0.0001,
enforce_connectivity=False,
convert2lab=False,
start_label=0,
channel_axis=None,
)
# Make sure nothing fatal occurs (e.g. buffer overflow) at low values of
# max_size_factor
segments_connected_low_max = slic(
img,
2,
compactness=0.0001,
enforce_connectivity=True,
convert2lab=False,
max_size_factor=0.8,
start_label=0,
channel_axis=None,
)
result_connected = np.array(
[[0, 0, 0, 1, 1, 1], [0, 0, 0, 1, 1, 1], [0, 0, 0, 1, 1, 1]], float
)
result_disconnected = np.array(
[[0, 0, 0, 1, 1, 1], [1, 0, 0, 1, 1, 0], [0, 0, 0, 1, 1, 0]], float
)
assert_equal(segments_connected, result_connected)
assert_equal(segments_disconnected, result_disconnected)
assert_equal(segments_connected_low_max, result_connected)
def test_slic_zero():
# Same as test_color_2d but with slic_zero=True
rng = np.random.default_rng(0)
img = np.zeros((20, 21, 3))
img[:10, :10, 0] = 1
img[10:, :10, 1] = 1
img[10:, 10:, 2] = 1
img += 0.01 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
seg = slic(img, n_segments=4, sigma=0, slic_zero=True, start_label=0)
# we expect 4 segments
assert_equal(len(np.unique(seg)), 4)
assert_equal(seg.shape, img.shape[:-1])
assert_equal(seg[:10, :10], 0)
assert_equal(seg[10:, :10], 2)
assert_equal(seg[:10, 10:], 1)
assert_equal(seg[10:, 10:], 3)
def test_more_segments_than_pixels():
rng = np.random.default_rng(0)
img = np.zeros((20, 21))
img[:10, :10] = 0.33
img[10:, :10] = 0.67
img[10:, 10:] = 1.00
img += 0.0033 * rng.normal(size=img.shape)
img[img > 1] = 1
img[img < 0] = 0
seg = slic(
img,
sigma=0,
n_segments=500,
compactness=1,
channel_axis=None,
convert2lab=False,
start_label=0,
)
assert np.all(seg.ravel() == np.arange(seg.size))
def test_color_2d_mask():
rng = np.random.default_rng(0)
msk = np.zeros((20, 21))
msk[2:-2, 2:-2] = 1
img = np.zeros((20, 21, 3))
img[:10, :10, 0] = 1
img[10:, :10, 1] = 1
img[10:, 10:, 2] = 1
img += 0.01 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(img, n_segments=4, sigma=0, enforce_connectivity=False, mask=msk)
# we expect 4 segments + masked area
assert_equal(len(np.unique(seg)), 5)
assert_equal(seg.shape, img.shape[:-1])
# segments
assert_equal(seg[2:10, 2:10], 1)
assert_equal(seg[10:-2, 2:10], 4)
assert_equal(seg[2:10, 10:-2], 2)
assert_equal(seg[10:-2, 10:-2], 3)
# non masked area
assert_equal(seg[:2, :], 0)
assert_equal(seg[-2:, :], 0)
assert_equal(seg[:, :2], 0)
assert_equal(seg[:, -2:], 0)
def test_multichannel_2d_mask():
rng = np.random.default_rng(0)
msk = np.zeros((20, 20))
msk[2:-2, 2:-2] = 1
img = np.zeros((20, 20, 8))
img[:10, :10, 0:2] = 1
img[:10, 10:, 2:4] = 1
img[10:, :10, 4:6] = 1
img[10:, 10:, 6:8] = 1
img += 0.01 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(img, n_segments=4, enforce_connectivity=False, mask=msk)
# we expect 4 segments + masked area
assert_equal(len(np.unique(seg)), 5)
assert_equal(seg.shape, img.shape[:-1])
# segments
assert_equal(seg[2:10, 2:10], 2)
assert_equal(seg[2:10, 10:-2], 1)
assert_equal(seg[10:-2, 2:10], 4)
assert_equal(seg[10:-2, 10:-2], 3)
# non masked area
assert_equal(seg[:2, :], 0)
assert_equal(seg[-2:, :], 0)
assert_equal(seg[:, :2], 0)
assert_equal(seg[:, -2:], 0)
def test_gray_2d_mask():
rng = np.random.default_rng(0)
msk = np.zeros((20, 21))
msk[2:-2, 2:-2] = 1
img = np.zeros((20, 21))
img[:10, :10] = 0.33
img[10:, :10] = 0.67
img[10:, 10:] = 1.00
img += 0.0033 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(
img,
sigma=0,
n_segments=4,
compactness=1,
channel_axis=None,
convert2lab=False,
mask=msk,
)
assert_equal(len(np.unique(seg)), 5)
assert_equal(seg.shape, img.shape)
# segments
assert_equal(seg[2:10, 2:10], 1)
assert_equal(seg[2:10, 10:-2], 2)
assert_equal(seg[10:-2, 2:10], 3)
assert_equal(seg[10:-2, 10:-2], 4)
# non masked area
assert_equal(seg[:2, :], 0)
assert_equal(seg[-2:, :], 0)
assert_equal(seg[:, :2], 0)
assert_equal(seg[:, -2:], 0)
def test_list_sigma_mask():
rng = np.random.default_rng(0)
msk = np.zeros((2, 6))
msk[:, 1:-1] = 1
img = np.array([[1, 1, 1, 0, 0, 0], [0, 0, 0, 1, 1, 1]], float)
img += 0.1 * rng.normal(size=img.shape)
result_sigma = np.array([[0, 1, 1, 2, 2, 0], [0, 1, 1, 2, 2, 0]], int)
seg_sigma = slic(img, n_segments=2, sigma=[50, 1], channel_axis=None, mask=msk)
assert_equal(seg_sigma, result_sigma)
def test_spacing_mask():
rng = np.random.default_rng(0)
msk = np.zeros((2, 5))
msk[:, 1:-1] = 1
img = np.array([[1, 1, 1, 0, 0], [1, 1, 0, 0, 0]], float)
result_non_spaced = np.array([[0, 1, 1, 2, 0], [0, 1, 2, 2, 0]], int)
result_spaced = np.array([[0, 1, 1, 1, 0], [0, 2, 2, 2, 0]], int)
img += 0.1 * rng.normal(size=img.shape)
seg_non_spaced = slic(
img, n_segments=2, sigma=0, channel_axis=None, compactness=1.0, mask=msk
)
seg_spaced = slic(
img,
n_segments=2,
sigma=0,
spacing=[50, 1],
compactness=1.0,
channel_axis=None,
mask=msk,
)
assert_equal(seg_non_spaced, result_non_spaced)
assert_equal(seg_spaced, result_spaced)
def test_enforce_connectivity_mask():
msk = np.zeros((3, 6))
msk[:, 1:-1] = 1
img = np.array([[0, 0, 0, 1, 1, 1], [1, 0, 0, 1, 1, 0], [0, 0, 0, 1, 1, 0]], float)
segments_connected = slic(
img,
2,
compactness=0.0001,
enforce_connectivity=True,
convert2lab=False,
mask=msk,
channel_axis=None,
)
segments_disconnected = slic(
img,
2,
compactness=0.0001,
enforce_connectivity=False,
convert2lab=False,
mask=msk,
channel_axis=None,
)
# Make sure nothing fatal occurs (e.g. buffer overflow) at low values of
# max_size_factor
segments_connected_low_max = slic(
img,
2,
compactness=0.0001,
enforce_connectivity=True,
convert2lab=False,
max_size_factor=0.8,
mask=msk,
channel_axis=None,
)
result_connected = np.array(
[[0, 1, 1, 2, 2, 0], [0, 1, 1, 2, 2, 0], [0, 1, 1, 2, 2, 0]], float
)
result_disconnected = np.array(
[[0, 1, 1, 2, 2, 0], [0, 1, 1, 2, 2, 0], [0, 1, 1, 2, 2, 0]], float
)
assert_equal(segments_connected, result_connected)
assert_equal(segments_disconnected, result_disconnected)
assert_equal(segments_connected_low_max, result_connected)
def test_slic_zero_mask():
rng = np.random.default_rng(0)
msk = np.zeros((20, 21))
msk[2:-2, 2:-2] = 1
img = np.zeros((20, 21, 3))
img[:10, :10, 0] = 1
img[10:, :10, 1] = 1
img[10:, 10:, 2] = 1
img += 0.01 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(img, n_segments=4, sigma=0, slic_zero=True, mask=msk)
# we expect 4 segments + masked area
assert_equal(len(np.unique(seg)), 5)
assert_equal(seg.shape, img.shape[:-1])
# segments
assert_equal(seg[2:10, 2:10], 1)
assert_equal(seg[2:10, 10:-2], 2)
assert_equal(seg[10:-2, 2:10], 3)
assert_equal(seg[10:-2, 10:-2], 4)
# non masked area
assert_equal(seg[:2, :], 0)
assert_equal(seg[-2:, :], 0)
assert_equal(seg[:, :2], 0)
assert_equal(seg[:, -2:], 0)
def test_more_segments_than_pixels_mask():
rng = np.random.default_rng(0)
msk = np.zeros((20, 21))
msk[2:-2, 2:-2] = 1
img = np.zeros((20, 21))
img[:10, :10] = 0.33
img[10:, :10] = 0.67
img[10:, 10:] = 1.00
img += 0.0033 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(
img,
sigma=0,
n_segments=500,
compactness=1,
channel_axis=None,
convert2lab=False,
mask=msk,
)
expected = np.arange(seg[2:-2, 2:-2].size) + 1
assert np.all(seg[2:-2, 2:-2].ravel() == expected)
def test_color_3d_mask():
msk = np.zeros((20, 21, 22))
msk[2:-2, 2:-2, 2:-2] = 1
rng = np.random.default_rng(0)
img = np.zeros((20, 21, 22, 3))
slices = []
for dim_size in msk.shape:
midpoint = dim_size // 2
slices.append((slice(None, midpoint), slice(midpoint, None)))
slices = list(product(*slices))
colors = list(product(*(([0, 1],) * 3)))
for s, c in zip(slices, colors):
img[s] = c
img += 0.01 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(img, sigma=0, n_segments=8, mask=msk)
# we expect 8 segments + masked area
assert_equal(len(np.unique(seg)), 9)
for s, c in zip(slices, range(1, 9)):
assert_equal(seg[s][2:-2, 2:-2, 2:-2], c)
def test_gray_3d_mask():
msk = np.zeros((20, 21, 22))
msk[2:-2, 2:-2, 2:-2] = 1
rng = np.random.default_rng(0)
img = np.zeros((20, 21, 22))
slices = []
for dim_size in img.shape:
midpoint = dim_size // 2
slices.append((slice(None, midpoint), slice(midpoint, None)))
slices = list(product(*slices))
shades = np.linspace(0, 1, 8)
for s, sh in zip(slices, shades):
img[s] = sh
img += 0.001 * rng.normal(size=img.shape)
np.clip(img, 0, 1, out=img)
seg = slic(
img, sigma=0, n_segments=8, channel_axis=None, convert2lab=False, mask=msk
)
# we expect 8 segments + masked area
assert_equal(len(np.unique(seg)), 9)
for s, c in zip(slices, range(1, 9)):
assert_equal(seg[s][2:-2, 2:-2, 2:-2], c)
@pytest.mark.parametrize("dtype", ['float16', 'float32', 'float64', 'uint8', 'int'])
def test_dtype_support(dtype):
img = np.random.rand(28, 28).astype(dtype)
# Simply run the function to assert that it runs without error
slic(img, start_label=1, channel_axis=None)
def test_start_label_fix():
"""Tests the fix for a bug producing a label < start_label (gh-6240).
For the v0.19.1 release, the `img` and `slic` call as below result in two
non-contiguous regions with value 0 despite `start_label=1`. We verify that
the minimum label is now `start_label` as expected.
"""
# generate bumpy data that gives unexpected label prior to bug fix
rng = np.random.default_rng(9)
img = rng.standard_normal((8, 13)) > 0
img = filters.gaussian(img, sigma=1)
start_label = 1
superp = slic(
img,
start_label=start_label,
channel_axis=None,
n_segments=6,
compactness=0.01,
enforce_connectivity=True,
max_num_iter=10,
)
assert superp.min() == start_label
def test_raises_ValueError_if_input_has_NaN():
img = np.zeros((4, 5), dtype=float)
img[2, 3] = np.nan
with pytest.raises(ValueError):
slic(img, channel_axis=None)
mask = ~np.isnan(img)
slic(img, mask=mask, channel_axis=None)
@pytest.mark.parametrize("inf", [-np.inf, np.inf])
def test_raises_ValueError_if_input_has_inf(inf):
img = np.zeros((4, 5), dtype=float)
img[2, 3] = inf
with pytest.raises(ValueError):
slic(img, channel_axis=None)
mask = np.isfinite(img)
slic(img, mask=mask, channel_axis=None)

View File

@@ -0,0 +1,896 @@
"""test_watershed.py - tests the watershed function
"""
import math
import unittest
import numpy as np
import pytest
from scipy import ndimage as ndi
from skimage._shared.filters import gaussian
from skimage.measure import label
from .._watershed import watershed
eps = 1e-12
# fmt: off
blob = np.array([[255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255],
[255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255],
[255, 255, 255, 255, 255, 204, 204, 204, 204, 204, 204, 255, 255, 255, 255, 255],
[255, 255, 255, 204, 204, 183, 153, 153, 153, 153, 183, 204, 204, 255, 255, 255],
[255, 255, 204, 183, 153, 141, 111, 103, 103, 111, 141, 153, 183, 204, 255, 255],
[255, 255, 204, 153, 111, 94, 72, 52, 52, 72, 94, 111, 153, 204, 255, 255],
[255, 255, 204, 153, 111, 72, 39, 1, 1, 39, 72, 111, 153, 204, 255, 255],
[255, 255, 204, 183, 141, 111, 72, 39, 39, 72, 111, 141, 183, 204, 255, 255],
[255, 255, 255, 204, 183, 141, 111, 72, 72, 111, 141, 183, 204, 255, 255, 255],
[255, 255, 255, 255, 204, 183, 141, 94, 94, 141, 183, 204, 255, 255, 255, 255],
[255, 255, 255, 255, 255, 204, 153, 103, 103, 153, 204, 255, 255, 255, 255, 255],
[255, 255, 255, 255, 204, 183, 141, 94, 94, 141, 183, 204, 255, 255, 255, 255],
[255, 255, 255, 204, 183, 141, 111, 72, 72, 111, 141, 183, 204, 255, 255, 255],
[255, 255, 204, 183, 141, 111, 72, 39, 39, 72, 111, 141, 183, 204, 255, 255],
[255, 255, 204, 153, 111, 72, 39, 1, 1, 39, 72, 111, 153, 204, 255, 255],
[255, 255, 204, 153, 111, 94, 72, 52, 52, 72, 94, 111, 153, 204, 255, 255],
[255, 255, 204, 183, 153, 141, 111, 103, 103, 111, 141, 153, 183, 204, 255, 255],
[255, 255, 255, 204, 204, 183, 153, 153, 153, 153, 183, 204, 204, 255, 255, 255],
[255, 255, 255, 255, 255, 204, 204, 204, 204, 204, 204, 255, 255, 255, 255, 255],
[255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255],
[255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]])
# fmt: on
def diff(a, b):
if not isinstance(a, np.ndarray):
a = np.asarray(a)
if not isinstance(b, np.ndarray):
b = np.asarray(b)
if (0 in a.shape) and (0 in b.shape):
return 0.0
b[a == 0] = 0
if a.dtype in [np.complex64, np.complex128] or b.dtype in [
np.complex64,
np.complex128,
]:
a = np.asarray(a, np.complex128)
b = np.asarray(b, np.complex128)
t = ((a.real - b.real) ** 2).sum() + ((a.imag - b.imag) ** 2).sum()
else:
a = np.asarray(a)
a = a.astype(np.float64)
b = np.asarray(b)
b = b.astype(np.float64)
t = ((a - b) ** 2).sum()
return math.sqrt(t)
class TestWatershed(unittest.TestCase):
eight = np.ones((3, 3), bool)
def test_watershed01(self):
"watershed 1"
data = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.uint8,
)
markers = np.array(
[
[-1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.int8,
)
out = watershed(data, markers, self.eight)
expected = np.array(
[
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
]
)
error = diff(expected, out)
assert error < eps
def test_watershed02(self):
"watershed 2"
data = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.uint8,
)
markers = np.array(
[
[-1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.int8,
)
out = watershed(data, markers)
error = diff(
[
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, 1, 1, 1, -1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, -1, 1, 1, 1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
],
out,
)
self.assertTrue(error < eps)
def test_watershed03(self):
"watershed 3"
data = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.uint8,
)
markers = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 2, 0, 3, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, -1],
],
np.int8,
)
out = watershed(data, markers)
error = diff(
[
[-1, -1, -1, -1, -1, -1, -1],
[-1, 0, 2, 0, 3, 0, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 0, 2, 0, 3, 0, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
],
out,
)
self.assertTrue(error < eps)
def test_watershed04(self):
"watershed 4"
data = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.uint8,
)
markers = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 2, 0, 3, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, -1],
],
np.int8,
)
out = watershed(data, markers, self.eight)
error = diff(
[
[-1, -1, -1, -1, -1, -1, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, 2, 2, 0, 3, 3, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
],
out,
)
self.assertTrue(error < eps)
def test_watershed05(self):
"watershed 5"
data = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.uint8,
)
markers = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 3, 0, 2, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, -1],
],
np.int8,
)
out = watershed(data, markers, self.eight)
error = diff(
[
[-1, -1, -1, -1, -1, -1, -1],
[-1, 3, 3, 0, 2, 2, -1],
[-1, 3, 3, 0, 2, 2, -1],
[-1, 3, 3, 0, 2, 2, -1],
[-1, 3, 3, 0, 2, 2, -1],
[-1, 3, 3, 0, 2, 2, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
],
out,
)
self.assertTrue(error < eps)
def test_watershed06(self):
"watershed 6"
data = np.array(
[
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
np.uint8,
)
markers = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0],
],
np.int8,
)
out = watershed(data, markers, self.eight)
error = diff(
[
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, 1, 1, 1, 1, 1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
[-1, -1, -1, -1, -1, -1, -1],
],
out,
)
self.assertTrue(error < eps)
def test_watershed07(self):
"A regression test of a competitive case that failed"
data = blob
mask = data != 255
markers = np.zeros(data.shape, int)
markers[6, 7] = 1
markers[14, 7] = 2
out = watershed(data, markers, self.eight, mask=mask)
#
# The two objects should be the same size, except possibly for the
# border region
#
size1 = np.sum(out == 1)
size2 = np.sum(out == 2)
self.assertTrue(abs(size1 - size2) <= 6)
def test_watershed08(self):
"The border pixels + an edge are all the same value"
data = blob.copy()
data[10, 7:9] = 141
mask = data != 255
markers = np.zeros(data.shape, int)
markers[6, 7] = 1
markers[14, 7] = 2
out = watershed(data, markers, self.eight, mask=mask)
#
# The two objects should be the same size, except possibly for the
# border region
#
size1 = np.sum(out == 1)
size2 = np.sum(out == 2)
self.assertTrue(abs(size1 - size2) <= 6)
def test_watershed09(self):
"""Test on an image of reasonable size
This is here both for timing (does it take forever?) and to
ensure that the memory constraints are reasonable
"""
image = np.zeros((1000, 1000))
coords = np.random.uniform(0, 1000, (100, 2)).astype(int)
markers = np.zeros((1000, 1000), int)
idx = 1
for x, y in coords:
image[x, y] = 1
markers[x, y] = idx
idx += 1
image = gaussian(image, sigma=4, mode='reflect')
watershed(image, markers, self.eight)
ndi.watershed_ift(image.astype(np.uint16), markers, self.eight)
def test_watershed10(self):
"watershed 10"
data = np.array(
[[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]], np.uint8
)
markers = np.array(
[[1, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [3, 0, 0, 4]], np.int8
)
out = watershed(data, markers, self.eight)
error = diff([[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4]], out)
self.assertTrue(error < eps)
def test_watershed11(self):
'''Make sure that all points on this plateau are assigned to closest seed'''
# https://github.com/scikit-image/scikit-image/issues/803
#
# Make sure that no point in a level image is farther away
# from its seed than any other
#
image = np.zeros((21, 21))
markers = np.zeros((21, 21), int)
markers[5, 5] = 1
markers[5, 10] = 2
markers[10, 5] = 3
markers[10, 10] = 4
structure = np.array(
[[False, True, False], [True, True, True], [False, True, False]]
)
out = watershed(image, markers, structure)
i, j = np.mgrid[0:21, 0:21]
d = np.dstack(
[
np.sqrt((i.astype(float) - i0) ** 2, (j.astype(float) - j0) ** 2)
for i0, j0 in ((5, 5), (5, 10), (10, 5), (10, 10))
]
)
dmin = np.min(d, 2)
self.assertTrue(np.all(d[i, j, out[i, j] - 1] == dmin))
def test_watershed12(self):
"The watershed line"
data = np.array(
[
[
203,
255,
203,
153,
153,
153,
153,
153,
153,
153,
153,
153,
153,
153,
153,
153,
],
[
203,
255,
203,
153,
153,
153,
102,
102,
102,
102,
102,
102,
153,
153,
153,
153,
],
[
203,
255,
203,
203,
153,
153,
102,
102,
77,
0,
102,
102,
153,
153,
203,
203,
],
[
203,
255,
255,
203,
153,
153,
153,
102,
102,
102,
102,
153,
153,
203,
203,
255,
],
[
203,
203,
255,
203,
203,
203,
153,
153,
153,
153,
153,
153,
203,
203,
255,
255,
],
[
153,
203,
255,
255,
255,
203,
203,
203,
203,
203,
203,
203,
203,
255,
255,
203,
],
[
153,
203,
203,
203,
255,
255,
255,
255,
255,
255,
255,
255,
255,
255,
203,
203,
],
[
153,
153,
153,
203,
203,
203,
203,
203,
255,
203,
203,
203,
203,
203,
203,
153,
],
[
102,
102,
153,
153,
153,
153,
203,
203,
255,
203,
203,
255,
203,
153,
153,
153,
],
[
102,
102,
102,
102,
102,
153,
203,
255,
255,
203,
203,
203,
203,
153,
102,
153,
],
[
102,
51,
51,
102,
102,
153,
203,
255,
203,
203,
153,
153,
153,
153,
102,
153,
],
[
77,
51,
51,
102,
153,
153,
203,
255,
203,
203,
203,
153,
102,
102,
102,
153,
],
[
77,
0,
51,
102,
153,
203,
203,
255,
203,
255,
203,
153,
102,
51,
102,
153,
],
[
77,
0,
51,
102,
153,
203,
255,
255,
203,
203,
203,
153,
102,
0,
102,
153,
],
[
102,
0,
51,
102,
153,
203,
255,
203,
203,
153,
153,
153,
102,
102,
102,
153,
],
[
102,
102,
102,
102,
153,
203,
255,
203,
153,
153,
153,
153,
153,
153,
153,
153,
],
]
)
markerbin = data == 0
marker = label(markerbin)
ws = watershed(data, marker, connectivity=2, watershed_line=True)
for lab, area in zip(range(4), [34, 74, 74, 74]):
self.assertTrue(np.sum(ws == lab) == area)
def test_watershed_input_not_modified(self):
"""Test to ensure input markers are not modified."""
image = np.random.default_rng().random(size=(21, 21))
markers = np.zeros((21, 21), dtype=np.uint8)
markers[[5, 5, 15, 15], [5, 15, 5, 15]] = [1, 2, 3, 4]
original_markers = np.copy(markers)
result = watershed(image, markers)
np.testing.assert_equal(original_markers, markers)
assert not np.all(result == markers)
def test_compact_watershed():
image = np.zeros((5, 6))
image[:, 3:] = 1
seeds = np.zeros((5, 6), dtype=int)
seeds[2, 0] = 1
seeds[2, 3] = 2
compact = watershed(image, seeds, compactness=0.01)
expected = np.array(
[
[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
],
dtype=int,
)
np.testing.assert_equal(compact, expected)
normal = watershed(image, seeds)
expected = np.ones(image.shape, dtype=int)
expected[2, 3:] = 2
np.testing.assert_equal(normal, expected)
def test_numeric_seed_watershed():
"""Test that passing just the number of seeds to watershed works."""
image = np.zeros((5, 6))
image[:, 3:] = 1
compact = watershed(image, 2, compactness=0.01)
expected = np.array(
[
[1, 1, 1, 1, 2, 2],
[1, 1, 1, 1, 2, 2],
[1, 1, 1, 1, 2, 2],
[1, 1, 1, 1, 2, 2],
[1, 1, 1, 1, 2, 2],
],
dtype=np.int32,
)
np.testing.assert_equal(compact, expected)
@pytest.mark.parametrize(
'dtype',
[np.uint8, np.int8, np.uint16, np.int16, np.uint32, np.int32, np.uint64, np.int64],
)
def test_watershed_output_dtype(dtype):
image = np.zeros((100, 100))
markers = np.zeros((100, 100), dtype)
out = watershed(image, markers)
assert out.dtype == markers.dtype
def test_incorrect_markers_shape():
image = np.ones((5, 6))
markers = np.ones((5, 7))
with pytest.raises(ValueError):
watershed(image, markers)
def test_incorrect_mask_shape():
image = np.ones((5, 6))
mask = np.ones((5, 7))
with pytest.raises(ValueError):
watershed(image, markers=4, mask=mask)
def test_markers_in_mask():
data = blob
mask = data != 255
out = watershed(data, 25, connectivity=2, mask=mask)
# There should be no markers where the mask is false
assert np.all(out[~mask] == 0)
def test_no_markers():
data = blob
mask = data != 255
out = watershed(data, mask=mask)
assert np.max(out) == 2
def test_connectivity():
"""
Watershed segmentation should output different result for
different connectivity
when markers are calculated where None is supplied.
Issue = 5084
"""
# Generate a dummy BrightnessTemperature image
x, y = np.indices((406, 270))
x1, y1, x2, y2, x3, y3, x4, y4 = 200, 208, 300, 120, 100, 100, 340, 208
r1, r2, r3, r4 = 100, 50, 40, 80
mask_circle1 = (x - x1) ** 2 + (y - y1) ** 2 < r1**2
mask_circle2 = (x - x2) ** 2 + (y - y2) ** 2 < r2**2
mask_circle3 = (x - x3) ** 2 + (y - y3) ** 2 < r3**2
mask_circle4 = (x - x4) ** 2 + (y - y4) ** 2 < r4**2
image = np.logical_or(mask_circle1, mask_circle2)
image = np.logical_or(image, mask_circle3)
image = np.logical_or(image, mask_circle4)
# calculate distance in discrete increase
DummyBT = ndi.distance_transform_edt(image)
DummyBT_dis = np.around(DummyBT / 12, decimals=0) * 12
# calculate the mask
Img_mask = np.where(DummyBT_dis == 0, 0, 1)
# segments for connectivity 1 and 2
labels_c1 = watershed(
200 - DummyBT_dis, mask=Img_mask, connectivity=1, compactness=0.01
)
labels_c2 = watershed(
200 - DummyBT_dis, mask=Img_mask, connectivity=2, compactness=0.01
)
# assertions
assert np.unique(labels_c1).shape[0] == 6
assert np.unique(labels_c2).shape[0] == 5
# checking via area of each individual segment.
for lab, area in zip(range(6), [61824, 3653, 20467, 11097, 1301, 11278]):
assert np.sum(labels_c1 == lab) == area
for lab, area in zip(range(5), [61824, 3653, 20466, 12386, 11291]):
assert np.sum(labels_c2 == lab) == area