bec-widgets 2.10.3__py3-none-any.whl → 2.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1062 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ import numpy as np
6
+ import pyqtgraph as pg
7
+ from bec_lib import bec_logger
8
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
9
+ from qtpy.QtCore import QPointF, Signal, SignalInstance
10
+ from qtpy.QtWidgets import QDialog, QVBoxLayout
11
+
12
+ from bec_widgets.utils.container_utils import WidgetContainerUtils
13
+ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
14
+ from bec_widgets.utils.side_panel import SidePanel
15
+ from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
16
+ from bec_widgets.widgets.plots.image.image_item import ImageItem
17
+ from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
18
+ from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
19
+ from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
20
+ MonitorSelectionToolbarBundle,
21
+ )
22
+ from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
23
+ from bec_widgets.widgets.plots.plot_base import PlotBase
24
+ from bec_widgets.widgets.plots.roi.image_roi import (
25
+ BaseROI,
26
+ CircularROI,
27
+ RectangularROI,
28
+ ROIController,
29
+ )
30
+
31
+ logger = bec_logger.logger
32
+
33
+
34
+ class ImageLayerSync(BaseModel):
35
+ """
36
+ Model for the image layer synchronization.
37
+ """
38
+
39
+ autorange: bool = Field(
40
+ True, description="Whether to synchronize the autorange of the image layer."
41
+ )
42
+ autorange_mode: bool = Field(
43
+ True, description="Whether to synchronize the autorange mode of the image layer."
44
+ )
45
+ color_map: bool = Field(
46
+ True, description="Whether to synchronize the color map of the image layer."
47
+ )
48
+ v_range: bool = Field(
49
+ True, description="Whether to synchronize the v_range of the image layer."
50
+ )
51
+ fft: bool = Field(True, description="Whether to synchronize the FFT of the image layer.")
52
+ log: bool = Field(True, description="Whether to synchronize the log of the image layer.")
53
+ rotation: bool = Field(
54
+ True, description="Whether to synchronize the rotation of the image layer."
55
+ )
56
+ transpose: bool = Field(
57
+ True, description="Whether to synchronize the transpose of the image layer."
58
+ )
59
+
60
+
61
+ class ImageLayer(BaseModel):
62
+ """
63
+ Model for the image layer.
64
+ """
65
+
66
+ name: str = Field(description="The name of the image layer.")
67
+ image: ImageItem = Field(description="The image item to be displayed.")
68
+ sync: ImageLayerSync = Field(
69
+ default_factory=ImageLayerSync,
70
+ description="The synchronization settings for the image layer.",
71
+ )
72
+ model_config = ConfigDict(arbitrary_types_allowed=True)
73
+
74
+
75
+ class ImageLayerManager:
76
+ """
77
+ Manager for the image layers.
78
+ """
79
+
80
+ Z_RANGE_USER = (-100, 100)
81
+
82
+ def __init__(
83
+ self,
84
+ parent: ImageBase,
85
+ plot_item: pg.PlotItem,
86
+ on_add: SignalInstance | None = None,
87
+ on_remove: SignalInstance | None = None,
88
+ ):
89
+ self.parent = parent
90
+ self.plot_item = plot_item
91
+ self.on_add = on_add
92
+ self.on_remove = on_remove
93
+ self.layers: dict[str, ImageLayer] = {}
94
+
95
+ def add(
96
+ self,
97
+ name: str | None = None,
98
+ z_position: int | Literal["top", "bottom"] | None = None,
99
+ sync: ImageLayerSync | None = None,
100
+ **kwargs,
101
+ ) -> ImageLayer:
102
+ """
103
+ Add an image layer to the widget.
104
+
105
+ Args:
106
+ name (str | None): The name of the image layer. If None, a default name is generated.
107
+ image (ImageItem): The image layer to add.
108
+ z_position (int | None): The z position of the image layer. If None, the layer is added to the top.
109
+ sync (ImageLayerSync | None): The synchronization settings for the image layer.
110
+ **kwargs: ImageLayerSync settings. Only used if sync is None.
111
+ """
112
+ if name is None:
113
+ name = WidgetContainerUtils.generate_unique_name(
114
+ "image_layer", list(self.layers.keys())
115
+ )
116
+ if name in self.layers:
117
+ raise ValueError(f"Layer with name '{name}' already exists.")
118
+ if sync is None:
119
+ sync = ImageLayerSync(**kwargs)
120
+ if z_position is None or z_position == "top":
121
+ z_position = self._get_top_z_position()
122
+ elif z_position == "bottom":
123
+ z_position = self._get_bottom_z_position()
124
+ image = ImageItem(parent_image=self.parent, object_name=name)
125
+ image.setZValue(z_position)
126
+ image.removed.connect(self._remove_destroyed_layer)
127
+
128
+ # FIXME: For now, we hard-code the default color map here. In the future, this should be configurable.
129
+ image.color_map = "plasma"
130
+
131
+ self.layers[name] = ImageLayer(name=name, image=image, sync=sync)
132
+ self.plot_item.addItem(image)
133
+
134
+ if self.on_add is not None:
135
+ self.on_add.emit(name)
136
+
137
+ return self.layers[name]
138
+
139
+ @SafeSlot(str)
140
+ def _remove_destroyed_layer(self, layer: str):
141
+ """
142
+ Remove a layer that has been destroyed.
143
+
144
+ Args:
145
+ layer (str): The name of the layer to remove.
146
+ """
147
+ self.remove(layer)
148
+ if self.on_remove is not None:
149
+ self.on_remove.emit(layer)
150
+
151
+ def remove(self, layer: ImageLayer | str):
152
+ """
153
+ Remove an image layer from the widget.
154
+
155
+ Args:
156
+ layer (ImageLayer | str): The image layer to remove. Can be the layer object or the name of the layer.
157
+ """
158
+ if isinstance(layer, str):
159
+ name = layer
160
+ else:
161
+ name = layer.name
162
+
163
+ removed_layer = self.layers.pop(name, None)
164
+
165
+ if not removed_layer:
166
+ return
167
+ self.plot_item.removeItem(removed_layer.image)
168
+ removed_layer.image.remove(emit=False)
169
+ removed_layer.image.deleteLater()
170
+ removed_layer.image = None
171
+
172
+ def clear(self):
173
+ """
174
+ Clear all image layers from the manager.
175
+ """
176
+ for layer in list(self.layers.keys()):
177
+ # Remove each layer from the plot item and delete it
178
+ self.remove(layer)
179
+ self.layers.clear()
180
+
181
+ def _get_top_z_position(self) -> int:
182
+ """
183
+ Get the top z position of the image layers, capping it to the maximum z value.
184
+
185
+ Returns:
186
+ int: The top z position of the image layers.
187
+ """
188
+ if not self.layers:
189
+ return 0
190
+ z = max(layer.image.zValue() for layer in self.layers.values()) + 1
191
+ return min(z, self.Z_RANGE_USER[1])
192
+
193
+ def _get_bottom_z_position(self) -> int:
194
+ """
195
+ Get the bottom z position of the image layers, capping it to the minimum z value.
196
+
197
+ Returns:
198
+ int: The bottom z position of the image layers.
199
+ """
200
+ if not self.layers:
201
+ return 0
202
+ z = min(layer.image.zValue() for layer in self.layers.values()) - 1
203
+ return max(z, self.Z_RANGE_USER[0])
204
+
205
+ def __iter__(self):
206
+ """
207
+ Iterate over the image layers.
208
+
209
+ Returns:
210
+ Iterator[ImageLayer]: An iterator over the image layers.
211
+ """
212
+ return iter(self.layers.values())
213
+
214
+ def __getitem__(self, name: str) -> ImageLayer:
215
+ """
216
+ Get an image layer by name.
217
+
218
+ Args:
219
+ name (str): The name of the image layer.
220
+
221
+ Returns:
222
+ ImageLayer: The image layer with the given name.
223
+ """
224
+ if not isinstance(name, str):
225
+ raise TypeError("name must be a string")
226
+ if name == "main" and name not in self.layers:
227
+ # If 'main' is requested, create a default layer if it doesn't exist
228
+ return self.add(name=name, z_position="top")
229
+ return self.layers[name]
230
+
231
+ def __len__(self) -> int:
232
+ """
233
+ Get the number of image layers.
234
+
235
+ Returns:
236
+ int: The number of image layers.
237
+ """
238
+ return len(self.layers)
239
+
240
+
241
+ class ImageBase(PlotBase):
242
+ """
243
+ Base class for the Image widget.
244
+ """
245
+
246
+ sync_colorbar_with_autorange = Signal()
247
+ image_updated = Signal()
248
+ layer_added = Signal(str)
249
+ layer_removed = Signal(str)
250
+
251
+ def __init__(self, *args, **kwargs):
252
+ """
253
+ Initialize the ImageBase widget.
254
+ """
255
+ self.x_roi = None
256
+ self.y_roi = None
257
+ super().__init__(*args, **kwargs)
258
+ self.roi_controller = ROIController(colormap="viridis")
259
+
260
+ # Headless controller keeps the canonical list.
261
+ self.roi_manager_dialog = None
262
+ self.layer_manager: ImageLayerManager = ImageLayerManager(
263
+ self, plot_item=self.plot_item, on_add=self.layer_added, on_remove=self.layer_removed
264
+ )
265
+ self.layer_manager.add("main")
266
+
267
+ self.autorange = True
268
+ self.autorange_mode = "mean"
269
+
270
+ # Initialize ROI plots and side panels
271
+ self._add_roi_plots()
272
+
273
+ # Refresh theme for ROI plots
274
+ self._update_theme()
275
+
276
+ ################################################################################
277
+ # Widget Specific GUI interactions
278
+ ################################################################################
279
+
280
+ def apply_theme(self, theme: str):
281
+ super().apply_theme(theme)
282
+ if self.x_roi is not None and self.y_roi is not None:
283
+ self.x_roi.apply_theme(theme)
284
+ self.y_roi.apply_theme(theme)
285
+
286
+ def add_layer(self, name: str | None = None, **kwargs) -> ImageLayer:
287
+ """
288
+ Add a new image layer to the widget.
289
+
290
+ Args:
291
+ name (str | None): The name of the image layer. If None, a default name is generated.
292
+ **kwargs: Additional arguments for the image layer.
293
+
294
+ Returns:
295
+ ImageLayer: The added image layer.
296
+ """
297
+ layer = self.layer_manager.add(name=name, **kwargs)
298
+ self.image_updated.emit()
299
+ return layer
300
+
301
+ def remove_layer(self, layer: ImageLayer | str):
302
+ """
303
+ Remove an image layer from the widget.
304
+
305
+ Args:
306
+ layer (ImageLayer | str): The image layer to remove. Can be the layer object or the name of the layer.
307
+ """
308
+ self.layer_manager.remove(layer)
309
+ self.image_updated.emit()
310
+
311
+ def layers(self) -> list[ImageLayer]:
312
+ """
313
+ Get the list of image layers.
314
+
315
+ Returns:
316
+ list[ImageLayer]: The list of image layers.
317
+ """
318
+ return list(self.layer_manager.layers.values())
319
+
320
+ def _init_toolbar(self):
321
+
322
+ try:
323
+ # add to the first position
324
+ self.selection_bundle = MonitorSelectionToolbarBundle(
325
+ bundle_id="selection", target_widget=self
326
+ )
327
+ self.toolbar.add_bundle(self.selection_bundle, self)
328
+
329
+ super()._init_toolbar()
330
+
331
+ # Image specific changes to PlotBase toolbar
332
+ self.toolbar.widgets["reset_legend"].action.setVisible(False)
333
+
334
+ # ROI Bundle replacement with switchable crosshair
335
+ self.toolbar.remove_bundle("roi")
336
+ crosshair = MaterialIconAction(
337
+ icon_name="point_scan", tooltip="Show Crosshair", checkable=True, parent=self
338
+ )
339
+ crosshair_roi = MaterialIconAction(
340
+ icon_name="my_location",
341
+ tooltip="Show Crosshair with ROI plots",
342
+ checkable=True,
343
+ parent=self,
344
+ )
345
+ crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
346
+ crosshair.action.toggled.connect(self.toggle_crosshair)
347
+ switch_crosshair = SwitchableToolBarAction(
348
+ actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
349
+ initial_action="crosshair_simple",
350
+ tooltip="Crosshair",
351
+ checkable=True,
352
+ parent=self,
353
+ )
354
+ self.toolbar.add_action(
355
+ action_id="switch_crosshair", action=switch_crosshair, target_widget=self
356
+ )
357
+
358
+ # Lock aspect ratio button
359
+ self.lock_aspect_ratio_action = MaterialIconAction(
360
+ icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
361
+ )
362
+ self.toolbar.add_action_to_bundle(
363
+ bundle_id="mouse_interaction",
364
+ action_id="lock_aspect_ratio",
365
+ action=self.lock_aspect_ratio_action,
366
+ target_widget=self,
367
+ )
368
+ self.lock_aspect_ratio_action.action.toggled.connect(
369
+ lambda checked: self.setProperty("lock_aspect_ratio", checked)
370
+ )
371
+ self.lock_aspect_ratio_action.action.setChecked(True)
372
+
373
+ self._init_autorange_action()
374
+ self._init_colorbar_action()
375
+
376
+ # Processing Bundle
377
+ self.processing_bundle = ImageProcessingToolbarBundle(
378
+ bundle_id="processing", target_widget=self
379
+ )
380
+ self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
381
+ except Exception as e:
382
+ logger.error(f"Error initializing toolbar: {e}")
383
+
384
+ def _init_autorange_action(self):
385
+
386
+ self.autorange_mean_action = MaterialIconAction(
387
+ icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
388
+ )
389
+ self.autorange_max_action = MaterialIconAction(
390
+ icon_name="hdr_auto",
391
+ tooltip="Enable Auto Range (Max)",
392
+ checkable=True,
393
+ filled=True,
394
+ parent=self,
395
+ )
396
+
397
+ self.autorange_switch = SwitchableToolBarAction(
398
+ actions={
399
+ "auto_range_mean": self.autorange_mean_action,
400
+ "auto_range_max": self.autorange_max_action,
401
+ },
402
+ initial_action="auto_range_mean",
403
+ tooltip="Enable Auto Range",
404
+ checkable=True,
405
+ parent=self,
406
+ )
407
+
408
+ self.toolbar.add_action(
409
+ action_id="autorange_image", action=self.autorange_switch, target_widget=self
410
+ )
411
+
412
+ self.autorange_mean_action.action.toggled.connect(
413
+ lambda checked: self.toggle_autorange(checked, mode="mean")
414
+ )
415
+ self.autorange_max_action.action.toggled.connect(
416
+ lambda checked: self.toggle_autorange(checked, mode="max")
417
+ )
418
+
419
+ def _init_colorbar_action(self):
420
+ self.full_colorbar_action = MaterialIconAction(
421
+ icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
422
+ )
423
+ self.simple_colorbar_action = MaterialIconAction(
424
+ icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
425
+ )
426
+
427
+ self.colorbar_switch = SwitchableToolBarAction(
428
+ actions={
429
+ "full_colorbar": self.full_colorbar_action,
430
+ "simple_colorbar": self.simple_colorbar_action,
431
+ },
432
+ initial_action="full_colorbar",
433
+ tooltip="Enable Full Colorbar",
434
+ checkable=True,
435
+ parent=self,
436
+ )
437
+
438
+ self.toolbar.add_action(
439
+ action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
440
+ )
441
+
442
+ self.simple_colorbar_action.action.toggled.connect(
443
+ lambda checked: self.enable_colorbar(checked, style="simple")
444
+ )
445
+ self.full_colorbar_action.action.toggled.connect(
446
+ lambda checked: self.enable_colorbar(checked, style="full")
447
+ )
448
+
449
+ ########################################
450
+ # ROI Gui Manager
451
+ def add_side_menus(self):
452
+ super().add_side_menus()
453
+
454
+ roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
455
+ self.side_panel.add_menu(
456
+ action_id="roi_mgr",
457
+ icon_name="view_list",
458
+ tooltip="ROI Manager",
459
+ widget=roi_mgr,
460
+ title="ROI Manager",
461
+ )
462
+
463
+ def add_popups(self):
464
+ super().add_popups() # keep Axis Settings
465
+
466
+ roi_action = MaterialIconAction(
467
+ icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
468
+ )
469
+ # self.popup_bundle.add_action("roi_mgr", roi_action)
470
+ self.toolbar.add_action_to_bundle(
471
+ bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
472
+ )
473
+ self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
474
+
475
+ def show_roi_manager_popup(self):
476
+ roi_action = self.toolbar.widgets["roi_mgr"].action
477
+ if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
478
+ self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
479
+ self.roi_manager_dialog = QDialog(modal=False)
480
+ self.roi_manager_dialog.layout = QVBoxLayout(self.roi_manager_dialog)
481
+ self.roi_manager_dialog.layout.addWidget(self.roi_mgr)
482
+ self.roi_manager_dialog.finished.connect(self._roi_mgr_closed)
483
+ self.roi_manager_dialog.show()
484
+ roi_action.setChecked(True)
485
+ else:
486
+ self.roi_manager_dialog.raise_()
487
+ self.roi_manager_dialog.activateWindow()
488
+ roi_action.setChecked(True)
489
+
490
+ def _roi_mgr_closed(self):
491
+ self.roi_mgr.close()
492
+ self.roi_mgr.deleteLater()
493
+ self.roi_manager_dialog.close()
494
+ self.roi_manager_dialog.deleteLater()
495
+ self.roi_manager_dialog = None
496
+ self.toolbar.widgets["roi_mgr"].action.setChecked(False)
497
+
498
+ def enable_colorbar(
499
+ self,
500
+ enabled: bool,
501
+ style: Literal["full", "simple"] = "full",
502
+ vrange: tuple[int, int] | None = None,
503
+ ):
504
+ """
505
+ Enable the colorbar and switch types of colorbars.
506
+
507
+ Args:
508
+ enabled(bool): Whether to enable the colorbar.
509
+ style(Literal["full", "simple"]): The type of colorbar to enable.
510
+ vrange(tuple): The range of values to use for the colorbar.
511
+ """
512
+ autorange_state = self.layer_manager["main"].image.autorange
513
+ if enabled:
514
+ if self._color_bar:
515
+ if self.config.color_bar == "full":
516
+ self.cleanup_histogram_lut_item(self._color_bar)
517
+ self.plot_widget.removeItem(self._color_bar)
518
+ self._color_bar = None
519
+
520
+ if style == "simple":
521
+
522
+ def disable_autorange():
523
+ print("Disabling autorange")
524
+ self.setProperty("autorange", False)
525
+
526
+ self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
527
+ self._color_bar.setImageItem(self.layer_manager["main"].image)
528
+ self._color_bar.sigLevelsChangeFinished.connect(disable_autorange)
529
+
530
+ elif style == "full":
531
+ self._color_bar = pg.HistogramLUTItem()
532
+ self._color_bar.setImageItem(self.layer_manager["main"].image)
533
+ self._color_bar.gradient.loadPreset(self.config.color_map)
534
+ self._color_bar.sigLevelsChanged.connect(
535
+ lambda: self.setProperty("autorange", False)
536
+ )
537
+
538
+ self.plot_widget.addItem(self._color_bar, row=0, col=1)
539
+ self.config.color_bar = style
540
+ else:
541
+ if self._color_bar:
542
+ self.plot_widget.removeItem(self._color_bar)
543
+ self._color_bar = None
544
+ self.config.color_bar = None
545
+
546
+ self.autorange = autorange_state
547
+ self._sync_colorbar_actions()
548
+
549
+ if vrange: # should be at the end to disable the autorange if defined
550
+ self.v_range = vrange
551
+
552
+ ################################################################################
553
+ # Static rois with roi manager
554
+
555
+ def add_roi(
556
+ self,
557
+ kind: Literal["rect", "circle"] = "rect",
558
+ name: str | None = None,
559
+ line_width: int | None = 5,
560
+ pos: tuple[float, float] | None = (10, 10),
561
+ size: tuple[float, float] | None = (50, 50),
562
+ **pg_kwargs,
563
+ ) -> RectangularROI | CircularROI:
564
+ """
565
+ Add a ROI to the image.
566
+
567
+ Args:
568
+ kind(str): The type of ROI to add. Options are "rect" or "circle".
569
+ name(str): The name of the ROI.
570
+ line_width(int): The line width of the ROI.
571
+ pos(tuple): The position of the ROI.
572
+ size(tuple): The size of the ROI.
573
+ **pg_kwargs: Additional arguments for the ROI.
574
+
575
+ Returns:
576
+ RectangularROI | CircularROI: The created ROI object.
577
+ """
578
+ if name is None:
579
+ name = f"ROI_{len(self.roi_controller.rois) + 1}"
580
+ if kind == "rect":
581
+ roi = RectangularROI(
582
+ pos=pos,
583
+ size=size,
584
+ parent_image=self,
585
+ line_width=line_width,
586
+ label=name,
587
+ **pg_kwargs,
588
+ )
589
+ elif kind == "circle":
590
+ roi = CircularROI(
591
+ pos=pos,
592
+ size=size,
593
+ parent_image=self,
594
+ line_width=line_width,
595
+ label=name,
596
+ **pg_kwargs,
597
+ )
598
+ else:
599
+ raise ValueError("kind must be 'rect' or 'circle'")
600
+
601
+ # Add to plot and controller (controller assigns color)
602
+ self.plot_item.addItem(roi)
603
+ self.roi_controller.add_roi(roi)
604
+ roi.add_scale_handle()
605
+ return roi
606
+
607
+ def remove_roi(self, roi: int | str):
608
+ """Remove an ROI by index or label via the ROIController."""
609
+ if isinstance(roi, int):
610
+ self.roi_controller.remove_roi_by_index(roi)
611
+ elif isinstance(roi, str):
612
+ self.roi_controller.remove_roi_by_name(roi)
613
+ else:
614
+ raise ValueError("roi must be an int index or str name")
615
+
616
+ def _add_roi_plots(self):
617
+ """
618
+ Initialize the ROI plots and side panels.
619
+ """
620
+ # Create ROI plot widgets
621
+ self.x_roi = ImageROIPlot(parent=self)
622
+ self.y_roi = ImageROIPlot(parent=self)
623
+ self.x_roi.apply_theme("dark")
624
+ self.y_roi.apply_theme("dark")
625
+
626
+ # Set titles for the plots
627
+ self.x_roi.plot_item.setTitle("X ROI")
628
+ self.y_roi.plot_item.setTitle("Y ROI")
629
+
630
+ # Create side panels
631
+ self.side_panel_x = SidePanel(
632
+ parent=self, orientation="bottom", panel_max_width=200, show_toolbar=False
633
+ )
634
+ self.side_panel_y = SidePanel(
635
+ parent=self, orientation="left", panel_max_width=200, show_toolbar=False
636
+ )
637
+
638
+ # Add ROI plots to side panels
639
+ self.x_panel_index = self.side_panel_x.add_menu(widget=self.x_roi)
640
+ self.y_panel_index = self.side_panel_y.add_menu(widget=self.y_roi)
641
+
642
+ # # Add side panels to the layout
643
+ self.layout_manager.add_widget_relative(
644
+ self.side_panel_x, self.round_plot_widget, position="bottom", shift_direction="down"
645
+ )
646
+ self.layout_manager.add_widget_relative(
647
+ self.side_panel_y, self.round_plot_widget, position="left", shift_direction="right"
648
+ )
649
+
650
+ def toggle_roi_panels(self, checked: bool):
651
+ """
652
+ Show or hide the ROI panels based on the test action toggle state.
653
+
654
+ Args:
655
+ checked (bool): Whether the test action is checked.
656
+ """
657
+ if checked:
658
+ # Show the ROI panels
659
+ self.hook_crosshair()
660
+ self.side_panel_x.show_panel(self.x_panel_index)
661
+ self.side_panel_y.show_panel(self.y_panel_index)
662
+ self.crosshair.coordinatesChanged2D.connect(self.update_image_slices)
663
+ self.image_updated.connect(self.update_image_slices)
664
+ else:
665
+ self.unhook_crosshair()
666
+ # Hide the ROI panels
667
+ self.side_panel_x.hide_panel()
668
+ self.side_panel_y.hide_panel()
669
+ self.image_updated.disconnect(self.update_image_slices)
670
+
671
+ @SafeSlot()
672
+ def update_image_slices(self, coordinates: tuple[int, int, int] = None):
673
+ """
674
+ Update the image slices based on the crosshair position.
675
+
676
+ Args:
677
+ coordinates(tuple): The coordinates of the crosshair.
678
+ """
679
+ if coordinates is None:
680
+ # Try to get coordinates from crosshair position (like in crosshair mouse_moved)
681
+ if (
682
+ hasattr(self, "crosshair")
683
+ and hasattr(self.crosshair, "v_line")
684
+ and hasattr(self.crosshair, "h_line")
685
+ ):
686
+ x = int(round(self.crosshair.v_line.value()))
687
+ y = int(round(self.crosshair.h_line.value()))
688
+ else:
689
+ return
690
+ else:
691
+ x = coordinates[1]
692
+ y = coordinates[2]
693
+ image = self.layer_manager["main"].image.image
694
+ if image is None:
695
+ return
696
+ max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
697
+ row, col = x, y
698
+ if not (0 <= row <= max_row and 0 <= col <= max_col):
699
+ return
700
+ # Horizontal slice
701
+ h_slice = image[:, col]
702
+ x_axis = np.arange(h_slice.shape[0])
703
+ self.x_roi.plot_item.clear()
704
+ self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
705
+ # Vertical slice
706
+ v_slice = image[row, :]
707
+ y_axis = np.arange(v_slice.shape[0])
708
+ self.y_roi.plot_item.clear()
709
+ self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
710
+
711
+ ################################################################################
712
+ # Widget Specific Properties
713
+ ################################################################################
714
+ ################################################################################
715
+ # Rois
716
+
717
+ @property
718
+ def rois(self) -> list[BaseROI]:
719
+ """
720
+ Get the list of ROIs.
721
+ """
722
+ return self.roi_controller.rois
723
+
724
+ ################################################################################
725
+ # Colorbar toggle
726
+
727
+ @SafeProperty(bool)
728
+ def enable_simple_colorbar(self) -> bool:
729
+ """
730
+ Enable the simple colorbar.
731
+ """
732
+ enabled = False
733
+ if self.config.color_bar == "simple":
734
+ enabled = True
735
+ return enabled
736
+
737
+ @enable_simple_colorbar.setter
738
+ def enable_simple_colorbar(self, value: bool):
739
+ """
740
+ Enable the simple colorbar.
741
+
742
+ Args:
743
+ value(bool): Whether to enable the simple colorbar.
744
+ """
745
+ self.enable_colorbar(enabled=value, style="simple")
746
+
747
+ @SafeProperty(bool)
748
+ def enable_full_colorbar(self) -> bool:
749
+ """
750
+ Enable the full colorbar.
751
+ """
752
+ enabled = False
753
+ if self.config.color_bar == "full":
754
+ enabled = True
755
+ return enabled
756
+
757
+ @enable_full_colorbar.setter
758
+ def enable_full_colorbar(self, value: bool):
759
+ """
760
+ Enable the full colorbar.
761
+
762
+ Args:
763
+ value(bool): Whether to enable the full colorbar.
764
+ """
765
+ self.enable_colorbar(enabled=value, style="full")
766
+
767
+ ################################################################################
768
+ # Appearance
769
+
770
+ @SafeProperty(str)
771
+ def color_map(self) -> str:
772
+ """
773
+ Set the color map of the image.
774
+ """
775
+ return self.config.color_map
776
+
777
+ @color_map.setter
778
+ def color_map(self, value: str):
779
+ """
780
+ Set the color map of the image.
781
+
782
+ Args:
783
+ value(str): The color map to set.
784
+ """
785
+ try:
786
+ self.config.color_map = value
787
+ for layer in self.layer_manager:
788
+ if not layer.sync.color_map:
789
+ continue
790
+ layer.image.color_map = value
791
+
792
+ if self._color_bar:
793
+ if self.config.color_bar == "simple":
794
+ self._color_bar.setColorMap(value)
795
+ elif self.config.color_bar == "full":
796
+ self._color_bar.gradient.loadPreset(value)
797
+ except ValidationError:
798
+ return
799
+
800
+ @SafeProperty("QPointF")
801
+ def v_range(self) -> QPointF:
802
+ """
803
+ Set the v_range of the main image.
804
+ """
805
+ vmin, vmax = self.layer_manager["main"].image.v_range
806
+ return QPointF(vmin, vmax)
807
+
808
+ @v_range.setter
809
+ def v_range(self, value: tuple | list | QPointF):
810
+ """
811
+ Set the v_range of the main image.
812
+
813
+ Args:
814
+ value(tuple | list | QPointF): The range of values to set.
815
+ """
816
+ if isinstance(value, (tuple, list)):
817
+ value = self._tuple_to_qpointf(value)
818
+
819
+ vmin, vmax = value.x(), value.y()
820
+
821
+ for layer in self.layer_manager:
822
+ if not layer.sync.v_range:
823
+ continue
824
+ layer.image.v_range = (vmin, vmax)
825
+
826
+ # propagate to colorbar if exists
827
+ if self._color_bar:
828
+ if self.config.color_bar == "simple":
829
+ self._color_bar.setLevels(low=vmin, high=vmax)
830
+ elif self.config.color_bar == "full":
831
+ self._color_bar.setLevels(min=vmin, max=vmax)
832
+ self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
833
+
834
+ self.autorange_switch.set_state_all(False)
835
+
836
+ @property
837
+ def v_min(self) -> float:
838
+ """
839
+ Get the minimum value of the v_range.
840
+ """
841
+ return self.v_range.x()
842
+
843
+ @v_min.setter
844
+ def v_min(self, value: float):
845
+ """
846
+ Set the minimum value of the v_range.
847
+
848
+ Args:
849
+ value(float): The minimum value to set.
850
+ """
851
+ self.v_range = (value, self.v_range.y())
852
+
853
+ @property
854
+ def v_max(self) -> float:
855
+ """
856
+ Get the maximum value of the v_range.
857
+ """
858
+ return self.v_range.y()
859
+
860
+ @v_max.setter
861
+ def v_max(self, value: float):
862
+ """
863
+ Set the maximum value of the v_range.
864
+
865
+ Args:
866
+ value(float): The maximum value to set.
867
+ """
868
+ self.v_range = (self.v_range.x(), value)
869
+
870
+ @SafeProperty(bool)
871
+ def lock_aspect_ratio(self) -> bool:
872
+ """
873
+ Whether the aspect ratio is locked.
874
+ """
875
+ return self.config.lock_aspect_ratio
876
+
877
+ @lock_aspect_ratio.setter
878
+ def lock_aspect_ratio(self, value: bool):
879
+ """
880
+ Set the aspect ratio lock.
881
+
882
+ Args:
883
+ value(bool): Whether to lock the aspect ratio.
884
+ """
885
+ self.config.lock_aspect_ratio = bool(value)
886
+ self.plot_item.setAspectLocked(value)
887
+
888
+ ################################################################################
889
+ # Autorange + Colorbar sync
890
+
891
+ @SafeProperty(bool)
892
+ def autorange(self) -> bool:
893
+ """
894
+ Whether autorange is enabled.
895
+ """
896
+
897
+ # FIXME: this should be made more general
898
+ return self.layer_manager["main"].image.autorange
899
+
900
+ @autorange.setter
901
+ def autorange(self, enabled: bool):
902
+ """
903
+ Set autorange.
904
+
905
+ Args:
906
+ enabled(bool): Whether to enable autorange.
907
+ """
908
+ for layer in self.layer_manager:
909
+ if not layer.sync.autorange:
910
+ continue
911
+ layer.image.autorange = enabled
912
+ if enabled and layer.image.raw_data is not None:
913
+ layer.image.apply_autorange()
914
+ self._sync_colorbar_levels()
915
+ self._sync_autorange_switch()
916
+
917
+ @SafeProperty(str)
918
+ def autorange_mode(self) -> str:
919
+ """
920
+ Autorange mode.
921
+
922
+ Options:
923
+ - "max": Use the maximum value of the image for autoranging.
924
+ - "mean": Use the mean value of the image for autoranging.
925
+
926
+ """
927
+ return self.layer_manager["main"].image.autorange_mode
928
+
929
+ @autorange_mode.setter
930
+ def autorange_mode(self, mode: str):
931
+ """
932
+ Set the autorange mode.
933
+
934
+ Args:
935
+ mode(str): The autorange mode. Options are "max" or "mean".
936
+ """
937
+ # for qt Designer
938
+ if mode not in ["max", "mean"]:
939
+ return
940
+ for layer in self.layer_manager:
941
+ if not layer.sync.autorange_mode:
942
+ continue
943
+ layer.image.autorange_mode = mode
944
+
945
+ self._sync_autorange_switch()
946
+
947
+ @SafeSlot(bool, str, bool)
948
+ def toggle_autorange(self, enabled: bool, mode: str):
949
+ """
950
+ Toggle autorange.
951
+
952
+ Args:
953
+ enabled(bool): Whether to enable autorange.
954
+ mode(str): The autorange mode. Options are "max" or "mean".
955
+ """
956
+ if not self.layer_manager:
957
+ return
958
+
959
+ for layer in self.layer_manager:
960
+ if layer.sync.autorange:
961
+ layer.image.autorange = enabled
962
+ if layer.sync.autorange_mode:
963
+ layer.image.autorange_mode = mode
964
+
965
+ if not enabled:
966
+ continue
967
+ # We only need to apply autorange if we enabled it
968
+ layer.image.apply_autorange()
969
+
970
+ if enabled:
971
+ self._sync_colorbar_levels()
972
+
973
+ def _sync_autorange_switch(self):
974
+ """
975
+ Synchronize the autorange switch with the current autorange state and mode if changed from outside.
976
+ """
977
+ self.autorange_switch.block_all_signals(True)
978
+ self.autorange_switch.set_default_action(
979
+ f"auto_range_{self.layer_manager['main'].image.autorange_mode}"
980
+ )
981
+ self.autorange_switch.set_state_all(self.layer_manager["main"].image.autorange)
982
+ self.autorange_switch.block_all_signals(False)
983
+
984
+ def _sync_colorbar_levels(self):
985
+ """Immediately propagate current levels to the active colorbar."""
986
+
987
+ if not self._color_bar:
988
+ return
989
+
990
+ total_vrange = (0, 0)
991
+ for layer in self.layer_manager:
992
+ if not layer.sync.v_range:
993
+ continue
994
+ img = layer.image
995
+ total_vrange = (min(total_vrange[0], img.v_min), max(total_vrange[1], img.v_max))
996
+
997
+ self._color_bar.blockSignals(True)
998
+ self.v_range = total_vrange # type: ignore
999
+ self._color_bar.blockSignals(False)
1000
+
1001
+ def _sync_colorbar_actions(self):
1002
+ """
1003
+ Synchronize the colorbar actions with the current colorbar state.
1004
+ """
1005
+ self.colorbar_switch.block_all_signals(True)
1006
+ if self._color_bar is not None:
1007
+ self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
1008
+ self.colorbar_switch.set_state_all(True)
1009
+ else:
1010
+ self.colorbar_switch.set_state_all(False)
1011
+ self.colorbar_switch.block_all_signals(False)
1012
+
1013
+ @staticmethod
1014
+ def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
1015
+ """
1016
+ Clean up HistogramLUTItem safely, including open ViewBox menus and child widgets.
1017
+
1018
+ Args:
1019
+ histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up.
1020
+ """
1021
+ histogram_lut_item.vb.menu.close()
1022
+ histogram_lut_item.vb.menu.deleteLater()
1023
+
1024
+ histogram_lut_item.gradient.menu.close()
1025
+ histogram_lut_item.gradient.menu.deleteLater()
1026
+ histogram_lut_item.gradient.colorDialog.close()
1027
+ histogram_lut_item.gradient.colorDialog.deleteLater()
1028
+
1029
+ def cleanup(self):
1030
+ """
1031
+ Cleanup the widget.
1032
+ """
1033
+
1034
+ # Remove all ROIs
1035
+ rois = self.rois
1036
+ for roi in rois:
1037
+ roi.remove()
1038
+
1039
+ # Colorbar Cleanup
1040
+ if self._color_bar:
1041
+ if self.config.color_bar == "full":
1042
+ self.cleanup_histogram_lut_item(self._color_bar)
1043
+ if self.config.color_bar == "simple":
1044
+ self.plot_widget.removeItem(self._color_bar)
1045
+ self._color_bar.deleteLater()
1046
+ self._color_bar = None
1047
+
1048
+ # Popup cleanup
1049
+ if self.roi_manager_dialog is not None:
1050
+ self.roi_manager_dialog.reject()
1051
+ self.roi_manager_dialog = None
1052
+
1053
+ # ROI plots cleanup
1054
+ if self.x_roi is not None:
1055
+ self.x_roi.cleanup_pyqtgraph()
1056
+ if self.y_roi is not None:
1057
+ self.y_roi.cleanup_pyqtgraph()
1058
+
1059
+ self.layer_manager.clear()
1060
+ self.layer_manager = None
1061
+
1062
+ super().cleanup()