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,12 @@
|
|
|
1
|
+
"""Intermediate processing stages (DLC, mesomap, lab pipelines).
|
|
2
|
+
|
|
3
|
+
Each processor is a small subclass of :class:`ProcessorRunner` that
|
|
4
|
+
defines ``run(inputs, **params) -> list[Path]``. Calling the runner
|
|
5
|
+
wraps that work in a hashing + manifest-writing harness so every
|
|
6
|
+
processed file lands with a ``<tool_name>.process.json`` sidecar
|
|
7
|
+
recording inputs, parameters, tool version, and upstream provenance.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from mesofield.processing.runner import ProcessorRunner
|
|
11
|
+
|
|
12
|
+
__all__ = ["ProcessorRunner"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Base class for intermediate processing stages.
|
|
2
|
+
|
|
3
|
+
A processor takes raw acquisition files and produces derived files. The
|
|
4
|
+
runner handles the boilerplate: hashing inputs, locating the upstream
|
|
5
|
+
AcquisitionManifest, writing a ProcessingManifest sidecar alongside the
|
|
6
|
+
outputs, and turning declared outputs into ProducerEntry shapes the
|
|
7
|
+
ingest layer can consume.
|
|
8
|
+
|
|
9
|
+
Typical use:
|
|
10
|
+
|
|
11
|
+
from mesofield.processing import ProcessorRunner
|
|
12
|
+
|
|
13
|
+
class SpikeSorter(ProcessorRunner):
|
|
14
|
+
tool_name = "my_lab_spikesort"
|
|
15
|
+
tool_version = "0.1.0"
|
|
16
|
+
|
|
17
|
+
def run(self, inputs, *, sigma=4.0):
|
|
18
|
+
in_path = inputs[0]
|
|
19
|
+
out = in_path.parent / "spikes.csv"
|
|
20
|
+
# ... do the work; write to `out` ...
|
|
21
|
+
return [out]
|
|
22
|
+
|
|
23
|
+
runner = SpikeSorter()
|
|
24
|
+
runner([recording_path], sigma=5.0)
|
|
25
|
+
# → spikes.csv written, plus my_lab_spikesort.process.json next to it
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import inspect
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, ClassVar, Iterable, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
from mesokit_schema import (
|
|
36
|
+
AcquisitionManifest,
|
|
37
|
+
InputRef,
|
|
38
|
+
ProcessingManifest,
|
|
39
|
+
ProducerEntry,
|
|
40
|
+
TimeBasis,
|
|
41
|
+
)
|
|
42
|
+
from mesokit_schema.dataset import hash_file
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProcessorRunner:
|
|
46
|
+
"""Wrap a file-to-file transformation in a ProcessingManifest contract.
|
|
47
|
+
|
|
48
|
+
Subclasses must set :attr:`tool_name` and :attr:`tool_version`, and
|
|
49
|
+
override :meth:`run`. Calling the instance executes the work and
|
|
50
|
+
writes the sidecar.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
tool_name: ClassVar[str] = ""
|
|
54
|
+
tool_version: ClassVar[str] = "0.0.0"
|
|
55
|
+
|
|
56
|
+
#: Where the manifest sidecar lands. ``"output_dir"`` writes
|
|
57
|
+
#: ``<tool_name>.process.json`` in the directory of the first output;
|
|
58
|
+
#: subclasses can override :meth:`manifest_path` for custom placement.
|
|
59
|
+
manifest_placement: ClassVar[str] = "output_dir"
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------ user hooks
|
|
62
|
+
|
|
63
|
+
def run(self, inputs: Sequence[Path], **params: Any) -> list[Path]:
|
|
64
|
+
"""Do the actual work. Return the list of files written."""
|
|
65
|
+
raise NotImplementedError("ProcessorRunner subclasses must implement run()")
|
|
66
|
+
|
|
67
|
+
def declare_outputs(
|
|
68
|
+
self,
|
|
69
|
+
outputs: Sequence[Path],
|
|
70
|
+
params: dict[str, Any],
|
|
71
|
+
session_root: Optional[Path],
|
|
72
|
+
) -> list[ProducerEntry]:
|
|
73
|
+
"""Turn run() outputs into ProducerEntry instances.
|
|
74
|
+
|
|
75
|
+
Default: one entry per output, with data_type=tool_name and
|
|
76
|
+
bids_type/file_type inferred from the path. Override for richer
|
|
77
|
+
declarations (e.g. multiple roles, sidecars).
|
|
78
|
+
"""
|
|
79
|
+
entries: list[ProducerEntry] = []
|
|
80
|
+
for path in outputs:
|
|
81
|
+
rel = self._relative_to_session(path, session_root)
|
|
82
|
+
entries.append(
|
|
83
|
+
ProducerEntry(
|
|
84
|
+
device_id=self.tool_name,
|
|
85
|
+
device_type="processor",
|
|
86
|
+
data_type=self.tool_name,
|
|
87
|
+
bids_type=self._infer_bids_type(path, session_root),
|
|
88
|
+
file_type=self._infer_file_type(path),
|
|
89
|
+
output_path=rel,
|
|
90
|
+
time_basis=TimeBasis(
|
|
91
|
+
clock_source="derived",
|
|
92
|
+
description=f"Derived by {self.tool_name} v{self.tool_version}",
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return entries
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ orchestration
|
|
99
|
+
|
|
100
|
+
def __call__(
|
|
101
|
+
self,
|
|
102
|
+
inputs: Sequence[Path],
|
|
103
|
+
*,
|
|
104
|
+
upstream: Optional[AcquisitionManifest | Path] = None,
|
|
105
|
+
session_root: Optional[Path] = None,
|
|
106
|
+
**params: Any,
|
|
107
|
+
) -> tuple[list[Path], ProcessingManifest]:
|
|
108
|
+
"""Run + emit the sidecar. Returns (outputs, manifest)."""
|
|
109
|
+
if not self.tool_name:
|
|
110
|
+
raise ValueError(f"{type(self).__name__} must set tool_name")
|
|
111
|
+
|
|
112
|
+
input_paths = [Path(p) for p in inputs]
|
|
113
|
+
input_refs = [
|
|
114
|
+
InputRef(path=str(p), content_hash=hash_file(p)) for p in input_paths
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
upstream_manifest, upstream_hash, resolved_session_root = self._resolve_upstream(
|
|
118
|
+
upstream, session_root, input_paths
|
|
119
|
+
)
|
|
120
|
+
if session_root is None:
|
|
121
|
+
session_root = resolved_session_root
|
|
122
|
+
|
|
123
|
+
outputs = [Path(p) for p in self.run(input_paths, **params)]
|
|
124
|
+
if not outputs:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"{self.tool_name}.run() returned no outputs; nothing to declare"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
producer_entries = self.declare_outputs(outputs, params, session_root)
|
|
130
|
+
|
|
131
|
+
manifest = ProcessingManifest(
|
|
132
|
+
tool_name=self.tool_name,
|
|
133
|
+
tool_version=self.tool_version,
|
|
134
|
+
tool_invocation=self._invocation(input_paths, params),
|
|
135
|
+
built_at=datetime.now(timezone.utc),
|
|
136
|
+
upstream_acquisition_hash=upstream_hash,
|
|
137
|
+
inputs=input_refs,
|
|
138
|
+
parameters=self._jsonable_params(params),
|
|
139
|
+
outputs=producer_entries,
|
|
140
|
+
)
|
|
141
|
+
sidecar = self.manifest_path(outputs)
|
|
142
|
+
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
manifest.write(sidecar)
|
|
144
|
+
return outputs, manifest
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ helpers
|
|
147
|
+
|
|
148
|
+
def manifest_path(self, outputs: Sequence[Path]) -> Path:
|
|
149
|
+
if self.manifest_placement == "output_dir":
|
|
150
|
+
return Path(outputs[0]).parent / f"{self.tool_name}.process.json"
|
|
151
|
+
raise ValueError(f"Unknown manifest_placement: {self.manifest_placement!r}")
|
|
152
|
+
|
|
153
|
+
def _invocation(self, inputs: Sequence[Path], params: dict[str, Any]) -> str:
|
|
154
|
+
sig = inspect.signature(self.run)
|
|
155
|
+
bound = ", ".join(
|
|
156
|
+
[f"inputs={[p.name for p in inputs]}"]
|
|
157
|
+
+ [f"{k}={v!r}" for k, v in params.items()]
|
|
158
|
+
)
|
|
159
|
+
return f"{type(self).__name__}().run({bound})"
|
|
160
|
+
|
|
161
|
+
def _jsonable_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
out: dict[str, Any] = {}
|
|
163
|
+
for k, v in params.items():
|
|
164
|
+
try:
|
|
165
|
+
import json
|
|
166
|
+
json.dumps(v)
|
|
167
|
+
out[k] = v
|
|
168
|
+
except TypeError:
|
|
169
|
+
out[k] = repr(v)
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _relative_to_session(path: Path, session_root: Optional[Path]) -> str:
|
|
174
|
+
if session_root is None:
|
|
175
|
+
return str(path)
|
|
176
|
+
try:
|
|
177
|
+
return str(Path(path).resolve().relative_to(Path(session_root).resolve()))
|
|
178
|
+
except ValueError:
|
|
179
|
+
return str(path)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _infer_bids_type(path: Path, session_root: Optional[Path]) -> Optional[str]:
|
|
183
|
+
if session_root is None:
|
|
184
|
+
return path.parent.name or None
|
|
185
|
+
try:
|
|
186
|
+
rel = Path(path).resolve().relative_to(Path(session_root).resolve())
|
|
187
|
+
except ValueError:
|
|
188
|
+
return path.parent.name or None
|
|
189
|
+
# rel is e.g. processed/<bids_type>/<file> or processed/<file>
|
|
190
|
+
parts = rel.parts
|
|
191
|
+
if len(parts) >= 3 and parts[0] == "processed":
|
|
192
|
+
return parts[1]
|
|
193
|
+
if len(parts) == 2:
|
|
194
|
+
return parts[0] if parts[0] != "processed" else None
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _infer_file_type(path: Path) -> str:
|
|
199
|
+
# Preserve multi-dot extensions like ome.tiff.
|
|
200
|
+
name = path.name
|
|
201
|
+
if "." not in name:
|
|
202
|
+
return ""
|
|
203
|
+
first_dot = name.index(".")
|
|
204
|
+
return name[first_dot + 1:]
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _resolve_upstream(
|
|
208
|
+
upstream: Optional[AcquisitionManifest | Path],
|
|
209
|
+
session_root: Optional[Path],
|
|
210
|
+
input_paths: Sequence[Path],
|
|
211
|
+
) -> tuple[Optional[AcquisitionManifest], Optional[str], Optional[Path]]:
|
|
212
|
+
"""Locate the AcquisitionManifest for the provenance chain.
|
|
213
|
+
|
|
214
|
+
Accepts an already-loaded manifest, an explicit path, or `None`
|
|
215
|
+
(in which case we walk up from the first input looking for
|
|
216
|
+
`manifest.json`). Returns (manifest, content_hash, session_root).
|
|
217
|
+
"""
|
|
218
|
+
if isinstance(upstream, AcquisitionManifest):
|
|
219
|
+
return upstream, upstream.content_hash(), session_root
|
|
220
|
+
|
|
221
|
+
candidate: Optional[Path] = None
|
|
222
|
+
if isinstance(upstream, (str, Path)):
|
|
223
|
+
candidate = Path(upstream)
|
|
224
|
+
elif input_paths:
|
|
225
|
+
for parent in [input_paths[0].resolve().parent, *input_paths[0].resolve().parents]:
|
|
226
|
+
if (parent / "manifest.json").exists():
|
|
227
|
+
candidate = parent / "manifest.json"
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
if candidate is None or not candidate.exists():
|
|
231
|
+
return None, None, session_root
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
manifest = AcquisitionManifest.read(candidate)
|
|
235
|
+
except Exception:
|
|
236
|
+
return None, None, session_root
|
|
237
|
+
return manifest, manifest.content_hash(), candidate.parent
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Real-time, per-frame processors for camera DataProducers.
|
|
2
|
+
|
|
3
|
+
A :class:`FrameProcessor` subscribes to a camera's ``signals.frame``,
|
|
4
|
+
runs ``compute(img, idx, ts)`` on a daemon worker thread, and emits the
|
|
5
|
+
result on both ``signals.data`` (for the DataQueue / CSV logger) and a
|
|
6
|
+
Qt-compatible ``valueUpdated(time, value)`` signal (for the existing
|
|
7
|
+
:class:`~mesofield.gui.speedplotter.SerialWidget` plotter).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import FrameProcessor
|
|
11
|
+
from .frame_mean import FrameMean
|
|
12
|
+
|
|
13
|
+
__all__ = ["FrameProcessor", "FrameMean"]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Threaded base class for real-time camera frame processors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, ClassVar, Dict, Optional, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
11
|
+
|
|
12
|
+
from mesofield.signals import DeviceSignals
|
|
13
|
+
from mesofield.utils._logger import get_logger
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from mesofield.protocols import DataProducer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _QtAdapter(QObject):
|
|
20
|
+
"""QObject carrier so the processor can expose a pyqtSignal that
|
|
21
|
+
cross-thread emits are auto-queued to the GUI thread."""
|
|
22
|
+
|
|
23
|
+
valueUpdated = pyqtSignal(float, float)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FrameProcessor:
|
|
27
|
+
"""Base class for per-frame, real-time processors.
|
|
28
|
+
|
|
29
|
+
Acquisition stays on the camera thread; ``compute`` runs on a daemon
|
|
30
|
+
worker thread fed by a bounded queue (size 1, drop-oldest replace)
|
|
31
|
+
so processing latency cannot stall the camera. Subclasses implement
|
|
32
|
+
one method::
|
|
33
|
+
|
|
34
|
+
def compute(self, img, idx, ts) -> float | None: ...
|
|
35
|
+
|
|
36
|
+
Returning ``None`` skips emission for that frame. Otherwise the
|
|
37
|
+
scalar is emitted on:
|
|
38
|
+
|
|
39
|
+
* ``self.signals.data(value, ts)`` -- psygnal; pushed onto the
|
|
40
|
+
DataQueue / CSV logger when the processor is registered with
|
|
41
|
+
:meth:`DataManager.register_hardware_device`.
|
|
42
|
+
* ``self.valueUpdated(t, value)`` -- ``pyqtSignal(float, float)``;
|
|
43
|
+
Qt's cross-thread queued connection makes
|
|
44
|
+
:class:`~mesofield.gui.speedplotter.SerialWidget` safe to drive
|
|
45
|
+
from the worker thread.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
device_type: ClassVar[str] = "processor"
|
|
49
|
+
data_type: ClassVar[str] = "scalar"
|
|
50
|
+
file_type: ClassVar[str] = "csv"
|
|
51
|
+
bids_type: ClassVar[Optional[str]] = None
|
|
52
|
+
|
|
53
|
+
# Recognized SerialWidget styling kwargs collected into ``plot_config``.
|
|
54
|
+
_PLOT_KWARGS = (
|
|
55
|
+
"label",
|
|
56
|
+
"value_label",
|
|
57
|
+
"value_units",
|
|
58
|
+
"y_range",
|
|
59
|
+
"value_scale",
|
|
60
|
+
"max_points",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
name: Optional[str] = None,
|
|
66
|
+
camera: Optional["DataProducer"] = None,
|
|
67
|
+
sampling_rate: float = 0.0,
|
|
68
|
+
plot: bool = False,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> None:
|
|
71
|
+
# Default name = lowercased class name so ``FrameMean(camera=cam)`` works.
|
|
72
|
+
if name is None:
|
|
73
|
+
name = self.__class__.__name__.lower()
|
|
74
|
+
self.device_id: str = name
|
|
75
|
+
self.id: str = name
|
|
76
|
+
self.name: str = name
|
|
77
|
+
self.signals = DeviceSignals()
|
|
78
|
+
self.sampling_rate: float = (
|
|
79
|
+
sampling_rate
|
|
80
|
+
or float(getattr(camera, "sampling_rate", 0.0) or 0.0)
|
|
81
|
+
)
|
|
82
|
+
self.is_active: bool = False
|
|
83
|
+
self.output_path: Optional[str] = None
|
|
84
|
+
self.metadata_path: Optional[str] = None
|
|
85
|
+
self.logger = get_logger(
|
|
86
|
+
f"{self.__class__.__module__}.{self.__class__.__name__}[{self.device_id}]"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self._qt = _QtAdapter()
|
|
90
|
+
self.valueUpdated = self._qt.valueUpdated
|
|
91
|
+
|
|
92
|
+
self._queue: "queue.Queue[tuple]" = queue.Queue(maxsize=1)
|
|
93
|
+
self._worker: Optional[threading.Thread] = None
|
|
94
|
+
self._stop = threading.Event()
|
|
95
|
+
self._camera: Optional["DataProducer"] = None
|
|
96
|
+
# Remembered camera reference for no-arg attach() / status reporting.
|
|
97
|
+
self.camera: Optional["DataProducer"] = camera
|
|
98
|
+
|
|
99
|
+
# ---- compute-load counters (see ``status()``) ------------------
|
|
100
|
+
self.n_enqueued: int = 0
|
|
101
|
+
self.n_dropped: int = 0
|
|
102
|
+
self.n_processed: int = 0
|
|
103
|
+
self.n_errors: int = 0
|
|
104
|
+
self.compute_ms_ewma: float = 0.0
|
|
105
|
+
self.compute_ms_max: float = 0.0
|
|
106
|
+
self._ewma_alpha: float = 0.1
|
|
107
|
+
|
|
108
|
+
# ---- GUI plot wiring (opt-in) ----------------------------------
|
|
109
|
+
self.plot_enabled: bool = bool(plot)
|
|
110
|
+
self.plot_config: Dict[str, Any] = {
|
|
111
|
+
k: kwargs.pop(k) for k in list(kwargs) if k in self._PLOT_KWARGS
|
|
112
|
+
}
|
|
113
|
+
if kwargs:
|
|
114
|
+
# Unknown kwargs are usually a typo (e.g. ``y_lim`` for ``y_range``).
|
|
115
|
+
raise TypeError(
|
|
116
|
+
f"{type(self).__name__}: unexpected keyword argument(s) "
|
|
117
|
+
f"{sorted(kwargs)}. Recognized plot kwargs: {self._PLOT_KWARGS}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ---- auto-attach if a camera was supplied ----------------------
|
|
121
|
+
if camera is not None:
|
|
122
|
+
self.attach(camera)
|
|
123
|
+
|
|
124
|
+
# ---- subclass contract --------------------------------------------
|
|
125
|
+
def compute(self, img: Any, idx: Any, ts: Any) -> Optional[float]:
|
|
126
|
+
"""Subclass hook: return one scalar (or ``None``) per frame.
|
|
127
|
+
|
|
128
|
+
Called on every frame the attached camera emits. Implementations
|
|
129
|
+
should be fast — anything heavy will starve the camera buffer.
|
|
130
|
+
Return ``None`` to skip emitting a sample for this frame.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
img: Frame from the camera (typically a 2-D ``ndarray``).
|
|
134
|
+
idx: Frame index assigned by the camera (monotonic).
|
|
135
|
+
ts: Device timestamp for the frame.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A single ``float`` to be pushed to the data queue / plot, or
|
|
139
|
+
``None`` to skip this frame.
|
|
140
|
+
"""
|
|
141
|
+
raise NotImplementedError
|
|
142
|
+
|
|
143
|
+
# ---- compute-load reporting ----------------------------------------
|
|
144
|
+
def status(self) -> Dict[str, Any]:
|
|
145
|
+
"""Snapshot of the per-run compute-load counters."""
|
|
146
|
+
enq = self.n_enqueued or 1
|
|
147
|
+
return {
|
|
148
|
+
"device_id": self.device_id,
|
|
149
|
+
"n_enqueued": self.n_enqueued,
|
|
150
|
+
"n_processed": self.n_processed,
|
|
151
|
+
"n_dropped": self.n_dropped,
|
|
152
|
+
"n_errors": self.n_errors,
|
|
153
|
+
"drop_ratio": self.n_dropped / enq,
|
|
154
|
+
"compute_ms_ewma": round(self.compute_ms_ewma, 3),
|
|
155
|
+
"compute_ms_max": round(self.compute_ms_max, 3),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def _log_status(self) -> None:
|
|
159
|
+
s = self.status()
|
|
160
|
+
self.logger.info(
|
|
161
|
+
f"compute-load: processed={s['n_processed']} "
|
|
162
|
+
f"dropped={s['n_dropped']} ({s['drop_ratio']*100:.1f}%) "
|
|
163
|
+
f"compute_ms ewma={s['compute_ms_ewma']} max={s['compute_ms_max']} "
|
|
164
|
+
f"errors={s['n_errors']}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ---- public API ----------------------------------------------------
|
|
168
|
+
def attach(self, camera: Optional["DataProducer"] = None) -> None:
|
|
169
|
+
"""Attach to a camera's ``signals.frame`` and start the worker.
|
|
170
|
+
|
|
171
|
+
``camera`` defaults to whatever was passed at construction time
|
|
172
|
+
(``self.camera``); callers building processors first and attaching
|
|
173
|
+
later may pass it explicitly.
|
|
174
|
+
"""
|
|
175
|
+
if self._camera is not None:
|
|
176
|
+
return
|
|
177
|
+
if camera is None:
|
|
178
|
+
camera = self.camera
|
|
179
|
+
if camera is None:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{type(self).__name__}: no camera to attach to "
|
|
182
|
+
"(pass camera= to attach() or to the constructor)"
|
|
183
|
+
)
|
|
184
|
+
self._camera = camera
|
|
185
|
+
self.camera = camera
|
|
186
|
+
# Reset counters at the start of each attach session.
|
|
187
|
+
self.n_enqueued = 0
|
|
188
|
+
self.n_dropped = 0
|
|
189
|
+
self.n_processed = 0
|
|
190
|
+
self.n_errors = 0
|
|
191
|
+
self.compute_ms_ewma = 0.0
|
|
192
|
+
self.compute_ms_max = 0.0
|
|
193
|
+
self._stop.clear()
|
|
194
|
+
self._worker = threading.Thread(
|
|
195
|
+
target=self._loop,
|
|
196
|
+
name=f"FrameProcessor-{self.device_id}",
|
|
197
|
+
daemon=True,
|
|
198
|
+
)
|
|
199
|
+
self._worker.start()
|
|
200
|
+
self.is_active = True
|
|
201
|
+
try:
|
|
202
|
+
camera.signals.frame.connect(self._enqueue)
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
self.logger.warning(f"attach: signals.frame.connect failed: {exc}")
|
|
205
|
+
try:
|
|
206
|
+
camera.signals.finished.connect(self._log_status)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def detach(self) -> None:
|
|
211
|
+
cam = self._camera
|
|
212
|
+
if cam is not None:
|
|
213
|
+
try:
|
|
214
|
+
cam.signals.frame.disconnect(self._enqueue)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
cam.signals.finished.disconnect(self._log_status)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
self._camera = None
|
|
222
|
+
self._stop.set()
|
|
223
|
+
if self._worker is not None:
|
|
224
|
+
self._worker.join(timeout=1.0)
|
|
225
|
+
self._worker = None
|
|
226
|
+
self.is_active = False
|
|
227
|
+
|
|
228
|
+
# SerialWidget toggles call device.start()/device.stop().
|
|
229
|
+
def start(self) -> bool:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
def stop(self) -> bool:
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
# ---- internals -----------------------------------------------------
|
|
236
|
+
def _enqueue(self, img: Any, idx: Any, ts: Any) -> None:
|
|
237
|
+
self.n_enqueued += 1
|
|
238
|
+
try:
|
|
239
|
+
self._queue.put_nowait((img, idx, ts))
|
|
240
|
+
return
|
|
241
|
+
except queue.Full:
|
|
242
|
+
pass
|
|
243
|
+
# Drop the stale item so we always work on the freshest frame.
|
|
244
|
+
self.n_dropped += 1
|
|
245
|
+
try:
|
|
246
|
+
self._queue.get_nowait()
|
|
247
|
+
except queue.Empty:
|
|
248
|
+
pass
|
|
249
|
+
try:
|
|
250
|
+
self._queue.put_nowait((img, idx, ts))
|
|
251
|
+
except queue.Full:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
def _loop(self) -> None:
|
|
255
|
+
while not self._stop.is_set():
|
|
256
|
+
try:
|
|
257
|
+
img, idx, ts = self._queue.get(timeout=0.1)
|
|
258
|
+
except queue.Empty:
|
|
259
|
+
continue
|
|
260
|
+
t0 = time.perf_counter()
|
|
261
|
+
try:
|
|
262
|
+
value = self.compute(img, idx, ts)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
self.n_errors += 1
|
|
265
|
+
self.logger.warning(f"compute failed: {exc}")
|
|
266
|
+
continue
|
|
267
|
+
dt_ms = (time.perf_counter() - t0) * 1000.0
|
|
268
|
+
self.n_processed += 1
|
|
269
|
+
if dt_ms > self.compute_ms_max:
|
|
270
|
+
self.compute_ms_max = dt_ms
|
|
271
|
+
self.compute_ms_ewma = (
|
|
272
|
+
self._ewma_alpha * dt_ms
|
|
273
|
+
+ (1.0 - self._ewma_alpha) * self.compute_ms_ewma
|
|
274
|
+
)
|
|
275
|
+
if value is None:
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
self.signals.data.emit(value, ts)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
try:
|
|
282
|
+
self._qt.valueUpdated.emit(
|
|
283
|
+
float(ts) if ts is not None else 0.0,
|
|
284
|
+
float(value),
|
|
285
|
+
)
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Reference :class:`FrameProcessor` that emits per-frame mean intensity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .base import FrameProcessor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrameMean(FrameProcessor):
|
|
11
|
+
data_type = "frame_mean"
|
|
12
|
+
|
|
13
|
+
def compute(self, img: Any, idx: Any, ts: Any) -> Optional[float]:
|
|
14
|
+
if img is None:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
return float(img.mean())
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|