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,98 @@
"""Utilities that operate on shapes in images.
These operations are particularly suited for binary images,
although some may be useful for images of other types as well.
Basic morphological operations include dilation and erosion.
"""
from .binary import binary_closing, binary_dilation, binary_erosion, binary_opening
from .gray import black_tophat, closing, dilation, erosion, opening, white_tophat
from .isotropic import (
isotropic_erosion,
isotropic_dilation,
isotropic_opening,
isotropic_closing,
)
from .footprints import (
ball,
cube,
diamond,
disk,
ellipse,
footprint_from_sequence,
mirror_footprint,
octagon,
octahedron,
pad_footprint,
rectangle,
square,
star,
)
from ..measure._label import label
from ._skeletonize import medial_axis, skeletonize, skeletonize_3d, thin
from .convex_hull import convex_hull_image, convex_hull_object
from .grayreconstruct import reconstruction
from .misc import remove_small_holes, remove_small_objects, remove_objects_by_distance
from .extrema import h_maxima, h_minima, local_minima, local_maxima
from ._flood_fill import flood, flood_fill
from .max_tree import (
area_opening,
area_closing,
diameter_closing,
diameter_opening,
max_tree,
max_tree_local_maxima,
)
__all__ = [
'area_closing',
'area_opening',
'ball',
'binary_closing',
'binary_dilation',
'binary_erosion',
'binary_opening',
'black_tophat',
'closing',
'convex_hull_image',
'convex_hull_object',
'cube',
'diameter_closing',
'diameter_opening',
'diamond',
'dilation',
'disk',
'ellipse',
'erosion',
'flood',
'flood_fill',
'footprint_from_sequence',
'h_maxima',
'h_minima',
'isotropic_closing',
'isotropic_dilation',
'isotropic_erosion',
'isotropic_opening',
'label',
'local_maxima',
'local_minima',
'max_tree',
'max_tree_local_maxima',
'medial_axis',
'mirror_footprint',
'octagon',
'octahedron',
'opening',
'pad_footprint',
'reconstruction',
'rectangle',
'remove_small_holes',
'remove_small_objects',
'remove_objects_by_distance',
'skeletonize',
'square',
'star',
'thin',
'white_tophat',
]

View File

@@ -0,0 +1,310 @@
"""flood_fill.py - in place flood fill algorithm
This module provides a function to fill all equal (or within tolerance) values
connected to a given seed point with a different value.
"""
import numpy as np
from ..util import crop
from ._flood_fill_cy import _flood_fill_equal, _flood_fill_tolerance
from ._util import (
_offsets_to_raveled_neighbors,
_resolve_neighborhood,
_set_border_values,
)
from .._shared.dtype import numeric_dtype_min_max
def flood_fill(
image,
seed_point,
new_value,
*,
footprint=None,
connectivity=None,
tolerance=None,
in_place=False,
):
"""Perform flood filling on an image.
Starting at a specific `seed_point`, connected points equal or within
`tolerance` of the seed value are found, then set to `new_value`.
Parameters
----------
image : ndarray
An n-dimensional array.
seed_point : tuple or int
The point in `image` used as the starting point for the flood fill. If
the image is 1D, this point may be given as an integer.
new_value : `image` type
New value to set the entire fill. This must be chosen in agreement
with the dtype of `image`.
footprint : ndarray, optional
The footprint (structuring element) used to determine the neighborhood
of each evaluated pixel. It must contain only 1's and 0's, have the
same number of dimensions as `image`. If not given, all adjacent pixels
are considered as part of the neighborhood (fully connected).
connectivity : int, optional
A number used to determine the neighborhood of each evaluated pixel.
Adjacent pixels whose squared distance from the center is less than or
equal to `connectivity` are considered neighbors. Ignored if
`footprint` is not None.
tolerance : float or int, optional
If None (default), adjacent values must be strictly equal to the
value of `image` at `seed_point` to be filled. This is fastest.
If a tolerance is provided, adjacent points with values within plus or
minus tolerance from the seed point are filled (inclusive).
in_place : bool, optional
If True, flood filling is applied to `image` in place. If False, the
flood filled result is returned without modifying the input `image`
(default).
Returns
-------
filled : ndarray
An array with the same shape as `image` is returned, with values in
areas connected to and equal (or within tolerance of) the seed point
replaced with `new_value`.
Notes
-----
The conceptual analogy of this operation is the 'paint bucket' tool in many
raster graphics programs.
Examples
--------
>>> from skimage.morphology import flood_fill
>>> image = np.zeros((4, 7), dtype=int)
>>> image[1:3, 1:3] = 1
>>> image[3, 0] = 1
>>> image[1:3, 4:6] = 2
>>> image[3, 6] = 3
>>> image
array([[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3]])
Fill connected ones with 5, with full connectivity (diagonals included):
>>> flood_fill(image, (1, 1), 5)
array([[0, 0, 0, 0, 0, 0, 0],
[0, 5, 5, 0, 2, 2, 0],
[0, 5, 5, 0, 2, 2, 0],
[5, 0, 0, 0, 0, 0, 3]])
Fill connected ones with 5, excluding diagonal points (connectivity 1):
>>> flood_fill(image, (1, 1), 5, connectivity=1)
array([[0, 0, 0, 0, 0, 0, 0],
[0, 5, 5, 0, 2, 2, 0],
[0, 5, 5, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3]])
Fill with a tolerance:
>>> flood_fill(image, (0, 0), 5, tolerance=1)
array([[5, 5, 5, 5, 5, 5, 5],
[5, 5, 5, 5, 2, 2, 5],
[5, 5, 5, 5, 2, 2, 5],
[5, 5, 5, 5, 5, 5, 3]])
"""
mask = flood(
image,
seed_point,
footprint=footprint,
connectivity=connectivity,
tolerance=tolerance,
)
if not in_place:
image = image.copy()
image[mask] = new_value
return image
def flood(image, seed_point, *, footprint=None, connectivity=None, tolerance=None):
"""Mask corresponding to a flood fill.
Starting at a specific `seed_point`, connected points equal or within
`tolerance` of the seed value are found.
Parameters
----------
image : ndarray
An n-dimensional array.
seed_point : tuple or int
The point in `image` used as the starting point for the flood fill. If
the image is 1D, this point may be given as an integer.
footprint : ndarray, optional
The footprint (structuring element) used to determine the neighborhood
of each evaluated pixel. It must contain only 1's and 0's, have the
same number of dimensions as `image`. If not given, all adjacent pixels
are considered as part of the neighborhood (fully connected).
connectivity : int, optional
A number used to determine the neighborhood of each evaluated pixel.
Adjacent pixels whose squared distance from the center is less than or
equal to `connectivity` are considered neighbors. Ignored if
`footprint` is not None.
tolerance : float or int, optional
If None (default), adjacent values must be strictly equal to the
initial value of `image` at `seed_point`. This is fastest. If a value
is given, a comparison will be done at every point and if within
tolerance of the initial value will also be filled (inclusive).
Returns
-------
mask : ndarray
A Boolean array with the same shape as `image` is returned, with True
values for areas connected to and equal (or within tolerance of) the
seed point. All other values are False.
Notes
-----
The conceptual analogy of this operation is the 'paint bucket' tool in many
raster graphics programs. This function returns just the mask
representing the fill.
If indices are desired rather than masks for memory reasons, the user can
simply run `numpy.nonzero` on the result, save the indices, and discard
this mask.
Examples
--------
>>> from skimage.morphology import flood
>>> image = np.zeros((4, 7), dtype=int)
>>> image[1:3, 1:3] = 1
>>> image[3, 0] = 1
>>> image[1:3, 4:6] = 2
>>> image[3, 6] = 3
>>> image
array([[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3]])
Fill connected ones with 5, with full connectivity (diagonals included):
>>> mask = flood(image, (1, 1))
>>> image_flooded = image.copy()
>>> image_flooded[mask] = 5
>>> image_flooded
array([[0, 0, 0, 0, 0, 0, 0],
[0, 5, 5, 0, 2, 2, 0],
[0, 5, 5, 0, 2, 2, 0],
[5, 0, 0, 0, 0, 0, 3]])
Fill connected ones with 5, excluding diagonal points (connectivity 1):
>>> mask = flood(image, (1, 1), connectivity=1)
>>> image_flooded = image.copy()
>>> image_flooded[mask] = 5
>>> image_flooded
array([[0, 0, 0, 0, 0, 0, 0],
[0, 5, 5, 0, 2, 2, 0],
[0, 5, 5, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3]])
Fill with a tolerance:
>>> mask = flood(image, (0, 0), tolerance=1)
>>> image_flooded = image.copy()
>>> image_flooded[mask] = 5
>>> image_flooded
array([[5, 5, 5, 5, 5, 5, 5],
[5, 5, 5, 5, 2, 2, 5],
[5, 5, 5, 5, 2, 2, 5],
[5, 5, 5, 5, 5, 5, 3]])
"""
# Correct start point in ravelled image - only copy if non-contiguous
image = np.asarray(image)
if image.flags.f_contiguous is True:
order = 'F'
elif image.flags.c_contiguous is True:
order = 'C'
else:
image = np.ascontiguousarray(image)
order = 'C'
# Shortcut for rank zero
if 0 in image.shape:
return np.zeros(image.shape, dtype=bool)
# Convenience for 1d input
try:
iter(seed_point)
except TypeError:
seed_point = (seed_point,)
seed_value = image[seed_point]
seed_point = tuple(np.asarray(seed_point) % image.shape)
footprint = _resolve_neighborhood(
footprint, connectivity, image.ndim, enforce_adjacency=False
)
center = tuple(s // 2 for s in footprint.shape)
# Compute padding width as the maximum offset to neighbors on each axis.
# Generates a 2-tuple of (pad_start, pad_end) for each axis.
pad_width = [
(np.max(np.abs(idx - c)),) * 2 for idx, c in zip(np.nonzero(footprint), center)
]
# Must annotate borders
working_image = np.pad(
image, pad_width, mode='constant', constant_values=image.min()
)
# Stride-aware neighbors - works for both C- and Fortran-contiguity
ravelled_seed_idx = np.ravel_multi_index(
[i + pad_start for i, (pad_start, pad_end) in zip(seed_point, pad_width)],
working_image.shape,
order=order,
)
neighbor_offsets = _offsets_to_raveled_neighbors(
working_image.shape, footprint, center=center, order=order
)
# Use a set of flags; see _flood_fill_cy.pyx for meanings
flags = np.zeros(working_image.shape, dtype=np.uint8, order=order)
_set_border_values(flags, value=2, border_width=pad_width)
try:
if tolerance is not None:
tolerance = abs(tolerance)
# Account for over- & underflow problems with seed_value ± tolerance
# in a way that works with NumPy 1 & 2
min_value, max_value = numeric_dtype_min_max(seed_value.dtype)
low_tol = max(min_value.item(), seed_value.item() - tolerance)
high_tol = min(max_value.item(), seed_value.item() + tolerance)
_flood_fill_tolerance(
working_image.ravel(order),
flags.ravel(order),
neighbor_offsets,
ravelled_seed_idx,
seed_value,
low_tol,
high_tol,
)
else:
_flood_fill_equal(
working_image.ravel(order),
flags.ravel(order),
neighbor_offsets,
ravelled_seed_idx,
seed_value,
)
except TypeError:
if working_image.dtype == np.float16:
# Provide the user with clearer error message
raise TypeError(
"dtype of `image` is float16 which is not "
"supported, try upcasting to float32"
)
else:
raise
# Output what the user requested; view does not create a new copy.
return crop(flags, pad_width, copy=False).view(bool)

View File

@@ -0,0 +1,663 @@
"""
Algorithms for computing the skeleton of a binary image
"""
import numpy as np
from scipy import ndimage as ndi
from .._shared.utils import check_nD, deprecate_func
from ..util import crop
from ._skeletonize_3d_cy import _compute_thin_image
from ._skeletonize_cy import _fast_skeletonize, _skeletonize_loop, _table_lookup_index
def skeletonize(image, *, method=None):
"""Compute the skeleton of a binary image.
Thinning is used to reduce each connected component in a binary image
to a single-pixel wide skeleton.
Parameters
----------
image : ndarray, 2D or 3D
An image containing the objects to be skeletonized. Zeros or ``False``
represent background, nonzero values or ``True`` are foreground.
method : {'zhang', 'lee'}, optional
Which algorithm to use. Zhang's algorithm [Zha84]_ only works for
2D images, and is the default for 2D. Lee's algorithm [Lee94]_
works for 2D or 3D images and is the default for 3D.
Returns
-------
skeleton : ndarray of bool
The thinned image.
See Also
--------
medial_axis
References
----------
.. [Lee94] T.-C. Lee, R.L. Kashyap and C.-N. Chu, Building skeleton models
via 3-D medial surface/axis thinning algorithms.
Computer Vision, Graphics, and Image Processing, 56(6):462-478, 1994.
.. [Zha84] A fast parallel algorithm for thinning digital patterns,
T. Y. Zhang and C. Y. Suen, Communications of the ACM,
March 1984, Volume 27, Number 3.
Examples
--------
>>> X, Y = np.ogrid[0:9, 0:9]
>>> ellipse = (1./3 * (X - 4)**2 + (Y - 4)**2 < 3**2).astype(bool)
>>> ellipse.view(np.uint8)
array([[0, 0, 0, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 1, 0, 0, 0]], dtype=uint8)
>>> skel = skeletonize(ellipse)
>>> skel.view(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, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 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]], dtype=uint8)
"""
image = image.astype(bool, order="C", copy=False)
if method not in {'zhang', 'lee', None}:
raise ValueError(
f'skeletonize method should be either "lee" or "zhang", ' f'got {method}.'
)
if image.ndim == 2 and (method is None or method == 'zhang'):
skeleton = _skeletonize_2d(image)
elif image.ndim == 3 and method == 'zhang':
raise ValueError('skeletonize method "zhang" only works for 2D ' 'images.')
elif image.ndim == 3 or (image.ndim == 2 and method == 'lee'):
skeleton = _skeletonize_3d(image)
else:
raise ValueError(
f'skeletonize requires a 2D or 3D image as input, ' f'got {image.ndim}D.'
)
return skeleton
def _skeletonize_2d(image):
"""Return the skeleton of a 2D binary image.
Thinning is used to reduce each connected component in a binary image
to a single-pixel wide skeleton.
Parameters
----------
image : numpy.ndarray
An image containing the objects to be skeletonized. Zeros or ``False``
represent background, nonzero values or ``True`` are foreground.
Returns
-------
skeleton : ndarray
A matrix containing the thinned image.
See Also
--------
medial_axis, skeletonize, skeletonize_3d, thin
Notes
-----
The algorithm [Zha84]_ works by making successive passes of the image,
removing pixels on object borders. This continues until no
more pixels can be removed. The image is correlated with a
mask that assigns each pixel a number in the range [0...255]
corresponding to each possible pattern of its 8 neighboring
pixels. A look up table is then used to assign the pixels a
value of 0, 1, 2 or 3, which are selectively removed during
the iterations.
Note that this algorithm will give different results than a
medial axis transform, which is also often referred to as
"skeletonization".
References
----------
.. [Zha84] A fast parallel algorithm for thinning digital patterns,
T. Y. Zhang and C. Y. Suen, Communications of the ACM,
March 1984, Volume 27, Number 3.
Examples
--------
>>> X, Y = np.ogrid[0:9, 0:9]
>>> ellipse = (1./3 * (X - 4)**2 + (Y - 4)**2 < 3**2).astype(bool)
>>> ellipse.view(np.uint8)
array([[0, 0, 0, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 1, 0, 0, 0]], dtype=uint8)
>>> skel = skeletonize(ellipse)
>>> skel.view(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, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 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]], dtype=uint8)
"""
if image.ndim != 2:
raise ValueError("Zhang's skeletonize method requires a 2D array")
return _fast_skeletonize(image)
# --------- Skeletonization and thinning based on Guo and Hall 1989 ---------
def _generate_thin_luts():
"""generate LUTs for thinning algorithm (for reference)"""
def nabe(n):
return np.array([n >> i & 1 for i in range(0, 9)]).astype(bool)
def G1(n):
s = 0
bits = nabe(n)
for i in (0, 2, 4, 6):
if not (bits[i]) and (bits[i + 1] or bits[(i + 2) % 8]):
s += 1
return s == 1
g1_lut = np.array([G1(n) for n in range(256)])
def G2(n):
n1, n2 = 0, 0
bits = nabe(n)
for k in (1, 3, 5, 7):
if bits[k] or bits[k - 1]:
n1 += 1
if bits[k] or bits[(k + 1) % 8]:
n2 += 1
return min(n1, n2) in [2, 3]
g2_lut = np.array([G2(n) for n in range(256)])
g12_lut = g1_lut & g2_lut
def G3(n):
bits = nabe(n)
return not ((bits[1] or bits[2] or not (bits[7])) and bits[0])
def G3p(n):
bits = nabe(n)
return not ((bits[5] or bits[6] or not (bits[3])) and bits[4])
g3_lut = np.array([G3(n) for n in range(256)])
g3p_lut = np.array([G3p(n) for n in range(256)])
g123_lut = g12_lut & g3_lut
g123p_lut = g12_lut & g3p_lut
return g123_lut, g123p_lut
# fmt: off
G123_LUT = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0,
0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1,
0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0,
0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 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, 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, 1,
1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0,
0, 1, 1, 0, 0, 1, 0, 0, 0], dtype=bool)
G123P_LUT = np.array([0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0,
0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 1, 0, 1, 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, 1, 0,
1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1, 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, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1,
0, 1, 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], dtype=bool)
# fmt: on
def thin(image, max_num_iter=None):
"""
Perform morphological thinning of a binary image.
Parameters
----------
image : binary (M, N) ndarray
The image to thin. If this input isn't already a binary image,
it gets converted into one: In this case, zero values are considered
background (False), nonzero values are considered foreground (True).
max_num_iter : int, number of iterations, optional
Regardless of the value of this parameter, the thinned image
is returned immediately if an iteration produces no change.
If this parameter is specified it thus sets an upper bound on
the number of iterations performed.
Returns
-------
out : ndarray of bool
Thinned image.
See Also
--------
skeletonize, medial_axis
Notes
-----
This algorithm [1]_ works by making multiple passes over the image,
removing pixels matching a set of criteria designed to thin
connected regions while preserving eight-connected components and
2 x 2 squares [2]_. In each of the two sub-iterations the algorithm
correlates the intermediate skeleton image with a neighborhood mask,
then looks up each neighborhood in a lookup table indicating whether
the central pixel should be deleted in that sub-iteration.
References
----------
.. [1] Z. Guo and R. W. Hall, "Parallel thinning with
two-subiteration algorithms," Comm. ACM, vol. 32, no. 3,
pp. 359-373, 1989. :DOI:`10.1145/62065.62074`
.. [2] Lam, L., Seong-Whan Lee, and Ching Y. Suen, "Thinning
Methodologies-A Comprehensive Survey," IEEE Transactions on
Pattern Analysis and Machine Intelligence, Vol 14, No. 9,
p. 879, 1992. :DOI:`10.1109/34.161346`
Examples
--------
>>> square = np.zeros((7, 7), dtype=bool)
>>> square[1:-1, 2:-2] = 1
>>> square[0, 1] = 1
>>> square.view(np.uint8)
array([[0, 1, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
>>> skel = thin(square)
>>> skel.view(np.uint8)
array([[0, 1, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
"""
# check that image is 2d
check_nD(image, 2)
# convert image to uint8 with values in {0, 1}
skel = np.asanyarray(image, dtype=bool).view(np.uint8)
# neighborhood mask
mask = np.array([[8, 4, 2], [16, 0, 1], [32, 64, 128]], dtype=np.uint8)
# iterate until convergence, up to the iteration limit
max_num_iter = max_num_iter or np.inf
num_iter = 0
n_pts_old, n_pts_new = np.inf, np.sum(skel)
while n_pts_old != n_pts_new and num_iter < max_num_iter:
n_pts_old = n_pts_new
# perform the two "subiterations" described in the paper
for lut in [G123_LUT, G123P_LUT]:
# correlate image with neighborhood mask
N = ndi.correlate(skel, mask, mode='constant')
# take deletion decision from this subiteration's LUT
D = np.take(lut, N)
# perform deletion
skel[D] = 0
n_pts_new = np.sum(skel) # count points after thinning
num_iter += 1
return skel.astype(bool)
# --------- Skeletonization by medial axis transform --------
_eight_connect = ndi.generate_binary_structure(2, 2)
def medial_axis(image, mask=None, return_distance=False, *, rng=None):
"""Compute the medial axis transform of a binary image.
Parameters
----------
image : binary ndarray, shape (M, N)
The image of the shape to skeletonize. If this input isn't already a
binary image, it gets converted into one: In this case, zero values are
considered background (False), nonzero values are considered
foreground (True).
mask : binary ndarray, shape (M, N), optional
If a mask is given, only those elements in `image` with a true
value in `mask` are used for computing the medial axis.
return_distance : bool, optional
If true, the distance transform is returned as well as the skeleton.
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 determines the order in which pixels are processed for
tiebreaking.
.. versionadded:: 0.19
Returns
-------
out : ndarray of bools
Medial axis transform of the image
dist : ndarray of ints, optional
Distance transform of the image (only returned if `return_distance`
is True)
See Also
--------
skeletonize, thin
Notes
-----
This algorithm computes the medial axis transform of an image
as the ridges of its distance transform.
The different steps of the algorithm are as follows
* A lookup table is used, that assigns 0 or 1 to each configuration of
the 3x3 binary square, whether the central pixel should be removed
or kept. We want a point to be removed if it has more than one neighbor
and if removing it does not change the number of connected components.
* The distance transform to the background is computed, as well as
the cornerness of the pixel.
* The foreground (value of 1) points are ordered by
the distance transform, then the cornerness.
* A cython function is called to reduce the image to its skeleton. It
processes pixels in the order determined at the previous step, and
removes or maintains a pixel according to the lookup table. Because
of the ordering, it is possible to process all pixels in only one
pass.
Examples
--------
>>> square = np.zeros((7, 7), dtype=bool)
>>> square[1:-1, 2:-2] = 1
>>> square.view(np.uint8)
array([[0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
>>> medial_axis(square).view(np.uint8)
array([[0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
"""
global _eight_connect
if mask is None:
masked_image = image.astype(bool)
else:
masked_image = image.astype(bool).copy()
masked_image[~mask] = False
#
# Build lookup table - three conditions
# 1. Keep only positive pixels (center_is_foreground array).
# AND
# 2. Keep if removing the pixel results in a different connectivity
# (if the number of connected components is different with and
# without the central pixel)
# OR
# 3. Keep if # pixels in neighborhood is 2 or less
# Note that table is independent of image
center_is_foreground = (np.arange(512) & 2**4).astype(bool)
table = (
center_is_foreground # condition 1.
& (
np.array(
[
ndi.label(_pattern_of(index), _eight_connect)[1]
!= ndi.label(_pattern_of(index & ~(2**4)), _eight_connect)[1]
for index in range(512)
]
) # condition 2
| np.array([np.sum(_pattern_of(index)) < 3 for index in range(512)])
)
# condition 3
)
# Build distance transform
distance = ndi.distance_transform_edt(masked_image)
if return_distance:
store_distance = distance.copy()
# Corners
# The processing order along the edge is critical to the shape of the
# resulting skeleton: if you process a corner first, that corner will
# be eroded and the skeleton will miss the arm from that corner. Pixels
# with fewer neighbors are more "cornery" and should be processed last.
# We use a cornerness_table lookup table where the score of a
# configuration is the number of background (0-value) pixels in the
# 3x3 neighborhood
cornerness_table = np.array(
[9 - np.sum(_pattern_of(index)) for index in range(512)]
)
corner_score = _table_lookup(masked_image, cornerness_table)
# Define arrays for inner loop
i, j = np.mgrid[0 : image.shape[0], 0 : image.shape[1]]
result = masked_image.copy()
distance = distance[result]
i = np.ascontiguousarray(i[result], dtype=np.intp)
j = np.ascontiguousarray(j[result], dtype=np.intp)
result = np.ascontiguousarray(result, np.uint8)
# Determine the order in which pixels are processed.
# We use a random # for tiebreaking. Assign each pixel in the image a
# predictable, random # so that masking doesn't affect arbitrary choices
# of skeletons
#
generator = np.random.default_rng(rng)
tiebreaker = generator.permutation(np.arange(masked_image.sum()))
order = np.lexsort((tiebreaker, corner_score[masked_image], distance))
order = np.ascontiguousarray(order, dtype=np.int32)
table = np.ascontiguousarray(table, dtype=np.uint8)
# Remove pixels not belonging to the medial axis
_skeletonize_loop(result, i, j, order, table)
result = result.astype(bool)
if mask is not None:
result[~mask] = image[~mask]
if return_distance:
return result, store_distance
else:
return result
def _pattern_of(index):
"""
Return the pattern represented by an index value
Byte decomposition of index
"""
return np.array(
[
[index & 2**0, index & 2**1, index & 2**2],
[index & 2**3, index & 2**4, index & 2**5],
[index & 2**6, index & 2**7, index & 2**8],
],
bool,
)
def _table_lookup(image, table):
"""
Perform a morphological transform on an image, directed by its
neighbors
Parameters
----------
image : ndarray
A binary image
table : ndarray
A 512-element table giving the transform of each pixel given
the values of that pixel and its 8-connected neighbors.
Returns
-------
result : ndarray of same shape as `image`
Transformed image
Notes
-----
The pixels are numbered like this::
0 1 2
3 4 5
6 7 8
The index at a pixel is the sum of 2**<pixel-number> for pixels
that evaluate to true.
"""
#
# We accumulate into the indexer to get the index into the table
# at each point in the image
#
if image.shape[0] < 3 or image.shape[1] < 3:
image = image.astype(bool)
indexer = np.zeros(image.shape, int)
indexer[1:, 1:] += image[:-1, :-1] * 2**0
indexer[1:, :] += image[:-1, :] * 2**1
indexer[1:, :-1] += image[:-1, 1:] * 2**2
indexer[:, 1:] += image[:, :-1] * 2**3
indexer[:, :] += image[:, :] * 2**4
indexer[:, :-1] += image[:, 1:] * 2**5
indexer[:-1, 1:] += image[1:, :-1] * 2**6
indexer[:-1, :] += image[1:, :] * 2**7
indexer[:-1, :-1] += image[1:, 1:] * 2**8
else:
indexer = _table_lookup_index(np.ascontiguousarray(image, np.uint8))
image = table[indexer]
return image
def _skeletonize_3d(image):
"""Compute the skeleton of a binary image.
Thinning is used to reduce each connected component in a binary image
to a single-pixel wide skeleton.
Parameters
----------
image : ndarray, 2D or 3D
An image containing the objects to be skeletonized. Zeros or ``False``
represent background, nonzero values or ``True`` are foreground.
Returns
-------
skeleton : ndarray of bool
The thinned image.
See Also
--------
skeletonize, medial_axis
Notes
-----
The method of [Lee94]_ uses an octree data structure to examine a 3x3x3
neighborhood of a pixel. The algorithm proceeds by iteratively sweeping
over the image, and removing pixels at each iteration until the image
stops changing. Each iteration consists of two steps: first, a list of
candidates for removal is assembled; then pixels from this list are
rechecked sequentially, to better preserve connectivity of the image.
The algorithm this function implements is different from the algorithms
used by either `skeletonize` or `medial_axis`, thus for 2D images the
results produced by this function are generally different.
References
----------
.. [Lee94] T.-C. Lee, R.L. Kashyap and C.-N. Chu, Building skeleton models
via 3-D medial surface/axis thinning algorithms.
Computer Vision, Graphics, and Image Processing, 56(6):462-478, 1994.
"""
# make sure the image is 3D or 2D
if image.ndim < 2 or image.ndim > 3:
raise ValueError(
"skeletonize_3d can only handle 2D or 3D images; "
f"got image.ndim = {image.ndim} instead."
)
image_o = image.astype(bool, order="C", copy=False)
# make a 2D input image 3D and pad it w/ zeros to simplify dealing w/ boundaries
# NB: careful here to not clobber the original *and* minimize copying
if image.ndim == 2:
image_o = image_o[np.newaxis, ...]
image_o = np.pad(image_o, pad_width=1, mode='constant') # copies
# do the computation
image_o = _compute_thin_image(image_o)
# crop it back and restore the original intensity range
image_o = crop(image_o, crop_width=1)
if image.ndim == 2:
image_o = image_o[0]
return image_o
def skeletonize_3d(image):
return _skeletonize_3d(image)
skeletonize_3d.__doc__ = _skeletonize_3d.__doc__
skeletonize_3d = deprecate_func(
deprecated_version="0.23",
removed_version="0.25",
hint="Use `skimage.morphology.skeletonize` instead.",
)(skeletonize_3d)

View File

