bec-widgets 2.3.0__py3-none-any.whl → 2.5.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.
Files changed (46) hide show
  1. .github/ISSUE_TEMPLATE/bug_report.md +26 -0
  2. .github/ISSUE_TEMPLATE/feature_request.md +48 -0
  3. .github/workflows/check_pr.yml +28 -0
  4. .github/workflows/ci.yml +36 -0
  5. .github/workflows/end2end-conda.yml +48 -0
  6. .github/workflows/formatter.yml +61 -0
  7. .github/workflows/generate-cli-check.yml +49 -0
  8. .github/workflows/pytest-matrix.yml +49 -0
  9. .github/workflows/pytest.yml +65 -0
  10. .github/workflows/semantic_release.yml +103 -0
  11. CHANGELOG.md +1726 -1546
  12. LICENSE +1 -1
  13. PKG-INFO +2 -1
  14. README.md +11 -0
  15. bec_widgets/cli/client.py +346 -0
  16. bec_widgets/examples/jupyter_console/jupyter_console_window.py +8 -8
  17. bec_widgets/tests/utils.py +3 -3
  18. bec_widgets/utils/entry_validator.py +13 -3
  19. bec_widgets/utils/side_panel.py +65 -39
  20. bec_widgets/utils/toolbar.py +79 -0
  21. bec_widgets/widgets/containers/layout_manager/layout_manager.py +34 -1
  22. bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +1 -1
  23. bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +27 -31
  24. bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +1 -1
  25. bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +1 -1
  26. bec_widgets/widgets/editors/dict_backed_table.py +7 -0
  27. bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +1 -0
  28. bec_widgets/widgets/editors/web_console/register_web_console.py +15 -0
  29. bec_widgets/widgets/editors/web_console/web_console.py +230 -0
  30. bec_widgets/widgets/editors/web_console/web_console.pyproject +1 -0
  31. bec_widgets/widgets/editors/web_console/web_console_plugin.py +54 -0
  32. bec_widgets/widgets/plots/image/image.py +90 -0
  33. bec_widgets/widgets/plots/roi/__init__.py +0 -0
  34. bec_widgets/widgets/plots/roi/image_roi.py +867 -0
  35. bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +11 -46
  36. bec_widgets/widgets/utility/visual/color_button_native/__init__.py +0 -0
  37. bec_widgets/widgets/utility/visual/color_button_native/color_button_native.py +58 -0
  38. bec_widgets/widgets/utility/visual/color_button_native/color_button_native.pyproject +1 -0
  39. bec_widgets/widgets/utility/visual/color_button_native/color_button_native_plugin.py +56 -0
  40. bec_widgets/widgets/utility/visual/color_button_native/register_color_button_native.py +17 -0
  41. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/METADATA +2 -1
  42. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/RECORD +46 -25
  43. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/licenses/LICENSE +1 -1
  44. pyproject.toml +17 -5
  45. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/WHEEL +0 -0
  46. {bec_widgets-2.3.0.dist-info → bec_widgets-2.5.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,867 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import pyqtgraph as pg
6
+ from pyqtgraph import TextItem
7
+ from pyqtgraph import functions as fn
8
+ from pyqtgraph import mkPen
9
+ from qtpy import QtCore
10
+ from qtpy.QtCore import QObject, Signal
11
+
12
+ from bec_widgets import SafeProperty
13
+ from bec_widgets.utils import BECConnector, ConnectionConfig
14
+ from bec_widgets.utils.colors import Colors
15
+
16
+ if TYPE_CHECKING:
17
+ from bec_widgets.widgets.plots.image.image import Image
18
+
19
+
20
+ class LabelAdorner:
21
+ """Manages a TextItem label on top of any ROI, keeping it aligned."""
22
+
23
+ def __init__(
24
+ self,
25
+ roi: BaseROI,
26
+ anchor: tuple[int, int] = (0, 1),
27
+ padding: int = 2,
28
+ bg_color: str | tuple[int, int, int, int] = (0, 0, 0, 100),
29
+ text_color: str | tuple[int, int, int, int] = "white",
30
+ ):
31
+ """
32
+ Initializes a label overlay for a given region of interest (ROI), allowing for customization
33
+ of text placement, padding, background color, and text color. Automatically attaches the label
34
+ to the ROI and updates its position and content based on ROI changes.
35
+
36
+ Args:
37
+ roi: The region of interest to which the label will be attached.
38
+ anchor: Tuple specifying the label's anchor relative to the ROI. Default is (0, 1).
39
+ padding: Integer specifying the padding around the label's text. Default is 2.
40
+ bg_color: RGBA tuple for the label's background color. Default is (0, 0, 0, 100).
41
+ text_color: String specifying the color of the label's text. Default is "white".
42
+ """
43
+ self.roi = roi
44
+ self.label = TextItem(anchor=anchor)
45
+ self.padding = padding
46
+ self.bg_rgba = bg_color
47
+ self.text_color = text_color
48
+ roi.addItem(self.label) if hasattr(roi, "addItem") else self.label.setParentItem(roi)
49
+ # initial draw
50
+ self._update_html(roi.label)
51
+ self._reposition()
52
+ # reconnect on geometry/name changes
53
+ roi.sigRegionChanged.connect(self._reposition)
54
+ if hasattr(roi, "nameChanged"):
55
+ roi.nameChanged.connect(self._update_html)
56
+
57
+ def _update_html(self, text: str):
58
+ """
59
+ Updates the HTML content of the label with the given text.
60
+
61
+ Creates an HTML div with the configured background color, text color, and padding,
62
+ then sets this HTML as the content of the label.
63
+
64
+ Args:
65
+ text (str): The text to display in the label.
66
+ """
67
+ html = (
68
+ f'<div style="background: rgba{self.bg_rgba}; '
69
+ f"font-weight:bold; color:{self.text_color}; "
70
+ f'padding:{self.padding}px;">{text}</div>'
71
+ )
72
+ self.label.setHtml(html)
73
+
74
+ def _reposition(self):
75
+ """
76
+ Repositions the label to align with the ROI's current position.
77
+
78
+ This method is called whenever the ROI's position or size changes.
79
+ It places the label at the bottom-left corner of the ROI's bounding rectangle.
80
+ """
81
+ # put at top-left corner of ROI’s bounding rect
82
+ size = self.roi.state["size"]
83
+ height = size[1]
84
+ self.label.setPos(0, height)
85
+
86
+
87
+ class BaseROI(BECConnector):
88
+ """Base class for all Region of Interest (ROI) implementations.
89
+
90
+ This class serves as a mixin that provides common properties and methods for ROIs,
91
+ including name, line color, and line width properties. It inherits from BECConnector
92
+ to enable remote procedure call functionality.
93
+
94
+ Attributes:
95
+ RPC (bool): Flag indicating if remote procedure calls are enabled.
96
+ PLUGIN (bool): Flag indicating if this class is a plugin.
97
+ nameChanged (Signal): Signal emitted when the ROI name changes.
98
+ penChanged (Signal): Signal emitted when the ROI pen (color/width) changes.
99
+ USER_ACCESS (list): List of methods and properties accessible via RPC.
100
+ """
101
+
102
+ RPC = True
103
+ PLUGIN = False
104
+
105
+ nameChanged = Signal(str)
106
+ penChanged = Signal()
107
+ USER_ACCESS = [
108
+ "label",
109
+ "label.setter",
110
+ "line_color",
111
+ "line_color.setter",
112
+ "line_width",
113
+ "line_width.setter",
114
+ "get_coordinates",
115
+ "get_data_from_image",
116
+ ]
117
+
118
+ def __init__(
119
+ self,
120
+ *,
121
+ # BECConnector kwargs
122
+ config: ConnectionConfig | None = None,
123
+ gui_id: str | None = None,
124
+ parent_image: Image | None,
125
+ # ROI-specific
126
+ label: str | None = None,
127
+ line_color: str | None = None,
128
+ line_width: int = 10,
129
+ # all remaining pg.*ROI kwargs (pos, size, pen, …)
130
+ **pg_kwargs,
131
+ ):
132
+ """Base class for all modular ROIs.
133
+
134
+ Args:
135
+ label (str): Human-readable name shown in ROI Manager and labels.
136
+ line_color (str | None, optional): Initial pen color. Defaults to None.
137
+ Controller may override color later.
138
+ line_width (int, optional): Initial pen width. Defaults to 15.
139
+ Controller may override width later.
140
+ config (ConnectionConfig | None, optional): Standard BECConnector argument. Defaults to None.
141
+ gui_id (str | None, optional): Standard BECConnector argument. Defaults to None.
142
+ parent_image (BECConnector | None, optional): Standard BECConnector argument. Defaults to None.
143
+ """
144
+ if config is None:
145
+ config = ConnectionConfig(widget_class=self.__class__.__name__)
146
+ self.config = config
147
+
148
+ self.set_parent(parent_image)
149
+ self.parent_plot_item = parent_image.plot_item
150
+ object_name = label.replace("-", "_").replace(" ", "_") if label else None
151
+ super().__init__(
152
+ object_name=object_name, config=config, gui_id=gui_id, removable=True, **pg_kwargs
153
+ )
154
+
155
+ self._label = label or "ROI"
156
+ self._line_color = line_color or "#ffffff"
157
+ self._line_width = line_width
158
+ self._description = True
159
+ self.setPen(mkPen(self._line_color, width=self._line_width))
160
+
161
+ def set_parent(self, parent: Image):
162
+ """
163
+ Sets the parent image for this ROI.
164
+
165
+ Args:
166
+ parent (Image): The parent image object to associate with this ROI.
167
+ """
168
+ self.parent_image = parent
169
+
170
+ def parent(self):
171
+ """
172
+ Gets the parent image associated with this ROI.
173
+
174
+ Returns:
175
+ Image: The parent image object, or None if no parent is set.
176
+ """
177
+ return self.parent_image
178
+
179
+ @property
180
+ def label(self) -> str:
181
+ """
182
+ Gets the display name of this ROI.
183
+
184
+ Returns:
185
+ str: The current name of the ROI.
186
+ """
187
+ return self._label
188
+
189
+ @label.setter
190
+ def label(self, new: str):
191
+ """
192
+ Sets the display name of this ROI.
193
+
194
+ If the new name is different from the current name, this method updates
195
+ the internal name, emits the nameChanged signal, and updates the object name.
196
+
197
+ Args:
198
+ new (str): The new name to set for the ROI.
199
+ """
200
+ if new != self._label:
201
+ self._label = new
202
+ self.nameChanged.emit(new)
203
+ self.change_object_name(new)
204
+
205
+ @property
206
+ def line_color(self) -> str:
207
+ """
208
+ Gets the current line color of the ROI.
209
+
210
+ Returns:
211
+ str: The current line color as a string (e.g., hex color code).
212
+ """
213
+ return self._line_color
214
+
215
+ @line_color.setter
216
+ def line_color(self, value: str):
217
+ """
218
+ Sets the line color of the ROI.
219
+
220
+ If the new color is different from the current color, this method updates
221
+ the internal color value, updates the pen while preserving the line width,
222
+ and emits the penChanged signal.
223
+
224
+ Args:
225
+ value (str): The new color to set for the ROI's outline (e.g., hex color code).
226
+ """
227
+ if value != self._line_color:
228
+ self._line_color = value
229
+ # update pen but preserve width
230
+ self.setPen(mkPen(value, width=self._line_width))
231
+ self.penChanged.emit()
232
+
233
+ @property
234
+ def line_width(self) -> int:
235
+ """
236
+ Gets the current line width of the ROI.
237
+
238
+ Returns:
239
+ int: The current line width in pixels.
240
+ """
241
+ return self._line_width
242
+
243
+ @line_width.setter
244
+ def line_width(self, value: int):
245
+ """
246
+ Sets the line width of the ROI.
247
+
248
+ If the new width is different from the current width and is greater than 0,
249
+ this method updates the internal width value, updates the pen while preserving
250
+ the line color, and emits the penChanged signal.
251
+
252
+ Args:
253
+ value (int): The new width to set for the ROI's outline in pixels.
254
+ Must be greater than 0.
255
+ """
256
+ if value != self._line_width and value > 0:
257
+ self._line_width = value
258
+ self.setPen(mkPen(self._line_color, width=value))
259
+ self.penChanged.emit()
260
+
261
+ @property
262
+ def description(self) -> bool:
263
+ """
264
+ Gets whether ROI coordinates should be emitted with descriptive keys by default.
265
+
266
+ Returns:
267
+ bool: True if coordinates should include descriptive keys, False otherwise.
268
+ """
269
+ return self._description
270
+
271
+ @description.setter
272
+ def description(self, value: bool):
273
+ """
274
+ Sets whether ROI coordinates should be emitted with descriptive keys by default.
275
+
276
+ This affects the default behavior of the get_coordinates method.
277
+
278
+ Args:
279
+ value (bool): True to emit coordinates with descriptive keys, False to emit
280
+ as a simple tuple of values.
281
+ """
282
+ self._description = value
283
+
284
+ def get_coordinates(self):
285
+ """
286
+ Gets the coordinates that define this ROI's position and shape.
287
+
288
+ This is an abstract method that must be implemented by subclasses.
289
+ Implementations should return either a dictionary with descriptive keys
290
+ or a tuple of coordinates, depending on the value of self.description.
291
+
292
+ Returns:
293
+ dict or tuple: The coordinates defining the ROI's position and shape.
294
+
295
+ Raises:
296
+ NotImplementedError: This method must be implemented by subclasses.
297
+ """
298
+ raise NotImplementedError("Subclasses must implement get_coordinates()")
299
+
300
+ def get_data_from_image(
301
+ self, image_item: pg.ImageItem | None = None, returnMappedCoords: bool = False, **kwargs
302
+ ):
303
+ """Wrapper around `pyqtgraph.ROI.getArrayRegion`.
304
+
305
+ Args:
306
+ image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
307
+ the first `ImageItem` in the same GraphicsScene as this ROI.
308
+ returnMappedCoords (bool): If True, also returns the coordinate array generated by
309
+ *getArrayRegion*.
310
+ **kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
311
+ such as `axes`, `order`, `shape`, etc.
312
+
313
+ Returns:
314
+ ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
315
+ """
316
+ if image_item is None:
317
+ image_item = next(
318
+ (
319
+ it
320
+ for it in self.scene().items()
321
+ if isinstance(it, pg.ImageItem) and it.image is not None
322
+ ),
323
+ None,
324
+ )
325
+ if image_item is None:
326
+ raise RuntimeError("No ImageItem found in the current scene.")
327
+
328
+ data = image_item.image # the raw ndarray held by ImageItem
329
+ return self.getArrayRegion(
330
+ data, img=image_item, returnMappedCoords=returnMappedCoords, **kwargs
331
+ )
332
+
333
+ def add_scale_handle(self):
334
+ return
335
+
336
+ def remove(self):
337
+ handles = self.handles
338
+ for i in range(len(handles)):
339
+ try:
340
+ self.removeHandle(0)
341
+ except IndexError:
342
+ continue
343
+ self.rpc_register.remove_rpc(self)
344
+ self.parent_image.plot_item.removeItem(self)
345
+ if hasattr(self.parent_image, "roi_controller"):
346
+ self.parent_image.roi_controller._rois.remove(self)
347
+ self.parent_image.roi_controller._rebuild_color_buffer()
348
+
349
+
350
+ class RectangularROI(BaseROI, pg.RectROI):
351
+ """
352
+ Defines a rectangular Region of Interest (ROI) with additional functionality.
353
+
354
+ Provides tools for manipulating and extracting data from rectangular areas on
355
+ images, includes support for GUI features and event-driven signaling.
356
+
357
+ Attributes:
358
+ edgesChanged (Signal): Signal emitted when the ROI edges change, providing
359
+ the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
360
+ edgesReleased (Signal): Signal emitted when the ROI edges are released,
361
+ providing the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
362
+ """
363
+
364
+ edgesChanged = Signal(float, float, float, float)
365
+ edgesReleased = Signal(float, float, float, float)
366
+
367
+ def __init__(
368
+ self,
369
+ *,
370
+ # pg.RectROI kwargs
371
+ pos: tuple[float, float],
372
+ size: tuple[float, float],
373
+ pen=None,
374
+ # BECConnector kwargs
375
+ config: ConnectionConfig | None = None,
376
+ gui_id: str | None = None,
377
+ parent_image: Image | None = None,
378
+ # ROI specifics
379
+ label: str | None = None,
380
+ line_color: str | None = None,
381
+ line_width: int = 10,
382
+ resize_handles: bool = True,
383
+ **extra_pg,
384
+ ):
385
+ """
386
+ Initializes an instance with properties for defining a rectangular ROI with handles,
387
+ configurations, and an auto-aligning label. Also connects a signal for region updates.
388
+
389
+ Args:
390
+ pos: Initial position of the ROI.
391
+ size: Initial size of the ROI.
392
+ pen: Defines the border appearance; can be color or style.
393
+ config: Optional configuration details for the connection.
394
+ gui_id: Optional identifier for the associated GUI element.
395
+ parent_image: Optional parent object the ROI is related to.
396
+ label: Optional label for identification within the context.
397
+ line_color: Optional color of the ROI outline.
398
+ line_width: Width of the ROI's outline in pixels.
399
+ parent_plot_item: The plot item this ROI belongs to.
400
+ **extra_pg: Additional keyword arguments specific to pg.RectROI.
401
+ """
402
+ super().__init__(
403
+ config=config,
404
+ gui_id=gui_id,
405
+ parent_image=parent_image,
406
+ label=label,
407
+ line_color=line_color,
408
+ line_width=line_width,
409
+ pos=pos,
410
+ size=size,
411
+ pen=pen,
412
+ **extra_pg,
413
+ )
414
+
415
+ self.sigRegionChanged.connect(self._on_region_changed)
416
+ self.adorner = LabelAdorner(roi=self)
417
+ if resize_handles:
418
+ self.add_scale_handle()
419
+ self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
420
+ self.handleHoverPen = fn.mkPen("lime", width=4)
421
+
422
+ def add_scale_handle(self):
423
+ """
424
+ Add scale handles at every corner and edge of the ROI.
425
+
426
+ Corner handles are anchored to the centre for two-axis scaling.
427
+ Edge handles are anchored to the midpoint of the opposite edge for single-axis scaling.
428
+ """
429
+ centre = [0.5, 0.5]
430
+
431
+ # Corner handles – anchored to the centre for two-axis scaling
432
+ self.addScaleHandle([0, 0], centre) # top‑left
433
+ self.addScaleHandle([1, 0], centre) # top‑right
434
+ self.addScaleHandle([0, 1], centre) # bottom‑left
435
+ self.addScaleHandle([1, 1], centre) # bottom‑right
436
+
437
+ # Edge handles – anchored to the midpoint of the opposite edge
438
+ self.addScaleHandle([0.5, 0], [0.5, 1]) # top edge
439
+ self.addScaleHandle([0.5, 1], [0.5, 0]) # bottom edge
440
+ self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
441
+ self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
442
+
443
+ def _on_region_changed(self):
444
+ """
445
+ Handles ROI region change events.
446
+
447
+ This method is called whenever the ROI's position or size changes.
448
+ It calculates the new corner coordinates and emits the edgesChanged signal
449
+ with the updated coordinates.
450
+ """
451
+ x0, y0 = self.pos().x(), self.pos().y()
452
+ w, h = self.state["size"]
453
+ self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
454
+ viewBox = self.parent_plot_item.vb
455
+ viewBox.update()
456
+
457
+ def mouseDragEvent(self, ev):
458
+ """
459
+ Handles mouse drag events on the ROI.
460
+
461
+ This method extends the parent class implementation to emit the edgesReleased
462
+ signal when the mouse drag is finished, providing the final coordinates of the ROI.
463
+
464
+ Args:
465
+ ev: The mouse event object containing information about the drag operation.
466
+ """
467
+ super().mouseDragEvent(ev)
468
+ if ev.isFinish():
469
+ x0, y0 = self.pos().x(), self.pos().y()
470
+ w, h = self.state["size"]
471
+ self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
472
+
473
+ def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
474
+ """
475
+ Returns the coordinates of a rectangle's corners. Supports returning them
476
+ as either a dictionary with descriptive keys or a tuple of coordinates.
477
+
478
+ Args:
479
+ typed (bool | None): If True, returns coordinates as a dictionary with
480
+ descriptive keys. If False, returns them as a tuple. Defaults to
481
+ the value of `self.description`.
482
+
483
+ Returns:
484
+ dict | tuple: The rectangle's corner coordinates, where the format
485
+ depends on the `typed` parameter.
486
+ """
487
+ if typed is None:
488
+ typed = self.description
489
+
490
+ x0, y0 = self.pos().x(), self.pos().y()
491
+ w, h = self.state["size"]
492
+ x1, y1 = x0 + w, y0 + h
493
+ if typed:
494
+ return {
495
+ "bottom_left": (x0, y0),
496
+ "bottom_right": (x1, y0),
497
+ "top_left": (x0, y1),
498
+ "top_right": (x1, y1),
499
+ }
500
+ return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
501
+
502
+ def _lookup_scene_image(self):
503
+ """
504
+ Searches for an image in the current scene.
505
+
506
+ This helper method iterates through all items in the scene and returns
507
+ the first pg.ImageItem that has a non-None image property.
508
+
509
+ Returns:
510
+ numpy.ndarray or None: The image from the first found ImageItem,
511
+ or None if no suitable image is found.
512
+ """
513
+ for it in self.scene().items():
514
+ if isinstance(it, pg.ImageItem) and it.image is not None:
515
+ return it.image
516
+ return None
517
+
518
+
519
+ class CircularROI(BaseROI, pg.CircleROI):
520
+ """Circular Region of Interest with center/diameter tracking and auto-labeling.
521
+
522
+ This class extends the BaseROI and pg.CircleROI classes to provide a circular ROI
523
+ that emits signals when its center or diameter changes, and includes an auto-aligning
524
+ label for visual identification.
525
+
526
+ Attributes:
527
+ centerChanged (Signal): Signal emitted when the ROI center or diameter changes,
528
+ providing the new (center_x, center_y, diameter) values.
529
+ centerReleased (Signal): Signal emitted when the ROI is released after dragging,
530
+ providing the final (center_x, center_y, diameter) values.
531
+ """
532
+
533
+ centerChanged = Signal(float, float, float)
534
+ centerReleased = Signal(float, float, float)
535
+
536
+ def __init__(
537
+ self,
538
+ *,
539
+ pos,
540
+ size,
541
+ pen=None,
542
+ config: ConnectionConfig | None = None,
543
+ gui_id: str | None = None,
544
+ parent_image: Image | None = None,
545
+ label: str | None = None,
546
+ line_color: str | None = None,
547
+ line_width: int = 10,
548
+ **extra_pg,
549
+ ):
550
+ """
551
+ Initializes a circular ROI with the specified properties.
552
+
553
+ Creates a circular ROI at the given position and with the given size,
554
+ connects signals for tracking changes, and attaches an auto-aligning label.
555
+
556
+ Args:
557
+ pos: Initial position of the ROI as [x, y].
558
+ size: Initial size of the ROI as [diameter, diameter].
559
+ pen: Defines the border appearance; can be color or style.
560
+ config (ConnectionConfig | None, optional): Configuration for BECConnector. Defaults to None.
561
+ gui_id (str | None, optional): Identifier for the GUI element. Defaults to None.
562
+ parent_image (BECConnector | None, optional): Parent image object. Defaults to None.
563
+ label (str | None, optional): Display name for the ROI. Defaults to None.
564
+ line_color (str | None, optional): Color of the ROI outline. Defaults to None.
565
+ line_width (int, optional): Width of the ROI outline in pixels. Defaults to 3.
566
+ parent_plot_item: The plot item this ROI belongs to.
567
+ **extra_pg: Additional keyword arguments for pg.CircleROI.
568
+ """
569
+ super().__init__(
570
+ config=config,
571
+ gui_id=gui_id,
572
+ parent_image=parent_image,
573
+ label=label,
574
+ line_color=line_color,
575
+ line_width=line_width,
576
+ pos=pos,
577
+ size=size,
578
+ pen=pen,
579
+ **extra_pg,
580
+ )
581
+ self.sigRegionChanged.connect(self._on_region_changed)
582
+ self._adorner = LabelAdorner(self)
583
+
584
+ def _on_region_changed(self):
585
+ """
586
+ Handles ROI region change events.
587
+
588
+ This method is called whenever the ROI's position or size changes.
589
+ It calculates the center coordinates and diameter of the circle and
590
+ emits the centerChanged signal with these values.
591
+ """
592
+ d = self.state["size"][0]
593
+ cx = self.pos().x() + d / 2
594
+ cy = self.pos().y() + d / 2
595
+ self.centerChanged.emit(cx, cy, d)
596
+ viewBox = self.parent_plot_item.getViewBox()
597
+ viewBox.update()
598
+
599
+ def mouseDragEvent(self, ev):
600
+ """
601
+ Handles mouse drag events on the ROI.
602
+
603
+ This method extends the parent class implementation to emit the centerReleased
604
+ signal when the mouse drag is finished, providing the final center coordinates
605
+ and diameter of the circular ROI.
606
+
607
+ Args:
608
+ ev: The mouse event object containing information about the drag operation.
609
+ """
610
+ super().mouseDragEvent(ev)
611
+ if ev.isFinish():
612
+ d = self.state["size"][0]
613
+ cx = self.pos().x() + d / 2
614
+ cy = self.pos().y() + d / 2
615
+ self.centerReleased.emit(cx, cy, d)
616
+
617
+ def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
618
+ """
619
+ Calculates and returns the coordinates and size of an object, either as a
620
+ typed dictionary or as a tuple.
621
+
622
+ Args:
623
+ typed (bool | None): If True, returns coordinates as a dictionary. Defaults
624
+ to None, which utilizes the object's description value.
625
+
626
+ Returns:
627
+ dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
628
+ if `typed` is True.
629
+ tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
630
+ """
631
+ if typed is None:
632
+ typed = self.description
633
+
634
+ d = self.state["size"][0]
635
+ cx = self.pos().x() + d / 2
636
+ cy = self.pos().y() + d / 2
637
+
638
+ if typed:
639
+ return {"center_x": cx, "center_y": cy, "diameter": d, "radius": d / 2}
640
+ return (cx, cy, d, d / 2)
641
+
642
+ def _lookup_scene_image(self) -> pg.ImageItem | None:
643
+ """
644
+ Retrieves an image from the scene items if available.
645
+
646
+ Iterates over all items in the scene and checks if any of them are of type
647
+ `pg.ImageItem` and have a non-None image. If such an item is found, its image
648
+ is returned.
649
+
650
+ Returns:
651
+ pg.ImageItem or None: The first found ImageItem with a non-None image,
652
+ or None if no suitable image is found.
653
+ """
654
+ for it in self.scene().items():
655
+ if isinstance(it, pg.ImageItem) and it.image is not None:
656
+ return it.image
657
+ return None
658
+
659
+
660
+ class ROIController(QObject):
661
+ """Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
662
+
663
+ Handles creating, adding, removing, and managing ROI instances. Supports color assignment
664
+ from a colormap, and provides utility methods to access and manipulate ROIs.
665
+
666
+ Attributes:
667
+ roiAdded (Signal): Emits the new ROI instance when added.
668
+ roiRemoved (Signal): Emits the removed ROI instance when deleted.
669
+ cleared (Signal): Emits when all ROIs are removed.
670
+ paletteChanged (Signal): Emits the new colormap name when updated.
671
+ _colormap (str): Name of the colormap used for ROI colors.
672
+ _rois (list[BaseROI]): Internal list storing currently managed ROIs.
673
+ _colors (list[str]): Internal list of colors for the ROIs.
674
+ """
675
+
676
+ roiAdded = Signal(object) # emits the new ROI instance
677
+ roiRemoved = Signal(object) # emits the removed ROI instance
678
+ cleared = Signal() # emits when all ROIs are removed
679
+ paletteChanged = Signal(str) # emits new colormap name
680
+
681
+ def __init__(self, colormap="viridis"):
682
+ """
683
+ Initializes the ROI controller with the specified colormap.
684
+
685
+ Sets up internal data structures for managing ROIs and their colors.
686
+
687
+ Args:
688
+ colormap (str, optional): The name of the colormap to use for ROI colors.
689
+ Defaults to "viridis".
690
+ """
691
+ super().__init__()
692
+ self._colormap = colormap
693
+ self._rois: list[BaseROI] = []
694
+ self._colors: list[str] = []
695
+ self._rebuild_color_buffer()
696
+
697
+ def _rebuild_color_buffer(self):
698
+ """
699
+ Regenerates the color buffer for ROIs.
700
+
701
+ This internal method creates a new list of colors based on the current colormap
702
+ and the number of ROIs. It ensures there's always one more color than the number
703
+ of ROIs to allow for adding a new ROI without regenerating the colors.
704
+ """
705
+ n = len(self._rois) + 1
706
+ self._colors = Colors.golden_angle_color(colormap=self._colormap, num=n, format="HEX")
707
+
708
+ def add_roi(self, roi: BaseROI):
709
+ """
710
+ Registers an externally created ROI with this controller.
711
+
712
+ Adds the ROI to the internal list, assigns it a color from the color buffer,
713
+ ensures it has an appropriate line width, and emits the roiAdded signal.
714
+
715
+ Args:
716
+ roi (BaseROI): The ROI instance to register. Can be any subclass of BaseROI,
717
+ such as RectangularROI or CircularROI.
718
+ """
719
+ self._rois.append(roi)
720
+ self._rebuild_color_buffer()
721
+ idx = len(self._rois) - 1
722
+ if roi.label == "ROI" or roi.label.startswith("ROI "):
723
+ roi.label = f"ROI {idx}"
724
+ color = self._colors[idx]
725
+ roi.line_color = color
726
+ # ensure line width default is at least 3 if not previously set
727
+ if getattr(roi, "line_width", 0) < 1:
728
+ roi.line_width = 10
729
+ self.roiAdded.emit(roi)
730
+
731
+ def remove_roi(self, roi: BaseROI):
732
+ """
733
+ Removes an ROI from this controller.
734
+
735
+ If the ROI is found in the internal list, it is removed, the color buffer
736
+ is regenerated, and the roiRemoved signal is emitted.
737
+
738
+ Args:
739
+ roi (BaseROI): The ROI instance to remove.
740
+ """
741
+ rois = self._rois
742
+ if roi not in rois:
743
+ roi.remove()
744
+
745
+ def get_roi(self, index: int) -> BaseROI | None:
746
+ """
747
+ Returns the ROI at the specified index.
748
+
749
+ Args:
750
+ index (int): The index of the ROI to retrieve.
751
+
752
+ Returns:
753
+ BaseROI or None: The ROI at the specified index, or None if the index
754
+ is out of range.
755
+ """
756
+ if 0 <= index < len(self._rois):
757
+ return self._rois[index]
758
+ return None
759
+
760
+ def get_roi_by_name(self, name: str) -> BaseROI | None:
761
+ """
762
+ Returns the first ROI with the specified name.
763
+
764
+ Args:
765
+ name (str): The name to search for (case-sensitive).
766
+
767
+ Returns:
768
+ BaseROI or None: The first ROI with a matching name, or None if no
769
+ matching ROI is found.
770
+ """
771
+ for r in self._rois:
772
+ if r.label == name:
773
+ return r
774
+ return None
775
+
776
+ def remove_roi_by_index(self, index: int):
777
+ """
778
+ Removes the ROI at the specified index.
779
+
780
+ Args:
781
+ index (int): The index of the ROI to remove.
782
+ """
783
+ roi = self.get_roi(index)
784
+ if roi is not None:
785
+ roi.remove()
786
+
787
+ def remove_roi_by_name(self, name: str):
788
+ """
789
+ Removes the first ROI with the specified name.
790
+
791
+ Args:
792
+ name (str): The name of the ROI to remove (case-sensitive).
793
+ """
794
+ roi = self.get_roi_by_name(name)
795
+ if roi is not None:
796
+ roi.remove()
797
+
798
+ def clear(self):
799
+ """
800
+ Removes all ROIs from this controller.
801
+
802
+ Iterates through all ROIs and removes them one by one, then emits
803
+ the cleared signal to notify listeners that all ROIs have been removed.
804
+ """
805
+ for roi in list(self._rois):
806
+ roi.remove()
807
+ self.cleared.emit()
808
+
809
+ def renormalize_colors(self):
810
+ """
811
+ Reassigns palette colors to all ROIs in order.
812
+
813
+ Regenerates the color buffer based on the current colormap and number of ROIs,
814
+ then assigns each ROI a color from the buffer in the order they were added.
815
+ This is useful after changing the colormap or when ROIs need to be visually
816
+ distinguished from each other.
817
+ """
818
+ self._rebuild_color_buffer()
819
+ for idx, roi in enumerate(self._rois):
820
+ roi.line_color = self._colors[idx]
821
+
822
+ @SafeProperty(str)
823
+ def colormap(self):
824
+ """
825
+ Gets the name of the colormap used for ROI colors.
826
+
827
+ Returns:
828
+ str: The name of the colormap.
829
+ """
830
+ return self._colormap
831
+
832
+ @colormap.setter
833
+ def colormap(self, cmap: str):
834
+ """
835
+ Sets the colormap used for ROI colors.
836
+
837
+ Updates the internal colormap name and reassigns colors to all ROIs
838
+ based on the new colormap.
839
+
840
+ Args:
841
+ cmap (str): The name of the colormap to use (e.g., "viridis", "plasma").
842
+ """
843
+
844
+ self.set_colormap(cmap)
845
+
846
+ def set_colormap(self, cmap: str):
847
+ Colors.validate_color_map(cmap)
848
+ self._colormap = cmap
849
+ self.paletteChanged.emit(cmap)
850
+ self.renormalize_colors()
851
+
852
+ @property
853
+ def rois(self) -> list[BaseROI]:
854
+ """
855
+ Gets a copy of the list of ROIs managed by this controller.
856
+
857
+ Returns a new list containing all the ROIs currently managed by this controller.
858
+ The list is a copy, so modifying it won't affect the controller's internal list.
859
+
860
+ Returns:
861
+ list[BaseROI]: A list of all ROIs currently managed by this controller.
862
+ """
863
+ return list(self._rois)
864
+
865
+ def cleanup(self):
866
+ for roi in self._rois:
867
+ self.remove_roi(roi)