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,295 @@
1
+ """Shared surface for camera-shaped devices.
2
+
3
+ Three concrete cameras converge on this base:
4
+
5
+ - :class:`mesofield.devices.cameras.MMCamera` (Micro-Manager backend)
6
+ - :class:`mesofield.devices.cameras.OpenCVCamera` (OpenCV/UVC)
7
+ - :class:`mesofield.devices.mocks.MockFrameProducer` (synthetic)
8
+
9
+ They have wildly different acquisition loops (mmcore-driven MDA vs Qt thread
10
+ vs synthetic frame generator) so we don't try to unify the run-loop here.
11
+ What we DO unify is the *surface* every camera must expose:
12
+
13
+ - Identity & cosmetic attrs the MDA GUI reads
14
+ (``name``, ``viewer``, ``auto_contrast``, ``core``, ``backend``).
15
+ - Output paths and metadata sidecar plumbing.
16
+ - The standard lifecycle hooks (``arm``, ``set_writer``, ``set_sequence``,
17
+ ``shutdown``, ``status``, ``calibration``).
18
+ - The :class:`DeviceSignals` bundle that :class:`DataManager` subscribes to
19
+ (``signals.started``, ``signals.finished``, ``signals.data``).
20
+
21
+ Subclasses keep their backend-specific ``initialize``, ``start``, ``stop``,
22
+ ``save_data``, and writer setup.
23
+
24
+ Design notes:
25
+
26
+ - :class:`BaseCamera` is a *regular* class -- not a Protocol -- so subclasses
27
+ can multiply-inherit alongside Qt's :class:`QThread` (``OpenCVCamera``),
28
+ pymmcore-plus's ``DataProducer``/``HardwareDevice`` Protocols
29
+ (``MMCamera``), and our pure-Python :class:`BaseDataProducer`
30
+ (``MockFrameProducer``).
31
+ - We avoid ``BaseCamera.__init__`` to dodge multiple-inheritance ``super()``
32
+ chains with QThread. Subclasses call :meth:`_init_camera_surface` after
33
+ whatever parent ``__init__`` they need.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from datetime import datetime
39
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Optional, Type
40
+
41
+ from mesofield.signals import DeviceSignals
42
+ from mesofield.utils._logger import get_logger
43
+
44
+ if TYPE_CHECKING:
45
+ from mesofield.config import ExperimentConfig
46
+
47
+
48
+ class BaseCamera:
49
+ """Common camera surface (no Qt, no acquisition loop).
50
+
51
+ Subclasses call :meth:`_init_camera_surface` from their ``__init__``
52
+ after any superclass ``super().__init__()`` calls. Override
53
+ :meth:`set_writer`, :meth:`set_sequence`, :meth:`start`, :meth:`stop`,
54
+ :meth:`save_data`, and :meth:`get_data` as needed.
55
+ """
56
+
57
+ # --- class-level defaults; subclasses override as appropriate -------
58
+ device_type: ClassVar[str] = "camera"
59
+ viewer: ClassVar[str] = "static"
60
+ file_type: ClassVar[str] = "ome.tiff"
61
+ bids_type: ClassVar[Optional[str]] = "func"
62
+ data_type: ClassVar[str] = "frames"
63
+
64
+ # --- initialisation helper ------------------------------------------
65
+ def _init_camera_surface(self, cfg: Dict[str, Any], *, backend: str) -> None:
66
+ """Populate the shared camera attributes from a YAML stanza.
67
+
68
+ Sets identity (id/device_id/name), primary flag, viewer cosmetics,
69
+ sampling rate, the :class:`DeviceSignals` bundle, output paths,
70
+ and timing slots. Idempotent on ``signals`` if the subclass has
71
+ already created it (e.g. :class:`BaseDataProducer` does).
72
+ """
73
+ self.cfg: Dict[str, Any] = dict(cfg or {})
74
+ self.id: str = str(self.cfg.get("id", "camera"))
75
+ self.device_id: str = self.id
76
+ self.name: str = str(self.cfg.get("name", self.id))
77
+ self.backend: str = backend
78
+ self.is_primary: bool = bool(self.cfg.get("primary", False))
79
+ self.is_active: bool = False
80
+ self.auto_contrast: Any = self.cfg.get("auto_contrast", True)
81
+ self.viewer = self.cfg.get("viewer_type", self.viewer)
82
+ # `sampling_rate` is the canonical fps for the camera. Subclasses
83
+ # may overwrite from cfg["fps"] or driver introspection.
84
+ self.sampling_rate: float = float(
85
+ self.cfg.get("sampling_rate", self.cfg.get("fps", 0.0)) or 0.0
86
+ )
87
+ # Output state (filled in by set_writer/save_data).
88
+ self.output_path: Optional[str] = None
89
+ self.metadata_path: Optional[str] = None
90
+ self.writer: Any = None
91
+ # Injected once by HardwareManager.initialize() so the camera can
92
+ # resolve paths (`config.make_path`) outside the per-run `arm()`.
93
+ self.config: Optional["ExperimentConfig"] = None
94
+ # MDA gui reads `cam.core` (None for non-mmcore cameras). MMCamera
95
+ # overrides during backend setup; others leave it None.
96
+ self.core: Any = None
97
+ self.camera_device: Any = None
98
+ self._engine = None
99
+ # Lifecycle timestamps.
100
+ self._started: Optional[datetime] = None
101
+ self._stopped: Optional[datetime] = None
102
+ # `image_ready` is a pyqtSignal (or psygnal wrapper) the MDA gui's
103
+ # non-mmcore viewer subscribes to. Subclasses set this when they
104
+ # have a Qt-friendly emitter (OpenCVCamera does directly via
105
+ # `pyqtSignal`; MockFrameProducer composes a `QtImageAdapter`).
106
+ if not hasattr(self, "image_ready"):
107
+ self.image_ready = None
108
+ # `signals` may already be created by a parent class (e.g.
109
+ # BaseDataProducer.__init__). Don't clobber if so.
110
+ if not hasattr(self, "signals"):
111
+ self.signals = DeviceSignals()
112
+ # Per-instance logger keyed on the actual subclass name + id.
113
+ self.logger = get_logger(
114
+ f"{type(self).__module__}.{type(self).__name__}[{self.id}]"
115
+ )
116
+
117
+ # --- per-run prep ---------------------------------------------------
118
+ def arm(self, config: "ExperimentConfig") -> None:
119
+ """Per-run prep: set up the writer + (optionally) an MDA sequence.
120
+
121
+ The default body fits both MMCamera (which needs a sequence) and
122
+ OpenCV/Mock (where ``set_sequence`` is a no-op). Subclasses
123
+ override only when they need additional prep.
124
+ """
125
+ self.set_writer(config.make_path)
126
+ self.set_sequence(config.build_sequence)
127
+
128
+ # --- writer selection ------------------------------------------------
129
+ # The default mapping is `file_type` (the YAML stanza's `output.file_type`
130
+ # key) -> writer class. Subclasses can override the mapping to add new
131
+ # output formats (e.g. a future Zarr writer) without touching `set_writer`.
132
+ # GUI code wanting to switch writers at runtime only has to update
133
+ # `self.file_type` and call `set_writer` again.
134
+ _WRITER_FOR_FILE_TYPE: ClassVar[Dict[str, str]] = {
135
+ "ome.tiff": "CustomWriter",
136
+ "tiff": "CustomWriter",
137
+ "mp4": "CV2Writer",
138
+ "avi": "CV2Writer",
139
+ }
140
+
141
+ def _make_writer(self, filename: str) -> Any:
142
+ """Construct the writer matching ``self.file_type``.
143
+
144
+ Default selection: OME-TIFF (``CustomWriter``) for ``ome.tiff`` /
145
+ ``tiff``, MP4 (``CV2Writer``) for ``mp4`` / ``avi``. Override
146
+ :attr:`_WRITER_FOR_FILE_TYPE` on a subclass to register additional
147
+ writers, or override this method entirely to control construction
148
+ per-format (e.g. passing ``fps`` to a video writer).
149
+ """
150
+ from mesofield.data import CustomWriter, CV2Writer
151
+
152
+ name = self._WRITER_FOR_FILE_TYPE.get(self.file_type)
153
+ if name is None:
154
+ raise ValueError(
155
+ f"No writer registered for file_type {self.file_type!r}. "
156
+ f"Known: {sorted(self._WRITER_FOR_FILE_TYPE)}"
157
+ )
158
+ if name == "CustomWriter":
159
+ return CustomWriter(filename=filename)
160
+ if name == "CV2Writer":
161
+ fps = int(self.sampling_rate) if self.sampling_rate else 30
162
+ fourcc = None
163
+ if isinstance(getattr(self, "cfg", None), dict):
164
+ fourcc = self.cfg.get("fourcc")
165
+ fourcc = getattr(self, "fourcc", fourcc)
166
+ if fourcc:
167
+ return CV2Writer(filename=filename, fps=fps, fourcc=str(fourcc))
168
+ return CV2Writer(filename=filename, fps=fps)
169
+ raise ValueError(f"Unknown writer class name {name!r}")
170
+
171
+ def set_writer(self, make_path: Callable[[str, str, str, bool], str]) -> None:
172
+ """Generate the camera's output path and construct the writer.
173
+
174
+ Resolves ``self.output_path`` via ``make_path``, then instantiates
175
+ the writer that :meth:`_make_writer` picks for ``self.file_type``
176
+ and copies the writer's sidecar filename onto ``self.metadata_path``
177
+ for the AcquisitionManifest. Subclasses override only when they
178
+ need additional plumbing on top (e.g. OpenCVCamera resetting its
179
+ capture-loop timing).
180
+ """
181
+ self.output_path = make_path(self.name, self.file_type, self.bids_type, True)
182
+ self.writer = self._make_writer(self.output_path)
183
+ if hasattr(self.writer, "_frame_metadata_filename"):
184
+ self.metadata_path = self.writer._frame_metadata_filename
185
+
186
+ def set_sequence(self, build_mda: Callable[[Any], Any]) -> None:
187
+ """Default no-op (only MMCamera with an MDA backend overrides this)."""
188
+ return None
189
+
190
+ # --- live-view + snap contract --------------------------------------
191
+ # The MDA GUI's snap/live buttons used to call mmcore directly; with
192
+ # BaseCamera in place they can call these methods on the camera and
193
+ # work uniformly across MMCamera (mmcore-driven), OpenCVCamera
194
+ # (cv2.VideoCapture-driven), and MockFrameProducer (synthetic).
195
+ # Subclasses MUST implement; the base raises NotImplementedError so a
196
+ # mis-wired GUI surfaces it immediately instead of silently no-op'ing.
197
+
198
+ def snap(self):
199
+ """Capture a single frame outside any recording, return it as an ndarray.
200
+
201
+ Used by the GUI's snap button. Implementations should NOT alter
202
+ recording state -- snap is preview-only -- but they SHOULD call
203
+ :meth:`_save_snap_png` so each snap also lands a ``*_snap.png``.
204
+ """
205
+ raise NotImplementedError(f"{type(self).__name__}.snap() not implemented")
206
+
207
+ def _save_snap_png(self, frame: Any) -> Optional[str]:
208
+ """Write a snapped frame to ``<name>_snap.png`` at the BIDS path.
209
+
210
+ No-op when no :class:`ExperimentConfig` has been injected. Uses
211
+ ``config.make_path`` so the snapshot follows the same BIDS layout
212
+ (and ``bids_type``) as the camera's recordings.
213
+ """
214
+ if frame is None or self.config is None:
215
+ return None
216
+ make_path = getattr(self.config, "make_path", None)
217
+ if make_path is None:
218
+ return None
219
+ try:
220
+ import numpy as np
221
+ from PIL import Image
222
+
223
+ path = make_path(f"{self.name}_snap", "png", self.bids_type, True)
224
+ arr = np.asarray(frame)
225
+ if arr.ndim == 3 and arr.shape[2] == 3:
226
+ arr = arr[..., ::-1] # OpenCV BGR -> RGB
227
+ if arr.dtype != np.uint8:
228
+ # Scale to 8-bit so the snapshot PNG is viewable.
229
+ lo, hi = float(arr.min()), float(arr.max())
230
+ scale = 255.0 / (hi - lo) if hi > lo else 1.0
231
+ arr = np.clip((arr.astype(np.float32) - lo) * scale, 0, 255).astype(np.uint8)
232
+ Image.fromarray(arr).save(path)
233
+ self.logger.info(f"Snapshot saved to {path}")
234
+ return path
235
+ except Exception as exc:
236
+ self.logger.error(f"Failed to save snapshot PNG: {exc}")
237
+ return None
238
+
239
+ def start_live(self) -> None:
240
+ """Begin continuous live preview WITHOUT writing to disk.
241
+
242
+ Subscribers receive frames via ``image_ready`` (Qt) / ``signals.data``
243
+ (psygnal). No recording side-effects; pair with :meth:`stop_live`.
244
+ """
245
+ raise NotImplementedError(f"{type(self).__name__}.start_live() not implemented")
246
+
247
+ def stop_live(self) -> None:
248
+ """End the continuous live preview started by :meth:`start_live`."""
249
+ raise NotImplementedError(f"{type(self).__name__}.stop_live() not implemented")
250
+
251
+ # --- standard introspection -----------------------------------------
252
+ def status(self) -> Dict[str, Any]:
253
+ return {
254
+ "device_id": self.device_id,
255
+ "device_type": self.device_type,
256
+ "backend": self.backend,
257
+ "is_primary": self.is_primary,
258
+ "is_active": self.is_active,
259
+ "output_path": self.output_path,
260
+ "metadata_path": self.metadata_path,
261
+ "started": self._started.isoformat() if self._started else None,
262
+ "stopped": self._stopped.isoformat() if self._stopped else None,
263
+ }
264
+
265
+ @property
266
+ def metadata(self) -> Dict[str, Any]:
267
+ return {
268
+ "device_id": self.device_id,
269
+ "device_type": self.device_type,
270
+ "backend": self.backend,
271
+ "name": self.name,
272
+ "fps": self.sampling_rate,
273
+ "file_type": self.file_type,
274
+ "bids_type": self.bids_type,
275
+ }
276
+
277
+ @property
278
+ def calibration(self) -> Dict[str, Any]:
279
+ """Camera-specific constants worth recording in the AcquisitionManifest."""
280
+ return {
281
+ "name": self.name,
282
+ "sampling_rate_hz": self.sampling_rate,
283
+ "viewer": self.viewer,
284
+ }
285
+
286
+ def sidecars(self) -> list:
287
+ """Extra sidecars beyond ``metadata_path``. Cameras typically have none."""
288
+ return []
289
+
290
+ def shutdown(self) -> None:
291
+ """Default cleanup: stop the camera. Subclasses extend if needed."""
292
+ try:
293
+ self.stop()
294
+ except Exception:
295
+ pass