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,560 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid as _uuid
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import Any, Optional, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Frame(np.ndarray):
|
|
12
|
+
"""A numpy array subclass that carries per-frame metadata.
|
|
13
|
+
|
|
14
|
+
Because ``Frame`` *is* a numpy array it works transparently with any
|
|
15
|
+
OpenCV or numpy function::
|
|
16
|
+
|
|
17
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # works as-is
|
|
18
|
+
print(frame.timestamp, frame.uuid) # metadata intact
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
timestamp (float): UNIX wall-clock time of capture (``time.time()``).
|
|
22
|
+
Use this for human-readable timestamps, logging, or correlating
|
|
23
|
+
frames with external wall-clock events. It is subject to system
|
|
24
|
+
clock adjustments (NTP sync, manual changes, DST), so it is not
|
|
25
|
+
reliable for measuring elapsed time or latency.
|
|
26
|
+
monotonic (float): Monotonic clock reading at capture
|
|
27
|
+
(``time.monotonic()``). Use this for latency and FPS math
|
|
28
|
+
(elapsed-time computations) since it always moves forward and is
|
|
29
|
+
immune to wall-clock jumps.
|
|
30
|
+
count (int | None): Monotonically-increasing counter per source.
|
|
31
|
+
uuid (str): UUID4 string uniquely identifying this frame.
|
|
32
|
+
source (str | None): Identifies the capture source.
|
|
33
|
+
metadata (dict): Free-form key/value store for extra fields.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __new__(
|
|
37
|
+
cls,
|
|
38
|
+
input_array: np.ndarray,
|
|
39
|
+
timestamp: Optional[float] = None,
|
|
40
|
+
count: Optional[int] = None,
|
|
41
|
+
uuid: Optional[str] = None,
|
|
42
|
+
source: Optional[str] = None,
|
|
43
|
+
metadata: Optional[dict] = None,
|
|
44
|
+
monotonic: Optional[float] = None,
|
|
45
|
+
) -> "Frame":
|
|
46
|
+
obj = np.asarray(input_array).view(cls)
|
|
47
|
+
obj.timestamp = timestamp if timestamp is not None else time.time()
|
|
48
|
+
obj.count = count
|
|
49
|
+
obj.uuid = uuid if uuid is not None else str(_uuid.uuid4())
|
|
50
|
+
obj.source = source
|
|
51
|
+
obj.metadata = metadata if metadata is not None else {}
|
|
52
|
+
obj.monotonic = monotonic if monotonic is not None else time.monotonic()
|
|
53
|
+
return obj
|
|
54
|
+
|
|
55
|
+
def __array_finalize__(self, obj: Optional[np.ndarray]) -> None:
|
|
56
|
+
"""Called whenever a new view / copy of a Frame is produced."""
|
|
57
|
+
if obj is None:
|
|
58
|
+
return
|
|
59
|
+
self.timestamp = getattr(obj, "timestamp", None)
|
|
60
|
+
self.count = getattr(obj, "count", None)
|
|
61
|
+
self.uuid = getattr(obj, "uuid", None)
|
|
62
|
+
self.source = getattr(obj, "source", None)
|
|
63
|
+
self.metadata = getattr(obj, "metadata", {})
|
|
64
|
+
self.monotonic = getattr(obj, "monotonic", None)
|
|
65
|
+
|
|
66
|
+
def __reduce__(self):
|
|
67
|
+
"""Extend numpy pickling so metadata survives pickle / deepcopy."""
|
|
68
|
+
pickled = super().__reduce__()
|
|
69
|
+
extra = (self.timestamp, self.count, self.uuid, self.source, self.metadata, self.monotonic)
|
|
70
|
+
np_state = pickled[2] if isinstance(pickled[2], tuple) else (pickled[2],)
|
|
71
|
+
return (pickled[0], pickled[1], np_state + extra)
|
|
72
|
+
|
|
73
|
+
def __setstate__(self, state):
|
|
74
|
+
*np_state, ts, cnt, uid, src, meta, mono = state
|
|
75
|
+
super().__setstate__(tuple(np_state))
|
|
76
|
+
self.timestamp = ts
|
|
77
|
+
self.count = cnt
|
|
78
|
+
self.uuid = uid
|
|
79
|
+
self.source = src
|
|
80
|
+
self.metadata = meta
|
|
81
|
+
self.monotonic = mono
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class VideoCaptureBase(ABC):
|
|
85
|
+
# Class attribute indicating if this capture type supports device discovery
|
|
86
|
+
has_discovery = False
|
|
87
|
+
|
|
88
|
+
# Capability flags. These are base-class defaults; subclasses that lack a
|
|
89
|
+
# given capability (e.g. a source with no controllable exposure/gain, or
|
|
90
|
+
# a depth-producing device) should override them. `supports_discovery`
|
|
91
|
+
# is derived below from `has_discovery` rather than duplicated.
|
|
92
|
+
supports_exposure: bool = True
|
|
93
|
+
supports_gain: bool = True
|
|
94
|
+
supports_depth: bool = False
|
|
95
|
+
|
|
96
|
+
"""Abstract base class for video capture implementations."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, source: Any = None, **kwargs):
|
|
99
|
+
"""
|
|
100
|
+
Initialize the capture device.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
source: Source identifier (device index, URL, etc.)
|
|
104
|
+
**kwargs: Additional parameters specific to the capture type
|
|
105
|
+
"""
|
|
106
|
+
self.source = source
|
|
107
|
+
self.is_connected = False
|
|
108
|
+
self._exposure = None
|
|
109
|
+
self._gain = None
|
|
110
|
+
self._frame_count = 0
|
|
111
|
+
self.config = kwargs
|
|
112
|
+
self.type = self.__class__.__name__
|
|
113
|
+
# Rolling window of recent frame timestamps for fps_actual()
|
|
114
|
+
self._frame_timestamps: deque = deque(maxlen=30)
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
return f"{self.type}(source={self.source}, connected={self.is_connected})"
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def connect(self) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Connect to the capture device.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
bool: True if connection successful, False otherwise
|
|
126
|
+
"""
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
@abstractmethod
|
|
130
|
+
def disconnect(self) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Disconnect from the capture device.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
bool: True if disconnection successful, False otherwise
|
|
136
|
+
"""
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def _read_implementation(self) -> tuple[bool, Optional[np.ndarray]]:
|
|
141
|
+
"""
|
|
142
|
+
Read a single frame from the capture device.
|
|
143
|
+
|
|
144
|
+
This is the synchronous, blocking primitive each subclass must
|
|
145
|
+
implement. Callers should use :meth:`read` (which wraps the result in
|
|
146
|
+
a :class:`Frame` and applies any attached processors) rather than
|
|
147
|
+
calling this directly.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Tuple[bool, Optional[np.ndarray]]: (success, frame)
|
|
151
|
+
"""
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def enable_auto_exposure(self, enable: bool = True) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
Enable or disable auto exposure.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
enable: True to enable, False to disable
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
bool: True if set successfully, False otherwise
|
|
164
|
+
"""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def get_exposure_range(self) -> Optional[tuple[float, float]]:
|
|
168
|
+
"""
|
|
169
|
+
Get the minimum and maximum exposure values supported by the device.
|
|
170
|
+
Returns:
|
|
171
|
+
Optional[Tuple[float, float]]: (min_exposure, max_exposure) or None if not available
|
|
172
|
+
"""
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def get_gain_range(self) -> Optional[tuple[float, float]]:
|
|
176
|
+
"""
|
|
177
|
+
Get the minimum and maximum gain values supported by the device.
|
|
178
|
+
Returns:
|
|
179
|
+
Optional[Tuple[float, float]]: (min_gain, max_gain) or None if not available
|
|
180
|
+
"""
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def set_exposure(self, value: float) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Set exposure value.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
value: Exposure value (range depends on implementation)
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
bool: True if set successfully, False otherwise
|
|
193
|
+
"""
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def get_exposure(self) -> Optional[float]:
|
|
198
|
+
"""
|
|
199
|
+
Get current exposure value.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Optional[float]: Current exposure value or None if not available
|
|
203
|
+
"""
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
@abstractmethod
|
|
207
|
+
def set_gain(self, value: float) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Set gain value.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
value: Gain value (range depends on implementation)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
bool: True if set successfully, False otherwise
|
|
216
|
+
"""
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
@abstractmethod
|
|
220
|
+
def get_gain(self) -> Optional[float]:
|
|
221
|
+
"""
|
|
222
|
+
Get current gain value.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Optional[float]: Current gain value or None if not available
|
|
226
|
+
"""
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def discover(cls) -> list:
|
|
231
|
+
"""
|
|
232
|
+
Discover available devices for this capture type.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
list: List of available devices. Format depends on implementation:
|
|
236
|
+
- For cameras: List of device indices, serial numbers, or device info dicts
|
|
237
|
+
- For audio: List of microphone devices with info
|
|
238
|
+
- For network cameras: List of discovered IP cameras
|
|
239
|
+
- For files: Empty list (no discovery needed)
|
|
240
|
+
|
|
241
|
+
Note:
|
|
242
|
+
This is a class method that can be called without instantiating the capture class.
|
|
243
|
+
Subclasses should override this method to implement device-specific discovery.
|
|
244
|
+
Default implementation returns empty list.
|
|
245
|
+
"""
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
def get_frame_size(self) -> Optional[tuple[int, int]]:
|
|
249
|
+
"""
|
|
250
|
+
Get frame dimensions (width, height).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Optional[Tuple[int, int]]: Frame size or None if not available
|
|
254
|
+
"""
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def set_frame_size(self, width: int, height: int) -> bool:
|
|
258
|
+
"""
|
|
259
|
+
Set frame dimensions.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
width: Frame width
|
|
263
|
+
height: Frame height
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
bool: True if set successfully, False otherwise
|
|
267
|
+
"""
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
def get_fps(self) -> Optional[float]:
|
|
271
|
+
"""Get current FPS."""
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def set_fps(self, fps: float) -> bool:
|
|
275
|
+
"""Set FPS."""
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
def __enter__(self):
|
|
279
|
+
"""Context manager entry."""
|
|
280
|
+
if not self.is_connected:
|
|
281
|
+
self.connect()
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
285
|
+
"""Context manager exit."""
|
|
286
|
+
if self.is_connected:
|
|
287
|
+
self.disconnect()
|
|
288
|
+
|
|
289
|
+
def isOpened(self) -> bool:
|
|
290
|
+
"""
|
|
291
|
+
Check if the capture device is opened/connected.
|
|
292
|
+
OpenCV-compatible API method.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
bool: True if device is connected, False otherwise
|
|
296
|
+
"""
|
|
297
|
+
return self.is_connected
|
|
298
|
+
|
|
299
|
+
def is_open(self) -> bool:
|
|
300
|
+
"""Check if the capture device is connected (Pythonic alias for isOpened)."""
|
|
301
|
+
return self.is_connected
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def supports_discovery(self) -> bool:
|
|
305
|
+
"""Whether this capture type supports :meth:`discover`.
|
|
306
|
+
|
|
307
|
+
Read-only alias for the ``has_discovery`` class attribute, exposed as
|
|
308
|
+
a property so it lines up with the other ``supports_*`` capability
|
|
309
|
+
flags (``supports_exposure``, ``supports_gain``, ``supports_depth``).
|
|
310
|
+
"""
|
|
311
|
+
return bool(type(self).has_discovery)
|
|
312
|
+
|
|
313
|
+
def release(self) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Release the capture device.
|
|
316
|
+
OpenCV-compatible API method that disconnects from the device.
|
|
317
|
+
"""
|
|
318
|
+
if self.is_connected:
|
|
319
|
+
self.disconnect()
|
|
320
|
+
|
|
321
|
+
def set(self, prop_id: int, value: Any) -> bool:
|
|
322
|
+
"""
|
|
323
|
+
Set a property of the capture device.
|
|
324
|
+
OpenCV-compatible API method.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
prop_id: Property identifier (OpenCV constant)
|
|
328
|
+
value: Value to set
|
|
329
|
+
Returns:
|
|
330
|
+
bool: True if set successfully, False otherwise
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
if prop_id == cv2.CAP_PROP_EXPOSURE:
|
|
334
|
+
self.set_exposure(value)
|
|
335
|
+
return True
|
|
336
|
+
elif prop_id == cv2.CAP_PROP_BRIGHTNESS:
|
|
337
|
+
return False
|
|
338
|
+
elif prop_id == cv2.CAP_PROP_CONTRAST:
|
|
339
|
+
return False
|
|
340
|
+
elif prop_id == cv2.CAP_PROP_SATURATION:
|
|
341
|
+
return False
|
|
342
|
+
elif prop_id == cv2.CAP_PROP_GAIN:
|
|
343
|
+
self.set_gain(value)
|
|
344
|
+
return True
|
|
345
|
+
elif prop_id == cv2.CAP_PROP_FPS:
|
|
346
|
+
self.set_fps(value)
|
|
347
|
+
return True
|
|
348
|
+
elif prop_id == cv2.CAP_PROP_FRAME_WIDTH:
|
|
349
|
+
size = self.get_frame_size()
|
|
350
|
+
if size:
|
|
351
|
+
self.set_frame_size(int(value), size[1])
|
|
352
|
+
return True
|
|
353
|
+
else:
|
|
354
|
+
return False
|
|
355
|
+
elif prop_id == cv2.CAP_PROP_FRAME_HEIGHT:
|
|
356
|
+
size = self.get_frame_size()
|
|
357
|
+
if size:
|
|
358
|
+
self.set_frame_size(size[0], int(value))
|
|
359
|
+
return True
|
|
360
|
+
else:
|
|
361
|
+
return False
|
|
362
|
+
else:
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
def get(self, prop_id: int) -> Any:
|
|
366
|
+
"""
|
|
367
|
+
Get a property of the capture device.
|
|
368
|
+
OpenCV-compatible API method.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
prop_id: Property identifier (OpenCV constant)
|
|
372
|
+
Returns:
|
|
373
|
+
Any: Property value or None if not available
|
|
374
|
+
"""
|
|
375
|
+
if prop_id == cv2.CAP_PROP_EXPOSURE:
|
|
376
|
+
return self.get_exposure()
|
|
377
|
+
elif prop_id == cv2.CAP_PROP_BRIGHTNESS:
|
|
378
|
+
# return self.get_brightness()
|
|
379
|
+
return None
|
|
380
|
+
elif prop_id == cv2.CAP_PROP_CONTRAST:
|
|
381
|
+
# return self.get_contrast()
|
|
382
|
+
return None
|
|
383
|
+
elif prop_id == cv2.CAP_PROP_SATURATION:
|
|
384
|
+
# return self.get_saturation()
|
|
385
|
+
return None
|
|
386
|
+
elif prop_id == cv2.CAP_PROP_GAIN:
|
|
387
|
+
return self.get_gain()
|
|
388
|
+
elif prop_id == cv2.CAP_PROP_FPS:
|
|
389
|
+
return self.get_fps()
|
|
390
|
+
elif prop_id == cv2.CAP_PROP_FRAME_WIDTH:
|
|
391
|
+
size = self.get_frame_size()
|
|
392
|
+
if size:
|
|
393
|
+
return size[0]
|
|
394
|
+
else:
|
|
395
|
+
return None
|
|
396
|
+
elif prop_id == cv2.CAP_PROP_FRAME_HEIGHT:
|
|
397
|
+
size = self.get_frame_size()
|
|
398
|
+
if size:
|
|
399
|
+
return size[1]
|
|
400
|
+
else:
|
|
401
|
+
return None
|
|
402
|
+
else:
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
def attach_processor(self, processor):
|
|
406
|
+
"""Attach a frame processor to this camera."""
|
|
407
|
+
if not hasattr(self, "_processors"):
|
|
408
|
+
self._processors = []
|
|
409
|
+
self._processors.append(processor)
|
|
410
|
+
return processor
|
|
411
|
+
|
|
412
|
+
def detach_processor(self, processor):
|
|
413
|
+
"""Remove a processor from this camera."""
|
|
414
|
+
if hasattr(self, "_processors"):
|
|
415
|
+
if processor in self._processors:
|
|
416
|
+
self._processors.remove(processor)
|
|
417
|
+
return True
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
def clear_processors(self):
|
|
421
|
+
"""Remove all processors."""
|
|
422
|
+
if hasattr(self, "_processors"):
|
|
423
|
+
self._processors.clear()
|
|
424
|
+
|
|
425
|
+
def get_processors(self):
|
|
426
|
+
"""Get all attached processors."""
|
|
427
|
+
if not hasattr(self, "_processors"):
|
|
428
|
+
self._processors = []
|
|
429
|
+
return self._processors
|
|
430
|
+
|
|
431
|
+
def read(self):
|
|
432
|
+
"""Read a frame, wrap it in a Frame with metadata, and apply processors."""
|
|
433
|
+
ret, raw = self._read_implementation()
|
|
434
|
+
|
|
435
|
+
if ret and raw is not None:
|
|
436
|
+
frame: np.ndarray = Frame(
|
|
437
|
+
raw,
|
|
438
|
+
count=self._frame_count,
|
|
439
|
+
source=str(self.source),
|
|
440
|
+
)
|
|
441
|
+
self._frame_count += 1
|
|
442
|
+
# Use the monotonic clock (not wall-clock timestamp) so fps_actual()
|
|
443
|
+
# stays correct across system clock adjustments.
|
|
444
|
+
self._frame_timestamps.append(frame.monotonic)
|
|
445
|
+
if hasattr(self, "_processors") and self._processors:
|
|
446
|
+
for processor in self._processors:
|
|
447
|
+
frame = processor.process(frame)
|
|
448
|
+
else:
|
|
449
|
+
frame = raw # type: ignore[assignment] # raw is None when ret is False
|
|
450
|
+
|
|
451
|
+
return ret, frame
|
|
452
|
+
|
|
453
|
+
def fps_actual(self) -> Optional[float]:
|
|
454
|
+
"""Measured frame rate over the most recent frames.
|
|
455
|
+
|
|
456
|
+
Computes the average FPS from the monotonic-clock readings
|
|
457
|
+
(:attr:`Frame.monotonic`) of recently read frames (rolling window of
|
|
458
|
+
up to 30). The monotonic clock is used rather than the wall-clock
|
|
459
|
+
``timestamp`` so the measurement stays accurate even if the system
|
|
460
|
+
clock jumps (NTP sync, DST, manual changes). Returns ``None`` until
|
|
461
|
+
at least two frames have been read.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Optional[float]: Measured frames-per-second, or None if not enough
|
|
465
|
+
frames have been read yet.
|
|
466
|
+
"""
|
|
467
|
+
timestamps = self._frame_timestamps
|
|
468
|
+
if len(timestamps) < 2:
|
|
469
|
+
return None
|
|
470
|
+
span = timestamps[-1] - timestamps[0]
|
|
471
|
+
if span <= 0:
|
|
472
|
+
return None
|
|
473
|
+
return (len(timestamps) - 1) / span
|
|
474
|
+
|
|
475
|
+
def wait_until_ready(self, timeout: float = 5.0) -> bool:
|
|
476
|
+
"""Block until the source is actually producing frames.
|
|
477
|
+
|
|
478
|
+
Some sources (notably RTSP/network cameras) report a successful
|
|
479
|
+
``connect()`` before the underlying stream has started delivering
|
|
480
|
+
frames -- the socket/session is open, but the first ``read()`` calls
|
|
481
|
+
may fail for a bit while the stream negotiates. This method polls
|
|
482
|
+
:meth:`read` until a frame is successfully returned or ``timeout``
|
|
483
|
+
seconds have elapsed, so callers can avoid treating that startup
|
|
484
|
+
window as a hard failure.
|
|
485
|
+
|
|
486
|
+
Note:
|
|
487
|
+
This consumes a frame: on success, the frame that proved
|
|
488
|
+
readiness has already been read (and passed through any
|
|
489
|
+
attached processors) and is not returned or buffered. Callers
|
|
490
|
+
that need that particular frame should call :meth:`read` again
|
|
491
|
+
immediately after, or capture it by overriding/wrapping this
|
|
492
|
+
method.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
timeout: Maximum number of seconds to wait for a frame.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
bool: True as soon as a frame is successfully read, False if no
|
|
499
|
+
frame arrived before ``timeout`` elapsed.
|
|
500
|
+
"""
|
|
501
|
+
deadline = time.monotonic() + timeout
|
|
502
|
+
while True:
|
|
503
|
+
ret, frame = self.read()
|
|
504
|
+
if ret and frame is not None:
|
|
505
|
+
return True
|
|
506
|
+
if time.monotonic() >= deadline:
|
|
507
|
+
return False
|
|
508
|
+
time.sleep(0.01)
|
|
509
|
+
|
|
510
|
+
def __iter__(self) -> "VideoCaptureBase":
|
|
511
|
+
"""Return self, allowing the capture to be used as an iterator.
|
|
512
|
+
|
|
513
|
+
Enables the idiom::
|
|
514
|
+
|
|
515
|
+
with cap:
|
|
516
|
+
for frame in cap:
|
|
517
|
+
... # process each frame until the source is exhausted
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
VideoCaptureBase: self.
|
|
521
|
+
"""
|
|
522
|
+
return self
|
|
523
|
+
|
|
524
|
+
def __next__(self):
|
|
525
|
+
"""Read and return the next frame, or stop iteration.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Frame: The next frame from :meth:`read`.
|
|
529
|
+
|
|
530
|
+
Raises:
|
|
531
|
+
StopIteration: If ``read()`` reports failure (``ret`` is False)
|
|
532
|
+
or returns ``None`` for the frame (end of stream / no data).
|
|
533
|
+
"""
|
|
534
|
+
ret, frame = self.read()
|
|
535
|
+
if not ret or frame is None:
|
|
536
|
+
raise StopIteration
|
|
537
|
+
return frame
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@runtime_checkable
|
|
541
|
+
class FrameSourceProtocol(Protocol):
|
|
542
|
+
"""Structural interface implemented by every FrameSource capture class.
|
|
543
|
+
|
|
544
|
+
Use this for static type hints or ``isinstance`` checks when you want to
|
|
545
|
+
accept any frame source without requiring inheritance from
|
|
546
|
+
:class:`VideoCaptureBase`::
|
|
547
|
+
|
|
548
|
+
def run(source: FrameSourceProtocol) -> None:
|
|
549
|
+
source.connect()
|
|
550
|
+
ok, frame = source.read()
|
|
551
|
+
...
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def connect(self) -> bool: ...
|
|
555
|
+
|
|
556
|
+
def disconnect(self) -> bool: ...
|
|
557
|
+
|
|
558
|
+
def read(self) -> tuple[bool, Optional[np.ndarray]]: ...
|
|
559
|
+
|
|
560
|
+
def is_open(self) -> bool: ...
|