phenotypic.abc_.ObjectDetector#

class phenotypic.abc_.ObjectDetector[source]#

Bases: ImageOperation, ABC

Abstract base class for colony detection operations on agar plate images.

ObjectDetector defines the interface for algorithms that identify and label microbial colonies (or other objects) in image data. Detection is a critical step in the PhenoTypic image processing pipeline: it bridges image preprocessing (enhancement) and downstream analysis (measurement, refinement, and statistical analysis).

What does ObjectDetector do?

ObjectDetector subclasses analyze image data and produce two outputs that describe detected colonies:

  • image.objmask (binary mask): A 2D boolean array where True indicates colony pixels and False indicates background. Each True pixel belongs to some colony but the mask does not distinguish which colony each pixel belongs to—that is the role of objmap.

  • image.objmap (labeled map): A 2D integer array where each pixel value identifies the colony it belongs to. Background is 0, and each unique positive integer (1, 2, 3, …, N) represents a distinct labeled colony. This enables accessing individual colonies via image.objects after detection.

Key principle: ObjectDetector is READ-ONLY for image data

ObjectDetector operations:

  • Read image.enh_gray[:] (enhanced grayscale), image.rgb[:], and optionally other image data to inform detection.

  • Write only image.objmask[:] and image.objmap[:].

  • Protect image.rgb, image.gray, and image.enh_gray via automatic integrity validation (@validate_operation_integrity decorator).

Any attempt to modify protected image components raises OperationIntegrityError when VALIDATE_OPS=True in the environment (enabled during development/testing).

Why is detection central to the pipeline?

Detection enables:

  1. Object identification: Distinguishes individual colonies from background and from each other.

  2. Downstream analysis: Once colonies are labeled, image.objects provides access to properties (area, intensity, centroid, morphology) for each colony.

  3. Refinement: ObjectRefiner operations clean up detection masks/maps post-detection (e.g., removing spurious objects, merging fragments, filtering by size).

  4. Phenotyping: Measurement operations (MeasureFeatures) extract colony features (color, morphology, growth) for statistical analysis.

Differences: objmask vs objmap

  • objmask (binary): Answers “is this pixel part of any colony?” Simple, useful for visualization or as input to further processing (e.g., morphological operations). Generated by most detectors via thresholding or edge detection.

  • objmap (labeled): Answers “which colony does this pixel belong to?” Enables per-object analysis. Each colony has a unique integer label, and connected-component labeling (usually scipy.ndimage.label) assigns these labels.

Both are typically set together in _operate() via:

image.objmask[:] = binary_mask
image.objmap[:] = labeled_map

When to use ObjectDetector vs ThresholdDetector vs ObjectRefiner

  • ObjectDetector (this class): Implement when you have a novel algorithm that produces both objmask and objmap from image data. Examples: Otsu thresholding, Canny edges, peak detection (RoundPeaks), watershed.

  • ThresholdDetector (ObjectDetector subclass): Inherit from this if your detection relies on a threshold value. Provides common patterns and may offer utility methods. Examples: OtsuDetector, TriangleDetector, LocalThresholdDetector.

  • ObjectRefiner (different ABC): Use when modifying existing masks/maps without analyzing image data. Examples: size filtering, morphological cleanup, erosion/dilation, merging nearby objects, removing objects near borders.

