Source code for phenotypic.tools_.mixin._grid_inference_mixin

"""Mixin providing grid inference capabilities for gridded plate image processing.

This module contains GridInferenceMixin, which provides static methods for inferring
grid structure from colony patterns. Used by both ObjectDetector (RoundPeaksDetector)
and ObjectRefiner (GridAlignmentRefiner) subclasses.
"""

from __future__ import annotations

from typing import Any, Literal

import numpy as np
import scipy.ndimage as ndimage
from scipy.signal import find_peaks
from scipy.ndimage import gaussian_filter1d, distance_transform_edt
from skimage.feature import peak_local_max
from skimage.segmentation import watershed


[docs] class GridInferenceMixin: """Mixin providing grid inference capabilities from binary masks. Provides static methods for inferring grid structure from colony patterns using peak detection on row/column projections. Used by detectors and refiners that work with gridded plate images (96-well, 384-well formats, pinned cultures). All methods are static to support parallelization in pipeline operations. This is an internal utility for advanced users creating custom grid-based operations. Most users should use RoundPeaksDetector for detection or GridAlignmentRefiner for post-detection refinement directly. """ @staticmethod def _clean_and_sum_binary( binary_image: np.ndarray, p: float = 0.2, axis: int = 0 ) -> np.ndarray: """Compute projection sums while removing problematic edge artifacts. This method identifies rows (axis=0) or columns (axis=1) near image edges that contain abnormally long stretches of foreground pixels (likely artifacts or plate edges) and excludes them from the sum to avoid spurious peaks. Args: binary_image: Binary mask of detected colonies. p: Proportion of image dimension to use as threshold for detecting problematic long runs (default: 0.2 = 20%). axis: Direction to sum along following numpy convention. - axis=0: Sum along rows (collapse rows → column sums for row edge detection) - axis=1: Sum along columns (collapse columns → row sums for column edge detection) Returns: np.ndarray: 1D array of cleaned sums along the specified axis. Problematic edge regions are set to 0. Note: This cleaning step helps avoid detecting false peaks from plate edges or imaging artifacts that span large portions of rows/columns. """ # Calculate threshold based on image dimensions # For axis=0: we're summing columns, so check for long runs across columns # For axis=1: we're summing rows, so check for long runs across rows if axis == 0: c = p * binary_image.shape[1] # Threshold based on number of columns n_slices = binary_image.shape[0] # Number of rows to iterate through else: c = p * binary_image.shape[0] # Threshold based on number of rows n_slices = binary_image.shape[1] # Number of columns to iterate through # Identify problematic rows/columns with long stretches of 1s problematic = np.zeros(n_slices, dtype=bool) for i in range(n_slices): if axis == 0: slice_data = binary_image[i, :] # Get row i else: slice_data = binary_image[:, i] # Get column i # Run-length encoding to find stretches of 1s diff = np.diff(np.concatenate(([0], slice_data.astype(int), [0]))) starts = np.where(diff == 1)[0] ends = np.where(diff == -1)[0] lengths = ends - starts # Check if any stretch of 1s is longer than threshold if len(lengths) > 0 and np.any(lengths > c): problematic[i] = True # Compute sums along the specified axis sums = np.sum(binary_image, axis=axis, dtype=np.float64) # Split problematic array in half and zero out problematic regions at edges mid = len(problematic) // 2 left_prob = problematic[:mid] right_prob = problematic[mid:] # Zero out sums for problematic regions at edges if np.any(left_prob): last_prob = np.where(left_prob)[0][-1] sums[: last_prob + 1] = 0 if np.any(right_prob): first_prob = np.where(right_prob)[0][0] + mid sums[first_prob:] = 0 return sums @staticmethod def _estimate_edges( binary_image: np.ndarray, axis: int, n_bins: int, smoothing_sigma: float = 2.0, min_peak_distance: int | None = None, peak_prominence: float | None = None, ) -> np.ndarray: """Estimate grid edges by detecting periodic peaks in row/column intensity sums. This method implements the core of the gitter algorithm by analyzing the projection of colonies onto rows or columns. It detects peaks corresponding to colony centers and derives grid edges between them. 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). smoothing_sigma: Gaussian smoothing standard deviation for intensity profile. Default 2.0. Higher values smooth noise but may merge peaks. min_peak_distance: Minimum pixel distance between detected peaks. If None, automatically estimated from grid dimensions. peak_prominence: Minimum prominence threshold for peak detection. If None, auto-calculated as 0.1 * signal range. Returns: np.ndarray: Array of edge positions including image borders. Length is n_bins + 1. Note: The method applies smoothing to the intensity profile before peak detection to improve robustness. If automatic peak detection fails to find enough peaks, it falls back to evenly-spaced bins. """ # Get cleaned sums along the specified axis sums = GridInferenceMixin._clean_and_sum_binary(binary_image, axis=axis) # Apply Gaussian smoothing if requested to reduce noise if smoothing_sigma > 0: sums = gaussian_filter1d(sums, sigma=smoothing_sigma) # Calculate expected spacing between colonies image_size = binary_image.shape[1 - axis] # Size along the summed dimension expected_spacing = max(image_size // max(n_bins, 1), 1) # Determine peak detection parameters min_distance = ( min_peak_distance if min_peak_distance is not None else max(expected_spacing // 2, 1) ) # Calculate prominence if not provided if peak_prominence is not None: prominence = peak_prominence else: signal_range = np.max(sums) - np.min(sums) prominence = 0.1 * signal_range if signal_range > 0 else None # Detect peaks with prominence and distance constraints peaks, properties = find_peaks(sums, distance=min_distance, prominence=prominence) if peaks.size < n_bins: # Fallback: enforce evenly spaced peaks if auto detection under-fits peaks = np.linspace( start=expected_spacing // 2, stop=image_size - expected_spacing // 2, num=n_bins, dtype=int, ) elif peaks.size > n_bins: # Keep the strongest n_bins peaks by height peak_heights = sums[peaks] top_indices = np.argsort(peak_heights)[-n_bins:] peaks = np.sort(peaks[top_indices]) # Derive edges midway between peaks if len(peaks) > 1: # Calculate midpoints between consecutive peaks midpoints = ((peaks[:-1] + peaks[1:]) / 2).astype(int) # Prepend/append image borders edges = np.concatenate(([0], midpoints, [image_size])) else: # Fallback for single or no peaks: evenly divide the space edges = np.linspace(0, image_size, n_bins + 1, dtype=int) # Ensure we have 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 _refine_edges( binary_image: np.ndarray, edges: np.ndarray, axis: int ) -> np.ndarray: """Refine grid edges using local intensity profiles for improved accuracy. This method adjusts edge positions by analyzing the intensity distribution near each initial edge estimate. It shifts edges to positions of minimum intensity (background) between colonies. Args: binary_image: Binary mask of detected colonies. edges: Initial edge estimates from peak detection. axis: Direction of edges (0 for row edges, 1 for column edges). Returns: np.ndarray: Refined edge positions. Note: This refinement step can significantly improve accuracy by placing edges in the valleys between colonies rather than at fixed positions. """ refined_edges = edges.copy() sums = np.sum(binary_image, axis=axis, dtype=np.float64) # Refine each internal edge (not the borders) for i in range(1, len(edges) - 1): edge_pos = edges[i] # Define search window around current edge search_radius = min(10, (edges[i + 1] - edges[i - 1]) // 4) search_start = max(0, edge_pos - search_radius) search_end = min(len(sums), edge_pos + search_radius + 1) # Find position of minimum intensity in search window search_window = sums[search_start:search_end] if len(search_window) > 0: local_min_idx = np.argmin(search_window) refined_edges[i] = search_start + local_min_idx return refined_edges.astype(int) @staticmethod def _infer_grid_shape(binary_image: np.ndarray) -> tuple[int, int]: """Infer grid dimensions from the binary mask when not explicitly provided. This method estimates the number of rows and columns in the grid by counting connected components and assuming a roughly rectangular layout. Common plate formats (96-well, 384-well) are used as fallbacks. Args: binary_image: Binary mask of detected colonies. Returns: tuple[int, int]: Estimated (n_rows, n_cols) for the grid. Note: This is a best-effort estimate. For accurate results, provide grid dimensions explicitly using GridImage. """ labeled, num = ndimage.label(binary_image) if num == 0: # Default to 96-well plate format (8x12) return 8, 12 # Estimate based on aspect ratio and colony count aspect_ratio = binary_image.shape[1] / binary_image.shape[0] if aspect_ratio > 1.3: # Wide plate (likely 8x12 or similar) # Try 8x12 (96 wells), 16x24 (384 wells), etc. if num <= 100: return 8, 12 elif num <= 400: return 16, 24 else: approx_rows = int(np.ceil(np.sqrt(num / aspect_ratio))) approx_cols = int(np.ceil(np.sqrt(num * aspect_ratio))) return approx_rows, approx_cols else: # Square-ish layout approx_side = int(np.ceil(np.sqrt(num))) return approx_side, max(approx_side, 1) @staticmethod def _select_cell_object( region: np.ndarray, selection_mode: Literal["dominant", "centered"], ) -> int | None: """Select a single object label from a grid cell region. Args: region: 2D labeled array for a single grid cell (0 = background). selection_mode: Strategy for choosing the object. ``"dominant"`` picks the label with the most pixels. ``"centered"`` picks the label whose centroid is closest to the cell center. Returns: The selected label, or ``None`` if the region contains no foreground objects. Raises: ValueError: If *selection_mode* is not ``"dominant"`` or ``"centered"``. """ uniq, counts = np.unique(region, return_counts=True) valid = uniq != 0 if not np.any(valid): return None uniq = uniq[valid] counts = counts[valid] if selection_mode == "dominant": return int(uniq[np.argmax(counts)]) if selection_mode == "centered": center_r = region.shape[0] / 2.0 center_c = region.shape[1] / 2.0 centroids = ndimage.center_of_mass( np.ones_like(region), region, uniq.tolist() ) best_label = None best_dist = np.inf for label, (cr, cc) in zip(uniq, centroids): dist = (cr - center_r) ** 2 + (cc - center_c) ** 2 if dist < best_dist: best_dist = dist best_label = int(label) return best_label raise ValueError( f"Unknown selection_mode {selection_mode!r}. " "Use 'dominant' or 'centered'." ) @staticmethod def _split_merged_objects( labeled: np.ndarray, row_edges: np.ndarray, col_edges: np.ndarray, min_peak_distance: int = 5, relative_peak_threshold: float = 0.5, ) -> np.ndarray: """Split merged colonies that span multiple grid cells using EDT watershed. Detects objects touching multiple grid cells with distinct EDT peaks in different cells and splits them via watershed segmentation. Objects with all peaks in a single cell are left intact. Args: labeled: Labeled array (0 = background, >0 = objects). row_edges: Sorted array of row boundary positions. col_edges: Sorted array of column boundary positions. min_peak_distance: Minimum pixel distance between EDT peaks. relative_peak_threshold: Fraction of maximum EDT value; peaks below this threshold are discarded. Returns: A new labeled array where merged objects have been split into separate labels. """ out_labeled = labeled.copy() next_label = int(labeled.max()) + 1 unique_labels = np.unique(labeled) unique_labels = unique_labels[unique_labels != 0] for lbl in unique_labels: # Tight bounding box rows, cols = np.where(labeled == lbl) r_min, r_max = rows.min(), rows.max() + 1 c_min, c_max = cols.min(), cols.max() + 1 # Determine which grid cells this object touches r_cell_min = max(int(np.searchsorted(row_edges, r_min, side="right")) - 1, 0) r_cell_max = max(int(np.searchsorted(row_edges, r_max - 1, side="right")) - 1, 0) c_cell_min = max(int(np.searchsorted(col_edges, c_min, side="right")) - 1, 0) c_cell_max = max(int(np.searchsorted(col_edges, c_max - 1, side="right")) - 1, 0) if r_cell_min == r_cell_max and c_cell_min == c_cell_max: continue # Single cell — skip # EDT on the object's binary mask patch mask_patch = labeled[r_min:r_max, c_min:c_max] == lbl edt = distance_transform_edt(mask_patch) if edt.max() == 0: continue # Find local maxima coords = peak_local_max( edt, min_distance=min_peak_distance, labels=mask_patch.astype(np.uint8), ) if len(coords) < 2: continue # Filter by relative threshold threshold = relative_peak_threshold * edt.max() coords = coords[edt[coords[:, 0], coords[:, 1]] >= threshold] if len(coords) < 2: continue # Map each peak to its grid cell (absolute coords) peak_cells = set() for pr, pc in coords: abs_r = pr + r_min abs_c = pc + c_min ri = max(int(np.searchsorted(row_edges, abs_r, side="right")) - 1, 0) ci = max(int(np.searchsorted(col_edges, abs_c, side="right")) - 1, 0) peak_cells.add((ri, ci)) if len(peak_cells) < 2: continue # All peaks in same cell — single large colony # Build markers: one ID per distinct grid cell cell_to_id: dict[tuple[int, int], int] = {} marker_id = 1 markers = np.zeros_like(mask_patch, dtype=np.int32) for pr, pc in coords: abs_r = pr + r_min abs_c = pc + c_min ri = max(int(np.searchsorted(row_edges, abs_r, side="right")) - 1, 0) ci = max(int(np.searchsorted(col_edges, abs_c, side="right")) - 1, 0) cell_key = (ri, ci) if cell_key not in cell_to_id: cell_to_id[cell_key] = marker_id marker_id += 1 markers[pr, pc] = cell_to_id[cell_key] # Watershed split ws = watershed(-edt, markers, mask=mask_patch) # Write split sub-labels into out_labeled ws_labels = np.unique(ws) ws_labels = ws_labels[ws_labels != 0] for i, ws_lbl in enumerate(ws_labels): sub_mask = ws == ws_lbl abs_rows = np.where(sub_mask)[0] + r_min abs_cols = np.where(sub_mask)[1] + c_min out_labeled[abs_rows, abs_cols] = next_label + i next_label += len(ws_labels) return out_labeled @staticmethod def _compute_object_centroids( labeled: np.ndarray, intensity: np.ndarray | None = None, ) -> dict[int, tuple[float, float]]: """Compute centroids for all labeled objects. Args: labeled: Labeled array (0 = background, >0 = objects). intensity: Optional intensity image for weighted centroids. If provided, computes intensity-weighted center of mass. If None, computes geometric centroids. Returns: Dict mapping each label to its (row, col) centroid. """ unique_labels = np.unique(labeled) unique_labels = unique_labels[unique_labels != 0] if len(unique_labels) == 0: return {} if intensity is not None: coms = ndimage.center_of_mass(intensity, labeled, unique_labels.tolist()) else: coms = ndimage.center_of_mass( np.ones_like(labeled, dtype=np.uint8), labeled, unique_labels.tolist() ) return {int(lbl): (float(cr), float(cc)) for lbl, (cr, cc) in zip(unique_labels, coms)} @staticmethod def _assign_grid_objects( labeled: np.ndarray, row_edges: np.ndarray, col_edges: np.ndarray, selection_mode: Literal["dominant", "centered", "regularized"], dtype: np.dtype[Any] | type, intensity: np.ndarray | None = None, split_merged: bool = True, ) -> np.ndarray: """Assign one object per grid cell using centroid-based whole-object assignment. Each object is assigned to the grid cell containing its centroid. The entire object (all pixels) is written to the output — no boundary cleaving. When multiple objects map to the same cell the *selection_mode* tie-break determines which one wins. Args: labeled: Full labeled array (0 = background, >0 = objects). row_edges: Sorted array of row boundary positions. col_edges: Sorted array of column boundary positions. selection_mode: ``"dominant"`` keeps the largest object per cell, ``"centered"`` keeps the most centred object, and ``"regularized"`` uses a two-pass global fit to recover expected colony positions from median row/column centroids. dtype: NumPy dtype for the output array. intensity: Optional intensity image for weighted centroids. If provided, centroids are intensity-weighted. split_merged: If True (default), pre-split merged colonies that span multiple grid cells using EDT watershed before assignment. Returns: A new labeled array with sequential labels (1, 2, 3, ...). """ # Optional EDT pre-splitting if split_merged: labeled = GridInferenceMixin._split_merged_objects( labeled, row_edges, col_edges ) nrows = len(row_edges) - 1 ncols = len(col_edges) - 1 refined_map = np.zeros_like(labeled, dtype=dtype) # Compute centroids for all objects centroids = GridInferenceMixin._compute_object_centroids(labeled, intensity) # Pre-compute pixel counts uniq_all, counts_all = np.unique(labeled, return_counts=True) label_pixel_counts: dict[int, int] = { int(lbl): int(cnt) for lbl, cnt in zip(uniq_all, counts_all) if lbl != 0 } # Map each object to a grid cell via its centroid cell_to_labels: dict[tuple[int, int], list[int]] = {} for lbl, (cr, cc) in centroids.items(): ri = int(np.clip( np.searchsorted(row_edges, cr, side="right") - 1, 0, nrows - 1 )) ci = int(np.clip( np.searchsorted(col_edges, cc, side="right") - 1, 0, ncols - 1 )) cell_to_labels.setdefault((ri, ci), []).append(lbl) if selection_mode in ("dominant", "centered"): label_counter = 1 for r in range(nrows): for c in range(ncols): labels_in_cell = cell_to_labels.get((r, c)) if not labels_in_cell: continue if len(labels_in_cell) == 1: chosen = labels_in_cell[0] elif selection_mode == "dominant": chosen = max(labels_in_cell, key=lambda lb: label_pixel_counts.get(lb, 0)) else: # centered cell_center_r = (row_edges[r] + row_edges[r + 1]) / 2.0 cell_center_c = (col_edges[c] + col_edges[c + 1]) / 2.0 chosen = min( labels_in_cell, key=lambda lb: ( (centroids[lb][0] - cell_center_r) ** 2 + (centroids[lb][1] - cell_center_c) ** 2 ), ) refined_map[labeled == chosen] = label_counter label_counter += 1 return refined_map # --- regularized: two-pass global approach --- # Pass 1: collect dominant per cell + record centroids candidates: list[tuple[int, int, float, float]] = [] for r in range(nrows): for c in range(ncols): labels_in_cell = cell_to_labels.get((r, c)) if not labels_in_cell: continue dominant = max(labels_in_cell, key=lambda lb: label_pixel_counts.get(lb, 0)) cr, cc = centroids[dominant] candidates.append((r, c, cr, cc)) # Fit global grid model: median row/column positions expected_r = np.empty(nrows) expected_c = np.empty(ncols) for i in range(nrows): row_vals = [abs_r for (ri, _, abs_r, _) in candidates if ri == i] if row_vals: expected_r[i] = float(np.median(row_vals)) else: expected_r[i] = (row_edges[i] + row_edges[i + 1]) / 2.0 for j in range(ncols): col_vals = [abs_c for (_, cj, _, abs_c) in candidates if cj == j] if col_vals: expected_c[j] = float(np.median(col_vals)) else: expected_c[j] = (col_edges[j] + col_edges[j + 1]) / 2.0 # Pass 2: re-select closest to expected position label_counter = 1 for r in range(nrows): for c in range(ncols): labels_in_cell = cell_to_labels.get((r, c)) if not labels_in_cell: continue best_label = min( labels_in_cell, key=lambda lb: ( (centroids[lb][0] - expected_r[r]) ** 2 + (centroids[lb][1] - expected_c[c]) ** 2 ), ) refined_map[labeled == best_label] = label_counter label_counter += 1 return refined_map