Source code for phenotypic.refine._sine_alignment_refiner

"""Grid-aligned object refinement using sinusoidal cross-correlation.

Refines detected colonies by filtering to keep only the dominant object in each grid cell,
using FFT-based sinusoidal cross-correlation (gitter-faithful, Wagih & Parts 2014) for
grid edge estimation. More robust to outlier colonies than simple peak-finding because
rank-based Spearman correlation is insensitive to monotonic intensity transformations.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import gc

import numpy as np
from scipy.ndimage import gaussian_filter1d
from scipy.signal import find_peaks, fftconvolve, medfilt
from scipy.stats import rankdata

from phenotypic.abc_ import ObjectRefiner
from phenotypic.tools_.mixin import GridInferenceMixin
from phenotypic.tools_.funcs_ import validate_operation_integrity


[docs] class SineAlignmentRefiner(GridInferenceMixin, ObjectRefiner): """Retain only grid-aligned colonies using sinusoidal cross-correlation for grid estimation. Estimates grid edges by computing FFT-based normalized cross-correlation against a sinusoidal template of expected colony periodicity, then keeps one dominant object per cell. Rank-based (Spearman) correlation provides robustness to outlier colony intensities and monotonic intensity transformations compared to simple peak-finding. Args: smoothing_sigma: Gaussian smoothing sigma for intensity profiles. Typical range: 0.5--5.0. Higher values smooth noise but may merge adjacent peaks. Default: 2.0. min_peak_distance: Minimum pixel distance between grid peaks. ``None`` auto-estimates. Default: None. peak_prominence: Minimum prominence for peak detection. ``None`` auto-calculates. Default: None. edge_refinement: Refine grid edges using local intensity minima. Default: True. correlation_threshold: Minimum NCC value for a valid peak. Typical range: 0.1--0.6. Lower values accept weaker matches; higher values are more selective. Default: 0.3. selection_mode: Strategy for choosing one object per cell. ``"dominant"`` keeps the largest, ``"centered"`` keeps the most centered, ``"regularized"`` fits a global model. Default: ``"dominant"``. Returns: Image: Input image with ``objmap`` filtered to grid-aligned objects and ``objmask`` updated to match. Raises: ValueError: If grid inference fails or image lacks detection results. Best For: - Gridded plates (96-well, 384-well, pinned cultures) where colony intensities are heterogeneous or unevenly grown. - Post-detection cleanup when simple peak-finding grid estimation is unreliable. - Plates with variable colony sizes or uneven growth where rank-based correlation outperforms direct intensity matching. Consider Also: - :class:`GridAlignmentRefiner` for faster grid estimation when colony intensities are relatively uniform. - :class:`GridSectionLargest` for a simpler largest-per-cell strategy on GridImage inputs. - :class:`ReduceMultipleGridObjects` for regression-based multi- detection reduction within grid cells. 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. See Also: :doc:`/how_to/notebooks/refine_noisy_boundaries` for grid-based cleanup workflows. :doc:`/explanation/refinement_strategies` for a comparison of grid refinement approaches. """
[docs] def __init__( self, smoothing_sigma: float = 2.0, min_peak_distance: int | None = None, peak_prominence: float | None = None, edge_refinement: bool = True, correlation_threshold: float = 0.3, selection_mode: Literal["dominant", "centered", "regularized"] = "dominant", split_merged: bool = False, ): """Initialize SineAlignmentRefiner with grid inference and correlation parameters. Args: smoothing_sigma: Gaussian smoothing sigma for intensity profiles. min_peak_distance: Minimum distance between grid peaks. peak_prominence: Minimum prominence for peak detection. edge_refinement: Enable edge refinement via local intensity minima. correlation_threshold: Minimum NCC value for valid peaks. selection_mode: Strategy for choosing the object per grid cell. 'dominant' (default) keeps the largest, 'centered' keeps the most centred, 'regularized' uses a global fit. split_merged: If True, pre-split merged colonies that span multiple grid cells using EDT watershed before assignment. Default False for refiners (splitting is more useful during initial detection). """ super().__init__() self.smoothing_sigma = smoothing_sigma self.min_peak_distance = min_peak_distance self.peak_prominence = peak_prominence self.edge_refinement = edge_refinement self.correlation_threshold = correlation_threshold self.selection_mode = selection_mode self.split_merged = split_merged
[docs] @validate_operation_integrity("image.rgb", "image.gray", "image.detect_mat") def apply(self, image: Image, inplace: bool = False) -> Image: return super().apply(image=image, inplace=inplace)
def _operate(self, image: Image) -> Image: """Refine detected objects to grid-aligned colonies using sinusoidal cross-correlation. This method filters the object map to keep only the dominant object within each grid cell. Grid edges are estimated using FFT-based normalized cross-correlation against a sinusoidal template for robust peak detection. Objects are reassigned new labels (1, 2, 3, ...) to ensure contiguous labeling after filtering. Returns: Image: Modified image with filtered objmap and updated objmask. """ from phenotypic import GridImage # Get existing objmap objmap = image.objmap[:] # Determine grid edges (GridImage or infer via sine cross-correlation) 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: objmask = image.objmask[:] nrows, ncols = self._infer_grid_shape(objmask) row_edges = self._estimate_edges( objmask, axis=0, n_bins=nrows, ) col_edges = self._estimate_edges( objmask, axis=1, n_bins=ncols, ) if self.edge_refinement: row_edges = self._refine_edges(objmask, row_edges, axis=0) col_edges = self._refine_edges(objmask, col_edges, axis=1) # Clip and unique edges row_edges = np.clip(np.unique(row_edges), 0, objmap.shape[0]) col_edges = np.clip(np.unique(col_edges), 0, objmap.shape[1]) # Assign objects per grid cell using selection strategy refined_map = self._assign_grid_objects( objmap, row_edges, col_edges, self.selection_mode, image._OBJMAP_DTYPE, intensity=image.detect_mat[:], split_merged=self.split_merged, ) # Update image with refined map image.objmap[:] = refined_map image.objmap.relabel(connectivity=1) gc.collect() return image 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 gitter-faithful 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) correlation provides robustness to outliers and monotonic intensity transformations. 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. """ # 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)