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.
Files changed (60) hide show
  1. {dxcam-0.3.0.dev1/dxcam.egg-info → dxcam-0.4.0.dev2}/PKG-INFO +47 -25
  2. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/README.md +46 -24
  3. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/__init__.py +61 -77
  4. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/d3d11.py +11 -0
  5. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/dxgi.py +1 -0
  6. dxcam-0.4.0.dev2/dxcam/core/__init__.py +6 -0
  7. dxcam-0.4.0.dev2/dxcam/core/com_ptr.py +26 -0
  8. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/device.py +31 -2
  9. dxcam-0.4.0.dev2/dxcam/core/duplicator_protocol.py +18 -0
  10. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_duplicator.py +78 -50
  11. dxcam-0.4.0.dev2/dxcam/core/stagesurf.py +168 -0
  12. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/winrt_duplicator.py +45 -45
  13. dxcam-0.4.0.dev2/dxcam/dxcam.py +819 -0
  14. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/__init__.py +1 -2
  15. dxcam-0.4.0.dev2/dxcam/processor/_cython_kernels.c +34023 -0
  16. dxcam-0.4.0.dev2/dxcam/processor/_cython_kernels.pyx +900 -0
  17. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.c +753 -193
  18. dxcam-0.4.0.dev2/dxcam/processor/base.py +90 -0
  19. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/cv2_processor.py +1 -2
  20. dxcam-0.4.0.dev2/dxcam/processor/cython_processor.py +158 -0
  21. dxcam-0.4.0.dev2/dxcam/runtime/__init__.py +18 -0
  22. {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev2/dxcam/runtime}/backend.py +7 -6
  23. dxcam-0.4.0.dev2/dxcam/runtime/capture_worker.py +133 -0
  24. {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev2/dxcam/runtime}/display_recovery.py +5 -4
  25. dxcam-0.4.0.dev2/dxcam/runtime/frame_buffer.py +167 -0
  26. {dxcam-0.3.0.dev1/dxcam/core → dxcam-0.4.0.dev2/dxcam/runtime}/output_recovery.py +33 -22
  27. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/types.py +1 -1
  28. dxcam-0.4.0.dev2/dxcam/util/frame.py +76 -0
  29. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/io.py +18 -14
  30. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2/dxcam.egg-info}/PKG-INFO +47 -25
  31. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/SOURCES.txt +17 -8
  32. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/pyproject.toml +13 -5
  33. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/setup.py +6 -0
  34. dxcam-0.4.0.dev2/tests/test_capture_lifecycle.py +665 -0
  35. dxcam-0.4.0.dev2/tests/test_factory.py +254 -0
  36. dxcam-0.4.0.dev2/tests/test_frame_buffer.py +265 -0
  37. dxcam-0.4.0.dev2/tests/test_processor_parity.py +187 -0
  38. dxcam-0.3.0.dev1/dxcam/core/__init__.py +0 -10
  39. dxcam-0.3.0.dev1/dxcam/core/capture_loop.py +0 -85
  40. dxcam-0.3.0.dev1/dxcam/core/capture_runtime.py +0 -123
  41. dxcam-0.3.0.dev1/dxcam/core/duplicator.py +0 -22
  42. dxcam-0.3.0.dev1/dxcam/core/stagesurf.py +0 -94
  43. dxcam-0.3.0.dev1/dxcam/dxcam.py +0 -839
  44. dxcam-0.3.0.dev1/dxcam/processor/base.py +0 -125
  45. dxcam-0.3.0.dev1/tests/test_bgra_conversion_parity.py +0 -88
  46. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/LICENSE +0 -0
  47. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/MANIFEST.in +0 -0
  48. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/__init__.py +0 -0
  49. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/_libs/user32.py +0 -0
  50. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/dxgi_errors.py +0 -0
  51. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/core/output.py +0 -0
  52. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/_numpy_kernels.pyx +0 -0
  53. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/processor/numpy_processor.py +0 -0
  54. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/__init__.py +0 -0
  55. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam/util/timer.py +0 -0
  56. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/dependency_links.txt +0 -0
  57. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/requires.txt +0 -0
  58. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/dxcam.egg-info/top_level.txt +0 -0
  59. {dxcam-0.3.0.dev1 → dxcam-0.4.0.dev2}/setup.cfg +0 -0
  60. {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.0.dev1
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 since the last capture (for backward compatibility); use `camera.grab(new_frame_only=False)` to make dxcam always return the latest frame.
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 `copy=False` (or `camera.grab_view()`) for a zero-copy view. This is faster, but the returned buffer can be overwritten by later captures.
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() # blocks until a frame is available
139
+ frame = camera.get_latest_frame() # waits for the first available frame
136
140
  ```
137
- >The screen capture mode spins up a thread that polls newly rendered frames and stores them in an in-memory ring buffer. The blocking and `video_mode` behavior is designed for downstream video recording and machine learning workloads.
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.get_latest_frame_view()` -> zero-copy view into the frame buffer
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 ring buffer instead of directly polling DXGI.
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-size ring buffer in-memory. New frames overwrite old frames when full.
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(max_buffer_len=120) # default is 8
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 fills the buffer at target FPS, reusing the previous frame if needed, even if no new frame is rendered.
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, (1920, 1080)
243
+ "video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
244
+ (camera.width, camera.height),
239
245
  )
