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,388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
DataManager & DataSaver Workflow Overview
|
|
4
|
+
1. Initialization
|
|
5
|
+
├─ dm = DataManager()
|
|
6
|
+
└─ dm.setup(config, path, devices)
|
|
7
|
+
2. Saving experiment outputs via DataSaver
|
|
8
|
+
├─ dm.saver.configuration() # writes experiment config to disk
|
|
9
|
+
├─ dm.saver.all_hardware() # writes hardware outputs
|
|
10
|
+
├─ dm.saver.all_notes() # writes notes.txt if any
|
|
11
|
+
└─ dm.saver.writer_for(camera) # returns writer & updates camera output paths
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional, List, Any, Dict, Iterable
|
|
16
|
+
from logging import Logger
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import queue
|
|
20
|
+
import csv
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from mesofield.config import ExperimentConfig
|
|
28
|
+
from mesofield.data.writer import CustomWriter, CV2Writer
|
|
29
|
+
from mesofield.utils._logger import get_logger, log_this_fr, hyperlink
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DataPacket:
|
|
34
|
+
"""Entry in :class:`DataQueue`."""
|
|
35
|
+
|
|
36
|
+
device_id: str
|
|
37
|
+
timestamp: datetime
|
|
38
|
+
payload: Any
|
|
39
|
+
device_ts: float | None = None
|
|
40
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DataQueue:
|
|
44
|
+
"""Thread-safe queue for data streaming between devices and consumers.
|
|
45
|
+
|
|
46
|
+
Registered DataProducer (the :class:`DataManager`) devices can push data packets to this queue,
|
|
47
|
+
which can then be consumed by other parts of the system.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, maxsize: int = 0) -> None:
|
|
51
|
+
self._queue: queue.Queue[DataPacket] = queue.Queue(maxsize)
|
|
52
|
+
|
|
53
|
+
def push(
|
|
54
|
+
self,
|
|
55
|
+
device_id: str,
|
|
56
|
+
payload: Any,
|
|
57
|
+
*,
|
|
58
|
+
timestamp: datetime | None = None,
|
|
59
|
+
device_ts: float | None = None,
|
|
60
|
+
**meta: Any,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Add a new data packet to the queue."""
|
|
63
|
+
if timestamp is None:
|
|
64
|
+
timestamp = datetime.now()
|
|
65
|
+
self._queue.put(DataPacket(device_id, timestamp, payload, device_ts, meta))
|
|
66
|
+
|
|
67
|
+
def pop(self, block: bool = True, timeout: float | None = None) -> DataPacket:
|
|
68
|
+
"""Return the next :class:`DataPacket` from the queue."""
|
|
69
|
+
return self._queue.get(block=block, timeout=timeout)
|
|
70
|
+
|
|
71
|
+
def empty(self) -> bool:
|
|
72
|
+
return self._queue.empty()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DataPaths:
|
|
77
|
+
"""Structured storage for all output paths used by the :class:`DataSaver`.
|
|
78
|
+
|
|
79
|
+
Relies on the :class:`ExperimentConfig` to generate paths based on the
|
|
80
|
+
experiment's BIDS directory using the `make_path` method, which relies
|
|
81
|
+
on HardwareDevices implementing a file_type, bids_type.
|
|
82
|
+
|
|
83
|
+
These paths can then be passed to HardwareDevice.save_data(path)
|
|
84
|
+
"""
|
|
85
|
+
configuration: str
|
|
86
|
+
notes: str
|
|
87
|
+
timestamps: str
|
|
88
|
+
hardware: Dict[str, str] = field(default_factory=dict)
|
|
89
|
+
writers: Dict[str, str] = field(default_factory=dict)
|
|
90
|
+
queue: str = ""
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def build(cls, cfg: ExperimentConfig) -> DataPaths:
|
|
94
|
+
cfg_paths = {
|
|
95
|
+
"configuration": cfg.make_path("configuration", "csv"),
|
|
96
|
+
"notes": cfg.make_path("notes", "txt"),
|
|
97
|
+
"timestamps": cfg.make_path("timestamps", "csv"),
|
|
98
|
+
}
|
|
99
|
+
hw_paths: Dict[str, str] = {}
|
|
100
|
+
for dev_id, device in cfg.hardware.devices.items():
|
|
101
|
+
args = getattr(device, "path_args", {})
|
|
102
|
+
suffix = args.get("suffix", dev_id)
|
|
103
|
+
ext = args.get("extension", getattr(device, "file_type", "dat"))
|
|
104
|
+
bids_type = args.get("bids_type", getattr(device, "bids_type"))
|
|
105
|
+
hw_paths[dev_id] = cfg.make_path(suffix, ext, bids_type)
|
|
106
|
+
return cls(
|
|
107
|
+
configuration=cfg_paths["configuration"],
|
|
108
|
+
notes=cfg_paths["notes"],
|
|
109
|
+
timestamps=cfg_paths["timestamps"],
|
|
110
|
+
hardware=hw_paths,
|
|
111
|
+
writers={},
|
|
112
|
+
queue=cfg.make_path("dataqueue", "csv", "beh"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class DataSaver:
|
|
117
|
+
"""Helper class for saving experiment data to disk.
|
|
118
|
+
|
|
119
|
+
This class handles saving configuration, hardware data, notes, timestamps,
|
|
120
|
+
and camera data to disk.
|
|
121
|
+
|
|
122
|
+
Takes paths generated from :class:`DataPaths` instance and calls `Device.save_data(path)`
|
|
123
|
+
on all hardware devices that implement this method.
|
|
124
|
+
|
|
125
|
+
(NOTE: If a device does not implement `save_data`, it will be skipped.)
|
|
126
|
+
|
|
127
|
+
Takes an :class:`ExperimentConfig` to save configuration.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
cfg: ExperimentConfig
|
|
131
|
+
paths: DataPaths = field(init=False)
|
|
132
|
+
logger: Logger = field(default_factory=lambda: get_logger("DataSaver"))
|
|
133
|
+
|
|
134
|
+
def __post_init__(self) -> None:
|
|
135
|
+
self.paths = DataPaths.build(self.cfg)
|
|
136
|
+
self.logger.debug(f"Prepared output paths: {self.paths}")
|
|
137
|
+
|
|
138
|
+
def configuration(self) -> None:
|
|
139
|
+
path = self.paths.configuration
|
|
140
|
+
try:
|
|
141
|
+
params = self.cfg.items()
|
|
142
|
+
df = pd.DataFrame(params.items(), columns=["Parameter", "Value"])
|
|
143
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
144
|
+
df.to_csv(path, index=False)
|
|
145
|
+
self.logger.info(
|
|
146
|
+
f"Configuration {hyperlink(path, os.path.basename(path))} [saved]"
|
|
147
|
+
)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self.logger.error(f"Error saving configuration: {e}")
|
|
150
|
+
|
|
151
|
+
def all_hardware(self) -> None:
|
|
152
|
+
for dev_id, path in self.paths.hardware.items():
|
|
153
|
+
device = self.cfg.hardware.devices.get(dev_id)
|
|
154
|
+
if not device:
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
158
|
+
device.output_path = path
|
|
159
|
+
if hasattr(device, "save_data"):
|
|
160
|
+
device.save_data(path)
|
|
161
|
+
self.logger.info(
|
|
162
|
+
f"Device {dev_id} data {hyperlink(path, os.path.basename(path))} [saved]"
|
|
163
|
+
)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
self.logger.error(f"Error saving device {dev_id}: {e}")
|
|
166
|
+
|
|
167
|
+
def all_notes(self) -> None:
|
|
168
|
+
if not self.cfg.notes:
|
|
169
|
+
return
|
|
170
|
+
path = self.paths.notes
|
|
171
|
+
try:
|
|
172
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
173
|
+
with open(path, "w") as f:
|
|
174
|
+
f.write("\n".join(self.cfg.notes))
|
|
175
|
+
self.logger.info(f"Notes {hyperlink(path, os.path.basename(path))} [saved]")
|
|
176
|
+
except Exception as e:
|
|
177
|
+
self.logger.error(f"Error saving notes: {e}")
|
|
178
|
+
|
|
179
|
+
def save_timestamps(self, id, start_time, stop_time) -> None:
|
|
180
|
+
path = self.paths.timestamps
|
|
181
|
+
try:
|
|
182
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
183
|
+
with open(path, "w", newline="") as csvfile:
|
|
184
|
+
writer = csv.writer(csvfile)
|
|
185
|
+
writer.writerow(["device_id", "started", "stopped"])
|
|
186
|
+
writer.writerow([id, start_time, stop_time])
|
|
187
|
+
for dev_id, device in self.cfg.hardware.devices.items():
|
|
188
|
+
started = getattr(device, "_started", "")
|
|
189
|
+
stopped = getattr(device, "_stopped", "")
|
|
190
|
+
writer.writerow([dev_id, started, stopped])
|
|
191
|
+
self.logger.info(
|
|
192
|
+
f"Timestamps {hyperlink(path, os.path.basename(path))} [saved]"
|
|
193
|
+
)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
self.logger.error(f"Error saving timestamps: {e}")
|
|
196
|
+
|
|
197
|
+
def save_queue(self, rows: list[list[Any]], path: str | None = None) -> None:
|
|
198
|
+
"""Save queued data rows to CSV file specified in DataPaths or override path.
|
|
199
|
+
|
|
200
|
+
Rows are produced by ``DataManager._queue_writer_loop`` as
|
|
201
|
+
``[queue_elapsed, packet_ts, device_ts, device_id, payload]``.
|
|
202
|
+
If any payload is a ``dict``, its keys are fanned out into
|
|
203
|
+
dedicated columns; the ``payload`` column then holds the
|
|
204
|
+
non-dict payloads only (blank when a row has dict columns).
|
|
205
|
+
"""
|
|
206
|
+
if path is None:
|
|
207
|
+
path = self.paths.queue
|
|
208
|
+
try:
|
|
209
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
210
|
+
|
|
211
|
+
keys: list[str] = []
|
|
212
|
+
seen: set[str] = set()
|
|
213
|
+
for row in rows:
|
|
214
|
+
payload = row[4] if len(row) > 4 else None
|
|
215
|
+
if isinstance(payload, dict):
|
|
216
|
+
for k in payload:
|
|
217
|
+
if k not in seen:
|
|
218
|
+
seen.add(k)
|
|
219
|
+
keys.append(k)
|
|
220
|
+
|
|
221
|
+
base_cols = ["queue_elapsed", "packet_ts", "device_ts", "device_id"]
|
|
222
|
+
with open(path, "w", newline="") as csvfile:
|
|
223
|
+
writer = csv.writer(csvfile)
|
|
224
|
+
if keys:
|
|
225
|
+
writer.writerow([*base_cols, "payload", *keys])
|
|
226
|
+
for row in rows:
|
|
227
|
+
elapsed, ts, dts, dev_id, payload = row[:5]
|
|
228
|
+
if isinstance(payload, dict):
|
|
229
|
+
writer.writerow(
|
|
230
|
+
[elapsed, ts, dts, dev_id, "",
|
|
231
|
+
*(payload.get(k, "") for k in keys)]
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
writer.writerow(
|
|
235
|
+
[elapsed, ts, dts, dev_id, payload,
|
|
236
|
+
*("" for _ in keys)]
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
writer.writerow([*base_cols, "payload"])
|
|
240
|
+
writer.writerows(rows)
|
|
241
|
+
self.logger.info(
|
|
242
|
+
f"Queue log {hyperlink(path, os.path.basename(path))} [saved]"
|
|
243
|
+
)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
self.logger.error(f"Error saving queue log: {e}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class DataManager:
|
|
249
|
+
"""Wrapper providing :class:`DataSaver` and :class:`DataQueue`."""
|
|
250
|
+
|
|
251
|
+
def __init__(self) -> None:
|
|
252
|
+
self.save: DataSaver
|
|
253
|
+
self.queue = DataQueue()
|
|
254
|
+
|
|
255
|
+
self.devices: List[Any] = []
|
|
256
|
+
self._registered_ids: set[str] = set()
|
|
257
|
+
|
|
258
|
+
self.queue_log_path: Optional[str] = None
|
|
259
|
+
self.queue_packets: list[list[Any]] = []
|
|
260
|
+
|
|
261
|
+
self._queue_thread: Optional[threading.Thread] = None
|
|
262
|
+
self._stop_queue: bool = False
|
|
263
|
+
|
|
264
|
+
def setup(
|
|
265
|
+
self, config: ExperimentConfig, devices: Iterable[Any] | None = None
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Attach configuration and optionally register devices."""
|
|
268
|
+
self.save = DataSaver(config)
|
|
269
|
+
if devices is not None:
|
|
270
|
+
self.register_devices(devices)
|
|
271
|
+
|
|
272
|
+
#@log_this_fr
|
|
273
|
+
def register_devices(self, devices: Iterable[Any]) -> None:
|
|
274
|
+
"""Register a list of hardware devices with the manager.
|
|
275
|
+
|
|
276
|
+
Any device exposing a :class:`mesofield.signals.DeviceSignals`
|
|
277
|
+
bundle on ``device.signals`` is registered. Devices that do not
|
|
278
|
+
emit on ``signals.data`` (e.g. stimulus devices) are still tracked
|
|
279
|
+
but contribute nothing to the queue.
|
|
280
|
+
"""
|
|
281
|
+
for dev in devices:
|
|
282
|
+
if hasattr(dev, "signals"):
|
|
283
|
+
self.register_hardware_device(dev)
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
def start_queue_logger(self, path: str | None = None) -> None:
|
|
287
|
+
"""Begin capturing :class:`DataQueue` contents."""
|
|
288
|
+
# determine log path, default to DataPaths.queue
|
|
289
|
+
if path is None and getattr(self, "save", None):
|
|
290
|
+
path = self.save.paths.queue
|
|
291
|
+
if path is None:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
self.queue_log_path = path
|
|
295
|
+
# in-memory storage for log rows
|
|
296
|
+
self.queue_packets = []
|
|
297
|
+
self._stop_queue = False
|
|
298
|
+
|
|
299
|
+
# start background thread to record queue packets
|
|
300
|
+
self._queue_thread = threading.Thread(
|
|
301
|
+
target=self._queue_writer_loop,
|
|
302
|
+
daemon=True,
|
|
303
|
+
)
|
|
304
|
+
self._queue_thread.start()
|
|
305
|
+
|
|
306
|
+
def stop_queue_logger(self) -> None:
|
|
307
|
+
"""Stop the queue logging thread and flush data to disk."""
|
|
308
|
+
self._stop_queue = True
|
|
309
|
+
if self._queue_thread:
|
|
310
|
+
self._queue_thread.join(timeout=1)
|
|
311
|
+
|
|
312
|
+
# save recorded packets via DataSaver
|
|
313
|
+
if self.save and self.queue_log_path:
|
|
314
|
+
# use the configured log path for writing
|
|
315
|
+
self.save.save_queue(self.queue_packets, self.queue_log_path)
|
|
316
|
+
|
|
317
|
+
def _queue_writer_loop(self) -> None:
|
|
318
|
+
|
|
319
|
+
while not self._stop_queue or not self.queue.empty():
|
|
320
|
+
try:
|
|
321
|
+
pkt = self.queue.pop(timeout=0.1)
|
|
322
|
+
except queue.Empty:
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
now = time.perf_counter() # Use monotonic time for consistency
|
|
326
|
+
row = [now, pkt.timestamp, pkt.device_ts, pkt.device_id, pkt.payload]
|
|
327
|
+
self.queue_packets.append(row)
|
|
328
|
+
|
|
329
|
+
# if self._stream and self._writer:
|
|
330
|
+
# self._writer.writerow(row)
|
|
331
|
+
# assert self._file is not None
|
|
332
|
+
# self._file.flush()
|
|
333
|
+
|
|
334
|
+
def register_hardware_device(self, device: Any) -> None:
|
|
335
|
+
"""Track a hardware device and connect its data stream to the queue.
|
|
336
|
+
|
|
337
|
+
Devices expose a uniform :class:`mesofield.signals.DeviceSignals`
|
|
338
|
+
bundle on ``device.signals``. We simply connect ``signals.data``
|
|
339
|
+
to push ``(payload, device_ts)`` packets onto the queue. Devices
|
|
340
|
+
that never emit on ``signals.data`` (e.g. ``StimulusDevice``) are
|
|
341
|
+
registered but never produce queue entries.
|
|
342
|
+
"""
|
|
343
|
+
dev_id = getattr(device, "device_id", getattr(device, "id", "unknown"))
|
|
344
|
+
if device in self.devices or dev_id in self._registered_ids:
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
self.devices.append(device)
|
|
348
|
+
self._registered_ids.add(dev_id)
|
|
349
|
+
|
|
350
|
+
signals = getattr(device, "signals", None)
|
|
351
|
+
data_sig = getattr(signals, "data", None) if signals is not None else None
|
|
352
|
+
if data_sig is None or not hasattr(data_sig, "connect"):
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
def _push(payload: Any, device_ts: Any = None, *, _id=dev_id) -> None:
|
|
356
|
+
self.queue.push(_id, payload, device_ts=device_ts)
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
data_sig.connect(_push)
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
#TODO: move this logic to DataSaver
|
|
364
|
+
def get_device_outputs(self, subject: str, session: str) -> pd.DataFrame:
|
|
365
|
+
"""Return a DataFrame of output file paths for registered devices."""
|
|
366
|
+
records = {}
|
|
367
|
+
for dev in self.devices:
|
|
368
|
+
dev_id = getattr(dev, "device_id", getattr(dev, "id", "unknown"))
|
|
369
|
+
out = getattr(dev, "output_path", None)
|
|
370
|
+
if out:
|
|
371
|
+
key = (dev.device_type, dev_id, "file")
|
|
372
|
+
records[key] = out
|
|
373
|
+
|
|
374
|
+
# derive metadata path if attribute not set
|
|
375
|
+
meta = getattr(dev, "metadata_path", None)
|
|
376
|
+
if not meta:
|
|
377
|
+
if out.endswith("ome.tiff"):
|
|
378
|
+
meta = out.replace("ome.tiff", "ome.tiff_frame_metadata.json")
|
|
379
|
+
elif out.endswith("ome.tif"):
|
|
380
|
+
meta = out.replace("ome.tif", "ome.tif_frame_metadata.json")
|
|
381
|
+
if meta and os.path.exists(meta):
|
|
382
|
+
key = (dev.device_type, dev_id, "metadata")
|
|
383
|
+
records[key] = meta
|
|
384
|
+
if not records:
|
|
385
|
+
return pd.DataFrame()
|
|
386
|
+
idx = pd.MultiIndex.from_arrays([[subject], [session]], names=["Subject", "Session"])
|
|
387
|
+
return pd.DataFrame(records, index=idx)
|
|
388
|
+
|
mesofield/data/writer.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Custom OME.TIFF + MP4 writers for MDASequences.
|
|
2
|
+
|
|
3
|
+
`CustomWriter` extends pymmcore-plus's public :class:`OMETiffWriter` with two
|
|
4
|
+
additions:
|
|
5
|
+
|
|
6
|
+
1. ``bigtiff=True`` on the underlying tifffile call -- mesoscope acquisitions
|
|
7
|
+
routinely exceed the classic-TIFF 4 GiB ceiling.
|
|
8
|
+
2. A per-frame ``<filename>_frame_metadata.json`` sidecar emitted from
|
|
9
|
+
:meth:`finalize_metadata`, containing the same metadata pymmcore-plus
|
|
10
|
+
accumulates internally. This is the legacy ``mesofield`` contract every
|
|
11
|
+
downstream parser already reads.
|
|
12
|
+
|
|
13
|
+
The sidecar JSON is the current source of truth for per-frame metadata. The
|
|
14
|
+
broader goal (tracked separately) is to push the same fields into the OME-XML
|
|
15
|
+
embedded in the TIFF itself so the JSON becomes supplementary and redundant;
|
|
16
|
+
see the TODO in :meth:`OMETiffWriter._sequence_metadata` upstream.
|
|
17
|
+
|
|
18
|
+
`CV2Writer` subclasses the public :class:`OMETiffWriter` purely to reuse its
|
|
19
|
+
inherited ``frameReady`` plumbing (the machinery that turns MMCamera/MDA signals
|
|
20
|
+
into ``new_array`` / ``write_frame`` / ``store_frame_metadata`` calls and
|
|
21
|
+
accumulates pymmcore-plus metadata). It overrides every TIFF-specific method to
|
|
22
|
+
emit MP4/AVI instead -- there is no public MP4 handler in pymmcore-plus to
|
|
23
|
+
inherit from, and inheriting the public ``OMETiffWriter`` avoids depending on
|
|
24
|
+
pymmcore-plus's private ``_5d_writer_base`` module.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from datetime import timedelta
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from pymmcore_plus.mda.metadata import SummaryMetaV1 # type: ignore
|
|
32
|
+
|
|
33
|
+
from pymmcore_plus.mda.handlers import OMETiffWriter
|
|
34
|
+
from useq import MDAEvent
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
FRAME_MD_FILENAME = "_frame_metadata.json"
|
|
41
|
+
|
|
42
|
+
# ─── H264 Video Codec ─────────────────────────────────────────────────────
|
|
43
|
+
# OpenH264 codec DLL for OpenCV video encoding (Windows only).
|
|
44
|
+
# The DLL lives at <repo-root>/external/video-codecs/.
|
|
45
|
+
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
46
|
+
CODEC_DIRECTORY = str(_REPO_ROOT / "external" / "video-codecs")
|
|
47
|
+
OPENH264_DLL_PATH = str(Path(CODEC_DIRECTORY) / "openh264-1.8.0-win64.dll")
|
|
48
|
+
# ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
class CustomWriter(OMETiffWriter):
|
|
51
|
+
"""OME-TIFF writer extending pymmcore-plus's :class:`OMETiffWriter`.
|
|
52
|
+
|
|
53
|
+
Two divergences from the public base:
|
|
54
|
+
|
|
55
|
+
- Uses ``bigtiff=True`` so multi-GiB mesoscope acquisitions write cleanly.
|
|
56
|
+
- Emits the per-frame metadata JSON sidecar mesofield's downstream parsers
|
|
57
|
+
(and the AcquisitionManifest's ``metadata_path``) depend on.
|
|
58
|
+
|
|
59
|
+
Everything else -- filename validation, frame writing, OME-XML sequence
|
|
60
|
+
metadata, memmap handling -- is inherited from :class:`OMETiffWriter`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, filename: Path | str) -> None:
|
|
64
|
+
super().__init__(filename)
|
|
65
|
+
self._frame_metadata_filename = self._filename + FRAME_MD_FILENAME
|
|
66
|
+
|
|
67
|
+
def new_array(
|
|
68
|
+
self, position_key: str, dtype: np.dtype, sizes: dict[str, int]
|
|
69
|
+
) -> np.memmap:
|
|
70
|
+
"""Mirror :meth:`OMETiffWriter.new_array` but with ``bigtiff=True``.
|
|
71
|
+
|
|
72
|
+
Upstream's implementation hardcodes the ``imwrite`` call; we duplicate
|
|
73
|
+
it here to flip the ``bigtiff`` flag. Keep the bodies in sync if a
|
|
74
|
+
pymmcore-plus bump changes the upstream version.
|
|
75
|
+
"""
|
|
76
|
+
from tifffile import imwrite, memmap
|
|
77
|
+
|
|
78
|
+
dims, shape = zip(*sizes.items())
|
|
79
|
+
|
|
80
|
+
metadata: dict[str, Any] = self._sequence_metadata()
|
|
81
|
+
metadata["axes"] = "".join(dims).upper()
|
|
82
|
+
|
|
83
|
+
if (seq := self.current_sequence) and seq.sizes.get("p", 1) > 1:
|
|
84
|
+
ext = ".ome.tif" if self._is_ome else ".tif"
|
|
85
|
+
fname = self._filename.replace(ext, f"_{position_key}{ext}")
|
|
86
|
+
else:
|
|
87
|
+
fname = self._filename
|
|
88
|
+
|
|
89
|
+
imwrite(
|
|
90
|
+
fname,
|
|
91
|
+
shape=shape,
|
|
92
|
+
bigtiff=True,
|
|
93
|
+
dtype=dtype,
|
|
94
|
+
metadata=metadata,
|
|
95
|
+
imagej=not self._is_ome,
|
|
96
|
+
ome=self._is_ome,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
mmap = memmap(fname, dtype=dtype)
|
|
100
|
+
mmap.shape = shape
|
|
101
|
+
return mmap # type: ignore
|
|
102
|
+
|
|
103
|
+
def finalize_metadata(self) -> None:
|
|
104
|
+
"""Write the per-frame metadata sidecar.
|
|
105
|
+
|
|
106
|
+
Called by ``OMETiffWriter.sequenceFinished`` after the last frame.
|
|
107
|
+
Serialises ``self.frame_metadatas`` (the dict pymmcore-plus accumulates
|
|
108
|
+
for us in ``frameReady``) to JSON at ``<filename>_frame_metadata.json``.
|
|
109
|
+
"""
|
|
110
|
+
regular_dict = dict(self.frame_metadatas)
|
|
111
|
+
json_str = json.dumps(regular_dict, indent=4, cls=CustomJSONEncoder)
|
|
112
|
+
with open(self._frame_metadata_filename, "w") as fh:
|
|
113
|
+
fh.write(json_str)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CustomJSONEncoder(json.JSONEncoder):
|
|
117
|
+
def default(self, object: Any) -> Any:
|
|
118
|
+
if isinstance(object, MDAEvent):
|
|
119
|
+
return None #ignore the MDAEvents for now
|
|
120
|
+
return super().default(object)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def configure_opencv_codec() -> None:
|
|
124
|
+
"""Configure environment so OpenCV can locate the bundled OpenH264 DLL.
|
|
125
|
+
|
|
126
|
+
Safe to call repeatedly. Silences OpenCV/FFMPEG logging and prepends the
|
|
127
|
+
project's ``external/video-codecs`` directory to PATH / DLL search.
|
|
128
|
+
"""
|
|
129
|
+
import os
|
|
130
|
+
|
|
131
|
+
# Set environment variables to suppress OpenCV/FFMPEG output BEFORE importing cv2
|
|
132
|
+
os.environ.setdefault('OPENCV_LOG_LEVEL', 'SILENT')
|
|
133
|
+
os.environ.setdefault('OPENCV_FFMPEG_CAPTURE_OPTIONS', 'loglevel;quiet')
|
|
134
|
+
os.environ.setdefault('OPENCV_VIDEOIO_DEBUG', '0')
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
import cv2 # noqa: F401
|
|
138
|
+
except ImportError as e: # pragma: no cover - optional dependency
|
|
139
|
+
raise ImportError(
|
|
140
|
+
"opencv-python is required. Please `pip install opencv-python`."
|
|
141
|
+
) from e
|
|
142
|
+
|
|
143
|
+
# OpenCV log silencing is version-dependent.
|
|
144
|
+
try:
|
|
145
|
+
if hasattr(cv2, 'setLogLevel'):
|
|
146
|
+
cv2.setLogLevel(0) # 0 = Silent
|
|
147
|
+
elif (
|
|
148
|
+
hasattr(cv2, 'utils')
|
|
149
|
+
and hasattr(cv2.utils, 'logging')
|
|
150
|
+
and hasattr(cv2.utils.logging, 'setLogLevel')
|
|
151
|
+
):
|
|
152
|
+
cv2.utils.logging.setLogLevel(0)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
os.environ['OPENH264_LIBRARY'] = OPENH264_DLL_PATH
|
|
157
|
+
if CODEC_DIRECTORY not in os.environ.get('PATH', ''):
|
|
158
|
+
os.environ['PATH'] = CODEC_DIRECTORY + os.pathsep + os.environ.get('PATH', '')
|
|
159
|
+
if hasattr(os, 'add_dll_directory'):
|
|
160
|
+
try:
|
|
161
|
+
os.add_dll_directory(CODEC_DIRECTORY)
|
|
162
|
+
except (OSError, FileNotFoundError):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class CV2Writer(OMETiffWriter):
|
|
167
|
+
"""Write frames to an mp4/avi video using OpenCV.
|
|
168
|
+
|
|
169
|
+
Subclasses the public :class:`OMETiffWriter` only to reuse its inherited
|
|
170
|
+
MDA-signal handling (``frameReady`` / ``sequenceStarted`` /
|
|
171
|
+
``sequenceFinished`` / ``store_frame_metadata`` and the
|
|
172
|
+
``frame_metadatas`` accumulation). Every TIFF-specific method
|
|
173
|
+
(``__init__`` / ``new_array`` / ``write_frame`` / ``finalize_metadata``) is
|
|
174
|
+
overridden below to emit video instead, so none of ``OMETiffWriter``'s
|
|
175
|
+
tifffile machinery is ever reached.
|
|
176
|
+
|
|
177
|
+
Two usage modes share the same codec/fourcc/metadata logic:
|
|
178
|
+
|
|
179
|
+
- **MDA-driven** (``new_array`` / ``write_frame`` / ``finalize_metadata``)
|
|
180
|
+
when handed to ``CMMCorePlus.run_mda`` as an output handler.
|
|
181
|
+
- **Direct** (``begin`` / ``add_frame`` / ``finish``) for cameras that run
|
|
182
|
+
their own capture loop (e.g. :class:`OpenCVCamera`).
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, filename: Path | str, fps: int = 30, fourcc: str = "H264") -> None:
|
|
186
|
+
configure_opencv_codec()
|
|
187
|
+
|
|
188
|
+
self._filename = str(filename)
|
|
189
|
+
if not self._filename.endswith((".mp4", ".avi")):
|
|
190
|
+
raise ValueError("filename must end with '.mp4' or '.avi'")
|
|
191
|
+
self._fps = fps
|
|
192
|
+
self._fourcc = fourcc
|
|
193
|
+
# FFmpeg expects H.264-in-MP4 with the 'avc1' tag; OpenCV often gets
|
|
194
|
+
# 'H264' from callers, which triggers a noisy fallback warning.
|
|
195
|
+
if self._filename.endswith(".mp4") and self._fourcc.upper() == "H264":
|
|
196
|
+
self._fourcc = "avc1"
|
|
197
|
+
self._frame_metadata_filename = self._filename + FRAME_MD_FILENAME
|
|
198
|
+
# Direct-use (non-MDA) capture-loop writer; opened by `begin`.
|
|
199
|
+
self._direct_writer: Any = None
|
|
200
|
+
|
|
201
|
+
# `OMETiffWriter.sequenceStarted` only reorders position_sizes into
|
|
202
|
+
# ImageJ axis order when `not self._is_ome`; setting it True makes that
|
|
203
|
+
# override a pass-through to the base `sequenceStarted` (axis order is
|
|
204
|
+
# irrelevant to video) and avoids an AttributeError.
|
|
205
|
+
self._is_ome = True
|
|
206
|
+
|
|
207
|
+
# Skip `OMETiffWriter.__init__` (it validates a .tif/.tiff filename and
|
|
208
|
+
# imports tifffile); go straight to the base initializer to set up the
|
|
209
|
+
# frameReady plumbing state (position_arrays, frame_metadatas, etc.).
|
|
210
|
+
super(OMETiffWriter, self).__init__()
|
|
211
|
+
|
|
212
|
+
def new_array(self, position_key: str, dtype: np.dtype, sizes: dict[str, int]):
|
|
213
|
+
import cv2
|
|
214
|
+
|
|
215
|
+
width = sizes["x"]
|
|
216
|
+
height = sizes["y"]
|
|
217
|
+
is_color = sizes.get("c", 1) > 1
|
|
218
|
+
|
|
219
|
+
if (seq := self.current_sequence) and seq.sizes.get("p", 1) > 1:
|
|
220
|
+
fname = self._filename.replace(".mp4", f"_{position_key}.mp4")
|
|
221
|
+
fname = fname.replace(".avi", f"_{position_key}.avi")
|
|
222
|
+
else:
|
|
223
|
+
fname = self._filename
|
|
224
|
+
|
|
225
|
+
fourcc = cv2.VideoWriter.fourcc(*self._fourcc)
|
|
226
|
+
writer = cv2.VideoWriter(fname, fourcc, self._fps, (width, height), isColor=is_color)
|
|
227
|
+
return writer
|
|
228
|
+
|
|
229
|
+
def write_frame(self, ary: Any, index: tuple[int, ...], frame: np.ndarray) -> None:
|
|
230
|
+
import cv2
|
|
231
|
+
|
|
232
|
+
frame_8u = cv2.normalize(frame, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
|
233
|
+
ary.write(frame_8u)
|
|
234
|
+
|
|
235
|
+
def finalize_metadata(self) -> None:
|
|
236
|
+
for writer in self.position_arrays.values():
|
|
237
|
+
try:
|
|
238
|
+
writer.release()
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
regular_dict = dict(self.frame_metadatas)
|
|
243
|
+
json_str = json.dumps(regular_dict, indent=4, cls=CustomJSONEncoder)
|
|
244
|
+
with open(self._frame_metadata_filename, "w") as file:
|
|
245
|
+
file.write(json_str)
|
|
246
|
+
|
|
247
|
+
# ----- direct (non-MDA) capture-loop interface ----------------------
|
|
248
|
+
def begin(self, width: int, height: int, is_color: bool = True) -> None:
|
|
249
|
+
"""Open the underlying ``cv2.VideoWriter`` for a self-driven loop."""
|
|
250
|
+
import cv2
|
|
251
|
+
|
|
252
|
+
Path(self._filename).parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
fourcc = cv2.VideoWriter.fourcc(*self._fourcc)
|
|
254
|
+
writer = cv2.VideoWriter(
|
|
255
|
+
self._filename, fourcc, float(self._fps), (width, height), isColor=is_color
|
|
256
|
+
)
|
|
257
|
+
if not writer.isOpened():
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"cv2.VideoWriter failed to open '{self._filename}' "
|
|
260
|
+
f"(fourcc={self._fourcc}, fps={self._fps})"
|
|
261
|
+
)
|
|
262
|
+
self._direct_writer = writer
|
|
263
|
+
|
|
264
|
+
def add_frame(self, frame: np.ndarray) -> None:
|
|
265
|
+
"""Write one frame to the direct-mode video (uint8 frames pass through)."""
|
|
266
|
+
if self._direct_writer is None:
|
|
267
|
+
raise RuntimeError("CV2Writer.add_frame called before begin()")
|
|
268
|
+
if frame.dtype != np.uint8:
|
|
269
|
+
import cv2
|
|
270
|
+
|
|
271
|
+
frame = cv2.normalize(frame, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
|
272
|
+
self._direct_writer.write(frame)
|
|
273
|
+
|
|
274
|
+
def finish(self, extra_metadata: dict | None = None) -> None:
|
|
275
|
+
"""Release the direct-mode writer and write the metadata sidecar."""
|
|
276
|
+
if self._direct_writer is not None:
|
|
277
|
+
try:
|
|
278
|
+
self._direct_writer.release()
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
self._direct_writer = None
|
|
282
|
+
|
|
283
|
+
payload: dict[str, Any] = {"frame_metadatas": dict(self.frame_metadatas)}
|
|
284
|
+
if extra_metadata:
|
|
285
|
+
payload.update(extra_metadata)
|
|
286
|
+
json_str = json.dumps(payload, indent=4, cls=CustomJSONEncoder)
|
|
287
|
+
with open(self._frame_metadata_filename, "w") as file:
|
|
288
|
+
file.write(json_str)
|
|
289
|
+
|