Source code for robotblockset.cameras.image_transform
"""Composable transforms for image arrays and pixel coordinates.
The transforms support two-dimensional grayscale images and channel-last color
images. Each transform can be applied both to image data and to corresponding
``(x, y)`` coordinates.
"""
from abc import ABC
from typing import Sequence, Tuple, TypeAlias, Union
import cv2
import numpy as np
from robotblockset.rbs_typing import NumpyFloatImageType, NumpyIntImageType, OpenCVIntImageType
ImageArrayType: TypeAlias = Union[OpenCVIntImageType, NumpyFloatImageType, NumpyIntImageType]
"""Grayscale ``(H, W)`` or channel-last color ``(H, W, C)`` image array."""
# Retained for compatibility with code importing the former public alias.
HWCImageType: TypeAlias = ImageArrayType
"""Compatibility alias for :data:`ImageArrayType`."""
ImageShapeType: TypeAlias = Union[Tuple[int, int], Tuple[int, int, int]]
ImageCoordinateType: TypeAlias = Union[int, float]
ImagePointType: TypeAlias = Tuple[ImageCoordinateType, ImageCoordinateType]
[docs]
class ImageTransform(ABC):
"""Base interface for related image and point-coordinate transforms."""
[docs]
def __init__(self, input_shape: ImageShapeType) -> None:
"""Initialize a transform for images with a known input shape.
Parameters
----------
input_shape : ImageShapeType
Grayscale ``(height, width)`` or color
``(height, width, channels)`` input shape.
"""
self._input_shape = input_shape
@property
def _input_h(self) -> int:
"""Input image height in pixels."""
return self._input_shape[0]
@property
def _input_w(self) -> int:
"""Input image width in pixels."""
return self._input_shape[1]
@property
def shape(self) -> ImageShapeType:
"""Return the shape of the transformed image.
Returns
-------
ImageShapeType
Grayscale or channel-last output shape.
Raises
------
NotImplementedError
If a subclass does not implement the property.
"""
raise NotImplementedError
[docs]
def transform_image(self, image: ImageArrayType) -> ImageArrayType:
"""Apply the transform to an image array.
Parameters
----------
image : ImageArrayType
Source image. Implementations return a transformed image without
intentionally modifying this array.
Returns
-------
ImageArrayType
Transformed image array.
Raises
------
NotImplementedError
If a subclass does not implement the method.
"""
raise NotImplementedError
[docs]
def transform_point(self, point: ImagePointType) -> ImagePointType:
"""Map a source-image point into transformed-image coordinates.
Parameters
----------
point : ImagePointType
Source ``(x, y)`` coordinate.
Returns
-------
ImagePointType
Coordinate in the transformed image.
Raises
------
NotImplementedError
If a subclass does not implement the method.
"""
raise NotImplementedError
[docs]
def reverse_transform_point(self, point: ImagePointType) -> ImagePointType:
"""Map a transformed-image point back to source-image coordinates.
Parameters
----------
point : ImagePointType
Transformed ``(x, y)`` coordinate.
Returns
-------
ImagePointType
Coordinate in the source image.
Raises
------
NotImplementedError
If a subclass does not implement the method.
"""
raise NotImplementedError
def __call__(self, image: ImageArrayType) -> ImageArrayType:
"""Apply :meth:`transform_image` to ``image``."""
return self.transform_image(image)
[docs]
class ComposedTransform(ImageTransform):
"""Apply a non-empty sequence of image transforms in order."""
[docs]
def __init__(self, transforms: Sequence[ImageTransform]) -> None:
"""Create a transform composition.
Parameters
----------
transforms : Sequence[ImageTransform]
Ordered, non-empty sequence of transforms.
Raises
------
ValueError
If ``transforms`` is empty.
"""
if len(transforms) == 0:
raise ValueError("transforms must be a non-empty sequence.")
super().__init__(transforms[0]._input_shape)
self.transforms = transforms
@property
def shape(self) -> ImageShapeType:
"""Return the output shape of the final transform."""
return self.transforms[-1].shape
[docs]
def transform_image(self, image: ImageArrayType) -> ImageArrayType:
"""Apply every transform to an image in sequence."""
for transform in self.transforms:
image = transform.transform_image(image)
return image
[docs]
def transform_point(self, point: ImagePointType) -> ImagePointType:
"""Map a point through every transform in sequence."""
for transform in self.transforms:
point = transform.transform_point(point)
print(point)
return point
[docs]
def reverse_transform_point(self, point: ImagePointType) -> ImagePointType:
"""Map a point backward through the transforms in reverse order."""
for transform in reversed(self.transforms):
point = transform.reverse_transform_point(point)
return point
[docs]
def crop(image: ImageArrayType, x: int, y: int, w: int, h: int) -> ImageArrayType:
"""Return a copy of a rectangular image region.
Parameters
----------
image : ImageArrayType
Grayscale or channel-last source image.
x : int
X-coordinate of the top-left crop corner.
y : int
Y-coordinate of the top-left crop corner.
w : int
Crop width in pixels.
h : int
Crop height in pixels.
Returns
-------
ImageArrayType
Copy of the selected region. Coordinates outside the image follow NumPy
slicing semantics.
"""
# The first array index is y because it selects rows, while the second is x.
if len(image.shape) == 2:
return image[y : y + h, x : x + w].copy()
return image[y : y + h, x : x + w, :].copy()
[docs]
class Crop(ImageTransform):
"""Crop an image and translate corresponding point coordinates."""
[docs]
def __init__(self, input_shape: ImageShapeType, x: int, y: int, w: int, h: int) -> None:
"""Create a rectangular crop transform.
Parameters
----------
input_shape : ImageShapeType
Shape of the source image.
x : int
X-coordinate of the crop's top-left corner.
y : int
Y-coordinate of the crop's top-left corner.
w : int
Crop width in pixels.
h : int
Crop height in pixels.
"""
super().__init__(input_shape)
self.x = x
self.y = y
self.w = w
self.h = h
@property
def shape(self) -> ImageShapeType:
"""Return the declared crop shape."""
if len(self._input_shape) == 2:
return self.h, self.w
c = self._input_shape[2]
return self.h, self.w, c
[docs]
def transform_image(self, image: ImageArrayType) -> ImageArrayType:
"""Return a copy of the cropped image region."""
return crop(image, self.x, self.y, self.w, self.h)
[docs]
def transform_point(self, point: ImagePointType) -> ImagePointType:
"""Translate a source point into crop-local coordinates.
Raises
------
ValueError
If the source point lies outside the crop rectangle.
"""
x, y = point
if not (x >= self.x and x < self.x + self.w):
raise ValueError(f"x-coordinate {x} is outside of the crop range [{self.x}, {self.x + self.w})")
if not (y >= self.y and y < self.y + self.h):
raise ValueError(f"y-coordinate {y} is outside of the crop range [{self.y}, {self.y + self.h})")
return x - self.x, y - self.y
[docs]
def reverse_transform_point(self, point: ImagePointType) -> ImagePointType:
"""Translate a crop-local point into source-image coordinates.
Raises
------
ValueError
If the point lies outside the crop dimensions.
"""
x, y = point
if not (x >= 0 and x < self.w):
raise ValueError(f"x-coordinate {x} is outside of the crop range [0, {self.w})")
if not (y >= 0 and y < self.h):
raise ValueError(f"y-coordinate {y} is outside of the crop range [0, {self.h})")
return x + self.x, y + self.y
[docs]
class Resize(ImageTransform):
"""Resize an image and scale corresponding point coordinates."""
[docs]
def __init__(self, input_shape: ImageShapeType, h: int, w: int, round_transformed_points: bool = True) -> None:
"""Create an image resize transform.
Transforming points to or from a resized image can produce fractional
coordinates. By default these coordinates are rounded to the nearest
integer. Set ``round_transformed_points`` to ``False`` to preserve the
floating-point result.
Parameters
----------
input_shape : ImageShapeType
Shape of the source image.
h : int
Output height in pixels.
w : int
Output width in pixels.
round_transformed_points : bool, optional
Whether point transformations return rounded coordinates. The
default is ``True``.
"""
super().__init__(input_shape)
self.h = h
self.w = w
self.round_transformed_points = round_transformed_points
@property
def shape(self) -> ImageShapeType:
"""Return the resized image shape."""
if len(self._input_shape) == 2:
return self.h, self.w
c = self._input_shape[2]
return self.h, self.w, c
[docs]
def transform_image(self, image: ImageArrayType) -> ImageArrayType:
"""Resize an image using OpenCV's default interpolation."""
return cv2.resize(image, (self.w, self.h))
[docs]
def transform_point(self, point: ImagePointType) -> ImagePointType:
"""Scale a source point into resized-image coordinates.
Raises
------
ValueError
If the point lies outside the source image dimensions.
"""
x, y = point
if not (x >= 0 and x < self._input_w):
raise ValueError(f"x-coordinate {x} is outside of the input image range [0, {self._input_w})")
if not (y >= 0 and y < self._input_h):
raise ValueError(f"y-coordinate {y} is outside of the input image range [0, {self._input_h})")
w_scale = self.w / self._input_w
h_scale = self.h / self._input_h
x_float = w_scale * x
y_float = h_scale * y
if self.round_transformed_points:
return round(x_float), round(y_float)
return x_float, y_float
[docs]
def reverse_transform_point(self, point: ImagePointType) -> ImagePointType:
"""Scale a resized-image point back into source coordinates.
Raises
------
ValueError
If the point lies outside the resized image dimensions.
"""
x, y = point
if not (x >= 0 and x < self.w):
raise ValueError(f"x-coordinate {x} is outside of the resized image range [0, {self.w})")
if not (y >= 0 and y < self.h):
raise ValueError(f"y-coordinate {y} is outside of the resized image range [0, {self.h})")
w_scale_inverse = self._input_w / self.w
h_scale_inverse = self._input_h / self.h
x_float = w_scale_inverse * x
y_float = h_scale_inverse * y
if self.round_transformed_points:
return round(x_float), round(y_float)
return x_float, y_float
[docs]
class Rotate90(ImageTransform):
"""Rotate images and corresponding points by multiples of 90 degrees."""
[docs]
def __init__(
self,
input_shape: ImageShapeType,
num_rotations: int = 1,
) -> None:
"""Create a counter-clockwise right-angle rotation transform.
Parameters
----------
input_shape : ImageShapeType
Shape of the source image.
num_rotations : int, optional
Number of counter-clockwise 90-degree rotations. Values are reduced
modulo four. The default is ``1``.
Raises
------
TypeError
If ``num_rotations`` is not an integer.
"""
super().__init__(input_shape)
if not isinstance(num_rotations, int):
raise TypeError("num_rotations must be an int")
self._num_rotations = num_rotations % 4
@property
def shape(self) -> ImageShapeType:
"""Return the rotated image shape."""
if self._num_rotations % 2 == 0:
h, w = self._input_shape[:2]
else:
w, h = self._input_shape[:2]
if len(self._input_shape) == 2:
return h, w
c = self._input_shape[2]
return h, w, c
[docs]
def transform_image(self, image: ImageArrayType) -> ImageArrayType:
"""Return a copied array rotated counter-clockwise by 90-degree steps."""
# The copy here ensure the result is not a view into the original image.
return np.rot90(image, self._num_rotations).copy()
[docs]
def transform_point(self, point: ImagePointType) -> ImagePointType:
"""Rotate a source point into output-image coordinates.
Raises
------
ValueError
If the point lies outside the source image dimensions.
"""
x, y = point
if not (x >= 0 and x < self._input_w):
raise ValueError(f"x-coordinate {x} is outside of the input image range [0, {self._input_w})")
if not (y >= 0 and y < self._input_h):
raise ValueError(f"y-coordinate {y} is outside of the input image range [0, {self._input_h})")
if self._num_rotations == 1:
return y, self._input_w - x - 1
elif self._num_rotations == 2:
return self._input_w - x - 1, self._input_h - y - 1
elif self._num_rotations == 3:
return self._input_h - y - 1, x
return x, y
[docs]
def reverse_transform_point(self, point: ImagePointType) -> ImagePointType:
"""Rotate an output-image point back into source coordinates."""
x, y = point
if self._num_rotations == 1:
return self._input_w - y - 1, x
elif self._num_rotations == 2:
return self._input_w - x - 1, self._input_h - y - 1
elif self._num_rotations == 3:
return y, self._input_h - x - 1
return x, y