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,511 @@
1
+ import logging
2
+ import platform
3
+ import warnings
4
+ from typing import Any, Optional, Union
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+ from ..discovery import DeviceInfo
10
+ from .video_capture_base import VideoCaptureBase
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # String aliases for OpenCV capture backends, resolved to the cv2.CAP_* ints.
15
+ # Only backends present in this OpenCV build are included.
16
+ _BACKEND_ALIASES: dict[str, int] = {}
17
+ for _alias, _const_name in (
18
+ ("any", "CAP_ANY"),
19
+ ("auto", "CAP_ANY"),
20
+ ("dshow", "CAP_DSHOW"),
21
+ ("msmf", "CAP_MSMF"),
22
+ ("v4l2", "CAP_V4L2"),
23
+ ("avfoundation", "CAP_AVFOUNDATION"),
24
+ ("gstreamer", "CAP_GSTREAMER"),
25
+ ("ffmpeg", "CAP_FFMPEG"),
26
+ ):
27
+ if hasattr(cv2, _const_name):
28
+ _BACKEND_ALIASES[_alias] = getattr(cv2, _const_name)
29
+
30
+ # Uncompressed pixel formats. When one of these is negotiated at high
31
+ # resolution the USB link is easily saturated, throttling the frame rate far
32
+ # below what MJPG (compressed) would sustain.
33
+ _UNCOMPRESSED_FOURCCS = frozenset(
34
+ {"YUY2", "YUYV", "UYVY", "I420", "IYUV", "YV12", "NV12", "RGB3", "BGR3"}
35
+ )
36
+
37
+ # Resolutions at or above this pixel count are where uncompressed transport
38
+ # starts to hurt (720p and up).
39
+ _HIGH_RES_PIXELS = 1280 * 720
40
+
41
+
42
+ class WebcamCapture(VideoCaptureBase):
43
+ has_discovery = True
44
+ supports_exposure = True
45
+ supports_gain = True
46
+
47
+ """Webcam capture using OpenCV."""
48
+
49
+ def __init__(
50
+ self,
51
+ source: int = 0,
52
+ *,
53
+ width: Optional[int] = None,
54
+ height: Optional[int] = None,
55
+ fps: Optional[float] = None,
56
+ backend: Optional[Union[str, int]] = None,
57
+ fourcc: Optional[str] = None,
58
+ **kwargs,
59
+ ):
60
+ """Initialize the webcam capture.
61
+
62
+ Args:
63
+ source: Camera device index, or an ``api_pref:index`` /
64
+ ``api_pref:path`` string (as produced in the ``id`` field of
65
+ :meth:`discover`).
66
+ width: Desired frame width in pixels. Applied on :meth:`connect`
67
+ (together with ``height``) when provided.
68
+ height: Desired frame height in pixels. Applied on
69
+ :meth:`connect` (together with ``width``) when provided.
70
+ fps: Desired frames per second. Applied on :meth:`connect` when
71
+ provided.
72
+ backend: OpenCV capture backend to open the device with. Accepts a
73
+ name (``'msmf'``, ``'dshow'``, ``'v4l2'``, ``'avfoundation'``,
74
+ ``'gstreamer'``, ``'ffmpeg'``, ``'any'``) or a raw
75
+ ``cv2.CAP_*`` integer. ``None`` (default) uses the OS default
76
+ (DirectShow on Windows, AVFoundation on macOS, V4L2 on Linux).
77
+ Note that on Windows ``'dshow'`` and ``'msmf'`` interpret the
78
+ exposure/gain values in :meth:`set_exposure`/:meth:`set_gain`
79
+ differently. If a high-resolution stream is stuck at a low
80
+ frame rate, ``'msmf'`` will often negotiate a compressed format
81
+ where DirectShow will not.
82
+ fourcc: Desired pixel format as a four-character code, e.g.
83
+ ``'MJPG'``. Applied on :meth:`connect` *before* the resolution
84
+ so it is not reset by the resolution change. ``None`` (default)
85
+ leaves the backend's default format untouched. Requesting
86
+ ``'MJPG'`` is the usual fix for uncompressed formats saturating
87
+ the USB link at 1080p and above (see the warning logged by
88
+ :meth:`connect`). Not all backends honour every code.
89
+ **kwargs: Additional passthrough options stored on ``self.config``.
90
+ """
91
+ # Preserve the historical ``self.config`` contents: these options were
92
+ # previously mined from ``**kwargs`` and read back via ``self.config``
93
+ # in ``connect()``. Only forward explicitly-provided values so the
94
+ # ``'width' in self.config`` presence checks keep their old meaning.
95
+ for _key, _val in (("width", width), ("height", height), ("fps", fps)):
96
+ if _val is not None:
97
+ kwargs[_key] = _val
98
+ super().__init__(source, **kwargs)
99
+ self.cap = None
100
+ self.fourcc = fourcc
101
+ self.api_preference = self._resolve_backend(backend)
102
+
103
+ self.source = source
104
+
105
+ if "is_mono" in kwargs:
106
+ logger.warning(
107
+ "'is_mono' argument is only used for certain industrial cameras "
108
+ "and has no effect for webcams."
109
+ )
110
+
111
+ @staticmethod
112
+ def _resolve_backend(backend: Optional[Union[str, int]]) -> int:
113
+ """Resolve a backend name/int to a cv2.CAP_* constant.
114
+
115
+ ``None`` maps to the OS default; an int is used verbatim; a string is
116
+ looked up (case-insensitively) in :data:`_BACKEND_ALIASES`.
117
+ """
118
+ if backend is None:
119
+ if platform.system() == "Windows":
120
+ return cv2.CAP_DSHOW
121
+ elif platform.system() == "Darwin":
122
+ return cv2.CAP_AVFOUNDATION
123
+ else:
124
+ return cv2.CAP_V4L2
125
+ if isinstance(backend, int):
126
+ return backend
127
+ key = backend.strip().lower()
128
+ if key not in _BACKEND_ALIASES:
129
+ available = ", ".join(sorted(_BACKEND_ALIASES))
130
+ raise ValueError(
131
+ f"Unknown webcam backend {backend!r}. Use one of: {available}, "
132
+ f"or pass a cv2.CAP_* integer."
133
+ )
134
+ return _BACKEND_ALIASES[key]
135
+
136
+ def connect(self) -> bool:
137
+ """Connect to webcam."""
138
+ try:
139
+ src = self.source
140
+ api_pref = self.api_preference
141
+ # Support `api_pref/index` format or `api_pref/path` format used in
142
+ # list_devices `id` field
143
+ if isinstance(src, str) and ":" in src:
144
+ parts = src.split(":")
145
+ api_pref, src = parts[0], parts[1]
146
+
147
+ if api_pref.isdigit():
148
+ api_pref = int(api_pref)
149
+
150
+ if src.isdigit():
151
+ src = int(src)
152
+
153
+ self.cap = cv2.VideoCapture(src, api_pref)
154
+ if not self.cap.isOpened():
155
+ logger.error(f"Failed to open webcam {src}")
156
+ return False
157
+
158
+ # Pixel format must be set before the resolution: several drivers
159
+ # reset the format back to their default when the frame size
160
+ # changes.
161
+ if self.fourcc:
162
+ self._apply_fourcc(self.fourcc)
163
+
164
+ # Set additional parameters if provided
165
+ if "width" in self.config and "height" in self.config:
166
+ self.set_frame_size(self.config["width"], self.config["height"])
167
+ if "fps" in self.config:
168
+ self.cap.set(cv2.CAP_PROP_FPS, self.config["fps"])
169
+
170
+ self.is_connected = True
171
+ self._warn_if_bandwidth_limited()
172
+ logger.info(f"Connected to webcam {src}")
173
+ return True
174
+ except Exception as e:
175
+ logger.error(f"Error connecting to webcam: {e}")
176
+ return False
177
+
178
+ def disconnect(self) -> bool:
179
+ """Disconnect from webcam."""
180
+ try:
181
+ if self.cap is not None:
182
+ self.cap.release()
183
+ self.cap = None
184
+ self.is_connected = False
185
+ logger.info("Disconnected from webcam")
186
+ return True
187
+ except Exception as e:
188
+ logger.error(f"Error disconnecting from webcam: {e}")
189
+ return False
190
+
191
+ def _read_implementation(self) -> tuple[bool, Optional[np.ndarray]]:
192
+ """
193
+ Read a single frame from the webcam.
194
+ Returns:
195
+ Tuple[bool, Optional[np.ndarray]]: (success, frame)
196
+ """
197
+ if not self.is_connected or self.cap is None:
198
+ return False, None
199
+ ret, frame = self.cap.read()
200
+ return ret, frame if ret else None
201
+
202
+ def set_exposure(self, value: float) -> bool:
203
+ """Set exposure (-13 to -1 for most webcams)."""
204
+ if not self.is_connected or self.cap is None:
205
+ return False
206
+
207
+ try:
208
+ self.cap.set(cv2.CAP_PROP_EXPOSURE, value)
209
+ self._exposure = value
210
+ return True
211
+ except Exception as e:
212
+ logger.error(f"Error setting exposure: {e}")
213
+ return False
214
+
215
+ def get_exposure(self) -> Optional[float]:
216
+ """Get current exposure."""
217
+ if not self.is_connected or self.cap is None:
218
+ return None
219
+
220
+ try:
221
+ return self.cap.get(cv2.CAP_PROP_EXPOSURE)
222
+ except Exception:
223
+ return self._exposure
224
+
225
+ def set_gain(self, value: float) -> bool:
226
+ """Set gain (0-255 for most webcams)."""
227
+ if not self.is_connected or self.cap is None:
228
+ return False
229
+
230
+ try:
231
+ self.cap.set(cv2.CAP_PROP_GAIN, value)
232
+ self._gain = value
233
+ return True
234
+ except Exception as e:
235
+ logger.error(f"Error setting gain: {e}")
236
+ return False
237
+
238
+ def get_gain(self) -> Optional[float]:
239
+ """Get current gain."""
240
+ if not self.is_connected or self.cap is None:
241
+ return None
242
+
243
+ try:
244
+ return self.cap.get(cv2.CAP_PROP_GAIN)
245
+ except Exception:
246
+ return self._gain
247
+
248
+ def get_frame_size(self) -> Optional[tuple[int, int]]:
249
+ """Get frame size."""
250
+ if not self.is_connected or self.cap is None:
251
+ return None
252
+
253
+ width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
254
+ height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
255
+ return (width, height)
256
+
257
+ def set_frame_size(self, width: int, height: int) -> bool:
258
+ """Set frame size."""
259
+ if self.cap is None:
260
+ return False
261
+ result1 = self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
262
+ result2 = self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
263
+ logger.info(f"Set webcam resolution to {width}x{height} (success: {result1 and result2})")
264
+ return result1 and result2
265
+
266
+ def _apply_fourcc(self, fourcc: str) -> bool:
267
+ """Request a pixel format from the device by its four-character code.
268
+
269
+ Returns True if OpenCV accepted the request. Note that acceptance does
270
+ not guarantee the driver actually switches format (see
271
+ :meth:`get_fourcc` / :meth:`_warn_if_bandwidth_limited`).
272
+ """
273
+ if self.cap is None:
274
+ return False
275
+ if len(fourcc) != 4:
276
+ logger.warning("Ignoring invalid fourcc %r (must be 4 characters, e.g. 'MJPG')", fourcc)
277
+ return False
278
+ # VideoWriter_fourcc moved to VideoWriter.fourcc in newer OpenCV builds.
279
+ fourcc_fn = getattr(cv2, "VideoWriter_fourcc", None) or cv2.VideoWriter.fourcc
280
+ code = fourcc_fn(*fourcc)
281
+ ok = bool(self.cap.set(cv2.CAP_PROP_FOURCC, code))
282
+ logger.info("Requested pixel format %s (accepted: %s)", fourcc, ok)
283
+ return ok
284
+
285
+ def get_fourcc(self) -> Optional[str]:
286
+ """Return the currently negotiated pixel format as a 4-char code.
287
+
288
+ Returns None if not connected. Some backends (notably MSMF) report an
289
+ empty string even while delivering a compressed stream.
290
+ """
291
+ if not self.is_connected or self.cap is None:
292
+ return None
293
+ value = int(self.cap.get(cv2.CAP_PROP_FOURCC))
294
+ return "".join(chr((value >> (8 * i)) & 0xFF) for i in range(4)).strip("\x00 ")
295
+
296
+ def _warn_if_bandwidth_limited(self) -> None:
297
+ """Log a warning when a high-res stream negotiated an uncompressed format.
298
+
299
+ This is the common cause of a webcam that requests 1080p30 but only
300
+ delivers a handful of frames per second: an uncompressed format (e.g.
301
+ YUY2) saturates the USB link. Requesting ``fourcc='MJPG'`` or a
302
+ different ``backend`` usually resolves it.
303
+ """
304
+ size = self.get_frame_size()
305
+ if not size:
306
+ return
307
+ width, height = size
308
+ if width * height < _HIGH_RES_PIXELS:
309
+ return
310
+ negotiated = self.get_fourcc()
311
+ # MSMF reports an empty code even on a fast compressed stream, so only
312
+ # warn when we positively identify an uncompressed format.
313
+ if negotiated and negotiated.upper() in _UNCOMPRESSED_FOURCCS:
314
+ logger.warning(
315
+ "Webcam negotiated uncompressed %s at %dx%d; USB bandwidth may cap the "
316
+ "frame rate well below the requested value. Pass fourcc='MJPG', or try a "
317
+ "different backend (e.g. backend='msmf' on Windows).",
318
+ negotiated,
319
+ width,
320
+ height,
321
+ )
322
+
323
+ def get_fps(self) -> Optional[float]:
324
+ """Get FPS."""
325
+ if not self.is_connected or self.cap is None:
326
+ return None
327
+ return self.cap.get(cv2.CAP_PROP_FPS)
328
+
329
+ def set_fps(self, fps: float) -> bool:
330
+ """Set FPS."""
331
+ if not self.is_connected or self.cap is None:
332
+ return False
333
+ self.cap.set(cv2.CAP_PROP_FPS, fps)
334
+ return True
335
+
336
+ def enable_auto_exposure(self, enable: bool = True) -> bool:
337
+ """
338
+ Enable or disable auto exposure for webcam.
339
+ """
340
+ if not self.is_connected or self.cap is None:
341
+ return False
342
+ try:
343
+ # OpenCV expects 0.75 for auto, 0.25 for manual (on many webcams)
344
+ value = 0.75 if enable else 0.25
345
+ result = self.cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, value)
346
+ logger.info(f"Set auto exposure to {enable} (cv2 value: {value})")
347
+ return result
348
+ except Exception as e:
349
+ logger.error(f"Error setting auto exposure: {e}")
350
+ return False
351
+
352
+ @classmethod
353
+ def discover(cls) -> list[DeviceInfo]:
354
+ try:
355
+ devices: list[DeviceInfo] = []
356
+ from cv2.videoio_registry import getBackendName
357
+ from cv2_enumerate_cameras import enumerate_cameras
358
+
359
+ camera_list = []
360
+ if platform.system() == "Windows":
361
+ backends = [cv2.CAP_DSHOW, cv2.CAP_MSMF]
362
+ elif platform.system() == "Darwin": # macOS
363
+ backends = [cv2.CAP_AVFOUNDATION]
364
+ else: # Linux
365
+ backends = [cv2.CAP_V4L2]
366
+
367
+ for backend in backends:
368
+ camera_list.extend(enumerate_cameras(backend))
369
+
370
+ # Sort camera list by index
371
+ camera_list.sort(key=lambda cam: cam.index)
372
+
373
+ for camera_info in camera_list:
374
+ # OS enumeration index -> not a stable identifier across replug.
375
+ devices.append(
376
+ DeviceInfo(
377
+ device_id=f"{camera_info.backend}:{camera_info.index}:{camera_info.path}",
378
+ index=camera_info.index,
379
+ name=camera_info.name,
380
+ driver="opencv",
381
+ id_stable=False,
382
+ metadata={
383
+ "backend_index": camera_info.backend,
384
+ "backend_name": getBackendName(camera_info.backend),
385
+ },
386
+ )
387
+ )
388
+ logger.info(f"Found {cls.__name__} input device: {devices}")
389
+ return devices
390
+ except ImportError:
391
+ logger.warning(
392
+ "cv2-enumerate-cameras module not available. Install cv2-enumerate-cameras "
393
+ "to list available (web)cameras."
394
+ )
395
+ return []
396
+
397
+ @classmethod
398
+ def get_config_schema(cls) -> dict[str, Any]:
399
+ """Get configuration schema for webcam capture"""
400
+ warnings.warn(
401
+ "get_config_schema() is deprecated and will be removed in a future release; "
402
+ "UI form schemas belong in the consuming application.",
403
+ DeprecationWarning,
404
+ stacklevel=2,
405
+ )
406
+ return {
407
+ "title": "Webcam Configuration",
408
+ "description": "Configure USB webcam or built-in camera settings",
409
+ "fields": [
410
+ {
411
+ "name": "source",
412
+ "label": "Camera Index",
413
+ "type": "number",
414
+ "min": 0,
415
+ "max": 10,
416
+ "placeholder": "0",
417
+ "description": "Camera device index (0 for default, 1 for second camera, etc.)",
418
+ "required": False,
419
+ "default": 0,
420
+ },
421
+ {
422
+ "name": "width",
423
+ "label": "Width",
424
+ "type": "number",
425
+ "min": 160,
426
+ "max": 4096,
427
+ "placeholder": "1920",
428
+ "description": "Frame width in pixels",
429
+ "required": False,
430
+ },
431
+ {
432
+ "name": "height",
433
+ "label": "Height",
434
+ "type": "number",
435
+ "min": 120,
436
+ "max": 2160,
437
+ "placeholder": "1080",
438
+ "description": "Frame height in pixels",
439
+ "required": False,
440
+ },
441
+ {
442
+ "name": "fps",
443
+ "label": "Frame Rate (FPS)",
444
+ "type": "number",
445
+ "min": 1,
446
+ "max": 120,
447
+ "placeholder": "30",
448
+ "description": "Frames per second",
449
+ "required": False,
450
+ "default": 30,
451
+ },
452
+ {
453
+ "name": "exposure",
454
+ "label": "Exposure",
455
+ "type": "number",
456
+ "min": -13,
457
+ "max": -1,
458
+ "step": 1,
459
+ "placeholder": "-6",
460
+ "description": "Manual exposure value (-13 to -1, lower = brighter)",
461
+ "required": False,
462
+ },
463
+ {
464
+ "name": "gain",
465
+ "label": "Gain",
466
+ "type": "number",
467
+ "min": 0,
468
+ "max": 255,
469
+ "placeholder": "0",
470
+ "description": "Camera gain (0-255)",
471
+ "required": False,
472
+ },
473
+ ],
474
+ }
475
+
476
+
477
+ if __name__ == "__main__":
478
+ # Example usage
479
+
480
+ print("Discovering webcams...")
481
+ webcams = WebcamCapture.discover()
482
+ print(f"\nFound {len(webcams)} webcam(s):\n")
483
+ for cam in webcams:
484
+ print(f" [{cam['index']}] {cam['name']}")
485
+
486
+ if webcams:
487
+ print("\nTesting first camera...")
488
+ camera = WebcamCapture(source=0)
489
+ if camera.connect():
490
+ print("Webcam connected successfully.")
491
+ print(f"Exposure: {camera.get_exposure()}")
492
+ print(f"Gain: {camera.get_gain()}")
493
+ print(f"Frame size: {camera.get_frame_size()}")
494
+
495
+ # Read a few frames
496
+ print("Press 'q' to quit...")
497
+ while camera.is_connected:
498
+ ret, frame = camera.read()
499
+ if ret:
500
+ cv2.imshow("Webcam", frame) # type: ignore
501
+ if cv2.waitKey(1) & 0xFF == ord("q"):
502
+ break
503
+ else:
504
+ print("Failed to read frame from webcam.")
505
+ break
506
+
507
+ camera.disconnect()
508
+ else:
509
+ print("Failed to connect to webcam.")
510
+ else:
511
+ print("\nNo webcams found.")