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,800 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .video_capture_base import VideoCaptureBase
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import mss
|
|
14
|
+
except ImportError:
|
|
15
|
+
mss = None
|
|
16
|
+
logging.warning("mss is not installed. Install it with 'pip install mss' to use ScreenCapture.")
|
|
17
|
+
|
|
18
|
+
import platform
|
|
19
|
+
|
|
20
|
+
SYSTEM = platform.system()
|
|
21
|
+
|
|
22
|
+
# Windows support
|
|
23
|
+
try:
|
|
24
|
+
import win32con
|
|
25
|
+
import win32gui
|
|
26
|
+
|
|
27
|
+
WINDOWS_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
win32gui = None
|
|
30
|
+
win32con = None
|
|
31
|
+
WINDOWS_AVAILABLE = False
|
|
32
|
+
if SYSTEM == "Windows":
|
|
33
|
+
logging.warning(
|
|
34
|
+
"pywin32 is not installed. Install it with 'pip install pywin32' to discover windows."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# macOS support
|
|
38
|
+
try:
|
|
39
|
+
if SYSTEM == "Darwin":
|
|
40
|
+
from Quartz import (
|
|
41
|
+
CGWindowListCopyWindowInfo,
|
|
42
|
+
kCGNullWindowID,
|
|
43
|
+
kCGWindowBounds,
|
|
44
|
+
kCGWindowLayer,
|
|
45
|
+
kCGWindowListOptionOnScreenOnly,
|
|
46
|
+
kCGWindowName,
|
|
47
|
+
kCGWindowNumber,
|
|
48
|
+
kCGWindowOwnerName,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
MACOS_AVAILABLE = True
|
|
52
|
+
else:
|
|
53
|
+
MACOS_AVAILABLE = False
|
|
54
|
+
except ImportError:
|
|
55
|
+
MACOS_AVAILABLE = False
|
|
56
|
+
if SYSTEM == "Darwin":
|
|
57
|
+
logging.warning(
|
|
58
|
+
"pyobjc-framework-Quartz is not installed. Install it with "
|
|
59
|
+
"'pip install pyobjc-framework-Quartz' to discover windows on macOS."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Linux support
|
|
63
|
+
try:
|
|
64
|
+
if SYSTEM == "Linux":
|
|
65
|
+
import subprocess
|
|
66
|
+
|
|
67
|
+
LINUX_AVAILABLE = True
|
|
68
|
+
else:
|
|
69
|
+
LINUX_AVAILABLE = False
|
|
70
|
+
except ImportError:
|
|
71
|
+
LINUX_AVAILABLE = False
|
|
72
|
+
|
|
73
|
+
# Configure logging
|
|
74
|
+
logger = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ScreenCapture(VideoCaptureBase):
|
|
78
|
+
"""
|
|
79
|
+
Capture class for grabbing frames from a region of the screen.
|
|
80
|
+
Args:
|
|
81
|
+
x (int): Top-left x coordinate
|
|
82
|
+
y (int): Top-left y coordinate
|
|
83
|
+
w (int): Width of region
|
|
84
|
+
h (int): Height of region
|
|
85
|
+
fps (float): Target FPS (default 30)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
has_discovery = True
|
|
89
|
+
supports_exposure = False # Screen pixels have no controllable exposure
|
|
90
|
+
supports_gain = False # Screen pixels have no controllable gain
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
x: int = 0,
|
|
95
|
+
y: int = 0,
|
|
96
|
+
w: int = 640,
|
|
97
|
+
h: int = 480,
|
|
98
|
+
fps: float = 30.0,
|
|
99
|
+
*,
|
|
100
|
+
hwnd: Optional[Any] = None,
|
|
101
|
+
**kwargs,
|
|
102
|
+
):
|
|
103
|
+
"""Initialize the screen-region capture.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
x: Left edge of the capture region (pixels from the left).
|
|
107
|
+
y: Top edge of the capture region (pixels from the top).
|
|
108
|
+
w: Width of the capture region in pixels.
|
|
109
|
+
h: Height of the capture region in pixels.
|
|
110
|
+
fps: Target frame rate in frames per second.
|
|
111
|
+
hwnd: Optional native window handle to track (set automatically by
|
|
112
|
+
:meth:`from_window`).
|
|
113
|
+
**kwargs: Additional passthrough options stored on ``self.config``.
|
|
114
|
+
"""
|
|
115
|
+
# ``hwnd`` was previously accepted through ``**kwargs``; forward it
|
|
116
|
+
# unchanged so ``self.config`` keeps carrying it when provided.
|
|
117
|
+
if hwnd is not None:
|
|
118
|
+
kwargs["hwnd"] = hwnd
|
|
119
|
+
super().__init__(**kwargs)
|
|
120
|
+
self.x = x
|
|
121
|
+
self.y = y
|
|
122
|
+
self.w = w
|
|
123
|
+
self.h = h
|
|
124
|
+
self.fps = fps
|
|
125
|
+
self.monitor = {"top": y, "left": x, "width": w, "height": h}
|
|
126
|
+
self._thread_local = threading.local()
|
|
127
|
+
self.is_connected = False
|
|
128
|
+
self.time_of_last_frame = 0.0
|
|
129
|
+
self.hwnd = hwnd # Optional window handle for tracking
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_window(cls, window_id: Any, fps: float = 30.0, **kwargs):
|
|
133
|
+
"""
|
|
134
|
+
Create a ScreenCapture instance configured to capture a specific window.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
window_id: Window identifier (hwnd on Windows, window number on macOS,
|
|
138
|
+
window ID on Linux)
|
|
139
|
+
fps: Target frame rate
|
|
140
|
+
**kwargs: Additional parameters
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
ScreenCapture: Configured instance
|
|
144
|
+
"""
|
|
145
|
+
if SYSTEM == "Windows":
|
|
146
|
+
if not WINDOWS_AVAILABLE:
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
"Window capture requires pywin32. Install with 'pip install pywin32'"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
rect = win32gui.GetWindowRect(window_id)
|
|
153
|
+
left, top, right, bottom = rect
|
|
154
|
+
width = right - left
|
|
155
|
+
height = bottom - top
|
|
156
|
+
|
|
157
|
+
instance = cls(x=left, y=top, w=width, h=height, fps=fps, hwnd=window_id, **kwargs)
|
|
158
|
+
return instance
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"Failed to get window dimensions for hwnd {window_id}: {e}"
|
|
163
|
+
) from e
|
|
164
|
+
|
|
165
|
+
elif SYSTEM == "Darwin":
|
|
166
|
+
if not MACOS_AVAILABLE:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
"Window capture requires pyobjc-framework-Quartz. "
|
|
169
|
+
"Install with 'pip install pyobjc-framework-Quartz'"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
# Get window info
|
|
174
|
+
window_list = CGWindowListCopyWindowInfo(
|
|
175
|
+
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
|
176
|
+
)
|
|
177
|
+
for window in window_list:
|
|
178
|
+
if window.get(kCGWindowNumber) == window_id:
|
|
179
|
+
bounds = window.get(kCGWindowBounds)
|
|
180
|
+
left = int(bounds["X"])
|
|
181
|
+
top = int(bounds["Y"])
|
|
182
|
+
width = int(bounds["Width"])
|
|
183
|
+
height = int(bounds["Height"])
|
|
184
|
+
|
|
185
|
+
instance = cls(
|
|
186
|
+
x=left, y=top, w=width, h=height, fps=fps, hwnd=window_id, **kwargs
|
|
187
|
+
)
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
raise RuntimeError(f"Window {window_id} not found")
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
f"Failed to get window dimensions for window {window_id}: {e}"
|
|
195
|
+
) from e
|
|
196
|
+
|
|
197
|
+
elif SYSTEM == "Linux":
|
|
198
|
+
if not LINUX_AVAILABLE:
|
|
199
|
+
raise RuntimeError("Window capture requires wmctrl or xdotool")
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
# Try to get window geometry using xdotool
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["xdotool", "getwindowgeometry", "--shell", str(window_id)],
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
check=True,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Parse output
|
|
211
|
+
geometry = {}
|
|
212
|
+
for line in result.stdout.strip().split("\n"):
|
|
213
|
+
if "=" in line:
|
|
214
|
+
key, value = line.split("=", 1)
|
|
215
|
+
geometry[key] = int(value)
|
|
216
|
+
|
|
217
|
+
left = geometry.get("X", 0)
|
|
218
|
+
top = geometry.get("Y", 0)
|
|
219
|
+
width = geometry.get("WIDTH", 640)
|
|
220
|
+
height = geometry.get("HEIGHT", 480)
|
|
221
|
+
|
|
222
|
+
instance = cls(x=left, y=top, w=width, h=height, fps=fps, hwnd=window_id, **kwargs)
|
|
223
|
+
return instance
|
|
224
|
+
|
|
225
|
+
except subprocess.CalledProcessError:
|
|
226
|
+
# Try wmctrl as fallback
|
|
227
|
+
try:
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
["wmctrl", "-lG"], capture_output=True, text=True, check=True
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
for line in result.stdout.strip().split("\n"):
|
|
233
|
+
parts = line.split(None, 7)
|
|
234
|
+
if len(parts) >= 7:
|
|
235
|
+
win_id = parts[0]
|
|
236
|
+
if win_id == hex(window_id) or win_id == str(window_id):
|
|
237
|
+
left = int(parts[2])
|
|
238
|
+
top = int(parts[3])
|
|
239
|
+
width = int(parts[4])
|
|
240
|
+
height = int(parts[5])
|
|
241
|
+
|
|
242
|
+
instance = cls(
|
|
243
|
+
x=left,
|
|
244
|
+
y=top,
|
|
245
|
+
w=width,
|
|
246
|
+
h=height,
|
|
247
|
+
fps=fps,
|
|
248
|
+
hwnd=window_id,
|
|
249
|
+
**kwargs,
|
|
250
|
+
)
|
|
251
|
+
return instance
|
|
252
|
+
|
|
253
|
+
raise RuntimeError(f"Window {window_id} not found in wmctrl output")
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise RuntimeError(f"Failed to get window dimensions using wmctrl: {e}") from e
|
|
257
|
+
|
|
258
|
+
except Exception as e:
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
f"Failed to get window dimensions for window {window_id}: {e}"
|
|
261
|
+
) from e
|
|
262
|
+
|
|
263
|
+
else:
|
|
264
|
+
raise RuntimeError(f"Window capture not supported on {SYSTEM}")
|
|
265
|
+
|
|
266
|
+
def connect(self) -> bool:
|
|
267
|
+
if mss is None:
|
|
268
|
+
logger.error("mss is not installed. Cannot use ScreenCapture.")
|
|
269
|
+
return False
|
|
270
|
+
self.is_connected = True
|
|
271
|
+
logger.info(
|
|
272
|
+
f"ScreenCapture connected to region x={self.x}, y={self.y}, w={self.w}, h={self.h}"
|
|
273
|
+
)
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
def disconnect(self) -> bool:
|
|
277
|
+
self.is_connected = False
|
|
278
|
+
logger.info("ScreenCapture disconnected.")
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
def _get_sct(self):
|
|
282
|
+
if not hasattr(self._thread_local, "sct") or self._thread_local.sct is None:
|
|
283
|
+
if mss is None:
|
|
284
|
+
raise RuntimeError("mss is not installed. Cannot create screen capture.")
|
|
285
|
+
self._thread_local.sct = mss.mss()
|
|
286
|
+
return self._thread_local.sct
|
|
287
|
+
|
|
288
|
+
def _read_implementation(self) -> tuple[bool, Optional[np.ndarray]]:
|
|
289
|
+
if not self.is_connected:
|
|
290
|
+
return False, None
|
|
291
|
+
sct = self._get_sct()
|
|
292
|
+
# Real-time playback control
|
|
293
|
+
if self.fps > 0:
|
|
294
|
+
frame_duration = 1.0 / self.fps
|
|
295
|
+
now = time.time()
|
|
296
|
+
elapsed = now - self.time_of_last_frame
|
|
297
|
+
if elapsed < frame_duration:
|
|
298
|
+
time.sleep(frame_duration - elapsed)
|
|
299
|
+
self.time_of_last_frame = time.time()
|
|
300
|
+
img = np.array(sct.grab(self.monitor))
|
|
301
|
+
# Convert BGRA to BGR
|
|
302
|
+
frame = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
|
303
|
+
return True, frame
|
|
304
|
+
|
|
305
|
+
def set_exposure(self, value: float) -> bool:
|
|
306
|
+
logger.warning("Exposure control not applicable for screen capture.")
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
def get_exposure(self) -> Optional[float]:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def set_gain(self, value: float) -> bool:
|
|
313
|
+
logger.warning("Gain control not applicable for screen capture.")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def get_gain(self) -> Optional[float]:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def enable_auto_exposure(self, enable: bool = True) -> bool:
|
|
320
|
+
logger.warning("Auto exposure control not applicable for screen capture.")
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
def get_frame_size(self) -> Optional[tuple[int, int]]:
|
|
324
|
+
return (self.w, self.h)
|
|
325
|
+
|
|
326
|
+
def set_frame_size(self, width: int, height: int) -> bool:
|
|
327
|
+
self.w = width
|
|
328
|
+
self.h = height
|
|
329
|
+
self.monitor["width"] = width
|
|
330
|
+
self.monitor["height"] = height
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
def get_fps(self) -> Optional[float]:
|
|
334
|
+
return self.fps
|
|
335
|
+
|
|
336
|
+
def set_fps(self, fps: float) -> bool:
|
|
337
|
+
self.fps = fps
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def discover(cls) -> list:
|
|
342
|
+
"""
|
|
343
|
+
Discover available screen capture sources (monitors/displays and windows).
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
list: List of dictionaries containing screen/window information.
|
|
347
|
+
Each dict contains:
|
|
348
|
+
- For monitors: {'type': 'monitor', 'index': int, 'name': str, 'width': int,
|
|
349
|
+
'height': int, 'left': int, 'top': int}
|
|
350
|
+
- For windows: {'type': 'window', 'id': str/int, 'name': str, 'title': str,
|
|
351
|
+
'width': int, 'height': int, 'left': int, 'top': int, 'is_visible': bool}
|
|
352
|
+
"""
|
|
353
|
+
devices = []
|
|
354
|
+
|
|
355
|
+
# Discover monitors
|
|
356
|
+
if mss is None:
|
|
357
|
+
logger.warning("mss module not available. Cannot discover screen sources.")
|
|
358
|
+
else:
|
|
359
|
+
try:
|
|
360
|
+
with mss.mss() as sct:
|
|
361
|
+
# Get all monitors (index 0 is typically all monitors combined)
|
|
362
|
+
monitors = sct.monitors
|
|
363
|
+
|
|
364
|
+
for i, monitor in enumerate(monitors):
|
|
365
|
+
device_data = {
|
|
366
|
+
"type": "monitor",
|
|
367
|
+
"index": i,
|
|
368
|
+
"id": f"monitor_{i}",
|
|
369
|
+
"name": f"Monitor {i}" if i > 0 else "All Monitors",
|
|
370
|
+
"width": monitor["width"],
|
|
371
|
+
"height": monitor["height"],
|
|
372
|
+
"left": monitor["left"],
|
|
373
|
+
"top": monitor["top"],
|
|
374
|
+
}
|
|
375
|
+
devices.append(device_data)
|
|
376
|
+
logger.info(f"Found screen source: {device_data}")
|
|
377
|
+
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.error(f"Error discovering screen sources: {e}")
|
|
380
|
+
|
|
381
|
+
# Discover windows - platform specific
|
|
382
|
+
if SYSTEM == "Windows":
|
|
383
|
+
devices.extend(cls._discover_windows_windows())
|
|
384
|
+
elif SYSTEM == "Darwin":
|
|
385
|
+
devices.extend(cls._discover_windows_macos())
|
|
386
|
+
elif SYSTEM == "Linux":
|
|
387
|
+
devices.extend(cls._discover_windows_linux())
|
|
388
|
+
else:
|
|
389
|
+
logger.info(f"Window discovery not supported on {SYSTEM}")
|
|
390
|
+
|
|
391
|
+
return devices
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def _discover_windows_windows(cls) -> list:
|
|
395
|
+
"""Discover windows on Windows using win32gui."""
|
|
396
|
+
if not WINDOWS_AVAILABLE:
|
|
397
|
+
logger.info("Window discovery not available (pywin32 not installed)")
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
windows = []
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
|
|
404
|
+
def window_callback(hwnd, extra):
|
|
405
|
+
"""Callback function to enumerate windows"""
|
|
406
|
+
if not win32gui.IsWindowVisible(hwnd):
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
# Get window title
|
|
410
|
+
title = win32gui.GetWindowText(hwnd)
|
|
411
|
+
|
|
412
|
+
# Skip windows without titles or with empty titles
|
|
413
|
+
if not title or len(title.strip()) == 0:
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
# Get window rectangle
|
|
417
|
+
try:
|
|
418
|
+
rect = win32gui.GetWindowRect(hwnd)
|
|
419
|
+
left, top, right, bottom = rect
|
|
420
|
+
width = right - left
|
|
421
|
+
height = bottom - top
|
|
422
|
+
|
|
423
|
+
# Skip windows that are too small (likely not real windows)
|
|
424
|
+
if width < 50 or height < 50:
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
# Get class name for additional context
|
|
428
|
+
try:
|
|
429
|
+
class_name = win32gui.GetClassName(hwnd)
|
|
430
|
+
except Exception:
|
|
431
|
+
class_name = "Unknown"
|
|
432
|
+
|
|
433
|
+
window_data = {
|
|
434
|
+
"type": "window",
|
|
435
|
+
"hwnd": hwnd,
|
|
436
|
+
"id": f"window_{hwnd}",
|
|
437
|
+
"name": title,
|
|
438
|
+
"title": title,
|
|
439
|
+
"class_name": class_name,
|
|
440
|
+
"width": width,
|
|
441
|
+
"height": height,
|
|
442
|
+
"left": left,
|
|
443
|
+
"top": top,
|
|
444
|
+
"is_visible": True,
|
|
445
|
+
}
|
|
446
|
+
windows.append(window_data)
|
|
447
|
+
|
|
448
|
+
except Exception as e:
|
|
449
|
+
logger.debug(f"Error getting window rect for hwnd {hwnd}: {e}")
|
|
450
|
+
|
|
451
|
+
# Enumerate all windows
|
|
452
|
+
win32gui.EnumWindows(window_callback, None)
|
|
453
|
+
|
|
454
|
+
# Sort windows by title for consistent ordering
|
|
455
|
+
windows.sort(key=lambda w: w["title"].lower())
|
|
456
|
+
|
|
457
|
+
logger.info(f"Found {len(windows)} visible windows")
|
|
458
|
+
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.error(f"Error discovering windows: {e}")
|
|
461
|
+
|
|
462
|
+
return windows
|
|
463
|
+
|
|
464
|
+
@classmethod
|
|
465
|
+
def _discover_windows_macos(cls) -> list:
|
|
466
|
+
"""Discover windows on macOS using Quartz."""
|
|
467
|
+
if not MACOS_AVAILABLE:
|
|
468
|
+
logger.info("Window discovery not available (pyobjc-framework-Quartz not installed)")
|
|
469
|
+
return []
|
|
470
|
+
|
|
471
|
+
windows = []
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
# Get list of all on-screen windows
|
|
475
|
+
window_list = CGWindowListCopyWindowInfo(
|
|
476
|
+
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
for window in window_list:
|
|
480
|
+
# Get window properties
|
|
481
|
+
window_layer = window.get(kCGWindowLayer, 0)
|
|
482
|
+
|
|
483
|
+
# Skip desktop and other background windows (layer > 0)
|
|
484
|
+
if window_layer != 0:
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
window_name = window.get(kCGWindowName, "")
|
|
488
|
+
owner_name = window.get(kCGWindowOwnerName, "")
|
|
489
|
+
window_number = window.get(kCGWindowNumber, 0)
|
|
490
|
+
bounds = window.get(kCGWindowBounds, {})
|
|
491
|
+
|
|
492
|
+
# Skip windows without names
|
|
493
|
+
if not window_name or len(window_name.strip()) == 0:
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
# Get dimensions
|
|
497
|
+
left = int(bounds.get("X", 0))
|
|
498
|
+
top = int(bounds.get("Y", 0))
|
|
499
|
+
width = int(bounds.get("Width", 0))
|
|
500
|
+
height = int(bounds.get("Height", 0))
|
|
501
|
+
|
|
502
|
+
# Skip windows that are too small
|
|
503
|
+
if width < 50 or height < 50:
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
window_data = {
|
|
507
|
+
"type": "window",
|
|
508
|
+
"window_number": window_number,
|
|
509
|
+
"id": f"window_{window_number}",
|
|
510
|
+
"name": window_name,
|
|
511
|
+
"title": window_name,
|
|
512
|
+
"owner": owner_name,
|
|
513
|
+
"width": width,
|
|
514
|
+
"height": height,
|
|
515
|
+
"left": left,
|
|
516
|
+
"top": top,
|
|
517
|
+
"is_visible": True,
|
|
518
|
+
}
|
|
519
|
+
windows.append(window_data)
|
|
520
|
+
|
|
521
|
+
# Sort windows by title for consistent ordering
|
|
522
|
+
windows.sort(key=lambda w: w["title"].lower())
|
|
523
|
+
|
|
524
|
+
logger.info(f"Found {len(windows)} visible windows")
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
logger.error(f"Error discovering windows on macOS: {e}")
|
|
528
|
+
|
|
529
|
+
return windows
|
|
530
|
+
|
|
531
|
+
@classmethod
|
|
532
|
+
def _discover_windows_linux(cls) -> list:
|
|
533
|
+
"""Discover windows on Linux using wmctrl or xdotool."""
|
|
534
|
+
if not LINUX_AVAILABLE:
|
|
535
|
+
return []
|
|
536
|
+
|
|
537
|
+
windows = []
|
|
538
|
+
|
|
539
|
+
# Try wmctrl first (more reliable)
|
|
540
|
+
try:
|
|
541
|
+
result = subprocess.run(["wmctrl", "-lGp"], capture_output=True, text=True, check=True)
|
|
542
|
+
|
|
543
|
+
for line in result.stdout.strip().split("\n"):
|
|
544
|
+
parts = line.split(None, 8)
|
|
545
|
+
if len(parts) >= 9:
|
|
546
|
+
win_id = parts[0]
|
|
547
|
+
desktop = parts[1]
|
|
548
|
+
pid = parts[2]
|
|
549
|
+
left = int(parts[3])
|
|
550
|
+
top = int(parts[4])
|
|
551
|
+
width = int(parts[5])
|
|
552
|
+
height = int(parts[6])
|
|
553
|
+
title = parts[8] if len(parts) > 8 else ""
|
|
554
|
+
|
|
555
|
+
# Skip windows without titles
|
|
556
|
+
if not title or len(title.strip()) == 0:
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
# Skip windows that are too small
|
|
560
|
+
if width < 50 or height < 50:
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
# Convert hex window ID to int
|
|
564
|
+
try:
|
|
565
|
+
window_id_int = int(win_id, 16)
|
|
566
|
+
except Exception:
|
|
567
|
+
window_id_int = win_id
|
|
568
|
+
|
|
569
|
+
window_data = {
|
|
570
|
+
"type": "window",
|
|
571
|
+
"window_id": window_id_int,
|
|
572
|
+
"id": f"window_{win_id}",
|
|
573
|
+
"name": title,
|
|
574
|
+
"title": title,
|
|
575
|
+
"desktop": desktop,
|
|
576
|
+
"pid": pid,
|
|
577
|
+
"width": width,
|
|
578
|
+
"height": height,
|
|
579
|
+
"left": left,
|
|
580
|
+
"top": top,
|
|
581
|
+
"is_visible": True,
|
|
582
|
+
}
|
|
583
|
+
windows.append(window_data)
|
|
584
|
+
|
|
585
|
+
# Sort windows by title for consistent ordering
|
|
586
|
+
windows.sort(key=lambda w: w["title"].lower())
|
|
587
|
+
|
|
588
|
+
logger.info(f"Found {len(windows)} visible windows using wmctrl")
|
|
589
|
+
|
|
590
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
591
|
+
# Try xdotool as fallback
|
|
592
|
+
try:
|
|
593
|
+
result = subprocess.run(
|
|
594
|
+
["xdotool", "search", "--onlyvisible", "--name", ".*"],
|
|
595
|
+
capture_output=True,
|
|
596
|
+
text=True,
|
|
597
|
+
check=True,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
window_ids = result.stdout.strip().split("\n")
|
|
601
|
+
|
|
602
|
+
for win_id in window_ids:
|
|
603
|
+
if not win_id:
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
# Get window name
|
|
608
|
+
name_result = subprocess.run(
|
|
609
|
+
["xdotool", "getwindowname", win_id],
|
|
610
|
+
capture_output=True,
|
|
611
|
+
text=True,
|
|
612
|
+
check=True,
|
|
613
|
+
)
|
|
614
|
+
title = name_result.stdout.strip()
|
|
615
|
+
|
|
616
|
+
if not title or len(title.strip()) == 0:
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
# Get window geometry
|
|
620
|
+
geom_result = subprocess.run(
|
|
621
|
+
["xdotool", "getwindowgeometry", "--shell", win_id],
|
|
622
|
+
capture_output=True,
|
|
623
|
+
text=True,
|
|
624
|
+
check=True,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Parse geometry
|
|
628
|
+
geometry = {}
|
|
629
|
+
for line in geom_result.stdout.strip().split("\n"):
|
|
630
|
+
if "=" in line:
|
|
631
|
+
key, value = line.split("=", 1)
|
|
632
|
+
try:
|
|
633
|
+
geometry[key] = int(value)
|
|
634
|
+
except Exception:
|
|
635
|
+
geometry[key] = value
|
|
636
|
+
|
|
637
|
+
left = geometry.get("X", 0)
|
|
638
|
+
top = geometry.get("Y", 0)
|
|
639
|
+
width = geometry.get("WIDTH", 0)
|
|
640
|
+
height = geometry.get("HEIGHT", 0)
|
|
641
|
+
|
|
642
|
+
# Skip windows that are too small
|
|
643
|
+
if width < 50 or height < 50:
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
window_data = {
|
|
647
|
+
"type": "window",
|
|
648
|
+
"window_id": int(win_id),
|
|
649
|
+
"id": f"window_{win_id}",
|
|
650
|
+
"name": title,
|
|
651
|
+
"title": title,
|
|
652
|
+
"width": width,
|
|
653
|
+
"height": height,
|
|
654
|
+
"left": left,
|
|
655
|
+
"top": top,
|
|
656
|
+
"is_visible": True,
|
|
657
|
+
}
|
|
658
|
+
windows.append(window_data)
|
|
659
|
+
|
|
660
|
+
except Exception as e:
|
|
661
|
+
logger.debug(f"Error getting info for window {win_id}: {e}")
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
# Sort windows by title for consistent ordering
|
|
665
|
+
windows.sort(key=lambda w: w["title"].lower())
|
|
666
|
+
|
|
667
|
+
logger.info(f"Found {len(windows)} visible windows using xdotool")
|
|
668
|
+
|
|
669
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
670
|
+
logger.warning(
|
|
671
|
+
"Neither wmctrl nor xdotool available. "
|
|
672
|
+
"Install one to discover windows on Linux."
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
return windows
|
|
676
|
+
|
|
677
|
+
@classmethod
|
|
678
|
+
def get_config_schema(cls) -> dict[str, Any]:
|
|
679
|
+
"""Get configuration schema for screen capture"""
|
|
680
|
+
warnings.warn(
|
|
681
|
+
"get_config_schema() is deprecated and will be removed in a future release; "
|
|
682
|
+
"UI form schemas belong in the consuming application.",
|
|
683
|
+
DeprecationWarning,
|
|
684
|
+
stacklevel=2,
|
|
685
|
+
)
|
|
686
|
+
return {
|
|
687
|
+
"title": "Screen Capture Configuration",
|
|
688
|
+
"description": "Configure screen/monitor capture settings",
|
|
689
|
+
"fields": [
|
|
690
|
+
{
|
|
691
|
+
"name": "x",
|
|
692
|
+
"label": "X Position",
|
|
693
|
+
"type": "number",
|
|
694
|
+
"min": 0,
|
|
695
|
+
"max": 5000,
|
|
696
|
+
"placeholder": "0",
|
|
697
|
+
"description": "Left edge of capture area (pixels from left)",
|
|
698
|
+
"required": False,
|
|
699
|
+
"default": 0,
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
"name": "y",
|
|
703
|
+
"label": "Y Position",
|
|
704
|
+
"type": "number",
|
|
705
|
+
"min": 0,
|
|
706
|
+
"max": 5000,
|
|
707
|
+
"placeholder": "0",
|
|
708
|
+
"description": "Top edge of capture area (pixels from top)",
|
|
709
|
+
"required": False,
|
|
710
|
+
"default": 0,
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
"name": "w",
|
|
714
|
+
"label": "Width",
|
|
715
|
+
"type": "number",
|
|
716
|
+
"min": 160,
|
|
717
|
+
"max": 5000,
|
|
718
|
+
"placeholder": "640",
|
|
719
|
+
"description": "Width of capture area in pixels",
|
|
720
|
+
"required": False,
|
|
721
|
+
"default": 640,
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
"name": "h",
|
|
725
|
+
"label": "Height",
|
|
726
|
+
"type": "number",
|
|
727
|
+
"min": 120,
|
|
728
|
+
"max": 5000,
|
|
729
|
+
"placeholder": "480",
|
|
730
|
+
"description": "Height of capture area in pixels",
|
|
731
|
+
"required": False,
|
|
732
|
+
"default": 480,
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
"name": "fps",
|
|
736
|
+
"label": "Frame Rate (FPS)",
|
|
737
|
+
"type": "number",
|
|
738
|
+
"min": 1,
|
|
739
|
+
"max": 60,
|
|
740
|
+
"placeholder": "30",
|
|
741
|
+
"description": "Frames per second for screen capture",
|
|
742
|
+
"required": False,
|
|
743
|
+
"default": 30,
|
|
744
|
+
},
|
|
745
|
+
],
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
if __name__ == "__main__":
|
|
750
|
+
# Example usage
|
|
751
|
+
|
|
752
|
+
sources = ScreenCapture.discover()
|
|
753
|
+
print(f"\nDiscovered {len(sources)} screen capture sources:\n")
|
|
754
|
+
|
|
755
|
+
# Display monitors
|
|
756
|
+
monitors = [s for s in sources if s.get("type") == "monitor"]
|
|
757
|
+
if monitors:
|
|
758
|
+
print(f"Monitors ({len(monitors)}):")
|
|
759
|
+
for screen in monitors:
|
|
760
|
+
print(
|
|
761
|
+
f" - {screen['name']} (#{screen['index']}): "
|
|
762
|
+
f"{screen['width']}x{screen['height']} at ({screen['left']}, {screen['top']})"
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
# Display windows
|
|
766
|
+
windows = [s for s in sources if s.get("type") == "window"]
|
|
767
|
+
if windows:
|
|
768
|
+
print(f"\nWindows ({len(windows)}):")
|
|
769
|
+
for _i, window in enumerate(windows[:20]): # Show first 20 windows
|
|
770
|
+
print(
|
|
771
|
+
f" - {window['title'][:60]:60s} | "
|
|
772
|
+
f"{window['width']}x{window['height']} at ({window['left']}, {window['top']})"
|
|
773
|
+
)
|
|
774
|
+
if len(windows) > 20:
|
|
775
|
+
print(f" ... and {len(windows) - 20} more windows")
|
|
776
|
+
|
|
777
|
+
print("\n" + "=" * 80)
|
|
778
|
+
print("Example: Capture from a specific monitor")
|
|
779
|
+
print("=" * 80)
|
|
780
|
+
|
|
781
|
+
camera = ScreenCapture(x=100, y=100, w=800, h=600, fps=30)
|
|
782
|
+
if camera.connect():
|
|
783
|
+
print("Screen capture connected successfully.")
|
|
784
|
+
print(f"Frame size: {camera.get_frame_size()}")
|
|
785
|
+
print(f"FPS: {camera.get_fps()}")
|
|
786
|
+
|
|
787
|
+
# Read a few frames
|
|
788
|
+
frame_count = 0
|
|
789
|
+
while camera.is_connected and frame_count < 100:
|
|
790
|
+
ret, frame = camera.read()
|
|
791
|
+
if ret:
|
|
792
|
+
cv2.imshow("Screen Capture", frame) # type: ignore
|
|
793
|
+
frame_count += 1
|
|
794
|
+
if cv2.waitKey(1) & 0xFF == ord("q"):
|
|
795
|
+
break
|
|
796
|
+
|
|
797
|
+
camera.stop()
|
|
798
|
+
camera.disconnect()
|
|
799
|
+
else:
|
|
800
|
+
print("Failed to connect to screen capture.")
|