How to implement a custom ObjectDetector

  1. Create the class:

    from phenotypic.abc_ import ObjectDetector
    from phenotypic import Image
    
    class MyDetector(ObjectDetector):
        def __init__(self, param1: float, param2: int = 10):
            super().__init__()
            self.param1 = param1
            self.param2 = param2
    
        @staticmethod
        def _operate(image: Image, param1: float, param2: int = 10) -> Image:
            # Detection logic here
            return image
    
  2. Within _operate(), read image data carefully:

    • Access via accessors: image.enh_gray[:], image.gray[:], image.rgb[:]

    • Never modify these; integrity validation will catch it

    • Consider the data type and range (uint8, uint16, float, etc.)

  3. Perform detection: Use your algorithm to create a binary mask and labeled map. Typical approaches:

    • Thresholding-based: Global or local threshold → binary mask → label

    • Edge-based: Edge detector (Canny) → invert edges → label regions

    • Peak-based: Detect intensity peaks → grow regions → label

    • Region-based: Watershed or morphological operations

  4. Create and set the binary mask and labeled map:

    from scipy import ndimage
    import numpy as np
    
    # Example: simple Otsu thresholding
    enh = image.enh_gray[:]
    threshold = skimage.filters.threshold_otsu(enh)
    binary_mask = enh > threshold
    
    # Remove small noise
    binary_mask = skimage.morphology.remove_small_objects(binary_mask, min_size=20)
    
    # Label connected components
    labeled_map, num_objects = ndimage.label(binary_mask)
    
    # Set both outputs
    image.objmask[:] = binary_mask
    image.objmap[:] = labeled_map
    
    return image
    
  5. Post-processing (optional): Some detectors include additional cleanup:

    • Clear borders: Use skimage.segmentation.clear_border() to remove objects touching image edges.

    • Remove small/large objects: Use skimage.morphology.remove_small_objects() or skimage.morphology.remove_large_objects() to filter by area.

    • Relabel: Call image.objmap.relabel(connectivity=...) to ensure consecutive labels.

Helper functions from scipy and scikit-image

Common utilities for ObjectDetector implementations:

  • scipy.ndimage.label(): Assigns unique integers to connected components in a binary mask. Returns (labeled_array, num_features). Specify structure for connectivity (default 3x3 with all 8 neighbors; use generate_binary_structure(2, 1) for 4-connectivity).

  • skimage.morphology.remove_small_objects(): Removes binary regions smaller than min_size pixels. Helpful for filtering noise or spurious detections.

  • skimage.morphology.remove_large_objects(): Removes regions larger than a threshold. Useful for excluding large artefacts or plate boundaries.

  • skimage.segmentation.clear_border(): Sets pixels on the image border to False, eliminating objects that touch the edge (common in arrayed imaging where wells at plate boundaries may be partially cut off).

  • skimage.morphology.binary_opening(): Erosion followed by dilation; removes small noise while preserving larger objects. Use with a suitable footprint (disk, square, or diamond).

  • scipy.ndimage.binary_dilation() / binary_erosion(): Expand or shrink objects morphologically. Useful for bridging fragmented colonies or removing small protrusions.

  • skimage.feature.canny(): Multi-stage edge detection (Gaussian → gradient → non-max suppression → hysteresis). Robust but requires threshold tuning.

Examples of different detection strategies

Otsu thresholding (simple, fast, global intensity)
from phenotypic.abc_ import ObjectDetector
from phenotypic import Image
from skimage import filters
from scipy import ndimage
import numpy as np

class SimpleOtsuDetector(ObjectDetector):
    """Detects colonies using global Otsu thresholding."""

    def __init__(self):
        super().__init__()

    @staticmethod
    def _operate(image: Image) -> Image:
        enh = image.enh_gray[:]
        # Compute threshold
        thresh = filters.threshold_otsu(enh)
        # Create binary mask
        mask = enh > thresh
        # Label connected components
        labeled, num = ndimage.label(mask)
        # Set results
        image.objmask[:] = mask
        image.objmap[:] = labeled
        return image

    # Usage:
    # detector = SimpleOtsuDetector()
    # plate = Image.from_image_path("plate.jpg")
    # result = detector.apply(plate)
    # colonies = result.objects  # Access detected colonies
Edge-based detection with Canny and post-processing
from phenotypic.abc_ import ObjectDetector
from phenotypic import Image
from skimage import feature, morphology
from scipy import ndimage
import numpy as np

class EdgeDetector(ObjectDetector):
    """Detects colonies by finding edges and labeling enclosed regions."""

    def __init__(self, sigma: float = 1.5, min_size: int = 50):
        super().__init__()
        self.sigma = sigma
        self.min_size = min_size

    @staticmethod
    def _operate(image: Image, sigma: float = 1.5,
                 min_size: int = 50) -> Image:
        enh = image.enh_gray[:]
        # Detect edges using Canny
        edges = feature.canny(enh, sigma=sigma)
        # Invert to get regions (not edges)
        regions = ~edges
        # Label connected regions
        labeled, num = ndimage.label(regions)
        # Remove small objects
        labeled = morphology.remove_small_objects(
            labeled, min_size=min_size)
        # Create binary mask from labeled map
        mask = labeled > 0
        # Set results
        image.objmask[:] = mask
        image.objmap[:] = labeled
        return image

    # Usage:
    # detector = EdgeDetector(sigma=1.0, min_size=100)
    # result = detector.apply(plate)
