Source code for phenotypic.correction._image_padder

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Tuple

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import numpy as np
from scipy.sparse import csr_matrix
from phenotypic.abc_ import ImageCorrector


[docs] class ImagePadder(ImageCorrector): """Extend image dimensions by adding pixels to any combination of edges. Pad the image on the left, right, top, and/or bottom using a configurable fill mode. All image components (RGB, gray, detect_mat, objmap) are padded in sync; the object map is always zero-padded to preserve label integrity. When applied to a GridImage, grid structure is preserved and positions are recalculated automatically. For usage context, see :doc:`/how_to/notebooks/crop_and_pad`. Args: left: Pixels to add on the left edge. ``None`` means no padding. Typical range: 50--200. Default: ``None``. right: Pixels to add on the right edge. ``None`` means no padding. Typical range: 50--200. Default: ``None``. top: Pixels to add on the top edge. ``None`` means no padding. Typical range: 50--200. Default: ``None``. bottom: Pixels to add on the bottom edge. ``None`` means no padding. Typical range: 50--200. Default: ``None``. mode: Fill strategy passed to ``np.pad``. Accepted values: ``'constant'``, ``'reflect'``, ``'edge'``, ``'symmetric'``, ``'wrap'``, ``'linear_ramp'``, ``'maximum'``, ``'mean'``, ``'median'``, ``'minimum'``, ``'empty'``. ``'edge'`` is safest for colony analysis; ``'reflect'`` reduces convolution boundary artifacts. Default: ``'constant'``. constant_value: Fill value when ``mode='constant'``. ``0`` for black borders, ``255`` for white. Default: ``0``. Returns: Image: Input image with all components padded by the specified amounts. GridImage grid positions are recalculated. Raises: ValueError: If any padding value is negative. ValueError: If ``mode`` is not a valid ``np.pad`` mode. Best For: - Adding safety margins before rotation so corner colonies are not clipped. - Standardizing image dimensions across a batch for pipelines that require uniform size. - Creating border space when colonies grow near plate edges, improving grid detection accuracy. Consider Also: - :class:`ImageCropper` when the image needs to be reduced rather than extended. - :class:`GridAligner` for correcting plate rotation after padding. See Also: :doc:`/how_to/notebooks/crop_and_pad` for a visual walkthrough of padding and cropping plate images. :doc:`/how_to/notebooks/correct_grid_rotation` for combining padding with rotation correction. """
[docs] def __init__(self, left: int | None = None, right: int | None = None, top: int | None = None, bottom: int | None = None, mode: str = "constant", constant_value: int | float = 0 ): """Initialize an ImagePadder with pixel margins to add on each edge. Creates a padder that adds the specified number of pixels to each edge of the image. All margin parameters are optional and default to None (no padding from that edge). Args: left (int | None, optional): Pixels to add on left edge. Must be non-negative. If None, no left padding (equivalent to 0). Defaults to None. right (int | None, optional): Pixels to add on right edge. Must be non-negative. If None, no right padding (equivalent to 0). Defaults to None. top (int | None, optional): Pixels to add on top edge. Must be non-negative. If None, no top padding (equivalent to 0). Defaults to None. bottom (int | None, optional): Pixels to add on bottom edge. Must be non-negative. If None, no bottom padding (equivalent to 0). Defaults to None. mode (str, optional): Padding mode for np.pad. Options include 'constant' (uniform value), 'reflect' (mirror at boundary), 'edge' (replicate edge pixels), 'symmetric' (symmetric reflection), 'wrap' (periodic), and others. Defaults to 'constant'. constant_value (int | float, optional): Value for constant mode padding. Only used when mode='constant'. Typical values: 0 for black (default), 255 for white. Defaults to 0. Raises: ValueError: If any padding parameter is negative. All padding margins must be non-negative integers (or None). ValueError: If mode is not a valid np.pad mode. Examples: Create a padder for symmetric margins: >>> from phenotypic.correction import ImagePadder >>> # Add 50 pixels to all four edges >>> padder = ImagePadder(left=50, right=50, top=50, bottom=50) Create a padder for asymmetric margins: >>> from phenotypic.correction import ImagePadder >>> # Add padding on top and right, keep left and bottom minimal >>> padder = ImagePadder(top=100, right=75, left=0, bottom=0) Create a padder with reflection to avoid artifacts: >>> from phenotypic.correction import ImagePadder >>> padder = ImagePadder( ... left=80, right=80, top=80, bottom=80, ... mode='reflect' ... ) >>> # Reflection preserves edge patterns, good for convolutions """ self.left = left self.right = right self.top = top self.bottom = bottom self.mode = mode self.constant_value = constant_value self.__prescreen_params()
def __prescreen_params(self): """Validate padding parameters before use. Raises: ValueError: If any padding parameter is negative. ValueError: If mode is not a valid np.pad mode. """ if (self.left is not None) and (self.left < 0): raise ValueError("left cannot be negative") if (self.right is not None) and (self.right < 0): raise ValueError("right cannot be negative") if (self.top is not None) and (self.top < 0): raise ValueError("top cannot be negative") if (self.bottom is not None) and (self.bottom < 0): raise ValueError("bottom cannot be negative") valid_modes = [ 'constant', 'edge', 'reflect', 'symmetric', 'wrap', 'linear_ramp', 'maximum', 'mean', 'median', 'minimum', 'empty' ] if self.mode not in valid_modes: raise ValueError( f"mode must be one of {valid_modes}, got '{self.mode}'" ) def _get_pad_width_2d(self) -> Tuple[Tuple[int, int], Tuple[int, int]]: """Calculate pad_width tuple for 2D arrays (gray, detect_mat, objmap). Converts None → 0 for each parameter and returns the pad_width format expected by np.pad for 2D arrays. Returns: Tuple of ((top, bottom), (left, right)) for np.pad. Examples: >>> padder = ImagePadder(left=10, right=20, top=30, bottom=40) >>> padder._get_pad_width_2d() ((30, 40), (10, 20)) """ top = 0 if self.top is None else self.top bottom = 0 if self.bottom is None else self.bottom left = 0 if self.left is None else self.left right = 0 if self.right is None else self.right return ((top, bottom), (left, right)) def _get_pad_width_3d(self) -> Tuple[Tuple[int, int], Tuple[int, int], Tuple[int, int]]: """Calculate pad_width tuple for 3D arrays (RGB). Returns pad_width for RGB arrays where spatial dimensions (height, width) are padded but channel dimension is NOT padded. This preserves the number of channels. Returns: Tuple of ((top, bottom), (left, right), (0, 0)) for np.pad. The third dimension (channels) has (0, 0) padding. Examples: >>> padder = ImagePadder(left=10, right=20, top=30, bottom=40) >>> padder._get_pad_width_3d() ((30, 40), (10, 20), (0, 0)) """ top = 0 if self.top is None else self.top bottom = 0 if self.bottom is None else self.bottom left = 0 if self.left is None else self.left right = 0 if self.right is None else self.right return ((top, bottom), (left, right), (0, 0)) def _operate(self, image: Image) -> Image: """Pad the image by adding pixels to edges specified in __init__. Pads all image components (rgb, gray, detect_mat, objmask, objmap) together, maintaining synchronization. Object map is ALWAYS padded with constant mode and value 0 to preserve integer label integrity, regardless of user mode. For RGB arrays, padding is applied ONLY to spatial dimensions (height, width), not to the channel dimension. This preserves the number of channels. Args: image (Image): The image to pad. The image is modified with padding applied. Returns: Image: A new Image instance (or GridImage if input was GridImage) with all components padded by the specified amounts. Original image is unchanged unless inplace=True is used with apply(). Raises: ValueError: If mode is not supported by np.pad. Examples: Basic padding of a loaded image: >>> from phenotypic import Image >>> from phenotypic.correction import ImagePadder >>> image = Image.imread('plate.jpg') # doctest: +SKIP >>> padder = ImagePadder(left=50, right=50, top=50, bottom=50) >>> # Returns new padded Image; original is unchanged >>> padded = padder.apply(image) # doctest: +SKIP >>> print(f"Original shape: {image.shape}") # doctest: +SKIP >>> print(f"Padded shape: {padded.shape}") # doctest: +SKIP Padding a GridImage preserves grid settings: >>> from phenotypic import GridImage >>> from phenotypic.correction import ImagePadder >>> # Load plate image >>> grid_img = GridImage('plate.tiff', nrows=8, ncols=12) # doctest: +SKIP >>> # Add safety margin >>> padder = ImagePadder(left=50, right=50, top=50, bottom=50) >>> padded = padder.apply(grid_img) # doctest: +SKIP >>> # GridImage type and settings preserved >>> assert isinstance(padded, GridImage) # doctest: +SKIP >>> assert padded.nrows == 8 # doctest: +SKIP >>> assert padded.ncols == 12 # doctest: +SKIP """ # Get padding widths pad_width_2d = self._get_pad_width_2d() pad_width_3d = self._get_pad_width_3d() # Prepare kwargs for np.pad pad_kwargs: dict[str, Any] = {} if self.mode == 'constant': pad_kwargs['constant_values'] = self.constant_value # Pad RGB if it exists (3D array, spatial dims only) if not image.rgb.isempty(): image._data.rgb = np.pad( # type: ignore image._data.rgb, pad_width=pad_width_3d, mode=self.mode, **pad_kwargs ) # Pad gray (2D array) image._data.gray = np.pad( # type: ignore image._data.gray, pad_width=pad_width_2d, mode=self.mode, **pad_kwargs ) # Pad detect_mat (2D array) image._data.detect_mat = np.pad( # type: ignore image._data.detect_mat, pad_width=pad_width_2d, mode=self.mode, **pad_kwargs ) # CRITICAL: Pad objmap with constant mode and value 0 ALWAYS # This preserves integer object labels regardless of user mode. # Must modify _data directly to handle shape change. padded_objmap = np.pad( image._data.sparse_object_map.toarray(), pad_width=pad_width_2d, mode='constant', constant_values=0 ) # Convert back to sparse and update image._data.sparse_object_map = csr_matrix(padded_objmap) # Handle GridImage type preservation from phenotypic import GridImage original_name = image.name if isinstance(image, GridImage): # Grid positions will be recalculated by grid_finder automatically padded = GridImage( arr=image, name=image.name, grid_finder=image.grid_finder, nrows=image.nrows, ncols=image.ncols, bit_depth=image.bit_depth, illuminant=image.illuminant, gamma=image.gamma, ) image = padded # Restore original name image.name = original_name return image