Source code for phenotypic.measure._measure_texture

from __future__ import annotations

import functools
import warnings
from typing import ClassVar, List, Literal, TYPE_CHECKING

import numpy as np
import pandas as pd
from pydantic import field_validator
from skimage import exposure

from phenotypic.abc_ import MeasureFeatures
from phenotypic.schema import OBJECT
from phenotypic.schema import TEXTURE

if TYPE_CHECKING:
    from phenotypic._core._image import Image

# Suppress mahotas' module-load SyntaxWarning before importing it.
warnings.filterwarnings('ignore', category=SyntaxWarning, module='mahotas')

import mahotas as mh  # noqa: E402 - must follow the filterwarnings call above


[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_infoclass: ClassVar[type] = TEXTURE scale: List[int] = [5] quant_lvl: Literal[8, 16, 32, 64] = 32 enhance: bool = False warn: bool = False @field_validator("scale", mode="before") @classmethod def _coerce_scale_to_list(cls, scale: int | List[int]) -> List[int]: """Normalize a bare integer ``scale`` to a single-element list. The legacy constructor stored ``scale`` as a list regardless of whether the caller passed an ``int`` or a ``list[int]`` (the ``_operate`` body indexes ``self.scale[0]`` / ``self.scale[1:]``). This validator reproduces that coercion so both call styles keep working while the declared field type stays an honest list. """ if not hasattr(scale, "__getitem__"): # coerce iterable input return [scale] return scale 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)