# -*- coding: utf-8 -*-
"""
This module contains the Analysis class, responsible for the analysis of
segmented grain-based microstructures.
All the examples assume that the modules `numpy` and `matplotlib.pyplot` were imported as `np`
and `plt`, respectively.
Classes
-------
.. autosummary::
:nosignatures:
:toctree: classes/
Analysis
Functions
---------
.. autosummary::
:toctree: functions/
feret_diameter
plot_prop
plot_grain_characteristic
show_label_image
label_image_skeleton
thicken_skeleton
label_image_apply_mask
"""
from math import sqrt
import warnings
import numpy as np
from scipy.interpolate import griddata
from scipy.spatial.distance import pdist
from skimage.io import imshow, show
from skimage.color import label2rgb
from skimage.measure._find_contours import find_contours
from skimage.measure._marching_cubes_lewiner import marching_cubes_lewiner as marching_cubes
from skimage.measure import regionprops
from skimage.segmentation import find_boundaries
from skimage.morphology import skeletonize, dilation, square
import matplotlib.pyplot as plt
import pandas as pd
from grains.utils import parse_kwargs
[docs]class Analysis:
"""Analysis of grain assemblies.
Attributes
----------
original_image : ndarray
Matrix representing the initial, unprocessed image.
save_location : str
Directory where the processed images are saved
"""
[docs] def __init__(self, label_image, interactive_mode=False):
"""Initialize the class with file paths and with some options
Parameters
----------
label_image : ndarray
Segmented image.
interactive_mode : bool, optional
When True, images of each image manipulation step are plotted and
details are shown in the console.
Default is False.
Returns
-------
None.
"""
# Check inputs
self.label_image = label_image
self.__interactive_mode = interactive_mode
self.scale = 1
# Test whether it is a valid label image
# Hack, delete it later (only needed for OOF)
self.label_image = self.label_image + 1
if not np.issubdtype(self.label_image.dtype, np.integer):
raise Exception('Integer array is expected, received {0}.'.format(self.label_image.dtype))
if not np.all(self.label_image > 0):
raise Exception('Label image must contain positive integer entries')
if np.ndim(self.label_image) != 2:
raise Exception('Label image must be a matrix, received an array of dimension {0}.'.format(np.ndim(self.label_image)))
# Optionally show the loaded image
if self.__interactive_mode:
imshow(label2rgb(self.label_image))
show()
print('Image successfully loaded.')
# Initialize a dictionary holding the grain properties
self.properties = {key: None for key in ['label', 'area', 'centroid',
'coordinate', 'diameter']}
[docs] def set_scale(self, pixel_per_unit=1):
"""Defines a scale for performing computations in that unit.
Image measures (area, diameter, etc.) are performed on a matrix
corresponding to a label image. Therefore, the result of all the
computations are obtained in pixel units. It is often of interest to
access the results in physical units (mm, cm, inch, etc.). Manually
converting pixels, pixel squares, etc. to pyhsical units, physical unit
sqaures, etc. are tedious and error prone. Once the conversion between
a pixel and a physical unit is given, all the subsequent calculations
are performed in the desired physical unit.
Parameters
----------
pixel_per_unit : float or int or scalar ndarray, optional
Number of pixels contained in a certain unit. The default is 1, in
which case all measurements are performed in pixel units.
Returns
-------
None.
"""
correct_type = isinstance(pixel_per_unit, (int, float, np.ndarray))
scalar = np.isscalar(pixel_per_unit)
if not correct_type or not scalar:
raise Exception('A scalar number expected.')
self.scale = pixel_per_unit
[docs] def compute_properties(self):
"""Determines relevant properties of the grains.
The area of each grain is determined in the units previously given in
the `set_scale` method.
Parameters
----------
window_size : int
Size of the sampling window.
image_matrix : 3D ndarray with size 3 in the third dimension, optional
Input image to be filtered. If not given, the original image is used.
Returns
-------
filtered_image : 3D ndarray with size 3 in the third dimension
Filtered image, output of the median filter algorithm.
"""
# Do not compute all the region properties (https://stackoverflow.com/a/36323763/4892892)
properties = regionprops(self.label_image, cache=False)
self.properties = dict(label=[prop.label for prop in properties],
area=[self.scale**2*prop.area for prop in properties],
centroid=[prop.centroid for prop in properties],
coordinate=[prop.coords for prop in properties],
equivalent_diameter=[self.scale*prop.equivalent_diameter for prop in properties],
feret_diameter=[self.scale*feret_diameter(prop) for prop in properties])
if self.__interactive_mode:
print('Grain properties determined.')
[docs] def show_properties(self, gui=False):
"""Displays previously computed properties of the grains
Parameters
----------
gui : bool, optional
If true, the grain properties are shown in a GUI. If false, they
are printed to stdout. The default is False.
The GUI requires the `dfgui` modul, which can be obtained from
https://github.com/bluenote10/PandasDataFrameGUI
Returns
-------
None.
"""
table = pd.DataFrame(self.properties)
# Print the table or show it in a GUI
if gui:
# PandasDataFrameGUI uses the wxwidgets backend of matplotlib
import matplotlib
matplotlib.use('WXAgg', force=True)
import dfgui
dfgui.show(table)
else:
pd.options.display.max_rows = None # show all grains
print(table)
[docs] def show_grains(self, grain_property=None):
"""Display the grains, optionally with a property superposed.
Parameters
----------
grain_property : {None, 'area', 'centroid', 'coordinate',
'equivalent_diameter', 'feret_diameter', 'label'}
optional
If not None, the selected property is shown on the grain as text.
Returns
-------
None.
"""
nlabel = len(np.unique(self.label_image))
imshow(label2rgb(self.label_image, colors=np.random.random((nlabel, 3))))
# Display information on the grains, if requested
if grain_property is not None:
grain_property = self.properties[grain_property]
grain_centroid = self.properties["centroid"]
for cent, prop in zip(grain_centroid, grain_property):
cent = (cent[1], cent[0]) # image vs matrix indexing!
plt.annotate('%.1f'%prop, cent, ha='center', va='center')
plt.show()
[docs]def feret_diameter(prop):
"""Determines the maximum Feret diameter.
Parameters
----------
prop : RegionProperties
Describes a labeled region.
Returns
-------
max_feret_diameter : float
Maximum Feret diameter of the region.
See also
--------
:func:`skimage.measure.regionprops` : Measure properties of labeled image regions
Examples
--------
>>> import numpy as np
>>> from skimage.measure import regionprops
>>> image = np.ones((2,2), dtype=np.int8)
>>> prop = regionprops(image)[0]
>>> feret_diameter(prop)
2.23606797749979
"""
identity_convex_hull = np.pad(prop.convex_image, 2, mode='constant',
constant_values=0)
if prop._ndim == 2:
coordinates = np.vstack(find_contours(identity_convex_hull, 0.5,
fully_connected='high'))
elif prop._ndim == 3:
coordinates, _, _, _ = marching_cubes(identity_convex_hull, level=0.5)
distances = pdist(coordinates, 'sqeuclidean')
# TODO: allow computing the Feret diameter along a given direction (t,s)
# For this, restrict the maximum distance to those pairwise distances whose
# orientation (up to a given tolerance) is (t,s)
max_feret_diameter = sqrt(np.max(distances))
return max_feret_diameter
[docs]def plot_prop(prop, pixel_per_unit=1, show_axis=True):
"""Plots relevant region properties into a single figure.
Four subfigures are created, giving the region's
- image, its area and its center
- filled image, its area
- bounding box, its area
- convex image, its area
Parameters
----------
prop : RegionProperties
Describes a labeled region.
pixel_per_unit : float or int, optional
Number of pixels contained in a certain unit. The default is 1, in
which case all measurements are performed in pixel units.
Returns
------
fig : matplotlib.figure.Figure
The figure object is returned in case further manipulations are necessary.
"""
scale = pixel_per_unit
fig, ax = plt.subplots(2, 2, constrained_layout=True)
# Use actual coordinates as appears in the label image
extent = [prop.bbox[1], prop.bbox[3], prop.bbox[2], prop.bbox[0]]
# Image, its area and its geometrical center
ax[0, 0].imshow(prop.image, origin='upper', extent=extent)
ax[0, 0].text(prop.centroid[1], prop.centroid[0], '+',
horizontalalignment='center', verticalalignment='center',
fontsize='x-large', fontweight='bold')
ax[0, 0].set_title('Image, area: {0:.2f}'.format(scale**2*prop.area))
# Image with holes filled, and its area
ax[0, 1].imshow(prop.filled_image, origin='upper', extent=extent)
ax[0, 1].set_title('Filled image, area: {0:.2f}'.format(scale**2*prop.filled_area))
# Convex image, and its area
ax[1, 0].imshow(prop.convex_image, origin='upper', extent=extent)
ax[1, 0].set_title('Convex image, area: {0:.2f}'.format(scale**2*prop.convex_area))
# Switch off axes if requested
if not show_axis:
for axis in ax.flat:
axis.set_axis_off()
plt.show()
return fig
[docs]def plot_grain_characteristic(characteristic, centers, interpolation='linear',
grid_size=(100, 100), **kwargs):
"""Plots the distribution of a given grain characteristic.
One way to gain insight into a grain assembly is to plot the distribution of a certain grain
property in the domain the grains occupy. In this function, for each grain, and arbitrary
(scalar) quantity is associated to the center of the grain. In case of `n` grains, `n` data
points span the interpolant and the given characteristic is interpolated on a grid of the
AABB of the grain centers.
Parameters
----------
characteristic : ndarray
Characteristic property, the distribution of which is sought. A 1D numpy array.
centers : ndarray
2D numpy array with 2 columns, each row corresponding to a grain, and the two columns
giving the Cartesian coordinates of the grain center.
interpolation : {'nearest', 'linear', 'cubic'}, optional
Type of the interpolation for creating the distribution. The default is `'linear'`.
grid_size : tuple of int, optional
2-tuple, the size of the grid on which the data is interpolated. The default is (100, 100).
Other Parameters
----------------
center_marker : str, optional
Marker indicating the center of the grains. The default is `'P'`. For a list of supported
markers, see the `documentation
<https://matplotlib.org/3.2.1/gallery/lines_bars_and_markers/marker_reference.html>`_.
If you do not want the centers to be shown, choose `'none'`.
show_axis : bool, optional
If True, the axes are displayed. The default is False.
Returns
-------
None
See Also
--------
:func:`scipy.interpolate.griddata`
Notes
-----
This function knows nothing about how the center of a grain is determined and what
characteristic features a grain has. It only performs interpolation and visualization,
hence decoupling the plotting from the actual representation of grains and their
characteristics. For instance, a grain can be represented as a spline surface, as a polygon,
as an assembly of primitives (often triangles), as pixels, just to mention some typical
scenarios. Calculating the center of a grain depends on the grain representation at hand.
Similarly, one can imagine various grain characteristics, such as area, diameter, Young modulus.
Examples
--------
Assume that the grain centers are sampled from a uniformly random distribution on the unit
square.
>>> n_data = 100
>>> points = np.random.random((n_data, 2))
The quantity we want to plot has a parabolic distribution with respect to the position of the
grain centers.
>>> func = lambda x, y: 1 - (x-0.5)**2 - (y-0.5)**2
>>> plot_grain_characteristic(func(points[:, 0], points[:, 1]), points, center_marker='*')
>>> plt.show()
"""
n = np.size(characteristic)
characteristic = np.reshape(characteristic, (n,))
parsed_options, unknown_options = \
parse_kwargs(kwargs, {'center_marker': 'P', 'show_axis': False, 'ax': None})
if unknown_options:
warnings.warn('The following keyword arguments are not recognized: {0}.'.format(list(
unknown_options.keys())), Warning)
# Grid on which the data is interpolated
x_range = (np.min(centers[:, 0]), np.max(centers[:, 0]))
y_range = (np.min(centers[:, 1]), np.max(centers[:, 1]))
x, y = np.mgrid[x_range[0]:x_range[1]:complex(0, grid_size[0]),
y_range[0]:y_range[1]:complex(0, grid_size[1])]
# Interpolate the grain characteristic at the grid points
f = griddata(centers, characteristic, (x, y), method=interpolation)
f_nearest = griddata(centers, characteristic, (x, y), method='nearest')
outside_region = np.isnan(f)
ff = f.copy()
ff[outside_region] = f_nearest[outside_region]
# Plot the distribution of the grain characteristic
ax = parsed_options['ax'] if parsed_options['ax'] else plt.figure().add_subplot()
# fig, ax = plt.subplots()
image = ax.imshow(np.flipud(ff.T), interpolation='none', extent=(*x_range, *y_range))
if not parsed_options['show_axis']:
plt.axis('off')
plt.colorbar(image)
ax.plot(centers[:, 0], centers[:, 1], parsed_options['center_marker'],
markeredgecolor='black', markerfacecolor='black')
ax.set_aspect('equal')
[docs]def show_label_image(label_image, alpha=1):
"""Displays a labeled image.
A random color is associated with each labeled region. If boundary pixels are present in the
image, they are plotted in black.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of non-negative integers.
The label 0 is assumed to denote a boundary pixel.
alpha : float, optional
Opacity of colorized labels. Must be within [0, 1].
Returns
-------
None
"""
label_image = label_image.astype(int)
n_label = len(np.unique(label_image))
region_colors = np.random.random((n_label, 3))
if 0 in label_image:
region_colors[0] = [0, 0, 0]
imshow(label2rgb(label_image, colors=region_colors), alpha=alpha)
[docs]def label_image_skeleton(label_image):
"""Skeleton of a labeled image.
The skeleton of a labeled image is a single-pixel wide network that separates the labeled
regions.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of positive integers.
Returns
-------
ndarray
A 2D bool numpy array having the same size as :code:`label_image`, where True represents
the skeleton pixels.
See Also
--------
thicken_skeleton
"""
boundaries = find_boundaries(label_image)
# Erode the two-pixel wide boundaries to obtain singe-pixel wide network
return skeletonize(boundaries)
[docs]def thicken_skeleton(skeleton, thickness):
"""Thickens a skeleton by morphological dilation.
Parameters
----------
skeleton : ndarray
Skeleton of a binary image, represented as a bool 2D numpy array.
thickness : int
Thickness of the resulting boundaries.
Returns
-------
ndarray
A 2D bool numpy array, where True represents the thickened skeleton.
See Also
--------
label_image_skeleton
"""
return dilation(skeleton, square(thickness))
[docs]def label_image_apply_mask(label_image, mask, value):
"""Changes parts of a labeled image to a given value.
Convenience function, equivalent to :code:`label_image[mask] = value` but the original array
:code:`label_image` is `not` overwritten.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of positive integers.
mask : ndarray
Boolean array of the same size as :code:`label_image`, marking the pixels that will be
replaced by :code:`value`.
value : int
The masked pixels are replaced by this value.
Returns
-------
ndarray
Copy of the input image, its selected pixels being replaced by the given value.
"""
label_image = label_image.copy()
label_image[mask] = value
return label_image
[docs]def truecolor2label(color_image):
"""Truecolor image into labeled image.
It is often the case that you need to deal with a labeled image that was saved as a truecolor
image (e.g. RGB). A labeled region is then the set of pixels with the same colors.
Parameters
----------
color_image : ndarray
3D array, the first two dimensions corresponding to the image pixels, the third one for
the channels (e.g. RGB, HSV, CMYK, etc.).
Returns
-------
ndarray
Labeled image, represented as a 2D numpy array of non-negative integers.
"""
image_shape = np.shape(color_image)
if len(image_shape) != 3:
raise ValueError('A truecolor image with multiple channels is expected.')
image_size = image_shape[0:2]
n_pixel = np.prod(image_size)
n_channel = image_shape[2]
u, indices = np.unique(color_image.reshape((n_pixel, n_channel)), return_inverse=True, axis=0)
return indices.reshape(image_size) + 1