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/playback.py
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
"""Replay a saved mesofield session as if it were a live acquisition.
|
|
2
|
+
|
|
3
|
+
A playback run instantiates :class:`PlaybackCamera` and :class:`PlaybackEncoder`
|
|
4
|
+
devices that read recorded OME-TIFF frames and encoder CSVs off disk and re-emit
|
|
5
|
+
them through the standard :class:`~mesofield.signals.DeviceSignals` bundle. The
|
|
6
|
+
:class:`~mesofield.data.manager.DataManager` registers them like any other
|
|
7
|
+
producer, so the in-memory :class:`~mesofield.data.manager.DataQueue`, every
|
|
8
|
+
subscribed processor and the GUI viewer see the data move through the same
|
|
9
|
+
pipeline they would during a live capture.
|
|
10
|
+
|
|
11
|
+
Public surface used by ``mesofield playback <experiment_dir>``:
|
|
12
|
+
|
|
13
|
+
- :func:`discover_playback_context` -- locate ``manifest.json``, build a
|
|
14
|
+
:class:`PlaybackProcedure` with one playback device per producer.
|
|
15
|
+
- :func:`launch_playback_app` -- run that procedure inside the standard
|
|
16
|
+
:class:`~mesofield.gui.maingui.MainWindow`.
|
|
17
|
+
|
|
18
|
+
Playback is read-only: devices do not allocate writers, and the procedure
|
|
19
|
+
skips the dataqueue logger, the per-device ``save_data`` step, and the
|
|
20
|
+
``manifest.json`` re-write so the original session folder is left untouched.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import csv
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
import importlib.util
|
|
38
|
+
import sys
|
|
39
|
+
import uuid
|
|
40
|
+
|
|
41
|
+
from mesofield import DeviceRegistry
|
|
42
|
+
from mesofield.base import Procedure
|
|
43
|
+
from mesofield.devices.base import BaseDataProducer
|
|
44
|
+
from mesofield.devices.base_camera import BaseCamera
|
|
45
|
+
from mesofield.utils._logger import get_logger
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from mesofield.config import ExperimentConfig
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"PlaybackClock",
|
|
53
|
+
"PlaybackCamera",
|
|
54
|
+
"PlaybackEncoder",
|
|
55
|
+
"PlaybackContext",
|
|
56
|
+
"PlaybackSession",
|
|
57
|
+
"discover_playback_context",
|
|
58
|
+
"discover_playback_sessions",
|
|
59
|
+
"PlaybackBrowser",
|
|
60
|
+
"launch_playback_app",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Shared clock
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class PlaybackClock:
|
|
71
|
+
"""Wall-clock anchor shared by every playback device in a session.
|
|
72
|
+
|
|
73
|
+
``t0_original`` is the earliest device timestamp across all producers in
|
|
74
|
+
the manifest; ``t0_wall`` is captured at ``arm()`` time. Each replay thread
|
|
75
|
+
sleeps until ``t0_wall + (original_ts - t0_original) / speed``, which keeps
|
|
76
|
+
the two cameras and the encoder in their original relative order.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
t0_original: float = 0.0
|
|
80
|
+
speed: float = 1.0
|
|
81
|
+
t0_wall: float = 0.0
|
|
82
|
+
|
|
83
|
+
def reset(self, wall_now: Optional[float] = None) -> None:
|
|
84
|
+
self.t0_wall = time.time() if wall_now is None else wall_now
|
|
85
|
+
|
|
86
|
+
def sleep_until(self, original_ts: float, stop_event: threading.Event) -> bool:
|
|
87
|
+
"""Block until the wall-clock moment matching ``original_ts``.
|
|
88
|
+
|
|
89
|
+
Returns ``True`` when interrupted via ``stop_event``.
|
|
90
|
+
"""
|
|
91
|
+
if self.speed <= 0:
|
|
92
|
+
return stop_event.wait(0)
|
|
93
|
+
delay = (original_ts - self.t0_original) / self.speed
|
|
94
|
+
target = self.t0_wall + delay
|
|
95
|
+
wait = target - time.time()
|
|
96
|
+
if wait <= 0:
|
|
97
|
+
return stop_event.is_set()
|
|
98
|
+
return stop_event.wait(wait)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Frame metadata / CSV readers
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_iso_to_unix(value: str) -> float:
|
|
107
|
+
if value.endswith("Z"):
|
|
108
|
+
value = value[:-1] + "+00:00"
|
|
109
|
+
return datetime.fromisoformat(value).timestamp()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_frame_timestamps(meta_path: Path) -> List[float]:
|
|
113
|
+
"""Return UNIX-second timestamps from a ``*_frame_metadata.json`` sidecar.
|
|
114
|
+
|
|
115
|
+
Handles three on-disk shapes mesofield writers produce:
|
|
116
|
+
|
|
117
|
+
- ``CustomWriter`` (OME-TIFF) and ``CV2Writer`` MDA-mode emit
|
|
118
|
+
``{"p0": [...]}`` keyed by position.
|
|
119
|
+
- ``CV2Writer.finish`` (direct capture-loop mode used by ``OpenCVCamera``)
|
|
120
|
+
wraps the same payload under ``{"frame_metadatas": {"p0": [...]}}``.
|
|
121
|
+
- A bare ``[{...}, ...]`` list is also tolerated.
|
|
122
|
+
"""
|
|
123
|
+
with meta_path.open("r", encoding="utf-8") as fh:
|
|
124
|
+
records = json.load(fh)
|
|
125
|
+
|
|
126
|
+
# Unwrap CV2Writer.finish's outer envelope.
|
|
127
|
+
if isinstance(records, dict) and "frame_metadatas" in records:
|
|
128
|
+
records = records["frame_metadatas"]
|
|
129
|
+
|
|
130
|
+
timestamps: List[float] = []
|
|
131
|
+
if isinstance(records, dict):
|
|
132
|
+
for _pos_key, entries in records.items():
|
|
133
|
+
for entry in entries or []:
|
|
134
|
+
ts_raw = entry.get("TimeReceivedByCore")
|
|
135
|
+
if ts_raw is None:
|
|
136
|
+
continue
|
|
137
|
+
timestamps.append(_parse_iso_to_unix(ts_raw))
|
|
138
|
+
elif isinstance(records, list):
|
|
139
|
+
for entry in records:
|
|
140
|
+
ts_raw = entry.get("TimeReceivedByCore")
|
|
141
|
+
if ts_raw is None:
|
|
142
|
+
continue
|
|
143
|
+
timestamps.append(_parse_iso_to_unix(ts_raw))
|
|
144
|
+
return timestamps
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Frame readers (OME-TIFF and video)
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _FrameReader:
|
|
156
|
+
"""Uniform iterator over a recorded camera output.
|
|
157
|
+
|
|
158
|
+
Hides the difference between ``tifffile``-readable stacks and
|
|
159
|
+
``cv2.VideoCapture``-readable videos so :meth:`PlaybackCamera._run_loop`
|
|
160
|
+
only writes the timing logic once. Used as a context manager; supports
|
|
161
|
+
``rewind()`` for the ``loop=True`` mode.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, path: Path) -> None:
|
|
165
|
+
self.path = path
|
|
166
|
+
self.kind = "video" if path.suffix.lower() in _VIDEO_SUFFIXES else "tiff"
|
|
167
|
+
self.n_frames: int = 0
|
|
168
|
+
self._tf: Any = None
|
|
169
|
+
self._pages: Optional[List[Any]] = None
|
|
170
|
+
self._cap: Any = None
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> "_FrameReader":
|
|
173
|
+
if self.kind == "tiff":
|
|
174
|
+
import tifffile
|
|
175
|
+
|
|
176
|
+
self._tf = tifffile.TiffFile(str(self.path))
|
|
177
|
+
self._pages = list(self._tf.pages)
|
|
178
|
+
self.n_frames = len(self._pages)
|
|
179
|
+
else:
|
|
180
|
+
import cv2
|
|
181
|
+
|
|
182
|
+
self._cap = cv2.VideoCapture(str(self.path))
|
|
183
|
+
if not self._cap.isOpened():
|
|
184
|
+
raise RuntimeError(f"Could not open video {self.path}")
|
|
185
|
+
count = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
186
|
+
self.n_frames = max(count, 0)
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *exc: Any) -> None:
|
|
190
|
+
if self._tf is not None:
|
|
191
|
+
try:
|
|
192
|
+
self._tf.close()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
self._tf = None
|
|
196
|
+
if self._cap is not None:
|
|
197
|
+
try:
|
|
198
|
+
self._cap.release()
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
self._cap = None
|
|
202
|
+
|
|
203
|
+
def read(self, idx: int) -> Optional[np.ndarray]:
|
|
204
|
+
"""Return frame ``idx`` (0-based) as an ndarray, or ``None`` if absent."""
|
|
205
|
+
if self.kind == "tiff":
|
|
206
|
+
if self._pages is None or idx >= len(self._pages):
|
|
207
|
+
return None
|
|
208
|
+
return np.asarray(self._pages[idx].asarray())
|
|
209
|
+
# Video: cv2 advances sequentially; idx is informational.
|
|
210
|
+
if self._cap is None:
|
|
211
|
+
return None
|
|
212
|
+
ret, frame = self._cap.read()
|
|
213
|
+
return frame if ret else None
|
|
214
|
+
|
|
215
|
+
def rewind(self) -> None:
|
|
216
|
+
if self.kind == "video" and self._cap is not None:
|
|
217
|
+
import cv2
|
|
218
|
+
|
|
219
|
+
self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
220
|
+
# tiff is indexed, no state to reset.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _load_encoder_rows(csv_path: Path) -> Tuple[List[str], List[Tuple[float, Any]]]:
|
|
224
|
+
"""Read a saved producer CSV.
|
|
225
|
+
|
|
226
|
+
Returns ``(payload_columns, rows)`` where each row is ``(ts, payload)``.
|
|
227
|
+
``payload`` is the scalar from the single ``payload`` column, or a dict
|
|
228
|
+
keyed by ``payload_columns`` when the CSV fans multiple columns out (the
|
|
229
|
+
treadmill case).
|
|
230
|
+
"""
|
|
231
|
+
rows: List[Tuple[float, Any]] = []
|
|
232
|
+
with csv_path.open("r", newline="", encoding="utf-8") as fh:
|
|
233
|
+
reader = csv.reader(fh)
|
|
234
|
+
try:
|
|
235
|
+
header = next(reader)
|
|
236
|
+
except StopIteration:
|
|
237
|
+
return [], []
|
|
238
|
+
if not header or header[0] != "timestamp":
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"{csv_path}: expected first column 'timestamp', got {header!r}"
|
|
241
|
+
)
|
|
242
|
+
data_cols = header[1:]
|
|
243
|
+
scalar = len(data_cols) == 1 and data_cols[0] == "payload"
|
|
244
|
+
|
|
245
|
+
for raw in reader:
|
|
246
|
+
if not raw:
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
ts = float(raw[0])
|
|
250
|
+
except ValueError:
|
|
251
|
+
continue
|
|
252
|
+
if scalar:
|
|
253
|
+
payload: Any = _coerce_scalar(raw[1] if len(raw) > 1 else "")
|
|
254
|
+
else:
|
|
255
|
+
payload = {
|
|
256
|
+
col: _coerce_scalar(raw[i + 1]) if i + 1 < len(raw) else None
|
|
257
|
+
for i, col in enumerate(data_cols)
|
|
258
|
+
}
|
|
259
|
+
rows.append((ts, payload))
|
|
260
|
+
return data_cols, rows
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _coerce_scalar(text: str) -> Any:
|
|
264
|
+
if text == "" or text is None:
|
|
265
|
+
return None
|
|
266
|
+
try:
|
|
267
|
+
return int(text)
|
|
268
|
+
except ValueError:
|
|
269
|
+
pass
|
|
270
|
+
try:
|
|
271
|
+
return float(text)
|
|
272
|
+
except ValueError:
|
|
273
|
+
return text
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# Playback camera
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@DeviceRegistry.register("playback_camera")
|
|
282
|
+
class PlaybackCamera(BaseCamera, BaseDataProducer):
|
|
283
|
+
"""Replays an OME-TIFF + sidecar JSON pair through the standard camera surface."""
|
|
284
|
+
|
|
285
|
+
device_type: ClassVar[str] = "camera"
|
|
286
|
+
file_type: ClassVar[str] = "ome.tiff"
|
|
287
|
+
bids_type: ClassVar[Optional[str]] = "func"
|
|
288
|
+
data_type: ClassVar[str] = "frames"
|
|
289
|
+
clock_source: ClassVar[str] = "wall_unix_s"
|
|
290
|
+
|
|
291
|
+
def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
|
|
292
|
+
BaseDataProducer.__init__(self, cfg, **kwargs)
|
|
293
|
+
self._init_camera_surface(self.cfg, backend="playback")
|
|
294
|
+
|
|
295
|
+
frames_path = self.cfg.get("frames_path")
|
|
296
|
+
meta_path = self.cfg.get("metadata_path")
|
|
297
|
+
if not frames_path:
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f"PlaybackCamera({self.device_id}): 'frames_path' is required"
|
|
300
|
+
)
|
|
301
|
+
self._frames_path = Path(frames_path)
|
|
302
|
+
self._meta_path = Path(meta_path) if meta_path else None
|
|
303
|
+
self._timestamps: List[float] = []
|
|
304
|
+
# Reflect the on-disk format in status()/metadata even though playback
|
|
305
|
+
# never allocates a writer.
|
|
306
|
+
ft = self.cfg.get("file_type")
|
|
307
|
+
if ft:
|
|
308
|
+
self.file_type = str(ft).lower()
|
|
309
|
+
|
|
310
|
+
self._clock: PlaybackClock = self.cfg.get("clock") or PlaybackClock()
|
|
311
|
+
self._loop: bool = bool(self.cfg.get("loop", False))
|
|
312
|
+
|
|
313
|
+
self.sampling_rate = float(
|
|
314
|
+
self.cfg.get("sampling_rate") or self.cfg.get("fps") or 0.0
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self._stop_event = threading.Event()
|
|
318
|
+
self._thread: Optional[threading.Thread] = None
|
|
319
|
+
self._frame_count: Optional[int] = None
|
|
320
|
+
|
|
321
|
+
self._qt_image_adapter = None
|
|
322
|
+
self.image_ready = None
|
|
323
|
+
try:
|
|
324
|
+
from mesofield.gui.qt_device_adapter import QtImageAdapter
|
|
325
|
+
|
|
326
|
+
self._qt_image_adapter = QtImageAdapter()
|
|
327
|
+
self.image_ready = self._qt_image_adapter.image_ready
|
|
328
|
+
except Exception:
|
|
329
|
+
self.logger.debug("QtImageAdapter unavailable; running headless.")
|
|
330
|
+
|
|
331
|
+
# ---- lifecycle -----------------------------------------------------
|
|
332
|
+
def initialize(self) -> bool:
|
|
333
|
+
if not self._frames_path.is_file():
|
|
334
|
+
raise FileNotFoundError(
|
|
335
|
+
f"PlaybackCamera({self.device_id}): frames file missing: "
|
|
336
|
+
f"{self._frames_path}"
|
|
337
|
+
)
|
|
338
|
+
if self._meta_path and self._meta_path.is_file():
|
|
339
|
+
self._timestamps = _load_frame_timestamps(self._meta_path)
|
|
340
|
+
else:
|
|
341
|
+
self.logger.warning(
|
|
342
|
+
f"No frame metadata sidecar for {self.device_id}; "
|
|
343
|
+
f"synthesising timestamps from sampling_rate={self.sampling_rate}"
|
|
344
|
+
)
|
|
345
|
+
self._timestamps = []
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
def arm(self, config: "ExperimentConfig") -> None:
|
|
349
|
+
# Read-only: clear the buffer but skip writer / output_path setup.
|
|
350
|
+
self.clear_buffer()
|
|
351
|
+
|
|
352
|
+
def set_writer(self, make_path: Any) -> None: # noqa: D401
|
|
353
|
+
"""No-op: playback never allocates a writer."""
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
def start(self) -> bool:
|
|
357
|
+
if self._thread is not None and self._thread.is_alive():
|
|
358
|
+
return False
|
|
359
|
+
self._stop_event.clear()
|
|
360
|
+
self._thread = threading.Thread(
|
|
361
|
+
target=self._run_loop,
|
|
362
|
+
name=f"PlaybackCamera-{self.device_id}",
|
|
363
|
+
daemon=True,
|
|
364
|
+
)
|
|
365
|
+
self._thread.start()
|
|
366
|
+
return BaseDataProducer.start(self)
|
|
367
|
+
|
|
368
|
+
def stop(self) -> bool:
|
|
369
|
+
self._stop_event.set()
|
|
370
|
+
thread = self._thread
|
|
371
|
+
if thread is not None and thread is not threading.current_thread():
|
|
372
|
+
thread.join(timeout=2.0)
|
|
373
|
+
self._thread = None
|
|
374
|
+
return BaseDataProducer.stop(self)
|
|
375
|
+
|
|
376
|
+
# ---- live preview contract (abstract on BaseCamera) ----------------
|
|
377
|
+
def snap(self) -> Optional[np.ndarray]:
|
|
378
|
+
try:
|
|
379
|
+
with _FrameReader(self._frames_path) as reader:
|
|
380
|
+
if reader.n_frames == 0:
|
|
381
|
+
return None
|
|
382
|
+
frame = reader.read(0)
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
self.logger.error(f"snap failed for {self._frames_path}: {exc}")
|
|
385
|
+
return None
|
|
386
|
+
if frame is not None and self._qt_image_adapter is not None:
|
|
387
|
+
self._qt_image_adapter.emit_frame(frame)
|
|
388
|
+
return frame
|
|
389
|
+
|
|
390
|
+
def start_live(self) -> None:
|
|
391
|
+
self.start()
|
|
392
|
+
|
|
393
|
+
def stop_live(self) -> None:
|
|
394
|
+
self.stop()
|
|
395
|
+
|
|
396
|
+
# ---- run loop ------------------------------------------------------
|
|
397
|
+
def _run_loop(self) -> None:
|
|
398
|
+
try:
|
|
399
|
+
with _FrameReader(self._frames_path) as reader:
|
|
400
|
+
self._frame_count = reader.n_frames
|
|
401
|
+
if reader.n_frames == 0:
|
|
402
|
+
self.logger.warning(f"No frames in {self._frames_path}")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
timestamps = self._effective_timestamps(reader.n_frames)
|
|
406
|
+
|
|
407
|
+
while not self._stop_event.is_set():
|
|
408
|
+
for idx in range(reader.n_frames):
|
|
409
|
+
if self._stop_event.is_set():
|
|
410
|
+
return
|
|
411
|
+
ts = timestamps[idx]
|
|
412
|
+
if self._clock.sleep_until(ts, self._stop_event):
|
|
413
|
+
return
|
|
414
|
+
try:
|
|
415
|
+
frame = reader.read(idx)
|
|
416
|
+
except Exception as exc:
|
|
417
|
+
self.logger.exception(f"frame read failed: {exc}")
|
|
418
|
+
continue
|
|
419
|
+
if frame is None:
|
|
420
|
+
continue
|
|
421
|
+
if self._qt_image_adapter is not None:
|
|
422
|
+
try:
|
|
423
|
+
self._qt_image_adapter.emit_frame(frame)
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
try:
|
|
427
|
+
self.signals.frame.emit(frame, idx, ts)
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
self.record({"frame_index": idx}, ts=ts)
|
|
431
|
+
|
|
432
|
+
if not self._loop:
|
|
433
|
+
return
|
|
434
|
+
# Re-anchor the clock so the next pass replays at speed.
|
|
435
|
+
reader.rewind()
|
|
436
|
+
self._clock.reset()
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
self.logger.exception(f"playback loop crashed: {exc}")
|
|
439
|
+
finally:
|
|
440
|
+
self._emit_finished_if_natural()
|
|
441
|
+
self.logger.debug(f"playback camera {self.device_id} exited")
|
|
442
|
+
|
|
443
|
+
def _emit_finished_if_natural(self) -> None:
|
|
444
|
+
"""Emit ``signals.finished`` when the loop exits without a stop call.
|
|
445
|
+
|
|
446
|
+
:meth:`stop` already emits via :class:`BaseDataProducer`; we only
|
|
447
|
+
emit here when the thread ran out of frames on its own, so the
|
|
448
|
+
:class:`~mesofield.base.Procedure`'s primary-finished hook fires
|
|
449
|
+
even though nothing explicitly called ``stop()``.
|
|
450
|
+
"""
|
|
451
|
+
if self._stop_event.is_set():
|
|
452
|
+
return
|
|
453
|
+
self.is_active = False
|
|
454
|
+
self.is_running = False
|
|
455
|
+
try:
|
|
456
|
+
self.signals.finished.emit()
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
def _effective_timestamps(self, n_pages: int) -> List[float]:
|
|
461
|
+
if self._timestamps and len(self._timestamps) >= n_pages:
|
|
462
|
+
return self._timestamps[:n_pages]
|
|
463
|
+
if self._timestamps:
|
|
464
|
+
# Pad with cadence of the last delta to match page count.
|
|
465
|
+
ts = list(self._timestamps)
|
|
466
|
+
if len(ts) >= 2:
|
|
467
|
+
step = ts[-1] - ts[-2]
|
|
468
|
+
elif self.sampling_rate:
|
|
469
|
+
step = 1.0 / self.sampling_rate
|
|
470
|
+
else:
|
|
471
|
+
step = 0.05
|
|
472
|
+
while len(ts) < n_pages:
|
|
473
|
+
ts.append(ts[-1] + step)
|
|
474
|
+
return ts
|
|
475
|
+
# No metadata at all: synthesise from sampling_rate, anchored at t0.
|
|
476
|
+
step = 1.0 / self.sampling_rate if self.sampling_rate else 0.05
|
|
477
|
+
t0 = self._clock.t0_original
|
|
478
|
+
return [t0 + i * step for i in range(n_pages)]
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def first_timestamp(self) -> Optional[float]:
|
|
482
|
+
if self._timestamps:
|
|
483
|
+
return self._timestamps[0]
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def calibration(self) -> Dict[str, Any]:
|
|
488
|
+
return {
|
|
489
|
+
"source": str(self._frames_path),
|
|
490
|
+
"frame_count": self._frame_count,
|
|
491
|
+
"sampling_rate_hz": self.sampling_rate,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
# Playback encoder
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@DeviceRegistry.register("playback_encoder")
|
|
501
|
+
class PlaybackEncoder(BaseDataProducer):
|
|
502
|
+
"""Replays a saved encoder/treadmill CSV through ``signals.data``."""
|
|
503
|
+
|
|
504
|
+
device_type: ClassVar[str] = "encoder"
|
|
505
|
+
file_type: ClassVar[str] = "csv"
|
|
506
|
+
bids_type: ClassVar[Optional[str]] = "beh"
|
|
507
|
+
|
|
508
|
+
def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
|
|
509
|
+
super().__init__(cfg, **kwargs)
|
|
510
|
+
csv_path = self.cfg.get("csv_path")
|
|
511
|
+
if not csv_path:
|
|
512
|
+
raise ValueError(
|
|
513
|
+
f"PlaybackEncoder({self.device_id}): 'csv_path' is required"
|
|
514
|
+
)
|
|
515
|
+
self._csv_path = Path(csv_path)
|
|
516
|
+
self._rows: List[Tuple[float, Any]] = []
|
|
517
|
+
self._payload_columns: List[str] = []
|
|
518
|
+
self._clock: PlaybackClock = self.cfg.get("clock") or PlaybackClock()
|
|
519
|
+
self._loop: bool = bool(self.cfg.get("loop", False))
|
|
520
|
+
self._stop_event = threading.Event()
|
|
521
|
+
self._thread: Optional[threading.Thread] = None
|
|
522
|
+
|
|
523
|
+
# ---- lifecycle -----------------------------------------------------
|
|
524
|
+
def initialize(self) -> bool:
|
|
525
|
+
if not self._csv_path.is_file():
|
|
526
|
+
raise FileNotFoundError(
|
|
527
|
+
f"PlaybackEncoder({self.device_id}): csv missing: {self._csv_path}"
|
|
528
|
+
)
|
|
529
|
+
self._payload_columns, self._rows = _load_encoder_rows(self._csv_path)
|
|
530
|
+
return True
|
|
531
|
+
|
|
532
|
+
def arm(self, config: "ExperimentConfig") -> None:
|
|
533
|
+
# Read-only: clear buffer, skip make_path.
|
|
534
|
+
self.clear_buffer()
|
|
535
|
+
|
|
536
|
+
def start(self) -> bool:
|
|
537
|
+
if self._thread is not None and self._thread.is_alive():
|
|
538
|
+
return False
|
|
539
|
+
self._stop_event.clear()
|
|
540
|
+
self._thread = threading.Thread(
|
|
541
|
+
target=self._run_loop,
|
|
542
|
+
name=f"PlaybackEncoder-{self.device_id}",
|
|
543
|
+
daemon=True,
|
|
544
|
+
)
|
|
545
|
+
self._thread.start()
|
|
546
|
+
return super().start()
|
|
547
|
+
|
|
548
|
+
def stop(self) -> bool:
|
|
549
|
+
self._stop_event.set()
|
|
550
|
+
thread = self._thread
|
|
551
|
+
if thread is not None and thread is not threading.current_thread():
|
|
552
|
+
thread.join(timeout=2.0)
|
|
553
|
+
self._thread = None
|
|
554
|
+
return super().stop()
|
|
555
|
+
|
|
556
|
+
def save_data(self, path: Optional[str] = None) -> Optional[str]:
|
|
557
|
+
# Read-only.
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
# ---- run loop ------------------------------------------------------
|
|
561
|
+
def _run_loop(self) -> None:
|
|
562
|
+
try:
|
|
563
|
+
if not self._rows:
|
|
564
|
+
self.logger.warning(f"No rows in {self._csv_path}")
|
|
565
|
+
return
|
|
566
|
+
while not self._stop_event.is_set():
|
|
567
|
+
for ts, payload in self._rows:
|
|
568
|
+
if self._stop_event.is_set():
|
|
569
|
+
return
|
|
570
|
+
if self._clock.sleep_until(ts, self._stop_event):
|
|
571
|
+
return
|
|
572
|
+
self.record(payload, ts=ts)
|
|
573
|
+
if not self._loop:
|
|
574
|
+
return
|
|
575
|
+
self._clock.reset()
|
|
576
|
+
finally:
|
|
577
|
+
if not self._stop_event.is_set():
|
|
578
|
+
self.is_active = False
|
|
579
|
+
self.is_running = False
|
|
580
|
+
try:
|
|
581
|
+
self.signals.finished.emit()
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def first_timestamp(self) -> Optional[float]:
|
|
587
|
+
return self._rows[0][0] if self._rows else None
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def calibration(self) -> Dict[str, Any]:
|
|
591
|
+
return {
|
|
592
|
+
"source": str(self._csv_path),
|
|
593
|
+
"row_count": len(self._rows),
|
|
594
|
+
"payload_columns": list(self._payload_columns),
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# Context + discovery
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@dataclass
|
|
604
|
+
class PlaybackContext:
|
|
605
|
+
"""Bundle returned by :func:`discover_playback_context`."""
|
|
606
|
+
|
|
607
|
+
procedure: Procedure
|
|
608
|
+
session_dir: Path
|
|
609
|
+
speed: float
|
|
610
|
+
loop: bool
|
|
611
|
+
clock: PlaybackClock
|
|
612
|
+
producers: List[str] = field(default_factory=list)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _find_manifest(experiment_dir: Path) -> Path:
|
|
616
|
+
"""Locate a ``manifest.json`` for *experiment_dir*.
|
|
617
|
+
|
|
618
|
+
Accepts either:
|
|
619
|
+
|
|
620
|
+
- a session directory containing ``manifest.json`` directly, or
|
|
621
|
+
- an experiment root with ``data/sub-*/ses-*/manifest.json`` below it
|
|
622
|
+
(the first match in lexicographic order is used).
|
|
623
|
+
"""
|
|
624
|
+
direct = experiment_dir / "manifest.json"
|
|
625
|
+
if direct.is_file():
|
|
626
|
+
return direct
|
|
627
|
+
candidates = sorted(experiment_dir.glob("data/sub-*/ses-*/manifest.json"))
|
|
628
|
+
if candidates:
|
|
629
|
+
return candidates[-1] # most recent session lexicographically
|
|
630
|
+
# As a last resort, recursive search (capped by glob).
|
|
631
|
+
deep = sorted(experiment_dir.rglob("manifest.json"))
|
|
632
|
+
if deep:
|
|
633
|
+
return deep[-1]
|
|
634
|
+
raise FileNotFoundError(
|
|
635
|
+
f"No manifest.json found under {experiment_dir}. Pass either a session "
|
|
636
|
+
"directory or an experiment root containing data/sub-*/ses-*/manifest.json."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _find_experiment_root(session_dir: Path) -> Optional[Path]:
|
|
641
|
+
"""Walk up from *session_dir* looking for an experiment.json/hardware.yaml."""
|
|
642
|
+
for parent in [session_dir, *session_dir.parents]:
|
|
643
|
+
if (parent / "experiment.json").is_file() or (parent / "hardware.yaml").is_file():
|
|
644
|
+
return parent
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _resolve_user_procedure_class(experiment_json: Path) -> Optional[type]:
|
|
649
|
+
"""Return the :class:`Procedure` subclass declared in *experiment_json*.
|
|
650
|
+
|
|
651
|
+
Mirrors :func:`mesofield.base.load_procedure_from_config` but stops before
|
|
652
|
+
instantiation -- we only want the class so we can subclass it and inject
|
|
653
|
+
playback devices via :meth:`define_hardware`. Avoiding the instantiation
|
|
654
|
+
side-steps the live ``initialize_hardware`` step that would otherwise
|
|
655
|
+
try to open real cameras and serial ports during ``mesofield playback``.
|
|
656
|
+
"""
|
|
657
|
+
try:
|
|
658
|
+
with experiment_json.open("r", encoding="utf-8") as fh:
|
|
659
|
+
cfg = json.load(fh)
|
|
660
|
+
except Exception:
|
|
661
|
+
return None
|
|
662
|
+
proc_file = cfg.get("procedure_file")
|
|
663
|
+
proc_class = cfg.get("procedure_class")
|
|
664
|
+
if not proc_file or not proc_class:
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
if not Path(proc_file).is_absolute():
|
|
668
|
+
proc_file = str(experiment_json.parent / proc_file)
|
|
669
|
+
if not Path(proc_file).is_file():
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
mod_name = f"mesofield_playback_procedure_{uuid.uuid4().hex}"
|
|
673
|
+
spec = importlib.util.spec_from_file_location(mod_name, proc_file)
|
|
674
|
+
if spec is None or spec.loader is None:
|
|
675
|
+
return None
|
|
676
|
+
module = importlib.util.module_from_spec(spec)
|
|
677
|
+
sys.modules[mod_name] = module
|
|
678
|
+
try:
|
|
679
|
+
spec.loader.exec_module(module)
|
|
680
|
+
except Exception:
|
|
681
|
+
return None
|
|
682
|
+
cls = getattr(module, proc_class, None)
|
|
683
|
+
if isinstance(cls, type) and issubclass(cls, Procedure):
|
|
684
|
+
return cls
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _read_primary_device_id(hardware_yaml: Path) -> Optional[str]:
|
|
689
|
+
"""Return the device_id flagged ``primary: true`` in a hardware.yaml.
|
|
690
|
+
|
|
691
|
+
Matches the convention :class:`HardwareManager` enforces: each top-level
|
|
692
|
+
stanza is a device, and the one with ``primary: true`` is the timing
|
|
693
|
+
anchor. Returns ``None`` if the file is unreadable or has no primary.
|
|
694
|
+
"""
|
|
695
|
+
try:
|
|
696
|
+
import yaml
|
|
697
|
+
|
|
698
|
+
with hardware_yaml.open("r", encoding="utf-8") as fh:
|
|
699
|
+
data = yaml.safe_load(fh) or {}
|
|
700
|
+
except Exception:
|
|
701
|
+
return None
|
|
702
|
+
for key, stanza in data.items():
|
|
703
|
+
if isinstance(stanza, dict) and stanza.get("primary") is True:
|
|
704
|
+
return str(stanza.get("id", key))
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _resolve_playback_camera_path(
|
|
709
|
+
entry: Dict[str, Any],
|
|
710
|
+
session_dir: Path,
|
|
711
|
+
logger: Any,
|
|
712
|
+
) -> Optional[Path]:
|
|
713
|
+
"""Resolve a camera producer's frames path with a metadata-sidecar fallback.
|
|
714
|
+
|
|
715
|
+
Some writers (e.g. ``OpenCVCamera`` -> ``CV2Writer``) name the on-disk file
|
|
716
|
+
by the device's display name while :class:`DataSaver` populates the
|
|
717
|
+
manifest's ``output_path`` from the YAML ``suffix:`` -- the two diverge
|
|
718
|
+
and the recorded ``output_path`` ends up pointing at a file that was
|
|
719
|
+
never written. Fall back to ``metadata_path`` minus the
|
|
720
|
+
``_frame_metadata.json`` tail, which matches the writer's actual filename.
|
|
721
|
+
"""
|
|
722
|
+
output_path = entry.get("output_path")
|
|
723
|
+
if output_path:
|
|
724
|
+
abs_output = (session_dir / output_path).resolve()
|
|
725
|
+
if abs_output.is_file():
|
|
726
|
+
return abs_output
|
|
727
|
+
|
|
728
|
+
meta_path = entry.get("metadata_path")
|
|
729
|
+
if meta_path and meta_path.endswith("_frame_metadata.json"):
|
|
730
|
+
derived = (session_dir / meta_path[: -len("_frame_metadata.json")]).resolve()
|
|
731
|
+
if derived.is_file():
|
|
732
|
+
logger.warning(
|
|
733
|
+
f"{entry.get('device_id')}: manifest output_path "
|
|
734
|
+
f"{output_path!r} not on disk; using {derived.name} derived "
|
|
735
|
+
"from metadata_path"
|
|
736
|
+
)
|
|
737
|
+
return derived
|
|
738
|
+
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _build_devices(
|
|
743
|
+
manifest: Dict[str, Any],
|
|
744
|
+
session_dir: Path,
|
|
745
|
+
clock: PlaybackClock,
|
|
746
|
+
loop: bool,
|
|
747
|
+
logger: Any,
|
|
748
|
+
primary_device_id: Optional[str] = None,
|
|
749
|
+
) -> Tuple[List[Any], List[str], List[float]]:
|
|
750
|
+
"""Construct one playback device per replayable producer in *manifest*."""
|
|
751
|
+
devices: List[Any] = []
|
|
752
|
+
producer_ids: List[str] = []
|
|
753
|
+
first_timestamps: List[float] = []
|
|
754
|
+
producers = manifest.get("producers", []) or []
|
|
755
|
+
|
|
756
|
+
for entry in producers:
|
|
757
|
+
device_id = entry.get("device_id") or "device"
|
|
758
|
+
device_type = entry.get("device_type")
|
|
759
|
+
|
|
760
|
+
if device_type == "camera":
|
|
761
|
+
file_type = (entry.get("file_type") or "").lower()
|
|
762
|
+
if file_type not in {
|
|
763
|
+
"ome.tiff", "ome.tif", "tiff", "tif",
|
|
764
|
+
"mp4", "avi", "mov", "mkv",
|
|
765
|
+
}:
|
|
766
|
+
logger.warning(
|
|
767
|
+
f"Skipping camera {device_id}: file_type "
|
|
768
|
+
f"{file_type!r} not supported for playback"
|
|
769
|
+
)
|
|
770
|
+
continue
|
|
771
|
+
frames_path = _resolve_playback_camera_path(entry, session_dir, logger)
|
|
772
|
+
if frames_path is None:
|
|
773
|
+
logger.error(
|
|
774
|
+
f"Skipping camera {device_id}: cannot find frames file on "
|
|
775
|
+
f"disk (manifest output_path={entry.get('output_path')!r})"
|
|
776
|
+
)
|
|
777
|
+
continue
|
|
778
|
+
meta_path = entry.get("metadata_path")
|
|
779
|
+
abs_meta = (session_dir / meta_path).resolve() if meta_path else None
|
|
780
|
+
cfg = {
|
|
781
|
+
"id": device_id,
|
|
782
|
+
"frames_path": str(frames_path),
|
|
783
|
+
"metadata_path": str(abs_meta) if abs_meta else None,
|
|
784
|
+
"file_type": file_type,
|
|
785
|
+
"sampling_rate": entry.get("sampling_rate_hz") or 0.0,
|
|
786
|
+
"clock": clock,
|
|
787
|
+
"loop": loop,
|
|
788
|
+
}
|
|
789
|
+
device = PlaybackCamera(cfg)
|
|
790
|
+
elif device_type == "encoder":
|
|
791
|
+
output_path = entry.get("output_path")
|
|
792
|
+
if not output_path:
|
|
793
|
+
logger.warning(f"Skipping {device_id}: no output_path in manifest")
|
|
794
|
+
continue
|
|
795
|
+
abs_output = (session_dir / output_path).resolve()
|
|
796
|
+
cfg = {
|
|
797
|
+
"id": device_id,
|
|
798
|
+
"csv_path": str(abs_output),
|
|
799
|
+
"clock": clock,
|
|
800
|
+
"loop": loop,
|
|
801
|
+
}
|
|
802
|
+
device = PlaybackEncoder(cfg)
|
|
803
|
+
else:
|
|
804
|
+
logger.warning(
|
|
805
|
+
f"Skipping {device_id}: device_type "
|
|
806
|
+
f"{device_type!r} not supported in playback v1"
|
|
807
|
+
)
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
device.initialize()
|
|
812
|
+
except Exception as exc:
|
|
813
|
+
logger.error(f"initialize() failed for {device_id}: {exc}")
|
|
814
|
+
continue
|
|
815
|
+
|
|
816
|
+
ts0 = getattr(device, "first_timestamp", None)
|
|
817
|
+
if ts0 is not None:
|
|
818
|
+
first_timestamps.append(float(ts0))
|
|
819
|
+
|
|
820
|
+
devices.append(device)
|
|
821
|
+
producer_ids.append(device_id)
|
|
822
|
+
|
|
823
|
+
# Promote the device matching the recorded primary (preferred) or the
|
|
824
|
+
# first camera (fallback) to is_primary=True. HardwareManager enforces
|
|
825
|
+
# exactly-one-primary; Procedure.run wires cleanup off its `finished`.
|
|
826
|
+
primary_set = False
|
|
827
|
+
if primary_device_id:
|
|
828
|
+
for dev in devices:
|
|
829
|
+
if dev.device_id == primary_device_id:
|
|
830
|
+
dev.is_primary = True
|
|
831
|
+
primary_set = True
|
|
832
|
+
break
|
|
833
|
+
if not primary_set:
|
|
834
|
+
logger.warning(
|
|
835
|
+
f"hardware.yaml's primary device {primary_device_id!r} is not "
|
|
836
|
+
"in the replayable set; falling back to first camera"
|
|
837
|
+
)
|
|
838
|
+
if not primary_set:
|
|
839
|
+
for dev in devices:
|
|
840
|
+
if isinstance(dev, PlaybackCamera):
|
|
841
|
+
dev.is_primary = True
|
|
842
|
+
primary_set = True
|
|
843
|
+
break
|
|
844
|
+
if not primary_set and devices:
|
|
845
|
+
devices[0].is_primary = True
|
|
846
|
+
|
|
847
|
+
return devices, producer_ids, first_timestamps
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _make_playback_procedure(
|
|
851
|
+
base_cls: type,
|
|
852
|
+
*,
|
|
853
|
+
devices: List[Any],
|
|
854
|
+
session_dir: Path,
|
|
855
|
+
session_info: Dict[str, Any],
|
|
856
|
+
speed: float,
|
|
857
|
+
loop: bool,
|
|
858
|
+
) -> Procedure:
|
|
859
|
+
"""Dynamically subclass *base_cls* to inject playback devices.
|
|
860
|
+
|
|
861
|
+
The wrapper sets ``playback = True`` (so the base ``Procedure`` skips its
|
|
862
|
+
disk-writing side-effects), overrides ``define_hardware`` to return the
|
|
863
|
+
pre-built playback devices, and -- only when the base class has no real
|
|
864
|
+
``experiment.json`` to drive it -- overrides ``define_config`` from the
|
|
865
|
+
manifest's session block. When an experiment.json is available we let the
|
|
866
|
+
user's class load it normally so duration/notes/processors line up with
|
|
867
|
+
the original run.
|
|
868
|
+
"""
|
|
869
|
+
|
|
870
|
+
class _PlaybackWrapper(base_cls): # type: ignore[valid-type, misc]
|
|
871
|
+
playback = True
|
|
872
|
+
|
|
873
|
+
def define_hardware(self): # noqa: D401
|
|
874
|
+
return list(devices)
|
|
875
|
+
|
|
876
|
+
_PlaybackWrapper.__name__ = f"Playback[{base_cls.__name__}]"
|
|
877
|
+
_PlaybackWrapper.__qualname__ = _PlaybackWrapper.__name__
|
|
878
|
+
|
|
879
|
+
if base_cls is Procedure:
|
|
880
|
+
# No user procedure -> manufacture a config from the manifest so
|
|
881
|
+
# subject/session/task/protocol are populated for logging/paths.
|
|
882
|
+
def _define_config(self): # type: ignore[no-redef]
|
|
883
|
+
info = session_info
|
|
884
|
+
return {
|
|
885
|
+
"subject": str(info.get("subject", "playback")),
|
|
886
|
+
"session": str(info.get("session", "00")),
|
|
887
|
+
"task": str(info.get("task", "playback")),
|
|
888
|
+
"protocol": str(info.get("protocol", "playback")),
|
|
889
|
+
"experimenter": str(info.get("experimenter", "playback")),
|
|
890
|
+
"duration": int(info.get("duration", 0) or 0),
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
_PlaybackWrapper.define_config = _define_config # type: ignore[assignment]
|
|
894
|
+
proc = _PlaybackWrapper(config_path=None)
|
|
895
|
+
try:
|
|
896
|
+
proc.config.experiment_dir = str(session_dir.parent.parent.parent)
|
|
897
|
+
except Exception:
|
|
898
|
+
pass
|
|
899
|
+
return proc
|
|
900
|
+
|
|
901
|
+
# The user's procedure class drives __init__ from its own experiment.json.
|
|
902
|
+
# _PlaybackWrapper.define_hardware short-circuits the YAML path so devices
|
|
903
|
+
# come from our pre-built list.
|
|
904
|
+
experiment_root = _find_experiment_root(session_dir)
|
|
905
|
+
config_path = None
|
|
906
|
+
if experiment_root is not None:
|
|
907
|
+
candidate = experiment_root / "experiment.json"
|
|
908
|
+
if candidate.is_file():
|
|
909
|
+
config_path = str(candidate)
|
|
910
|
+
proc = _PlaybackWrapper(config_path)
|
|
911
|
+
return proc
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def discover_playback_context(
|
|
915
|
+
experiment_dir: Path,
|
|
916
|
+
*,
|
|
917
|
+
speed: float = 1.0,
|
|
918
|
+
loop: bool = False,
|
|
919
|
+
) -> PlaybackContext:
|
|
920
|
+
"""Build a :class:`PlaybackContext` from a recorded session directory."""
|
|
921
|
+
logger = get_logger(__name__)
|
|
922
|
+
experiment_dir = Path(experiment_dir).expanduser().resolve()
|
|
923
|
+
manifest_path = _find_manifest(experiment_dir)
|
|
924
|
+
session_dir = manifest_path.parent
|
|
925
|
+
|
|
926
|
+
with manifest_path.open("r", encoding="utf-8") as fh:
|
|
927
|
+
manifest = json.load(fh)
|
|
928
|
+
|
|
929
|
+
experiment_root = _find_experiment_root(session_dir)
|
|
930
|
+
primary_id: Optional[str] = None
|
|
931
|
+
base_cls: type = Procedure
|
|
932
|
+
if experiment_root is not None:
|
|
933
|
+
hw_yaml = experiment_root / "hardware.yaml"
|
|
934
|
+
if hw_yaml.is_file():
|
|
935
|
+
primary_id = _read_primary_device_id(hw_yaml)
|
|
936
|
+
exp_json = experiment_root / "experiment.json"
|
|
937
|
+
if exp_json.is_file():
|
|
938
|
+
resolved = _resolve_user_procedure_class(exp_json)
|
|
939
|
+
if resolved is not None:
|
|
940
|
+
base_cls = resolved
|
|
941
|
+
logger.info(
|
|
942
|
+
f"Using user procedure class {base_cls.__name__} from "
|
|
943
|
+
f"{exp_json}"
|
|
944
|
+
)
|
|
945
|
+
else:
|
|
946
|
+
logger.debug(
|
|
947
|
+
f"No procedure_file/procedure_class in {exp_json}; "
|
|
948
|
+
"using base Procedure."
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
clock = PlaybackClock(speed=float(speed))
|
|
952
|
+
devices, producer_ids, first_timestamps = _build_devices(
|
|
953
|
+
manifest, session_dir, clock, loop, logger,
|
|
954
|
+
primary_device_id=primary_id,
|
|
955
|
+
)
|
|
956
|
+
if not devices:
|
|
957
|
+
raise RuntimeError(
|
|
958
|
+
f"No replayable producers found in {manifest_path}. "
|
|
959
|
+
"Playback supports camera (OME-TIFF / mp4) and encoder (CSV) producers."
|
|
960
|
+
)
|
|
961
|
+
clock.t0_original = min(first_timestamps) if first_timestamps else 0.0
|
|
962
|
+
clock.reset() # wall-clock anchor; reset again at procedure start.
|
|
963
|
+
|
|
964
|
+
session_info = dict(manifest.get("session") or {})
|
|
965
|
+
procedure = _make_playback_procedure(
|
|
966
|
+
base_cls,
|
|
967
|
+
devices=devices,
|
|
968
|
+
session_dir=session_dir,
|
|
969
|
+
session_info=session_info,
|
|
970
|
+
speed=float(speed),
|
|
971
|
+
loop=bool(loop),
|
|
972
|
+
)
|
|
973
|
+
logger.info(
|
|
974
|
+
f"Playback context ready: {len(producer_ids)} producers from "
|
|
975
|
+
f"{manifest_path} (speed={speed:.2f}x, loop={loop})"
|
|
976
|
+
)
|
|
977
|
+
return PlaybackContext(
|
|
978
|
+
procedure=procedure,
|
|
979
|
+
session_dir=session_dir,
|
|
980
|
+
speed=float(speed),
|
|
981
|
+
loop=bool(loop),
|
|
982
|
+
clock=clock,
|
|
983
|
+
producers=producer_ids,
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# ---------------------------------------------------------------------------
|
|
988
|
+
# Session-list discovery (drives the ConfigController dropdowns)
|
|
989
|
+
# ---------------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@dataclass
|
|
993
|
+
class PlaybackSession:
|
|
994
|
+
"""One ``(subject, session)`` directory discovered on disk.
|
|
995
|
+
|
|
996
|
+
Holds just enough metadata for the ConfigController dropdowns to present
|
|
997
|
+
a choice: the parsed subject/session ids, the absolute session directory,
|
|
998
|
+
the path to its manifest, and the task/protocol read from the manifest's
|
|
999
|
+
session block (used as display hints and to seed ``ExperimentConfig``).
|
|
1000
|
+
"""
|
|
1001
|
+
|
|
1002
|
+
subject: str
|
|
1003
|
+
session: str
|
|
1004
|
+
session_dir: Path
|
|
1005
|
+
manifest_path: Path
|
|
1006
|
+
task: Optional[str] = None
|
|
1007
|
+
protocol: Optional[str] = None
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
_SUB_RE = re.compile(r"^sub-(.+)$")
|
|
1011
|
+
_SES_RE = re.compile(r"^ses-(.+)$")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def discover_playback_sessions(experiment_dir: Path) -> List[PlaybackSession]:
|
|
1015
|
+
"""Enumerate every ``data/sub-*/ses-*/manifest.json`` under *experiment_dir*.
|
|
1016
|
+
|
|
1017
|
+
Falls back to scanning *experiment_dir* itself when it points directly at
|
|
1018
|
+
a session directory. Sessions are returned sorted by (subject, session) so
|
|
1019
|
+
the dropdowns have a predictable order.
|
|
1020
|
+
"""
|
|
1021
|
+
experiment_dir = Path(experiment_dir).expanduser().resolve()
|
|
1022
|
+
candidates = sorted(experiment_dir.glob("data/sub-*/ses-*/manifest.json"))
|
|
1023
|
+
if not candidates and (experiment_dir / "manifest.json").is_file():
|
|
1024
|
+
candidates = [experiment_dir / "manifest.json"]
|
|
1025
|
+
sessions: List[PlaybackSession] = []
|
|
1026
|
+
for manifest_path in candidates:
|
|
1027
|
+
session_dir = manifest_path.parent
|
|
1028
|
+
sub_match = _SUB_RE.match(session_dir.parent.name)
|
|
1029
|
+
ses_match = _SES_RE.match(session_dir.name)
|
|
1030
|
+
if not (sub_match and ses_match):
|
|
1031
|
+
continue
|
|
1032
|
+
info: Dict[str, Any] = {}
|
|
1033
|
+
try:
|
|
1034
|
+
with manifest_path.open("r", encoding="utf-8") as fh:
|
|
1035
|
+
info = (json.load(fh) or {}).get("session") or {}
|
|
1036
|
+
except Exception:
|
|
1037
|
+
pass
|
|
1038
|
+
sessions.append(
|
|
1039
|
+
PlaybackSession(
|
|
1040
|
+
subject=sub_match.group(1),
|
|
1041
|
+
session=ses_match.group(1),
|
|
1042
|
+
session_dir=session_dir,
|
|
1043
|
+
manifest_path=manifest_path,
|
|
1044
|
+
task=info.get("task"),
|
|
1045
|
+
protocol=info.get("protocol"),
|
|
1046
|
+
)
|
|
1047
|
+
)
|
|
1048
|
+
sessions.sort(key=lambda s: (s.subject, s.session))
|
|
1049
|
+
return sessions
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
# ---------------------------------------------------------------------------
|
|
1053
|
+
# Browser: ties subject/session dropdowns to procedure swaps
|
|
1054
|
+
# ---------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
class PlaybackBrowser:
|
|
1058
|
+
"""Drives :class:`~mesofield.gui.controller.ConfigController` for playback.
|
|
1059
|
+
|
|
1060
|
+
Two responsibilities:
|
|
1061
|
+
|
|
1062
|
+
1. Re-seed ``ExperimentConfig.subjects`` from the set of recorded sessions
|
|
1063
|
+
on disk so the subject dropdown lists what is actually playable, and
|
|
1064
|
+
register the per-subject session ids as ``register_choices`` on the
|
|
1065
|
+
``session`` key so it renders as a dropdown.
|
|
1066
|
+
2. When the user picks a different subject or session, tear down the
|
|
1067
|
+
current playback procedure and swap a freshly-built one into
|
|
1068
|
+
:class:`~mesofield.gui.maingui.MainWindow`, re-using the existing
|
|
1069
|
+
widget-rebuild path (``_build_acquisition_ui`` + ``_on_config_applied``).
|
|
1070
|
+
|
|
1071
|
+
Implementation note: the actual swap is deferred via ``QTimer.singleShot``
|
|
1072
|
+
because the change callback fires inside a QComboBox signal handler --
|
|
1073
|
+
rebuilding the parent ConfigController during that callback would delete
|
|
1074
|
+
the QObject that emitted the signal.
|
|
1075
|
+
"""
|
|
1076
|
+
|
|
1077
|
+
def __init__(
|
|
1078
|
+
self,
|
|
1079
|
+
main_window: Any,
|
|
1080
|
+
sessions: List[PlaybackSession],
|
|
1081
|
+
*,
|
|
1082
|
+
speed: float = 1.0,
|
|
1083
|
+
loop: bool = False,
|
|
1084
|
+
) -> None:
|
|
1085
|
+
self.main_window = main_window
|
|
1086
|
+
self.sessions = list(sessions)
|
|
1087
|
+
self.speed = float(speed)
|
|
1088
|
+
self.loop = bool(loop)
|
|
1089
|
+
self.logger = get_logger(f"{__name__}.PlaybackBrowser")
|
|
1090
|
+
|
|
1091
|
+
self._by_subject: Dict[str, List[PlaybackSession]] = {}
|
|
1092
|
+
for ses in self.sessions:
|
|
1093
|
+
self._by_subject.setdefault(ses.subject, []).append(ses)
|
|
1094
|
+
|
|
1095
|
+
self._swapping: bool = False
|
|
1096
|
+
self._current: Optional[PlaybackSession] = None
|
|
1097
|
+
self._install()
|
|
1098
|
+
|
|
1099
|
+
# -- public API -----------------------------------------------------
|
|
1100
|
+
@property
|
|
1101
|
+
def current(self) -> Optional[PlaybackSession]:
|
|
1102
|
+
return self._current
|
|
1103
|
+
|
|
1104
|
+
# -- install ---------------------------------------------------------
|
|
1105
|
+
def _install(self) -> None:
|
|
1106
|
+
"""Apply browser-aware behaviour to the live ConfigController.
|
|
1107
|
+
|
|
1108
|
+
Called once at startup and again after every procedure swap (because
|
|
1109
|
+
the new procedure has a fresh ``ExperimentConfig`` object and the
|
|
1110
|
+
ConfigController is rebuilt by ``MainWindow._on_config_applied``).
|
|
1111
|
+
"""
|
|
1112
|
+
proc = self.main_window.procedure
|
|
1113
|
+
cfg = proc.config
|
|
1114
|
+
|
|
1115
|
+
# Mirror the disk inventory onto cfg.subjects so the existing subject
|
|
1116
|
+
# dropdown wiring (built in ConfigController._populate_subjects from
|
|
1117
|
+
# cfg.subjects.keys()) reflects what's available for replay.
|
|
1118
|
+
cfg.subjects = {
|
|
1119
|
+
sub: {
|
|
1120
|
+
"session": ses_list[0].session,
|
|
1121
|
+
"task": ses_list[0].task or cfg.get("task", "playback") or "playback",
|
|
1122
|
+
}
|
|
1123
|
+
for sub, ses_list in self._by_subject.items()
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
current_sub = cfg.get("subject")
|
|
1127
|
+
if current_sub not in cfg.subjects and cfg.subjects:
|
|
1128
|
+
current_sub = next(iter(cfg.subjects))
|
|
1129
|
+
|
|
1130
|
+
# Build (and lock in) session choices for the current subject so the
|
|
1131
|
+
# ConfigController's form renders a session dropdown rather than a
|
|
1132
|
+
# free-text editor.
|
|
1133
|
+
self._set_session_choices(current_sub)
|
|
1134
|
+
|
|
1135
|
+
# When _change_subject was last called by the ConfigController, it ran
|
|
1136
|
+
# select_subject(initial) which set "session" to whatever cfg.subjects
|
|
1137
|
+
# said. If we have a more precise current_session (from the active
|
|
1138
|
+
# PlaybackContext), enforce it here so the form picks the right entry.
|
|
1139
|
+
if self._current is not None and current_sub == self._current.subject:
|
|
1140
|
+
cfg.set("session", self._current.session)
|
|
1141
|
+
|
|
1142
|
+
# Listen for subject/session changes; subject changes also update
|
|
1143
|
+
# session choices (the dropdown gets repopulated for the new subject).
|
|
1144
|
+
cfg.register_callback("subject", self._on_subject_changed)
|
|
1145
|
+
cfg.register_callback("session", self._on_session_changed)
|
|
1146
|
+
|
|
1147
|
+
# Push a refreshed ConfigController tab (the form widget reads
|
|
1148
|
+
# choices at construction time, so a rebuild is required for the new
|
|
1149
|
+
# session list to render).
|
|
1150
|
+
try:
|
|
1151
|
+
self.main_window._on_config_applied()
|
|
1152
|
+
except Exception as exc:
|
|
1153
|
+
self.logger.debug(f"_on_config_applied() reinstall failed: {exc}")
|
|
1154
|
+
|
|
1155
|
+
# Relabel the Record button now that the controller has been rebuilt.
|
|
1156
|
+
cc = getattr(self.main_window, "_config_controller", None)
|
|
1157
|
+
if cc is not None and hasattr(cc, "record_button"):
|
|
1158
|
+
cc.record_button.setText("Play")
|
|
1159
|
+
cc.record_button.setToolTip("Play back the selected session")
|
|
1160
|
+
|
|
1161
|
+
def _set_session_choices(self, subject: str) -> None:
|
|
1162
|
+
cfg = self.main_window.procedure.config
|
|
1163
|
+
sess_list = [s.session for s in self._by_subject.get(subject, [])]
|
|
1164
|
+
if sess_list:
|
|
1165
|
+
cfg.register_choices("session", sess_list)
|
|
1166
|
+
|
|
1167
|
+
# -- callbacks -------------------------------------------------------
|
|
1168
|
+
def _on_subject_changed(self, _key: str, value: Any) -> None:
|
|
1169
|
+
if self._swapping:
|
|
1170
|
+
return
|
|
1171
|
+
# Update choices for the new subject so the form, when ConfigController
|
|
1172
|
+
# rebuilds it after select_subject returns, renders the right options.
|
|
1173
|
+
self._set_session_choices(str(value))
|
|
1174
|
+
# The session change that select_subject() fires next will dispatch
|
|
1175
|
+
# the actual procedure swap.
|
|
1176
|
+
|
|
1177
|
+
def _on_session_changed(self, _key: str, value: Any) -> None:
|
|
1178
|
+
if self._swapping:
|
|
1179
|
+
return
|
|
1180
|
+
cfg = self.main_window.procedure.config
|
|
1181
|
+
subject = cfg.get("subject")
|
|
1182
|
+
# Defer to the event loop so the QComboBox that emitted this change
|
|
1183
|
+
# is not destroyed in the middle of its own signal dispatch.
|
|
1184
|
+
try:
|
|
1185
|
+
from PyQt6.QtCore import QTimer
|
|
1186
|
+
|
|
1187
|
+
QTimer.singleShot(0, lambda: self._swap_to(str(subject), str(value)))
|
|
1188
|
+
except Exception:
|
|
1189
|
+
# Headless / no Qt loop -- swap synchronously (e.g. tests).
|
|
1190
|
+
self._swap_to(str(subject), str(value))
|
|
1191
|
+
|
|
1192
|
+
# -- swap ------------------------------------------------------------
|
|
1193
|
+
def _swap_to(self, subject: str, session: str) -> None:
|
|
1194
|
+
target = next(
|
|
1195
|
+
(s for s in self.sessions if s.subject == subject and s.session == session),
|
|
1196
|
+
None,
|
|
1197
|
+
)
|
|
1198
|
+
if target is None:
|
|
1199
|
+
self.logger.warning(
|
|
1200
|
+
f"No session found on disk for sub-{subject}/ses-{session}; "
|
|
1201
|
+
"ignoring change"
|
|
1202
|
+
)
|
|
1203
|
+
return
|
|
1204
|
+
if self._current is not None and target.session_dir == self._current.session_dir:
|
|
1205
|
+
return # no-op
|
|
1206
|
+
self.logger.info(
|
|
1207
|
+
f"Swapping playback target -> sub-{subject}/ses-{session} "
|
|
1208
|
+
f"({target.session_dir})"
|
|
1209
|
+
)
|
|
1210
|
+
self._swapping = True
|
|
1211
|
+
try:
|
|
1212
|
+
self._teardown_current()
|
|
1213
|
+
new_ctx = discover_playback_context(
|
|
1214
|
+
target.session_dir, speed=self.speed, loop=self.loop
|
|
1215
|
+
)
|
|
1216
|
+
self.main_window.procedure = new_ctx.procedure
|
|
1217
|
+
try:
|
|
1218
|
+
self.main_window._build_acquisition_ui()
|
|
1219
|
+
except Exception as exc:
|
|
1220
|
+
self.logger.exception(f"_build_acquisition_ui failed: {exc}")
|
|
1221
|
+
self._current = target
|
|
1222
|
+
# Re-install on the new procedure's fresh config object.
|
|
1223
|
+
self._install()
|
|
1224
|
+
finally:
|
|
1225
|
+
self._swapping = False
|
|
1226
|
+
|
|
1227
|
+
def _teardown_current(self) -> None:
|
|
1228
|
+
proc = self.main_window.procedure
|
|
1229
|
+
try:
|
|
1230
|
+
proc.cleanup()
|
|
1231
|
+
except Exception:
|
|
1232
|
+
pass
|
|
1233
|
+
try:
|
|
1234
|
+
proc.config.hardware.shutdown()
|
|
1235
|
+
except Exception:
|
|
1236
|
+
pass
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
# ---------------------------------------------------------------------------
|
|
1240
|
+
# GUI launcher
|
|
1241
|
+
# ---------------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def launch_playback_app(
|
|
1245
|
+
context: PlaybackContext,
|
|
1246
|
+
*,
|
|
1247
|
+
browser_sessions: Optional[List[PlaybackSession]] = None,
|
|
1248
|
+
) -> int:
|
|
1249
|
+
"""Open the standard mesofield GUI against a :class:`PlaybackContext`.
|
|
1250
|
+
|
|
1251
|
+
When ``browser_sessions`` is supplied, a :class:`PlaybackBrowser` is wired
|
|
1252
|
+
into the MainWindow's ConfigController so the subject/session dropdowns
|
|
1253
|
+
swap playback targets on change.
|
|
1254
|
+
"""
|
|
1255
|
+
from PyQt6.QtWidgets import QApplication
|
|
1256
|
+
|
|
1257
|
+
from mesofield.gui.maingui import MainWindow
|
|
1258
|
+
from mesofield.gui import theme
|
|
1259
|
+
|
|
1260
|
+
app = QApplication.instance() or QApplication([])
|
|
1261
|
+
theme.apply_theme(app)
|
|
1262
|
+
|
|
1263
|
+
window = MainWindow(context.procedure)
|
|
1264
|
+
title = (
|
|
1265
|
+
f"Mesofield Playback (speed={context.speed:g}x"
|
|
1266
|
+
f"{', loop' if context.loop else ''})"
|
|
1267
|
+
)
|
|
1268
|
+
window.setWindowTitle(title)
|
|
1269
|
+
window.show()
|
|
1270
|
+
|
|
1271
|
+
browser: Optional[PlaybackBrowser] = None
|
|
1272
|
+
if browser_sessions:
|
|
1273
|
+
# Find the session entry that matches the loaded context so the
|
|
1274
|
+
# browser's _current pointer is correct before any change events.
|
|
1275
|
+
match = next(
|
|
1276
|
+
(s for s in browser_sessions if s.session_dir == context.session_dir),
|
|
1277
|
+
None,
|
|
1278
|
+
)
|
|
1279
|
+
browser = PlaybackBrowser(
|
|
1280
|
+
window,
|
|
1281
|
+
browser_sessions,
|
|
1282
|
+
speed=context.speed,
|
|
1283
|
+
loop=context.loop,
|
|
1284
|
+
)
|
|
1285
|
+
browser._current = match
|
|
1286
|
+
# Hold a reference on the window so the browser isn't GC'd while the
|
|
1287
|
+
# GUI is alive.
|
|
1288
|
+
window._playback_browser = browser # type: ignore[attr-defined]
|
|
1289
|
+
|
|
1290
|
+
# Re-anchor the wall clock at the moment we kick off, so any GUI startup
|
|
1291
|
+
# delay does not eat the head of the recording, then start the procedure.
|
|
1292
|
+
context.clock.reset()
|
|
1293
|
+
try:
|
|
1294
|
+
context.procedure.run()
|
|
1295
|
+
except Exception:
|
|
1296
|
+
get_logger(__name__).exception("playback procedure.run failed")
|
|
1297
|
+
|
|
1298
|
+
return app.exec()
|