Source code for phenotypic.detect._watershed_detector

from __future__ import annotations
from typing import TYPE_CHECKING

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

from typing import Literal
import gc

import numpy as np
import numpy.ma as ma
from scipy.ndimage import distance_transform_edt
from skimage import feature, filters, morphology, segmentation

from phenotypic.abc_ import ThresholdDetector


[docs] class WatershedDetector(ThresholdDetector): """Detect and separate touching colonies by watershed segmentation on a distance-transform surface. Threshold the plate image to a binary mask, compute a Euclidean distance transform to locate colony centres, seed markers at local maxima, and propagate labelled regions via watershed on the Sobel gradient. This region-growing approach individually labels colonies that are in physical contact -- a scenario where global thresholding merges them into a single object. For a full comparison see :doc:`/explanation/detection_strategies_compared`. Args: footprint: Structuring element for peak detection. ``'auto'`` infers size from grid spacing (GridImage only); an int creates a diamond of that radius; an ndarray supplies a custom footprint; None (default) lets scikit-image choose. Larger footprints merge nearby peaks into fewer seeds; smaller footprints yield finer segmentation. Typical range: 5--50 (diamond radius in pixels). min_size: Minimum object area in pixels (default 50). Objects smaller than this are removed as dust or debris. Typical range: 20--200 depending on image resolution and colony size. compactness: Watershed compactness parameter (default 0.001). Higher values enforce more regularly shaped segments; lower values let regions follow intensity gradients freely. Typical range: 0.0001--0.1. Increase if colonies are round and over-segmented; decrease for irregular morphologies. connectivity: Connectivity for region labelling (1 = 4-connected, 2 = 8-connected; default 1). Higher connectivity merges diagonally adjacent pixels. relabel: If True (default), relabel segments to consecutive IDs after watershed. ignore_zeros: If True (default), exclude zero-intensity pixels from threshold computation. Enable for plates with black borders or masked regions. Returns: Image: Input image with ``objmap`` set to a labelled colony map where each colony receives a unique integer label. ``objmask`` is derived from the non-zero region of the label map. Raises: ValueError: If invalid parameters are provided or if the distance transform / watershed computation fails. Best For: * Dense plates where colonies touch or overlap and must be counted individually. * Plates with variable colony sizes (e.g., mutant libraries) where the distance transform naturally adapts seed placement. * Irregular colony morphologies that follow local intensity gradients better than geometric assumptions. * Post-incubation plates where colony crowding is the primary segmentation challenge. Consider Also: * :class:`OtsuDetector` when colonies are well-separated and a simple binary mask suffices. * :class:`RoundPeaksDetector` when colonies sit on a regular pinned grid and peak-based assignment is more efficient. * :class:`FilamentousFungiDetector` when colonies exhibit spreading, filamentous growth rather than compact morphology. * :class:`CannyDetector` when edge contrast is stronger than intensity contrast for delineating colony boundaries. References: [1] S. Beucher and C. Lantuejoul, "Use of watersheds in contour detection," in *Proc. Int. Workshop on Image Processing*, CCETT, Rennes, France, 1979. 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, footprint: Literal["auto"] | np.ndarray | int | None = None, min_size: int = 50, compactness: float = 0.001, connectivity: int = 1, relabel: bool = True, ignore_zeros: bool = False, ): super().__init__() match footprint: case x if isinstance(x, int): self.footprint = morphology.diamond(footprint) case x if isinstance(x, np.ndarray): self.footprint = footprint case "auto": self.footprint = "auto" case None: # shape will be automatically determined by implementation self.footprint = None self.min_size = min_size self.compactness = compactness self.connectivity = connectivity self.relabel = relabel self.ignore_zeros = ignore_zeros def _operate(self, image: Image | GridImage) -> Image: from phenotypic import Image, GridImage enhanced_matrix = image.detect_mat[ : ] # direct access to reduce memory shape, but careful to not delete self._log_memory_usage("getting detection matrix") # Determine shape for peak detection if self.footprint == "auto": if isinstance(image, GridImage): est_footprint_diameter = max( image.shape[0] // image.grid.nrows, image.shape[1] // image.grid.ncols, ) footprint = morphology.diamond(est_footprint_diameter // 2) del est_footprint_diameter elif isinstance(image, Image): # Not enough information with a normal image to infer footprint = None else: # Use the shape as defined in __init__ (None, ndarray, or processed int) footprint = self.footprint self._log_memory_usage("determining shape") # Prepare values for threshold calculation if self.ignore_zeros: # Use masked array to avoid copying non-zero values masked_enh = ma.masked_equal(enhanced_matrix, 0) # Safety check: if all values are zero, fall back to using all values if masked_enh.count() == 0: threshold = filters.threshold_otsu(enhanced_matrix) else: threshold = filters.threshold_otsu(masked_enh) # Create binary mask: zeros are always background, non-zeros compared to threshold binary = (enhanced_matrix >= threshold) & (enhanced_matrix != 0) del masked_enh else: threshold = filters.threshold_otsu(enhanced_matrix) binary = enhanced_matrix >= threshold del threshold # don't need this after obtaining binary mask self._log_memory_usage("threshold calculation and binary mask creation") binary = morphology.remove_small_objects( binary, min_size=self.min_size ) # clean to reduce runtime # Ensure binary is contiguous for memory-efficient operations (only if needed) if not binary.flags["C_CONTIGUOUS"]: binary = np.ascontiguousarray(binary) # Memory-intensive distance transform operation self._log_memory_usage("before distance transform", include_tracemalloc=True) # Allocate float32 output directly to avoid intermediate float64 array dist_matrix = np.empty(binary.shape, dtype=np.float64) distance_transform_edt(binary, distances=dist_matrix) self._log_memory_usage("after distance transform", include_tracemalloc=True) max_peak_indices = feature.peak_local_max( image=dist_matrix, footprint=footprint, labels=binary ) del footprint, dist_matrix gc.collect() # Force garbage collection to free memory before watershed self._log_memory_usage("after peak detection", include_tracemalloc=True) # Create markers more efficiently: allocate once and label directly max_peaks = np.zeros(shape=enhanced_matrix.shape, dtype=np.int32) max_peaks[tuple(max_peak_indices.T)] = np.arange(1, len(max_peak_indices) + 1) del max_peak_indices self._log_memory_usage("creating max peaks array") # Sobel filter enhances edges which improve watershed to nearly the point of necessity in most cases gradient = filters.sobel(enhanced_matrix) # Convert to float32 and ensure contiguity in one step if needed if gradient.dtype != np.float32 or not gradient.flags["C_CONTIGUOUS"]: gradient = np.asarray(gradient, dtype=np.float32, order="C") self._log_memory_usage("Sobel filter for gradient", include_tracemalloc=True) # Memory-intensive watershed operation - detailed tracking self._log_memory_usage( "before watershed segmentation", include_process=True, include_tracemalloc=True, ) objmap = segmentation.watershed( image=gradient, markers=max_peaks, compactness=self.compactness, connectivity=self.connectivity, mask=binary, ) self._log_memory_usage( "after watershed segmentation", include_process=True, include_tracemalloc=True, ) if objmap.dtype != np.uint16: objmap = objmap.astype(image._OBJMAP_DTYPE) del max_peaks, gradient, binary gc.collect() # Force garbage collection after watershed to free memory objmap = morphology.remove_small_objects(objmap, min_size=self.min_size) image.objmap[:] = objmap image.objmap.relabel(connectivity=self.connectivity) # Final comprehensive memory report self._log_memory_usage( "final cleanup and relabeling", include_process=True, include_tracemalloc=True, ) return image