Source code for phenotypic.abc_._measure_features

from __future__ import annotations

import abc
from typing import TYPE_CHECKING

from typing_extensions import Callable

if TYPE_CHECKING:
    from phenotypic import Image

import numpy as np
from numpy.typing import ArrayLike
import pandas as pd
from pandas.api.types import is_scalar
import scipy
import warnings
from functools import partial, wraps

from ._base_operation import BaseOperation
from phenotypic.tools.exceptions_ import OperationFailedError
from phenotypic.tools.funcs_ import validate_measure_integrity
from phenotypic.tools.constants_ import OBJECT
from abc import ABC


def catch_warnings_decorator(func):
    """
    A decorator that catches warnings, prepends the method name to the warning message,
    and reraises the warning.
    """

    @wraps(func)
    def wrapper(*args, **kwargs):
        with warnings.catch_warnings(record=True) as recorded_warnings:
            # Call the original function
            warnings.simplefilter("ignore")
            result = func(*args, **kwargs)

            # If any warnings were raised, prepend the method name and reraise
        for warning in recorded_warnings:
            message = f"{func.__name__}: {warning.message}"
            warnings.warn(message, warning.category, stacklevel=2)

        return result

    return wrapper


# <<Interface>>
[docs] class MeasureFeatures(BaseOperation, ABC): """Extract quantitative measurements from detected colony objects in images. MeasureFeatures is the abstract base class for all feature extraction operations in PhenoTypic. Unlike ImageOperation classes that return modified images, MeasureFeatures subclasses extract numerical measurements from detected objects and return pandas DataFrames. **Design Principles:** This class follows a strict pattern where subclasses implement minimal code: 1. **__init__:** Define parameters and configuration for your measurement 2. **_operate(image: Image) -> pd.DataFrame:** Implement your measurement logic 3. Everything else (type validation, metadata handling, exception handling) is handled by the `measure()` method This ensures consistent behavior, robust error handling, and automatic memory profiling across all measurement operations. **How It Works:** Users call the public API method `measure(image, include_meta=False)`, which: 1. Validates input (Image object with detected objects) 2. Extracts operation parameters using introspection 3. Calls `_operate()` with matched parameters 4. Optionally merges image metadata into results 5. Returns a pandas DataFrame with object labels in the first column Subclasses **only** override `_operate()` and `__init__()`. The `measure()` method provides automatic validation, exception handling, and metadata merging. **Accessing Image Data in _operate():** Within your `_operate()` implementation, access image data through accessors (lazy-evaluated, cached): - **image.gray[:]** - Grayscale intensity values (weighted luminance) - **image.enh_gray[:]** - Enhanced grayscale (preprocessed for analysis) - **image.objmask[:]** - Binary mask of detected objects (1 = object, 0 = background) - **image.objmap[:]** - Labeled integer array (label ID per object, 0 = background) - **image.color.Lab[:]** - CIE Lab color space (perceptually uniform) - **image.color.XYZ[:]** - CIE XYZ color space - **image.color.HSV[:]** - HSV color space (hue, saturation, value) - **image.objects** - High-level object interface (iterate with for loop) - **image.num_objects** - Count of detected objects **DataFrame Output Format:** Your `_operate()` method must return a pandas DataFrame with: - First column: OBJECT.LABEL (integer object IDs matching image.objmap[:]) - Subsequent columns: Measurement results (numeric values) - One row per detected object Example structure: .. code-block:: python OBJECT.LABEL | Area | MeanIntensity | StdDev ----------- |------|---------------|-------- 1 | 1024 | 128.5 | 12.3 2 | 956 | 135.2 | 14.1 3 | 1101 | 120.8 | 11.9 **Static Helper Methods:** This class provides 20+ static helper methods for common measurements on labeled objects: - **Statistical:** _calculate_mean(), _calculate_median(), _calculate_stddev(), _calculate_variance(), _calculate_sum(), _calculate_center_of_mass() - **Extrema:** _calculate_minimum(), _calculate_maximum(), _calculate_extrema() - **Quantiles:** _calculate_q1(), _calculate_q3(), _calculate_iqr() - **Advanced:** _calculate_coeff_variation(), _calculate_min_extrema(), _calculate_max_extrema() - **Custom:** _funcmap2objects() (apply arbitrary functions to labeled regions) - **Utility:** _ensure_array() (normalize scalar/array inputs) All helpers accept an `objmap` parameter (labeled integer array). If None, the entire non-zero region is treated as a single object. **Example: Creating a Custom Measurer for Bacterial Colonies** .. dropdown:: Implementing a custom colony measurement class .. code-block:: python from phenotypic.abc_ import MeasureFeatures from phenotypic.tools.constants_ import OBJECT import pandas as pd import numpy as np class MeasureCustom(MeasureFeatures): \"\"\"Measure custom morphology metrics for microbial colonies.\"\"\" def __init__(self, intensity_threshold=100): \"\"\"Initialize with intensity threshold for bright pixels.\"\"\" self.intensity_threshold = intensity_threshold def _operate(self, image): \"\"\"Extract bright region area and mean intensity.\"\"\" gray = image.enh_gray[:] objmap = image.objmap[:] # Identify bright pixels within each object bright_mask = gray > self.intensity_threshold # Count bright pixels per object bright_area = self._calculate_sum( array=bright_mask.astype(int), objmap=objmap ) # Mean intensity of bright pixels bright_intensity = self._funcmap2objects( func=lambda arr: np.mean(arr[arr > self.intensity_threshold]), out_dtype=float, array=gray, objmap=objmap, default=np.nan ) # Create results DataFrame results = pd.DataFrame({ 'BrightArea': bright_area, 'BrightMeanIntensity': bright_intensity, }) results.insert(0, OBJECT.LABEL, image.objects.labels2series()) return results # Usage: from phenotypic import Image image = Image.from_image_path('colony_plate.jpg') # (After detection...) measurer = MeasureCustom(intensity_threshold=150) measurements = measurer.measure(image) # Returns DataFrame **When to Use MeasureFeatures vs ImageOperation:** Use MeasureFeatures when you want to extract **numerical metrics** from objects (returns DataFrame). Use ImageOperation (ImageEnhancer, ImageCorrector, ObjectDetector) when you want to **modify the image** (returns Image). **Microbe Phenotyping Context:** In arrayed microbial growth assays, measurements extract colony phenotypes: morphology (size, shape, compactness), color (pigmentation, growth medium binding), texture (biofilm formation, colony surface roughness), and intensity distribution (density variation, heterogeneity). These measurements feed into genetic and environmental association studies. Attributes: No public attributes. Configuration is passed through __init__() parameters. Examples: .. dropdown:: Basic usage: measure colony area and intensity .. code-block:: python from phenotypic import Image from phenotypic.measure import MeasureSize # Load and detect colonies image = Image.from_image_path('plate_image.jpg') from phenotypic.detect import OtsuDetector detector = OtsuDetector() image = detector.operate(image) # Extract size measurements measurer = MeasureSize() df = measurer.measure(image) print(df) # Output: # OBJECT.LABEL Area IntegratedIntensity # 0 1 1024 128512 # 1 2 956 121232 # 2 3 1101 134232 .. dropdown:: Advanced: extract multiple measurement types with metadata .. code-block:: python from phenotypic.measure import ( MeasureSize, MeasureShape, MeasureColor ) from phenotypic.core import ImagePipeline # Create pipeline combining detectors and measurers pipeline = ImagePipeline( detector=OtsuDetector(), measurers=[ MeasureSize(), MeasureShape(), MeasureColor(include_XYZ=False) ] ) # Measure a single image with metadata results = pipeline.operate(image) # Combine measurements: merge multiple DataFrames by OBJECT.LABEL combined = results[0] for df in results[1:]: combined = combined.merge(df, on=OBJECT.LABEL) """
[docs] @validate_measure_integrity() def measure(self, image: Image, include_meta: bool = False) -> pd.DataFrame: """Execute the measurement operation on a detected-object image. This is the main public API method for extracting measurements. It handles: input validation, parameter extraction via introspection, calling the subclass-specific `_operate()` method, optional metadata merging, and exception handling. **How it works (for users):** 1. Pass your processed Image (with detected objects) to measure() 2. The method calls your subclass's _operate() implementation 3. Results are validated and returned as a pandas DataFrame 4. If include_meta=True, image metadata (filename, grid info) is merged in **How it works (for developers):** When you subclass MeasureFeatures, you only implement _operate(). This measure() method automatically: - Extracts __init__ parameters from your instance (introspection) - Passes matched parameters to _operate() as keyword arguments - Validates the Image has detected objects (objmap) - Wraps exceptions in OperationFailedError with context - Merges grid/object metadata if requested Args: image (Image): A PhenoTypic Image object with detected objects (must have non-empty objmap from a prior detection operation). include_meta (bool, optional): If True, merge image metadata columns (filename, grid position, etc.) into the results DataFrame. Defaults to False. Returns: pd.DataFrame: Measurement results with structure: - First column: OBJECT.LABEL (integer IDs from image.objmap[:]) - Remaining columns: Measurement values (float, int, or string) - One row per detected object If include_meta=True, additional metadata columns are prepended before OBJECT.LABEL (e.g., Filename, GridRow, GridCol). Raises: OperationFailedError: If _operate() raises any exception, it is caught and re-raised as OperationFailedError with details including the original exception type, message, image name, and operation class. This provides consistent error handling across all measurers. Notes: - This method is the main entry point; do not override in subclasses - Subclasses implement _operate() only, not this method - Automatic memory profiling is available via logging configuration - Image must have detected objects (image.objmap should be non-empty) Examples: .. dropdown:: Basic measurement extraction .. code-block:: python from phenotypic import Image from phenotypic.measure import MeasureSize from phenotypic.detect import OtsuDetector # Load and detect image = Image.from_image_path('plate.jpg') image = OtsuDetector().operate(image) # Extract measurements measurer = MeasureSize() df = measurer.measure(image) print(df.head()) .. dropdown:: Include metadata in measurements .. code-block:: python # With image metadata (filename, grid info) df_with_meta = measurer.measure(image, include_meta=True) print(df_with_meta.columns) # Output: ['Filename', 'GridRow', 'GridCol', 'OBJECT.LABEL', # 'Area', 'IntegratedIntensity', ...] """ try: matched_args = self._get_matched_operation_args() meas = self._operate(image, **matched_args) if include_meta: meta = ( image.grid.info(include_metadata=True) if hasattr(image, "grid") else image.objects.info(include_metadata=True) ) meas = meta.merge(meas, on=OBJECT.LABEL) return meas except Exception as e: raise OperationFailedError( operation=self.__class__.__name__, image_name=image.name, err_type=type(e), message=str(e), )
@staticmethod @abc.abstractmethod def _operate(image: Image) -> pd.DataFrame: return pd.DataFrame() @staticmethod def _ensure_array(value) -> np.ndarray: """Ensure value is a numpy array (handle both scalars and arrays uniformly). This utility method normalizes different input types (scalars, lists, tuples, already-array values) into a consistent numpy array format. Useful internally by helper methods to ensure consistent return types and by custom measurers that need uniform handling of computation results. Args: value: Input value (scalar, list, tuple, array, or any array-like). Can be float, int, bool, or numpy-compatible type. Returns: np.ndarray: Converted numpy array. Preserves dtype of input when possible. Scalars become 0-d arrays. Notes: This is primarily used internally by _calculate_*() helper methods to ensure that both single-object results and multi-object arrays are returned in consistent numpy array format. Examples: .. dropdown:: Normalize various input types .. code-block:: python # Scalar to array result = MeasureFeatures._ensure_array(5) print(type(result), result.shape) # Output: <class 'numpy.ndarray'> () # List to array result = MeasureFeatures._ensure_array([1, 2, 3]) print(result, result.shape) # Output: [1 2 3] (3,) # Already array arr = np.array([1.0, 2.0, 3.0]) result = MeasureFeatures._ensure_array(arr) print(np.array_equal(result, arr)) # Output: True """ if is_scalar(value): return np.asarray(value) else: return np.asarray(value) @staticmethod @catch_warnings_decorator def _calculate_center_of_mass(array: np.ndarray, objmap: ArrayLike = None): """Calculate the weighted center of mass for each labeled object. Computes the coordinate position (row, column) of the geometric center of mass for each labeled object. The center of mass is the intensity-weighted average position - objects with higher intensity values contribute more to the center location. Useful for tracking colony center position or detecting eccentricity in colony growth patterns. Args: array (np.ndarray): 2D intensity array (e.g., grayscale image or intensity within object regions). Values are weights; higher intensities shift center toward those pixels. objmap (ArrayLike, optional): 2D labeled integer array with same shape as `array`. Each unique positive integer (1, 2, 3...) identifies an object; 0 is background. If None, all non-zero elements of `array` are treated as a single object. Defaults to None. Returns: np.ndarray: If objmap provided: array of shape (num_objects, 2) with (row, col) coordinates for each object center of mass. If objmap=None: single (row, col) tuple. Coordinates are floating-point (subpixel precision possible). Notes: - Input array values act as weights; zero/near-zero values have minimal influence on center position - Useful for detecting colonies with uneven intensity distribution - Center coordinates may fall outside object pixels (edge effect) - For equal-weighted center, use uniform-intensity array with same shape Examples: .. dropdown:: Find colony centroid positions .. code-block:: python # Measure intensity-weighted centers for stained colonies gray = image.enh_gray[:] # Preprocessed intensity objmap = image.objmap[:] centers = MeasureFeatures._calculate_center_of_mass( array=gray, objmap=objmap ) # Returns: [[42.3, 105.8], [156.2, 89.5], ...] # (row, col) for each object # Detect colonies with off-center intensity (asymmetric growth) for i, (row, col) in enumerate(centers): obj_label = i + 1 # Analyze intensity distribution around center .. dropdown:: Single colony mass center (no objmap) .. code-block:: python # Find center for isolated colony region colony_region = image.enh_gray[50:150, 50:150] center = MeasureFeatures._calculate_center_of_mass(colony_region) # Returns: (42.5, 52.3) - single (row, col) tuple """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.center_of_mass(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_maximum(array: np.ndarray, objmap: ArrayLike = None): """Calculate the maximum pixel value within each labeled object. Finds the brightest (highest intensity) pixel within each object region. Useful for detecting bright spots in colonies (spore centers, pigment accumulation) or evaluating maximum growth intensity. Args: array (np.ndarray): 2D intensity array (e.g., grayscale or color channel). objmap (ArrayLike, optional): 2D labeled integer array. Each positive integer (1, 2, 3...) identifies an object; 0 is background. If None, all non-zero elements of `array` are treated as a single object. Defaults to None. Returns: np.ndarray: 1D array of maximum values, one per object. If objmap=None, returns scalar (0-d array). Notes: - Returns NaN for objects with all-zero pixels - Sensitive to image noise; consider preprocessing with smoothing first - Useful with binary masks to count bright-region pixels Examples: .. dropdown:: Find brightest pixels in colonies .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] max_intensity = MeasureFeatures._calculate_maximum(gray, objmap) # Returns: [245, 238, 241, ...] brightness per object """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.maximum(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_mean(array: np.ndarray, objmap: ArrayLike = None): """Calculate the mean (average) intensity for each labeled object. Computes the arithmetic mean of all pixels within each object region. Primary measure of overall colony brightness/density. In microbial phenotyping, mean intensity correlates with growth density, pigmentation, and biofilm formation. Args: array (np.ndarray): 2D intensity array (grayscale, color channel, etc.). objmap (ArrayLike, optional): 2D labeled integer array (same shape as array). Positive integers identify objects; 0 is background. If None, all non-zero array elements are treated as single object. Defaults to None. Returns: np.ndarray: 1D array of mean values (float), one per object. If objmap=None, returns scalar (0-d array). Returns NaN for empty objects. Notes: - Most commonly used intensity statistic - Not robust to outliers; use median for outlier-resistant average - Zero/background pixels excluded from calculation Examples: .. dropdown:: Compare colony growth intensity .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] mean_intensities = MeasureFeatures._calculate_mean(gray, objmap) # Returns: [128.5, 142.3, 135.8, ...] avg brightness per colony # High values = dense/pigmented growth """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.mean(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_median(array: np.ndarray, objmap: ArrayLike = None): """Calculate the median intensity for each labeled object. Finds the middle value (50th percentile) of all pixels in each object. More robust to outliers than mean - useful when colonies contain bright specks, debris, or uneven staining. Also useful for binary masks (median of 0/1 gives fraction threshold). Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers = objects, 0 = background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: np.ndarray: 1D array of median values, one per object. If objmap=None, returns scalar. Returns NaN for empty objects. Notes: - Robust to outliers and extreme values (unlike mean) - Slower computation than mean (requires sorting) - Good for colonies with speckling or uneven stain distribution Examples: .. dropdown:: Robust growth intensity measurement .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] # For colonies with bright debris or speckles median_int = MeasureFeatures._calculate_median(gray, objmap) # More stable than mean for noisy images """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.median(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_minimum(array: np.ndarray, objmap: ArrayLike = None): """Calculate the minimum pixel value within each labeled object. Finds the darkest (lowest intensity) pixel within each object region. Useful for detecting thin regions, detecting edge pixels, or checking for completely black (dead) regions within colonies. Args: array (np.ndarray): 2D intensity array (grayscale or channel). objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers identify objects; 0 is background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: np.ndarray: 1D array of minimum values, one per object. If objmap=None, returns scalar (0-d array). Notes: - Returns NaN for objects with all-zero pixels - Sensitive to shadows, indentations, or dark contaminants - Combined with maximum, reveals intensity range within colony Examples: .. dropdown:: Detect dark regions in colonies .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] min_int = MeasureFeatures._calculate_minimum(gray, objmap) # Identifies colonies with dark cores (aging or death) """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.minimum(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_stddev(array: np.ndarray, objmap: ArrayLike = None): """Calculate the standard deviation of pixel values within each object. Measures intensity variation within each colony region. High stddev indicates uneven growth, texture, or biofilm formation. Low stddev indicates uniform, homogeneous colonies. Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers = objects, 0 = background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: np.ndarray: 1D array of standard deviations, one per object. If objmap=None, returns scalar. Notes: - Zero for perfectly uniform colonies - High values suggest rough surfaces, biofilms, or sectoring - Useful for phenotyping mixed cultures or detecting morphological mutants with rougher colony surfaces Examples: .. dropdown:: Detect rough/textured colonies .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] stddev = MeasureFeatures._calculate_stddev(gray, objmap) # High stddev = rough/textured colonies # Low stddev = smooth/uniform growth rough_colonies = np.where(stddev > 20)[0] """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.standard_deviation(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_sum(array: np.ndarray, objmap: ArrayLike = None): """Calculate the sum of all pixel values within each labeled object. Integrates intensity across entire object region. For binary masks (array=objmask), this equals the number of pixels (area). For intensity arrays, gives total accumulated intensity (used for "integrated intensity" in colony phenotyping). Proportional to both colony size and density. Args: array (np.ndarray): 2D value array (intensity, binary mask, etc.). objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers identify objects; 0 is background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: np.ndarray: 1D array of sums, one per object. If objmap=None, returns scalar (0-d array). Notes: - On binary mask (0/1): sum = area (pixel count) - On grayscale: sum = total brightness (size * mean intensity) - Sensitive to both object size and intensity - No division by area (unlike mean), so larger objects always sum higher Examples: .. dropdown:: Calculate colony area and integrated intensity .. code-block:: python # Area = sum of binary mask area = MeasureFeatures._calculate_sum(image.objmask[:], image.objmap[:]) # Returns pixel count per colony # Integrated intensity = sum of grayscale intensity = MeasureFeatures._calculate_sum(image.gray[:], image.objmap[:]) # Returns total brightness per colony """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.sum_labels(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_variance(array: np.ndarray, objmap: ArrayLike = None): """Calculate the variance (stddev squared) of pixel values in each object. Measures intensity spread within each colony. Variance = stddev^2. Used in statistical analysis and texture characterization. Higher variance indicates more heterogeneous growth or surface texture. Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers = objects, 0 = background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: np.ndarray: 1D array of variances, one per object. If objmap=None, returns scalar. Notes: - Variance = stddev^2; variance is useful for statistical tests - Units are (intensity)^2, making interpretation less intuitive - Preferred over stddev in statistical/ML contexts - Zero for perfectly uniform colonies Examples: .. dropdown:: Analyze colony texture variability .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] variance = MeasureFeatures._calculate_variance(gray, objmap) stddev = np.sqrt(variance) # Convert back to original units """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None return MeasureFeatures._ensure_array( scipy.ndimage.variance(array, objmap, index=indexes) ) @staticmethod @catch_warnings_decorator def _calculate_coeff_variation(array: np.ndarray, objmap: ArrayLike = None): """Calculate the unbiased coefficient of variation (CV) for each object. The coefficient of variation measures relative intensity variability, normalized by the mean. CV = stddev / mean * 100%. Unbiased estimator accounts for sample size. Useful for comparing texture across colonies of different sizes or intensities. Lower CV = more uniform/homogeneous; higher CV = more heterogeneous/textured (biofilm, sectoring). Args: array (np.ndarray): 2D intensity array (grayscale or channel). objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers identify objects; 0 is background. If None, calculation returns NaN (requires object counts for unbiasing). Defaults to None. Returns: np.ndarray: 1D array of unbiased CV values (dimensionless ratio), one per object. If objmap=None, returns NaN. Values typically range 0.0 to 1.0+ (can exceed 1 for highly variable colonies). Notes: - Unbiased estimator: CV_unbiased = (1 + 1/n) * CV_biased - Dimensionless (unlike stddev), allows cross-sample comparison - Requires labeled objects (objmap); cannot compute for single-object mode - Returns NaN if objmap=None (need object counts for unbiasing) - Colonies with CV < 0.1 are very uniform - Colonies with CV > 0.3 show significant texture/heterogeneity References: - https://en.wikipedia.org/wiki/Coefficient_of_variation - Unbiased estimator: (1 + 1/n) * biased_cv Examples: .. dropdown:: Compare colony growth uniformity .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] cv = MeasureFeatures._calculate_coeff_variation(gray, objmap) # Returns: [0.08, 0.15, 0.32, ...] relative variation # CV < 0.1: smooth, uniform colonies # CV > 0.2: rough, textured growth (biofilm) textured = np.where(cv > 0.2)[0] # Find biofilm-like colonies """ if objmap is not None: unique_labels, unique_counts = np.unique(objmap, return_counts=True) unique_counts = unique_counts[unique_labels != 0] biased_cv = MeasureFeatures._calculate_stddev( array, objmap ) / MeasureFeatures._calculate_mean(array, objmap) result = (1 + (1 / unique_counts)) * biased_cv else: # For the case when objmap is None, we can't calculate the coefficient of variation # because we need the counts of each label result = np.nan return MeasureFeatures._ensure_array(result) @staticmethod def _calculate_extrema(array: np.ndarray, objmap: ArrayLike = None): """Internal helper: calculate min/max values and positions for labeled objects. Returns tuple of (min_values, max_values, min_positions, max_positions). Mostly used internally by _calculate_min_extrema() and _calculate_max_extrema(). Use those public methods instead. Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers identify objects; 0 is background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: tuple: (min_vals, max_vals, min_pos, max_pos) where each is numpy array of shape (num_objects, 2) with (row, col) coordinates for extrema or (num_objects,) for values. """ if objmap is not None: indexes = np.unique(objmap) indexes = indexes[indexes != 0] else: indexes = None min_extrema, max_extrema, min_pos, max_pos = MeasureFeatures._ensure_array( scipy.ndimage.extrema(array, objmap, index=indexes) ) return ( MeasureFeatures._ensure_array(min_extrema), MeasureFeatures._ensure_array(max_extrema), MeasureFeatures._ensure_array(min_pos), MeasureFeatures._ensure_array(max_pos), ) @staticmethod @catch_warnings_decorator def _calculate_min_extrema(array: np.ndarray, objmap: ArrayLike = None): """Find the darkest pixel and its (row, col) location in each object. Identifies the minimum intensity value and the spatial coordinate where that minimum occurs. Useful for detecting dark spots, shadows, or low- intensity regions within colonies. Combined with max_extrema, reveals intensity range and spatial distribution of brightness. Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers = objects, 0 = background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: tuple: (min_values, min_positions) where: - min_values: 1D array of minimum intensity per object (float) - min_positions: Array of (row, col) coordinates per object (shape depends on objmap) Notes: - Positions are pixel coordinates (row, col) - Useful for detecting dark colonies or dead regions - May return multiple positions if minimum occurs multiple times Examples: .. dropdown:: Locate dark regions in colonies .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] min_vals, min_pos = MeasureFeatures._calculate_min_extrema( gray, objmap ) # min_vals: [50, 45, 52, ...] darkest pixel per colony # min_pos: [(45, 103), (156, 87), ...] locations """ min_extrema, _, min_pos, _ = MeasureFeatures._calculate_extrema(array, objmap) return min_extrema, min_pos @staticmethod @catch_warnings_decorator def _calculate_max_extrema(array: np.ndarray, objmap: ArrayLike = None): """Find the brightest pixel and its (row, col) location in each object. Identifies the maximum intensity value and the spatial coordinate where that maximum occurs. Useful for detecting bright spores, pigment centers, or regions of highest colony density. Combined with min_extrema, reveals intensity range and spatial variation within colonies. Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array (same shape). Positive integers identify objects; 0 is background. If None, all non-zero array elements treated as single object. Defaults to None. Returns: tuple: (max_values, max_positions) where: - max_values: 1D array of maximum intensity per object (float) - max_positions: Array of (row, col) coordinates per object Notes: - Positions are pixel coordinates (row, col) - Useful for locating spore centers, pigmentation hotspots - Bright colonies have high max values; dim colonies have low max values - Multiple positions possible if maximum occurs multiple times Examples: .. dropdown:: Find brightest spots in colonies .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] max_vals, max_pos = MeasureFeatures._calculate_max_extrema( gray, objmap ) # max_vals: [245, 238, 241, ...] brightest pixel per colony # max_pos: [(42, 105), (155, 89), ...] center locations """ _, max_extreme, _, max_pos = MeasureFeatures._calculate_extrema(array, objmap) return max_extreme, max_pos @staticmethod def _funcmap2objects( func: Callable, out_dtype: np.dtype, array: np.ndarray, objmap: ArrayLike = None, default: int | float | np.nan = np.nan, pass_positions: bool = False, ): """Apply a custom function to each labeled region in an array (advanced helper). Powerful utility for computing arbitrary measurements on labeled objects. Applies your custom function to the pixels within each object and returns aggregated results. Useful for custom statistics not covered by built-in helpers (percentiles, custom transformations, etc.). Internally uses scipy.ndimage.labeled_comprehension, which iterates over unique object labels and extracts the corresponding pixels, passes them to your function, and collects results. Args: func (Callable): Function to apply to each object's pixels. Signature: `func(pixel_array) -> scalar`. If pass_positions=True: `func(pixel_array, pixel_positions) -> scalar`. Examples: np.mean, np.percentile (use partial for arguments). out_dtype (np.dtype): Output array data type (float64, int32, etc.). Affects precision and memory usage. array (np.ndarray): 2D input array (intensity, counts, etc.). objmap (ArrayLike, optional): 2D labeled integer array (same shape as array). Positive integers identify objects; 0 is background. If None, all non-zero array elements are treated as a single object. Defaults to None. default (int | float | np.nan, optional): Value to assign to labels not found in objmap (e.g., skipped background). Defaults to np.nan. pass_positions (bool, optional): If True, pass (pixel_array, positions) to func instead of just pixel_array. Positions are (row, col) tuples of non-zero pixels. Defaults to False. Returns: np.ndarray: 1D array of results (one per object). Output dtype matches out_dtype parameter. Returns scalar if single object. Notes: - Powerful for custom measurements beyond standard statistics - Can be slow for large images; vectorize if possible - Use functools.partial() to fix function parameters - Positions only available if pass_positions=True (slower) - func() receives actual pixel values, not labeled indices Examples: .. dropdown:: Custom measurement: find 90th percentile intensity .. code-block:: python from functools import partial gray = image.enh_gray[:] objmap = image.objmap[:] # Define custom function q90 = partial(np.percentile, q=90) results = MeasureFeatures._funcmap2objects( func=q90, out_dtype=float, array=gray, objmap=objmap, default=np.nan ) # Returns: [200, 195, 210, ...] 90th percentile per colony .. dropdown:: Custom: count pixels above threshold .. code-block:: python # Count bright pixels within each colony def count_bright(pixels): return np.sum(pixels > 200) bright_pixels = MeasureFeatures._funcmap2objects( func=count_bright, out_dtype=int, array=gray, objmap=objmap, default=0 ) # Returns: [45, 32, 58, ...] bright pixel count per colony .. dropdown:: Advanced: use positions for spatial analysis .. code-block:: python # Find distance from top-left for brightest pixel def distance_to_topleft(pixels, positions): if len(pixels) == 0: return np.nan brightest_idx = np.argmax(pixels) row, col = positions[brightest_idx] return np.sqrt(row**2 + col**2) distances = MeasureFeatures._funcmap2objects( func=distance_to_topleft, out_dtype=float, array=gray, objmap=objmap, pass_positions=True, default=np.nan ) """ if objmap is not None: index = np.unique(objmap) index = index[index != 0] else: index = None return MeasureFeatures._ensure_array( scipy.ndimage.labeled_comprehension( input=array, labels=objmap, index=index, func=func, out_dtype=out_dtype, pass_positions=pass_positions, default=default, ), ) @staticmethod @catch_warnings_decorator def _calculate_q1(array, objmap=None, method: str = "linear"): """Calculate the first quartile (Q1, 25th percentile) for each object. Finds the value below which 25% of pixels fall. Less sensitive to extremes than mean/median, useful for characterizing lower intensity tail of distribution (sparse/background regions within colonies). Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array. Defaults to None. method (str, optional): Quantile interpolation method ('linear', 'lower', 'higher', 'nearest', 'midpoint'). See numpy.quantile docs. Defaults to 'linear'. Returns: np.ndarray: 1D array of Q1 values, one per object. Notes: - Q1, Q2 (median), Q3 together form quartile summary - Low Q1 suggests colonies with sparse pixel coverage - Used to compute IQR (Q3 - Q1) """ find_q1 = partial(np.quantile, q=0.25, method=method) q1 = MeasureFeatures._funcmap2objects( func=find_q1, out_dtype=array.dtype, array=array, objmap=objmap, default=np.nan, pass_positions=False, ) return MeasureFeatures._ensure_array(q1) @staticmethod @catch_warnings_decorator def _calculate_q3(array, objmap=None, method: str = "linear"): """Calculate the third quartile (Q3, 75th percentile) for each object. Finds the value below which 75% of pixels fall. Characterizes upper intensity distribution (bright regions, dense growth areas). Together with Q1, defines the interquartile range (IQR). Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array. Defaults to None. method (str, optional): Quantile interpolation method ('linear', 'lower', 'higher', 'nearest', 'midpoint'). Defaults to 'linear'. Returns: np.ndarray: 1D array of Q3 values, one per object. Notes: - High Q3 indicates colonies with bright, densely-packed pixels - IQR = Q3 - Q1 (robust measure of spread) - Used with Q1 to identify outlier intensities """ find_q3 = partial(np.quantile, q=0.75, method=method) q3 = MeasureFeatures._funcmap2objects( func=find_q3, out_dtype=array.dtype, array=array, objmap=objmap, default=np.nan, pass_positions=False, ) return MeasureFeatures._ensure_array(q3) @staticmethod @catch_warnings_decorator def _calculate_iqr( array, objmap=None, method: str = "linear", nan_policy: str = "omit" ): """Calculate the interquartile range (Q3 - Q1) for each object. Measures the spread of the middle 50% of intensities. Robust to outliers (unlike range or variance). Used for detecting intensity outliers and characterizing colony texture uniformity. Low IQR = narrow intensity range (uniform); high IQR = broad range (variable). Args: array (np.ndarray): 2D intensity array. objmap (ArrayLike, optional): 2D labeled integer array. Defaults to None. method (str, optional): Quantile interpolation method. Defaults to 'linear'. nan_policy (str, optional): How to handle NaN values ('omit', 'propagate'). Defaults to 'omit'. Returns: np.ndarray: 1D array of IQR values, one per object. Notes: - IQR = Q3 - Q1; always non-negative - Robust measure; unaffected by extreme outliers - Colonies with IQR < 30: uniform intensity distribution - Colonies with IQR > 100: highly variable (texture, biofilm) - Preferred over stddev/variance for non-normal distributions Examples: .. dropdown:: Compare colony texture via IQR .. code-block:: python gray = image.enh_gray[:] objmap = image.objmap[:] iqr = MeasureFeatures._calculate_iqr(gray, objmap) # IQR < 30: smooth, uniform colonies # IQR > 100: rough, textured growth """ find_iqr = partial( scipy.stats.iqr, axis=None, nan_policy=nan_policy, interpolation=method ) return MeasureFeatures._ensure_array( MeasureFeatures._funcmap2objects( func=find_iqr, out_dtype=array.dtype, array=array, objmap=objmap, default=np.nan, pass_positions=False, ), )