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,
    threshold_otsu,
    threshold_isodata,
    threshold_li,
    threshold_mean,
    threshold_minimum,
    threshold_triangle,
    threshold_yen,
)
from skimage.segmentation import clear_border

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`. Args: low: Lower threshold controlling expansion sensitivity. 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'``. Lower values include more faint colony pixels but increase false positives. Typical tuning: start with ``'mean'`` and switch to a numeric value if automatic methods are too aggressive or too conservative. high: Upper threshold seeding strong colony regions. Same format as *low*. Default ``'otsu'``. Must be >= *low* after computation. Higher values restrict seeds to the brightest colony pixels, producing fewer but higher-confidence detections. ignore_zeros: If True (default), exclude zero-intensity pixels from automatic threshold computation. Enable for plates with black borders or masked regions. ignore_borders: If True (default), remove colonies touching image edges via ``clear_border()``. Recommended for grid-based colony counting. 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). ``objmap`` is not modified. Raises: ValueError: If the computed high threshold is less than the computed low threshold, or if an unrecognised threshold method name is provided. 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. 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. """ def __init__( self, low: Union[str, float] = "mean", high: Union[str, float] = "otsu", ignore_zeros: bool = False, ignore_borders: bool = True, ): self.low = low self.high = high self.ignore_zeros = ignore_zeros self.ignore_borders = ignore_borders 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. """ if isinstance(threshold_spec, (int, float)): # Manual threshold value return float(threshold_spec) # Map method names to scikit-image functions method_map = { "otsu": threshold_otsu, "isodata": threshold_isodata, "li": threshold_li, "mean": threshold_mean, "minimum": threshold_minimum, "triangle": threshold_triangle, "yen": threshold_yen, } # Methods that accept nbins parameter # Note: li does NOT accept nbins despite being histogram-based methods_with_nbins = {"otsu", "isodata", "triangle", "yen"} method_name = threshold_spec.lower() if method_name not in method_map: raise ValueError( f"Unknown threshold method '{threshold_spec}'. " f"Valid methods: {list(method_map.keys())}" ) threshold_func = method_map[method_name] # Compute threshold with or without nbins depending on method if method_name in methods_with_nbins: nbins = 2 ** int(bit_depth) return float(threshold_func(data, nbins=nbins)) else: # Methods like 'mean' and 'minimum' don't accept nbins return float(threshold_func(data))
# Set the docstring so that it appears in the sphinx documentation HysteresisDetector.apply.__doc__ = HysteresisDetector._operate.__doc__