diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py
index 57ef207a..86284b23 100644
--- a/bec_widgets/cli/client.py
+++ b/bec_widgets/cli/client.py
@@ -504,6 +504,204 @@ class BECStatusBox(RPCBase):
"""
+class BaseROI(RPCBase):
+ """Base class for all Region of Interest (ROI) implementations."""
+
+ @property
+ @rpc_call
+ def label(self) -> "str":
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+
+ @label.setter
+ @rpc_call
+ def label(self) -> "str":
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+
+ @property
+ @rpc_call
+ def line_color(self) -> "str":
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+
+ @line_color.setter
+ @rpc_call
+ def line_color(self) -> "str":
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+
+ @property
+ @rpc_call
+ def line_width(self) -> "int":
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+
+ @line_width.setter
+ @rpc_call
+ def line_width(self) -> "int":
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+
+ @rpc_call
+ def get_coordinates(self):
+ """
+ Gets the coordinates that define this ROI's position and shape.
+
+ This is an abstract method that must be implemented by subclasses.
+ Implementations should return either a dictionary with descriptive keys
+ or a tuple of coordinates, depending on the value of self.description.
+
+ Returns:
+ dict or tuple: The coordinates defining the ROI's position and shape.
+
+ Raises:
+ NotImplementedError: This method must be implemented by subclasses.
+ """
+
+ @rpc_call
+ def get_data_from_image(
+ self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
+ ):
+ """
+ Wrapper around `pyqtgraph.ROI.getArrayRegion`.
+
+ Args:
+ image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
+ the first `ImageItem` in the same GraphicsScene as this ROI.
+ returnMappedCoords (bool): If True, also returns the coordinate array generated by
+ *getArrayRegion*.
+ **kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
+ such as `axes`, `order`, `shape`, etc.
+
+ Returns:
+ ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
+ """
+
+
+class CircularROI(RPCBase):
+ """Circular Region of Interest with center/diameter tracking and auto-labeling."""
+
+ @property
+ @rpc_call
+ def label(self) -> "str":
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+
+ @label.setter
+ @rpc_call
+ def label(self) -> "str":
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+
+ @property
+ @rpc_call
+ def line_color(self) -> "str":
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+
+ @line_color.setter
+ @rpc_call
+ def line_color(self) -> "str":
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+
+ @property
+ @rpc_call
+ def line_width(self) -> "int":
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+
+ @line_width.setter
+ @rpc_call
+ def line_width(self) -> "int":
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+
+ @rpc_call
+ def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
+ """
+ Calculates and returns the coordinates and size of an object, either as a
+ typed dictionary or as a tuple.
+
+ Args:
+ typed (bool | None): If True, returns coordinates as a dictionary. Defaults
+ to None, which utilizes the object's description value.
+
+ Returns:
+ dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
+ if `typed` is True.
+ tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
+ """
+
+ @rpc_call
+ def get_data_from_image(
+ self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
+ ):
+ """
+ Wrapper around `pyqtgraph.ROI.getArrayRegion`.
+
+ Args:
+ image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
+ the first `ImageItem` in the same GraphicsScene as this ROI.
+ returnMappedCoords (bool): If True, also returns the coordinate array generated by
+ *getArrayRegion*.
+ **kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
+ such as `axes`, `order`, `shape`, etc.
+
+ Returns:
+ ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
+ """
+
+
class Curve(RPCBase):
@rpc_call
def remove(self):
@@ -1215,6 +1413,44 @@ class Image(RPCBase):
Access the main image item.
"""
+ @rpc_call
+ def add_roi(
+ self,
+ kind: "Literal['rect', 'circle']" = "rect",
+ name: "str | None" = None,
+ line_width: "int | None" = 10,
+ pos: "tuple[float, float] | None" = (10, 10),
+ size: "tuple[float, float] | None" = (50, 50),
+ **pg_kwargs,
+ ) -> "RectangularROI | CircularROI":
+ """
+ Add a ROI to the image.
+
+ Args:
+ kind(str): The type of ROI to add. Options are "rect" or "circle".
+ name(str): The name of the ROI.
+ line_width(int): The line width of the ROI.
+ pos(tuple): The position of the ROI.
+ size(tuple): The size of the ROI.
+ **pg_kwargs: Additional arguments for the ROI.
+
+ Returns:
+ RectangularROI | CircularROI: The created ROI object.
+ """
+
+ @rpc_call
+ def remove_roi(self, roi: "int | str"):
+ """
+ Remove an ROI by index or label via the ROIController.
+ """
+
+ @property
+ @rpc_call
+ def rois(self) -> "list[BaseROI]":
+ """
+ Get the list of ROIs.
+ """
+
class ImageItem(RPCBase):
@property
@@ -2318,6 +2554,105 @@ class PositionerGroup(RPCBase):
"""
+class RectangularROI(RPCBase):
+ """Defines a rectangular Region of Interest (ROI) with additional functionality."""
+
+ @property
+ @rpc_call
+ def label(self) -> "str":
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+
+ @label.setter
+ @rpc_call
+ def label(self) -> "str":
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+
+ @property
+ @rpc_call
+ def line_color(self) -> "str":
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+
+ @line_color.setter
+ @rpc_call
+ def line_color(self) -> "str":
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+
+ @property
+ @rpc_call
+ def line_width(self) -> "int":
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+
+ @line_width.setter
+ @rpc_call
+ def line_width(self) -> "int":
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+
+ @rpc_call
+ def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
+ """
+ Returns the coordinates of a rectangle's corners. Supports returning them
+ as either a dictionary with descriptive keys or a tuple of coordinates.
+
+ Args:
+ typed (bool | None): If True, returns coordinates as a dictionary with
+ descriptive keys. If False, returns them as a tuple. Defaults to
+ the value of `self.description`.
+
+ Returns:
+ dict | tuple: The rectangle's corner coordinates, where the format
+ depends on the `typed` parameter.
+ """
+
+ @rpc_call
+ def get_data_from_image(
+ self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
+ ):
+ """
+ Wrapper around `pyqtgraph.ROI.getArrayRegion`.
+
+ Args:
+ image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
+ the first `ImageItem` in the same GraphicsScene as this ROI.
+ returnMappedCoords (bool): If True, also returns the coordinate array generated by
+ *getArrayRegion*.
+ **kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
+ such as `axes`, `order`, `shape`, etc.
+
+ Returns:
+ ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
+ """
+
+
class ResetButton(RPCBase):
"""A button that resets the scan queue."""
diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
index 32aca6fe..c2cfa5a3 100644
--- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py
+++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
@@ -43,7 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"pg": pg,
"wh": wh,
"dock": self.dock,
- # "im": self.im,
+ "im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
@@ -112,13 +112,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
- # sixth_tab = QWidget()
- # sixth_tab_layout = QVBoxLayout(sixth_tab)
- # self.im = Image()
- # self.mi = self.im.main_image
- # sixth_tab_layout.addWidget(self.im)
- # tab_widget.addTab(sixth_tab, "Image Next Gen")
- # tab_widget.setCurrentIndex(5)
+ sixth_tab = QWidget()
+ sixth_tab_layout = QVBoxLayout(sixth_tab)
+ self.im = Image(popups=False)
+ self.mi = self.im.main_image
+ sixth_tab_layout.addWidget(self.im)
+ tab_widget.addTab(sixth_tab, "Image Next Gen")
+ tab_widget.setCurrentIndex(1)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py
index 4b02eba1..9a35a763 100644
--- a/bec_widgets/widgets/plots/image/image.py
+++ b/bec_widgets/widgets/plots/image/image.py
@@ -20,6 +20,12 @@ from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
)
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
from bec_widgets.widgets.plots.plot_base import PlotBase
+from bec_widgets.widgets.plots.roi.image_roi import (
+ BaseROI,
+ CircularROI,
+ RectangularROI,
+ ROIController,
+)
logger = bec_logger.logger
@@ -111,6 +117,9 @@ class Image(PlotBase):
"transpose.setter",
"image",
"main_image",
+ "add_roi",
+ "remove_roi",
+ "rois",
]
sync_colorbar_with_autorange = Signal()
@@ -128,6 +137,7 @@ class Image(PlotBase):
self.gui_id = config.gui_id
self._color_bar = None
self._main_image = ImageItem()
+ self.roi_controller = ROIController(colormap="viridis")
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
@@ -139,6 +149,9 @@ class Image(PlotBase):
# Default Color map to plasma
self.color_map = "plasma"
+ # Headless controller keeps the canonical list.
+ self._roi_manager_dialog = None
+
################################################################################
# Widget Specific GUI interactions
################################################################################
@@ -304,9 +317,81 @@ class Image(PlotBase):
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
+ ################################################################################
+ # Static rois with roi manager
+
+ def add_roi(
+ self,
+ kind: Literal["rect", "circle"] = "rect",
+ name: str | None = None,
+ line_width: int | None = 10,
+ pos: tuple[float, float] | None = (10, 10),
+ size: tuple[float, float] | None = (50, 50),
+ **pg_kwargs,
+ ) -> RectangularROI | CircularROI:
+ """
+ Add a ROI to the image.
+
+ Args:
+ kind(str): The type of ROI to add. Options are "rect" or "circle".
+ name(str): The name of the ROI.
+ line_width(int): The line width of the ROI.
+ pos(tuple): The position of the ROI.
+ size(tuple): The size of the ROI.
+ **pg_kwargs: Additional arguments for the ROI.
+
+ Returns:
+ RectangularROI | CircularROI: The created ROI object.
+ """
+ if name is None:
+ name = f"ROI_{len(self.roi_controller.rois) + 1}"
+ if kind == "rect":
+ roi = RectangularROI(
+ pos=pos,
+ size=size,
+ parent_image=self,
+ line_width=line_width,
+ label=name,
+ **pg_kwargs,
+ )
+ elif kind == "circle":
+ roi = CircularROI(
+ pos=pos,
+ size=size,
+ parent_image=self,
+ line_width=line_width,
+ label=name,
+ **pg_kwargs,
+ )
+ else:
+ raise ValueError("kind must be 'rect' or 'circle'")
+
+ # Add to plot and controller (controller assigns color)
+ self.plot_item.addItem(roi)
+ self.roi_controller.add_roi(roi)
+ return roi
+
+ def remove_roi(self, roi: int | str):
+ """Remove an ROI by index or label via the ROIController."""
+ if isinstance(roi, int):
+ self.roi_controller.remove_roi_by_index(roi)
+ elif isinstance(roi, str):
+ self.roi_controller.remove_roi_by_name(roi)
+ else:
+ raise ValueError("roi must be an int index or str name")
+
################################################################################
# Widget Specific Properties
################################################################################
+ ################################################################################
+ # Rois
+
+ @property
+ def rois(self) -> list[BaseROI]:
+ """
+ Get the list of ROIs.
+ """
+ return self.roi_controller.rois
################################################################################
# Colorbar toggle
@@ -925,6 +1010,11 @@ class Image(PlotBase):
"""
Disconnect the image update signals and clean up the image.
"""
+ # Remove all ROIs
+ rois = self.rois
+ for roi in rois:
+ roi.remove()
+
# Main Image cleanup
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
diff --git a/bec_widgets/widgets/plots/roi/__init__.py b/bec_widgets/widgets/plots/roi/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots/roi/image_roi.py b/bec_widgets/widgets/plots/roi/image_roi.py
new file mode 100644
index 00000000..aac64a75
--- /dev/null
+++ b/bec_widgets/widgets/plots/roi/image_roi.py
@@ -0,0 +1,867 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pyqtgraph as pg
+from pyqtgraph import TextItem
+from pyqtgraph import functions as fn
+from pyqtgraph import mkPen
+from qtpy import QtCore
+from qtpy.QtCore import QObject, Signal
+
+from bec_widgets import SafeProperty
+from bec_widgets.utils import BECConnector, ConnectionConfig
+from bec_widgets.utils.colors import Colors
+
+if TYPE_CHECKING:
+ from bec_widgets.widgets.plots.image.image import Image
+
+
+class LabelAdorner:
+ """Manages a TextItem label on top of any ROI, keeping it aligned."""
+
+ def __init__(
+ self,
+ roi: BaseROI,
+ anchor: tuple[int, int] = (0, 1),
+ padding: int = 2,
+ bg_color: str | tuple[int, int, int, int] = (0, 0, 0, 100),
+ text_color: str | tuple[int, int, int, int] = "white",
+ ):
+ """
+ Initializes a label overlay for a given region of interest (ROI), allowing for customization
+ of text placement, padding, background color, and text color. Automatically attaches the label
+ to the ROI and updates its position and content based on ROI changes.
+
+ Args:
+ roi: The region of interest to which the label will be attached.
+ anchor: Tuple specifying the label's anchor relative to the ROI. Default is (0, 1).
+ padding: Integer specifying the padding around the label's text. Default is 2.
+ bg_color: RGBA tuple for the label's background color. Default is (0, 0, 0, 100).
+ text_color: String specifying the color of the label's text. Default is "white".
+ """
+ self.roi = roi
+ self.label = TextItem(anchor=anchor)
+ self.padding = padding
+ self.bg_rgba = bg_color
+ self.text_color = text_color
+ roi.addItem(self.label) if hasattr(roi, "addItem") else self.label.setParentItem(roi)
+ # initial draw
+ self._update_html(roi.label)
+ self._reposition()
+ # reconnect on geometry/name changes
+ roi.sigRegionChanged.connect(self._reposition)
+ if hasattr(roi, "nameChanged"):
+ roi.nameChanged.connect(self._update_html)
+
+ def _update_html(self, text: str):
+ """
+ Updates the HTML content of the label with the given text.
+
+ Creates an HTML div with the configured background color, text color, and padding,
+ then sets this HTML as the content of the label.
+
+ Args:
+ text (str): The text to display in the label.
+ """
+ html = (
+ f'
{text}
'
+ )
+ self.label.setHtml(html)
+
+ def _reposition(self):
+ """
+ Repositions the label to align with the ROI's current position.
+
+ This method is called whenever the ROI's position or size changes.
+ It places the label at the bottom-left corner of the ROI's bounding rectangle.
+ """
+ # put at top-left corner of ROI’s bounding rect
+ size = self.roi.state["size"]
+ height = size[1]
+ self.label.setPos(0, height)
+
+
+class BaseROI(BECConnector):
+ """Base class for all Region of Interest (ROI) implementations.
+
+ This class serves as a mixin that provides common properties and methods for ROIs,
+ including name, line color, and line width properties. It inherits from BECConnector
+ to enable remote procedure call functionality.
+
+ Attributes:
+ RPC (bool): Flag indicating if remote procedure calls are enabled.
+ PLUGIN (bool): Flag indicating if this class is a plugin.
+ nameChanged (Signal): Signal emitted when the ROI name changes.
+ penChanged (Signal): Signal emitted when the ROI pen (color/width) changes.
+ USER_ACCESS (list): List of methods and properties accessible via RPC.
+ """
+
+ RPC = True
+ PLUGIN = False
+
+ nameChanged = Signal(str)
+ penChanged = Signal()
+ USER_ACCESS = [
+ "label",
+ "label.setter",
+ "line_color",
+ "line_color.setter",
+ "line_width",
+ "line_width.setter",
+ "get_coordinates",
+ "get_data_from_image",
+ ]
+
+ def __init__(
+ self,
+ *,
+ # BECConnector kwargs
+ config: ConnectionConfig | None = None,
+ gui_id: str | None = None,
+ parent_image: Image | None,
+ # ROI-specific
+ label: str | None = None,
+ line_color: str | None = None,
+ line_width: int = 10,
+ # all remaining pg.*ROI kwargs (pos, size, pen, …)
+ **pg_kwargs,
+ ):
+ """Base class for all modular ROIs.
+
+ Args:
+ label (str): Human-readable name shown in ROI Manager and labels.
+ line_color (str | None, optional): Initial pen color. Defaults to None.
+ Controller may override color later.
+ line_width (int, optional): Initial pen width. Defaults to 15.
+ Controller may override width later.
+ config (ConnectionConfig | None, optional): Standard BECConnector argument. Defaults to None.
+ gui_id (str | None, optional): Standard BECConnector argument. Defaults to None.
+ parent_image (BECConnector | None, optional): Standard BECConnector argument. Defaults to None.
+ """
+ if config is None:
+ config = ConnectionConfig(widget_class=self.__class__.__name__)
+ self.config = config
+
+ self.set_parent(parent_image)
+ self.parent_plot_item = parent_image.plot_item
+ object_name = label.replace("-", "_").replace(" ", "_") if label else None
+ super().__init__(
+ object_name=object_name, config=config, gui_id=gui_id, removable=True, **pg_kwargs
+ )
+
+ self._label = label or "ROI"
+ self._line_color = line_color or "#ffffff"
+ self._line_width = line_width
+ self._description = True
+ self.setPen(mkPen(self._line_color, width=self._line_width))
+
+ def set_parent(self, parent: Image):
+ """
+ Sets the parent image for this ROI.
+
+ Args:
+ parent (Image): The parent image object to associate with this ROI.
+ """
+ self.parent_image = parent
+
+ def parent(self):
+ """
+ Gets the parent image associated with this ROI.
+
+ Returns:
+ Image: The parent image object, or None if no parent is set.
+ """
+ return self.parent_image
+
+ @property
+ def label(self) -> str:
+ """
+ Gets the display name of this ROI.
+
+ Returns:
+ str: The current name of the ROI.
+ """
+ return self._label
+
+ @label.setter
+ def label(self, new: str):
+ """
+ Sets the display name of this ROI.
+
+ If the new name is different from the current name, this method updates
+ the internal name, emits the nameChanged signal, and updates the object name.
+
+ Args:
+ new (str): The new name to set for the ROI.
+ """
+ if new != self._label:
+ self._label = new
+ self.nameChanged.emit(new)
+ self.change_object_name(new)
+
+ @property
+ def line_color(self) -> str:
+ """
+ Gets the current line color of the ROI.
+
+ Returns:
+ str: The current line color as a string (e.g., hex color code).
+ """
+ return self._line_color
+
+ @line_color.setter
+ def line_color(self, value: str):
+ """
+ Sets the line color of the ROI.
+
+ If the new color is different from the current color, this method updates
+ the internal color value, updates the pen while preserving the line width,
+ and emits the penChanged signal.
+
+ Args:
+ value (str): The new color to set for the ROI's outline (e.g., hex color code).
+ """
+ if value != self._line_color:
+ self._line_color = value
+ # update pen but preserve width
+ self.setPen(mkPen(value, width=self._line_width))
+ self.penChanged.emit()
+
+ @property
+ def line_width(self) -> int:
+ """
+ Gets the current line width of the ROI.
+
+ Returns:
+ int: The current line width in pixels.
+ """
+ return self._line_width
+
+ @line_width.setter
+ def line_width(self, value: int):
+ """
+ Sets the line width of the ROI.
+
+ If the new width is different from the current width and is greater than 0,
+ this method updates the internal width value, updates the pen while preserving
+ the line color, and emits the penChanged signal.
+
+ Args:
+ value (int): The new width to set for the ROI's outline in pixels.
+ Must be greater than 0.
+ """
+ if value != self._line_width and value > 0:
+ self._line_width = value
+ self.setPen(mkPen(self._line_color, width=value))
+ self.penChanged.emit()
+
+ @property
+ def description(self) -> bool:
+ """
+ Gets whether ROI coordinates should be emitted with descriptive keys by default.
+
+ Returns:
+ bool: True if coordinates should include descriptive keys, False otherwise.
+ """
+ return self._description
+
+ @description.setter
+ def description(self, value: bool):
+ """
+ Sets whether ROI coordinates should be emitted with descriptive keys by default.
+
+ This affects the default behavior of the get_coordinates method.
+
+ Args:
+ value (bool): True to emit coordinates with descriptive keys, False to emit
+ as a simple tuple of values.
+ """
+ self._description = value
+
+ def get_coordinates(self):
+ """
+ Gets the coordinates that define this ROI's position and shape.
+
+ This is an abstract method that must be implemented by subclasses.
+ Implementations should return either a dictionary with descriptive keys
+ or a tuple of coordinates, depending on the value of self.description.
+
+ Returns:
+ dict or tuple: The coordinates defining the ROI's position and shape.
+
+ Raises:
+ NotImplementedError: This method must be implemented by subclasses.
+ """
+ raise NotImplementedError("Subclasses must implement get_coordinates()")
+
+ def get_data_from_image(
+ self, image_item: pg.ImageItem | None = None, returnMappedCoords: bool = False, **kwargs
+ ):
+ """Wrapper around `pyqtgraph.ROI.getArrayRegion`.
+
+ Args:
+ image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
+ the first `ImageItem` in the same GraphicsScene as this ROI.
+ returnMappedCoords (bool): If True, also returns the coordinate array generated by
+ *getArrayRegion*.
+ **kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
+ such as `axes`, `order`, `shape`, etc.
+
+ Returns:
+ ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
+ """
+ if image_item is None:
+ image_item = next(
+ (
+ it
+ for it in self.scene().items()
+ if isinstance(it, pg.ImageItem) and it.image is not None
+ ),
+ None,
+ )
+ if image_item is None:
+ raise RuntimeError("No ImageItem found in the current scene.")
+
+ data = image_item.image # the raw ndarray held by ImageItem
+ return self.getArrayRegion(
+ data, img=image_item, returnMappedCoords=returnMappedCoords, **kwargs
+ )
+
+ def add_scale_handle(self):
+ return
+
+ def remove(self):
+ handles = self.handles
+ for i in range(len(handles)):
+ try:
+ self.removeHandle(0)
+ except IndexError:
+ continue
+ self.rpc_register.remove_rpc(self)
+ self.parent_image.plot_item.removeItem(self)
+ if hasattr(self.parent_image, "roi_controller"):
+ self.parent_image.roi_controller._rois.remove(self)
+ self.parent_image.roi_controller._rebuild_color_buffer()
+
+
+class RectangularROI(BaseROI, pg.RectROI):
+ """
+ Defines a rectangular Region of Interest (ROI) with additional functionality.
+
+ Provides tools for manipulating and extracting data from rectangular areas on
+ images, includes support for GUI features and event-driven signaling.
+
+ Attributes:
+ edgesChanged (Signal): Signal emitted when the ROI edges change, providing
+ the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
+ edgesReleased (Signal): Signal emitted when the ROI edges are released,
+ providing the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
+ """
+
+ edgesChanged = Signal(float, float, float, float)
+ edgesReleased = Signal(float, float, float, float)
+
+ def __init__(
+ self,
+ *,
+ # pg.RectROI kwargs
+ pos: tuple[float, float],
+ size: tuple[float, float],
+ pen=None,
+ # BECConnector kwargs
+ config: ConnectionConfig | None = None,
+ gui_id: str | None = None,
+ parent_image: Image | None = None,
+ # ROI specifics
+ label: str | None = None,
+ line_color: str | None = None,
+ line_width: int = 10,
+ resize_handles: bool = True,
+ **extra_pg,
+ ):
+ """
+ Initializes an instance with properties for defining a rectangular ROI with handles,
+ configurations, and an auto-aligning label. Also connects a signal for region updates.
+
+ Args:
+ pos: Initial position of the ROI.
+ size: Initial size of the ROI.
+ pen: Defines the border appearance; can be color or style.
+ config: Optional configuration details for the connection.
+ gui_id: Optional identifier for the associated GUI element.
+ parent_image: Optional parent object the ROI is related to.
+ label: Optional label for identification within the context.
+ line_color: Optional color of the ROI outline.
+ line_width: Width of the ROI's outline in pixels.
+ parent_plot_item: The plot item this ROI belongs to.
+ **extra_pg: Additional keyword arguments specific to pg.RectROI.
+ """
+ super().__init__(
+ config=config,
+ gui_id=gui_id,
+ parent_image=parent_image,
+ label=label,
+ line_color=line_color,
+ line_width=line_width,
+ pos=pos,
+ size=size,
+ pen=pen,
+ **extra_pg,
+ )
+
+ self.sigRegionChanged.connect(self._on_region_changed)
+ self.adorner = LabelAdorner(roi=self)
+ if resize_handles:
+ self.add_scale_handle()
+ self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
+ self.handleHoverPen = fn.mkPen("lime", width=4)
+
+ def add_scale_handle(self):
+ """
+ Add scale handles at every corner and edge of the ROI.
+
+ Corner handles are anchored to the centre for two-axis scaling.
+ Edge handles are anchored to the midpoint of the opposite edge for single-axis scaling.
+ """
+ centre = [0.5, 0.5]
+
+ # Corner handles – anchored to the centre for two-axis scaling
+ self.addScaleHandle([0, 0], centre) # top‑left
+ self.addScaleHandle([1, 0], centre) # top‑right
+ self.addScaleHandle([0, 1], centre) # bottom‑left
+ self.addScaleHandle([1, 1], centre) # bottom‑right
+
+ # Edge handles – anchored to the midpoint of the opposite edge
+ self.addScaleHandle([0.5, 0], [0.5, 1]) # top edge
+ self.addScaleHandle([0.5, 1], [0.5, 0]) # bottom edge
+ self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
+ self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
+
+ def _on_region_changed(self):
+ """
+ Handles ROI region change events.
+
+ This method is called whenever the ROI's position or size changes.
+ It calculates the new corner coordinates and emits the edgesChanged signal
+ with the updated coordinates.
+ """
+ x0, y0 = self.pos().x(), self.pos().y()
+ w, h = self.state["size"]
+ self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
+ viewBox = self.parent_plot_item.vb
+ viewBox.update()
+
+ def mouseDragEvent(self, ev):
+ """
+ Handles mouse drag events on the ROI.
+
+ This method extends the parent class implementation to emit the edgesReleased
+ signal when the mouse drag is finished, providing the final coordinates of the ROI.
+
+ Args:
+ ev: The mouse event object containing information about the drag operation.
+ """
+ super().mouseDragEvent(ev)
+ if ev.isFinish():
+ x0, y0 = self.pos().x(), self.pos().y()
+ w, h = self.state["size"]
+ self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
+
+ def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
+ """
+ Returns the coordinates of a rectangle's corners. Supports returning them
+ as either a dictionary with descriptive keys or a tuple of coordinates.
+
+ Args:
+ typed (bool | None): If True, returns coordinates as a dictionary with
+ descriptive keys. If False, returns them as a tuple. Defaults to
+ the value of `self.description`.
+
+ Returns:
+ dict | tuple: The rectangle's corner coordinates, where the format
+ depends on the `typed` parameter.
+ """
+ if typed is None:
+ typed = self.description
+
+ x0, y0 = self.pos().x(), self.pos().y()
+ w, h = self.state["size"]
+ x1, y1 = x0 + w, y0 + h
+ if typed:
+ return {
+ "bottom_left": (x0, y0),
+ "bottom_right": (x1, y0),
+ "top_left": (x0, y1),
+ "top_right": (x1, y1),
+ }
+ return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
+
+ def _lookup_scene_image(self):
+ """
+ Searches for an image in the current scene.
+
+ This helper method iterates through all items in the scene and returns
+ the first pg.ImageItem that has a non-None image property.
+
+ Returns:
+ numpy.ndarray or None: The image from the first found ImageItem,
+ or None if no suitable image is found.
+ """
+ for it in self.scene().items():
+ if isinstance(it, pg.ImageItem) and it.image is not None:
+ return it.image
+ return None
+
+
+class CircularROI(BaseROI, pg.CircleROI):
+ """Circular Region of Interest with center/diameter tracking and auto-labeling.
+
+ This class extends the BaseROI and pg.CircleROI classes to provide a circular ROI
+ that emits signals when its center or diameter changes, and includes an auto-aligning
+ label for visual identification.
+
+ Attributes:
+ centerChanged (Signal): Signal emitted when the ROI center or diameter changes,
+ providing the new (center_x, center_y, diameter) values.
+ centerReleased (Signal): Signal emitted when the ROI is released after dragging,
+ providing the final (center_x, center_y, diameter) values.
+ """
+
+ centerChanged = Signal(float, float, float)
+ centerReleased = Signal(float, float, float)
+
+ def __init__(
+ self,
+ *,
+ pos,
+ size,
+ pen=None,
+ config: ConnectionConfig | None = None,
+ gui_id: str | None = None,
+ parent_image: Image | None = None,
+ label: str | None = None,
+ line_color: str | None = None,
+ line_width: int = 10,
+ **extra_pg,
+ ):
+ """
+ Initializes a circular ROI with the specified properties.
+
+ Creates a circular ROI at the given position and with the given size,
+ connects signals for tracking changes, and attaches an auto-aligning label.
+
+ Args:
+ pos: Initial position of the ROI as [x, y].
+ size: Initial size of the ROI as [diameter, diameter].
+ pen: Defines the border appearance; can be color or style.
+ config (ConnectionConfig | None, optional): Configuration for BECConnector. Defaults to None.
+ gui_id (str | None, optional): Identifier for the GUI element. Defaults to None.
+ parent_image (BECConnector | None, optional): Parent image object. Defaults to None.
+ label (str | None, optional): Display name for the ROI. Defaults to None.
+ line_color (str | None, optional): Color of the ROI outline. Defaults to None.
+ line_width (int, optional): Width of the ROI outline in pixels. Defaults to 3.
+ parent_plot_item: The plot item this ROI belongs to.
+ **extra_pg: Additional keyword arguments for pg.CircleROI.
+ """
+ super().__init__(
+ config=config,
+ gui_id=gui_id,
+ parent_image=parent_image,
+ label=label,
+ line_color=line_color,
+ line_width=line_width,
+ pos=pos,
+ size=size,
+ pen=pen,
+ **extra_pg,
+ )
+ self.sigRegionChanged.connect(self._on_region_changed)
+ self._adorner = LabelAdorner(self)
+
+ def _on_region_changed(self):
+ """
+ Handles ROI region change events.
+
+ This method is called whenever the ROI's position or size changes.
+ It calculates the center coordinates and diameter of the circle and
+ emits the centerChanged signal with these values.
+ """
+ d = self.state["size"][0]
+ cx = self.pos().x() + d / 2
+ cy = self.pos().y() + d / 2
+ self.centerChanged.emit(cx, cy, d)
+ viewBox = self.parent_plot_item.getViewBox()
+ viewBox.update()
+
+ def mouseDragEvent(self, ev):
+ """
+ Handles mouse drag events on the ROI.
+
+ This method extends the parent class implementation to emit the centerReleased
+ signal when the mouse drag is finished, providing the final center coordinates
+ and diameter of the circular ROI.
+
+ Args:
+ ev: The mouse event object containing information about the drag operation.
+ """
+ super().mouseDragEvent(ev)
+ if ev.isFinish():
+ d = self.state["size"][0]
+ cx = self.pos().x() + d / 2
+ cy = self.pos().y() + d / 2
+ self.centerReleased.emit(cx, cy, d)
+
+ def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
+ """
+ Calculates and returns the coordinates and size of an object, either as a
+ typed dictionary or as a tuple.
+
+ Args:
+ typed (bool | None): If True, returns coordinates as a dictionary. Defaults
+ to None, which utilizes the object's description value.
+
+ Returns:
+ dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
+ if `typed` is True.
+ tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
+ """
+ if typed is None:
+ typed = self.description
+
+ d = self.state["size"][0]
+ cx = self.pos().x() + d / 2
+ cy = self.pos().y() + d / 2
+
+ if typed:
+ return {"center_x": cx, "center_y": cy, "diameter": d, "radius": d / 2}
+ return (cx, cy, d, d / 2)
+
+ def _lookup_scene_image(self) -> pg.ImageItem | None:
+ """
+ Retrieves an image from the scene items if available.
+
+ Iterates over all items in the scene and checks if any of them are of type
+ `pg.ImageItem` and have a non-None image. If such an item is found, its image
+ is returned.
+
+ Returns:
+ pg.ImageItem or None: The first found ImageItem with a non-None image,
+ or None if no suitable image is found.
+ """
+ for it in self.scene().items():
+ if isinstance(it, pg.ImageItem) and it.image is not None:
+ return it.image
+ return None
+
+
+class ROIController(QObject):
+ """Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
+
+ Handles creating, adding, removing, and managing ROI instances. Supports color assignment
+ from a colormap, and provides utility methods to access and manipulate ROIs.
+
+ Attributes:
+ roiAdded (Signal): Emits the new ROI instance when added.
+ roiRemoved (Signal): Emits the removed ROI instance when deleted.
+ cleared (Signal): Emits when all ROIs are removed.
+ paletteChanged (Signal): Emits the new colormap name when updated.
+ _colormap (str): Name of the colormap used for ROI colors.
+ _rois (list[BaseROI]): Internal list storing currently managed ROIs.
+ _colors (list[str]): Internal list of colors for the ROIs.
+ """
+
+ roiAdded = Signal(object) # emits the new ROI instance
+ roiRemoved = Signal(object) # emits the removed ROI instance
+ cleared = Signal() # emits when all ROIs are removed
+ paletteChanged = Signal(str) # emits new colormap name
+
+ def __init__(self, colormap="viridis"):
+ """
+ Initializes the ROI controller with the specified colormap.
+
+ Sets up internal data structures for managing ROIs and their colors.
+
+ Args:
+ colormap (str, optional): The name of the colormap to use for ROI colors.
+ Defaults to "viridis".
+ """
+ super().__init__()
+ self._colormap = colormap
+ self._rois: list[BaseROI] = []
+ self._colors: list[str] = []
+ self._rebuild_color_buffer()
+
+ def _rebuild_color_buffer(self):
+ """
+ Regenerates the color buffer for ROIs.
+
+ This internal method creates a new list of colors based on the current colormap
+ and the number of ROIs. It ensures there's always one more color than the number
+ of ROIs to allow for adding a new ROI without regenerating the colors.
+ """
+ n = len(self._rois) + 1
+ self._colors = Colors.golden_angle_color(colormap=self._colormap, num=n, format="HEX")
+
+ def add_roi(self, roi: BaseROI):
+ """
+ Registers an externally created ROI with this controller.
+
+ Adds the ROI to the internal list, assigns it a color from the color buffer,
+ ensures it has an appropriate line width, and emits the roiAdded signal.
+
+ Args:
+ roi (BaseROI): The ROI instance to register. Can be any subclass of BaseROI,
+ such as RectangularROI or CircularROI.
+ """
+ self._rois.append(roi)
+ self._rebuild_color_buffer()
+ idx = len(self._rois) - 1
+ if roi.label == "ROI" or roi.label.startswith("ROI "):
+ roi.label = f"ROI {idx}"
+ color = self._colors[idx]
+ roi.line_color = color
+ # ensure line width default is at least 3 if not previously set
+ if getattr(roi, "line_width", 0) < 1:
+ roi.line_width = 10
+ self.roiAdded.emit(roi)
+
+ def remove_roi(self, roi: BaseROI):
+ """
+ Removes an ROI from this controller.
+
+ If the ROI is found in the internal list, it is removed, the color buffer
+ is regenerated, and the roiRemoved signal is emitted.
+
+ Args:
+ roi (BaseROI): The ROI instance to remove.
+ """
+ rois = self._rois
+ if roi not in rois:
+ roi.remove()
+
+ def get_roi(self, index: int) -> BaseROI | None:
+ """
+ Returns the ROI at the specified index.
+
+ Args:
+ index (int): The index of the ROI to retrieve.
+
+ Returns:
+ BaseROI or None: The ROI at the specified index, or None if the index
+ is out of range.
+ """
+ if 0 <= index < len(self._rois):
+ return self._rois[index]
+ return None
+
+ def get_roi_by_name(self, name: str) -> BaseROI | None:
+ """
+ Returns the first ROI with the specified name.
+
+ Args:
+ name (str): The name to search for (case-sensitive).
+
+ Returns:
+ BaseROI or None: The first ROI with a matching name, or None if no
+ matching ROI is found.
+ """
+ for r in self._rois:
+ if r.label == name:
+ return r
+ return None
+
+ def remove_roi_by_index(self, index: int):
+ """
+ Removes the ROI at the specified index.
+
+ Args:
+ index (int): The index of the ROI to remove.
+ """
+ roi = self.get_roi(index)
+ if roi is not None:
+ roi.remove()
+
+ def remove_roi_by_name(self, name: str):
+ """
+ Removes the first ROI with the specified name.
+
+ Args:
+ name (str): The name of the ROI to remove (case-sensitive).
+ """
+ roi = self.get_roi_by_name(name)
+ if roi is not None:
+ roi.remove()
+
+ def clear(self):
+ """
+ Removes all ROIs from this controller.
+
+ Iterates through all ROIs and removes them one by one, then emits
+ the cleared signal to notify listeners that all ROIs have been removed.
+ """
+ for roi in list(self._rois):
+ roi.remove()
+ self.cleared.emit()
+
+ def renormalize_colors(self):
+ """
+ Reassigns palette colors to all ROIs in order.
+
+ Regenerates the color buffer based on the current colormap and number of ROIs,
+ then assigns each ROI a color from the buffer in the order they were added.
+ This is useful after changing the colormap or when ROIs need to be visually
+ distinguished from each other.
+ """
+ self._rebuild_color_buffer()
+ for idx, roi in enumerate(self._rois):
+ roi.line_color = self._colors[idx]
+
+ @SafeProperty(str)
+ def colormap(self):
+ """
+ Gets the name of the colormap used for ROI colors.
+
+ Returns:
+ str: The name of the colormap.
+ """
+ return self._colormap
+
+ @colormap.setter
+ def colormap(self, cmap: str):
+ """
+ Sets the colormap used for ROI colors.
+
+ Updates the internal colormap name and reassigns colors to all ROIs
+ based on the new colormap.
+
+ Args:
+ cmap (str): The name of the colormap to use (e.g., "viridis", "plasma").
+ """
+
+ self.set_colormap(cmap)
+
+ def set_colormap(self, cmap: str):
+ Colors.validate_color_map(cmap)
+ self._colormap = cmap
+ self.paletteChanged.emit(cmap)
+ self.renormalize_colors()
+
+ @property
+ def rois(self) -> list[BaseROI]:
+ """
+ Gets a copy of the list of ROIs managed by this controller.
+
+ Returns a new list containing all the ROIs currently managed by this controller.
+ The list is a copy, so modifying it won't affect the controller's internal list.
+
+ Returns:
+ list[BaseROI]: A list of all ROIs currently managed by this controller.
+ """
+ return list(self._rois)
+
+ def cleanup(self):
+ for roi in self._rois:
+ self.remove_roi(roi)
diff --git a/tests/unit_tests/test_image_rois.py b/tests/unit_tests/test_image_rois.py
new file mode 100644
index 00000000..a4a402e4
--- /dev/null
+++ b/tests/unit_tests/test_image_rois.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+from typing import Literal
+
+import numpy as np
+import pyqtgraph as pg
+import pytest
+from qtpy.QtCore import QPointF
+
+from bec_widgets.widgets.plots.image.image import Image
+from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
+from tests.unit_tests.client_mocks import mocked_client
+from tests.unit_tests.conftest import create_widget
+
+
+@pytest.fixture(params=["rect", "circle"])
+def bec_image_widget_with_roi(qtbot, request, mocked_client):
+ """Return (widget, roi, shape_label) for each ROI class."""
+
+ roi_type: Literal["rect", "circle"] = request.param
+
+ # Build an Image widget with a trivial 100×100 zeros array
+ widget: Image = create_widget(qtbot, Image, client=mocked_client)
+ data = np.zeros((100, 100), dtype=float)
+ data[20:40, 20:40] = 5 # content assertion for roi to check
+ widget.main_image.set_data(data)
+
+ # Add a single ROI via the public API
+ roi = widget.add_roi(kind=roi_type)
+
+ yield widget, roi, roi_type
+
+
+def test_default_properties(bec_image_widget_with_roi):
+ """Label, width, type sanity‑check."""
+
+ _widget, roi, roi_type = bec_image_widget_with_roi
+
+ assert roi.label.startswith("ROI")
+
+ assert roi.line_width == 10
+
+ # concrete subclass type
+ assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
+
+
+def test_coordinate_structures(bec_image_widget_with_roi):
+ """Typed vs untyped coordinate structures are consistent."""
+
+ _widget, roi, _ = bec_image_widget_with_roi
+
+ raw = roi.get_coordinates(typed=False)
+ typed = roi.get_coordinates(typed=True)
+
+ # untyped is always a tuple
+ assert isinstance(raw, tuple)
+
+ # typed is always a dict and has same number of scalars as raw flattens to
+ assert isinstance(typed, dict)
+ assert sum(isinstance(v, (tuple, list)) and len(v) or 1 for v in typed.values()) == len(
+ np.ravel(raw)
+ )
+
+
+def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
+ """Pixels reported by get_data_from_image have non‑zero size and match ROI extents."""
+
+ widget, roi, _ = bec_image_widget_with_roi
+
+ pixels = roi.get_data_from_image() # auto‑detect ImageItem
+
+ assert pixels.size > 0 # ROI covers at least one pixel
+
+ # For rectangular ROI: pixel bounding box equals coordinate bbox
+ if isinstance(roi, RectangularROI):
+ (x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
+ # ensure ints inside image shape
+ x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
+ expected = widget.main_image.image[y0:y1, x0:x1]
+ assert pixels.shape == expected.shape
+
+
+@pytest.mark.parametrize("index", [0])
+def test_controller_remove_by_index(bec_image_widget_with_roi, index):
+ """Image.remove_roi(index) removes the graphics item and updates controller."""
+
+ widget, roi, _ = bec_image_widget_with_roi
+ controller: ROIController = widget.roi_controller
+
+ assert controller.rois # non‑empty before
+
+ widget.remove_roi(index)
+
+ # ROI list now empty and item no longer in scene
+ assert not controller.rois
+ assert roi not in widget.plot_item.items
+
+
+def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
+ widget: Image = create_widget(qtbot, Image, client=mocked_client)
+
+ # add two of each ROI type
+ for _kind in ("rect", "circle"):
+ widget.add_roi(kind=_kind)
+ widget.add_roi(kind=_kind)
+
+ colors = [r.line_color for r in widget.roi_controller.rois]
+ assert len(colors) == len(set(colors)), "Colors must be unique per ROI"
+
+
+def test_roi_label_and_signals(bec_image_widget_with_roi):
+ widget, roi, _ = bec_image_widget_with_roi
+ changed = []
+ roi.nameChanged.connect(lambda name: changed.append(name))
+ roi.label = "new_label"
+ assert roi.label == "new_label"
+ assert changed and changed[0] == "new_label"
+
+
+def test_roi_line_color_and_width(bec_image_widget_with_roi):
+ _widget, roi, _ = bec_image_widget_with_roi
+ changed = []
+ roi.penChanged.connect(lambda: changed.append(True))
+ roi.line_color = "#123456"
+ assert roi.line_color == "#123456"
+ roi.line_width = 5
+ assert roi.line_width == 5
+ assert changed # penChanged should have been emitted
+
+
+def test_roi_controller_add_remove_multiple(qtbot, mocked_client):
+ widget = create_widget(qtbot, Image, client=mocked_client)
+ controller = widget.roi_controller
+ r1 = widget.add_roi(kind="rect", name="r1")
+ r2 = widget.add_roi(kind="circle", name="c1")
+ assert r1 in controller.rois and r2 in controller.rois
+ widget.remove_roi("r1")
+ assert r1 not in controller.rois and r2 in controller.rois
+ widget.remove_roi("c1")
+ assert not controller.rois
+
+
+def test_roi_controller_colormap_changes(qtbot, mocked_client):
+ widget = create_widget(qtbot, Image, client=mocked_client)
+ controller = widget.roi_controller
+ widget.add_roi(kind="rect")
+ widget.add_roi(kind="circle")
+ old_colors = [r.line_color for r in controller.rois]
+ controller.colormap = "plasma"
+ new_colors = [r.line_color for r in controller.rois]
+ assert old_colors != new_colors
+ assert all(isinstance(c, str) for c in new_colors)
+
+
+def test_roi_controller_clear(qtbot, mocked_client):
+ widget = create_widget(qtbot, Image, client=mocked_client)
+ widget.add_roi(kind="rect")
+ widget.add_roi(kind="circle")
+ controller = widget.roi_controller
+ controller.clear()
+ assert not controller.rois
+
+
+def test_roi_get_data_from_image_no_image(qtbot, mocked_client):
+ widget = create_widget(qtbot, Image, client=mocked_client)
+ roi = widget.add_roi(kind="rect")
+ # Remove all images from scene
+ for item in list(widget.plot_item.items):
+ if hasattr(item, "image"):
+ widget.plot_item.removeItem(item)
+ import pytest
+
+ with pytest.raises(RuntimeError):
+ roi.get_data_from_image()
+
+
+def test_roi_remove_cleans_up(bec_image_widget_with_roi):
+ widget, roi, _ = bec_image_widget_with_roi
+ roi.remove()
+ assert roi not in widget.roi_controller.rois
+ assert roi not in widget.plot_item.items
+
+
+def test_roi_controller_get_roi_methods(qtbot, mocked_client):
+ widget = create_widget(qtbot, Image, client=mocked_client)
+ r1 = widget.add_roi(kind="rect", name="findme")
+ r2 = widget.add_roi(kind="circle")
+ controller = widget.roi_controller
+ assert controller.get_roi_by_name("findme") == r1
+ assert controller.get_roi(1) == r2
+ assert controller.get_roi(99) is None
+ assert controller.get_roi_by_name("notfound") is None
diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py
index 231b7e54..758825dd 100644
--- a/tests/unit_tests/test_image_view_next_gen.py
+++ b/tests/unit_tests/test_image_view_next_gen.py
@@ -329,3 +329,60 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
assert bec_image_view.main_image.log is False
assert bec_image_view.transpose is False
assert bec_image_view.main_image.transpose is False
+
+
+def test_roi_add_remove_and_properties(qtbot, mocked_client):
+ view = create_widget(qtbot, Image, client=mocked_client)
+ # Add ROIs
+ rect = view.add_roi(kind="rect", name="rect_roi", line_width=7)
+ circ = view.add_roi(kind="circle", name="circ_roi", line_width=5)
+ assert rect in view.roi_controller.rois
+ assert circ in view.roi_controller.rois
+ assert rect.label == "rect_roi"
+ assert circ.label == "circ_roi"
+ assert rect.line_width == 7
+ assert circ.line_width == 5
+ # Change properties
+ rect.label = "rect_roi2"
+ circ.line_color = "#ff0000"
+ assert rect.label == "rect_roi2"
+ assert circ.line_color == "#ff0000"
+ # Remove by name
+ view.remove_roi("rect_roi2")
+ assert rect not in view.roi_controller.rois
+ # Remove by index
+ view.remove_roi(0)
+ assert not view.roi_controller.rois
+
+
+def test_roi_controller_palette_signal(qtbot, mocked_client):
+ view = create_widget(qtbot, Image, client=mocked_client)
+ controller = view.roi_controller
+ changed = []
+ controller.paletteChanged.connect(lambda cmap: changed.append(cmap))
+ view.add_roi(kind="rect")
+ controller.colormap = "plasma"
+ assert changed and changed[0] == "plasma"
+
+
+def test_roi_controller_clear_and_get_methods(qtbot, mocked_client):
+ view = create_widget(qtbot, Image, client=mocked_client)
+ r1 = view.add_roi(kind="rect", name="r1")
+ r2 = view.add_roi(kind="circle", name="c1")
+ controller = view.roi_controller
+ assert controller.get_roi_by_name("r1") == r1
+ assert controller.get_roi(1) == r2
+ controller.clear()
+ assert not controller.rois
+
+
+def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
+ view = create_widget(qtbot, Image, client=mocked_client)
+ roi = view.add_roi(kind="rect")
+ # Remove all images from scene
+ for item in list(view.plot_item.items):
+ if hasattr(item, "image"):
+ view.plot_item.removeItem(item)
+
+ with pytest.raises(RuntimeError):
+ roi.get_data_from_image()