from __future__ import annotations
import functools
from typing import List, Literal, TYPE_CHECKING
if TYPE_CHECKING:
from phenotypic._core._image import Image
import warnings
warnings.filterwarnings('ignore', category=SyntaxWarning, module='mahotas')
import mahotas as mh
import numpy as np
import pandas as pd
from skimage import exposure
from phenotypic.abc_ import MeasureFeatures
from phenotypic.tools_.constants_ import OBJECT
from ..tools_.measurement_info_ import TEXTURE
[docs]
class MeasureTexture(MeasureFeatures):
"""Measure colony surface texture using Haralick features from gray-level co-occurrence matrices.
Compute 13 second-order Haralick texture features per colony at one
or more pixel-offset scales, across four directional angles (0, 45,
90, 135 degrees), plus direction-averaged values. These features
quantify surface roughness, regularity, and directional patterns
that distinguish colony morphotypes.
Args:
scale: Pixel offset(s) for the co-occurrence matrix. A single
integer or list of integers. Small values (1--2) capture fine
texture; large values (5--10) capture coarse patterns.
Default: ``5``.
quant_lvl: Number of gray-level bins for quantization. Accepted
values: ``8``, ``16``, ``32``, ``64``. Lower values are
faster; higher values preserve texture nuance but are more
noise-sensitive. Default: ``32``.
enhance: Rescale each colony's intensity to [0, 1] before
computing Haralick features. Improves contrast in
low-variance regions but can bias cross-colony comparisons.
Default: ``False``.
warn: Emit warnings when Haralick computation fails for
individual colonies (typically very small objects).
Default: ``False``.
Returns:
pd.DataFrame: Object-level texture measurements with columns:
- Label: unique object identifier.
- 13 Haralick features x 4 angles = 52 directional columns
per scale (e.g., ``Contrast-deg000-scale05``).
- 13 direction-averaged columns per scale (e.g.,
``Contrast-avg-scale05``).
References:
[1] R. M. Haralick, K. Shanmugam, and I. Dinstein, "Textural
features for image classification," *IEEE Trans. Syst., Man,
Cybern.*, vol. SMC-3, no. 6, pp. 610--621, Nov. 1973.
Best For:
- Distinguishing smooth wild-type colonies from rough, wrinkled,
or sporulated mutants.
- Assessing mycelial organization in filamentous fungi (radial
vs cottony growth).
- Multi-feature phenotypic clustering when combined with size,
shape, and color measurements.
Consider Also:
- :class:`MeasureShape` for geometric morphology metrics
(circularity, Feret diameters) that complement texture.
- :class:`MeasureIntensity` for brightness statistics without
spatial co-occurrence information.
- :class:`MeasureColor` for pigmentation-based phenotyping.
See Also:
:doc:`/tutorials/notebooks/07_measuring_and_exporting` for a
walkthrough of measuring and exporting colony data.
:doc:`/explanation/measurement_metrics_biological_meaning` for
interpreting texture metrics in a biological context.
"""
_measurement_info_class = TEXTURE
[docs]
def __init__(
self,
scale: int | List[int] = 5,
quant_lvl: Literal[8, 16, 32, 64] = 32,
enhance: bool = False,
warn: bool = False,
):
"""
Initializes an object with specific configurations for scale, quantization level,
enhance, and warning behaviors. This constructor ensures that the 'scale'
parameter is always stored as a list.
Args:
scale (int | List[int]): A single integer or a list of integers representing
the scale configuration. If a single integer is provided, it will be
converted into a list containing that integer.
quant_lvl (Literal[8, 16, 32, 64]): The quantization level. A higher level adds
more computational complexity but captures more discrete texture changes. A higher value is
not always more meaningful. Think of this like sensitivity to texture. Acceptable values are either 8, 16, 32, or 64.
enhance (bool): A flag indicating whether to enhance the image before measuring texture. This can
increase the amount of detail captured but can bias the measurements in cases where the relative
variance between pixel intensities of an object is small.
warn (bool): A flag indicating whether warnings should be issued.
"""
if not hasattr(scale, "__getitem__"): # coerce iterable input
scale = [scale]
self.scale = scale
self.quant_lvl = quant_lvl
self.enhance = enhance
self.warn = warn
def _operate(self, image: Image) -> pd.DataFrame:
"""Performs texture measurements on the image objects.
This method extracts texture features from the foreground objects in the image using
Haralick texture features. It processes the image's foreground array and returns
the measurements as a DataFrame.
Args:
image (Image): The image containing objects to measure.
Returns:
pd.DataFrame: A DataFrame containing texture measurements for each object in the image.
The nrows are indexed by object labels, and columns represent different texture features.
"""
compute_haralick = functools.partial(
self._compute_haralick,
image=image,
foreground_array=image.gray.foreground(),
foreground_name="Gray",
quant_lvl=self.quant_lvl,
enhance=self.enhance,
warn=self.warn,
)
meas = compute_haralick(scale=self.scale[0])
if len(self.scale) > 1:
for scale in self.scale[1:]:
meas.merge(compute_haralick(scale=scale), on=OBJECT.LABEL, how="outer")
return meas
@staticmethod
def _compute_haralick(
image: Image,
foreground_array: np.ndarray,
foreground_name: str,
scale: int,
quant_lvl: int,
enhance: bool,
warn: bool,
) -> pd.DataFrame:
"""
Computes texture feature measurements using Haralick features for objects in a given image. The method
calculates various statistical texture features such as Angular Second Moment, Contrast, Correlation,
Variance, Inverse Difference Moment, among others, for different directional orientations. These
features are computed for each segmented object within the foreground array using the specified
scale parameter.
Args:
image (Image): The image containing objects and their associated properties, including
labels and slices used for extracting foreground objects.
foreground_array (np.ndarray): The 2D numpy array representing the foreground objects,
where pixel values indicate the object intensity.
foreground_name (str): The name of the foreground for labeling the resulting features.
scale (int, optional): The distance parameter used in calculating Haralick features.
Defaults to 5.
Returns:
dict: A dictionary mapping computed texture feature names (e.g.,
"angular_second_moment", "contrast") to their corresponding values
for each object in the foreground array.
Raises:
KeyboardInterrupt: If the computation process is interrupted manually.
Warning: If an error occurs during the computation of Haralick features for specific objects, a
warning is issued with details of the error, and NaN values are assigned for the corresponding
measurements.
"""
if foreground_array.min() < 0 or foreground_array.max() > 1:
raise ValueError("Foreground array must be normalized between 0 and 1")
props = image.objects.props
objmap = image.objmap[:]
measurement_names = TEXTURE.get_headers(scale, foreground_name)
deg_measurement_names = measurement_names[
:-13
] # there are 13 haralick features so we separate the avgs out
avg_measurement_names = measurement_names[-13:]
deg_meas = np.empty(
shape=(
image.num_objects,
len(deg_measurement_names),
),
dtype=np.float64,
)
for idx, label in enumerate(image.objects.labels):
slices = props[idx].slice
obj_fg = foreground_array[slices].copy()
# In case there's more than one object in the crop
obj_fg[objmap[slices] != label] = 0
try:
if obj_fg.sum() == 0: # In case an empty array is given
texture_statistics = np.full((4, 13), np.nan, dtype=np.float64)
else:
# Pad object with zero if its dimensions are smaller than the scale
if enhance:
# contrast stretch to normalized range
# this can improve texture detail, but can
# add bias when the variance of the original range is small
obj_fg = exposure.rescale_intensity(
obj_fg, in_range="image", out_range=(0.0, 1.0)
)
texture_statistics = mh.features.haralick(
MeasureTexture._quantize_arr(arr=obj_fg,
quant_lvl=quant_lvl),
distance=scale,
ignore_zeros=True,
return_mean=False,
)
except KeyboardInterrupt:
raise KeyboardInterrupt
except Exception as e:
# 4 for each direction, 13 for each texture feature
if warn:
warnings.warn(
f"Error in computing Haralick features for object {label}: {e}"
)
texture_statistics = np.full((4, 13), np.nan, dtype=np.float64)
deg_meas[idx, :] = texture_statistics.T.ravel()
avg_meas = np.empty(
shape=(
image.num_objects,
len(avg_measurement_names),
),
dtype=np.float64,
)
# step through each feature and avg across degrees
for avg_col_idx, deg_start_idx in enumerate(range(0, deg_meas.shape[1], 4)):
avg_meas[:, avg_col_idx] = np.average(
deg_meas[:, deg_start_idx: deg_start_idx + 4], axis=1
)
meas = pd.DataFrame(np.hstack([deg_meas, avg_meas]), columns=measurement_names)
meas.insert(loc=0, column=OBJECT.LABEL, value=image.objects.labels2series())
return meas
@staticmethod
def _quantize_arr(arr: np.ndarray, quant_lvl) -> np.ndarray:
"""quantizes a normalized array to specific levels"""
if arr.min() < 0 or arr.max() > 1:
raise ValueError("Array is not normalized")
quant_arr = np.floor(arr * quant_lvl)
# handle edge case where a value was 1.0
quant_arr = np.clip(quant_arr, a_min=0, a_max=quant_lvl - 1)
return quant_arr.astype(np.uint8)
MeasureTexture.__doc__ = TEXTURE.append_rst_to_doc(MeasureTexture)