From cb39ff3fbde99f4e4bed49dee8a5e5987d257b23 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 13 Feb 2025 11:21:23 +0100 Subject: [PATCH] feat(image): new Image widget based on new PlotBase --- bec_widgets/cli/client.py | 658 ++++++++++++ .../jupyter_console/jupyter_console_window.py | 28 +- bec_widgets/qt_utils/toolbar.py | 30 +- .../containers/figure/plots/image/image.py | 1 + .../figure/plots/image/image_item.py | 1 + .../figure/plots/image/image_processor.py | 2 + .../widgets/plots/image/image_widget.py | 1 + .../widgets/plots_next_gen/image/__init__.py | 0 .../widgets/plots_next_gen/image/image.py | 943 ++++++++++++++++++ .../plots_next_gen/image/image.pyproject | 1 + .../plots_next_gen/image/image_item.py | 260 +++++ .../plots_next_gen/image/image_plugin.py | 54 + .../plots_next_gen/image/image_processor.py | 150 +++ .../plots_next_gen/image/register_image.py | 15 + .../image/toolbar_bundles/__init__.py | 0 .../image/toolbar_bundles/image_selection.py | 57 ++ .../image/toolbar_bundles/processing.py | 79 ++ .../widgets/plots_next_gen/plot_base.py | 5 +- tests/unit_tests/test_image_view_next_gen.py | 331 ++++++ 19 files changed, 2599 insertions(+), 17 deletions(-) create mode 100644 bec_widgets/widgets/plots_next_gen/image/__init__.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/image.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/image.pyproject create mode 100644 bec_widgets/widgets/plots_next_gen/image/image_item.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/image_plugin.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/image_processor.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/register_image.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/__init__.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/image_selection.py create mode 100644 bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/processing.py create mode 100644 tests/unit_tests/test_image_view_next_gen.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index aa632399..bbb547dd 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -18,6 +18,7 @@ class Widgets(str, enum.Enum): AbortButton = "AbortButton" BECColorMapWidget = "BECColorMapWidget" BECDockArea = "BECDockArea" + BECImageView = "BECImageView" BECImageWidget = "BECImageWidget" BECMotorMapWidget = "BECMotorMapWidget" BECMultiWaveformWidget = "BECMultiWaveformWidget" @@ -686,6 +687,148 @@ class BECFigure(RPCBase): """ +class BECImage(RPCBase): + @property + @rpc_call + def color_map(self) -> "str": + """ + Get the current color map. + """ + + @color_map.setter + @rpc_call + def color_map(self) -> "str": + """ + Get the current color map. + """ + + @property + @rpc_call + def v_range(self) -> "tuple[float, float]": + """ + Get the color intensity range of the image. + """ + + @v_range.setter + @rpc_call + def v_range(self) -> "tuple[float, float]": + """ + Get the color intensity range of the image. + """ + + @property + @rpc_call + def v_min(self) -> "float": + """ + None + """ + + @v_min.setter + @rpc_call + def v_min(self) -> "float": + """ + None + """ + + @property + @rpc_call + def v_max(self) -> "float": + """ + None + """ + + @v_max.setter + @rpc_call + def v_max(self) -> "float": + """ + None + """ + + @property + @rpc_call + def autorange(self) -> "bool": + """ + None + """ + + @autorange.setter + @rpc_call + def autorange(self) -> "bool": + """ + None + """ + + @property + @rpc_call + def autorange_mode(self) -> "Literal['max', 'mean']": + """ + None + """ + + @autorange_mode.setter + @rpc_call + def autorange_mode(self) -> "Literal['max', 'mean']": + """ + None + """ + + @property + @rpc_call + def fft(self) -> "bool": + """ + Get or set whether FFT postprocessing is enabled. + """ + + @fft.setter + @rpc_call + def fft(self) -> "bool": + """ + Get or set whether FFT postprocessing is enabled. + """ + + @property + @rpc_call + def log(self) -> "bool": + """ + Get or set whether logarithmic scaling is applied. + """ + + @log.setter + @rpc_call + def log(self) -> "bool": + """ + Get or set whether logarithmic scaling is applied. + """ + + @property + @rpc_call + def rotation(self) -> "Optional[int]": + """ + Get or set the number of 90° rotations to apply. + """ + + @rotation.setter + @rpc_call + def rotation(self) -> "Optional[int]": + """ + Get or set the number of 90° rotations to apply. + """ + + @property + @rpc_call + def transpose(self) -> "bool": + """ + Get or set whether the image is transposed. + """ + + @transpose.setter + @rpc_call + def transpose(self) -> "bool": + """ + Get or set whether the image is transposed. + """ + + class BECImageItem(RPCBase): @property @rpc_call @@ -1195,6 +1338,521 @@ class BECImageShow(RPCBase): """ +class BECImageView(RPCBase): + @property + @rpc_call + def enable_toolbar(self) -> "bool": + """ + Show Toolbar. + """ + + @enable_toolbar.setter + @rpc_call + def enable_toolbar(self) -> "bool": + """ + Show Toolbar. + """ + + @property + @rpc_call + def enable_side_panel(self) -> "bool": + """ + Show Side Panel + """ + + @enable_side_panel.setter + @rpc_call + def enable_side_panel(self) -> "bool": + """ + Show Side Panel + """ + + @property + @rpc_call + def enable_fps_monitor(self) -> "bool": + """ + Enable the FPS monitor. + """ + + @enable_fps_monitor.setter + @rpc_call + def enable_fps_monitor(self) -> "bool": + """ + Enable the FPS monitor. + """ + + @rpc_call + def set(self, **kwargs): + """ + Set the properties of the plot widget. + + Args: + **kwargs: Keyword arguments for the properties to be set. + + Possible properties: + """ + + @property + @rpc_call + def title(self) -> "str": + """ + Set title of the plot. + """ + + @title.setter + @rpc_call + def title(self) -> "str": + """ + Set title of the plot. + """ + + @property + @rpc_call + def x_label(self) -> "str": + """ + The set label for the x-axis. + """ + + @x_label.setter + @rpc_call + def x_label(self) -> "str": + """ + The set label for the x-axis. + """ + + @property + @rpc_call + def y_label(self) -> "str": + """ + The set label for the y-axis. + """ + + @y_label.setter + @rpc_call + def y_label(self) -> "str": + """ + The set label for the y-axis. + """ + + @property + @rpc_call + def x_limits(self) -> "QPointF": + """ + Get the x limits of the plot. + """ + + @x_limits.setter + @rpc_call + def x_limits(self) -> "QPointF": + """ + Get the x limits of the plot. + """ + + @property + @rpc_call + def y_limits(self) -> "QPointF": + """ + Get the y limits of the plot. + """ + + @y_limits.setter + @rpc_call + def y_limits(self) -> "QPointF": + """ + Get the y limits of the plot. + """ + + @property + @rpc_call + def x_grid(self) -> "bool": + """ + Show grid on the x-axis. + """ + + @x_grid.setter + @rpc_call + def x_grid(self) -> "bool": + """ + Show grid on the x-axis. + """ + + @property + @rpc_call + def y_grid(self) -> "bool": + """ + Show grid on the y-axis. + """ + + @y_grid.setter + @rpc_call + def y_grid(self) -> "bool": + """ + Show grid on the y-axis. + """ + + @property + @rpc_call + def inner_axes(self) -> "bool": + """ + Show inner axes of the plot widget. + """ + + @inner_axes.setter + @rpc_call + def inner_axes(self) -> "bool": + """ + Show inner axes of the plot widget. + """ + + @property + @rpc_call + def outer_axes(self) -> "bool": + """ + Show the outer axes of the plot widget. + """ + + @outer_axes.setter + @rpc_call + def outer_axes(self) -> "bool": + """ + Show the outer axes of the plot widget. + """ + + @property + @rpc_call + def lock_aspect_ratio(self) -> "bool": + """ + Whether the aspect ratio is locked. + """ + + @lock_aspect_ratio.setter + @rpc_call + def lock_aspect_ratio(self) -> "bool": + """ + Whether the aspect ratio is locked. + """ + + @property + @rpc_call + def auto_range_x(self) -> "bool": + """ + Set auto range for the x-axis. + """ + + @auto_range_x.setter + @rpc_call + def auto_range_x(self) -> "bool": + """ + Set auto range for the x-axis. + """ + + @property + @rpc_call + def auto_range_y(self) -> "bool": + """ + Set auto range for the y-axis. + """ + + @auto_range_y.setter + @rpc_call + def auto_range_y(self) -> "bool": + """ + Set auto range for the y-axis. + """ + + @property + @rpc_call + def x_log(self) -> "bool": + """ + Set X-axis to log scale if True, linear if False. + """ + + @x_log.setter + @rpc_call + def x_log(self) -> "bool": + """ + Set X-axis to log scale if True, linear if False. + """ + + @property + @rpc_call + def y_log(self) -> "bool": + """ + Set Y-axis to log scale if True, linear if False. + """ + + @y_log.setter + @rpc_call + def y_log(self) -> "bool": + """ + Set Y-axis to log scale if True, linear if False. + """ + + @property + @rpc_call + def legend_label_size(self) -> "int": + """ + The font size of the legend font. + """ + + @legend_label_size.setter + @rpc_call + def legend_label_size(self) -> "int": + """ + The font size of the legend font. + """ + + @property + @rpc_call + def color_map(self) -> "str": + """ + Set the color map of the image. + """ + + @color_map.setter + @rpc_call + def color_map(self) -> "str": + """ + Set the color map of the image. + """ + + @property + @rpc_call + def vrange(self) -> "tuple": + """ + Get the vrange of the image. + """ + + @vrange.setter + @rpc_call + def vrange(self) -> "tuple": + """ + Get the vrange of the image. + """ + + @property + @rpc_call + def v_min(self) -> "float": + """ + Get the minimum value of the v_range. + """ + + @v_min.setter + @rpc_call + def v_min(self) -> "float": + """ + Get the minimum value of the v_range. + """ + + @property + @rpc_call + def v_max(self) -> "float": + """ + Get the maximum value of the v_range. + """ + + @v_max.setter + @rpc_call + def v_max(self) -> "float": + """ + Get the maximum value of the v_range. + """ + + @property + @rpc_call + def lock_aspect_ratio(self) -> "bool": + """ + Whether the aspect ratio is locked. + """ + + @lock_aspect_ratio.setter + @rpc_call + def lock_aspect_ratio(self) -> "bool": + """ + Whether the aspect ratio is locked. + """ + + @property + @rpc_call + def autorange(self) -> "bool": + """ + Whether autorange is enabled. + """ + + @autorange.setter + @rpc_call + def autorange(self) -> "bool": + """ + Whether autorange is enabled. + """ + + @property + @rpc_call + def autorange_mode(self) -> "str": + """ + Autorange mode. + + Options: + - "max": Use the maximum value of the image for autoranging. + - "mean": Use the mean value of the image for autoranging. + """ + + @autorange_mode.setter + @rpc_call + def autorange_mode(self) -> "str": + """ + Autorange mode. + + Options: + - "max": Use the maximum value of the image for autoranging. + - "mean": Use the mean value of the image for autoranging. + """ + + @property + @rpc_call + def monitor(self) -> "str": + """ + The name of the monitor to use for the image. + """ + + @monitor.setter + @rpc_call + def monitor(self) -> "str": + """ + The name of the monitor to use for the image. + """ + + @rpc_call + def enable_colorbar( + self, + enabled: "bool", + style: "Literal['full', 'simple']" = "full", + vrange: "tuple[int, int] | None" = None, + ): + """ + Enable the colorbar and switch types of colorbars. + + Args: + enabled(bool): Whether to enable the colorbar. + style(Literal["full", "simple"]): The type of colorbar to enable. + vrange(tuple): The range of values to use for the colorbar. + """ + + @property + @rpc_call + def enable_simple_colorbar(self) -> "bool": + """ + Enable the simple colorbar. + """ + + @enable_simple_colorbar.setter + @rpc_call + def enable_simple_colorbar(self) -> "bool": + """ + Enable the simple colorbar. + """ + + @property + @rpc_call + def enable_full_colorbar(self) -> "bool": + """ + Enable the full colorbar. + """ + + @enable_full_colorbar.setter + @rpc_call + def enable_full_colorbar(self) -> "bool": + """ + Enable the full colorbar. + """ + + @property + @rpc_call + def fft(self) -> "bool": + """ + Whether FFT postprocessing is enabled. + """ + + @fft.setter + @rpc_call + def fft(self) -> "bool": + """ + Whether FFT postprocessing is enabled. + """ + + @property + @rpc_call + def log(self) -> "bool": + """ + Whether logarithmic scaling is applied. + """ + + @log.setter + @rpc_call + def log(self) -> "bool": + """ + Whether logarithmic scaling is applied. + """ + + @property + @rpc_call + def rotation(self) -> "int": + """ + The number of 90° rotations to apply. + """ + + @rotation.setter + @rpc_call + def rotation(self) -> "int": + """ + The number of 90° rotations to apply. + """ + + @property + @rpc_call + def transpose(self) -> "bool": + """ + Whether the image is transposed. + """ + + @transpose.setter + @rpc_call + def transpose(self) -> "bool": + """ + Whether the image is transposed. + """ + + @rpc_call + def image( + self, + monitor: "str | None" = None, + monitor_type: "Literal['auto', '1d', '2d']" = "auto", + color_map: "str | None" = None, + color_bar: "Literal['simple', 'full'] | None" = None, + vrange: "tuple[int, int] | None" = None, + ) -> "BECImage": + """ + Set the image source and update the image. + + Args: + monitor(str): The name of the monitor to use for the image. + monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + color_map(str): The color map to use for the image. + color_bar(str): The type of color bar to use. Options are "simple" or "full". + vrange(tuple): The range of values to use for the color map. + + Returns: + BECImage: The image object. + """ + + @property + @rpc_call + def main_image(self) -> "BECImage": + """ + Access the main image item. + """ + + class BECImageWidget(RPCBase): @rpc_call def image( diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 2b5dbc67..65d9c0d2 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -15,11 +15,12 @@ from qtpy.QtWidgets import ( ) from bec_widgets.utils import BECDispatcher -from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.widget_io import WidgetHierarchy as wh from bec_widgets.widgets.containers.dock import BECDockArea from bec_widgets.widgets.containers.figure import BECFigure from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole +from bec_widgets.widgets.plots_next_gen.image.image import Image from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform @@ -38,6 +39,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: { "np": np, "pg": pg, + "wh": wh, "fig": self.figure, "dock": self.dock, "w1": self.w1, @@ -53,6 +55,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: "d0": self.d0, "d1": self.d1, "im": self.im, + "im_old": self.im_old, + "it_old": self.it_old, + "mi": self.mi, "mm": self.mm, "mw": self.mw, "lm": self.lm, @@ -123,6 +128,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: fifth_tab_layout.addWidget(self.wf) 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) + # add stuff to the new Waveform widget self._init_waveform() @@ -203,13 +217,10 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: self.mm.change_motors("samx", "samy") self.d1 = self.dock.new(name="dock_1", position="right") - self.im = self.d1.new("BECImageWidget") - self.im.image("waveform", "1d") + self.im_old = self.d1.new("BECImageWidget") + self.im_old.image("waveform", "1d") + self.it_old = self.im_old._image._images["device_monitor_1d"]["waveform"] - self.d2 = self.dock.new(name="dock_2", position="bottom") - self.wf = self.d2.new("BECFigure", row=0, col=0) - - self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config) self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config) self.dock.save_state() @@ -235,7 +246,6 @@ if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) app.setApplicationName("Jupyter Console") app.setApplicationDisplayName("Jupyter Console") - apply_theme("dark") icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True) app.setWindowIcon(icon) @@ -245,7 +255,7 @@ if __name__ == "__main__": # pragma: no cover win = JupyterConsoleWindow() win.show() - win.resize(1200, 800) + win.resize(1500, 800) app.aboutToQuit.connect(win.close) sys.exit(app.exec_()) diff --git a/bec_widgets/qt_utils/toolbar.py b/bec_widgets/qt_utils/toolbar.py index f3b70eed..10be145b 100644 --- a/bec_widgets/qt_utils/toolbar.py +++ b/bec_widgets/qt_utils/toolbar.py @@ -316,13 +316,24 @@ class SwitchableToolBarAction(ToolBarAction): new_action.action.setChecked(True) self.main_button.setChecked(True) - def uncheck_all(self): + def block_all_signals(self, block: bool = True): + """ + Blocks or unblocks all signals for the actions in the toolbar. + + Args: + block (bool): Whether to block signals. Defaults to True. + """ + self.main_button.blockSignals(block) + for action in self.actions.values(): + action.action.blockSignals(block) + + def set_state_all(self, state: bool): """ Uncheck all actions in the toolbar. """ for action in self.actions.values(): - action.action.setChecked(False) - self.main_button.setChecked(False) + action.action.setChecked(state) + self.main_button.setChecked(state) def get_icon(self) -> QIcon: return self.actions[self.current_key].get_icon() @@ -337,11 +348,18 @@ class WidgetAction(ToolBarAction): widget (QWidget): The widget to be added to the toolbar. """ - def __init__(self, label: str | None = None, widget: QWidget = None, parent=None): + def __init__( + self, + label: str | None = None, + widget: QWidget = None, + adjust_size: bool = True, + parent=None, + ): super().__init__(icon_path=None, tooltip=label, checkable=False) self.label = label self.widget = widget self.container = None + self.adjust_size = adjust_size def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): """ @@ -362,7 +380,7 @@ class WidgetAction(ToolBarAction): label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) layout.addWidget(label_widget) - if isinstance(self.widget, QComboBox): + if isinstance(self.widget, QComboBox) and self.adjust_size: self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -863,7 +881,7 @@ class MainWindow(QMainWindow): # pragma: no cover ], ) self.toolbar.add_bundle(main_actions_bundle, target_widget=self) - home_action.action.triggered.connect(self.switchable_action.uncheck_all) + home_action.action.triggered.connect(lambda: self.switchable_action.set_state_all(False)) search_action = MaterialIconAction( icon_name="search", tooltip="Search", checkable=False, parent=self diff --git a/bec_widgets/widgets/containers/figure/plots/image/image.py b/bec_widgets/widgets/containers/figure/plots/image/image.py index c6eaf05a..e879232c 100644 --- a/bec_widgets/widgets/containers/figure/plots/image/image.py +++ b/bec_widgets/widgets/containers/figure/plots/image/image.py @@ -33,6 +33,7 @@ class ImageConfig(SubplotConfig): ) +# TODO old version will be deprecated class BECImageShow(BECPlotBase): USER_ACCESS = [ "_rpc_id", diff --git a/bec_widgets/widgets/containers/figure/plots/image/image_item.py b/bec_widgets/widgets/containers/figure/plots/image/image_item.py index 2b94b31f..49f4c631 100644 --- a/bec_widgets/widgets/containers/figure/plots/image/image_item.py +++ b/bec_widgets/widgets/containers/figure/plots/image/image_item.py @@ -41,6 +41,7 @@ class ImageItemConfig(ConnectionConfig): ) +# TODO old version will be deprecated class BECImageItem(BECConnector, pg.ImageItem): USER_ACCESS = [ "_rpc_id", diff --git a/bec_widgets/widgets/containers/figure/plots/image/image_processor.py b/bec_widgets/widgets/containers/figure/plots/image/image_processor.py index 5ec8bf05..b644ef72 100644 --- a/bec_widgets/widgets/containers/figure/plots/image/image_processor.py +++ b/bec_widgets/widgets/containers/figure/plots/image/image_processor.py @@ -7,6 +7,8 @@ import numpy as np from pydantic import BaseModel, Field from qtpy.QtCore import QObject, Signal, Slot +# TODO will be deleted + @dataclass class ImageStats: diff --git a/bec_widgets/widgets/plots/image/image_widget.py b/bec_widgets/widgets/plots/image/image_widget.py index 9945d811..39f14d3a 100644 --- a/bec_widgets/widgets/plots/image/image_widget.py +++ b/bec_widgets/widgets/plots/image/image_widget.py @@ -25,6 +25,7 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +# TODO will be deprecated class BECImageWidget(BECWidget, QWidget): PLUGIN = True ICON_NAME = "image" diff --git a/bec_widgets/widgets/plots_next_gen/image/__init__.py b/bec_widgets/widgets/plots_next_gen/image/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/plots_next_gen/image/image.py b/bec_widgets/widgets/plots_next_gen/image/image.py new file mode 100644 index 00000000..edebe6a3 --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/image.py @@ -0,0 +1,943 @@ +from __future__ import annotations + +from typing import Literal + +import numpy as np +import pyqtgraph as pg +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from pydantic import Field, ValidationError, field_validator +from qtpy.QtCore import QPointF, Signal +from qtpy.QtWidgets import QWidget + +from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction +from bec_widgets.utils import ConnectionConfig +from bec_widgets.utils.colors import Colors +from bec_widgets.widgets.plots_next_gen.image.image_item import ImageItem +from bec_widgets.widgets.plots_next_gen.image.toolbar_bundles.image_selection import ( + MonitorSelectionToolbarBundle, +) +from bec_widgets.widgets.plots_next_gen.image.toolbar_bundles.processing import ( + ImageProcessingToolbarBundle, +) +from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase + +logger = bec_logger.logger + + +# noinspection PyDataclass +class ImageConfig(ConnectionConfig): + color_map: str = Field( + "magma", description="The colormap of the figure widget.", validate_default=True + ) + color_bar: Literal["full", "simple"] | None = Field( + None, description="The type of the color bar." + ) + lock_aspect_ratio: bool = Field( + False, description="Whether to lock the aspect ratio of the image." + ) + + model_config: dict = {"validate_assignment": True} + _validate_color_map = field_validator("color_map")(Colors.validate_color_map) + + +class Image(PlotBase): + PLUGIN = True + RPC = True + ICON_NAME = "image" + USER_ACCESS = [ + # General PlotBase Settings + "enable_toolbar", + "enable_toolbar.setter", + "enable_side_panel", + "enable_side_panel.setter", + "enable_fps_monitor", + "enable_fps_monitor.setter", + "set", + "title", + "title.setter", + "x_label", + "x_label.setter", + "y_label", + "y_label.setter", + "x_limits", + "x_limits.setter", + "y_limits", + "y_limits.setter", + "x_grid", + "x_grid.setter", + "y_grid", + "y_grid.setter", + "inner_axes", + "inner_axes.setter", + "outer_axes", + "outer_axes.setter", + "auto_range_x", + "auto_range_x.setter", + "auto_range_y", + "auto_range_y.setter", + "x_log", + "x_log.setter", + "y_log", + "y_log.setter", + "legend_label_size", + "legend_label_size.setter", + # ImageView Specific Settings + "color_map", + "color_map.setter", + "vrange", + "vrange.setter", + "v_min", + "v_min.setter", + "v_max", + "v_max.setter", + "lock_aspect_ratio", + "lock_aspect_ratio.setter", + "autorange", + "autorange.setter", + "autorange_mode", + "autorange_mode.setter", + "monitor", + "monitor.setter", + "enable_colorbar", + "enable_simple_colorbar", + "enable_simple_colorbar.setter", + "enable_full_colorbar", + "enable_full_colorbar.setter", + "fft", + "fft.setter", + "log", + "log.setter", + "rotation", + "rotation.setter", + "transpose", + "transpose.setter", + "image", + "main_image", + ] + sync_colorbar_with_autorange = Signal() + + def __init__( + self, + parent: QWidget | None = None, + config: ImageConfig | None = None, + client=None, + gui_id: str | None = None, + popups: bool = True, + **kwargs, + ): + self._main_image = ImageItem(parent_image=self) + self._color_bar = None + if config is None: + config = ImageConfig(widget_class=self.__class__.__name__) + super().__init__( + parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs + ) + + # For PropertyManager identification + self.setObjectName("Image") + + self.plot_item.addItem(self._main_image) + self.scan_id = None + + # Default Color map to magma + self.color_map = "magma" + + ################################################################################ + # Widget Specific GUI interactions + ################################################################################ + def _init_toolbar(self): + + # add to the first position + self.selection_bundle = MonitorSelectionToolbarBundle( + bundle_id="selection", target_widget=self + ) + self.toolbar.add_bundle(self.selection_bundle, self) + + super()._init_toolbar() + + # Image specific changes to PlotBase toolbar + self.toolbar.widgets["reset_legend"].action.setVisible(False) + + # Lock aspect ratio button + self.lock_aspect_ratio_action = MaterialIconAction( + icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self + ) + self.toolbar.add_action_to_bundle( + bundle_id="mouse_interaction", + action_id="lock_aspect_ratio", + action=self.lock_aspect_ratio_action, + target_widget=self, + ) + self.lock_aspect_ratio_action.action.toggled.connect( + lambda checked: self.setProperty("lock_aspect_ratio", checked) + ) + self.lock_aspect_ratio_action.action.setChecked(True) + + self._init_autorange_action() + self._init_colorbar_action() + + # Processing Bundle + self.processing_bundle = ImageProcessingToolbarBundle( + bundle_id="processing", target_widget=self + ) + self.toolbar.add_bundle(self.processing_bundle, target_widget=self) + + def _init_autorange_action(self): + + self.autorange_mean_action = MaterialIconAction( + icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self + ) + self.autorange_max_action = MaterialIconAction( + icon_name="hdr_auto", + tooltip="Enable Auto Range (Max)", + checkable=True, + filled=True, + parent=self, + ) + + self.autorange_switch = SwitchableToolBarAction( + actions={ + "auto_range_mean": self.autorange_mean_action, + "auto_range_max": self.autorange_max_action, + }, + initial_action="auto_range_mean", + tooltip="Enable Auto Range", + checkable=True, + parent=self, + ) + + self.toolbar.add_action_to_bundle( + bundle_id="roi", + action_id="autorange_image", + action=self.autorange_switch, + target_widget=self, + ) + + self.autorange_mean_action.action.toggled.connect( + lambda checked: self.toggle_autorange(checked, mode="mean") + ) + self.autorange_max_action.action.toggled.connect( + lambda checked: self.toggle_autorange(checked, mode="max") + ) + + self.autorange = True + self.autorange_mode = "mean" + + def _init_colorbar_action(self): + self.full_colorbar_action = MaterialIconAction( + icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self + ) + self.simple_colorbar_action = MaterialIconAction( + icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self + ) + + self.colorbar_switch = SwitchableToolBarAction( + actions={ + "full_colorbar": self.full_colorbar_action, + "simple_colorbar": self.simple_colorbar_action, + }, + initial_action="full_colorbar", + tooltip="Enable Full Colorbar", + checkable=True, + parent=self, + ) + + self.toolbar.add_action_to_bundle( + bundle_id="roi", + action_id="switch_colorbar", + action=self.colorbar_switch, + target_widget=self, + ) + + self.simple_colorbar_action.action.toggled.connect( + lambda checked: self.enable_colorbar(checked, style="simple") + ) + self.full_colorbar_action.action.toggled.connect( + lambda checked: self.enable_colorbar(checked, style="full") + ) + + def enable_colorbar( + self, + enabled: bool, + style: Literal["full", "simple"] = "full", + vrange: tuple[int, int] | None = None, + ): + """ + Enable the colorbar and switch types of colorbars. + + Args: + enabled(bool): Whether to enable the colorbar. + style(Literal["full", "simple"]): The type of colorbar to enable. + vrange(tuple): The range of values to use for the colorbar. + """ + autorange_state = self._main_image.autorange + if enabled: + if self._color_bar: + if self.config.color_bar == "full": + self.cleanup_histogram_lut_item(self._color_bar) + self.plot_widget.removeItem(self._color_bar) + self._color_bar = None + + if style == "simple": + self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map) + self._color_bar.setImageItem(self._main_image) + self._color_bar.sigLevelsChangeFinished.connect( + lambda: self.setProperty("autorange", False) + ) + + elif style == "full": + self._color_bar = pg.HistogramLUTItem() + self._color_bar.setImageItem(self._main_image) + self._color_bar.gradient.loadPreset(self.config.color_map) + self._color_bar.sigLevelsChanged.connect( + lambda: self.setProperty("autorange", False) + ) + + self.plot_widget.addItem(self._color_bar, row=0, col=1) + self.config.color_bar = style + else: + if self._color_bar: + self.plot_widget.removeItem(self._color_bar) + self._color_bar = None + self.config.color_bar = None + + self.autorange = autorange_state + self._sync_colorbar_actions() + + if vrange: # should be at the end to disable the autorange if defined + self.v_range = vrange + + ################################################################################ + # Widget Specific Properties + ################################################################################ + + ################################################################################ + # Colorbar toggle + + @SafeProperty(bool) + def enable_simple_colorbar(self) -> bool: + """ + Enable the simple colorbar. + """ + enabled = False + if self.config.color_bar == "simple": + enabled = True + return enabled + + @enable_simple_colorbar.setter + def enable_simple_colorbar(self, value: bool): + """ + Enable the simple colorbar. + + Args: + value(bool): Whether to enable the simple colorbar. + """ + self.enable_colorbar(enabled=value, style="simple") + + @SafeProperty(bool) + def enable_full_colorbar(self) -> bool: + """ + Enable the full colorbar. + """ + enabled = False + if self.config.color_bar == "full": + enabled = True + return enabled + + @enable_full_colorbar.setter + def enable_full_colorbar(self, value: bool): + """ + Enable the full colorbar. + + Args: + value(bool): Whether to enable the full colorbar. + """ + self.enable_colorbar(enabled=value, style="full") + + ################################################################################ + # Appearance + + @SafeProperty(str) + def color_map(self) -> str: + """ + Set the color map of the image. + """ + return self.config.color_map + + @color_map.setter + def color_map(self, value: str): + """ + Set the color map of the image. + + Args: + value(str): The color map to set. + """ + try: + self.config.color_map = value + self._main_image.color_map = value + + if self._color_bar: + if self.config.color_bar == "simple": + self._color_bar.setColorMap(value) + elif self.config.color_bar == "full": + self._color_bar.gradient.loadPreset(value) + except ValidationError: + return + + # v_range is for designer, vrange is for RPC + @SafeProperty("QPointF") + def v_range(self) -> QPointF: + """ + Set the v_range of the main image. + """ + vmin, vmax = self._main_image.v_range + return QPointF(vmin, vmax) + + @v_range.setter + def v_range(self, value: tuple | list | QPointF): + """ + Set the v_range of the main image. + + Args: + value(tuple | list | QPointF): The range of values to set. + """ + if isinstance(value, (tuple, list)): + value = self._tuple_to_qpointf(value) + + vmin, vmax = value.x(), value.y() + + self._main_image.v_range = (vmin, vmax) + + # propagate to colorbar if exists + if self._color_bar: + if self.config.color_bar == "simple": + self._color_bar.setLevels(low=vmin, high=vmax) + elif self.config.color_bar == "full": + self._color_bar.setLevels(min=vmin, max=vmax) + self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax) + + self.autorange_switch.set_state_all(False) + + @property + def vrange(self) -> tuple: + """ + Get the vrange of the image. + """ + return (self.v_range.x(), self.v_range.y()) + + @vrange.setter + def vrange(self, value): + """ + Set the vrange of the image. + + Args: + value(tuple): + """ + self.v_range = value + + @property + def v_min(self) -> float: + """ + Get the minimum value of the v_range. + """ + return self.v_range.x() + + @v_min.setter + def v_min(self, value: float): + """ + Set the minimum value of the v_range. + + Args: + value(float): The minimum value to set. + """ + self.v_range = (value, self.v_range.y()) + + @property + def v_max(self) -> float: + """ + Get the maximum value of the v_range. + """ + return self.v_range.y() + + @v_max.setter + def v_max(self, value: float): + """ + Set the maximum value of the v_range. + + Args: + value(float): The maximum value to set. + """ + self.v_range = (self.v_range.x(), value) + + @SafeProperty(bool) + def lock_aspect_ratio(self) -> bool: + """ + Whether the aspect ratio is locked. + """ + return self.config.lock_aspect_ratio + + @lock_aspect_ratio.setter + def lock_aspect_ratio(self, value: bool): + """ + Set the aspect ratio lock. + + Args: + value(bool): Whether to lock the aspect ratio. + """ + self.config.lock_aspect_ratio = bool(value) + self.plot_item.setAspectLocked(value) + + ################################################################################ + # Data Acquisition + + @SafeProperty(str) + def monitor(self) -> str: + """ + The name of the monitor to use for the image. + """ + return self._main_image.config.monitor + + @monitor.setter + def monitor(self, value: str): + """ + Set the monitor for the image. + + Args: + value(str): The name of the monitor to set. + """ + if self._main_image.config.monitor == value: + return + try: + self.entry_validator.validate_monitor(value) + except ValueError: + return + self.image(monitor=value) + + @property + def main_image(self) -> ImageItem: + """Access the main image item.""" + return self._main_image + + ################################################################################ + # Autorange + Colorbar sync + + @SafeProperty(bool) + def autorange(self) -> bool: + """ + Whether autorange is enabled. + """ + return self._main_image.autorange + + @autorange.setter + def autorange(self, enabled: bool): + """ + Set autorange. + + Args: + enabled(bool): Whether to enable autorange. + """ + self._main_image.autorange = enabled + if enabled and self._main_image.raw_data is not None: + self._main_image.apply_autorange() + self._sync_colorbar_levels() + self._sync_autorange_switch() + + @SafeProperty(str) + def autorange_mode(self) -> str: + """ + Autorange mode. + + Options: + - "max": Use the maximum value of the image for autoranging. + - "mean": Use the mean value of the image for autoranging. + + """ + return self._main_image.autorange_mode + + @autorange_mode.setter + def autorange_mode(self, mode: str): + """ + Set the autorange mode. + + Args: + mode(str): The autorange mode. Options are "max" or "mean". + """ + # for qt Designer + if mode not in ["max", "mean"]: + return + self._main_image.autorange_mode = mode + + self._sync_autorange_switch() + + @SafeSlot(bool, str, bool) + def toggle_autorange(self, enabled: bool, mode: str): + """ + Toggle autorange. + + Args: + enabled(bool): Whether to enable autorange. + mode(str): The autorange mode. Options are "max" or "mean". + """ + if self._main_image is not None: + self._main_image.autorange = enabled + self._main_image.autorange_mode = mode + if enabled: + self._main_image.apply_autorange() + self._sync_colorbar_levels() + + def _sync_autorange_switch(self): + """ + Synchronize the autorange switch with the current autorange state and mode if changed from outside. + """ + self.autorange_switch.block_all_signals(True) + self.autorange_switch.set_default_action(f"auto_range_{self._main_image.autorange_mode}") + self.autorange_switch.set_state_all(self._main_image.autorange) + self.autorange_switch.block_all_signals(False) + + def _sync_colorbar_levels(self): + """Immediately propagate current levels to the active colorbar.""" + vrange = self._main_image.v_range + if self._color_bar: + self._color_bar.blockSignals(True) + self.v_range = vrange + self._color_bar.blockSignals(False) + + def _sync_colorbar_actions(self): + """ + Synchronize the colorbar actions with the current colorbar state. + """ + self.colorbar_switch.block_all_signals(True) + if self._color_bar is not None: + self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar") + self.colorbar_switch.set_state_all(True) + else: + self.colorbar_switch.set_state_all(False) + self.colorbar_switch.block_all_signals(False) + + ################################################################################ + # Post Processing + ################################################################################ + + @SafeProperty(bool) + def fft(self) -> bool: + """ + Whether FFT postprocessing is enabled. + """ + return self._main_image.fft + + @fft.setter + def fft(self, enable: bool): + """ + Set FFT postprocessing. + + Args: + enable(bool): Whether to enable FFT postprocessing. + """ + self._main_image.fft = enable + + @SafeProperty(bool) + def log(self) -> bool: + """ + Whether logarithmic scaling is applied. + """ + return self._main_image.log + + @log.setter + def log(self, enable: bool): + """ + Set logarithmic scaling. + + Args: + enable(bool): Whether to enable logarithmic scaling. + """ + self._main_image.log = enable + + @SafeProperty(int) + def rotation(self) -> int: + """ + The number of 90° rotations to apply. + """ + return self._main_image.rotation + + @rotation.setter + def rotation(self, value: int): + """ + Set the number of 90° rotations to apply. + + Args: + value(int): The number of 90° rotations to apply. + """ + self._main_image.rotation = value + + @SafeProperty(bool) + def transpose(self) -> bool: + """ + Whether the image is transposed. + """ + return self._main_image.transpose + + @transpose.setter + def transpose(self, enable: bool): + """ + Set the image to be transposed. + + Args: + enable(bool): Whether to enable transposing the image. + """ + self._main_image.transpose = enable + + ################################################################################ + # High Level methods for API + ################################################################################ + @SafeSlot(popup_error=True) + def image( + self, + monitor: str | None = None, + monitor_type: Literal["auto", "1d", "2d"] = "auto", + color_map: str | None = None, + color_bar: Literal["simple", "full"] | None = None, + vrange: tuple[int, int] | None = None, + ) -> ImageItem: + """ + Set the image source and update the image. + + Args: + monitor(str): The name of the monitor to use for the image. + monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + color_map(str): The color map to use for the image. + color_bar(str): The type of color bar to use. Options are "simple" or "full". + vrange(tuple): The range of values to use for the color map. + + Returns: + ImageItem: The image object. + """ + + if self._main_image.config.monitor is not None: + self.disconnect_monitor(self._main_image.config.monitor) + self.entry_validator.validate_monitor(monitor) + self._main_image.config.monitor = monitor + + if monitor_type == "1d": + self._main_image.config.source = "device_monitor_1d" + self._main_image.config.monitor_type = "1d" + elif monitor_type == "2d": + self._main_image.config.source = "device_monitor_2d" + self._main_image.config.monitor_type = "2d" + elif monitor_type == "auto": + self._main_image.config.source = "auto" + logger.warning( + f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints." + ) + self._main_image.config.monitor_type = "auto" + + self.set_image_update(monitor=monitor, type=monitor_type) + if color_map is not None: + self._main_image.color_map = color_map + if color_bar is not None: + self.enable_colorbar(True, color_bar) + if vrange is not None: + self.vrange = vrange + + self._sync_device_selection() + + return self._main_image + + def _sync_device_selection(self): + """ + Synchronize the device selection with the current monitor. + """ + if self._main_image.config.monitor is not None: + for combo in ( + self.selection_bundle.device_combo_box, + self.selection_bundle.dim_combo_box, + ): + combo.blockSignals(True) + self.selection_bundle.device_combo_box.set_device(self._main_image.config.monitor) + self.selection_bundle.dim_combo_box.setCurrentText(self._main_image.config.monitor_type) + for combo in ( + self.selection_bundle.device_combo_box, + self.selection_bundle.dim_combo_box, + ): + combo.blockSignals(False) + + ################################################################################ + # Image Update Methods + ################################################################################ + + ######################################## + # Connections + + def set_image_update(self, monitor: str, type: Literal["1d", "2d", "auto"]): + """ + Set the image update method for the given monitor. + + Args: + monitor(str): The name of the monitor to use for the image. + type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + """ + + # TODO consider moving connecting and disconnecting logic to Image itself if multiple images + if type == "1d": + self.bec_dispatcher.connect_slot( + self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) + ) + elif type == "2d": + self.bec_dispatcher.connect_slot( + self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) + ) + elif type == "auto": + self.bec_dispatcher.connect_slot( + self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) + ) + self.bec_dispatcher.connect_slot( + self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) + ) + print(f"Connected to {monitor} with type {type}") + self._main_image.config.monitor = monitor + + def disconnect_monitor(self, monitor: str): + """ + Disconnect the monitor from the image update signals, both 1D and 2D. + + Args: + monitor(str): The name of the monitor to disconnect. + """ + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) + ) + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) + ) + self._main_image.config.monitor = None + + ######################################## + # 1D updates + + @SafeSlot(dict, dict) + def on_image_update_1d(self, msg: dict, metadata: dict): + """ + Update the image with 1D data. + + Args: + msg(dict): The message containing the data. + metadata(dict): The metadata associated with the message. + """ + data = msg["data"] + current_scan_id = metadata.get("scan_id", None) + + if current_scan_id is None: + return + if current_scan_id != self.scan_id: + self.scan_id = current_scan_id + self._main_image.clear() + self._main_image.buffer = [] + self._main_image.max_len = 0 + image_buffer = self.adjust_image_buffer(self._main_image, data) + if self._color_bar is not None: + self._color_bar.blockSignals(True) + self._main_image.set_data(image_buffer) + if self._color_bar is not None: + self._color_bar.blockSignals(False) + + def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray: + """ + Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length. + + Args: + image: The image object (used to store a buffer list and max_len). + new_data (np.ndarray): The new incoming 1D waveform data. + + Returns: + np.ndarray: The updated image buffer with adjusted shapes. + """ + new_len = new_data.shape[0] + if not hasattr(image, "buffer"): + image.buffer = [] + image.max_len = 0 + + if new_len > image.max_len: + image.max_len = new_len + for i in range(len(image.buffer)): + wf = image.buffer[i] + pad_width = image.max_len - wf.shape[0] + if pad_width > 0: + image.buffer[i] = np.pad(wf, (0, pad_width), mode="constant", constant_values=0) + image.buffer.append(new_data) + else: + pad_width = image.max_len - new_len + if pad_width > 0: + new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0) + image.buffer.append(new_data) + + image_buffer = np.array(image.buffer) + return image_buffer + + ######################################## + # 2D updates + + def on_image_update_2d(self, msg: dict, metadata: dict): + """ + Update the image with 2D data. + + Args: + msg(dict): The message containing the data. + metadata(dict): The metadata associated with the message. + """ + data = msg["data"] + if self._color_bar is not None: + self._color_bar.blockSignals(True) + self._main_image.set_data(data) + if self._color_bar is not None: + self._color_bar.blockSignals(False) + + ################################################################################ + # Clean up + ################################################################################ + + @staticmethod + def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem): + """ + Clean up HistogramLUTItem safely, including open ViewBox menus and child widgets. + + Args: + histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up. + """ + histogram_lut_item.vb.menu.close() + histogram_lut_item.vb.menu.deleteLater() + + histogram_lut_item.gradient.menu.close() + histogram_lut_item.gradient.menu.deleteLater() + histogram_lut_item.gradient.colorDialog.close() + histogram_lut_item.gradient.colorDialog.deleteLater() + + def cleanup(self): + """ + Disconnect the image update signals and clean up the image. + """ + if self._main_image.config.monitor is not None: + self.disconnect_monitor(self._main_image.config.monitor) + self._main_image.config.monitor = None + + if self._color_bar: + if self.config.color_bar == "full": + self.cleanup_histogram_lut_item(self._color_bar) + if self.config.color_bar == "simple": + self.plot_widget.removeItem(self._color_bar) + self._color_bar.deleteLater() + self._color_bar = None + + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = Image(popups=True) + widget.show() + widget.resize(1000, 800) + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/plots_next_gen/image/image.pyproject b/bec_widgets/widgets/plots_next_gen/image/image.pyproject new file mode 100644 index 00000000..9624907e --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/image.pyproject @@ -0,0 +1 @@ +{'files': ['image.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/plots_next_gen/image/image_item.py b/bec_widgets/widgets/plots_next_gen/image/image_item.py new file mode 100644 index 00000000..18473a1f --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/image_item.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from typing import Literal, Optional + +import numpy as np +import pyqtgraph as pg +from bec_lib.logger import bec_logger +from pydantic import Field, ValidationError, field_validator +from qtpy.QtCore import Signal + +from bec_widgets.utils import BECConnector, Colors, ConnectionConfig +from bec_widgets.widgets.plots_next_gen.image.image_processor import ( + ImageProcessor, + ImageStats, + ProcessingConfig, +) + +logger = bec_logger.logger + + +# noinspection PyDataclass +class ImageItemConfig(ConnectionConfig): # TODO review config + parent_id: str | None = Field(None, description="The parent plot of the image.") + monitor: str | None = Field(None, description="The name of the monitor.") + monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.") + source: str | None = Field(None, description="The source of the curve.") + color_map: str | None = Field("magma", description="The color map of the image.") + downsample: bool | None = Field(True, description="Whether to downsample the image.") + opacity: float | None = Field(1.0, description="The opacity of the image.") + v_range: tuple[float | int, float | int] | None = Field( + None, description="The range of the color bar. If None, the range is automatically set." + ) + autorange: bool | None = Field(True, description="Whether to autorange the color bar.") + autorange_mode: Literal["max", "mean"] = Field( + "mean", description="Whether to use the mean of the image for autoscaling." + ) + processing: ProcessingConfig = Field( + default_factory=ProcessingConfig, description="The post processing of the image." + ) + + model_config: dict = {"validate_assignment": True} + _validate_color_map = field_validator("color_map")(Colors.validate_color_map) + + +class ImageItem(BECConnector, pg.ImageItem): + RPC = True + USER_ACCESS = [ + "color_map", + "color_map.setter", + "v_range", + "v_range.setter", + "v_min", + "v_min.setter", + "v_max", + "v_max.setter", + "autorange", + "autorange.setter", + "autorange_mode", + "autorange_mode.setter", + "fft", + "fft.setter", + "log", + "log.setter", + "rotation", + "rotation.setter", + "transpose", + "transpose.setter", + ] + + vRangeChangedManually = Signal(tuple) + + def __init__( + self, + config: Optional[ImageItemConfig] = None, + gui_id: Optional[str] = None, + parent_image=None, + **kwargs, + ): + if config is None: + config = ImageItemConfig(widget_class=self.__class__.__name__) + self.config = config + else: + self.config = config + super().__init__(config=config, gui_id=gui_id) + pg.ImageItem.__init__(self) + + self.parent_image = parent_image + + self.raw_data = None + self.buffer = [] + self.max_len = 0 + + # Image processor will handle any setting of data + self._image_processor = ImageProcessor(config=self.config.processing) + + def set_data(self, data: np.ndarray): + self.raw_data = data + self._process_image() + + ################################################################################ + # Properties + @property + def color_map(self) -> str: + """Get the current color map.""" + return self.config.color_map + + @color_map.setter + def color_map(self, value: str): + """Set a new color map.""" + try: + self.config.color_map = value + self.setColorMap(value) + except ValidationError: + logger.error(f"Invalid colormap '{value}' provided.") + + @property + def v_range(self) -> tuple[float, float]: + """ + Get the color intensity range of the image. + """ + if self.levels is not None: + return tuple(float(x) for x in self.levels) + return 0.0, 1.0 + + @v_range.setter + def v_range(self, vrange: tuple[float, float]): + """ + Set the color intensity range of the image. + """ + self.set_v_range(vrange, disable_autorange=True) + + def set_v_range(self, vrange: tuple[float, float], disable_autorange=True): + if disable_autorange: + self.config.autorange = False + self.vRangeChangedManually.emit(vrange) + self.setLevels(vrange) + self.config.v_range = vrange + + @property + def v_min(self) -> float: + return self.v_range[0] + + @v_min.setter + def v_min(self, value: float): + self.v_range = (value, self.v_range[1]) + + @property + def v_max(self) -> float: + return self.v_range[1] + + @v_max.setter + def v_max(self, value: float): + self.v_range = (self.v_range[0], value) + + ################################################################################ + # Autorange Logic + + @property + def autorange(self) -> bool: + return self.config.autorange + + @autorange.setter + def autorange(self, value: bool): + self.config.autorange = value + if value: + self.apply_autorange() + + @property + def autorange_mode(self) -> Literal["max", "mean"]: + return self.config.autorange_mode + + @autorange_mode.setter + def autorange_mode(self, mode: Literal["max", "mean"]): + self.config.autorange_mode = mode + if self.autorange: + self.apply_autorange() + + def apply_autorange(self): + if self.raw_data is None: + return + data = self.image + if data is None: + data = self.raw_data + stats = ImageStats.from_data(data) + self.auto_update_vrange(stats) + + def auto_update_vrange(self, stats: ImageStats) -> None: + """Update the v_range based on the stats of the image.""" + fumble_factor = 2 + if self.config.autorange_mode == "mean": + vmin = max(stats.mean - fumble_factor * stats.std, 0) + vmax = stats.mean + fumble_factor * stats.std + elif self.config.autorange_mode == "max": + vmin, vmax = stats.minimum, stats.maximum + else: + return + self.set_v_range(vrange=(vmin, vmax), disable_autorange=False) + + ################################################################################ + # Data Processing Logic + + def _process_image(self): + """ + Reprocess the current raw data and update the image display. + """ + if self.raw_data is not None: + autorange = self.config.autorange + self._image_processor.set_config(self.config.processing) + processed_data = self._image_processor.process_image(self.raw_data) + self.setImage(processed_data, autoLevels=False) + self.autorange = autorange + + @property + def fft(self) -> bool: + """Get or set whether FFT postprocessing is enabled.""" + return self.config.processing.fft + + @fft.setter + def fft(self, enable: bool): + self.config.processing.fft = enable + self._process_image() + + @property + def log(self) -> bool: + """Get or set whether logarithmic scaling is applied.""" + return self.config.processing.log + + @log.setter + def log(self, enable: bool): + self.config.processing.log = enable + self._process_image() + + @property + def rotation(self) -> Optional[int]: + """Get or set the number of 90° rotations to apply.""" + return self.config.processing.rotation + + @rotation.setter + def rotation(self, value: Optional[int]): + self.config.processing.rotation = value + self._process_image() + + @property + def transpose(self) -> bool: + """Get or set whether the image is transposed.""" + return self.config.processing.transpose + + @transpose.setter + def transpose(self, enable: bool): + self.config.processing.transpose = enable + self._process_image() + + ################################################################################ + # Data Update Logic + + def clear(self): + super().clear() + self.raw_data = None + self.buffer = [] + self.max_len = 0 diff --git a/bec_widgets/widgets/plots_next_gen/image/image_plugin.py b/bec_widgets/widgets/plots_next_gen/image/image_plugin.py new file mode 100644 index 00000000..e15a9458 --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/image_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.plots_next_gen.image.image import Image + +DOM_XML = """ + + + + +""" + + +class ImagePlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = Image(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "Plot Widgets Next Gen" + + def icon(self): + return designer_material_icon(Image.ICON_NAME) + + def includeFile(self): + return "image" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "Image" + + def toolTip(self): + return "Image" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/plots_next_gen/image/image_processor.py b/bec_widgets/widgets/plots_next_gen/image/image_processor.py new file mode 100644 index 00000000..77360fec --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/image_processor.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from pydantic import BaseModel, Field +from qtpy.QtCore import QObject, Signal + + +@dataclass +class ImageStats: + """Container to store stats of an image.""" + + maximum: float + minimum: float + mean: float + std: float + + @classmethod + def from_data(cls, data: np.ndarray) -> ImageStats: + """ + Get the statistics of the image data. + + Args: + data(np.ndarray): The image data. + + Returns: + ImageStats: The statistics of the image data. + """ + return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data)) + + +# noinspection PyDataclass +class ProcessingConfig(BaseModel): + fft: bool = Field(False, description="Whether to perform FFT on the monitor data.") + log: bool = Field(False, description="Whether to perform log on the monitor data.") + transpose: bool = Field( + False, description="Whether to transpose the monitor data before displaying." + ) + rotation: int = Field( + 0, description="The rotation angle of the monitor data before displaying." + ) + stats: ImageStats = Field( + ImageStats(maximum=0, minimum=0, mean=0, std=0), + description="The statistics of the image data.", + ) + + model_config: dict = {"validate_assignment": True} + + +class ImageProcessor(QObject): + """ + Class for processing the image data. + """ + + image_processed = Signal(np.ndarray) + + def __init__(self, parent=None, config: ProcessingConfig = None): + super().__init__(parent=parent) + if config is None: + config = ProcessingConfig() + self.config = config + self._current_thread = None + + def set_config(self, config: ProcessingConfig): + """ + Set the configuration of the processor. + + Args: + config(ProcessingConfig): The configuration of the processor. + """ + self.config = config + + def FFT(self, data: np.ndarray) -> np.ndarray: + """ + Perform FFT on the data. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + return np.abs(np.fft.fftshift(np.fft.fft2(data))) + + def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray: + """ + Rotate the data by 90 degrees n times. + + Args: + data(np.ndarray): The data to be processed. + rotate_90(int): The number of 90 degree rotations. + + Returns: + np.ndarray: The processed data. + """ + return np.rot90(data, k=rotate_90, axes=(0, 1)) + + def transpose(self, data: np.ndarray) -> np.ndarray: + """ + Transpose the data. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + return np.transpose(data) + + def log(self, data: np.ndarray) -> np.ndarray: + """ + Perform log on the data. + + Args: + data(np.ndarray): The data to be processed. + + Returns: + np.ndarray: The processed data. + """ + # TODO this is not final solution -> data should stay as int16 + data = data.astype(np.float32) + offset = 1e-6 + data_offset = data + offset + return np.log10(data_offset) + + def update_image_stats(self, data: np.ndarray) -> None: + """Get the statistics of the image data. + + Args: + data(np.ndarray): The image data. + + """ + self.config.stats.maximum = np.max(data) + self.config.stats.minimum = np.min(data) + self.config.stats.mean = np.mean(data) + self.config.stats.std = np.std(data) + + def process_image(self, data: np.ndarray) -> np.ndarray: + """Core processing logic without threading overhead.""" + if self.config.fft: + data = self.FFT(data) + if self.config.rotation is not None: + data = self.rotation(data, self.config.rotation) + if self.config.transpose: + data = self.transpose(data) + if self.config.log: + data = self.log(data) + self.update_image_stats(data) + return data diff --git a/bec_widgets/widgets/plots_next_gen/image/register_image.py b/bec_widgets/widgets/plots_next_gen/image/register_image.py new file mode 100644 index 00000000..a155fb50 --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/register_image.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.plots_next_gen.image.image_plugin import ImagePlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(ImagePlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/image_selection.py b/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/image_selection.py new file mode 100644 index 00000000..00e2f58e --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/image_selection.py @@ -0,0 +1,57 @@ +from bec_lib.device import ReadoutPriority +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QComboBox, QStyledItemDelegate + +from bec_widgets.qt_utils.error_popups import SafeSlot +from bec_widgets.qt_utils.toolbar import ToolbarBundle, WidgetAction +from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox + + +class NoCheckDelegate(QStyledItemDelegate): + """To reduce space in combo boxes by removing the checkmark.""" + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + # Remove any check indicator + option.checkState = Qt.Unchecked + + +class MonitorSelectionToolbarBundle(ToolbarBundle): + """ + A bundle of actions for a toolbar that controls monitor selection on a plot. + """ + + def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs): + super().__init__(bundle_id=bundle_id, actions=[], **kwargs) + self.target_widget = target_widget + + # 1) Device combo box + self.device_combo_box = DeviceComboBox( + device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=[ReadoutPriority.ASYNC] + ) + self.device_combo_box.addItem("", None) + self.device_combo_box.setCurrentText("") + self.device_combo_box.setToolTip("Select Device") + self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) + + self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True)) + + # 2) Dimension combo box + self.dim_combo_box = QComboBox() + self.dim_combo_box.addItems(["auto", "1d", "2d"]) + self.dim_combo_box.setCurrentText("auto") + self.dim_combo_box.setToolTip("Monitor Dimension") + self.dim_combo_box.setFixedWidth(60) + self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) + + self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True)) + + # Connect slots, a device will be connected upon change of any combobox + self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor()) + self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor()) + + @SafeSlot() + def connect_monitor(self): + dim = self.dim_combo_box.currentText() + self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) diff --git a/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/processing.py b/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/processing.py new file mode 100644 index 00000000..84ea549c --- /dev/null +++ b/bec_widgets/widgets/plots_next_gen/image/toolbar_bundles/processing.py @@ -0,0 +1,79 @@ +from bec_widgets.qt_utils.error_popups import SafeSlot +from bec_widgets.qt_utils.toolbar import MaterialIconAction, ToolbarBundle + + +class ImageProcessingToolbarBundle(ToolbarBundle): + """ + A bundle of actions for a toolbar that controls processing of monitor. + """ + + def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs): + super().__init__(bundle_id=bundle_id, actions=[], **kwargs) + self.target_widget = target_widget + + self.fft = MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True) + self.log = MaterialIconAction(icon_name="log_scale", tooltip="Toggle Log", checkable=True) + self.transpose = MaterialIconAction( + icon_name="transform", tooltip="Transpose Image", checkable=True + ) + self.right = MaterialIconAction( + icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg" + ) + self.left = MaterialIconAction( + icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg" + ) + self.reset = MaterialIconAction(icon_name="reset_settings", tooltip="Reset Image Settings") + + self.add_action("fft", self.fft) + self.add_action("log", self.log) + self.add_action("transpose", self.transpose) + self.add_action("rotate_right", self.right) + self.add_action("rotate_left", self.left) + self.add_action("reset", self.reset) + + self.fft.action.triggered.connect(self.toggle_fft) + self.log.action.triggered.connect(self.toggle_log) + self.transpose.action.triggered.connect(self.toggle_transpose) + self.right.action.triggered.connect(self.rotate_right) + self.left.action.triggered.connect(self.rotate_left) + self.reset.action.triggered.connect(self.reset_settings) + + @SafeSlot() + def toggle_fft(self): + checked = self.fft.action.isChecked() + self.target_widget.fft = checked + + @SafeSlot() + def toggle_log(self): + checked = self.log.action.isChecked() + self.target_widget.log = checked + + @SafeSlot() + def toggle_transpose(self): + checked = self.transpose.action.isChecked() + self.target_widget.transpose = checked + + @SafeSlot() + def rotate_right(self): + if self.target_widget.rotation is None: + return + rotation = (self.target_widget.rotation - 1) % 4 + self.target_widget.rotation = rotation + + @SafeSlot() + def rotate_left(self): + if self.target_widget.rotation is None: + return + rotation = (self.target_widget.rotation + 1) % 4 + self.target_widget.rotation = rotation + + @SafeSlot() + def reset_settings(self): + self.target_widget.fft = False + self.target_widget.log = False + self.target_widget.transpose = False + self.target_widget.rotation = 0 + + self.fft.action.setChecked(False) + self.log.action.setChecked(False) + self.transpose.action.setChecked(False) diff --git a/bec_widgets/widgets/plots_next_gen/plot_base.py b/bec_widgets/widgets/plots_next_gen/plot_base.py index b91a1c36..68e8901d 100644 --- a/bec_widgets/widgets/plots_next_gen/plot_base.py +++ b/bec_widgets/widgets/plots_next_gen/plot_base.py @@ -929,9 +929,10 @@ class PlotBase(BECWidget, QWidget): self.cleanup_pyqtgraph() super().cleanup() - def cleanup_pyqtgraph(self): + def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None): """Cleanup pyqtgraph items.""" - item = self.plot_item + if item is None: + item = self.plot_item item.vb.menu.close() item.vb.menu.deleteLater() item.ctrlMenu.close() diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py new file mode 100644 index 00000000..08f5ddb3 --- /dev/null +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -0,0 +1,331 @@ +import numpy as np +import pyqtgraph as pg +import pytest + +from bec_widgets.widgets.plots_next_gen.image.image import Image +from tests.unit_tests.client_mocks import mocked_client +from tests.unit_tests.conftest import create_widget + +################################################## +# Image widget base functionality tests +################################################## + + +def test_initialization_defaults(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + assert bec_image_view.color_map == "magma" + assert bec_image_view.autorange is True + assert bec_image_view.autorange_mode == "mean" + assert bec_image_view.config.lock_aspect_ratio is True + assert bec_image_view.main_image is not None + assert bec_image_view._color_bar is None + + +def test_setting_color_map(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.color_map = "viridis" + assert bec_image_view.color_map == "viridis" + assert bec_image_view.config.color_map == "viridis" + + +def test_invalid_color_map_handling(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + previous_colormap = bec_image_view.color_map + bec_image_view.color_map = "invalid_colormap_name" + assert bec_image_view.color_map == previous_colormap + assert bec_image_view.main_image.color_map == previous_colormap + + +def test_toggle_autorange(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.autorange = False + assert bec_image_view.autorange is False + + bec_image_view.toggle_autorange(True, "max") + assert bec_image_view.autorange is True + assert bec_image_view.autorange_mode == "max" + + assert bec_image_view.main_image.autorange is True + assert bec_image_view.main_image.autorange_mode == "max" + assert bec_image_view.main_image.config.autorange is True + assert bec_image_view.main_image.config.autorange_mode == "max" + + +def test_lock_aspect_ratio(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.lock_aspect_ratio = True + assert bec_image_view.lock_aspect_ratio is True + assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is True + assert bec_image_view.config.lock_aspect_ratio is True + + +def test_set_vrange(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.vrange = (10, 100) + assert bec_image_view.vrange == (10, 100) + assert bec_image_view.main_image.levels == (10, 100) + assert bec_image_view.main_image.config.v_range == (10, 100) + + +def test_enable_simple_colorbar(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.enable_simple_colorbar = True + assert bec_image_view.enable_simple_colorbar is True + assert bec_image_view.config.color_bar == "simple" + assert isinstance(bec_image_view._color_bar, pg.ColorBarItem) + + # Enabling color bar should not cancel autorange + assert bec_image_view.autorange is True + assert bec_image_view.autorange_mode == "mean" + assert bec_image_view.main_image.autorange is True + assert bec_image_view.main_image.autorange_mode == "mean" + + +def test_enable_full_colorbar(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.enable_full_colorbar = True + assert bec_image_view.enable_full_colorbar is True + assert bec_image_view.config.color_bar == "full" + assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem) + + # Enabling color bar should not cancel autorange + assert bec_image_view.autorange is True + assert bec_image_view.autorange_mode == "mean" + assert bec_image_view.main_image.autorange is True + assert bec_image_view.main_image.autorange_mode == "mean" + + +@pytest.mark.parametrize("colorbar_type", ["simple", "full"]) +def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.enable_colorbar(True, colorbar_type, (0, 100)) + + if colorbar_type == "simple": + assert isinstance(bec_image_view._color_bar, pg.ColorBarItem) + assert bec_image_view.enable_simple_colorbar is True + else: + assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem) + assert bec_image_view.enable_full_colorbar is True + assert bec_image_view.config.color_bar == colorbar_type + assert bec_image_view.vrange == (0, 100) + assert bec_image_view.main_image.levels == (0, 100) + assert bec_image_view._color_bar is not None + + +def test_image_setup_image_2d(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.image(monitor="eiger", monitor_type="2d") + assert bec_image_view.monitor == "eiger" + assert bec_image_view.main_image.config.source == "device_monitor_2d" + assert bec_image_view.main_image.config.monitor_type == "2d" + assert bec_image_view.main_image.raw_data is None + assert bec_image_view.main_image.image is None + + +def test_image_setup_image_1d(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.image(monitor="eiger", monitor_type="1d") + assert bec_image_view.monitor == "eiger" + assert bec_image_view.main_image.config.source == "device_monitor_1d" + assert bec_image_view.main_image.config.monitor_type == "1d" + assert bec_image_view.main_image.raw_data is None + assert bec_image_view.main_image.image is None + + +def test_image_setup_image_auto(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.image(monitor="eiger", monitor_type="auto") + assert bec_image_view.monitor == "eiger" + assert bec_image_view.main_image.config.source == "auto" + assert bec_image_view.main_image.config.monitor_type == "auto" + assert bec_image_view.main_image.raw_data is None + assert bec_image_view.main_image.image is None + + +def test_image_data_update_2d(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + test_data = np.random.rand(20, 30) + message = {"data": test_data} + metadata = {} + + bec_image_view.on_image_update_2d(message, metadata) + + np.testing.assert_array_equal(bec_image_view._main_image.image, test_data) + + +def test_image_data_update_1d(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + waveform1 = np.random.rand(50) + waveform2 = np.random.rand(60) # Different length, tests padding logic + metadata = {"scan_id": "scan_test"} + + bec_image_view.on_image_update_1d({"data": waveform1}, metadata) + assert bec_image_view._main_image.raw_data.shape == (1, 50) + + bec_image_view.on_image_update_1d({"data": waveform2}, metadata) + assert bec_image_view._main_image.raw_data.shape == (2, 60) + + +def test_toolbar_actions_presence(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + assert "autorange_image" in bec_image_view.toolbar.bundles["roi"] + assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"] + assert "processing" in bec_image_view.toolbar.bundles + assert "selection" in bec_image_view.toolbar.bundles + + +def test_image_processing_fft_toggle(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.fft = True + assert bec_image_view.fft is True + bec_image_view.fft = False + assert bec_image_view.fft is False + + +def test_image_processing_log_toggle(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.log = True + assert bec_image_view.log is True + bec_image_view.log = False + assert bec_image_view.log is False + + +def test_image_rotation_and_transpose(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.rotation = 2 + assert bec_image_view.rotation == 2 + + bec_image_view.transpose = True + assert bec_image_view.transpose is True + + +@pytest.mark.parametrize("colorbar_type", ["none", "simple", "full"]) +def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + if colorbar_type == "simple": + bec_image_view.enable_simple_colorbar = True + elif colorbar_type == "full": + bec_image_view.enable_full_colorbar = True + + bec_image_view.vrange = (0, 100) + assert bec_image_view.vrange == (0, 100) + assert bec_image_view.main_image.levels == (0, 100) + assert bec_image_view.main_image.config.v_range == (0, 100) + assert bec_image_view.v_min == 0 + assert bec_image_view.v_max == 100 + + if colorbar_type == "simple": + assert isinstance(bec_image_view._color_bar, pg.ColorBarItem) + assert bec_image_view._color_bar.levels() == (0, 100) + elif colorbar_type == "full": + assert isinstance(bec_image_view._color_bar, pg.HistogramLUTItem) + assert bec_image_view._color_bar.getLevels() == (0, 100) + + +################################### +# Toolbar Actions +################################### + + +def test_setup_image_from_toolbar(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + bec_image_view.selection_bundle.device_combo_box.setCurrentText("eiger") + bec_image_view.selection_bundle.dim_combo_box.setCurrentText("2d") + + assert bec_image_view.monitor == "eiger" + assert bec_image_view.main_image.config.source == "device_monitor_2d" + assert bec_image_view.main_image.config.monitor_type == "2d" + assert bec_image_view.main_image.raw_data is None + assert bec_image_view.main_image.image is None + + +def test_image_actions_interactions(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.autorange = False # Change the initial state to False + + bec_image_view.autorange_mean_action.action.trigger() + assert bec_image_view.autorange is True + assert bec_image_view.main_image.autorange is True + assert bec_image_view.autorange_mode == "mean" + + bec_image_view.autorange_max_action.action.trigger() + assert bec_image_view.autorange is True + assert bec_image_view.main_image.autorange is True + assert bec_image_view.autorange_mode == "max" + + bec_image_view.toolbar.widgets["lock_aspect_ratio"].action.trigger() + assert bec_image_view.lock_aspect_ratio is False + assert bool(bec_image_view.plot_item.getViewBox().state["aspectLocked"]) is False + + +def test_image_toggle_action_fft(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + bec_image_view.processing_bundle.fft.action.trigger() + + assert bec_image_view.fft is True + assert bec_image_view.main_image.fft is True + assert bec_image_view.main_image.config.processing.fft is True + + +def test_image_toggle_action_log(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + bec_image_view.processing_bundle.log.action.trigger() + + assert bec_image_view.log is True + assert bec_image_view.main_image.log is True + assert bec_image_view.main_image.config.processing.log is True + + +def test_image_toggle_action_transpose(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + bec_image_view.processing_bundle.transpose.action.trigger() + + assert bec_image_view.transpose is True + assert bec_image_view.main_image.transpose is True + assert bec_image_view.main_image.config.processing.transpose is True + + +def test_image_toggle_action_rotate_right(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + bec_image_view.processing_bundle.right.action.trigger() + + assert bec_image_view.rotation == 3 + assert bec_image_view.main_image.rotation == 3 + assert bec_image_view.main_image.config.processing.rotation == 3 + + +def test_image_toggle_action_rotate_left(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + bec_image_view.processing_bundle.left.action.trigger() + + assert bec_image_view.rotation == 1 + assert bec_image_view.main_image.rotation == 1 + assert bec_image_view.main_image.config.processing.rotation == 1 + + +def test_image_toggle_action_reset(qtbot, mocked_client): + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + + # Setup some processing + bec_image_view.fft = True + bec_image_view.log = True + bec_image_view.transpose = True + bec_image_view.rotation = 2 + + bec_image_view.processing_bundle.reset.action.trigger() + + assert bec_image_view.rotation == 0 + assert bec_image_view.main_image.rotation == 0 + assert bec_image_view.main_image.config.processing.rotation == 0 + assert bec_image_view.fft is False + assert bec_image_view.main_image.fft is False + assert bec_image_view.log is False + 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