from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, Any, Literal, Tuple
from pydantic import ValidationInfo, field_validator
if TYPE_CHECKING:
from phenotypic._core._image import Image
import numpy as np
from scipy.sparse import csr_matrix
from phenotypic.abc_ import ImageCorrector
from phenotypic.sdk_.typing_ import TuneSpec
#: The eleven fill strategies accepted by ``np.pad``. Declared as a
#: ``Literal`` so the pre-migration ``__prescreen_params`` mode-membership
#: check becomes a type-level constraint that also enumerates the allowed
#: values in ``model_json_schema()``.
PadMode = Literal[
"constant",
"edge",
"reflect",
"symmetric",
"wrap",
"linear_ramp",
"maximum",
"mean",
"median",
"minimum",
"empty",
]
[docs]
class ImagePadder(ImageCorrector):
"""Extend image dimensions by adding pixels to any combination of edges.
Pads 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
regardless of ``mode`` to preserve integer label integrity. When applied
to a GridImage, grid structure is preserved and well positions are
recalculated automatically on the extended dimensions.
For usage context, see :doc:`/how_to/notebooks/crop_and_pad`.
Best For:
- Adding safety margins before rotation so corner colonies are not
clipped by the fill boundary during rotation.
- Standardising image dimensions across a batch when plates were
positioned inconsistently in the scanner.
- Creating border space when colonies grow near plate edges,
improving subsequent grid detection accuracy.
- Pre-processing before convolution-based enhancers that produce
boundary artefacts on tight image borders.
Consider Also:
- :class:`ImageCropper` when the goal is to reduce image dimensions
rather than extend them.
- :class:`GridAligner` for correcting plate rotation, which is
typically paired with padding to protect corner colonies.
Args:
left: Pixels to add on the left edge. ``None`` applies no padding.
Size to the artifact being protected (e.g. a margin wider than the
largest expected rotation shift). Default: ``None``.
right: Pixels to add on the right edge. ``None`` applies no padding.
Default: ``None``.
top: Pixels to add on the top edge. ``None`` applies no padding.
Default: ``None``.
bottom: Pixels to add on the bottom edge. ``None`` applies no padding.
Default: ``None``.
mode: Fill strategy passed to ``np.pad``. Accepted values:
``'constant'``, ``'edge'``, ``'reflect'``, ``'symmetric'``,
``'wrap'``, ``'linear_ramp'``, ``'maximum'``, ``'mean'``,
``'median'``, ``'minimum'``, ``'empty'``. ``'edge'`` replicates
the nearest border pixel (safest for colony analysis); ``'reflect'``
reduces convolution boundary artefacts; ``'constant'`` fills with a
uniform value. Default: ``'constant'``.
constant_value: Fill value used when ``mode='constant'``. Use ``0``
for black borders or ``255`` for white borders matching bright-agar
backgrounds. Default: ``0``.
Returns:
Image: Input image with all components padded by the specified
amounts. GridImage grid positions are recalculated on the extended
dimensions.
Raises:
ValueError: If any padding value is negative.
ValueError: If ``mode`` is not a recognised ``np.pad`` mode.
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.
"""
# Per-edge pixel pad offsets — image-specific geometry, not a quality knob.
left: Annotated[int | None, TuneSpec(tunable=False)] = None
right: Annotated[int | None, TuneSpec(tunable=False)] = None
top: Annotated[int | None, TuneSpec(tunable=False)] = None
bottom: Annotated[int | None, TuneSpec(tunable=False)] = None
mode: PadMode = "constant"
constant_value: int | float = 0
@field_validator("left", "right", "top", "bottom")
@classmethod
def _reject_negative_margin(
cls, value: int | None, info: ValidationInfo
) -> int | None:
"""Reject a negative padding margin, preserving the legacy message.
Reproduces the pre-migration ``__prescreen_params`` guard: ``None``
is accepted (no padding on that edge), any negative value raises
``"<edge> cannot be negative"``.
"""
if value is not None and value < 0:
raise ValueError(f"{info.field_name} cannot be negative")
return value
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