Source code for phenotypic.detect._inoculum_detector

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 Annotated, Literal

import numpy as np
from pydantic import Field, model_validator
from typing_extensions import Self

from phenotypic.abc_ import ObjectDetector
from phenotypic.sdk_.typing_ import TuneSpec
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._focus_blob_log import FocusBlobLoG
from phenotypic.enhance._subtract_gaussian import SubtractGaussian
from phenotypic.refine._extract_colony_core import ExtractColonyCore
from phenotypic.refine._keep_section_largest import KeepSectionLargest

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`. 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. 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. Typical range: 5--80. Default: 30.0. max_diameter: Largest expected inoculum diameter in pixels. Used to derive Gaussian background subtraction sigma and LoG maximum radius. Must be greater than ``min_diameter``. Typical range: 50--300. Default: 100.0. thresh_method: Thresholding method for binary segmentation within the RoundPeaksDetector step. 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'``. enable_gmm: Apply Gaussian Mixture Model core extraction to refine detected regions to bright, compact cores. Disable for inocula that lack clear core-surround structure. Default: True. gmm_n_components: Number of GMM components per region. Two components separate core from surround; increase only for complex multi-layered spots. Default: 2. gmm_separation_threshold: Normalised Euclidean distance between GMM component means below which a region is left unmodified (no clear core). Increase to make refinement less aggressive. Typical range: 0.8--1.2. Default: 0.9. validate_obj_count: When True and the input is a ``GridImage``, raise ``ValueError`` when the detected object count exceeds ``nrows * ncols``. Catches over-segmentation early. Default: True. Returns: Image: Input image with ``objmask`` (binary inoculum mask) and ``objmap`` (labelled inoculum map) populated. Raises: ValueError: If ``min_diameter`` >= ``max_diameter``, or if detected object count exceeds grid capacity when ``validate_obj_count`` is True and input is a GridImage. 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. """ # TODO: review bound (unverified vs literature) min_diameter: Annotated[float, TuneSpec(5.0, 80.0, log=True)] = Field(30.0, gt=0.0) # TODO: review bound (unverified vs literature) max_diameter: Annotated[float, TuneSpec(50.0, 300.0, log=True)] = Field(100.0, gt=0.0) thresh_method: Literal[ "otsu", "mean", "local", "triangle", "minimum", "isodata", "li" ] = "otsu" enable_gmm: bool = True gmm_n_components: Annotated[int, TuneSpec(2, 4)] = 2 # TODO: review bound (unverified vs literature) gmm_separation_threshold: Annotated[float, TuneSpec(0.8, 1.2)] = 0.9 validate_obj_count: bool = True @model_validator(mode="after") def _check_diameter_order(self) -> Self: """Require ``min_diameter`` to be strictly less than ``max_diameter``. Reproduces the cross-field guard from the pre-migration ``__init__``; the per-field positivity checks are enforced by the ``Field(gt=0.0)`` constraints on ``min_diameter`` / ``max_diameter`` above. """ if self.min_diameter >= self.max_diameter: raise ValueError( f"min_diameter ({self.min_diameter}) must be less than " f"max_diameter ({self.max_diameter})" ) return self 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 --- FocusBlobLoG( min_radius=log_min_radius, max_radius=log_max_radius, num_scales=15, ).apply(work, inplace=True) self._log_memory_usage("FocusBlobLoG") # --- 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): KeepSectionLargest().apply(work, inplace=True) self._log_memory_usage("KeepSectionLargest") # --- Step 9: Optional GMM core extraction --- if self.enable_gmm: ExtractColonyCore( 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("ExtractColonyCore") # --- 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