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.
Files changed (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -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