Source code for phenotypic.correction._image_cropper

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