diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 357c28fe..93d72a61 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -1135,6 +1135,15 @@ class BECImageShow(RPCBase): y(bool): Show grid on the y-axis. """ + @rpc_call + def enable_fps_monitor(self, enable: "bool" = True): + """ + Enable the FPS monitor. + + Args: + enable(bool): True to enable, False to disable. + """ + @rpc_call def lock_aspect_ratio(self, lock): """ @@ -1330,6 +1339,15 @@ class BECImageWidget(RPCBase): y_grid(bool): Visibility of the y-axis grid. """ + @rpc_call + def enable_fps_monitor(self, enabled: "bool"): + """ + Enable the FPS monitor of the plot widget. + + Args: + enabled(bool): If True, enable the FPS monitor. + """ + @rpc_call def lock_aspect_ratio(self, lock: "bool"): """ @@ -1666,6 +1684,15 @@ class BECPlotBase(RPCBase): show(bool): Show the outer axes. """ + @rpc_call + def enable_fps_monitor(self, enable: "bool" = True): + """ + Enable the FPS monitor. + + Args: + enable(bool): True to enable, False to disable. + """ + @rpc_call def lock_aspect_ratio(self, lock): """ @@ -2069,6 +2096,15 @@ class BECWaveform(RPCBase): colormap(str, optional): Scale the colors of curves to colormap. If None, use the default color palette. """ + @rpc_call + def enable_fps_monitor(self, enable: "bool" = True): + """ + Enable the FPS monitor. + + Args: + enable(bool): True to enable, False to disable. + """ + @rpc_call def lock_aspect_ratio(self, lock): """ @@ -2374,6 +2410,15 @@ class BECWaveformWidget(RPCBase): y_grid(bool): Visibility of the y-axis grid. """ + @rpc_call + def enable_fps_monitor(self, enabled: "bool"): + """ + Enable the FPS monitor of the plot widget. + + Args: + enabled(bool): If True, enable the FPS monitor. + """ + @rpc_call def lock_aspect_ratio(self, lock: "bool"): """ diff --git a/bec_widgets/utils/fps_counter.py b/bec_widgets/utils/fps_counter.py new file mode 100644 index 00000000..15e40844 --- /dev/null +++ b/bec_widgets/utils/fps_counter.py @@ -0,0 +1,84 @@ +""" +This module provides a utility class for counting and reporting frames per second (FPS) in a PyQtGraph application. + +Classes: + FPSCounter: A class that monitors the paint events of a `ViewBox` to calculate and emit FPS values. + +Usage: + The `FPSCounter` class can be used to monitor the rendering performance of a `ViewBox` in a PyQtGraph application. + It connects to the `ViewBox`'s paint event and calculates the FPS over a specified interval, emitting the FPS value + at regular intervals. + +Example: + from qtpy import QtWidgets, QtCore + import pyqtgraph as pg + from fps_counter import FPSCounter + + app = pg.mkQApp("FPS Counter Example") + win = pg.GraphicsLayoutWidget() + win.show() + + vb = pg.ViewBox() + plot_item = pg.PlotItem(viewBox=vb) + win.addItem(plot_item) + + fps_counter = FPSCounter(vb) + fps_counter.sigFpsUpdate.connect(lambda fps: print(f"FPS: {fps:.2f}")) + + sys.exit(app.exec_()) +""" + +from time import perf_counter + +import pyqtgraph as pg +from qtpy import QtCore + + +class FPSCounter(QtCore.QObject): + """ + A utility class for counting and reporting frames per second (FPS). + + This class connects to a `ViewBox`'s paint event to count the number of + frames rendered and calculates the FPS over a specified interval. It emits + a signal with the FPS value at regular intervals. + + Attributes: + sigFpsUpdate (QtCore.Signal): Signal emitted with the FPS value. + view_box (pg.ViewBox): The `ViewBox` instance to monitor. + """ + + sigFpsUpdate = QtCore.Signal(float) + + def __init__(self, view_box): + super().__init__() + self.view_box = view_box + self.view_box.sigPaint.connect(self.increment_count) + self.count = 0 + self.last_update = perf_counter() + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self.calculate_fps) + self.timer.start(1000) + + def increment_count(self): + """ + Increment the frame count when the `ViewBox` is painted. + """ + self.count += 1 + + def calculate_fps(self): + """ + Calculate the frames per second (FPS) based on the number of frames + """ + now = perf_counter() + elapsed = now - self.last_update + fps = self.count / elapsed if elapsed > 0 else 0.0 + self.last_update = now + self.count = 0 + self.sigFpsUpdate.emit(fps) + + def cleanup(self): + """ + Clean up the FPS counter by stopping the timer and disconnecting the signal. + """ + self.timer.stop() + self.timer.timeout.disconnect(self.calculate_fps) diff --git a/bec_widgets/widgets/figure/plots/image/image.py b/bec_widgets/widgets/figure/plots/image/image.py index ee708ce3..0bf3abeb 100644 --- a/bec_widgets/widgets/figure/plots/image/image.py +++ b/bec_widgets/widgets/figure/plots/image/image.py @@ -57,6 +57,7 @@ class BECImageShow(BECPlotBase): "set_x_lim", "set_y_lim", "set_grid", + "enable_fps_monitor", "lock_aspect_ratio", "export", "remove", diff --git a/bec_widgets/widgets/figure/plots/image/image_item.py b/bec_widgets/widgets/figure/plots/image/image_item.py index 0843c636..ce3d069f 100644 --- a/bec_widgets/widgets/figure/plots/image/image_item.py +++ b/bec_widgets/widgets/figure/plots/image/image_item.py @@ -307,7 +307,7 @@ class BECImageItem(BECConnector, pg.ImageItem): if vrange is not None: self.color_bar.setLevels(low=vrange[0], high=vrange[1]) self.color_bar.setImageItem(self) - self.parent_image.addItem(self.color_bar) # , row=0, col=1) + self.parent_image.addItem(self.color_bar, row=1, col=1) self.config.color_bar = "simple" elif color_bar_style == "full": # Setting histogram @@ -321,7 +321,7 @@ class BECImageItem(BECConnector, pg.ImageItem): ) # Adding histogram to the layout - self.parent_image.addItem(self.color_bar) # , row=0, col=1) + self.parent_image.addItem(self.color_bar, row=1, col=1) # save settings self.config.color_bar = "full" diff --git a/bec_widgets/widgets/figure/plots/plot_base.py b/bec_widgets/widgets/figure/plots/plot_base.py index 49aac682..b226e1a9 100644 --- a/bec_widgets/widgets/figure/plots/plot_base.py +++ b/bec_widgets/widgets/figure/plots/plot_base.py @@ -1,6 +1,5 @@ from __future__ import annotations -from collections import defaultdict from typing import Literal, Optional import bec_qthemes @@ -12,6 +11,7 @@ from qtpy.QtWidgets import QApplication, QWidget from bec_widgets.utils import BECConnector, ConnectionConfig from bec_widgets.utils.crosshair import Crosshair +from bec_widgets.utils.fps_counter import FPSCounter from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem logger = bec_logger.logger @@ -51,6 +51,11 @@ class SubplotConfig(ConnectionConfig): class BECViewBox(pg.ViewBox): + sigPaint = Signal() + + def paint(self, painter, opt, widget): + super().paint(painter, opt, widget) + self.sigPaint.emit() def itemBoundsChanged(self, item): self._itemBoundsCache.pop(item, None) @@ -79,6 +84,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): "set_y_lim", "set_grid", "set_outer_axes", + "enable_fps_monitor", "lock_aspect_ratio", "export", "remove", @@ -100,12 +106,13 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): self.figure = parent_figure - # self.plot_item = self.addPlot(row=0, col=0) self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self) - self.addItem(self.plot_item, row=0, col=0) + self.addItem(self.plot_item, row=1, col=0) self.add_legend() self.crosshair = None + self.fps_monitor = None + self.fps_label = None self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item) self._connect_to_theme_change() @@ -379,6 +386,10 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): """ self.plot_item.enableAutoRange(axis, enabled) + ############################################################ + ###################### Crosshair ########################### + ############################################################ + def hook_crosshair(self) -> None: """Hook the crosshair to all plots.""" if self.crosshair is None: @@ -417,6 +428,54 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): self.crosshair.clear_markers() self.crosshair.update_markers() + ############################################################ + ##################### FPS Counter ########################## + ############################################################ + + def update_fps_label(self, fps: float) -> None: + """ + Update the FPS label. + + Args: + fps(float): The frames per second. + """ + if self.fps_label: + self.fps_label.setText(f"FPS: {fps:.2f}") + + def hook_fps_monitor(self): + """Hook the FPS monitor to the plot.""" + if self.fps_monitor is None: + # text_color = self.get_text_color()#TODO later + self.fps_monitor = FPSCounter(self.plot_item.vb) # text_color=text_color) + self.fps_label = pg.LabelItem(justify="right") + self.addItem(self.fps_label, row=0, col=0) + + self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label) + + def unhook_fps_monitor(self): + """Unhook the FPS monitor from the plot.""" + if self.fps_monitor is not None: + # Remove Monitor + self.fps_monitor.cleanup() + self.fps_monitor.deleteLater() + self.fps_monitor = None + # Remove Label + self.removeItem(self.fps_label) + self.fps_label.deleteLater() + self.fps_label = None + + def enable_fps_monitor(self, enable: bool = True): + """ + Enable the FPS monitor. + + Args: + enable(bool): True to enable, False to disable. + """ + if enable and self.fps_monitor is None: + self.hook_fps_monitor() + elif not enable and self.fps_monitor is not None: + self.unhook_fps_monitor() + def export(self): """Show the Export Dialog of the plot widget.""" scene = self.plot_item.scene() @@ -431,6 +490,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): def cleanup_pyqtgraph(self): """Cleanup pyqtgraph items.""" self.unhook_crosshair() + self.unhook_fps_monitor() self.tick_item.cleanup() self.arrow_item.cleanup() item = self.plot_item diff --git a/bec_widgets/widgets/figure/plots/waveform/waveform.py b/bec_widgets/widgets/figure/plots/waveform/waveform.py index 4510ad72..3a2a5808 100644 --- a/bec_widgets/widgets/figure/plots/waveform/waveform.py +++ b/bec_widgets/widgets/figure/plots/waveform/waveform.py @@ -72,6 +72,7 @@ class BECWaveform(BECPlotBase): "set_y_lim", "set_grid", "set_colormap", + "enable_fps_monitor", "lock_aspect_ratio", "export", "remove", diff --git a/bec_widgets/widgets/image/image_widget.py b/bec_widgets/widgets/image/image_widget.py index 5e49fb9c..1234e111 100644 --- a/bec_widgets/widgets/image/image_widget.py +++ b/bec_widgets/widgets/image/image_widget.py @@ -40,6 +40,7 @@ class BECImageWidget(BECWidget, QWidget): "set_rotation", "set_log", "set_grid", + "enable_fps_monitor", "lock_aspect_ratio", ] @@ -104,6 +105,9 @@ class BECImageWidget(BECWidget, QWidget): icon_name="reset_settings", tooltip="Reset Image Settings" ), "separator_3": SeparatorAction(), + "fps_monitor": MaterialIconAction( + icon_name="speed", tooltip="Show FPS Monitor", checkable=True + ), "axis_settings": MaterialIconAction( icon_name="settings", tooltip="Open Configuration Dialog" ), @@ -150,6 +154,7 @@ class BECImageWidget(BECWidget, QWidget): self.toolbar.widgets["reset"].action.triggered.connect(self.reset_settings) # sepatator self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings) + self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor) ################################### # Dialog Windows @@ -450,6 +455,18 @@ class BECImageWidget(BECWidget, QWidget): self.toolbar.widgets["rectangle_mode"].action.setChecked(False) self._image.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode) + @SafeSlot() + def enable_fps_monitor(self, enabled: bool): + """ + Enable the FPS monitor of the plot widget. + + Args: + enabled(bool): If True, enable the FPS monitor. + """ + self._image.enable_fps_monitor(enabled) + if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled: + self.toolbar.widgets["fps_monitor"].action.setChecked(enabled) + def export(self): """ Show the export dialog for the plot widget. diff --git a/bec_widgets/widgets/waveform/waveform_widget.py b/bec_widgets/widgets/waveform/waveform_widget.py index aaa8fa31..05333278 100644 --- a/bec_widgets/widgets/waveform/waveform_widget.py +++ b/bec_widgets/widgets/waveform/waveform_widget.py @@ -52,6 +52,7 @@ class BECWaveformWidget(BECWidget, QWidget): "set_legend_label_size", "set_auto_range", "set_grid", + "enable_fps_monitor", "lock_aspect_ratio", "export", "export_to_matplotlib", @@ -118,9 +119,7 @@ class BECWaveformWidget(BECWidget, QWidget): "fit_params": MaterialIconAction( icon_name="monitoring", tooltip="Open Fitting Parameters" ), - "axis_settings": MaterialIconAction( - icon_name="settings", tooltip="Open Configuration Dialog" - ), + "separator_3": SeparatorAction(), "crosshair": MaterialIconAction( icon_name="point_scan", tooltip="Show Crosshair", checkable=True ), @@ -129,6 +128,13 @@ class BECWaveformWidget(BECWidget, QWidget): tooltip="Add ROI region for DAP", checkable=True, ), + "separator_4": SeparatorAction(), + "fps_monitor": MaterialIconAction( + icon_name="speed", tooltip="Show FPS Monitor", checkable=True + ), + "axis_settings": MaterialIconAction( + icon_name="settings", tooltip="Open Configuration Dialog" + ), }, target_widget=self, ) @@ -186,6 +192,7 @@ class BECWaveformWidget(BECWidget, QWidget): self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings) self.toolbar.widgets["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair) self.toolbar.widgets["roi_select"].action.toggled.connect(self.waveform.toggle_roi) + self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor) # self.toolbar.widgets["import"].action.triggered.connect( # lambda: self.load_config(path=None, gui=True) # ) @@ -594,6 +601,8 @@ class BECWaveformWidget(BECWidget, QWidget): checked(bool): If True, enable the linear region selector. """ self.waveform.toggle_roi(checked) + if self.toolbar.widgets["roi_select"].action.isChecked() != checked: + self.toolbar.widgets["roi_select"].action.setChecked(checked) def select_roi(self, region: tuple): """ @@ -604,6 +613,17 @@ class BECWaveformWidget(BECWidget, QWidget): """ self.waveform.select_roi(region) + def enable_fps_monitor(self, enabled: bool): + """ + Enable the FPS monitor of the plot widget. + + Args: + enabled(bool): If True, enable the FPS monitor. + """ + self.waveform.enable_fps_monitor(enabled) + if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled: + self.toolbar.widgets["fps_monitor"].action.setChecked(enabled) + @SafeSlot() def _auto_range_from_toolbar(self): """