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