"""Grid-based watershed segmentation for separating touching colonies.
Refines detected colonies by separating touching/merged objects using watershed segmentation
seeded at grid intersection points inferred from colony layout.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING:
from phenotypic._core._grid_image import GridImage
from phenotypic._core._image import Image
import gc
import numpy as np
from phenotypic.abc_ import ObjectRefiner
from phenotypic.sdk_.typing_ import TuneSpec
[docs]
class SeparateObjects(ObjectRefiner):
"""Separate touching or merged colonies using distance-transform watershed segmentation.
Finds peaks in the Euclidean distance transform of the object mask and
uses them as seed markers for watershed region growing, producing a
distinct labeled region for each colony. For GridImages, one peak per
grid cell is used; for plain Images, peaks are spaced by ``min_distance``.
For a comparison of colony separation strategies, see
:doc:`/explanation/refinement_strategies`.
Best For:
- Pinned library plates (96-well, 384-well) where thresholding
merges adjacent colonies into a single detection.
- Dense yeast or bacterial arrays where colony boundaries are
difficult to resolve by thresholding alone.
- Variable colony sizes where the distance transform naturally
adapts seed strength to each colony's diameter.
- Non-grid images where a global minimum-spacing constraint is
sufficient to individualize colonies.
Consider Also:
- :class:`MaskOpening` for gentle morphological separation of
lightly touching colonies without watershed.
- :class:`MaskErosion` when uniform inward shrinking is enough to
break weak contact between adjacent colony masks.
- :class:`GridAlignmentRefiner` when off-grid placement artefacts
are the primary concern rather than merged regions.
Args:
min_distance: Minimum pixel distance between watershed seed peaks
for plain Images. Ignored for GridImages, which use one peak
per grid cell. Higher values reduce over-segmentation; lower
values detect more seeds and produce finer splits. Typical
range: 5--50. A reasonable starting point is roughly the
expected minimum colony radius, so that each colony seeds at
most one peak. Default: 10.
Returns:
Image: Input image with ``objmap`` updated so that previously
merged colonies are separated into distinct labeled regions.
Raises:
ValueError: If no peaks are detected in the score map, or if
watershed produces an empty result.
See Also:
:doc:`/how_to/notebooks/merge_fragmented_detections` for
separation and merging workflows on real plate images.
:doc:`/explanation/refinement_strategies` for a comparison of
colony separation approaches.
"""
min_distance: Annotated[int, TuneSpec(5, 50, log=True)] = 10
@staticmethod
def _make_elevation_map(score_map: np.ndarray) -> np.ndarray:
"""Compute elevation map for watershed segmentation.
Args:
score_map: Combined intensity/distance score map from peak detection.
Returns:
Elevation map where high-score regions become valleys (local minima).
Note:
Inverts score_map so markers at high-score colony centers are placed
in valleys, allowing watershed to flood into adjacent regions.
"""
# Invert score_map: high scores → low elevation (valleys)
# Markers at peaks (high score) become markers at valleys (low elevation)
# Watershed floods from valleys upward into surrounding regions
inverted = 1.0 - score_map
return inverted.astype(np.float32)
@staticmethod
def _compute_peak_scores(
objmask: np.ndarray,
distance: np.ndarray,
) -> np.ndarray:
"""Compute distance-based score for peak detection.
Uses distance transform to identify colony centers (far from edges).
Peaks are placed at positions maximizing distance from object boundaries.
Args:
objmask: Binary mask of detected objects.
distance: Distance transform (distance from edges).
Returns:
Normalized score map where higher values indicate better peak positions.
"""
# Normalize distance to [0, 1]
score = distance.astype(float)
if score.max() > 0:
score /= score.max()
# Constrain to objmask region
score[~objmask] = 0
return score
def _find_peaks_gridimage(
self,
score_map: np.ndarray,
objmask: np.ndarray,
row_edges: np.ndarray,
col_edges: np.ndarray,
) -> np.ndarray:
"""Find one peak per grid cell using grid boundaries.
Iterates through each grid cell and finds the position with maximum score.
Only creates peaks in cells that contain detected objects.
Args:
score_map: Combined intensity/distance score map.
objmask: Binary mask of detected objects.
row_edges: Grid row boundaries (nrows+1 values).
col_edges: Grid column boundaries (ncols+1 values).
Returns:
Array of shape (N, 2) with (row, col) peak positions in image coordinates.
"""
peaks = []
# Iterate through each grid cell
for r in range(len(row_edges) - 1):
r0, r1 = row_edges[r], row_edges[r + 1]
for c in range(len(col_edges) - 1):
c0, c1 = col_edges[c], col_edges[c + 1]
# Extract cell region
cell_score = score_map[r0:r1, c0:c1]
cell_mask = objmask[r0:r1, c0:c1]
# Skip empty cells
if not cell_mask.any():
continue
# Find peak in cell (constrained to objmask)
cell_score_masked = cell_score.copy()
cell_score_masked[~cell_mask] = -np.inf
# Get position of maximum score
peak_r_local, peak_c_local = np.unravel_index(
cell_score_masked.argmax(), cell_score_masked.shape
)
# Convert to image coordinates
peak_r = r0 + peak_r_local
peak_c = c0 + peak_c_local
peaks.append([peak_r, peak_c])
return np.array(peaks, dtype=int)
def _find_peaks_regular(
self,
score_map: np.ndarray,
objmask: np.ndarray,
min_distance: int,
) -> np.ndarray:
"""Find peaks globally using minimum distance constraints.
Uses peak_local_max to find local maxima with specified minimum spacing.
Only finds peaks within detected object regions.
Args:
score_map: Combined intensity/distance score map.
objmask: Binary mask of detected objects.
min_distance: Minimum pixel distance between peaks.
Returns:
Array of shape (N, 2) with (row, col) peak positions.
"""
from skimage.feature import peak_local_max
# Find peaks with minimum distance constraint
peaks = peak_local_max(
score_map,
min_distance=min_distance,
labels=objmask.astype(int), # Constrain to objmask
exclude_border=False,
)
return peaks
@staticmethod
def _create_markers_from_peaks(
objmask: np.ndarray,
peaks: np.ndarray,
) -> np.ndarray:
"""Create watershed markers from peak positions.
Places unique integer labels at each peak position to serve as
watershed seed markers.
Args:
objmask: Binary mask of detected objects.
peaks: Array of shape (N, 2) with (row, col) peak coordinates.
Returns:
Integer marker array with unique labels at peak positions.
Note:
Peaks are assumed to be within image bounds and objmask regions.
No validation or filtering is performed.
"""
markers = np.zeros_like(objmask, dtype=np.int32)
# Place markers at peak positions
for idx, (peak_r, peak_c) in enumerate(peaks, start=1):
markers[peak_r, peak_c] = idx
return markers
def _operate(self, image: Image | GridImage) -> Image:
"""Separate touching colonies using intensity and distance-based peak detection.
This method finds peaks based on intensity and distance transform, then uses
watershed segmentation to separate touching colonies. For GridImages, peaks
are constrained to one per grid cell. For regular Images, peaks are spaced
by minimum distance.
Returns:
Image: Modified image with separated objmap and updated objmask.
Raises:
ValueError: If no peaks detected or watershed fails.
"""
from scipy.ndimage import distance_transform_edt
from skimage import segmentation
import logging
logger = logging.getLogger(__name__)
objmask = image.objmask[:]
logger.info(
f"SeparateObjects: objmask shape={objmask.shape}, True pixels={objmask.sum()}")
# Compute distance transform for peak detection
distance = distance_transform_edt(objmask)
logger.info(
f"SeparateObjects: distance range=[{distance.min():.2f}, {distance.max():.2f}]")
score_map = self._compute_peak_scores(objmask, distance)
logger.info(
f"SeparateObjects: score_map range=[{score_map.min():.2f}, {score_map.max():.2f}], non-zero={np.count_nonzero(score_map)}")
# Peak detection: GridImage vs regular Image
try:
from phenotypic import GridImage
is_gridimage = isinstance(image, GridImage)
except ImportError:
is_gridimage = False
logger.info(f"SeparateObjects: is_gridimage={is_gridimage}")
if is_gridimage:
# Use grid boundaries to constrain peaks (one per cell)
row_edges = np.round(image.grid.get_row_edges()).astype(
int) # type: ignore[attr-defined]
col_edges = np.round(image.grid.get_col_edges()).astype(
int) # type: ignore[attr-defined]
logger.info(
f"SeparateObjects: grid={len(row_edges) - 1}x{len(col_edges) - 1} cells")
peaks = self._find_peaks_gridimage(score_map, objmask, row_edges, col_edges)
else:
# Use global peak detection with minimum distance
peaks = self._find_peaks_regular(score_map, objmask, self.min_distance)
logger.info(f"SeparateObjects: detected {len(peaks)} peaks")
if len(peaks) > 0:
logger.info(f"SeparateObjects: peak positions sample: {peaks[:5]}")
# Create markers from peaks
markers = self._create_markers_from_peaks(objmask, peaks)
logger.info(
f"SeparateObjects: markers max={markers.max()}, non-zero pixels={np.count_nonzero(markers)}")
# Validate that at least one marker was created
if markers.max() == 0:
raise ValueError(
"No valid peaks detected - no local maxima found in score map. "
"Check that image has detected objects (objmask) and try adjusting "
"use_intensity/use_distance parameters or min_distance for regular Images."
)
logger.info(f"SeparateObjects: using {markers.max()} markers for watershed")
# Compute elevation map for watershed segmentation
# Use same metric as peak detection so markers are at local minima
elevation = self._make_elevation_map(score_map)
logger.info(
f"SeparateObjects: elevation range=[{elevation.min():.2f}, {elevation.max():.2f}]")
# Watershed segmentation
objmap = segmentation.watershed(
elevation,
markers=markers,
mask=objmask, # Limit to detected regions only
)
n_regions = objmap.max()
n_labeled_pixels = np.sum(objmap > 0)
logger.info(
f"SeparateObjects: watershed result - max_label={n_regions}, labeled_pixels={n_labeled_pixels}")
# Validate result
if objmap.max() == 0:
n_markers = markers.max()
raise ValueError(
f"Watershed produced empty result. Created {n_markers} markers but "
f"got 0 regions. Labeled {n_labeled_pixels} pixels. "
f"objmask has {objmask.sum()} True pixels."
)
# Convert to proper dtype
if objmap.dtype != image._OBJMAP_DTYPE:
objmap = objmap.astype(image._OBJMAP_DTYPE)
# Update image with separated map
# NOTE: Do NOT call relabel() - it would merge touching regions back together,
# undoing the watershed separation. Watershed already produces properly labeled regions.
image.objmap[:] = objmap
gc.collect()
return image