dxcam 0.4.0.dev1__tar.gz → 0.4.0.dev2__tar.gz
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.
- {dxcam-0.4.0.dev1/dxcam.egg-info → dxcam-0.4.0.dev2}/PKG-INFO +30 -13
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/README.md +29 -12
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/__init__.py +55 -74
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/device.py +31 -2
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_duplicator.py +2 -2
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/stagesurf.py +18 -10
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/winrt_duplicator.py +11 -35
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/dxcam.py +81 -82
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.c +753 -193
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/base.py +2 -2
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/backend.py +2 -2
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/capture_worker.py +24 -29
- dxcam-0.4.0.dev2/dxcam/runtime/frame_buffer.py +167 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2/dxcam.egg-info}/PKG-INFO +30 -13
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/SOURCES.txt +3 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/pyproject.toml +12 -5
- dxcam-0.4.0.dev2/tests/test_capture_lifecycle.py +665 -0
- dxcam-0.4.0.dev2/tests/test_factory.py +254 -0
- dxcam-0.4.0.dev2/tests/test_frame_buffer.py +265 -0
- dxcam-0.4.0.dev1/dxcam/runtime/frame_buffer.py +0 -159
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/LICENSE +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/MANIFEST.in +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/__init__.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/d3d11.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/dxgi.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/user32.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/__init__.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/com_ptr.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/duplicator_protocol.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_errors.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/output.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/__init__.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_cython_kernels.c +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_cython_kernels.pyx +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.pyx +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/cv2_processor.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/cython_processor.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/numpy_processor.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/__init__.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/display_recovery.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/runtime/output_recovery.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/types.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/__init__.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/frame.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/io.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/timer.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/dependency_links.txt +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/requires.txt +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/top_level.txt +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/setup.cfg +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/setup.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/tests/test_dxgi_transient_policy.py +0 -0
- {dxcam-0.4.0.dev1 → dxcam-0.4.0.dev2}/tests/test_processor_parity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dxcam
|
|
3
|
-
Version: 0.4.0.
|
|
3
|
+
Version: 0.4.0.dev2
|
|
4
4
|
Summary: A Python high-performance screenshot library for Windows using Desktop Duplication API
|
|
5
5
|
Author: ra1nty
|
|
6
6
|
License-Expression: MIT
|
|
@@ -105,13 +105,15 @@ camera = dxcam.create(
|
|
|
105
105
|
)
|
|
106
106
|
```
|
|
107
107
|
Note:
|
|
108
|
-
-
|
|
108
|
+
- Version 0.4 uses a fixed three-slot frame buffer; `max_buffer_len` has been removed.
|
|
109
|
+
- Device discovery happens on the first `create()`, `device_info()`, or `output_info()` call.
|
|
110
|
+
- Upgrading from 0.3? See the [0.4 migration guide](https://github.com/ra1nty/DXcam/blob/dev/docs/migration-0.4.md).
|
|
109
111
|
|
|
110
112
|
### Screenshot
|
|
111
113
|
```python
|
|
112
114
|
frame = camera.grab()
|
|
113
115
|
```
|
|
114
|
-
`grab()` returns a `numpy.ndarray`. `None` if no new frame is available
|
|
116
|
+
`grab()` returns a `numpy.ndarray`. In one-shot mode it returns `None` if no new frame is available; `camera.grab(new_frame_only=False)` can reuse the last cached frame. During threaded capture, it reads the latest published frame and ignores `new_frame_only`.
|
|
115
117
|
|
|
116
118
|
Use `camera.grab_into(dst)` to reuse caller-managed memory.
|
|
117
119
|
|
|
@@ -134,9 +136,9 @@ camera.is_capturing # False
|
|
|
134
136
|
#### Consume the Screen Capture Data
|
|
135
137
|
```python
|
|
136
138
|
for _ in range(1000):
|
|
137
|
-
frame = camera.get_latest_frame() #
|
|
139
|
+
frame = camera.get_latest_frame() # waits for the first available frame
|
|
138
140
|
```
|
|
139
|
-
|
|
141
|
+
The capture thread publishes into a latest-only frame buffer. Once a frame is available, reads return immediately and can return the same timestamp repeatedly. Consumers control their own pacing; compare timestamps when you need only fresh frames. `target_fps` controls the producer, not the frequency of consumer reads.
|
|
140
142
|
|
|
141
143
|
Useful variants:
|
|
142
144
|
- `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
|
|
@@ -147,6 +149,7 @@ Useful variants:
|
|
|
147
149
|
### Safely Releasing Resources
|
|
148
150
|
`release()` stops capture, frees buffers, and releases capture resources.
|
|
149
151
|
After `release()`, the same instance cannot be reused.
|
|
152
|
+
In-flight readers retain their staging surfaces until readout completes, including across stop or output recovery.
|
|
150
153
|
|
|
151
154
|
```python
|
|
152
155
|
camera = dxcam.create(output_idx=0, output_color="BGR")
|
|
@@ -197,10 +200,10 @@ Notes:
|
|
|
197
200
|
- `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
|
|
198
201
|
|
|
199
202
|
### Frame Buffer
|
|
200
|
-
DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers
|
|
203
|
+
DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers consume the newest published frame. A surface being read is never overwritten; if no safe write slot is available, capture skips that cycle. Older surfaces are released only after their readers finish.
|
|
201
204
|
|
|
202
205
|
```python
|
|
203
|
-
camera = dxcam.create()
|
|
206
|
+
camera = dxcam.create()
|
|
204
207
|
```
|
|
205
208
|
|
|
206
209
|
### Target FPS
|
|
@@ -230,21 +233,32 @@ With `video_mode=True`, DXcam continues publishing at target FPS, reusing the pr
|
|
|
230
233
|
```python
|
|
231
234
|
import cv2
|
|
232
235
|
import dxcam
|
|
236
|
+
import time
|
|
233
237
|
|
|
234
238
|
target_fps = 30
|
|
235
239
|
camera = dxcam.create(output_color="BGR")
|
|
236
240
|
camera.start(target_fps=target_fps, video_mode=True)
|
|
237
241
|
|
|
238
242
|
writer = cv2.VideoWriter(
|
|
239
|
-
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
243
|
+
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
244
|
+
(camera.width, camera.height),
|
|
240
245
|
)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
+
try:
|
|
247
|
+
next_frame = time.perf_counter()
|
|
248
|
+
for _ in range(600):
|
|
249
|
+
time.sleep(max(0, next_frame - time.perf_counter()))
|
|
250
|
+
next_frame = time.perf_counter() + 1 / target_fps
|
|
251
|
+
frame = camera.get_latest_frame()
|
|
252
|
+
if frame is not None:
|
|
253
|
+
writer.write(frame)
|
|
254
|
+
finally:
|
|
255
|
+
camera.release()
|
|
256
|
+
writer.release()
|
|
246
257
|
```
|
|
247
258
|
|
|
259
|
+
Latest-frame reads return immediately once a frame exists. Pace the consumer as
|
|
260
|
+
above to avoid filling the video with repeated reads as fast as Python can run.
|
|
261
|
+
|
|
248
262
|
### Capture Backend
|
|
249
263
|
DXcam supports two capture backends:
|
|
250
264
|
- `dxgi` (default): Desktop Duplication API path with broad compatibility.
|
|
@@ -294,6 +308,9 @@ DXcam raises a runtime error because that backend is explicitly the direct
|
|
|
294
308
|
Cython path.
|
|
295
309
|
|
|
296
310
|
## Benchmarks
|
|
311
|
+
See the [0.4 development comparison against PyPI 0.3.0](https://github.com/ra1nty/DXcam/blob/dev/benchmarks/capture_comparison.md)
|
|
312
|
+
for measured fresh-frame throughput, frame age, CPU use, and reproducible steps.
|
|
313
|
+
|
|
297
314
|
When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
|
|
298
315
|
|
|
299
316
|
| | DXcam | python-mss | D3DShot |
|
|
@@ -60,13 +60,15 @@ camera = dxcam.create(
|
|
|
60
60
|
)
|
|
61
61
|
```
|
|
62
62
|
Note:
|
|
63
|
-
-
|
|
63
|
+
- Version 0.4 uses a fixed three-slot frame buffer; `max_buffer_len` has been removed.
|
|
64
|
+
- Device discovery happens on the first `create()`, `device_info()`, or `output_info()` call.
|
|
65
|
+
- Upgrading from 0.3? See the [0.4 migration guide](https://github.com/ra1nty/DXcam/blob/dev/docs/migration-0.4.md).
|
|
64
66
|
|
|
65
67
|
### Screenshot
|
|
66
68
|
```python
|
|
67
69
|
frame = camera.grab()
|
|
68
70
|
```
|
|
69
|
-
`grab()` returns a `numpy.ndarray`. `None` if no new frame is available
|
|
71
|
+
`grab()` returns a `numpy.ndarray`. In one-shot mode it returns `None` if no new frame is available; `camera.grab(new_frame_only=False)` can reuse the last cached frame. During threaded capture, it reads the latest published frame and ignores `new_frame_only`.
|
|
70
72
|
|
|
71
73
|
Use `camera.grab_into(dst)` to reuse caller-managed memory.
|
|
72
74
|
|
|
@@ -89,9 +91,9 @@ camera.is_capturing # False
|
|
|
89
91
|
#### Consume the Screen Capture Data
|
|
90
92
|
```python
|
|
91
93
|
for _ in range(1000):
|
|
92
|
-
frame = camera.get_latest_frame() #
|
|
94
|
+
frame = camera.get_latest_frame() # waits for the first available frame
|
|
93
95
|
```
|
|
94
|
-
|
|
96
|
+
The capture thread publishes into a latest-only frame buffer. Once a frame is available, reads return immediately and can return the same timestamp repeatedly. Consumers control their own pacing; compare timestamps when you need only fresh frames. `target_fps` controls the producer, not the frequency of consumer reads.
|
|
95
97
|
|
|
96
98
|
Useful variants:
|
|
97
99
|
- `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
|
|
@@ -102,6 +104,7 @@ Useful variants:
|
|
|
102
104
|
### Safely Releasing Resources
|
|
103
105
|
`release()` stops capture, frees buffers, and releases capture resources.
|
|
104
106
|
After `release()`, the same instance cannot be reused.
|
|
107
|
+
In-flight readers retain their staging surfaces until readout completes, including across stop or output recovery.
|
|
105
108
|
|
|
106
109
|
```python
|
|
107
110
|
camera = dxcam.create(output_idx=0, output_color="BGR")
|
|
@@ -152,10 +155,10 @@ Notes:
|
|
|
152
155
|
- `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
|
|
153
156
|
|
|
154
157
|
### Frame Buffer
|
|
155
|
-
DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers
|
|
158
|
+
DXcam uses a fixed three-slot latest-only frame buffer in-memory. Readers consume the newest published frame. A surface being read is never overwritten; if no safe write slot is available, capture skips that cycle. Older surfaces are released only after their readers finish.
|
|
156
159
|
|
|
157
160
|
```python
|
|
158
|
-
camera = dxcam.create()
|
|
161
|
+
camera = dxcam.create()
|
|
159
162
|
```
|
|
160
163
|
|
|
161
164
|
### Target FPS
|
|
@@ -185,21 +188,32 @@ With `video_mode=True`, DXcam continues publishing at target FPS, reusing the pr
|
|
|
185
188
|
```python
|
|
186
189
|
import cv2
|
|
187
190
|
import dxcam
|
|
191
|
+
import time
|
|
188
192
|
|
|
189
193
|
target_fps = 30
|
|
190
194
|
camera = dxcam.create(output_color="BGR")
|
|
191
195
|
camera.start(target_fps=target_fps, video_mode=True)
|
|
192
196
|
|
|
193
197
|
writer = cv2.VideoWriter(
|
|
194
|
-
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
198
|
+
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
199
|
+
(camera.width, camera.height),
|
|
195
200
|
)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
try:
|
|
202
|
+
next_frame = time.perf_counter()
|
|
203
|
+
for _ in range(600):
|
|
204
|
+
time.sleep(max(0, next_frame - time.perf_counter()))
|
|
205
|
+
next_frame = time.perf_counter() + 1 / target_fps
|
|
206
|
+
frame = camera.get_latest_frame()
|
|
207
|
+
if frame is not None:
|
|
208
|
+
writer.write(frame)
|
|
209
|
+
finally:
|
|
210
|
+
camera.release()
|
|
211
|
+
writer.release()
|
|
201
212
|
```
|
|
202
213
|
|
|
214
|
+
Latest-frame reads return immediately once a frame exists. Pace the consumer as
|
|
215
|
+
above to avoid filling the video with repeated reads as fast as Python can run.
|
|
216
|
+
|
|
203
217
|
### Capture Backend
|
|
204
218
|
DXcam supports two capture backends:
|
|
205
219
|
- `dxgi` (default): Desktop Duplication API path with broad compatibility.
|
|
@@ -249,6 +263,9 @@ DXcam raises a runtime error because that backend is explicitly the direct
|
|
|
249
263
|
Cython path.
|
|
250
264
|
|
|
251
265
|
## Benchmarks
|
|
266
|
+
See the [0.4 development comparison against PyPI 0.3.0](https://github.com/ra1nty/DXcam/blob/dev/benchmarks/capture_comparison.md)
|
|
267
|
+
for measured fresh-frame throughput, frame age, CPU use, and reproducible steps.
|
|
268
|
+
|
|
252
269
|
When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
|
|
253
270
|
|
|
254
271
|
| | DXcam | python-mss | D3DShot |
|
|
@@ -22,6 +22,7 @@ import signal
|
|
|
22
22
|
import time
|
|
23
23
|
import weakref
|
|
24
24
|
from types import FrameType
|
|
25
|
+
from threading import Lock, RLock
|
|
25
26
|
from typing import Any, Callable, cast
|
|
26
27
|
|
|
27
28
|
from dxcam.runtime.backend import normalize_backend_name
|
|
@@ -50,43 +51,22 @@ __all__ = [
|
|
|
50
51
|
|
|
51
52
|
# Hide internal factory/signal plumbing from pdoc output.
|
|
52
53
|
__pdoc__: dict[str, bool] = {
|
|
53
|
-
"Singleton": False,
|
|
54
54
|
"DXFactory": False,
|
|
55
|
-
"
|
|
55
|
+
"_get_factory": False,
|
|
56
56
|
"_handle_sigterm": False,
|
|
57
57
|
"_install_sigterm_handler": False,
|
|
58
58
|
"__factory": False,
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
# Suppress noisy per-frame COM Release debug logs from comtypes internals.
|
|
64
|
-
logging.getLogger("comtypes").setLevel(logging.INFO)
|
|
65
|
-
logging.getLogger("comtypes._post_coinit.unknwn").setLevel(logging.INFO)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class Singleton(type):
|
|
69
|
-
"""Metaclass that allows exactly one instance per class."""
|
|
70
|
-
|
|
71
|
-
_instances = {}
|
|
72
|
-
|
|
73
|
-
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
|
74
|
-
if cls not in cls._instances:
|
|
75
|
-
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
|
|
76
|
-
else:
|
|
77
|
-
logger.warning("Only 1 instance of %s is allowed.", cls.__name__)
|
|
78
|
-
|
|
79
|
-
return cls._instances[cls]
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class DXFactory(metaclass=Singleton):
|
|
62
|
+
class DXFactory:
|
|
83
63
|
"""Factory that owns device/output discovery and camera singletons."""
|
|
84
64
|
|
|
85
|
-
_camera_instances: weakref.WeakValueDictionary[
|
|
86
|
-
tuple[int, int, CaptureBackend], DXCamera
|
|
87
|
-
] = weakref.WeakValueDictionary()
|
|
88
|
-
|
|
89
65
|
def __init__(self) -> None:
|
|
66
|
+
self._camera_instances: weakref.WeakValueDictionary[
|
|
67
|
+
tuple[int, int, CaptureBackend], DXCamera
|
|
68
|
+
] = weakref.WeakValueDictionary()
|
|
69
|
+
self._camera_lock = RLock()
|
|
90
70
|
p_adapters = enum_dxgi_adapters()
|
|
91
71
|
self.devices: list[Device] = []
|
|
92
72
|
self.outputs: list[list[Output]] = []
|
|
@@ -104,7 +84,7 @@ class DXFactory(metaclass=Singleton):
|
|
|
104
84
|
output_idx: int | None = None,
|
|
105
85
|
region: Region | None = None,
|
|
106
86
|
output_color: ColorMode = "RGB",
|
|
107
|
-
|
|
87
|
+
*,
|
|
108
88
|
backend: CaptureBackend = "dxgi",
|
|
109
89
|
processor_backend: ProcessorBackend = "cv2",
|
|
110
90
|
) -> DXCamera:
|
|
@@ -127,41 +107,32 @@ class DXFactory(metaclass=Singleton):
|
|
|
127
107
|
)
|
|
128
108
|
output_idx = primary_output_indices[0]
|
|
129
109
|
instance_key = (device_idx, output_idx, backend)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
110
|
+
with self._camera_lock:
|
|
111
|
+
existing_camera = self._camera_instances.get(instance_key)
|
|
112
|
+
if existing_camera is not None and not existing_camera.is_released:
|
|
113
|
+
logger.warning(
|
|
114
|
+
"DXCamera instance already exists for device=%s output=%s "
|
|
115
|
+
"backend=%s; returning the existing instance. Call release() "
|
|
116
|
+
"before recreating it with new parameters.",
|
|
117
|
+
device_idx,
|
|
118
|
+
output_idx,
|
|
119
|
+
backend,
|
|
120
|
+
)
|
|
121
|
+
return existing_camera
|
|
122
|
+
|
|
123
|
+
output = self.outputs[device_idx][output_idx]
|
|
124
|
+
output.update_desc()
|
|
125
|
+
camera = DXCamera(
|
|
126
|
+
output=output,
|
|
127
|
+
device=device,
|
|
128
|
+
region=region,
|
|
129
|
+
output_color=output_color,
|
|
130
|
+
backend=backend,
|
|
131
|
+
processor_backend=processor_backend,
|
|
148
132
|
)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
output.update_desc()
|
|
153
|
-
camera = DXCamera(
|
|
154
|
-
output=output,
|
|
155
|
-
device=device,
|
|
156
|
-
region=region,
|
|
157
|
-
output_color=output_color,
|
|
158
|
-
max_buffer_len=max_buffer_len,
|
|
159
|
-
backend=backend,
|
|
160
|
-
processor_backend=processor_backend,
|
|
161
|
-
)
|
|
162
|
-
self._camera_instances[instance_key] = camera
|
|
163
|
-
time.sleep(0.1) # Fix for https://github.com/ra1nty/DXcam/issues/31
|
|
164
|
-
return camera
|
|
133
|
+
self._camera_instances[instance_key] = camera
|
|
134
|
+
time.sleep(0.1) # Fix for https://github.com/ra1nty/DXcam/issues/31
|
|
135
|
+
return camera
|
|
165
136
|
|
|
166
137
|
def device_info(self) -> str:
|
|
167
138
|
ret = ""
|
|
@@ -181,18 +152,30 @@ class DXFactory(metaclass=Singleton):
|
|
|
181
152
|
return ret
|
|
182
153
|
|
|
183
154
|
def clean_up(self) -> None:
|
|
184
|
-
|
|
155
|
+
with self._camera_lock:
|
|
156
|
+
cameras = list(self._camera_instances.values())
|
|
157
|
+
for camera in cameras:
|
|
185
158
|
camera.release()
|
|
186
159
|
|
|
187
160
|
|
|
188
|
-
__factory =
|
|
189
|
-
|
|
161
|
+
__factory: DXFactory | None = None
|
|
162
|
+
_factory_lock = Lock()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_factory() -> DXFactory:
|
|
166
|
+
global __factory
|
|
167
|
+
|
|
168
|
+
with _factory_lock:
|
|
169
|
+
if __factory is None:
|
|
170
|
+
__factory = DXFactory()
|
|
171
|
+
return __factory
|
|
190
172
|
|
|
191
173
|
|
|
192
174
|
def _handle_sigterm(signum: int, frame: FrameType | None) -> None:
|
|
193
175
|
logger.info("Received SIGTERM; releasing active DXCamera instances.")
|
|
194
176
|
try:
|
|
195
|
-
__factory
|
|
177
|
+
if __factory is not None:
|
|
178
|
+
__factory.clean_up()
|
|
196
179
|
except Exception:
|
|
197
180
|
logger.exception("Failed during DXCamera SIGTERM cleanup.")
|
|
198
181
|
|
|
@@ -213,8 +196,9 @@ def _install_sigterm_handler() -> None:
|
|
|
213
196
|
if _sigterm_handler_installed:
|
|
214
197
|
return
|
|
215
198
|
try:
|
|
216
|
-
|
|
199
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
217
200
|
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
201
|
+
_previous_sigterm_handler = previous
|
|
218
202
|
_sigterm_handler_installed = True
|
|
219
203
|
except (ValueError, AttributeError):
|
|
220
204
|
# ValueError: called outside the main thread.
|
|
@@ -227,7 +211,7 @@ def create(
|
|
|
227
211
|
output_idx: int | None = None,
|
|
228
212
|
region: Region | None = None,
|
|
229
213
|
output_color: ColorMode = "RGB",
|
|
230
|
-
|
|
214
|
+
*,
|
|
231
215
|
backend: CaptureBackend = "dxgi",
|
|
232
216
|
processor_backend: ProcessorBackend = "cv2",
|
|
233
217
|
) -> DXCamera:
|
|
@@ -239,8 +223,6 @@ def create(
|
|
|
239
223
|
the primary output.
|
|
240
224
|
region: Optional capture region as ``(left, top, right, bottom)``.
|
|
241
225
|
output_color: Output pixel format.
|
|
242
|
-
max_buffer_len: Kept for API compatibility. Threaded capture uses a
|
|
243
|
-
fixed three-slot latest-only frame buffer.
|
|
244
226
|
backend: Capture backend, ``"dxgi"`` or ``"winrt"``.
|
|
245
227
|
processor_backend: Post-processing backend, ``"cv2"`` (default),
|
|
246
228
|
``"cython"``, or ``"numpy"``. The ``"cython"`` backend uses the
|
|
@@ -262,12 +244,11 @@ def create(
|
|
|
262
244
|
>>> cam.release()
|
|
263
245
|
"""
|
|
264
246
|
_install_sigterm_handler()
|
|
265
|
-
return
|
|
247
|
+
return _get_factory().create(
|
|
266
248
|
device_idx=device_idx,
|
|
267
249
|
output_idx=output_idx,
|
|
268
250
|
region=region,
|
|
269
251
|
output_color=output_color,
|
|
270
|
-
max_buffer_len=max_buffer_len,
|
|
271
252
|
backend=backend,
|
|
272
253
|
processor_backend=processor_backend,
|
|
273
254
|
)
|
|
@@ -280,7 +261,7 @@ def device_info() -> str:
|
|
|
280
261
|
>>> import dxcam
|
|
281
262
|
>>> print(dxcam.device_info())
|
|
282
263
|
"""
|
|
283
|
-
return
|
|
264
|
+
return _get_factory().device_info()
|
|
284
265
|
|
|
285
266
|
|
|
286
267
|
def output_info() -> str:
|
|
@@ -290,4 +271,4 @@ def output_info() -> str:
|
|
|
290
271
|
>>> import dxcam
|
|
291
272
|
>>> print(dxcam.output_info())
|
|
292
273
|
"""
|
|
293
|
-
return
|
|
274
|
+
return _get_factory().output_info()
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import ctypes
|
|
4
|
-
from
|
|
5
|
-
from
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Iterator, cast
|
|
6
7
|
|
|
7
8
|
import comtypes
|
|
8
9
|
from dxcam._libs.d3d11 import (
|
|
@@ -11,6 +12,7 @@ from dxcam._libs.d3d11 import (
|
|
|
11
12
|
D3D_FEATURE_LEVEL_11_0,
|
|
12
13
|
ID3D11Device,
|
|
13
14
|
ID3D11DeviceContext,
|
|
15
|
+
ID3D11Multithread,
|
|
14
16
|
)
|
|
15
17
|
from dxcam._libs.dxgi import (
|
|
16
18
|
DXGI_ADAPTER_DESC1,
|
|
@@ -32,6 +34,7 @@ class Device:
|
|
|
32
34
|
context: Any = None
|
|
33
35
|
im_context: Any = None
|
|
34
36
|
desc: DXGI_ADAPTER_DESC1 | None = None
|
|
37
|
+
_multithread: Any | None = field(default=None, init=False, repr=False)
|
|
35
38
|
|
|
36
39
|
def __post_init__(self) -> None:
|
|
37
40
|
self.desc = DXGI_ADAPTER_DESC1()
|
|
@@ -63,6 +66,32 @@ class Device:
|
|
|
63
66
|
)
|
|
64
67
|
device = cast(Any, self.device)
|
|
65
68
|
device.GetImmediateContext(ctypes.byref(self.im_context))
|
|
69
|
+
try:
|
|
70
|
+
context = cast(Any, self.im_context)
|
|
71
|
+
multithread = context.QueryInterface(ID3D11Multithread)
|
|
72
|
+
multithread.SetMultithreadProtected(True)
|
|
73
|
+
if not multithread.GetMultithreadProtected():
|
|
74
|
+
raise RuntimeError("The device left multithread protection disabled.")
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"DXcam requires native Direct3D multithread protection "
|
|
78
|
+
"(ID3D11Multithread), but it could not be enabled."
|
|
79
|
+
) from exc
|
|
80
|
+
self._multithread = multithread
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def context_guard(self) -> Iterator[None]:
|
|
84
|
+
"""Serialize context/DXGI calls across every camera using this device."""
|
|
85
|
+
multithread = self._multithread
|
|
86
|
+
if multithread is None:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"Native Direct3D multithread protection is not initialized."
|
|
89
|
+
)
|
|
90
|
+
multithread.Enter()
|
|
91
|
+
try:
|
|
92
|
+
yield
|
|
93
|
+
finally:
|
|
94
|
+
multithread.Leave()
|
|
66
95
|
|
|
67
96
|
def enum_outputs(self) -> list[Any]:
|
|
68
97
|
i = 0
|
|
@@ -221,7 +221,7 @@ class DXGIDuplicator:
|
|
|
221
221
|
So DXCam default to early release.
|
|
222
222
|
|
|
223
223
|
Returns:
|
|
224
|
-
bool:
|
|
224
|
+
bool: Successfully
|
|
225
225
|
"""
|
|
226
226
|
if self.duplicator is None or not self._frame_held:
|
|
227
227
|
return True
|
|
@@ -263,7 +263,7 @@ class DXGIDuplicator:
|
|
|
263
263
|
self.duplicator = None
|
|
264
264
|
|
|
265
265
|
def __repr__(self) -> str:
|
|
266
|
-
return "<{}
|
|
266
|
+
return "<{} Initialized:{}>".format(
|
|
267
267
|
self.__class__.__name__,
|
|
268
268
|
self.duplicator is not None,
|
|
269
269
|
)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import ctypes
|
|
4
|
+
from _thread import LockType
|
|
4
5
|
from contextlib import contextmanager
|
|
5
6
|
from dataclasses import InitVar, dataclass, field
|
|
7
|
+
from threading import Lock
|
|
6
8
|
from typing import Any, Iterator, cast
|
|
7
9
|
|
|
8
10
|
from dxcam._libs.d3d11 import (
|
|
@@ -30,6 +32,7 @@ class StageSurface:
|
|
|
30
32
|
texture: Any = None
|
|
31
33
|
interface: Any = None
|
|
32
34
|
_copy_region_box: D3D11_BOX = field(default_factory=D3D11_BOX, repr=False)
|
|
35
|
+
_map_lock: LockType = field(default_factory=Lock, init=False, repr=False)
|
|
33
36
|
output: InitVar[Output | None] = None
|
|
34
37
|
device: InitVar[Device | None] = None
|
|
35
38
|
dim: InitVar[tuple[int, int] | None] = None
|
|
@@ -106,25 +109,30 @@ class StageSurface:
|
|
|
106
109
|
self.rebuild(dim=dim)
|
|
107
110
|
|
|
108
111
|
def map(self) -> DXGI_MAPPED_RECT:
|
|
109
|
-
if self.interface is None:
|
|
112
|
+
if self.interface is None or self._device is None:
|
|
110
113
|
raise RuntimeError("StageSurface interface is not initialized.")
|
|
111
114
|
rect: DXGI_MAPPED_RECT = DXGI_MAPPED_RECT()
|
|
112
|
-
self.
|
|
115
|
+
with self._device.context_guard():
|
|
116
|
+
self.interface.Map(ctypes.byref(rect), 1)
|
|
113
117
|
return rect
|
|
114
118
|
|
|
115
119
|
def unmap(self) -> None:
|
|
116
|
-
if self.interface is None:
|
|
120
|
+
if self.interface is None or self._device is None:
|
|
117
121
|
raise RuntimeError("StageSurface interface is not initialized.")
|
|
118
|
-
self.
|
|
122
|
+
with self._device.context_guard():
|
|
123
|
+
self.interface.Unmap()
|
|
119
124
|
|
|
120
125
|
@contextmanager
|
|
121
126
|
def mapped(self) -> Iterator[DXGI_MAPPED_RECT]:
|
|
122
|
-
"""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
"""Map/unmap under the device guard, without guarding CPU work."""
|
|
128
|
+
# Several readers can lease the latest frame, but DXGI permits only one
|
|
129
|
+
# active mapping of a surface. Leases keep it alive while readers queue.
|
|
130
|
+
with self._map_lock:
|
|
131
|
+
rect = self.map()
|
|
132
|
+
try:
|
|
133
|
+
yield rect
|
|
134
|
+
finally:
|
|
135
|
+
self.unmap()
|
|
128
136
|
|
|
129
137
|
def copy_region_from(
|
|
130
138
|
self,
|
|
@@ -12,7 +12,7 @@ from typing import Any, Callable, Iterator, Literal, cast
|
|
|
12
12
|
|
|
13
13
|
import comtypes
|
|
14
14
|
|
|
15
|
-
from dxcam._libs.d3d11 import
|
|
15
|
+
from dxcam._libs.d3d11 import ID3D11Texture2D
|
|
16
16
|
from dxcam._libs.dxgi import IDXGIDevice, IDXGISurface
|
|
17
17
|
from dxcam.core.com_ptr import clear_com_pointer
|
|
18
18
|
from dxcam.core.device import Device
|
|
@@ -102,7 +102,6 @@ class WinRTDuplicator:
|
|
|
102
102
|
_session: Any | None = field(default=None, init=False, repr=False)
|
|
103
103
|
_capture_item: Any | None = field(default=None, init=False, repr=False)
|
|
104
104
|
_winrt_device: Any | None = field(default=None, init=False, repr=False)
|
|
105
|
-
_multithread: Any | None = field(default=None, init=False, repr=False)
|
|
106
105
|
_dxgi_surface: Any = field(
|
|
107
106
|
default_factory=lambda: ctypes.POINTER(IDXGISurface)(), init=False, repr=False
|
|
108
107
|
)
|
|
@@ -138,7 +137,6 @@ class WinRTDuplicator:
|
|
|
138
137
|
)
|
|
139
138
|
self._border_required = self._resolve_bool_env("DXCAM_WINRT_BORDER_REQUIRED")
|
|
140
139
|
self._configure_qpc_frequency()
|
|
141
|
-
self._configure_multithread_protection(device=device)
|
|
142
140
|
self._create_capture_session(output=output, device=device)
|
|
143
141
|
|
|
144
142
|
def _resolve_frame_wait_seconds(self) -> float:
|
|
@@ -246,7 +244,9 @@ class WinRTDuplicator:
|
|
|
246
244
|
mode = self._dirty_region_mode_enum.REPORT_AND_RENDER
|
|
247
245
|
self._session.dirty_region_mode = mode
|
|
248
246
|
except Exception:
|
|
249
|
-
logger.warning(
|
|
247
|
+
logger.warning(
|
|
248
|
+
"Failed to set session dirty_region_mode.", exc_info=True
|
|
249
|
+
)
|
|
250
250
|
if self._cursor_capture_enabled is not None:
|
|
251
251
|
try:
|
|
252
252
|
self._session.is_cursor_capture_enabled = self._cursor_capture_enabled
|
|
@@ -259,7 +259,9 @@ class WinRTDuplicator:
|
|
|
259
259
|
try:
|
|
260
260
|
self._session.is_border_required = self._border_required
|
|
261
261
|
except Exception:
|
|
262
|
-
logger.warning(
|
|
262
|
+
logger.warning(
|
|
263
|
+
"Failed to set session is_border_required.", exc_info=True
|
|
264
|
+
)
|
|
263
265
|
|
|
264
266
|
def _configure_qpc_frequency(self) -> None:
|
|
265
267
|
freq = ctypes.c_longlong()
|
|
@@ -329,34 +331,6 @@ class WinRTDuplicator:
|
|
|
329
331
|
if self._frame_arrived_event is not None:
|
|
330
332
|
self._frame_arrived_event.set()
|
|
331
333
|
|
|
332
|
-
def _configure_multithread_protection(self, device: Device) -> None:
|
|
333
|
-
try:
|
|
334
|
-
self._multithread = device.im_context.QueryInterface(ID3D11Multithread)
|
|
335
|
-
except comtypes.COMError:
|
|
336
|
-
logger.debug("ID3D11Multithread not available for WinRT backend.")
|
|
337
|
-
self._multithread = None
|
|
338
|
-
return
|
|
339
|
-
try:
|
|
340
|
-
self._multithread.SetMultithreadProtected(True)
|
|
341
|
-
except Exception:
|
|
342
|
-
logger.debug("Failed to enable multithread protection.", exc_info=True)
|
|
343
|
-
|
|
344
|
-
def enter_multithread(self) -> None:
|
|
345
|
-
if self._multithread is None:
|
|
346
|
-
return
|
|
347
|
-
try:
|
|
348
|
-
self._multithread.Enter()
|
|
349
|
-
except Exception:
|
|
350
|
-
logger.debug("Failed to enter ID3D11Multithread lock.", exc_info=True)
|
|
351
|
-
|
|
352
|
-
def leave_multithread(self) -> None:
|
|
353
|
-
if self._multithread is None:
|
|
354
|
-
return
|
|
355
|
-
try:
|
|
356
|
-
self._multithread.Leave()
|
|
357
|
-
except Exception:
|
|
358
|
-
logger.debug("Failed to leave ID3D11Multithread lock.", exc_info=True)
|
|
359
|
-
|
|
360
334
|
def _monitor_handle_to_int(self, hmonitor: Any) -> int:
|
|
361
335
|
value = getattr(hmonitor, "value", hmonitor)
|
|
362
336
|
monitor = int(value or 0)
|
|
@@ -372,7 +346,9 @@ class WinRTDuplicator:
|
|
|
372
346
|
try:
|
|
373
347
|
close_fn()
|
|
374
348
|
except Exception:
|
|
375
|
-
logger.debug(
|
|
349
|
+
logger.debug(
|
|
350
|
+
"Ignoring exception while closing %s.", name, exc_info=True
|
|
351
|
+
)
|
|
376
352
|
|
|
377
353
|
def _release_dxgi_surface(self) -> None:
|
|
378
354
|
# The pointer returned by winrt interop behaves like a borrowed pointer in
|
|
@@ -567,7 +543,7 @@ class WinRTDuplicator:
|
|
|
567
543
|
self._winrt_device = None
|
|
568
544
|
|
|
569
545
|
def __repr__(self) -> str:
|
|
570
|
-
return "<{}
|
|
546
|
+
return "<{} Initialized:{}>".format(
|
|
571
547
|
self.__class__.__name__,
|
|
572
548
|
self._session is not None,
|
|
573
549
|
)
|