240
- for _ in range(600):
241
- writer.write(camera.get_latest_frame())
242
-
243
- camera.stop()
244
- writer.release()
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`) first acquire a BGRA frame.
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` (Cython kernels)
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 NumPy kernels.
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 | 61.71, 0.26 :checkered_flag: | N/A | 47.11, 1.33 |
305
- | 30fps | 30.08, 0.02 :checkered_flag: | N/A | 21.24, 0.17 |
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 since the last capture (for backward compatibility); use `camera.grab(new_frame_only=False)` to make dxcam always return the latest frame.
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 `copy=False` (or `camera.grab_view()`) for a zero-copy view. This is faster, but the returned buffer can be overwritten by later captures.
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() # blocks until a frame is available
94
+ frame = camera.get_latest_frame() # waits for the first available frame
91
95
  ```
92
- >The screen capture mode spins up a thread that polls newly rendered frames and stores them in an in-memory ring buffer. The blocking and `video_mode` behavior is designed for downstream video recording and machine learning workloads.
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.get_latest_frame_view()` -> zero-copy view into the frame buffer
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 ring buffer instead of directly polling DXGI.
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-size ring buffer in-memory. New frames overwrite old frames when full.
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(max_buffer_len=120) # default is 8
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 fills the buffer at target FPS, reusing the previous frame if needed, even if no new frame is rendered.
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, (1920, 1080)
198
+ "video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), target_fps,
199
+ (camera.width, camera.height),
194
200
  )
195
- for _ in range(600):
196
- writer.write(camera.get_latest_frame())
197
-
198
- camera.stop()
199
- writer.release()
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`) first acquire a BGRA frame.
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` (Cython kernels)
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 NumPy kernels.
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 | 61.71, 0.26 :checkered_flag: | N/A | 47.11, 1.33 |
260
- | 30fps | 30.08, 0.02 :checkered_flag: | N/A | 21.24, 0.17 |
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.core.backend import normalize_backend_name
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
- "_configure_comtypes_logging": False,
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
- def _configure_comtypes_logging() -> None:
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
- max_buffer_len: int = 8,
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
- existing_camera = self._camera_instances.get(instance_key)
131
- if existing_camera is not None and existing_camera.is_released:
132
- logger.info(
133
- "Dropping released DXCamera instance for device=%s output=%s backend=%s.",
134
- device_idx,
135
- output_idx,
136
- backend,
137
- )
138
- del self._camera_instances[instance_key]
139
- existing_camera = None
140
- if existing_camera is not None:
141
- logger.warning(
142
- "DXCamera instance already exists for device=%s output=%s backend=%s; "
143
- "returning existing instance. Delete the old object with `del obj` "
144
- "to recreate it with new parameters.",
145
- device_idx,
146
- output_idx,
147
- backend,
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
- return existing_camera
150
-
151
- output = self.outputs[device_idx][output_idx]
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
- for _, camera in list(self._camera_instances.items()):
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 = DXFactory()
189
- _configure_comtypes_logging()
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.clean_up()
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
- _previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
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
- max_buffer_len: int = 8,
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 ``"numpy"`` backend uses compiled Cython
246
- kernels when available and falls back to cv2 behavior otherwise.
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 __factory.create(
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 __factory.device_info()
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 __factory.output_info()
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,6 @@
1
+ __all__ = ["Device", "Output", "StageSurface"]
2
+
3
+
4
+ from dxcam.core.device import Device
5
+ from dxcam.core.output import Output
6
+ from dxcam.core.stagesurf import StageSurface
@@ -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)