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.
@@ -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