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