Peak detection with cleanup (RoundPeaks-inspired approach)
from phenotypic.abc_ import ObjectDetector
from phenotypic import Image
from scipy import ndimage
from scipy.signal import find_peaks
import numpy as np

class PeakColumnDetector(ObjectDetector):
    """Detects colonies via 1D peak finding on intensity profiles."""

    def __init__(self, min_distance: int = 15, threshold_abs: int = 100):
        super().__init__()
        self.min_distance = min_distance
        self.threshold_abs = threshold_abs

    @staticmethod
    def _operate(image: Image, min_distance: int = 15,
                 threshold_abs: int = 100) -> Image:
        enh = image.enh_gray[:]
        # Sum intensity along rows to get column profile
        col_sums = np.sum(enh, axis=0)
        # Find peaks in profile
        peaks, _ = find_peaks(col_sums, distance=min_distance,
                              height=threshold_abs)
        # Simple approach: threshold and label
        # (Production code would do more sophisticated grid inference)
        mask = enh > np.median(enh)  # Placeholder threshold
        labeled, num = ndimage.label(mask)
        image.objmask[:] = mask
        image.objmap[:] = labeled
        return image

When and how to refine detections (post-processing)

Raw detections often need cleanup:

  • Remove small noise: Spurious single-pixel detections or tiny salt-and-pepper artifacts. Use ObjectRefiner + remove_small_objects.

  • Clean borders: Colonies at plate edges may be incomplete. Use ObjectRefiner or clear_border() in detector.

  • Merge fragments: Noise or uneven lighting can fragment a single colony into multiple labels. Use ObjectRefiner with morphological dilation or connected-component merging.

  • Remove large objects: Plate edges, dust on the scanner, or agar artifacts appear as large regions. Use ObjectRefiner + remove_large_objects.

  • Grid-aware filtering: In arrayed formats (96-well, 384-well), one object per grid cell is expected. Use GridObjectRefiner to enforce this constraint or GridRefiner to assign dominant objects to grid positions.

Example pipeline with detection + refinement:

from phenotypic import Image, ImagePipeline
from phenotypic.detect import OtsuDetector
from phenotypic.refine import RemoveSmallObjectsRefiner, ClearBorderRefiner

pipeline = ImagePipeline()
pipeline.add(OtsuDetector())  # Initial detection
pipeline.add(ClearBorderRefiner())  # Remove edge-touching objects
pipeline.add(RemoveSmallObjectsRefiner(min_size=100))  # Filter noise

image = Image.from_image_path("plate.jpg")
result = pipeline.operate([image])[0]
# result now has clean, labeled colonies ready for measurement
None#
Type:

all operation parameters are stored in subclass instances

apply(image, inplace=False)[source]#

User-facing method to apply detection to an image. Handles copy/inplace logic and parameter matching.

_operate(image, **kwargs)#

Abstract static method implemented by subclasses with detection logic. Must set image.objmask and image.objmap.

Notes

  • Integrity protection: The @validate_operation_integrity decorator on apply() ensures image.rgb, image.gray, and image.enh_gray are not modified. Violations raise OperationIntegrityError during development (VALIDATE_OPS=True).

  • Binary mask is often intermediate: Many implementations create objmask first, then derive objmap via connected-component labeling. Both must be set for downstream code to work correctly.

  • Label consistency: Use image.objmap.relabel() after manipulating the labeled map to ensure labels are consecutive (1, 2, 3, …, N) and to update objmask.

  • Memory efficiency: Large images and detailed segmentations consume memory. Consider inplace=True in pipelines processing many images, or use sparse representations (objmap uses scipy.sparse internally).

  • Static _operate() method: Required for parallel execution in ImagePipeline. All parameters (except image) must be instance attributes.

Examples

Detect colonies in a plate image and access results
from phenotypic import Image
from phenotypic.detect import OtsuDetector

