framesource 0.3.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.
- frame_processors/__init__.py +37 -0
- frame_source/__init__.py +50 -0
- framesource/__init__.py +93 -0
- framesource/_msmf_config.py +26 -0
- framesource/_version.py +24 -0
- framesource/discovery.py +109 -0
- framesource/errors.py +82 -0
- framesource/factory.py +290 -0
- framesource/processors/__init__.py +34 -0
- framesource/processors/equirectangular360_processor.py +577 -0
- framesource/processors/fisheye2equirectangular_processor.py +328 -0
- framesource/processors/frame_processor.py +30 -0
- framesource/processors/hyperspectral_processor.py +23 -0
- framesource/processors/realsense_depth_processor.py +90 -0
- framesource/py.typed +0 -0
- framesource/sources/__init__.py +40 -0
- framesource/sources/audiospectrogram_capture.py +1068 -0
- framesource/sources/basler_capture.py +477 -0
- framesource/sources/folder_capture.py +920 -0
- framesource/sources/genicam_capture.py +681 -0
- framesource/sources/huateng_capture.py +245 -0
- framesource/sources/ipcamera_capture.py +254 -0
- framesource/sources/mvsdk.py +2454 -0
- framesource/sources/realsense_capture.py +565 -0
- framesource/sources/screen_capture.py +800 -0
- framesource/sources/video_capture_base.py +560 -0
- framesource/sources/video_file_capture.py +259 -0
- framesource/sources/webcam_capture.py +511 -0
- framesource/sources/ximea_capture.py +299 -0
- framesource/threading_utils.py +790 -0
- framesource-0.3.0.dist-info/METADATA +787 -0
- framesource-0.3.0.dist-info/RECORD +37 -0
- framesource-0.3.0.dist-info/WHEEL +5 -0
- framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
- framesource-0.3.0.dist-info/scm_file_list.json +276 -0
- framesource-0.3.0.dist-info/scm_version.json +8 -0
- framesource-0.3.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Threading utilities for FrameSource library.
|
|
3
|
+
|
|
4
|
+
This module provides helper functions and classes for implementing
|
|
5
|
+
external threading patterns with FrameSource capture sources.
|
|
6
|
+
|
|
7
|
+
These utilities demonstrate best practices for:
|
|
8
|
+
- Producer-consumer patterns
|
|
9
|
+
- Thread-safe frame handling
|
|
10
|
+
- Queue-based communication
|
|
11
|
+
- Race condition avoidance
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import queue
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from .sources.video_capture_base import FrameSourceProtocol
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Drop rate above this fraction triggers a warning (queue can't keep up).
|
|
29
|
+
_HIGH_DROP_RATE_THRESHOLD = 0.1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def simple_frame_producer(
|
|
33
|
+
capture_source: FrameSourceProtocol,
|
|
34
|
+
frame_queue: queue.Queue,
|
|
35
|
+
stop_event: threading.Event,
|
|
36
|
+
target_fps: Optional[float] = None,
|
|
37
|
+
stats: Optional[dict] = None,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Simple producer function that runs in a thread.
|
|
41
|
+
|
|
42
|
+
This demonstrates the recommended approach for external threading with FrameSource.
|
|
43
|
+
The producer connects to a capture source, reads frames synchronously, and puts
|
|
44
|
+
them in a thread-safe queue for consumers.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
capture_source: Any FrameSource capture object (WebcamCapture, RealsenseCapture, etc.)
|
|
48
|
+
frame_queue: Thread-safe queue to put frames into
|
|
49
|
+
stop_event: Threading event to signal when to stop
|
|
50
|
+
target_fps: Optional target frame rate (None for unlimited)
|
|
51
|
+
stats: Optional dict updated in-place with running statistics
|
|
52
|
+
(frames_captured, frames_dropped, _latency_sum, _latency_count)
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
```python
|
|
56
|
+
from framesource import WebcamCapture
|
|
57
|
+
from framesource.threading_utils import simple_frame_producer
|
|
58
|
+
import queue
|
|
59
|
+
import threading
|
|
60
|
+
|
|
61
|
+
camera = WebcamCapture(source=0)
|
|
62
|
+
frame_queue = queue.Queue(maxsize=10)
|
|
63
|
+
stop_event = threading.Event()
|
|
64
|
+
|
|
65
|
+
producer_thread = threading.Thread(
|
|
66
|
+
target=simple_frame_producer,
|
|
67
|
+
args=(camera, frame_queue, stop_event, 30), # 30 FPS
|
|
68
|
+
daemon=True
|
|
69
|
+
)
|
|
70
|
+
producer_thread.start()
|
|
71
|
+
|
|
72
|
+
# Consumer loop
|
|
73
|
+
while True:
|
|
74
|
+
success, frame = frame_queue.get()
|
|
75
|
+
if success:
|
|
76
|
+
process_frame(frame)
|
|
77
|
+
```
|
|
78
|
+
"""
|
|
79
|
+
frame_delay = 1.0 / target_fps if target_fps else 0.0
|
|
80
|
+
frames_captured = 0
|
|
81
|
+
frames_dropped = 0
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
logger.info("Producer: connecting to source...")
|
|
85
|
+
if not capture_source.connect():
|
|
86
|
+
logger.error("Producer: failed to connect to source")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
logger.info("Producer: connected successfully")
|
|
90
|
+
|
|
91
|
+
while not stop_event.is_set():
|
|
92
|
+
start_time = time.time()
|
|
93
|
+
|
|
94
|
+
# The key insight: just a simple, synchronous read
|
|
95
|
+
success, frame = capture_source.read()
|
|
96
|
+
|
|
97
|
+
if success and frame is not None:
|
|
98
|
+
try:
|
|
99
|
+
# Put frame in queue (non-blocking)
|
|
100
|
+
frame_queue.put((success, frame), block=False)
|
|
101
|
+
frames_captured += 1
|
|
102
|
+
if stats is not None:
|
|
103
|
+
stats["frames_captured"] = frames_captured
|
|
104
|
+
# Latency from when the frame was stamped to now.
|
|
105
|
+
timestamp = getattr(frame, "timestamp", None)
|
|
106
|
+
if timestamp is not None:
|
|
107
|
+
stats["_latency_sum"] = stats.get("_latency_sum", 0.0) + (
|
|
108
|
+
time.time() - timestamp
|
|
109
|
+
)
|
|
110
|
+
stats["_latency_count"] = stats.get("_latency_count", 0) + 1
|
|
111
|
+
except queue.Full:
|
|
112
|
+
# Drop frame if queue is full - no race condition!
|
|
113
|
+
frames_dropped += 1
|
|
114
|
+
if stats is not None:
|
|
115
|
+
stats["frames_dropped"] = frames_dropped
|
|
116
|
+
|
|
117
|
+
# Control frame rate if specified
|
|
118
|
+
if frame_delay > 0:
|
|
119
|
+
elapsed = time.time() - start_time
|
|
120
|
+
sleep_time = max(0, frame_delay - elapsed)
|
|
121
|
+
if sleep_time > 0:
|
|
122
|
+
time.sleep(sleep_time)
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.exception("Producer error: %s", e)
|
|
126
|
+
if stats is not None:
|
|
127
|
+
stats["errors"] = stats.get("errors", 0) + 1
|
|
128
|
+
finally:
|
|
129
|
+
capture_source.disconnect()
|
|
130
|
+
total = frames_captured + frames_dropped
|
|
131
|
+
if total > 0 and (frames_dropped / total) > _HIGH_DROP_RATE_THRESHOLD:
|
|
132
|
+
logger.warning(
|
|
133
|
+
"Producer: high drop rate %.1f%% (%d/%d) - consumer cannot keep up; "
|
|
134
|
+
"increase queue size or processing speed.",
|
|
135
|
+
100.0 * frames_dropped / total,
|
|
136
|
+
frames_dropped,
|
|
137
|
+
total,
|
|
138
|
+
)
|
|
139
|
+
logger.info("Producer: captured %d frames, dropped %d", frames_captured, frames_dropped)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class FrameProducer:
|
|
143
|
+
"""
|
|
144
|
+
A more sophisticated frame producer class with statistics and control.
|
|
145
|
+
|
|
146
|
+
This class wraps a capture source and provides thread-safe frame production
|
|
147
|
+
with built-in statistics, error handling, and flexible configuration.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
```python
|
|
151
|
+
from framesource import WebcamCapture
|
|
152
|
+
from framesource.threading_utils import FrameProducer
|
|
153
|
+
|
|
154
|
+
camera = WebcamCapture(source=0)
|
|
155
|
+
producer = FrameProducer(camera, max_queue_size=10, target_fps=30)
|
|
156
|
+
|
|
157
|
+
producer.start()
|
|
158
|
+
|
|
159
|
+
# Get frames
|
|
160
|
+
while True:
|
|
161
|
+
success, frame = producer.get_frame(timeout=0.1)
|
|
162
|
+
if success:
|
|
163
|
+
process_frame(frame)
|
|
164
|
+
|
|
165
|
+
producer.stop()
|
|
166
|
+
```
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
capture_source: FrameSourceProtocol,
|
|
172
|
+
max_queue_size: int = 10,
|
|
173
|
+
target_fps: Optional[float] = None,
|
|
174
|
+
):
|
|
175
|
+
"""
|
|
176
|
+
Initialize the frame producer.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
capture_source: FrameSource capture object
|
|
180
|
+
max_queue_size: Maximum size of the frame queue
|
|
181
|
+
target_fps: Target frame rate (None for unlimited)
|
|
182
|
+
"""
|
|
183
|
+
self.capture_source = capture_source
|
|
184
|
+
self.max_queue_size = max_queue_size
|
|
185
|
+
self.target_fps = target_fps
|
|
186
|
+
|
|
187
|
+
self._frame_queue = None
|
|
188
|
+
self._stop_event = None
|
|
189
|
+
self._producer_thread = None
|
|
190
|
+
|
|
191
|
+
self._stats = {
|
|
192
|
+
"frames_captured": 0,
|
|
193
|
+
"frames_dropped": 0,
|
|
194
|
+
"errors": 0,
|
|
195
|
+
"start_time": None,
|
|
196
|
+
"_latency_sum": 0.0,
|
|
197
|
+
"_latency_count": 0,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def start(self):
|
|
201
|
+
"""Start the frame producer thread."""
|
|
202
|
+
if self._producer_thread and self._producer_thread.is_alive():
|
|
203
|
+
logger.warning("Producer already running")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
self._frame_queue = queue.Queue(maxsize=self.max_queue_size)
|
|
207
|
+
self._stop_event = threading.Event()
|
|
208
|
+
self._stats["start_time"] = time.time()
|
|
209
|
+
|
|
210
|
+
self._producer_thread = threading.Thread(target=self._producer_loop, daemon=True)
|
|
211
|
+
self._producer_thread.start()
|
|
212
|
+
logger.info(
|
|
213
|
+
"Started frame producer (queue_size=%s, fps=%s)", self.max_queue_size, self.target_fps
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _producer_loop(self):
|
|
217
|
+
"""Internal producer loop."""
|
|
218
|
+
if self._frame_queue is not None and self._stop_event is not None:
|
|
219
|
+
simple_frame_producer(
|
|
220
|
+
self.capture_source,
|
|
221
|
+
self._frame_queue,
|
|
222
|
+
self._stop_event,
|
|
223
|
+
self.target_fps,
|
|
224
|
+
self._stats,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def get_frame(self, timeout: float = 0.1) -> tuple[bool, Optional[np.ndarray]]:
|
|
228
|
+
"""
|
|
229
|
+
Get the next frame from the producer queue.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
timeout: Maximum time to wait for a frame
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple[bool, Optional[np.ndarray]]: (success, frame)
|
|
236
|
+
"""
|
|
237
|
+
if not self._frame_queue:
|
|
238
|
+
return False, None
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
return self._frame_queue.get(timeout=timeout)
|
|
242
|
+
except queue.Empty:
|
|
243
|
+
return False, None
|
|
244
|
+
|
|
245
|
+
def stop(self):
|
|
246
|
+
"""Stop the producer thread."""
|
|
247
|
+
if self._stop_event:
|
|
248
|
+
self._stop_event.set()
|
|
249
|
+
if self._producer_thread:
|
|
250
|
+
self._producer_thread.join(timeout=2)
|
|
251
|
+
|
|
252
|
+
self._print_stats()
|
|
253
|
+
|
|
254
|
+
def get_stats(self) -> dict:
|
|
255
|
+
"""Get producer statistics.
|
|
256
|
+
|
|
257
|
+
Returns a dict with ``frames_captured``, ``frames_dropped``, ``errors``,
|
|
258
|
+
``runtime``, ``fps`` and ``avg_latency`` (mean seconds between a frame
|
|
259
|
+
being stamped and enqueued). Internal accumulator keys (prefixed with
|
|
260
|
+
``_``) are omitted.
|
|
261
|
+
"""
|
|
262
|
+
stats = self._stats.copy()
|
|
263
|
+
latency_sum = stats.pop("_latency_sum", 0.0)
|
|
264
|
+
latency_count = stats.pop("_latency_count", 0)
|
|
265
|
+
stats["avg_latency"] = (latency_sum / latency_count) if latency_count else None
|
|
266
|
+
if stats["start_time"]:
|
|
267
|
+
stats["runtime"] = time.time() - stats["start_time"]
|
|
268
|
+
if stats["runtime"] > 0:
|
|
269
|
+
stats["fps"] = stats["frames_captured"] / stats["runtime"]
|
|
270
|
+
return stats
|
|
271
|
+
|
|
272
|
+
def _print_stats(self):
|
|
273
|
+
"""Log producer statistics."""
|
|
274
|
+
stats = self.get_stats()
|
|
275
|
+
logger.info("Producer stopped. Stats: %s", stats)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def multiprocess_frame_producer(source_config: dict, frame_queue, stop_event):
|
|
279
|
+
"""
|
|
280
|
+
Producer function for multiprocessing (bypasses GIL).
|
|
281
|
+
|
|
282
|
+
This function runs in a separate process and communicates via
|
|
283
|
+
multiprocessing queues and events.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
source_config: Dictionary with capture source configuration
|
|
287
|
+
frame_queue: multiprocessing.Queue for frames
|
|
288
|
+
stop_event: multiprocessing.Event to signal stop
|
|
289
|
+
|
|
290
|
+
Example:
|
|
291
|
+
```python
|
|
292
|
+
import multiprocessing
|
|
293
|
+
from framesource.threading_utils import multiprocess_frame_producer
|
|
294
|
+
|
|
295
|
+
source_config = {'source_id': 0, 'width': 640, 'height': 480}
|
|
296
|
+
frame_queue = multiprocessing.Queue(maxsize=10)
|
|
297
|
+
stop_event = multiprocessing.Event()
|
|
298
|
+
|
|
299
|
+
producer_process = multiprocessing.Process(
|
|
300
|
+
target=multiprocess_frame_producer,
|
|
301
|
+
args=(source_config, frame_queue, stop_event)
|
|
302
|
+
)
|
|
303
|
+
producer_process.start()
|
|
304
|
+
|
|
305
|
+
# Consumer in main process
|
|
306
|
+
while True:
|
|
307
|
+
success, frame = frame_queue.get()
|
|
308
|
+
if success:
|
|
309
|
+
process_frame(frame)
|
|
310
|
+
```
|
|
311
|
+
"""
|
|
312
|
+
# Import here to avoid issues with multiprocessing
|
|
313
|
+
from .factory import FrameSourceFactory
|
|
314
|
+
|
|
315
|
+
source = None
|
|
316
|
+
frames_sent = 0
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
# Create capture source in the new process
|
|
320
|
+
source = FrameSourceFactory.create(**source_config)
|
|
321
|
+
|
|
322
|
+
if not source.connect():
|
|
323
|
+
logger.error("Multiprocess producer: failed to connect")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
logger.info("Multiprocess producer: connected")
|
|
327
|
+
|
|
328
|
+
while not stop_event.is_set():
|
|
329
|
+
success, frame = source.read() # Simple synchronous call
|
|
330
|
+
|
|
331
|
+
if success and frame is not None:
|
|
332
|
+
try:
|
|
333
|
+
# Send frame to main process
|
|
334
|
+
frame_queue.put((success, frame), timeout=0.1)
|
|
335
|
+
frames_sent += 1
|
|
336
|
+
except queue.Full:
|
|
337
|
+
pass # Queue full, drop frame
|
|
338
|
+
|
|
339
|
+
time.sleep(0.01) # ~100 FPS max
|
|
340
|
+
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.exception("Multiprocess producer error: %s", e)
|
|
343
|
+
finally:
|
|
344
|
+
if source is not None:
|
|
345
|
+
source.disconnect()
|
|
346
|
+
logger.info("Multiprocess producer finished. Frames sent: %s", frames_sent)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ProducerConsumer:
|
|
350
|
+
"""Explicit producer/consumer pair with a handle to both threads.
|
|
351
|
+
|
|
352
|
+
A single daemon producer thread reads frames from a capture source into a
|
|
353
|
+
bounded queue, while a daemon consumer thread pulls each ``(success, frame)``
|
|
354
|
+
tuple off the queue and passes it to ``consumer_function``. Unlike the
|
|
355
|
+
convenience :func:`create_producer_consumer_pair` (which only hands back the
|
|
356
|
+
producer thread), this class exposes *both* threads plus the shared
|
|
357
|
+
:attr:`stop_event`, so callers can join and shut the pair down cleanly.
|
|
358
|
+
|
|
359
|
+
Concurrency is entirely opt-in: constructing the object starts nothing. Call
|
|
360
|
+
:meth:`start` explicitly, or use the object as a context manager (which
|
|
361
|
+
starts on entry and stops on exit).
|
|
362
|
+
|
|
363
|
+
Attributes:
|
|
364
|
+
producer_thread (Optional[threading.Thread]): The producer thread, or
|
|
365
|
+
None before :meth:`start` has been called.
|
|
366
|
+
consumer_thread (Optional[threading.Thread]): The consumer thread, or
|
|
367
|
+
None before :meth:`start` has been called.
|
|
368
|
+
stop_event (threading.Event): Set to signal both threads to stop.
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
```python
|
|
372
|
+
from framesource import WebcamCapture
|
|
373
|
+
from framesource.threading_utils import ProducerConsumer
|
|
374
|
+
|
|
375
|
+
def my_processor(success, frame):
|
|
376
|
+
if success:
|
|
377
|
+
cv2.imshow("Frame", frame)
|
|
378
|
+
cv2.waitKey(1)
|
|
379
|
+
|
|
380
|
+
camera = WebcamCapture(source=0)
|
|
381
|
+
with ProducerConsumer(camera, my_processor, target_fps=30) as pc:
|
|
382
|
+
time.sleep(10) # threads run; __exit__ stops and joins them
|
|
383
|
+
```
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def __init__(
|
|
387
|
+
self,
|
|
388
|
+
capture_source: FrameSourceProtocol,
|
|
389
|
+
consumer_function: Callable,
|
|
390
|
+
max_queue_size: int = 10,
|
|
391
|
+
target_fps: Optional[float] = None,
|
|
392
|
+
):
|
|
393
|
+
"""Initialize the producer/consumer pair (does not start any thread).
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
capture_source: FrameSource capture object to read from.
|
|
397
|
+
consumer_function: Callable invoked as ``consumer_function(success,
|
|
398
|
+
frame)`` for each frame pulled off the queue.
|
|
399
|
+
max_queue_size: Maximum size of the frame queue.
|
|
400
|
+
target_fps: Target frame rate for the producer (None = unlimited).
|
|
401
|
+
"""
|
|
402
|
+
self.capture_source = capture_source
|
|
403
|
+
self.consumer_function = consumer_function
|
|
404
|
+
self.max_queue_size = max_queue_size
|
|
405
|
+
self.target_fps = target_fps
|
|
406
|
+
|
|
407
|
+
self._frame_queue: Optional[queue.Queue] = None
|
|
408
|
+
self.stop_event = threading.Event()
|
|
409
|
+
self.producer_thread: Optional[threading.Thread] = None
|
|
410
|
+
self.consumer_thread: Optional[threading.Thread] = None
|
|
411
|
+
|
|
412
|
+
def _consumer_loop(self) -> None:
|
|
413
|
+
"""Internal consumer loop: pull frames and dispatch to the callback."""
|
|
414
|
+
assert self._frame_queue is not None
|
|
415
|
+
while not self.stop_event.is_set():
|
|
416
|
+
try:
|
|
417
|
+
success, frame = self._frame_queue.get(timeout=0.1)
|
|
418
|
+
self.consumer_function(success, frame)
|
|
419
|
+
except queue.Empty:
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
def start(self) -> None:
|
|
423
|
+
"""Start (or restart) the producer and consumer threads.
|
|
424
|
+
|
|
425
|
+
No-op with a warning if the pair is already running. A fresh queue and
|
|
426
|
+
:attr:`stop_event` are created on each start so the pair can be reused
|
|
427
|
+
after :meth:`stop`.
|
|
428
|
+
"""
|
|
429
|
+
if self.producer_thread and self.producer_thread.is_alive():
|
|
430
|
+
logger.warning("ProducerConsumer already running")
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
self._frame_queue = queue.Queue(maxsize=self.max_queue_size)
|
|
434
|
+
self.stop_event = threading.Event()
|
|
435
|
+
|
|
436
|
+
self.producer_thread = threading.Thread(
|
|
437
|
+
target=simple_frame_producer,
|
|
438
|
+
args=(self.capture_source, self._frame_queue, self.stop_event, self.target_fps),
|
|
439
|
+
daemon=True,
|
|
440
|
+
)
|
|
441
|
+
self.consumer_thread = threading.Thread(target=self._consumer_loop, daemon=True)
|
|
442
|
+
|
|
443
|
+
self.producer_thread.start()
|
|
444
|
+
self.consumer_thread.start()
|
|
445
|
+
logger.info(
|
|
446
|
+
"Started ProducerConsumer (queue_size=%s, fps=%s)", self.max_queue_size, self.target_fps
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def stop(self, join: bool = True, timeout: Optional[float] = None) -> None:
|
|
450
|
+
"""Signal both threads to stop.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
join: If True (default), join both threads after signalling.
|
|
454
|
+
timeout: Per-thread join timeout in seconds (None = block until the
|
|
455
|
+
thread exits). Ignored when ``join`` is False.
|
|
456
|
+
"""
|
|
457
|
+
self.stop_event.set()
|
|
458
|
+
if join:
|
|
459
|
+
if self.producer_thread:
|
|
460
|
+
self.producer_thread.join(timeout=timeout)
|
|
461
|
+
if self.consumer_thread:
|
|
462
|
+
self.consumer_thread.join(timeout=timeout)
|
|
463
|
+
|
|
464
|
+
def __enter__(self) -> "ProducerConsumer":
|
|
465
|
+
if not (self.producer_thread and self.producer_thread.is_alive()):
|
|
466
|
+
self.start()
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
470
|
+
self.stop(join=True)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def create_producer_consumer_pair(
|
|
474
|
+
capture_source: FrameSourceProtocol,
|
|
475
|
+
consumer_function: Callable,
|
|
476
|
+
max_queue_size: int = 10,
|
|
477
|
+
target_fps: Optional[float] = None,
|
|
478
|
+
):
|
|
479
|
+
"""
|
|
480
|
+
Convenience function to create a producer-consumer pair.
|
|
481
|
+
|
|
482
|
+
Thin wrapper around :class:`ProducerConsumer` that starts the pair and
|
|
483
|
+
returns just the producer thread and stop event. For a handle on *both*
|
|
484
|
+
threads (e.g. to join the consumer) use :class:`ProducerConsumer` directly.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
capture_source: FrameSource capture object
|
|
488
|
+
consumer_function: Function that processes frames (receives success, frame)
|
|
489
|
+
max_queue_size: Maximum queue size
|
|
490
|
+
target_fps: Target frame rate
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Tuple of (producer_thread, stop_event) for control
|
|
494
|
+
|
|
495
|
+
Example:
|
|
496
|
+
```python
|
|
497
|
+
def my_processor(success, frame):
|
|
498
|
+
if success:
|
|
499
|
+
cv2.imshow("Frame", frame)
|
|
500
|
+
cv2.waitKey(1)
|
|
501
|
+
|
|
502
|
+
camera = WebcamCapture(source=0)
|
|
503
|
+
producer_thread, stop_event = create_producer_consumer_pair(
|
|
504
|
+
camera, my_processor, max_queue_size=5, target_fps=30
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Let it run for a while
|
|
508
|
+
time.sleep(10)
|
|
509
|
+
|
|
510
|
+
# Stop it
|
|
511
|
+
stop_event.set()
|
|
512
|
+
producer_thread.join()
|
|
513
|
+
```
|
|
514
|
+
"""
|
|
515
|
+
pair = ProducerConsumer(
|
|
516
|
+
capture_source, consumer_function, max_queue_size=max_queue_size, target_fps=target_fps
|
|
517
|
+
)
|
|
518
|
+
pair.start()
|
|
519
|
+
return pair.producer_thread, pair.stop_event
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class AsyncFrameSource:
|
|
523
|
+
"""Opt-in asyncio adapter around a synchronous FrameSource capture object.
|
|
524
|
+
|
|
525
|
+
FrameSource capture objects are synchronous by design ("bring-your-own-
|
|
526
|
+
concurrency"): the core never spawns threads on your behalf. This adapter
|
|
527
|
+
is a thin, explicit bridge for callers whose application is built on
|
|
528
|
+
``asyncio``. It offloads the blocking ``connect``/``read``/``disconnect``
|
|
529
|
+
calls onto a dedicated single-worker thread so they do not stall the event
|
|
530
|
+
loop.
|
|
531
|
+
|
|
532
|
+
The executor is created lazily on first use, so sync-only users who merely
|
|
533
|
+
construct (but never ``await``) an :class:`AsyncFrameSource` never pay for a
|
|
534
|
+
thread. A single worker is used deliberately: capture objects are not
|
|
535
|
+
thread-safe, so all calls are serialized onto the same thread.
|
|
536
|
+
|
|
537
|
+
Note:
|
|
538
|
+
This does not make the capture source itself asynchronous — it only
|
|
539
|
+
moves the synchronous work off the event loop thread. The core remains
|
|
540
|
+
synchronous; async support is entirely opt-in and lives here.
|
|
541
|
+
|
|
542
|
+
Example:
|
|
543
|
+
```python
|
|
544
|
+
import asyncio
|
|
545
|
+
from framesource import WebcamCapture
|
|
546
|
+
from framesource.threading_utils import AsyncFrameSource
|
|
547
|
+
|
|
548
|
+
async def main():
|
|
549
|
+
async with AsyncFrameSource(WebcamCapture(source=0)) as src:
|
|
550
|
+
for _ in range(100):
|
|
551
|
+
ret, frame = await src.read()
|
|
552
|
+
if ret:
|
|
553
|
+
... # process frame without blocking the loop
|
|
554
|
+
|
|
555
|
+
asyncio.run(main())
|
|
556
|
+
```
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
def __init__(self, capture_source: FrameSourceProtocol):
|
|
560
|
+
"""Initialize the adapter.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
capture_source: Any synchronous FrameSource capture object.
|
|
564
|
+
"""
|
|
565
|
+
self.capture_source = capture_source
|
|
566
|
+
self._executor: Optional[ThreadPoolExecutor] = None
|
|
567
|
+
|
|
568
|
+
def _ensure_executor(self) -> ThreadPoolExecutor:
|
|
569
|
+
"""Lazily create the single-worker executor on first use."""
|
|
570
|
+
if self._executor is None:
|
|
571
|
+
self._executor = ThreadPoolExecutor(
|
|
572
|
+
max_workers=1, thread_name_prefix="framesource-async"
|
|
573
|
+
)
|
|
574
|
+
return self._executor
|
|
575
|
+
|
|
576
|
+
async def connect(self) -> bool:
|
|
577
|
+
"""Connect to the underlying source without blocking the event loop.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
bool: True if the connection succeeded.
|
|
581
|
+
"""
|
|
582
|
+
loop = asyncio.get_running_loop()
|
|
583
|
+
return await loop.run_in_executor(self._ensure_executor(), self.capture_source.connect)
|
|
584
|
+
|
|
585
|
+
async def read(self):
|
|
586
|
+
"""Read one frame without blocking the event loop.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Tuple[bool, Optional[Frame]]: The OpenCV-style ``(ret, frame)``
|
|
590
|
+
tuple returned by the underlying source.
|
|
591
|
+
"""
|
|
592
|
+
loop = asyncio.get_running_loop()
|
|
593
|
+
return await loop.run_in_executor(self._ensure_executor(), self.capture_source.read)
|
|
594
|
+
|
|
595
|
+
async def disconnect(self) -> bool:
|
|
596
|
+
"""Disconnect from the underlying source without blocking the loop.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
bool: True if the disconnection succeeded.
|
|
600
|
+
"""
|
|
601
|
+
loop = asyncio.get_running_loop()
|
|
602
|
+
return await loop.run_in_executor(self._ensure_executor(), self.capture_source.disconnect)
|
|
603
|
+
|
|
604
|
+
def close(self) -> None:
|
|
605
|
+
"""Shut down the worker thread, if one was created.
|
|
606
|
+
|
|
607
|
+
Safe to call even if the executor was never created (sync-only use).
|
|
608
|
+
"""
|
|
609
|
+
if self._executor is not None:
|
|
610
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
611
|
+
self._executor = None
|
|
612
|
+
|
|
613
|
+
async def __aenter__(self) -> "AsyncFrameSource":
|
|
614
|
+
await self.connect()
|
|
615
|
+
return self
|
|
616
|
+
|
|
617
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
618
|
+
try:
|
|
619
|
+
await self.disconnect()
|
|
620
|
+
finally:
|
|
621
|
+
self.close()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class SharedProducer:
|
|
625
|
+
"""Explicit one-producer / many-consumer fan-out for a single source.
|
|
626
|
+
|
|
627
|
+
A single daemon thread reads frames from one capture source and fans each
|
|
628
|
+
``(ret, frame)`` tuple out to every subscribed queue. This lets several
|
|
629
|
+
independent consumers (e.g. a live UI preview and a background recorder)
|
|
630
|
+
share one camera without each opening its own connection and without any
|
|
631
|
+
hidden global state or reference-counting magic — you explicitly
|
|
632
|
+
:meth:`subscribe`, :meth:`start`, :meth:`stop`, and :meth:`unsubscribe`.
|
|
633
|
+
|
|
634
|
+
Each subscriber gets its own bounded queue. Frames are delivered with
|
|
635
|
+
``put(block=False)``; if a subscriber's queue is full its frame is dropped
|
|
636
|
+
(counted per-queue) so a slow consumer never stalls the producer or the
|
|
637
|
+
other consumers.
|
|
638
|
+
|
|
639
|
+
Lifecycle:
|
|
640
|
+
:meth:`start` connects the source if it is not already open (checked via
|
|
641
|
+
``is_open()``). :meth:`stop` does **not** disconnect — the caller owns
|
|
642
|
+
the source's lifecycle and should disconnect it when finished. This
|
|
643
|
+
keeps ownership explicit when the same source is shared or reused.
|
|
644
|
+
|
|
645
|
+
Example:
|
|
646
|
+
```python
|
|
647
|
+
from framesource import WebcamCapture
|
|
648
|
+
from framesource.threading_utils import SharedProducer
|
|
649
|
+
|
|
650
|
+
camera = WebcamCapture(source=0)
|
|
651
|
+
shared = SharedProducer(camera, target_fps=30)
|
|
652
|
+
|
|
653
|
+
ui_q = shared.subscribe(maxsize=2) # UI: latest frames only
|
|
654
|
+
rec_q = shared.subscribe(maxsize=120) # recorder: bigger buffer
|
|
655
|
+
|
|
656
|
+
shared.start()
|
|
657
|
+
try:
|
|
658
|
+
ok, frame = ui_q.get(timeout=1.0) # feed the preview widget
|
|
659
|
+
ok, frame = rec_q.get(timeout=1.0) # write to the video file
|
|
660
|
+
finally:
|
|
661
|
+
shared.stop()
|
|
662
|
+
camera.disconnect() # caller owns the source
|
|
663
|
+
```
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
def __init__(self, capture_source: FrameSourceProtocol, target_fps: Optional[float] = None):
|
|
667
|
+
"""Initialize the shared producer.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
capture_source: FrameSource capture object to read from.
|
|
671
|
+
target_fps: Optional target frame rate for pacing (None = unlimited).
|
|
672
|
+
"""
|
|
673
|
+
self.capture_source = capture_source
|
|
674
|
+
self.target_fps = target_fps
|
|
675
|
+
|
|
676
|
+
self._subscribers: list[queue.Queue] = []
|
|
677
|
+
self._drops: dict[int, int] = {}
|
|
678
|
+
self._lock = threading.Lock()
|
|
679
|
+
|
|
680
|
+
self._stop_event = threading.Event()
|
|
681
|
+
self._thread: Optional[threading.Thread] = None
|
|
682
|
+
self._frames_captured = 0
|
|
683
|
+
self._start_time: Optional[float] = None
|
|
684
|
+
|
|
685
|
+
def subscribe(self, maxsize: int = 10) -> queue.Queue:
|
|
686
|
+
"""Register a new consumer queue and return it.
|
|
687
|
+
|
|
688
|
+
May be called before or after :meth:`start`.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
maxsize: Maximum size of the created queue (0 = unbounded).
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
queue.Queue: A newly created queue that will receive
|
|
695
|
+
``(ret, frame)`` tuples.
|
|
696
|
+
"""
|
|
697
|
+
q: queue.Queue = queue.Queue(maxsize=maxsize)
|
|
698
|
+
with self._lock:
|
|
699
|
+
self._subscribers.append(q)
|
|
700
|
+
self._drops[id(q)] = 0
|
|
701
|
+
return q
|
|
702
|
+
|
|
703
|
+
def unsubscribe(self, q: queue.Queue) -> bool:
|
|
704
|
+
"""Deregister a consumer queue so it stops receiving frames.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
q: A queue previously returned by :meth:`subscribe`.
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
bool: True if the queue was registered and has been removed.
|
|
711
|
+
"""
|
|
712
|
+
with self._lock:
|
|
713
|
+
if q in self._subscribers:
|
|
714
|
+
self._subscribers.remove(q)
|
|
715
|
+
self._drops.pop(id(q), None)
|
|
716
|
+
return True
|
|
717
|
+
return False
|
|
718
|
+
|
|
719
|
+
def start(self) -> None:
|
|
720
|
+
"""Start the producer thread (connecting the source if needed)."""
|
|
721
|
+
if self._thread and self._thread.is_alive():
|
|
722
|
+
logger.warning("SharedProducer already running")
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
if not self.capture_source.is_open():
|
|
726
|
+
if not self.capture_source.connect():
|
|
727
|
+
logger.error("SharedProducer: failed to connect to source")
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
self._stop_event = threading.Event()
|
|
731
|
+
self._start_time = time.time()
|
|
732
|
+
self._thread = threading.Thread(target=self._producer_loop, daemon=True)
|
|
733
|
+
self._thread.start()
|
|
734
|
+
logger.info("Started SharedProducer (fps=%s)", self.target_fps)
|
|
735
|
+
|
|
736
|
+
def _producer_loop(self) -> None:
|
|
737
|
+
"""Internal producer loop: read then fan out to every subscriber."""
|
|
738
|
+
frame_delay = 1.0 / self.target_fps if self.target_fps else 0.0
|
|
739
|
+
try:
|
|
740
|
+
while not self._stop_event.is_set():
|
|
741
|
+
start_time = time.time()
|
|
742
|
+
|
|
743
|
+
ret, frame = self.capture_source.read()
|
|
744
|
+
if ret and frame is not None:
|
|
745
|
+
self._frames_captured += 1
|
|
746
|
+
with self._lock:
|
|
747
|
+
for q in self._subscribers:
|
|
748
|
+
try:
|
|
749
|
+
q.put((ret, frame), block=False)
|
|
750
|
+
except queue.Full:
|
|
751
|
+
self._drops[id(q)] = self._drops.get(id(q), 0) + 1
|
|
752
|
+
|
|
753
|
+
if frame_delay > 0:
|
|
754
|
+
elapsed = time.time() - start_time
|
|
755
|
+
sleep_time = frame_delay - elapsed
|
|
756
|
+
if sleep_time > 0:
|
|
757
|
+
time.sleep(sleep_time)
|
|
758
|
+
except Exception as e:
|
|
759
|
+
logger.exception("SharedProducer error: %s", e)
|
|
760
|
+
|
|
761
|
+
def stop(self) -> None:
|
|
762
|
+
"""Signal the producer thread to stop and join it (2s timeout).
|
|
763
|
+
|
|
764
|
+
Does not disconnect the source; the caller owns its lifecycle.
|
|
765
|
+
"""
|
|
766
|
+
self._stop_event.set()
|
|
767
|
+
if self._thread:
|
|
768
|
+
self._thread.join(timeout=2)
|
|
769
|
+
|
|
770
|
+
def get_stats(self) -> dict:
|
|
771
|
+
"""Return producer statistics.
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
dict: With keys ``frames_captured``, ``frames_dropped`` (total
|
|
775
|
+
across all subscribers), ``drops_per_subscriber`` (list of per-queue
|
|
776
|
+
drop counts), ``runtime`` (seconds) and ``fps`` (captured frames per
|
|
777
|
+
second).
|
|
778
|
+
"""
|
|
779
|
+
with self._lock:
|
|
780
|
+
per_subscriber = list(self._drops.values())
|
|
781
|
+
total_drops = sum(per_subscriber)
|
|
782
|
+
runtime = (time.time() - self._start_time) if self._start_time else 0.0
|
|
783
|
+
fps = (self._frames_captured / runtime) if runtime > 0 else 0.0
|
|
784
|
+
return {
|
|
785
|
+
"frames_captured": self._frames_captured,
|
|
786
|
+
"frames_dropped": total_drops,
|
|
787
|
+
"drops_per_subscriber": per_subscriber,
|
|
788
|
+
"runtime": runtime,
|
|
789
|
+
"fps": fps,
|
|
790
|
+
}
|