From 1d018e863ca0cdb3274002cf35d69a6961aaf07d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 16 May 2025 15:48:31 +0200 Subject: [PATCH] feat(image_rois): image rois with RPC can be added to Image widget --- bec_widgets/cli/client.py | 335 +++++++ .../jupyter_console/jupyter_console_window.py | 16 +- bec_widgets/widgets/plots/image/image.py | 90 ++ bec_widgets/widgets/plots/roi/__init__.py | 0 bec_widgets/widgets/plots/roi/image_roi.py | 867 ++++++++++++++++++ tests/unit_tests/test_image_rois.py | 192 ++++ tests/unit_tests/test_image_view_next_gen.py | 57 ++ 7 files changed, 1549 insertions(+), 8 deletions(-) create mode 100644 bec_widgets/widgets/plots/roi/__init__.py create mode 100644 bec_widgets/widgets/plots/roi/image_roi.py create mode 100644 tests/unit_tests/test_image_rois.py 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()