dxcam 0.3.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.3.0.dev1/dxcam.egg-info → dxcam-0.4.0.dev2}/PKG-INFO +47 -25
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/README.md +46 -24
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/__init__.py +61 -77
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/d3d11.py +11 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/dxgi.py +1 -0
- dxcam-0.4.0.dev2/dxcam/core/__init__.py +6 -0
- dxcam-0.4.0.dev2/dxcam/core/com_ptr.py +26 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/device.py +31 -2
- dxcam-0.4.0.dev2/dxcam/core/duplicator_protocol.py +18 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_duplicator.py +78 -50
- dxcam-0.4.0.dev2/dxcam/core/stagesurf.py +168 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/winrt_duplicator.py +45 -45
- dxcam-0.4.0.dev2/dxcam/dxcam.py +819 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/__init__.py +1 -2
- dxcam-0.4.0.dev2/dxcam/processor/_cython_kernels.c +34023 -0
- dxcam-0.4.0.dev2/dxcam/processor/_cython_kernels.pyx +900 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.c +753 -193
- dxcam-0.4.0.dev2/dxcam/processor/base.py +90 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/cv2_processor.py +1 -2
- dxcam-0.4.0.dev2/dxcam/processor/cython_processor.py +158 -0
- dxcam-0.4.0.dev2/dxcam/runtime/__init__.py +18 -0
- {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev2/dxcam/runtime}/backend.py +7 -6
- dxcam-0.4.0.dev2/dxcam/runtime/capture_worker.py +133 -0
- {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev2/dxcam/runtime}/display_recovery.py +5 -4
- dxcam-0.4.0.dev2/dxcam/runtime/frame_buffer.py +167 -0
- {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev2/dxcam/runtime}/output_recovery.py +33 -22
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/types.py +1 -1
- dxcam-0.4.0.dev2/dxcam/util/frame.py +76 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/io.py +18 -14
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2/dxcam.egg-info}/PKG-INFO +47 -25
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/SOURCES.txt +17 -8
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/pyproject.toml +13 -5
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/setup.py +6 -0
- 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.dev2/tests/test_processor_parity.py +187 -0
- dxcam-0.3.0.dev1/dxcam/core/__init__.py +0 -10
- dxcam-0.3.0.dev1/dxcam/core/capture_loop.py +0 -85
- dxcam-0.3.0.dev1/dxcam/core/capture_runtime.py +0 -123
- dxcam-0.3.0.dev1/dxcam/core/duplicator.py +0 -22
- dxcam-0.3.0.dev1/dxcam/core/stagesurf.py +0 -94
- dxcam-0.3.0.dev1/dxcam/dxcam.py +0 -839
- dxcam-0.3.0.dev1/dxcam/processor/base.py +0 -125
- dxcam-0.3.0.dev1/tests/test_bgra_conversion_parity.py +0 -88
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/LICENSE +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/MANIFEST.in +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/__init__.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/user32.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_errors.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/output.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.pyx +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/numpy_processor.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/__init__.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/timer.py +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/dependency_links.txt +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/requires.txt +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/top_level.txt +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/setup.cfg +0 -0
- {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/tests/test_dxgi_transient_policy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dxcam
|
|
3
|
-
Version: 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
|
|
@@ -104,14 +104,18 @@ camera = dxcam.create(
|
|
|
104
104
|
processor_backend="cv2" # default OpenCV processor
|
|
105
105
|
)
|
|
106
106
|
```
|
|
107
|
+
Note:
|
|
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).
|
|
107
111
|
|
|
108
112
|
### Screenshot
|
|
109
113
|
```python
|
|
110
114
|
frame = camera.grab()
|
|
111
115
|
```
|
|
112
|
-
`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`.
|
|
113
117
|
|
|
114
|
-
Use `
|
|
118
|
+
Use `camera.grab_into(dst)` to reuse caller-managed memory.
|
|
115
119
|
|
|
116
120
|
To capture a region:
|
|
117
121
|
```python
|
|
@@ -132,20 +136,20 @@ camera.is_capturing # False
|
|
|
132
136
|
#### Consume the Screen Capture Data
|
|
133
137
|
```python
|
|
134
138
|
for _ in range(1000):
|
|
135
|
-
frame = camera.get_latest_frame() #
|
|
139
|
+
frame = camera.get_latest_frame() # waits for the first available frame
|
|
136
140
|
```
|
|
137
|
-
|
|
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.
|
|
138
142
|
|
|
139
143
|
Useful variants:
|
|
140
144
|
- `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
|
|
141
|
-
- `camera.
|
|
142
|
-
- `camera.grab(copy=False)` / `camera.grab_view()` -> zero-copy latest-frame snapshot
|
|
145
|
+
- `camera.get_latest_frame_into(dst)` -> write latest frame into caller-provided array
|
|
143
146
|
|
|
144
|
-
> When `start()` capture is running, calling `grab()` reads from the in-memory
|
|
147
|
+
> When `start()` capture is running, calling `grab()` reads from the in-memory frame buffer instead of directly polling the capture backend.
|
|
145
148
|
|
|
146
149
|
### Safely Releasing Resources
|
|
147
150
|
`release()` stops capture, frees buffers, and releases capture resources.
|
|
148
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.
|
|
149
153
|
|
|
150
154
|
```python
|
|
151
155
|
camera = dxcam.create(output_idx=0, output_color="BGR")
|
|
@@ -193,13 +197,13 @@ Supported modes: `"RGB"`, `"RGBA"`, `"BGR"`, `"BGRA"`, `"GRAY"`.
|
|
|
193
197
|
Notes:
|
|
194
198
|
- Data is returned as `numpy.ndarray`.
|
|
195
199
|
- `BGRA` does not require OpenCV and is the leanest dependency path.
|
|
196
|
-
- `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2` or compiled `numpy` backend).
|
|
200
|
+
- `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
|
|
197
201
|
|
|
198
202
|
### Frame Buffer
|
|
199
|
-
DXcam uses a fixed-
|
|
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.
|
|
200
204
|
|
|
201
205
|
```python
|
|
202
|
-
camera = dxcam.create(
|
|
206
|
+
camera = dxcam.create()
|
|
203
207
|
```
|
|
204
208
|
|
|
205
209
|
### Target FPS
|
|
@@ -224,26 +228,37 @@ For `backend="dxgi"`, this value comes from `DXGI_OUTDUPL_FRAME_INFO.LastPresent
|
|
|
224
228
|
For `backend="winrt"`, this value is derived from WinRT `SystemRelativeTime`.
|
|
225
229
|
|
|
226
230
|
### Video Mode
|
|
227
|
-
With `video_mode=True`, DXcam
|
|
231
|
+
With `video_mode=True`, DXcam continues publishing at target FPS, reusing the previous frame when no new frame is rendered.
|
|
228
232
|
|
|
229
233
|
```python
|
|
230
234
|
import cv2
|
|
231
235
|
import dxcam
|
|
236
|
+
import time
|
|
232
237
|
|
|
233
238
|
target_fps = 30
|
|
234
239
|
camera = dxcam.create(output_color="BGR")
|
|
235
240
|
camera.start(target_fps=target_fps, video_mode=True)
|
|
236
241
|
|
|
237
242
|
writer = cv2.VideoWriter(
|
|
238
|
-
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
243
|
+
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
244
|
+
(camera.width, camera.height),
|
|
239
245
|
)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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()
|
|
245
257
|
```
|
|
246
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
|
+
|
|
247
262
|
### Capture Backend
|
|
248
263
|
DXcam supports two capture backends:
|
|
249
264
|
- `dxgi` (default): Desktop Duplication API path with broad compatibility.
|
|
@@ -261,22 +276,22 @@ Guideline:
|
|
|
261
276
|
- Try `winrt` if it performs better on your machine or fits your app constraints.
|
|
262
277
|
|
|
263
278
|
### Processor Backend
|
|
264
|
-
DXcam capture backends (`dxgi`/`winrt`)
|
|
265
|
-
The processor backend then handles post-processing:
|
|
279
|
+
DXcam capture backends (`dxgi`/`winrt`) acquire raw BGRA frame. The processor backend then handles post-processing:
|
|
266
280
|
- optional rotation/cropping preparation
|
|
267
281
|
- color conversion to your `output_color`
|
|
268
282
|
|
|
269
283
|
Recommended backend choice:
|
|
270
284
|
- OpenCV installed: use `cv2` (default)
|
|
271
|
-
- No OpenCV installed: use `numpy`
|
|
285
|
+
- No OpenCV installed: use `numpy`/`cython`
|
|
272
286
|
|
|
273
287
|
Use it like this:
|
|
274
288
|
```python
|
|
275
289
|
camera = dxcam.create(processor_backend="cv2")
|
|
290
|
+
camera = dxcam.create(processor_backend="cython")
|
|
276
291
|
camera = dxcam.create(processor_backend="numpy")
|
|
277
292
|
```
|
|
278
293
|
|
|
279
|
-
Official Windows wheels already include the compiled
|
|
294
|
+
Official Windows wheels already include the compiled Cython processor kernels.
|
|
280
295
|
|
|
281
296
|
Only for source installs:
|
|
282
297
|
```bash
|
|
@@ -288,7 +303,14 @@ If `processor_backend="numpy"` is selected but compiled kernels are unavailable,
|
|
|
288
303
|
DXcam logs a warning and falls back to `cv2` behavior. In that fallback path,
|
|
289
304
|
install OpenCV for non-`BGRA` output modes.
|
|
290
305
|
|
|
306
|
+
If `processor_backend="cython"` is selected but compiled kernels are unavailable,
|
|
307
|
+
DXcam raises a runtime error because that backend is explicitly the direct
|
|
308
|
+
Cython path.
|
|
309
|
+
|
|
291
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
|
+
|
|
292
314
|
When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
|
|
293
315
|
|
|
294
316
|
| | DXcam | python-mss | D3DShot |
|
|
@@ -299,10 +321,10 @@ When using a similar logic (only capture newly rendered frames) running on a 240
|
|
|
299
321
|
The benchmark is across 5 runs, with a light-moderate usage on my PC (5900X + 3090; Chrome ~30tabs, VS Code opened, etc.), I used the [Blur Buster UFO test](https://www.testufo.com/framerates#count=5&background=stars&pps=960) to constantly render 240 fps on my monitor. DXcam captured almost every frame rendered. You will see some benchmarks online claiming 1000+fps capture while most of them is busy-spinning a for loop on a staled frame (no new frame rendered on screen in test scenario).
|
|
300
322
|
|
|
301
323
|
### For Targeting FPS:
|
|
302
|
-
| (Target)\\(mean,std) | DXcam | python-mss | D3DShot |
|
|
324
|
+
| (Target)\\(mean,std) | DXcam (4k) | python-mss | D3DShot (1080p) |
|
|
303
325
|
|------------- |-------- |------------|---------|
|
|
304
|
-
| 60fps |
|
|
305
|
-
| 30fps | 30.
|
|
326
|
+
| 60fps | 59.99, 0.04 :checkered_flag: | N/A | 47.11, 1.33 |
|
|
327
|
+
| 30fps | 30.00, 0.00 :checkered_flag: | N/A | 21.24, 0.17 |
|
|
306
328
|
|
|
307
329
|
|
|
308
330
|
## Work Referenced
|
|
@@ -59,14 +59,18 @@ camera = dxcam.create(
|
|
|
59
59
|
processor_backend="cv2" # default OpenCV processor
|
|
60
60
|
)
|
|
61
61
|
```
|
|
62
|
+
Note:
|
|
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).
|
|
62
66
|
|
|
63
67
|
### Screenshot
|
|
64
68
|
```python
|
|
65
69
|
frame = camera.grab()
|
|
66
70
|
```
|
|
67
|
-
`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`.
|
|
68
72
|
|
|
69
|
-
Use `
|
|
73
|
+
Use `camera.grab_into(dst)` to reuse caller-managed memory.
|
|
70
74
|
|
|
71
75
|
To capture a region:
|
|
72
76
|
```python
|
|
@@ -87,20 +91,20 @@ camera.is_capturing # False
|
|
|
87
91
|
#### Consume the Screen Capture Data
|
|
88
92
|
```python
|
|
89
93
|
for _ in range(1000):
|
|
90
|
-
frame = camera.get_latest_frame() #
|
|
94
|
+
frame = camera.get_latest_frame() # waits for the first available frame
|
|
91
95
|
```
|
|
92
|
-
|
|
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.
|
|
93
97
|
|
|
94
98
|
Useful variants:
|
|
95
99
|
- `camera.get_latest_frame(with_timestamp=True)` -> `(frame, frame_timestamp)` -> return frame timestamp
|
|
96
|
-
- `camera.
|
|
97
|
-
- `camera.grab(copy=False)` / `camera.grab_view()` -> zero-copy latest-frame snapshot
|
|
100
|
+
- `camera.get_latest_frame_into(dst)` -> write latest frame into caller-provided array
|
|
98
101
|
|
|
99
|
-
> When `start()` capture is running, calling `grab()` reads from the in-memory
|
|
102
|
+
> When `start()` capture is running, calling `grab()` reads from the in-memory frame buffer instead of directly polling the capture backend.
|
|
100
103
|
|
|
101
104
|
### Safely Releasing Resources
|
|
102
105
|
`release()` stops capture, frees buffers, and releases capture resources.
|
|
103
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.
|
|
104
108
|
|
|
105
109
|
```python
|
|
106
110
|
camera = dxcam.create(output_idx=0, output_color="BGR")
|
|
@@ -148,13 +152,13 @@ Supported modes: `"RGB"`, `"RGBA"`, `"BGR"`, `"BGRA"`, `"GRAY"`.
|
|
|
148
152
|
Notes:
|
|
149
153
|
- Data is returned as `numpy.ndarray`.
|
|
150
154
|
- `BGRA` does not require OpenCV and is the leanest dependency path.
|
|
151
|
-
- `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2` or compiled `numpy` backend).
|
|
155
|
+
- `RGB`, `BGR`, `RGBA`, `GRAY` require conversion (`cv2`, `cython`, or compiled `numpy` backend).
|
|
152
156
|
|
|
153
157
|
### Frame Buffer
|
|
154
|
-
DXcam uses a fixed-
|
|
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.
|
|
155
159
|
|
|
156
160
|
```python
|
|
157
|
-
camera = dxcam.create(
|
|
161
|
+
camera = dxcam.create()
|
|
158
162
|
```
|
|
159
163
|
|
|
160
164
|
### Target FPS
|
|
@@ -179,26 +183,37 @@ For `backend="dxgi"`, this value comes from `DXGI_OUTDUPL_FRAME_INFO.LastPresent
|
|
|
179
183
|
For `backend="winrt"`, this value is derived from WinRT `SystemRelativeTime`.
|
|
180
184
|
|
|
181
185
|
### Video Mode
|
|
182
|
-
With `video_mode=True`, DXcam
|
|
186
|
+
With `video_mode=True`, DXcam continues publishing at target FPS, reusing the previous frame when no new frame is rendered.
|
|
183
187
|
|
|
184
188
|
```python
|
|
185
189
|
import cv2
|
|
186
190
|
import dxcam
|
|
191
|
+
import time
|
|
187
192
|
|
|
188
193
|
target_fps = 30
|
|
189
194
|
camera = dxcam.create(output_color="BGR")
|
|
190
195
|
camera.start(target_fps=target_fps, video_mode=True)
|
|
191
196
|
|
|
192
197
|
writer = cv2.VideoWriter(
|
|
193
|
-
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
198
|
+
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
|
|
199
|
+
(camera.width, camera.height),
|
|
194
200
|
)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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()
|
|
200
212
|
```
|
|
201
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
|
+
|
|
202
217
|
### Capture Backend
|
|
203
218
|
DXcam supports two capture backends:
|
|
204
219
|
- `dxgi` (default): Desktop Duplication API path with broad compatibility.
|
|
@@ -216,22 +231,22 @@ Guideline:
|
|
|
216
231
|
- Try `winrt` if it performs better on your machine or fits your app constraints.
|
|
217
232
|
|
|
218
233
|
### Processor Backend
|
|
219
|
-
DXcam capture backends (`dxgi`/`winrt`)
|
|
220
|
-
The processor backend then handles post-processing:
|
|
234
|
+
DXcam capture backends (`dxgi`/`winrt`) acquire raw BGRA frame. The processor backend then handles post-processing:
|
|
221
235
|
- optional rotation/cropping preparation
|
|
222
236
|
- color conversion to your `output_color`
|
|
223
237
|
|
|
224
238
|
Recommended backend choice:
|
|
225
239
|
- OpenCV installed: use `cv2` (default)
|
|
226
|
-
- No OpenCV installed: use `numpy`
|
|
240
|
+
- No OpenCV installed: use `numpy`/`cython`
|
|
227
241
|
|
|
228
242
|
Use it like this:
|
|
229
243
|
```python
|
|
230
244
|
camera = dxcam.create(processor_backend="cv2")
|
|
245
|
+
camera = dxcam.create(processor_backend="cython")
|
|
231
246
|
camera = dxcam.create(processor_backend="numpy")
|
|
232
247
|
```
|
|
233
248
|
|
|
234
|
-
Official Windows wheels already include the compiled
|
|
249
|
+
Official Windows wheels already include the compiled Cython processor kernels.
|
|
235
250
|
|
|
236
251
|
Only for source installs:
|
|
237
252
|
```bash
|
|
@@ -243,7 +258,14 @@ If `processor_backend="numpy"` is selected but compiled kernels are unavailable,
|
|
|
243
258
|
DXcam logs a warning and falls back to `cv2` behavior. In that fallback path,
|
|
244
259
|
install OpenCV for non-`BGRA` output modes.
|
|
245
260
|
|
|
261
|
+
If `processor_backend="cython"` is selected but compiled kernels are unavailable,
|
|
262
|
+
DXcam raises a runtime error because that backend is explicitly the direct
|
|
263
|
+
Cython path.
|
|
264
|
+
|
|
246
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
|
+
|
|
247
269
|
When using a similar logic (only capture newly rendered frames) running on a 240fps output, ```DXCam, python-mss, D3DShot``` benchmarked as follow:
|
|
248
270
|
|
|
249
271
|
| | DXcam | python-mss | D3DShot |
|
|
@@ -254,10 +276,10 @@ When using a similar logic (only capture newly rendered frames) running on a 240
|
|
|
254
276
|
The benchmark is across 5 runs, with a light-moderate usage on my PC (5900X + 3090; Chrome ~30tabs, VS Code opened, etc.), I used the [Blur Buster UFO test](https://www.testufo.com/framerates#count=5&background=stars&pps=960) to constantly render 240 fps on my monitor. DXcam captured almost every frame rendered. You will see some benchmarks online claiming 1000+fps capture while most of them is busy-spinning a for loop on a staled frame (no new frame rendered on screen in test scenario).
|
|
255
277
|
|
|
256
278
|
### For Targeting FPS:
|
|
257
|
-
| (Target)\\(mean,std) | DXcam | python-mss | D3DShot |
|
|
279
|
+
| (Target)\\(mean,std) | DXcam (4k) | python-mss | D3DShot (1080p) |
|
|
258
280
|
|------------- |-------- |------------|---------|
|
|
259
|
-
| 60fps |
|
|
260
|
-
| 30fps | 30.
|
|
281
|
+
| 60fps | 59.99, 0.04 :checkered_flag: | N/A | 47.11, 1.33 |
|
|
282
|
+
| 30fps | 30.00, 0.00 :checkered_flag: | N/A | 21.24, 0.17 |
|
|
261
283
|
|
|
262
284
|
|
|
263
285
|
## Work Referenced
|
|
@@ -22,9 +22,10 @@ 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
|
-
from dxcam.
|
|
28
|
+
from dxcam.runtime.backend import normalize_backend_name
|
|
28
29
|
from dxcam.dxcam import DXCamera, Output, Device
|
|
29
30
|
from dxcam.processor import normalize_processor_backend_name
|
|
30
31
|
from dxcam.types import CaptureBackend, ColorMode, ProcessorBackend, Region
|
|
@@ -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,11 +223,12 @@ 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: Ring-buffer size used in threaded capture mode.
|
|
243
226
|
backend: Capture backend, ``"dxgi"`` or ``"winrt"``.
|
|
244
|
-
processor_backend: Post-processing backend, ``"cv2"`` (default)
|
|
245
|
-
or ``"numpy"``. The ``"
|
|
246
|
-
|
|
227
|
+
processor_backend: Post-processing backend, ``"cv2"`` (default),
|
|
228
|
+
``"cython"``, or ``"numpy"``. The ``"cython"`` backend uses the
|
|
229
|
+
direct compiled Cython processor, while ``"numpy"`` uses compiled
|
|
230
|
+
conversion kernels when available and falls back to cv2 behavior
|
|
231
|
+
otherwise.
|
|
247
232
|
|
|
248
233
|
Returns:
|
|
249
234
|
A :class:`dxcam.dxcam.DXCamera` instance.
|
|
@@ -259,12 +244,11 @@ def create(
|
|
|
259
244
|
>>> cam.release()
|
|
260
245
|
"""
|
|
261
246
|
_install_sigterm_handler()
|
|
262
|
-
return
|
|
247
|
+
return _get_factory().create(
|
|
263
248
|
device_idx=device_idx,
|
|
264
249
|
output_idx=output_idx,
|
|
265
250
|
region=region,
|
|
266
251
|
output_color=output_color,
|
|
267
|
-
max_buffer_len=max_buffer_len,
|
|
268
252
|
backend=backend,
|
|
269
253
|
processor_backend=processor_backend,
|
|
270
254
|
)
|
|
@@ -277,7 +261,7 @@ def device_info() -> str:
|
|
|
277
261
|
>>> import dxcam
|
|
278
262
|
>>> print(dxcam.device_info())
|
|
279
263
|
"""
|
|
280
|
-
return
|
|
264
|
+
return _get_factory().device_info()
|
|
281
265
|
|
|
282
266
|
|
|
283
267
|
def output_info() -> str:
|
|
@@ -287,4 +271,4 @@ def output_info() -> str:
|
|
|
287
271
|
>>> import dxcam
|
|
288
272
|
>>> print(dxcam.output_info())
|
|
289
273
|
"""
|
|
290
|
-
return
|
|
274
|
+
return _get_factory().output_info()
|
|
@@ -19,6 +19,9 @@ D3D11_USAGE_IMMUTABLE = 1
|
|
|
19
19
|
D3D11_USAGE_DYNAMIC = 2
|
|
20
20
|
D3D11_USAGE_STAGING = 3
|
|
21
21
|
|
|
22
|
+
D3D11_MAP_READ = 1
|
|
23
|
+
D3D11_MAP_FLAG_DO_NOT_WAIT = 0x100000
|
|
24
|
+
|
|
22
25
|
DXGI_FORMAT_B8G8R8A8_UNORM = 87
|
|
23
26
|
|
|
24
27
|
|
|
@@ -55,6 +58,14 @@ class D3D11_TEXTURE2D_DESC(ctypes.Structure):
|
|
|
55
58
|
]
|
|
56
59
|
|
|
57
60
|
|
|
61
|
+
class D3D11_MAPPED_SUBRESOURCE(ctypes.Structure):
|
|
62
|
+
_fields_ = [
|
|
63
|
+
("pData", ctypes.c_void_p),
|
|
64
|
+
("RowPitch", wintypes.UINT),
|
|
65
|
+
("DepthPitch", wintypes.UINT),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
58
69
|
class ID3D11DeviceChild(comtypes.IUnknown):
|
|
59
70
|
_iid_ = comtypes.GUID("{1841e5c8-16b0-489b-bcc8-44cfb0d5deae}")
|
|
60
71
|
_methods_ = [
|
|
@@ -10,6 +10,7 @@ DXGI_ERROR_NOT_FOUND = 0x887A0002
|
|
|
10
10
|
DXGI_ERROR_SESSION_DISCONNECTED = 0x887A0028
|
|
11
11
|
DXGI_ERROR_UNSUPPORTED = 0x887A0004
|
|
12
12
|
DXGI_ERROR_WAIT_TIMEOUT = 0x887A0027
|
|
13
|
+
DXGI_ERROR_WAS_STILL_DRAWING = 0x887A000A
|
|
13
14
|
|
|
14
15
|
DXGI_OUTDUPL_FLAG_NONE = 0x0
|
|
15
16
|
DXGI_OUTDUPL_FLAG_COMPOSITED_UI_CAPTURE_ONLY = 0x1
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clear_com_pointer(pointer: Any) -> None:
|
|
8
|
+
"""Set a COM pointer value to NULL in-place without calling Release()."""
|
|
9
|
+
if pointer is None:
|
|
10
|
+
return
|
|
11
|
+
try:
|
|
12
|
+
raw = ctypes.cast(ctypes.byref(pointer), ctypes.POINTER(ctypes.c_void_p))
|
|
13
|
+
raw[0] = None
|
|
14
|
+
except Exception:
|
|
15
|
+
# Best-effort cleanup helper; callers may still overwrite the attribute.
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def release_com_pointer(pointer: Any) -> None:
|
|
20
|
+
"""Release a COM pointer once, then null it to avoid late double-Release."""
|
|
21
|
+
if pointer is None or not pointer:
|
|
22
|
+
return
|
|
23
|
+
try:
|
|
24
|
+
pointer.Release()
|
|
25
|
+
finally:
|
|
26
|
+
clear_com_pointer(pointer)
|