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)