Source code for phenotypic.detect._hysteresis_detector

from __future__ import annotations

from typing import TYPE_CHECKING, Union

import numpy as np
if TYPE_CHECKING:
    from phenotypic._core._image import Image

from skimage.filters import apply_hysteresis_threshold
from skimage.segmentation import clear_border

from phenotypic.detect._thresholding_registry import ThresholdingRegistry
from ..abc_ import ThresholdDetector


[docs] class HysteresisDetector(ThresholdDetector): """Detect colonies by dual-threshold hysteresis, bridging bright cores to faint edges. Seed strong colony regions that exceed the high threshold and expand each seed via pixel connectivity to include neighbouring pixels above the low threshold. This two-pass approach captures colonies whose intensity varies from bright centres to faint margins -- a common pattern across growth stages and under uneven illumination. For a full comparison see :doc:`/explanation/detection_strategies_compared`. Best For: * Plates where colony brightness varies (e.g., young versus mature growth, or centre-to-edge intensity gradients within a colony). * Noisy agar backgrounds where isolated noise pixels sit above a single threshold but lack connectivity to true colony regions. * Moderate vignetting or lighting gradients that cause a single global threshold to over- or under-segment parts of the plate. * Mixed-species plates where different organisms produce colonies of different intensities on the same agar. Consider Also: * :class:`OtsuDetector` when colony and background peaks are balanced and a single threshold suffices. * :class:`WatershedDetector` when touching colonies must be split into individually labelled regions. * :class:`CannyDetector` when colonies are best delineated by edge contrast rather than intensity. Args: low: Lower threshold controlling expansion sensitivity. There is no universal best value -- it is a user choice. Accepts a method name (``'otsu'``, ``'triangle'``, ``'li'``, ``'yen'``, ``'isodata'``, ``'mean'``, ``'minimum'``) for automatic computation, or a float for a manual value (0--255 for 8-bit, 0--65535 for 16-bit). Default ``'mean'``. Lowering *low* grows each seed outward to capture more faint marginal pixels but risks bridging colonies into noise; raising it tightens each colony toward its bright core. Start with ``'mean'`` and adjust by observing whether faint colony edges are kept or lost. Dense plates and filamentous fungi (thin faint margins) often need a lower *low*; noisy agar needs it raised. high: Upper threshold seeding strong colony regions. Same format and user-choice character as *low*. Default ``'otsu'``. Must resolve to a value >= *low*. Raising *high* restricts seeds to the brightest colony centres (fewer, higher-confidence detections); lowering it admits more, dimmer seeds. Raise *high* on noisy or textured plates so agar granularity is not seeded; lower it when genuine faint colonies fail to seed at all. ignore_zeros: If True, exclude zero-intensity pixels from automatic threshold computation. Enable for plates with black borders or masked regions. Default: False. ignore_borders: If True (default), remove colonies touching image edges via ``clear_border()``. Recommended for grid-based colony counting; disable when peripheral colonies must be retained. Returns: Image: Input image with ``objmask`` set to a binary colony mask where True pixels are colony foreground (including faint pixels connected to strong seed regions). Assigning ``objmask`` rebuilds ``objmap`` from the binary mask via the accessor. Raises: ValueError: If the computed high threshold is less than the computed low threshold, or if an unrecognised threshold method name is provided. See Also: :doc:`/tutorials/notebooks/02_detecting_colonies` Step-by-step tutorial for basic colony detection. :doc:`/how_to/notebooks/choose_detection_algorithm` Guide for selecting the right detector for your plate images. :doc:`/explanation/detection_strategies_compared` In-depth comparison of all detection strategies. """ low: Union[str, float] = "mean" high: Union[str, float] = "otsu" ignore_zeros: bool = False ignore_borders: bool = True def _operate(self, image: Image) -> Image: """Apply hysteresis thresholding to detect colonies. Computes low and high thresholds (automatically from method names or from manual float values), validates that high >= low, then applies apply_hysteresis_threshold() to identify regions that exceed the high threshold OR exceed the low threshold while connected to regions above the high threshold. If low == high, performs simple threshold segmentation instead. Args: image: The input image object. Must have ``detect_mat`` attribute (detection matrix for processing). Uses ``bit_depth`` to determine nbins for automatic threshold computation. Returns: Image: The input image with ``objmask`` attribute set to the binary mask (True = colony or connected weak region, False = background). Raises: ValueError: If high < low after computation, or if threshold_spec contains an invalid method name. """ enh_matrix = image.detect_mat[:] # Prepare data for threshold computation (exclude zeros if requested) if self.ignore_zeros: thresh_data = enh_matrix[enh_matrix != 0] else: thresh_data = enh_matrix # Compute low threshold low_val = self._compute_threshold(self.low, thresh_data, image.bit_depth) # Compute high threshold high_val = self._compute_threshold( self.high, thresh_data, image.bit_depth ) # Validate threshold order if high_val < low_val: raise ValueError( f"High threshold ({high_val:.2f}) must be >= low threshold " f"({low_val:.2f})" ) # Apply thresholding (fallback to simple threshold if low == high) if high_val == low_val: # Simple threshold segmentation when thresholds are identical mask = enh_matrix >= low_val else: # Hysteresis thresholding with dual thresholds mask = apply_hysteresis_threshold(enh_matrix, low_val, high_val) # Ensure mask is boolean (apply_hysteresis_threshold returns int64) mask = mask.astype(bool) # Optionally clear borders mask = clear_border(mask) if self.ignore_borders else mask # Set objmask image.objmask = mask return image @staticmethod def _compute_threshold( threshold_spec: Union[str, float], data: np.ndarray, bit_depth: int, ) -> float: """Compute threshold value from specification. Args: threshold_spec: Either method name (str) or manual value (float). If string, must be one of: 'otsu', 'isodata', 'li', 'mean', 'minimum', 'triangle', 'yen'. data: Image data (usually with zeros excluded) for automatic threshold computation. bit_depth: Image bit depth (8 or 16) to determine nbins. Returns: Computed threshold value as float. Raises: ValueError: If threshold_spec is a string but not a valid method name. """ return ThresholdingRegistry.threshold_value( threshold_spec, data, bit_depth=bit_depth, allowed_methods=ThresholdingRegistry.SCALAR_METHODS, )
# Set the docstring so that it appears in the sphinx documentation HysteresisDetector.apply.__doc__ = HysteresisDetector._operate.__doc__