Source code for phenotypic.detect._manual_point_detector

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, Any, Literal

import numpy as np
from pydantic import field_validator

if TYPE_CHECKING:
    from phenotypic._core._image import Image

from phenotypic.abc_ import ObjectDetector
from phenotypic.sdk_.mixin._footprint_mixin import FootprintMixin
from phenotypic.sdk_.mixin._point_picker_mixin import PointPickerMixin
from phenotypic.sdk_.typing_ import TuneSpec


[docs] class ManualPointDetector(ObjectDetector, PointPickerMixin, FootprintMixin): """Detect objects by stamping footprint masks at user-specified coordinates. Place a morphological footprint at each explicitly provided ``(y, x)`` centre coordinate to produce ``objmask`` and ``objmap``. Unlike :class:`ManualGridPointDetector`, which extrapolates a regular grid from one or two anchor points, this class requires an explicit list of *every* colony centre. This makes it suitable for plates without regular geometry, sparse or irregular layouts, and manual annotation workflows where each object position is known individually. Best For: * Manual annotation and ground-truth mask generation for benchmarking detection algorithms. * Non-grid plates (e.g., streak plates, random inoculations, environmental samples) where colony positions are irregular. * Validating other detection algorithms by comparing their output against user-curated centre coordinates. * Quick prototyping on small numbers of colonies without needing automatic detection. Consider Also: * :class:`ManualGridPointDetector` when colonies lie on a regular grid and only one or two anchor coordinates are needed. * :class:`RoundPeaksDetector` when colony centres can be inferred automatically from intensity profiles. Args: centers: An N x 2 array-like of ``(y, x)`` pixel coordinates, one per colony, supplied by the user (purely a manual choice). Accepts any sequence that ``np.asarray`` can convert (list of tuples, nested list, or NumPy array). When *None* (default) or empty, :meth:`apply` zeros out ``objmask`` and ``objmap`` and returns immediately. shape: Morphological footprint shape stamped at each coordinate. ``"disk"`` (default) preserves round colony geometry. ``"square"`` covers rectangular regions. ``"diamond"`` offers a compromise between the two. width: Diameter of the footprint in pixels. Larger values cover more area per colony (risking overlap of touching colonies); smaller values produce tighter, more precise masks. A reasonable starting point is roughly the visible colony diameter, then adjust by checking whether stamps under-cover large colonies or spill onto neighbours. Typical range: 5--50, depending on image resolution and colony size. Default: 15. Returns: Image: Input image with ``objmask`` set to the union of all stamped footprints and ``objmap`` set to uniquely labelled regions (1-indexed, in the order centres were supplied). 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. """ centers: list[tuple[float, float]] | None = None shape: Literal["square", "diamond", "disk"] = "disk" width: Annotated[int, TuneSpec(tunable=False)] = 15 @field_validator("centers", mode="before") @classmethod def _normalize_centers(cls, value: Any) -> list[tuple[float, float]] | None: """Coerce a list/tuple/``np.ndarray`` of centres to a list of tuples. Replaces the ``PointPickerMixin.__setattr__`` coercion removed in the pydantic v2 migration. ``None`` (the legacy "unset" sentinel) passes through unchanged; any array-like of ``(y, x)`` pairs is normalized to a plain ``list[tuple[float, float]]`` so the untouched ``_operate`` can iterate it. Coordinates are kept as floats (not pre-rounded) — ``_operate`` does ``int(round(...))`` per coordinate, exactly as before the migration. """ if value is None: return None if isinstance(value, np.ndarray): value = value.tolist() return [(float(y), float(x)) for y, x in value] def _operate(self, image: Image) -> Image: # type: ignore[override] h, w = image.shape[:2] if self.centers is None or len(self.centers) == 0: image.objmask[:] = np.zeros((h, w), dtype=bool) image.objmap[:] = np.zeros((h, w), dtype=np.int32) return image fp_mask = self._make_footprint(shape=self.shape, width=self.width).astype(bool) mask = np.zeros((h, w), dtype=bool) labeled = np.zeros((h, w), dtype=np.int32) for idx, (cy, cx) in enumerate(self.centers, start=1): self._stamp_footprint( mask, labeled, fp_mask, int(round(cy)), int(round(cx)), idx, ) image.objmask[:] = mask image.objmap[:] = labeled return image
ManualPointDetector.apply.__doc__ = ManualPointDetector._operate.__doc__