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,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
|