# Load a plate image
plate = Image.from_image_path("agar_plate.jpg")

# Apply detection
detector = OtsuDetector()
detected = detector.apply(plate)

# Access binary mask
mask = detected.objmask[:]  # numpy array
print(f"Mask shape: {mask.shape}, True pixels: {mask.sum()}")

# Access labeled map
objmap = detected.objmap[:]
print(f"Detected {objmap.max()} colonies")

# Iterate over colonies and measure properties
for colony in detected.objects:
    print(f"Colony area: {colony.area} px, "
          f"centroid: {colony.centroid}")
Custom detector with parameter tuning
from phenotypic.abc_ import ObjectDetector
from phenotypic import Image
from skimage import filters
from scipy import ndimage

class LocalThresholdDetector(ObjectDetector):
    """Detects colonies using adaptive local thresholding."""

    def __init__(self, block_size: int = 31):
        super().__init__()
        self.block_size = block_size

    @staticmethod
    def _operate(image: Image, block_size: int = 31) -> Image:
        enh = image.enh_gray[:]
        # Ensure block_size is odd
        if block_size % 2 == 0:
            block_size += 1
        # Apply local threshold
        threshold = filters.threshold_local(enh, block_size=block_size)
        mask = enh > threshold
        # Label
        labeled, num = ndimage.label(mask)
        # Set results
        image.objmask[:] = mask
        image.objmap[:] = labeled
        return image

# Usage
detector = LocalThresholdDetector(block_size=51)
result = detector.apply(plate)
print(f"Found {result.objmap[:].max()} colonies")
Detection in a full pipeline with enhancement and refinement
from phenotypic import Image, ImagePipeline
from phenotypic.enhance import GaussianBlur
from phenotypic.detect import CannyDetector
from phenotypic.refine import RemoveSmallObjectsRefiner
from phenotypic.measure import MeasureColor

# Create a processing pipeline
pipeline = ImagePipeline()
pipeline.add(GaussianBlur(sigma=2.0))  # Preprocessing
pipeline.add(CannyDetector(sigma=1.5))  # Detection
pipeline.add(RemoveSmallObjectsRefiner(min_size=50))  # Cleanup
pipeline.add(MeasureColor())  # Downstream analysis

# Load image and process
image = Image.from_image_path("plate.jpg")
result = pipeline.operate([image])[0]

# Results include enhanced image, detected/refined colonies, and measurements
print(f"Colonies: {result.objmap[:].max()}")
print(f"Measurements: {result.measurements.shape}")

Methods

__init__

apply

Binarizes the given image gray using the Yen threshold method.

dispose_widgets

Drop references to the UI widgets.

sync_widgets_from_state

Push internal state into widgets.

widget

Return (and optionally display) the root widget.

apply(image, inplace=False)[source]#

Binarizes the given image gray using the Yen threshold method.

This function modifies the arr image by applying a binary mask to its enhanced gray (enh_gray). The binarization threshold is automatically determined using Yen’s method. The resulting binary mask is stored in the image’s objmask attribute.

Parameters:

image (Image) – The arr image object. It must have an enh_gray attribute, which is used as the basis for creating the binary mask.

Returns:

The arr image object with its objmask attribute updated

to the computed binary mask other_image.

Return type:

Image

__del__()#

Automatically stop tracemalloc when the object is deleted.

__getstate__()#

Prepare the object for pickling by disposing of any widgets.

This ensures that UI components (which may contain unpickleable objects like input functions or thread locks) are cleaned up before serialization.

Note

This method modifies the object state by calling dispose_widgets(). Any active widgets will be detached from the object.

dispose_widgets() None#

Drop references to the UI widgets.

Return type:

None

sync_widgets_from_state() None#

Push internal state into widgets.

Return type:

None

widget(image: Image | None = None, show: bool = False) Widget#

Return (and optionally display) the root widget.

Parameters:
  • image (Image | None) – Optional image to visualize. If provided, visualization controls will be added to the widget.

  • show (bool) – Whether to display the widget immediately. Defaults to False.

Returns:

The root widget.

Return type:

ipywidgets.Widget

Raises:

ImportError – If ipywidgets or IPython are not installed.