phenotypic.abc_#
Abstract interfaces for fungal colony image operations.
Defines the base contracts that power the processing pipeline: enhancers, detectors, refiners, grid operations, and measurement classes. Implement these to add new steps tailored to agar plate imaging, building on MeasurementInfo, MeasureFeatures, ImageOperation, GridOperation, and the prefab pipeline foundation.
Functions
Class decorator that instantiates cls and registers it by name. |
Classes
Extract quantitative measurements from detected colony objects in images. |
|
Core abstract base class for all single-image transformation operations in PhenoTypic. |
|
Abstract base class for preprocessing operations that improve colony detection through detection matrix. |
|
Abstract base class for whole-image transformation operations affecting all components. |
|
Abstract base class for colony detection operations on agar plate images. |
|
Abstract base class for post-detection refinement operations that modify object masks and maps. |
|
Marker ABC for threshold-based colony detection strategies. |
|
Marker ABC for object detectors that require GPU acceleration. |
|
Abstract base class for operations on grid-aligned plate images. |
|
Abstract base class for detecting grid structure and assigning objects to wells. |
|
Apply whole-image transformations (rotation, alignment, perspective) to GridImage objects. |
|
Abstract base class for post-detection refinement operations on grid-aligned plate images. |
|
Extract feature measurements from detected colonies in GridImage objects. |
|
Root abstract base class for all operations in PhenoTypic. |
|
Base class for creating standardized measurement information enumerations. |
|
Detect and label colonies in GridImage objects using grid structure. |
|
Marker class for pre-built, validated image processing pipelines from the PhenoTypic team. |
|
Provides a mixin for creating morphological footprints for image processing. |
|
Transform a measurement DataFrame after feature extraction. |
|
Base class for detection matrix source modes. |
- class phenotypic.abc_.BaseOperation(*args, **kwargs)[source]
Bases:
ABCRoot abstract base class for all operations in PhenoTypic.
BaseOperation is the foundation of PhenoTypic’s operation system. It provides automatic memory tracking, logging integration, and utilities for parallel execution. All operations in PhenoTypic inherit from BaseOperation (either directly or through intermediate ABCs like ImageOperation and MeasureFeatures).
This class is a blueprint for extending the framework: when you create a new operation, BaseOperation automatically handles memory profiling and logging so you can focus on the algorithm implementation.
What it provides automatically:
Memory Tracking: BaseOperation automatically initiates tracemalloc when the logger is enabled for INFO level or higher. This enables per-operation memory usage monitoring without explicit instrumentation. Three levels of memory tracking are available:
Object memory (via pympler if available): Detailed breakdown of memory used by Python objects in your operation.
Process memory (via psutil if available): System-level memory usage (RSS - resident set size).
Tracemalloc snapshots: Python’s built-in memory tracking showing current and peak allocations.
Logging Integration: A logger is created automatically for each operation class with the name format: module.ClassName. Subclasses can log messages and memory usage without additional setup.
Parallel Execution Support: Operations are serialized with all instance attributes (op.__dict__) for parallel execution. Worker processes unpickle the complete operation object and execute it.
Inheritance hierarchy:
BaseOperation (this class) ├── ImageOperation │ ├── ImageEnhancer (preprocessing filters, noise reduction) │ ├── ImageCorrector (rotation, alignment, quality fixes) │ └── ObjectDetector (colony detection algorithms) │ ├── MeasureFeatures (feature extraction from detected objects) │ └── GridOperation (grid detection and refinement)
How to subclass BaseOperation:
When extending BaseOperation, you typically implement one of its subclasses (ImageOperation, MeasureFeatures, etc.) which provides the specific interface for your operation type. All the memory tracking and logging happens automatically in the parent class.
Example: Creating a custom operation (without image details):
from phenotypic.abc_ import BaseOperation import logging class MyCustomOperation(BaseOperation): def __init__(self, param1, param2=5): # Always call parent __init__ first super().__init__() # Store your parameters as attributes self.param1 = param1 self.param2 = param2 def _operate(self, data): # Your algorithm here # Logger available as self._logger self._logger.info(f"Processing with param1={self.param1}") # Log memory usage after expensive operations self._log_memory_usage("after processing") return result
- _logger
Logger instance created automatically with the format module.ClassName. Use _logger.info(), _logger.debug() to log messages during operation execution.
- Type:
- _tracemalloc_started
Internal flag indicating whether tracemalloc was started. Set to True automatically if logger is enabled for INFO level or higher.
- Type:
Notes
Memory tracking is only enabled if the logger is configured to handle INFO level messages or higher. If you want to disable memory tracking, set the logger level to WARNING or higher.
Tracemalloc is automatically stopped when the operation object is deleted (in __del__), even if an exception occurs.
On Windows, pympler may not be available, so object memory tracking will fall back gracefully. psutil is available on all platforms.
Examples
Enabling memory tracking for an operation:
>>> import logging >>> from phenotypic.detect import OtsuDetector >>> # Set up logging to see memory usage >>> logging.basicConfig(level=logging.INFO) >>> # Create detector instance >>> detector = OtsuDetector() >>> # Apply operation - memory usage is logged automatically >>> result = detector.apply(image) # Console output shows: # INFO: Memory usage after <step>: XX.XX MB (objects), YY.YY MB (process)
Accessing memory information programmatically:
>>> import logging >>> from phenotypic.enhance import GaussianBlur >>> # Create custom logger to capture memory messages >>> logger = logging.getLogger('phenotypic.enhance.GaussianBlur') >>> logger.setLevel(logging.INFO) >>> handler = logging.StreamHandler() >>> handler.setLevel(logging.INFO) >>> logger.addHandler(handler) >>> # Use operation >>> blur = GaussianBlur(sigma=2) >>> enhanced = blur.apply(image) # Memory tracking happens automatically during operation
Custom operation with parameter matching for parallel execution:
>>> from phenotypic.abc_ import ImageOperation >>> from phenotypic import Image >>> class CustomThreshold(ImageOperation): ... def __init__(self, threshold_value: int): ... super().__init__() ... self.threshold_value = threshold_value ... ... @staticmethod ... def _operate(image: Image, threshold_value: int = 128) -> Image: ... # Apply threshold algorithm ... image.detect_mat[:] = image.detect_mat[:] > threshold_value ... return image >>> # When operation is applied via pipeline: >>> operation = CustomThreshold(threshold_value=100) # The operation object is serialized with all attributes # for parallel execution in worker processes
- __del__()[source]
Automatically stop tracemalloc when the object is deleted.
- class phenotypic.abc_.DetectionMode[source]
Bases:
ABCBase class for detection matrix source modes.
Subclasses define how the detection matrix is computed from raw image data (grayscale, individual RGB channels, etc.).
- abstract compute(image: Image) np.ndarray[source]
Return a fresh detection matrix from image.
- Parameters:
image (Image) – The
Imageinstance to compute from.- Returns:
A 2-D float32 array normalised to [0, 1].
- Return type:
np.ndarray
- abstract property name: str
Short identifier used in
detect_modestrings.
- abstract property requires_rgb: bool
Whether this mode needs RGB data to compute.
- class phenotypic.abc_.FootprintMixin[source]
Bases:
objectProvides a mixin for creating morphological footprints for image processing.
The FootprintMixin class contains a static utility method to generate structuring elements (footprints) used in various image processing tasks. This functionality is particularly helpful in the context of analyzing microbial colonies on solid media agar plates. Morphological footprints are used to highlight specific features in images, such as colony edges, shapes, or connectivity, and can assist in segmentation, noise reduction, and feature extraction.
- None
- class phenotypic.abc_.GpuDetector(*args, **kwargs)[source]
Bases:
ObjectDetector,ABCMarker ABC for object detectors that require GPU acceleration.
Subclass GpuDetector when your detection algorithm depends on a GPU (e.g., deep-learning foundation models like SAM2 or micro-sam).
When a pipeline contains a GpuDetector, the CLI enforces:
Local execution: Sequential processing (n_jobs=1) to avoid multiple workers competing for the same GPU.
SLURM execution: Automatically requests GPU resources (
--gpus-per-node=1) if the user hasn’t specified GPU args. Raises an error if the target partition has no GPUs.No GPU available: Raises RuntimeError at pipeline validation time with a clear message.
When to subclass GpuDetector vs ObjectDetector
Subclass GpuDetector if your detector relies on GPU-accelerated inference:
Deep-learning models (SAM2, micro-sam, or custom neural networks).
Any algorithm that requires
torch,tensorflow, or similar GPU-backed frameworks at inference time.Detectors where CPU fallback is technically possible but impractically slow for production use.
Subclass ObjectDetector directly if your algorithm is CPU-based:
Classical computer vision (thresholding, edge detection, watershed).
Algorithms implemented with NumPy, SciPy, or scikit-image.
Detectors that run in milliseconds on CPU.
Lazy model loading
GpuDetector subclasses should defer model construction to the first
apply()call rather than__init__(). This enables:Fast construction and serialization round-trips without GPU/torch.
Pipeline
to_json()/from_json()without importing heavy dependencies.Parameter inspection and validation before committing GPU memory.
Use a
_ensure_model_loaded()pattern:class MyGpuDetector(GpuDetector): def __init__(self, model_size="small", device="auto"): super().__init__() self.model_size = model_size self.device = device self._model = None # underscore prefix → skipped by serialization def _ensure_model_loaded(self): if getattr(self, "_model", None) is not None: return import torch # lazy import # ... build model ... def _operate(self, image): self._ensure_model_loaded() # ... run inference ... return image
Notes
This is a marker ABC with no additional methods beyond those inherited from ObjectDetector. It exists to categorize GPU-requiring detectors in the class hierarchy and enable the CLI to make informed resource-allocation decisions.
- __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.
- apply(image, inplace=False)
Detect colonies using sinusoidal cross-correlation grid estimation.
This method performs the core detection workflow: 1. Extract grid dimensions (if GridImage) 2. Threshold the detection matrix with adaptive kernel sizing 3. Remove noise if requested 4. Label connected components 5. Determine or estimate grid edges (via sinusoidal cross-correlation) 6. Assign dominant colonies to grid cells 7. Create final object map
- Parameters:
image – Image object to process. Can be a regular Image or GridImage.
- Returns:
The processed image with updated objmask and objmap.
- Return type:
Image
- 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.
- class phenotypic.abc_.GridCorrector(*args, **kwargs)[source]
Bases:
ImageCorrector,GridOperation,ABCApply whole-image transformations (rotation, alignment, perspective) to GridImage objects.
GridCorrector is a type-safe wrapper around ImageCorrector that enforces GridImage input and output types. It is specialized for grid-aware image corrections on arrayed plate images.
Quick Decision Guide
Choose GridCorrector vs ImageCorrector:
GridCorrector: Transformation modifies well structure or assumes grid layout. Examples: align colonies to grid axes, per-well perspective correction, rotation with grid-aware interpolation.
ImageCorrector: Transformation works on any Image. Examples: general rotation, perspective correction on ungridded images, generic image transformations without grid awareness.
Grid-aware operations: Use GridCorrector when grid rows/columns matter for the transformation or when the transformation affects well-level alignment and downstream analysis.
Type safety: GridCorrector enforces GridImage input/output; ImageCorrector accepts plain Image.
Implementation complexity: GridCorrector typically requires understanding grid.rotation_angle, grid.alignment, and per-well coordinate mapping via image.grid.info().
Output consistency: GridCorrector guarantees all components rotate identically. ImageCorrector gives you flexibility but requires manual synchronization of all components.
Grid preservation: GridCorrector may update grid state after transformation. ImageCorrector leaves grid structure unchanged (if present).
Purpose
Use GridCorrector when implementing transformations that modify entire GridImage objects while respecting their grid structure. Like ImageCorrector, it updates all image components (rgb, gray, detect_mat, objmask, objmap) together to maintain synchronization. The difference is that it requires GridImage input and output, making explicit that your transformation works in the context of grid-structured plate images.
What GridCorrector modifies
GridCorrector operations modify ALL image components simultaneously:
Color data: rgb, gray (pixel coordinates change due to rotation/perspective)
Preprocessed data: detect_mat (detection matrix also rotates/transforms)
Detection results: objmask, objmap (colony masks and labels transform identically)
Grid structure: Grid rotation angle and alignment state (optional, depends on operation)
This ensures that a rotated colony mask aligns perfectly with the rotated rgb and gray data.
GridImage vs Image
Image: Generic image with optional, unvalidated grid information.
GridImage: Specialized Image subclass with validated grid structure (row/column layout, well positions, grid alignment angle). Typically used after GridFinder detects the grid structure.
Typical Use Cases
Grid alignment: Rotate the entire image so detected colonies align with grid rows and columns. Improves downstream grid-based analysis via [GridAligner](src/phenotypic/correction/_grid_aligner.py).
Perspective correction: Correct camera tilt or lens distortion that skews the grid layout.
Plate reorientation: Rotate plate image to canonical orientation for consistent well assignment.
Color calibration per well: Apply per-well color correction that respects grid well boundaries.
Implementation Pattern
Inherit from GridCorrector and implement
_operate()as an instance method:from phenotypic.abc_ import GridCorrector from phenotypic import GridImage class GridAligner(GridCorrector): '''Rotate GridImage to align colonies with grid rows/columns.''' def __init__(self, axis: int = 0, max_rotation: float = 45.0): super().__init__() self.axis = axis self.max_rotation = max_rotation def _operate(self, image: GridImage) -> GridImage: # image is guaranteed to be GridImage with valid grid structure # Access grid structure and compute needed transformation grid_info = image.grid.info() nrows, ncols = grid_info['grid_shape'] # Calculate rotation needed to align colonies with grid axes rotation_angle = self._calculate_grid_rotation(image, self.axis) # Clamp rotation to reasonable range to prevent over-correction if abs(rotation_angle) > self.max_rotation: rotation_angle = self.max_rotation * (1 if rotation_angle > 0 else -1) # Apply rotation to all image components automatically image.rotate(angle_of_rotation=rotation_angle, mode='edge') # Grid structure (rows/cols) unchanged; rotation_angle updated automatically return image
Critical Implementation Details
Ensure ALL image components are transformed identically:
Transformation synchronization: When you rotate/warp rgb, also rotate gray, detect_mat, objmask, objmap. Use image.rotate() or similar methods that handle this automatically. Failure to synchronize causes misalignment between visual and label data.
Coordinate system consistency: Grid coordinates (well centers, row/column positions) must match the transformed pixel coordinates. Update grid.rotation_angle and grid.alignment after transformation so downstream operations use correct well boundaries.
Grid state preservation: Maintain grid.rows, grid.cols, and grid.well_size unchanged unless explicitly needed (e.g., perspective correction may change well size). Update only rotation_angle and alignment for simple rotations.
Interpolation order: Use order=1+ for color data (smooth), order=0 for labels (preserve integers). Mixed interpolation on different components is OK and expected.
Edge handling: Define how image boundaries are handled during transformation (reflect, wrap, constant). Choose mode=’edge’ or mode=’constant’ depending on plate structure and whether border wells matter.
In-place vs return: GridCorrector operations typically modify image in-place AND return it. Follow this pattern for consistency with parent ImageCorrector class.
Interpolation Considerations
When rotating or warping:
Color data (rgb, gray): Use smooth interpolation (order=1+) to preserve colony edges
Detection data (objmask, objmap): Use nearest-neighbor interpolation (order=0) to preserve discrete object labels (must remain integers)
Detection matrix: Use same interpolation as color data for consistency
Common Transformations
Rotation: Most common GridCorrector operation. Rotate entire plate to align colonies with grid axes. Use image.rotate(angle_of_rotation, mode=’edge’) to handle all components automatically.
Perspective correction: Correct camera tilt or lens distortion. More complex; requires affine or homography transformation on all components with careful interpolation.
Scale and crop: Resize plate image while maintaining grid structure. Update grid.well_size accordingly.
Flip/transpose: Flip or transpose plate (e.g., for reorientation). Update grid rows/cols and rotation_angle.
Best Practices
Always verify grid structure is valid before applying GridCorrector (check grid.rows, grid.cols > 0).
Test transformation on synthetic data first; grid misalignment can cascade through entire pipeline.
Update grid metadata (rotation_angle, alignment) immediately after transformation for consistency.
Log the transformation (angle, parameters) for reproducibility and debugging.
Consider edge well effects (evaporation, contamination); some corrections may need well-specific logic.
Notes
GridCorrector has no integrity checks (@validate_operation_integrity), by design. All components are intentionally modified together; there is nothing to validate.
Grid rotation angle and alignment state may be updated after the transformation. Downstream grid-aware operations will work with the updated grid structure.
GridImage must have valid grid structure before correction. Use GridFinder or specify grid manually before applying GridCorrector.
Output is always GridImage (type-safe). Attempting to apply to plain Image raises error.
Coordinate system: Grid rows/cols are logical indices; transformation affects pixel coordinates only. Update rotation_angle to track cumulative transformations.
Relationship to GridFinder and GridOperation
GridFinder: Detects grid structure automatically. Use BEFORE GridCorrector to establish rows/cols.
GridCorrector: Adjusts/aligns detected grid. Use AFTER GridFinder to optimize grid alignment.
GridOperation: Base class for all grid-aware operations. GridCorrector is a specialized subclass that combines ImageCorrector functionality with grid awareness.
Pipeline integration: Typical order: detect_grid (GridFinder) → correct_grid (GridCorrector) → measure/analyze (GridMeasureFeatures).
Image Synchronization Details
When implementing
_operate(), ensure these components stay in sync:rgb & gray: Always rotate together. Gray is derived from rgb, so maintain pixel correspondence.
detect_mat: Detection matrix processed from gray. Must rotate with same angle/transformation.
objmask & objmap: Detection results. Must use SAME interpolation as rgb to maintain object alignment.
Grid metadata: Update grid.rotation_angle if rotation applied. Keep grid.rows/cols unchanged unless grid structure itself changes.
Example Coordinate Transformation
For a 96-well plate rotated by θ degrees:
Original well positions: grid.info()[‘well_centers’] in absolute pixel coordinates
After rotation by θ: well_centers shift to new pixel positions
Update grid.rotation_angle += θ
Grid rows/cols (logical structure) remain unchanged (8 rows × 12 cols)
Known Implementations
Reference implementations in the PhenoTypic framework:
[GridAligner](src/phenotypic/correction/_grid_aligner.py): Rotates entire image to align detected colonies with grid rows and columns. Uses Hough transform to detect dominant angles in colony positions.
ImageRotation (ImageCorrector): Simple rotation without grid awareness. Baseline for understanding how GridCorrector extends basic functionality with grid metadata updates.
Custom implementations: Users can subclass GridCorrector for domain-specific plate corrections (e.g., multi-stage perspective correction, plate-specific calibrations).
Testing GridCorrector Implementations
Best practices for testing new GridCorrector subclasses:
Use
load_synth_yeast_plate()from phenotypic.data (creates GridImage with synthetic colonies).Verify all components (rgb, gray, detect_mat, objmask, objmap) rotate identically by computing pixel differences before/after transformation.
Check that grid.rotation_angle is updated correctly and accumulated rotations are tracked.
Validate on multiple plate formats (96-well, 384-well) to ensure well positions are handled correctly.
Examples
GridAligner: rotate to align colonies with grid axes:
>>> from phenotypic import GridImage, Image >>> from phenotypic.detect import RoundPeaksDetector >>> from phenotypic.correction import GridAligner >>> # Load and detect colonies >>> image = Image('plate.jpg') >>> image = RoundPeaksDetector().operate(image) >>> # Create GridImage with grid structure >>> grid_image = GridImage(image) >>> grid_image.detect_grid() >>> # Align entire image to grid rows/columns >>> aligner = GridAligner(axis=0) # Align rows horizontally >>> aligned = aligner.apply(grid_image) >>> # All components (rgb, gray, masks, map) rotated together >>> # Grid structure updated to reflect rotation >>> print(f"Rotation angle: {aligned.grid.rotation_angle}")
Custom perspective correction (conceptual):
>>> from phenotypic.abc_ import GridCorrector >>> from phenotypic import GridImage >>> class GridPerspectiveCorrector(GridCorrector): ... '''Correct camera tilt or lens distortion on grid plate.''' ... ... def __init__(self, tilt_angle: float): ... super().__init__() ... self.tilt_angle = tilt_angle ... ... def _operate(self, image: GridImage) -> GridImage: ... # Apply perspective transform to all components ... # Update grid coordinates accordingly ... grid_info = image.grid.info() ... image.apply_perspective(self.tilt_angle) ... # Re-validate grid after transformation ... return image >>> # Usage: correct skewed plate image >>> corrector = GridPerspectiveCorrector(tilt_angle=10.0) >>> corrected = corrector.apply(grid_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.
- apply(image: GridImage, inplace=False) GridImage[source]
Calculates the optimal rotation angle and applies it to a grid image for alignment along the specified axis.
The method performs alignment of a GridImage object along either nrows or columns based on the specified axis. It calculates the linear regression slope and intercept for the axis, determines geometric properties of the grid vertices, and computes rotation angles needed to align the image. The optimal angle is found by minimizing the error across all computed angles, and the image is rotated accordingly.
- Raises:
ValueError – If the axis is not 0 (row-wise) or 1 (column-wise).
- Parameters:
image (ImageGridHandler) – The arr grid image object to be aligned.
- Returns:
The rotated grid image object after alignment.
- Return type:
ImageGridHandler
- 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.
- class phenotypic.abc_.GridFinder(nrows: int, ncols: int)[source]
Bases:
GridMeasureFeatures,ABCAbstract base class for detecting grid structure and assigning objects to wells.
GridFinder is the foundation for grid detection algorithms in arrayed plate imaging. It detects the row and column spacing of colonies on agar plates and assigns each detected object to its corresponding grid cell (well). This is essential for high-throughput phenotyping experiments where samples are arranged in regular grids (e.g., 96-well, 384-well formats).
Quick Decision Guide
Use [AutoGridFinder](src/phenotypic/grid/_auto_grid_finder.py) when: - Grid position is unknown or image is rotated/shifted - Colonies are detected but well boundaries are unclear - You want automatic optimization of row/column edge positions - Tolerance parameter allows tuning optimization precision
Use [ManualGridFinder](src/phenotypic/grid/_manual_grid_finder.py) when: - You know exact grid geometry from microscope calibration - Grid position is fixed and repeatable across images - You have pre-measured row and column edge coordinates - You want deterministic, non-optimized grid assignment
Combining with detection pipelines: - Use GridFinder after ObjectDetector to map colonies to wells - AutoGridFinder works with any detection result - ManualGridFinder requires pre-computed edge coordinates - Grid assignment is independent of detection algorithm
What it does
GridFinder implementations analyze the spatial distribution of detected objects in an image and determine the underlying grid structure. They compute pixel coordinates where grid rows and columns are located (row_edges and col_edges), then use these edges to assign each object to a row number, column number, and section number (unique well identifier).
Why it’s important for colony phenotyping
In arrayed plate experiments, colonies are grown at fixed positions corresponding to wells in a microplate. By mapping detected colonies to grid positions, downstream analysis can:
Sample tracking: Correlate colony measurements with sample metadata inoculated in each well
Replicate analysis: Track growth across identical replicate wells
Spatial detection: Identify contamination patterns or edge effects
Data export: Organize results by well coordinates for database import and statistical analysis
Without grid assignment, measurements are just unorganized lists of objects with no link to experimental design.
Grid concepts
Row edges: Array of pixel row coordinates marking row boundaries. For 8 rows, array has 9 values: [0, y1, y2, …, y8, image_height]. Objects between row_edges[i] and row_edges[i+1] belong to row i.
Column edges: Array of pixel column coordinates marking column boundaries. For 12 columns, array has 13 values: [0, x1, x2, …, x12, image_width]. Objects between col_edges[j] and col_edges[j+1] belong to column j.
Grid cell assignment: Each object’s centroid (center_rr, center_cc) is tested against row/column edges using pd.cut(), assigning it to row i (0 to nrows-1) and column j (0 to ncols-1).
Section number: A unique well ID computed as row*ncols + col, ordered left-to-right, top-to-bottom (top-left well = 0, bottom-right well = nrows*ncols - 1).
Typical plate formats
96-well plate: 8 rows × 12 columns (A1-H12)
384-well plate: 16 rows × 24 columns (A1-P24)
1536-well plate: 32 rows × 48 columns (very high-throughput)
- Attributes:
nrows (int): Number of rows in the grid. For 96-well plates, typically 8. ncols (int): Number of columns in the grid. For 96-well plates, typically 12.
Abstract Methods
Subclasses must implement these methods:
_operate(image: Image) -> pd.DataFrame: Main entry point. Compute row and column edges, then call _get_grid_info() to assemble the complete grid DataFrame. Return the DataFrame with all grid assignments.
get_row_edges(image: Image) -> np.ndarray: Return array of row edge pixel coordinates. Length must be exactly nrows + 1 (e.g., 9 values for 8 rows).
get_col_edges(image: Image) -> np.ndarray: Return array of column edge pixel coordinates. Length must be exactly ncols + 1 (e.g., 13 values for 12 columns).
Helper Methods for Implementation
These protected methods reduce code duplication and handle grid assignment:
_get_grid_info(image, row_edges, col_edges) -> pd.DataFrame: Assembles complete grid information from pre-computed edge coordinates. Calls internal methods to populate ROW_NUM, COL_NUM, and ROW_MAJOR_IDX columns. Use this after computing edges in your _operate() implementation.
_add_row_number_info(): Assigns row indices using pd.cut() with object centroids and row edges.
_add_col_number_info(): Assigns column indices using pd.cut() with object centroids and column edges.
_add_section_number_info(): Computes section numbers from row and column indices using vectorized operations.
_clip_row_edges() / _clip_col_edges(): Ensures edge coordinates are clipped to image bounds (prevents indexing errors).
Output Format
The _operate() method returns a pandas DataFrame with detected objects and their grid assignments:
ROW_NUM: Grid row index (0 to nrows-1), representing vertical well position
COL_NUM: Grid column index (0 to ncols-1), representing horizontal well position
ROW_MAJOR_IDX: Well identifier (0 to nrows*ncols-1), ordered left-to-right, top-to-bottom for convenient database mapping
Additional columns: Object metadata (centroid, bounding box, morphology) from image.objects.info()
Objects whose centers fall outside the grid edges are clamped to the nearest edge cell (row 0 or nrows-1, col 0 or ncols-1).
Concrete Implementations
PhenoTypic provides two built-in GridFinder implementations:
[AutoGridFinder](src/phenotypic/grid/_auto_grid_finder.py): Deterministic robust fit using weighted object centers. Estimates pitch from center range, fits grid indices via least-squares, rejects outliers, and refits. Robust to protruding colonies (e.g., filamentous fungi). Use when grid position is unknown.
[ManualGridFinder](src/phenotypic/grid/_manual_grid_finder.py): User specifies exact row and column edge coordinates from calibration or measurement. Use when grid geometry is known and fixed.
Fitting Strategy (for AutoGridFinder)
AutoGridFinder uses a deterministic center-based robust fit:
Centers: Extract weighted centroids from detected objects, sorted along each axis
Pitch estimate:
(max_center - min_center) / (n_expected - 1)(robust to multiple objects per cell)Grid indices:
round((center - min_center) / pitch)Least-squares fit:
center = pitch * idx + offsetvia closed-form normal equationsOutlier rejection: Remove centers where
|residual| > pitch * residual_fractionRefit: Refined pitch and offset from inliers only
Symmetry anchoring: When detected span < expected, center the grid in the image
Edges:
offset + pitch * i - pitch/2fori = 0..n, clipped to image bounds
Notes
GridFinder subclasses work with regular Image objects (not just GridImage)
Edge coordinates must be sorted in ascending order (handled by _clip_row_edges and _clip_col_edges)
Ensure row_edges and col_edges are clipped to image bounds to prevent indexing errors
Grid assignment uses pandas.cut() with include_lowest=True and right=True, meaning objects are assigned based on which interval they fall into
NaN values in grid columns indicate objects outside all grid cells
Examples
Use AutoGridFinder when grid position is unknown:
When the image is rotated, shifted, or geometry is unclear, let AutoGridFinder automatically compute optimal edge positions by optimizing alignment:
>>> from phenotypic.grid import AutoGridFinder >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import OtsuDetector >>> # Load and detect colonies on plate >>> image = load_synth_yeast_plate() >>> detector = OtsuDetector() >>> image_with_objects = detector.apply(image) >>> # AutoGridFinder optimizes edge positions to align with colonies >>> grid_finder = AutoGridFinder(nrows=8, ncols=12) >>> grid_df = grid_finder.measure(image_with_objects) >>> # Access well assignments >>> print(f"Found {len(grid_df)} colonies assigned to grid") >>> print(grid_df[['ROW_NUM', 'COL_NUM', 'ROW_MAJOR_IDX']].head())
Create a ManualGridFinder for a 96-well plate with known geometry:
When grid geometry is known from microscope calibration, manually specify row and column edges for reproducible grid assignment:
>>> import numpy as np >>> from phenotypic.grid import ManualGridFinder >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import OtsuDetector >>> # Load and detect colonies >>> image = load_synth_yeast_plate() >>> detector = OtsuDetector() >>> image_with_objects = detector.apply(image) >>> # Define grid for 8 rows x 12 columns (96-well) >>> # Rows: 8 wells vertically, evenly spaced from pixel 100 to 2000 >>> row_edges = np.linspace(100, 2000, 9, dtype=int) >>> # Columns: 12 wells horizontally, evenly spaced from pixel 50 to 3050 >>> col_edges = np.linspace(50, 3050, 13, dtype=int) >>> # Create grid finder with known edge coordinates >>> grid_finder = ManualGridFinder(row_edges=row_edges, col_edges=col_edges) >>> grid_df = grid_finder.measure(image_with_objects) >>> # Result includes grid assignments plus object metadata >>> print(grid_df[['ROW_NUM', 'COL_NUM', 'ROW_MAJOR_IDX']].head())
Understanding ROW_MAJOR_IDX for well mapping:
ROW_MAJOR_IDX provides a single integer ID for each well, useful for organizing results and correlating with sample metadata:
>>> from phenotypic.grid import AutoGridFinder >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import OtsuDetector >>> # Detect and assign colonies to grid >>> image = load_synth_yeast_plate() >>> detector = OtsuDetector() >>> image_with_objects = detector.apply(image) >>> grid_finder = AutoGridFinder(nrows=8, ncols=12) >>> grid_df = grid_finder.measure(image_with_objects) >>> # Example: 8x12 grid (96-well plate) >>> # ROW_MAJOR_IDX runs 0-95, numbered left-to-right, top-to-bottom >>> # Section 0 = Row 0, Col 0 (top-left, A1) >>> # Section 11 = Row 0, Col 11 (top-right, A12) >>> # Section 12 = Row 1, Col 0 (second row left, B1) >>> # Section 95 = Row 7, Col 11 (bottom-right, H12) >>> # Filter colonies in a specific well >>> section_5_objects = grid_df[grid_df['ROW_MAJOR_IDX'] == 5] >>> # Map section numbers back to well coordinates >>> well_row = 5 // 12 # Row index >>> well_col = 5 % 12 # Column index
- __del__()
Automatically stop tracemalloc when the object is deleted.
- abstract get_col_edges(image: Image) np.ndarray[source]
This method is to returns the column edges of the grid as a numpy array. :param image:
- Returns:
Column-edges of the grid.
- Return type:
np.ndarray
- Parameters:
image (Image)
- abstract get_row_edges(image: Image) np.ndarray[source]
This method is to returns the row edges of the grid as a numpy array. :param image: Image object. :type image: Image
- Returns:
Row-edges of the grid.
- Return type:
np.ndarray
- Parameters:
image (Image)
- measure(image)
Compute grid edges and assign each detected object to a grid cell.
- Parameters:
image – Image with detected objects.
- Returns:
DataFrame with grid assignments (ROW_NUM, COL_NUM, ROW_MAJOR_IDX).
- class phenotypic.abc_.GridMeasureFeatures(*args, **kwargs)[source]
Bases:
MeasureFeatures,ABCExtract feature measurements from detected colonies in GridImage objects.
GridMeasureFeatures is a type-safe wrapper around MeasureFeatures that enforces GridImage input type. It is to MeasureFeatures what GridOperation is to ImageOperation: a specialization for grid-aware (arrayed plate) analysis.
What is GridMeasureFeatures?
GridMeasureFeatures enables measurements that leverage well structure in arrayed plate images:
Type-safe wrapper: Enforces GridImage input (guaranteed valid grid structure with rows, cols, well positions).
Grid-aware measurements: Access per-well positions, row/column layout via image.grid.info(), enabling well-normalized and position-aware metrics.
Per-well metrics: Compute measurements per colony but with awareness of which well it belongs to, well size, and row/column position.
Relationship to MeasureFeatures: Inherits all methods from MeasureFeatures (parent class). Only difference is stricter input type validation (GridImage required) plus guaranteed access to grid information.
Output format: Returns pandas.DataFrame with one row per detected colony, matching image.objmap labels. Can easily map back to grid layout.
Well-normalized analysis: Enables computing metrics relative to well size/position (e.g., colony area as fraction of well area, normalized by well position on plate).
Purpose
Use GridMeasureFeatures when implementing measurement operations that extract quantitative metrics from colonies in grid-structured agar plate images. Like MeasureFeatures, it returns pandas DataFrames with one row per detected colony. The only difference is that it requires GridImage input, making explicit that your measurement may leverage grid structure (well positions, row/column layout) if desired.
GridImage vs Image
Image: Generic image with optional, unvalidated grid information.
GridImage: Specialized Image subclass with validated grid structure (row/column layout, well positions, grid alignment). Suitable for 96-well, 384-well, or other arrayed plate formats.
Quick Decision Guide
Choose GridMeasureFeatures vs MeasureFeatures:
GridMeasureFeatures: Measurement depends on or benefits from well structure. Examples: per-well metrics, well-normalized values, measurements filtered by well position, edge well exclusion.
MeasureFeatures: Measurement works equally well on any Image. Examples: colony size, color, morphology computed globally without well awareness or position dependency.
Type safety: GridMeasureFeatures enforces GridImage; MeasureFeatures accepts any Image (grid optional).
Well-level data: Use GridMeasureFeatures when you need image.grid.info() for per-well analysis and when grid structure is essential to your measurement.
Multi-well experiments: GridMeasureFeatures simplifies tracking which well each colony belongs to, row/column position, and inter-well comparisons.
Subclass reference: Both GridMeasureSize and GridMeasureShape inherit from GridMeasureFeatures.
Performance: GridMeasureFeatures has minimal overhead vs MeasureFeatures; use it when grid structure matters.
Flexibility: If uncertain whether you need grid structure, start with MeasureFeatures; upgrade to GridMeasureFeatures if well-aware logic becomes necessary.
Implementation Pattern
Inherit from GridMeasureFeatures and implement
_operate()as an instance method:from phenotypic.abc_ import GridMeasureFeatures from phenotypic import GridImage from phenotypic.tools\_.constants_ import OBJECT import pandas as pd class GridMeasureWellOccupancy(GridMeasureFeatures): '''Measure fraction of well area occupied by colonies.''' def __init__(self, normalize: bool = True): super().__init__() self.normalize = normalize def _operate(self, image: GridImage) -> pd.DataFrame: # image is guaranteed to be GridImage with validated grid structure grid_info = image.grid.info() # Access well positions and layout # grid_info contains: 'grid_shape', 'well_centers', 'well_size', etc. nrows, ncols = grid_info['grid_shape'] well_size = grid_info['well_size'] # Calculate area occupied by colonies using MeasureFeatures methods area = self._calculate_sum(image.objmask[:], image.objmap[:]) # Optional: normalize by well size for grid-aware analysis if self.normalize and well_size > 0: area = area / (well_size ** 2) # Build results DataFrame with colony labels (required by contract) results = pd.DataFrame({'WellArea': area}) results.insert(0, OBJECT.LABEL, image.objects.labels2series()) return results
Typical Use Cases
Per-well phenotypic analysis: Extract growth metrics where well position and size matter for normalization (e.g., center wells may grow differently from edge wells due to evaporation).
Grid-based filtering: Measure only colonies in specific well regions (e.g., measure only center wells in high-variance experiments, exclude border wells due to contamination risk).
Well-normalized metrics: Compute colony area relative to well size, colony density within well boundaries, or occupancy rates per well in a 96-well or 384-well plate.
Multi-well experiments: Track which well each colony occupies via grid.info(), enabling growth curve fitting per well and inter-well statistical comparisons.
Quality control filtering: Exclude edge wells from analysis where plate handling artifacts are common, or normalize measurements by well position.
Helper Methods Available
Inherit all measurement methods from MeasureFeatures parent class:
_calculate_sum()- Total measurement per object (e.g., total area)_calculate_mean()- Average measurement per object (e.g., mean intensity)_calculate_median()- Median measurement per object_calculate_std()- Standard deviation per object_calculate_percentile()- Percentile-based measurements (e.g., 95th percentile size)_extract_properties_per_object()- Extract properties from regionprops or similar
Grid Information Available
Access grid structure via
image.grid.info()in _operate():'grid_shape'- Tuple (nrows, ncols) of well layout'well_centers'- List of (row_px, col_px) well center coordinates in pixel space'well_size'- Typical well size in pixels (varies by format: 96-well vs 384-well)'rotation_angle'- Current grid rotation angle in degrees'alignment'- Alignment state/quality metrics
Notes
The
measure()method is inherited from MeasureFeatures; the only difference is input type validation (GridImage required).Returns pandas.DataFrame with one row per detected object, first column is OBJECT.LABEL (matching image.objmap labels).
GridImage must have valid grid structure set before measuring. Typically set by GridFinder or GridCorrector operations in the pipeline.
All helper methods from MeasureFeatures (mean, median, sum, etc.) are available.
DataFrame returned can be easily mapped back to grid layout using image.grid.info().
Relationship to MeasureFeatures and GridOperation
MeasureFeatures: Parent class providing all measurement logic. GridMeasureFeatures enforces GridImage input and guarantees grid structure is available via image.grid.info().
GridOperation: Base class for all grid-aware operations. GridMeasureFeatures is a specialized subclass combining measurement functionality with grid awareness.
Pipeline integration: Typical order: detect_colonies (ObjectDetector) → measure_features (GridMeasureFeatures) → analyze_growth (e.g., growth curve fitting per well).
Well-to-Colony Mapping Patterns
Common workflows for mapping measurements back to grid:
Per-well aggregation: Group colonies by well position; compute statistics per well (mean, std, count).
Well-position filtering: Exclude edge wells, measure only center wells, or apply position-dependent normalization.
Row/column analysis: Group by row or column; detect spatial patterns or plate gradients.
Multi-well comparisons: Compare growth metrics across wells for identification of outliers or resistance.
Output DataFrame Structure
Standard format returned by GridMeasureFeatures._operate():
First column: OBJECT.LABEL (int) - Matches image.objmap labels, one row per colony
Measurement columns: Your custom measurements (Area, Intensity, Circularity, etc.)
Mapping to grid: Use image.grid.info()[‘well_centers’] + label → pixel position → well assignment
Validation Checklist
Before shipping GridMeasureFeatures subclass:
Verify grid.info() call succeeds and returns expected keys
Test on GridImage with known grid (use load_synth_yeast_plate())
Ensure DataFrame output matches expected column structure (OBJECT.LABEL first, then measurements)
Check that measurements make sense (e.g., area > 0, intensity in expected range)
Validate on both small (96-well) and large (384-well) plates if possible
Known Implementations
Reference implementations in the PhenoTypic framework:
GridMeasureSize: Measures colony size and morphological features per detected object in GridImage. Extends GridMeasureFeatures with size-specific methods and grid-aware normalization.
GridMeasureShape: Measures colony shape/circularity/symmetry features. Demonstrates how grid structure can influence shape interpretation (e.g., elongation along grid rows).
MeasureSize (MeasureFeatures): Parent class implementation. GridMeasureSize extends this with grid awareness.
Testing GridMeasureFeatures Implementations
Best practices for testing new GridMeasureFeatures subclasses:
Use
load_synth_yeast_plate()from phenotypic.data (creates GridImage with detected colonies).Verify DataFrame output structure: first column OBJECT.LABEL, subsequent columns are measurements.
Test with edge cases: image with no colonies detected, single large colony, multiple overlapping colonies.
Check well-mapping: ensure measurements map correctly to grid positions via image.grid.info().
Validate row/column filtering logic if using position-aware filtering (center wells, edge exclusion).
Examples
Grid-aware measurement of colony size per well:
>>> from phenotypic import GridImage >>> from phenotypic.abc_ import GridMeasureFeatures >>> from phenotypic.tools\_.constants_ import OBJECT >>> import pandas as pd >>> class MeasureWellOccupancy(GridMeasureFeatures): ... '''Measure total area occupied in each well.''' ... ... def _operate(self, image: GridImage) -> pd.DataFrame: ... # Use grid accessor to calculate per-well metrics ... area = self._calculate_sum(image.objmask[:], image.objmap[:]) ... well_info = image.grid.info() # Get well assignments ... # Combine area with well location ... results = pd.DataFrame({ ... 'WellArea': area, ... }) ... results.insert(0, OBJECT.LABEL, image.objects.labels2series()) ... return results >>> # Usage >>> from phenotypic import Image >>> from phenotypic.detect import OtsuDetector >>> image = Image('plate.jpg') >>> image = OtsuDetector().operate(image) >>> grid_image = GridImage(image) >>> grid_image.detect_grid() # Establish grid structure >>> measurer = MeasureWellOccupancy() >>> df = measurer.measure(grid_image) # Returns grid-aware measurements
Grid-aware filtering: measure only center wells:
>>> from phenotypic.abc_ import GridMeasureFeatures >>> from phenotypic import GridImage >>> import pandas as pd >>> class MeasureCenterWells(GridMeasureFeatures): ... '''Measure size only for colonies in center wells (exclude edge wells).''' ... ... def _operate(self, image: GridImage) -> pd.DataFrame: ... grid_info = image.grid.info() ... nrows, ncols = grid_info['grid_shape'] ... # Get all measurements first ... all_areas = self._calculate_sum(image.objmask[:], image.objmap[:]) ... labels = image.objects.labels2series() ... # Filter: keep only colonies in center wells ... center_mask = self._get_center_well_mask(grid_info, nrows, ncols) ... # Return measurements for center-well colonies only ... results = pd.DataFrame({'Area': all_areas[center_mask]}) ... results.insert(0, 'OBJECT.LABEL', labels[center_mask]) ... return results
- __del__()
Automatically stop tracemalloc when the object is deleted.
- measure(image)[source]
Compute grid edges and assign each detected object to a grid cell.
- Parameters:
image – Image with detected objects.
- Returns:
DataFrame with grid assignments (ROW_NUM, COL_NUM, ROW_MAJOR_IDX).
- class phenotypic.abc_.GridObjectDetector(*args, **kwargs)[source]
Bases:
ObjectDetector,GridOperation,ABCDetect and label colonies in GridImage objects using grid structure.
GridObjectDetector is a type-safe wrapper around ObjectDetector that enforces GridImage input type. It is specialized for colony detection on arrayed plate images with grid structure.
Purpose
Use GridObjectDetector when implementing detection algorithms that find and label colonies in grid-structured agar plate images. Like ObjectDetector, it sets image.objmask and image.objmap. The difference is that it requires GridImage input, making explicit that your detection may leverage or assumes grid structure (well boundaries, grid alignment).
What GridObjectDetector produces
GridObjectDetector sets two outputs:
image.objmask: Binary mask (True=colony pixel, False=background)
image.objmap: Labeled integer map (0=background, 1..N=colony labels)
Both are set synchronously to ensure consistency. The labels in objmap match the row/column structure of the grid (useful for tracking which colonies are in which wells).
GridImage vs Image
Image: Generic image with optional, unvalidated grid information.
GridImage: Specialized Image subclass with validated grid structure (row/column layout, well positions, grid alignment). Suitable for 96-well, 384-well, or other arrayed plate formats. Created by GridFinder or manually specified.
When to use GridObjectDetector vs ObjectDetector
ObjectDetector: Detection works equally well on any Image (with or without grid). Examples: Otsu thresholding, Canny edges, round peak detection on single images. Use when detection is global and grid-independent.
GridObjectDetector: Detection assumes or leverages grid structure. Examples: per-well detection (find colonies only within well boundaries), grid-aware peak detection (use well centers as hints), adaptive detection per well (tuning per grid region). Use when grid structure is essential to the detection algorithm.
Typical Use Cases
Per-well detection: Find colonies only within well boundaries; one mask/label per well.
Grid-hinted detection: Use well center positions or grid-aligned regions as hints to improve detection accuracy.
Adaptive detection: Adjust detection parameters (threshold, sensitivity) per well to handle uneven plate illumination.
Well isolation: Ensure detected colonies don’t bleed across well boundaries.
Implementation Pattern
Inherit from GridObjectDetector and implement
_operate()as normal:from phenotypic.abc_ import GridObjectDetector from phenotypic import GridImage class GridAdaptiveDetector(GridObjectDetector): '''Detect colonies using per-well adaptive thresholding.''' def __init__(self, neighborhood_size: int = 15): super().__init__() self.neighborhood_size = neighborhood_size @staticmethod def _operate(image: GridImage, neighborhood_size: int = 15) -> GridImage: # image is guaranteed to be GridImage with grid structure # Use well positions to apply per-well detection from scipy.ndimage import label from skimage.filters import threshold_local enh = image.detect_mat[:] grid = image.grid # Access grid structure # Apply adaptive threshold per well mask = threshold_local(enh, neighborhood_size) > enh # Label connected components labeled, _ = label(mask) image.objmask[:] = mask image.objmap[:] = labeled return image
Critical Implementation Detail
GridObjectDetector includes input validation (GridImage required) but NO output integrity checks. Like ObjectDetector, it is READ-ONLY for rgb, gray, detect_mat. You may only write to objmask and objmap.
@staticmethod def _operate(image: GridImage, **kwargs) -> GridImage: # Read (protected by @validate_operation_integrity): enh = image.detect_mat[:] gray = image.gray[:] rgb = image.rgb[:] # Write (allowed): image.objmask[:] = binary_mask image.objmap[:] = labeled_map # GridImage structure (optional modification): # image.grid can be read, but typically not written return image
Grid-Aware Detection Patterns
Per-well detection: Create a mask/label per well independently
Well-boundary enforcement: Mask pixels outside well boundaries after detection
Well-center hinting: Use well positions as priors for peak detection
Adaptive parameters: Vary detection thresholds based on well position or intensity
Notes
GridObjectDetector enforces GridImage input type at runtime. Passing plain Image raises error.
Input validation uses @validate_operation_integrity(‘image.rgb’, ‘image.gray’, ‘image.detect_mat’) to ensure image color data is not modified.
GridImage must have valid grid structure before detection. Typically set by GridFinder or manually specified grid before applying GridObjectDetector.
All ObjectDetector helper methods and patterns apply identically.
Output is always GridImage (input type is preserved).
Examples
Per-well Otsu detection with grid structure:
>>> from phenotypic import GridImage, Image >>> from phenotypic.abc_ import GridObjectDetector >>> from scipy.ndimage import label >>> from skimage.filters import threshold_otsu >>> import numpy as np >>> class GridOtsuDetector(GridObjectDetector): ... '''Detect colonies using global Otsu threshold on grid plate.''' ... ... def _operate(self, image: GridImage) -> GridImage: ... enh = image.detect_mat[:] ... # Apply global Otsu threshold ... threshold = threshold_otsu(enh) ... binary_mask = enh > threshold ... # Label connected components ... labeled_map, _ = label(binary_mask) ... # Set detection results ... image.objmask[:] = binary_mask ... image.objmap[:] = labeled_map ... return image >>> # Usage >>> image = Image.imread('plate.jpg') >>> grid_image = GridImage(image) >>> grid_image.detect_grid() >>> detector = GridOtsuDetector() >>> detected = detector.operate(grid_image) >>> # Grid structure preserved; can access wells >>> for well_row in range(grid_image.nrows): ... for well_col in range(grid_image.ncols): ... # Colonies in this well available via grid accessor ... pass
Per-well adaptive detection using well centers as hints:
>>> from phenotypic.abc_ import GridObjectDetector >>> from phenotypic import GridImage >>> from scipy.ndimage import label >>> from skimage.filters import threshold_local >>> class GridAdaptiveDetector(GridObjectDetector): ... '''Adaptive per-well detection using well center positions.''' ... ... def __init__(self, neighborhood_size: int = 31): ... super().__init__() ... self.neighborhood_size = neighborhood_size ... ... def _operate(self, image: GridImage) -> GridImage: ... enh = image.detect_mat[:] ... grid = image.grid ... # Apply local adaptive threshold (per-well region) ... binary_mask = threshold_local( ... enh, self.neighborhood_size ... ) > enh ... # Label and store ... labeled_map, _ = label(binary_mask) ... image.objmask[:] = binary_mask ... image.objmap[:] = labeled_map ... return image >>> # Usage: handle uneven illumination on large plates >>> detector = GridAdaptiveDetector(neighborhood_size=31) >>> detected = detector.operate(grid_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.
- apply(image, inplace=False)[source]
Detect colonies using sinusoidal cross-correlation grid estimation.
This method performs the core detection workflow: 1. Extract grid dimensions (if GridImage) 2. Threshold the detection matrix with adaptive kernel sizing 3. Remove noise if requested 4. Label connected components 5. Determine or estimate grid edges (via sinusoidal cross-correlation) 6. Assign dominant colonies to grid cells 7. Create final object map
- Parameters:
image – Image object to process. Can be a regular Image or GridImage.
- Returns:
The processed image with updated objmask and objmap.
- Return type:
Image
- 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.
- class phenotypic.abc_.GridObjectRefiner(*args, **kwargs)[source]
Bases:
ObjectRefiner,GridOperation,ABCAbstract base class for post-detection refinement operations on grid-aligned plate images.
GridObjectRefiner is the grid-aware variant of ObjectRefiner, combining object mask refinement with grid structure awareness. It refines detected objects (colony masks and labeled maps) while respecting well boundaries and grid-aligned regions in arrayed plate images (96-well, 384-well, etc.). Like ObjectRefiner, it protects original image data (RGB, grayscale, detection matrix) and modifies only detection results.
Quick Decision Guide: GridObjectRefiner vs ObjectRefiner
Use GridObjectRefiner if: Refining detections on GridImage where well structure or grid position affects refinement logic (per-well filtering, boundary enforcement, position-aware cleanup).
Use ObjectRefiner if: Refining on plain Image without grid, or using global algorithms that don’t need grid awareness (size filtering, shape filtering, general morphology).
GridImage requirement: GridObjectRefiner only accepts GridImage input; plain Image raises GridImageInputError at runtime.
Grid-aware refinement: Access well positions, row/column boundaries, and grid metadata via image.grid to make position-aware decisions (e.g., filter colonies oversized for their well).
Oversized colonies: [GridOversizedObjectRemover](src/phenotypic/refine/_grid_oversized_object_remover.py) removes objects spanning nearly entire well (merged colonies, segmentation spillover).
Per-well largest: [GridSectionLargest](src/phenotypic/refine/_grid_section_largest.py) keeps only the largest object per grid cell (one colony per well).
Multi-well reducer: [ReduceMultipleGridObjects](src/phenotypic/refine/_min_residual_error_reducer.py) merges multiple objects per well into single representative region.
Border handling: Grid structure enables identifying and filtering objects near plate/well edges that may be incomplete or distorted.
Grid registration: When grid detection is imperfect, grid-aware refinement helps filter mis-assigned or boundary-adjacent objects by position.
When to chain: Combine grid refiners in ImagePipeline (e.g., global size filter, then per-well filtering) for comprehensive cleanup respecting array structure.
What is GridObjectRefiner?
GridObjectRefiner is the specialized version of ObjectRefiner for GridImage objects, adding grid-aware refinement to the core post-detection cleanup workflow:
Grid-structure awareness: Unlike ObjectRefiner (which operates globally), GridObjectRefiner can access grid boundaries, well positions, row/column indices, and per-cell metadata via
image.grid. This enables refinement logic that respects array structure.GridImage requirement: Accepts only GridImage input (with detected grid structure), enforced at runtime via
GridImageInputError. Passing a plain Image raises an error.Grid access interface: Within
_operate(), callimage.grid.get_row_edges(),image.grid.get_col_edges(), andimage.grid.info()to retrieve grid metadata and make position-aware refinement decisions.Detection-only modification: Like ObjectRefiner, modifies only
image.objmask[:]andimage.objmap[:]. Original image components (RGB, grayscale, detection matrix) are protected via@validate_operation_integritydecorator.Array phenotyping: Well-suited for high-throughput plate analysis where grid structure matters for biology (one colony per well expected, oversized objects indicate merging, boundary objects may be incomplete or distorted).
When to use GridObjectRefiner vs ObjectRefiner
ObjectRefiner: Use when refining detections on a plain Image without grid structure. Examples: general-purpose size filtering, morphological cleanup, shape filtering (applies globally regardless of position).
GridObjectRefiner: Use when refining detections on a GridImage where well structure matters. Examples: removing objects larger than their grid cell (
GridOversizedObjectRemover), per-well filtering, grid-aligned edge removal. The grid structure enables position-aware refinement that improves array phenotyping accuracy.
Typical Use Cases
GridObjectRefiner is useful for addressing grid-specific artifacts:
Oversized colonies: Objects spanning nearly an entire well (merged colonies, agar edges, segmentation spillover). Filtering improves per-well consistency.
Inter-well artifacts: Detections touching or bridging grid cell boundaries from uneven lighting or thresholding errors.
Boundary contamination: Colonies near plate edges that are incomplete or distorted. Grid structure allows identifying and filtering boundary-adjacent objects.
Grid registration errors: When grid detection is imperfect, some objects may be mis-assigned to wells; grid-aware refinement can filter or relocate based on position.
Implementing a Custom GridObjectRefiner
Subclass GridObjectRefiner and implement
_operate(). Use this template:from phenotypic.abc_ import GridObjectRefiner from phenotypic import GridImage import numpy as np class MyGridRefiner(GridObjectRefiner): def __init__(self, max_width_fraction: float = 0.9): super().__init__() self.max_width_fraction = max_width_fraction @staticmethod def _operate(image: GridImage, max_width_fraction: float = 0.9) -> GridImage: # Get grid structure information col_edges = image.grid.get_col_edges() # x-coordinates of column boundaries row_edges = image.grid.get_row_edges() # y-coordinates of row boundaries nrows, ncols = image.grid.nrows, image.grid.ncols # Get per-object grid metadata (label, row, column, position) grid_info = image.grid.info() # pd.DataFrame # Measure object properties objmap = image.objmap[:] from skimage.measure import regionprops_table props = regionprops_table(objmap, properties=['label', 'bbox', 'area']) # Make position-aware refinement decisions # Example: filter objects by grid position or size relative to well max_width = (col_edges[1:] - col_edges[:-1]).max() # ... implement your grid-aware logic ... return image
Key Rules
_operate()must be an instance method (access parameters viaself).All parameters except
imagemust exist as instance attributes.Only modify
image.objmask[:]andimage.objmap[:].Access grid via
image.gridmethods: get_row_edges(), get_col_edges(), info().Return the modified GridImage object.
Per-Well Filtering Patterns
Common grid-aware refinement strategies:
Remove oversized objects: Filter objects larger than well dimensions (merged colonies, segmentation spillover). Compare object bounding box to max_width and max_height of grid cells.
Keep largest per well: Select only the largest object per grid cell (assumes one colony per well). Use grid_info to group objects by row and column, then keep max-area object per group.
Remove boundary objects: Filter objects touching or near well edges (incomplete detections). Use grid_info’s boundary flags or compute distance to nearest grid boundary.
Per-row/column filtering: Apply different thresholds by row or column position (accounts for uneven illumination across plate). Use grid_info to stratify objects by position.
Grid Access Interface
Within
_operate(), use the GridImage accessor to retrieve grid metadata:# Grid structure information nrows, ncols = image.grid.nrows, image.grid.ncols row_edges = image.grid.get_row_edges() # Row boundary positions (y-coordinates) col_edges = image.grid.get_col_edges() # Col boundary positions (x-coordinates) grid_info = image.grid.info() # DataFrame with per-object grid metadata # grid_info columns (example): # ['label', 'row', 'col', 'row_edge_min', 'row_edge_max', 'col_edge_min', 'col_edge_max', ...] # Per-well cell dimensions cell_heights = row_edges[1:] - row_edges[:-1] cell_widths = col_edges[1:] - col_edges[:-1]
Notes
GridImage input required:
apply()enforces GridImage type at runtime. Passing a plain Image raisesGridImageInputError.Protected components: The
@validate_operation_integritydecorator ensuresimage.rgb,image.gray,image.detect_matcannot be modified. Onlyimage.objmaskandimage.objmapcan be refined.Immutability by default:
apply(image)returns a modified copy. Setinplace=Truefor memory-efficient in-place modification.Grid structure assumption: Your algorithm should assume a valid, registered grid. If grid metadata is unreliable, refinement may fail or produce wrong results.
Instance _operate() method:
_operate()is an instance method; access parameters viaself.Parameter matching: All
_operate()parameters exceptimagemust exist as instance attributes for automatic parameter matching.
Examples
Remove objects larger than their grid cell width:
>>> from phenotypic.abc_ import GridObjectRefiner >>> from phenotypic import GridImage >>> import numpy as np >>> class OversizedObjectRemover(GridObjectRefiner): ... '''Remove objects exceeding cell dimensions.''' ... ... def __init__(self): ... super().__init__() ... ... @staticmethod ... def _operate(image: GridImage) -> GridImage: ... # Get grid boundaries ... col_edges = image.grid.get_col_edges() ... row_edges = image.grid.get_row_edges() ... max_width = (col_edges[1:] - col_edges[:-1]).max() ... max_height = (row_edges[1:] - row_edges[:-1]).max() ... # Measure objects ... objmap = image.objmap[:] ... from skimage.measure import regionprops_table ... props = regionprops_table(objmap, properties=['label', 'bbox']) ... # Filter oversized ... import pandas as pd ... df = pd.DataFrame(props) ... df['width'] = df['bbox-2'] - df['bbox-0'] ... df['height'] = df['bbox-3'] - df['bbox-1'] ... keep = df[(df['width'] < max_width) & ... (df['height'] < max_height)]['label'].values ... # Refine map ... refined = np.where(np.isin(objmap, keep), objmap, 0) ... image.objmap[:] = refined ... return image >>> # Usage on gridded plate image >>> from phenotypic.detect import OtsuDetector >>> image = GridImage.imread('plate.jpg', nrows=8, ncols=12) >>> detected = OtsuDetector().apply(image) >>> cleaned = OversizedObjectRemover().apply(detected)
Per-well filtering: remove colonies oversized for their well:
>>> from phenotypic.abc_ import GridObjectRefiner >>> from phenotypic import GridImage >>> from phenotypic.data import load_synth_yeast_plate >>> import numpy as np >>> import pandas as pd >>> class PerWellOversizedRemover(GridObjectRefiner): ... '''Remove objects that exceed size threshold relative to their well.''' ... ... def __init__(self, max_area_fraction: float = 0.8): ... super().__init__() ... self.max_area_fraction = max_area_fraction ... ... @staticmethod ... def _operate(image: GridImage, max_area_fraction: float = 0.8) -> GridImage: ... # Get grid structure ... col_edges = image.grid.get_col_edges() ... row_edges = image.grid.get_row_edges() ... # Compute well area (assume uniform grid cells) ... cell_width = (col_edges[1:] - col_edges[:-1]).mean() ... cell_height = (row_edges[1:] - row_edges[:-1]).mean() ... max_cell_area = cell_width * cell_height ... # Get grid info and measure object areas ... objmap = image.objmap[:] ... grid_info = image.grid.info() ... from skimage.measure import regionprops_table ... props = regionprops_table(objmap, properties=['label', 'area']) ... props_df = pd.DataFrame(props) ... # Filter: keep objects smaller than max_area_fraction of their well ... max_allowed_area = max_cell_area * max_area_fraction ... keep = props_df[props_df['area'] < max_allowed_area]['label'].values ... # Refine map ... refined = np.where(np.isin(objmap, keep), objmap, 0) ... image.objmap[:] = refined ... return image >>> # Usage: remove merged colonies and artifacts spanning most of well >>> from phenotypic.detect import OtsuDetector >>> image = load_synth_yeast_plate() # Returns GridImage >>> detected = OtsuDetector().apply(image) >>> # Remove colonies > 80% of well area (likely merged or segmentation error) >>> cleaner = PerWellOversizedRemover(max_area_fraction=0.8) >>> refined = cleaner.apply(detected) >>> print(f"Removed oversized: {detected.objmap[:].max()} -> {refined.objmap[:].max()}")
Chaining grid and non-grid refinements:
>>> from phenotypic import GridImage, ImagePipeline >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.refine import SmallObjectRemover, GridOversizedObjectRemover >>> # Create detection pipeline with mixed refinements >>> pipeline = ImagePipeline() >>> pipeline.add(OtsuDetector()) # Detect colonies >>> pipeline.add(SmallObjectRemover(min_size=100)) # Global size filter >>> pipeline.add(GridOversizedObjectRemover()) # Grid-aware filter >>> # Apply to gridded plate >>> image = GridImage.imread('plate.jpg', nrows=8, ncols=12) >>> results = pipeline.operate([image]) >>> refined_image = results[0] >>> print(f"Refined: {refined_image.objmap[:].max()} colonies")
- __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.
- apply(image, inplace=False)[source]
Applies the operation to an image, either in-place or on a copy.
- Parameters:
image (Image) – The arr image to apply the operation on.
inplace (bool) – If True, modifies the image in place; otherwise, operates on a copy of the image.
- Returns:
The modified image after applying the operation.
- Return type:
Image
- 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.
- class phenotypic.abc_.GridOperation(*args, **kwargs)[source]
Bases:
ImageOperation,ABCAbstract base class for operations on grid-aligned plate images.
GridOperation is a marker abstract base class that enforces type safety for operations designed to work exclusively with GridImage objects. It’s a lightweight subclass of ImageOperation that overrides the apply() method to require a GridImage input instead of a generic Image.
Quick Decision Guide
Use GridOperation when: - Your operation requires grid structure information (well positions, row/column layout) - You’re processing arrayed plate images with regular grid layouts (96-well, 384-well) - Your algorithm needs per-well analysis or grid-aligned regions - You want to enforce that input must be GridImage (type safety)
Use ImageOperation when: - Your operation works on general Image objects regardless of grid state - You’re doing global preprocessing, detection, or measurement - Your algorithm doesn’t depend on well structure or grid alignment - Your operation should accept both Image and GridImage inputs
Combining GridOperation with ImageOperation: - GridOperation is typically paired with other ABCs (ObjectDetector, ImageCorrector, etc.) - Use multiple inheritance: class GridObjectDetector(ObjectDetector, GridOperation, ABC) - GridOperation adds type safety without changing algorithm implementation - Most grid operations inherit from both a specific ABC and GridOperation
What is GridOperation?
GridOperation exists to distinguish between two categories of image operations:
ImageOperation: Works on single, unaligned Image objects. The image may or may not have grid information. Used for general-purpose preprocessing, detection, and measurement. Examples: GaussianBlur, OtsuDetector, MeasureColorComposition.
GridOperation: Works only on GridImage objects that have grid structure information (row/column layout of wells on an agar plate). The operation assumes grid information is present and available. Used for grid-aware operations where well-level analysis or grid alignment is required. Examples: GridObjectDetector, GridCorrector, GridRefiner.
Why GridOperation exists
GridOperation provides three key benefits:
Type Safety: The apply() method signature requires a GridImage argument, catching misuse at runtime if someone tries to apply a grid operation to a plain Image.
Intent Clarity: Developers can immediately see which operations require grid information, making the design space clear: “Use ImageOperation for general image ops, GridOperation for plate-specific grid-aware ops.”
Documentation: Allows documentation and tutorials to clearly distinguish operations by their input type requirements.
What is GridImage?
GridImage is a specialized Image subclass that adds grid structure information:
Inherits from Image: All standard image capabilities (RGB, grayscale, color spaces, object detection results, etc.) are available.
Adds grid field: Contains a
gridattribute (GridInfo object) storing the detected or specified grid layout (row/column positions, cell dimensions, rotation angle).Arrayed plate context: Represents images of agar plates with samples arranged in regular grids (96-well, 384-well, 1536-well formats). Typical nrows=8, ncols=12 for 96-well plates.
Grid accessors: Via
image.grid, provides row/column counts, well positions, and grid-related metadata.
GridOperation Subclasses
Most concrete grid operations inherit from BOTH a specific operation ABC (like ObjectDetector) AND GridOperation to create specialized grid-aware variants:
GridObjectDetector: Detects objects using grid structure. Subclasses implement well-level colony detection on gridded plates.GridCorrector: Corrects grid alignment, rotation, and per-well color correction. Improves grid positioning and well-level alignment.GridObjectRefiner: Refines detection masks at the well level. Filters and adjusts masks based on well location and size constraints.GridMeasureFeatures: Extracts per-well measurements. Computes features organized by grid coordinates rather than globally.GridFinder: Detects grid structure from object positions. Assigns detected objects to grid cells and determines well locations.
Multiple Inheritance Pattern
Most GridOperation subclasses use multiple inheritance to combine operation behavior with grid type safety:
Combine with ObjectDetector: class GridObjectDetector(ObjectDetector, GridOperation, ABC)
Combine with ImageCorrector: class GridCorrector(ImageCorrector, GridOperation, ABC)
Combine with any operation ABC: class CustomGridOp(SomeABC, GridOperation, ABC)
The inheritance order matters: specific ABC first, then GridOperation.
Example of multiple inheritance pattern:
>>> from phenotypic.abc_ import ImageOperation, GridOperation >>> from phenotypic import GridImage, Image >>> # Concrete implementation combining ObjectDetector + GridOperation >>> # class GridObjectDetector(ObjectDetector, GridOperation, ABC): >>> # def _operate(self, image: GridImage) -> GridImage: >>> # # Implementation uses grid structure from image.grid >>> # return image
This combines:
Operation behavior: Sets image.objmask and image.objmap, with integrity checks.
GridOperation type safety: Requires GridImage input, enforced at runtime.
ABC pattern: Subclasses implement _operate() with grid-aware logic.
The key insight: GridOperation is just a type annotation layer over ImageOperation that makes the grid requirement explicit in the method signature.
Type Safety Example
GridOperation enforces type checking at apply() time to catch errors early:
>>> from phenotypic import Image, GridImage >>> from phenotypic.abc_ import GridOperation >>> # When a GridOperation is called with wrong type: >>> # detector = SomeGridOperation() # subclass of GridOperation >>> # result = detector.apply(Image('plain.jpg')) # Raises GridImageInputError >>> # result = detector.apply(GridImage('plate.jpg', nrows=8, ncols=12)) # OK
When to subclass GridOperation
Subclass GridOperation when your operation:
Requires grid information: Needs to access
image.gridto get well positions, row/column structure, or grid-aligned regions.Operates on well-level data: Processes colonies at the well level rather than globally on the image (e.g., per-well filtering, well-based alignment).
Makes assumptions about grid structure: Your algorithm assumes a regular grid layout and would fail or produce nonsensical results on an image without grid info.
Otherwise, subclass ImageOperation instead. GridOperation operations are more specialized and less broadly applicable.
Notes
GridOperation is a marker class with no implementation. It only overrides apply() to specify the GridImage type and enforce input validation.
GridImage inherits all Image functionality. Grid information is accessed via the
gridaccessor:image.grid.nrows,image.grid.ncols, etc.If you’re unsure whether your operation needs GridOperation, ask: “Does this algorithm fundamentally depend on grid structure?” If yes, use GridOperation. If it works equally well on plain Images, use ImageOperation.
GridImage is typically created with GridFinder operations that detect grid structure. GridFinder detects grid positions and creates a GridImage suitable for downstream GridOperation subclasses.
Examples
Using a GridOperation subclass with GridImage:
>>> from phenotypic import GridImage >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import GridObjectDetector >>> # Load plate image with grid info >>> image = load_synth_yeast_plate() # GridImage with detected colonies >>> grid_image = image >>> # Apply a grid-aware detector (subclass of GridObjectDetector) >>> # GridImage is required - type-safe operation >>> # detector = GridObjectDetector() # Concrete subclass in practice >>> # detected = detector.apply(grid_image)
Type safety: GridOperation prevents misuse:
>>> from phenotypic import Image, GridImage >>> from phenotypic.enhance import GaussianBlur >>> from phenotypic.data import load_synth_yeast_plate >>> image = Image('generic.jpg') # Plain Image >>> grid_image = load_synth_yeast_plate() # GridImage >>> # ImageOperation (GaussianBlur) accepts both >>> enhancer = GaussianBlur(sigma=2) >>> result1 = enhancer.apply(image) # OK: Image -> Image >>> result2 = enhancer.apply(grid_image) # OK: GridImage -> GridImage >>> # GridOperation requires GridImage only >>> # detector = SomeGridOperation() # subclass of GridOperation >>> # result3 = detector.apply(grid_image) # OK: GridImage -> GridImage >>> # result4 = detector.apply(image) # ERROR: raises GridImageInputError
- __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.
- apply(image: GridImage, inplace: bool = False) GridImage[source]
Applies the operation to an image, either in-place or on a copy.
- Parameters:
image (Image) – The arr image to apply the operation on.
inplace (bool) – If True, modifies the image in place; otherwise, operates on a copy of the image.
- Returns:
The modified image after applying the operation.
- Return type:
Image
- 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.
- class phenotypic.abc_.ImageCorrector(*args, **kwargs)[source]
Bases:
ImageOperation,ABCAbstract base class for whole-image transformation operations affecting all components.
ImageCorrector is a specialized subclass of ImageOperation for global image transformations that modify every image component together (rgb, gray, detect_mat, objmask, objmap). Unlike ImageEnhancer (modifies only detect_mat) or ObjectDetector/ObjectRefiner (modify only detection results), an ImageCorrector transforms the entire image geometry or structure, ensuring all components remain synchronized.
Quick Decision Guide: Which Operation Type?
ImageEnhancer: Modify only
image.detect_matfor preprocessing. Use for: noise reduction, contrast enhancement, background subtraction.ObjectDetector: Analyze image, produce only
objmaskandobjmap. Use for: colony/object detection and labeling.ObjectRefiner: Edit mask and map (filtering, merging, removing objects). Use for: post-detection cleanup and refinement.
ImageCorrector (this class): Transform entire image (rotation, resampling, perspective). Use for: geometric corrections, coordinate system changes, alignment. Example: [GridAligner](src/phenotypic/correction/_grid_aligner.py).
What is ImageCorrector?
ImageCorrector handles operations where it is impossible or meaningless to modify only a single component. When you rotate, warp, or apply perspective transforms to an image, the rgb and gray representations must change together, and any existing detection masks and maps must be rotated identically. ImageCorrector guarantees this synchronization without requiring manual alignment of separate components.
Key Design Principle: No Integrity Checks
Unlike ImageEnhancer and ObjectDetector, ImageCorrector uses no @validate_operation_integrity decorator. This is by design: since all components must change together in a coordinated way, there is nothing to “protect” or “validate”. The entire image is intentionally modified as a unit. The absence of integrity checks reflects this design, not a security weakness.
Typical Use Cases
ImageCorrector is designed for operations that physically transform the image:
Rotation: Align plate image with detected grid structure to make colony rows parallel to axes.
Perspective transformation: Correct camera angle or lens distortion effects.
Image resampling: Change resolution or interpolation method for downstream processing.
Global color correction: Apply white balance or color space mapping to entire image.
Alignment: Register image to reference coordinate system for grid-based analysis.
When NOT to use ImageCorrector
Do NOT use ImageCorrector for operations that affect only specific image aspects:
Don’t use for: Enhancing only
detect_mat(preprocessing). UseImageEnhancerinstead.Don’t use for: Detecting colonies or objects. Use
ObjectDetectorinstead.Don’t use for: Filtering or editing detection results. Use
ObjectRefinerinstead.Don’t use for: Local edits (e.g., removing a specific region). These typically require custom masking or
ObjectRefiner.
The key question: Are you transforming the entire image geometry, or only a specific aspect? If only an aspect, use the specialized operation type (Enhancer, Detector, Refiner).
Why ImageCorrector is Rare in Practice
Most image processing operations are targeted to specific aspects of the image:
Colony detection focuses on finding objects in image data.
Post-detection cleanup focuses on refining the mask/map.
Preprocessing focuses on making detection more robust.
Operations transforming the entire image structure are comparatively rare because:
Plate images are typically already well-oriented from the scanner/camera.
Most analysis works directly with image data as acquired (no rotation needed).
Grid-based alignment is a specialized step, not routine preprocessing.
However, when needed, ImageCorrector provides the correct abstraction.
Subclass References
ImageCorrector implementations are rare. The canonical example is:
[GridAligner](src/phenotypic/correction/_grid_aligner.py): Rotates entire GridImage to align detected colonies with expected grid structure. Demonstrates synchronizing all components during transformation.
How to Implement a Custom ImageCorrector
Inherit from ImageCorrector and implement the
_operate()instance method. Access parameters viaselfattributes within the method body.from phenotypic.abc_ import ImageCorrector from phenotypic import Image class MyRotator(ImageCorrector): def __init__(self, angle: float): super().__init__() self.angle = angle # Instance attribute, matched to _operate() @staticmethod def _operate(image: Image, angle: float) -> Image: # Rotate ALL image components together image.rotate(angle_of_rotation=angle, mode='edge') return image # Usage rotator = MyRotator(angle=5.0) rotated_image = rotator.apply(image)
Key Implementation Rules
_operate()must be an instance method (access parameters viaself).All parameters must be stored as instance attributes.
The method must transform all components equally (rgb, gray, detect_mat, objmask, objmap).
Never modify only one component—this breaks the “whole-image transformation” contract.
Use the Image class’s helper methods (
image.rotate()) whenever possible for consistency.Always return the modified Image object after transformation.
Parameter Matching Example
When instance attributes don’t match
_operate()parameters, serialization and parallelization fail:# CORRECT: attribute names match parameter names class GoodRotator(ImageCorrector): def __init__(self, angle: float): super().__init__() self.angle = angle # Matches _operate() parameter @staticmethod def _operate(image: Image, angle: float) -> Image: image.rotate(angle_of_rotation=angle, mode='edge') return image # WRONG: attribute name doesn't match parameter name class BadRotator(ImageCorrector): def __init__(self, angle: float): super().__init__() self.rotation_angle = angle # Mismatch! @staticmethod def _operate(image: Image, angle: float) -> Image: # Parameter is 'angle' # This will fail—no 'angle' attribute on self image.rotate(angle_of_rotation=angle, mode='edge') return image
Critical Implementation Detail: Updating All Components
Your
_operate()method must ensure all image components are updated together. When any geometric transformation is applied, it must affect every component identically:@staticmethod def _operate(image: Image, angle: float) -> Image: # Rotate rgb and gray (color representation) image.rotate(angle_of_rotation=angle, mode='edge') # The following are automatically handled by image.rotate(): # - Rotate detect_mat (enhanced version for detection) # - Rotate objmask and objmap (detection results) # - Synchronize all caches and metadata return image
What happens if components get out of sync?
If you accidentally rotate only
image.rgbwithout rotatingimage.objmap, downstream analysis breaks because pixel coordinates no longer match object labels. The Image class’s helper methods protect against this by guaranteeing synchronized updates.Access image data through accessors (never direct attributes):
When implementing custom transformations, always use the accessor interface:
Reading:
image.rgb[:],image.gray[:],image.detect_mat[:],image.objmask[:],image.objmap[:]Modifying:
image.rgb[:] = new_data,image.objmap[:] = new_map
The accessor interface ensures that:
Caches are invalidated appropriately after modifications.
Color space conversions remain synchronized with RGB data.
Object detection results stay consistent with image geometry.
The Image class provides helper methods for common transformations:
image.rotate(angle_of_rotation, mode='edge')- Rotates all components identicallyFor custom transformations, apply the same operation to all components
Always verify that helper methods exist before implementing custom transform code
Pipeline Integration and Serialization
ImageCorrector operations are fully serializable and can be included in ImagePipeline for batch processing. The static method design enables distributed execution:
Automatic parameter passing: Instance attributes are extracted when
apply()is called.Serialization: Operations can be saved to JSON/YAML and reconstructed on worker processes.
Batch processing: Use
ImagePipeline.apply_and_measure()for automatic benchmarking.Reproducibility: Serialized pipelines document the exact transformations applied.
Performance and Interpolation Considerations
When rotating or resampling, use appropriate interpolation for each component:
Color data (rgb, gray): Use smooth interpolation (order=1 bilinear or higher) to preserve color gradients and colony boundaries.
Detection data (objmask, objmap): Use nearest-neighbor interpolation (order=0) to preserve discrete object identities and integer labels.
Detection matrix (detect_mat): Use same interpolation as color data for consistency.
Example with explicit interpolation control:
>>> from scipy.ndimage import rotate as ndimage_rotate >>> from skimage.transform import rotate as skimage_rotate >>> # For rgb/gray: use bilinear interpolation >>> rotated_rgb = skimage_rotate(image.rgb[:], angle=5.0, order=1, preserve_range=True) >>> # For objmap: use nearest-neighbor to preserve integer labels >>> rotated_objmap = ndimage_rotate(image.objmap[:], angle=5.0, order=0, reshape=False)
Edge Handling During Transformation
Transformations introduce edge artifacts; choose mode based on downstream analysis:
‘edge’ mode: Replicas image border pixels (minimal artifacts, safest for colony detection).
‘constant’ mode: Fills with constant value (usually 0 for dark edge, may create false boundaries).
‘reflect’ mode: Reflects image at boundary (avoids abrupt discontinuities but changes image content).
Common Pitfalls and Best Practices
Pitfall: Modifying only one component (e.g., rotating RGB but not objmap). Result: pixel coordinates become misaligned.
Best practice: Use Image class helper methods (
image.rotate()) which synchronize all components automatically.Pitfall: Using smooth interpolation for object maps. Result: object labels become non-integer, breaking downstream analysis.
Best practice: Use order=0 (nearest-neighbor) for masks and maps to preserve discrete identities.
Pitfall: Forgetting to handle edge artifacts. Result: false objects detected at image boundaries.
Best practice: Choose ‘edge’ mode for colony detection (minimal artifacts) or use image padding before transformation.
Attributes
ImageCorrector has no public attributes; subclasses define operation-specific parameters as instance attributes.
All subclass attributes must match
_operate()method signature for parallelization support.
Methods
apply(image, inplace=False)- Execute the correction (default: returns new image)._operate(image, **kwargs)- Abstract method you implement with transformation logic.
Notes
Instance method:
_operate()is an instance method; access parameters viaself.Parameter matching: All
_operate()parameters (exceptimage) must exist as instance attributes.No copy by default: Operations return modified copies by default (inplace=False).
Coordinate system changes: Downstream operations may need re-detection after transformation.
Grid alignment workflow: [GridAligner](src/phenotypic/correction/_grid_aligner.py) is the canonical example.
Examples
Basic rotation operation:
>>> from phenotypic.abc_ import ImageCorrector >>> from phenotypic.data import load_synth_yeast_plate >>> >>> class SimpleRotator(ImageCorrector): ... def __init__(self, angle=5.0): ... super().__init__() ... self.angle = angle ... @staticmethod ... def _operate(image, angle): ... image.rotate(angle_of_rotation=angle, mode='edge') ... return image >>> >>> image = load_synth_yeast_plate() >>> rotator = SimpleRotator(angle=5.0) >>> rotated = rotator.apply(image) >>> # All components rotated together: rgb, gray, detect_mat, objmask, objmap >>> rotated.shape == image.shape False
Custom perspective correction (preserving component synchronization):
>>> from phenotypic.abc_ import ImageCorrector >>> from phenotypic.data import load_synth_yeast_plate >>> import numpy as np >>> >>> class PerspectiveCorrector(ImageCorrector): ... def __init__(self, tilt_angle=10.0): ... super().__init__() ... self.tilt_angle = tilt_angle ... @staticmethod ... def _operate(image, tilt_angle): ... # Apply perspective transform to all components ... image.rotate(angle_of_rotation=tilt_angle, mode='edge') ... return image >>> >>> image = load_synth_yeast_plate() >>> corrector = PerspectiveCorrector(tilt_angle=10.0) >>> corrected = corrector.apply(image) >>> # All components are transformed together, maintaining synchronization
- __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.
- apply(image: Image, inplace: bool = False) Image[source]
- apply(image: GridImage, inplace: bool = False) GridImage
Applies the operation to an image, either in-place or on a copy.
- Parameters:
image (Image) – The arr image to apply the operation on.
inplace (bool) – If True, modifies the image in place; otherwise, operates on a copy of the image.
- Returns:
The modified image after applying the operation.
- Return type:
Image
- 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.
- class phenotypic.abc_.ImageEnhancer(*args, **kwargs)[source]
Bases:
FootprintMixin,ImageOperation,ABCAbstract base class for preprocessing operations that improve colony detection through detection matrix.
ImageEnhancer is the foundation for all preprocessing algorithms that modify only the enhanced grayscale channel (image.detect_mat) to improve colony visibility and detection quality. Unlike ImageCorrector (which transforms the entire Image), ImageEnhancer leaves the original RGB and grayscale data untouched, protecting image integrity while enabling targeted preprocessing.
Quick Decision Guide: Which Operation Type?
ImageEnhancer (this class): Modify only
image.detect_matfor preprocessing. Use for: noise reduction, contrast enhancement, illumination correction. Examples: [GaussianBlur](src/phenotypic/enhance/_gaussian_blur.py), [CLAHE](src/phenotypic/enhance/_clahe.py), [BilateralDenoise](src/phenotypic/enhance/_bilateral_denoise.py).ImageCorrector: Transform entire image (rotation, cropping, perspective). Use for: geometric corrections, global color transformations.
ObjectDetector: Analyze image, produce only
objmaskandobjmap. Use for: colony/object detection and labeling.ObjectRefiner: Edit mask and map (filtering, merging, removing objects). Use for: post-detection cleanup and refinement.
What is ImageEnhancer?
ImageEnhancer operates on the principle of non-destructive preprocessing: all modifications are applied to image.detect_mat (a working copy of grayscale), while original image components (image.rgb, image.gray, image.objmask, image.objmap) remain protected and unchanged. This allows you to experiment with multiple enhancement chains without affecting raw data or detection results.
Role in the Detection Pipeline
ImageEnhancer sits at the beginning of the processing chain:
Raw Image (image.rgb, image.gray) ↓ ImageEnhancer(s) → Improve visibility, reduce noise ↓ ObjectDetector → Detect colonies/objects ↓ ObjectRefiner → Clean up detections (optional)When you call enhancer.apply(image), you get back an Image with improved detect_mat but identical RGB/gray data—ready for detection algorithms to operate on enhanced contrast.
Why Enhancement Matters for Colony Phenotyping
Real agar plate imaging introduces several challenges that enhancement operations address:
Uneven illumination: Vignetting, shadows, and scanner lighting gradients make colonies appear faint in dark regions.
Noise and texture: Scanner noise, agar granularity, dust, and condensation create artifacts confusing detection.
Faint colonies: Small or translucent colonies blend into background, reducing detectability.
Poor contrast: Low-contrast colonies on dense plates require local contrast enhancement.
Enhancement operations preserve colony morphology while suppressing artifacts for robust detection.
Subclass References
The following are canonical examples of ImageEnhancer implementations:
[GaussianBlur](src/phenotypic/enhance/_gaussian_blur.py): Noise reduction via Gaussian filtering.
[CLAHE](src/phenotypic/enhance/_clahe.py): Contrast-limited adaptive histogram equalization for local contrast.
GrayOpening: Morphological opening usingFootprintMixin.[BilateralDenoise](src/phenotypic/enhance/_bilateral_denoise.py): Edge-preserving denoising.
Integrity Validation: Protection of Core Data
ImageEnhancer uses the
@validate_operation_integritydecorator on theapply()method to guarantee that RGB and grayscale data are never modified:@validate_operation_integrity('image.rgb', 'image.gray') def apply(self, image: Image, inplace: bool = False) -> Image: return super().apply(image=image, inplace=inplace)
This decorator:
Calculates cryptographic signatures of image.rgb and image.gray before processing
Calls the parent apply() method to execute your _operate() implementation
Recalculates signatures after operation completes
Raises
OperationIntegrityErrorif any protected component was modified
Note: Integrity validation only runs if the
VALIDATE_OPS=Trueenvironment variable is set (development-time safety; disabled in production for performance).Implementing a Custom ImageEnhancer
Subclass ImageEnhancer and implement a single method:
from phenotypic.abc_ import ImageEnhancer from phenotypic import Image from scipy.ndimage import gaussian_filter class MyCustomEnhancer(ImageEnhancer): def __init__(self, sigma: float = 1.0): super().__init__() self.sigma = sigma # Instance attribute matched to _operate() def _operate(self, image: Image) -> Image: # Modify ONLY detect_mat; read, process, write back enh = image.detect_mat[:] filtered = gaussian_filter(enh.astype(float), sigma=self.sigma) image.detect_mat[:] = filtered.astype(enh.dtype) return image
Key Rules for Implementation:
_operate()should be an instance method (no@staticmethoddecorator).Access operation parameters directly via
self.param_name.Only modify ``image.detect_mat[:]``—all other components are protected.
Always use the accessor pattern:
image.detect_mat[:] = new_data(never direct attribute assignment likeimage._detect_mat = ...).Return the modified Image object.
Accessing and Modifying detect_mat
Within your _operate() method, use the accessor interface:
# Reading detection matrix data enh_data = image.detect_mat[:] # Full array region = image.detect_mat[10:50, 20:80] # Slicing with NumPy syntax # Modifying detection matrix image.detect_mat[:] = processed_array # Full replacement image.detect_mat[10:50, 20:80] = new_region # Partial update
The accessor handles all consistency checks and automatic cache invalidation.
The _make_footprint() Static Utility
ImageEnhancer provides a static helper for generating morphological structuring elements (footprints) used in morphological operations like erosion, dilation, and median filtering:
@staticmethod def _make_footprint(shape: Literal["square", "diamond", "disk"], width: int) -> np.ndarray: '''Creates a binary morphological shape for image processing.'''
Footprint Shapes and When to Use Each
“disk”: Circular/isotropic shape. Best for preserving rounded colony shapes and applying uniform processing in all directions. Use for: general-purpose smoothing, median filtering, dilations that expand colonies symmetrically.
“square”: Square shape with 8-connectivity. Emphasizes horizontal/vertical edges and aligns with pixel grid. Use for: grid-aligned artifacts (imaging hardware stripe patterns), when processing speed matters (slightly faster than disk).
“diamond”: Diamond-shaped (rotated square) shape with 4-connectivity. Creates a cross-like neighborhood pattern. Use for: specialized cases where diagonal connections should be de-emphasized; less common in practice.
The width parameter controls the neighborhood size (in pixels). Larger radii affect more neighbors and produce broader effects (more noise suppression, but potential colony merging). Choose width smaller than the minimum colony diameter to avoid destroying fine details.
Common Morphological Patterns
Use _make_footprint() with morphological operations from scipy.ndimage or skimage.morphology:
from skimage.morphology import erosion, dilation from phenotypic.abc_ import ImageEnhancer disk_fp = ImageEnhancer._make_footprint('disk', width=5) # Erosion: shrink bright regions (removes small colonies/noise) eroded = erosion(binary_image, footprint=disk_fp) # Dilation: expand bright regions (closes holes, merges nearby colonies) dilated = dilation(binary_image, footprint=disk_fp)
When and Why to Chain Multiple Enhancements
Enhancement operations are typically chained together to address multiple issues in sequence:
# Example pipeline: handle uneven illumination + noise # Step 1: Remove background gradients result = SubtractRollingBall(width=50).apply(image) # Step 2: Boost local contrast for faint colonies result = CLAHE(kernel_size=50, clip_limit=0.02).apply(result) # Step 3: Smooth remaining noise result = GaussianBlur(sigma=2).apply(result) # Step 4: Detect colonies in detection matrix result = OtsuDetector().apply(result)
Rationale for chaining:
Order matters: Background correction before contrast enhancement yields better results than vice versa.
Divide and conquer: One enhancer per problem (illumination, noise, contrast) is more maintainable and tunable than one monolithic algorithm.
No data loss: Each enhancer preserves the original RGB/gray, so intermediate results can be inspected and validated.
Reproducibility: Chained operations can be serialized to YAML for documentation and reuse across experiments.
Use ImagePipeline for convenient chaining:
from phenotypic import Image, ImagePipeline from phenotypic.enhance import SubtractRollingBall, CLAHE, GaussianBlur from phenotypic.detect import OtsuDetector pipeline = ImagePipeline() pipeline.add(SubtractRollingBall(width=50)) pipeline.add(CLAHE(kernel_size=50, clip_limit=0.02)) pipeline.add(GaussianBlur(sigma=2)) pipeline.add(OtsuDetector()) # Process a batch of images with automatic parallelization images = [Image.imread(f) for f in plate_scans] results = pipeline.operate(images)
Methods and Attributes
- None at the ImageEnhancer level; subclasses define enhancement parameters
- as instance attributes
- Type:
e.g., sigma, kernel_size, clip_limit
- apply(image, inplace=False)[source]
Applies the enhancement to an image. Returns a modified Image with enhanced detect_mat but unchanged RGB/gray/objects. Handles copy/inplace logic and validates data integrity.
- _operate(self, image)[source]
Abstract instance method implemented by subclasses. Performs the actual enhancement algorithm. Access parameters via
self.param_name.
- _make_footprint(shape, width)
Static utility that creates a binary morphological shape (disk, square, or diamond) for use in morphological operations.
Notes
Protected components: The
@validate_operation_integritydecorator ensures thatimage.rgbandimage.graycannot be modified. Onlyimage.detect_matcan be changed.Immutability by default:
apply(image)returns a modified copy by default. Setinplace=Truefor memory-efficient in-place modification.Instance method pattern: The
_operate()method should be an instance method (no@staticmethoddecorator). Access operation parameters directly viaself.param_name. This is simpler and more Pythonic.Accessor pattern: Always use
image.detect_mat[:] = new_datato modify detection matrix. Never use direct attribute assignment.Automatic cache invalidation: When you modify
image.detect_mat[:], the Image’s internal caches (e.g., color space conversions, object maps) are automatically invalidated to prevent stale results.
Examples
Basic usage with noise reduction:
>>> from phenotypic.abc_ import ImageEnhancer >>> from phenotypic.data import load_synth_yeast_plate >>> from scipy.ndimage import gaussian_filter >>> >>> class GaussianEnhancer(ImageEnhancer): ... def __init__(self, sigma=1.5): ... super().__init__() ... self.sigma = sigma ... def _operate(self, image): ... enh = image.detect_mat[:] ... filtered = gaussian_filter(enh.astype(float), sigma=self.sigma) ... image.detect_mat[:] = filtered.astype(enh.dtype) ... return image >>> >>> image = load_synth_yeast_plate() >>> enhancer = GaussianEnhancer(sigma=2.0) >>> enhanced = enhancer.apply(image) >>> # Original RGB and gray are unchanged >>> assert (image.gray[:] == enhanced.gray[:]).all()
Morphological enhancement with FootprintMixin for colony hole-filling:
>>> from phenotypic.abc_ import ImageEnhancer >>> from phenotypic.data import load_synth_yeast_plate >>> from skimage.morphology import closing >>> >>> class MorphologicalEnhancer(ImageEnhancer): ... def __init__(self, operation='closing', width=3): ... super().__init__() ... self.operation = operation ... self.width = width ... def _operate(self, image): ... enh = image.detect_mat[:] ... footprint = ImageEnhancer._make_footprint('disk', self.width) ... binary = enh > enh.mean() ... refined = closing(binary, footprint=footprint) ... image.detect_mat[:] = (refined * 255).astype(enh.dtype) ... return image >>> >>> image = load_synth_yeast_plate() >>> enhancer = MorphologicalEnhancer(operation='closing', width=5) >>> enhanced = enhancer.apply(image)
Chaining multiple enhancements in pipeline:
>>> from phenotypic import ImagePipeline >>> from phenotypic.enhance import GaussianBlur, CLAHE >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.data import load_synth_yeast_plate >>> >>> image = load_synth_yeast_plate() >>> pipeline = ImagePipeline([ ... GaussianBlur(sigma=1.5), ... CLAHE(clip_limit=2.0), ... OtsuDetector() ... ]) >>> result = pipeline.apply(image) >>> colonies = result.objects >>> len(colonies) > 0 True
- __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.
- apply(image: Image, inplace: bool = False) Image[source]
- apply(image: GridImage, inplace: bool = False) GridImage
Applies the operation to an image, either in-place or on a copy.
- Parameters:
image (Image) – The arr image to apply the operation on.
inplace (bool) – If True, modifies the image in place; otherwise, operates on a copy of the image.
- Returns:
The modified image after applying the operation.
- Return type:
Image
- 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.
- class phenotypic.abc_.ImageOperation(*args, **kwargs)[source]
Bases:
BaseOperation,LazyWidgetMixin,ABCCore abstract base class for all single-image transformation operations in PhenoTypic.
ImageOperation is the foundation of PhenoTypic’s algorithm system. It defines the interface for algorithms that transform an Image object by modifying specific components. Unlike GridOperation (which handles grid-aligned operations on plate images), ImageOperation acts on a single image independently.
What is ImageOperation?
ImageOperation manages the distinction between:
apply() method: The user-facing interface that handles memory management (copy vs. in-place) and integrity validation
_operate() method: The abstract algorithm-specific method that subclasses implement with the actual processing logic
This separation ensures consistent behavior, automatic memory tracking, and validation across all image operations.
The Operation Hierarchy
ImageOperation has four main subclass categories, each modifying different image components with different integrity guarantees:
ImageOperation (this class) ├── ImageEnhancer │ └── Modifies ONLY image.detect_mat │ ├── GaussianBlur, CLAHE, RankMedianEnhancer, ... │ └── Use for: noise reduction, contrast, edge sharpening │ ├── ObjectDetector │ └── Modifies ONLY image.objmask and image.objmap │ ├── OtsuDetector, CannyDetector, RoundPeaksDetector, ... │ └── Use for: discovering and labeling colonies/particles │ ├── ObjectRefiner │ └── Modifies ONLY image.objmask and image.objmap │ ├── Size filtering, merging, removing objects │ └── Use for: cleaning up detection results │ └── ImageCorrector └── Modifies ALL image components ├── GridAligner, rotation, color correction └── Use for: general-purpose transformationsWhen to inherit from each subclass:
ImageEnhancer: You only modify
image.detect_mat(detection matrix). Originalimage.rgbandimage.grayare protected by integrity checks. Typical use: preprocessing before detection.ObjectDetector: You analyze image data and produce only
image.objmask(binary mask) andimage.objmap(labeled object map). Input image data is protected. Typical use: colony detection, particle finding.ObjectRefiner: You edit the mask and map (filtering, merging, removing). Input image data is protected. Typical use: post-detection cleanup.
ImageCorrector: You transform the entire Image (every component may change). No integrity checks are performed. Typical use: rotation, alignment, global color correction.
Never inherit directly from ImageOperation. Always choose one of the four subclasses above, as each provides appropriate integrity validation and shared utilities (e.g.,
_make_footprint()for morphology operations).How apply() and _operate() work together
The user-facing method
apply(image, inplace=False)is the entry point:Calls ``_apply_to_single_image()`` with the operation logic
Handles copy/inplace semantics:
If
inplace=False(default): Image is copied before modification, original unchangedIf
inplace=True: Image is modified in-place for memory efficiency
Calls your _operate() instance method with the image
Validates integrity (subclass-specific via
@validate_operation_integrity) - Detects unexpected modifications to protected image components - Only enabled ifVALIDATE_OPS=Truein environment
Your subclass only needs to implement
_operate(self, image) -> Image.The _operate() method contract
_operate()is an instance method (no@staticmethoddecorator):Signature:
def _operate(self, image: Image) -> Image:Parameters: Access operation parameters directly via
self.param_nameBehavior: Modify only the allowed image components (determined by subclass)
Returns: The modified Image object
Example implementation:
class MyEnhancer(ImageEnhancer): def __init__(self, sigma: float): super().__init__() self.sigma = sigma # Instance attribute def _operate(self, image: Image) -> Image: # Access parameters via self image.detect_mat[:] = gaussian_filter(image.detect_mat[:], sigma=self.sigma) return image
The instance method pattern is simpler and more Pythonic than the old static method approach.
Data access through accessors
Within
_operate(), always access image data through accessors (never direct attribute modification). This ensures lazy evaluation, caching, and consistency:Reading data:
image.detect_mat[:]- Detection matrix (for enhancers)image.rgb[:]- Original RGB dataimage.gray[:]- Luminance grayscaleimage.objmask[:]- Binary object maskimage.objmap[:]- Labeled object mapimage.color.Lab[:],image.color.HSV[:]- Color spaces
Modifying data:
image.detect_mat[:] = new_array- Set detection matriximage.objmask[:] = binary_array- Set object maskimage.objmap[:] = labeled_array- Set object map
Never do this:
# ✗ WRONG - direct attribute modification image.rgb = new_data image._detect_mat = new_data image.objects_handler.detect_mat = new_data
Do this instead:
# ✓ CORRECT - use accessors image.detect_mat[:] = new_data image.objmask[:] = new_mask
Integrity validation with @validate_operation_integrity
Intermediate subclasses use the
@validate_operation_integritydecorator to enforce that modifications are limited to specific components. For example:class ImageEnhancer(ImageOperation, ABC): @validate_operation_integrity('image.rgb', 'image.gray') def apply(self, image: Image, inplace=False) -> Image: return super().apply(image=image, inplace=inplace)
This decorator:
Calculates MurmurHash3 signatures of protected arrays before
apply()Calls the parent
apply()methodRecalculates signatures after operation completes
Raises
OperationIntegrityErrorif any protected component changed
Only enabled if
VALIDATE_OPS=Truein environment (for performance).Operation chaining and pipelines
Operations are designed for method chaining:
result = (GaussianBlur(sigma=2).apply(image) .apply_operation(OtsuDetector()))
Or use
ImagePipelinefor multi-step workflows with automatic benchmarking:pipeline = ImagePipeline() pipeline.add(GaussianBlur(sigma=2)) pipeline.add(OtsuDetector()) pipeline.add(GridFinder()) results = pipeline.operate([image1, image2, image3])
Parallel execution support
ImageOperation supports parallel execution through operation serialization. When
ImagePipelineruns with multiple images, it:Serializes the operation instance with all attributes (
op.__dict__)Sends the pickled operation to worker processes
Workers unpickle the operation (restoring all
self.paramvalues)Workers call
operation.apply(image)which invokes_operate(self, image)
Instance methods work perfectly with parallel execution because the entire operation object (with all parameters) is serialized together.
- None
- Type:
all operation state is stored in subclass instances as attributes
- apply(image, inplace=False)[source]
User-facing method that applies the operation. Handles copy/inplace logic, calls
_operate(), and validates integrity.
- _operate(self, image)[source]
Abstract instance method implemented by subclasses with algorithm logic. Access parameters via
self.param_name.
- _apply_to_single_image(cls_name, image, operation, inplace)[source]
Static helper method that performs the actual apply operation. Handles copy/inplace logic and error handling. Called internally by apply(). Also used by ImagePipeline for parallel execution.
Notes
No direct Image attribute modification: Never write to
image.rgb,image.gray, or other attributes directly. Use the accessor pattern (image.component[:] = new_data).Immutability by default: Operations return modified copies by default. Original image is unchanged unless
inplace=Trueis explicitly passed.Instance method pattern: The
_operate()method should be an instance method (no@staticmethoddecorator). Access operation parameters directly viaself.param_name. This is simpler and more Pythonic than the old static method approach.Parallel execution compatibility: Instance methods work seamlessly with parallel execution. Operations are serialized with all instance attributes (
op.__dict__) and unpickled in worker processes with full state restored.Automatic memory/performance tracking: BaseOperation (parent class) automatically tracks memory usage and execution time when the logger is configured for INFO level or higher. Disable by setting logger to WARNING.
Cross-platform compatibility: Some dependencies (rawpy, pympler) are platform-specific. Code must gracefully handle missing optional dependencies.
Integrity validation is optional: The
@validate_operation_integritydecorator only runs ifVALIDATE_OPS=Truein environment. This provides development-time safety without production overhead.
Examples
Implementing a custom ImageEnhancer:
>>> from phenotypic.abc_ import ImageEnhancer >>> from phenotypic import Image >>> from scipy.ndimage import gaussian_filter >>> class GaussianEnhancer(ImageEnhancer): ... '''Custom enhancer applying Gaussian blur to detect_mat.''' ... ... def __init__(self, sigma: float = 1.0): ... super().__init__() ... self.sigma = sigma # Instance attribute ... ... def _operate(self, image: Image) -> Image: ... '''Apply Gaussian blur to detect_mat.''' ... # Read detection matrix ... enh = image.detect_mat[:] ... # Apply Gaussian filter (access parameter via self) ... blurred = gaussian_filter(enh.astype(float), sigma=self.sigma) ... # Modify detect_mat through accessor ... image.detect_mat[:] = blurred.astype(enh.dtype) ... return image >>> # Usage >>> enhancer = GaussianEnhancer(sigma=2.5) >>> enhanced = enhancer.apply(image) # Original unchanged >>> enhanced_inplace = enhancer.apply(image, inplace=True) # Original modified
Implementing a custom ObjectDetector:
>>> from phenotypic.abc_ import ObjectDetector >>> from phenotypic import Image >>> from skimage.feature import peak_local_max >>> from skimage.measure import label as measure_label >>> import numpy as np >>> class PeakDetector(ObjectDetector): ... '''Detector using local peak finding to locate colonies.''' ... ... def __init__(self, min_distance: int = 10, threshold_abs: int = 100): ... super().__init__() ... self.min_distance = min_distance ... self.threshold_abs = threshold_abs ... ... def _operate(self, image: Image) -> Image: ... '''Find peaks in detect_mat and create object mask/map.''' ... # Find local maxima (colony peaks) - access parameters via self ... coords = peak_local_max( ... image.detect_mat[:], ... min_distance=self.min_distance, ... threshold_abs=self.threshold_abs ... ) ... # Create binary mask from peaks ... mask = np.zeros(image.detect_mat.shape, dtype=bool) ... for y, x in coords: ... mask[y, x] = True ... # Create labeled map from mask ... labeled_map = measure_label(mask) ... # Set detection results ... image.objmask[:] = mask ... image.objmap[:] = labeled_map ... return image >>> # Usage - automatic integrity validation in ImageDetector >>> detector = PeakDetector(min_distance=15, threshold_abs=120) >>> detected = detector.apply(image) >>> colonies = detected.objects >>> print(f"Detected {len(colonies)} colonies")
Understanding inplace parameter and memory efficiency:
>>> from phenotypic.enhance import GaussianBlur >>> from phenotypic import Image >>> image = Image.imread('colony_plate.jpg') >>> enhancer = GaussianBlur(sigma=2.0) >>> # Default: inplace=False (safe, creates copy) >>> enhanced = enhancer.apply(image) >>> print(f"Same object? {id(image) == id(enhanced)}") # False >>> # For memory efficiency with large images >>> result = enhancer.apply(image, inplace=True) >>> print(f"Same object? {id(image) == id(result)}") # True # inplace=True is useful in pipelines with many large images # to minimize memory overhead, but modifies the original
Using operations in a processing pipeline:
>>> from phenotypic import Image, ImagePipeline >>> from phenotypic.enhance import GaussianBlur >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.grid import GridFinder >>> # Load image >>> image = Image.imread('colony_plate.jpg') >>> # Sequential chaining >>> enhanced = GaussianBlur(sigma=2).apply(image) >>> detected = OtsuDetector().apply(enhanced) >>> grid = GridFinder().apply(detected) >>> # Or use ImagePipeline for batch processing >>> pipeline = ImagePipeline() >>> pipeline.add(GaussianBlur(sigma=2)) >>> pipeline.add(OtsuDetector()) >>> pipeline.add(GridFinder()) >>> # Process multiple images with automatic parallelization >>> images = [Image.imread(f) for f in image_files] >>> results = pipeline.operate(images) # Results are fully processed images
How instance methods work with parallel execution:
>>> from phenotypic.abc_ import ImageOperation >>> from phenotypic import Image >>> class CustomThreshold(ImageOperation): ... def __init__(self, threshold: int, min_size: int = 5): ... super().__init__() ... self.threshold = threshold ... self.min_size = min_size ... ... def _operate(self, image: Image) -> Image: ... # Access parameters via self ... binary = image.detect_mat[:] > self.threshold ... image.objmask[:] = binary ... return image >>> # When apply() is called: >>> op = CustomThreshold(threshold=100, min_size=10) # apply() internally: # 1. Calls _apply_to_single_image() with self._operate (bound method) # 2. _apply_to_single_image calls operation(image) # 3. The bound method includes self, so all parameters are available >>> result = op.apply(image) # For parallel execution, the entire operation object (with all # attributes) is pickled and sent to worker processes
- __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.
- apply(image: Image, inplace: bool = False) Image[source]
- apply(image: GridImage, inplace: bool = False) GridImage
Applies the operation to an image, either in-place or on a copy.
- Parameters:
image (Image) – The arr image to apply the operation on.
inplace (bool) – If True, modifies the image in place; otherwise, operates on a copy of the image.
- Returns:
The modified image after applying the operation.
- Return type:
Image
- 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.
- class phenotypic.abc_.MeasureFeatures(*args, **kwargs)[source]
Bases:
BaseOperation,ABCExtract quantitative measurements from detected colony objects in images.
MeasureFeatures is the abstract base class for all feature extraction operations in PhenoTypic. Unlike ImageOperation classes that return modified images, MeasureFeatures subclasses extract numerical measurements from detected objects and return pandas DataFrames.
Quick Decision Guide:
Use MeasureFeatures when you need to extract numerical colony phenotypes from detected objects. Choose specific measurers based on your phenotype of interest:
Size/morphology: [MeasureSize](src/phenotypic/measure/_measure_size.py) for area, perimeter, circularity
Shape characteristics: [MeasureShape](src/phenotypic/measure/_measure_shape.py) for aspect ratio, eccentricity
Color/pigmentation: [MeasureColor](src/phenotypic/measure/_measure_color.py) for RGB, Lab, HSV measurements
Intensity distribution: [MeasureIntensity](src/phenotypic/measure/_measure_intensity.py) for brightness statistics
Surface texture: [MeasureTexture](src/phenotypic/measure/_measure_texture.py) for roughness, biofilm detection
Custom features: Subclass MeasureFeatures directly for novel measurements
Design Principles:
This class follows a strict pattern where subclasses implement minimal code:
__init__: Define parameters and configuration for your measurement
_operate(image: Image) -> pd.DataFrame: Implement your measurement logic
Everything else (type validation, metadata handling, exception handling) is handled by the measure() method
This ensures consistent behavior, robust error handling, and automatic memory profiling across all measurement operations.
How It Works:
Users call the public API method measure(image, include_meta=False), which:
Validates input (Image object with detected objects)
Extracts operation parameters using introspection
Calls _operate() with matched parameters
Optionally merges image metadata into results
Returns a pandas DataFrame with object labels in the first column
Subclasses only override _operate() and __init__(). The measure() method provides automatic validation, exception handling, and metadata merging.
Accessing Image Data in _operate():
Within your _operate() implementation, access image data through accessors (lazy-evaluated, cached):
image.gray[:] - Grayscale intensity values (weighted luminance)
image.detect_mat[:] - Detection matrix (preprocessed for analysis)
image.objmask[:] - Binary mask of detected objects (1 = object, 0 = background)
image.objmap[:] - Labeled integer array (label ID per object, 0 = background)
image.color.Lab[:] - CIE Lab color space (perceptually uniform)
image.color.XYZ[:] - CIE XYZ color space
image.color.HSV[:] - HSV color space (hue, saturation, value)
image.objects - High-level object interface (iterate with for loop)
image.num_objects - Count of detected objects
DataFrame Output Format:
Your _operate() method must return a pandas DataFrame with:
First column: OBJECT.LABEL (integer object IDs matching image.objmap[:])
Subsequent columns: Measurement results (numeric values)
One row per detected object
Example structure:
OBJECT.LABEL | Area | MeanIntensity | StdDev ----------- |------|---------------|-------- 1 | 1024 | 128.5 | 12.3 2 | 956 | 135.2 | 14.1 3 | 1101 | 120.8 | 11.9
Static Helper Methods for Common Measurements:
This class provides 20+ static helper methods to compute statistics on labeled objects:
Statistical: mean, median, stddev, variance, sum, center_of_mass
Extrema: minimum, maximum, min_extrema (position + value), max_extrema (position + value)
Quantiles: Q1 (25th percentile), Q3 (75th percentile), IQR (interquartile range)
Advanced: coefficient_of_variation (relative texture measure), custom function application
Custom computation: _funcmap2objects() to apply arbitrary Python functions to object regions
Utilities: _ensure_array() to normalize scalars and arrays uniformly
Helper Method Usage Pattern:
All helpers follow a consistent signature: _calculate_*(array, objmap=None) where array is your 2D data and objmap is the labeled integer array from image.objmap[:]. If objmap=None, the entire non-zero region is treated as one object. Returns 1D numpy array with one value per object (or scalar for single-object mode).
Example within _operate():
gray = image.detect_mat[:] objmap = image.objmap[:] area = self._calculate_sum(image.objmask[:], objmap) # Pixel count mean_int = self._calculate_mean(gray, objmap) # Average brightness stddev = self._calculate_stddev(gray, objmap) # Texture uniformity cv = self._calculate_coeff_variation(gray, objmap) # Relative variation
Example: Creating a Custom Measurer for Bacterial Colonies
Implementing a custom colony measurement class:
>>> from phenotypic.abc_ import MeasureFeatures >>> from phenotypic.tools\_.constants_ import OBJECT >>> import pandas as pd >>> import numpy as np >>> class MeasureCustom(MeasureFeatures): ... '''Measure custom morphology metrics for microbial colonies.''' ... ... def __init__(self, intensity_threshold=100): ... '''Initialize with intensity threshold for bright pixels.''' ... self.intensity_threshold = intensity_threshold ... ... def _operate(self, image): ... '''Extract bright region area and mean intensity.''' ... gray = image.detect_mat[:] ... objmap = image.objmap[:] ... # Identify bright pixels within each object ... bright_mask = gray > self.intensity_threshold ... # Count bright pixels per object ... bright_area = self._calculate_sum( ... array=bright_mask.astype(int), ... objmap=objmap ... ) ... # Mean intensity of bright pixels ... bright_intensity = self._funcmap2objects( ... func=lambda arr: np.mean(arr[arr > self.intensity_threshold]), ... out_dtype=float, ... array=gray, ... objmap=objmap, ... default=np.nan ... ) ... # Create results DataFrame ... results = pd.DataFrame({ ... 'BrightArea': bright_area, ... 'BrightMeanIntensity': bright_intensity, ... }) ... results.insert(0, OBJECT.LABEL, image.objects.labels2series()) ... return results >>> # Usage: >>> from phenotypic import Image >>> image = Image('colony_plate.jpg') >>> # (After detection...) >>> measurer = MeasureCustom(intensity_threshold=150) >>> measurements = measurer.measure(image) # Returns DataFrame
When to Use MeasureFeatures vs ImageOperation:
Use MeasureFeatures when you want to extract numerical metrics from objects (returns DataFrame). Use ImageOperation (ImageEnhancer, ImageCorrector, ObjectDetector) when you want to modify the image (returns Image).
Microbe Phenotyping Context:
In arrayed microbial growth assays, measurements extract colony phenotypes: morphology (size, shape, compactness), color (pigmentation, growth medium binding), texture (biofilm formation, colony surface roughness), and intensity distribution (density variation, heterogeneity). These measurements feed into genetic and environmental association studies.
- No public attributes. Configuration is passed through __init__() parameters.
Examples
Basic usage: measure colony area and intensity:
>>> from phenotypic import Image >>> from phenotypic.measure import MeasureSize >>> # Load and detect colonies >>> image = Image('plate_image.jpg') >>> from phenotypic.detect import OtsuDetector >>> detector = OtsuDetector() >>> image = detector.operate(image) >>> # Extract size measurements >>> measurer = MeasureSize() >>> df = measurer.measure(image) >>> print(df) # Output: # OBJECT.LABEL Area IntegratedIntensity # 0 1 1024 128512 # 1 2 956 121232 # 2 3 1101 134232
Advanced: extract multiple measurement types with metadata:
>>> from phenotypic.measure import ( ... MeasureSize, ... MeasureShape, ... MeasureColor ... ) >>> from phenotypic._core import ImagePipeline >>> # Create pipeline combining detectors and measurers >>> pipeline = ImagePipeline( ... detector=OtsuDetector(), ... measurers=[ ... MeasureSize(), ... MeasureShape(), ... MeasureColor(include_XYZ=False) ... ] ... ) >>> # Measure a single image with metadata >>> results = pipeline.operate(image) >>> # Combine measurements: merge multiple DataFrames by OBJECT.LABEL >>> combined = results[0] >>> for df in results[1:]: ... combined = combined.merge(df, on=OBJECT.LABEL)
Custom feature engineering: growth density index:
>>> from phenotypic.abc_ import MeasureFeatures >>> from phenotypic.tools\_.constants_ import OBJECT >>> import pandas as pd >>> import numpy as np >>> class MeasureGrowthDensity(MeasureFeatures): ... '''Custom measurer: compute colony compactness index.''' ... ... def _operate(self, image): ... '''Calculate area, perimeter, and density index.''' ... objmap = image.objmap[:] ... gray = image.detect_mat[:] ... # Area and perimeter ... area = self._calculate_sum(image.objmask[:], objmap) ... perimeter = self._funcmap2objects( ... func=lambda arr: np.sqrt(np.sum(arr > 0)) * 4, ... out_dtype=float, ... array=image.objmask[:], ... objmap=objmap ... ) ... # Mean intensity (growth density) ... mean_intensity = self._calculate_mean(gray, objmap) ... # Compactness = 4*pi*Area / Perimeter^2 ... compactness = (4 * np.pi * area) / (perimeter ** 2 + 1e-6) ... results = pd.DataFrame({ ... 'Area': area, ... 'Perimeter': perimeter, ... 'MeanIntensity': mean_intensity, ... 'CompactnessIndex': compactness, ... }) ... results.insert(0, OBJECT.LABEL, image.objects.labels2series()) ... return results
- __del__()
Automatically stop tracemalloc when the object is deleted.
- measure(image, include_meta=False)[source]
Execute the measurement operation on a detected-object image.
This is the main public API method for extracting measurements. It handles: input validation, parameter extraction via introspection, calling the subclass-specific _operate() method, optional metadata merging, and exception handling.
How it works (for users):
Pass your processed Image (with detected objects) to measure()
The method calls your subclass’s _operate() implementation
Results are validated and returned as a pandas DataFrame
If include_meta=True, image metadata (filename, grid info) is merged in
How it works (for developers):
When you subclass MeasureFeatures, you only implement _operate(). This measure() method automatically:
Extracts __init__ parameters from your instance (introspection)
Passes matched parameters to _operate() as keyword arguments
Validates the Image has detected objects (objmap)
Wraps exceptions in OperationFailedError with context
Merges grid/object metadata if requested
- Parameters:
image (Image) – A PhenoTypic Image object with detected objects (must have non-empty objmap from a prior detection operation).
include_meta (bool, optional) – If True, merge image metadata columns (filename, grid position, etc.) into the results DataFrame. Defaults to False.
- Returns:
Measurement results with structure:
First column: OBJECT.LABEL (integer IDs from image.objmap[:])
Remaining columns: Measurement values (float, int, or string)
One row per detected object
If include_meta=True, additional metadata columns are prepended before OBJECT.LABEL (e.g., Filename, GridRow, GridCol).
- Return type:
pd.DataFrame
- Raises:
OperationFailedError – If _operate() raises any exception, it is caught and re-raised as OperationFailedError with details including the original exception type, message, image name, and operation class. This provides consistent error handling across all measurers.
Notes
This method is the main entry point; do not override in subclasses
Subclasses implement _operate() only, not this method
Automatic memory profiling is available via logging configuration
Image must have detected objects (image.objmap should be non-empty)
Examples
Basic measurement extraction:
>>> from phenotypic import Image >>> from phenotypic.measure import MeasureSize >>> from phenotypic.detect import OtsuDetector >>> # Load and detect >>> image = Image('plate.jpg') >>> image = OtsuDetector().operate(image) >>> # Extract measurements >>> measurer = MeasureSize() >>> df = measurer.measure(image) >>> print(df.head())
Include metadata in measurements:
>>> # With image metadata (filename, grid info) >>> df_with_meta = measurer.measure(image, include_meta=True) >>> print(df_with_meta.columns) # Output: ['Filename', 'GridRow', 'GridCol', 'OBJECT.LABEL', # 'Area', 'IntegratedIntensity', ...]
- class phenotypic.abc_.MeasurementInfo(value)[source]
-
Base class for creating standardized measurement information enumerations.
This class provides a structured way to define measurement types with consistent naming conventions, descriptive metadata, and automatic documentation generation. By inheriting from both str and Enum, MeasurementInfo enables measurement definitions to behave as enumeration members while maintaining string representation. The class automatically prefixes measurement labels with a category name, ensuring consistent naming across code and outputs.
Key Purposes:
Standardize measurement naming conventions (category_label format) to reduce errors
Centralize measurement definitions with labels and descriptions in one place
Automatically generate RST documentation tables from measurement definitions
Provide easy access to headers, labels, and category information for analysis workflows
Enable type-safe column names in measurement DataFrames
Usage with MeasureFeatures Subclasses:
MeasurementInfo enums are used internally by measurement operations ([MeasureSize](src/phenotypic/measure/_measure_size.py), [MeasureShape](src/phenotypic/measure/_measure_shape.py), [MeasureColor](src/phenotypic/measure/_measure_color.py), etc.) to define column names in output DataFrames. Each measurement class defines its own enum and uses the enum values as DataFrame column headers.
- label
The short label for the measurement (without category prefix). Set automatically by __new__ from the first element of the enum value tuple.
- Type:
- desc
The description of what the measurement represents. Set automatically by __new__ from the second element of the enum value tuple. Defaults to empty string if not provided.
- Type:
- pair
A tuple of (label, description) for convenient access to both pieces of information together.
- CATEGORY
The category name returned by the category() classmethod. Provides instance-level access to the measurement category.
- Type:
Examples
Define a custom measurement enumeration:
>>> from phenotypic.abc_ import MeasurementInfo >>> class SHAPE(MeasurementInfo): ... @classmethod ... def category(cls): ... return 'Shape' ... ... AREA = ('Area', 'Total number of pixels in the detected object') ... PERIMETER = ('Perimeter', 'Total length of object boundary in pixels')
Access measurement information and generate headers:
>>> SHAPE.AREA <Shape_Area: 'Shape_Area'> >>> str(SHAPE.AREA) 'Shape_Area' >>> SHAPE.AREA.label 'Area' >>> SHAPE.AREA.desc 'Total number of pixels in the detected object' >>> SHAPE.AREA.CATEGORY 'Shape' >>> SHAPE.get_labels() ['Area', 'Perimeter'] >>> SHAPE.get_headers() ['Shape_Area', 'Shape_Perimeter']
Use in DataFrame column naming (as MeasureFeatures do internally):
>>> import pandas as pd >>> measurements = pd.DataFrame({ ... str(SHAPE.AREA): [1024, 956, 1101], ... str(SHAPE.PERIMETER): [128, 120, 135] ... }) >>> measurements.columns.tolist() ['Shape_Area', 'Shape_Perimeter'] >>> measurements[str(SHAPE.AREA)] 0 1024 1 956 2 1101
Generate and append RST documentation:
>>> table = SHAPE.rst_table() >>> class MeasureShape: ... '''Measures object morphology.''' ... pass >>> MeasureShape.__doc__ = SHAPE.append_rst_to_doc(MeasureShape)
- __format__(format_spec)
Returns format using actual value type unless __str__ has been overridden.
- __new_member__(label: str, desc: str | None = None)
Create a new measurement enumeration member with prefixed name.
Converts the input label and description into an enumeration member whose string value is automatically prefixed with the category name. The label and description are stored as instance attributes for convenient access.
This method is called automatically by Python’s Enum machinery when defining enum members. The enum member’s value becomes the full prefixed name (e.g., ‘Shape_Area’), while the label and description are stored separately as instance attributes.
- Parameters:
label (str) – The short label for the measurement without category prefix (e.g., ‘Area’). This will be combined with the category name to create the full enumeration value.
desc (str, optional) – The description of what the measurement represents. If not provided, defaults to an empty string. This should briefly explain what the measurement measures.
- Returns:
- An enumeration member that behaves as a string with the value
’{category}_{label}’ (e.g., ‘Shape_Area’). The instance also has label, desc, and pair attributes set.
- Return type:
- __str__() str[source]
Return the string representation of this measurement as the prefixed name.
Returns the full enumeration value, which is the category-prefixed label (e.g., ‘Shape_Area’). This is used when the measurement is converted to a string or used in string formatting.
- Returns:
The full prefixed name of the measurement (e.g., ‘{category}_{label}’).
- Return type:
- classmethod append_rst_to_doc(module: str | object) str[source]
Append the measurement documentation table to a module or class docstring.
Generates the RST documentation table for this measurement enumeration and appends it to the provided module’s or class’s existing docstring. This is useful for automatically documenting which measurements a class produces or uses.
If the input is a string, it is treated as the docstring itself. If it is an object (class, function, module), its __doc__ attribute is used.
- Parameters:
module (str | object) – Either a docstring string or an object (class, function, module) whose __doc__ attribute contains the docstring. The existing docstring is preserved, and the measurement table is appended with a blank line separator.
- Returns:
- The original docstring (or string) with the RST measurement table appended,
separated by two blank lines. The returned string is ready to be assigned back to the target’s __doc__ attribute.
- Return type:
- capitalize()
Return a capitalized version of the string.
More specifically, make the first character have upper case and the rest lower case.
- casefold()
Return a version of the string suitable for caseless comparisons.
- classmethod category() str[source]
Return the category name for this measurement enumeration.
Subclasses must implement this method to provide a category name that will be used to prefix all measurement labels. This ensures consistent naming conventions across the codebase.
- Returns:
- The category name (e.g., ‘Shape’, ‘Color’, ‘Texture’). This string is
prepended to each measurement label with an underscore separator to form the full header name (e.g., ‘Shape_Area’).
- Return type:
- Raises:
NotImplementedError – If not implemented by a subclass.
- center(width, fillchar=' ', /)
Return a centered string of length width.
Padding is done using the specified fill character (default is a space).
- count(sub[, start[, end]]) int
Return the number of non-overlapping occurrences of substring sub in string S[start:end]. Optional arguments start and end are interpreted as in slice notation.
- encode(encoding='utf-8', errors='strict')
Encode the string using the codec registered for encoding.
- encoding
The encoding in which to encode the string.
- errors
The error handling scheme to use for encoding errors. The default is ‘strict’ meaning that encoding errors raise a UnicodeEncodeError. Other possible values are ‘ignore’, ‘replace’ and ‘xmlcharrefreplace’ as well as any other name registered with codecs.register_error that can handle UnicodeEncodeErrors.
- endswith(suffix[, start[, end]]) bool
Return True if S ends with the specified suffix, False otherwise. With optional start, test S beginning at that position. With optional end, stop comparing S at that position. suffix can also be a tuple of strings to try.
- expandtabs(tabsize=8)
Return a copy where all tab characters are expanded using spaces.
If tabsize is not given, a tab size of 8 characters is assumed.
- find(sub[, start[, end]]) int
Return the lowest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation.
Return -1 on failure.
- format(*args, **kwargs) str
Return a formatted version of S, using substitutions from args and kwargs. The substitutions are identified by braces (‘{’ and ‘}’).
- format_map(mapping) str
Return a formatted version of S, using substitutions from mapping. The substitutions are identified by braces (‘{’ and ‘}’).
- classmethod get_headers() list[str][source]
Get all measurement headers with category prefix.
Returns a list of the full enumeration values (with category prefix) for all measurements defined in this enumeration. These strings are suitable for use as DataFrame column names, dictionary keys, or in any context where the full categorized name is needed.
- classmethod get_labels() list[str][source]
Get all measurement labels without category prefix.
Returns a list of the short labels (without category prefix) for all measurements defined in this enumeration. These are the first element of each enum value tuple. Useful for creating human-readable lists or column names when the category context is already established.
- index(sub[, start[, end]]) int
Return the lowest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation.
Raises ValueError when the substring is not found.
- isalnum()
Return True if the string is an alpha-numeric string, False otherwise.
A string is alpha-numeric if all characters in the string are alpha-numeric and there is at least one character in the string.
- isalpha()
Return True if the string is an alphabetic string, False otherwise.
A string is alphabetic if all characters in the string are alphabetic and there is at least one character in the string.
- isascii()
Return True if all characters in the string are ASCII, False otherwise.
ASCII characters have code points in the range U+0000-U+007F. Empty string is ASCII too.
- isdecimal()
Return True if the string is a decimal string, False otherwise.
A string is a decimal string if all characters in the string are decimal and there is at least one character in the string.
- isdigit()
Return True if the string is a digit string, False otherwise.
A string is a digit string if all characters in the string are digits and there is at least one character in the string.
- isidentifier()
Return True if the string is a valid Python identifier, False otherwise.
Call keyword.iskeyword(s) to test whether string s is a reserved identifier, such as “def” or “class”.
- islower()
Return True if the string is a lowercase string, False otherwise.
A string is lowercase if all cased characters in the string are lowercase and there is at least one cased character in the string.
- isnumeric()
Return True if the string is a numeric string, False otherwise.
A string is numeric if all characters in the string are numeric and there is at least one character in the string.
- isprintable()
Return True if the string is printable, False otherwise.
A string is printable if all of its characters are considered printable in repr() or if it is empty.
- isspace()
Return True if the string is a whitespace string, False otherwise.
A string is whitespace if all characters in the string are whitespace and there is at least one character in the string.
- istitle()
Return True if the string is a title-cased string, False otherwise.
In a title-cased string, upper- and title-case characters may only follow uncased characters and lowercase characters only cased ones.
- isupper()
Return True if the string is an uppercase string, False otherwise.
A string is uppercase if all cased characters in the string are uppercase and there is at least one cased character in the string.
- join(iterable, /)
Concatenate any number of strings.
The string whose method is called is inserted in between each given string. The result is returned as a new string.
Example: ‘.’.join([‘ab’, ‘pq’, ‘rs’]) -> ‘ab.pq.rs’
- ljust(width, fillchar=' ', /)
Return a left-justified string of length width.
Padding is done using the specified fill character (default is a space).
- lower()
Return a copy of the string converted to lowercase.
- lstrip(chars=None, /)
Return a copy of the string with leading whitespace removed.
If chars is given and not None, remove characters in chars instead.
- static maketrans()
Return a translation table usable for str.translate().
If there is only one argument, it must be a dictionary mapping Unicode ordinals (integers) or characters to Unicode ordinals, strings or None. Character keys will be then converted to ordinals. If there are two arguments, they must be strings of equal length, and in the resulting dictionary, each character in x will be mapped to the character at the same position in y. If there is a third argument, it must be a string, whose characters will be mapped to None in the result.
- partition(sep, /)
Partition the string into three parts using the given separator.
This will search for the separator in the string. If the separator is found, returns a 3-tuple containing the part before the separator, the separator itself, and the part after it.
If the separator is not found, returns a 3-tuple containing the original string and two empty strings.
- removeprefix(prefix, /)
Return a str with the given prefix string removed if present.
If the string starts with the prefix string, return string[len(prefix):]. Otherwise, return a copy of the original string.
- removesuffix(suffix, /)
Return a str with the given suffix string removed if present.
If the string ends with the suffix string and that suffix is not empty, return string[:-len(suffix)]. Otherwise, return a copy of the original string.
- replace(old, new, count=-1, /)
Return a copy with all occurrences of substring old replaced by new.
- count
Maximum number of occurrences to replace. -1 (the default value) means replace all occurrences.
If the optional argument count is given, only the first count occurrences are replaced.
- rfind(sub[, start[, end]]) int
Return the highest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation.
Return -1 on failure.
- rindex(sub[, start[, end]]) int
Return the highest index in S where substring sub is found, such that sub is contained within S[start:end]. Optional arguments start and end are interpreted as in slice notation.
Raises ValueError when the substring is not found.
- rjust(width, fillchar=' ', /)
Return a right-justified string of length width.
Padding is done using the specified fill character (default is a space).
- rpartition(sep, /)
Partition the string into three parts using the given separator.
This will search for the separator in the string, starting at the end. If the separator is found, returns a 3-tuple containing the part before the separator, the separator itself, and the part after it.
If the separator is not found, returns a 3-tuple containing two empty strings and the original string.
- rsplit(sep=None, maxsplit=-1)
Return a list of the substrings in the string, using sep as the separator string.
- sep
The separator used to split the string.
When set to None (the default value), will split on any whitespace character (including \n \r \t \f and spaces) and will discard empty strings from the result.
- maxsplit
Maximum number of splits (starting from the left). -1 (the default value) means no limit.
Splitting starts at the end of the string and works to the front.
- classmethod rst_table(*, title: str | None = None, header: tuple[str, str] = ('Name', 'Description')) str[source]
Generate an RST (reStructuredText) table documenting the measurements.
Creates a formatted list-table in reStructuredText format that documents all measurements in this enumeration, including their labels and descriptions. This is useful for generating API documentation, parameter guides, or measurement reference tables that will be rendered in Sphinx documentation.
- Parameters:
title (str, optional) – The title for the RST table. Defaults to the class name (e.g., ‘SHAPE’). The title is formatted as “Category: {title}” in the output.
header (tuple[str, str], optional) – A tuple of (left_column, right_column) header names. Defaults to (“Name”, “Description”). The left column typically contains measurement labels and the right column contains their descriptions.
- Returns:
- A formatted reStructuredText list-table string. The output includes:
RST list-table directive with the title
Column headers (Name and Description by default)
One row per measurement with label and description
The returned string is ready to be embedded in Sphinx documentation files or appended to docstrings.
- Return type:
- rstrip(chars=None, /)
Return a copy of the string with trailing whitespace removed.
If chars is given and not None, remove characters in chars instead.
- split(sep=None, maxsplit=-1)
Return a list of the substrings in the string, using sep as the separator string.
- sep
The separator used to split the string.
When set to None (the default value), will split on any whitespace character (including \n \r \t \f and spaces) and will discard empty strings from the result.
- maxsplit
Maximum number of splits (starting from the left). -1 (the default value) means no limit.
Note, str.split() is mainly useful for data that has been intentionally delimited. With natural text that includes punctuation, consider using the regular expression module.
- splitlines(keepends=False)
Return a list of the lines in the string, breaking at line boundaries.
Line breaks are not included in the resulting list unless keepends is given and true.
- startswith(prefix[, start[, end]]) bool
Return True if S starts with the specified prefix, False otherwise. With optional start, test S beginning at that position. With optional end, stop comparing S at that position. prefix can also be a tuple of strings to try.
- strip(chars=None, /)
Return a copy of the string with leading and trailing whitespace removed.
If chars is given and not None, remove characters in chars instead.
- swapcase()
Convert uppercase characters to lowercase and lowercase characters to uppercase.
- title()
Return a version of the string where each word is titlecased.
More specifically, words start with uppercased characters and all remaining cased characters have lower case.
- translate(table, /)
Replace each character in the string using the given translation table.
- table
Translation table, which must be a mapping of Unicode ordinals to Unicode ordinals, strings, or None.
The table must implement lookup/indexing via __getitem__, for instance a dictionary or list. If this operation raises LookupError, the character is left untouched. Characters mapped to None are deleted.
- upper()
Return a copy of the string converted to uppercase.
- zfill(width, /)
Pad a numeric string with zeros on the left, to fill a field of the given width.
The string is never truncated.
- property CATEGORY: str
Get the category name for this measurement instance.
Provides instance-level access to the category name defined by the category() classmethod. This is useful when you have a measurement instance and need to know which category it belongs to without explicitly referencing the class.
- Returns:
The category name from the enum class’s category() method.
- Return type:
- class phenotypic.abc_.ObjectDetector(*args, **kwargs)[source]
Bases:
ImageOperation,ABCAbstract 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).
Quick Decision Guide
Use this guide to choose the right operation for your task:
ObjectDetector: Implementing a novel detection algorithm? Produces both objmask (binary) and objmap (labeled).
ThresholdDetector: Your algorithm converts intensity to binary via thresholding? Subclass ThresholdDetector for specialized threshold strategies.
ImageEnhancer: Need to preprocess image data (blur, contrast, denoise) before detection? Use enhancement to prepare detect_mat for better detection.
ObjectRefiner: Need to clean up existing masks (size filter, morphology, merge)? Refiner operates on objmask/objmap without analyzing image data.
Threshold vs Edge vs Peak: Threshold works when intensity separates colonies from background; edge-based (Canny) finds boundaries; peak-based assumes circular shapes.
Grid-aware analysis: Processing arrayed plates? Use GridObjectRefiner or GridFinder for well-plate-specific logic.
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.objectsafter detection.
Key principle: ObjectDetector is READ-ONLY for image data
ObjectDetector operations:
Read
image.detect_mat[:](detection matrix),image.rgb[:], and optionally other image data to inform detection.Write only
image.objmask[:]andimage.objmap[:].Protect
image.rgb,image.gray, andimage.detect_matvia automatic integrity validation (@validate_operation_integritydecorator).
Any attempt to modify protected image components raises
OperationIntegrityErrorwhenVALIDATE_OPS=Truein the environment (enabled during development/testing).Why is detection central to the pipeline?
Detection enables:
Object identification: Distinguishes individual colonies from background and from each other.
Downstream analysis: Once colonies are labeled,
image.objectsprovides access to properties (area, intensity, centroid, morphology) for each colony.Refinement: ObjectRefiner operations clean up detection masks/maps post-detection (e.g., removing spurious objects, merging fragments, filtering by size).
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: [OtsuDetector](src/phenotypic/detect/_otsu_detector.py), [CannyDetector](src/phenotypic/detect/_canny_detector.py), [RoundPeaksDetector](src/phenotypic/detect/_round_peaks_detector.py), [WatershedDetector](src/phenotypic/detect/_watershed_detector.py).
ThresholdDetector (ObjectDetector subclass): Inherit from this if your detection relies on a threshold value. Provides common patterns and signals intent. Examples: [OtsuDetector](src/phenotypic/detect/_otsu_detector.py), [LiDetector](src/phenotypic/detect/_li_detector.py), [YenDetector](src/phenotypic/detect/_yen_detector.py), [TriangleDetector](src/phenotypic/detect/_triangle_detector.py).
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
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
Within _operate(), read image data carefully:
Access via accessors:
image.detect_mat[:],image.gray[:],image.rgb[:]Never modify these; integrity validation will catch it
Consider the data type and range (uint8, uint16, float, etc.)
Perform detection: Use your algorithm to create a binary mask and labeled map. Typical approaches:
Thresholding-based: Global or local threshold → binary mask → label (see [OtsuDetector](src/phenotypic/detect/_otsu_detector.py))
Edge-based: Edge detector (Canny) → invert edges → label regions (see [CannyDetector](src/phenotypic/detect/_canny_detector.py))
Peak-based: Detect intensity peaks → grow regions → label (see [RoundPeaksDetector](src/phenotypic/detect/_round_peaks_detector.py))
Region-based: Watershed or morphological operations (see [WatershedDetector](src/phenotypic/detect/_watershed_detector.py))
Create and set the binary mask and labeled map:
from scipy import ndimage import numpy as np # Example: simple Otsu thresholding enh = image.detect_mat[:] 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
Post-processing (optional): Some detectors include additional cleanup:
Morphological operations: Apply erosion, dilation, opening, or closing to refine mask topology (remove noise, bridge fragments, smooth boundaries).
Clear borders: Use
skimage.segmentation.clear_border()to remove objects touching image edges.Remove small/large objects: Use
skimage.morphology.remove_small_objects()orskimage.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
structurefor connectivity (default 3x3 with all 8 neighbors; usegenerate_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 shape (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.
Reference implementations in PhenoTypic
Study these implementations to learn detection patterns:
[OtsuDetector](src/phenotypic/detect/_otsu_detector.py): Simple thresholding with global Otsu method
[HysteresisDetector](src/phenotypic/detect/_hysteresis_detector.py): Advanced dual-threshold with edge tracking (excellent reference for complex detection)
[CannyDetector](src/phenotypic/detect/_canny_detector.py): Edge-based detection with connectivity cleanup
[RoundPeaksDetector](src/phenotypic/detect/_round_peaks_detector.py): Peak-based approach for round colonies
[WatershedDetector](src/phenotypic/detect/_watershed_detector.py): Region-based segmentation
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("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)[source]
Abstract instance 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.detect_mat 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).
Instance _operate() method: Access parameters via
selfattributes.
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("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}")
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("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}")
- __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.
- apply(image: Image, inplace: bool = False) Image[source]
- apply(image: GridImage, inplace: bool = False) GridImage
Detect colonies using sinusoidal cross-correlation grid estimation.
This method performs the core detection workflow: 1. Extract grid dimensions (if GridImage) 2. Threshold the detection matrix with adaptive kernel sizing 3. Remove noise if requested 4. Label connected components 5. Determine or estimate grid edges (via sinusoidal cross-correlation) 6. Assign dominant colonies to grid cells 7. Create final object map
- Parameters:
image – Image object to process. Can be a regular Image or GridImage.
- Returns:
The processed image with updated objmask and objmap.
- Return type:
Image
- 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.
- class phenotypic.abc_.ObjectRefiner(*args, **kwargs)[source]
Bases:
ImageOperation,ABCAbstract base class for post-detection refinement operations that modify object masks and maps.
ObjectRefiner is the foundation for all post-detection cleanup algorithms that refine colony detections through morphological operations, filtering, and merging. Unlike ObjectDetector (which analyzes image data to create initial detections), ObjectRefiner only modifies the object mask and labeled map, leaving preprocessing data untouched.
Quick Decision Guide: ObjectRefiner vs Alternatives
Use ObjectRefiner if: Detector produces mostly correct detections with manageable noise/artifacts (small objects, fragmented regions, holes, low circularity) that can be characterized and filtered.
Use ObjectDetector if: Detector fundamentally fails to detect colonies or produces too much noise to salvage via post-hoc cleanup.
Use ImageEnhancer if: Problem is image quality (blur, contrast, noise) affecting detection; improve input before detection rather than refining output.
ObjectRefiner vs ObjectDetector: Refiners work on existing masks (objmask/objmap), detectors create masks from image data. Refiners are for cleanup, detectors are for initial analysis.
Size filtering: Use for removing dust, noise, agar artifacts (too small) or unrealistic regions (too large). Example: [SmallObjectRemover](src/phenotypic/refine/_small_object_remover.py).
Morphological cleanup: Use for fragmented edges, thin protrusions, internal gaps. Example: [MaskDilator](src/phenotypic/refine/_mask_dilator.py) (uses FootprintMixin).
Hole filling: Use for voids from uneven illumination or pigment patterns within colonies.
Shape filtering: Use for removing elongated artifacts, merged colonies, low-circularity debris.
Merging operations: Use for bridging fragmented colonies or combining nearby regions. Example: [NearestNeighborMerger](src/phenotypic/refine/_nearest_neighbor_merger.py).
When to chain: Combine multiple refiners in ImagePipeline (remove small noise before filling holes, filter shapes before morphological operations) for clearer, divide-and-conquer approach.
What is ObjectRefiner?
ObjectRefiner operates on the principle of non-destructive post-processing: all modifications are applied only to image.objmask (binary mask) and image.objmap (labeled map), while original image components (image.rgb, image.gray, image.detect_mat) remain protected and unchanged. This allows you to experiment with multiple refinement chains without affecting raw or enhanced image data, ensuring reproducibility and enabling comparison of different cleanup strategies.
Key Principle: ObjectRefiner Modifies Only Detection Results
ObjectRefiner operations:
Read image.objmask[:] (binary mask) and image.objmap[:] (labeled map) from prior detection.
Write only image.objmask[:] and image.objmap[:] with refined results.
Protect image.rgb, image.gray, and image.detect_mat 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).
Role in the Detection-to-Measurement Pipeline
ObjectRefiner sits after detection but before measurement:
Raw Image (rgb, gray, detect_mat) ↓ ImageEnhancer(s) → Improve visibility, reduce noise ↓ ObjectDetector → Detect colonies/objects (initial, often noisy) ↓ ObjectRefiner(s) → Clean up detections (optional but recommended) ↓ MeasureFeatures → Extract colony properties ↓ Analysis → Statistical phenotyping, clustering, growth curvesWhen you call refiner.apply(image), you get back an Image with refined objmask and objmap but identical preprocessing and image data—ready for downstream measurement and analysis.
Why Refinement Matters for Colony Phenotyping
Raw detections from ObjectDetector often contain artifacts:
Spurious small objects: Dust, sensor noise, agar texture, or salt-and-pepper thresholding artifacts create false-positive detections that bias colony counts and statistics.
Fragmented colonies: Uneven lighting, pigment heterogeneity, or aggressive thresholding fragments a single colony into multiple disconnected regions, inflating counts and distorting area measurements.
Merged colonies: In dense plates or when colonies touch, thresholding may merge adjacent colonies into a single detection, losing individuality and requiring post-hoc separation.
Holes in masks: Internal voids within colony masks (from glare or non-uniform pigmentation) create discontinuous shapes that confuse morphological measurements or downstream analysis.
Border artifacts: Colonies touching plate or well boundaries may be incomplete, biasing per-well phenotyping in high-throughput formats.
Refinement operations target these issues with domain-specific strategies: morphological operations (erosion, dilation, opening, closing), shape filtering (circularity, solidity), size thresholding, and boundary enforcement to produce clean, valid detection results.
Differences: ObjectDetector vs ObjectRefiner
ObjectDetector: Analyzes image data (grayscale, RGB, color spaces) and produces initial objmask and objmap. Input: enhanced image. Output: detection results. Typical use: thresholding, edge detection, peak finding, watershed segmentation.
ObjectRefiner: Modifies existing objmask and objmap without analyzing image data. Input: detection results. Output: refined detection results. Typical use: size filtering, morphological cleanup, shape filtering, merging/splitting objects, border removal.
When to Use ObjectRefiner vs Building Better ObjectDetector
Should you refine or improve the detector? Consider:
Use ObjectRefiner if: - The detector produces mostly correct detections but with manageable noise/artifacts - You can characterize the artifacts (small, fragmented, low-circularity, etc.) - Chaining simple refinement operations is clearer than tuning detector parameters - You want to compare cleanup strategies or enable parameter sweeps
Improve ObjectDetector if: - The detector fundamentally fails (misses most colonies, detects at wrong threshold) - Raw detections are too noisy to salvage through simple refinement - The problem is best solved through domain-specific detection logic, not post-hoc cleanup - You have labeled ground truth for detector optimization
Typical Refinement Strategies
Common ObjectRefiner implementations address specific issues:
Size filtering: [SmallObjectRemover](src/phenotypic/refine/_small_object_remover.py) removes objects below/above thresholds. Targets: spurious noise, dust, agar artifacts, oversized regions.
Shape filtering: Remove objects with poor morphology (low circularity, low solidity, high aspect ratio). Targets: elongated artifacts, merged colonies, debris.
Hole filling: Fill interior voids within colony masks for solid shape representation. Targets: voids from uneven illumination, pigment heterogeneity. Improves area measurements.
Morphological operations: Erosion, dilation, opening, closing with [MaskDilator](src/phenotypic/refine/_mask_dilator.py), [MaskEroder](src/phenotypic/refine/_mask_eroder.py), [MaskOpener](src/phenotypic/refine/_mask_opener.py). Targets: fragmented edges, thin protrusions, internal gaps. Uses FootprintMixin for shape control.
Border removal: Remove or exclude objects touching image/well boundaries. Targets: incomplete colonies in arrayed formats.
Merging/splitting: [NearestNeighborMerger](src/phenotypic/refine/_nearest_neighbor_merger.py) combines nearby objects via dilation and relabeling. Targets: fragmented colonies, nearby regions.
Integrity Validation: Protection of Core Data
ObjectRefiner uses the
@validate_operation_integritydecorator on theapply()method to guarantee that preprocessing data are never modified:@validate_operation_integrity('image.rgb', 'image.gray', 'image.detect_mat') def apply(self, image: Image, inplace: bool = False) -> Image: return super().apply(image=image, inplace=inplace)
This decorator:
Calculates cryptographic signatures of image.rgb, image.gray, and image.detect_mat before processing
Calls the parent apply() method to execute your _operate() implementation
Recalculates signatures after operation completes
Raises
OperationIntegrityErrorif any protected component was modified
Note: Integrity validation only runs if the
VALIDATE_OPS=Trueenvironment variable is set (development-time safety; disabled in production for performance).Implementing a Custom ObjectRefiner
Subclass ObjectRefiner and implement a single method:
from phenotypic.abc_ import ObjectRefiner from phenotypic import Image from skimage.morphology import remove_small_objects class MyCustomRefiner(ObjectRefiner): def __init__(self, min_size: int = 50): super().__init__() self.min_size = min_size # Instance attribute matched to _operate() @staticmethod def _operate(image: Image, min_size: int = 50) -> Image: # Modify ONLY objmap; read, process, write back # objmask will be auto-updated from objmap via relabel() refined_map = remove_small_objects(image.objmap[:], min_size=min_size) image.objmap[:] = refined_map return image
Morphological Operations with FootprintMixin
For operations requiring morphological structuring elements (dilation, erosion, opening, closing), inherit from FootprintMixin. See [MaskDilator](src/phenotypic/refine/_mask_dilator.py) for example:
from phenotypic.abc_ import ObjectRefiner from phenotypic.tools_ import FootprintMixin from phenotypic import Image from skimage.morphology import dilation class MyMorphRefiner(ObjectRefiner, FootprintMixin): def __init__(self, footprint_shape: str = 'disk', footprint_width: int = 2): super().__init__() self.footprint_shape = footprint_shape self.footprint_width = footprint_width @staticmethod def _operate(image: Image, footprint_shape: str = 'disk', footprint_width: int = 2) -> Image: # Use _make_footprint from ObjectRefiner or FootprintMixin fp = ObjectRefiner._make_footprint(footprint_shape, footprint_width) dilated = dilation(image.objmask[:], footprint=fp) image.objmask[:] = dilated # Reconstruct objmap from dilated mask from scipy.ndimage import label as ndi_label relabeled, _ = ndi_label(dilated) image.objmap[:] = relabeled return image
Key Rules for Implementation:
_operate()must be an instance method (access parameters viaself).All parameters except image must exist as instance attributes with matching names (enables automatic parameter matching via _get_matched_operation_args()).
Only modify ``image.objmask[:]`` and ``image.objmap[:]``—all other components are protected. Reading image data is allowed but modifications will trigger integrity errors.
Always use the accessor pattern:
image.objmap[:] = new_data(never direct attribute assignment).Return the modified Image object.
Modifying objmask and objmap
Within your _operate() method, use the accessor interface to read and write detection results:
# Reading detection data mask = image.objmask[:] # Binary mask (True = object) objmap = image.objmap[:] # Labeled map (0 = background, 1+ = object label) objects = image.objects # High-level ObjectCollection interface # Modifying detection data image.objmask[:] = refined_mask # Full replacement of binary mask image.objmap[:] = refined_map # Full replacement of labeled map # Partial updates (boolean indexing) # Mark certain labels as background (set to 0) keep_labels = [1, 3, 5] # Labels to retain filtered_map = np.where(np.isin(objmap, keep_labels), objmap, 0) image.objmap[:] = filtered_map
Relationship Between objmask and objmap
objmap (labeled map): Each pixel contains the object label (0 = background, 1+ = object ID). Authoritative source of truth; defines which pixels belong to which colony.
objmask (binary mask): Simple binary version of objmap; True where objmap > 0, False elsewhere. Derived from objmap via image.objmap.relabel().
When you modify objmap, objmask is automatically updated. When you modify objmask directly, call image.objmap.relabel() to ensure consistency (or reconstruct objmap from objmask via connected-component labeling).
The _make_footprint() Static Utility
ObjectRefiner provides a static helper for generating morphological structuring elements (footprints) used in erosion, dilation, and other morphological operations:
@staticmethod def _make_footprint(shape: Literal["square", "diamond", "disk"], width: int) -> np.ndarray: '''Creates a binary morphological shape for image processing.'''
Footprint Shapes and When to Use Each
“disk”: Circular/isotropic shape. Best for preserving rounded colony shapes and applying uniform processing in all directions. Use for: general-purpose morphology (dilation to merge fragments, erosion to remove noise), operations that respect colony roundness.
“square”: Square shape with 8-connectivity. Emphasizes horizontal/vertical edges and aligns with pixel grid. Use for: grid-aligned artifacts, operations aligned with imaging hardware, when processing speed matters (slightly faster than disk).
“diamond”: Diamond-shaped (rotated square) shape with 4-connectivity. Creates a cross-like neighborhood pattern. Use for: specialized cases where diagonal connections should be de-emphasized; less common in practice.
The width parameter controls the neighborhood size (in pixels). Larger radii affect more neighbors and produce broader morphological effects (merge more fragments, remove larger noise, but risk bridging adjacent colonies). Choose width smaller than minimum inter-colony spacing to avoid creating false merges.
Common Morphological Refinement Patterns
Use _make_footprint() with morphological operations from skimage.morphology:
from skimage.morphology import dilation, erosion, closing, opening from phenotypic.abc_ import ObjectRefiner disk_fp = ObjectRefiner._make_footprint('disk', width=3) # Dilation: expand object regions (merge fragmented colonies) dilated_mask = dilation(binary_mask, footprint=disk_fp) # Erosion: shrink object regions (remove thin protrusions, small noise) eroded_mask = erosion(binary_mask, footprint=disk_fp) # Closing: dilation then erosion (fill small holes) closed_mask = closing(binary_mask, footprint=disk_fp) # Opening: erosion then dilation (remove small noise) opened_mask = opening(binary_mask, footprint=disk_fp)
Chaining Multiple Refinements
Refinement operations are typically chained to address multiple issues in sequence:
from phenotypic import Image, ImagePipeline from phenotypic.refine import SmallObjectRemover, MaskFill, LowCircularityRemover # Build a refinement pipeline pipeline = ImagePipeline() pipeline.add(SmallObjectRemover(min_size=100)) # Remove dust/noise pipeline.add(MaskFill()) # Fill holes in colonies pipeline.add(LowCircularityRemover(cutoff=0.75)) # Remove elongated artifacts # Apply to detected image image = Image.imread('plate.jpg') from phenotypic.detect import OtsuDetector detected = OtsuDetector().apply(image) # Refine refined = pipeline.operate([detected])[0] colonies = refined.objects print(f"After refinement: {len(colonies)} colonies")
Rationale for chaining:
Order matters: Remove small noise before filling holes (no point filling tiny artifacts). Remove low-circularity objects before morphological operations (cleaner starting point).
Divide and conquer: One refiner per issue (size, shape, holes, borders) is clearer than monolithic operations.
No data loss: Original detection and image data are preserved, so intermediate steps can be inspected and validated.
Reproducibility: Chained operations can be serialized to YAML for documentation and reuse.
Methods and Attributes
- None at the ObjectRefiner level; subclasses define refinement parameters
- as instance attributes
- Type:
e.g., min_size, cutoff, width
- apply(image, inplace=False)[source]
Applies the refinement to an image. Returns a modified Image with refined objmask and objmap but unchanged RGB/gray/detect_mat. Handles copy/inplace logic and validates data integrity.
- _operate(image, **kwargs)[source]
Abstract instance method implemented by subclasses. Performs the actual refinement algorithm. Access parameters via
self.
- _make_footprint(shape, width)
Static utility that creates a binary morphological shape (disk, square, or diamond) for use in morphological operations.
Notes
Protected components: The
@validate_operation_integritydecorator ensures thatimage.rgb,image.gray, andimage.detect_matcannot be modified. Onlyimage.objmaskandimage.objmapcan be changed.Immutability by default:
apply(image)returns a modified copy by default. Setinplace=Truefor memory-efficient in-place modification.Instance _operate() method: The
_operate()method is an instance method; access parameters viaself.Parameter matching for parallelization: All
_operate()parameters exceptimagemust exist as instance attributes. Whenapply()is called, these values are extracted and passed to_operate().Accessor pattern: Always use
image.objmap[:] = new_datato modify object maps. Never use direct attribute assignment.objmap/objmask consistency: When modifying objmap, call image.objmap.relabel() to ensure objmask is updated. When modifying objmask directly, reconstruct objmap via connected-component labeling.
Boolean indexing for filtering: Use numpy boolean arrays to filter labels:
mask = np.isin(objmap, keep_labels); filtered_map = objmap * mask
Examples
Removing small spurious objects below minimum size:
>>> from phenotypic.abc_ import ObjectRefiner >>> from phenotypic import Image >>> from skimage.morphology import remove_small_objects >>> from scipy import ndimage >>> class SimpleSmallObjectRemover(ObjectRefiner): ... '''Remove objects smaller than a minimum size threshold.''' ... ... def __init__(self, min_size: int = 50): ... super().__init__() ... self.min_size = min_size ... ... @staticmethod ... def _operate(image: Image, min_size: int = 50) -> Image: ... '''Remove small objects from labeled map.''' ... # Get current labeled map ... objmap = image.objmap[:] ... # Remove small objects (automatically updates objmap) ... refined = remove_small_objects(objmap, min_size=min_size) ... # Set refined result ... image.objmap[:] = refined ... return image >>> # Usage >>> from phenotypic.detect import OtsuDetector >>> image = Image.imread('plate.jpg') >>> detected = OtsuDetector().apply(image) >>> # Remove noise below 100 pixels >>> refiner = SimpleSmallObjectRemover(min_size=100) >>> cleaned = refiner.apply(detected) >>> print(f"Before: {detected.objmap[:].max()} objects") >>> print(f"After: {cleaned.objmap[:].max()} objects")
Removing low-circularity objects (merged colonies, artifacts):
>>> from phenotypic.abc_ import ObjectRefiner >>> from phenotypic import Image >>> from skimage.measure import regionprops_table >>> import pandas as pd >>> import numpy as np >>> import math >>> class CircularityFilter(ObjectRefiner): ... '''Remove objects with low circularity (merged colonies, artifacts).''' ... ... def __init__(self, min_circularity: float = 0.7): ... super().__init__() ... self.min_circularity = min_circularity ... ... @staticmethod ... def _operate(image: Image, min_circularity: float = 0.7) -> Image: ... '''Filter objects by circularity using Polsby-Popper metric.''' ... objmap = image.objmap[:] ... # Measure shape properties ... props = regionprops_table( ... label_image=objmap, ... properties=['label', 'area', 'perimeter'] ... ) ... df = pd.DataFrame(props) ... # Calculate circularity (Polsby-Popper: 4*pi*area / perimeter^2) ... df['circularity'] = (4 * math.pi * df['area']) / (df['perimeter'] ** 2) ... # Keep only circular objects ... keep_labels = df[df['circularity'] >= min_circularity]['label'].values ... # Filter map: keep only selected labels ... refined_map = np.where(np.isin(objmap, keep_labels), objmap, 0) ... image.objmap[:] = refined_map ... return image >>> # Usage >>> image = Image.imread('plate.jpg') >>> from phenotypic.detect import OtsuDetector >>> detected = OtsuDetector().apply(image) >>> # Keep only well-formed circular colonies >>> refiner = CircularityFilter(min_circularity=0.75) >>> refined = refiner.apply(detected) >>> print(f"Removed elongated artifacts: {detected.objmap[:].max()} -> {refined.objmap[:].max()}")
Filling holes in colony masks for solid shape representation:
>>> from phenotypic.abc_ import ObjectRefiner >>> from phenotypic import Image >>> from scipy.ndimage import binary_fill_holes >>> class HoleFiller(ObjectRefiner): ... '''Fill holes within colony masks for solid shape representation.''' ... ... def __init__(self): ... super().__init__() ... ... @staticmethod ... def _operate(image: Image) -> Image: ... '''Fill holes in binary mask.''' ... mask = image.objmask[:] ... # Fill holes (interior voids within objects) ... filled = binary_fill_holes(mask) ... # Update mask ... image.objmask[:] = filled ... # Reconstruct labeled map from filled mask ... from scipy import ndimage ... labeled, _ = ndimage.label(filled) ... image.objmap[:] = labeled ... return image >>> # Usage >>> image = Image.imread('plate.jpg') >>> from phenotypic.detect import OtsuDetector >>> detected = OtsuDetector().apply(image) >>> # Fill holes from uneven illumination or pigmentation >>> refiner = HoleFiller() >>> refined = refiner.apply(detected) >>> # Result: solid, contiguous colony shapes better for area measurements >>> print(f"Holes filled; colonies now solid")
Morphological refinement with dilation to merge fragmented colonies:
>>> from phenotypic.abc_ import ObjectRefiner >>> from phenotypic import Image >>> from scipy.ndimage import label as ndi_label >>> from skimage.morphology import dilation >>> import numpy as np >>> class FragmentMerger(ObjectRefiner): ... '''Merge fragmented colonies via morphological dilation and relabeling.''' ... ... def __init__(self, dilation_radius: int = 2): ... super().__init__() ... self.dilation_radius = dilation_radius ... ... @staticmethod ... def _operate(image: Image, dilation_radius: int = 2) -> Image: ... '''Dilate mask and relabel to merge nearby fragments.''' ... mask = image.objmask[:] ... # Create disk shape for isotropic dilation ... fp = ObjectRefiner._make_footprint('disk', dilation_radius) ... # Dilate to bridge fragmented regions ... dilated = dilation(mask, footprint=fp) ... # Relabel connected components ... relabeled, _ = ndi_label(dilated) ... # Set refined results ... image.objmask[:] = dilated ... image.objmap[:] = relabeled ... return image >>> # Usage >>> image = Image.imread('plate.jpg') >>> from phenotypic.detect import OtsuDetector >>> detected = OtsuDetector().apply(image) >>> # Merge fragments from uneven lighting >>> refiner = FragmentMerger(dilation_radius=3) >>> merged = refiner.apply(detected) >>> print(f"Merged fragments: {detected.objmap[:].max()} -> {merged.objmap[:].max()} objects")
Merging nearby objects via nearest-neighbor distance:
>>> from phenotypic.abc_ import ObjectRefiner >>> from phenotypic import Image >>> from scipy.ndimage import label as ndi_label, distance_transform_edt >>> from skimage.morphology import dilation >>> import numpy as np >>> class TransitiveDistanceMerger(ObjectRefiner): ... '''Merge objects within specified distance via distance transform.''' ... ... def __init__(self, merge_distance: int = 5): ... super().__init__() ... self.merge_distance = merge_distance ... ... @staticmethod ... def _operate(image: Image, merge_distance: int = 5) -> Image: ... '''Merge objects closer than merge_distance via dilation of distance map.''' ... mask = image.objmask[:] ... # Compute distance transform from object interior ... dist_map = distance_transform_edt(mask) ... # Dilate distance map to bridge nearby objects ... fp = ObjectRefiner._make_footprint('disk', merge_distance) ... dilated_dist = dilation(dist_map > 0, footprint=fp) ... # Relabel connected components in dilated region ... relabeled, _ = ndi_label(dilated_dist) ... # Set refined results ... image.objmask[:] = dilated_dist ... image.objmap[:] = relabeled ... return image >>> # Usage: merge nearby fragments from partial colonies >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import OtsuDetector >>> image = load_synth_yeast_plate() >>> detected = OtsuDetector().apply(image) >>> # Merge fragments within 10-pixel distance >>> merger = TransitiveDistanceMerger(merge_distance=10) >>> merged = merger.apply(detected) >>> print(f"Merged nearby objects: {detected.objmap[:].max()} -> {merged.objmap[:].max()}")
Chaining multiple refinements in a pipeline:
>>> from phenotypic import Image, ImagePipeline >>> from phenotypic.enhance import GaussianBlur >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.refine import ( ... SmallObjectRemover, MaskFill, LowCircularityRemover ... ) >>> from phenotypic.measure import MeasureColor >>> # Build complete processing pipeline with enhancement, detection, and refinement >>> pipeline = ImagePipeline() >>> # Preprocessing >>> pipeline.add(GaussianBlur(sigma=1.5)) >>> # Detection >>> pipeline.add(OtsuDetector()) >>> # Refinement (chain multiple cleanup operations) >>> pipeline.add(SmallObjectRemover(min_size=100)) # Remove dust >>> pipeline.add(MaskFill()) # Fill internal holes >>> pipeline.add(LowCircularityRemover(cutoff=0.75)) # Remove merged/irregular >>> # Measurement >>> pipeline.add(MeasureColor()) >>> # Load images and process >>> image = Image.imread('plate.jpg') >>> results = pipeline.operate([image]) >>> final = results[0] >>> # Access final clean detection results >>> colonies = final.objects >>> measurements = final.measurements >>> print(f"Detected and cleaned: {len(colonies)} colonies") >>> print(f"Color measurements: {measurements.shape}")
- __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.
- apply(image: Image, inplace: bool = False) Image[source]
- apply(image: GridImage, inplace: bool = False) GridImage
Applies the operation to an image, either in-place or on a copy.
- Parameters:
image (Image) – The arr image to apply the operation on.
inplace (bool) – If True, modifies the image in place; otherwise, operates on a copy of the image.
- Returns:
The modified image after applying the operation.
- Return type:
Image
- 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.
- class phenotypic.abc_.PostMeasurement[source]
Bases:
BaseOperation,ABCTransform a measurement DataFrame after feature extraction.
PostMeasurement is the abstract base class for operations that reshape, enrich, or clean measurement DataFrames produced by the pipeline’s measurement step. Unlike MeasureFeatures (which extracts data from images), PostMeasurement operates on the already-assembled DataFrame.
Post-measurement transforms run after all MeasureFeatures have executed and their results have been merged. They receive the complete DataFrame and return a modified copy.
- Parameters:
None
- Returns:
The transformed measurement DataFrame.
- Return type:
pd.DataFrame
Examples
Subclass to create a custom post-measurement transform:
>>> from phenotypic.abc_ import PostMeasurement >>> import pandas as pd >>> class AddConstant(PostMeasurement): ... def __init__(self, column, value): ... super().__init__() ... self.column = column ... self.value = value ... def _operate(self, df): ... df[self.column] = self.value ... return df >>> post = AddConstant("Metadata_Flag", "OK") >>> df = pd.DataFrame({"ObjectLabel": [1, 2]}) >>> result = post.apply(df) >>> list(result.columns) ['ObjectLabel', 'Metadata_Flag']
- __del__()
Automatically stop tracemalloc when the object is deleted.
- apply(df: pandas.DataFrame) pandas.DataFrame[source]
Apply the post-measurement transform.
- Parameters:
df (pandas.DataFrame) – The merged measurement DataFrame.
- Returns:
The transformed DataFrame.
- Return type:
- class phenotypic.abc_.PrefabPipeline(ops: List[ImageOperation | ImagePipeline] | Dict[str, ImageOperation | ImagePipeline] | None = None, meas: List[MeasureFeatures] | Dict[str, MeasureFeatures] | None = None, post: List[PostMeasurement] | Dict[str, PostMeasurement] | None = None, benchmark: bool = False, verbose: bool = False, name: str | None = None, desc: str | None = None, reset: bool = False)[source]
Bases:
ImagePipelineMarker class for pre-built, validated image processing pipelines from the PhenoTypic team.
PrefabPipeline is a specialized subclass of ImagePipeline that distinguishes “official” pre-built pipelines maintained by the PhenoTypic development team from user-created custom pipelines. It serves as a marker class (no additional functionality) that signals “this pipeline is validated, documented, and recommended for specific use cases in microbe colony phenotyping.”
What is PrefabPipeline?
PrefabPipeline is NOT an operation ABC and does NOT inherit from BaseOperation. Instead, it’s a subclass of ImagePipeline that:
Is a marker class: Inherits all ImagePipeline functionality unchanged; no new methods.
Indicates official status: Subclasses of PrefabPipeline are pre-built, validated pipelines with documented performance, parameter settings, and recommended use cases.
Enables classification: Code can distinguish official pipelines (
isinstance(obj, PrefabPipeline)) from user-defined pipelines for documentation, discovery, or defaulting.Provides templates: Each PrefabPipeline subclass is a complete processing workflow (enhancement, detection, refinement, measurement) ready to use out-of-the-box.
Quick Decision Guide: PrefabPipeline vs Custom ImagePipeline
Use PrefabPipeline for standard colony phenotyping on agar plates with validated workflows
Use custom ImagePipeline for novel imaging scenarios, optimization experiments, or specialized workflows
Start with PrefabPipeline to understand the pipeline structure, then customize via parameter tuning
Clone and modify a PrefabPipeline subclass when you need significant algorithm changes
Combine multiple PrefabPipelines sequentially for complex multi-stage workflows
Available PrefabPipeline Subclasses
The PhenoTypic team maintains several pre-built pipelines optimized for different imaging scenarios:
[HeavyOtsuPipeline](src/phenotypic/prefab/_heavy_otsu_pipeline.py): Multi-layer Otsu detection with aggressive refinement. Robust detection on challenging images (uneven lighting, varied sizes). Computationally expensive.
[HeavyWatershedPipeline](src/phenotypic/prefab/_heavy_watershed_pipeline.py): Watershed segmentation for separated colonies. Handles closely-spaced or merged colonies. Very expensive; for small batches or deep analysis.
[RoundPeaksPipeline](src/phenotypic/prefab/_round_peaks_pipeline.py): Peak detection for circular, well-separated colonies. Fast and suitable for high-throughput screening of early-time-point growth.
[GridSectionPipeline](src/phenotypic/prefab/_grid_section_pipeline.py): Per-well section extraction and fine-grained analysis. Moderate cost; enables per-well quality control and segmentation.
[FilamentousFungiPipeline](src/phenotypic/prefab/_filamentous_fungi_pipeline.py): Two-stage filamentous fungi detection with optional Dijkstra branch reconnection. For irregular spreading colonies.
When to use PrefabPipeline vs Custom ImagePipeline
Use PrefabPipeline if: - You’re analyzing colony growth on agar plates (the intended use case). - You want an immediately usable, tested workflow without configuration. - You want to reproduce results matching published benchmarks or team documentation. - You need a baseline for custom extensions (subclass or copy and modify).
Create a custom ImagePipeline if: - Your imaging scenario is novel (unusual plate format, different organisms, special preparation). - You want to experiment with different detector/refiner/measurement combinations. - You have labeled ground truth and want to optimize parameters for your specific images. - You need pipeline extensions (custom operations not in standard library).
Using a PrefabPipeline
PrefabPipeline subclasses are used exactly like ImagePipeline:
from phenotypic import Image, GridImage from phenotypic.prefab import HeavyOtsuPipeline # Load image(s) image = GridImage.imread('plate.jpg', nrows=8, ncols=12) # Instantiate and apply pipeline pipeline = HeavyOtsuPipeline() result = pipeline.apply(image) # or .operate([image]) # Access results colonies = result.objects measurements = result.measurements print(f"Detected: {len(colonies)} colonies") print(f"Measurements shape: {measurements.shape}")
Customizing a PrefabPipeline
PrefabPipelines accept tunable parameters in
__init__()to adapt to your images without rebuilding the pipeline structure:from phenotypic.prefab import HeavyOtsuPipeline # Use defaults (recommended for most cases) pipeline1 = HeavyOtsuPipeline() # Tune for noisier images pipeline2 = HeavyOtsuPipeline( gaussian_sigma=7, # Stronger blur small_object_min_size=150, # More aggressive noise removal border_remover_size=2 # Remove more edge objects ) # Parameters are typically named after the algorithm or parameter they control. # See pipeline docstring for available parameters and typical values.
When Parameters Fail: Creating a Custom Pipeline
If PrefabPipeline parameter tuning doesn’t solve your problem:
Analyze failures: Which step fails (detection, refinement, measurement)?
Use
pipeline.benchmark=True, verbose=Trueto trace execution.Visually inspect intermediate results (detection masks, refined masks).
Create a custom pipeline:
from phenotypic import ImagePipeline from phenotypic.enhance import GaussianBlur, CLAHE from phenotypic.detect import CannyDetector # Different detector from phenotypic.refine import SmallObjectRemover, MaskFill from phenotypic.measure import MeasureShape, MeasureColor # Custom pipeline for your specific use case custom = ImagePipeline() custom.add(GaussianBlur(sigma=3)) custom.add(CLAHE()) custom.add(CannyDetector(sigma=1.5, low_threshold=0.1, high_threshold=0.4)) custom.add(SmallObjectRemover(min_size=100)) custom.add(MaskFill()) custom.add(MeasureShape()) custom.add(MeasureColor()) # Test and iterate result = custom.operate([image])
Share successful custom pipelines: If you develop a successful custom pipeline for a new imaging scenario, consider contributing it as a PrefabPipeline subclass to the project.
Pipeline Composition Pattern
Combine multiple PrefabPipelinesor mix PrefabPipeline with custom operations:
from phenotypic import ImagePipeline from phenotypic.prefab import HeavyOtsuPipeline, RoundPeaksPipeline from phenotypic.refine import SmallObjectRemover from phenotypic.measure import MeasureIntensity # Combine different detection strategies with shared refinement pipeline = ImagePipeline() pipeline.add(HeavyOtsuPipeline()) # First detection attempt pipeline.add(SmallObjectRemover(min_size=100)) # Noise removal pipeline.add(MeasureIntensity()) # Measurement # Apply to image result = pipeline.apply(image)
Pipeline Serialization Pattern
Save and load pipelines for reproducible batch processing:
from phenotypic.prefab import HeavyOtsuPipeline # Create, configure, and save pipeline = HeavyOtsuPipeline(gaussian_sigma=2.0, small_object_min_size=150) pipeline.to_json('my_colony_pipeline.json') # Save configuration # pipeline.to_yaml('my_colony_pipeline.yaml') # Alternative format # Load for batch processing (reproducible results) loaded = HeavyOtsuPipeline.from_json('my_colony_pipeline.json') results = loaded.operate([image1, image2, image3])
Extending PrefabPipeline
To create a new official PrefabPipeline subclass:
from phenotypic.abc_ import PrefabPipeline from phenotypic.enhance import GaussianBlur, CLAHE from phenotypic.detect import OtsuDetector from phenotypic.refine import SmallObjectRemover from phenotypic.measure import MeasureShape class MyCustomPrefabPipeline(PrefabPipeline): '''Brief description of when to use this pipeline.''' def __init__(self, param1: int = 100, param2: float = 1.5, benchmark: bool = False, verbose: bool = False): '''Initialize with tunable parameters.''' pipe_cfgs = [ GaussianBlur(sigma=param2), CLAHE(), OtsuDetector(), SmallObjectRemover(min_size=param1), ] meas = [MeasureShape()] super().__init__(pipe_cfgs=pipe_cfgs, meas=meas, benchmark=benchmark, verbose=verbose)
Notes
Is a marker, not an operation: PrefabPipeline does not inherit from BaseOperation. It’s a convenient subclass of ImagePipeline for classification and discovery.
Inheritance of ImagePipeline features: PrefabPipeline inherits all ImagePipeline functionality: sequential operation chaining, benchmarking, verbose logging, batch processing via
.operate(), and serialization via.to_yaml()/.from_yaml().Parameter tuning via __init__(): Most PrefabPipeline subclasses expose key algorithm parameters in
__init__()(e.g., detection threshold, smoothing sigma, refinement shape). Adjust these for your specific images before scaling to large batches.Benchmarking for profiling: Set
benchmark=Truewhen instantiating to track execution time and memory usage per operation. Useful for identifying bottlenecks in large batch runs.Documentation and examples: Each PrefabPipeline subclass is documented with use cases, typical parameters, performance characteristics, and example code. Check the subclass docstring for guidance.
Not for operations: Use PrefabPipeline only for complete pipelines. For individual operations (detection, enhancement, measurement), use operation ABCs directly.
Examples
Quick start: Detect colonies with HeavyOtsuPipeline:
>>> from phenotypic import GridImage >>> from phenotypic.prefab import HeavyOtsuPipeline >>> # Load a 96-well plate image >>> image = GridImage.imread('agar_plate.jpg', nrows=8, ncols=12) >>> # Use the pre-built, validated pipeline >>> pipeline = HeavyOtsuPipeline() >>> result = pipeline.apply(image) >>> # Access results >>> print(f"Detected {len(result.objects)} colonies") >>> print(f"Measurements: {result.measurements.columns.tolist()}")
Batch processing multiple plates with a PrefabPipeline:
>>> from phenotypic import GridImage >>> from phenotypic.prefab import HeavyOtsuPipeline >>> import glob >>> # Load multiple plate images >>> image_paths = glob.glob('batch_*.jpg') >>> images = [GridImage.imread(p, nrows=8, ncols=12) ... for p in image_paths] >>> # Create pipeline (reusable for all images) >>> pipeline = HeavyOtsuPipeline(benchmark=True) >>> # Batch process >>> results = pipeline.operate(images) >>> # Collect results >>> for i, result in enumerate(results): ... print(f"Image {i}: {len(result.objects)} colonies") ... print(f"Measurements shape: {result.measurements.shape}")
Customizing pipeline parameters for difficult images:
>>> from phenotypic import GridImage >>> from phenotypic.prefab import HeavyOtsuPipeline >>> image = GridImage.imread('noisy_plate.jpg', nrows=8, ncols=12) >>> # Increase smoothing and noise removal for difficult images >>> pipeline = HeavyOtsuPipeline( ... gaussian_sigma=8, # Stronger blur ... small_object_min_size=200, # Aggressive noise removal ... border_remover_size=2 # More border filtering ... ) >>> result = pipeline.apply(image) >>> print(f"Robust detection: {len(result.objects)} colonies")
Comparing PrefabPipeline vs custom pipeline:
>>> from phenotypic import GridImage, ImagePipeline >>> from phenotypic.prefab import HeavyOtsuPipeline >>> from phenotypic.detect import CannyDetector >>> from phenotypic.refine import SmallObjectRemover >>> image = GridImage.imread('plate.jpg', nrows=8, ncols=12) >>> # Option 1: Use pre-built validated pipeline >>> prefab = HeavyOtsuPipeline() >>> result1 = prefab.apply(image) >>> # Option 2: Create custom pipeline for comparison >>> custom = ImagePipeline() >>> from phenotypic.enhance import GaussianBlur >>> custom.add(GaussianBlur(sigma=2)) >>> custom.add(CannyDetector(sigma=1.5, low_threshold=0.1, high_threshold=0.4)) >>> custom.add(SmallObjectRemover(min_size=100)) >>> result2 = custom.apply(image) >>> # Compare results >>> print(f"Prefab: {len(result1.objects)}, Custom: {len(result2.objects)}")
- Parameters:
ops (List[ImageOperation | ImagePipeline] | Dict[str, ImageOperation | ImagePipeline] | None)
meas (List[MeasureFeatures] | Dict[str, MeasureFeatures] | None)
post (List[PostMeasurement] | Dict[str, PostMeasurement] | None)
benchmark (bool)
verbose (bool)
name (Optional[str])
desc (Optional[str])
reset (bool)
- __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.
- __init__(ops: List[ImageOperation | ImagePipeline] | Dict[str, ImageOperation | ImagePipeline] | None = None, meas: List[MeasureFeatures] | Dict[str, MeasureFeatures] | None = None, post: List[PostMeasurement] | Dict[str, PostMeasurement] | None = None, benchmark: bool = False, verbose: bool = False, name: str | None = None, desc: str | None = None, reset: bool = False)
This class represents a processing and measurement interface for Image operations and feature extraction. It initializes operational and measurement queues based on the provided dictionaries.
- Parameters:
ops (List[ImageOperation | ImagePipeline] | Dict[str, ImageOperation | ImagePipeline] | None) – A list or dictionary of ImageOperation or ImagePipeline objects. If a list, class names are used as keys. If a dictionary, keys are operation names (strings) and values are ImageOperation or ImagePipeline objects responsible for performing specific Image processing tasks.
meas (List[MeasureFeatures] | Dict[str, MeasureFeatures] | None) – An optional dictionary where the keys are feature names (strings) and the values are FeatureExtractor objects responsible for extracting specific features.
benchmark (bool) – A flag indicating whether to track execution times for operations and measurements. Defaults to False.
verbose (bool) – A flag indicating whether to print progress information when benchmark mode is on. Defaults to False.
name (Optional[str]) – An optional string identifier for this pipeline. If not provided, a randomly generated UUID4 string will be assigned automatically.
desc (Optional[str]) – An optional description for this pipeline. If not provided, the class docstring will be used when accessing the desc property.
reset (bool) – Default reset behavior for the apply() method. When True, the image will be reset before applying operations. Can be overridden per-call in apply() and apply_and_measure(). Defaults to False.
post (List[PostMeasurement] | Dict[str, PostMeasurement] | None)
- __str__() str
Return a JSON-formatted string representation of the pipeline.
The generated JSON string provides a structured representation of the object’s current state, including its operations and measurements. This output can be used for logging, debugging, or to recreate the object’s configuration in another context.
- Returns:
A JSON-formatted string that encodes the object’s current configuration in a human-readable manner. This includes the phenotypic version, pipeline name, description, and the lists of operations and measurements.
- Return type:
- apply(image: Image, inplace: bool = False, reset: bool | None = None) GridImage | Image
The class provides an interface to process and apply a series of operations on an Image. The operations are maintained in a queue and executed sequentially when applied to the given Image.
- Parameters:
image (Image) – The arr Image to be processed. The type Image refers to an instance of the Image object to which transformations are applied.
inplace (bool, optional) – A flag indicating whether to apply the transformations directly on the provided Image (True) or create a copy of the Image before performing transformations (False). Defaults to False.
reset (bool, optional) – Whether to reset the image before applying the pipeline. If None (default), uses the pipeline’s reset setting from __init__. If explicitly set to True or False, overrides the pipeline setting.
- Return type:
Union[GridImage, Image]
- apply_and_measure(image: Image, inplace: bool = False, reset: bool | None = None, include_metadata: bool = True) pd.DataFrame
Applies processing to the given image and measures the results.
This function first applies a processing method to the supplied image, adjusting it based on the given parameters. After processing, the resulting image is measured, and a DataFrame containing the measurement data is returned.
- Parameters:
image (Image) – The image to process and measure.
inplace (bool) – Whether to modify the original image directly or work on a copy. Default is False.
reset (bool, optional) – Whether to reset any previous processing on the image before applying the current method. If None (default), uses the pipeline’s reset setting. If explicitly set, overrides the pipeline setting.
include_metadata (bool) – Whether to include metadata in the measurement results. Default is True.
- Returns:
A DataFrame containing measurement data for the processed image.
- Return type:
pd.DataFrame
- apply_napari(image: Image, inplace: bool = False, reset: bool | None = None, viewer: napari.Viewer | None = None) NapariPipelineResult
Apply the pipeline and progressively add layers to a napari viewer.
Creates (or reuses) a napari viewer and adds the original image layers as a baseline, then adds the modified layer after each operation completes. Layer names follow the pattern
{step:02d}_{OperationName}_{accessor}.- Parameters:
image (Image) – The input image to process.
inplace (bool) – If
Truethe image is modified in place; otherwise a copy is made first. Defaults toFalse.reset (bool | None) – Whether to reset the image before applying operations.
None(default) uses the pipeline-level setting.viewer (napari.Viewer | None) – An existing napari viewer to add layers to. If
None(default), a new viewer is created.
- Returns:
Named tuple with the final image and the napari viewer reference.
- Return type:
NapariPipelineResult
- Raises:
ImportError – If napari is not installed.
- apply_with_intermediates(image: Image, inplace: bool = False, reset: bool | None = None, output_dir: str | Path | None = None) IntermediateResult
Apply the pipeline and capture a snapshot of the image after each operation.
Behaves identically to
apply()(respecting inplace, reset, benchmark timing, and verbose/tqdm progress) but additionally records the image state after every operation completes.- Parameters:
image (Image) – The input image to process.
inplace (bool) – If
Truethe image is modified in place; otherwise a copy is made first. Defaults toFalse.reset (Optional[bool]) – Whether to reset the image before applying operations.
None(default) uses the pipeline-level setting.output_dir (Optional[Union[str, Path]]) – Optional directory path. When provided, each intermediate image is persisted to an HDF5 file inside this directory (created automatically) and the corresponding dict value is set to
Noneto conserve memory. WhenNone, intermediates are kept in memory asImagecopies.
- Returns:
A named tuple containing the final image and a dictionary mapping operation names to intermediate snapshots (or
Nonewhen output_dir is used).- Return type:
IntermediateResult
- benchmark_results() pandas.DataFrame
Return execution times and memory usage for operations and measurements.
This method should be called after applying the pipeline on an image to get the execution times and memory consumption of the different processes.
When an operation is itself an
ImagePipelineCore(nested pipeline), its sub-operations are expanded as indented sub-rows beneath the parent entry with names like"ParentOp > ChildOp".- Returns:
- A DataFrame with columns
Process Type, Process Name,Execution Time (s),Memory Delta (MB), andRSS After (MB).
- A DataFrame with columns
- Return type:
pd.DataFrame
- classmethod from_json(json_data: str | Path | dict, benchmark: bool = False, verbose: bool = False) PrefabPipeline[source]
Deserialize a PrefabPipeline from JSON.
PrefabPipeline subclasses build their ops/meas inside
__init__, so the basefrom_json(which passesops=directly) would conflict. This override deserializes viaImagePipelineand re-tags the instance as the correct PrefabPipeline subclass.- Parameters:
- Returns:
A PrefabPipeline (or subclass) instance with the loaded configuration.
- Return type:
- get_meas() Dict[str, MeasureFeatures]
Get a copy of the measurements dictionary.
Returns a shallow copy to prevent accidental mutation of internal state.
- Returns:
- Dictionary mapping measurement names to
MeasureFeatures instances.
- Return type:
Dict[str, MeasureFeatures]
- get_ops() Dict[str, ImageOperation]
Get a copy of the operations dictionary.
Returns a shallow copy to prevent accidental mutation of internal state.
- Returns:
- Dictionary mapping operation names to
ImageOperation instances.
- Return type:
Dict[str, ImageOperation]
- measure(image: Image, include_metadata=True) pd.DataFrame
Measures properties of a given image and optionally includes metadata. The method performs measurements using a set of predefined measurement operations. If benchmarking is enabled, the execution time of each measurement is recorded. When verbose mode is active, detailed logging of the measurement process is displayed. A progress bar is used to track progress if the tqdm library is available.
- Parameters:
image (Image) – The image object for which measurements are performed. It must support the info method and optionally a grid or objects attribute.
include_metadata (bool, optional) – Indicates whether metadata should be included in the measurements. Defaults to True.
- Returns:
- A DataFrame containing the results of all performed measurements combined
on the same index.
- Return type:
pd.DataFrame
- Raises:
Exception – An exception is raised if a measurement operation fails while being applied to the image.
- set_meas(measurements: List[MeasureFeatures] | Dict[str, MeasureFeatures])
Sets the measurements to be used for further computation. The input can be either a list of MeasureFeatures objects or a dictionary with string keys and MeasureFeatures objects as values.
The method processes the given input to construct a dictionary mapping measurement names to MeasureFeatures instances. If a list is passed, unique class names of the MeasureFeatures instances in the list are used as keys.
- Parameters:
measurements (List[MeasureFeatures] | Dict[str, MeasureFeatures]) – A collection of measurement features either as a list of MeasureFeatures objects, where class names are used as keys for dictionary creation, or as a dictionary where keys are predefined strings and values are MeasureFeatures objects.
- Raises:
TypeError – If the measurements argument is neither a list nor a dictionary.
- set_ops(ops: List[ImageOperation | ImagePipeline] | Dict[str, ImageOperation | ImagePipeline])
Sets the operations to be performed. The operations can be passed as either a list of ImageOperation or ImagePipeline instances or a dictionary mapping operation names to ImageOperation or ImagePipeline instances. This method ensures that each operation in the list has a unique name. Raises a TypeError if the input is neither a list nor a dictionary.
- Parameters:
ops (List[ImageOperation | ImagePipeline] | Dict[str, ImageOperation | ImagePipeline]) – A list of ImageOperation or ImagePipeline objects, or a dictionary where keys are operation names and values are ImageOperation or ImagePipeline objects.
- Raises:
TypeError – If the input is not a list or a dictionary.
- set_post(post: List[PostMeasurement] | Dict[str, PostMeasurement])
Set the post-measurement transforms.
- Parameters:
post (List[PostMeasurement] | Dict[str, PostMeasurement]) – A list or dictionary of PostMeasurement objects. If a list, class names are used as keys.
- Raises:
TypeError – If post is neither a list nor a dictionary.
- to_json(filepath: str | Path | None = None) str
Serialize the pipeline configuration to JSON format.
This method captures the pipeline’s operations and measurements. It excludes internal state (attributes starting with ‘_’) and pandas DataFrames to keep the serialization clean and focused on reproducible configuration.
- Parameters:
filepath (str | Path | None) – Optional path to save the JSON. If None, returns JSON string. Can be a string or Path object.
- Returns:
JSON string representation of the pipeline configuration.
- Return type:
Example
Serialize a pipeline to JSON format:
>>> from phenotypic import ImagePipeline >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.measure import MeasureShape >>> pipe = ImagePipeline(pipe_cfgs=[OtsuDetector()], meas=[MeasureShape()]) >>> json_str = pipe.to_json() >>> pipe.to_json('my_pipeline.json') # Save to file
- 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.
- property desc: str
Get pipeline description. Returns class docstring if no description set.
- class phenotypic.abc_.ThresholdDetector(*args, **kwargs)[source]
Bases:
ObjectDetector,ABCMarker 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:
Read detection matrix:
enh = image.detect_mat[:](preprocessed for contrast and noise suppression).Compute threshold: Use chosen strategy (Otsu, Li, Yen, etc.) to find optimal threshold value from histogram.
Create binary mask:
mask = enh > thresholdormask = enh >= threshold(test both if edge pixels ambiguous).Post-process (optional): Remove small noise, clear borders, morphological cleanup to improve mask quality.
Label connected components: Use
scipy.ndimage.label()to assign unique integer IDs to each colony (objmap).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=Trueto 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=Trueparameter orclear_border()in ObjectRefiner to remove edge-touching objects.Uneven illumination (vignetting, shadows): Apply enhancement (CLAHE, 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:
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:
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:
Inherit from ThresholdDetector (which provides ObjectDetector’s interface).
Implement
_operate(image: Image) -> Imageas an instance method.Within
_operate():Read
image.detect_mat[:](and optionallyimage.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.
Add to
phenotypic.detect.__init__.pyexports 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__}")
- __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.
- apply(image, inplace=False)
Detect colonies using sinusoidal cross-correlation grid estimation.
This method performs the core detection workflow: 1. Extract grid dimensions (if GridImage) 2. Threshold the detection matrix with adaptive kernel sizing 3. Remove noise if requested 4. Label connected components 5. Determine or estimate grid edges (via sinusoidal cross-correlation) 6. Assign dominant colonies to grid cells 7. Create final object map
- Parameters:
image – Image object to process. Can be a regular Image or GridImage.
- Returns:
The processed image with updated objmask and objmap.
- Return type:
Image
- 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.
- phenotypic.abc_.register_detection_mode(cls: type[DetectionMode]) type[DetectionMode][source]
Class decorator that instantiates cls and registers it by name.
- Raises:
ValueError – If a mode with the same name is already registered.
- Parameters:
cls (type[DetectionMode])
- Return type: