mesofield 0.3.2b0__py3-none-any.whl

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