@@ -0,0 +1,328 @@
"""Utility functions used in the morphology subpackage."""
import numpy as np
from scipy import ndimage as ndi
def _validate_connectivity(image_dim, connectivity, offset):
"""Convert any valid connectivity to a footprint and offset.
Parameters
----------
image_dim : int
The number of dimensions of the input image.
connectivity : int, array, or None
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 and its shape is validated against
the input image shape. ``None`` is interpreted as a connectivity of 1.
offset : tuple of int, or None
The coordinates of the center of the footprint.
Returns
-------
c_connectivity : array of bool
The footprint (structuring element) corresponding to the input
`connectivity`.
offset : array of int
The offset corresponding to the center of the footprint.
Raises
------
ValueError:
If the image dimension and the connectivity or offset dimensions don't
match.
"""
if connectivity is None:
connectivity = 1
if np.isscalar(connectivity):
c_connectivity = ndi.generate_binary_structure(image_dim, connectivity)
else:
c_connectivity = np.array(connectivity, bool)
if c_connectivity.ndim != image_dim:
raise ValueError("Connectivity dimension must be same as image")
if offset is None:
if any([x % 2 == 0 for x in c_connectivity.shape]):
raise ValueError("Connectivity array must have an unambiguous " "center")
offset = np.array(c_connectivity.shape) // 2
return c_connectivity, offset
def _raveled_offsets_and_distances(
image_shape,
*,
footprint=None,
connectivity=1,
center=None,
spacing=None,
order='C',
):
"""Compute offsets to neighboring pixels in raveled coordinate space.
This function also returns the corresponding distances from the center
pixel given a spacing (assumed to be 1 along each axis by default).
Parameters
----------
image_shape : tuple of int
The shape of the image for which the offsets are being computed.
footprint : array of bool
The footprint of the neighborhood, expressed as an n-dimensional array
of 1s and 0s. If provided, the connectivity argument is ignored.
connectivity : {1, ..., ndim}
The square connectivity of the neighborhood: the number of orthogonal
steps allowed to consider a pixel a neighbor. See
`scipy.ndimage.generate_binary_structure`. Ignored if footprint is
provided.
center : tuple of int
Tuple of indices to the center of the footprint. If not provided, it
is assumed to be the center of the footprint, either provided or
generated by the connectivity argument.
spacing : tuple of float
The spacing between pixels/voxels along each axis.
order : 'C' or 'F'
The ordering of the array, either C or Fortran ordering.
Returns
-------
raveled_offsets : ndarray
Linear offsets to a samples neighbors in the raveled image, sorted by
their distance from the center.
distances : ndarray
The pixel distances corresponding to each offset.
Notes
-----
This function will return values even if `image_shape` contains a dimension
length that is smaller than `footprint`.
Examples
--------
>>> off, d = _raveled_offsets_and_distances(
... (4, 5), footprint=np.ones((4, 3)), center=(1, 1)
... )
>>> off
array([-5, -1, 1, 5, -6, -4, 4, 6, 10, 9, 11])
>>> d[0]
1.0
>>> d[-1] # distance from (1, 1) to (3, 2)
2.236...
"""
ndim = len(image_shape)
if footprint is None:
footprint = ndi.generate_binary_structure(rank=ndim, connectivity=connectivity)
if center is None:
center = tuple(s // 2 for s in footprint.shape)
if not footprint.ndim == ndim == len(center):
raise ValueError(
"number of dimensions in image shape, footprint and its"
"center index does not match"
)
offsets = np.stack(
[(idx - c) for idx, c in zip(np.nonzero(footprint), center)], axis=-1
)
if order == 'F':
offsets = offsets[:, ::-1]
image_shape = image_shape[::-1]
elif order != 'C':
raise ValueError("order must be 'C' or 'F'")
# Scale offsets in each dimension and sum
ravel_factors = image_shape[1:] + (1,)
ravel_factors = np.cumprod(ravel_factors[::-1])[::-1]
raveled_offsets = (offsets * ravel_factors).sum(axis=1)
# Sort by distance
if spacing is None:
spacing = np.ones(ndim)
weighted_offsets = offsets * spacing
distances = np.sqrt(np.sum(weighted_offsets**2, axis=1))
sorted_raveled_offsets = raveled_offsets[np.argsort(distances, kind="stable")]
sorted_distances = np.sort(distances, kind="stable")
# If any dimension in image_shape is smaller than footprint.shape
# duplicates might occur, remove them
if any(x < y for x, y in zip(image_shape, footprint.shape)):
# np.unique reorders, which we don't want
_, indices = np.unique(sorted_raveled_offsets, return_index=True)
indices = np.sort(indices, kind="stable")
sorted_raveled_offsets = sorted_raveled_offsets[indices]
sorted_distances = sorted_distances[indices]
# Remove "offset to center"
sorted_raveled_offsets = sorted_raveled_offsets[1:]
sorted_distances = sorted_distances[1:]
return sorted_raveled_offsets, sorted_distances
def _offsets_to_raveled_neighbors(image_shape, footprint, center, order='C'):
"""Compute offsets to a samples neighbors if the image would be raveled.
Parameters
----------
image_shape : tuple
The shape of the image for which the offsets are computed.
footprint : ndarray
The footprint (structuring element) determining the neighborhood
expressed as an n-D array of 1's and 0's.
center : tuple
Tuple of indices to the center of `footprint`.
order : {"C", "F"}, optional
Whether the image described by `image_shape` is in row-major (C-style)
or column-major (Fortran-style) order.
Returns
-------
raveled_offsets : ndarray
Linear offsets to a samples neighbors in the raveled image, sorted by
their distance from the center.
Notes
-----
This function will return values even if `image_shape` contains a dimension
length that is smaller than `footprint`.
Examples
--------
>>> _offsets_to_raveled_neighbors((4, 5), np.ones((4, 3)), (1, 1))
array([-5, -1, 1, 5, -6, -4, 4, 6, 10, 9, 11])
>>> _offsets_to_raveled_neighbors((2, 3, 2), np.ones((3, 3, 3)), (1, 1, 1))
array([-6, -2, -1, 1, 2, 6, -8, -7, -5, -4, -3, 3, 4, 5, 7, 8, -9,
9])
"""
raveled_offsets = _raveled_offsets_and_distances(
image_shape, footprint=footprint, center=center, order=order
)[0]
return raveled_offsets
def _resolve_neighborhood(footprint, connectivity, ndim, enforce_adjacency=True):
"""Validate or create a footprint (structuring element).
Depending on the values of `connectivity` and `footprint` this function
either creates a new footprint (`footprint` is None) using `connectivity`
or validates the given footprint (`footprint` is not None).
Parameters
----------
footprint : ndarray
The footprint (structuring) element used to determine the neighborhood
of each evaluated pixel (``True`` denotes a connected pixel). It must
be a boolean array and have the same number of dimensions as `image`.
If neither `footprint` nor `connectivity` are given, all adjacent
pixels are considered as part of the neighborhood.
connectivity : int
A number used to determine the neighborhood of each evaluated pixel.
Adjacent pixels whose squared distance from the center is less than or
equal to `connectivity` are considered neighbors. Ignored if
`footprint` is not None.
ndim : int
Number of dimensions `footprint` ought to have.
enforce_adjacency : bool
A boolean that determines whether footprint must only specify direct
neighbors.
Returns
-------
footprint : ndarray
Validated or new footprint specifying the neighborhood.
Examples
--------
>>> _resolve_neighborhood(None, 1, 2)
array([[False, True, False],
[ True, True, True],
[False, True, False]])
>>> _resolve_neighborhood(None, None, 3).shape
(3, 3, 3)
"""
if footprint is None:
if connectivity is None:
connectivity = ndim
footprint = ndi.generate_binary_structure(ndim, connectivity)
else:
# Validate custom structured element
footprint = np.asarray(footprint, dtype=bool)
# Must specify neighbors for all dimensions
if footprint.ndim != ndim:
raise ValueError(
"number of dimensions in image and footprint do not" "match"
)
# Must only specify direct neighbors
if enforce_adjacency and any(s != 3 for s in footprint.shape):
raise ValueError("dimension size in footprint is not 3")
elif any((s % 2 != 1) for s in footprint.shape):
raise ValueError("footprint size must be odd along all dimensions")
return footprint
def _set_border_values(image, value, border_width=1):
"""Set edge values along all axes to a constant value.
Parameters
----------
image : ndarray
The array to modify inplace.
value : scalar
The value to use. Should be compatible with `image`'s dtype.
border_width : int or sequence of tuples
A sequence with one 2-tuple per axis where the first and second values
are the width of the border at the start and end of the axis,
respectively. If an int is provided, a uniform border width along all
axes is used.
Examples
--------
>>> image = np.zeros((4, 5), dtype=int)
>>> _set_border_values(image, 1)
>>> image
array([[1, 1, 1, 1, 1],
[1, 0, 0, 0, 1],
[1, 0, 0, 0, 1],
[1, 1, 1, 1, 1]])
>>> image = np.zeros((8, 8), dtype=int)
>>> _set_border_values(image, 1, border_width=((1, 1), (2, 3)))
>>> image
array([[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1]])
"""
if np.isscalar(border_width):
border_width = ((border_width, border_width),) * image.ndim
elif len(border_width) != image.ndim:
raise ValueError('length of `border_width` must match image.ndim')
for axis, npad in enumerate(border_width):
if len(npad) != 2:
raise ValueError('each sequence in `border_width` must have ' 'length 2')
w_start, w_end = npad
if w_start == w_end == 0:
continue
elif w_start == w_end == 1:
# Index first and last element in the current dimension
sl = (slice(None),) * axis + ((0, -1),) + (...,)
image[sl] = value
continue
if w_start > 0:
# set first w_start entries along axis to value
sl = (slice(None),) * axis + (slice(0, w_start),) + (...,)
image[sl] = value
if w_end > 0:
# set last w_end entries along axis to value
sl = (slice(None),) * axis + (slice(-w_end, None),) + (...,)
image[sl] = value

View File

@@ -0,0 +1,320 @@
"""
Binary morphological operations
"""
import numpy as np
from scipy import ndimage as ndi
from .footprints import _footprint_is_sequence, pad_footprint
from .misc import default_footprint
def _iterate_binary_func(binary_func, image, footprint, out, border_value):
"""Helper to call `binary_func` for each footprint in a sequence.
binary_func is a binary morphology function that accepts "structure",
"output" and "iterations" keyword arguments
(e.g. `scipy.ndimage.binary_erosion`).
"""
fp, num_iter = footprint[0]
binary_func(
image, structure=fp, output=out, iterations=num_iter, border_value=border_value
)
for fp, num_iter in footprint[1:]:
# Note: out.copy() because the computation cannot be in-place!
# SciPy <= 1.7 did not automatically make a copy if needed.
binary_func(
out.copy(),
structure=fp,
output=out,
iterations=num_iter,
border_value=border_value,
)
return out
# The default_footprint decorator provides a diamond footprint as
# default with the same dimension as the input image and size 3 along each
# axis.
@default_footprint
def binary_erosion(image, footprint=None, out=None, *, mode='ignore'):
"""Return fast binary morphological erosion of an image.
This function returns the same result as grayscale erosion but performs
faster for binary images.
Morphological erosion sets a pixel at ``(i,j)`` to the minimum over all
pixels in the neighborhood centered at ``(i,j)``. Erosion shrinks bright
regions and enlarges dark regions.
Parameters
----------
image : ndarray
Binary input image.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray of bool, optional
The array to store the result of the morphology. If None is
passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'max', 'min', 'ignore'.
If 'max' or 'ignore', pixels outside the image domain are assumed
to be `True`, which causes them to not influence the result.
Default is 'ignore'.
.. versionadded:: 0.23
`mode` was added in 0.23.
Returns
-------
eroded : ndarray of bool or uint
The result of the morphological erosion taking values in
``[False, True]``.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate a
footprint sequence of this type.
For even-sized footprints, :func:`skimage.morphology.erosion` and
this function produce an output that differs: one is shifted by one pixel
compared to the other.
See also
--------
skimage.morphology.isotropic_erosion
"""
if out is None:
out = np.empty(image.shape, dtype=bool)
if mode not in {"max", "min", "ignore"}:
raise ValueError(f"unsupported mode, got {mode!r}")
border_value = False if mode == 'min' else True
footprint = pad_footprint(footprint, pad_end=True)
if not _footprint_is_sequence(footprint):
footprint = [(footprint, 1)]
out = _iterate_binary_func(
binary_func=ndi.binary_erosion,
image=image,
footprint=footprint,
out=out,
border_value=border_value,
)
return out
@default_footprint
def binary_dilation(image, footprint=None, out=None, *, mode='ignore'):
"""Return fast binary morphological dilation of an image.
This function returns the same result as grayscale dilation but performs
faster for binary images.
Morphological dilation sets a pixel at ``(i,j)`` to the maximum over all
pixels in the neighborhood centered at ``(i,j)``. Dilation enlarges bright
regions and shrinks dark regions.
Parameters
----------
image : ndarray
Binary input image.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray of bool, optional
The array to store the result of the morphology. If None is
passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'max', 'min', 'ignore'.
If 'min' or 'ignore', pixels outside the image domain are assumed
to be `False`, which causes them to not influence the result.
Default is 'ignore'.
.. versionadded:: 0.23
`mode` was added in 0.23.
Returns
-------
dilated : ndarray of bool or uint
The result of the morphological dilation with values in
``[False, True]``.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate a
footprint sequence of this type.
For non-symmetric footprints, :func:`skimage.morphology.binary_dilation`
and :func:`skimage.morphology.dilation` produce an output that differs:
`binary_dilation` mirrors the footprint, whereas `dilation` does not.
See also
--------
skimage.morphology.isotropic_dilation
"""
if out is None:
out = np.empty(image.shape, dtype=bool)
if mode not in {"max", "min", "ignore"}:
raise ValueError(f"unsupported mode, got {mode!r}")
border_value = True if mode == 'max' else False
footprint = pad_footprint(footprint, pad_end=True)
if not _footprint_is_sequence(footprint):
footprint = [(footprint, 1)]
out = _iterate_binary_func(
binary_func=ndi.binary_dilation,
image=image,
footprint=footprint,
out=out,
border_value=border_value,
)
return out
@default_footprint
def binary_opening(image, footprint=None, out=None, *, mode='ignore'):
"""Return fast binary morphological opening of an image.
This function returns the same result as grayscale opening but performs
faster for binary images.
The morphological opening on an image is defined as an erosion followed by
a dilation. Opening can remove small bright spots (i.e. "salt") and connect
small dark cracks. This tends to "open" up (dark) gaps between (bright)
features.
Parameters
----------
image : ndarray
Binary input image.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray of bool, optional
The array to store the result of the morphology. If None
is passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'max', 'min', 'ignore'.
If 'ignore', pixels outside the image domain are assumed to be `True`
for the erosion and `False` for the dilation, which causes them to not
influence the result. Default is 'ignore'.
.. versionadded:: 0.23
`mode` was added in 0.23.
Returns
-------
opening : ndarray of bool
The result of the morphological opening.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate a
footprint sequence of this type.
See also
--------
skimage.morphology.isotropic_opening
"""
tmp = binary_erosion(image, footprint, mode=mode)
out = binary_dilation(tmp, footprint, out=out, mode=mode)
return out
@default_footprint
def binary_closing(image, footprint=None, out=None, *, mode='ignore'):
"""Return fast binary morphological closing of an image.
This function returns the same result as grayscale closing but performs
faster for binary images.
The morphological closing on an image is defined as a dilation followed by
an erosion. Closing can remove small dark spots (i.e. "pepper") and connect
small bright cracks. This tends to "close" up (dark) gaps between (bright)
features.
Parameters
----------
image : ndarray
Binary input image.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray of bool, optional
The array to store the result of the morphology. If None,
is passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'max', 'min', 'ignore'.
If 'ignore', pixels outside the image domain are assumed to be `True`
for the erosion and `False` for the dilation, which causes them to not
influence the result. Default is 'ignore'.
.. versionadded:: 0.23
`mode` was added in 0.23.
Returns
-------
closing : ndarray of bool
The result of the morphological closing.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate a
footprint sequence of this type.
See also
--------
skimage.morphology.isotropic_closing
"""
tmp = binary_dilation(image, footprint, mode=mode)
out = binary_erosion(tmp, footprint, out=out, mode=mode)
return out

View File

@@ -0,0 +1,222 @@
"""Convex Hull."""
from itertools import product
import numpy as np
from scipy.spatial import ConvexHull, QhullError
from ..measure.pnpoly import grid_points_in_poly
from ._convex_hull import possible_hull
from ..measure._label import label
from ..util import unique_rows
from .._shared.utils import warn
__all__ = ['convex_hull_image', 'convex_hull_object']
def _offsets_diamond(ndim):
offsets = np.zeros((2 * ndim, ndim))
for vertex, (axis, offset) in enumerate(product(range(ndim), (-0.5, 0.5))):
offsets[vertex, axis] = offset
return offsets
def _check_coords_in_hull(gridcoords, hull_equations, tolerance):
r"""Checks all the coordinates for inclusiveness in the convex hull.
Parameters
----------
gridcoords : (M, N) ndarray
Coordinates of ``N`` points in ``M`` dimensions.
hull_equations : (M, N) ndarray
Hyperplane equations of the facets of the convex hull.
tolerance : float
Tolerance when determining whether a point is inside the hull. Due
to numerical floating point errors, a tolerance of 0 can result in
some points erroneously being classified as being outside the hull.
Returns
-------
coords_in_hull : ndarray of bool
Binary 1D ndarray representing points in n-dimensional space
with value ``True`` set for points inside the convex hull.
Notes
-----
Checking the inclusiveness of coordinates in a convex hull requires
intermediate calculations of dot products which are memory-intensive.
Thus, the convex hull equations are checked individually with all
coordinates to keep within the memory limit.
References
----------
.. [1] https://github.com/scikit-image/scikit-image/issues/5019
"""
ndim, n_coords = gridcoords.shape
n_hull_equations = hull_equations.shape[0]
coords_in_hull = np.ones(n_coords, dtype=bool)
# Pre-allocate arrays to cache intermediate results for reducing overheads
dot_array = np.empty(n_coords, dtype=np.float64)
test_ineq_temp = np.empty(n_coords, dtype=np.float64)
coords_single_ineq = np.empty(n_coords, dtype=bool)
# A point is in the hull if it satisfies all of the hull's inequalities
for idx in range(n_hull_equations):
# Tests a hyperplane equation on all coordinates of volume
np.dot(hull_equations[idx, :ndim], gridcoords, out=dot_array)
np.add(dot_array, hull_equations[idx, ndim:], out=test_ineq_temp)
np.less(test_ineq_temp, tolerance, out=coords_single_ineq)
coords_in_hull *= coords_single_ineq
return coords_in_hull
def convex_hull_image(
image, offset_coordinates=True, tolerance=1e-10, include_borders=True
):
"""Compute the convex hull image of a binary image.
The convex hull is the set of pixels included in the smallest convex
polygon that surround all white pixels in the input image.
Parameters
----------
image : array
Binary input image. This array is cast to bool before processing.
offset_coordinates : bool, optional
If ``True``, a pixel at coordinate, e.g., (4, 7) will be represented
by coordinates (3.5, 7), (4.5, 7), (4, 6.5), and (4, 7.5). This adds
some "extent" to a pixel when computing the hull.
tolerance : float, optional
Tolerance when determining whether a point is inside the hull. Due
to numerical floating point errors, a tolerance of 0 can result in
some points erroneously being classified as being outside the hull.
include_borders: bool, optional
If ``False``, vertices/edges are excluded from the final hull mask.
Returns
-------
hull : (M, N) array of bool
Binary image with pixels in convex hull set to True.
References
----------
.. [1] https://blogs.mathworks.com/steve/2011/10/04/binary-image-convex-hull-algorithm-notes/
"""
ndim = image.ndim
if np.count_nonzero(image) == 0:
warn(
"Input image is entirely zero, no valid convex hull. "
"Returning empty image",
UserWarning,
)
return np.zeros(image.shape, dtype=bool)
# In 2D, we do an optimisation by choosing only pixels that are
# the starting or ending pixel of a row or column. This vastly
# limits the number of coordinates to examine for the virtual hull.
if ndim == 2:
coords = possible_hull(np.ascontiguousarray(image, dtype=np.uint8))
else:
coords = np.transpose(np.nonzero(image))
if offset_coordinates:
# when offsetting, we multiply number of vertices by 2 * ndim.
# therefore, we reduce the number of coordinates by using a
# convex hull on the original set, before offsetting.
try:
hull0 = ConvexHull(coords)
except QhullError as err:
warn(
f"Failed to get convex hull image. "
f"Returning empty image, see error message below:\n"
f"{err}"
)
return np.zeros(image.shape, dtype=bool)
coords = hull0.points[hull0.vertices]
# Add a vertex for the middle of each pixel edge
if offset_coordinates:
offsets = _offsets_diamond(image.ndim)
coords = (coords[:, np.newaxis, :] + offsets).reshape(-1, ndim)
# repeated coordinates can *sometimes* cause problems in
# scipy.spatial.ConvexHull, so we remove them.
coords = unique_rows(coords)
# Find the convex hull
try:
hull = ConvexHull(coords)
except QhullError as err:
warn(
f"Failed to get convex hull image. "
f"Returning empty image, see error message below:\n"
f"{err}"
)
return np.zeros(image.shape, dtype=bool)
vertices = hull.points[hull.vertices]
# If 2D, use fast Cython function to locate convex hull pixels
if ndim == 2:
labels = grid_points_in_poly(image.shape, vertices, binarize=False)
# If include_borders is True, we include vertices (2) and edge
# points (3) in the mask, otherwise only the inside of the hull (1)
mask = labels >= 1 if include_borders else labels == 1
else:
gridcoords = np.reshape(np.mgrid[tuple(map(slice, image.shape))], (ndim, -1))
coords_in_hull = _check_coords_in_hull(gridcoords, hull.equations, tolerance)
mask = np.reshape(coords_in_hull, image.shape)
return mask
def convex_hull_object(image, *, connectivity=2):
r"""Compute the convex hull image of individual objects in a binary image.
The convex hull is the set of pixels included in the smallest convex
polygon that surround all white pixels in the input image.
Parameters
----------
image : (M, N) ndarray
Binary input image.
connectivity : {1, 2}, int, optional
Determines the neighbors of each pixel. Adjacent elements
within a squared distance of ``connectivity`` from pixel center
are considered neighbors.::
1-connectivity 2-connectivity
[ ] [ ] [ ] [ ]
| \ | /
[ ]--[x]--[ ] [ ]--[x]--[ ]
| / | \
[ ] [ ] [ ] [ ]
Returns
-------
hull : ndarray of bool
Binary image with pixels inside convex hull set to ``True``.
Notes
-----
This function uses ``skimage.morphology.label`` to define unique objects,
finds the convex hull of each using ``convex_hull_image``, and combines
these regions with logical OR. Be aware the convex hulls of unconnected
objects may overlap in the result. If this is suspected, consider using
convex_hull_image separately on each object or adjust ``connectivity``.
"""
if image.ndim > 2:
raise ValueError("Input must be a 2D image")
if connectivity not in (1, 2):
raise ValueError('`connectivity` must be either 1 or 2.')
labeled_im = label(image, connectivity=connectivity, background=0)
convex_obj = np.zeros(image.shape, dtype=bool)
convex_img = np.zeros(image.shape, dtype=bool)
for i in range(1, labeled_im.max() + 1):
convex_obj = convex_hull_image(labeled_im == i)
convex_img = np.logical_or(convex_img, convex_obj)
return convex_img

View File

@@ -0,0 +1,549 @@
"""extrema.py - local minima and maxima
This module provides functions to find local maxima and minima of an image.
Here, local maxima (minima) are defined as connected sets of pixels with equal
gray level which is strictly greater (smaller) than the gray level of all
pixels in direct neighborhood of the connected set. In addition, the module
provides the related functions h-maxima and h-minima.
Soille, P. (2003). Morphological Image Analysis: Principles and Applications
(2nd ed.), Chapter 6. Springer-Verlag New York, Inc.
"""
import numpy as np
from .._shared.utils import warn
from ..util import dtype_limits, invert, crop
from . import grayreconstruct, _util
from ._extrema_cy import _local_maxima
def _add_constant_clip(image, const_value):
"""Add constant to the image while handling overflow issues gracefully."""
min_dtype, max_dtype = dtype_limits(image, clip_negative=False)
if const_value > (max_dtype - min_dtype):
raise ValueError(
"The added constant is not compatible" "with the image data type."
)
result = image + const_value
result[image > max_dtype - const_value] = max_dtype
return result
def _subtract_constant_clip(image, const_value):
"""Subtract constant from image while handling underflow issues."""
min_dtype, max_dtype = dtype_limits(image, clip_negative=False)
if const_value > (max_dtype - min_dtype):
raise ValueError(
"The subtracted constant is not compatible" "with the image data type."
)
result = image - const_value
result[image < (const_value + min_dtype)] = min_dtype
return result
def h_maxima(image, h, footprint=None):
"""Determine all maxima of the image with height >= h.
The local maxima are defined as connected sets of pixels with equal
gray level strictly greater than the gray level of all pixels in direct
neighborhood of the set.
A local maximum M of height h is a local maximum for which
there is at least one path joining M with an equal or higher local maximum
on which the minimal value is f(M) - h (i.e. the values along the path
are not decreasing by more than h with respect to the maximum's value)
and no path to an equal or higher local maximum for which the minimal
value is greater.
The global maxima of the image are also found by this function.
Parameters
----------
image : ndarray
The input image for which the maxima are to be calculated.
h : unsigned integer
The minimal height of all extracted maxima.
footprint : ndarray, optional
The neighborhood expressed as an n-D array of 1's and 0's.
Default is the ball of radius 1 according to the maximum norm
(i.e. a 3x3 square for 2D images, a 3x3x3 cube for 3D images, etc.)
Returns
-------
h_max : ndarray
The local maxima of height >= h and the global maxima.
The resulting image is a binary image, where pixels belonging to
the determined maxima take value 1, the others take value 0.
See Also
--------
skimage.morphology.h_minima
skimage.morphology.local_maxima
skimage.morphology.local_minima
References
----------
.. [1] Soille, P., "Morphological Image Analysis: Principles and
Applications" (Chapter 6), 2nd edition (2003), ISBN 3540429883.
Examples
--------
>>> import numpy as np
>>> from skimage.morphology import extrema
We create an image (quadratic function with a maximum in the center and
4 additional constant maxima.
The heights of the maxima are: 1, 21, 41, 61, 81
>>> w = 10
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 20 - 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:4,2:4] = 40; f[2:4,7:9] = 60; f[7:9,2:4] = 80; f[7:9,7:9] = 100
>>> f = f.astype(int)
We can calculate all maxima with a height of at least 40:
>>> maxima = extrema.h_maxima(f, 40)
The resulting image will contain 3 local maxima.
"""
# Check for h value that is larger then range of the image. If this
# is True then there are no h-maxima in the image.
if h > np.ptp(image):
return np.zeros(image.shape, dtype=np.uint8)
# Check for floating point h value. For this to work properly
# we need to explicitly convert image to float64.
#
# FIXME: This could give incorrect results if image is int64 and
# has a very high dynamic range. The dtype of image is
# changed to float64, and different integer values could
# become the same float due to rounding.
#
# >>> ii64 = np.iinfo(np.int64)
# >>> a = np.array([ii64.max, ii64.max - 2])
# >>> a[0] == a[1]
# False
# >>> b = a.astype(np.float64)
# >>> b[0] == b[1]
# True
#
if np.issubdtype(type(h), np.floating) and np.issubdtype(image.dtype, np.integer):
if (h % 1) != 0:
warn(
'possible precision loss converting image to '
'floating point. To silence this warning, '
'ensure image and h have same data type.',
stacklevel=2,
)
image = image.astype(float)
else:
h = image.dtype.type(h)
if h == 0:
raise ValueError("h = 0 is ambiguous, use local_maxima() " "instead?")
if np.issubdtype(image.dtype, np.floating):
# The purpose of the resolution variable is to allow for the
# small rounding errors that inevitably occur when doing
# floating point arithmetic. We want shifted_img to be
# guaranteed to be h less than image. If we only subtract h
# there may be pixels were shifted_img ends up being
# slightly greater than image - h.
#
# The resolution is scaled based on the pixel values in the
# image because floating point precision is relative. A
# very large value of 1.0e10 will have a large precision,
# say +-1.0e4, and a very small value of 1.0e-10 will have
# a very small precision, say +-1.0e-16.
#
resolution = 2 * np.finfo(image.dtype).resolution * np.abs(image)
shifted_img = image - h - resolution
else:
shifted_img = _subtract_constant_clip(image, h)
rec_img = grayreconstruct.reconstruction(
shifted_img, image, method='dilation', footprint=footprint
)
residue_img = image - rec_img
return (residue_img >= h).astype(np.uint8)
def h_minima(image, h, footprint=None):
"""Determine all minima of the image with depth >= h.
The local minima are defined as connected sets of pixels with equal
gray level strictly smaller than the gray levels of all pixels in direct
neighborhood of the set.
A local minimum M of depth h is a local minimum for which
there is at least one path joining M with an equal or lower local minimum
on which the maximal value is f(M) + h (i.e. the values along the path
are not increasing by more than h with respect to the minimum's value)
and no path to an equal or lower local minimum for which the maximal
value is smaller.
The global minima of the image are also found by this function.
Parameters
----------
image : ndarray
The input image for which the minima are to be calculated.
h : unsigned integer
The minimal depth of all extracted minima.
footprint : ndarray, optional
The neighborhood expressed as an n-D array of 1's and 0's.
Default is the ball of radius 1 according to the maximum norm
(i.e. a 3x3 square for 2D images, a 3x3x3 cube for 3D images, etc.)
Returns
-------
h_min : ndarray
The local minima of depth >= h and the global minima.
The resulting image is a binary image, where pixels belonging to
the determined minima take value 1, the others take value 0.
See Also
--------
skimage.morphology.h_maxima
skimage.morphology.local_maxima
skimage.morphology.local_minima
References
----------
.. [1] Soille, P., "Morphological Image Analysis: Principles and
Applications" (Chapter 6), 2nd edition (2003), ISBN 3540429883.
Examples
--------
>>> import numpy as np
>>> from skimage.morphology import extrema
We create an image (quadratic function with a minimum in the center and
4 additional constant maxima.
The depth of the minima are: 1, 21, 41, 61, 81
>>> w = 10
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 180 + 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:4,2:4] = 160; f[2:4,7:9] = 140; f[7:9,2:4] = 120; f[7:9,7:9] = 100
>>> f = f.astype(int)
We can calculate all minima with a depth of at least 40:
>>> minima = extrema.h_minima(f, 40)
The resulting image will contain 3 local minima.
"""
if h > np.ptp(image):
return np.zeros(image.shape, dtype=np.uint8)
if np.issubdtype(type(h), np.floating) and np.issubdtype(image.dtype, np.integer):
if (h % 1) != 0:
warn(
'possible precision loss converting image to '
'floating point. To silence this warning, '
'ensure image and h have same data type.',
stacklevel=2,
)
image = image.astype(float)
else:
h = image.dtype.type(h)
if h == 0:
raise ValueError("h = 0 is ambiguous, use local_minima() " "instead?")
if np.issubdtype(image.dtype, np.floating):
resolution = 2 * np.finfo(image.dtype).resolution * np.abs(image)
shifted_img = image + h + resolution
else:
shifted_img = _add_constant_clip(image, h)
rec_img = grayreconstruct.reconstruction(
shifted_img, image, method='erosion', footprint=footprint
)
residue_img = rec_img - image
return (residue_img >= h).astype(np.uint8)
def local_maxima(
image, footprint=None, connectivity=None, indices=False, allow_borders=True
):
"""Find local maxima of n-dimensional array.
The local maxima are defined as connected sets of pixels with equal gray
level (plateaus) strictly greater than the gray levels of all pixels in the
neighborhood.
Parameters
----------
image : ndarray
An n-dimensional array.
footprint : ndarray, optional
The footprint (structuring element) used to determine the neighborhood
of each evaluated pixel (``True`` denotes a connected pixel). It must
be a boolean array and have the same number of dimensions as `image`.
If neither `footprint` nor `connectivity` are given, all adjacent
pixels are considered as part of the neighborhood.
connectivity : int, optional
A number used to determine the neighborhood of each evaluated pixel.
Adjacent pixels whose squared distance from the center is less than or
equal to `connectivity` are considered neighbors. Ignored if
`footprint` is not None.
indices : bool, optional
If True, the output will be a tuple of one-dimensional arrays
representing the indices of local maxima in each dimension. If False,
the output will be a boolean array with the same shape as `image`.
allow_borders : bool, optional
If true, plateaus that touch the image border are valid maxima.
Returns
-------
maxima : ndarray or tuple[ndarray]
If `indices` is false, a boolean array with the same shape as `image`
is returned with ``True`` indicating the position of local maxima
(``False`` otherwise). If `indices` is true, a tuple of one-dimensional
arrays containing the coordinates (indices) of all found maxima.
Warns
-----
UserWarning
If `allow_borders` is false and any dimension of the given `image` is
shorter than 3 samples, maxima can't exist and a warning is shown.
See Also
--------
skimage.morphology.local_minima
skimage.morphology.h_maxima
skimage.morphology.h_minima
Notes
-----
This function operates on the following ideas:
1. Make a first pass over the image's last dimension and flag candidates
for local maxima by comparing pixels in only one direction.
If the pixels aren't connected in the last dimension all pixels are
flagged as candidates instead.
For each candidate:
2. Perform a flood-fill to find all connected pixels that have the same
gray value and are part of the plateau.
3. Consider the connected neighborhood of a plateau: if no bordering sample
has a higher gray level, mark the plateau as a definite local maximum.
Examples
--------
>>> from skimage.morphology import local_maxima
>>> image = np.zeros((4, 7), dtype=int)
>>> image[1:3, 1:3] = 1
>>> image[3, 0] = 1
>>> image[1:3, 4:6] = 2
>>> image[3, 6] = 3
>>> image
array([[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3]])
Find local maxima by comparing to all neighboring pixels (maximal
connectivity):
>>> local_maxima(image)
array([[False, False, False, False, False, False, False],
[False, True, True, False, False, False, False],
[False, True, True, False, False, False, False],
[ True, False, False, False, False, False, True]])
>>> local_maxima(image, indices=True)
(array([1, 1, 2, 2, 3, 3]), array([1, 2, 1, 2, 0, 6]))
Find local maxima without comparing to diagonal pixels (connectivity 1):
>>> local_maxima(image, connectivity=1)
array([[False, False, False, False, False, False, False],
[False, True, True, False, True, True, False],
[False, True, True, False, True, True, False],
[ True, False, False, False, False, False, True]])
and exclude maxima that border the image edge:
>>> local_maxima(image, connectivity=1, allow_borders=False)
array([[False, False, False, False, False, False, False],
[False, True, True, False, True, True, False],
[False, True, True, False, True, True, False],
[False, False, False, False, False, False, False]])
"""
image = np.asarray(image, order="C")
if image.size == 0:
# Return early for empty input
if indices:
# Make sure that output is a tuple of 1 empty array per dimension
return np.nonzero(image)
else:
return np.zeros(image.shape, dtype=bool)
if allow_borders:
# Ensure that local maxima are always at least one smaller sample away
# from the image border
image = np.pad(image, 1, mode='constant', constant_values=image.min())
# Array of flags used to store the state of each pixel during evaluation.
# See _extrema_cy.pyx for their meaning
flags = np.zeros(image.shape, dtype=np.uint8)
_util._set_border_values(flags, value=3)
if any(s < 3 for s in image.shape):
# Warn and skip if any dimension is smaller than 3
# -> no maxima can exist & footprint can't be applied
warn(
"maxima can't exist for an image with any dimension smaller 3 "
"if borders aren't allowed",
stacklevel=3,
)
else:
footprint = _util._resolve_neighborhood(footprint, connectivity, image.ndim)
neighbor_offsets = _util._offsets_to_raveled_neighbors(
image.shape, footprint, center=((1,) * image.ndim)
)
try:
_local_maxima(image.ravel(), flags.ravel(), neighbor_offsets)
except TypeError:
if image.dtype == np.float16:
# Provide the user with clearer error message
raise TypeError(
"dtype of `image` is float16 which is not "
"supported, try upcasting to float32"
)
else:
raise # Otherwise raise original message
if allow_borders:
# Revert padding performed at the beginning of the function
flags = crop(flags, 1)
else:
# No padding was performed but set edge values back to 0
_util._set_border_values(flags, value=0)
if indices:
return np.nonzero(flags)
else:
return flags.view(bool)
def local_minima(
image, footprint=None, connectivity=None, indices=False, allow_borders=True
):
"""Find local minima of n-dimensional array.
The local minima are defined as connected sets of pixels with equal gray
level (plateaus) strictly smaller than the gray levels of all pixels in the
neighborhood.
Parameters
----------
image : ndarray
An n-dimensional array.
footprint : ndarray, optional
The footprint (structuring element) used to determine the neighborhood
of each evaluated pixel (``True`` denotes a connected pixel). It must
be a boolean array and have the same number of dimensions as `image`.
If neither `footprint` nor `connectivity` are given, all adjacent
pixels are considered as part of the neighborhood.
connectivity : int, optional
A number used to determine the neighborhood of each evaluated pixel.
Adjacent pixels whose squared distance from the center is less than or
equal to `connectivity` are considered neighbors. Ignored if
`footprint` is not None.
indices : bool, optional
If True, the output will be a tuple of one-dimensional arrays
representing the indices of local minima in each dimension. If False,
the output will be a boolean array with the same shape as `image`.
allow_borders : bool, optional
If true, plateaus that touch the image border are valid minima.
Returns
-------
minima : ndarray or tuple[ndarray]
If `indices` is false, a boolean array with the same shape as `image`
is returned with ``True`` indicating the position of local minima
(``False`` otherwise). If `indices` is true, a tuple of one-dimensional
arrays containing the coordinates (indices) of all found minima.
See Also
--------
skimage.morphology.local_maxima
skimage.morphology.h_maxima
skimage.morphology.h_minima
Notes
-----
This function operates on the following ideas:
1. Make a first pass over the image's last dimension and flag candidates
for local minima by comparing pixels in only one direction.
If the pixels aren't connected in the last dimension all pixels are
flagged as candidates instead.
For each candidate:
2. Perform a flood-fill to find all connected pixels that have the same
gray value and are part of the plateau.
3. Consider the connected neighborhood of a plateau: if no bordering sample
has a smaller gray level, mark the plateau as a definite local minimum.
Examples
--------
>>> from skimage.morphology import local_minima
>>> image = np.zeros((4, 7), dtype=int)
>>> image[1:3, 1:3] = -1
>>> image[3, 0] = -1
>>> image[1:3, 4:6] = -2
>>> image[3, 6] = -3
>>> image
array([[ 0, 0, 0, 0, 0, 0, 0],
[ 0, -1, -1, 0, -2, -2, 0],
[ 0, -1, -1, 0, -2, -2, 0],
[-1, 0, 0, 0, 0, 0, -3]])
Find local minima by comparing to all neighboring pixels (maximal
connectivity):
>>> local_minima(image)
array([[False, False, False, False, False, False, False],
[False, True, True, False, False, False, False],
[False, True, True, False, False, False, False],
[ True, False, False, False, False, False, True]])
>>> local_minima(image, indices=True)
(array([1, 1, 2, 2, 3, 3]), array([1, 2, 1, 2, 0, 6]))
Find local minima without comparing to diagonal pixels (connectivity 1):
>>> local_minima(image, connectivity=1)
array([[False, False, False, False, False, False, False],
[False, True, True, False, True, True, False],
[False, True, True, False, True, True, False],
[ True, False, False, False, False, False, True]])
and exclude minima that border the image edge:
>>> local_minima(image, connectivity=1, allow_borders=False)
array([[False, False, False, False, False, False, False],
[False, True, True, False, True, True, False],
[False, True, True, False, True, True, False],
[False, False, False, False, False, False, False]])
"""
return local_maxima(
image=invert(image),
footprint=footprint,
connectivity=connectivity,
indices=indices,
allow_borders=allow_borders,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,703 @@
"""
Grayscale morphological operations
"""
import warnings
import numpy as np
from scipy import ndimage as ndi
from .footprints import _footprint_is_sequence, mirror_footprint, pad_footprint
from .misc import default_footprint
from .._shared.utils import DEPRECATED
__all__ = ['erosion', 'dilation', 'opening', 'closing', 'white_tophat', 'black_tophat']
def _iterate_gray_func(gray_func, image, footprints, out, mode, cval):
"""Helper to call `gray_func` for each footprint in a sequence.
`gray_func` is a morphology function that accepts `footprint`, `output`,
`mode` and `cval` keyword arguments (e.g. `scipy.ndimage.grey_erosion`).
"""
fp, num_iter = footprints[0]
gray_func(image, footprint=fp, output=out, mode=mode, cval=cval)
for _ in range(1, num_iter):
gray_func(out.copy(), footprint=fp, output=out, mode=mode, cval=cval)
for fp, num_iter in footprints[1:]:
# Note: out.copy() because the computation cannot be in-place!
for _ in range(num_iter):
gray_func(out.copy(), footprint=fp, output=out, mode=mode, cval=cval)
return out
def _shift_footprint(footprint, shift_x, shift_y):
"""Shift the binary image `footprint` in the left and/or up.
This only affects 2D footprints with even number of rows
or columns.
Parameters
----------
footprint : 2D array, shape (M, N)
The input footprint.
shift_x, shift_y : bool or None
Whether to move `footprint` along each axis. If ``None``, the
array is not modified along that dimension.
Returns
-------
out : 2D array, shape (M + int(shift_x), N + int(shift_y))
The shifted footprint.
"""
footprint = np.asarray(footprint)
if footprint.ndim != 2:
# do nothing for 1D or 3D or higher footprints
return footprint
m, n = footprint.shape
if m % 2 == 0:
extra_row = np.zeros((1, n), footprint.dtype)
if shift_x:
footprint = np.vstack((footprint, extra_row))
else:
footprint = np.vstack((extra_row, footprint))
m += 1
if n % 2 == 0:
extra_col = np.zeros((m, 1), footprint.dtype)
if shift_y:
footprint = np.hstack((footprint, extra_col))
else:
footprint = np.hstack((extra_col, footprint))
return footprint
def _shift_footprints(footprint, shift_x, shift_y):
"""Shifts the footprints, whether it's a single array or a sequence.
See `_shift_footprint`, which is called for each array in the sequence.
"""
if shift_x is DEPRECATED and shift_y is DEPRECATED:
return footprint
warning_msg = (
"The parameters `shift_x` and `shift_y` are deprecated since v0.23 and "
"will be removed in v0.26. Use `pad_footprint` or modify the footprint"
"manually instead."
)
warnings.warn(warning_msg, FutureWarning, stacklevel=4)
if _footprint_is_sequence(footprint):
return tuple((_shift_footprint(fp, shift_x, shift_y), n) for fp, n in footprint)
return _shift_footprint(footprint, shift_x, shift_y)
def _min_max_to_constant_mode(dtype, mode, cval):
"""Replace 'max' and 'min' with appropriate 'cval' and 'constant' mode."""
if mode == "max":
mode = "constant"
if np.issubdtype(dtype, bool):
cval = True
elif np.issubdtype(dtype, np.integer):
cval = np.iinfo(dtype).max
else:
cval = np.inf
elif mode == "min":
mode = "constant"
if np.issubdtype(dtype, bool):
cval = False
elif np.issubdtype(dtype, np.integer):
cval = np.iinfo(dtype).min
else:
cval = -np.inf
return mode, cval
_SUPPORTED_MODES = {
"reflect",
"constant",
"nearest",
"mirror",
"wrap",
"max",
"min",
"ignore",
}
@default_footprint
def erosion(
image,
footprint=None,
out=None,
shift_x=DEPRECATED,
shift_y=DEPRECATED,
*,
mode="reflect",
cval=0.0,
):
"""Return grayscale morphological erosion of an image.
Morphological erosion sets a pixel at (i,j) to the minimum over all pixels
in the neighborhood centered at (i,j). Erosion shrinks bright regions and
enlarges dark regions.
Parameters
----------
image : ndarray
Image array.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarrays, optional
The array to store the result of the morphology. If None is
passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
'max', 'min', or 'ignore'.
If 'max' or 'ignore', pixels outside the image domain are assumed
to be the maximum for the image's dtype, which causes them to not
influence the result. Default is 'reflect'.
cval : scalar, optional
Value to fill past edges of input if `mode` is 'constant'. Default
is 0.0.
.. versionadded:: 0.23
`mode` and `cval` were added in 0.23.
Returns
-------
eroded : array, same shape as `image`
The result of the morphological erosion.
Other Parameters
----------------
shift_x, shift_y : DEPRECATED
.. deprecated:: 0.23
Notes
-----
For ``uint8`` (and ``uint16`` up to a certain bit-depth) data, the
lower algorithm complexity makes the :func:`skimage.filters.rank.minimum`
function more efficient for larger images and footprints.
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate
a footprint sequence of this type.
For even-sized footprints, :func:`skimage.morphology.binary_erosion` and
this function produce an output that differs: one is shifted by one pixel
compared to the other.
Examples
--------
>>> # Erosion shrinks bright regions
>>> import numpy as np
>>> from skimage.morphology import square
>>> bright_square = np.array([[0, 0, 0, 0, 0],
... [0, 1, 1, 1, 0],
... [0, 1, 1, 1, 0],
... [0, 1, 1, 1, 0],
... [0, 0, 0, 0, 0]], dtype=np.uint8)
>>> erosion(bright_square, square(3))
array([[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]], dtype=uint8)
"""
if out is None:
out = np.empty_like(image)
if mode not in _SUPPORTED_MODES:
raise ValueError(f"unsupported mode, got {mode!r}")
if mode == "ignore":
mode = "max"
mode, cval = _min_max_to_constant_mode(image.dtype, mode, cval)
footprint = _shift_footprints(footprint, shift_x, shift_y)
footprint = pad_footprint(footprint, pad_end=False)
if not _footprint_is_sequence(footprint):
footprint = [(footprint, 1)]
out = _iterate_gray_func(
gray_func=ndi.grey_erosion,
image=image,
footprints=footprint,
out=out,
mode=mode,
cval=cval,
)
return out
@default_footprint
def dilation(
image,
footprint=None,
out=None,
shift_x=DEPRECATED,
shift_y=DEPRECATED,
*,
mode="reflect",
cval=0.0,
):
"""Return grayscale morphological dilation of an image.
Morphological dilation sets the value of a pixel to the maximum over all
pixel values within a local neighborhood centered about it. The values
where the footprint is 1 define this neighborhood.
Dilation enlarges bright regions and shrinks dark regions.
Parameters
----------
image : ndarray
Image array.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray, optional
The array to store the result of the morphology. If None is
passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
'max', 'min', or 'ignore'.
If 'min' or 'ignore', pixels outside the image domain are assumed
to be the maximum for the image's dtype, which causes them to not
influence the result. Default is 'reflect'.
cval : scalar, optional
Value to fill past edges of input if `mode` is 'constant'. Default
is 0.0.
.. versionadded:: 0.23
`mode` and `cval` were added in 0.23.
Returns
-------
dilated : uint8 array, same shape and type as `image`
The result of the morphological dilation.
Other Parameters
----------------
shift_x, shift_y : DEPRECATED
.. deprecated:: 0.23
Notes
-----
For ``uint8`` (and ``uint16`` up to a certain bit-depth) data, the lower
algorithm complexity makes the :func:`skimage.filters.rank.maximum`
function more efficient for larger images and footprints.
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate
a footprint sequence of this type.
For non-symmetric footprints, :func:`skimage.morphology.binary_dilation`
and :func:`skimage.morphology.dilation` produce an output that differs:
`binary_dilation` mirrors the footprint, whereas `dilation` does not.
Examples
--------
>>> # Dilation enlarges bright regions
>>> import numpy as np
>>> from skimage.morphology import square
>>> bright_pixel = np.array([[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]], dtype=np.uint8)
>>> dilation(bright_pixel, square(3))
array([[0, 0, 0, 0, 0],
[0, 1, 1, 1, 0],
[0, 1, 1, 1, 0],
[0, 1, 1, 1, 0],
[0, 0, 0, 0, 0]], dtype=uint8)
"""
if out is None:
out = np.empty_like(image)
if mode not in _SUPPORTED_MODES:
raise ValueError(f"unsupported mode, got {mode!r}")
if mode == "ignore":
mode = "min"
mode, cval = _min_max_to_constant_mode(image.dtype, mode, cval)
footprint = _shift_footprints(footprint, shift_x, shift_y)
footprint = pad_footprint(footprint, pad_end=False)
# Note that `ndi.grey_dilation` mirrors the footprint and this
# additional inversion should be removed in skimage2, see gh-6676.
footprint = mirror_footprint(footprint)
if not _footprint_is_sequence(footprint):
footprint = [(footprint, 1)]
out = _iterate_gray_func(
gray_func=ndi.grey_dilation,
image=image,
footprints=footprint,
out=out,
mode=mode,
cval=cval,
)
return out
@default_footprint
def opening(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
"""Return grayscale morphological opening of an image.
The morphological opening of an image is defined as an erosion followed by
a dilation. Opening can remove small bright spots (i.e. "salt") and connect
small dark cracks. This tends to "open" up (dark) gaps between (bright)
features.
Parameters
----------
image : ndarray
Image array.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray, optional
The array to store the result of the morphology. If None
is passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
'max', 'min', or 'ignore'.
If 'ignore', pixels outside the image domain are assumed
to be the maximum for the image's dtype in the erosion, and minimum
in the dilation, which causes them to not influence the result.
Default is 'reflect'.
cval : scalar, optional
Value to fill past edges of input if `mode` is 'constant'. Default
is 0.0.
.. versionadded:: 0.23
`mode` and `cval` were added in 0.23.
Returns
-------
opening : array, same shape and type as `image`
The result of the morphological opening.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate
a footprint sequence of this type.
Examples
--------
>>> # Open up gap between two bright regions (but also shrink regions)
>>> import numpy as np
>>> from skimage.morphology import square
>>> bad_connection = np.array([[1, 0, 0, 0, 1],
... [1, 1, 0, 1, 1],
... [1, 1, 1, 1, 1],
... [1, 1, 0, 1, 1],
... [1, 0, 0, 0, 1]], dtype=np.uint8)
>>> opening(bad_connection, square(3))
array([[0, 0, 0, 0, 0],
[1, 1, 0, 1, 1],
[1, 1, 0, 1, 1],
[1, 1, 0, 1, 1],
[0, 0, 0, 0, 0]], dtype=uint8)
"""
footprint = pad_footprint(footprint, pad_end=False)
eroded = erosion(image, footprint, mode=mode, cval=cval)
out = dilation(eroded, mirror_footprint(footprint), out=out, mode=mode, cval=cval)
return out
@default_footprint
def closing(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
"""Return grayscale morphological closing of an image.
The morphological closing of an image is defined as a dilation followed by
an erosion. Closing can remove small dark spots (i.e. "pepper") and connect
small bright cracks. This tends to "close" up (dark) gaps between (bright)
features.
Parameters
----------
image : ndarray
Image array.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray, optional
The array to store the result of the morphology. If None,
a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
'max', 'min', or 'ignore'.
If 'ignore', pixels outside the image domain are assumed
to be the maximum for the image's dtype in the erosion, and minimum
in the dilation, which causes them to not influence the result.
Default is 'reflect'.
cval : scalar, optional
Value to fill past edges of input if `mode` is 'constant'. Default
is 0.0.
.. versionadded:: 0.23
`mode` and `cval` were added in 0.23.
Returns
-------
closing : array, same shape and type as `image`
The result of the morphological closing.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate
a footprint sequence of this type.
Examples
--------
>>> # Close a gap between two bright lines
>>> import numpy as np
>>> from skimage.morphology import square
>>> broken_line = np.array([[0, 0, 0, 0, 0],
... [0, 0, 0, 0, 0],
... [1, 1, 0, 1, 1],
... [0, 0, 0, 0, 0],
... [0, 0, 0, 0, 0]], dtype=np.uint8)
>>> closing(broken_line, square(3))
array([[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[1, 1, 1, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]], dtype=uint8)
"""
footprint = pad_footprint(footprint, pad_end=False)
dilated = dilation(image, footprint, mode=mode, cval=cval)
out = erosion(dilated, mirror_footprint(footprint), out=out, mode=mode, cval=cval)
return out
@default_footprint
def white_tophat(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
"""Return white top hat of an image.
The white top hat of an image is defined as the image minus its
morphological opening. This operation returns the bright spots of the image
that are smaller than the footprint.
Parameters
----------
image : ndarray
Image array.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray, optional
The array to store the result of the morphology. If None
is passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
'max', 'min', or 'ignore'. See :func:`skimage.morphology.opening`.
Default is 'reflect'.
cval : scalar, optional
Value to fill past edges of input if `mode` is 'constant'. Default
is 0.0.
.. versionadded:: 0.23
`mode` and `cval` were added in 0.23.
Returns
-------
out : array, same shape and type as `image`
The result of the morphological white top hat.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate
a footprint sequence of this type.
See Also
--------
black_tophat
References
----------
.. [1] https://en.wikipedia.org/wiki/Top-hat_transform
Examples
--------
>>> # Subtract gray background from bright peak
>>> import numpy as np
>>> from skimage.morphology import square
>>> bright_on_gray = np.array([[2, 3, 3, 3, 2],
... [3, 4, 5, 4, 3],
... [3, 5, 9, 5, 3],
... [3, 4, 5, 4, 3],
... [2, 3, 3, 3, 2]], dtype=np.uint8)
>>> white_tophat(bright_on_gray, square(3))
array([[0, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 1, 5, 1, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 0, 0]], dtype=uint8)
"""
if out is image:
# We need a temporary image
opened = opening(image, footprint, mode=mode, cval=cval)
if np.issubdtype(opened.dtype, bool):
np.logical_xor(out, opened, out=out)
else:
out -= opened
return out
# Else write intermediate result into output image
out = opening(image, footprint, out=out, mode=mode, cval=cval)
if np.issubdtype(out.dtype, bool):
np.logical_xor(image, out, out=out)
else:
np.subtract(image, out, out=out)
return out
@default_footprint
def black_tophat(image, footprint=None, out=None, *, mode="reflect", cval=0.0):
"""Return black top hat of an image.
The black top hat of an image is defined as its morphological closing minus
the original image. This operation returns the dark spots of the image that
are smaller than the footprint. Note that dark spots in the
original image are bright spots after the black top hat.
Parameters
----------
image : ndarray
Image array.
footprint : ndarray or tuple, optional
The neighborhood expressed as a 2-D array of 1's and 0's.
If None, use a cross-shaped footprint (connectivity=1). The footprint
can also be provided as a sequence of smaller footprints as described
in the notes below.
out : ndarray, optional
The array to store the result of the morphology. If None
is passed, a new array will be allocated.
mode : str, optional
The `mode` parameter determines how the array borders are handled.
Valid modes are: 'reflect', 'constant', 'nearest', 'mirror', 'wrap',
'max', 'min', or 'ignore'. See :func:`skimage.morphology.closing`.
Default is 'reflect'.
cval : scalar, optional
Value to fill past edges of input if `mode` is 'constant'. Default
is 0.0.
.. versionadded:: 0.23
`mode` and `cval` were added in 0.23.
Returns
-------
out : array, same shape and type as `image`
The result of the morphological black top hat.
Notes
-----
The footprint can also be a provided as a sequence of 2-tuples where the
first element of each 2-tuple is a footprint ndarray and the second element
is an integer describing the number of times it should be iterated. For
example ``footprint=[(np.ones((9, 1)), 1), (np.ones((1, 9)), 1)]``
would apply a 9x1 footprint followed by a 1x9 footprint resulting in a net
effect that is the same as ``footprint=np.ones((9, 9))``, but with lower
computational cost. Most of the builtin footprints such as
:func:`skimage.morphology.disk` provide an option to automatically generate
a footprint sequence of this type.
See Also
--------
white_tophat
References
----------
.. [1] https://en.wikipedia.org/wiki/Top-hat_transform
Examples
--------
>>> # Change dark peak to bright peak and subtract background
>>> import numpy as np
>>> from skimage.morphology import square
>>> dark_on_gray = np.array([[7, 6, 6, 6, 7],
... [6, 5, 4, 5, 6],
... [6, 4, 0, 4, 6],
... [6, 5, 4, 5, 6],
... [7, 6, 6, 6, 7]], dtype=np.uint8)
>>> black_tophat(dark_on_gray, square(3))
array([[0, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 1, 5, 1, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 0, 0]], dtype=uint8)
"""
if out is image:
# We need a temporary image
closed = closing(image, footprint, mode=mode, cval=cval)
if np.issubdtype(closed.dtype, bool):
np.logical_xor(closed, out, out=out)
else:
np.subtract(closed, out, out=out)
return out
out = closing(image, footprint, out=out, mode=mode, cval=cval)
if np.issubdtype(out.dtype, np.bool_):
np.logical_xor(out, image, out=out)
else:
out -= image
return out

View File

@@ -0,0 +1,217 @@
import numpy as np
from .._shared.utils import _supported_float_type
from ..filters._rank_order import rank_order
from ._grayreconstruct import reconstruction_loop
def reconstruction(seed, mask, method='dilation', footprint=None, offset=None):
"""Perform a morphological reconstruction of an image.
Morphological reconstruction by dilation is similar to basic morphological
dilation: high-intensity values will replace nearby low-intensity values.
The basic dilation operator, however, uses a footprint to
determine how far a value in the input image can spread. In contrast,
reconstruction uses two images: a "seed" image, which specifies the values
that spread, and a "mask" image, which gives the maximum allowed value at
each pixel. The mask image, like the footprint, limits the spread
of high-intensity values. Reconstruction by erosion is simply the inverse:
low-intensity values spread from the seed image and are limited by the mask
image, which represents the minimum allowed value.
Alternatively, you can think of reconstruction as a way to isolate the
connected regions of an image. For dilation, reconstruction connects
regions marked by local maxima in the seed image: neighboring pixels
less-than-or-equal-to those seeds are connected to the seeded region.
Local maxima with values larger than the seed image will get truncated to
the seed value.
Parameters
----------
seed : ndarray
The seed image (a.k.a. marker image), which specifies the values that
are dilated or eroded.
mask : ndarray
The maximum (dilation) / minimum (erosion) allowed value at each pixel.
method : {'dilation'|'erosion'}, optional
Perform reconstruction by dilation or erosion. In dilation (or
erosion), the seed image is dilated (or eroded) until limited by the
mask image. For dilation, each seed value must be less than or equal
to the corresponding mask value; for erosion, the reverse is true.
Default is 'dilation'.
footprint : ndarray, optional
The neighborhood expressed as an n-D array of 1's and 0's.
Default is the n-D square of radius equal to 1 (i.e. a 3x3 square
for 2D images, a 3x3x3 cube for 3D images, etc.)
offset : ndarray, optional
The coordinates of the center of the footprint.
Default is located on the geometrical center of the footprint, in that
case footprint dimensions must be odd.
Returns
-------
reconstructed : ndarray
The result of morphological reconstruction.
Examples
--------
>>> import numpy as np
>>> from skimage.morphology import reconstruction
First, we create a sinusoidal mask image with peaks at middle and ends.
>>> x = np.linspace(0, 4 * np.pi)
>>> y_mask = np.cos(x)
Then, we create a seed image initialized to the minimum mask value (for
reconstruction by dilation, min-intensity values don't spread) and add
"seeds" to the left and right peak, but at a fraction of peak value (1).
>>> y_seed = y_mask.min() * np.ones_like(x)
>>> y_seed[0] = 0.5
>>> y_seed[-1] = 0
>>> y_rec = reconstruction(y_seed, y_mask)
The reconstructed image (or curve, in this case) is exactly the same as the
mask image, except that the peaks are truncated to 0.5 and 0. The middle
peak disappears completely: Since there were no seed values in this peak
region, its reconstructed value is truncated to the surrounding value (-1).
As a more practical example, we try to extract the bright features of an
image by subtracting a background image created by reconstruction.
>>> y, x = np.mgrid[:20:0.5, :20:0.5]
>>> bumps = np.sin(x) + np.sin(y)
To create the background image, set the mask image to the original image,
and the seed image to the original image with an intensity offset, `h`.
>>> h = 0.3
>>> seed = bumps - h
>>> background = reconstruction(seed, bumps)
The resulting reconstructed image looks exactly like the original image,
but with the peaks of the bumps cut off. Subtracting this reconstructed
image from the original image leaves just the peaks of the bumps
>>> hdome = bumps - background
This operation is known as the h-dome of the image and leaves features
of height `h` in the subtracted image.
Notes
-----
The algorithm is taken from [1]_. Applications for grayscale reconstruction
are discussed in [2]_ and [3]_.
References
----------
.. [1] Robinson, "Efficient morphological reconstruction: a downhill
filter", Pattern Recognition Letters 25 (2004) 1759-1767.
.. [2] Vincent, L., "Morphological Grayscale Reconstruction in Image
Analysis: Applications and Efficient Algorithms", IEEE Transactions
on Image Processing (1993)
.. [3] Soille, P., "Morphological Image Analysis: Principles and
Applications", Chapter 6, 2nd edition (2003), ISBN 3540429883.
"""
assert tuple(seed.shape) == tuple(mask.shape)
if method == 'dilation' and np.any(seed > mask):
raise ValueError(
"Intensity of seed image must be less than that "
"of the mask image for reconstruction by dilation."
)
elif method == 'erosion' and np.any(seed < mask):
raise ValueError(
"Intensity of seed image must be greater than that "
"of the mask image for reconstruction by erosion."
)
if footprint is None:
footprint = np.ones([3] * seed.ndim, dtype=bool)
else:
footprint = footprint.astype(bool, copy=True)
if offset is None:
if not all([d % 2 == 1 for d in footprint.shape]):
raise ValueError("Footprint dimensions must all be odd")
offset = np.array([d // 2 for d in footprint.shape])
else:
if offset.ndim != footprint.ndim:
raise ValueError("Offset and footprint ndims must be equal.")
if not all([(0 <= o < d) for o, d in zip(offset, footprint.shape)]):
raise ValueError("Offset must be included inside footprint")
# Cross out the center of the footprint
footprint[tuple(slice(d, d + 1) for d in offset)] = False
# Make padding for edges of reconstructed image so we can ignore boundaries
dims = np.zeros(seed.ndim + 1, dtype=int)
dims[1:] = np.array(seed.shape) + (np.array(footprint.shape) - 1)
dims[0] = 2
inside_slices = tuple(slice(o, o + s) for o, s in zip(offset, seed.shape))
# Set padded region to minimum image intensity and mask along first axis so
# we can interleave image and mask pixels when sorting.
if method == 'dilation':
pad_value = np.min(seed)
elif method == 'erosion':
pad_value = np.max(seed)
else:
raise ValueError(
"Reconstruction method can be one of 'erosion' "
f"or 'dilation'. Got '{method}'."
)
float_dtype = _supported_float_type(mask.dtype)
images = np.full(dims, pad_value, dtype=float_dtype)
images[(0, *inside_slices)] = seed
images[(1, *inside_slices)] = mask
# determine whether image is large enough to require 64-bit integers
isize = images.size
# use -isize so we get a signed dtype rather than an unsigned one
signed_int_dtype = np.result_type(np.min_scalar_type(-isize), np.int32)
# the corresponding unsigned type has same char, but uppercase
unsigned_int_dtype = np.dtype(signed_int_dtype.char.upper())
# Create a list of strides across the array to get the neighbors within
# a flattened array
value_stride = np.array(images.strides[1:]) // images.dtype.itemsize
image_stride = images.strides[0] // images.dtype.itemsize
footprint_mgrid = np.mgrid[
[slice(-o, d - o) for d, o in zip(footprint.shape, offset)]
]
footprint_offsets = footprint_mgrid[:, footprint].transpose()
nb_strides = np.array(
[
np.sum(value_stride * footprint_offset)
for footprint_offset in footprint_offsets
],
signed_int_dtype,
)
images = images.reshape(-1)
# Erosion goes smallest to largest; dilation goes largest to smallest.
index_sorted = np.argsort(images).astype(signed_int_dtype, copy=False)
if method == 'dilation':
index_sorted = index_sorted[::-1]
# Make a linked list of pixels sorted by value. -1 is the list terminator.
prev = np.full(isize, -1, signed_int_dtype)
next = np.full(isize, -1, signed_int_dtype)
prev[index_sorted[1:]] = index_sorted[:-1]
next[index_sorted[:-1]] = index_sorted[1:]
# Cython inner-loop compares the rank of pixel values.
if method == 'dilation':
value_rank, value_map = rank_order(images)
elif method == 'erosion':
value_rank, value_map = rank_order(-images)
value_map = -value_map
start = index_sorted[0]
value_rank = value_rank.astype(unsigned_int_dtype, copy=False)
reconstruction_loop(value_rank, prev, next, nb_strides, start, image_stride)
# Reshape reconstructed image to original image shape and remove padding.
rec_img = value_map[value_rank[:image_stride]]
rec_img.shape = np.array(seed.shape) + (np.array(footprint.shape) - 1)
return rec_img[inside_slices]

View File

@@ -0,0 +1,194 @@
"""
Binary morphological operations
"""
import numpy as np
from scipy import ndimage as ndi
def isotropic_erosion(image, radius, out=None, spacing=None):
"""Return binary morphological erosion of an image.
This function returns the same result as :func:`skimage.morphology.binary_erosion`
but performs faster for large circular structuring elements.
This works by applying a threshold to the exact Euclidean distance map
of the image [1]_, [2]_.
The implementation is based on: func:`scipy.ndimage.distance_transform_edt`.
Parameters
----------
image : ndarray
Binary input image.
radius : float
The radius by which regions should be eroded.
out : ndarray of bool, optional
The array to store the result of the morphology. If None,
a new array will be allocated.
spacing : float, or sequence of float, optional
Spacing of elements along each dimension.
If a sequence, must be of length equal to the input's dimension (number of axes).
If a single number, this value is used for all axes.
If not specified, a grid spacing of unity is implied.
Returns
-------
eroded : ndarray of bool
The result of the morphological erosion taking values in
``[False, True]``.
References
----------
.. [1] Cuisenaire, O. and Macq, B., "Fast Euclidean morphological operators
using local distance transformation by propagation, and applications,"
Image Processing And Its Applications, 1999. Seventh International
Conference on (Conf. Publ. No. 465), 1999, pp. 856-860 vol.2.
:DOI:`10.1049/cp:19990446`
.. [2] Ingemar Ragnemalm, Fast erosion and dilation by contour processing
and thresholding of distance maps, Pattern Recognition Letters,
Volume 13, Issue 3, 1992, Pages 161-166.
:DOI:`10.1016/0167-8655(92)90055-5`
"""
dist = ndi.distance_transform_edt(image, sampling=spacing)
return np.greater(dist, radius, out=out)
def isotropic_dilation(image, radius, out=None, spacing=None):
"""Return binary morphological dilation of an image.
This function returns the same result as :func:`skimage.morphology.binary_dilation`
but performs faster for large circular structuring elements.
This works by applying a threshold to the exact Euclidean distance map
of the inverted image [1]_, [2]_.
The implementation is based on: func:`scipy.ndimage.distance_transform_edt`.
Parameters
----------
image : ndarray
Binary input image.
radius : float
The radius by which regions should be dilated.
out : ndarray of bool, optional
The array to store the result of the morphology. If None is
passed, a new array will be allocated.
spacing : float, or sequence of float, optional
Spacing of elements along each dimension.
If a sequence, must be of length equal to the input's dimension (number of axes).
If a single number, this value is used for all axes.
If not specified, a grid spacing of unity is implied.
Returns
-------
dilated : ndarray of bool
The result of the morphological dilation with values in
``[False, True]``.
References
----------
.. [1] Cuisenaire, O. and Macq, B., "Fast Euclidean morphological operators
using local distance transformation by propagation, and applications,"
Image Processing And Its Applications, 1999. Seventh International
Conference on (Conf. Publ. No. 465), 1999, pp. 856-860 vol.2.
:DOI:`10.1049/cp:19990446`
.. [2] Ingemar Ragnemalm, Fast erosion and dilation by contour processing
and thresholding of distance maps, Pattern Recognition Letters,
Volume 13, Issue 3, 1992, Pages 161-166.
:DOI:`10.1016/0167-8655(92)90055-5`
"""
dist = ndi.distance_transform_edt(np.logical_not(image), sampling=spacing)
return np.less_equal(dist, radius, out=out)
def isotropic_opening(image, radius, out=None, spacing=None):
"""Return binary morphological opening of an image.
This function returns the same result as :func:`skimage.morphology.binary_opening`
but performs faster for large circular structuring elements.
This works by thresholding the exact Euclidean distance map [1]_, [2]_.
The implementation is based on: func:`scipy.ndimage.distance_transform_edt`.
Parameters
----------
image : ndarray
Binary input image.
radius : float
The radius with which the regions should be opened.
out : ndarray of bool, optional
The array to store the result of the morphology. If None
is passed, a new array will be allocated.
spacing : float, or sequence of float, optional
Spacing of elements along each dimension.
If a sequence, must be of length equal to the input's dimension (number of axes).
If a single number, this value is used for all axes.
If not specified, a grid spacing of unity is implied.
Returns
-------
opened : ndarray of bool
The result of the morphological opening.
References
----------
.. [1] Cuisenaire, O. and Macq, B., "Fast Euclidean morphological operators
using local distance transformation by propagation, and applications,"
Image Processing And Its Applications, 1999. Seventh International
Conference on (Conf. Publ. No. 465), 1999, pp. 856-860 vol.2.
:DOI:`10.1049/cp:19990446`
.. [2] Ingemar Ragnemalm, Fast erosion and dilation by contour processing
and thresholding of distance maps, Pattern Recognition Letters,
Volume 13, Issue 3, 1992, Pages 161-166.
:DOI:`10.1016/0167-8655(92)90055-5`
"""
eroded = isotropic_erosion(image, radius, out=out, spacing=spacing)
return isotropic_dilation(eroded, radius, out=out, spacing=spacing)
def isotropic_closing(image, radius, out=None, spacing=None):
"""Return binary morphological closing of an image.
This function returns the same result as binary :func:`skimage.morphology.binary_closing`
but performs faster for large circular structuring elements.
This works by thresholding the exact Euclidean distance map [1]_, [2]_.
The implementation is based on: func:`scipy.ndimage.distance_transform_edt`.
Parameters
----------
image : ndarray
Binary input image.
radius : float
The radius with which the regions should be closed.
out : ndarray of bool, optional
The array to store the result of the morphology. If None,
is passed, a new array will be allocated.
spacing : float, or sequence of float, optional
Spacing of elements along each dimension.
If a sequence, must be of length equal to the input's dimension (number of axes).
If a single number, this value is used for all axes.
If not specified, a grid spacing of unity is implied.
Returns
-------
closed : ndarray of bool
The result of the morphological closing.
References
----------
.. [1] Cuisenaire, O. and Macq, B., "Fast Euclidean morphological operators
using local distance transformation by propagation, and applications,"
Image Processing And Its Applications, 1999. Seventh International
Conference on (Conf. Publ. No. 465), 1999, pp. 856-860 vol.2.
:DOI:`10.1049/cp:19990446`
.. [2] Ingemar Ragnemalm, Fast erosion and dilation by contour processing
and thresholding of distance maps, Pattern Recognition Letters,
Volume 13, Issue 3, 1992, Pages 161-166.
:DOI:`10.1016/0167-8655(92)90055-5`
"""
dilated = isotropic_dilation(image, radius, out=out, spacing=spacing)
return isotropic_erosion(dilated, radius, out=out, spacing=spacing)

View File

@@ -0,0 +1,700 @@
"""max_tree.py - max_tree representation of images.
This module provides operators based on the max-tree representation of images.
A grayscale image can be seen as a pile of nested sets, each of which is the
result of a threshold operation. These sets can be efficiently represented by
max-trees, where the inclusion relation between connected components at
different levels are represented by parent-child relationships.
These representations allow efficient implementations of many algorithms, such
as attribute operators. Unlike morphological openings and closings, these
operators do not require a fixed footprint, but rather act with a flexible
footprint that meets a certain criterion.
This implementation provides functions for:
1. max-tree generation
2. area openings / closings
3. diameter openings / closings
4. local maxima
References:
.. [1] Salembier, P., Oliveras, A., & Garrido, L. (1998). Antiextensive
Connected Operators for Image and Sequence Processing.
IEEE Transactions on Image Processing, 7(4), 555-570.
:DOI:`10.1109/83.663500`
.. [2] Berger, C., Geraud, T., Levillain, R., Widynski, N., Baillard, A.,
Bertin, E. (2007). Effective Component Tree Computation with
Application to Pattern Recognition in Astronomical Imaging.
In International Conference on Image Processing (ICIP) (pp. 41-44).
:DOI:`10.1109/ICIP.2007.4379949`
.. [3] Najman, L., & Couprie, M. (2006). Building the component tree in
quasi-linear time. IEEE Transactions on Image Processing, 15(11),
3531-3539.
:DOI:`10.1109/TIP.2006.877518`
.. [4] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
"""
import numpy as np
from ._util import _validate_connectivity, _offsets_to_raveled_neighbors
from ..util import invert
from . import _max_tree
unsigned_int_types = [np.uint8, np.uint16, np.uint32, np.uint64]
signed_int_types = [np.int8, np.int16, np.int32, np.int64]
signed_float_types = [np.float16, np.float32, np.float64]
# building the max tree.
def max_tree(image, connectivity=1):
"""Build the max tree from an image.
Component trees represent the hierarchical structure of the connected
components resulting from sequential thresholding operations applied to an
image. A connected component at one level is parent of a component at a
higher level if the latter is included in the first. A max-tree is an
efficient representation of a component tree. A connected component at
one level is represented by one reference pixel at this level, which is
parent to all other pixels at that level and to the reference pixel at the
level above. The max-tree is the basis for many morphological operators,
namely connected operators.
Parameters
----------
image : ndarray
The input image for which the max-tree is to be calculated.
This image can be of any type.
connectivity : unsigned int, optional
The neighborhood connectivity. The integer represents the maximum
number of orthogonal steps to reach a neighbor. In 2D, it is 1 for
a 4-neighborhood and 2 for a 8-neighborhood. Default value is 1.
Returns
-------
parent : ndarray, int64
Array of same shape as image. The value of each pixel is the index of
its parent in the ravelled array.
tree_traverser : 1D array, int64
The ordered pixel indices (referring to the ravelled array). The pixels
are ordered such that every pixel is preceded by its parent (except for
the root which has no parent).
References
----------
.. [1] Salembier, P., Oliveras, A., & Garrido, L. (1998). Antiextensive
Connected Operators for Image and Sequence Processing.
IEEE Transactions on Image Processing, 7(4), 555-570.
:DOI:`10.1109/83.663500`
.. [2] Berger, C., Geraud, T., Levillain, R., Widynski, N., Baillard, A.,
Bertin, E. (2007). Effective Component Tree Computation with
Application to Pattern Recognition in Astronomical Imaging.
In International Conference on Image Processing (ICIP) (pp. 41-44).
:DOI:`10.1109/ICIP.2007.4379949`
.. [3] Najman, L., & Couprie, M. (2006). Building the component tree in
quasi-linear time. IEEE Transactions on Image Processing, 15(11),
3531-3539.
:DOI:`10.1109/TIP.2006.877518`
.. [4] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
Examples
--------
We create a small sample image (Figure 1 from [4]) and build the max-tree.
>>> image = np.array([[15, 13, 16], [12, 12, 10], [16, 12, 14]])
>>> P, S = max_tree(image, connectivity=2)
"""
# User defined masks are not allowed, as there might be more than one
# connected component in the mask (and therefore not a single tree that
# represents the image). Mask here is an image that is 0 on the border
# and 1 everywhere else.
mask = np.ones(image.shape)
for k in range(len(image.shape)):
np.moveaxis(mask, k, 0)[0] = 0
np.moveaxis(mask, k, 0)[-1] = 0
neighbors, offset = _validate_connectivity(image.ndim, connectivity, offset=None)
# initialization of the parent image
parent = np.zeros(image.shape, dtype=np.int64)
# flat_neighborhood contains a list of offsets allowing one to find the
# neighbors in the ravelled image.
flat_neighborhood = _offsets_to_raveled_neighbors(
image.shape, neighbors, offset
).astype(np.int32)
# pixels need to be sorted according to their gray level.
tree_traverser = np.argsort(image.ravel(), kind="stable").astype(np.int64)
# call of cython function.
_max_tree._max_tree(
image.ravel(),
mask.ravel().astype(np.uint8),
flat_neighborhood,
offset.astype(np.int32),
np.array(image.shape, dtype=np.int32),
parent.ravel(),
tree_traverser,
)
return parent, tree_traverser
def area_opening(
image, area_threshold=64, connectivity=1, parent=None, tree_traverser=None
):
"""Perform an area opening of the image.
Area opening removes all bright structures of an image with
a surface smaller than area_threshold.
The output image is thus the largest image smaller than the input
for which all local maxima have at least a surface of
area_threshold pixels.
Area openings are similar to morphological openings, but
they do not use a fixed footprint, but rather a deformable
one, with surface = area_threshold. Consequently, the area_opening
with area_threshold=1 is the identity.
In the binary case, area openings are equivalent to
remove_small_objects; this operator is thus extended to gray-level images.
Technically, this operator is based on the max-tree representation of
the image.
Parameters
----------
image : ndarray
The input image for which the area_opening is to be calculated.
This image can be of any type.
area_threshold : unsigned int
The size parameter (number of pixels). The default value is arbitrarily
chosen to be 64.
connectivity : unsigned int, optional
The neighborhood connectivity. The integer represents the maximum
number of orthogonal steps to reach a neighbor. In 2D, it is 1 for
a 4-neighborhood and 2 for a 8-neighborhood. Default value is 1.
parent : ndarray, int64, optional
Parent image representing the max tree of the image. The
value of each pixel is the index of its parent in the ravelled array.
tree_traverser : 1D array, int64, optional
The ordered pixel indices (referring to the ravelled array). The pixels
are ordered such that every pixel is preceded by its parent (except for
the root which has no parent).
Returns
-------
output : ndarray
Output image of the same shape and type as the input image.
See Also
--------
skimage.morphology.area_closing
skimage.morphology.diameter_opening
skimage.morphology.diameter_closing
skimage.morphology.max_tree
skimage.morphology.remove_small_objects
skimage.morphology.remove_small_holes
References
----------
.. [1] Vincent L., Proc. "Grayscale area openings and closings,
their efficient implementation and applications",
EURASIP Workshop on Mathematical Morphology and its
Applications to Signal Processing, Barcelona, Spain, pp.22-27,
May 1993.
.. [2] Soille, P., "Morphological Image Analysis: Principles and
Applications" (Chapter 6), 2nd edition (2003), ISBN 3540429883.
:DOI:`10.1007/978-3-662-05088-0`
.. [3] Salembier, P., Oliveras, A., & Garrido, L. (1998). Antiextensive
Connected Operators for Image and Sequence Processing.
IEEE Transactions on Image Processing, 7(4), 555-570.
:DOI:`10.1109/83.663500`
.. [4] Najman, L., & Couprie, M. (2006). Building the component tree in
quasi-linear time. IEEE Transactions on Image Processing, 15(11),
3531-3539.
:DOI:`10.1109/TIP.2006.877518`
.. [5] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
Examples
--------
We create an image (quadratic function with a maximum in the center and
4 additional local maxima.
>>> w = 12
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 20 - 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:3,1:5] = 40; f[2:4,9:11] = 60; f[9:11,2:4] = 80
>>> f[9:10,9:11] = 100; f[10,10] = 100
>>> f = f.astype(int)
We can calculate the area opening:
>>> open = area_opening(f, 8, connectivity=1)
The peaks with a surface smaller than 8 are removed.
"""
output = image.copy()
if parent is None or tree_traverser is None:
parent, tree_traverser = max_tree(image, connectivity)
area = _max_tree._compute_area(image.ravel(), parent.ravel(), tree_traverser)
_max_tree._direct_filter(
image.ravel(),
output.ravel(),
parent.ravel(),
tree_traverser,
area,
area_threshold,
)
return output
def diameter_opening(
image, diameter_threshold=8, connectivity=1, parent=None, tree_traverser=None
):
"""Perform a diameter opening of the image.
Diameter opening removes all bright structures of an image with
maximal extension smaller than diameter_threshold. The maximal
extension is defined as the maximal extension of the bounding box.
The operator is also called Bounding Box Opening. In practice,
the result is similar to a morphological opening, but long and thin
structures are not removed.
Technically, this operator is based on the max-tree representation of
the image.
Parameters
----------
image : ndarray
The input image for which the area_opening is to be calculated.
This image can be of any type.
diameter_threshold : unsigned int
The maximal extension parameter (number of pixels). The default value
is 8.
connectivity : unsigned int, optional
The neighborhood connectivity. The integer represents the maximum
number of orthogonal steps to reach a neighbor. In 2D, it is 1 for
a 4-neighborhood and 2 for a 8-neighborhood. Default value is 1.
parent : ndarray, int64, optional
Parent image representing the max tree of the image. The
value of each pixel is the index of its parent in the ravelled array.
tree_traverser : 1D array, int64, optional
The ordered pixel indices (referring to the ravelled array). The pixels
are ordered such that every pixel is preceded by its parent (except for
the root which has no parent).
Returns
-------
output : ndarray
Output image of the same shape and type as the input image.
See Also
--------
skimage.morphology.area_opening
skimage.morphology.area_closing
skimage.morphology.diameter_closing
skimage.morphology.max_tree
References
----------
.. [1] Walter, T., & Klein, J.-C. (2002). Automatic Detection of
Microaneurysms in Color Fundus Images of the Human Retina by Means
of the Bounding Box Closing. In A. Colosimo, P. Sirabella,
A. Giuliani (Eds.), Medical Data Analysis. Lecture Notes in Computer
Science, vol 2526, pp. 210-220. Springer Berlin Heidelberg.
:DOI:`10.1007/3-540-36104-9_23`
.. [2] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
Examples
--------
We create an image (quadratic function with a maximum in the center and
4 additional local maxima.
>>> w = 12
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 20 - 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:3,1:5] = 40; f[2:4,9:11] = 60; f[9:11,2:4] = 80
>>> f[9:10,9:11] = 100; f[10,10] = 100
>>> f = f.astype(int)
We can calculate the diameter opening:
>>> open = diameter_opening(f, 3, connectivity=1)
The peaks with a maximal extension of 2 or less are removed.
The remaining peaks have all a maximal extension of at least 3.
"""
output = image.copy()
if parent is None or tree_traverser is None:
parent, tree_traverser = max_tree(image, connectivity)
diam = _max_tree._compute_extension(
image.ravel(),
np.array(image.shape, dtype=np.int32),
parent.ravel(),
tree_traverser,
)
_max_tree._direct_filter(
image.ravel(),
output.ravel(),
parent.ravel(),
tree_traverser,
diam,
diameter_threshold,
)
return output
def area_closing(
image, area_threshold=64, connectivity=1, parent=None, tree_traverser=None
):
"""Perform an area closing of the image.
Area closing removes all dark structures of an image with
a surface smaller than area_threshold.
The output image is larger than or equal to the input image
for every pixel and all local minima have at least a surface of
area_threshold pixels.
Area closings are similar to morphological closings, but
they do not use a fixed footprint, but rather a deformable
one, with surface = area_threshold.
In the binary case, area closings are equivalent to
remove_small_holes; this operator is thus extended to gray-level images.
Technically, this operator is based on the max-tree representation of
the image.
Parameters
----------
image : ndarray
The input image for which the area_closing is to be calculated.
This image can be of any type.
area_threshold : unsigned int
The size parameter (number of pixels). The default value is arbitrarily
chosen to be 64.
connectivity : unsigned int, optional
The neighborhood connectivity. The integer represents the maximum
number of orthogonal steps to reach a neighbor. In 2D, it is 1 for
a 4-neighborhood and 2 for a 8-neighborhood. Default value is 1.
parent : ndarray, int64, optional
Parent image representing the max tree of the inverted image. The
value of each pixel is the index of its parent in the ravelled array.
See Note for further details.
tree_traverser : 1D array, int64, optional
The ordered pixel indices (referring to the ravelled array). The pixels
are ordered such that every pixel is preceded by its parent (except for
the root which has no parent).
Returns
-------
output : ndarray
Output image of the same shape and type as input image.
See Also
--------
skimage.morphology.area_opening
skimage.morphology.diameter_opening
skimage.morphology.diameter_closing
skimage.morphology.max_tree
skimage.morphology.remove_small_objects
skimage.morphology.remove_small_holes
References
----------
.. [1] Vincent L., Proc. "Grayscale area openings and closings,
their efficient implementation and applications",
EURASIP Workshop on Mathematical Morphology and its
Applications to Signal Processing, Barcelona, Spain, pp.22-27,
May 1993.
.. [2] Soille, P., "Morphological Image Analysis: Principles and
Applications" (Chapter 6), 2nd edition (2003), ISBN 3540429883.
:DOI:`10.1007/978-3-662-05088-0`
.. [3] Salembier, P., Oliveras, A., & Garrido, L. (1998). Antiextensive
Connected Operators for Image and Sequence Processing.
IEEE Transactions on Image Processing, 7(4), 555-570.
:DOI:`10.1109/83.663500`
.. [4] Najman, L., & Couprie, M. (2006). Building the component tree in
quasi-linear time. IEEE Transactions on Image Processing, 15(11),
3531-3539.
:DOI:`10.1109/TIP.2006.877518`
.. [5] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
Examples
--------
We create an image (quadratic function with a minimum in the center and
4 additional local minima.
>>> w = 12
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 180 + 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:3,1:5] = 160; f[2:4,9:11] = 140; f[9:11,2:4] = 120
>>> f[9:10,9:11] = 100; f[10,10] = 100
>>> f = f.astype(int)
We can calculate the area closing:
>>> closed = area_closing(f, 8, connectivity=1)
All small minima are removed, and the remaining minima have at least
a size of 8.
Notes
-----
If a max-tree representation (parent and tree_traverser) are given to the
function, they must be calculated from the inverted image for this
function, i.e.:
>>> P, S = max_tree(invert(f))
>>> closed = diameter_closing(f, 3, parent=P, tree_traverser=S)
"""
# inversion of the input image
image_inv = invert(image)
output = image_inv.copy()
if parent is None or tree_traverser is None:
parent, tree_traverser = max_tree(image_inv, connectivity)
area = _max_tree._compute_area(image_inv.ravel(), parent.ravel(), tree_traverser)
_max_tree._direct_filter(
image_inv.ravel(),
output.ravel(),
parent.ravel(),
tree_traverser,
area,
area_threshold,
)
# inversion of the output image
output = invert(output)
return output
def diameter_closing(
image, diameter_threshold=8, connectivity=1, parent=None, tree_traverser=None
):
"""Perform a diameter closing of the image.
Diameter closing removes all dark structures of an image with
maximal extension smaller than diameter_threshold. The maximal
extension is defined as the maximal extension of the bounding box.
The operator is also called Bounding Box Closing. In practice,
the result is similar to a morphological closing, but long and thin
structures are not removed.
Technically, this operator is based on the max-tree representation of
the image.
Parameters
----------
image : ndarray
The input image for which the diameter_closing is to be calculated.
This image can be of any type.
diameter_threshold : unsigned int
The maximal extension parameter (number of pixels). The default value
is 8.
connectivity : unsigned int, optional
The neighborhood connectivity. The integer represents the maximum
number of orthogonal steps to reach a neighbor. In 2D, it is 1 for
a 4-neighborhood and 2 for a 8-neighborhood. Default value is 1.
parent : ndarray, int64, optional
Precomputed parent image representing the max tree of the inverted
image. This function is fast, if precomputed parent and tree_traverser
are provided. See Note for further details.
tree_traverser : 1D array, int64, optional
Precomputed traverser, where the pixels are ordered such that every
pixel is preceded by its parent (except for the root which has no
parent). This function is fast, if precomputed parent and
tree_traverser are provided. See Note for further details.
Returns
-------
output : ndarray
Output image of the same shape and type as input image.
See Also
--------
skimage.morphology.area_opening
skimage.morphology.area_closing
skimage.morphology.diameter_opening
skimage.morphology.max_tree
References
----------
.. [1] Walter, T., & Klein, J.-C. (2002). Automatic Detection of
Microaneurysms in Color Fundus Images of the Human Retina by Means
of the Bounding Box Closing. In A. Colosimo, P. Sirabella,
A. Giuliani (Eds.), Medical Data Analysis. Lecture Notes in Computer
Science, vol 2526, pp. 210-220. Springer Berlin Heidelberg.
:DOI:`10.1007/3-540-36104-9_23`
.. [2] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
Examples
--------
We create an image (quadratic function with a minimum in the center and
4 additional local minima.
>>> w = 12
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 180 + 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:3,1:5] = 160; f[2:4,9:11] = 140; f[9:11,2:4] = 120
>>> f[9:10,9:11] = 100; f[10,10] = 100
>>> f = f.astype(int)
We can calculate the diameter closing:
>>> closed = diameter_closing(f, 3, connectivity=1)
All small minima with a maximal extension of 2 or less are removed.
The remaining minima have all a maximal extension of at least 3.
Notes
-----
If a max-tree representation (parent and tree_traverser) are given to the
function, they must be calculated from the inverted image for this
function, i.e.:
>>> P, S = max_tree(invert(f))
>>> closed = diameter_closing(f, 3, parent=P, tree_traverser=S)
"""
# inversion of the input image
image_inv = invert(image)
output = image_inv.copy()
if parent is None or tree_traverser is None:
parent, tree_traverser = max_tree(image_inv, connectivity)
diam = _max_tree._compute_extension(
image_inv.ravel(),
np.array(image_inv.shape, dtype=np.int32),
parent.ravel(),
tree_traverser,
)
_max_tree._direct_filter(
image_inv.ravel(),
output.ravel(),
parent.ravel(),
tree_traverser,
diam,
diameter_threshold,
)
output = invert(output)
return output
def max_tree_local_maxima(image, connectivity=1, parent=None, tree_traverser=None):
"""Determine all local maxima of the image.
The local maxima are defined as connected sets of pixels with equal
gray level strictly greater than the gray levels of all pixels in direct
neighborhood of the set. The function labels the local maxima.
Technically, the implementation is based on the max-tree representation
of an image. The function is very efficient if the max-tree representation
has already been computed. Otherwise, it is preferable to use
the function local_maxima.
Parameters
----------
image : ndarray
The input image for which the maxima are to be calculated.
connectivity : unsigned int, optional
The neighborhood connectivity. The integer represents the maximum
number of orthogonal steps to reach a neighbor. In 2D, it is 1 for
a 4-neighborhood and 2 for a 8-neighborhood. Default value is 1.
parent : ndarray, int64, optional
The value of each pixel is the index of its parent in the ravelled
array.
tree_traverser : 1D array, int64, optional
The ordered pixel indices (referring to the ravelled array). The pixels
are ordered such that every pixel is preceded by its parent (except for
the root which has no parent).
Returns
-------
local_max : ndarray, uint64
Labeled local maxima of the image.
See Also
--------
skimage.morphology.local_maxima
skimage.morphology.max_tree
References
----------
.. [1] Vincent L., Proc. "Grayscale area openings and closings,
their efficient implementation and applications",
EURASIP Workshop on Mathematical Morphology and its
Applications to Signal Processing, Barcelona, Spain, pp.22-27,
May 1993.
.. [2] Soille, P., "Morphological Image Analysis: Principles and
Applications" (Chapter 6), 2nd edition (2003), ISBN 3540429883.
:DOI:`10.1007/978-3-662-05088-0`
.. [3] Salembier, P., Oliveras, A., & Garrido, L. (1998). Antiextensive
Connected Operators for Image and Sequence Processing.
IEEE Transactions on Image Processing, 7(4), 555-570.
:DOI:`10.1109/83.663500`
.. [4] Najman, L., & Couprie, M. (2006). Building the component tree in
quasi-linear time. IEEE Transactions on Image Processing, 15(11),
3531-3539.
:DOI:`10.1109/TIP.2006.877518`
.. [5] Carlinet, E., & Geraud, T. (2014). A Comparative Review of
Component Tree Computation Algorithms. IEEE Transactions on Image
Processing, 23(9), 3885-3895.
:DOI:`10.1109/TIP.2014.2336551`
Examples
--------
We create an image (quadratic function with a maximum in the center and
4 additional constant maxima.
>>> w = 10
>>> x, y = np.mgrid[0:w,0:w]
>>> f = 20 - 0.2*((x - w/2)**2 + (y-w/2)**2)
>>> f[2:4,2:4] = 40; f[2:4,7:9] = 60; f[7:9,2:4] = 80; f[7:9,7:9] = 100
>>> f = f.astype(int)
We can calculate all local maxima:
>>> maxima = max_tree_local_maxima(f)
The resulting image contains the labeled local maxima.
"""
output = np.ones(image.shape, dtype=np.uint64)
if parent is None or tree_traverser is None:
parent, tree_traverser = max_tree(image, connectivity)
_max_tree._max_tree_local_maxima(
image.ravel(), output.ravel(), parent.ravel(), tree_traverser
)
return output

View File

@@ -0,0 +1,455 @@
"""Miscellaneous morphology functions."""
import numpy as np
import functools
from scipy import ndimage as ndi
from scipy.spatial import cKDTree
from .._shared.utils import warn
from .._shared._dependency_checks import is_wasm
from ._misc_cy import _remove_objects_by_distance
# Our function names don't exactly correspond to ndimages.
# This dictionary translates from our names to scipy's.
funcs = ('erosion', 'dilation', 'opening', 'closing')
skimage2ndimage = {x: 'grey_' + x for x in funcs}
# These function names are the same in ndimage.
funcs = (
'binary_erosion',
'binary_dilation',
'binary_opening',
'binary_closing',
'black_tophat',
'white_tophat',
)
skimage2ndimage.update({x: x for x in funcs})
def default_footprint(func):
"""Decorator to add a default footprint to morphology functions.
Parameters
----------
func : function
A morphology function such as erosion, dilation, opening, closing,
white_tophat, or black_tophat.
Returns
-------
func_out : function
The function, using a default footprint of same dimension
as the input image with connectivity 1.
"""
@functools.wraps(func)
def func_out(image, footprint=None, *args, **kwargs):
if footprint is None:
footprint = ndi.generate_binary_structure(image.ndim, 1)
return func(image, footprint=footprint, *args, **kwargs)
return func_out
def _check_dtype_supported(ar):
# Should use `issubdtype` for bool below, but there's a bug in numpy 1.7
if not (ar.dtype == bool or np.issubdtype(ar.dtype, np.integer)):
raise TypeError(
"Only bool or integer image types are supported. " f"Got {ar.dtype}."
)
def remove_small_objects(ar, min_size=64, connectivity=1, *, out=None):
"""Remove objects smaller than the specified size.
Expects ar to be an array with labeled objects, and removes objects
smaller than min_size. If `ar` is bool, the image is first labeled.
This leads to potentially different behavior for bool and 0-and-1
arrays.
Parameters
----------
ar : ndarray (arbitrary shape, int or bool type)
The array containing the objects of interest. If the array type is
int, the ints must be non-negative.
min_size : int, optional (default: 64)
The smallest allowable object size.
connectivity : int, {1, 2, ..., ar.ndim}, optional (default: 1)
The connectivity defining the neighborhood of a pixel. Used during
labelling if `ar` is bool.
out : ndarray
Array of the same shape as `ar`, into which the output is
placed. By default, a new array is created.
Raises
------
TypeError
If the input array is of an invalid type, such as float or string.
ValueError
If the input array contains negative values.
Returns
-------
out : ndarray, same shape and type as input `ar`
The input array with small connected components removed.
See Also
--------
skimage.morphology.remove_objects_by_distance
Examples
--------
>>> from skimage import morphology
>>> a = np.array([[0, 0, 0, 1, 0],
... [1, 1, 1, 0, 0],
... [1, 1, 1, 0, 1]], bool)
>>> b = morphology.remove_small_objects(a, 6)
>>> b
array([[False, False, False, False, False],
[ True, True, True, False, False],
[ True, True, True, False, False]])
>>> c = morphology.remove_small_objects(a, 7, connectivity=2)
>>> c
array([[False, False, False, True, False],
[ True, True, True, False, False],
[ True, True, True, False, False]])
>>> d = morphology.remove_small_objects(a, 6, out=a)
>>> d is a
True
"""
# Raising type error if not int or bool
_check_dtype_supported(ar)
if out is None:
out = ar.copy()
else:
out[:] = ar
if min_size == 0: # shortcut for efficiency
return out
if out.dtype == bool:
footprint = ndi.generate_binary_structure(ar.ndim, connectivity)
ccs = np.zeros_like(ar, dtype=np.int32)
ndi.label(ar, footprint, output=ccs)
else:
ccs = out
try:
component_sizes = np.bincount(ccs.ravel())
except ValueError:
raise ValueError(
"Negative value labels are not supported. Try "
"relabeling the input with `scipy.ndimage.label` or "
"`skimage.morphology.label`."
)
if len(component_sizes) == 2 and out.dtype != bool:
warn(
"Only one label was provided to `remove_small_objects`. "
"Did you mean to use a boolean array?"
)
too_small = component_sizes < min_size
too_small_mask = too_small[ccs]
out[too_small_mask] = 0
return out
def remove_small_holes(ar, area_threshold=64, connectivity=1, *, out=None):
"""Remove contiguous holes smaller than the specified size.
Parameters
----------
ar : ndarray (arbitrary shape, int or bool type)
The array containing the connected components of interest.
area_threshold : int, optional (default: 64)
The maximum area, in pixels, of a contiguous hole that will be filled.
Replaces `min_size`.
connectivity : int, {1, 2, ..., ar.ndim}, optional (default: 1)
The connectivity defining the neighborhood of a pixel.
out : ndarray
Array of the same shape as `ar` and bool dtype, into which the
output is placed. By default, a new array is created.
Raises
------
TypeError
If the input array is of an invalid type, such as float or string.
ValueError
If the input array contains negative values.
Returns
-------
out : ndarray, same shape and type as input `ar`
The input array with small holes within connected components removed.
Examples
--------
>>> from skimage import morphology
>>> a = np.array([[1, 1, 1, 1, 1, 0],
... [1, 1, 1, 0, 1, 0],
... [1, 0, 0, 1, 1, 0],
... [1, 1, 1, 1, 1, 0]], bool)
>>> b = morphology.remove_small_holes(a, 2)
>>> b
array([[ True, True, True, True, True, False],
[ True, True, True, True, True, False],
[ True, False, False, True, True, False],
[ True, True, True, True, True, False]])
>>> c = morphology.remove_small_holes(a, 2, connectivity=2)
>>> c
array([[ True, True, True, True, True, False],
[ True, True, True, False, True, False],
[ True, False, False, True, True, False],
[ True, True, True, True, True, False]])
>>> d = morphology.remove_small_holes(a, 2, out=a)
>>> d is a
True
Notes
-----
If the array type is int, it is assumed that it contains already-labeled
objects. The labels are not kept in the output image (this function always
outputs a bool image). It is suggested that labeling is completed after
using this function.
"""
_check_dtype_supported(ar)
# Creates warning if image is an integer image
if ar.dtype != bool:
warn(
"Any labeled images will be returned as a boolean array. "
"Did you mean to use a boolean array?",
UserWarning,
)
if out is not None:
if out.dtype != bool:
raise TypeError("out dtype must be bool")
else:
out = ar.astype(bool, copy=True)
# Creating the inverse of ar
np.logical_not(ar, out=out)
# removing small objects from the inverse of ar
out = remove_small_objects(out, area_threshold, connectivity, out=out)
np.logical_not(out, out=out)
return out
def remove_objects_by_distance(
label_image,
min_distance,
*,
priority=None,
p_norm=2,
spacing=None,
out=None,
):
"""Remove objects, in specified order, until remaining are a minimum distance apart.
Remove labeled objects from an image until the remaining ones are spaced
more than a given distance from one another. By default, smaller objects
are removed first.
Parameters
----------
label_image : ndarray of integers
An n-dimensional array containing object labels, e.g. as returned by
:func:`~.label`. A value of zero is considered background, all other
object IDs must be positive integers.
min_distance : int or float
Remove objects whose distance to other objects is not greater than this
positive value. Objects with a lower `priority` are removed first.
priority : ndarray, optional
Defines the priority with which objects are removed. Expects a
1-dimensional array of length
:func:`np.amax(label_image) + 1 <numpy.amax>` that contains the priority
for each object's label at the respective index. Objects with a lower value
are removed first until all remaining objects fulfill the distance
requirement. If not given, priority is given to objects with a higher
number of samples and their label value second.
p_norm : int or float, optional
The Minkowski distance of order p, used to calculate the distance
between objects. The default ``2`` corresponds to the Euclidean
distance, ``1`` to the "Manhattan" distance, and ``np.inf`` to the
Chebyshev distance.
spacing : sequence of float, optional
The pixel spacing along each axis of `label_image`. If not specified,
a grid spacing of unity (1) is implied.
out : ndarray, optional
Array of the same shape and dtype as `image`, into which the output is
placed. By default, a new array is created.
Returns
-------
out : ndarray
Array of the same shape as `label_image`, for which objects that violate
the `min_distance` condition were removed.
See Also
--------
skimage.morphology.remove_small_objects
Remove objects smaller than the specified size.
Notes
-----
The basic steps of this algorithm work as follows:
1. Find the indices for of all given objects and separate them depending on
if they point to an object's border or not.
2. Sort indices by their label value, ensuring that indices which point to
the same object are next to each other. This optimization allows finding
all parts of an object, simply by stepping to the neighboring indices.
3. Sort boundary indices by `priority`. Use a stable-sort to preserve the
ordering from the previous sorting step. If `priority` is not given,
use :func:`numpy.bincount` as a fallback.
4. Construct a :class:`scipy.spatial.cKDTree` from the boundary indices.
5. Iterate across boundary indices in priority-sorted order, and query the
kd-tree for objects that are too close. Remove ones that are and don't
take them into account when evaluating other objects later on.
The performance of this algorithm depends on the number of samples in
`label_image` that belong to an object's border.
Examples
--------
>>> import skimage as ski
>>> ski.morphology.remove_objects_by_distance(np.array([2, 0, 1, 1]), 2)
array([0, 0, 1, 1])
>>> ski.morphology.remove_objects_by_distance(
... np.array([2, 0, 1, 1]), 2, priority=np.array([0, 1, 9])
... )
array([2, 0, 0, 0])
>>> label_image = np.array(
... [[8, 0, 0, 0, 0, 0, 0, 0, 0, 9, 9],
... [8, 8, 8, 0, 0, 0, 0, 0, 0, 9, 9],
... [0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0],
... [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
... [0, 0, 3, 0, 0, 0, 1, 0, 0, 0, 0],
... [2, 0, 0, 0, 0, 1, 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, 7, 7]]
... )
>>> ski.morphology.remove_objects_by_distance(
... label_image, min_distance=3
... )
array([[8, 0, 0, 0, 0, 0, 0, 0, 0, 9, 9],
[8, 8, 8, 0, 0, 0, 0, 0, 0, 9, 9],
[0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 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, 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, 7, 7]])
"""
if min_distance < 0:
raise ValueError(f"min_distance must be >= 0, was {min_distance}")
if not np.issubdtype(label_image.dtype, np.integer):
raise ValueError(
f"`label_image` must be of integer dtype, got {label_image.dtype}"
)
if out is None:
out = label_image.copy(order="C")
elif out is not label_image:
out[:] = label_image
# May create a copy if order is not C, account for that later
out_raveled = out.ravel(order="C")
if spacing is not None:
spacing = np.array(spacing)
if spacing.shape != (out.ndim,) or spacing.min() <= 0:
raise ValueError(
"`spacing` must contain exactly one positive factor "
"for each dimension of `label_image`"
)
indices = np.flatnonzero(out_raveled)
# Optimization: Split indices into those on the object boundaries and inner
# ones. The KDTree is built only from the boundary indices, which reduces
# the size of the critical loop significantly! Remaining indices are only
# used to remove the inner parts of objects as well.
if (spacing is None or np.all(spacing[0] == spacing)) and p_norm <= 2:
# For unity spacing we can make the borders more sparse by using a
# lower connectivity
footprint = ndi.generate_binary_structure(out.ndim, 1)
else:
footprint = ndi.generate_binary_structure(out.ndim, out.ndim)
border = (
ndi.maximum_filter(out, footprint=footprint)
!= ndi.minimum_filter(out, footprint=footprint)
).ravel()[indices]
border_indices = indices[border]
inner_indices = indices[~border]
if border_indices.size == 0:
# Image without any or only one object, return early
return out
# Sort by label ID first, so that IDs of the same object are contiguous
# in the sorted index. This allows fast discovery of the whole object by
# simple iteration up or down the index!
border_indices = border_indices[np.argsort(out_raveled[border_indices])]
inner_indices = inner_indices[np.argsort(out_raveled[inner_indices])]
if priority is None:
if is_wasm:
# bincount expects intp (32-bit) on WASM, so down-cast to that
priority = np.bincount(out_raveled.astype(np.intp, copy=False))
else:
priority = np.bincount(out_raveled)
# `priority` can only be indexed by positive object IDs,
# `border_indices` contains all unique sorted IDs so check the lowest / first
smallest_id = out_raveled[border_indices[0]]
if smallest_id < 0:
raise ValueError(f"found object with negative ID {smallest_id!r}")
try:
# Sort by priority second using a stable sort to preserve the contiguous
# sorting of objects. Because each pixel in an object has the same
# priority we don't need to worry about separating objects.
border_indices = border_indices[
np.argsort(priority[out_raveled[border_indices]], kind="stable")[::-1]
]
except IndexError as error:
# Use np.amax only for the exception path to provide a nicer error message
expected_shape = (np.amax(out_raveled) + 1,)
if priority.shape != expected_shape:
raise ValueError(
"shape of `priority` must be (np.amax(label_image) + 1,), "
f"expected {expected_shape}, got {priority.shape} instead"
) from error
else:
raise
# Construct kd-tree from unraveled border indices (optionally scale by `spacing`)
unraveled_indices = np.unravel_index(border_indices, out.shape)
if spacing is not None:
unraveled_indices = tuple(
unraveled_indices[dim] * spacing[dim] for dim in range(out.ndim)
)
kdtree = cKDTree(data=np.asarray(unraveled_indices, dtype=np.float64).T)
_remove_objects_by_distance(
out=out_raveled,
border_indices=border_indices,
inner_indices=inner_indices,
kdtree=kdtree,
min_distance=min_distance,
p_norm=p_norm,
shape=label_image.shape,
)
if out_raveled.base is not out:
# `out_raveled` is a copy, re-assign
out[:] = out_raveled.reshape(out.shape)
return out

View File

@@ -0,0 +1,363 @@
import numpy as np
import pytest
from numpy.testing import assert_array_equal, assert_equal
from scipy import ndimage as ndi
from skimage import data, color, morphology
from skimage.util import img_as_bool
from skimage.morphology import binary, footprints, gray
img = color.rgb2gray(data.astronaut())
bw_img = img > 100 / 255.0
def test_non_square_image():
footprint = morphology.square(3)
binary_res = binary.binary_erosion(bw_img[:100, :200], footprint)
gray_res = img_as_bool(gray.erosion(bw_img[:100, :200], footprint))
assert_array_equal(binary_res, gray_res)
def test_binary_erosion():
footprint = morphology.square(3)
binary_res = binary.binary_erosion(bw_img, footprint)
gray_res = img_as_bool(gray.erosion(bw_img, footprint))
assert_array_equal(binary_res, gray_res)
def test_binary_dilation():
footprint = morphology.square(3)
binary_res = binary.binary_dilation(bw_img, footprint)
gray_res = img_as_bool(gray.dilation(bw_img, footprint))
assert_array_equal(binary_res, gray_res)
def test_binary_closing():
footprint = morphology.square(3)
binary_res = binary.binary_closing(bw_img, footprint)
gray_res = img_as_bool(gray.closing(bw_img, footprint))
assert_array_equal(binary_res, gray_res)
def test_binary_closing_extensive():
footprint = np.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
result_default = binary.binary_closing(bw_img, footprint=footprint)
assert np.all(result_default >= bw_img)
# mode="min" is expected to be not extensive
result_min = binary.binary_closing(img, footprint=footprint, mode="min")
assert not np.all(result_min >= bw_img)
def test_binary_opening():
footprint = morphology.square(3)
binary_res = binary.binary_opening(bw_img, footprint)
gray_res = img_as_bool(gray.opening(bw_img, footprint))
assert_array_equal(binary_res, gray_res)
def test_binary_opening_anti_extensive():
footprint = np.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
result_default = binary.binary_opening(bw_img, footprint=footprint)
assert np.all(result_default <= bw_img)
# mode="max" is expected to be not extensive
result_max = binary.binary_opening(bw_img, footprint=footprint, mode="max")
assert not np.all(result_max <= bw_img)
def _get_decomp_test_data(function, ndim=2):
if function == 'binary_erosion':
img = np.ones((17,) * ndim, dtype=np.uint8)
img[8, 8] = 0
elif function == 'binary_dilation':
img = np.zeros((17,) * ndim, dtype=np.uint8)
img[8, 8] = 1
else:
img = data.binary_blobs(32, n_dim=ndim, rng=1)
return img
@pytest.mark.parametrize(
"function",
["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
)
@pytest.mark.parametrize("size", (3, 4, 11))
@pytest.mark.parametrize("decomposition", ['separable', 'sequence'])
def test_square_decomposition(function, size, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.square(size, decomposition=None)
footprint = footprints.square(size, decomposition=decomposition)
img = _get_decomp_test_data(function)
func = getattr(binary, function)
expected = func(img, footprint=footprint_ndarray)
out = func(img, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
)
@pytest.mark.parametrize("nrows", (3, 4, 11))
@pytest.mark.parametrize("ncols", (3, 4, 11))
@pytest.mark.parametrize("decomposition", ['separable', 'sequence'])
def test_rectangle_decomposition(function, nrows, ncols, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.rectangle(nrows, ncols, decomposition=None)
footprint = footprints.rectangle(nrows, ncols, decomposition=decomposition)
img = _get_decomp_test_data(function)
func = getattr(binary, function)
expected = func(img, footprint=footprint_ndarray)
out = func(img, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
)
@pytest.mark.parametrize("m", (0, 1, 2, 3, 4, 5))
@pytest.mark.parametrize("n", (0, 1, 2, 3, 4, 5))
@pytest.mark.parametrize("decomposition", ['sequence'])
def test_octagon_decomposition(function, m, n, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
if m == 0 and n == 0:
with pytest.raises(ValueError):
footprints.octagon(m, n, decomposition=decomposition)
else:
footprint_ndarray = footprints.octagon(m, n, decomposition=None)
footprint = footprints.octagon(m, n, decomposition=decomposition)
img = _get_decomp_test_data(function)
func = getattr(binary, function)
expected = func(img, footprint=footprint_ndarray)
out = func(img, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
)
@pytest.mark.parametrize("radius", (1, 2, 5))
@pytest.mark.parametrize("decomposition", ['sequence'])
def test_diamond_decomposition(function, radius, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.diamond(radius, decomposition=None)
footprint = footprints.diamond(radius, decomposition=decomposition)
img = _get_decomp_test_data(function)
func = getattr(binary, function)
expected = func(img, footprint=footprint_ndarray)
out = func(img, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
)
@pytest.mark.parametrize("size", (3, 4, 5))
@pytest.mark.parametrize("decomposition", ['separable', 'sequence'])
def test_cube_decomposition(function, size, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.cube(size, decomposition=None)
footprint = footprints.cube(size, decomposition=decomposition)
img = _get_decomp_test_data(function, ndim=3)
func = getattr(binary, function)
expected = func(img, footprint=footprint_ndarray)
out = func(img, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["binary_erosion", "binary_dilation", "binary_closing", "binary_opening"],
)
@pytest.mark.parametrize("radius", (1, 2, 3))
@pytest.mark.parametrize("decomposition", ['sequence'])
def test_octahedron_decomposition(function, radius, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.octahedron(radius, decomposition=None)
footprint = footprints.octahedron(radius, decomposition=decomposition)
img = _get_decomp_test_data(function, ndim=3)
func = getattr(binary, function)
expected = func(img, footprint=footprint_ndarray)
out = func(img, footprint=footprint)
assert_array_equal(expected, out)
def test_footprint_overflow():
footprint = np.ones((17, 17), dtype=np.uint8)
img = np.zeros((20, 20), dtype=bool)
img[2:19, 2:19] = True
binary_res = binary.binary_erosion(img, footprint)
gray_res = img_as_bool(gray.erosion(img, footprint))
assert_array_equal(binary_res, gray_res)
def test_out_argument():
for func in (binary.binary_erosion, binary.binary_dilation):
footprint = np.ones((3, 3), dtype=np.uint8)
img = np.ones((10, 10))
out = np.zeros_like(img)
out_saved = out.copy()
func(img, footprint, out=out)
assert np.any(out != out_saved)
assert_array_equal(out, func(img, footprint))
binary_functions = [
binary.binary_erosion,
binary.binary_dilation,
binary.binary_opening,
binary.binary_closing,
]
@pytest.mark.parametrize("func", binary_functions)
@pytest.mark.parametrize("mode", ['max', 'min', 'ignore'])
def test_supported_mode(func, mode):
img = np.ones((10, 10), dtype=bool)
func(img, mode=mode)
@pytest.mark.parametrize("func", binary_functions)
@pytest.mark.parametrize("mode", ["reflect", 3, None])
def test_unsupported_mode(func, mode):
img = np.ones((10, 10))
with pytest.raises(ValueError, match="unsupported mode"):
func(img, mode=mode)
@pytest.mark.parametrize("function", binary_functions)
def test_default_footprint(function):
footprint = morphology.diamond(radius=1)
image = 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, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 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],
],
np.uint8,
)
im_expected = function(image, footprint)
im_test = function(image)
assert_array_equal(im_expected, im_test)
def test_3d_fallback_default_footprint():
# 3x3x3 cube inside a 7x7x7 image:
image = np.zeros((7, 7, 7), bool)
image[2:-2, 2:-2, 2:-2] = 1
opened = binary.binary_opening(image)
# expect a "hyper-cross" centered in the 5x5x5:
image_expected = np.zeros((7, 7, 7), dtype=bool)
image_expected[2:5, 2:5, 2:5] = ndi.generate_binary_structure(3, 1)
assert_array_equal(opened, image_expected)
binary_3d_fallback_functions = [binary.binary_opening, binary.binary_closing]
@pytest.mark.parametrize("function", binary_3d_fallback_functions)
def test_3d_fallback_cube_footprint(function):
# 3x3x3 cube inside a 7x7x7 image:
image = np.zeros((7, 7, 7), bool)
image[2:-2, 2:-2, 2:-2] = 1
cube = np.ones((3, 3, 3), dtype=np.uint8)
new_image = function(image, cube)
assert_array_equal(new_image, image)
def test_2d_ndimage_equivalence():
image = np.zeros((9, 9), np.uint16)
image[2:-2, 2:-2] = 2**14
image[3:-3, 3:-3] = 2**15
image[4, 4] = 2**16 - 1
bin_opened = binary.binary_opening(image)
bin_closed = binary.binary_closing(image)
footprint = ndi.generate_binary_structure(2, 1)
ndimage_opened = ndi.binary_opening(image, structure=footprint)
ndimage_closed = ndi.binary_closing(image, structure=footprint)
assert_array_equal(bin_opened, ndimage_opened)
assert_array_equal(bin_closed, ndimage_closed)
def test_binary_output_2d():
image = np.zeros((9, 9), np.uint16)
image[2:-2, 2:-2] = 2**14
image[3:-3, 3:-3] = 2**15
image[4, 4] = 2**16 - 1
bin_opened = binary.binary_opening(image)
bin_closed = binary.binary_closing(image)
int_opened = np.empty_like(image, dtype=np.uint8)
int_closed = np.empty_like(image, dtype=np.uint8)
binary.binary_opening(image, out=int_opened)
binary.binary_closing(image, out=int_closed)
assert_equal(bin_opened.dtype, bool)
assert_equal(bin_closed.dtype, bool)
assert_equal(int_opened.dtype, np.uint8)
assert_equal(int_closed.dtype, np.uint8)
def test_binary_output_3d():
image = np.zeros((9, 9, 9), np.uint16)
image[2:-2, 2:-2, 2:-2] = 2**14
image[3:-3, 3:-3, 3:-3] = 2**15
image[4, 4, 4] = 2**16 - 1
bin_opened = binary.binary_opening(image)
bin_closed = binary.binary_closing(image)
int_opened = np.empty_like(image, dtype=np.uint8)
int_closed = np.empty_like(image, dtype=np.uint8)
binary.binary_opening(image, out=int_opened)
binary.binary_closing(image, out=int_closed)
assert_equal(bin_opened.dtype, bool)
assert_equal(bin_closed.dtype, bool)
assert_equal(int_opened.dtype, np.uint8)
assert_equal(int_closed.dtype, np.uint8)

View File

@@ -0,0 +1,349 @@
import numpy as np
from skimage.morphology import convex_hull_image, convex_hull_object
from skimage.morphology._convex_hull import possible_hull
from skimage._shared import testing
from skimage._shared.testing import assert_array_equal
from skimage._shared._warnings import expected_warnings
def test_basic():
image = np.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, 1, 0, 0, 0, 1, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
assert_array_equal(convex_hull_image(image), expected)
def test_empty_image():
image = np.zeros((6, 6), dtype=bool)
with expected_warnings(['entirely zero']):
assert_array_equal(convex_hull_image(image), image)
def test_qhull_offset_example():
nonzeros = (
(
[
1367,
1368,
1368,
1368,
1369,
1369,
1369,
1369,
1369,
1370,
1370,
1370,
1370,
1370,
1370,
1370,
1371,
1371,
1371,
1371,
1371,
1371,
1371,
1371,
1371,
1372,
1372,
1372,
1372,
1372,
1372,
1372,
1372,
1372,
1373,
1373,
1373,
1373,
1373,
1373,
1373,
1373,
1373,
1374,
1374,
1374,
1374,
1374,
1374,
1374,
1375,
1375,
1375,
1375,
1375,
1376,
1376,
1376,
1377,
1372,
]
),
(
[
151,
150,
151,
152,
149,
150,
151,
152,
153,
148,
149,
150,
151,
152,
153,
154,
147,
148,
149,
150,
151,
152,
153,
154,
155,
146,
147,
148,
149,
150,
151,
152,
153,
154,
146,
147,
148,
149,
150,
151,
152,
153,
154,
147,
148,
149,
150,
151,
152,
153,
148,
149,
150,
151,
152,
149,
150,
151,
150,
155,
]
),
)
image = np.zeros((1392, 1040), dtype=bool)
image[nonzeros] = True
expected = image.copy()
assert_array_equal(convex_hull_image(image), expected)
def test_pathological_qhull_example():
image = np.array(
[[0, 0, 0, 0, 1, 0, 0], [0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 0, 0, 0, 0]],
dtype=bool,
)
expected = np.array(
[[0, 0, 0, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0]],
dtype=bool,
)
assert_array_equal(convex_hull_image(image), expected)
def test_pathological_qhull_labels():
image = np.array(
[[0, 0, 0, 0, 1, 0, 0], [0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 0, 0, 0, 0]],
dtype=bool,
)
expected = np.array(
[[0, 0, 0, 0, 1, 0, 0], [0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 0, 0, 0]],
dtype=bool,
)
actual = convex_hull_image(image, include_borders=False)
assert_array_equal(actual, expected)
def test_possible_hull():
image = np.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, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
expected = np.array(
[
[1, 4],
[2, 3],
[3, 2],
[4, 1],
[4, 1],
[3, 2],
[2, 3],
[1, 4],
[2, 5],
[3, 6],
[4, 7],
[2, 5],
[3, 6],
[4, 7],
[4, 2],
[4, 3],
[4, 4],
[4, 5],
[4, 6],
]
)
ph = possible_hull(image)
assert_array_equal(ph, expected)
def test_object():
image = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 1, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 1, 0],
[1, 0, 0, 0, 0, 0, 1, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
expected_conn_1 = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 1, 0, 1],
[1, 1, 1, 0, 0, 0, 0, 1, 0],
[1, 1, 0, 0, 0, 0, 1, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
assert_array_equal(convex_hull_object(image, connectivity=1), expected_conn_1)
expected_conn_2 = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 1, 1, 1],
[1, 1, 1, 0, 0, 0, 1, 1, 1],
[1, 1, 0, 0, 0, 0, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
assert_array_equal(convex_hull_object(image, connectivity=2), expected_conn_2)
with testing.raises(ValueError):
convex_hull_object(image, connectivity=3)
out = convex_hull_object(image, connectivity=1)
assert_array_equal(out, expected_conn_1)
def test_non_c_contiguous():
# 2D Fortran-contiguous
image = np.ones((2, 2), order='F', dtype=bool)
assert_array_equal(convex_hull_image(image), image)
# 3D Fortran-contiguous
image = np.ones((2, 2, 2), order='F', dtype=bool)
assert_array_equal(convex_hull_image(image), image)
# 3D non-contiguous
image = np.transpose(np.ones((2, 2, 2), dtype=bool), [0, 2, 1])
assert_array_equal(convex_hull_image(image), image)
@testing.fixture
def images2d3d():
from ...measure.tests.test_regionprops import SAMPLE as image
image3d = np.stack((image, image, image))
return image, image3d
def test_consistent_2d_3d_hulls(images2d3d):
image, image3d = images2d3d
chimage = convex_hull_image(image)
chimage[8, 0] = True # correct for single point exactly on hull edge
chimage3d = convex_hull_image(image3d)
assert_array_equal(chimage3d[1], chimage)
def test_few_points():
image = np.array(
[
[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, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
image3d = np.stack([image, image, image])
with testing.assert_warns(UserWarning):
chimage3d = convex_hull_image(image3d)
assert_array_equal(chimage3d, np.zeros(image3d.shape, dtype=bool))

View File

@@ -0,0 +1,690 @@
import math
import unittest
import numpy as np
from numpy.testing import assert_equal
from pytest import raises, warns
from skimage._shared.testing import expected_warnings
from skimage.morphology import extrema
eps = 1e-12
def diff(a, b):
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
t = ((a - b) ** 2).sum()
return math.sqrt(t)
class TestExtrema:
def test_saturated_arithmetic(self):
"""Adding/subtracting a constant and clipping"""
# Test for unsigned integer
data = np.array(
[[250, 251, 5, 5], [100, 200, 253, 252], [4, 10, 1, 3]], dtype=np.uint8
)
# adding the constant
img_constant_added = extrema._add_constant_clip(data, 4)
expected = np.array(
[[254, 255, 9, 9], [104, 204, 255, 255], [8, 14, 5, 7]], dtype=np.uint8
)
error = diff(img_constant_added, expected)
assert error < eps
img_constant_subtracted = extrema._subtract_constant_clip(data, 4)
expected = np.array(
[[246, 247, 1, 1], [96, 196, 249, 248], [0, 6, 0, 0]], dtype=np.uint8
)
error = diff(img_constant_subtracted, expected)
assert error < eps
# Test for signed integer
data = np.array([[32767, 32766], [-32768, -32767]], dtype=np.int16)
img_constant_added = extrema._add_constant_clip(data, 1)
expected = np.array([[32767, 32767], [-32767, -32766]], dtype=np.int16)
error = diff(img_constant_added, expected)
assert error < eps
img_constant_subtracted = extrema._subtract_constant_clip(data, 1)
expected = np.array([[32766, 32765], [-32768, -32768]], dtype=np.int16)
error = diff(img_constant_subtracted, expected)
assert error < eps
def test_h_maxima(self):
"""h-maxima for various data types"""
data = np.array(
[
[10, 11, 13, 14, 14, 15, 14, 14, 13, 11],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
[13, 15, 40, 40, 18, 18, 18, 60, 60, 15],
[14, 16, 40, 40, 19, 19, 19, 60, 60, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[15, 16, 18, 19, 19, 20, 19, 19, 18, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[14, 16, 80, 80, 19, 19, 19, 100, 100, 16],
[13, 15, 80, 80, 18, 18, 18, 100, 100, 15],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
],
dtype=np.uint8,
)
expected_result = 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, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 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, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
for dtype in [np.uint8, np.uint64, np.int8, np.int64]:
data = data.astype(dtype)
out = extrema.h_maxima(data, 40)
error = diff(expected_result, out)
assert error < eps
def test_h_minima(self):
"""h-minima for various data types"""
data = np.array(
[
[10, 11, 13, 14, 14, 15, 14, 14, 13, 11],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
[13, 15, 40, 40, 18, 18, 18, 60, 60, 15],
[14, 16, 40, 40, 19, 19, 19, 60, 60, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[15, 16, 18, 19, 19, 20, 19, 19, 18, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[14, 16, 80, 80, 19, 19, 19, 100, 100, 16],
[13, 15, 80, 80, 18, 18, 18, 100, 100, 15],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
],
dtype=np.uint8,
)
data = 100 - data
expected_result = 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, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 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, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
for dtype in [np.uint8, np.uint64, np.int8, np.int64]:
data = data.astype(dtype)
out = extrema.h_minima(data, 40)
error = diff(expected_result, out)
assert error < eps
assert out.dtype == expected_result.dtype
def test_extrema_float(self):
"""specific tests for float type"""
data = np.array(
[
[0.10, 0.11, 0.13, 0.14, 0.14, 0.15, 0.14, 0.14, 0.13, 0.11],
[0.11, 0.13, 0.15, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.13],
[0.13, 0.15, 0.40, 0.40, 0.18, 0.18, 0.18, 0.60, 0.60, 0.15],
[0.14, 0.16, 0.40, 0.40, 0.19, 0.19, 0.19, 0.60, 0.60, 0.16],
[0.14, 0.16, 0.18, 0.19, 0.19, 0.19, 0.19, 0.19, 0.18, 0.16],
[0.15, 0.182, 0.18, 0.19, 0.204, 0.20, 0.19, 0.19, 0.18, 0.16],
[0.14, 0.16, 0.18, 0.19, 0.19, 0.19, 0.19, 0.19, 0.18, 0.16],
[0.14, 0.16, 0.80, 0.80, 0.19, 0.19, 0.19, 1.0, 1.0, 0.16],
[0.13, 0.15, 0.80, 0.80, 0.18, 0.18, 0.18, 1.0, 1.0, 0.15],
[0.11, 0.13, 0.15, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.13],
],
dtype=np.float32,
)
inverted_data = 1.0 - data
out = extrema.h_maxima(data, 0.003)
expected_result = 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, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 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, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
error = diff(expected_result, out)
assert error < eps
out = extrema.h_minima(inverted_data, 0.003)
error = diff(expected_result, out)
assert error < eps
def test_h_maxima_float_image(self):
"""specific tests for h-maxima float image type"""
w = 10
x, y = np.mgrid[0:w, 0:w]
data = 20 - 0.2 * ((x - w / 2) ** 2 + (y - w / 2) ** 2)
data[2:4, 2:4] = 40
data[2:4, 7:9] = 60
data[7:9, 2:4] = 80
data[7:9, 7:9] = 100
data = data.astype(np.float32)
expected_result = np.zeros_like(data)
expected_result[(data > 19.9)] = 1.0
for h in [1.0e-12, 1.0e-6, 1.0e-3, 1.0e-2, 1.0e-1, 0.1]:
out = extrema.h_maxima(data, h)
error = diff(expected_result, out)
assert error < eps
def test_h_maxima_float_h(self):
"""specific tests for h-maxima float h parameter"""
data = np.array(
[
[0, 0, 0, 0, 0],
[0, 3, 3, 3, 0],
[0, 3, 4, 3, 0],
[0, 3, 3, 3, 0],
[0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
h_vals = np.linspace(1.0, 2.0, 100)
failures = 0
for h in h_vals:
if h % 1 != 0:
msgs = ['possible precision loss converting image']
else:
msgs = []
with expected_warnings(msgs):
maxima = extrema.h_maxima(data, h)
if maxima[2, 2] == 0:
failures += 1
assert failures == 0
def test_h_maxima_large_h(self):
"""test that h-maxima works correctly for large h"""
data = np.array(
[
[10, 10, 10, 10, 10],
[10, 13, 13, 13, 10],
[10, 13, 14, 13, 10],
[10, 13, 13, 13, 10],
[10, 10, 10, 10, 10],
],
dtype=np.uint8,
)
maxima = extrema.h_maxima(data, 5)
assert np.sum(maxima) == 0
data = np.array(
[
[10, 10, 10, 10, 10],
[10, 13, 13, 13, 10],
[10, 13, 14, 13, 10],
[10, 13, 13, 13, 10],
[10, 10, 10, 10, 10],
],
dtype=np.float32,
)
maxima = extrema.h_maxima(data, 5.0)
assert np.sum(maxima) == 0
def test_h_minima_float_image(self):
"""specific tests for h-minima float image type"""
w = 10
x, y = np.mgrid[0:w, 0:w]
data = 180 + 0.2 * ((x - w / 2) ** 2 + (y - w / 2) ** 2)
data[2:4, 2:4] = 160
data[2:4, 7:9] = 140
data[7:9, 2:4] = 120
data[7:9, 7:9] = 100
data = data.astype(np.float32)
expected_result = np.zeros_like(data)
expected_result[(data < 180.1)] = 1.0
for h in [1.0e-12, 1.0e-6, 1.0e-3, 1.0e-2, 1.0e-1, 0.1]:
out = extrema.h_minima(data, h)
error = diff(expected_result, out)
assert error < eps
def test_h_minima_float_h(self):
"""specific tests for h-minima float h parameter"""
data = np.array(
[
[4, 4, 4, 4, 4],
[4, 1, 1, 1, 4],
[4, 1, 0, 1, 4],
[4, 1, 1, 1, 4],
[4, 4, 4, 4, 4],
],
dtype=np.uint8,
)
h_vals = np.linspace(1.0, 2.0, 100)
failures = 0
for h in h_vals:
if h % 1 != 0:
msgs = ['possible precision loss converting image']
else:
msgs = []
with expected_warnings(msgs):
minima = extrema.h_minima(data, h)
if minima[2, 2] == 0:
failures += 1
assert failures == 0
def test_h_minima_large_h(self):
"""test that h-minima works correctly for large h"""
data = np.array(
[
[14, 14, 14, 14, 14],
[14, 11, 11, 11, 14],
[14, 11, 10, 11, 14],
[14, 11, 11, 11, 14],
[14, 14, 14, 14, 14],
],
dtype=np.uint8,
)
maxima = extrema.h_minima(data, 5)
assert np.sum(maxima) == 0
data = np.array(
[
[14, 14, 14, 14, 14],
[14, 11, 11, 11, 14],
[14, 11, 10, 11, 14],
[14, 11, 11, 11, 14],
[14, 14, 14, 14, 14],
],
dtype=np.float32,
)
maxima = extrema.h_minima(data, 5.0)
assert np.sum(maxima) == 0
class TestLocalMaxima(unittest.TestCase):
"""Some tests for local_minima are included as well."""
supported_dtypes = [
np.uint8,
np.uint16,
np.uint32,
np.uint64,
np.int8,
np.int16,
np.int32,
np.int64,
np.float32,
np.float64,
]
image = np.array(
[
[1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 2, 0, 0, 3, 3, 0, 0, 4, 0, 2, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 4, 4, 0, 3, 0, 0, 0],
[0, 2, 0, 1, 0, 2, 1, 0, 0, 0, 0, 3, 0, 0, 0],
[0, 0, 2, 0, 2, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
# Connectivity 2, maxima can touch border, returned with default values
expected_default = np.array(
[
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
# Connectivity 1 (cross), maxima can touch border
expected_cross = np.array(
[
[1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0],
[0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
def test_empty(self):
"""Test result with empty image."""
result = extrema.local_maxima(np.array([[]]), indices=False)
assert result.size == 0
assert result.dtype == bool
assert result.shape == (1, 0)
result = extrema.local_maxima(np.array([]), indices=True)
assert isinstance(result, tuple)
assert len(result) == 1
assert result[0].size == 0
assert result[0].dtype == np.intp
result = extrema.local_maxima(np.array([[]]), indices=True)
assert isinstance(result, tuple)
assert len(result) == 2
assert result[0].size == 0
assert result[0].dtype == np.intp
assert result[1].size == 0
assert result[1].dtype == np.intp
def test_dtypes(self):
"""Test results with default configuration for all supported dtypes."""
for dtype in self.supported_dtypes:
result = extrema.local_maxima(self.image.astype(dtype))
assert result.dtype == bool
assert_equal(result, self.expected_default)
def test_dtypes_old(self):
"""
Test results with default configuration and data copied from old unit
tests for all supported dtypes.
"""
data = np.array(
[
[10, 11, 13, 14, 14, 15, 14, 14, 13, 11],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
[13, 15, 40, 40, 18, 18, 18, 60, 60, 15],
[14, 16, 40, 40, 19, 19, 19, 60, 60, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[15, 16, 18, 19, 19, 20, 19, 19, 18, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[14, 16, 80, 80, 19, 19, 19, 100, 100, 16],
[13, 15, 80, 80, 18, 18, 18, 100, 100, 15],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
],
dtype=np.uint8,
)
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, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 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, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
for dtype in self.supported_dtypes:
image = data.astype(dtype)
result = extrema.local_maxima(image)
assert result.dtype == bool
assert_equal(result, expected)
def test_connectivity(self):
"""Test results if footprint is a scalar."""
# Connectivity 1: generates cross shaped footprint
result_conn1 = extrema.local_maxima(self.image, connectivity=1)
assert result_conn1.dtype == bool
assert_equal(result_conn1, self.expected_cross)
# Connectivity 2: generates square shaped footprint
result_conn2 = extrema.local_maxima(self.image, connectivity=2)
assert result_conn2.dtype == bool
assert_equal(result_conn2, self.expected_default)
# Connectivity 3: generates square shaped footprint
result_conn3 = extrema.local_maxima(self.image, connectivity=3)
assert result_conn3.dtype == bool
assert_equal(result_conn3, self.expected_default)
def test_footprint(self):
"""Test results if footprint is given."""
footprint_cross = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=bool)
result_footprint_cross = extrema.local_maxima(
self.image, footprint=footprint_cross
)
assert result_footprint_cross.dtype == bool
assert_equal(result_footprint_cross, self.expected_cross)
for footprint in [
((True,) * 3,) * 3,
np.ones((3, 3), dtype=np.float64),
np.ones((3, 3), dtype=np.uint8),
np.ones((3, 3), dtype=bool),
]:
# Test different dtypes for footprint which expects a boolean array
# but will accept and convert other types if possible
result_footprint_square = extrema.local_maxima(
self.image, footprint=footprint
)
assert result_footprint_square.dtype == bool
assert_equal(result_footprint_square, self.expected_default)
footprint_x = np.array([[1, 0, 1], [0, 1, 0], [1, 0, 1]], dtype=bool)
expected_footprint_x = np.array(
[
[1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
],
dtype=bool,
)
result_footprint_x = extrema.local_maxima(self.image, footprint=footprint_x)
assert result_footprint_x.dtype == bool
assert_equal(result_footprint_x, expected_footprint_x)
def test_indices(self):
"""Test output if indices of peaks are desired."""
# Connectivity 1
expected_conn1 = np.nonzero(self.expected_cross)
result_conn1 = extrema.local_maxima(self.image, connectivity=1, indices=True)
assert_equal(result_conn1, expected_conn1)
# Connectivity 2
expected_conn2 = np.nonzero(self.expected_default)
result_conn2 = extrema.local_maxima(self.image, connectivity=2, indices=True)
assert_equal(result_conn2, expected_conn2)
def test_allow_borders(self):
"""Test maxima detection at the image border."""
# Use connectivity 1 to allow many maxima, only filtering at border is
# of interest
result_with_boder = extrema.local_maxima(
self.image, connectivity=1, allow_borders=True
)
assert result_with_boder.dtype == bool
assert_equal(result_with_boder, self.expected_cross)
expected_without_border = 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, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0],
[0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
result_without_border = extrema.local_maxima(
self.image, connectivity=1, allow_borders=False
)
assert result_with_boder.dtype == bool
assert_equal(result_without_border, expected_without_border)
def test_nd(self):
"""Test one- and three-dimensional case."""
# One-dimension
x_1d = np.array([1, 1, 0, 1, 2, 3, 0, 2, 1, 2, 0])
expected_1d = np.array([1, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0], dtype=bool)
result_1d = extrema.local_maxima(x_1d)
assert result_1d.dtype == bool
assert_equal(result_1d, expected_1d)
# 3-dimensions (adapted from old unit test)
x_3d = np.zeros((8, 8, 8), dtype=np.uint8)
expected_3d = np.zeros((8, 8, 8), dtype=bool)
# first maximum: only one pixel
x_3d[1, 1:3, 1:3] = 100
x_3d[2, 2, 2] = 200
x_3d[3, 1:3, 1:3] = 100
expected_3d[2, 2, 2] = 1
# second maximum: three pixels in z-direction
x_3d[5:8, 1, 1] = 200
expected_3d[5:8, 1, 1] = 1
# third: two maxima in 0 and 3.
x_3d[0, 5:8, 5:8] = 200
x_3d[1, 6, 6] = 100
x_3d[2, 5:7, 5:7] = 200
x_3d[0:3, 5:8, 5:8] += 50
expected_3d[0, 5:8, 5:8] = 1
expected_3d[2, 5:7, 5:7] = 1
# four : one maximum in the corner of the square
x_3d[6:8, 6:8, 6:8] = 200
x_3d[7, 7, 7] = 255
expected_3d[7, 7, 7] = 1
result_3d = extrema.local_maxima(x_3d)
assert result_3d.dtype == bool
assert_equal(result_3d, expected_3d)
def test_constant(self):
"""Test behaviour for 'flat' images."""
const_image = np.full((7, 6), 42, dtype=np.uint8)
expected = np.zeros((7, 6), dtype=np.uint8)
for dtype in self.supported_dtypes:
const_image = const_image.astype(dtype)
# test for local maxima
result = extrema.local_maxima(const_image)
assert result.dtype == bool
assert_equal(result, expected)
# test for local minima
result = extrema.local_minima(const_image)
assert result.dtype == bool
assert_equal(result, expected)
def test_extrema_float(self):
"""Specific tests for float type."""
# Copied from old unit test for local_maxma
image = np.array(
[
[0.10, 0.11, 0.13, 0.14, 0.14, 0.15, 0.14, 0.14, 0.13, 0.11],
[0.11, 0.13, 0.15, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.13],
[0.13, 0.15, 0.40, 0.40, 0.18, 0.18, 0.18, 0.60, 0.60, 0.15],
[0.14, 0.16, 0.40, 0.40, 0.19, 0.19, 0.19, 0.60, 0.60, 0.16],
[0.14, 0.16, 0.18, 0.19, 0.19, 0.19, 0.19, 0.19, 0.18, 0.16],
[0.15, 0.182, 0.18, 0.19, 0.204, 0.20, 0.19, 0.19, 0.18, 0.16],
[0.14, 0.16, 0.18, 0.19, 0.19, 0.19, 0.19, 0.19, 0.18, 0.16],
[0.14, 0.16, 0.80, 0.80, 0.19, 0.19, 0.19, 1.0, 1.0, 0.16],
[0.13, 0.15, 0.80, 0.80, 0.18, 0.18, 0.18, 1.0, 1.0, 0.15],
[0.11, 0.13, 0.15, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.13],
],
dtype=np.float32,
)
inverted_image = 1.0 - image
expected_result = 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, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
# Test for local maxima with automatic step calculation
result = extrema.local_maxima(image)
assert result.dtype == bool
assert_equal(result, expected_result)
# Test for local minima with automatic step calculation
result = extrema.local_minima(inverted_image)
assert result.dtype == bool
assert_equal(result, expected_result)
def test_exceptions(self):
"""Test if input validation triggers correct exceptions."""
# Mismatching number of dimensions
with raises(ValueError, match="number of dimensions"):
extrema.local_maxima(self.image, footprint=np.ones((3, 3, 3), dtype=bool))
with raises(ValueError, match="number of dimensions"):
extrema.local_maxima(self.image, footprint=np.ones((3,), dtype=bool))
# All dimensions in footprint must be of size 3
with raises(ValueError, match="dimension size"):
extrema.local_maxima(self.image, footprint=np.ones((2, 3), dtype=bool))
with raises(ValueError, match="dimension size"):
extrema.local_maxima(self.image, footprint=np.ones((5, 5), dtype=bool))
with raises(TypeError, match="float16 which is not supported"):
extrema.local_maxima(np.empty(1, dtype=np.float16))
def test_small_array(self):
"""Test output for arrays with dimension smaller 3.
If any dimension of an array is smaller than 3 and `allow_borders` is
false a footprint, which has at least 3 elements in each
dimension, can't be applied. This is an implementation detail so
`local_maxima` should still return valid output (see gh-3261).
If `allow_borders` is true the array is padded internally and there is
no problem.
"""
warning_msg = "maxima can't exist .* any dimension smaller 3 .*"
x = np.array([0, 1])
extrema.local_maxima(x, allow_borders=True) # no warning
with warns(UserWarning, match=warning_msg):
result = extrema.local_maxima(x, allow_borders=False)
assert_equal(result, [0, 0])
assert result.dtype == bool
x = np.array([[1, 2], [2, 2]])
extrema.local_maxima(x, allow_borders=True, indices=True) # no warning
with warns(UserWarning, match=warning_msg):
result = extrema.local_maxima(x, allow_borders=False, indices=True)
assert_equal(result, np.zeros((2, 0), dtype=np.intp))
assert result[0].dtype == np.intp
assert result[1].dtype == np.intp

View File

@@ -0,0 +1,360 @@
import numpy as np
import pytest
from skimage.morphology import flood, flood_fill
eps = 1e-12
def test_empty_input():
# Test shortcut
output = flood_fill(np.empty(0), (), 2)
assert output.size == 0
# Boolean output type
assert flood(np.empty(0), ()).dtype == bool
# Maintain shape, even with zero size present
assert flood(np.empty((20, 0, 4)), ()).shape == (20, 0, 4)
def test_float16():
image = np.array([9.0, 0.1, 42], dtype=np.float16)
with pytest.raises(TypeError, match="dtype of `image` is float16"):
flood_fill(image, 0, 1)
@pytest.mark.parametrize("tolerance", [-150, 150, -379, 379])
def test_overrange_tolerance_int(tolerance):
image = np.arange(256, dtype=np.uint8).reshape((8, 8, 4))
seed = (3, 4, 2)
expected = np.zeros_like(image)
output = flood_fill(image, seed, 0, tolerance=tolerance)
np.testing.assert_equal(output, expected)
def test_overrange_tolerance_float():
max_value = np.finfo(np.float32).max
image = np.random.uniform(size=(64, 64), low=-1.0, high=1.0).astype(np.float32)
image *= max_value
expected = np.ones_like(image)
output = flood_fill(image, (0, 1), 1.0, tolerance=max_value.item() * 10)
np.testing.assert_equal(output, expected)
def test_inplace_int():
image = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3],
[0, 1, 1, 1, 3, 3, 4],
]
)
flood_fill(image, (0, 0), 5, in_place=True)
expected = np.array(
[
[5, 5, 5, 5, 5, 5, 5],
[5, 1, 1, 5, 2, 2, 5],
[5, 1, 1, 5, 2, 2, 5],
[1, 5, 5, 5, 5, 5, 3],
[5, 1, 1, 1, 3, 3, 4],
]
)
np.testing.assert_array_equal(image, expected)
def test_inplace_float():
image = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3],
[0, 1, 1, 1, 3, 3, 4],
],
dtype=np.float32,
)
flood_fill(image, (0, 0), 5, in_place=True)
expected = np.array(
[
[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
[5.0, 1.0, 1.0, 5.0, 2.0, 2.0, 5.0],
[5.0, 1.0, 1.0, 5.0, 2.0, 2.0, 5.0],
[1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 3.0],
[5.0, 1.0, 1.0, 1.0, 3.0, 3.0, 4.0],
],
dtype=np.float32,
)
np.testing.assert_allclose(image, expected)
def test_inplace_noncontiguous():
image = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3],
[0, 1, 1, 1, 3, 3, 4],
]
)
# Transpose is noncontiguous
image2 = image[::2, ::2]
flood_fill(image2, (0, 0), 5, in_place=True)
# The inplace modified result
expected2 = np.array([[5, 5, 5, 5], [5, 1, 2, 5], [5, 1, 3, 4]])
np.testing.assert_allclose(image2, expected2)
# Projected back through the view, `image` also modified
expected = np.array(
[
[5, 0, 5, 0, 5, 0, 5],
[0, 1, 1, 0, 2, 2, 0],
[5, 1, 1, 0, 2, 2, 5],
[1, 0, 0, 0, 0, 0, 3],
[5, 1, 1, 1, 3, 3, 4],
]
)
np.testing.assert_allclose(image, expected)
def test_1d():
image = np.arange(11)
expected = np.array([0, 1, -20, -20, -20, -20, -20, -20, -20, 9, 10])
output = flood_fill(image, 5, -20, tolerance=3)
output2 = flood_fill(image, (5,), -20, tolerance=3)
np.testing.assert_equal(output, expected)
np.testing.assert_equal(output, output2)
def test_wraparound():
# If the borders (or neighbors) aren't correctly accounted for, this fails,
# because the algorithm uses an ravelled array.
test = np.zeros((5, 7), dtype=np.float64)
test[:, 3] = 100
expected = np.array(
[
[-1.0, -1.0, -1.0, 100.0, 0.0, 0.0, 0.0],
[-1.0, -1.0, -1.0, 100.0, 0.0, 0.0, 0.0],
[-1.0, -1.0, -1.0, 100.0, 0.0, 0.0, 0.0],
[-1.0, -1.0, -1.0, 100.0, 0.0, 0.0, 0.0],
[-1.0, -1.0, -1.0, 100.0, 0.0, 0.0, 0.0],
]
)
np.testing.assert_equal(flood_fill(test, (0, 0), -1), expected)
def test_neighbors():
# This test will only pass if the neighbors are exactly correct
test = np.zeros((5, 7), dtype=np.float64)
test[:, 3] = 100
expected = np.array(
[
[0, 0, 0, 255, 0, 0, 0],
[0, 0, 0, 255, 0, 0, 0],
[0, 0, 0, 255, 0, 0, 0],
[0, 0, 0, 255, 0, 0, 0],
[0, 0, 0, 255, 0, 0, 0],
]
)
output = flood_fill(test, (0, 3), 255)
np.testing.assert_equal(output, expected)
test[2] = 100
expected[2] = 255
output2 = flood_fill(test, (2, 3), 255)
np.testing.assert_equal(output2, expected)
def test_footprint():
# Basic tests for nonstandard footprints
footprint = np.array([[0, 1, 1], [0, 1, 1], [0, 0, 0]]) # Cannot grow left or down
output = flood_fill(
np.zeros((5, 6), dtype=np.uint8), (3, 1), 255, footprint=footprint
)
expected = np.array(
[
[0, 255, 255, 255, 255, 255],
[0, 255, 255, 255, 255, 255],
[0, 255, 255, 255, 255, 255],
[0, 255, 255, 255, 255, 255],
[0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
np.testing.assert_equal(output, expected)
footprint = np.array([[0, 0, 0], [1, 1, 0], [1, 1, 0]]) # Cannot grow right or up
output = flood_fill(
np.zeros((5, 6), dtype=np.uint8), (1, 4), 255, footprint=footprint
)
expected = np.array(
[
[0, 0, 0, 0, 0, 0],
[255, 255, 255, 255, 255, 0],
[255, 255, 255, 255, 255, 0],
[255, 255, 255, 255, 255, 0],
[255, 255, 255, 255, 255, 0],
],
dtype=np.uint8,
)
np.testing.assert_equal(output, expected)
def test_basic_nd():
for dimension in (3, 4, 5):
shape = (5,) * dimension
hypercube = np.zeros(shape)
slice_mid = tuple(slice(1, -1, None) for dim in range(dimension))
hypercube[slice_mid] = 1 # sum is 3**dimension
filled = flood_fill(hypercube, (2,) * dimension, 2)
# Test that the middle sum is correct
assert filled.sum() == 3**dimension * 2
# Test that the entire array is as expected
np.testing.assert_equal(
filled, np.pad(np.ones((3,) * dimension) * 2, 1, 'constant')
)
@pytest.mark.parametrize("tolerance", [None, 0])
def test_f_order(tolerance):
image = np.array(
[
[0, 0, 0, 0],
[1, 0, 0, 0],
[0, 1, 0, 0],
],
order="F",
)
expected = np.array(
[
[0, 0, 0, 0],
[1, 0, 0, 0],
[0, 1, 0, 0],
],
dtype=bool,
)
mask = flood(image, seed_point=(1, 0), tolerance=tolerance)
np.testing.assert_array_equal(expected, mask)
mask = flood(image, seed_point=(2, 1), tolerance=tolerance)
np.testing.assert_array_equal(expected, mask)
def test_negative_indexing_seed_point():
image = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 2, 2, 0],
[0, 1, 1, 0, 2, 2, 0],
[1, 0, 0, 0, 0, 0, 3],
[0, 1, 1, 1, 3, 3, 4],
],
dtype=np.float32,
)
expected = np.array(
[
[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
[5.0, 1.0, 1.0, 5.0, 2.0, 2.0, 5.0],
[5.0, 1.0, 1.0, 5.0, 2.0, 2.0, 5.0],
[1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 3.0],
[5.0, 1.0, 1.0, 1.0, 3.0, 3.0, 4.0],
],
dtype=np.float32,
)
image = flood_fill(image, (0, -1), 5)
np.testing.assert_allclose(image, expected)
def test_non_adjacent_footprint():
# Basic tests for non-adjacent footprints
footprint = np.array(
[
[1, 0, 0, 0, 1],
[0, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 0, 0],
[1, 0, 0, 0, 1],
]
)
output = flood_fill(
np.zeros((5, 6), dtype=np.uint8), (2, 3), 255, footprint=footprint
)
expected = np.array(
[
[0, 255, 0, 0, 0, 255],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 255, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 255, 0, 0, 0, 255],
],
dtype=np.uint8,
)
np.testing.assert_equal(output, expected)
footprint = 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],
]
)
image = np.zeros((5, 10), dtype=np.uint8)
image[:, (3, 7, 8)] = 100
output = flood_fill(image, (0, 0), 255, footprint=footprint)
expected = np.array(
[
[255, 255, 255, 100, 255, 255, 255, 100, 100, 0],
[255, 255, 255, 100, 255, 255, 255, 100, 100, 0],
[255, 255, 255, 100, 255, 255, 255, 100, 100, 0],
[255, 255, 255, 100, 255, 255, 255, 100, 100, 0],
[255, 255, 255, 100, 255, 255, 255, 100, 100, 0],
],
dtype=np.uint8,
)
np.testing.assert_equal(output, expected)

View File

@@ -0,0 +1,269 @@
"""
Tests for Morphological footprints
(skimage.morphology.footprint)
Author: Damian Eads
"""
import numpy as np
import pytest
from numpy.testing import assert_equal
from skimage._shared.testing import fetch
from skimage.morphology import footprints
class TestFootprints:
def test_square_footprint(self):
"""Test square footprints"""
for k in range(0, 5):
actual_mask = footprints.square(k)
expected_mask = np.ones((k, k), dtype='uint8')
assert_equal(expected_mask, actual_mask)
def test_rectangle_footprint(self):
"""Test rectangle footprints"""
for i in range(0, 5):
for j in range(0, 5):
actual_mask = footprints.rectangle(i, j)
expected_mask = np.ones((i, j), dtype='uint8')
assert_equal(expected_mask, actual_mask)
def test_cube_footprint(self):
"""Test cube footprints"""
for k in range(0, 5):
actual_mask = footprints.cube(k)
expected_mask = np.ones((k, k, k), dtype='uint8')
assert_equal(expected_mask, actual_mask)
def strel_worker(self, fn, func):
matlab_masks = np.load(fetch(fn))
k = 0
for arrname in sorted(matlab_masks):
expected_mask = matlab_masks[arrname]
actual_mask = func(k)
if expected_mask.shape == (1,):
expected_mask = expected_mask[:, np.newaxis]
assert_equal(expected_mask, actual_mask)
k = k + 1
def strel_worker_3d(self, fn, func):
matlab_masks = np.load(fetch(fn))
k = 0
for arrname in sorted(matlab_masks):
expected_mask = matlab_masks[arrname]
actual_mask = func(k)
if expected_mask.shape == (1,):
expected_mask = expected_mask[:, np.newaxis]
# Test center slice for each dimension. This gives a good
# indication of validity without the need for a 3D reference
# mask.
c = int(expected_mask.shape[0] / 2)
assert_equal(expected_mask, actual_mask[c, :, :])
assert_equal(expected_mask, actual_mask[:, c, :])
assert_equal(expected_mask, actual_mask[:, :, c])
k = k + 1
def test_footprint_disk(self):
"""Test disk footprints"""
self.strel_worker("data/disk-matlab-output.npz", footprints.disk)
def test_footprint_diamond(self):
"""Test diamond footprints"""
self.strel_worker("data/diamond-matlab-output.npz", footprints.diamond)
def test_footprint_ball(self):
"""Test ball footprints"""
self.strel_worker_3d("data/disk-matlab-output.npz", footprints.ball)
def test_footprint_octahedron(self):
"""Test octahedron footprints"""
self.strel_worker_3d("data/diamond-matlab-output.npz", footprints.octahedron)
def test_footprint_octagon(self):
"""Test octagon footprints"""
expected_mask1 = np.array(
[
[0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 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, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
],
dtype=np.uint8,
)
actual_mask1 = footprints.octagon(5, 3)
expected_mask2 = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.uint8)
actual_mask2 = footprints.octagon(1, 1)
assert_equal(expected_mask1, actual_mask1)
assert_equal(expected_mask2, actual_mask2)
def test_footprint_ellipse(self):
"""Test ellipse footprints"""
expected_mask1 = 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, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
],
dtype=np.uint8,
)
actual_mask1 = footprints.ellipse(5, 3)
expected_mask2 = np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8)
actual_mask2 = footprints.ellipse(1, 1)
assert_equal(expected_mask1, actual_mask1)
assert_equal(expected_mask2, actual_mask2)
assert_equal(expected_mask1, footprints.ellipse(3, 5).T)
assert_equal(expected_mask2, footprints.ellipse(1, 1).T)
def test_footprint_star(self):
"""Test star footprints"""
expected_mask1 = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
actual_mask1 = footprints.star(4)
expected_mask2 = np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8)
actual_mask2 = footprints.star(1)
assert_equal(expected_mask1, actual_mask1)
assert_equal(expected_mask2, actual_mask2)
@pytest.mark.parametrize(
'function, args, supports_sequence_decomposition',
[
(footprints.disk, (3,), True),
(footprints.ball, (3,), True),
(footprints.square, (3,), True),
(footprints.cube, (3,), True),
(footprints.diamond, (3,), True),
(footprints.octahedron, (3,), True),
(footprints.rectangle, (3, 4), True),
(footprints.ellipse, (3, 4), False),
(footprints.octagon, (3, 4), True),
(footprints.star, (3,), False),
],
)
@pytest.mark.parametrize("dtype", [np.uint8, np.float64])
def test_footprint_dtype(function, args, supports_sequence_decomposition, dtype):
# make sure footprint dtype matches what was requested
footprint = function(*args, dtype=dtype)
assert footprint.dtype == dtype
if supports_sequence_decomposition:
sequence = function(*args, dtype=dtype, decomposition='sequence')
assert all([fp_tuple[0].dtype == dtype for fp_tuple in sequence])
@pytest.mark.parametrize("function", ["disk", "ball"])
@pytest.mark.parametrize("radius", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 50, 75, 100])
def test_nsphere_series_approximation(function, radius):
fp_func = getattr(footprints, function)
expected = fp_func(radius, strict_radius=False, decomposition=None)
footprint_sequence = fp_func(radius, strict_radius=False, decomposition="sequence")
approximate = footprints.footprint_from_sequence(footprint_sequence)
assert approximate.shape == expected.shape
# verify that maximum error does not exceed some fraction of the size
error = np.sum(np.abs(expected.astype(int) - approximate.astype(int)))
if radius == 1:
assert error == 0
else:
max_error = 0.1 if function == "disk" else 0.15
assert error / expected.size <= max_error
@pytest.mark.parametrize("radius", [1, 2, 3, 4, 5, 10, 20, 50, 75])
@pytest.mark.parametrize("strict_radius", [False, True])
def test_disk_crosses_approximation(radius, strict_radius):
fp_func = footprints.disk
expected = fp_func(radius, strict_radius=strict_radius, decomposition=None)
footprint_sequence = fp_func(
radius, strict_radius=strict_radius, decomposition="crosses"
)
approximate = footprints.footprint_from_sequence(footprint_sequence)
assert approximate.shape == expected.shape
# verify that maximum error does not exceed some fraction of the size
error = np.sum(np.abs(expected.astype(int) - approximate.astype(int)))
max_error = 0.05
assert error / expected.size <= max_error
@pytest.mark.parametrize("width", [3, 8, 20, 50])
@pytest.mark.parametrize("height", [3, 8, 20, 50])
def test_ellipse_crosses_approximation(width, height):
fp_func = footprints.ellipse
expected = fp_func(width, height, decomposition=None)
footprint_sequence = fp_func(width, height, decomposition="crosses")
approximate = footprints.footprint_from_sequence(footprint_sequence)
assert approximate.shape == expected.shape
# verify that maximum error does not exceed some fraction of the size
error = np.sum(np.abs(expected.astype(int) - approximate.astype(int)))
max_error = 0.05
assert error / expected.size <= max_error
def test_disk_series_approximation_unavailable():
# ValueError if radius is too large (only precomputed up to radius=250)
with pytest.raises(ValueError):
footprints.disk(radius=10000, decomposition="sequence")
def test_ball_series_approximation_unavailable():
# ValueError if radius is too large (only precomputed up to radius=100)
with pytest.raises(ValueError):
footprints.ball(radius=10000, decomposition="sequence")
@pytest.mark.parametrize("as_sequence", [tuple, None])
def test_mirror_footprint(as_sequence):
footprint = np.array([[0, 0, 0], [0, 1, 1], [0, 1, 1]], np.uint8)
expected_res = np.array([[1, 1, 0], [1, 1, 0], [0, 0, 0]], dtype=np.uint8)
if as_sequence is not None:
footprint = as_sequence([(footprint, 2), (footprint.T, 3)])
expected_res = as_sequence([(expected_res, 2), (expected_res.T, 3)])
actual_res = footprints.mirror_footprint(footprint)
assert type(expected_res) is type(actual_res)
assert_equal(expected_res, actual_res)
@pytest.mark.parametrize("as_sequence", [tuple, None])
@pytest.mark.parametrize("pad_end", [True, False])
def test_pad_footprint(as_sequence, pad_end):
footprint = np.array([[0, 0], [1, 0], [1, 1]], np.uint8)
pad_width = [(0, 0), (0, 1)] if pad_end is True else [(0, 0), (1, 0)]
expected_res = np.pad(footprint, pad_width)
if as_sequence is not None:
footprint = as_sequence([(footprint, 2), (footprint.T, 3)])
expected_res = as_sequence([(expected_res, 2), (expected_res.T, 3)])
actual_res = footprints.pad_footprint(footprint, pad_end=pad_end)
assert type(expected_res) is type(actual_res)
assert_equal(expected_res, actual_res)

View File

@@ -0,0 +1,514 @@
import numpy as np
import pytest
from scipy import ndimage as ndi
from numpy.testing import assert_allclose, assert_array_equal, assert_equal
from skimage import color, data, transform
from skimage._shared._warnings import expected_warnings
from skimage._shared.testing import fetch, assert_stacklevel
from skimage.morphology import gray, footprints
from skimage.util import img_as_uint, img_as_ubyte
@pytest.fixture
def cam_image():
from skimage import data
return np.ascontiguousarray(data.camera()[64:112, 64:96])
@pytest.fixture
def cell3d_image():
from skimage import data
return np.ascontiguousarray(data.cells3d()[30:48, 0, 20:36, 20:32])
gray_morphology_funcs = (
gray.erosion,
gray.dilation,
gray.opening,
gray.closing,
gray.white_tophat,
gray.black_tophat,
)
class TestMorphology:
# These expected outputs were generated with skimage v0.22.0 + PR #6695
# using:
#
# from skimage.morphology.tests.test_gray import TestMorphology
# import numpy as np
# output = TestMorphology()._build_expected_output()
# np.savez_compressed('gray_morph_output.npz', **output)
def _build_expected_output(self):
footprints_2D = (
footprints.square,
footprints.diamond,
footprints.disk,
footprints.star,
)
image = img_as_ubyte(
transform.downscale_local_mean(color.rgb2gray(data.coffee()), (20, 20))
)
output = {}
for n in range(1, 4):
for strel in footprints_2D:
for func in gray_morphology_funcs:
key = f'{strel.__name__}_{n}_{func.__name__}'
output[key] = func(image, strel(n))
return output
def test_gray_morphology(self):
expected = dict(np.load(fetch('data/gray_morph_output.npz')))
calculated = self._build_expected_output()
assert_equal(expected, calculated)
def test_gray_closing_extensive(self):
img = data.coins()
footprint = np.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
# Default mode="reflect" is not extensive for backwards-compatibility
result_default = gray.closing(img, footprint=footprint)
assert not np.all(result_default >= img)
result = gray.closing(img, footprint=footprint, mode="ignore")
assert np.all(result >= img)
def test_gray_opening_anti_extensive(self):
img = data.coins()
footprint = np.array([[0, 0, 1], [0, 1, 1], [1, 1, 1]])
# Default mode="reflect" is not extensive for backwards-compatibility
result_default = gray.opening(img, footprint=footprint)
assert not np.all(result_default <= img)
result_ignore = gray.opening(img, footprint=footprint, mode="ignore")
assert np.all(result_ignore <= img)
@pytest.mark.parametrize("func", gray_morphology_funcs)
@pytest.mark.parametrize("mode", gray._SUPPORTED_MODES)
def test_supported_mode(self, func, mode):
img = np.ones((10, 10))
func(img, mode=mode)
@pytest.mark.parametrize("func", gray_morphology_funcs)
@pytest.mark.parametrize("mode", ["", "symmetric", 3, None])
def test_unsupported_mode(self, func, mode):
img = np.ones((10, 10))
with pytest.raises(ValueError, match="unsupported mode"):
func(img, mode=mode)
class TestEccentricStructuringElements:
def setup_class(self):
self.black_pixel = 255 * np.ones((6, 6), dtype=np.uint8)
self.black_pixel[2, 2] = 0
self.white_pixel = 255 - self.black_pixel
self.footprints = [
footprints.square(2),
footprints.rectangle(2, 2),
footprints.rectangle(2, 1),
footprints.rectangle(1, 2),
]
def test_dilate_erode_symmetry(self):
for s in self.footprints:
c = gray.erosion(self.black_pixel, s)
d = gray.dilation(self.white_pixel, s)
assert np.all(c == (255 - d))
def test_open_black_pixel(self):
for s in self.footprints:
gray_open = gray.opening(self.black_pixel, s)
assert np.all(gray_open == self.black_pixel)
def test_close_white_pixel(self):
for s in self.footprints:
gray_close = gray.closing(self.white_pixel, s)
assert np.all(gray_close == self.white_pixel)
def test_open_white_pixel(self):
for s in self.footprints:
assert np.all(gray.opening(self.white_pixel, s) == 0)
def test_close_black_pixel(self):
for s in self.footprints:
assert np.all(gray.closing(self.black_pixel, s) == 255)
def test_white_tophat_white_pixel(self):
for s in self.footprints:
tophat = gray.white_tophat(self.white_pixel, s)
assert np.all(tophat == self.white_pixel)
def test_black_tophat_black_pixel(self):
for s in self.footprints:
tophat = gray.black_tophat(self.black_pixel, s)
assert np.all(tophat == self.white_pixel)
def test_white_tophat_black_pixel(self):
for s in self.footprints:
tophat = gray.white_tophat(self.black_pixel, s)
assert np.all(tophat == 0)
def test_black_tophat_white_pixel(self):
for s in self.footprints:
tophat = gray.black_tophat(self.white_pixel, s)
assert np.all(tophat == 0)
gray_functions = [
gray.erosion,
gray.dilation,
gray.opening,
gray.closing,
gray.white_tophat,
gray.black_tophat,
]
@pytest.mark.parametrize("function", gray_functions)
def test_default_footprint(function):
strel = footprints.diamond(radius=1)
image = 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, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 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],
],
np.uint8,
)
im_expected = function(image, strel)
im_test = function(image)
assert_array_equal(im_expected, im_test)
def test_3d_fallback_default_footprint():
# 3x3x3 cube inside a 7x7x7 image:
image = np.zeros((7, 7, 7), bool)
image[2:-2, 2:-2, 2:-2] = 1
opened = gray.opening(image)
# expect a "hyper-cross" centered in the 5x5x5:
image_expected = np.zeros((7, 7, 7), dtype=bool)
image_expected[2:5, 2:5, 2:5] = ndi.generate_binary_structure(3, 1)
assert_array_equal(opened, image_expected)
gray_3d_fallback_functions = [gray.closing, gray.opening]
@pytest.mark.parametrize("function", gray_3d_fallback_functions)
def test_3d_fallback_cube_footprint(function):
# 3x3x3 cube inside a 7x7x7 image:
image = np.zeros((7, 7, 7), bool)
image[2:-2, 2:-2, 2:-2] = 1
cube = np.ones((3, 3, 3), dtype=np.uint8)
new_image = function(image, cube)
assert_array_equal(new_image, image)
def test_3d_fallback_white_tophat():
image = np.zeros((7, 7, 7), dtype=bool)
image[2, 2:4, 2:4] = 1
image[3, 2:5, 2:5] = 1
image[4, 3:5, 3:5] = 1
with expected_warnings([r'operator.*deprecated|\A\Z']):
new_image = gray.white_tophat(image)
footprint = ndi.generate_binary_structure(3, 1)
with expected_warnings([r'operator.*deprecated|\A\Z']):
image_expected = ndi.white_tophat(
image.view(dtype=np.uint8), footprint=footprint
)
assert_array_equal(new_image, image_expected)
def test_3d_fallback_black_tophat():
image = np.ones((7, 7, 7), dtype=bool)
image[2, 2:4, 2:4] = 0
image[3, 2:5, 2:5] = 0
image[4, 3:5, 3:5] = 0
with expected_warnings([r'operator.*deprecated|\A\Z']):
new_image = gray.black_tophat(image)
footprint = ndi.generate_binary_structure(3, 1)
with expected_warnings([r'operator.*deprecated|\A\Z']):
image_expected = ndi.black_tophat(
image.view(dtype=np.uint8), footprint=footprint
)
assert_array_equal(new_image, image_expected)
def test_2d_ndimage_equivalence():
image = np.zeros((9, 9), np.uint8)
image[2:-2, 2:-2] = 128
image[3:-3, 3:-3] = 196
image[4, 4] = 255
opened = gray.opening(image)
closed = gray.closing(image)
footprint = ndi.generate_binary_structure(2, 1)
ndimage_opened = ndi.grey_opening(image, footprint=footprint)
ndimage_closed = ndi.grey_closing(image, footprint=footprint)
assert_array_equal(opened, ndimage_opened)
assert_array_equal(closed, ndimage_closed)
# float test images
im = np.array(
[
[0.55, 0.72, 0.6, 0.54, 0.42],
[0.65, 0.44, 0.89, 0.96, 0.38],
[0.79, 0.53, 0.57, 0.93, 0.07],
[0.09, 0.02, 0.83, 0.78, 0.87],
[0.98, 0.8, 0.46, 0.78, 0.12],
]
)
eroded = np.array(
[
[0.55, 0.44, 0.54, 0.42, 0.38],
[0.44, 0.44, 0.44, 0.38, 0.07],
[0.09, 0.02, 0.53, 0.07, 0.07],
[0.02, 0.02, 0.02, 0.78, 0.07],
[0.09, 0.02, 0.46, 0.12, 0.12],
]
)
dilated = np.array(
[
[0.72, 0.72, 0.89, 0.96, 0.54],
[0.79, 0.89, 0.96, 0.96, 0.96],
[0.79, 0.79, 0.93, 0.96, 0.93],
[0.98, 0.83, 0.83, 0.93, 0.87],
[0.98, 0.98, 0.83, 0.78, 0.87],
]
)
opened = np.array(
[
[0.55, 0.55, 0.54, 0.54, 0.42],
[0.55, 0.44, 0.54, 0.44, 0.38],
[0.44, 0.53, 0.53, 0.78, 0.07],
[0.09, 0.02, 0.78, 0.78, 0.78],
[0.09, 0.46, 0.46, 0.78, 0.12],
]
)
closed = np.array(
[
[0.72, 0.72, 0.72, 0.54, 0.54],
[0.72, 0.72, 0.89, 0.96, 0.54],
[0.79, 0.79, 0.79, 0.93, 0.87],
[0.79, 0.79, 0.83, 0.78, 0.87],
[0.98, 0.83, 0.78, 0.78, 0.78],
]
)
def test_float():
assert_allclose(gray.erosion(im), eroded)
assert_allclose(gray.dilation(im), dilated)
assert_allclose(gray.opening(im), opened)
assert_allclose(gray.closing(im), closed)
def test_uint16():
im16, eroded16, dilated16, opened16, closed16 = map(
img_as_uint, [im, eroded, dilated, opened, closed]
)
assert_allclose(gray.erosion(im16), eroded16)
assert_allclose(gray.dilation(im16), dilated16)
assert_allclose(gray.opening(im16), opened16)
assert_allclose(gray.closing(im16), closed16)
def test_discontiguous_out_array():
image = np.array([[5, 6, 2], [7, 2, 2], [3, 5, 1]], np.uint8)
out_array_big = np.zeros((5, 5), np.uint8)
out_array = out_array_big[::2, ::2]
expected_dilation = np.array(
[
[7, 0, 6, 0, 6],
[0, 0, 0, 0, 0],
[7, 0, 7, 0, 2],
[0, 0, 0, 0, 0],
[7, 0, 5, 0, 5],
],
np.uint8,
)
expected_erosion = np.array(
[
[5, 0, 2, 0, 2],
[0, 0, 0, 0, 0],
[2, 0, 2, 0, 1],
[0, 0, 0, 0, 0],
[3, 0, 1, 0, 1],
],
np.uint8,
)
gray.dilation(image, out=out_array)
assert_array_equal(out_array_big, expected_dilation)
gray.erosion(image, out=out_array)
assert_array_equal(out_array_big, expected_erosion)
def test_1d_erosion():
image = np.array([1, 2, 3, 2, 1])
expected = np.array([1, 1, 2, 1, 1])
eroded = gray.erosion(image)
assert_array_equal(eroded, expected)
@pytest.mark.parametrize(
"function",
["erosion", "dilation", "closing", "opening", "white_tophat", "black_tophat"],
)
@pytest.mark.parametrize("size", (7,))
@pytest.mark.parametrize("decomposition", ['separable', 'sequence'])
def test_square_decomposition(cam_image, function, size, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.square(size, decomposition=None)
footprint = footprints.square(size, decomposition=decomposition)
func = getattr(gray, function)
expected = func(cam_image, footprint=footprint_ndarray)
out = func(cam_image, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["erosion", "dilation", "closing", "opening", "white_tophat", "black_tophat"],
)
@pytest.mark.parametrize("nrows", (3, 11))
@pytest.mark.parametrize("ncols", (3, 11))
@pytest.mark.parametrize("decomposition", ['separable', 'sequence'])
def test_rectangle_decomposition(cam_image, function, nrows, ncols, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.rectangle(nrows, ncols, decomposition=None)
footprint = footprints.rectangle(nrows, ncols, decomposition=decomposition)
func = getattr(gray, function)
expected = func(cam_image, footprint=footprint_ndarray)
out = func(cam_image, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["erosion", "dilation", "closing", "opening", "white_tophat", "black_tophat"],
)
@pytest.mark.parametrize("radius", (2, 3))
@pytest.mark.parametrize("decomposition", ['sequence'])
def test_diamond_decomposition(cam_image, function, radius, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.diamond(radius, decomposition=None)
footprint = footprints.diamond(radius, decomposition=decomposition)
func = getattr(gray, function)
expected = func(cam_image, footprint=footprint_ndarray)
out = func(cam_image, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["erosion", "dilation", "closing", "opening", "white_tophat", "black_tophat"],
)
@pytest.mark.parametrize("m", (0, 1, 3, 5))
@pytest.mark.parametrize("n", (0, 1, 2, 3))
@pytest.mark.parametrize("decomposition", ['sequence'])
def test_octagon_decomposition(cam_image, function, m, n, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
if m == 0 and n == 0:
with pytest.raises(ValueError):
footprints.octagon(m, n, decomposition=decomposition)
else:
footprint_ndarray = footprints.octagon(m, n, decomposition=None)
footprint = footprints.octagon(m, n, decomposition=decomposition)
func = getattr(gray, function)
expected = func(cam_image, footprint=footprint_ndarray)
out = func(cam_image, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["erosion", "dilation", "closing", "opening", "white_tophat", "black_tophat"],
)
@pytest.mark.parametrize("size", (5,))
@pytest.mark.parametrize("decomposition", ['separable', 'sequence'])
def test_cube_decomposition(cell3d_image, function, size, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.cube(size, decomposition=None)
footprint = footprints.cube(size, decomposition=decomposition)
func = getattr(gray, function)
expected = func(cell3d_image, footprint=footprint_ndarray)
out = func(cell3d_image, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize(
"function",
["erosion", "dilation", "closing", "opening", "white_tophat", "black_tophat"],
)
@pytest.mark.parametrize("radius", (3,))
@pytest.mark.parametrize("decomposition", ['sequence'])
def test_octahedron_decomposition(cell3d_image, function, radius, decomposition):
"""Validate footprint decomposition for various shapes.
comparison is made to the case without decomposition.
"""
footprint_ndarray = footprints.octahedron(radius, decomposition=None)
footprint = footprints.octahedron(radius, decomposition=decomposition)
func = getattr(gray, function)
expected = func(cell3d_image, footprint=footprint_ndarray)
out = func(cell3d_image, footprint=footprint)
assert_array_equal(expected, out)
@pytest.mark.parametrize("func", [gray.erosion, gray.dilation])
@pytest.mark.parametrize("name", ["shift_x", "shift_y"])
@pytest.mark.parametrize("value", [True, False, None])
def test_deprecated_shift(func, name, value):
img = np.ones(10)
func(img) # Shouldn't warn
regex = "`shift_x` and `shift_y` are deprecated"
with pytest.warns(FutureWarning, match=regex) as record:
func(img, **{name: value})
assert_stacklevel(record)

View File

@@ -0,0 +1,83 @@
import numpy as np
from numpy.testing import assert_array_equal
from skimage import color, data, morphology
from skimage.morphology import binary, isotropic
from skimage.util import img_as_bool
img = color.rgb2gray(data.astronaut())
bw_img = img > 100 / 255.0
def test_non_square_image():
isotropic_res = isotropic.isotropic_erosion(bw_img[:100, :200], 3)
binary_res = img_as_bool(
binary.binary_erosion(bw_img[:100, :200], morphology.disk(3))
)
assert_array_equal(isotropic_res, binary_res)
def test_isotropic_erosion():
isotropic_res = isotropic.isotropic_erosion(bw_img, 3)
binary_res = img_as_bool(binary.binary_erosion(bw_img, morphology.disk(3)))
assert_array_equal(isotropic_res, binary_res)
def _disk_with_spacing(radius, dtype=np.uint8, *, strict_radius=True, spacing=None):
# Identical to morphology.disk, but with a spacing parameter and without decomposition.
# This is different from morphology.ellipse which produces a slightly different footprint.
L = np.arange(-radius, radius + 1)
X, Y = np.meshgrid(L, L)
if spacing is not None:
X *= spacing[1]
Y *= spacing[0]
if not strict_radius:
radius += 0.5
return np.array((X**2 + Y**2) <= radius**2, dtype=dtype)
def test_isotropic_erosion_spacing():
isotropic_res = isotropic.isotropic_dilation(bw_img, 6, spacing=(1, 2))
binary_res = img_as_bool(
binary.binary_dilation(bw_img, _disk_with_spacing(6, spacing=(1, 2)))
)
assert_array_equal(isotropic_res, binary_res)
def test_isotropic_dilation():
isotropic_res = isotropic.isotropic_dilation(bw_img, 3)
binary_res = img_as_bool(binary.binary_dilation(bw_img, morphology.disk(3)))
assert_array_equal(isotropic_res, binary_res)
def test_isotropic_closing():
isotropic_res = isotropic.isotropic_closing(bw_img, 3)
binary_res = img_as_bool(binary.binary_closing(bw_img, morphology.disk(3)))
assert_array_equal(isotropic_res, binary_res)
def test_isotropic_opening():
isotropic_res = isotropic.isotropic_opening(bw_img, 3)
binary_res = img_as_bool(binary.binary_opening(bw_img, morphology.disk(3)))
assert_array_equal(isotropic_res, binary_res)
def test_footprint_overflow():
img = np.zeros((20, 20), dtype=bool)
img[2:19, 2:19] = True
isotropic_res = isotropic.isotropic_erosion(img, 9)
binary_res = img_as_bool(binary.binary_erosion(img, morphology.disk(9)))
assert_array_equal(isotropic_res, binary_res)
def test_out_argument():
for func in (isotropic.isotropic_erosion, isotropic.isotropic_dilation):
radius = 3
img = np.ones((10, 10))
out = np.zeros_like(img)
out_saved = out.copy()
func(img, radius, out=out)
assert np.any(out != out_saved)
assert_array_equal(out, func(img, radius))

View File

@@ -0,0 +1,469 @@
import numpy as np
from skimage.morphology import max_tree, area_closing, area_opening
from skimage.morphology import max_tree_local_maxima, diameter_opening
from skimage.morphology import diameter_closing
from skimage.util import invert
from skimage._shared.testing import assert_array_equal, TestCase
eps = 1e-12
def _full_type_test(img, param, expected, func, param_scale=False, **keywords):
# images as they are
out = func(img, param, **keywords)
assert_array_equal(out, expected)
# unsigned int
for dt in [np.uint32, np.uint64]:
img_cast = img.astype(dt)
out = func(img_cast, param, **keywords)
exp_cast = expected.astype(dt)
assert_array_equal(out, exp_cast)
# float
data_float = img.astype(np.float64)
data_float = data_float / 255.0
expected_float = expected.astype(np.float64)
expected_float = expected_float / 255.0
if param_scale:
param_cast = param / 255.0
else:
param_cast = param
for dt in [np.float32, np.float64]:
data_cast = data_float.astype(dt)
out = func(data_cast, param_cast, **keywords)
exp_cast = expected_float.astype(dt)
error_img = 255.0 * exp_cast - 255.0 * out
error = (error_img >= 1.0).sum()
assert error < eps
# signed images
img_signed = img.astype(np.int16)
img_signed = img_signed - 128
exp_signed = expected.astype(np.int16)
exp_signed = exp_signed - 128
for dt in [np.int8, np.int16, np.int32, np.int64]:
img_s = img_signed.astype(dt)
out = func(img_s, param, **keywords)
exp_s = exp_signed.astype(dt)
assert_array_equal(out, exp_s)
class TestMaxtree(TestCase):
def test_max_tree(self):
"Test for max tree"
img_type = np.uint8
img = np.array(
[[10, 8, 8, 9], [7, 7, 9, 9], [8, 7, 10, 10], [9, 9, 10, 10]],
dtype=img_type,
)
P_exp = np.array(
[[1, 4, 1, 1], [4, 4, 3, 3], [1, 4, 3, 10], [3, 3, 10, 10]], dtype=np.int64
)
S_exp = np.array(
[4, 5, 9, 1, 2, 8, 3, 6, 7, 12, 13, 0, 10, 11, 14, 15], dtype=np.int64
)
for img_type in [np.uint8, np.uint16, np.uint32, np.uint64]:
img = img.astype(img_type)
P, S = max_tree(img, connectivity=2)
assert_array_equal(P, P_exp)
assert_array_equal(S, S_exp)
for img_type in [np.int8, np.int16, np.int32, np.int64]:
img = img.astype(img_type)
img_shifted = img - 9
P, S = max_tree(img_shifted, connectivity=2)
assert_array_equal(P, P_exp)
assert_array_equal(S, S_exp)
img_float = img.astype(float)
img_float = (img_float - 8) / 2.0
for img_type in [np.float32, np.float64]:
img_float = img_float.astype(img_type)
P, S = max_tree(img_float, connectivity=2)
assert_array_equal(P, P_exp)
assert_array_equal(S, S_exp)
return
def test_area_closing(self):
"Test for Area Closing (2 thresholds, all types)"
# original image
img = np.array(
[
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[240, 200, 200, 240, 200, 240, 200, 200, 240, 240, 200, 240],
[240, 200, 40, 240, 240, 240, 240, 240, 240, 240, 40, 240],
[240, 240, 240, 240, 100, 240, 100, 100, 240, 240, 200, 240],
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[200, 200, 200, 200, 200, 200, 200, 240, 200, 200, 255, 255],
[200, 255, 200, 200, 200, 255, 200, 240, 255, 255, 255, 40],
[200, 200, 200, 100, 200, 200, 200, 240, 255, 255, 255, 255],
[200, 200, 200, 100, 200, 200, 200, 240, 200, 200, 255, 255],
[200, 200, 200, 200, 200, 40, 200, 240, 240, 100, 255, 255],
[200, 40, 255, 255, 255, 40, 200, 255, 200, 200, 255, 255],
[200, 200, 200, 200, 200, 200, 200, 255, 255, 255, 255, 255],
],
dtype=np.uint8,
)
# expected area closing with area 2
expected_2 = np.array(
[
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[240, 200, 200, 240, 240, 240, 200, 200, 240, 240, 200, 240],
[240, 200, 200, 240, 240, 240, 240, 240, 240, 240, 200, 240],
[240, 240, 240, 240, 240, 240, 100, 100, 240, 240, 200, 240],
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[200, 200, 200, 200, 200, 200, 200, 240, 200, 200, 255, 255],
[200, 255, 200, 200, 200, 255, 200, 240, 255, 255, 255, 255],
[200, 200, 200, 100, 200, 200, 200, 240, 255, 255, 255, 255],
[200, 200, 200, 100, 200, 200, 200, 240, 200, 200, 255, 255],
[200, 200, 200, 200, 200, 40, 200, 240, 240, 200, 255, 255],
[200, 200, 255, 255, 255, 40, 200, 255, 200, 200, 255, 255],
[200, 200, 200, 200, 200, 200, 200, 255, 255, 255, 255, 255],
],
dtype=np.uint8,
)
# expected diameter closing with diameter 4
expected_4 = np.array(
[
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[240, 200, 200, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[240, 200, 200, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240, 240],
[200, 200, 200, 200, 200, 200, 200, 240, 240, 240, 255, 255],
[200, 255, 200, 200, 200, 255, 200, 240, 255, 255, 255, 255],
[200, 200, 200, 200, 200, 200, 200, 240, 255, 255, 255, 255],
[200, 200, 200, 200, 200, 200, 200, 240, 200, 200, 255, 255],
[200, 200, 200, 200, 200, 200, 200, 240, 240, 200, 255, 255],
[200, 200, 255, 255, 255, 200, 200, 255, 200, 200, 255, 255],
[200, 200, 200, 200, 200, 200, 200, 255, 255, 255, 255, 255],
],
dtype=np.uint8,
)
# _full_type_test makes a test with many image types.
_full_type_test(img, 2, expected_2, area_closing, connectivity=2)
_full_type_test(img, 4, expected_4, area_closing, connectivity=2)
P, S = max_tree(invert(img), connectivity=2)
_full_type_test(img, 4, expected_4, area_closing, parent=P, tree_traverser=S)
def test_area_opening(self):
"Test for Area Opening (2 thresholds, all types)"
# original image
img = np.array(
[
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[15, 55, 55, 15, 55, 15, 55, 55, 15, 15, 55, 15],
[15, 55, 215, 15, 15, 15, 15, 15, 15, 15, 215, 15],
[15, 15, 15, 15, 155, 15, 155, 155, 15, 15, 55, 15],
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[55, 55, 55, 55, 55, 55, 55, 15, 55, 55, 0, 0],
[55, 0, 55, 55, 55, 0, 55, 15, 0, 0, 0, 215],
[55, 55, 55, 155, 55, 55, 55, 15, 0, 0, 0, 0],
[55, 55, 55, 155, 55, 55, 55, 15, 55, 55, 0, 0],
[55, 55, 55, 55, 55, 215, 55, 15, 15, 155, 0, 0],
[55, 215, 0, 0, 0, 215, 55, 0, 55, 55, 0, 0],
[55, 55, 55, 55, 55, 55, 55, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
# expected area closing with area 2
expected_2 = np.array(
[
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[15, 55, 55, 15, 15, 15, 55, 55, 15, 15, 55, 15],
[15, 55, 55, 15, 15, 15, 15, 15, 15, 15, 55, 15],
[15, 15, 15, 15, 15, 15, 155, 155, 15, 15, 55, 15],
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[55, 55, 55, 55, 55, 55, 55, 15, 55, 55, 0, 0],
[55, 0, 55, 55, 55, 0, 55, 15, 0, 0, 0, 0],
[55, 55, 55, 155, 55, 55, 55, 15, 0, 0, 0, 0],
[55, 55, 55, 155, 55, 55, 55, 15, 55, 55, 0, 0],
[55, 55, 55, 55, 55, 215, 55, 15, 15, 55, 0, 0],
[55, 55, 0, 0, 0, 215, 55, 0, 55, 55, 0, 0],
[55, 55, 55, 55, 55, 55, 55, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
# expected diameter closing with diameter 4
expected_4 = np.array(
[
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[15, 55, 55, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[15, 55, 55, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15],
[55, 55, 55, 55, 55, 55, 55, 15, 15, 15, 0, 0],
[55, 0, 55, 55, 55, 0, 55, 15, 0, 0, 0, 0],
[55, 55, 55, 55, 55, 55, 55, 15, 0, 0, 0, 0],
[55, 55, 55, 55, 55, 55, 55, 15, 55, 55, 0, 0],
[55, 55, 55, 55, 55, 55, 55, 15, 15, 55, 0, 0],
[55, 55, 0, 0, 0, 55, 55, 0, 55, 55, 0, 0],
[55, 55, 55, 55, 55, 55, 55, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
# _full_type_test makes a test with many image types.
_full_type_test(img, 2, expected_2, area_opening, connectivity=2)
_full_type_test(img, 4, expected_4, area_opening, connectivity=2)
P, S = max_tree(img, connectivity=2)
_full_type_test(img, 4, expected_4, area_opening, parent=P, tree_traverser=S)
def test_diameter_closing(self):
"Test for Diameter Opening (2 thresholds, all types)"
img = np.array(
[
[97, 95, 93, 92, 91, 90, 90, 90, 91, 92, 93, 95],
[95, 93, 91, 89, 88, 88, 88, 88, 88, 89, 91, 93],
[93, 63, 63, 63, 63, 86, 86, 86, 87, 43, 43, 91],
[92, 89, 88, 86, 85, 85, 84, 85, 85, 43, 43, 89],
[91, 88, 87, 85, 84, 84, 83, 84, 84, 85, 87, 88],
[90, 88, 86, 85, 84, 83, 83, 83, 84, 85, 86, 88],
[90, 88, 86, 84, 83, 83, 82, 83, 83, 84, 86, 88],
[90, 88, 86, 85, 84, 83, 83, 83, 84, 85, 86, 88],
[91, 88, 87, 85, 84, 84, 83, 84, 84, 85, 87, 88],
[92, 89, 23, 23, 85, 85, 84, 85, 85, 3, 3, 89],
[93, 91, 23, 23, 87, 86, 86, 86, 87, 88, 3, 91],
[95, 93, 91, 89, 88, 88, 88, 88, 88, 89, 91, 93],
],
dtype=np.uint8,
)
ex2 = np.array(
[
[97, 95, 93, 92, 91, 90, 90, 90, 91, 92, 93, 95],
[95, 93, 91, 89, 88, 88, 88, 88, 88, 89, 91, 93],
[93, 63, 63, 63, 63, 86, 86, 86, 87, 43, 43, 91],
[92, 89, 88, 86, 85, 85, 84, 85, 85, 43, 43, 89],
[91, 88, 87, 85, 84, 84, 83, 84, 84, 85, 87, 88],
[90, 88, 86, 85, 84, 83, 83, 83, 84, 85, 86, 88],
[90, 88, 86, 84, 83, 83, 83, 83, 83, 84, 86, 88],
[90, 88, 86, 85, 84, 83, 83, 83, 84, 85, 86, 88],
[91, 88, 87, 85, 84, 84, 83, 84, 84, 85, 87, 88],
[92, 89, 23, 23, 85, 85, 84, 85, 85, 3, 3, 89],
[93, 91, 23, 23, 87, 86, 86, 86, 87, 88, 3, 91],
[95, 93, 91, 89, 88, 88, 88, 88, 88, 89, 91, 93],
],
dtype=np.uint8,
)
ex4 = np.array(
[
[97, 95, 93, 92, 91, 90, 90, 90, 91, 92, 93, 95],
[95, 93, 91, 89, 88, 88, 88, 88, 88, 89, 91, 93],
[93, 63, 63, 63, 63, 86, 86, 86, 87, 84, 84, 91],
[92, 89, 88, 86, 85, 85, 84, 85, 85, 84, 84, 89],
[91, 88, 87, 85, 84, 84, 83, 84, 84, 85, 87, 88],
[90, 88, 86, 85, 84, 83, 83, 83, 84, 85, 86, 88],
[90, 88, 86, 84, 83, 83, 83, 83, 83, 84, 86, 88],
[90, 88, 86, 85, 84, 83, 83, 83, 84, 85, 86, 88],
[91, 88, 87, 85, 84, 84, 83, 84, 84, 85, 87, 88],
[92, 89, 84, 84, 85, 85, 84, 85, 85, 84, 84, 89],
[93, 91, 84, 84, 87, 86, 86, 86, 87, 88, 84, 91],
[95, 93, 91, 89, 88, 88, 88, 88, 88, 89, 91, 93],
],
dtype=np.uint8,
)
# _full_type_test makes a test with many image types.
_full_type_test(img, 2, ex2, diameter_closing, connectivity=2)
_full_type_test(img, 4, ex4, diameter_closing, connectivity=2)
P, S = max_tree(invert(img), connectivity=2)
_full_type_test(img, 4, ex4, diameter_opening, parent=P, tree_traverser=S)
def test_diameter_opening(self):
"Test for Diameter Opening (2 thresholds, all types)"
img = np.array(
[
[5, 7, 9, 11, 12, 12, 12, 12, 12, 11, 9, 7],
[7, 10, 11, 13, 14, 14, 15, 14, 14, 13, 11, 10],
[9, 40, 40, 40, 40, 16, 16, 16, 16, 60, 60, 11],
[11, 13, 15, 16, 17, 18, 18, 18, 17, 60, 60, 13],
[12, 14, 16, 17, 18, 19, 19, 19, 18, 17, 16, 14],
[12, 14, 16, 18, 19, 19, 19, 19, 19, 18, 16, 14],
[12, 15, 16, 18, 19, 19, 20, 19, 19, 18, 16, 15],
[12, 14, 16, 18, 19, 19, 19, 19, 19, 18, 16, 14],
[12, 14, 16, 17, 18, 19, 19, 19, 18, 17, 16, 14],
[11, 13, 80, 80, 17, 18, 18, 18, 17, 100, 100, 13],
[9, 11, 80, 80, 16, 16, 16, 16, 16, 15, 100, 11],
[7, 10, 11, 13, 14, 14, 15, 14, 14, 13, 11, 10],
]
)
ex2 = np.array(
[
[5, 7, 9, 11, 12, 12, 12, 12, 12, 11, 9, 7],
[7, 10, 11, 13, 14, 14, 15, 14, 14, 13, 11, 10],
[9, 40, 40, 40, 40, 16, 16, 16, 16, 60, 60, 11],
[11, 13, 15, 16, 17, 18, 18, 18, 17, 60, 60, 13],
[12, 14, 16, 17, 18, 19, 19, 19, 18, 17, 16, 14],
[12, 14, 16, 18, 19, 19, 19, 19, 19, 18, 16, 14],
[12, 15, 16, 18, 19, 19, 19, 19, 19, 18, 16, 15],
[12, 14, 16, 18, 19, 19, 19, 19, 19, 18, 16, 14],
[12, 14, 16, 17, 18, 19, 19, 19, 18, 17, 16, 14],
[11, 13, 80, 80, 17, 18, 18, 18, 17, 100, 100, 13],
[9, 11, 80, 80, 16, 16, 16, 16, 16, 15, 100, 11],
[7, 10, 11, 13, 14, 14, 15, 14, 14, 13, 11, 10],
]
)
ex4 = np.array(
[
[5, 7, 9, 11, 12, 12, 12, 12, 12, 11, 9, 7],
[7, 10, 11, 13, 14, 14, 15, 14, 14, 13, 11, 10],
[9, 40, 40, 40, 40, 16, 16, 16, 16, 18, 18, 11],
[11, 13, 15, 16, 17, 18, 18, 18, 17, 18, 18, 13],
[12, 14, 16, 17, 18, 19, 19, 19, 18, 17, 16, 14],
[12, 14, 16, 18, 19, 19, 19, 19, 19, 18, 16, 14],
[12, 15, 16, 18, 19, 19, 19, 19, 19, 18, 16, 15],
[12, 14, 16, 18, 19, 19, 19, 19, 19, 18, 16, 14],
[12, 14, 16, 17, 18, 19, 19, 19, 18, 17, 16, 14],
[11, 13, 18, 18, 17, 18, 18, 18, 17, 18, 18, 13],
[9, 11, 18, 18, 16, 16, 16, 16, 16, 15, 18, 11],
[7, 10, 11, 13, 14, 14, 15, 14, 14, 13, 11, 10],
]
)
# _full_type_test makes a test with many image types.
_full_type_test(img, 2, ex2, diameter_opening, connectivity=2)
_full_type_test(img, 4, ex4, diameter_opening, connectivity=2)
P, S = max_tree(img, connectivity=2)
_full_type_test(img, 4, ex4, diameter_opening, parent=P, tree_traverser=S)
def test_local_maxima(self):
"local maxima for various data types"
data = np.array(
[
[10, 11, 13, 14, 14, 15, 14, 14, 13, 11],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
[13, 15, 40, 40, 18, 18, 18, 60, 60, 15],
[14, 16, 40, 40, 19, 19, 19, 60, 60, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[15, 16, 18, 19, 19, 20, 19, 19, 18, 16],
[14, 16, 18, 19, 19, 19, 19, 19, 18, 16],
[14, 16, 80, 80, 19, 19, 19, 100, 100, 16],
[13, 15, 80, 80, 18, 18, 18, 100, 100, 15],
[11, 13, 15, 16, 16, 16, 16, 16, 15, 13],
],
dtype=np.uint8,
)
expected_result = 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, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 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, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint64,
)
for dtype in [np.uint8, np.uint64, np.int8, np.int64]:
test_data = data.astype(dtype)
out = max_tree_local_maxima(test_data, connectivity=1)
out_bin = out > 0
assert_array_equal(expected_result, out_bin)
assert out.dtype == expected_result.dtype
assert np.max(out) == 5
P, S = max_tree(test_data)
out = max_tree_local_maxima(test_data, parent=P, tree_traverser=S)
assert_array_equal(expected_result, out_bin)
assert out.dtype == expected_result.dtype
assert np.max(out) == 5
def test_extrema_float(self):
"specific tests for float type"
data = np.array(
[
[0.10, 0.11, 0.13, 0.14, 0.14, 0.15, 0.14, 0.14, 0.13, 0.11],
[0.11, 0.13, 0.15, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.13],
[0.13, 0.15, 0.40, 0.40, 0.18, 0.18, 0.18, 0.60, 0.60, 0.15],
[0.14, 0.16, 0.40, 0.40, 0.19, 0.19, 0.19, 0.60, 0.60, 0.16],
[0.14, 0.16, 0.18, 0.19, 0.19, 0.19, 0.19, 0.19, 0.18, 0.16],
[0.15, 0.182, 0.18, 0.19, 0.204, 0.20, 0.19, 0.19, 0.18, 0.16],
[0.14, 0.16, 0.18, 0.19, 0.19, 0.19, 0.19, 0.19, 0.18, 0.16],
[0.14, 0.16, 0.80, 0.80, 0.19, 0.19, 0.19, 4.0, 1.0, 0.16],
[0.13, 0.15, 0.80, 0.80, 0.18, 0.18, 0.18, 1.0, 1.0, 0.15],
[0.11, 0.13, 0.15, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.13],
],
dtype=np.float32,
)
expected_result = 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, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=np.uint8,
)
# test for local maxima
out = max_tree_local_maxima(data, connectivity=1)
out_bin = out > 0
assert_array_equal(expected_result, out_bin)
assert np.max(out) == 6
def test_3d(self):
"""tests the detection of maxima in 3D."""
img = np.zeros((8, 8, 8), dtype=np.uint8)
local_maxima = np.zeros((8, 8, 8), dtype=np.uint64)
# first maximum: only one pixel
img[1, 1:3, 1:3] = 100
img[2, 2, 2] = 200
img[3, 1:3, 1:3] = 100
local_maxima[2, 2, 2] = 1
# second maximum: three pixels in z-direction
img[5:8, 1, 1] = 200
local_maxima[5:8, 1, 1] = 1
# third: two maxima in 0 and 3.
img[0, 5:8, 5:8] = 200
img[1, 6, 6] = 100
img[2, 5:7, 5:7] = 200
img[0:3, 5:8, 5:8] += 50
local_maxima[0, 5:8, 5:8] = 1
local_maxima[2, 5:7, 5:7] = 1
# four : one maximum in the corner of the square
img[6:8, 6:8, 6:8] = 200
img[7, 7, 7] = 255
local_maxima[7, 7, 7] = 1
out = max_tree_local_maxima(img)
out_bin = out > 0
assert_array_equal(local_maxima, out_bin)
assert np.max(out) == 5

View File

@@ -0,0 +1,522 @@
import numpy as np
import pytest
import scipy as sp
from skimage.morphology import (
remove_small_objects,
remove_small_holes,
remove_objects_by_distance,
local_maxima,
label,
)
from skimage._shared import testing
from skimage._shared.testing import assert_array_equal, assert_equal
from skimage._shared._warnings import expected_warnings
test_image = np.array([[0, 0, 0, 1, 0], [1, 1, 1, 0, 0], [1, 1, 1, 0, 1]], bool)
# Dtypes supported by the `label_image` parameter in `remove_objects_by_distance`
supported_dtypes = [
np.uint8,
np.uint16,
np.uint32,
np.int8,
np.int16,
np.int32,
np.int64,
]
def test_one_connectivity():
expected = np.array([[0, 0, 0, 0, 0], [1, 1, 1, 0, 0], [1, 1, 1, 0, 0]], bool)
observed = remove_small_objects(test_image, min_size=6)
assert_array_equal(observed, expected)
def test_two_connectivity():
expected = np.array([[0, 0, 0, 1, 0], [1, 1, 1, 0, 0], [1, 1, 1, 0, 0]], bool)
observed = remove_small_objects(test_image, min_size=7, connectivity=2)
assert_array_equal(observed, expected)
def test_in_place():
image = test_image.copy()
observed = remove_small_objects(image, min_size=6, out=image)
assert_equal(
observed is image, True, "remove_small_objects in_place argument failed."
)
@pytest.mark.parametrize("in_dtype", [bool, int, np.int32])
@pytest.mark.parametrize("out_dtype", [bool, int, np.int32])
def test_out(in_dtype, out_dtype):
image = test_image.astype(in_dtype, copy=True)
expected_out = np.empty_like(test_image, dtype=out_dtype)
if out_dtype != bool:
# object with only 1 label will warn on non-bool output dtype
exp_warn = ["Only one label was provided"]
else:
exp_warn = []
with expected_warnings(exp_warn):
out = remove_small_objects(image, min_size=6, out=expected_out)
assert out is expected_out
def test_labeled_image():
labeled_image = np.array(
[[2, 2, 2, 0, 1], [2, 2, 2, 0, 1], [2, 0, 0, 0, 0], [0, 0, 3, 3, 3]], dtype=int
)
expected = np.array(
[[2, 2, 2, 0, 0], [2, 2, 2, 0, 0], [2, 0, 0, 0, 0], [0, 0, 3, 3, 3]], dtype=int
)
observed = remove_small_objects(labeled_image, min_size=3)
assert_array_equal(observed, expected)
def test_uint_image():
labeled_image = np.array(
[[2, 2, 2, 0, 1], [2, 2, 2, 0, 1], [2, 0, 0, 0, 0], [0, 0, 3, 3, 3]],
dtype=np.uint8,
)
expected = np.array(
[[2, 2, 2, 0, 0], [2, 2, 2, 0, 0], [2, 0, 0, 0, 0], [0, 0, 3, 3, 3]],
dtype=np.uint8,
)
observed = remove_small_objects(labeled_image, min_size=3)
assert_array_equal(observed, expected)
def test_single_label_warning():
image = np.array([[0, 0, 0, 1, 0], [1, 1, 1, 0, 0], [1, 1, 1, 0, 0]], int)
with expected_warnings(['use a boolean array?']):
remove_small_objects(image, min_size=6)
def test_float_input():
float_test = np.random.rand(5, 5)
with testing.raises(TypeError):
remove_small_objects(float_test)
def test_negative_input():
negative_int = np.random.randint(-4, -1, size=(5, 5))
with testing.raises(ValueError):
remove_small_objects(negative_int)
test_holes_image = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 0, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
],
bool,
)
def test_one_connectivity_holes():
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
],
bool,
)
observed = remove_small_holes(test_holes_image, area_threshold=3)
assert_array_equal(observed, expected)
def test_two_connectivity_holes():
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
],
bool,
)
observed = remove_small_holes(test_holes_image, area_threshold=3, connectivity=2)
assert_array_equal(observed, expected)
def test_in_place_holes():
image = test_holes_image.copy()
observed = remove_small_holes(image, area_threshold=3, out=image)
assert_equal(
observed is image, True, "remove_small_holes in_place argument failed."
)
def test_out_remove_small_holes():
image = test_holes_image.copy()
expected_out = np.empty_like(image)
out = remove_small_holes(image, area_threshold=3, out=expected_out)
assert out is expected_out
def test_non_bool_out():
image = test_holes_image.copy()
expected_out = np.empty_like(image, dtype=int)
with testing.raises(TypeError):
remove_small_holes(image, area_threshold=3, out=expected_out)
def test_labeled_image_holes():
labeled_holes_image = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
[0, 0, 0, 0, 0, 0, 0, 2, 0, 2],
[0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
],
dtype=int,
)
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
],
dtype=bool,
)
with expected_warnings(['returned as a boolean array']):
observed = remove_small_holes(labeled_holes_image, area_threshold=3)
assert_array_equal(observed, expected)
def test_uint_image_holes():
labeled_holes_image = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
[0, 0, 0, 0, 0, 0, 0, 2, 0, 2],
[0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
],
dtype=np.uint8,
)
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
],
dtype=bool,
)
with expected_warnings(['returned as a boolean array']):
observed = remove_small_holes(labeled_holes_image, area_threshold=3)
assert_array_equal(observed, expected)
def test_label_warning_holes():
labeled_holes_image = np.array(
[
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 1, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 0, 1, 0, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
[0, 0, 0, 0, 0, 0, 0, 2, 0, 2],
[0, 0, 0, 0, 0, 0, 0, 2, 2, 2],
],
dtype=int,
)
with expected_warnings(['use a boolean array?']):
remove_small_holes(labeled_holes_image, area_threshold=3)
remove_small_holes(labeled_holes_image.astype(bool), area_threshold=3)
def test_float_input_holes():
float_test = np.random.rand(5, 5)
with testing.raises(TypeError):
remove_small_holes(float_test)
class Test_remove_near_objects:
@pytest.mark.parametrize("min_distance", [2.1, 5, 30.99, 49])
@pytest.mark.parametrize("dtype", supported_dtypes)
def test_min_distance_1d(self, min_distance, dtype):
# First 3 objects are only just to close, last one is just far enough
d = int(np.floor(min_distance))
labels = np.zeros(d * 3 + 2, dtype=dtype)
labels[[0, d, 2 * d, 3 * d + 1]] = 1
labels, _ = sp.ndimage.label(labels, output=dtype)
desired = labels.copy()
desired[d] = 0
result = remove_objects_by_distance(labels, min_distance)
assert result.dtype == desired.dtype
assert_array_equal(result, desired)
@pytest.mark.parametrize("dtype", supported_dtypes)
@pytest.mark.parametrize("order", ["C", "F"])
def test_handcrafted_2d(self, dtype, order):
label = np.array(
[
[8, 0, 0, 0, 0, 0, 0, 0, 0, 9, 9],
[8, 8, 8, 0, 0, 0, 0, 0, 0, 9, 9],
[0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 3, 0, 0, 0, 5, 0, 0, 0, 0],
[2, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 7],
],
dtype=dtype,
order=order,
)
priority = np.arange(10)
desired = np.array(
[
[8, 0, 0, 0, 0, 0, 0, 0, 0, 9, 9],
[8, 8, 8, 0, 0, 0, 0, 0, 0, 9, 9],
[0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 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, 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, 7, 7],
],
dtype=dtype,
)
result = remove_objects_by_distance(label, 3, priority=priority)
assert result.flags["C_CONTIGUOUS"]
assert_array_equal(result, desired)
@pytest.mark.parametrize("ndim", [1, 2, 3, 4, 5])
def test_large_objects_nd(self, ndim):
shape = (5,) * ndim
a = np.ones(shape, dtype=np.uint8)
a[-2, ...] = 0
labels, _ = sp.ndimage.label(a)
desired = labels.copy()
desired[-2:, ...] = 0
result = remove_objects_by_distance(labels, 2)
assert_array_equal(result, desired)
@pytest.mark.parametrize("distance", [5, 50, 100])
@pytest.mark.parametrize("p_norm", [1, 2, np.inf])
def test_random(self, distance, p_norm):
rng = np.random.default_rng(1713648513)
image = rng.random(size=(400, 400))
maxima = local_maxima(image)
objects = label(maxima)
spaced_objects = remove_objects_by_distance(objects, distance, p_norm=p_norm)
kdtree = sp.spatial.cKDTree(
np.array(np.nonzero(spaced_objects), dtype=np.float64).transpose(),
)
# Compute distance between all objects that are equal or smaller `distance`
distances = kdtree.sparse_distance_matrix(
kdtree, max_distance=distance, p=p_norm
)
# There should be no objects left
assert distances.count_nonzero() == 0
# But increasing by 1 should reveal a few objects
distances = kdtree.sparse_distance_matrix(
kdtree, max_distance=distance + 1, p=p_norm
)
assert distances.count_nonzero() > 0
@pytest.mark.parametrize("value", [0, 1])
@pytest.mark.parametrize("dtype", supported_dtypes)
def test_constant(self, value, dtype):
labels = np.empty((10, 10), dtype=dtype)
labels.fill(value)
result = remove_objects_by_distance(labels, 3)
assert_array_equal(labels, result)
def test_empty(self):
labels = np.empty((3, 3, 0), dtype=int)
result = remove_objects_by_distance(labels, 3)
assert_equal(labels, result)
def test_priority(self):
labels = np.array([0, 1, 4, 1])
# Object with more samples takes precedence
result = remove_objects_by_distance(labels, 3)
desired = np.array([0, 1, 0, 1])
assert_array_equal(result, desired)
# Assigning priority with equal values, sorts by higher label ID second
priority = np.array([0, 1, 1, 1, 1])
result = remove_objects_by_distance(labels, 3, priority=priority)
desired = np.array([0, 0, 4, 0])
assert_array_equal(result, desired)
# But given a different priority that order can be overruled
priority = np.array([0, 1, 1, 1, -1])
result = remove_objects_by_distance(labels, 3, priority=priority)
desired = np.array([0, 1, 0, 1])
assert_array_equal(result, desired)
@pytest.mark.parametrize("order", ["C", "F"])
def test_out(self, order):
labels_original = np.array([[1, 0, 2], [1, 0, 2]], order=order)
desired = np.array([[0, 0, 2], [0, 0, 2]], order=order)
# By default, input image is not modified
labels = labels_original.copy(order=order)
remove_objects_by_distance(labels, 2)
assert_array_equal(labels, labels_original)
# But modified if passed to `out`
remove_objects_by_distance(labels, 2, out=labels)
assert labels.flags[f"{order}_CONTIGUOUS"]
assert_array_equal(labels, desired)
@pytest.mark.parametrize("min_distance", [-10, -0.1])
def test_negative_min_distance(self, min_distance):
labels = np.array([1, 0, 2])
with pytest.raises(ValueError, match="must be >= 0"):
remove_objects_by_distance(labels, min_distance)
def test_p_norm(self):
labels = np.array([[2, 0], [0, 1]])
removed = np.array([[2, 0], [0, 0]])
# p_norm=2, default (Euclidean distance)
result = remove_objects_by_distance(labels, 1.4)
assert_array_equal(result, labels)
result = remove_objects_by_distance(labels, np.sqrt(2))
assert_array_equal(result, removed)
# p_norm=1 (Manhatten distance)
result = remove_objects_by_distance(
labels,
min_distance=1.9,
p_norm=1,
)
assert_array_equal(result, labels)
result = remove_objects_by_distance(labels, 2, p_norm=1)
assert_array_equal(result, removed)
# p_norm=np.inf (Chebyshev distance)
result = remove_objects_by_distance(labels, 0.9, p_norm=np.inf)
assert_array_equal(result, labels)
result = remove_objects_by_distance(labels, 1, p_norm=np.inf)
assert_array_equal(result, removed)
@pytest.mark.parametrize(
"shape",
[
(0,),
],
)
def test_priority_shape(self, shape):
remove_objects_by_distance(np.array([0, 0, 0]), 3, priority=np.ones((0,)))
remove_objects_by_distance(np.array([0, 0, 0]), 3, priority=np.ones((1,)))
error_msg = r"shape of `priority` must be \(np\.amax\(label_image\) \+ 1,\)"
with pytest.raises(ValueError, match=error_msg):
remove_objects_by_distance(np.array([1, 0, 0]), 3, priority=np.ones((0,)))
with pytest.raises(ValueError, match=error_msg):
remove_objects_by_distance(np.array([1, 0, 0]), 3, priority=np.ones((1,)))
with pytest.raises(ValueError, match=error_msg):
remove_objects_by_distance(np.array([1, 0, 0]), 3, priority=np.ones((1,)))
def test_negative_label_ids(self):
labels = np.array(
[
[1, 1, -1, 2, 2, 2],
[1, 1, 3, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
[3, 3, 3, 3, 3, 3],
]
)
with pytest.raises(ValueError, match=".*object with negative ID"):
remove_objects_by_distance(labels, 1, priority=np.ones(4))
def test_objects_with_inside(self):
labels = np.array(
[
[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2],
[3, 3, 3, 3, 3, 3],
]
)
desired = np.array(
[
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0],
[3, 3, 3, 3, 3, 3],
]
)
result = remove_objects_by_distance(labels, 1, priority=np.arange(4))
assert_array_equal(result, desired)
def test_spacing(self):
labels = np.array(
[[1, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [3, 0, 0, 4]], dtype=int
)
# Stretch second dimension
result = remove_objects_by_distance(labels, 3, spacing=(1, 3))
expected = np.array(
[[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [3, 0, 0, 4]], dtype=int
)
np.testing.assert_array_equal(result, expected)
# Compress second dimension
result = remove_objects_by_distance(labels, 1, spacing=(1, 1 / 3))
expected = np.array(
[[0, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 4]], dtype=int
)
np.testing.assert_array_equal(result, expected)
@pytest.mark.parametrize("spacing", [(-1, -1), (1,), (1, 1, 1), [[1, 1]], 1])
def test_spacing_raises(self, spacing):
labels = np.array(
[[1, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [3, 0, 0, 4]], dtype=int
)
regex = ".*must contain exactly one positive factor for each dimension"
with pytest.raises(ValueError, match=regex):
remove_objects_by_distance(labels, 3, spacing=spacing)

View File

@@ -0,0 +1,190 @@
import math
import numpy as np
import pytest
from numpy.testing import assert_array_almost_equal
from skimage._shared.utils import _supported_float_type
from skimage.morphology.grayreconstruct import reconstruction
def test_zeros():
"""Test reconstruction with image and mask of zeros"""
assert_array_almost_equal(reconstruction(np.zeros((5, 7)), np.zeros((5, 7))), 0)
def test_image_equals_mask():
"""Test reconstruction where the image and mask are the same"""
assert_array_almost_equal(reconstruction(np.ones((7, 5)), np.ones((7, 5))), 1)
def test_image_less_than_mask():
"""Test reconstruction where the image is uniform and less than mask"""
image = np.ones((5, 5))
mask = np.ones((5, 5)) * 2
assert_array_almost_equal(reconstruction(image, mask), 1)
def test_one_image_peak():
"""Test reconstruction with one peak pixel"""
image = np.ones((5, 5))
image[2, 2] = 2
mask = np.ones((5, 5)) * 3
assert_array_almost_equal(reconstruction(image, mask), 2)
# minsize chosen to test sizes covering use of 8, 16 and 32-bit integers
# internally
@pytest.mark.parametrize('minsize', [None, 200, 20000, 40000, 80000])
@pytest.mark.parametrize('dtype', [np.uint8, np.float32])
def test_two_image_peaks(minsize, dtype):
"""Test reconstruction with two peak pixels isolated by the mask"""
image = np.array(
[
[1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 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, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1],
],
dtype=dtype,
)
mask = np.array(
[
[4, 4, 4, 1, 1, 1, 1, 1, 1],
[4, 4, 4, 1, 1, 1, 1, 1, 1],
[4, 4, 4, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 4, 4, 4, 1],
[1, 1, 1, 1, 1, 4, 4, 4, 1],
[1, 1, 1, 1, 1, 4, 4, 4, 1],
],
dtype=dtype,
)
expected = np.array(
[
[2, 2, 2, 1, 1, 1, 1, 1, 1],
[2, 2, 2, 1, 1, 1, 1, 1, 1],
[2, 2, 2, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 3, 3, 3, 1],
[1, 1, 1, 1, 1, 3, 3, 3, 1],
[1, 1, 1, 1, 1, 3, 3, 3, 1],
],
dtype=dtype,
)
if minsize is not None:
# increase data size by tiling (done to test various int types)
nrow = math.ceil(math.sqrt(minsize / image.size))
ncol = math.ceil(minsize / (image.size * nrow))
image = np.tile(image, (nrow, ncol))
mask = np.tile(mask, (nrow, ncol))
expected = np.tile(expected, (nrow, ncol))
out = reconstruction(image, mask)
assert out.dtype == _supported_float_type(mask.dtype)
assert_array_almost_equal(out, expected)
def test_zero_image_one_mask():
"""Test reconstruction with an image of all zeros and a mask that's not"""
result = reconstruction(np.zeros((10, 10)), np.ones((10, 10)))
assert_array_almost_equal(result, 0)
@pytest.mark.parametrize(
'dtype',
[
np.int8,
np.uint8,
np.int16,
np.uint16,
np.int32,
np.uint32,
np.int64,
np.uint64,
np.float16,
np.float32,
np.float64,
],
)
def test_fill_hole(dtype):
"""Test reconstruction by erosion, which should fill holes in mask."""
seed = np.array([0, 8, 8, 8, 8, 8, 8, 8, 8, 0], dtype=dtype)
mask = np.array([0, 3, 6, 2, 1, 1, 1, 4, 2, 0], dtype=dtype)
result = reconstruction(seed, mask, method='erosion')
assert result.dtype == _supported_float_type(mask.dtype)
expected = np.array([0, 3, 6, 4, 4, 4, 4, 4, 2, 0], dtype=dtype)
assert_array_almost_equal(result, expected)
def test_invalid_seed():
seed = np.ones((5, 5))
mask = np.ones((5, 5))
with pytest.raises(ValueError):
reconstruction(seed * 2, mask, method='dilation')
with pytest.raises(ValueError):
reconstruction(seed * 0.5, mask, method='erosion')
def test_invalid_footprint():
seed = np.ones((5, 5))
mask = np.ones((5, 5))
with pytest.raises(ValueError):
reconstruction(seed, mask, footprint=np.ones((4, 4)))
with pytest.raises(ValueError):
reconstruction(seed, mask, footprint=np.ones((3, 4)))
reconstruction(seed, mask, footprint=np.ones((3, 3)))
def test_invalid_method():
seed = np.array([0, 8, 8, 8, 8, 8, 8, 8, 8, 0])
mask = np.array([0, 3, 6, 2, 1, 1, 1, 4, 2, 0])
with pytest.raises(ValueError):
reconstruction(seed, mask, method='foo')
def test_invalid_offset_not_none():
"""Test reconstruction with invalid not None offset parameter"""
image = np.array(
[
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 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, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
]
)
mask = np.array(
[
[4, 4, 4, 1, 1, 1, 1, 1],
[4, 4, 4, 1, 1, 1, 1, 1],
[4, 4, 4, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 4, 4, 4],
[1, 1, 1, 1, 1, 4, 4, 4],
[1, 1, 1, 1, 1, 4, 4, 4],
]
)
with pytest.raises(ValueError):
reconstruction(
image,
mask,
method='dilation',
footprint=np.ones((3, 3)),
offset=np.array([3, 0]),
)
def test_offset_not_none():
"""Test reconstruction with valid offset parameter"""
seed = np.array([0, 3, 6, 2, 1, 1, 1, 4, 2, 0])
mask = np.array([0, 8, 6, 8, 8, 8, 8, 4, 4, 0])
expected = np.array([0, 3, 6, 6, 6, 6, 6, 4, 4, 0])
assert_array_almost_equal(
reconstruction(
seed, mask, method='dilation', footprint=np.ones(3), offset=np.array([0])
),
expected,
)

View File

@@ -0,0 +1,275 @@
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from scipy.ndimage import correlate
from skimage import draw
from skimage._shared.testing import fetch
from skimage.io import imread
from skimage.morphology import medial_axis, skeletonize, thin
from skimage.morphology._skeletonize import G123_LUT, G123P_LUT, _generate_thin_luts
class TestSkeletonize:
def test_skeletonize_no_foreground(self):
im = np.zeros((5, 5))
result = skeletonize(im)
assert_array_equal(result, np.zeros((5, 5)))
def test_skeletonize_wrong_dim1(self):
im = np.zeros(5, dtype=bool)
with pytest.raises(ValueError):
skeletonize(im)
def test_skeletonize_wrong_dim2(self):
im = np.zeros((5, 5, 5), dtype=bool)
with pytest.raises(ValueError):
skeletonize(im, method='zhang')
def test_skeletonize_wrong_method(self):
im = np.ones((5, 5), dtype=bool)
with pytest.raises(ValueError):
skeletonize(im, method='foo')
def test_skeletonize_all_foreground(self):
im = np.ones((3, 4), dtype=bool)
skeletonize(im)
def test_skeletonize_single_point(self):
im = np.zeros((5, 5), dtype=bool)
im[3, 3] = 1
result = skeletonize(im)
assert_array_equal(result, im)
def test_skeletonize_already_thinned(self):
im = np.zeros((5, 5), dtype=bool)
im[3, 1:-1] = 1
im[2, -1] = 1
im[4, 0] = 1
result = skeletonize(im)
assert_array_equal(result, im)
def test_skeletonize_output(self):
im = imread(fetch("data/bw_text.png"), as_gray=True)
# make black the foreground
im = im == 0
result = skeletonize(im)
expected = np.load(fetch("data/bw_text_skeleton.npy"))
assert_array_equal(result, expected)
@pytest.mark.parametrize("dtype", [bool, float, int])
def test_skeletonize_num_neighbors(self, dtype):
# an empty image
image = np.zeros((300, 300), dtype=dtype)
# foreground object 1
image[10:-10, 10:100] = 2
image[-100:-10, 10:-10] = 2
image[10:-10, -100:-10] = 2
# foreground object 2
rs, cs = draw.line(250, 150, 10, 280)
for i in range(10):
image[rs + i, cs] = 1
rs, cs = draw.line(10, 150, 250, 280)
for i in range(20):
image[rs + i, cs] = 3
# foreground object 3
ir, ic = np.indices(image.shape)
circle1 = (ic - 135) ** 2 + (ir - 150) ** 2 < 30**2
circle2 = (ic - 135) ** 2 + (ir - 150) ** 2 < 20**2
image[circle1] = 1
image[circle2] = 0
result = skeletonize(image)
# there should never be a 2x2 block of foreground pixels in a skeleton
mask = np.array([[1, 1], [1, 1]], np.uint8)
blocks = correlate(result, mask, mode='constant')
assert not np.any(blocks == 4)
def test_lut_fix(self):
im = np.zeros((6, 6), dtype=bool)
im[1, 2] = 1
im[2, 2] = 1
im[2, 3] = 1
im[3, 3] = 1
im[3, 4] = 1
im[4, 4] = 1
im[4, 5] = 1
result = skeletonize(im)
expected = np.array(
[
[0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
assert np.all(result == expected)
@pytest.mark.parametrize("ndim", [2, 3])
def test_skeletonize_copies_input(self, ndim):
"""Skeletonize mustn't modify the original input image."""
image = np.ones((3,) * ndim, dtype=bool)
image = np.pad(image, 1)
original = image.copy()
skeletonize(image)
np.testing.assert_array_equal(image, original)
class TestThin:
@property
def input_image(self):
"""image to test thinning with"""
ii = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 2, 3, 4, 5, 0],
[0, 1, 0, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 6, 1, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
],
dtype=float,
)
return ii
def test_zeros(self):
image = np.zeros((10, 10), dtype=bool)
assert np.all(thin(image) == False)
@pytest.mark.parametrize("dtype", [bool, float, int])
def test_iter_1(self, dtype):
image = self.input_image.astype(dtype)
result = thin(image, 1).astype(bool)
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 1, 0, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
assert_array_equal(result, expected)
@pytest.mark.parametrize("dtype", [bool, float, int])
def test_noiter(self, dtype):
image = self.input_image.astype(dtype)
result = thin(image).astype(bool)
expected = np.array(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 1, 0, 1, 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],
],
dtype=bool,
)
assert_array_equal(result, expected)
def test_baddim(self):
for ii in [np.zeros(3, dtype=bool), np.zeros((3, 3, 3), dtype=bool)]:
with pytest.raises(ValueError):
thin(ii)
def test_lut_generation(self):
g123, g123p = _generate_thin_luts()
assert_array_equal(g123, G123_LUT)
assert_array_equal(g123p, G123P_LUT)
class TestMedialAxis:
def test_00_00_zeros(self):
'''Test skeletonize on an array of all zeros'''
result = medial_axis(np.zeros((10, 10), bool))
assert np.all(result == False)
def test_00_01_zeros_masked(self):
'''Test skeletonize on an array that is completely masked'''
result = medial_axis(np.zeros((10, 10), bool), np.zeros((10, 10), bool))
assert np.all(result == False)
@pytest.mark.parametrize("dtype", [bool, float, int])
def test_vertical_line(self, dtype):
'''Test a thick vertical line, issue #3861'''
img = np.zeros((9, 9), dtype=dtype)
img[:, 2] = 1
img[:, 3] = 2
img[:, 4] = 3
expected = np.full(img.shape, False)
expected[:, 3] = True
result = medial_axis(img)
assert_array_equal(result, expected)
def test_01_01_rectangle(self):
'''Test skeletonize on a rectangle'''
image = np.zeros((9, 15), bool)
image[1:-1, 1:-1] = True
#
# The result should be four diagonals from the
# corners, meeting in a horizontal line
#
expected = np.array(
[
[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, 1, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 1, 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],
],
dtype=bool,
)
result = medial_axis(image)
assert np.all(result == expected)
result, distance = medial_axis(image, return_distance=True)
assert distance.max() == 4
def test_01_02_hole(self):
'''Test skeletonize on a rectangle with a hole in the middle'''
image = np.zeros((9, 15), bool)
image[1:-1, 1:-1] = True
image[4, 4:-4] = False
expected = np.array(
[
[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, 1, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
[0, 1, 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],
],
dtype=bool,
)
result = medial_axis(image)
assert np.all(result == expected)
def test_narrow_image(self):
"""Test skeletonize on a 1-pixel thin strip"""
image = np.zeros((1, 5), bool)
image[:, 1:-1] = True
result = medial_axis(image)
assert np.all(result == image)

View File

@@ -0,0 +1,195 @@
import numpy as np
import pytest
import scipy.ndimage as ndi
from skimage import io, draw
from skimage.data import binary_blobs
from skimage.morphology import skeletonize, skeletonize_3d
from skimage._shared import testing
from skimage._shared.testing import assert_equal, assert_, parametrize, fetch
# basic behavior tests (mostly copied over from 2D skeletonize)
def test_skeletonize_wrong_dim():
im = np.zeros(5, dtype=bool)
with testing.raises(ValueError):
skeletonize(im, method='lee')
im = np.zeros((5, 5, 5, 5), dtype=bool)
with testing.raises(ValueError):
skeletonize(im, method='lee')
def test_skeletonize_1D_old_api():
# a corner case of an image of a shape(1, N)
im = np.ones((5, 1), dtype=bool)
res = skeletonize(im)
assert_equal(res, im)
def test_skeletonize_1D():
# a corner case of an image of a shape(1, N)
im = np.ones((5, 1), dtype=bool)
res = skeletonize(im, method='lee')
assert_equal(res, im)
def test_skeletonize_no_foreground():
im = np.zeros((5, 5), dtype=bool)
result = skeletonize(im, method='lee')
assert_equal(result, im)
def test_skeletonize_all_foreground():
im = np.ones((3, 4), dtype=bool)
assert_equal(
skeletonize(im, method='lee'),
np.array([[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0]], dtype=bool),
)
def test_skeletonize_single_point():
im = np.zeros((5, 5), dtype=bool)
im[3, 3] = 1
result = skeletonize(im, method='lee')
assert_equal(result, im)
def test_skeletonize_already_thinned():
im = np.zeros((5, 5), dtype=bool)
im[3, 1:-1] = 1
im[2, -1] = 1
im[4, 0] = 1
result = skeletonize(im, method='lee')
assert_equal(result, im)
def test_dtype_conv():
# check that the operation does the right thing with floats etc
# also check non-contiguous input
img = np.random.random((16, 16))[::2, ::2]
img[img < 0.5] = 0
orig = img.copy()
res = skeletonize(img, method='lee')
assert res.dtype == bool
assert_equal(img, orig) # operation does not clobber the original
@parametrize("img", [np.ones((8, 8), dtype=bool), np.ones((4, 8, 8), dtype=bool)])
def test_input_with_warning(img):
# check that the input is not clobbered
# for 2D and 3D images of varying dtypes
check_input(img)
@parametrize("img", [np.ones((8, 8), dtype=bool), np.ones((4, 8, 8), dtype=bool)])
def test_input_without_warning(img):
# check that the input is not clobbered
# for 2D and 3D images of varying dtypes
check_input(img)
def check_input(img):
orig = img.copy()
skeletonize(img, method='lee')
assert_equal(img, orig)
@pytest.mark.parametrize("dtype", [bool, float, int])
def test_skeletonize_num_neighbors(dtype):
# an empty image
image = np.zeros((300, 300), dtype=dtype)
# foreground object 1
image[10:-10, 10:100] = 1
image[-100:-10, 10:-10] = 2
image[10:-10, -100:-10] = 3
# foreground object 2
rs, cs = draw.line(250, 150, 10, 280)
for i in range(10):
image[rs + i, cs] = 4
rs, cs = draw.line(10, 150, 250, 280)
for i in range(20):
image[rs + i, cs] = 5
# foreground object 3
ir, ic = np.indices(image.shape)
circle1 = (ic - 135) ** 2 + (ir - 150) ** 2 < 30**2
circle2 = (ic - 135) ** 2 + (ir - 150) ** 2 < 20**2
image[circle1] = 1
image[circle2] = 0
result = skeletonize(image, method='lee').astype(np.uint8)
# there should never be a 2x2 block of foreground pixels in a skeleton
mask = np.array([[1, 1], [1, 1]], np.uint8)
blocks = ndi.correlate(result, mask, mode='constant')
assert_(not np.any(blocks == 4))
def test_two_hole_image():
# test a simple 2D image against FIJI
img_o = np.array(
[
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0],
[0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0],
[0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0],
[0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
img_f = 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, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
],
dtype=bool,
)
res = skeletonize(img_o, method='lee')
assert_equal(res, img_f)
def test_3d_vs_fiji():
# generate an image with blobs and compate its skeleton to
# the skeleton generated by FIJI (Plugins>Skeleton->Skeletonize)
img = binary_blobs(32, 0.05, n_dim=3, rng=1234)
img = img[:-2, ...]
img_s = skeletonize(img)
img_f = io.imread(fetch("data/_blobs_3d_fiji_skeleton.tif")).astype(bool)
assert_equal(img_s, img_f)
def test_deprecated_skeletonize_3d():
image = np.ones((10, 10), dtype=bool)
regex = "Use `skimage\\.morphology\\.skeletonize"
with pytest.warns(FutureWarning, match=regex) as record:
skeletonize_3d(image)
assert len(record) == 1
assert record[0].filename == __file__, "warning points at wrong file"

View File

@@ -0,0 +1,221 @@
"""Tests for `_util`."""
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from skimage.morphology import _util
@pytest.mark.parametrize("image_shape", [(111,), (33, 44), (22, 55, 11), (6, 5, 4, 3)])
@pytest.mark.parametrize("order", ["C", "F"])
def test_offsets_to_raveled_neighbors_highest_connectivity(image_shape, order):
"""
Check scenarios where footprint is always of the highest connectivity
and all dimensions are > 2.
"""
footprint = np.ones((3,) * len(image_shape), dtype=bool)
center = (1,) * len(image_shape)
offsets = _util._offsets_to_raveled_neighbors(image_shape, footprint, center, order)
# Assert only neighbors are present, center was removed
assert len(offsets) == footprint.sum() - 1
assert 0 not in offsets
# Assert uniqueness
assert len(set(offsets)) == offsets.size
# offsets form pairs of with same value but different signs
# if footprint is symmetric around center
assert all(-x in offsets for x in offsets)
# Construct image whose values are the Manhattan distance to its center
image_center = tuple(s // 2 for s in image_shape)
coords = [
np.abs(np.arange(s, dtype=np.intp) - c)
for s, c in zip(image_shape, image_center)
]
grid = np.meshgrid(*coords, indexing="ij")
image = np.sum(grid, axis=0)
image_raveled = image.ravel(order)
image_center_raveled = np.ravel_multi_index(image_center, image_shape, order=order)
# Sample raveled image around its center
samples = []
for offset in offsets:
index = image_center_raveled + offset
samples.append(image_raveled[index])
# Assert that center with value 0 wasn't selected
assert np.min(samples) == 1
# Assert that only neighbors where selected
# (highest value == connectivity)
assert np.max(samples) == len(image_shape)
# Assert that nearest neighbors are selected first
assert list(sorted(samples)) == samples
@pytest.mark.parametrize(
"image_shape", [(2,), (2, 2), (2, 1, 2), (2, 2, 1, 2), (0, 2, 1, 2)]
)
@pytest.mark.parametrize("order", ["C", "F"])
def test_offsets_to_raveled_neighbors_footprint_smaller_image(image_shape, order):
"""
Test if a dimension indicated by `image_shape` is smaller than in
`footprint`.
"""
footprint = np.ones((3,) * len(image_shape), dtype=bool)
center = (1,) * len(image_shape)
offsets = _util._offsets_to_raveled_neighbors(image_shape, footprint, center, order)
# Assert only neighbors are present, center and duplicates (possible
# for this scenario) where removed
assert len(offsets) <= footprint.sum() - 1
assert 0 not in offsets
# Assert uniqueness
assert len(set(offsets)) == offsets.size
# offsets form pairs of with same value but different signs
# if footprint is symmetric around center
assert all(-x in offsets for x in offsets)
def test_offsets_to_raveled_neighbors_explicit_0():
"""Check reviewed example."""
image_shape = (100, 200, 3)
footprint = np.ones((3, 3, 3), dtype=bool)
center = (1, 1, 1)
offsets = _util._offsets_to_raveled_neighbors(image_shape, footprint, center)
desired = np.array(
[
-600,
-3,
-1,
1,
3,
600,
-603,
-601,
-599,
-597,
-4,
-2,
2,
4,
597,
599,
601,
603,
-604,
-602,
-598,
-596,
596,
598,
602,
604,
]
)
assert_array_equal(offsets, desired)
def test_offsets_to_raveled_neighbors_explicit_1():
"""Check reviewed example where footprint is larger in last dimension."""
image_shape = (10, 9, 8, 3)
footprint = np.ones((3, 3, 3, 4), dtype=bool)
center = (1, 1, 1, 1)
offsets = _util._offsets_to_raveled_neighbors(image_shape, footprint, center)
desired = np.array(
[
-216,
-24,
-3,
-1,
1,
3,
24,
216,
-240,
-219,
-217,
-215,
-213,
-192,
-27,
-25,
-23,
-21,
-4,
-2,
2,
4,
21,
23,
25,
27,
192,
213,
215,
217,
219,
240,
-243,
-241,
-239,
-237,
-220,
-218,
-214,
-212,
-195,
-193,
-191,
-189,
-28,
-26,
-22,
-20,
20,
22,
26,
28,
189,
191,
193,
195,
212,
214,
218,
220,
237,
239,
241,
243,
-244,
-242,
-238,
-236,
-196,
-194,
-190,
-188,
188,
190,
194,
196,
236,
238,
242,
244,
5,
-211,
-19,
29,
221,
-235,
-187,
197,
245,
]
)
assert_array_equal(offsets, desired)