pymmcore-plus 0.14.0__py3-none-any.whl → 0.15.0__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.
- pymmcore_plus/_pymmcore.py +4 -2
- pymmcore_plus/core/_constants.py +21 -3
- pymmcore_plus/core/_mmcore_plus.py +96 -40
- pymmcore_plus/core/events/_protocol.py +49 -34
- pymmcore_plus/core/events/_psygnal.py +2 -2
- pymmcore_plus/experimental/unicore/__init__.py +7 -1
- pymmcore_plus/experimental/unicore/_proxy.py +20 -3
- pymmcore_plus/experimental/unicore/core/__init__.py +0 -0
- pymmcore_plus/experimental/unicore/core/_sequence_buffer.py +318 -0
- pymmcore_plus/experimental/unicore/core/_unicore.py +1702 -0
- pymmcore_plus/experimental/unicore/devices/_camera.py +196 -0
- pymmcore_plus/experimental/unicore/devices/_device.py +54 -28
- pymmcore_plus/experimental/unicore/devices/_properties.py +8 -1
- pymmcore_plus/experimental/unicore/devices/_slm.py +82 -0
- pymmcore_plus/experimental/unicore/devices/_state.py +152 -0
- pymmcore_plus/mda/events/_protocol.py +8 -8
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.0.dist-info}/METADATA +1 -1
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.0.dist-info}/RECORD +21 -16
- pymmcore_plus/experimental/unicore/_unicore.py +0 -703
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.0.dist-info}/WHEEL +0 -0
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.0.dist-info}/entry_points.txt +0 -0
- {pymmcore_plus-0.14.0.dist-info → pymmcore_plus-0.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""High-throughput, zero-copy ring buffer for camera image streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
from numpy.typing import DTypeLike, NDArray
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BufferSlot(NamedTuple):
|
|
18
|
+
"""Record describing one frame held in the pool."""
|
|
19
|
+
|
|
20
|
+
array: NDArray[Any]
|
|
21
|
+
metadata: Mapping[str, Any] | None # metadata associated with this frame
|
|
22
|
+
nbytes_total: int # full span in the pool (data + padding)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SequenceBuffer:
|
|
26
|
+
"""A lock-protected circular buffer backed by a single numpy byte array.
|
|
27
|
+
|
|
28
|
+
This buffer is designed for a single device. If you want to stream data from
|
|
29
|
+
multiple devices, it is recommended to use a separate `SequenceBuffer` for each
|
|
30
|
+
device (rather than to pack two streams into a single FIFO buffer).
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
size_mb:
|
|
35
|
+
Pool capacity in **mebibytes** (binary - 1 MiB = 1,048,576 bytes).
|
|
36
|
+
overwrite_on_overflow:
|
|
37
|
+
When `True` (default) the oldest frame will be discarded to make space.
|
|
38
|
+
When `False`, an attempt to acquire a slot that does not fit raises
|
|
39
|
+
`BufferError`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, size_mb: float, *, overwrite_on_overflow: bool = True) -> None:
|
|
43
|
+
self._size_bytes: int = int(size_mb * 1024 * 1024)
|
|
44
|
+
self._pool: NDArray[np.uint8] = np.empty(self._size_bytes, dtype=np.uint8)
|
|
45
|
+
|
|
46
|
+
# ring indices (bytes)
|
|
47
|
+
self._head: int = 0 # next write offset
|
|
48
|
+
self._tail: int = 0 # oldest frame offset
|
|
49
|
+
self._bytes_in_use: int = 0 # live bytes (includes padding)
|
|
50
|
+
|
|
51
|
+
# active frames in FIFO order (BufferSlot objects)
|
|
52
|
+
self._slots: deque[BufferSlot] = deque()
|
|
53
|
+
|
|
54
|
+
self._overwrite_on_overflow: bool = overwrite_on_overflow
|
|
55
|
+
self._overflow_occurred: bool = False
|
|
56
|
+
|
|
57
|
+
self._lock = threading.Lock() # not re-entrant, but slightly faster than RLock
|
|
58
|
+
self._pending_slot: tuple[NDArray, int] | None = None # only 1 outstanding slot
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------
|
|
61
|
+
# Producer API - acquire a slot, fill it, finalize it
|
|
62
|
+
# ---------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def acquire_slot(
|
|
65
|
+
self, shape: Sequence[int], dtype: DTypeLike = np.uint8
|
|
66
|
+
) -> NDArray[Any]:
|
|
67
|
+
"""Return a **writable** view into the internal pool.
|
|
68
|
+
|
|
69
|
+
After filling the array, *must* call `finalize_slot`.
|
|
70
|
+
"""
|
|
71
|
+
dtype_ = np.dtype(dtype)
|
|
72
|
+
nbytes_data = int(np.prod(shape, dtype=int) * dtype_.itemsize)
|
|
73
|
+
|
|
74
|
+
# ---------- NEW: explicit capacity check ---------------------------
|
|
75
|
+
if nbytes_data > self._size_bytes:
|
|
76
|
+
msg = (
|
|
77
|
+
f"Requested size ({nbytes_data} bytes) exceeds buffer capacity "
|
|
78
|
+
f"({self.size_mb} MiB)."
|
|
79
|
+
)
|
|
80
|
+
raise BufferError(msg)
|
|
81
|
+
|
|
82
|
+
# --- reserve space -------------------------------------------------
|
|
83
|
+
|
|
84
|
+
with self._lock:
|
|
85
|
+
if self._pending_slot is not None:
|
|
86
|
+
msg = "Cannot acquire a new slot before finalizing the pending one."
|
|
87
|
+
raise RuntimeError(msg)
|
|
88
|
+
|
|
89
|
+
# Calculate padding needed to align start address to dtype boundary
|
|
90
|
+
align_pad = (-self._head) % dtype_.itemsize
|
|
91
|
+
needed = nbytes_data + align_pad
|
|
92
|
+
|
|
93
|
+
# ensure capacity (may evict oldest frames if overwrite enabled)
|
|
94
|
+
self._ensure_space(needed)
|
|
95
|
+
|
|
96
|
+
# alignment may force wrapping to 0 => recompute alignment
|
|
97
|
+
if needed > self._contiguous_free_bytes:
|
|
98
|
+
# wrap head to start of buffer for contiguous allocation
|
|
99
|
+
self._head = 0
|
|
100
|
+
align_pad = 0 # new head is already aligned to buffer start
|
|
101
|
+
needed = nbytes_data # recalculated without padding
|
|
102
|
+
self._ensure_space(needed) # guaranteed to succeed after wrap
|
|
103
|
+
|
|
104
|
+
# Calculate actual start position after alignment padding
|
|
105
|
+
start = self._head + align_pad
|
|
106
|
+
# Advance head pointer past this allocation (with wraparound)
|
|
107
|
+
self._head = (start + nbytes_data) % self._size_bytes
|
|
108
|
+
# Track total bytes consumed (data + alignment padding)
|
|
109
|
+
self._bytes_in_use += needed
|
|
110
|
+
|
|
111
|
+
# Create zero-copy view into the pool buffer at calculated offset
|
|
112
|
+
arr: NDArray[Any] = np.ndarray(
|
|
113
|
+
shape, dtype_, buffer=self._pool, offset=start
|
|
114
|
+
)
|
|
115
|
+
self._pending_slot = (arr, needed)
|
|
116
|
+
return arr
|
|
117
|
+
|
|
118
|
+
def finalize_slot(self, metadata: Mapping[str, Any] | None = None) -> None:
|
|
119
|
+
"""Publish the frame acquired via `acquire_write_slot`.
|
|
120
|
+
|
|
121
|
+
This makes the frame available for retrieval via `pop_next` or
|
|
122
|
+
`peek_last`. If `metadata` is provided, it will be merged into the
|
|
123
|
+
slot's metadata dictionary.
|
|
124
|
+
"""
|
|
125
|
+
with self._lock:
|
|
126
|
+
if (slot := self._pending_slot) is None:
|
|
127
|
+
msg = "No pending slot to finalize"
|
|
128
|
+
raise RuntimeError(msg)
|
|
129
|
+
|
|
130
|
+
self._pending_slot = None
|
|
131
|
+
arr, nbytes_total = slot
|
|
132
|
+
self._slots.append(BufferSlot(arr, metadata, nbytes_total))
|
|
133
|
+
|
|
134
|
+
# Convenience: copy-in one-shot insert ------------------------------
|
|
135
|
+
|
|
136
|
+
def insert_data(
|
|
137
|
+
self, data: NDArray[Any], metadata: Mapping[str, Any] | None = None
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Insert data into the buffer, overwriting any existing frame.
|
|
140
|
+
|
|
141
|
+
This is a convenience method that acquires a slot, copies the data, and
|
|
142
|
+
finalizes the slot in one go.
|
|
143
|
+
|
|
144
|
+
For users who *can* write directly into our buffer, they should use
|
|
145
|
+
`acquire_slot` and `finalize_slot`. `insert_data` is for when the data already
|
|
146
|
+
exists in another NumPy array.
|
|
147
|
+
"""
|
|
148
|
+
self.acquire_slot(data.shape, data.dtype)[:] = data
|
|
149
|
+
self.finalize_slot(metadata)
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Consumer API - pop frames, peek at frames
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def pop_next(
|
|
156
|
+
self, *, copy: bool = False
|
|
157
|
+
) -> tuple[NDArray[Any], Mapping[str, Any]] | None:
|
|
158
|
+
"""Remove and return the oldest frame.
|
|
159
|
+
|
|
160
|
+
If `copy` is `True`, a copy of the data is returned, otherwise a read-only
|
|
161
|
+
view into the internal buffer is returned. The metadata is always returned
|
|
162
|
+
as a copy to prevent external modification.
|
|
163
|
+
"""
|
|
164
|
+
with self._lock:
|
|
165
|
+
if not self._slots:
|
|
166
|
+
return None
|
|
167
|
+
slot = self._slots.popleft()
|
|
168
|
+
self._evict_slot(slot)
|
|
169
|
+
|
|
170
|
+
if copy:
|
|
171
|
+
arr = slot.array.copy()
|
|
172
|
+
else:
|
|
173
|
+
# even though we're popping, we still return a read-only view since
|
|
174
|
+
# the caller should be able to modify our _pool directly.
|
|
175
|
+
arr = slot.array.view()
|
|
176
|
+
arr.flags.writeable = False
|
|
177
|
+
|
|
178
|
+
# return actual metadata, we're done with it.
|
|
179
|
+
return arr, (slot.metadata or {})
|
|
180
|
+
|
|
181
|
+
def peek_last(
|
|
182
|
+
self, *, copy: bool = False
|
|
183
|
+
) -> tuple[NDArray[Any], Mapping[str, Any]] | None:
|
|
184
|
+
"""Return the newest frame without removing it."""
|
|
185
|
+
return self.peek_nth_from_last(0, copy=copy)
|
|
186
|
+
|
|
187
|
+
def peek_nth_from_last(
|
|
188
|
+
self, n: int, *, copy: bool = False
|
|
189
|
+
) -> tuple[NDArray[Any], dict[str, Any]] | None:
|
|
190
|
+
"""Return the n-th most recent frame without removing it.
|
|
191
|
+
|
|
192
|
+
Last frame is n=0, second to last is n=1, etc.
|
|
193
|
+
"""
|
|
194
|
+
with self._lock:
|
|
195
|
+
if n < 0 or n >= len(self._slots):
|
|
196
|
+
return None
|
|
197
|
+
slot = self._slots[-(n + 1)]
|
|
198
|
+
|
|
199
|
+
if copy:
|
|
200
|
+
arr = slot.array.copy()
|
|
201
|
+
else:
|
|
202
|
+
# Return a read-only view to prevent modification of data in the buffer
|
|
203
|
+
arr = slot.array.view()
|
|
204
|
+
arr.flags.writeable = False
|
|
205
|
+
|
|
206
|
+
# Return a copy of the metadata to avoid external modification
|
|
207
|
+
return arr, (dict(slot.metadata) if slot.metadata else {})
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Administrative helpers
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def clear(self) -> None:
|
|
214
|
+
with self._lock:
|
|
215
|
+
self._slots.clear()
|
|
216
|
+
self._pending_slot = None
|
|
217
|
+
self._head = self._tail = self._bytes_in_use = 0
|
|
218
|
+
self._overflow_occurred = False
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Properties
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def size_mb(self) -> float: # human readable
|
|
226
|
+
return self._size_bytes / 1024**2
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def size_bytes(self) -> int:
|
|
230
|
+
"""Return the buffer size in bytes."""
|
|
231
|
+
return self._size_bytes
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def used_bytes(self) -> int:
|
|
235
|
+
"""Return the number of bytes currently in use."""
|
|
236
|
+
return self._bytes_in_use
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def free_bytes(self) -> int:
|
|
240
|
+
"""Return the number of free bytes in the buffer."""
|
|
241
|
+
return self._size_bytes - self._bytes_in_use
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def free_mb(self) -> float:
|
|
245
|
+
"""Get the free space in the buffer in mebibytes."""
|
|
246
|
+
return self.free_bytes / 1024**2
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def overwrite_on_overflow(self) -> bool:
|
|
250
|
+
"""Return the overflow policy (immutable while data is present)."""
|
|
251
|
+
return self._overwrite_on_overflow
|
|
252
|
+
|
|
253
|
+
@overwrite_on_overflow.setter
|
|
254
|
+
def overwrite_on_overflow(self, value: bool) -> None:
|
|
255
|
+
"""Set the overflow policy (immutable while data is present)."""
|
|
256
|
+
with self._lock:
|
|
257
|
+
if self._bytes_in_use > 0:
|
|
258
|
+
msg = "Cannot change overflow policy with active data in buffer."
|
|
259
|
+
raise RuntimeError(msg)
|
|
260
|
+
self._overwrite_on_overflow = value
|
|
261
|
+
|
|
262
|
+
def __len__(self) -> int:
|
|
263
|
+
"""Return the number of frames currently in the buffer."""
|
|
264
|
+
return len(self._slots)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def overflow_occurred(self) -> bool:
|
|
268
|
+
"""Return whether an overflow occurred since the last clear."""
|
|
269
|
+
return self._overflow_occurred
|
|
270
|
+
|
|
271
|
+
def __repr__(self) -> str:
|
|
272
|
+
used_mb = self.used_bytes / 1024**2
|
|
273
|
+
name = self.__class__.__name__
|
|
274
|
+
return (
|
|
275
|
+
f"{name}(size_mb={self.size_mb:.1f}, slots={len(self)}, "
|
|
276
|
+
f"used_mb={used_mb:.1f}, overwrite={self._overwrite_on_overflow})"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
# Internal helpers
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def _evict_slot(self, slot: BufferSlot) -> None:
|
|
284
|
+
"""Advance the tail pointer past `slot`, updating house-keeping."""
|
|
285
|
+
self._tail = (self._tail + slot.nbytes_total) % self._size_bytes
|
|
286
|
+
self._bytes_in_use -= slot.nbytes_total
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def _contiguous_free_bytes(self) -> int:
|
|
290
|
+
"""Get the number of contiguous free bytes in the buffer."""
|
|
291
|
+
if self._bytes_in_use >= self._size_bytes:
|
|
292
|
+
return 0
|
|
293
|
+
if self._bytes_in_use == 0:
|
|
294
|
+
return self._size_bytes
|
|
295
|
+
if self._head >= self._tail:
|
|
296
|
+
return self._size_bytes - self._head
|
|
297
|
+
# it's very hard to make this case happen...
|
|
298
|
+
return self._tail - self._head # pragma: no cover
|
|
299
|
+
|
|
300
|
+
def _ensure_space(self, needed: int) -> None:
|
|
301
|
+
"""Ensure `needed` bytes contiguous space is available."""
|
|
302
|
+
while self._contiguous_free_bytes < needed:
|
|
303
|
+
if not self._slots:
|
|
304
|
+
# Buffer empty but fragmented: just reset pointers
|
|
305
|
+
self._head = self._tail = self._bytes_in_use = 0
|
|
306
|
+
break
|
|
307
|
+
if not self._overwrite_on_overflow:
|
|
308
|
+
self._overflow_occurred = True
|
|
309
|
+
msg = "Buffer is full and overwrite is disabled."
|
|
310
|
+
raise BufferError(msg)
|
|
311
|
+
self._overflow_occurred = True
|
|
312
|
+
while self._slots and self._contiguous_free_bytes < needed:
|
|
313
|
+
slot = self._slots.popleft()
|
|
314
|
+
self._evict_slot(slot)
|
|
315
|
+
|
|
316
|
+
# If the buffer is now empty, reset head/tail to maximise contiguous space.
|
|
317
|
+
if self._bytes_in_use == 0:
|
|
318
|
+
self._head = self._tail = 0
|