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`.
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.
"""
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__