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
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration wizard for hot-loading experiment and hardware configurations.
|
|
3
|
+
|
|
4
|
+
Provides a unified widget for selecting and applying:
|
|
5
|
+
- Experiment JSON config files
|
|
6
|
+
- Hardware YAML config files
|
|
7
|
+
- MicroManager system .cfg files (via pymmcore-widgets ConfigurationWidget)
|
|
8
|
+
- Full pymmcore-widgets Hardware Configuration Wizard (popup)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import inspect
|
|
16
|
+
import shutil
|
|
17
|
+
from typing import TYPE_CHECKING, Optional, List
|
|
18
|
+
|
|
19
|
+
from PyQt6.QtCore import pyqtSignal, Qt, QSettings
|
|
20
|
+
from PyQt6.QtWidgets import (
|
|
21
|
+
QComboBox,
|
|
22
|
+
QFileDialog,
|
|
23
|
+
QGroupBox,
|
|
24
|
+
QHBoxLayout,
|
|
25
|
+
QLabel,
|
|
26
|
+
QLineEdit,
|
|
27
|
+
QPushButton,
|
|
28
|
+
QVBoxLayout,
|
|
29
|
+
QWidget,
|
|
30
|
+
QMessageBox,
|
|
31
|
+
QFrame,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from mesofield.gui import theme
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from pymmcore_plus import CMMCorePlus
|
|
38
|
+
from mesofield.base import Procedure
|
|
39
|
+
from mesofield.devices.cameras import MMCamera
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Dark-theme fix for pymmcore-widgets ConfigWizard on Windows
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _is_dark_palette(widget: QWidget) -> bool:
|
|
47
|
+
"""Return True if the widget's palette suggests a dark theme."""
|
|
48
|
+
bg = widget.palette().color(widget.backgroundRole())
|
|
49
|
+
# Use perceived luminance; a value < 128 indicates a dark background
|
|
50
|
+
return bg.lightness() < 128
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_DARK_WIZARD_QSS = """
|
|
54
|
+
/* ---- top-level wizard and every nested widget ---- */
|
|
55
|
+
QWizard, QWizard > QWidget {
|
|
56
|
+
background-color: #2b2b2b;
|
|
57
|
+
color: #e0e0e0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ---- wizard-page content area ---- */
|
|
61
|
+
QWizardPage, QWizardPage > QWidget, QWizardPage QFrame {
|
|
62
|
+
background-color: #2b2b2b;
|
|
63
|
+
color: #e0e0e0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ---- Modern-style header (title / subtitle banner) ---- */
|
|
67
|
+
QWizard QWidget#qt_wizard_header {
|
|
68
|
+
background-color: #333333;
|
|
69
|
+
border-bottom: 1px solid #555;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Side widget (step labels panel) */
|
|
73
|
+
QWizard QWidget#qt_wizard_sidebar {
|
|
74
|
+
background-color: #252525;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
QLabel {
|
|
78
|
+
color: #e0e0e0;
|
|
79
|
+
background: transparent;
|
|
80
|
+
}
|
|
81
|
+
QComboBox {
|
|
82
|
+
background-color: #3c3c3c;
|
|
83
|
+
color: #e0e0e0;
|
|
84
|
+
border: 1px solid #555;
|
|
85
|
+
padding: 4px;
|
|
86
|
+
}
|
|
87
|
+
QComboBox QAbstractItemView {
|
|
88
|
+
background-color: #3c3c3c;
|
|
89
|
+
color: #e0e0e0;
|
|
90
|
+
selection-background-color: #0078d4;
|
|
91
|
+
selection-color: #ffffff;
|
|
92
|
+
}
|
|
93
|
+
QComboBox::drop-down {
|
|
94
|
+
border-left: 1px solid #555;
|
|
95
|
+
}
|
|
96
|
+
QLineEdit {
|
|
97
|
+
background-color: #3c3c3c;
|
|
98
|
+
color: #e0e0e0;
|
|
99
|
+
border: 1px solid #555;
|
|
100
|
+
padding: 4px;
|
|
101
|
+
}
|
|
102
|
+
QCheckBox {
|
|
103
|
+
color: #e0e0e0;
|
|
104
|
+
}
|
|
105
|
+
QCheckBox::indicator {
|
|
106
|
+
border: 1px solid #888;
|
|
107
|
+
}
|
|
108
|
+
QRadioButton {
|
|
109
|
+
color: #e0e0e0;
|
|
110
|
+
}
|
|
111
|
+
QPushButton {
|
|
112
|
+
background-color: #3c3c3c;
|
|
113
|
+
color: #e0e0e0;
|
|
114
|
+
border: 1px solid #555;
|
|
115
|
+
padding: 4px 12px;
|
|
116
|
+
}
|
|
117
|
+
QPushButton:hover {
|
|
118
|
+
background-color: #4a4a4a;
|
|
119
|
+
}
|
|
120
|
+
QPushButton:pressed {
|
|
121
|
+
background-color: #555;
|
|
122
|
+
}
|
|
123
|
+
QTableWidget, QTableView, QTreeView {
|
|
124
|
+
background-color: #2b2b2b;
|
|
125
|
+
alternate-background-color: #323232;
|
|
126
|
+
color: #e0e0e0;
|
|
127
|
+
gridline-color: #555;
|
|
128
|
+
}
|
|
129
|
+
QHeaderView::section {
|
|
130
|
+
background-color: #3c3c3c;
|
|
131
|
+
color: #e0e0e0;
|
|
132
|
+
border: 1px solid #555;
|
|
133
|
+
padding: 4px;
|
|
134
|
+
}
|
|
135
|
+
QTableWidget::item, QTableView::item, QTreeView::item {
|
|
136
|
+
color: #e0e0e0;
|
|
137
|
+
}
|
|
138
|
+
QGroupBox {
|
|
139
|
+
color: #e0e0e0;
|
|
140
|
+
border: 1px solid #555;
|
|
141
|
+
margin-top: 8px;
|
|
142
|
+
padding-top: 8px;
|
|
143
|
+
}
|
|
144
|
+
QGroupBox::title {
|
|
145
|
+
color: #e0e0e0;
|
|
146
|
+
}
|
|
147
|
+
QSplitter::handle {
|
|
148
|
+
background-color: #555;
|
|
149
|
+
}
|
|
150
|
+
QFormLayout {
|
|
151
|
+
background: transparent;
|
|
152
|
+
}
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _apply_dark_fix(wizard: QWidget) -> None:
|
|
157
|
+
"""Apply a dark-theme stylesheet to the wizard if the system palette is dark.
|
|
158
|
+
|
|
159
|
+
The pymmcore-widgets ConfigWizard was designed for light themes. On
|
|
160
|
+
Windows 11 with dark mode, Qt applies dark backgrounds to native controls
|
|
161
|
+
(QComboBox, etc.) but leaves the text colour dark ⇒ invisible text.
|
|
162
|
+
This function detects a dark palette and overlays a comprehensive QSS fix.
|
|
163
|
+
"""
|
|
164
|
+
if _is_dark_palette(wizard):
|
|
165
|
+
wizard.setStyleSheet(_DARK_WIZARD_QSS)
|
|
166
|
+
|
|
167
|
+
class _FilePickerRow(QWidget):
|
|
168
|
+
"""A single row: [QLineEdit] [Browse…] [Load]."""
|
|
169
|
+
|
|
170
|
+
fileLoaded = pyqtSignal(str) # emits the chosen path after Load is pressed
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
label: str = "",
|
|
175
|
+
file_filter: str = "All Files (*)",
|
|
176
|
+
placeholder: str = "",
|
|
177
|
+
parent: QWidget | None = None,
|
|
178
|
+
):
|
|
179
|
+
super().__init__(parent)
|
|
180
|
+
self._file_filter = file_filter
|
|
181
|
+
|
|
182
|
+
layout = QHBoxLayout(self)
|
|
183
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
184
|
+
|
|
185
|
+
if label:
|
|
186
|
+
layout.addWidget(QLabel(label))
|
|
187
|
+
|
|
188
|
+
self.line_edit = QLineEdit()
|
|
189
|
+
self.line_edit.setPlaceholderText(placeholder)
|
|
190
|
+
layout.addWidget(self.line_edit)
|
|
191
|
+
|
|
192
|
+
browse_btn = QPushButton("Browse…")
|
|
193
|
+
browse_btn.setFixedWidth(80)
|
|
194
|
+
browse_btn.clicked.connect(self._browse)
|
|
195
|
+
layout.addWidget(browse_btn)
|
|
196
|
+
|
|
197
|
+
self.load_btn = QPushButton("Load")
|
|
198
|
+
self.load_btn.setFixedWidth(60)
|
|
199
|
+
self.load_btn.clicked.connect(self._load)
|
|
200
|
+
layout.addWidget(self.load_btn)
|
|
201
|
+
|
|
202
|
+
# -- helpers -------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _browse(self) -> None:
|
|
205
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
206
|
+
self, "Select file", "", self._file_filter
|
|
207
|
+
)
|
|
208
|
+
if path:
|
|
209
|
+
self.line_edit.setText(path)
|
|
210
|
+
|
|
211
|
+
def _load(self) -> None:
|
|
212
|
+
path = self.line_edit.text().strip()
|
|
213
|
+
if path and os.path.isfile(path):
|
|
214
|
+
self.fileLoaded.emit(path)
|
|
215
|
+
elif path:
|
|
216
|
+
QMessageBox.warning(self, "File not found", f"Cannot find:\n{path}")
|
|
217
|
+
|
|
218
|
+
def text(self) -> str:
|
|
219
|
+
return self.line_edit.text().strip()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Per-camera config card
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
class _CameraConfigCard(QFrame):
|
|
227
|
+
"""Displays the current .cfg status for a single MicroManager camera
|
|
228
|
+
and provides controls to load a different .cfg or launch the Hardware Wizard."""
|
|
229
|
+
|
|
230
|
+
cfgChanged = pyqtSignal() # emitted after a new .cfg is loaded
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
cam: MMCamera,
|
|
235
|
+
core: CMMCorePlus,
|
|
236
|
+
index: int,
|
|
237
|
+
parent: QWidget | None = None,
|
|
238
|
+
):
|
|
239
|
+
super().__init__(parent)
|
|
240
|
+
self._cam = cam
|
|
241
|
+
self._core = core
|
|
242
|
+
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
243
|
+
|
|
244
|
+
layout = QVBoxLayout(self)
|
|
245
|
+
layout.setContentsMargins(8, 6, 8, 6)
|
|
246
|
+
|
|
247
|
+
# Header
|
|
248
|
+
header = QLabel(f"<b>Camera {index + 1}:</b> {cam.name} "
|
|
249
|
+
f"<span style='color:gray'>({cam.id} / {cam.backend})</span>")
|
|
250
|
+
layout.addWidget(header)
|
|
251
|
+
|
|
252
|
+
# Status label showing which .cfg is loaded
|
|
253
|
+
self._status = QLabel()
|
|
254
|
+
layout.addWidget(self._status)
|
|
255
|
+
|
|
256
|
+
# File picker + buttons row
|
|
257
|
+
action_row = QHBoxLayout()
|
|
258
|
+
self._cfg_edit = QLineEdit()
|
|
259
|
+
self._cfg_edit.setPlaceholderText("Select a .cfg file…")
|
|
260
|
+
action_row.addWidget(self._cfg_edit)
|
|
261
|
+
|
|
262
|
+
browse_btn = QPushButton("Browse…")
|
|
263
|
+
browse_btn.setFixedWidth(80)
|
|
264
|
+
browse_btn.clicked.connect(self._browse_cfg)
|
|
265
|
+
action_row.addWidget(browse_btn)
|
|
266
|
+
|
|
267
|
+
load_btn = QPushButton("Load .cfg")
|
|
268
|
+
load_btn.setFixedWidth(80)
|
|
269
|
+
load_btn.clicked.connect(self._load_cfg)
|
|
270
|
+
action_row.addWidget(load_btn)
|
|
271
|
+
|
|
272
|
+
wizard_btn = QPushButton("🔧 Hardware Wizard…")
|
|
273
|
+
wizard_btn.setToolTip(
|
|
274
|
+
"Open the pymmcore-widgets Hardware Configuration Wizard\n"
|
|
275
|
+
"to inspect/edit devices, roles, delays, and labels."
|
|
276
|
+
)
|
|
277
|
+
wizard_btn.clicked.connect(self._open_hw_wizard)
|
|
278
|
+
action_row.addWidget(wizard_btn)
|
|
279
|
+
|
|
280
|
+
layout.addLayout(action_row)
|
|
281
|
+
|
|
282
|
+
# Initialise status from the core's current state
|
|
283
|
+
self._refresh_status()
|
|
284
|
+
|
|
285
|
+
# -- public --------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _refresh_status(self) -> None:
|
|
288
|
+
"""Update the status label from the core's loaded config file."""
|
|
289
|
+
cfg_file = self._core.systemConfigurationFile() or ""
|
|
290
|
+
yaml_cfg_path = self._cam.cfg.get("configuration_path", "")
|
|
291
|
+
|
|
292
|
+
if yaml_cfg_path:
|
|
293
|
+
self._cfg_edit.setText(yaml_cfg_path)
|
|
294
|
+
|
|
295
|
+
if cfg_file:
|
|
296
|
+
display = os.path.basename(cfg_file)
|
|
297
|
+
if "MMConfig_demo" in cfg_file or cfg_file.endswith("MMConfig_demo.cfg"):
|
|
298
|
+
self._status.setText(
|
|
299
|
+
f"✔ Loaded: <b>{display}</b> "
|
|
300
|
+
"<span style='color:#888'>(pymmcore-plus demo default)</span>"
|
|
301
|
+
)
|
|
302
|
+
self._status.setStyleSheet(f"color: {theme.ACCENT};")
|
|
303
|
+
else:
|
|
304
|
+
self._status.setText(f"✔ Loaded: <b>{display}</b>")
|
|
305
|
+
self._status.setStyleSheet(f"color: {theme.ACCENT};")
|
|
306
|
+
else:
|
|
307
|
+
self._status.setText("⚠ No system configuration loaded")
|
|
308
|
+
self._status.setStyleSheet(f"color: {theme.WARN};")
|
|
309
|
+
|
|
310
|
+
# -- slots ---------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _browse_cfg(self) -> None:
|
|
313
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
314
|
+
self, "Select MicroManager Configuration", "",
|
|
315
|
+
"MicroManager Config (*.cfg);;All Files (*)",
|
|
316
|
+
)
|
|
317
|
+
if path:
|
|
318
|
+
self._cfg_edit.setText(path)
|
|
319
|
+
self._load_cfg() # auto-load after selection
|
|
320
|
+
|
|
321
|
+
def _load_cfg(self) -> None:
|
|
322
|
+
path = self._cfg_edit.text().strip()
|
|
323
|
+
if not path:
|
|
324
|
+
QMessageBox.information(self, "No file", "Please select a .cfg file first.")
|
|
325
|
+
return
|
|
326
|
+
if not os.path.isfile(path):
|
|
327
|
+
QMessageBox.warning(self, "File not found", f"Cannot find:\n{path}")
|
|
328
|
+
return
|
|
329
|
+
try:
|
|
330
|
+
self._core.loadSystemConfiguration(path)
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
QMessageBox.critical(
|
|
333
|
+
self, "Load Error",
|
|
334
|
+
f"Failed to load .cfg:\n\n{exc}",
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
self._refresh_status()
|
|
338
|
+
self.cfgChanged.emit()
|
|
339
|
+
|
|
340
|
+
def _open_hw_wizard(self) -> None:
|
|
341
|
+
"""Launch the pymmcore-widgets Hardware Configuration Wizard as a popup dialog."""
|
|
342
|
+
try:
|
|
343
|
+
from pymmcore_widgets import ConfigWizard as _MMConfigWizard
|
|
344
|
+
except ImportError:
|
|
345
|
+
QMessageBox.information(
|
|
346
|
+
self,
|
|
347
|
+
"pymmcore-widgets not available",
|
|
348
|
+
"The Hardware Configuration Wizard requires the\n"
|
|
349
|
+
"pymmcore-widgets package.\n\n"
|
|
350
|
+
"Install it with:\n pip install pymmcore-widgets",
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
current_cfg = self._cfg_edit.text().strip() or ""
|
|
355
|
+
wizard = _MMConfigWizard(
|
|
356
|
+
config_file=current_cfg,
|
|
357
|
+
core=self._core,
|
|
358
|
+
parent=self.window(),
|
|
359
|
+
)
|
|
360
|
+
wizard.setWindowModality(Qt.WindowModality.ApplicationModal)
|
|
361
|
+
_apply_dark_fix(wizard)
|
|
362
|
+
result = wizard.exec() # blocks until closed
|
|
363
|
+
|
|
364
|
+
if result:
|
|
365
|
+
# Wizard accepted – grab the saved .cfg path and auto-load it
|
|
366
|
+
saved_path = wizard.field("dest_config") or ""
|
|
367
|
+
if saved_path and os.path.isfile(saved_path):
|
|
368
|
+
self._cfg_edit.setText(saved_path)
|
|
369
|
+
try:
|
|
370
|
+
self._core.loadSystemConfiguration(saved_path)
|
|
371
|
+
except Exception:
|
|
372
|
+
pass # refresh_status will show current state
|
|
373
|
+
|
|
374
|
+
# Refresh status regardless of accept/reject (wizard may have changed state)
|
|
375
|
+
self._refresh_status()
|
|
376
|
+
if result:
|
|
377
|
+
self.cfgChanged.emit()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# MicroManager config section (container for camera cards)
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
class _MMConfigSection(QGroupBox):
|
|
385
|
+
"""Holds a :class:`_CameraConfigCard` for each MicroManager camera.
|
|
386
|
+
|
|
387
|
+
Shows a placeholder when no cameras have been initialised yet.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
cfgChanged = pyqtSignal()
|
|
391
|
+
|
|
392
|
+
def __init__(self, parent: QWidget | None = None):
|
|
393
|
+
super().__init__("MicroManager System Config (.cfg)", parent)
|
|
394
|
+
self._layout = QVBoxLayout(self)
|
|
395
|
+
self._cards: List[_CameraConfigCard] = []
|
|
396
|
+
|
|
397
|
+
self._placeholder = QLabel(
|
|
398
|
+
"<i>Load a hardware config first to enable MicroManager .cfg loading.</i>"
|
|
399
|
+
)
|
|
400
|
+
self._layout.addWidget(self._placeholder)
|
|
401
|
+
|
|
402
|
+
def set_cameras(self, cameras) -> None:
|
|
403
|
+
"""Populate the section with one card per MicroManager camera."""
|
|
404
|
+
# Clear existing content
|
|
405
|
+
while self._layout.count():
|
|
406
|
+
item = self._layout.takeAt(0)
|
|
407
|
+
if item is not None:
|
|
408
|
+
w = item.widget()
|
|
409
|
+
if w is not None:
|
|
410
|
+
w.deleteLater()
|
|
411
|
+
self._cards.clear()
|
|
412
|
+
|
|
413
|
+
mm_cams = [
|
|
414
|
+
cam for cam in cameras
|
|
415
|
+
if cam.backend == "micromanager" and hasattr(cam, "core")
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
if not mm_cams:
|
|
419
|
+
self._placeholder = QLabel(
|
|
420
|
+
"<i>No MicroManager cameras detected in hardware config.</i>"
|
|
421
|
+
)
|
|
422
|
+
self._layout.addWidget(self._placeholder)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
for i, cam in enumerate(mm_cams):
|
|
426
|
+
card = _CameraConfigCard(cam, cam.core, index=i)
|
|
427
|
+
card.cfgChanged.connect(self.cfgChanged.emit)
|
|
428
|
+
self._cards.append(card)
|
|
429
|
+
self._layout.addWidget(card)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
# Main ConfigWizard
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
class ConfigWizard(QWidget):
|
|
437
|
+
"""Configuration wizard for loading experiment and hardware configs.
|
|
438
|
+
|
|
439
|
+
Signals
|
|
440
|
+
-------
|
|
441
|
+
configApplied
|
|
442
|
+
Emitted **after** the experiment JSON (and optionally hardware YAML)
|
|
443
|
+
have been successfully applied to the running :class:`Procedure`.
|
|
444
|
+
hardwareReady
|
|
445
|
+
Emitted after hardware has been initialised (cameras available).
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
configApplied = pyqtSignal()
|
|
449
|
+
hardwareReady = pyqtSignal()
|
|
450
|
+
procedureChanged = pyqtSignal(object) # emitted when a JSON declares a different Procedure subclass
|
|
451
|
+
|
|
452
|
+
_SETTINGS_KEY_JSON = "ConfigWizard/last_json"
|
|
453
|
+
_SETTINGS_KEY_YAML = "ConfigWizard/last_yaml"
|
|
454
|
+
|
|
455
|
+
def __init__(self, procedure: Procedure, parent: QWidget | None = None):
|
|
456
|
+
super().__init__(parent)
|
|
457
|
+
self.procedure = procedure
|
|
458
|
+
self._settings = QSettings("Mesofield", "Mesofield")
|
|
459
|
+
self._build_ui()
|
|
460
|
+
self._restore_recent_paths()
|
|
461
|
+
|
|
462
|
+
# If hardware is already configured, pre-populate the MM section
|
|
463
|
+
if self.procedure.config.hardware.is_configured:
|
|
464
|
+
self._mm_section.set_cameras(self.procedure.config.hardware.cameras)
|
|
465
|
+
|
|
466
|
+
# -- public API ----------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def refresh_mm_section(self) -> None:
|
|
469
|
+
"""Re-populate the MicroManager config section from current hardware."""
|
|
470
|
+
cameras = self.procedure.config.hardware.cameras
|
|
471
|
+
self._mm_section.set_cameras(cameras)
|
|
472
|
+
|
|
473
|
+
# -- UI ------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def _build_ui(self) -> None:
|
|
476
|
+
layout = QVBoxLayout(self)
|
|
477
|
+
|
|
478
|
+
# -- Title -----------------------------------------------------------
|
|
479
|
+
title = QLabel("<h3>⚙ Configuration Wizard</h3>")
|
|
480
|
+
layout.addWidget(title)
|
|
481
|
+
|
|
482
|
+
help_text = QLabel(
|
|
483
|
+
"Select your experiment config (.json) to get started.\n"
|
|
484
|
+
"The adjacent hardware.yaml will be discovered automatically.\n"
|
|
485
|
+
"You can also specify a hardware YAML or MicroManager .cfg manually."
|
|
486
|
+
)
|
|
487
|
+
help_text.setWordWrap(True)
|
|
488
|
+
layout.addWidget(help_text)
|
|
489
|
+
|
|
490
|
+
# -- Experiment JSON -------------------------------------------------
|
|
491
|
+
json_group = QGroupBox("Experiment Config (.json)")
|
|
492
|
+
json_layout = QVBoxLayout(json_group)
|
|
493
|
+
self._json_picker = _FilePickerRow(
|
|
494
|
+
file_filter="JSON Config (*.json)",
|
|
495
|
+
placeholder="devcfg.json",
|
|
496
|
+
)
|
|
497
|
+
json_layout.addWidget(self._json_picker)
|
|
498
|
+
layout.addWidget(json_group)
|
|
499
|
+
|
|
500
|
+
# -- Hardware YAML ---------------------------------------------------
|
|
501
|
+
yaml_group = QGroupBox("Hardware Config (.yaml)")
|
|
502
|
+
yaml_layout = QVBoxLayout(yaml_group)
|
|
503
|
+
self._yaml_picker = _FilePickerRow(
|
|
504
|
+
file_filter="YAML Config (*.yaml *.yml)",
|
|
505
|
+
placeholder="hardware.yaml (auto-detected from JSON directory)",
|
|
506
|
+
)
|
|
507
|
+
yaml_layout.addWidget(self._yaml_picker)
|
|
508
|
+
|
|
509
|
+
# -- Rig picker: copy a canonical hardware.yaml from the rig store ---
|
|
510
|
+
rig_row = QHBoxLayout()
|
|
511
|
+
rig_row.addWidget(QLabel("Rig:"))
|
|
512
|
+
self._rig_combo = QComboBox()
|
|
513
|
+
self._rig_combo.setToolTip(
|
|
514
|
+
"Copy a canonical hardware.yaml from this machine's rig store "
|
|
515
|
+
"into the experiment directory"
|
|
516
|
+
)
|
|
517
|
+
self._populate_rig_combo()
|
|
518
|
+
self._rig_combo.currentTextChanged.connect(self._on_rig_selected)
|
|
519
|
+
rig_row.addWidget(self._rig_combo, 1)
|
|
520
|
+
yaml_layout.addLayout(rig_row)
|
|
521
|
+
|
|
522
|
+
self._yaml_status = QLabel("")
|
|
523
|
+
yaml_layout.addWidget(self._yaml_status)
|
|
524
|
+
layout.addWidget(yaml_group)
|
|
525
|
+
|
|
526
|
+
# -- Apply button ----------------------------------------------------
|
|
527
|
+
self._apply_btn = QPushButton("▶ Apply Configuration")
|
|
528
|
+
self._apply_btn.setStyleSheet(
|
|
529
|
+
"QPushButton { padding: 8px 16px; font-weight: bold; }"
|
|
530
|
+
)
|
|
531
|
+
self._apply_btn.clicked.connect(self._apply)
|
|
532
|
+
layout.addWidget(self._apply_btn)
|
|
533
|
+
|
|
534
|
+
# -- MicroManager .cfg -----------------------------------------------
|
|
535
|
+
self._mm_section = _MMConfigSection()
|
|
536
|
+
layout.addWidget(self._mm_section)
|
|
537
|
+
|
|
538
|
+
# -- Spacer ----------------------------------------------------------
|
|
539
|
+
layout.addStretch()
|
|
540
|
+
|
|
541
|
+
# -- Auto-populate YAML when JSON is picked --------------------------
|
|
542
|
+
self._json_picker.line_edit.textChanged.connect(self._on_json_path_changed)
|
|
543
|
+
|
|
544
|
+
# -- Recent paths persistence ---------------------------------------------
|
|
545
|
+
|
|
546
|
+
def _restore_recent_paths(self) -> None:
|
|
547
|
+
"""Fill pickers from QSettings if the files still exist."""
|
|
548
|
+
last_json = self._settings.value(self._SETTINGS_KEY_JSON, "", type=str)
|
|
549
|
+
last_yaml = self._settings.value(self._SETTINGS_KEY_YAML, "", type=str)
|
|
550
|
+
if last_json and os.path.isfile(last_json):
|
|
551
|
+
self._json_picker.line_edit.setText(last_json)
|
|
552
|
+
if last_yaml and os.path.isfile(last_yaml):
|
|
553
|
+
self._yaml_picker.line_edit.setText(last_yaml)
|
|
554
|
+
|
|
555
|
+
def _save_recent_paths(self) -> None:
|
|
556
|
+
"""Persist current picker values to QSettings."""
|
|
557
|
+
json_path = self._json_picker.text()
|
|
558
|
+
yaml_path = self._yaml_picker.text()
|
|
559
|
+
if json_path:
|
|
560
|
+
self._settings.setValue(self._SETTINGS_KEY_JSON, json_path)
|
|
561
|
+
if yaml_path:
|
|
562
|
+
self._settings.setValue(self._SETTINGS_KEY_YAML, yaml_path)
|
|
563
|
+
|
|
564
|
+
# -- Slots ---------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
def _populate_rig_combo(self) -> None:
|
|
567
|
+
"""Fill the rig dropdown from the machine's rig store."""
|
|
568
|
+
from mesofield.scaffold import rigs
|
|
569
|
+
|
|
570
|
+
self._rig_combo.blockSignals(True)
|
|
571
|
+
self._rig_combo.clear()
|
|
572
|
+
self._rig_combo.addItem("— select rig —")
|
|
573
|
+
for name in rigs.list_rigs():
|
|
574
|
+
self._rig_combo.addItem(name)
|
|
575
|
+
self._rig_combo.addItem("dev (mock devices)")
|
|
576
|
+
# Needs a JSON destination dir before it can copy anything.
|
|
577
|
+
self._rig_combo.setEnabled(bool(self._json_picker.text()))
|
|
578
|
+
self._rig_combo.blockSignals(False)
|
|
579
|
+
|
|
580
|
+
def _on_rig_selected(self, label: str) -> None:
|
|
581
|
+
"""Copy the chosen canonical rig into the experiment's directory."""
|
|
582
|
+
if not label or label.startswith("—"):
|
|
583
|
+
return
|
|
584
|
+
json_path = self._json_picker.text()
|
|
585
|
+
if not json_path:
|
|
586
|
+
self._yaml_status.setText("⚠ pick an experiment JSON first")
|
|
587
|
+
self._yaml_status.setStyleSheet(f"color: {theme.WARN};")
|
|
588
|
+
return
|
|
589
|
+
dest = os.path.join(os.path.dirname(os.path.abspath(json_path)), "hardware.yaml")
|
|
590
|
+
if os.path.exists(dest):
|
|
591
|
+
reply = QMessageBox.question(
|
|
592
|
+
self, "Overwrite hardware.yaml?",
|
|
593
|
+
f"{dest}\nalready exists. Overwrite it with rig '{label}'?",
|
|
594
|
+
)
|
|
595
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
596
|
+
self._rig_combo.setCurrentIndex(0)
|
|
597
|
+
return
|
|
598
|
+
from mesofield.scaffold import rigs
|
|
599
|
+
from mesofield.scaffold.experiment import _hardware_yaml_mock
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
if label.startswith("dev"):
|
|
603
|
+
with open(dest, "w", encoding="utf-8") as fh:
|
|
604
|
+
fh.write(_hardware_yaml_mock())
|
|
605
|
+
else:
|
|
606
|
+
shutil.copyfile(rigs.rig_path(label), dest)
|
|
607
|
+
except Exception as exc:
|
|
608
|
+
QMessageBox.warning(self, "Rig copy failed", str(exc))
|
|
609
|
+
return
|
|
610
|
+
self._yaml_picker.line_edit.setText(dest)
|
|
611
|
+
self._yaml_status.setText(f"✔ copied rig '{label}' → hardware.yaml")
|
|
612
|
+
self._yaml_status.setStyleSheet(f"color: {theme.ACCENT};")
|
|
613
|
+
|
|
614
|
+
def _on_json_path_changed(self, text: str) -> None:
|
|
615
|
+
"""When the JSON line-edit changes, try to auto-detect hardware.yaml."""
|
|
616
|
+
self._rig_combo.setEnabled(bool(text))
|
|
617
|
+
if not text:
|
|
618
|
+
return
|
|
619
|
+
candidate = os.path.join(os.path.dirname(text), "hardware.yaml")
|
|
620
|
+
if os.path.isfile(candidate):
|
|
621
|
+
self._yaml_picker.line_edit.setText(candidate)
|
|
622
|
+
self._yaml_status.setText("✔ hardware.yaml auto-detected")
|
|
623
|
+
self._yaml_status.setStyleSheet(f"color: {theme.ACCENT};")
|
|
624
|
+
else:
|
|
625
|
+
self._yaml_status.setText("⚠ hardware.yaml not found in JSON directory")
|
|
626
|
+
self._yaml_status.setStyleSheet(f"color: {theme.WARN};")
|
|
627
|
+
|
|
628
|
+
def _apply(self) -> None:
|
|
629
|
+
"""Apply the selected configuration files to the Procedure."""
|
|
630
|
+
json_path = self._json_picker.text() or None
|
|
631
|
+
yaml_path = self._yaml_picker.text() or None
|
|
632
|
+
|
|
633
|
+
if not json_path and not yaml_path:
|
|
634
|
+
QMessageBox.information(
|
|
635
|
+
self,
|
|
636
|
+
"Nothing to load",
|
|
637
|
+
"Please select at least an experiment JSON or hardware YAML file.",
|
|
638
|
+
)
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
# If the JSON declares a custom Procedure subclass that differs
|
|
643
|
+
# from the currently active class, instantiate the new one and
|
|
644
|
+
# propagate to the parent (MainWindow). Otherwise just hot-load
|
|
645
|
+
# the new config in place.
|
|
646
|
+
from mesofield.base import load_procedure_from_config
|
|
647
|
+
|
|
648
|
+
if json_path and os.path.isfile(json_path):
|
|
649
|
+
with open(json_path, "r", encoding="utf-8") as fh:
|
|
650
|
+
cfg = json.load(fh)
|
|
651
|
+
|
|
652
|
+
proc_file = cfg.get("procedure_file") if isinstance(cfg, dict) else None
|
|
653
|
+
proc_class = cfg.get("procedure_class") if isinstance(cfg, dict) else None
|
|
654
|
+
|
|
655
|
+
should_switch_procedure = False
|
|
656
|
+
if proc_file and proc_class:
|
|
657
|
+
json_dir = os.path.dirname(os.path.abspath(json_path))
|
|
658
|
+
declared_file = proc_file
|
|
659
|
+
if not os.path.isabs(declared_file):
|
|
660
|
+
declared_file = os.path.join(json_dir, declared_file)
|
|
661
|
+
declared_file = os.path.abspath(declared_file)
|
|
662
|
+
|
|
663
|
+
current_cls = type(self.procedure)
|
|
664
|
+
current_file = inspect.getsourcefile(current_cls) or inspect.getfile(current_cls) or ""
|
|
665
|
+
current_file = os.path.abspath(current_file) if current_file else ""
|
|
666
|
+
|
|
667
|
+
same_class_name = current_cls.__name__ == proc_class
|
|
668
|
+
same_file = (
|
|
669
|
+
bool(current_file)
|
|
670
|
+
and os.path.normcase(os.path.normpath(current_file))
|
|
671
|
+
== os.path.normcase(os.path.normpath(declared_file))
|
|
672
|
+
)
|
|
673
|
+
should_switch_procedure = not (same_class_name and same_file)
|
|
674
|
+
|
|
675
|
+
if should_switch_procedure:
|
|
676
|
+
candidate = load_procedure_from_config(json_path)
|
|
677
|
+
# The candidate's __init__ already loaded the JSON and
|
|
678
|
+
# initialized hardware from <json_dir>/hardware.yaml. Only
|
|
679
|
+
# reload if the user explicitly picked a *different* YAML;
|
|
680
|
+
# otherwise we'd orphan the live HardwareManager (devices
|
|
681
|
+
# still hold the cameras) and loop on re-initialization.
|
|
682
|
+
if yaml_path:
|
|
683
|
+
existing_yaml = getattr(
|
|
684
|
+
candidate.config, "_hardware_yaml_path", None
|
|
685
|
+
)
|
|
686
|
+
picked_yaml = os.path.abspath(yaml_path)
|
|
687
|
+
if not existing_yaml or picked_yaml != os.path.abspath(existing_yaml):
|
|
688
|
+
candidate.load_config(hardware_yaml_path=yaml_path)
|
|
689
|
+
self.procedure = candidate
|
|
690
|
+
self.procedureChanged.emit(candidate)
|
|
691
|
+
else:
|
|
692
|
+
self.procedure.load_config(
|
|
693
|
+
json_path=json_path,
|
|
694
|
+
hardware_yaml_path=yaml_path,
|
|
695
|
+
)
|
|
696
|
+
else:
|
|
697
|
+
self.procedure.load_config(
|
|
698
|
+
json_path=json_path,
|
|
699
|
+
hardware_yaml_path=yaml_path,
|
|
700
|
+
)
|
|
701
|
+
except Exception as exc:
|
|
702
|
+
QMessageBox.critical(
|
|
703
|
+
self,
|
|
704
|
+
"Configuration Error",
|
|
705
|
+
f"Failed to apply configuration:\n\n{exc}",
|
|
706
|
+
)
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
# Persist the selected paths for next launch
|
|
710
|
+
self._save_recent_paths()
|
|
711
|
+
|
|
712
|
+
# Refresh the MM config section now that cameras are available
|
|
713
|
+
cameras = self.procedure.config.hardware.cameras
|
|
714
|
+
self._mm_section.set_cameras(cameras)
|
|
715
|
+
|
|
716
|
+
self.configApplied.emit()
|
|
717
|
+
|
|
718
|
+
if self.procedure.config.hardware.is_configured:
|
|
719
|
+
self.hardwareReady.emit()
|
|
720
|
+
|
|
721
|
+
self._apply_btn.setText("✔ Configuration Applied")
|
|
722
|
+
self._apply_btn.setStyleSheet(
|
|
723
|
+
"QPushButton { padding: 8px 16px; font-weight: bold; color: green; }"
|
|
724
|
+
)
|