"""Module containing several utils operating on binary signals.
"""
from __future__ import annotations
import abc
from typing import Callable, Optional, Union
import cv2
import numpy as np
import skimage
import darsia
[docs]
class BaseCriterion:
"""Abstract criterion class."""
[docs]
def bind(self, signal: np.ndarray, unprocessed_signal: np.ndarray) -> None:
"""
Binding routine, allowing to fix and prepare.
Args:
signal (np.ndarray): processed signal
unprocessed_signal (np.ndarray): unprocessed signal
"""
self.signal = signal
@abc.abstractmethod
def __call__(self, roi) -> np.ndarray:
"""
Main method, to be overwritten.
"""
pass
[docs]
class ValueCriterion(BaseCriterion):
"""
Criterion checking for absolute maximal values.
"""
def __init__(self, threshold: float) -> None:
self.threshold = threshold
def __call__(self, roi) -> np.ndarray:
return np.max(self.signal[roi]) > self.threshold
[docs]
class RelativeValueCriterion(BaseCriterion):
"""
Criterion checking for relative maximal values.
"""
def __init__(self, threshold: float) -> None:
self.threshold = threshold
def __call__(self, roi) -> np.ndarray:
return np.max(self.signal[roi]) > self.threshold * np.min(self.signal[roi])
[docs]
class GradientModulusCriterion(BaseCriterion):
"""
Criterion checking for absolute maximal value of the
gradient modulus of the signal.
"""
def __init__(
self, threshold: Optional[float] = None, key: str = "", **kwargs
) -> None:
self.threshold = threshold
[docs]
def bind(self, signal: np.ndarray, unprocessed_signal: np.ndarray) -> None:
dx = darsia.forward_diff_x(signal)
dy = darsia.forward_diff_y(signal)
self.gradient_modulus = np.sqrt(dx**2 + dy**2)
def __call__(self, roi) -> np.ndarray:
return np.max(self.gradient_modulus[roi]) > self.threshold
[docs]
class CombinedCriterion(BaseCriterion):
"""
General combination of criteria, provided as list.
"""
def __init__(self, criteria: list[BaseCriterion]) -> None:
self.criteria = criteria
[docs]
def bind(self, signal: np.ndarray, unprocessed_signal) -> None:
for i in range(len(self.criteria)):
self.criteria[i].bind(signal, unprocessed_signal)
def __call__(self, signal: np.ndarray) -> np.ndarray:
return all([criterion(signal) for criterion in self.criteria])
[docs]
class BinaryDataSelector:
"""
Data selector acting on criteria, both volume and contour based.
"""
def __init__(
self, criterion: Optional[BaseCriterion] = None, key: str = "", **kwargs
) -> None:
"""
Constructor, initializing criteria. Possibly from keyword arguments.
Provide the following possibilities:
* absolute value based
* relative value based
* absolute value for signal and transformed unprocessed signal
* gradient modulus based
"""
if criterion is not None:
self.criterion = criterion
else:
criterion_key: str = kwargs.get(key + "criterion")
threshold = kwargs.get(key + "threshold")
if criterion_key == "value":
self.criterion = ValueCriterion(threshold)
self.type = "volume"
elif criterion_key == "relative value":
self.criterion = RelativeValueCriterion(threshold)
self.type = "volume"
elif criterion_key == "value/value extra color":
# Value
value_criterion = ValueCriterion(threshold[0])
# Value of extra color
color: Union[str, Callable] = kwargs.get(key + "extra color")
transformation = darsia.MonochromaticReduction(color=color)
extra_color_criterion = TransformedValueCriterion(
transformation,
threshold[1],
)
# Combine both
self.criterion = CombinedCriterion(
[value_criterion, extra_color_criterion]
)
self.type = "volume"
elif criterion_key == "gradient modulus":
self.criterion = GradientModulusCriterion(threshold)
self.type = "contour"
else:
raise ValueError(f"Criterion type {criterion_key} not supported.")
def __call__(
self, signal: np.ndarray, mask: np.ndarray, unprocessed_signal: np.ndarray
) -> np.ndarray:
"""
Select from data labeled regions which satisfy some criterion.
Args:
signal (np.ndarray): signal
mask (np.ndarray): mask
unprocessed_signal (np.ndarray): original signal
Returns:
np.ndarray: boolean mask, reevaluated based on criteria.
"""
# Bind data
self.criterion.bind(signal, unprocessed_signal)
# Initialize result
cleaned_mask = np.zeros_like(mask, dtype=bool)
# Label the mask
labels, num_labels = skimage.measure.label(mask, return_num=True)
# Investigate each labeled region separately; omit label 0, which corresponds
# to non-marked area.
for label in range(1, num_labels + 1):
# Fix one label
labeled_region = labels == label
roi = np.logical_and(labeled_region, mask)
if self.type == "volume":
# Check the criterion for the subregion
accept = np.count_nonzero(roi) > 0 and self.criterion(roi)
cleaned_mask[labeled_region] = accept
elif self.type == "contour":
# Determine contour set of labeled region
contours, _ = cv2.findContours(
skimage.img_as_ubyte(labeled_region),
cv2.RETR_TREE,
cv2.CHAIN_APPROX_SIMPLE,
)
# For each part of the contour set, check whether the gradient is sufficiently
# large at any location
for c in contours:
# Extract coordinates of contours - have to flip columns, since cv2
# provides reverse matrix indexing, and also 3 components, with the
# second one single dimensioned.
c = (c[:, 0, 1], c[:, 0, 0])
# Check the criterion for the subregion
accept = np.count_nonzero(roi) > 0 and self.criterion(c)
cleaned_mask[labeled_region] = accept
return cleaned_mask