Source code for phenotypic.detect._mad_hysteresis_detector

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated

import numpy as np
from pydantic import Field

if TYPE_CHECKING:
    from phenotypic._core._image import Image

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

from ..abc_ import ThresholdDetector
from ..sdk_.typing_ import TuneSpec


[docs] class MadHysteresisDetector(ThresholdDetector): """Detect colonies by MAD-based noise estimation and hysteresis thresholding. Estimate the background noise floor using the Median Absolute Deviation (MAD), then apply hysteresis thresholding with thresholds set as multiples of the estimated noise standard deviation. Designed for filter response maps (CED, Hessian, LoG, Frangi) where the noise structure is approximately Gaussian and histogram-based methods produce unstable thresholds. For a full comparison see :doc:`/explanation/detection_strategies_compared`. Best For: * Filter response maps (CED, Hessian, LoG, Frangi) where the noise floor is approximately Gaussian. * Low-contrast colonies where signal is faint relative to background texture and MAD provides a stable noise estimate. * Standardised pipelines where multiplier-based thresholds generalise across plates with varying colony density. Consider Also: * :class:`HysteresisDetector` when thresholds should be derived from the intensity histogram rather than noise statistics. * :class:`OtsuDetector` when the image is a raw intensity plate with a bimodal histogram. * :class:`ChanVeseDetector` when colonies have diffuse edges and region-based segmentation is more appropriate. Args: k_high: High-threshold multiplier setting the seed threshold at ``k_high * sigma_noise`` standard deviations above the noise floor. There is no universal best value -- it is tuned to the scene. Raising *k_high* accepts only the most prominent colonies as seeds (more conservative); lowering it seeds dimmer structures. Typical range: 3.0--8.0. Default: 5.0. On noisy or strongly textured plates raise toward 6--8 so agar granularity is not seeded; on faint enhancer outputs (e.g. Frangi on weak hyphae) lower toward 3--4 so genuine signal seeds at all. k_low: Low-threshold multiplier setting the growth threshold at ``k_low * sigma_noise``; pixels above this join a seed if connected to it. Must be strictly less than *k_high*. The ratio *k_high* / *k_low* trades selectivity against coverage: a wider gap grows seeds further into faint margins. Typical range: 1.5--4.0. Default: 2.5. Lower toward 1.5--2.0 to recover the faint edges of filamentous or low-contrast colonies; raise it on clean high-contrast maps. min_size: Minimum colony area in pixels; connected components smaller than this are removed as noise, debris, or condensation. Raising it silences small artefacts at the cost of missing genuine micro-colonies; lowering it recovers faint colonies but raises false positives. Scale with resolution -- smaller for dense 384/1536 formats, larger for high-dpi scans. Default: 20. connectivity: Pixel connectivity for labelling connected components. ``1`` (4-connected) keeps diagonally touching objects separate; ``2`` (8-connected) bridges diagonal gaps in thin or branching structures into fewer, larger regions. Default: 2. ignore_zeros: Exclude zero-intensity pixels from MAD computation. Enable for plates with black borders or masked regions. Default: False. ignore_borders: Remove colonies touching image edges via ``clear_border()``. Recommended for grid-based colony counting to eliminate partial colonies at plate boundaries. Default: True. Returns: Image: Input image with ``objmask`` set to binary mask and ``objmap`` set to labeled connected components. Raises: ValueError: If ``k_low`` >= ``k_high``. 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. """ # Search windows are kept non-overlapping (k_low high < k_high low) so the # tuner — which samples each field independently — can never produce a # k_low >= k_high pair, which ``_operate`` rejects. The windows still sit # inside each field's documented typical range (k_low 1.5--4.0, k_high # 3.0--8.0); the fact-check PR may revisit the exact split. k_high: Annotated[float, TuneSpec(4.0, 8.0)] = 5.0 k_low: Annotated[float, TuneSpec(1.5, 3.5)] = 2.5 min_size: Annotated[int, TuneSpec(10, 500, log=True)] = 20 connectivity: Annotated[int, TuneSpec(categories=[1, 2])] = Field(2, ge=1, le=2) ignore_zeros: bool = False ignore_borders: bool = True def _operate(self, image: Image) -> Image: """Apply MAD-based hysteresis thresholding to detect colonies. Estimates background noise via the Median Absolute Deviation, computes high and low thresholds as multiples of the estimated noise standard deviation, then applies hysteresis thresholding followed by small-object removal and optional border clearing. Args: image: The input image object. Must have ``detect_mat`` attribute (detection matrix, typically a filter response map). Returns: Image: The input image with ``objmask`` attribute set to the binary mask (True = detected colony pixel, False = background). Raises: ValueError: If k_low >= k_high. """ if self.k_low >= self.k_high: raise ValueError( f"k_low ({self.k_low}) must be less than k_high ({self.k_high})" ) response = np.clip(image.detect_mat[:].astype(np.float64), 0, None) # Select data for MAD computation if self.ignore_zeros: data = response[response != 0] else: data = response.ravel() # Handle empty data if data.size == 0: image.objmask = np.zeros(response.shape, dtype=bool) return image # Compute MAD-based noise estimate median_val = np.median(data) mad = np.median(np.abs(data - median_val)) sigma_noise = 1.4826 * mad # Handle uniform image (zero noise) if sigma_noise == 0.0: image.objmask = np.zeros(response.shape, dtype=bool) return image # Compute thresholds t_high = self.k_high * sigma_noise t_low = self.k_low * sigma_noise # Hysteresis thresholding mask = apply_hysteresis_threshold(response, t_low, t_high) mask = mask.astype(bool) # Remove small objects mask = remove_small_objects(mask, min_size=self.min_size, connectivity=self.connectivity) # Optionally clear borders if self.ignore_borders: mask = clear_border(mask) image.objmask = mask return image
# Set the docstring so that it appears in the sphinx documentation MadHysteresisDetector.apply.__doc__ = MadHysteresisDetector._operate.__doc__