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,740 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional, Callable, Any, ClassVar, Dict
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ import inspect
8
+
9
+ import numpy as np
10
+ from PyQt6.QtCore import QThread, pyqtSignal
11
+ from pymmcore_plus import CMMCorePlus, DeviceType
12
+ from pymmcore_plus.core._device import CameraDevice
13
+
14
+ from mesofield.protocols import HardwareDevice, DataProducer
15
+ from mesofield.engines import DevEngine, MesoEngine, PupilEngine
16
+ #from tests.arducam import VideoThread
17
+ from mesofield.devices.base_camera import BaseCamera
18
+ from mesofield.data import CustomWriter, CV2Writer
19
+ from mesofield.data.writer import configure_opencv_codec
20
+ from mesofield import DeviceRegistry
21
+
22
+
23
+ @DeviceRegistry.register("camera")
24
+ class MMCamera(BaseCamera, DataProducer, HardwareDevice):
25
+ """Micro-Manager-backed camera.
26
+
27
+ Inherits the common camera surface (identity, output paths, manifest
28
+ metadata, ``arm`` / ``set_sequence`` defaults, ``status``, ``calibration``)
29
+ from :class:`BaseCamera`, and duck-types the ``DataProducer`` /
30
+ ``HardwareDevice`` Protocols so existing isinstance() checks keep
31
+ working. The actual frame flow is driven by pymmcore-plus's MDA event
32
+ system; this class wires those events into the standard
33
+ :class:`DeviceSignals` bundle and constructs a :class:`CustomWriter`
34
+ (OME-TIFF) or :class:`CV2Writer` (MP4) for the output.
35
+ """
36
+
37
+ sampling_rate: float = 30.0 # Default sampling rate in Hz
38
+ file_type: ClassVar[str] = "ome.tiff"
39
+ bids_type: ClassVar[Optional[str]] = "func"
40
+ writer: CustomWriter | CV2Writer
41
+
42
+ def __init__(self, cfg: dict):
43
+ # BaseCamera surface (signals, identity, viewer cosmetics, output
44
+ # slots, logger). MMCamera carries an explicit `backend` (set from
45
+ # cfg), so we pass through the cfg-declared value here.
46
+ backend = str(cfg.get("backend", "")).lower()
47
+ if backend not in {"micromanager", "opencv"}:
48
+ raise ValueError(f"Unknown camera backend '{backend}'")
49
+ self._init_camera_surface(cfg, backend=backend)
50
+ # MMCamera-specific state.
51
+ self.camera_device: Optional[CameraDevice] = None
52
+ self.core: Optional[CMMCorePlus] = None
53
+ self.properties = self.cfg.get("properties", {})
54
+
55
+ if self.backend == "micromanager":
56
+ self._setup_micromanager(self.cfg)
57
+ elif self.backend == "opencv":
58
+ self._setup_opencv()
59
+
60
+ # automatically apply all YAML properties
61
+ self.initialize()
62
+ self._wire_signals()
63
+
64
+ def _wire_signals(self) -> None:
65
+ """Bridge MMCore MDA events to the standard DeviceSignals."""
66
+ if self.backend != "micromanager" or self.core is None:
67
+ return
68
+ evt = self.core.mda.events # type: ignore[union-attr]
69
+
70
+ def _on_started(*_args, **_kw):
71
+ self.signals.started.emit()
72
+
73
+ def _on_finished(*_args, **_kw):
74
+ self.signals.finished.emit()
75
+
76
+ def _on_frame(_img, _event, metadata):
77
+ idx = ts = None
78
+ try:
79
+ cam_meta = metadata.get("camera_metadata", {}) if isinstance(metadata, dict) else {}
80
+ idx = cam_meta.get("ImageNumber")
81
+ ts = cam_meta.get("TimeReceivedByCore")
82
+ self.signals.data.emit(idx, ts)
83
+ except Exception:
84
+ pass
85
+ try:
86
+ self.signals.frame.emit(_img, idx, ts)
87
+ except Exception:
88
+ pass
89
+
90
+ try:
91
+ evt.sequenceStarted.connect(_on_started)
92
+ except Exception:
93
+ pass
94
+ try:
95
+ evt.sequenceFinished.connect(_on_finished)
96
+ except Exception:
97
+ pass
98
+ try:
99
+ evt.frameReady.connect(_on_frame)
100
+ except Exception:
101
+ pass
102
+
103
+ def _setup_micromanager(self, cfg):
104
+ core = CMMCorePlus(cfg.get("micromanager_path"))
105
+ cfg_path = cfg.get("configuration_path")
106
+ core.loadSystemConfiguration(cfg_path) if cfg_path else core.loadSystemConfiguration()
107
+ self.camera_device = core.getDeviceObject(core.getCameraDevice(),
108
+ DeviceType.Camera)
109
+ Engine = {"ThorCam": PupilEngine,
110
+ "Dhyana": MesoEngine}.get(self.id, DevEngine)
111
+ self._engine = Engine(core, use_hardware_sequencing=True)
112
+ # Back-reference so engines can call camera helpers (e.g. LED control).
113
+ try:
114
+ self._engine.camera = self
115
+ except Exception:
116
+ pass
117
+ core.mda.set_engine(self._engine)
118
+ self.core = core
119
+
120
+ # `set_writer` is inherited from BaseCamera: it resolves the output path
121
+ # and picks the writer (CustomWriter for OME-TIFF, CV2Writer for MP4)
122
+ # from the shared `_WRITER_FOR_FILE_TYPE` mapping. Override only if you
123
+ # need to swap the mapping for a specific MMCamera subclass.
124
+
125
+ def set_sequence(self, build_mda: Callable[[DataProducer], Any]):
126
+ """Build the MDA sequence (Micro-Manager backend only)."""
127
+ if self.backend == "micromanager":
128
+ self.sequence = build_mda(self)
129
+ self.logger.debug("MDA sequence set for Micromanager backend.")
130
+ else:
131
+ self.logger.warning("Setting sequence is not supported for OpenCV backend.")
132
+
133
+ def initialize(self):
134
+ """Apply the YAML ``properties`` block to the underlying camera.
135
+
136
+ Each ``{device_id: {property: value}}`` pair is forwarded to the
137
+ backend (``core.setROI`` for ROIs, ``core.setProperty`` otherwise)
138
+ with special handling for the synthetic ``fps``, ``viewer_type``,
139
+ and ``auto_contrast`` keys.
140
+ """
141
+ for dev_id, props in self.properties.items():
142
+ if not isinstance(props, dict):
143
+ continue
144
+ for prop, val in props.items():
145
+ self.logger.info(f"Setting {dev_id}.{prop} → {val}")
146
+ if prop == "ROI":
147
+ roi_setter = getattr(self.core, "setROI", None) if self.backend == "micromanager" else None
148
+ if roi_setter:
149
+ roi_setter(dev_id, *val)
150
+ elif prop == "fps":
151
+ setattr(self, "sampling_rate", val)
152
+ elif prop == "viewer_type":
153
+ setattr(self, "viewer", val)
154
+ elif prop == "auto_contrast":
155
+ setattr(self, "auto_contrast", val)
156
+ else:
157
+ if self.backend == "micromanager":
158
+ setter = getattr(self.core, "setProperty", None)
159
+ else:
160
+ setter = getattr(self.camera_device, "setProperty", None)
161
+ if setter:
162
+ setter(dev_id, prop, val)
163
+
164
+ # `arm()` is inherited from BaseCamera: it calls set_writer + set_sequence.
165
+
166
+ # ------------------------------------------------------------------
167
+ # LED control
168
+ # ------------------------------------------------------------------
169
+ # The Arduino-Switch device adapter in some Micro-Manager configs becomes
170
+ # unusable when certain Dhyana camera properties are set (its sequenced
171
+ # ``State`` property no longer responds to ``loadSequence``/``startSequence``).
172
+ # The underlying serial port still works, so when ``led_serial`` is present
173
+ # in the camera YAML block we bypass the MM Arduino device and write raw
174
+ # bytes through MM's SerialManager (which already owns the COM port).
175
+ #
176
+ # YAML schema (sibling of ``properties``)::
177
+ #
178
+ # led_serial:
179
+ # port: "COM3" # MM SerialManager device label
180
+ # start_command: # list of byte sequences sent in order
181
+ # - [5, 0, 4]
182
+ # - [6, 1]
183
+ # - [8]
184
+ # stop_command:
185
+ # - [9]
186
+ def _led_serial_cfg(self) -> Optional[dict]:
187
+ cfg = self.cfg.get("led_serial") if isinstance(self.cfg, dict) else None
188
+ return cfg if isinstance(cfg, dict) else None
189
+
190
+ def _send_serial_commands(self, commands) -> None:
191
+ """Write a list of byte sequences to the configured MM serial port."""
192
+ led = self._led_serial_cfg() or {}
193
+ port = led.get("port")
194
+ if not port or not commands or self.core is None:
195
+ return
196
+ writer = getattr(self.core, "writeToSerialPort", None)
197
+ if writer is None:
198
+ self.logger.warning("core has no writeToSerialPort; cannot send LED bytes")
199
+ return
200
+ for cmd in commands:
201
+ byte_list = [int(b) & 0xFF for b in cmd]
202
+ self.logger.info(f"writeToSerialPort({port!r}, {byte_list})")
203
+ writer(port, byte_list)
204
+
205
+ def start_led_sequence(self, pattern) -> None:
206
+ """Start the LED pattern.
207
+
208
+ If ``led_serial`` is configured on this camera, sends the configured
209
+ raw byte sequences via MM's SerialManager. Otherwise falls back to
210
+ the original ``Arduino-Switch.State.loadSequence/startSequence`` path.
211
+ """
212
+ led = self._led_serial_cfg()
213
+ self.logger.debug(
214
+ f"start_led_sequence: led_serial={'present' if led else 'absent'} "
215
+ f"pattern={pattern}"
216
+ )
217
+ if led is not None:
218
+ self._send_serial_commands(led.get("start_command", []))
219
+ return
220
+ if self.backend != "micromanager" or self.core is None:
221
+ return
222
+ prop = self.core.getPropertyObject("Arduino-Switch", "State")
223
+ prop.loadSequence(pattern)
224
+ prop.setValue(4) # seems essential to initiate serial communication
225
+ prop.startSequence()
226
+
227
+ def stop_led_sequence(self) -> None:
228
+ """Stop the LED pattern (mirror of :meth:`start_led_sequence`)."""
229
+ led = self._led_serial_cfg()
230
+ self.logger.debug(
231
+ f"stop_led_sequence: led_serial={'present' if led else 'absent'}"
232
+ )
233
+ if led is not None:
234
+ self._send_serial_commands(led.get("stop_command", []))
235
+ return
236
+ if self.backend != "micromanager" or self.core is None:
237
+ return
238
+ try:
239
+ self.core.getPropertyObject("Arduino-Switch", "State").stopSequence()
240
+ except Exception as exc:
241
+ self.logger.warning(f"stopSequence failed: {exc}")
242
+
243
+ def start(self) -> bool:
244
+ """Launch the MDA sequence non-blocking.
245
+
246
+ Returns:
247
+ Always ``True``. The sequence runs asynchronously on the
248
+ camera backend; lifecycle is reported via ``self.signals``.
249
+ """
250
+ self._started = datetime.now()
251
+ self.core.run_mda(events=self.sequence, output=self.writer, block=False) # type: ignore
252
+ return True
253
+
254
+ def stop(self) -> bool:
255
+ """Stop acquisition.
256
+
257
+ Non-primary Micro-Manager cameras must be told explicitly to halt
258
+ their sequence acquisition — the primary camera's MDA driver
259
+ does not stop them.
260
+ """
261
+ self._stopped = datetime.now()
262
+ if (
263
+ self.backend == "micromanager"
264
+ and not self.is_primary
265
+ and self.core is not None
266
+ ):
267
+ try:
268
+ self.core.stopSequenceAcquisition()
269
+ except Exception as exc:
270
+ self.logger.warning(f"stopSequenceAcquisition failed: {exc}")
271
+ return True
272
+
273
+ def get_data(self):
274
+ """Return the latest captured frame, or ``None`` if not active."""
275
+ return getattr(self.camera_device, "get_frame", lambda: None)() if self.is_active else None
276
+
277
+ # --- live preview contract (BaseCamera abstract methods) ------------
278
+ # Implementations delegate to mmcore so the GUI's snap/live buttons
279
+ # work without touching pymmcore directly.
280
+
281
+ def snap(self):
282
+ """Capture a single frame via ``mmcore.snap()`` and save a snapshot PNG."""
283
+ if self.backend != "micromanager" or self.core is None:
284
+ raise RuntimeError("snap() requires the micromanager backend")
285
+ frame = self.core.snap()
286
+ self._save_snap_png(frame)
287
+ return frame
288
+
289
+ def start_live(self) -> None:
290
+ """Begin continuous (untimed) sequence acquisition for preview."""
291
+ if self.backend != "micromanager" or self.core is None:
292
+ raise RuntimeError("start_live() requires the micromanager backend")
293
+ if not self.core.isSequenceRunning():
294
+ self.core.startContinuousSequenceAcquisition()
295
+
296
+ def stop_live(self) -> None:
297
+ """End the continuous sequence acquisition started by start_live."""
298
+ if self.backend != "micromanager" or self.core is None:
299
+ return
300
+ try:
301
+ self.core.stopSequenceAcquisition()
302
+ except Exception as exc:
303
+ self.logger.debug(f"stopSequenceAcquisition: {exc}")
304
+
305
+ def shutdown(self):
306
+ """Cancel any in-flight MDA on the Micro-Manager backend."""
307
+ if self.backend == "micromanager" and hasattr(self.core, "reset"):
308
+ self.core.mda.cancel()
309
+
310
+ def __getattr__(self, name: str):
311
+ """
312
+ Any attribute not found on MMCamera will be looked up
313
+ on the wrapped camera_device automatically.
314
+ """
315
+ if self.camera_device is not None and hasattr(self.camera_device, name):
316
+ return getattr(self.camera_device, name)
317
+ raise AttributeError(f"{self.__class__.__name__!r} has no attribute {name!r}")
318
+
319
+ def __dir__(self):
320
+ """
321
+ Include camera_device’s public attributes in dir(self) so
322
+ tab‐complete / introspection still works.
323
+ """
324
+ base = set(super().__dir__())
325
+ if self.camera_device is not None:
326
+ base.update(n for n in dir(self.camera_device) if not n.startswith("_"))
327
+ return sorted(base)
328
+
329
+ def __repr__(self):
330
+ # Compact one-liner so HardwareManager listings stay readable.
331
+ # Use ``cam.info()`` for the verbose dump.
332
+ cam_props = self.properties.get(self.id, {}) if isinstance(self.properties, dict) else {}
333
+ fps = cam_props.get("fps")
334
+ out = self.cfg.get("output", {}) if isinstance(self.cfg, dict) else {}
335
+ bits = [f"id={self.id!r}", f"backend={self.backend!r}"]
336
+ if fps is not None:
337
+ bits.append(f"fps={fps}")
338
+ if self.is_primary:
339
+ bits.append("primary=True")
340
+ if out.get("suffix") or out.get("file_type"):
341
+ bits.append(
342
+ f"output={out.get('suffix','?')}.{out.get('file_type','?')!r}"
343
+ )
344
+ if self._engine is not None:
345
+ bits.append(f"engine={type(self._engine).__name__!r}")
346
+ return f"<MMCamera {' '.join(bits)}>"
347
+
348
+ def info(self) -> str:
349
+ """Return the verbose multi-line description (module path, MRO, properties)."""
350
+ module = inspect.getmodule(self)
351
+ module_name = module.__name__ if module else "<unknown>"
352
+ module_file = getattr(module, "__file__", "<built-in>")
353
+ mro = inspect.getmro(self.__class__)
354
+ inheritance = " -> ".join(cls.__name__ for cls in reversed(mro))
355
+ return (
356
+ f"<MMCamera.{self.id}>\n"
357
+ f" backend = {self.backend!r}\n"
358
+ f" module = {module_name!r} ({module_file!r})\n"
359
+ f" properties = {self.properties!r}\n"
360
+ f" engine = {type(self._engine).__name__!r}\n"
361
+ f" inheritance = {inheritance}\n"
362
+ )
363
+
364
+ # ============================ OpenCV camera ============================ #
365
+ _DEFAULT_CV_BACKEND = "MSMF"
366
+
367
+
368
+ def _resolve_cv_backend(name: str | int | None) -> int:
369
+ """Resolve a backend name (e.g. ``"MSMF"``) or numeric id to an OpenCV
370
+ ``CAP_*`` constant. Falls back to ``CAP_ANY`` when unknown.
371
+ """
372
+ import cv2
373
+
374
+ if isinstance(name, int):
375
+ return name
376
+ if not name:
377
+ return cv2.CAP_ANY
378
+ upper = name.upper()
379
+ if not upper.startswith("CAP_"):
380
+ upper = f"CAP_{upper}"
381
+ return int(getattr(cv2, upper, cv2.CAP_ANY))
382
+
383
+
384
+ @DeviceRegistry.register("opencv_camera")
385
+ class OpenCVCamera(BaseCamera, QThread):
386
+ """Background-thread OpenCV camera capturing to MP4.
387
+
388
+ Emits via ``self.signals`` (a :class:`mesofield.signals.DeviceSignals`):
389
+ - ``signals.started`` / ``signals.finished`` for lifecycle.
390
+ - ``signals.data(idx, device_ts)`` per frame, consumed by
391
+ :meth:`DataManager.register_hardware_device`.
392
+ Plus Qt live-preview signals (GUI-only, decoupled from DataQueue):
393
+ - ``frame_ready(np.ndarray)`` / ``image_ready(np.ndarray)``.
394
+
395
+ Inherits the common camera surface (identity, output paths, manifest
396
+ metadata, ``arm``/``set_sequence`` defaults) from :class:`BaseCamera`,
397
+ and runs its own capture loop on top of :class:`QThread`.
398
+ """
399
+
400
+ # ----- Qt signals (GUI-only live preview) ----------------------------
401
+ # Class-level pyqtSignals are valid because QThread is a QObject.
402
+ frame_ready = pyqtSignal(np.ndarray)
403
+ # Alias used by the mesofield GUI's `InteractivePreview` (matches the
404
+ # signal name expected by `arducam.VideoThread`).
405
+ image_ready = pyqtSignal(np.ndarray)
406
+ # Recording progress: (frames_written, expected_total). expected_total is
407
+ # 0 when the duration is unknown (drives an indeterminate progress bar).
408
+ progress = pyqtSignal(int, int)
409
+
410
+ # ----- Camera surface overrides (BaseCamera defaults are tiff/func) --
411
+ file_type: ClassVar[str] = "mp4"
412
+ bids_type: ClassVar[Optional[str]] = "beh"
413
+ data_type: ClassVar[str] = "video"
414
+
415
+ def __init__(self, cfg: Dict[str, Any]):
416
+ QThread.__init__(self)
417
+ # BaseCamera surface (signals, identity, viewer cosmetics, output
418
+ # slots, logger). image_ready is already a class-level pyqtSignal,
419
+ # so _init_camera_surface's `if not hasattr(self, 'image_ready')`
420
+ # guard leaves it alone.
421
+ self._init_camera_surface(cfg, backend="opencv")
422
+ # OpenCV-specific knobs.
423
+ self.device_index: int = int(self.cfg.get("device_index", 0))
424
+ self.cv_backend_name: str = str(self.cfg.get("cv_backend", _DEFAULT_CV_BACKEND))
425
+ # Default fps if cfg didn't carry one (BaseCamera reads `fps` from cfg).
426
+ if not self.sampling_rate:
427
+ self.sampling_rate = 30.0
428
+ self.fourcc: str = str(self.cfg.get("fourcc", "H264"))
429
+ self.is_color: bool = bool(self.cfg.get("color", True))
430
+
431
+ # Optional explicit width/height overrides; otherwise driver default
432
+ self._req_width: Optional[int] = self.cfg.get("width")
433
+ self._req_height: Optional[int] = self.cfg.get("height")
434
+
435
+ # Apply YAML "properties" block (mirrors MMCamera semantics).
436
+ self.properties: Dict[str, Any] = self.cfg.get("properties", {}) or {}
437
+ for key, val in self.properties.items():
438
+ if key == "fps":
439
+ self.sampling_rate = float(val)
440
+ elif key == "viewer_type":
441
+ self.viewer = val
442
+ elif key == "auto_contrast":
443
+ self.auto_contrast = val
444
+
445
+ # Capture loop state.
446
+ self._capture: Optional[Any] = None # cv2.VideoCapture
447
+ self._frame_index: int = 0
448
+ self._frame_timestamps: list[tuple[int, float]] = [] # (idx, perf_counter)
449
+ self._stop = False
450
+ # Expected recording length in frames (0 = unknown); set by set_writer.
451
+ self._expected_frames: int = 0
452
+
453
+ self.initialize()
454
+
455
+ # ----- HardwareDevice protocol ----------------------------------------
456
+ def initialize(self) -> bool:
457
+ """Verify the camera can be opened. Returns immediately afterwards."""
458
+ configure_opencv_codec()
459
+ import cv2
460
+
461
+ backend_id = _resolve_cv_backend(self.cv_backend_name)
462
+ cap = cv2.VideoCapture(self.device_index, backend_id)
463
+ if not cap.isOpened():
464
+ cap.release()
465
+ self.logger.error(
466
+ f"Could not open OpenCV camera index={self.device_index} "
467
+ f"backend={self.cv_backend_name}"
468
+ )
469
+ return False
470
+ # Detect actual resolution; honour overrides if given
471
+ if self._req_width:
472
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(self._req_width))
473
+ if self._req_height:
474
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(self._req_height))
475
+ self._frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
476
+ self._frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
477
+ cap_fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
478
+ if cap_fps > 0 and not self.cfg.get("fps") and "fps" not in self.properties:
479
+ self.sampling_rate = cap_fps
480
+ cap.release()
481
+ self.logger.info(
482
+ f"OpenCV camera ready: index={self.device_index} "
483
+ f"backend={self.cv_backend_name} "
484
+ f"{self._frame_width}x{self._frame_height} @ {self.sampling_rate} fps"
485
+ )
486
+ return True
487
+
488
+ def status(self) -> Dict[str, Any]:
489
+ st = super().status()
490
+ st.update({
491
+ "device_index": self.device_index,
492
+ "cv_backend": self.cv_backend_name,
493
+ "frames_written": self._frame_index,
494
+ })
495
+ return st
496
+
497
+ # Backwards-compat alias used elsewhere in the codebase
498
+ def get_status(self) -> Dict[str, Any]:
499
+ return self.status()
500
+
501
+ @property
502
+ def metadata(self) -> Dict[str, Any]:
503
+ md = super().metadata
504
+ md.update({
505
+ "device_index": self.device_index,
506
+ "cv_backend": self.cv_backend_name,
507
+ "width": getattr(self, "_frame_width", None),
508
+ "height": getattr(self, "_frame_height", None),
509
+ "fourcc": self.fourcc,
510
+ })
511
+ return md
512
+
513
+ # ----- DataProducer protocol -------------------------------------------
514
+ def set_writer(self, make_path: Callable[[str, str, str, bool], str]) -> None:
515
+ """Resolve the output path and build the :class:`CV2Writer`.
516
+
517
+ ``BaseCamera.set_writer`` resolves ``output_path``, constructs the
518
+ ``CV2Writer`` (the project's shared MP4 writer), and copies its
519
+ sidecar path onto ``metadata_path``. The capture loop drives the
520
+ writer directly via ``begin``/``add_frame``/``finish``.
521
+ """
522
+ super().set_writer(make_path)
523
+ # Reset timing so saved timestamps reflect the recording start, not
524
+ # whenever the live-preview thread first launched.
525
+ self._started = datetime.now()
526
+ self._frame_timestamps = []
527
+ self._frame_index = 0
528
+ # Expected frame count drives the GUI progress bar. Falls back to 0
529
+ # (indeterminate) when the experiment duration is unavailable.
530
+ duration = getattr(self.config, "sequence_duration", None)
531
+ try:
532
+ self._expected_frames = int(self.sampling_rate * float(duration))
533
+ except (TypeError, ValueError):
534
+ self._expected_frames = 0
535
+ self.logger.info(f"Writer set to {self.output_path}")
536
+
537
+ def start(self) -> bool:
538
+ """Spawn the capture thread and begin writing frames to MP4.
539
+
540
+ Returns:
541
+ ``True`` if the thread started, ``False`` if it was already
542
+ running.
543
+ """
544
+ if self.isRunning():
545
+ self.logger.warning("OpenCVCamera.start: already running")
546
+ return False
547
+ self._stop = False
548
+ self._frame_index = 0
549
+ self._frame_timestamps = []
550
+ self._started = datetime.now()
551
+ self.is_active = True
552
+ self.signals.started.emit()
553
+ super().start() # spawns QThread.run
554
+ return True
555
+
556
+ def stop(self) -> bool:
557
+ """Signal the capture thread to stop and wait for it to join."""
558
+ if not self.isRunning() and not self._stop:
559
+ return True
560
+ self._stop = True
561
+ self.requestInterruption()
562
+ self.wait(5000)
563
+ self.is_active = False
564
+ self._stopped = datetime.now()
565
+ self.signals.finished.emit()
566
+ return True
567
+
568
+ def shutdown(self) -> None:
569
+ """Tear down the capture thread.
570
+
571
+ ``stop()`` joins the capture thread; its ``run()`` finally-block
572
+ releases the :class:`CV2Writer` and writes the sidecar JSON.
573
+ """
574
+ self.stop()
575
+
576
+ # --- live preview contract (BaseCamera abstract methods) ------------
577
+ # `snap()` reads a frame from a one-shot VideoCapture; the live capture
578
+ # thread is the same one driven by start()/stop(), just without a
579
+ # writer attached.
580
+
581
+ def snap(self) -> np.ndarray:
582
+ """Open the camera, read one frame, return it without recording."""
583
+ import cv2
584
+
585
+ backend_id = _resolve_cv_backend(self.cv_backend_name)
586
+ cap = cv2.VideoCapture(self.device_index, backend_id)
587
+ try:
588
+ if not cap.isOpened():
589
+ raise RuntimeError("OpenCVCamera.snap: could not open camera")
590
+ if self._req_width:
591
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(self._req_width))
592
+ if self._req_height:
593
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(self._req_height))
594
+ ok, frame = cap.read()
595
+ if not ok or frame is None:
596
+ raise RuntimeError("OpenCVCamera.snap: VideoCapture.read failed")
597
+ # Fan the snapped frame out to GUI subscribers too.
598
+ try:
599
+ self.image_ready.emit(frame)
600
+ except Exception:
601
+ pass
602
+ self._save_snap_png(frame)
603
+ return frame
604
+ finally:
605
+ cap.release()
606
+
607
+ def start_live(self) -> None:
608
+ """Start the capture thread WITHOUT a writer (preview-only)."""
609
+ if self.isRunning():
610
+ return
611
+ # Ensure no writer is attached so the capture loop's
612
+ # `if self.writer is not None` guard skips disk writes.
613
+ self.writer = None
614
+ self._stop = False
615
+ self._frame_index = 0
616
+ self._frame_timestamps = []
617
+ self.is_active = True
618
+ super().start()
619
+
620
+ def stop_live(self) -> None:
621
+ """End preview capture started by start_live."""
622
+ if not self.isRunning() and not self._stop:
623
+ return
624
+ self._stop = True
625
+ self.requestInterruption()
626
+ self.wait(5000)
627
+ self.is_active = False
628
+
629
+ def get_data(self) -> Optional[Dict[str, Any]]:
630
+ """Return the most recent frame index/timestamp pair, or ``None``."""
631
+ if not self._frame_timestamps:
632
+ return None
633
+ idx, ts = self._frame_timestamps[-1]
634
+ return {"frame_index": idx, "device_ts": ts}
635
+
636
+ def save_data(self, path: Optional[str] = None) -> None:
637
+ """No-op: the MP4 and its ``_frame_metadata.json`` sidecar are written
638
+ by the :class:`CV2Writer` when the capture loop finishes.
639
+ """
640
+ return None
641
+
642
+ def _frame_metadata(self) -> Dict[str, Any]:
643
+ """Per-frame timestamp metadata handed to ``CV2Writer.finish``."""
644
+ return {
645
+ "device": self.metadata,
646
+ "started": self._started.isoformat() if self._started else None,
647
+ "stopped": self._stopped.isoformat() if self._stopped else None,
648
+ "frames": [
649
+ {"index": i, "device_ts": ts} for i, ts in self._frame_timestamps
650
+ ],
651
+ }
652
+
653
+ # ----- Internal --------------------------------------------------------
654
+ def run(self) -> None: # QThread entry point
655
+ import cv2
656
+
657
+ backend_id = _resolve_cv_backend(self.cv_backend_name)
658
+ cap = cv2.VideoCapture(self.device_index, backend_id)
659
+ if not cap.isOpened():
660
+ cap.release()
661
+ self.is_active = False
662
+ self.logger.error("Failed to open camera in capture thread")
663
+ return
664
+
665
+ if self._req_width:
666
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(self._req_width))
667
+ if self._req_height:
668
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(self._req_height))
669
+
670
+ self._capture = cap
671
+ # Open the CV2Writer for this recording (skipped for live preview,
672
+ # where `start_live()` clears `self.writer`).
673
+ if self.writer is not None:
674
+ try:
675
+ self.writer.begin(self._frame_width, self._frame_height, self.is_color)
676
+ except Exception as exc: # pragma: no cover - codec failure
677
+ self.logger.error(f"CV2Writer.begin failed: {exc}")
678
+ period = 1.0 / self.sampling_rate if self.sampling_rate > 0 else 0.0
679
+ next_t = time.perf_counter()
680
+ try:
681
+ while not self.isInterruptionRequested() and not self._stop:
682
+ ok, frame = cap.read()
683
+ if not ok or frame is None:
684
+ self.msleep(1)
685
+ continue
686
+
687
+ ts = time.perf_counter()
688
+ idx = self._frame_index
689
+ self._frame_index += 1
690
+ self._frame_timestamps.append((idx, ts))
691
+
692
+ # Write to disk via the shared CV2Writer
693
+ if self.writer is not None:
694
+ if not self.is_color and frame.ndim == 3:
695
+ frame_to_write = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
696
+ else:
697
+ frame_to_write = frame
698
+ try:
699
+ self.writer.add_frame(frame_to_write)
700
+ except Exception as exc: # pragma: no cover - codec failure
701
+ self.logger.error(f"CV2Writer.add_frame failed: {exc}")
702
+
703
+ # GUI live-preview signals (Qt-native, decoupled from queue)
704
+ self.frame_ready.emit(frame)
705
+ self.image_ready.emit(frame)
706
+ # Recording progress (only meaningful while a writer is attached)
707
+ if self.writer is not None:
708
+ self.progress.emit(idx + 1, self._expected_frames)
709
+ # Standardized data signal -> DataQueue
710
+ self.signals.data.emit(idx, ts)
711
+ # Optional raw-frame signal for real-time processors.
712
+ try:
713
+ self.signals.frame.emit(frame, idx, ts)
714
+ except Exception:
715
+ pass
716
+
717
+ # Frame pacing — only sleep if we have headroom
718
+ if period:
719
+ next_t += period
720
+ delay = next_t - time.perf_counter()
721
+ if delay > 0:
722
+ self.msleep(int(delay * 1000))
723
+ else:
724
+ next_t = time.perf_counter()
725
+ finally:
726
+ try:
727
+ cap.release()
728
+ except Exception:
729
+ pass
730
+ self._capture = None
731
+ if self.writer is not None:
732
+ try:
733
+ self.writer.finish(extra_metadata=self._frame_metadata())
734
+ except Exception as exc:
735
+ self.logger.error(f"CV2Writer.finish failed: {exc}")
736
+ # `-1` is the "recording done -- hide the progress bar" sentinel.
737
+ self.progress.emit(-1, 0)
738
+ self.logger.info(
739
+ f"OpenCV capture stopped: wrote {self._frame_index} frames"
740
+ )