"""Find grains in an image."""
# pylint: disable=no-name-in-module
import logging
from collections import defaultdict
import numpy as np
import numpy.typing as npt
from skimage import morphology
from skimage.color import label2rgb
from skimage.measure import regionprops
from skimage.segmentation import clear_border
from topostats.logs.logs import LOGGER_NAME
from topostats.thresholds import threshold
from topostats.utils import _get_mask, get_thresholds
LOGGER = logging.getLogger(LOGGER_NAME)
# pylint: disable=fixme
# pylint: disable=line-too-long
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments
# pylint: disable=bare-except
# pylint: disable=dangerous-default-value
[docs]
class Grains:
"""
Find grains in an image.
Parameters
----------
image : npt.NDArray
2-D Numpy array of image.
filename : str
File being processed (used in logging).
pixel_to_nm_scaling : float
Scaling of pixels to nanometres.
threshold_method : str
Method for determining thershold to mask values, default is 'otsu'.
otsu_threshold_multiplier : float
Factor by which the below threshold is to be scaled prior to masking.
threshold_std_dev : dict
Dictionary of 'below' and 'above' factors by which standard deviation is multiplied to derive the threshold
if threshold_method is 'std_dev'.
threshold_absolute : dict
Dictionary of absolute 'below' and 'above' thresholds for grain finding.
absolute_area_threshold : dict
Dictionary of above and below grain's area thresholds.
direction : str
Direction for which grains are to be detected, valid values are 'above', 'below' and 'both'.
smallest_grain_size_nm2 : float
Whether or not to remove grains that intersect the edge of the image.
remove_edge_intersecting_grains : bool
Direction for which grains are to be detected, valid values are 'above', 'below' and 'both'.
"""
def __init__(
self,
image: npt.NDArray,
filename: str,
pixel_to_nm_scaling: float,
threshold_method: str = None,
otsu_threshold_multiplier: float = None,
threshold_std_dev: dict = None,
threshold_absolute: dict = None,
absolute_area_threshold: dict = None,
direction: str = None,
smallest_grain_size_nm2: float = None,
remove_edge_intersecting_grains: bool = True,
):
"""
Initialise the class.
Parameters
----------
image : npt.NDArray
2-D Numpy array of image.
filename : str
File being processed (used in logging).
pixel_to_nm_scaling : float
Scaling of pixels to nanometres.
threshold_method : str
Method for determining thershold to mask values, default is 'otsu'.
otsu_threshold_multiplier : float
Factor by which the below threshold is to be scaled prior to masking.
threshold_std_dev : dict
Dictionary of 'below' and 'above' factors by which standard deviation is multiplied to derive the threshold
if threshold_method is 'std_dev'.
threshold_absolute : dict
Dictionary of absolute 'below' and 'above' thresholds for grain finding.
absolute_area_threshold : dict
Dictionary of above and below grain's area thresholds.
direction : str
Direction for which grains are to be detected, valid values are 'above', 'below' and 'both'.
smallest_grain_size_nm2 : float
Whether or not to remove grains that intersect the edge of the image.
remove_edge_intersecting_grains : bool
Direction for which grains are to be detected, valid values are 'above', 'below' and 'both'.
"""
if absolute_area_threshold is None:
absolute_area_threshold = {"above": [None, None], "below": [None, None]}
self.image = image
self.filename = filename
self.pixel_to_nm_scaling = pixel_to_nm_scaling
self.threshold_method = threshold_method
self.otsu_threshold_multiplier = otsu_threshold_multiplier
self.threshold_std_dev = threshold_std_dev
self.threshold_absolute = threshold_absolute
self.absolute_area_threshold = absolute_area_threshold
# Only detect grains for the desired direction
self.direction = [direction] if direction != "both" else ["above", "below"]
self.smallest_grain_size_nm2 = smallest_grain_size_nm2
self.remove_edge_intersecting_grains = remove_edge_intersecting_grains
self.thresholds = None
self.images = {
"mask_grains": None,
"tidied_border": None,
"tiny_objects_removed": None,
"objects_removed": None,
# "labelled_regions": None,
# "coloured_regions": None,
}
self.directions = defaultdict()
self.minimum_grain_size = None
self.region_properties = defaultdict()
self.bounding_boxes = defaultdict()
self.grainstats = None
[docs]
def tidy_border(self, image: npt.NDArray, **kwargs) -> npt.NDArray:
"""
Remove grains touching the border.
Parameters
----------
image : npt.NDarray
2-D Numpy array representing the image.
**kwargs
Arguments passed to 'skimage.segmentation.clear_border(**kwargs)'.
Returns
-------
npt.NDarray
2-D Numpy array of image without objects touching the border.
"""
LOGGER.info(f"[{self.filename}] : Tidying borders")
return clear_border(image, **kwargs)
[docs]
def label_regions(self, image: npt.NDArray, background: int = 0) -> npt.NDArray:
"""
Label regions.
This method is used twice, once prior to removal of small regions and again afterwards which is why an image
must be supplied rather than using 'self'.
Parameters
----------
image : npt.NDArray
2-D Numpy array of image.
background : int
Value used to indicate background of image. Default = 0.
Returns
-------
npt.NDArray
2-D Numpy array of image with regions numbered.
"""
LOGGER.info(f"[{self.filename}] : Labelling Regions")
return morphology.label(image, background)
[docs]
def calc_minimum_grain_size(self, image: npt.NDArray) -> float:
"""
Calculate the minimum grain size in pixels squared.
Very small objects are first removed via thresholding before calculating the below extreme.
Parameters
----------
image : npt.NDArray
2-D Numpy image from which to calculate the minimum grain size.
Returns
-------
float
Minimum grains size in pixels squared. If there are areas a value of -1 is returned.
"""
region_properties = self.get_region_properties(image)
grain_areas = np.array([grain.area for grain in region_properties])
if len(grain_areas > 0):
# Exclude small objects less than a given threshold first
grain_areas = grain_areas[
grain_areas >= threshold(grain_areas, method="otsu", otsu_threshold_multiplier=1.0)
]
self.minimum_grain_size = np.median(grain_areas) - (
1.5 * (np.quantile(grain_areas, 0.75) - np.quantile(grain_areas, 0.25))
)
else:
self.minimum_grain_size = -1
[docs]
def remove_noise(self, image: npt.NDArray, **kwargs) -> npt.NDArray:
"""
Remove noise which are objects smaller than the 'smallest_grain_size_nm2'.
This ensures that the smallest objects ~1px are removed regardless of the size distribution of the grains.
Parameters
----------
image : npt.NDArray
2-D Numpy array to be cleaned.
**kwargs
Arguments passed to 'skimage.morphology.remove_small_objects(**kwargs)'.
Returns
-------
npt.NDArray
2-D Numpy array of image with objects < smallest_grain_size_nm2 removed.
"""
LOGGER.info(
f"[{self.filename}] : Removing noise (< {self.smallest_grain_size_nm2} nm^2"
"{self.smallest_grain_size_nm2 / (self.pixel_to_nm_scaling**2):.2f} px^2)"
)
return morphology.remove_small_objects(
image, min_size=self.smallest_grain_size_nm2 / (self.pixel_to_nm_scaling**2), **kwargs
)
[docs]
def remove_small_objects(self, image: np.array, **kwargs) -> npt.NDArray:
"""
Remove small objects from the input image.
Threshold determined by the minimum grain size, in pixels squared, of the classes initialisation.
Parameters
----------
image : np.array
2-D Numpy array to remove small objects from.
**kwargs
Arguments passed to 'skimage.morphology.remove_small_objects(**kwargs)'.
Returns
-------
npt.NDArray
2-D Numpy array of image with objects < minimumm_grain_size removed.
"""
# If self.minimum_grain_size is -1, then this means that
# there were no grains to calculate the minimum grian size from.
if self.minimum_grain_size != -1:
small_objects_removed = morphology.remove_small_objects(
image,
min_size=self.minimum_grain_size, # minimum_grain_size is in pixels squared
**kwargs,
)
LOGGER.info(
f"[{self.filename}] : Removed small objects (< \
{self.minimum_grain_size} px^2 / {self.minimum_grain_size / (self.pixel_to_nm_scaling)**2} nm^2)"
)
return small_objects_removed > 0.0
return image
[docs]
def area_thresholding(self, image: npt.NDArray, area_thresholds: tuple) -> npt.NDArray:
"""
Remove objects larger and smaller than the specified thresholds.
Parameters
----------
image : npt.NDArray
Image array where the background == 0 and grains are labelled as integers >0.
area_thresholds : tuple
List of area thresholds (in nanometres squared, not pixels squared), first is the lower limit for size,
second is the upper.
Returns
-------
npt.NDArray
Array with small and large objects removed.
"""
image_cp = image.copy()
lower_size_limit, upper_size_limit = area_thresholds
# if one value is None adjust for comparison
if upper_size_limit is None:
upper_size_limit = image.size * self.pixel_to_nm_scaling**2
if lower_size_limit is None:
lower_size_limit = 0
# Get array of grain numbers (discounting zero)
uniq = np.delete(np.unique(image), 0)
grain_count = 0
LOGGER.info(
f"[{self.filename}] : Area thresholding grains | Thresholds: L: {(lower_size_limit / self.pixel_to_nm_scaling**2):.2f},"
f"U: {(upper_size_limit / self.pixel_to_nm_scaling**2):.2f} px^2, L: {lower_size_limit:.2f}, U: {upper_size_limit:.2f} nm^2."
)
for grain_no in uniq: # Calculate grian area in nm^2
grain_area = np.sum(image_cp == grain_no) * (self.pixel_to_nm_scaling**2)
# Compare area in nm^2 to area thresholds
if grain_area > upper_size_limit or grain_area < lower_size_limit:
image_cp[image_cp == grain_no] = 0
else:
grain_count += 1
image_cp[image_cp == grain_no] = grain_count
return image_cp
[docs]
def colour_regions(self, image: npt.NDArray, **kwargs) -> npt.NDArray:
"""
Colour the regions.
Parameters
----------
image : npt.NDArray
2-D array of labelled regions to be coloured.
**kwargs
Arguments passed to 'skimage.color.label2rgb(**kwargs)'.
Returns
-------
np.array
Numpy array of image with objects coloured.
"""
coloured_regions = label2rgb(image, **kwargs)
LOGGER.info(f"[{self.filename}] : Coloured regions")
return coloured_regions
[docs]
@staticmethod
def get_region_properties(image: np.array, **kwargs) -> list:
"""
Extract the properties of each region.
Parameters
----------
image : np.array
Numpy array representing image.
**kwargs :
Arguments passed to 'skimage.measure.regionprops(**kwargs)'.
Returns
-------
list
List of region property objects.
"""
return regionprops(image, **kwargs)
[docs]
def get_bounding_boxes(self, direction: str) -> dict:
"""
Derive a list of bounding boxes for each region from the derived region_properties.
Parameters
----------
direction : str
Direction of threshold for which bounding boxes are being calculated.
Returns
-------
dict
Dictionary of bounding boxes indexed by region area.
"""
return {region.area: region.area_bbox for region in self.region_properties[direction]}
[docs]
def find_grains(self):
"""Find grains."""
LOGGER.info(f"[{self.filename}] : Thresholding method (grains) : {self.threshold_method}")
self.thresholds = get_thresholds(
image=self.image,
threshold_method=self.threshold_method,
otsu_threshold_multiplier=self.otsu_threshold_multiplier,
threshold_std_dev=self.threshold_std_dev,
absolute=self.threshold_absolute,
)
for direction in self.direction:
LOGGER.info(f"[{self.filename}] : Finding {direction} grains, threshold: ({self.thresholds[direction]})")
self.directions[direction] = {}
self.directions[direction]["mask_grains"] = _get_mask(
self.image,
thresh=self.thresholds[direction],
threshold_direction=direction,
img_name=self.filename,
)
self.directions[direction]["labelled_regions_01"] = self.label_regions(
self.directions[direction]["mask_grains"]
)
if self.remove_edge_intersecting_grains:
self.directions[direction]["tidied_border"] = self.tidy_border(
self.directions[direction]["labelled_regions_01"]
)
else:
self.directions[direction]["tidied_border"] = self.directions[direction]["labelled_regions_01"]
LOGGER.info(f"[{self.filename}] : Removing noise ({direction})")
self.directions[direction]["removed_noise"] = self.area_thresholding(
self.directions[direction]["tidied_border"],
[self.smallest_grain_size_nm2, None],
)
LOGGER.info(f"[{self.filename}] : Removing small / large grains ({direction})")
# if no area thresholds specified, use otsu
if self.absolute_area_threshold[direction].count(None) == 2:
self.calc_minimum_grain_size(self.directions[direction]["removed_noise"])
self.directions[direction]["removed_small_objects"] = self.remove_small_objects(
self.directions[direction]["removed_noise"]
)
else:
self.directions[direction]["removed_small_objects"] = self.area_thresholding(
self.directions[direction]["removed_noise"],
self.absolute_area_threshold[direction],
)
self.directions[direction]["labelled_regions_02"] = self.label_regions(
self.directions[direction]["removed_small_objects"]
)
self.region_properties[direction] = self.get_region_properties(
self.directions[direction]["labelled_regions_02"]
)
LOGGER.info(f"[{self.filename}] : Region properties calculated ({direction})")
self.directions[direction]["coloured_regions"] = self.colour_regions(
self.directions[direction]["labelled_regions_02"]
)
self.bounding_boxes[direction] = self.get_bounding_boxes(direction=direction)
LOGGER.info(f"[{self.filename}] : Extracted bounding boxes ({direction})")