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 scipy.ndimage import gaussian_filter1d
from scipy.signal import find_peaks, fftconvolve, medfilt
from scipy.stats import rankdata
from phenotypic.abc_ import ObjectDetector
from phenotypic.sdk_.typing_ import TuneSpec
from phenotypic.sdk_.mixin import GridInferenceMixin
import skimage.morphology as morphology
from phenotypic.detect._grid_peak_common import (
_round_odd as _grid_peak_round_odd,
grid_peak_threshold_mask,
)
[docs]
class SinePeakDetector(GridInferenceMixin, ObjectDetector):
"""Detect colonies on gridded plates using sinusoidal cross-correlation peak finding.
Generate a sinusoidal template matching the expected colony periodicity,
compute FFT-based rank (Spearman) cross-correlation against row and
column projection signals, and select peaks from the correlation output
to locate grid positions. Rank-based correlation is insensitive to
outlier colonies and monotonic intensity transformations, making this
more robust than direct peak finding on plates with heterogeneous
growth. For a full comparison see
:doc:`/explanation/detection_strategies_compared`.
Best For:
- Gridded plates (96-well, 384-well, pinned arrays) where colonies
are arranged in a regular periodic pattern.
- Plates with heterogeneous colony sizes or uneven growth where
rank-based correlation outperforms intensity-based peak finding.
- High-throughput batch processing of arrayed plates without
manual grid specification.
- Plates where a small number of empty or very faint wells would
mislead direct intensity peak detection.
Consider Also:
- :class:`RoundPeaksDetector` for a simpler grid detector when
colony intensities are uniform and direct peak finding suffices.
- :class:`OtsuDetector` when colonies are not gridded and a global
threshold is appropriate.
- :class:`RankOtsuDetector` when spatial illumination variation is
the primary challenge rather than grid localisation.
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 setups; ``'local'``
adapts to spatial illumination gradients. Default: ``'otsu'``.
subtract_background: Apply white tophat transform to remove uneven
illumination before thresholding. 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. Typical range: 1--3. Default: 1.
smoothing_sigma: Gaussian standard deviation for smoothing
row/column intensity profiles before cross-correlation. Higher
values suppress noise but may merge adjacent peaks. Set to 0 to
disable. 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 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.
correlation_threshold: Minimum normalised cross-correlation score
for a sinusoidal template peak to be accepted as a valid grid
position. Lower values recover grid cells with weak colony
signal (sparse plates); higher values reject false grid
positions at the cost of missing faint wells. Typical range:
0.1--0.5. Default: 0.3.
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 centroids, then re-selects per cell — best
for pinned arrays. Default: ``"dominant"``.
split_merged: Pre-split merged colonies spanning multiple grid
cells using EDT watershed before grid assignment. Set to False
when colonies are well-separated. Default: True.
Returns:
Image: Input image with ``objmask`` set to binary mask and
``objmap`` set to labeled connected components with one label per
grid cell.
Raises:
ValueError: If ``thresh_method`` is not one of the accepted
values.
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
# TODO: review bound (unverified vs literature)
correlation_threshold: Annotated[float, TuneSpec(0.1, 0.5)] = 0.3
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)."""
return _grid_peak_round_odd(n)
def _operate(self, image: Image) -> Image:
"""
Detect colonies using sinusoidal cross-correlation grid estimation.
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 (via sinusoidal cross-correlation)
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 sinusoidal cross-correlation 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)
col_edges = self._estimate_edges(objmask, axis=1, n_bins=ncols)
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.
"""
return grid_peak_threshold_mask(
matrix,
thresh_method=self.thresh_method,
subtract_background=self.subtract_background,
footprint_width=self.footprint_width,
nrows=nrows,
ncols=ncols,
round_odd=self._round_odd,
)
def _estimate_edges(self, binary_image: np.ndarray, axis: int, n_bins: int, **kwargs: object) -> np.ndarray: # type: ignore[override]
"""Estimate grid edges using sinusoidal cross-correlation.
Overrides GridInferenceMixin._estimate_edges with a projection-based
approach: generates a sine template matching expected colony periodicity,
computes FFT-based normalized cross-correlation against the projection
signal, and selects peaks from the correlation output. Rank-based
(Spearman-style) correlation provides robustness to outliers and
monotonic intensity transformations. The sinusoidal template and
rank-transform steps are project-specific extensions rather than
steps from the gitter paper cited above.
Args:
binary_image: Binary mask of detected colonies.
axis: Direction for edge detection (0 for row edges, 1 for column edges).
n_bins: Expected number of grid bins (rows or columns).
Returns:
np.ndarray: Array of edge positions including image borders.
Length is n_bins + 1.
Note:
Unlike the mixin's static ``_estimate_edges``, this instance method
reads ``smoothing_sigma``, ``min_peak_distance``, ``peak_prominence``,
and ``correlation_threshold`` from ``self``.
"""
# 1. Clean projection sums (from mixin)
sums = GridInferenceMixin._clean_and_sum_binary(binary_image, axis=axis)
# 2. Gaussian smooth
if self.smoothing_sigma > 0:
sums = gaussian_filter1d(sums, sigma=self.smoothing_sigma)
# 3. Signal enhancement: multiply by median-filtered version
image_size = binary_image.shape[1 - axis]
expected_spacing = max(image_size // max(n_bins, 1), 1)
window_size = max(expected_spacing, 3)
medfilt_kernel = max(window_size // 3, 3)
if medfilt_kernel % 2 == 0:
medfilt_kernel += 1
enhanced = sums * medfilt(sums, kernel_size=medfilt_kernel)
# 4. Rank transform for Spearman robustness
ranked_signal = rankdata(enhanced).astype(np.float64)
# 5. Rank sine template
template = np.sin(np.linspace(-np.pi, 2 * np.pi, window_size))
ranked_template = rankdata(template).astype(np.float64)
# 6. FFT normalized cross-correlation
ncc = self._normalized_cross_correlation(ranked_signal, ranked_template)
# 7. Threshold low correlations
ncc[ncc < self.correlation_threshold] = 0
# 8. Find peaks
min_distance = (
self.min_peak_distance if self.min_peak_distance is not None
else max(expected_spacing // 2, 1)
)
if self.peak_prominence is not None:
prominence: float | None = self.peak_prominence
else:
signal_range = np.max(ncc) - np.min(ncc)
prominence = 0.1 * signal_range if signal_range > 0 else None
peaks, _ = find_peaks(ncc, distance=min_distance, prominence=prominence)
# 9. Select best n_bins peaks by correlation height, sorted by position
if peaks.size > n_bins:
peak_heights = ncc[peaks]
top_indices = np.argsort(peak_heights)[-n_bins:]
peaks = np.sort(peaks[top_indices])
elif peaks.size < n_bins:
# Fallback: evenly spaced peaks
peaks = np.linspace(
start=expected_spacing // 2,
stop=image_size - expected_spacing // 2,
num=n_bins,
dtype=int,
)
# 10. Derive edges at midpoints
if len(peaks) > 1:
midpoints = ((peaks[:-1] + peaks[1:]) / 2).astype(int)
edges = np.concatenate(([0], midpoints, [image_size]))
else:
edges = np.linspace(0, image_size, n_bins + 1, dtype=int)
# Ensure exactly n_bins + 1 edges
if edges.size > n_bins + 1:
edges = edges[:n_bins + 1]
elif edges.size < n_bins + 1:
missing = (n_bins + 1) - edges.size
edges = np.concatenate((edges, np.full(missing, image_size)))
return edges.astype(int)
@staticmethod
def _normalized_cross_correlation(signal: np.ndarray, template: np.ndarray) -> np.ndarray:
"""FFT-based normalized cross-correlation.
Computes the normalized cross-correlation between a signal and a
template using FFT convolution for O(N log N) performance. The
result is clipped to [-1, 1].
Args:
signal: 1D input signal array.
template: 1D template array (typically shorter than signal).
Returns:
np.ndarray: Normalized cross-correlation values, same length as
signal, clipped to [-1, 1].
"""
n = len(signal)
k = len(template)
# Zero-mean template
template_mean = np.mean(template)
template_zm = template - template_mean
template_norm = np.sqrt(np.sum(template_zm ** 2))
if template_norm < 1e-10:
return np.zeros(n)
# Cross-correlation via FFT
xcorr = fftconvolve(signal, template_zm[::-1], mode="same")
# Local statistics via FFT with ones kernel
ones_kernel = np.ones(k)
local_sum = fftconvolve(signal, ones_kernel, mode="same")
local_sum_sq = fftconvolve(signal ** 2, ones_kernel, mode="same")
local_mean = local_sum / k
# Use sum-of-squares form: sqrt(sum((x-mean)^2)) to match template_norm scale
local_energy = np.maximum(local_sum_sq - local_sum ** 2 / k, 0)
local_std = np.sqrt(local_energy)
# Normalize (suppress divide-by-zero where denom is near-zero)
denom = local_std * template_norm
safe_denom = np.where(denom > 1e-10, denom, 1.0)
ncc = np.where(
denom > 1e-10,
(xcorr - local_mean * np.sum(template_zm)) / safe_denom,
0.0,
)
return np.clip(ncc, -1.0, 1.0)
SinePeakDetector.apply.__doc__ = SinePeakDetector._operate.__doc__