from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING:
from phenotypic._core._image import Image
import numpy as np
from pydantic import model_validator
from scipy.ndimage import gaussian_laplace
from typing_extensions import Self
from ..abc_ import FocusBlob
from ..sdk_.typing_ import TuneSpec
_SQRT2 = np.sqrt(2.0)
[docs]
class FocusBlobLoG(FocusBlob):
"""Enhance blob-like colonies in ``detect_mat`` with scale-normalised Laplacian of Gaussian.
Applies LoG filtering across a geometric series of Gaussian sigmas and
returns the pixel-wise maximum response across all scales. Bright blob-like
structures (colonies, inocula, droplets) produce strong peaks regardless of
size, making this a robust preprocessing step before thresholding or
GMM-based segmentation. The output ``detect_mat`` is a response map, not a
corrected grayscale image.
For algorithm details, see :doc:`/explanation/what_enhancement_does`.
Best For:
- Mixed-size colonies on mature plates where small emerging and large
mature colonies must both be detected in a single pass.
- Sparse inoculation spots that are faint and nearly invisible against
the agar background.
- Low-contrast or shadowed regions where the LoG emphasizes blob
structure over absolute intensity.
- Preprocessing before thresholding to sharpen blob boundaries and
suppress gradual illumination variation.
Consider Also:
- :class:`FocusEdgeSato` for elongated or filamentous colony structures
where the LoG's isotropic assumption is a poor fit.
- :class:`FocusEdgeLaplace` for simpler single-scale edge detection when
colony sizes are uniform.
- :class:`SubtractGaussian` when the primary issue is a broad
illumination gradient rather than blob emphasis.
Args:
min_radius: Smallest target blob radius in pixels. Blobs smaller than
this produce weaker LoG responses. Typical range: 1.0--15.0 at
standard resolutions; scale proportionally for higher-resolution
scans. Default: 3.0.
max_radius: Largest target blob radius in pixels. Blobs larger than
this also produce weaker responses. Typical range: 8.0--60.0 at
standard resolutions. Default: 12.0.
num_scales: Number of logarithmically spaced sigma values between
``min_radius / sqrt(2)`` and ``max_radius / sqrt(2)``. More scales
improve size discrimination across the radius range at higher
compute cost. Typical range: 4--20. Default: 12.
Returns:
Image: Input image with ``detect_mat`` replaced by the scale-normalised
LoG response map. ``rgb`` and ``gray`` are unchanged.
Raises:
ValueError: If ``min_radius`` <= 0, ``min_radius`` >= ``max_radius``,
or ``num_scales`` < 1.
See Also:
:doc:`/tutorials/notebooks/03_enhancing_before_detection` for a visual
walkthrough of blob enhancement on plate images.
:doc:`/explanation/what_enhancement_does` for background on scale-space
blob detection and LoG theory.
"""
# TODO: review bound (unverified vs literature)
min_radius: Annotated[float, TuneSpec(1.0, 5.0)] = 3.0
# TODO: review bound (unverified vs literature)
max_radius: Annotated[float, TuneSpec(8.0, 50.0)] = 12.0
num_scales: Annotated[int, TuneSpec(4, 20)] = 12
@model_validator(mode="after")
def _check_radius_range(self) -> Self:
"""Reproduce the pre-migration ``__init__`` radius/scale guards.
The ``min_radius >= max_radius`` check spans two fields, so it
lives in a model validator rather than a per-field validator.
Raises:
ValueError: If ``min_radius`` <= 0, ``min_radius`` >=
``max_radius``, or ``num_scales`` < 1.
"""
if self.min_radius <= 0:
raise ValueError(
f"min_radius must be positive, got {self.min_radius}"
)
if self.min_radius >= self.max_radius:
raise ValueError(
f"min_radius ({self.min_radius}) must be less than "
f"max_radius ({self.max_radius})"
)
if self.num_scales < 1:
raise ValueError(
f"num_scales must be >= 1, got {self.num_scales}"
)
return self
@staticmethod
def _enhance(
array: np.ndarray,
min_radius: float = 3.0,
max_radius: float = 12.0,
num_scales: int = 12,
) -> np.ndarray:
"""Multi-scale Laplacian of Gaussian blob enhancement (core kernel).
Applies scale-normalised Laplacian of Gaussian (LoG) filtering across a
geometric series of Gaussian sigmas and returns the maximum response at each
pixel. This is the core computation called by _operate() and available for
direct use on standalone arrays.
Args:
array (numpy.ndarray): 2-D grayscale array, shape (height, width).
Typically normalized to [0, 1] or [0, 255]. dtype should be float.
min_radius (float): Smallest target blob radius in pixels. The
corresponding Gaussian sigma is ``min_radius / sqrt(2)``. Blobs
smaller than this produce weaker responses. Default: 3.0.
max_radius (float): Largest target blob radius in pixels. The
corresponding Gaussian sigma is ``max_radius / sqrt(2)``. Blobs
larger than this also produce weaker responses. Default: 12.0.
num_scales (int): Number of logarithmically spaced sigma values
between ``min_radius / sqrt(2)`` and ``max_radius / sqrt(2)``.
More scales provide finer size resolution at cost of speed.
Default: 12.
Returns:
numpy.ndarray: Scale-normalised LoG response, same shape and dtype as
*array*. Each pixel contains the maximum absolute LoG response across
all scales, multiplied by sigma squared for scale normalization. All
values are non-negative (≥ 0.0). Output range depends on input
contrast and is typically [0, max_response] where max_response varies
with image content.
Raises:
ValueError: If min_radius <= 0, min_radius >= max_radius, or
num_scales < 1.
Notes:
At each scale σ, the Laplacian of Gaussian produces a response
proportional to local curvature. The response is scaled by σ² so that
blobs of different sizes produce comparable peak magnitudes. A max
projection across scales selects the strongest response at each pixel,
yielding size-invariant blob detection: a 3-pixel and a 12-pixel blob
both produce strong peaks if they fall within [min_radius, max_radius].
This function is the low-level kernel; for image processing via the
PhenoTypic pipeline, use the FocusBlobLoG class instead.
Examples:
Direct kernel use on a random array:
>>> import numpy as np
>>> from phenotypic.enhance._focus_blob_log import (
... FocusBlobLoG,
... )
>>> rng = np.random.default_rng(0)
>>> arr = rng.random((64, 64))
>>> out = FocusBlobLoG._enhance(arr)
>>> out.shape
(64, 64)
>>> out.min() >= 0.0
True
LoG enhancement on a synthetic image with known blob:
>>> import numpy as np
>>> from phenotypic.enhance._focus_blob_log import (
... FocusBlobLoG,
... )
>>> # Create a simple blob (Gaussian)
>>> y, x = np.ogrid[:100, :100]
>>> blob = np.exp(-((x - 50)**2 + (y - 50)**2) / 100.0)
>>> out = FocusBlobLoG._enhance(blob, min_radius=3, max_radius=12)
>>> peak_pos = np.unravel_index(out.argmax(), out.shape)
>>> abs(peak_pos[0] - 50) <= 2 and abs(peak_pos[1] - 50) <= 2
True
"""
if min_radius <= 0:
raise ValueError(f"min_radius must be positive, got {min_radius}")
if min_radius >= max_radius:
raise ValueError(
f"min_radius ({min_radius}) must be less than max_radius ({max_radius})"
)
if num_scales < 1:
raise ValueError(f"num_scales must be >= 1, got {num_scales}")
min_sigma = min_radius / _SQRT2
max_sigma = max_radius / _SQRT2
sigmas = np.geomspace(min_sigma, max_sigma, num_scales)
enhanced = np.zeros_like(array)
for sigma in sigmas:
log_response = gaussian_laplace(array, sigma=sigma)
scale_norm = sigma ** 2 * np.abs(log_response)
np.maximum(enhanced, scale_norm, out=enhanced)
return enhanced
def _operate(self, image: Image) -> Image:
image.detect_mat[:] = self._enhance(
array=image.detect_mat[:],
min_radius=self.min_radius,
max_radius=self.max_radius,
num_scales=self.num_scales,
)
return image