from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from phenotypic._core._image import Image
import gc
import logging
from typing import Literal
import numpy as np
from phenotypic.abc_ import ObjectDetector
from phenotypic.detect._round_peaks_detector import RoundPeaksDetector
from phenotypic.enhance._contrast_streching import ContrastStretching
from phenotypic.enhance._gray_opening import GrayOpening
from phenotypic.enhance._median_filter import MedianFilter
from phenotypic.enhance._multiscale_log_enhancer import MultiscaleLoGEnhancer
from phenotypic.enhance._subtract_gaussian import SubtractGaussian
from phenotypic.refine._gmm_core_extractor import GMMCoreExtractor
from phenotypic.refine._grid_section_largest import GridSectionLargest
logger = logging.getLogger(__name__)
[docs]
class InoculumDetector(ObjectDetector):
"""Detect inoculation sites on agar plates via Gaussian background subtraction and multi-scale blob enhancement.
Identify inoculation spots (from pin-deposition tools, liquid spotting,
or serial dilutions) by composing Gaussian background subtraction, median
filtering, multi-scale Laplacian-of-Gaussian blob enhancement, contrast
stretching, morphological opening, round-peaks detection, and optional
GMM-based core extraction. All processing occurs on a working copy so
that ``image.detect_mat`` is never modified. For a full comparison see
:doc:`/explanation/detection_strategies_compared`.
Args:
min_diameter: Smallest expected inoculum diameter in pixels. Used
to derive LoG minimum radius and GMM morphological parameters.
Set based on the smallest visible spot in your images. Default
30.0. Typical range: 5--80.
max_diameter: Largest expected inoculum diameter in pixels. Used
to derive Gaussian background subtraction sigma and LoG maximum
radius. Default 100.0. Typical range: 50--300. Must be greater
than *min_diameter*.
thresh_method: Thresholding method for binary segmentation within
the RoundPeaksDetector step. Options: ``'otsu'`` (default),
``'mean'``, ``'local'``, ``'triangle'``, ``'minimum'``,
``'isodata'``, ``'li'``. ``'otsu'`` works well for most
standardised setups; ``'local'`` adapts to spatial illumination
gradients.
enable_gmm: If True (default), apply Gaussian Mixture Model core
extraction to refine detected regions to bright, compact cores.
Disable for inocula that lack clear core-surround structure.
gmm_n_components: GMM components per region (default 2). 2
separates core from surround; increase only for complex
multi-layered spots.
gmm_separation_threshold: Normalised Euclidean distance between
GMM component means. Below this threshold a region is left
unmodified (no clear core). Default 0.9. Typical range:
0.8--1.2. Increase to make refinement less aggressive.
validate_obj_count: If True (default) and the input is a
``GridImage``, raise ``ValueError`` when the detected object
count exceeds ``nrows * ncols``. Catches over-segmentation
early.
Returns:
Image: Input image with ``objmask`` (binary inoculum mask) and
``objmap`` (labelled inoculum map) populated.
Raises:
ValueError: If detected object count exceeds grid capacity (when
*validate_obj_count* is True and input is a GridImage), or if
*min_diameter* >= *max_diameter*.
Best For:
* Pin-tool inoculation on high-density plates (96-well, 384-well)
where inocula are 30--80 pixels in diameter.
* Spot-dilution assays producing 10--150 pixel inocula across
serial dilution series.
* Pre-growth phenotyping baselines (T=0 imaging) for establishing
reference coordinates before growth measurements.
* Liquid spotting assays where GMM core extraction refines
boundaries for accurate area and circularity measurements.
Consider Also:
* :class:`RoundPeaksDetector` when colonies (not inocula) must be
detected on a regular grid without multi-scale blob enhancement.
* :class:`OtsuDetector` when inocula are high-contrast and a simple
global threshold is sufficient.
* :class:`FilamentousFungiDetector` when inoculation sites have
developed into filamentous fungal growth.
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.
"""
[docs]
def __init__(
self,
min_diameter: float = 30.0,
max_diameter: float = 100.0,
thresh_method: Literal[
"otsu", "mean", "local", "triangle", "minimum", "isodata", "li"
] = "otsu",
enable_gmm: bool = True,
gmm_n_components: int = 2,
gmm_separation_threshold: float = 0.9,
validate_obj_count: bool = True,
):
"""Initialise InoculumDetector with biology-driven parameters.
Args:
min_diameter: Smallest expected inoculum diameter (pixels).
Default: 30.0.
max_diameter: Largest expected inoculum diameter (pixels).
Default: 100.0.
thresh_method: Thresholding method. Default: ``'otsu'``.
enable_gmm: Apply GMM core extraction. Default: True.
gmm_n_components: GMM components per region. Default: 2.
gmm_separation_threshold: GMM mean separation threshold.
Default: 0.9.
validate_obj_count: Validate object count for GridImage.
Default: True.
"""
super().__init__()
if min_diameter <= 0:
raise ValueError(f"min_diameter must be positive, got {min_diameter}")
if max_diameter <= 0:
raise ValueError(f"max_diameter must be positive, got {max_diameter}")
if min_diameter >= max_diameter:
raise ValueError(
f"min_diameter ({min_diameter}) must be less than "
f"max_diameter ({max_diameter})"
)
self.min_diameter = min_diameter
self.max_diameter = max_diameter
self.thresh_method = thresh_method
self.enable_gmm = enable_gmm
self.gmm_n_components = gmm_n_components
self.gmm_separation_threshold = gmm_separation_threshold
self.validate_obj_count = validate_obj_count
def _operate(self, image: Image) -> Image:
"""Detect inoculation sites via composable Gaussian pipeline.
All enhancement and detection happens on a working copy; the returned
image has its ``objmask`` and ``objmap`` populated but ``detect_mat``
unchanged.
Args:
image: Image to process. May be ``Image`` or ``GridImage``.
Returns:
Image with ``objmask`` and ``objmap`` populated.
"""
from phenotypic import GridImage
# --- Derive internal parameters from diameter range ---
subtract_sigma = self.max_diameter * 2
log_min_radius = self.min_diameter / 2
log_max_radius = self.max_diameter / 2
gmm_morph_open = max(1, round(self.min_diameter / 30))
gmm_min_area = max(5, round(self.min_diameter * 0.8))
# --- Step 1: working copy with float32 detect_mat ---
work = image.copy()
# Direct _data access: the accessor (detect_mat[:] =) writes into the
# existing backing array, which would truncate float32 values if the
# backing is uint8. We must replace the entire array object.
dm = work._data.detect_mat
if dm.dtype.kind != "f":
work._data.detect_mat = dm.astype(np.float32) / np.iinfo(dm.dtype).max
elif dm.dtype != np.float32:
work._data.detect_mat = dm.astype(np.float32)
self._log_memory_usage("working copy created")
# --- Step 2: Gaussian background subtraction ---
SubtractGaussian(sigma=subtract_sigma, n_iter=2).apply(
work, inplace=True,
)
self._log_memory_usage("SubtractGaussian")
# --- Step 3: Median filter ---
MedianFilter(width=5, shape="square").apply(work, inplace=True)
self._log_memory_usage("MedianFilter")
# --- Step 4: Multi-scale LoG blob enhancement ---
MultiscaleLoGEnhancer(
min_radius=log_min_radius,
max_radius=log_max_radius,
num_scales=15,
).apply(work, inplace=True)
self._log_memory_usage("MultiscaleLoGEnhancer")
# --- Step 5: Contrast stretching ---
ContrastStretching().apply(work, inplace=True)
self._log_memory_usage("ContrastStretching")
# --- Step 6: Gray opening ---
GrayOpening(width=5, shape="disk", n_iter=2).apply(
work, inplace=True,
)
self._log_memory_usage("GrayOpening")
# --- Step 7: Round peaks detection ---
RoundPeaksDetector(
thresh_method=self.thresh_method,
noise_radius=2,
smoothing_sigma=0.0,
subtract_background=False,
edge_refinement=False,
).apply(work, inplace=True)
self._log_memory_usage("RoundPeaksDetector")
# --- Step 8: GridImage → keep largest per cell ---
if isinstance(work, GridImage):
GridSectionLargest().apply(work, inplace=True)
self._log_memory_usage("GridSectionLargest")
# --- Step 9: Optional GMM core extraction ---
if self.enable_gmm:
GMMCoreExtractor(
n_components=self.gmm_n_components,
separation_threshold=self.gmm_separation_threshold,
min_core_area=gmm_min_area,
morph_open_radius=gmm_morph_open,
morph_close_radius=2,
).apply(work, inplace=True)
self._log_memory_usage("GMMCoreExtractor")
# --- Step 10: Copy results back ---
image.objmask[:] = work.objmask[:]
image.objmap[:] = work.objmap[:]
image.objmap.relabel(connectivity=1)
del work
gc.collect()
# --- Step 11: Validate object count for GridImage ---
if self.validate_obj_count and isinstance(image, GridImage):
max_objects = image.nrows * image.ncols
num_objects = int(image.objmap[:].max())
if num_objects > max_objects:
raise ValueError(
f"Detected {num_objects} objects but GridImage has only "
f"{image.nrows}x{image.ncols} = {max_objects} cells. "
f"Set validate_obj_count=False to skip this check."
)
self._log_memory_usage(
"final cleanup", include_process=True, include_tracemalloc=True,
)
return image