Source code for phenotypic.detect._canny_detector

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, Any, cast

if TYPE_CHECKING:
    from phenotypic._core._grid_image import GridImage
    from phenotypic._core._image import Image

from pydantic import Field, model_validator
from skimage import feature, morphology
from scipy import ndimage

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


[docs] class CannyDetector(ThresholdDetector): """Detect colonies by tracing edges and labelling connected regions. Applies multi-stage Canny edge detection — Gaussian smoothing, gradient estimation, non-maximum suppression, and dual-threshold hysteresis — to produce thin edge pixels, then labels connected components of either the inverted edge map or the edge map itself. This does not explicitly close contours or fill interiors; colony-sized regions are recovered only when edges form useful barriers and size filtering removes background regions. Because detection relies on boundary contrast rather than absolute intensity, it can remain useful on plates with uneven illumination or translucent colonies. For a full comparison of detection strategies, see :doc:`/explanation/detection_strategies_compared`. Best For: - Well-separated colonies on solid media where colony edges are sharper than the intensity difference relative to background. - Translucent or lightly pigmented colonies that lack sufficient intensity contrast for threshold-based methods. - Plates with heterogeneous colony texture or pigmentation that cause intensity-based methods to fragment objects. - Images with moderate vignetting where edge contrast is preserved even though absolute intensity varies across the plate. Consider Also: - :class:`OtsuDetector` when colonies differ from background primarily in brightness rather than edge contrast. - :class:`WatershedDetector` when touching colonies must be separated by region-growing from interior distance-transform seeds. - :class:`HysteresisDetector` when dual-threshold intensity segmentation is preferred over edge-based detection. Args: sigma: Standard deviation of the Gaussian pre-smoothing kernel in pixels. Larger values suppress high-frequency noise but broaden edge profiles and may merge boundaries of closely spaced colonies. Typical range: 0.5--3.0. Default: 1.0. A reasonable starting point for noisy CCD images or plates with fine colony texture is 2.0--3.0. low_threshold: Lower hysteresis gate. When ``use_quantiles`` is ``True``, interpreted as a fractional rank of gradient magnitudes (0.1 retains edges stronger than 10 % of all measured gradients). When ``False``, an absolute gradient value requiring per-setup calibration. Raise to prune weak noise-driven edge fragments; lower to recover faint boundaries on low-contrast colonies. Typical quantile range: 0.05--0.2. Default: 0.1. high_threshold: Upper hysteresis gate that seeds new edge chains. Must exceed ``low_threshold``. A higher value seeds fewer but more confident edge segments; a lower value seeds more chains at the risk of noise-seeded spurious regions. A 2:1 to 3:1 high-to-low ratio is effective for moderately noisy images. Typical quantile range: 0.1--0.4. Default: 0.2. use_quantiles: When ``True`` (default), thresholds are interpreted as gradient-magnitude percentile ranks, adapting automatically to image contrast across batches with variable scanner gain or illumination. When ``False``, thresholds are absolute gradient values for tightly controlled imaging conditions. Default: True. min_size: Minimum connected-region area in pixels. Regions smaller than this are discarded as dust, debris, or condensation droplets after labelling. Typical range: 20--500 px, scaling with image resolution. Default: 50. invert_edges: When ``True`` (default), the binary edge map is inverted before labelling so that non-edge regions can become colony objects. Set to ``False`` to label the edge pixels themselves, which is useful for inspecting edge closure and gap locations during parameter tuning. Default: True. connectivity: Pixel connectivity for connected-component labelling. ``1`` for 4-connectivity; ``2`` for 8-connectivity. Use ``2`` (default) to bridge single-pixel diagonal gaps in Canny edge contours, preventing spurious region splits at diagonal steps. Default: 2. Returns: Image: Input image with ``objmap`` set to a labelled colony map where each retained connected region receives a unique integer label, and ``objmask`` derived from the non-zero entries of that map. Raises: ValueError: If ``high_threshold`` is less than ``low_threshold``. References: [1] J. Canny, "A computational approach to edge detection," *IEEE Trans. Pattern Anal. Mach. Intell.*, vol. 8, no. 6, pp. 679--698, Nov. 1986. See Also: :doc:`/tutorials/notebooks/02_detecting_colonies` for a step-by-step tutorial demonstrating colony detection on real plate images. :doc:`/how_to/notebooks/choose_detection_algorithm` for a guide to selecting the right detector for your plate images. :doc:`/explanation/detection_strategies_compared` for an in-depth comparison of all detection strategies and their failure modes. """ sigma: Annotated[float, TuneSpec(0.5, 3.0)] = Field(1.0, gt=0.0) # Mode-dependent: a fraction when use_quantiles=True, an absolute gradient # value otherwise — so a TuneSpec search window only, no tight Field bound. low_threshold: Annotated[float, TuneSpec(0.05, 0.2)] = 0.1 # Search windows are deliberately NON-OVERLAPPING (low ≤ 0.2 ≤ high) so the # optimizer can never sample ``low > high`` — a degenerate Canny config. The # constructor params are unchanged (no derived/delta field), so serialized # pipelines keep round-tripping; the ``_check_threshold_order`` validator is a # belt-and-suspenders guard for manually-constructed detectors. high_threshold: Annotated[float, TuneSpec(0.2, 0.4)] = 0.2 use_quantiles: bool = True # TODO: review bound (unverified vs literature) min_size: Annotated[int, TuneSpec(20, 500)] = Field(50, ge=1) invert_edges: bool = True connectivity: Annotated[int, TuneSpec(categories=[1, 2])] = Field(2, ge=1, le=2) @model_validator(mode="after") def _check_threshold_order(self) -> "CannyDetector": """Reject a configuration where ``high_threshold <= low_threshold``. Canny hysteresis requires the upper threshold to strictly exceed the lower one; a crossed pair produces a degenerate edge map. The tuning search windows are already non-overlapping (``low ≤ 0.2 ≤ high``), so the optimizer can never reach this state — this guard only fires for a hand-constructed detector. The defaults (low ``0.1``, high ``0.2``) pass. Returns: ``self`` unchanged when the thresholds are correctly ordered. Raises: ValueError: If ``high_threshold <= low_threshold``. """ if self.high_threshold <= self.low_threshold: raise ValueError( f"high_threshold ({self.high_threshold}) must be greater than " f"low_threshold ({self.low_threshold})" ) return self def _operate(self, image: Image | GridImage) -> Image: enhanced_matrix = image.detect_mat[:] # Apply Canny edge detection edges = feature.canny( image=enhanced_matrix, sigma=self.sigma, low_threshold=self.low_threshold, high_threshold=self.high_threshold, use_quantiles=self.use_quantiles, ) # Invert edges to get regions (colonies) if requested if self.invert_edges: regions = ~edges else: regions = edges # Label connected components. ``ndimage.label`` returns # ``(labels, count)`` here (default ``output=None``); the cast pins the # tuple branch of its overloaded return for the type checker. labeled = cast( "tuple[Any, int]", ndimage.label( regions, structure=ndimage.generate_binary_structure( 2, self.connectivity ), ), ) objmap = labeled[0] # Remove small objects (pass a boolean array when only one label # exists to avoid skimage's "single-label" ambiguity warning). if objmap.max() <= 1: objmap = morphology.remove_small_objects( objmap.astype(bool), min_size=self.min_size ).astype(objmap.dtype) else: objmap = morphology.remove_small_objects(objmap, min_size=self.min_size) # Ensure correct dtype if objmap.dtype != image._OBJMAP_DTYPE: objmap = objmap.astype(image._OBJMAP_DTYPE) # Relabel to ensure consecutive labels image.objmap[:] = objmap image.objmap.relabel(connectivity=self.connectivity) return image
# Set the docstring so that it appears in the sphinx documentation CannyDetector.apply.__doc__ = CannyDetector._operate.__doc__