from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from phenotypic._core._image import Image
import gc
from typing import Annotated, Literal, Optional
import numpy as np
import scipy.ndimage as ndimage
from pydantic import Field
from phenotypic.abc_ import ObjectDetector
from phenotypic.sdk_.mixin import GridInferenceMixin
from phenotypic.sdk_.typing_ import TuneSpec
import skimage.filters as filters
import skimage.morphology as morphology
[docs]
class RoundPeaksDetector(GridInferenceMixin, ObjectDetector):
"""Detect round colonies on gridded plates by row/column peak analysis (gitter algorithm).
Threshold the plate image, project row and column intensity sums to
detect periodic peaks, infer grid edges from peak positions, and assign
one colony per grid cell. This implements the gitter algorithm optimised
for pinned microbial culture plates with circular colonies arranged in
regular arrays (96, 384, 1536 formats). For a full comparison see
:doc:`/explanation/detection_strategies_compared`.
Best For:
- Pinned yeast or bacterial plates with colonies arranged in a
regular rectangular grid.
- Plates where colony shape is approximately circular and colonies
are well-separated or only mildly touching.
- High-throughput screens where automatic grid inference eliminates
the need for manual grid specification.
- Workflows that require one-colony-per-cell assignment for
downstream quantification.
Consider Also:
- :class:`SinePeakDetector` when colony sizes are heterogeneous or
some wells are empty, where rank cross-correlation locates the
grid more robustly than direct peak finding.
- :class:`WatershedDetector` when colonies are densely packed and
touching but not arranged on a regular grid.
- :class:`FilamentousFungiDetector` when colonies exhibit spreading,
filamentous growth that violates the round-colony assumption.
- :class:`ManualGridPointDetector` when colony positions are known a
priori from robotic spotting coordinates.
Args:
thresh_method: Thresholding method for binary mask creation.
Accepted values: ``'otsu'`` (default), ``'mean'``, ``'local'``,
``'triangle'``, ``'minimum'``, ``'isodata'``, ``'li'``.
``'otsu'`` works well for most standardised imaging setups;
``'local'`` adapts to spatial illumination gradients. Default:
``'otsu'``.
subtract_background: Apply white tophat transform to remove uneven
illumination before thresholding. Disable on plates with
uniform lighting to save compute time. Default: True.
remove_noise: Apply morphological opening to remove small noise
artefacts from the binary mask. Default: True.
footprint_width: Width in pixels for the background subtraction
kernel. When a GridImage is provided, an adaptive kernel sized
to 1.5x colony spacing is used instead, making this a fallback
for plain Image inputs. Typical range: 4--20. Default: 6.
noise_radius: Radius of the diamond structuring element for
morphological noise removal. Increase for larger noise
artefacts. Typical range: 1--3. Default: 1.
smoothing_sigma: Gaussian sigma for smoothing row/column intensity
profiles before peak detection. Higher values suppress noise but
may merge adjacent colony peaks. Set to 0 to disable smoothing.
Typical range: 0.0--5.0. Default: 2.0.
min_peak_distance: Minimum pixel distance between detected peaks.
When ``None``, automatically estimated from grid dimensions.
Default: None.
peak_prominence: Minimum prominence threshold for peak detection.
When ``None``, auto-calculated as 0.1 × signal range. Higher
values are more selective. Default: None.
edge_refinement: Refine grid edges using weighted local intensity
profiles for improved accuracy. Default: True.
selection_mode: Strategy for choosing one object per grid cell.
``"dominant"`` keeps the largest object by pixel count.
``"centered"`` keeps the object whose centroid is closest to
the cell centre. ``"regularized"`` fits a global regular-grid
model from median row/column centroids, then re-selects per
cell — best for pinned arrays. Default: ``"dominant"``.
split_merged: Pre-split merged colonies that span multiple grid
cells using EDT watershed before grid assignment. Set to False
when colonies are well-separated and splitting is unnecessary.
Default: True.
Returns:
Image: Input image with ``objmask`` set to a binary colony mask
and ``objmap`` set to a labelled colony map with one label per
grid cell.
Raises:
ValueError: If an invalid thresholding method is specified.
References:
[1] O. Wagih and L. Parts, "gitter: A robust and accurate method
for quantification of colony sizes from plate images," *G3
(Bethesda)*, vol. 4, no. 3, pp. 547--552, 2014.
doi: 10.1534/g3.113.009431.
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.
"""
thresh_method: Literal[
"otsu", "mean", "local", "triangle", "minimum", "isodata", "li"
] = "otsu"
subtract_background: bool = True
remove_noise: bool = True
# TODO: review bound (unverified vs literature)
footprint_width: Annotated[int, TuneSpec(4, 20)] = Field(6, ge=1)
noise_radius: Annotated[int, TuneSpec(1, 3)] = Field(1, ge=1)
smoothing_sigma: Annotated[float, TuneSpec(0.0, 5.0)] = 2.0
min_peak_distance: Annotated[Optional[int], TuneSpec(tunable=False)] = None
peak_prominence: Annotated[Optional[float], TuneSpec(tunable=False)] = None
edge_refinement: bool = True
selection_mode: Literal["dominant", "centered", "regularized"] = "dominant"
split_merged: bool = True
@staticmethod
def _round_odd(n: int) -> int:
"""Round to nearest odd integer (minimum 3)."""
n = max(n, 3)
return n if n % 2 == 1 else n + 1
def _operate(self, image: Image) -> Image:
"""
Detect colonies in the image using the gitter algorithm.
This method performs the _core detection workflow:
1. Extract grid dimensions (if GridImage)
2. Threshold the detection matrix with adaptive kernel sizing
3. Remove noise if requested
4. Label connected components
5. Determine or estimate grid edges
6. Assign dominant colonies to grid cells
7. Create final object map
Args:
image: Image object to process. Can be a regular Image or GridImage.
Returns:
Image: The processed image with updated objmask and objmap.
"""
from phenotypic import GridImage
enh_matrix = image.detect_mat[:]
self._log_memory_usage("getting detection matrix")
# Extract grid dimensions early for adaptive kernel sizing
if isinstance(image, GridImage):
nrows, ncols = image.nrows, image.ncols
else:
nrows = ncols = None
objmask = self._thresholding(enh_matrix, nrows=nrows, ncols=ncols)
self._log_memory_usage("thresholding")
if self.remove_noise:
objmask = morphology.opening(
objmask, footprint=morphology.diamond(radius=self.noise_radius)
)
self._log_memory_usage("noise removal")
# Keep a copy of the mask we intend to use for downstream measurements
image.objmask[:] = objmask
labeled, num_features = ndimage.label(
objmask, structure=ndimage.generate_binary_structure(
rank=2,
connectivity=2)
)
self._log_memory_usage(f"labeling ({num_features} features)")
# Determine grid edges either from GridImage or by estimating from the binary mask
if isinstance(image, GridImage):
row_edges = np.round(image.grid.get_row_edges()).astype(int)
col_edges = np.round(image.grid.get_col_edges()).astype(int)
else:
row_edges = col_edges = None
if row_edges is None or col_edges is None:
# Estimate edges using peak finding on row/col sums
nrows, ncols = self._infer_grid_shape(objmask)
self._log_memory_usage(f"inferred grid shape: {nrows}x{ncols}")
row_edges = self._estimate_edges(
objmask,
axis=0,
n_bins=nrows,
smoothing_sigma=self.smoothing_sigma,
min_peak_distance=self.min_peak_distance,
peak_prominence=self.peak_prominence,
)
col_edges = self._estimate_edges(
objmask,
axis=1,
n_bins=ncols,
smoothing_sigma=self.smoothing_sigma,
min_peak_distance=self.min_peak_distance,
peak_prominence=self.peak_prominence,
)
self._log_memory_usage("edge estimation")
# Refine edges if requested
if self.edge_refinement:
row_edges = self._refine_edges(objmask, row_edges, axis=0)
col_edges = self._refine_edges(objmask, col_edges, axis=1)
self._log_memory_usage("edge refinement")
row_edges = np.clip(np.unique(row_edges), 0, objmask.shape[0])
col_edges = np.clip(np.unique(col_edges), 0, objmask.shape[1])
# Assign colonies to grid cells using selection strategy
objmap = self._assign_grid_objects(
labeled, row_edges, col_edges, self.selection_mode, image._OBJMAP_DTYPE,
intensity=enh_matrix, split_merged=self.split_merged,
)
# Fallback if no regions were labeled (e.g., grid inference failed)
if objmap.max() == 0:
objmap = labeled.astype(image._OBJMAP_DTYPE, copy=False)
self._log_memory_usage("grid cell assignment")
image.objmap[:] = objmap
image.objmap.relabel(connectivity=1)
gc.collect() # Force garbage collection
self._log_memory_usage(
"final cleanup", include_process=True, include_tracemalloc=True
)
return image
def _thresholding(
self,
matrix: np.ndarray,
nrows: int | None = None,
ncols: int | None = None,
) -> np.ndarray:
"""
Threshold the image to create a binary mask of foreground colonies.
This method applies optional background subtraction followed by one of
several thresholding algorithms to separate colonies from background.
Args:
matrix: 2D detection matrix array with pixel intensities.
nrows: Number of grid rows (from GridImage). When provided, the
background subtraction kernel is adaptively sized to 1.5x
the colony spacing along the row axis.
ncols: Number of grid columns (from GridImage). When provided,
the background subtraction kernel is adaptively sized to 1.5x
the colony spacing along the column axis.
Returns:
np.ndarray: Binary mask where True/1 indicates colony pixels,
False/0 indicates background.
Raises:
ValueError: If an invalid thresholding method is specified.
"""
# Adaptive kernel sizing: use grid spacing when available, fallback otherwise
if nrows is not None:
bg_h = self._round_odd(round((matrix.shape[0] / nrows) * 1.5))
bg_w = self._round_odd(round((matrix.shape[1] / (ncols or nrows)) * 1.5))
kernel = morphology.footprint_rectangle((bg_h, bg_w))
else:
dim = self._round_odd(self.footprint_width * 2)
kernel = morphology.footprint_rectangle((dim, dim))
enh_matrix = matrix.copy() # Work on a copy to avoid modifying input
# Isolate bright foreground colonies via white tophat (image - opening)
if self.subtract_background:
enh_matrix = morphology.white_tophat(enh_matrix, kernel)
# Apply selected thresholding method
match self.thresh_method:
case "otsu":
thresh = filters.threshold_otsu(enh_matrix)
case "mean":
thresh = filters.threshold_mean(enh_matrix)
case "local":
block_size = max(
self.footprint_width * 2 + 1, 3
) # Ensure odd block size
thresh = filters.threshold_local(enh_matrix, block_size=block_size)
case "triangle":
thresh = filters.threshold_triangle(enh_matrix)
case "minimum":
thresh = filters.threshold_minimum(enh_matrix)
case "isodata":
thresh = filters.threshold_isodata(enh_matrix)
case "li":
thresh = filters.threshold_li(enh_matrix)
case _:
# Default to Otsu if method not recognized
thresh = filters.threshold_otsu(enh_matrix)
return enh_matrix >= thresh