mesofield 0.3.2b0__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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- scripts/bench_frame_processor.py +103 -0
mesofield/gui/viewer.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from typing import Tuple, Union, Literal
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
import numpy as np
|
|
5
|
+
from pymmcore_plus import CMMCorePlus
|
|
6
|
+
from qtpy.QtCore import Qt, QTimer
|
|
7
|
+
from qtpy.QtGui import QImage, QPixmap
|
|
8
|
+
from qtpy.QtWidgets import (
|
|
9
|
+
QVBoxLayout,
|
|
10
|
+
QProgressBar,
|
|
11
|
+
QLabel,
|
|
12
|
+
QWidget,
|
|
13
|
+
QSizePolicy,
|
|
14
|
+
)
|
|
15
|
+
from threading import Lock
|
|
16
|
+
|
|
17
|
+
class ImagePreview(QWidget):
|
|
18
|
+
"""
|
|
19
|
+
A PyQt widget that displays images from a `CMMCorePlus` instance (mmcore).
|
|
20
|
+
|
|
21
|
+
This widget displays images from a single `CMMCorePlus` instance,
|
|
22
|
+
updating the display in real-time as new images are captured.
|
|
23
|
+
|
|
24
|
+
The image is displayed using PyQt's `QLabel` and `QPixmap`, allowing for efficient
|
|
25
|
+
rendering without external dependencies like VisPy.
|
|
26
|
+
|
|
27
|
+
**Parameters**
|
|
28
|
+
----------
|
|
29
|
+
parent : QWidget, optional
|
|
30
|
+
The parent widget. Defaults to `None`.
|
|
31
|
+
mmcore : CMMCorePlus
|
|
32
|
+
The `CMMCorePlus` instance from which images will be displayed.
|
|
33
|
+
Represents the microscope control core.
|
|
34
|
+
use_with_mda : bool, optional
|
|
35
|
+
If `True`, the widget will update during Multi-Dimensional Acquisitions (MDA).
|
|
36
|
+
If `False`, the widget will not update during MDA. Defaults to `True`.
|
|
37
|
+
|
|
38
|
+
**Attributes**
|
|
39
|
+
----------
|
|
40
|
+
clims : Union[Tuple[float, float], Literal["auto"]]
|
|
41
|
+
The contrast limits for the image display. If set to `"auto"`, the widget will
|
|
42
|
+
automatically adjust the contrast limits based on the image data.
|
|
43
|
+
cmap : str
|
|
44
|
+
The colormap to use for the image display. Currently set to `"grayscale"`.
|
|
45
|
+
|
|
46
|
+
**Notes**
|
|
47
|
+
-----
|
|
48
|
+
- **Image Display**: Uses a `QLabel` widget to display the image.
|
|
49
|
+
The image is set to scale to fit the label size (`setScaledContents(True)`).
|
|
50
|
+
|
|
51
|
+
- **Image Conversion**: Converts images from the `CMMCorePlus` instance to `uint8`
|
|
52
|
+
and scales them appropriately for display using `QImage` and `QPixmap`.
|
|
53
|
+
|
|
54
|
+
- **Event Handling**: Connects to various events emitted by the `CMMCorePlus` instance:
|
|
55
|
+
- `imageSnapped`: Emitted when a new image is snapped.
|
|
56
|
+
- `continuousSequenceAcquisitionStarted` and `sequenceAcquisitionStarted`: Emitted when
|
|
57
|
+
a sequence acquisition starts.
|
|
58
|
+
- `sequenceAcquisitionStopped`: Emitted when a sequence acquisition stops.
|
|
59
|
+
- `exposureChanged`: Emitted when the exposure time changes.
|
|
60
|
+
- `frameReady` (MDA): Emitted when a new frame is ready during MDA.
|
|
61
|
+
|
|
62
|
+
- **Thread Safety**: Uses a threading lock (`Lock`) to ensure thread-safe access to
|
|
63
|
+
shared resources, such as the current frame. UI updates are performed in the main
|
|
64
|
+
thread using Qt's signals and slots mechanism, ensuring thread safety.
|
|
65
|
+
|
|
66
|
+
- **Timer for Updates**: A `QTimer` is used to periodically update the image
|
|
67
|
+
from the core. The timer interval can be adjusted based on the exposure time,
|
|
68
|
+
ensuring that updates occur at appropriate intervals.
|
|
69
|
+
|
|
70
|
+
- **Contrast Limits and Colormap**: Allows setting contrast limits (`clims`) and
|
|
71
|
+
colormap (`cmap`) for the image. Currently, only grayscale images are supported.
|
|
72
|
+
The `clims` can be set to a tuple `(min, max)` or `"auto"` for automatic adjustment.
|
|
73
|
+
|
|
74
|
+
- **Usage with MDA**: The `use_with_mda` parameter determines whether the widget updates
|
|
75
|
+
during Multi-Dimensional Acquisitions. If set to `False`, the widget will not update
|
|
76
|
+
during MDA runs.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
.. code-block:: python
|
|
80
|
+
|
|
81
|
+
from pymmcore_plus import CMMCorePlus
|
|
82
|
+
from PyQt6.QtWidgets import QApplication, QVBoxLayout, QWidget
|
|
83
|
+
|
|
84
|
+
mmc = CMMCorePlus()
|
|
85
|
+
|
|
86
|
+
from mesofield.gui import theme
|
|
87
|
+
app = QApplication([])
|
|
88
|
+
theme.apply_theme(app)
|
|
89
|
+
window = QWidget()
|
|
90
|
+
layout = QVBoxLayout(window)
|
|
91
|
+
|
|
92
|
+
image_preview = ImagePreview(mmcore=mmc)
|
|
93
|
+
layout.addWidget(image_preview)
|
|
94
|
+
window.show()
|
|
95
|
+
app.exec()
|
|
96
|
+
|
|
97
|
+
Methods:
|
|
98
|
+
``clims``: Property to get or set the contrast limits of the image.
|
|
99
|
+
``cmap``: Property to get or set the colormap of the image.
|
|
100
|
+
|
|
101
|
+
**Initialization Parameters**
|
|
102
|
+
----------
|
|
103
|
+
parent : QWidget, optional
|
|
104
|
+
The parent widget for this widget.
|
|
105
|
+
mmcore : CMMCorePlus
|
|
106
|
+
The `CMMCorePlus` instance to be used for image acquisition.
|
|
107
|
+
use_with_mda : bool, optional
|
|
108
|
+
Flag to determine if the widget should update during MDA sequences.
|
|
109
|
+
|
|
110
|
+
**Raises**
|
|
111
|
+
------
|
|
112
|
+
ValueError
|
|
113
|
+
If `mmcore` is not provided.
|
|
114
|
+
|
|
115
|
+
**Private Methods**
|
|
116
|
+
----------------
|
|
117
|
+
These methods handle internal functionality:
|
|
118
|
+
|
|
119
|
+
- `_disconnect()`: Disconnects all connected signals from the `CMMCorePlus` instance.
|
|
120
|
+
- `_on_streaming_start()`: Starts the streaming timer when a sequence acquisition starts.
|
|
121
|
+
- `_on_streaming_stop()`: Stops the streaming timer when the sequence acquisition stops.
|
|
122
|
+
- `_on_exposure_changed(device, value)`: Adjusts the timer interval when the exposure changes.
|
|
123
|
+
- `_on_streaming_timeout()`: Called periodically by the timer to fetch and display new images.
|
|
124
|
+
- `_on_image_snapped(img)`: Handles new images snapped outside of sequences.
|
|
125
|
+
- `_on_frame_ready(event)`: Handles new frames ready during MDA.
|
|
126
|
+
- `_display_image(img)`: Converts and displays the image in the label.
|
|
127
|
+
- `_adjust_image_data(img)`: Scales image data to `uint8` for display.
|
|
128
|
+
- `_convert_to_qimage(img)`: Converts a NumPy array to a `QImage` for display.
|
|
129
|
+
|
|
130
|
+
**Usage Notes**
|
|
131
|
+
------------
|
|
132
|
+
- **Initialization**: Provide an initialized and configured `CMMCorePlus` instance.
|
|
133
|
+
- **Thread Safety**: UI updates are performed in the main thread. Ensure that heavy computations are offloaded to avoid blocking the UI.
|
|
134
|
+
- **Customization**: You can adjust the `clims` and `cmap` properties to customize the image display.
|
|
135
|
+
|
|
136
|
+
**Performance Considerations**
|
|
137
|
+
--------------------------
|
|
138
|
+
- **Frame Rate**: The default timer interval is set to 10 milliseconds. Adjust the interval based on your performance needs.
|
|
139
|
+
- **Resource Management**: Disconnect signals properly by ensuring the `_disconnect()` method is called when the widget is destroyed.
|
|
140
|
+
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, parent: QWidget = None, *,
|
|
144
|
+
_clims: Union[Tuple[float, float], Literal["auto"]] = (0, 255),
|
|
145
|
+
use_with_mda: bool = True,
|
|
146
|
+
mmcore: CMMCorePlus | None = None,
|
|
147
|
+
image_payload=None,
|
|
148
|
+
progress_payload=None):
|
|
149
|
+
super().__init__(parent=parent)
|
|
150
|
+
if mmcore is None and image_payload is None:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"ImagePreview requires either an mmcore CMMCorePlus instance "
|
|
153
|
+
"or an image_payload pyqtSignal emitting numpy frames."
|
|
154
|
+
)
|
|
155
|
+
self._mmcore = mmcore
|
|
156
|
+
self._use_with_mda = use_with_mda
|
|
157
|
+
self._clims = _clims
|
|
158
|
+
self._cmap: str = "grayscale"
|
|
159
|
+
self._current_frame = None
|
|
160
|
+
self._frame_lock = Lock()
|
|
161
|
+
|
|
162
|
+
# Set up image label
|
|
163
|
+
self.image_label = QLabel()
|
|
164
|
+
self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
165
|
+
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
166
|
+
self.image_label.setMinimumSize(512, 512)
|
|
167
|
+
self.image_label.setScaledContents(False) # Keep aspect ratio
|
|
168
|
+
|
|
169
|
+
# Set up layout with an image view and an optional progress bar
|
|
170
|
+
self.setLayout(QVBoxLayout())
|
|
171
|
+
self.layout().setContentsMargins(0, 0, 0, 0)
|
|
172
|
+
self.layout().addWidget(self.image_label)
|
|
173
|
+
|
|
174
|
+
# Progress bar shown during MDA acquisitions
|
|
175
|
+
self.progress_bar = QProgressBar()
|
|
176
|
+
#self.progress_bar.setRange(0, 100)
|
|
177
|
+
self.progress_bar.setVisible(False)
|
|
178
|
+
self.layout().addWidget(self.progress_bar)
|
|
179
|
+
|
|
180
|
+
# Per-viewer FPS display for static live views.
|
|
181
|
+
self.fps_label = QLabel("FPS: --.-")
|
|
182
|
+
self.fps_label.setAlignment(
|
|
183
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
184
|
+
)
|
|
185
|
+
self.layout().addWidget(self.fps_label)
|
|
186
|
+
self.layout().setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
187
|
+
|
|
188
|
+
self._fps_window_start = perf_counter()
|
|
189
|
+
self._fps_frame_count = 0
|
|
190
|
+
self._fps_value = 0.0
|
|
191
|
+
|
|
192
|
+
# Set up timer
|
|
193
|
+
self.streaming_timer = QTimer(parent=self)
|
|
194
|
+
self.streaming_timer.setTimerType(Qt.TimerType.PreciseTimer)
|
|
195
|
+
self.streaming_timer.setInterval(10) # Default interval; adjust as needed
|
|
196
|
+
self.streaming_timer.timeout.connect(self._on_streaming_timeout)
|
|
197
|
+
|
|
198
|
+
if self._mmcore is not None:
|
|
199
|
+
# Connect events for the mmcore
|
|
200
|
+
ev = self._mmcore.events
|
|
201
|
+
#ev.imageSnapped.connect(self._on_image_snapped)
|
|
202
|
+
ev.continuousSequenceAcquisitionStarted.connect(self._on_streaming_start)
|
|
203
|
+
ev.sequenceAcquisitionStarted.connect(self._on_streaming_start)
|
|
204
|
+
ev.sequenceAcquisitionStopped.connect(self._on_streaming_stop)
|
|
205
|
+
ev.exposureChanged.connect(self._on_exposure_changed)
|
|
206
|
+
|
|
207
|
+
enev = self._mmcore.mda.events
|
|
208
|
+
enev.frameReady.connect(self._on_frame_ready)
|
|
209
|
+
enev.sequenceStarted.connect(self._on_sequence_started)
|
|
210
|
+
enev.sequenceFinished.connect(self._on_sequence_finished)
|
|
211
|
+
enev.sequenceCanceled.connect(self._on_sequence_finished)
|
|
212
|
+
|
|
213
|
+
# Optional non-mmcore frame source (e.g. OpenCVCamera.image_ready).
|
|
214
|
+
if image_payload is not None and hasattr(image_payload, "connect"):
|
|
215
|
+
image_payload.connect(
|
|
216
|
+
self._on_external_frame, type=Qt.ConnectionType.QueuedConnection
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._progress_total = 0
|
|
220
|
+
self._progress_count = 0
|
|
221
|
+
|
|
222
|
+
# Optional non-mmcore recording-progress source (e.g. OpenCVCamera).
|
|
223
|
+
if progress_payload is not None and hasattr(progress_payload, "connect"):
|
|
224
|
+
progress_payload.connect(
|
|
225
|
+
self._on_external_progress, type=Qt.ConnectionType.QueuedConnection
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self.destroyed.connect(self._disconnect)
|
|
229
|
+
|
|
230
|
+
def _disconnect(self) -> None:
|
|
231
|
+
if self._mmcore is None:
|
|
232
|
+
return
|
|
233
|
+
# Disconnect events for the mmcore
|
|
234
|
+
ev = self._mmcore.events
|
|
235
|
+
with suppress(TypeError):
|
|
236
|
+
ev.imageSnapped.disconnect()
|
|
237
|
+
ev.continuousSequenceAcquisitionStarted.disconnect()
|
|
238
|
+
ev.sequenceAcquisitionStarted.disconnect()
|
|
239
|
+
ev.sequenceAcquisitionStopped.disconnect()
|
|
240
|
+
ev.exposureChanged.disconnect()
|
|
241
|
+
|
|
242
|
+
enev = self._mmcore.mda.events
|
|
243
|
+
with suppress(TypeError):
|
|
244
|
+
enev.frameReady.disconnect()
|
|
245
|
+
enev.sequenceStarted.disconnect()
|
|
246
|
+
enev.sequenceFinished.disconnect()
|
|
247
|
+
enev.sequenceCanceled.disconnect()
|
|
248
|
+
|
|
249
|
+
def _on_streaming_start(self) -> None:
|
|
250
|
+
self._reset_fps_counter()
|
|
251
|
+
if not self.streaming_timer.isActive():
|
|
252
|
+
self.streaming_timer.start()
|
|
253
|
+
|
|
254
|
+
def _on_streaming_stop(self) -> None:
|
|
255
|
+
# Stop the streaming timer
|
|
256
|
+
if self._mmcore is None or not self._mmcore.isSequenceRunning():
|
|
257
|
+
self.streaming_timer.stop()
|
|
258
|
+
self.fps_label.setText(f"FPS: {self._fps_value:.1f}")
|
|
259
|
+
|
|
260
|
+
def _on_exposure_changed(self, device: str, value: str) -> None:
|
|
261
|
+
# Adjust timer interval if needed
|
|
262
|
+
exposure = self._mmcore.getExposure() or 10
|
|
263
|
+
interval = int(exposure) or 10
|
|
264
|
+
self.streaming_timer.setInterval(interval)
|
|
265
|
+
|
|
266
|
+
def _on_streaming_timeout(self) -> None:
|
|
267
|
+
frame = None
|
|
268
|
+
new_frames = 0
|
|
269
|
+
if self._mmcore is None:
|
|
270
|
+
with self._frame_lock:
|
|
271
|
+
if self._current_frame is not None:
|
|
272
|
+
frame = self._current_frame
|
|
273
|
+
self._current_frame = None
|
|
274
|
+
new_frames = 1
|
|
275
|
+
elif not self._mmcore.mda.is_running():
|
|
276
|
+
frame, new_frames = self._pop_latest_live_frame()
|
|
277
|
+
else:
|
|
278
|
+
with self._frame_lock:
|
|
279
|
+
if self._current_frame is not None:
|
|
280
|
+
frame = self._current_frame
|
|
281
|
+
self._current_frame = None
|
|
282
|
+
# Update the image if a frame is available
|
|
283
|
+
if frame is not None:
|
|
284
|
+
self._display_image(frame)
|
|
285
|
+
if new_frames:
|
|
286
|
+
self._update_fps_counter(new_frames)
|
|
287
|
+
|
|
288
|
+
def _on_image_snapped(self, img: np.ndarray) -> None:
|
|
289
|
+
self._update_image(img)
|
|
290
|
+
self._display_image(img)
|
|
291
|
+
self._update_fps_counter(1)
|
|
292
|
+
|
|
293
|
+
def _on_external_frame(self, img: np.ndarray) -> None:
|
|
294
|
+
"""Handle frames coming from a non-mmcore producer (e.g. OpenCVCamera).
|
|
295
|
+
|
|
296
|
+
The frame is stashed under the lock and displayed on the next timer
|
|
297
|
+
tick to keep all draw calls on the GUI thread.
|
|
298
|
+
"""
|
|
299
|
+
if img is None:
|
|
300
|
+
return
|
|
301
|
+
with self._frame_lock:
|
|
302
|
+
self._current_frame = img
|
|
303
|
+
if not self.streaming_timer.isActive():
|
|
304
|
+
self._reset_fps_counter()
|
|
305
|
+
self.streaming_timer.start()
|
|
306
|
+
|
|
307
|
+
def _on_frame_ready(self, img: np.ndarray) -> None:
|
|
308
|
+
frame = img
|
|
309
|
+
with self._frame_lock:
|
|
310
|
+
self._current_frame = frame
|
|
311
|
+
self._update_fps_counter(1)
|
|
312
|
+
if self.progress_bar.isVisible():
|
|
313
|
+
self._progress_count = min(self._progress_count + 1, self._progress_total)
|
|
314
|
+
self.progress_bar.setValue(self._progress_count)
|
|
315
|
+
# Update the text to "current/total"
|
|
316
|
+
self.progress_bar.setFormat(f"{self._progress_count}/{self._progress_total}")
|
|
317
|
+
|
|
318
|
+
def _on_external_progress(self, count: int, total: int) -> None:
|
|
319
|
+
"""Drive the progress bar from a non-mmcore producer (e.g. OpenCVCamera).
|
|
320
|
+
|
|
321
|
+
``count < 0`` is the "recording finished" sentinel. ``total <= 0``
|
|
322
|
+
renders an indeterminate (busy) bar when the length is unknown.
|
|
323
|
+
"""
|
|
324
|
+
if count < 0:
|
|
325
|
+
self.progress_bar.setVisible(False)
|
|
326
|
+
return
|
|
327
|
+
if not self.progress_bar.isVisible():
|
|
328
|
+
self.progress_bar.setTextVisible(True)
|
|
329
|
+
self.progress_bar.setVisible(True)
|
|
330
|
+
if total > 0:
|
|
331
|
+
self.progress_bar.setRange(0, total)
|
|
332
|
+
self.progress_bar.setValue(min(count, total))
|
|
333
|
+
self.progress_bar.setFormat(f"{min(count, total)}/{total}")
|
|
334
|
+
else:
|
|
335
|
+
# Unknown length -> indeterminate bar.
|
|
336
|
+
self.progress_bar.setRange(0, 0)
|
|
337
|
+
self.progress_bar.setFormat(f"{count} frames")
|
|
338
|
+
|
|
339
|
+
def _on_sequence_started(self, sequence, metadata) -> None:
|
|
340
|
+
self._reset_fps_counter()
|
|
341
|
+
self._progress_total = len(list(sequence.iter_events()))
|
|
342
|
+
self._progress_count = 0
|
|
343
|
+
self.progress_bar.setRange(0, self._progress_total)
|
|
344
|
+
self.progress_bar.setValue(0)
|
|
345
|
+
# Show "0/total" initially
|
|
346
|
+
self.progress_bar.setFormat(f"{self._progress_count}/{self._progress_total}")
|
|
347
|
+
self.progress_bar.setTextVisible(True)
|
|
348
|
+
self.progress_bar.setVisible(True)
|
|
349
|
+
|
|
350
|
+
def _on_sequence_finished(self, *_) -> None:
|
|
351
|
+
self.progress_bar.setValue(self._progress_total)
|
|
352
|
+
self.progress_bar.setFormat(f"{self._progress_total}/{self._progress_total}")
|
|
353
|
+
# Hide after a short delay
|
|
354
|
+
QTimer.singleShot(500, lambda: self.progress_bar.setVisible(False))
|
|
355
|
+
|
|
356
|
+
def _reset_fps_counter(self) -> None:
|
|
357
|
+
self._fps_window_start = perf_counter()
|
|
358
|
+
self._fps_frame_count = 0
|
|
359
|
+
self._fps_value = 0.0
|
|
360
|
+
self.fps_label.setText("FPS: --.-")
|
|
361
|
+
|
|
362
|
+
def _update_fps_counter(self, count: int = 1) -> None:
|
|
363
|
+
if count <= 0:
|
|
364
|
+
return
|
|
365
|
+
self._fps_frame_count += count
|
|
366
|
+
elapsed = perf_counter() - self._fps_window_start
|
|
367
|
+
if elapsed < 0.5:
|
|
368
|
+
return
|
|
369
|
+
self._fps_value = self._fps_frame_count / elapsed
|
|
370
|
+
self.fps_label.setText(f"FPS: {self._fps_value:.1f}")
|
|
371
|
+
self._fps_window_start = perf_counter()
|
|
372
|
+
self._fps_frame_count = 0
|
|
373
|
+
|
|
374
|
+
def _pop_latest_live_frame(self) -> tuple[np.ndarray | None, int]:
|
|
375
|
+
"""Pop all newly queued live frames and return the latest one and count.
|
|
376
|
+
|
|
377
|
+
FPS should reflect true incoming frames, not timer refresh ticks.
|
|
378
|
+
"""
|
|
379
|
+
frame = None
|
|
380
|
+
new_frames = 0
|
|
381
|
+
with suppress(RuntimeError, IndexError, TypeError, AttributeError):
|
|
382
|
+
remaining = int(self._mmcore.getRemainingImageCount())
|
|
383
|
+
for _ in range(remaining):
|
|
384
|
+
try:
|
|
385
|
+
frame, _ = self._mmcore.popNextImageAndMD()
|
|
386
|
+
except Exception:
|
|
387
|
+
frame = self._mmcore.popNextImage()
|
|
388
|
+
new_frames += 1
|
|
389
|
+
|
|
390
|
+
# Fallback for display continuity only; does not increment FPS.
|
|
391
|
+
if frame is None:
|
|
392
|
+
with suppress(RuntimeError, IndexError):
|
|
393
|
+
frame = self._mmcore.getLastImage()
|
|
394
|
+
|
|
395
|
+
return frame, new_frames
|
|
396
|
+
|
|
397
|
+
def _display_image(self, img: np.ndarray) -> None:
|
|
398
|
+
if img is None:
|
|
399
|
+
return
|
|
400
|
+
qimage = self._convert_to_qimage(img)
|
|
401
|
+
if qimage is not None:
|
|
402
|
+
pixmap = QPixmap.fromImage(qimage)
|
|
403
|
+
pixmap = pixmap.scaled(self.image_label.width(), self.image_label.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
404
|
+
self.image_label.setPixmap(pixmap)
|
|
405
|
+
|
|
406
|
+
def _update_image(self, img: np.ndarray) -> None:
|
|
407
|
+
# Update the current frame
|
|
408
|
+
with self._frame_lock:
|
|
409
|
+
self._current_frame = img
|
|
410
|
+
|
|
411
|
+
def _adjust_image_data(self, img: np.ndarray) -> np.ndarray:
|
|
412
|
+
# NOTE: This is the default implementation for grayscale images
|
|
413
|
+
# NOTE: This is the most processor-intensive part of this widget
|
|
414
|
+
|
|
415
|
+
# Color frames pass through unchanged (handled in _convert_to_qimage)
|
|
416
|
+
if img.ndim == 3:
|
|
417
|
+
if img.dtype != np.uint8:
|
|
418
|
+
img = np.clip(img, 0, 255).astype(np.uint8, copy=False)
|
|
419
|
+
return img
|
|
420
|
+
|
|
421
|
+
# Ensure the image is in float format for scaling
|
|
422
|
+
img = img.astype(np.float32, copy=False)
|
|
423
|
+
|
|
424
|
+
# Apply contrast limits
|
|
425
|
+
if self._clims == "auto":
|
|
426
|
+
min_val, max_val = np.min(img), np.max(img)
|
|
427
|
+
else:
|
|
428
|
+
min_val, max_val = self._clims
|
|
429
|
+
|
|
430
|
+
# Avoid division by zero
|
|
431
|
+
scale = 255.0 / (max_val - min_val) if max_val != min_val else 255.0
|
|
432
|
+
|
|
433
|
+
# Scale to 0-255
|
|
434
|
+
img = np.clip((img - min_val) * scale, 0, 255).astype(np.uint8, copy=False)
|
|
435
|
+
return img
|
|
436
|
+
|
|
437
|
+
def _convert_to_qimage(self, img: np.ndarray) -> QImage:
|
|
438
|
+
"""Convert a NumPy array to QImage."""
|
|
439
|
+
if img is None:
|
|
440
|
+
return None
|
|
441
|
+
img = self._adjust_image_data(img)
|
|
442
|
+
img = np.ascontiguousarray(img)
|
|
443
|
+
height, width = img.shape[:2]
|
|
444
|
+
|
|
445
|
+
if img.ndim == 2:
|
|
446
|
+
# Grayscale image
|
|
447
|
+
bytes_per_line = width
|
|
448
|
+
qimage = QImage(img.data, width, height, bytes_per_line, QImage.Format.Format_Grayscale8)
|
|
449
|
+
elif img.ndim == 3 and img.shape[2] in (3, 4):
|
|
450
|
+
# Color image (BGR from OpenCV) -> RGB888
|
|
451
|
+
if img.shape[2] == 4:
|
|
452
|
+
rgb = img[..., [2, 1, 0]] # drop alpha
|
|
453
|
+
else:
|
|
454
|
+
rgb = img[..., ::-1] # BGR -> RGB
|
|
455
|
+
rgb = np.ascontiguousarray(rgb)
|
|
456
|
+
bytes_per_line = 3 * width
|
|
457
|
+
qimage = QImage(
|
|
458
|
+
rgb.data, width, height, bytes_per_line, QImage.Format.Format_RGB888
|
|
459
|
+
)
|
|
460
|
+
# Keep the underlying buffer alive for the life of the QImage
|
|
461
|
+
qimage._np_data = rgb # type: ignore[attr-defined]
|
|
462
|
+
else:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
return qimage
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def clims(self) -> Union[Tuple[float, float], Literal["auto"]]:
|
|
469
|
+
"""Get the contrast limits of the image."""
|
|
470
|
+
return self._clims
|
|
471
|
+
|
|
472
|
+
@clims.setter
|
|
473
|
+
def clims(self, clims: Union[Tuple[float, float], Literal["auto"]] = (0, 255)) -> None:
|
|
474
|
+
"""Set the contrast limits of the image.
|
|
475
|
+
|
|
476
|
+
Parameters
|
|
477
|
+
----------
|
|
478
|
+
clims : tuple[float, float], or "auto"
|
|
479
|
+
The contrast limits to set.
|
|
480
|
+
"""
|
|
481
|
+
self._clims = clims
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def cmap(self) -> str:
|
|
485
|
+
"""Get the colormap (lookup table) of the image."""
|
|
486
|
+
return self._cmap
|
|
487
|
+
|
|
488
|
+
@cmap.setter
|
|
489
|
+
def cmap(self, cmap: str = "grayscale") -> None:
|
|
490
|
+
"""Set the colormap (lookup table) of the image.
|
|
491
|
+
|
|
492
|
+
Parameters
|
|
493
|
+
----------
|
|
494
|
+
cmap : str
|
|
495
|
+
The colormap to use.
|
|
496
|
+
"""
|
|
497
|
+
self._cmap = cmap
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
import pyqtgraph as pg
|
|
501
|
+
from PyQt6.QtGui import QPixmap
|
|
502
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
503
|
+
import numpy as np
|
|
504
|
+
from threading import Lock
|
|
505
|
+
from contextlib import suppress
|
|
506
|
+
from typing import Tuple, Union, Literal
|
|
507
|
+
from pymmcore_plus import CMMCorePlus
|
|
508
|
+
|
|
509
|
+
pg.setConfigOptions(imageAxisOrder='row-major', useOpenGL=True)
|
|
510
|
+
|
|
511
|
+
class InteractivePreview(pg.ImageView):
|
|
512
|
+
def __init__(self, parent=None, mmcore=None, use_with_mda=True, image_payload=None):
|
|
513
|
+
super().__init__(parent=parent)
|
|
514
|
+
self._mmcore: CMMCorePlus = mmcore
|
|
515
|
+
self._use_with_mda = use_with_mda
|
|
516
|
+
self._clims: Union[Tuple[float, float], Literal["auto"]] = (0, 65535)
|
|
517
|
+
self._current_frame = np.zeros((512, 512), dtype=np.uint8)
|
|
518
|
+
self._display_image(self._current_frame)
|
|
519
|
+
self._cmap: str = "grayscale"
|
|
520
|
+
self._current_frame = None
|
|
521
|
+
self._frame_lock = Lock()
|
|
522
|
+
|
|
523
|
+
self._fps_window_start = perf_counter()
|
|
524
|
+
self._fps_frame_count = 0
|
|
525
|
+
self._fps_value = 0.0
|
|
526
|
+
self._fps_text = pg.TextItem("FPS: --.-", color=(255, 255, 0), anchor=(1, 0))
|
|
527
|
+
self.view.addItem(self._fps_text)
|
|
528
|
+
|
|
529
|
+
if image_payload is not None:
|
|
530
|
+
image_payload.connect(self._on_image_payload)
|
|
531
|
+
|
|
532
|
+
if self._mmcore is not None:
|
|
533
|
+
self._mmcore.events.imageSnapped.connect(self._on_image_snapped)
|
|
534
|
+
self._mmcore.events.continuousSequenceAcquisitionStarted.connect(self._on_streaming_start)
|
|
535
|
+
self._mmcore.events.sequenceAcquisitionStarted.connect(self._on_streaming_start)
|
|
536
|
+
self._mmcore.events.sequenceAcquisitionStopped.connect(self._on_streaming_stop)
|
|
537
|
+
self._mmcore.events.exposureChanged.connect(self._on_exposure_changed)
|
|
538
|
+
|
|
539
|
+
enev = self._mmcore.mda.events
|
|
540
|
+
enev.frameReady.connect(self._on_image_payload, type=Qt.ConnectionType.QueuedConnection)
|
|
541
|
+
if self._use_with_mda:
|
|
542
|
+
self._mmcore.mda.events.frameReady.connect(self._on_frame_ready)
|
|
543
|
+
|
|
544
|
+
self.streaming_timer = QTimer(parent=self)
|
|
545
|
+
self.streaming_timer.setTimerType(Qt.TimerType.PreciseTimer)
|
|
546
|
+
self.streaming_timer.setInterval(10)
|
|
547
|
+
self.streaming_timer.timeout.connect(self._on_streaming_timeout)
|
|
548
|
+
|
|
549
|
+
self.destroyed.connect(self._disconnect)
|
|
550
|
+
|
|
551
|
+
def _disconnect(self) -> None:
|
|
552
|
+
if self._mmcore:
|
|
553
|
+
ev = self._mmcore.events
|
|
554
|
+
with suppress(TypeError):
|
|
555
|
+
ev.imageSnapped.disconnect()
|
|
556
|
+
ev.continuousSequenceAcquisitionStarted.disconnect()
|
|
557
|
+
ev.sequenceAcquisitionStarted.disconnect()
|
|
558
|
+
ev.sequenceAcquisitionStopped.disconnect()
|
|
559
|
+
ev.exposureChanged.disconnect()
|
|
560
|
+
enev = self._mmcore.mda.events
|
|
561
|
+
with suppress(TypeError):
|
|
562
|
+
enev.frameReady.disconnect()
|
|
563
|
+
|
|
564
|
+
def _on_streaming_start(self) -> None:
|
|
565
|
+
self._reset_fps_counter()
|
|
566
|
+
if not self.streaming_timer.isActive():
|
|
567
|
+
self.streaming_timer.start()
|
|
568
|
+
|
|
569
|
+
def _on_streaming_stop(self) -> None:
|
|
570
|
+
if not self._mmcore.isSequenceRunning():
|
|
571
|
+
self.streaming_timer.stop()
|
|
572
|
+
self._fps_text.setText(f"FPS: {self._fps_value:.1f}")
|
|
573
|
+
|
|
574
|
+
def _on_exposure_changed(self, device: str, value: str) -> None:
|
|
575
|
+
exposure = self._mmcore.getExposure() or 10
|
|
576
|
+
self.streaming_timer.setInterval(int(exposure) or 10)
|
|
577
|
+
|
|
578
|
+
def _on_frame_ready(self, img: np.ndarray) -> None:
|
|
579
|
+
with self._frame_lock:
|
|
580
|
+
self._current_frame = img
|
|
581
|
+
|
|
582
|
+
def _on_streaming_timeout(self) -> None:
|
|
583
|
+
frame = None
|
|
584
|
+
new_frames = 0
|
|
585
|
+
if not self._mmcore.mda.is_running():
|
|
586
|
+
frame, new_frames = self._pop_latest_live_frame()
|
|
587
|
+
else:
|
|
588
|
+
with self._frame_lock:
|
|
589
|
+
if self._current_frame is not None:
|
|
590
|
+
frame = self._current_frame
|
|
591
|
+
self._current_frame = None
|
|
592
|
+
if frame is not None:
|
|
593
|
+
self._display_image(frame)
|
|
594
|
+
if new_frames:
|
|
595
|
+
self._update_fps_counter(frame.shape, count=new_frames)
|
|
596
|
+
|
|
597
|
+
def _on_image_snapped(self, img: np.ndarray) -> None:
|
|
598
|
+
with self._frame_lock:
|
|
599
|
+
self._current_frame = img
|
|
600
|
+
self._display_image(img)
|
|
601
|
+
self._update_fps_counter(img.shape, count=1)
|
|
602
|
+
|
|
603
|
+
def _on_image_payload(self, img: np.ndarray) -> None:
|
|
604
|
+
if img is None:
|
|
605
|
+
return
|
|
606
|
+
#img = self._adjust_image_data(img)
|
|
607
|
+
self.setImage(img.T,
|
|
608
|
+
autoHistogramRange=False,
|
|
609
|
+
autoRange=False,
|
|
610
|
+
levelMode='mono',
|
|
611
|
+
autoLevels=(self._clims == "auto"),
|
|
612
|
+
)
|
|
613
|
+
self._update_fps_counter(img.shape, count=1)
|
|
614
|
+
|
|
615
|
+
def _display_image(self, img: np.ndarray) -> None:
|
|
616
|
+
if img is None:
|
|
617
|
+
return
|
|
618
|
+
img = self._adjust_image_data(img)
|
|
619
|
+
self.setImage(img.T,
|
|
620
|
+
autoHistogramRange=False,
|
|
621
|
+
autoRange=False,
|
|
622
|
+
levelMode='mono',
|
|
623
|
+
autoLevels=(self._clims == "auto"),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def _reset_fps_counter(self) -> None:
|
|
627
|
+
self._fps_window_start = perf_counter()
|
|
628
|
+
self._fps_frame_count = 0
|
|
629
|
+
self._fps_value = 0.0
|
|
630
|
+
self._fps_text.setText("FPS: --.-")
|
|
631
|
+
|
|
632
|
+
def _update_fps_counter(self, image_shape: tuple[int, ...], count: int = 1) -> None:
|
|
633
|
+
if count <= 0:
|
|
634
|
+
return
|
|
635
|
+
self._fps_frame_count += count
|
|
636
|
+
elapsed = perf_counter() - self._fps_window_start
|
|
637
|
+
if elapsed >= 0.5:
|
|
638
|
+
self._fps_value = self._fps_frame_count / elapsed
|
|
639
|
+
self._fps_text.setText(f"FPS: {self._fps_value:.1f}")
|
|
640
|
+
self._fps_window_start = perf_counter()
|
|
641
|
+
self._fps_frame_count = 0
|
|
642
|
+
if image_shape:
|
|
643
|
+
self._fps_text.setPos(max(image_shape[1] - 1, 0), 0)
|
|
644
|
+
|
|
645
|
+
def _pop_latest_live_frame(self) -> tuple[np.ndarray | None, int]:
|
|
646
|
+
"""Pop all newly queued live frames and return the latest one and count."""
|
|
647
|
+
frame = None
|
|
648
|
+
new_frames = 0
|
|
649
|
+
with suppress(RuntimeError, IndexError, TypeError, AttributeError):
|
|
650
|
+
remaining = int(self._mmcore.getRemainingImageCount())
|
|
651
|
+
for _ in range(remaining):
|
|
652
|
+
try:
|
|
653
|
+
frame, _ = self._mmcore.popNextImageAndMD()
|
|
654
|
+
except Exception:
|
|
655
|
+
frame = self._mmcore.popNextImage()
|
|
656
|
+
new_frames += 1
|
|
657
|
+
|
|
658
|
+
# Fallback for display continuity only; does not increment FPS.
|
|
659
|
+
if frame is None:
|
|
660
|
+
with suppress(RuntimeError, IndexError):
|
|
661
|
+
frame = self._mmcore.getLastImage()
|
|
662
|
+
|
|
663
|
+
return frame, new_frames
|
|
664
|
+
|
|
665
|
+
def _adjust_image_data(self, img: np.ndarray) -> np.ndarray:
|
|
666
|
+
img = img.astype(np.float32, copy=False)
|
|
667
|
+
if self._clims == "auto":
|
|
668
|
+
min_val, max_val = np.min(img), np.max(img)
|
|
669
|
+
else:
|
|
670
|
+
min_val, max_val = self._clims
|
|
671
|
+
scale = 255.0 / (max_val - min_val) if max_val != min_val else 255.0
|
|
672
|
+
img = np.clip((img - min_val) * scale, 0, 255).astype(np.uint8, copy=False)
|
|
673
|
+
return img
|
|
674
|
+
|
|
675
|
+
@property
|
|
676
|
+
def clims(self) -> Union[Tuple[float, float], Literal["auto"]]:
|
|
677
|
+
return self._clims
|
|
678
|
+
|
|
679
|
+
@clims.setter
|
|
680
|
+
def clims(self, clims: Union[Tuple[float, float], Literal["auto"]] = "auto") -> None:
|
|
681
|
+
self._clims = clims
|
|
682
|
+
if self._current_frame is not None:
|
|
683
|
+
self._display_image(self._current_frame)
|
|
684
|
+
|
|
685
|
+
# @property
|
|
686
|
+
# def cmap(self) -> str:
|
|
687
|
+
# return self._cmap
|
|
688
|
+
|
|
689
|
+
# @cmap.setter
|
|
690
|
+
# def cmap(self, cmap: str = "grayscale") -> None:
|
|
691
|
+
# self._cmap = cmap
|