0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat(image): new Image widget based on new PlotBase

This commit is contained in:
2025-02-13 11:21:23 +01:00
parent 240044d680
commit b06fc5e255
19 changed files with 2599 additions and 17 deletions

View File

@ -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(

View File

@ -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_())

View File

@ -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

View File

@ -33,6 +33,7 @@ class ImageConfig(SubplotConfig):
)
# TODO old version will be deprecated
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"_rpc_id",

View File

@ -41,6 +41,7 @@ class ImageItemConfig(ConnectionConfig):
)
# TODO old version will be deprecated
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"_rpc_id",

View File

@ -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:

View File

@ -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"

View File

@ -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_())

View File

@ -0,0 +1 @@
{'files': ['image.py']}

View File

@ -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

View File

@ -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 = """
<ui language='c++'>
<widget class='Image' name='image'>
</widget>
</ui>
"""
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()

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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