Source code for phenotypic.abc_._threshold_detector

from __future__ import annotations
from typing import TYPE_CHECKING
from abc import ABC, abstractmethod

if TYPE_CHECKING:
    from phenotypic._core._image import Image

from ._object_detector import ObjectDetector


# <<Interface>>
[docs] class ThresholdDetector(ObjectDetector, ABC): """Marker ABC for threshold-based colony detection strategies. ThresholdDetector specializes ObjectDetector for algorithms that detect colonies by converting grayscale intensity to a binary mask via thresholding. Unlike edge-based (Canny) or peak-based (RoundPeaks) approaches, thresholding works by partitioning intensity space: pixels above a threshold value become foreground (colonies), pixels below become background. **Quick Decision Guide** Choose your detection strategy based on image characteristics: - **Threshold-based:** Clear intensity separation between colonies and background? Try global threshold (Otsu, Yen) or local adaptive (block-based) thresholding. - **Edge-based (CannyDetector):** Faint or merged colonies? Canny edge detection finds boundaries where gradient is high; invert edges to label regions. - **Peak-based (RoundPeaksDetector):** Well-separated round colonies? Peak detection assumes circular shapes and grows from intensity maxima. - **Subclass decision:** Is your algorithm threshold-based? Subclass ThresholdDetector. Otherwise subclass ObjectDetector directly. - **Local vs global threshold:** Uneven illumination? Local (adaptive) thresholding adjusts per neighborhood; global methods fail on gradient-heavy plates. - **Advanced strategy:** Need dual-threshold with edge tracking? See [HysteresisDetector](src/phenotypic/detect/_hysteresis_detector.py). **Why threshold-based detection?** Thresholding is ideal when: - **Clear intensity separation:** Colonies have distinctly different intensity than background (common on high-contrast agar plates or with good lighting). - **Simplicity and speed:** Single-pass algorithms (no iterative edge tracking or distance computation). - **Robustness to morphology:** Works equally well on round and irregular colonies (unlike peak-based approaches that assume circular shapes). - **Well-defined boundary:** Sharp transitions between foreground and background (less effective on blurry or faded colonies). **Thresholding strategies implemented in PhenoTypic** - [OtsuDetector](src/phenotypic/detect/_otsu_detector.py): Minimizes within-class variance. Automatic, global, works for balanced histograms. - [LiDetector](src/phenotypic/detect/_li_detector.py): Minimizes Kullback-Leibler divergence. Good for dark colonies on bright background. - [YenDetector](src/phenotypic/detect/_yen_detector.py): Maximizes object variance. Excellent for sharply defined colonies. - [TriangleDetector](src/phenotypic/detect/_triangle_detector.py): Connects histogram extrema. Works well for non-overlapping bimodal distributions. - [IsodataDetector](src/phenotypic/detect/_isodata_detector.py): Iteratively refines based on class means. Robust but slower. - [MeanDetector](src/phenotypic/detect/_mean_detector.py) / [MinimumDetector](src/phenotypic/detect/_minimum_detector.py): Simple heuristic thresholds. Fast, useful for baseline. - [HysteresisDetector](src/phenotypic/detect/_hysteresis_detector.py): Advanced dual-threshold with edge tracking. Handles variable colony intensity. **When to subclass ThresholdDetector vs ObjectDetector directly** Subclass ThresholdDetector if your algorithm uses threshold-based intensity partitioning: - Converts intensity to binary mask via threshold comparison (``mask = enh > threshold``). - Uses automatic threshold selection (Otsu, Li, Yen, Triangle, Isodata, etc.). - Uses simple heuristic thresholds (mean, minimum, percentile-based). - Signals intent to other developers: "this detector groups with thresholding methods." - May share utility methods in future (e.g., post-processing filters). Subclass ObjectDetector directly if your algorithm uses alternative strategies: - Edge detection (find gradients, not intensity levels). - Peak finding (assumes round shapes, grows from maxima). - Watershed segmentation or other region-based approaches. - Hybrid methods that don't fit threshold → binary mask → label pattern. **Typical workflow: enhance → threshold → label → refine** Most ThresholdDetector implementations follow this pipeline: 1. **Read detection matrix:** ``enh = image.detect_mat[:]`` (preprocessed for contrast and noise suppression). 2. **Compute threshold:** Use chosen strategy (Otsu, Li, Yen, etc.) to find optimal threshold value from histogram. 3. **Create binary mask:** ``mask = enh > threshold`` or ``mask = enh >= threshold`` (test both if edge pixels ambiguous). 4. **Post-process (optional):** Remove small noise, clear borders, morphological cleanup to improve mask quality. 5. **Label connected components:** Use ``scipy.ndimage.label()`` to assign unique integer IDs to each colony (objmap). 6. **Set both outputs:** ``image.objmask = mask``, ``image.objmap = labeled_map``. **Parameter tuning guidance** Threshold-based detectors expose parameters affecting detection quality: - **Threshold value (manual methods only):** Higher → fewer, larger colonies; lower → more, noisier. Test range on representative images to find balance. - **Block size (local/adaptive methods):** Larger blocks smooth mask but miss small colonies; smaller blocks add detail but amplify noise. Start with 1/8 to 1/4 of image width. - **ignore_zeros:** Skip pure black pixels in threshold computation. Useful when background has significant black regions (shadows, vignetting). - **ignore_borders:** Automatically remove objects touching image edges. Prevents partial colonies at plate edges from skewing analysis. - **min_size / max_size:** Post-processing filters. Remove objects below min (noise) or above max (artifacts). Measure typical colony size on your plates first. **Comparison with other detection strategies** - [CannyDetector](src/phenotypic/detect/_canny_detector.py) (edge-based): Finds intensity gradients to locate boundaries. Better for faint or merged colonies; requires tuning gradient thresholds. - [RoundPeaksDetector](src/phenotypic/detect/_round_peaks_detector.py) (peak-based): Assumes round shapes, grows from maxima. Excellent for well-separated round colonies; fails on irregular or merged shapes. - **Threshold-based (this class):** Direct intensity partitioning. Robust, fast, works for any shape; requires good intensity separation between colonies and background. **Common pitfalls and remedies** - **Over-segmentation (too many small objects):** Use ``ignore_zeros=True`` to skip dark pixels, apply morphological opening (remove_small_objects), or refine with ObjectRefiner. - **Under-segmentation (merged colonies):** Try local thresholding (adaptive block-based), morphological closing, or watershed post-processing in ObjectRefiner. - **False positives at edges:** Use ``ignore_borders=True`` parameter or ``clear_border()`` in ObjectRefiner to remove edge-touching objects. - **Uneven illumination (vignetting, shadows):** Apply enhancement (EnhanceLocalContrast, illumination correction) before detection, or switch to local adaptive thresholding. - **Threshold too high/low:** Visualize objmask on sample images to diagnose. Adjust parameters and re-test on representative plates before batch processing. **Local thresholding pattern (adaptive to uneven illumination)** When images have uneven illumination or vignetting, local (adaptive) thresholding computes a threshold per neighborhood instead of globally. This handles gradual intensity changes: .. code-block:: python from skimage import filters from scipy import ndimage import numpy as np enh = image.detect_mat[:] # Compute local threshold for each pixel block_size = 31 # Neighborhood size (odd integer) threshold_map = filters.threshold_local(enh, block_size=block_size) # Create mask: pixel > its local threshold mask = enh > threshold_map # Label connected components labeled, _ = ndimage.label(mask) image.objmask[:] = mask image.objmap[:] = labeled return image **Implementation pattern: Global automatic threshold** For global automatic thresholding (Otsu, Li, Yen, Triangle, Isodata), follow this pattern: .. code-block:: python from skimage import filters from scipy import ndimage def _operate(self, image): enh = image.detect_mat[:] # Compute threshold value via automatic method threshold = filters.threshold_otsu(enh) # or threshold_li, threshold_yen, etc. # Create binary mask: pixels above threshold mask = enh > threshold # Label connected components labeled, num_objects = ndimage.label(mask) # Set both outputs image.objmask[:] = mask image.objmap[:] = labeled return image Key points: Read preprocessed ``detect_mat``, compute single threshold, compare all pixels at once, label result. This is fast and deterministic (same image always produces same result). **Interface specification** Subclasses of ThresholdDetector must: 1. Inherit from ThresholdDetector (which provides ObjectDetector's interface). 2. Implement ``_operate(image: Image) -> Image`` as an instance method. 3. Within ``_operate()``: - Read ``image.detect_mat[:]`` (and optionally ``image.rgb[:], image.gray[:]``). - Compute threshold (automatically or from parameter). - Generate binary mask via comparison: ``mask = enh > threshold``. - Label connected components: ``labeled, _ = ndimage.label(mask)``. - Set both outputs: ``image.objmask = mask``, ``image.objmap = labeled``. - Return modified image. 4. Add to ``phenotypic.detect.__init__.py`` exports for public discovery. Notes: This is a marker ABC with no additional methods. It exists to categorize threshold-based detectors in the class hierarchy and enable flexible discovery and code organization. Examples: Detect colonies using Otsu's automatic threshold: >>> from phenotypic import Image >>> from phenotypic.detect import OtsuDetector >>> # Load a plate image >>> plate = Image.imread("agar_plate.jpg") >>> # Apply Otsu threshold detection >>> detector = OtsuDetector(ignore_zeros=True, ignore_borders=True) >>> detected = detector.apply(plate) >>> # Access results >>> mask = detected.objmask[:] # Binary mask >>> objmap = detected.objmap[:] # Labeled map >>> num_colonies = objmap.max() >>> print(f"Detected {num_colonies} colonies") >>> # Iterate over colonies >>> for colony in detected.objects: ... print(f"Colony {colony.label}: area={colony.area} px") Compare different threshold strategies: >>> from phenotypic import Image >>> from phenotypic.detect import ( ... OtsuDetector, LiDetector, YenDetector, TriangleDetector ... ) >>> plate = Image.imread("agar_plate.jpg") >>> # Test multiple threshold strategies >>> detectors = { ... "Otsu": OtsuDetector(), ... "Li": LiDetector(), ... "Yen": YenDetector(), ... "Triangle": TriangleDetector(), ... } >>> for name, detector in detectors.items(): ... result = detector.apply(plate) ... num = result.objmap[:].max() ... print(f"{name}: detected {num} colonies") Build a pipeline with thresholding and refinement: >>> from phenotypic import Image, ImagePipeline >>> from phenotypic.enhance import ContrastEnhancer >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.refine import RemoveSmallObjectsRefiner >>> # Create pipeline >>> pipeline = ImagePipeline() >>> pipeline.add(ContrastEnhancer(factor=1.5)) # Boost contrast >>> pipeline.add(OtsuDetector(ignore_zeros=True)) # Threshold >>> pipeline.add(RemoveSmallObjectsRefiner(min_size=50)) # Cleanup >>> # Process image >>> plate = Image.imread("agar_plate.jpg") >>> result = pipeline.operate([plate])[0] >>> print(f"Final colonies: {result.objmap[:].max()}") Tuning threshold detection on your plate images: >>> from phenotypic import Image >>> from phenotypic.detect import ( ... OtsuDetector, YenDetector, TriangleDetector ... ) >>> # Load a sample plate image >>> plate = Image.imread("sample_plate.jpg") >>> # Test different threshold strategies >>> strategies = { ... "Otsu": OtsuDetector(ignore_zeros=True), ... "Yen": YenDetector(ignore_zeros=True), ... "Triangle": TriangleDetector(ignore_zeros=True), ... } >>> best_detector = None >>> best_count = 0 >>> for name, detector in strategies.items(): ... result = detector.apply(plate) ... num_colonies = result.objmap[:].max() ... print(f"{name}: {num_colonies} colonies detected") ... # Choose detector that finds expected number of colonies ... if best_detector is None: ... best_detector = detector ... best_count = num_colonies >>> # Use best detector for batch processing >>> print(f"Selected: {type(best_detector).__name__}") """ @abstractmethod def _operate(self, image: Image) -> Image: return image