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.
Files changed (37) hide show
  1. frame_processors/__init__.py +37 -0
  2. frame_source/__init__.py +50 -0
  3. framesource/__init__.py +93 -0
  4. framesource/_msmf_config.py +26 -0
  5. framesource/_version.py +24 -0
  6. framesource/discovery.py +109 -0
  7. framesource/errors.py +82 -0
  8. framesource/factory.py +290 -0
  9. framesource/processors/__init__.py +34 -0
  10. framesource/processors/equirectangular360_processor.py +577 -0
  11. framesource/processors/fisheye2equirectangular_processor.py +328 -0
  12. framesource/processors/frame_processor.py +30 -0
  13. framesource/processors/hyperspectral_processor.py +23 -0
  14. framesource/processors/realsense_depth_processor.py +90 -0
  15. framesource/py.typed +0 -0
  16. framesource/sources/__init__.py +40 -0
  17. framesource/sources/audiospectrogram_capture.py +1068 -0
  18. framesource/sources/basler_capture.py +477 -0
  19. framesource/sources/folder_capture.py +920 -0
  20. framesource/sources/genicam_capture.py +681 -0
  21. framesource/sources/huateng_capture.py +245 -0
  22. framesource/sources/ipcamera_capture.py +254 -0
  23. framesource/sources/mvsdk.py +2454 -0
  24. framesource/sources/realsense_capture.py +565 -0
  25. framesource/sources/screen_capture.py +800 -0
  26. framesource/sources/video_capture_base.py +560 -0
  27. framesource/sources/video_file_capture.py +259 -0
  28. framesource/sources/webcam_capture.py +511 -0
  29. framesource/sources/ximea_capture.py +299 -0
  30. framesource/threading_utils.py +790 -0
  31. framesource-0.3.0.dist-info/METADATA +787 -0
  32. framesource-0.3.0.dist-info/RECORD +37 -0
  33. framesource-0.3.0.dist-info/WHEEL +5 -0
  34. framesource-0.3.0.dist-info/licenses/LICENSE +21 -0
  35. framesource-0.3.0.dist-info/scm_file_list.json +276 -0
  36. framesource-0.3.0.dist-info/scm_version.json +8 -0
  37. 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
+ }