Source code for robotblockset.cameras.image_converter
"""Validate and convert three-channel RGB and BGR image arrays.
The canonical representation is a channel-last RGB NumPy array with floating
values in the range ``[0.0, 1.0]``. Conversion helpers also support channel-last
integer RGB arrays, channel-last integer BGR arrays used by OpenCV, and
channel-first floating NumPy arrays used by tensor-oriented workflows.
Copyright (c) 2026 Jozef Stefan Institute
Authors: Leon Zlajpah.
"""
from __future__ import annotations
from typing import TypeGuard
import numpy as np
from robotblockset.rbs_typing import NumpyFloatImageType, NumpyIntImageType, OpenCVIntImageType, TorchFloatImageType
[docs]
def is_image_array(image: object) -> TypeGuard[np.ndarray]:
"""Return whether an object is a three-dimensional NumPy array.
This shape check does not validate channel placement, channel count, data
type, or value range.
Parameters
----------
image : object
Object to inspect.
Returns
-------
TypeGuard[numpy.ndarray]
``True`` when ``image`` is a NumPy array with three dimensions.
"""
return isinstance(image, np.ndarray) and image.ndim == 3
[docs]
def is_float_image_array(image: object) -> TypeGuard[NumpyFloatImageType]:
"""Return whether an object looks like a floating-point image array.
The object must be a non-empty, three-dimensional NumPy array with dtype
``float16``, ``float32``, or ``float64``. Every value must be finite and in
the expected ``[0.0, 1.0]`` range. Channel placement and channel count are
not checked here.
Parameters
----------
image : object
Object to inspect.
Returns
-------
TypeGuard[NumpyFloatImageType]
``True`` when the structural, dtype, finiteness, and range checks pass.
"""
if not is_image_array(image) or image.size == 0:
return False
if image.dtype not in (np.float16, np.float32, np.float64):
return False
return bool(np.all(np.isfinite(image)) and np.all((image >= 0.0) & (image <= 1.0)))
[docs]
def is_int_image_array(image: object) -> TypeGuard[NumpyIntImageType]:
"""Return whether an object looks like an unsigned-integer image array.
The object must be a non-empty, three-dimensional NumPy array with dtype
``uint8``, ``uint16``, or ``uint32``. Every value must be in the expected
``[0, 255]`` range. Channel placement and channel count are not checked here.
Parameters
----------
image : object
Object to inspect.
Returns
-------
TypeGuard[NumpyIntImageType]
``True`` when the structural, dtype, and range checks pass.
"""
if not is_image_array(image) or image.size == 0:
return False
if image.dtype not in (np.uint8, np.uint16, np.uint32):
return False
return bool(np.all(image <= 255))
[docs]
class ImageConverter:
"""Convert between supported three-channel NumPy image layouts.
The internal representation is channel-last RGB floating-point data in the
range ``[0.0, 1.0]``. Despite the ``torch`` method names, tensor-oriented
inputs and outputs are channel-first NumPy arrays; ``torch.Tensor`` objects
and CUDA-resident data are not supported.
Conversions use the internal floating-point representation as an
intermediate format, favoring a small implementation over the fastest
possible direct conversion between every pair of formats.
"""
[docs]
def __init__(self, image_in_numpy_float_format: NumpyFloatImageType) -> None:
"""Initialize the converter from channel-last floating RGB data.
Parameters
----------
image_in_numpy_float_format : NumpyFloatImageType
RGB image with shape ``(height, width, 3)`` and floating values in
the expected ``[0.0, 1.0]`` range.
Raises
------
TypeError
If the input is not recognized as a floating-point image array.
IndexError
If the last dimension does not contain exactly three channels.
Notes
-----
The converter stores a copy of the input array.
"""
if not is_float_image_array(image_in_numpy_float_format):
raise TypeError("image_in_numpy_float_format must be a valid float image array")
if image_in_numpy_float_format.shape[2] != 3:
raise IndexError("image_in_numpy_float_format must have 3 channels in the last dimension")
self._image_in_numpy_float_format = np.copy(image_in_numpy_float_format)
[docs]
@classmethod
def from_numpy_format(cls, image: NumpyFloatImageType) -> ImageConverter:
"""Create a converter from a channel-last floating RGB image.
Parameters
----------
image : NumpyFloatImageType
RGB image with shape ``(height, width, 3)``.
Returns
-------
ImageConverter
Converter containing a copy of ``image``.
Raises
------
TypeError
If ``image`` is not recognized as a floating-point image array.
IndexError
If the last dimension does not contain exactly three channels.
"""
if not is_float_image_array(image):
raise TypeError("image must be a valid float image array")
if image.shape[2] != 3:
raise IndexError("image must have 3 channels in the last dimension")
return ImageConverter(image)
[docs]
@classmethod
def from_numpy_int_format(cls, image: NumpyIntImageType) -> ImageConverter:
"""Create a converter from a channel-last integer RGB image.
Parameters
----------
image : NumpyIntImageType
RGB image with shape ``(height, width, 3)`` and values expressed on
the ``[0, 255]`` scale.
Returns
-------
ImageConverter
Converter containing a floating-point copy of ``image``.
Raises
------
TypeError
If ``image`` is not recognized as an unsigned-integer image array.
IndexError
If the last dimension does not contain exactly three channels.
"""
if not is_int_image_array(image):
raise TypeError("image must be a valid int image array")
if image.shape[2] != 3:
raise IndexError("image must have 3 channels in the last dimension")
# convert to floats (creates a copy)
image = image.astype(np.float32) / 255.0
return ImageConverter(image)
[docs]
@classmethod
def from_opencv_format(cls, image: OpenCVIntImageType) -> ImageConverter:
"""Create a converter from a channel-last integer BGR image.
Parameters
----------
image : OpenCVIntImageType
OpenCV-style BGR image with shape ``(height, width, 3)`` and values
expressed on the ``[0, 255]`` scale.
Returns
-------
ImageConverter
Converter containing a floating-point RGB copy of ``image``.
Raises
------
TypeError
If ``image`` is not recognized as an unsigned-integer image array.
IndexError
If the last dimension does not contain exactly three channels.
"""
if not is_int_image_array(image):
raise TypeError("image must be a valid int image array")
if image.shape[2] != 3:
raise IndexError("image must have 3 channels in the last dimension")
# convert to float (creates copy)
# can take a few ms..
image = image.astype(np.float32) / 255.0
# convert BGR to RGB
image = image[:, :, ::-1]
return ImageConverter(image)
[docs]
@classmethod
def from_torch_format(cls, image: TorchFloatImageType) -> ImageConverter:
"""Create a converter from channel-first floating RGB data.
Parameters
----------
image : TorchFloatImageType
Channel-first NumPy array with shape ``(3, height, width)``. This
method does not accept a ``torch.Tensor``.
Returns
-------
ImageConverter
Converter containing a channel-last copy of ``image``.
Raises
------
TypeError
If ``image`` is not recognized as a floating-point image array.
IndexError
If the first dimension does not contain exactly three channels.
"""
if not is_float_image_array(image):
raise TypeError("image must be a valid float image array")
if image.shape[0] != 3:
raise IndexError("image must have 3 channels in the first dimension")
# channel first to channel last
image = np.transpose(image, (1, 2, 0))
return ImageConverter(image)
@property
def image_in_numpy_format(self) -> NumpyFloatImageType:
"""Return a copy of the channel-last floating RGB image."""
return np.copy(self._image_in_numpy_float_format)
@property
def image_in_opencv_format(self) -> OpenCVIntImageType:
"""Return the image as a channel-last ``uint8`` BGR array."""
image = self._image_in_numpy_float_format[:, :, ::-1] * 255.0
# can take up to a few ms.
return np.clip(image, 0.0, 255.0).astype(np.uint8)
@property
def image_in_torch_format(self) -> TorchFloatImageType:
"""Return a channel-first copy of the floating RGB image."""
return np.transpose(self._image_in_numpy_float_format, (2, 0, 1)).copy()
@property
def image_in_numpy_int_format(self) -> NumpyIntImageType:
"""Return the image as a channel-last ``uint8`` RGB array."""
image = self._image_in_numpy_float_format * 255.0
return np.clip(image, 0.0, 255.0).astype(np.uint8)