diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 79234a8d..67b1ccf5 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -711,6 +711,7 @@ class BECImageItem(RPCBase): - log - rot - transpose + - autorange_mode """ @rpc_call @@ -767,6 +768,15 @@ class BECImageItem(RPCBase): autorange(bool): Whether to autorange the color bar. """ + @rpc_call + def set_autorange_mode(self, mode: "Literal['max', 'mean']" = "mean"): + """ + Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std. + + Args: + mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std. + """ + @rpc_call def set_color_map(self, cmap: "str" = "magma"): """ @@ -796,7 +806,11 @@ class BECImageItem(RPCBase): @rpc_call def set_vrange( - self, vmin: "float" = None, vmax: "float" = None, vrange: "tuple[int, int]" = None + self, + vmin: "float" = None, + vmax: "float" = None, + vrange: "tuple[float, float]" = None, + change_autorange: "bool" = True, ): """ Set the range of the color bar. @@ -931,6 +945,17 @@ class BECImageShow(RPCBase): name(str): The name of the image. If None, apply to all images. """ + @rpc_call + def set_autorange_mode(self, mode: "Literal['max', 'mean']", name: "str" = None): + """ + Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled. + Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2). + + Args: + mode(str): The autoscale mode of the image. + name(str): The name of the image. If None, apply to all images. + """ + @rpc_call def set_monitor(self, monitor: "str", name: "str" = None): """ diff --git a/bec_widgets/widgets/figure/plots/image/image.py b/bec_widgets/widgets/figure/plots/image/image.py index e29933a6..c48bd3ef 100644 --- a/bec_widgets/widgets/figure/plots/image/image.py +++ b/bec_widgets/widgets/figure/plots/image/image.py @@ -12,7 +12,11 @@ from qtpy.QtWidgets import QWidget from bec_widgets.utils import EntryValidator from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig -from bec_widgets.widgets.figure.plots.image.image_processor import ImageProcessor, ProcessorWorker +from bec_widgets.widgets.figure.plots.image.image_processor import ( + ImageProcessor, + ImageStats, + ProcessorWorker, +) from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig @@ -35,6 +39,7 @@ class BECImageShow(BECPlotBase): "set_vrange", "set_color_map", "set_autorange", + "set_autorange_mode", "set_monitor", "set_processing", "set_image_properties", @@ -86,6 +91,7 @@ class BECImageShow(BECPlotBase): # Connect signals and slots thread.started.connect(lambda: worker.process_image(device, image)) worker.processed.connect(self.update_image) + worker.stats.connect(self.update_vrange) worker.finished.connect(thread.quit) worker.finished.connect(thread.wait) worker.finished.connect(worker.deleteLater) @@ -341,6 +347,17 @@ class BECImageShow(BECPlotBase): """ self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name) + def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None): + """ + Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled. + Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2). + + Args: + mode(str): The autoscale mode of the image. + name(str): The name of the image. If None, apply to all images. + """ + self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name) + def set_monitor(self, monitor: str, name: str = None): """ Set the monitor of the image. @@ -461,6 +478,7 @@ class BECImageShow(BECPlotBase): else: data = self.processor.process_image(data) self.update_image(device, data) + self.update_vrange(device, self.processor.config.stats) @pyqtSlot(str, np.ndarray) def update_image(self, device: str, data: np.ndarray): @@ -474,6 +492,18 @@ class BECImageShow(BECPlotBase): image_to_update = self._images["device_monitor"][device] image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange) + @pyqtSlot(str, ImageStats) + def update_vrange(self, device: str, stats: ImageStats): + """ + Update the scaling of the image. + + Args: + stats(ImageStats): The statistics of the image. + """ + image_to_update = self._images["device_monitor"][device] + if image_to_update.config.autorange: + image_to_update.auto_update_vrange(stats) + def _connect_device_monitor(self, monitor: str): """ Connect to the device monitor. diff --git a/bec_widgets/widgets/figure/plots/image/image_item.py b/bec_widgets/widgets/figure/plots/image/image_item.py index 30d06761..0746caae 100644 --- a/bec_widgets/widgets/figure/plots/image/image_item.py +++ b/bec_widgets/widgets/figure/plots/image/image_item.py @@ -7,7 +7,7 @@ import pyqtgraph as pg from pydantic import Field from bec_widgets.utils import BECConnector, ConnectionConfig -from bec_widgets.widgets.figure.plots.image.image_processor import ProcessingConfig +from bec_widgets.widgets.figure.plots.image.image_processor import ImageStats, ProcessingConfig if TYPE_CHECKING: from bec_widgets.widgets.figure.plots.image.image import BECImageShow @@ -20,13 +20,16 @@ class ImageItemConfig(ConnectionConfig): color_map: Optional[str] = Field("magma", description="The color map of the image.") downsample: Optional[bool] = Field(True, description="Whether to downsample the image.") opacity: Optional[float] = Field(1.0, description="The opacity of the image.") - vrange: Optional[tuple[int, int]] = Field( + vrange: Optional[tuple[float, float]] = Field( None, description="The range of the color bar. If None, the range is automatically set." ) color_bar: Optional[Literal["simple", "full"]] = Field( "simple", description="The type of the color bar." ) autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.") + autorange_mode: Optional[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." ) @@ -43,6 +46,7 @@ class BECImageItem(BECConnector, pg.ImageItem): "set_transpose", "set_opacity", "set_autorange", + "set_autorange_mode", "set_color_map", "set_auto_downsample", "set_monitor", @@ -101,6 +105,7 @@ class BECImageItem(BECConnector, pg.ImageItem): - log - rot - transpose + - autorange_mode """ method_map = { "downsample": self.set_auto_downsample, @@ -112,6 +117,7 @@ class BECImageItem(BECConnector, pg.ImageItem): "log": self.set_log, "rot": self.set_rotation, "transpose": self.set_transpose, + "autorange_mode": self.set_autorange_mode, } for key, value in kwargs.items(): if key in method_map: @@ -175,9 +181,18 @@ class BECImageItem(BECConnector, pg.ImageItem): autorange(bool): Whether to autorange the color bar. """ self.config.autorange = autorange - if self.color_bar is not None: + if self.color_bar and autorange: self.color_bar.autoHistogramRange() + def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"): + """ + Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std. + + Args: + mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std. + """ + self.config.autorange_mode = mode + def set_color_map(self, cmap: str = "magma"): """ Set the color map of the image. @@ -212,7 +227,29 @@ class BECImageItem(BECConnector, pg.ImageItem): """ self.config.monitor = monitor - def set_vrange(self, vmin: float = None, vmax: float = None, vrange: tuple[int, int] = None): + def auto_update_vrange(self, stats: ImageStats) -> None: + """Auto update of the vrange base on the stats of the image. + + Args: + stats(ImageStats): 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 + self.set_vrange(vmin, vmax, change_autorange=False) + return + if self.config.autorange_mode == "max": + self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False) + return + + def set_vrange( + self, + vmin: float = None, + vmax: float = None, + vrange: tuple[float, float] = None, + change_autorange: bool = True, + ): """ Set the range of the color bar. @@ -224,11 +261,13 @@ class BECImageItem(BECConnector, pg.ImageItem): vmin, vmax = vrange self.setLevels([vmin, vmax]) self.config.vrange = (vmin, vmax) - self.config.autorange = False + if change_autorange: + self.config.autorange = False if self.color_bar is not None: if self.config.color_bar == "simple": self.color_bar.setLevels(low=vmin, high=vmax) elif self.config.color_bar == "full": + # pylint: disable=unexpected-keyword-arg self.color_bar.setLevels(min=vmin, max=vmax) self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax) diff --git a/bec_widgets/widgets/figure/plots/image/image_processor.py b/bec_widgets/widgets/figure/plots/image/image_processor.py index 7580ad47..5ec8bf05 100644 --- a/bec_widgets/widgets/figure/plots/image/image_processor.py +++ b/bec_widgets/widgets/figure/plots/image/image_processor.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Optional import numpy as np @@ -7,6 +8,16 @@ from pydantic import BaseModel, Field from qtpy.QtCore import QObject, Signal, Slot +@dataclass +class ImageStats: + """Container to store stats of an image.""" + + maximum: float + minimum: float + mean: float + std: float + + class ProcessingConfig(BaseModel): fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.") log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.") @@ -20,6 +31,10 @@ class ProcessingConfig(BaseModel): None, description="The rotation angle of the monitor data before displaying." ) model_config: dict = {"validate_assignment": True} + stats: ImageStats = Field( + ImageStats(maximum=0, minimum=0, mean=0, std=0), + description="The statistics of the image data.", + ) class ImageProcessor: @@ -97,6 +112,18 @@ class ImageProcessor: # def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality # return np.unravel_index(np.argmax(data), data.shape) + 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: """ Process the data according to the configuration. @@ -115,6 +142,7 @@ class ImageProcessor: data = self.transpose(data) if self.config.log: data = self.log(data) + self.update_image_stats(data) return data @@ -124,6 +152,7 @@ class ProcessorWorker(QObject): """ processed = Signal(str, np.ndarray) + stats = Signal(str, ImageStats) stopRequested = Signal() finished = Signal() @@ -147,6 +176,7 @@ class ProcessorWorker(QObject): self._isRunning = False if not self._isRunning: self.processed.emit(device, processed_image) + self.stats.emit(self.processor.config.stats) self.finished.emit() def stop(self):