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/mdagui.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from pymmcore_plus import CMMCorePlus
|
|
2
|
+
|
|
3
|
+
from PyQt6.QtWidgets import (
|
|
4
|
+
QCheckBox,
|
|
5
|
+
QGroupBox,
|
|
6
|
+
QHBoxLayout,
|
|
7
|
+
QPushButton,
|
|
8
|
+
QVBoxLayout,
|
|
9
|
+
QWidget,
|
|
10
|
+
QSizePolicy,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from pymmcore_widgets import MDAWidget
|
|
14
|
+
|
|
15
|
+
from mesofield.data.writer import CustomWriter
|
|
16
|
+
from mesofield.gui.viewer import ImagePreview, InteractivePreview
|
|
17
|
+
from mesofield.utils._logger import get_logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CameraButtons(QWidget):
|
|
24
|
+
"""Snap + Live toggle wired through the BaseCamera contract.
|
|
25
|
+
|
|
26
|
+
Replaces pymmcore-widgets' ``SnapButton`` / ``LiveButton`` (which only
|
|
27
|
+
worked for mmcore-backed cameras) with two buttons that call
|
|
28
|
+
``cam.snap()`` / ``cam.start_live()`` / ``cam.stop_live()``. Those
|
|
29
|
+
methods are defined on :class:`mesofield.devices.base_camera.BaseCamera`
|
|
30
|
+
and implemented by every camera subclass, so the GUI no longer cares
|
|
31
|
+
whether the underlying backend is Micro-Manager, OpenCV, or the
|
|
32
|
+
synthetic :class:`MockFrameProducer`.
|
|
33
|
+
|
|
34
|
+
The widget delegates frame display to the camera's existing signal
|
|
35
|
+
plumbing:
|
|
36
|
+
|
|
37
|
+
- MMCamera: ``cam.snap()`` calls ``mmcore.snap()`` which fires the
|
|
38
|
+
``imageSnapped`` event the :class:`ImagePreview` is already wired
|
|
39
|
+
to; ``cam.start_live()`` triggers ``startContinuousSequenceAcquisition``
|
|
40
|
+
and frames flow through the MDA events.
|
|
41
|
+
- OpenCVCamera / MockFrameProducer: ``snap()`` and the live loop emit
|
|
42
|
+
``cam.image_ready`` (a ``pyqtSignal(np.ndarray)``) which the
|
|
43
|
+
``ImagePreview`` subscribes to via ``image_payload``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, cam, preview=None) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.cam = cam
|
|
49
|
+
self.preview = preview
|
|
50
|
+
layout = QHBoxLayout()
|
|
51
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
52
|
+
self.setLayout(layout)
|
|
53
|
+
|
|
54
|
+
self.snap_btn = QPushButton("Snap")
|
|
55
|
+
self.snap_btn.setToolTip(f"cam.snap() [{cam.backend} backend]")
|
|
56
|
+
self.snap_btn.clicked.connect(self._on_snap)
|
|
57
|
+
layout.addWidget(self.snap_btn)
|
|
58
|
+
|
|
59
|
+
self.live_btn = QPushButton("Live")
|
|
60
|
+
self.live_btn.setCheckable(True)
|
|
61
|
+
self.live_btn.setToolTip(
|
|
62
|
+
f"toggle cam.start_live() / cam.stop_live() [{cam.backend} backend]"
|
|
63
|
+
)
|
|
64
|
+
self.live_btn.toggled.connect(self._on_live_toggled)
|
|
65
|
+
layout.addWidget(self.live_btn)
|
|
66
|
+
|
|
67
|
+
# Runtime auto-contrast toggle for the live viewer. The "off" state
|
|
68
|
+
# restores a viewer-appropriate manual range (8-bit static preview
|
|
69
|
+
# vs 16-bit interactive preview).
|
|
70
|
+
self._manual_clims = (
|
|
71
|
+
(0, 65535) if isinstance(preview, InteractivePreview) else (0, 255)
|
|
72
|
+
)
|
|
73
|
+
self.auto_contrast_cb = QCheckBox("Auto-contrast")
|
|
74
|
+
self.auto_contrast_cb.setChecked(bool(getattr(cam, "auto_contrast", True)))
|
|
75
|
+
self.auto_contrast_cb.setToolTip("Toggle live-viewer auto-contrast")
|
|
76
|
+
self.auto_contrast_cb.toggled.connect(self._on_auto_contrast_toggled)
|
|
77
|
+
layout.addWidget(self.auto_contrast_cb)
|
|
78
|
+
# Sync the viewer's contrast mode to the checkbox's initial state.
|
|
79
|
+
self._on_auto_contrast_toggled(self.auto_contrast_cb.isChecked())
|
|
80
|
+
|
|
81
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
82
|
+
"""Enable/disable the snap + live controls (used during acquisition)."""
|
|
83
|
+
self.snap_btn.setEnabled(enabled)
|
|
84
|
+
self.live_btn.setEnabled(enabled)
|
|
85
|
+
|
|
86
|
+
def _on_auto_contrast_toggled(self, checked: bool) -> None:
|
|
87
|
+
if self.preview is None:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
self.preview.clims = "auto" if checked else self._manual_clims
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
_logger.warning(
|
|
93
|
+
"auto-contrast toggle failed on %s: %s", self.cam.device_id, exc
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _on_snap(self) -> None:
|
|
97
|
+
try:
|
|
98
|
+
self.cam.snap()
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
_logger.warning("snap failed on %s: %s", self.cam.device_id, exc)
|
|
101
|
+
|
|
102
|
+
def _on_live_toggled(self, checked: bool) -> None:
|
|
103
|
+
try:
|
|
104
|
+
if checked:
|
|
105
|
+
self.cam.start_live()
|
|
106
|
+
self.live_btn.setText("Stop Live")
|
|
107
|
+
else:
|
|
108
|
+
self.cam.stop_live()
|
|
109
|
+
self.live_btn.setText("Live")
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
_logger.warning(
|
|
112
|
+
"live toggle failed on %s: %s", self.cam.device_id, exc
|
|
113
|
+
)
|
|
114
|
+
# Reset the toggle state if start/stop failed.
|
|
115
|
+
self.live_btn.blockSignals(True)
|
|
116
|
+
self.live_btn.setChecked(not checked)
|
|
117
|
+
self.live_btn.setText("Live")
|
|
118
|
+
self.live_btn.blockSignals(False)
|
|
119
|
+
|
|
120
|
+
class CustomMDAWidget(MDAWidget):
|
|
121
|
+
def run_mda(self) -> None:
|
|
122
|
+
"""Run the MDA sequence experiment."""
|
|
123
|
+
# in case the user does not press enter after editing the save name.
|
|
124
|
+
self.save_info.save_name.editingFinished.emit()
|
|
125
|
+
|
|
126
|
+
sequence = self.value()
|
|
127
|
+
|
|
128
|
+
# technically, this is in the metadata as well, but isChecked is more direct
|
|
129
|
+
if self.save_info.isChecked():
|
|
130
|
+
save_path = self._update_save_path_from_metadata(
|
|
131
|
+
sequence, update_metadata=True
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
save_path = None
|
|
135
|
+
|
|
136
|
+
# run the MDA experiment asynchronously
|
|
137
|
+
self._mmc.run_mda(sequence, output=CustomWriter(save_path))
|
|
138
|
+
|
|
139
|
+
class MDA(QWidget):
|
|
140
|
+
"""
|
|
141
|
+
The `MDAWidget` provides a GUI to construct a `useq.MDASequence` object.
|
|
142
|
+
This object describes a full multi-dimensional acquisition;
|
|
143
|
+
In this example, we set the `MDAWidget` parameter `include_run_button` to `True`,
|
|
144
|
+
meaning that a `run` button is added to the GUI. When pressed, a `useq.MDASequence`
|
|
145
|
+
is first built depending on the GUI values and is then passed to the
|
|
146
|
+
`CMMCorePlus.run_mda` to actually execute the acquisition.
|
|
147
|
+
For details of the corresponding schema and methods, see
|
|
148
|
+
https://github.com/pymmcore-plus/useq-schema and
|
|
149
|
+
https://github.com/pymmcore-plus/pymmcore-plus.
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, procedure) -> None:
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
The layout adapts the viewer based on the number of cores:
|
|
157
|
+
|
|
158
|
+
Single Core Layout:
|
|
159
|
+
|
|
160
|
+
+----------------------------------------+
|
|
161
|
+
| Live Viewer |
|
|
162
|
+
| +-----------------+-----------------+ |
|
|
163
|
+
| | [Snap Button] | [Live Button} | |
|
|
164
|
+
| +-----------------+-----------------+ |
|
|
165
|
+
| | | |
|
|
166
|
+
| | Image Preview | |
|
|
167
|
+
| | | |
|
|
168
|
+
| +-----------------------------------+ |
|
|
169
|
+
+----------------------------------------+
|
|
170
|
+
|
|
171
|
+
Dual Core Layout:
|
|
172
|
+
|
|
173
|
+
+-----------------------------------------------+
|
|
174
|
+
| Live Viewer |
|
|
175
|
+
| +---------------------+-------------------+ |
|
|
176
|
+
| | Core 1 | Core 2 | |
|
|
177
|
+
| +---------------------+-------------------+ |
|
|
178
|
+
| | [Buttons] | [Buttons] | |
|
|
179
|
+
| +---------------------+-------------------+ |
|
|
180
|
+
| | | | |
|
|
181
|
+
| | Image Preview 1 | Image Preview 2 | |
|
|
182
|
+
| | | | |
|
|
183
|
+
| +---------------------+-------------------+ |
|
|
184
|
+
+-----------------------------------------------+
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
super().__init__()
|
|
188
|
+
# get the CMMCore instance and load the default config
|
|
189
|
+
self.procedure = procedure
|
|
190
|
+
config = procedure.config
|
|
191
|
+
self.cameras = config.hardware.cameras
|
|
192
|
+
self.mmcores = tuple(cam.core for cam in self.cameras)
|
|
193
|
+
self._viewer_type = config.hardware._viewer
|
|
194
|
+
# instantiate the MDAWidget
|
|
195
|
+
#self.mda = MDAWidget(mmcore=self.mmcores[0])
|
|
196
|
+
# ----------------------------------Auto-set MDASequence and save_info----------------------------------#
|
|
197
|
+
#self.mda.setValue(config.pupil_sequence)
|
|
198
|
+
#self.mda.save_info.setValue({'save_dir': r'C:/dev', 'save_name': 'file', 'format': 'ome-tiff', 'should_save': True})
|
|
199
|
+
# -------------------------------------------------------------------------------------------------------#
|
|
200
|
+
self.setLayout(QHBoxLayout())
|
|
201
|
+
|
|
202
|
+
live_viewer = QGroupBox()
|
|
203
|
+
live_viewer.setLayout(QVBoxLayout())
|
|
204
|
+
live_viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
205
|
+
buttons = QGroupBox()
|
|
206
|
+
buttons.setLayout(QHBoxLayout())
|
|
207
|
+
|
|
208
|
+
cores_groupbox = QGroupBox(f"{self.__module__}.{self.__class__.__name__}: Live Viewer")
|
|
209
|
+
cores_groupbox.setLayout(QHBoxLayout())
|
|
210
|
+
|
|
211
|
+
# Track the per-camera button widgets so acquisition can lock them out.
|
|
212
|
+
self._camera_buttons: list[CameraButtons] = []
|
|
213
|
+
|
|
214
|
+
for cam in self.cameras:
|
|
215
|
+
# Per-core container
|
|
216
|
+
core_box = QGroupBox(title=str(cam.name))
|
|
217
|
+
core_box.setLayout(QVBoxLayout())
|
|
218
|
+
core_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
219
|
+
auto_contrast = getattr(cam, "auto_contrast", True)
|
|
220
|
+
|
|
221
|
+
# Preview widget: ImagePreview drives display.
|
|
222
|
+
# - mmcore-backed cameras wire the preview to mmcore events;
|
|
223
|
+
# - everyone else feeds it via the camera's `image_ready` Qt
|
|
224
|
+
# signal (an attribute on every BaseCamera subclass).
|
|
225
|
+
if cam.viewer == "static":
|
|
226
|
+
if isinstance(cam.core, CMMCorePlus):
|
|
227
|
+
preview = ImagePreview(
|
|
228
|
+
mmcore=cam.core,
|
|
229
|
+
_clims='auto' if auto_contrast else (0, 255),
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
image_signal = getattr(cam, "image_ready", None)
|
|
233
|
+
if image_signal is None and cam.core is not None:
|
|
234
|
+
image_signal = getattr(cam.core, "image_ready", None)
|
|
235
|
+
preview = ImagePreview(
|
|
236
|
+
mmcore=None,
|
|
237
|
+
image_payload=image_signal,
|
|
238
|
+
progress_payload=getattr(cam, "progress", None),
|
|
239
|
+
_clims='auto' if auto_contrast else (0, 255),
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Interactive / pyqtgraph viewer.
|
|
243
|
+
image_signal = getattr(cam, "image_ready", None)
|
|
244
|
+
if image_signal is None and cam.core is not None:
|
|
245
|
+
image_signal = getattr(cam.core, "image_ready", None)
|
|
246
|
+
preview = InteractivePreview(image_payload=image_signal)
|
|
247
|
+
|
|
248
|
+
# Unified snap + live buttons -- driven by BaseCamera methods.
|
|
249
|
+
# Works for MMCamera (via mmcore), OpenCVCamera (capture thread),
|
|
250
|
+
# and MockFrameProducer (synthetic frames). Non-mmcore cameras
|
|
251
|
+
# used to auto-start on widget creation; now the user clicks
|
|
252
|
+
# "Live" to start, matching mmcore camera UX.
|
|
253
|
+
cam_buttons = CameraButtons(cam, preview)
|
|
254
|
+
self._camera_buttons.append(cam_buttons)
|
|
255
|
+
core_box.layout().addWidget(cam_buttons)
|
|
256
|
+
core_box.layout().addWidget(preview)
|
|
257
|
+
cores_groupbox.layout().addWidget(core_box)
|
|
258
|
+
|
|
259
|
+
# Add the cores_groupbox once, not once per camera (the old code
|
|
260
|
+
# added it inside the loop, producing duplicate top-level widgets).
|
|
261
|
+
self.layout().addWidget(cores_groupbox)
|
|
262
|
+
|
|
263
|
+
# Lock the Snap/Live buttons for the duration of a Procedure run.
|
|
264
|
+
# Bound-method connections auto-disconnect when this QWidget is
|
|
265
|
+
# destroyed (the widget is rebuilt on every hardware reload).
|
|
266
|
+
events = getattr(procedure, "events", None)
|
|
267
|
+
if events is not None:
|
|
268
|
+
events.procedure_started.connect(self._on_acquisition_started)
|
|
269
|
+
events.procedure_finished.connect(self._on_acquisition_finished)
|
|
270
|
+
events.procedure_error.connect(self._on_acquisition_finished)
|
|
271
|
+
|
|
272
|
+
def _on_acquisition_started(self, *_args) -> None:
|
|
273
|
+
self.set_acquisition_active(True)
|
|
274
|
+
|
|
275
|
+
def _on_acquisition_finished(self, *_args) -> None:
|
|
276
|
+
self.set_acquisition_active(False)
|
|
277
|
+
|
|
278
|
+
def set_acquisition_active(self, active: bool) -> None:
|
|
279
|
+
"""Lock out the Snap/Live buttons while a Procedure run is in progress."""
|
|
280
|
+
for cam_buttons in self._camera_buttons:
|
|
281
|
+
cam_buttons.set_enabled(not active)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Qt adapter that bridges pure-Python device signals into ``pyqtSignal``s.
|
|
2
|
+
|
|
3
|
+
GUI code (e.g. live plotting, image previews) needs Qt signals so that
|
|
4
|
+
emissions are delivered on the main thread with QueuedConnection
|
|
5
|
+
semantics. Devices built on
|
|
6
|
+
:class:`mesofield.devices.base.BaseDataProducer` use ``psygnal`` and
|
|
7
|
+
remain Qt-free. This module is the seam: attach an adapter to a
|
|
8
|
+
device, expose the adapter's ``pyqtSignal`` as an attribute on the
|
|
9
|
+
device, and the GUI reads that attribute.
|
|
10
|
+
|
|
11
|
+
Two adapters live here:
|
|
12
|
+
|
|
13
|
+
- :class:`QtDeviceAdapter` — for serial-style devices. Bridges
|
|
14
|
+
``signals.data`` into ``serialDataReceived`` / ``serialSpeedUpdated``.
|
|
15
|
+
- :class:`QtImageAdapter` — for camera-shaped devices. Provides an
|
|
16
|
+
``image_ready(np.ndarray)`` pyqtSignal that the MDA viewer subscribes
|
|
17
|
+
to. The device pushes frames into the adapter via
|
|
18
|
+
``adapter.emit_frame(frame)``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QtDeviceAdapter(QObject):
|
|
30
|
+
"""Bridges ``device.signals.data`` into Qt-friendly emissions.
|
|
31
|
+
|
|
32
|
+
Subscribes to the device's ``signals.data`` and re-emits:
|
|
33
|
+
|
|
34
|
+
- ``serialDataReceived(object)`` — the raw payload.
|
|
35
|
+
- ``serialSpeedUpdated(float, float)`` — ``(time_s, speed)`` whenever
|
|
36
|
+
the payload is a ``dict`` carrying a ``"speed"`` key. ``time_s``
|
|
37
|
+
defaults to the device timestamp; if absent, the queue timestamp.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
serialDataReceived = pyqtSignal(object)
|
|
41
|
+
serialSpeedUpdated = pyqtSignal(float, float)
|
|
42
|
+
|
|
43
|
+
def __init__(self, device: Any) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._device = device
|
|
46
|
+
signals = getattr(device, "signals", None)
|
|
47
|
+
data_sig = getattr(signals, "data", None) if signals is not None else None
|
|
48
|
+
if data_sig is not None and hasattr(data_sig, "connect"):
|
|
49
|
+
try:
|
|
50
|
+
data_sig.connect(self._on_data)
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def _on_data(self, payload: Any, ts: Any = None) -> None:
|
|
55
|
+
try:
|
|
56
|
+
self.serialDataReceived.emit(payload)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Pull a scalar "value" out of the payload for the live trace.
|
|
61
|
+
value: Optional[float] = None
|
|
62
|
+
t: Any = None
|
|
63
|
+
if isinstance(payload, dict):
|
|
64
|
+
if "speed" in payload:
|
|
65
|
+
value = payload["speed"]
|
|
66
|
+
t = payload.get("device_us")
|
|
67
|
+
elif isinstance(payload, (int, float)):
|
|
68
|
+
value = payload
|
|
69
|
+
|
|
70
|
+
if value is None:
|
|
71
|
+
return
|
|
72
|
+
if t is None:
|
|
73
|
+
t = ts if ts is not None else 0.0
|
|
74
|
+
try:
|
|
75
|
+
self.serialSpeedUpdated.emit(float(t), float(value))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class QtImageAdapter(QObject):
|
|
81
|
+
"""Bridges per-frame ndarray emissions into a Qt ``image_ready`` signal.
|
|
82
|
+
|
|
83
|
+
The MDA gui's static-viewer branch subscribes via
|
|
84
|
+
``preview = ImagePreview(image_payload=cam.image_ready, ...)``, which
|
|
85
|
+
calls ``image_payload.connect(cb, type=QueuedConnection)`` -- so
|
|
86
|
+
``image_ready`` MUST be a real ``pyqtSignal``. Devices built on
|
|
87
|
+
:class:`BaseDataProducer` are non-Qt; they hold an instance of this
|
|
88
|
+
adapter and expose its ``image_ready`` attribute as their own.
|
|
89
|
+
|
|
90
|
+
Usage::
|
|
91
|
+
|
|
92
|
+
class MyCam(BaseDataProducer):
|
|
93
|
+
def __init__(self, cfg=None, **kwargs):
|
|
94
|
+
super().__init__(cfg, **kwargs)
|
|
95
|
+
self._qt_image_adapter = QtImageAdapter()
|
|
96
|
+
self.image_ready = self._qt_image_adapter.image_ready
|
|
97
|
+
|
|
98
|
+
def _run_loop(self):
|
|
99
|
+
...
|
|
100
|
+
self._qt_image_adapter.emit_frame(frame)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
image_ready = pyqtSignal(np.ndarray)
|
|
104
|
+
|
|
105
|
+
def emit_frame(self, frame: np.ndarray) -> None:
|
|
106
|
+
try:
|
|
107
|
+
self.image_ready.emit(frame)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
|
2
|
+
import pyqtgraph as pg
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SerialWidget(QWidget):
|
|
6
|
+
"""
|
|
7
|
+
A live-plotting widget for any serial device that emits a
|
|
8
|
+
pyqtSignal(float, float) representing (time, value).
|
|
9
|
+
|
|
10
|
+
Parameters
|
|
11
|
+
----------
|
|
12
|
+
cfg : ExperimentConfig
|
|
13
|
+
The experiment configuration object. The device is resolved from
|
|
14
|
+
``cfg.hardware.<device_attr>``. If that attribute is ``None`` or
|
|
15
|
+
missing the widget renders in a safe *disconnected* state.
|
|
16
|
+
device_attr : str
|
|
17
|
+
Attribute name on ``cfg.hardware`` to look up (default ``"encoder"``).
|
|
18
|
+
signal_name : str
|
|
19
|
+
Name of the pyqtSignal attribute on the device to connect to.
|
|
20
|
+
label : str
|
|
21
|
+
Human-readable name shown in the info label.
|
|
22
|
+
value_label : str
|
|
23
|
+
Y-axis label for the plot.
|
|
24
|
+
value_units : str
|
|
25
|
+
Y-axis units.
|
|
26
|
+
y_range : tuple[float, float]
|
|
27
|
+
Initial Y-axis range.
|
|
28
|
+
value_scale : float
|
|
29
|
+
Multiplicative factor applied to incoming values before plotting.
|
|
30
|
+
max_points : int
|
|
31
|
+
Number of data points to keep visible.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
cfg,
|
|
37
|
+
device_attr: str = "encoder",
|
|
38
|
+
signal_name: str = "serialSpeedUpdated",
|
|
39
|
+
label: str = "Serial Device",
|
|
40
|
+
value_label: str = "Value",
|
|
41
|
+
value_units: str = "",
|
|
42
|
+
y_range: tuple = (-1, 1),
|
|
43
|
+
value_scale: float = 0.01,
|
|
44
|
+
max_points: int = 100,
|
|
45
|
+
):
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.device = getattr(cfg.hardware, device_attr, None)
|
|
48
|
+
self.signal_name = signal_name
|
|
49
|
+
self.label = label
|
|
50
|
+
self.value_label = value_label
|
|
51
|
+
self.value_units = value_units
|
|
52
|
+
self.y_range = y_range
|
|
53
|
+
self.value_scale = value_scale
|
|
54
|
+
self.max_points = max_points
|
|
55
|
+
self.connected = self.device is not None
|
|
56
|
+
|
|
57
|
+
self._signal = getattr(self.device, self.signal_name, None) if self.connected else None
|
|
58
|
+
|
|
59
|
+
self.init_ui()
|
|
60
|
+
self.init_data()
|
|
61
|
+
self.setFixedHeight(300)
|
|
62
|
+
|
|
63
|
+
# ---- UI ----------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def init_ui(self):
|
|
66
|
+
self.layout = QVBoxLayout()
|
|
67
|
+
|
|
68
|
+
if self.connected:
|
|
69
|
+
port = getattr(self.device, 'port', '?')
|
|
70
|
+
baud = getattr(self.device, 'baudrate', '?')
|
|
71
|
+
status_text = "Click 'Start Live View' to begin."
|
|
72
|
+
info_text = f'{self.label} on Port: {port} | Baud: {baud}'
|
|
73
|
+
else:
|
|
74
|
+
status_text = "No device connected."
|
|
75
|
+
info_text = f"{self.label}: not available"
|
|
76
|
+
|
|
77
|
+
self.status_label = QLabel(status_text)
|
|
78
|
+
self.info_label = QLabel(info_text)
|
|
79
|
+
self.start_button = QPushButton("Start Live View")
|
|
80
|
+
self.start_button.setCheckable(True)
|
|
81
|
+
self.start_button.setEnabled(self.connected)
|
|
82
|
+
self.plot_widget = pg.PlotWidget()
|
|
83
|
+
|
|
84
|
+
self.start_button.clicked.connect(self.toggle_serial_thread)
|
|
85
|
+
|
|
86
|
+
self.layout.addWidget(self.status_label)
|
|
87
|
+
self.layout.addWidget(self.info_label)
|
|
88
|
+
self.layout.addWidget(self.start_button)
|
|
89
|
+
self.layout.addWidget(self.plot_widget)
|
|
90
|
+
self.setLayout(self.layout)
|
|
91
|
+
|
|
92
|
+
units_str = self.value_units or None
|
|
93
|
+
self.plot_widget.setTitle('Serial Trace')
|
|
94
|
+
self.plot_widget.setLabel('left', self.value_label, units=units_str)
|
|
95
|
+
self.plot_widget.setLabel('bottom', 'Time', units='s')
|
|
96
|
+
self.data_curve = self.plot_widget.plot(pen='y')
|
|
97
|
+
|
|
98
|
+
self.plot_widget.setYRange(*self.y_range)
|
|
99
|
+
self.plot_widget.showGrid(x=True, y=True)
|
|
100
|
+
|
|
101
|
+
if self._signal is not None:
|
|
102
|
+
self._signal.connect(self.receive_data)
|
|
103
|
+
|
|
104
|
+
# ---- Data --------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def init_data(self):
|
|
107
|
+
self.times = []
|
|
108
|
+
self.values = []
|
|
109
|
+
self.start_time = None
|
|
110
|
+
self.timer = None
|
|
111
|
+
self.previous_time = 0
|
|
112
|
+
|
|
113
|
+
def toggle_serial_thread(self):
|
|
114
|
+
if not self.connected:
|
|
115
|
+
return
|
|
116
|
+
if self.start_button.isChecked():
|
|
117
|
+
self._signal.connect(self.receive_data)
|
|
118
|
+
self.device.start()
|
|
119
|
+
self.status_label.setText("Serial thread started.")
|
|
120
|
+
else:
|
|
121
|
+
self.stop_serial_thread()
|
|
122
|
+
self.status_label.setText("Serial thread stopped.")
|
|
123
|
+
|
|
124
|
+
def stop_serial_thread(self):
|
|
125
|
+
if self._signal is not None:
|
|
126
|
+
self._signal.disconnect()
|
|
127
|
+
|
|
128
|
+
def receive_data(self, time, value):
|
|
129
|
+
self.times.append(time)
|
|
130
|
+
self.values.append(value * self.value_scale)
|
|
131
|
+
self.times = self.times[-self.max_points:]
|
|
132
|
+
self.values = self.values[-self.max_points:]
|
|
133
|
+
self.update_plot()
|
|
134
|
+
unit_suffix = f" {self.value_units}" if self.value_units else ""
|
|
135
|
+
self.status_label.setText(
|
|
136
|
+
f"{self.value_label}: {value:.2f}{unit_suffix}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def update_plot(self):
|
|
140
|
+
try:
|
|
141
|
+
if self.times and self.values:
|
|
142
|
+
self.data_curve.setData(self.times, self.values)
|
|
143
|
+
self.plot_widget.setXRange(self.times[0], self.times[-1], padding=0)
|
|
144
|
+
else:
|
|
145
|
+
self.plot_widget.clear()
|
|
146
|
+
self.plot_widget.setTitle('No data received.')
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print(f"Exception in update_plot: {e}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Backwards-compatible alias
|
|
152
|
+
EncoderWidget = SerialWidget
|