bec-widgets 2.4.3__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.
- .github/workflows/pytest-matrix.yml +5 -4
- .github/workflows/pytest.yml +5 -4
- CHANGELOG.md +13 -0
- PKG-INFO +1 -1
- bec_widgets/cli/client.py +335 -0
- bec_widgets/examples/jupyter_console/jupyter_console_window.py +8 -8
- bec_widgets/widgets/plots/image/image.py +90 -0
- bec_widgets/widgets/plots/roi/__init__.py +0 -0
- bec_widgets/widgets/plots/roi/image_roi.py +867 -0
- {bec_widgets-2.4.3.dist-info → bec_widgets-2.5.0.dist-info}/METADATA +1 -1
- {bec_widgets-2.4.3.dist-info → bec_widgets-2.5.0.dist-info}/RECORD +15 -13
- pyproject.toml +1 -1
- {bec_widgets-2.4.3.dist-info → bec_widgets-2.5.0.dist-info}/WHEEL +0 -0
- {bec_widgets-2.4.3.dist-info → bec_widgets-2.5.0.dist-info}/entry_points.txt +0 -0
- {bec_widgets-2.4.3.dist-info → bec_widgets-2.5.0.dist-info}/licenses/LICENSE +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)
|