from __future__ import annotations
from typing import Annotated, Tuple, TYPE_CHECKING
from pydantic import ValidationInfo, field_validator
if TYPE_CHECKING:
from phenotypic._core._grid_image import GridImage
from phenotypic._core._image import Image
from phenotypic.abc_ import ImageCorrector
from phenotypic.sdk_.typing_ import TuneSpec
[docs]
class ImageCropper(ImageCorrector):
"""Remove pixels from image edges by specifying per-edge crop margins.
Crops all image components (rgb, gray, detect_mat, objmask, objmap)
simultaneously using the same margins, so every layer remains spatially
synchronised. When applied to a GridImage, the grid structure and finder
are preserved and well positions are recalculated for the reduced
dimensions.
For usage context, see :doc:`/how_to/notebooks/crop_and_pad`.
Best For:
- Removing scanner margins or plate borders that lie outside the
agar surface before detection.
- Eliminating edge artefacts such as bent agar, labelling tape, or
moisture condensation that would generate false detections.
- Standardising image dimensions across a batch of plates captured
with slightly different positioning.
- Isolating a sub-region of a large plate image for focused
analysis.
Consider Also:
- :class:`ImagePadder` for extending image dimensions rather than
reducing them.
- :class:`RemoveBorderObjects` when the goal is to exclude
edge-touching colonies from analysis without altering image
dimensions.
- :class:`GridAligner` when rotation correction is needed alongside
cropping.
Args:
left: Pixels to remove from the left edge. ``None`` applies no
cropping on this edge. Size to the off-agar margin you want to
discard. Default: ``None``.
right: Pixels to remove from the right edge. ``None`` applies no
cropping on this edge. Default: ``None``.
top: Pixels to remove from the top edge. ``None`` applies no
cropping on this edge. Default: ``None``.
bottom: Pixels to remove from the bottom edge. ``None`` applies no
cropping on this edge. Default: ``None``.
Returns:
Image: Input image with all components cropped to the specified
margins. GridImage grid positions are recalculated on the cropped
dimensions.
Raises:
ValueError: If any margin is negative.
ValueError: If opposite margins together exceed the image dimension
(e.g., ``top`` + ``bottom`` >= image height).
See Also:
:doc:`/how_to/notebooks/crop_and_pad` for a visual walkthrough of
cropping and padding operations on plate images.
:doc:`/how_to/notebooks/correct_grid_rotation` for combining
cropping with rotation correction.
"""
# Per-edge pixel crop 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
@field_validator("left", "right", "top", "bottom")
@classmethod
def _reject_negative_margin(
cls, value: int | None, info: ValidationInfo
) -> int | None:
"""Reject a negative crop margin, preserving the legacy message.
Reproduces the pre-migration ``__prescreen_idxes`` guard: ``None``
is accepted (no cropping 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 _operate(self, image: Image) -> Image:
"""Crop the image by removing pixels from edges specified in __init__.
Extracts the crop indices for each edge and returns a new Image with the cropped
region. The entire image structure (rgb, gray, detect_mat, objmask, objmap) is
cropped identically, ensuring all components remain synchronized.
For GridImage instances, preserves the grid structure (grid_finder, nrows, ncols)
while recalculating grid positions for the cropped region.
Args:
image (Image): The image to crop. The image is not modified; a new cropped
Image (or GridImage if input was GridImage) is returned.
Returns:
Image: A new Image instance (or GridImage if input was GridImage) containing
only the central cropped region, with all image components (rgb, gray,
detect_mat, objmask, objmap) reduced to the same rectangular region.
Raises:
ValueError: If the crop parameters would result in an invalid slice (e.g.,
top edge >= bottom edge or left edge >= right edge after cropping).
Examples:
Basic cropping of a loaded image:
>>> from phenotypic import Image
>>> from phenotypic.correction import ImageCropper
>>> image = Image.imread('plate.jpg') # doctest: +SKIP
>>> cropper = ImageCropper(top=50, bottom=50, left=40, right=40)
>>> # Returns new cropped Image; original is unchanged
>>> cropped = cropper.apply(image) # doctest: +SKIP
>>> print(f"Original shape: {image.shape}") # doctest: +SKIP
>>> print(f"Cropped shape: {cropped.shape}") # doctest: +SKIP
Cropping a GridImage preserves grid settings:
>>> from phenotypic import GridImage
>>> from phenotypic.correction import ImageCropper
>>> # Load plate image with scanner border
>>> grid_img = GridImage('plate_with_border.tiff', nrows=8, ncols=12) # doctest: +SKIP
>>> # Remove scanner border
>>> cropper = ImageCropper(left=50, right=50, top=50, bottom=50)
>>> cropped = cropper.apply(grid_img) # doctest: +SKIP
>>> # GridImage type and settings preserved
>>> assert isinstance(cropped, GridImage) # doctest: +SKIP
>>> assert cropped.nrows == 8 # doctest: +SKIP
>>> assert cropped.ncols == 12 # doctest: +SKIP
>>> # Grid positions are recalculated for cropped image
>>> cropped.show(overlay=True, show_grid=True) # doctest: +SKIP
"""
top, bottom_idx, left, right_idx = self._get_idxes(image)
# Use existing slicing (__getitem__) - returns Image for both Image and GridImage
cropped = image[top:bottom_idx, left:right_idx]
# Import GridImage locally to avoid potential circular imports
from phenotypic import GridImage
# Preserve original image's name before modifications
original_name = image.name
# If input was GridImage, upgrade the cropped Image back to GridImage
if isinstance(image, GridImage):
# Convert the cropped Image to GridImage with preserved settings
cropped = GridImage(
arr=cropped, # Pass the Image instance
name=image.name, # Preserve original image's name
grid_finder=image.grid_finder, # Preserve grid_finder instance
nrows=image.nrows,
ncols=image.ncols,
bit_depth=cropped.bit_depth,
illuminant=cropped.illuminant,
gamma=cropped.gamma,
)
result = self._cpy_new_arrs(image, cropped)
# Restore original name (may be overwritten by set_image).
# set_image() calls _set_from_class_instance() which deep copies all metadata
# from the source image, including the name. We preserve the original image's
# name to maintain its identity through the cropping operation.
result.name = original_name
return result
@staticmethod
def _cpy_new_arrs(img: Image | GridImage,
new_img: Image | GridImage) -> Image | GridImage:
"""Copies the array data over. This is needed due to needing to make changes to
the image in-place."""
img.set_image(new_img)
return img
def _get_idxes(self, image: Image) -> Tuple[int, int, int, int]:
"""Calculate array slice indices for the crop region based on image dimensions.
Given the image dimensions and the configured crop margins (left, right, top,
bottom), computes the array slice indices for the cropped region. Validates that
the resulting crop region is valid (i.e., top edge is above bottom edge and left
edge is left of right edge after cropping).
Args:
image (Image): The image to be cropped. The image's height and width are
used to determine the final slice indices.
Returns:
Tuple[int, int, int, int]: A tuple of (top_idx, bottom_idx, left_idx,
right_idx) representing the array slice bounds for the crop region:
- top_idx: The starting row index (pixels removed from top edge)
- bottom_idx: The ending row index (total height minus pixels removed
from bottom edge)
- left_idx: The starting column index (pixels removed from left edge)
- right_idx: The ending column index (total width minus pixels removed
from right edge)
These indices are intended to be used in NumPy slicing as
image_array[top_idx:bottom_idx, left_idx:right_idx].
Raises:
ValueError: If the crop parameters would result in invalid dimensions:
- If top_idx >= bottom_idx (no valid rows remain after cropping)
- If left_idx >= right_idx (no valid columns remain after cropping)
This can occur if the sum of crop margins from opposite edges exceeds
the image dimension (e.g., cropping 100 pixels top and 100 pixels bottom
from a 150-pixel tall image).
Examples:
Understanding slice indices from crop parameters:
>>> from phenotypic import Image
>>> from phenotypic.correction import ImageCropper
>>> # Example: 1000x1200 image, crop 50 from all edges
>>> image = Image.imread('large_plate.tiff') # doctest: +SKIP
>>> cropper = ImageCropper(top=50, bottom=50, left=50, right=50)
>>> # Internal calculation:
>>> # height=1000, width=1200
>>> # top_idx = 0 + 50 = 50
>>> # bottom_idx = 1000 - 50 - 1 = 949
>>> # left_idx = 0 + 50 = 50
>>> # right_idx = 1200 - 50 - 1 = 1149
>>> # Result: image[50:949, 50:1149] extracts central 899x1099 region
>>> cropped = cropper.apply(image) # doctest: +SKIP
>>> print(cropped.shape) # doctest: +SKIP
"""
height, width = image.shape[:2]
# Set dynamic defaults (None means 0 pixels cropped from that edge)
top = 0 if self.top is None else self.top
bottom = 0 if self.bottom is None else self.bottom
bottom_idx = height - bottom
if top > bottom_idx:
raise ValueError(
f"top index ({top}) cannot be > bottom index ({bottom_idx}).")
left = 0 if self.left is None else self.left
right = 0 if self.right is None else self.right
right_idx = width - right
if left > right_idx:
raise ValueError(
f"left index ({left}) cannot be > right index ({right_idx}).")
return top, bottom_idx, left, right_idx