mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-07 23:32:12 +02:00
1042 lines
34 KiB
Python
1042 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
import numpy as np
|
|
import pyqtgraph as pg
|
|
from bec_lib import bec_logger
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
from qtpy.QtCore import QPointF, Signal, SignalInstance
|
|
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
|
|
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
|
from bec_widgets.utils.side_panel import SidePanel
|
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
|
|
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
|
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
|
|
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
|
from bec_widgets.widgets.plots.image.toolbar_components.image_base_actions import (
|
|
ImageColorbarConnection,
|
|
ImageProcessingConnection,
|
|
ImageRoiConnection,
|
|
image_autorange,
|
|
image_colorbar,
|
|
image_processing,
|
|
image_roi_bundle,
|
|
)
|
|
from bec_widgets.widgets.plots.plot_base import PlotBase
|
|
from bec_widgets.widgets.plots.roi.image_roi import (
|
|
BaseROI,
|
|
CircularROI,
|
|
EllipticalROI,
|
|
RectangularROI,
|
|
ROIController,
|
|
)
|
|
|
|
logger = bec_logger.logger
|
|
|
|
|
|
class ImageLayerSync(BaseModel):
|
|
"""
|
|
Model for the image layer synchronization.
|
|
"""
|
|
|
|
autorange: bool = Field(
|
|
True, description="Whether to synchronize the autorange of the image layer."
|
|
)
|
|
autorange_mode: bool = Field(
|
|
True, description="Whether to synchronize the autorange mode of the image layer."
|
|
)
|
|
color_map: bool = Field(
|
|
True, description="Whether to synchronize the color map of the image layer."
|
|
)
|
|
v_range: bool = Field(
|
|
True, description="Whether to synchronize the v_range of the image layer."
|
|
)
|
|
fft: bool = Field(True, description="Whether to synchronize the FFT of the image layer.")
|
|
log: bool = Field(True, description="Whether to synchronize the log of the image layer.")
|
|
rotation: bool = Field(
|
|
True, description="Whether to synchronize the rotation of the image layer."
|
|
)
|
|
transpose: bool = Field(
|
|
True, description="Whether to synchronize the transpose of the image layer."
|
|
)
|
|
|
|
|
|
class ImageLayer(BaseModel):
|
|
"""
|
|
Model for the image layer.
|
|
"""
|
|
|
|
name: str = Field(description="The name of the image layer.")
|
|
image: ImageItem = Field(description="The image item to be displayed.")
|
|
sync: ImageLayerSync = Field(
|
|
default_factory=ImageLayerSync,
|
|
description="The synchronization settings for the image layer.",
|
|
)
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
|
|
class ImageLayerManager:
|
|
"""
|
|
Manager for the image layers.
|
|
"""
|
|
|
|
Z_RANGE_USER = (-100, 100)
|
|
|
|
def __init__(
|
|
self,
|
|
parent: ImageBase,
|
|
plot_item: pg.PlotItem,
|
|
on_add: SignalInstance | None = None,
|
|
on_remove: SignalInstance | None = None,
|
|
):
|
|
self.parent = parent
|
|
self.plot_item = plot_item
|
|
self.on_add = on_add
|
|
self.on_remove = on_remove
|
|
self.layers: dict[str, ImageLayer] = {}
|
|
|
|
def add(
|
|
self,
|
|
name: str | None = None,
|
|
z_position: int | Literal["top", "bottom"] | None = None,
|
|
sync: ImageLayerSync | None = None,
|
|
**kwargs,
|
|
) -> ImageLayer:
|
|
"""
|
|
Add an image layer to the widget.
|
|
|
|
Args:
|
|
name (str | None): The name of the image layer. If None, a default name is generated.
|
|
image (ImageItem): The image layer to add.
|
|
z_position (int | None): The z position of the image layer. If None, the layer is added to the top.
|
|
sync (ImageLayerSync | None): The synchronization settings for the image layer.
|
|
**kwargs: ImageLayerSync settings. Only used if sync is None.
|
|
"""
|
|
if name is None:
|
|
name = WidgetContainerUtils.generate_unique_name(
|
|
"image_layer", list(self.layers.keys())
|
|
)
|
|
if name in self.layers:
|
|
raise ValueError(f"Layer with name '{name}' already exists.")
|
|
if sync is None:
|
|
sync = ImageLayerSync(**kwargs)
|
|
if z_position is None or z_position == "top":
|
|
z_position = self._get_top_z_position()
|
|
elif z_position == "bottom":
|
|
z_position = self._get_bottom_z_position()
|
|
image = ImageItem(parent_image=self.parent, object_name=name)
|
|
image.setZValue(z_position)
|
|
image.removed.connect(self._remove_destroyed_layer)
|
|
|
|
# FIXME: For now, we hard-code the default color map here. In the future, this should be configurable.
|
|
image.color_map = "plasma"
|
|
|
|
self.layers[name] = ImageLayer(name=name, image=image, sync=sync)
|
|
self.plot_item.addItem(image)
|
|
|
|
if self.on_add is not None:
|
|
self.on_add.emit(name)
|
|
|
|
return self.layers[name]
|
|
|
|
@SafeSlot(str)
|
|
def _remove_destroyed_layer(self, layer: str):
|
|
"""
|
|
Remove a layer that has been destroyed.
|
|
|
|
Args:
|
|
layer (str): The name of the layer to remove.
|
|
"""
|
|
self.remove(layer)
|
|
if self.on_remove is not None:
|
|
self.on_remove.emit(layer)
|
|
|
|
def remove(self, layer: ImageLayer | str):
|
|
"""
|
|
Remove an image layer from the widget.
|
|
|
|
Args:
|
|
layer (ImageLayer | str): The image layer to remove. Can be the layer object or the name of the layer.
|
|
"""
|
|
if isinstance(layer, str):
|
|
name = layer
|
|
else:
|
|
name = layer.name
|
|
|
|
removed_layer = self.layers.pop(name, None)
|
|
|
|
if not removed_layer:
|
|
return
|
|
self.plot_item.removeItem(removed_layer.image)
|
|
removed_layer.image.remove(emit=False)
|
|
removed_layer.image.deleteLater()
|
|
removed_layer.image = None
|
|
|
|
def clear(self):
|
|
"""
|
|
Clear all image layers from the manager.
|
|
"""
|
|
for layer in list(self.layers.keys()):
|
|
# Remove each layer from the plot item and delete it
|
|
self.remove(layer)
|
|
self.layers.clear()
|
|
|
|
def _get_top_z_position(self) -> int:
|
|
"""
|
|
Get the top z position of the image layers, capping it to the maximum z value.
|
|
|
|
Returns:
|
|
int: The top z position of the image layers.
|
|
"""
|
|
if not self.layers:
|
|
return 0
|
|
z = max(layer.image.zValue() for layer in self.layers.values()) + 1
|
|
return min(z, self.Z_RANGE_USER[1])
|
|
|
|
def _get_bottom_z_position(self) -> int:
|
|
"""
|
|
Get the bottom z position of the image layers, capping it to the minimum z value.
|
|
|
|
Returns:
|
|
int: The bottom z position of the image layers.
|
|
"""
|
|
if not self.layers:
|
|
return 0
|
|
z = min(layer.image.zValue() for layer in self.layers.values()) - 1
|
|
return max(z, self.Z_RANGE_USER[0])
|
|
|
|
def __iter__(self):
|
|
"""
|
|
Iterate over the image layers.
|
|
|
|
Returns:
|
|
Iterator[ImageLayer]: An iterator over the image layers.
|
|
"""
|
|
return iter(self.layers.values())
|
|
|
|
def __getitem__(self, name: str) -> ImageLayer:
|
|
"""
|
|
Get an image layer by name.
|
|
|
|
Args:
|
|
name (str): The name of the image layer.
|
|
|
|
Returns:
|
|
ImageLayer: The image layer with the given name.
|
|
"""
|
|
if not isinstance(name, str):
|
|
raise TypeError("name must be a string")
|
|
if name == "main" and name not in self.layers:
|
|
# If 'main' is requested, create a default layer if it doesn't exist
|
|
return self.add(name=name, z_position="top")
|
|
return self.layers[name]
|
|
|
|
def __len__(self) -> int:
|
|
"""
|
|
Get the number of image layers.
|
|
|
|
Returns:
|
|
int: The number of image layers.
|
|
"""
|
|
return len(self.layers)
|
|
|
|
|
|
class ImageBase(PlotBase):
|
|
"""
|
|
Base class for the Image widget.
|
|
"""
|
|
|
|
sync_colorbar_with_autorange = Signal()
|
|
image_updated = Signal()
|
|
layer_added = Signal(str)
|
|
layer_removed = Signal(str)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
"""
|
|
Initialize the ImageBase widget.
|
|
"""
|
|
self.x_roi = None
|
|
self.y_roi = None
|
|
self._color_bar = None
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.roi_controller = ROIController(colormap="viridis")
|
|
|
|
# Headless controller keeps the canonical list.
|
|
self.roi_manager_dialog = None
|
|
self.layer_manager: ImageLayerManager = ImageLayerManager(
|
|
self, plot_item=self.plot_item, on_add=self.layer_added, on_remove=self.layer_removed
|
|
)
|
|
self.layer_manager.add("main")
|
|
self._init_image_base_toolbar()
|
|
|
|
self.autorange = True
|
|
self.autorange_mode = "mean"
|
|
|
|
# Initialize ROI plots and side panels
|
|
self._add_roi_plots()
|
|
|
|
# Refresh theme for ROI plots
|
|
self._update_theme()
|
|
|
|
self.toolbar.show_bundles(
|
|
[
|
|
"image_crosshair",
|
|
"mouse_interaction",
|
|
"image_autorange",
|
|
"image_colorbar",
|
|
"image_processing",
|
|
]
|
|
)
|
|
|
|
################################################################################
|
|
# Widget Specific GUI interactions
|
|
################################################################################
|
|
|
|
def apply_theme(self, theme: str):
|
|
super().apply_theme(theme)
|
|
if self.x_roi is not None and self.y_roi is not None:
|
|
self.x_roi.apply_theme(theme)
|
|
self.y_roi.apply_theme(theme)
|
|
|
|
def add_layer(self, name: str | None = None, **kwargs) -> ImageLayer:
|
|
"""
|
|
Add a new image layer to the widget.
|
|
|
|
Args:
|
|
name (str | None): The name of the image layer. If None, a default name is generated.
|
|
**kwargs: Additional arguments for the image layer.
|
|
|
|
Returns:
|
|
ImageLayer: The added image layer.
|
|
"""
|
|
layer = self.layer_manager.add(name=name, **kwargs)
|
|
self.image_updated.emit()
|
|
return layer
|
|
|
|
def remove_layer(self, layer: ImageLayer | str):
|
|
"""
|
|
Remove an image layer from the widget.
|
|
|
|
Args:
|
|
layer (ImageLayer | str): The image layer to remove. Can be the layer object or the name of the layer.
|
|
"""
|
|
self.layer_manager.remove(layer)
|
|
self.image_updated.emit()
|
|
|
|
def layers(self) -> list[ImageLayer]:
|
|
"""
|
|
Get the list of image layers.
|
|
|
|
Returns:
|
|
list[ImageLayer]: The list of image layers.
|
|
"""
|
|
return list(self.layer_manager.layers.values())
|
|
|
|
def _init_image_base_toolbar(self):
|
|
|
|
try:
|
|
|
|
# ROI Actions
|
|
self.toolbar.add_bundle(image_roi_bundle(self.toolbar.components))
|
|
self.toolbar.connect_bundle(
|
|
"image_base", ImageRoiConnection(self.toolbar.components, target_widget=self)
|
|
)
|
|
|
|
# Lock Aspect Ratio Action
|
|
lock_aspect_ratio_action = MaterialIconAction(
|
|
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
|
|
)
|
|
self.toolbar.components.add_safe("lock_aspect_ratio", lock_aspect_ratio_action)
|
|
self.toolbar.get_bundle("mouse_interaction").add_action("lock_aspect_ratio")
|
|
lock_aspect_ratio_action.action.toggled.connect(
|
|
lambda checked: self.setProperty("lock_aspect_ratio", checked)
|
|
)
|
|
lock_aspect_ratio_action.action.setChecked(True)
|
|
|
|
# Autorange Action
|
|
self.toolbar.add_bundle(image_autorange(self.toolbar.components))
|
|
action = self.toolbar.components.get_action("image_autorange")
|
|
action.actions["mean"].action.toggled.connect(
|
|
lambda checked: self.toggle_autorange(checked, mode="mean")
|
|
)
|
|
action.actions["max"].action.toggled.connect(
|
|
lambda checked: self.toggle_autorange(checked, mode="max")
|
|
)
|
|
|
|
# Colorbar Actions
|
|
self.toolbar.add_bundle(image_colorbar(self.toolbar.components))
|
|
|
|
self.toolbar.connect_bundle(
|
|
"image_colorbar",
|
|
ImageColorbarConnection(self.toolbar.components, target_widget=self),
|
|
)
|
|
|
|
# Image Processing Actions
|
|
self.toolbar.add_bundle(image_processing(self.toolbar.components))
|
|
self.toolbar.connect_bundle(
|
|
"image_processing",
|
|
ImageProcessingConnection(self.toolbar.components, target_widget=self),
|
|
)
|
|
|
|
# ROI Manager Action
|
|
self.toolbar.components.add_safe(
|
|
"roi_mgr",
|
|
MaterialIconAction(
|
|
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
|
),
|
|
)
|
|
self.toolbar.get_bundle("axis_popup").add_action("roi_mgr")
|
|
self.toolbar.components.get_action("roi_mgr").action.triggered.connect(
|
|
self.show_roi_manager_popup
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error initializing toolbar: {e}")
|
|
|
|
########################################
|
|
# ROI Gui Manager
|
|
def add_side_menus(self):
|
|
super().add_side_menus()
|
|
|
|
roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
|
self.side_panel.add_menu(
|
|
action_id="roi_mgr",
|
|
icon_name="view_list",
|
|
tooltip="ROI Manager",
|
|
widget=roi_mgr,
|
|
title="ROI Manager",
|
|
)
|
|
|
|
def show_roi_manager_popup(self):
|
|
roi_action = self.toolbar.components.get_action("roi_mgr").action
|
|
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
|
|
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
|
self.roi_manager_dialog = QDialog(modal=False)
|
|
self.roi_manager_dialog.layout = QVBoxLayout(self.roi_manager_dialog)
|
|
self.roi_manager_dialog.layout.addWidget(self.roi_mgr)
|
|
self.roi_manager_dialog.finished.connect(self._roi_mgr_closed)
|
|
self.roi_manager_dialog.show()
|
|
roi_action.setChecked(True)
|
|
else:
|
|
self.roi_manager_dialog.raise_()
|
|
self.roi_manager_dialog.activateWindow()
|
|
roi_action.setChecked(True)
|
|
|
|
def _roi_mgr_closed(self):
|
|
self.roi_mgr.close()
|
|
self.roi_mgr.deleteLater()
|
|
self.roi_manager_dialog.close()
|
|
self.roi_manager_dialog.deleteLater()
|
|
self.roi_manager_dialog = None
|
|
self.toolbar.components.get_action("roi_mgr").action.setChecked(False)
|
|
|
|
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.layer_manager["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
|
|
|
|
def disable_autorange():
|
|
print("Disabling autorange")
|
|
self.setProperty("autorange", False)
|
|
|
|
if style == "simple":
|
|
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
|
self._color_bar.setImageItem(self.layer_manager["main"].image)
|
|
self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
|
|
|
|
elif style == "full":
|
|
self._color_bar = pg.HistogramLUTItem()
|
|
self._color_bar.setImageItem(self.layer_manager["main"].image)
|
|
self._color_bar.gradient.loadPreset(self.config.color_map)
|
|
self._color_bar.sigLevelsChanged.connect(disable_autorange)
|
|
|
|
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
|
|
|
|
################################################################################
|
|
# Static rois with roi manager
|
|
|
|
def add_roi(
|
|
self,
|
|
kind: Literal["rect", "circle", "ellipse"] = "rect",
|
|
name: str | None = None,
|
|
line_width: int | None = 5,
|
|
pos: tuple[float, float] | None = (10, 10),
|
|
size: tuple[float, float] | None = (50, 50),
|
|
movable: bool = True,
|
|
**pg_kwargs,
|
|
) -> RectangularROI | CircularROI:
|
|
"""
|
|
Add a ROI to the image.
|
|
|
|
Args:
|
|
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
|
name(str): The name of the ROI.
|
|
line_width(int): The line width of the ROI.
|
|
pos(tuple): The position of the ROI.
|
|
size(tuple): The size of the ROI.
|
|
movable(bool): Whether the ROI is movable.
|
|
**pg_kwargs: Additional arguments for the ROI.
|
|
|
|
Returns:
|
|
RectangularROI | CircularROI: The created ROI object.
|
|
"""
|
|
if name is None:
|
|
name = f"ROI_{len(self.roi_controller.rois) + 1}"
|
|
if kind == "rect":
|
|
roi = RectangularROI(
|
|
pos=pos,
|
|
size=size,
|
|
parent_image=self,
|
|
line_width=line_width,
|
|
label=name,
|
|
movable=movable,
|
|
**pg_kwargs,
|
|
)
|
|
elif kind == "circle":
|
|
roi = CircularROI(
|
|
pos=pos,
|
|
size=size,
|
|
parent_image=self,
|
|
line_width=line_width,
|
|
label=name,
|
|
movable=movable,
|
|
**pg_kwargs,
|
|
)
|
|
elif kind == "ellipse":
|
|
roi = EllipticalROI(
|
|
pos=pos,
|
|
size=size,
|
|
parent_image=self,
|
|
line_width=line_width,
|
|
label=name,
|
|
movable=movable,
|
|
**pg_kwargs,
|
|
)
|
|
else:
|
|
raise ValueError("kind must be 'rect' or 'circle'")
|
|
|
|
# Add to plot and controller (controller assigns color)
|
|
self.plot_item.addItem(roi)
|
|
self.roi_controller.add_roi(roi)
|
|
return roi
|
|
|
|
def remove_roi(self, roi: int | str):
|
|
"""Remove an ROI by index or label via the ROIController."""
|
|
if isinstance(roi, int):
|
|
self.roi_controller.remove_roi_by_index(roi)
|
|
elif isinstance(roi, str):
|
|
self.roi_controller.remove_roi_by_name(roi)
|
|
else:
|
|
raise ValueError("roi must be an int index or str name")
|
|
|
|
def _add_roi_plots(self):
|
|
"""
|
|
Initialize the ROI plots and side panels.
|
|
"""
|
|
# Create ROI plot widgets
|
|
self.x_roi = ImageROIPlot(parent=self)
|
|
self.x_roi.plot_item.setXLink(self.plot_item)
|
|
self.y_roi = ImageROIPlot(parent=self)
|
|
self.y_roi.plot_item.setYLink(self.plot_item)
|
|
self.x_roi.apply_theme("dark")
|
|
self.y_roi.apply_theme("dark")
|
|
|
|
# Set titles for the plots
|
|
self.x_roi.plot_item.setTitle("X ROI")
|
|
self.y_roi.plot_item.setTitle("Y ROI")
|
|
|
|
# Create side panels
|
|
self.side_panel_x = SidePanel(
|
|
parent=self, orientation="bottom", panel_max_width=200, show_toolbar=False
|
|
)
|
|
self.side_panel_y = SidePanel(
|
|
parent=self, orientation="left", panel_max_width=200, show_toolbar=False
|
|
)
|
|
|
|
# Add ROI plots to side panels
|
|
self.x_panel_index = self.side_panel_x.add_menu(widget=self.x_roi)
|
|
self.y_panel_index = self.side_panel_y.add_menu(widget=self.y_roi)
|
|
|
|
# # Add side panels to the layout
|
|
self.layout_manager.add_widget_relative(
|
|
self.side_panel_x, self.round_plot_widget, position="bottom", shift_direction="down"
|
|
)
|
|
self.layout_manager.add_widget_relative(
|
|
self.side_panel_y, self.round_plot_widget, position="left", shift_direction="right"
|
|
)
|
|
|
|
def toggle_roi_panels(self, checked: bool):
|
|
"""
|
|
Show or hide the ROI panels based on the test action toggle state.
|
|
|
|
Args:
|
|
checked (bool): Whether the test action is checked.
|
|
"""
|
|
if checked:
|
|
# Show the ROI panels
|
|
self.hook_crosshair()
|
|
self.side_panel_x.show_panel(self.x_panel_index)
|
|
self.side_panel_y.show_panel(self.y_panel_index)
|
|
self.crosshair.coordinatesChanged2D.connect(self.update_image_slices)
|
|
self.image_updated.connect(self.update_image_slices)
|
|
else:
|
|
self.unhook_crosshair()
|
|
# Hide the ROI panels
|
|
self.side_panel_x.hide_panel()
|
|
self.side_panel_y.hide_panel()
|
|
self.image_updated.disconnect(self.update_image_slices)
|
|
|
|
@SafeSlot()
|
|
def update_image_slices(self, coordinates: tuple[int, int, int] = None):
|
|
"""
|
|
Update the image slices based on the crosshair position.
|
|
|
|
Args:
|
|
coordinates(tuple): The coordinates of the crosshair.
|
|
"""
|
|
if coordinates is None:
|
|
# Try to get coordinates from crosshair position (like in crosshair mouse_moved)
|
|
if (
|
|
hasattr(self, "crosshair")
|
|
and hasattr(self.crosshair, "v_line")
|
|
and hasattr(self.crosshair, "h_line")
|
|
):
|
|
x = int(round(self.crosshair.v_line.value()))
|
|
y = int(round(self.crosshair.h_line.value()))
|
|
else:
|
|
return
|
|
else:
|
|
x = coordinates[1]
|
|
y = coordinates[2]
|
|
image_item = self.layer_manager["main"].image
|
|
image = image_item.image
|
|
if image is None:
|
|
return
|
|
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
|
|
row, col = x, y
|
|
if not (0 <= row <= max_row and 0 <= col <= max_col):
|
|
return
|
|
# Horizontal slice
|
|
h_slice = image[:, col]
|
|
x_pixel_indices = np.arange(h_slice.shape[0])
|
|
if image_item.image_transform is None:
|
|
h_world_x = np.arange(h_slice.shape[0])
|
|
else:
|
|
h_world_x = [
|
|
image_item.image_transform.map(xi + 0.5, col + 0.5)[0] for xi in x_pixel_indices
|
|
]
|
|
self.x_roi.plot_item.clear()
|
|
self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
|
|
|
|
# Vertical slice
|
|
v_slice = image[row, :]
|
|
y_pixel_indices = np.arange(v_slice.shape[0])
|
|
if image_item.image_transform is None:
|
|
v_world_y = np.arange(v_slice.shape[0])
|
|
else:
|
|
v_world_y = [
|
|
image_item.image_transform.map(row + 0.5, yi + 0.5)[1] for yi in y_pixel_indices
|
|
]
|
|
self.y_roi.plot_item.clear()
|
|
self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3))
|
|
|
|
################################################################################
|
|
# Widget Specific Properties
|
|
################################################################################
|
|
################################################################################
|
|
# Rois
|
|
|
|
@property
|
|
def rois(self) -> list[BaseROI]:
|
|
"""
|
|
Get the list of ROIs.
|
|
"""
|
|
return self.roi_controller.rois
|
|
|
|
################################################################################
|
|
# 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
|
|
for layer in self.layer_manager:
|
|
if not layer.sync.color_map:
|
|
continue
|
|
layer.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
|
|
|
|
@SafeProperty("QPointF")
|
|
def v_range(self) -> QPointF:
|
|
"""
|
|
Set the v_range of the main image.
|
|
"""
|
|
vmin, vmax = self.layer_manager["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.
|
|
"""
|
|
self._set_vrange(value, disable_autorange=True)
|
|
|
|
def _set_vrange(self, value: tuple | list | QPointF, disable_autorange: bool = True):
|
|
if isinstance(value, (tuple, list)):
|
|
value = self._tuple_to_qpointf(value)
|
|
|
|
vmin, vmax = value.x(), value.y()
|
|
|
|
for layer in self.layer_manager:
|
|
if not layer.sync.v_range:
|
|
continue
|
|
layer.image.set_v_range((vmin, vmax), disable_autorange=disable_autorange)
|
|
|
|
# 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.toolbar.components.get_action("image_autorange").set_state_all(False)
|
|
|
|
@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)
|
|
|
|
################################################################################
|
|
# Autorange + Colorbar sync
|
|
|
|
@SafeProperty(bool)
|
|
def autorange(self) -> bool:
|
|
"""
|
|
Whether autorange is enabled.
|
|
"""
|
|
|
|
# FIXME: this should be made more general
|
|
return self.layer_manager["main"].image.autorange
|
|
|
|
@autorange.setter
|
|
def autorange(self, enabled: bool):
|
|
"""
|
|
Set autorange.
|
|
|
|
Args:
|
|
enabled(bool): Whether to enable autorange.
|
|
"""
|
|
self._set_autorange(enabled)
|
|
|
|
def _set_autorange(self, enabled: bool, sync: bool = True):
|
|
"""
|
|
Set the autorange for all layers.
|
|
|
|
Args:
|
|
enabled(bool): Whether to enable autorange.
|
|
sync(bool): Whether to synchronize the autorange state across all layers.
|
|
"""
|
|
for layer in self.layer_manager:
|
|
if not layer.sync.autorange:
|
|
continue
|
|
layer.image.autorange = enabled
|
|
if enabled and layer.image.raw_data is not None:
|
|
layer.image.apply_autorange()
|
|
# if sync:
|
|
self._sync_colorbar_levels()
|
|
self._sync_autorange_switch()
|
|
print(f"Autorange set to {enabled}")
|
|
|
|
@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.layer_manager["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
|
|
for layer in self.layer_manager:
|
|
if not layer.sync.autorange_mode:
|
|
continue
|
|
layer.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 not self.layer_manager:
|
|
return
|
|
for layer in self.layer_manager:
|
|
if layer.sync.autorange:
|
|
layer.image.autorange = enabled
|
|
if layer.sync.autorange_mode:
|
|
layer.image.autorange_mode = mode
|
|
|
|
if not enabled:
|
|
continue
|
|
# We only need to apply autorange if we enabled it
|
|
layer.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.
|
|
"""
|
|
action: SwitchableToolBarAction = self.toolbar.components.get_action("image_autorange") # type: ignore
|
|
with action.signal_blocker():
|
|
action.set_default_action(f"{self.layer_manager['main'].image.autorange_mode}")
|
|
action.set_state_all(self.layer_manager["main"].image.autorange)
|
|
|
|
def _sync_colorbar_levels(self):
|
|
"""Immediately propagate current levels to the active colorbar."""
|
|
|
|
if not self._color_bar:
|
|
return
|
|
|
|
total_vrange = (0, 0)
|
|
for layer in self.layer_manager:
|
|
if not layer.sync.v_range:
|
|
continue
|
|
img = layer.image
|
|
total_vrange = (min(total_vrange[0], img.v_min), max(total_vrange[1], img.v_max))
|
|
|
|
self._color_bar.blockSignals(True)
|
|
self._set_vrange(total_vrange, disable_autorange=False) # type: ignore
|
|
self._color_bar.blockSignals(False)
|
|
|
|
def _sync_colorbar_actions(self):
|
|
"""
|
|
Synchronize the colorbar actions with the current colorbar state.
|
|
"""
|
|
colorbar_switch: SwitchableToolBarAction = self.toolbar.components.get_action(
|
|
"image_colorbar_switch"
|
|
)
|
|
with colorbar_switch.signal_blocker():
|
|
if self._color_bar is not None:
|
|
colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
|
colorbar_switch.set_state_all(True)
|
|
else:
|
|
colorbar_switch.set_state_all(False)
|
|
|
|
@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):
|
|
"""
|
|
Cleanup the widget.
|
|
"""
|
|
self.toolbar.cleanup()
|
|
|
|
# Remove all ROIs
|
|
rois = self.rois
|
|
for roi in rois:
|
|
roi.remove()
|
|
|
|
# Colorbar Cleanup
|
|
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
|
|
|
|
# Popup cleanup
|
|
if self.roi_manager_dialog is not None:
|
|
self.roi_manager_dialog.reject()
|
|
self.roi_manager_dialog = None
|
|
|
|
# ROI plots cleanup
|
|
if self.x_roi is not None:
|
|
self.x_roi.cleanup_pyqtgraph()
|
|
if self.y_roi is not None:
|
|
self.y_roi.cleanup_pyqtgraph()
|
|
|
|
if self.layer_manager is not None:
|
|
self.layer_manager.clear()
|
|
self.layer_manager = None
|
|
|
|
super().cleanup()
|