rapidshot 2.2.0__tar.gz → 2.3.0__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.
- {rapidshot-2.2.0/rapidshot.egg-info → rapidshot-2.3.0}/PKG-INFO +121 -11
- {rapidshot-2.2.0 → rapidshot-2.3.0}/README.md +120 -10
- {rapidshot-2.2.0 → rapidshot-2.3.0}/pyproject.toml +1 -1
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/__init__.py +1 -1
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/capture.py +1 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/duplicator.py +53 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/frame.py +14 -1
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/native.py +44 -3
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/preprocess.py +37 -3
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/cupy_processor.py +108 -110
- rapidshot-2.3.0/rapidshot/util/desktop.py +147 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0/rapidshot.egg-info}/PKG-INFO +121 -11
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/SOURCES.txt +8 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/setup.py +1 -1
- rapidshot-2.3.0/tests/test_cuda_interop.py +220 -0
- rapidshot-2.3.0/tests/test_cupy_processor.py +175 -0
- rapidshot-2.3.0/tests/test_desktop_refusal.py +174 -0
- rapidshot-2.3.0/tests/test_exclusive_fullscreen.py +263 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_gpu_preprocess.py +7 -4
- rapidshot-2.3.0/tests/test_output_change_recovery.py +125 -0
- rapidshot-2.3.0/tests/test_preprocess_cache.py +146 -0
- rapidshot-2.3.0/tests/test_protected_content.py +174 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/LICENSE +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/__init__.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/d3d11.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/dxgi.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/_libs/user32.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/__init__.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/device.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/output.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/core/stagesurf.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/memory_pool.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/__init__.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/base.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/numpy_processor.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/processor/pillow_processor.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/py.typed +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/__init__.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/ctypes_helpers.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/errors.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/io.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/logging.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/timer.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot/util/topology.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/dependency_links.txt +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/requires.txt +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/rapidshot.egg-info/top_level.txt +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/setup.cfg +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_color_modes.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_cross_adapter.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_dirty_accumulator.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_dirty_rects.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_failure_paths.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_frame.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_memory_pool.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_native.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_output_pooling.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_perf_harness.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_preprocess.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_region_mapping.py +0 -0
- {rapidshot-2.2.0 → rapidshot-2.3.0}/tests/test_topology.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rapidshot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: A high-performance screencapture library for Windows using Desktop Duplication API
|
|
5
5
|
Home-page: https://github.com/Zaatra/Rapidshot
|
|
6
6
|
Author: Rapidshot Contributors
|
|
@@ -149,8 +149,9 @@ built around have no column here because the other libraries have no equivalent:
|
|
|
149
149
|
|
|
150
150
|
- **`grab_frame()`** hands back a GPU-resident frame in 0.17–0.21 ms that never
|
|
151
151
|
crosses to system memory.
|
|
152
|
-
- **The GPU tensor path** turns a frame into NCHW float32 in one
|
|
153
|
-
|
|
152
|
+
- **The GPU tensor path** turns a frame into model-ready NCHW float32 in one
|
|
153
|
+
dispatch — 0.075 ms for a 2560×1600 frame, against 3.1 ms to do the same work
|
|
154
|
+
on the CPU — and the result stays in VRAM, where CUDA or DirectML can read it.
|
|
154
155
|
- **Cross-adapter transfer** moves a frame to the discrete GPU on hybrid laptops,
|
|
155
156
|
which Desktop Duplication cannot capture from at all.
|
|
156
157
|
- **Dirty-rect metadata**, so a consumer can skip regions that did not change.
|
|
@@ -196,6 +197,10 @@ rewritten.
|
|
|
196
197
|
the memory system can move, and byte-identical to the pure-Python path
|
|
197
198
|
- **Only process what changed**: frames carry the compositor's dirty-rect
|
|
198
199
|
metadata, typically under 1% of the screen on a normal desktop
|
|
200
|
+
- **Feed CUDA directly**: the GPU tensor exposes a shared NT handle, so CuPy or
|
|
201
|
+
PyTorch can read it in place via `cudaImportExternalMemory` — capture to
|
|
202
|
+
`cupy.ndarray` with no CPU round-trip. One dispatch costs **0.075 ms** at
|
|
203
|
+
2560×1600 → 640×640, against 3.1 ms for the same work on the CPU
|
|
199
204
|
- **Hybrid GPU laptops**: move a frame to the discrete GPU that Desktop
|
|
200
205
|
Duplication cannot capture from
|
|
201
206
|
- **Multi-backend support**: NumPy, PIL, and CUDA/CuPy backends
|
|
@@ -206,8 +211,9 @@ rewritten.
|
|
|
206
211
|
- **Flexible output formats**: RGB, RGBA, BGR, BGRA, and grayscale support
|
|
207
212
|
- **Region-based capture**: Efficient capture of specific screen regions
|
|
208
213
|
- **Rotation handling**: Automatic handling of rotated displays
|
|
209
|
-
- **Actionable diagnostics**: headless machines
|
|
210
|
-
|
|
214
|
+
- **Actionable diagnostics**: headless machines, hybrid GPU setups, locked
|
|
215
|
+
workstations, UAC prompts and Session 0 services are detected and named
|
|
216
|
+
rather than failing opaquely
|
|
211
217
|
|
|
212
218
|
## Installation
|
|
213
219
|
|
|
@@ -319,14 +325,24 @@ writer.release()
|
|
|
319
325
|
### NVIDIA GPU Acceleration
|
|
320
326
|
|
|
321
327
|
```python
|
|
322
|
-
#
|
|
323
|
-
screencapture = rapidshot.create(nvidia_gpu=True)
|
|
328
|
+
# Frames come back as cupy.ndarray, converted on the device
|
|
329
|
+
screencapture = rapidshot.create(output_color="RGB", nvidia_gpu=True)
|
|
324
330
|
|
|
325
|
-
|
|
326
|
-
frame = screencapture.grab()
|
|
327
|
-
frame.release()
|
|
331
|
+
frame = screencapture.grab() # cupy.ndarray, stays in VRAM
|
|
328
332
|
```
|
|
329
333
|
|
|
334
|
+
> **Note:** unlike the CPU path, `nvidia_gpu=True` returns a bare
|
|
335
|
+
> `cupy.ndarray` rather than a pooled buffer, so there is no `.release()` to
|
|
336
|
+
> call — CuPy's allocator owns it. `pool_size_frames` does not apply.
|
|
337
|
+
|
|
338
|
+
Colour conversion runs in CuPy on the GPU and is **byte-identical** to the CPU
|
|
339
|
+
path, so turning `nvidia_gpu` on changes no pixel. OpenCV is not required.
|
|
340
|
+
|
|
341
|
+
> **Fixed in 2.3.0:** before this release, any `output_color` other than
|
|
342
|
+
> `"BGRA"` silently returned an unconverted 4-channel BGRA array — wrong shape
|
|
343
|
+
> and wrong channel order, reported as success. Conversion went through OpenCV,
|
|
344
|
+
> which is not a dependency, and the failure was swallowed.
|
|
345
|
+
|
|
330
346
|
### Cursor Capture
|
|
331
347
|
|
|
332
348
|
RapidShot provides comprehensive cursor capture capabilities, allowing you to track cursor position, visibility, and shape in your screen captures.
|
|
@@ -682,6 +698,7 @@ with screencapture.grab_frame() as frame:
|
|
|
682
698
|
print(pre.shape) # (1, 3, 640, 640)
|
|
683
699
|
resource = pre.output_resource_address # ID3D12Resource*
|
|
684
700
|
gpu_va = pre.output_gpu_address # GPU virtual address
|
|
701
|
+
handle = pre.shared_output_handle # shared NT handle, for CUDA etc.
|
|
685
702
|
```
|
|
686
703
|
|
|
687
704
|
`process()` resizes, normalises, converts BGRA→RGB and transposes to NCHW in a
|
|
@@ -690,6 +707,49 @@ frame**; here it is one dispatch and the result stays in VRAM. Optional
|
|
|
690
707
|
arguments cover the usual normalisation ranges (`scale=2.0, bias=-1.0` for
|
|
691
708
|
−1..1) and channel order (`bgr=True`).
|
|
692
709
|
|
|
710
|
+
#### Reading the tensor from CuPy / CUDA
|
|
711
|
+
|
|
712
|
+
`shared_output_handle` is a shared NT handle for the tensor, which CUDA imports
|
|
713
|
+
with `cudaImportExternalMemory` — so the frame reaches a CUDA consumer without
|
|
714
|
+
ever touching the CPU. **`examples/gpu_tensor_to_cupy.py` is a complete, working
|
|
715
|
+
version** of the ~60 lines of `ctypes` this takes; it verifies the resulting
|
|
716
|
+
`cupy.ndarray` is byte-identical to a readback of the same dispatch.
|
|
717
|
+
|
|
718
|
+
`CudaTensor` is defined in `examples/gpu_tensor_to_cupy.py`, not exported by
|
|
719
|
+
the package — copy it into your project rather than importing it:
|
|
720
|
+
|
|
721
|
+
```python
|
|
722
|
+
with CudaTensor(pre, (1, 3, 640, 640)) as view:
|
|
723
|
+
tensor = view.array # a cupy.ndarray in VRAM
|
|
724
|
+
while capturing:
|
|
725
|
+
with screencapture.grab_frame() as frame: # a *new* frame each pass
|
|
726
|
+
if frame is None:
|
|
727
|
+
continue # nothing changed on screen
|
|
728
|
+
view.sync() # queued CUDA work must finish
|
|
729
|
+
pre.process(frame) # overwrites tensor's buffer
|
|
730
|
+
model(tensor)
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Note the fresh `grab_frame()` inside the loop. The preprocessor and the CUDA
|
|
734
|
+
import are built once and reused; the *frame* is not — a released frame raises
|
|
735
|
+
`FrameReleasedError`, and reusing a live one just re-processes the same image.
|
|
736
|
+
Releasing it before `model()` also matters: DXGI cannot acquire the next frame
|
|
737
|
+
while a reference to the previous surface is outstanding.
|
|
738
|
+
|
|
739
|
+
`sync()` is not optional. `process()` waits on the D3D12 fence but knows
|
|
740
|
+
nothing about CUDA work you have queued against the same memory, so a kernel
|
|
741
|
+
still reading the tensor when the next dispatch lands sees a half-overwritten
|
|
742
|
+
frame — with no error, just wrong numbers.
|
|
743
|
+
|
|
744
|
+
Keep the `CudaTensor` for as long as you use the array: it owns the CUDA
|
|
745
|
+
import and the preprocessor that owns the VRAM. On a machine with more than
|
|
746
|
+
one CUDA device, pass `device=N` — the tensor can only be imported by the
|
|
747
|
+
device that owns the adapter which captured the frame.
|
|
748
|
+
|
|
749
|
+
The import is paid once. After that `pre.process(frame)` overwrites the same
|
|
750
|
+
buffer the CuPy array points at, so a capture loop pays nothing per frame to
|
|
751
|
+
keep the view.
|
|
752
|
+
|
|
693
753
|
**Where Rapidshot stops.** The output is an `ID3D12Resource` on the DirectML
|
|
694
754
|
device — exactly what ONNX Runtime's DirectML provider consumes. Rapidshot
|
|
695
755
|
deliberately does **not** bind it to a session: that would couple this library
|
|
@@ -826,6 +886,25 @@ What is measured, reproducibly, is the per-frame cost of RapidShot's own paths
|
|
|
826
886
|
The first three are synthetic and deterministic: they move when the library
|
|
827
887
|
changes and not otherwise, which is why they are the ones on the badges above.
|
|
828
888
|
|
|
889
|
+
**Second machine, NVIDIA RTX 4060 Laptop** (`benchmarks/baseline-rtx4060.json`,
|
|
890
|
+
P-core-pinned, 1920×1080 synthetic source, minimums):
|
|
891
|
+
|
|
892
|
+
| Path | Per frame |
|
|
893
|
+
| --- | --- |
|
|
894
|
+
| Colour conversion, BGRA→RGB | 0.198 ms |
|
|
895
|
+
| Colour conversion, BGRA→GRAY | 0.252 ms |
|
|
896
|
+
| CPU resize + normalise + NCHW | 3.09 ms |
|
|
897
|
+
| **GPU dispatch → NCHW tensor, D3D12** | **0.070 ms** (2560×1600 source) |
|
|
898
|
+
| GPU dispatch + forced readback | 2.20 ms |
|
|
899
|
+
| `grab_frame()` — texture stays on the GPU | 0.099–0.156 ms |
|
|
900
|
+
|
|
901
|
+
Cross-machine comparison is not valid — different CPU, GPU, panel and, on two
|
|
902
|
+
rows, different code. Both recordings are kept so each answers for its own
|
|
903
|
+
machine.
|
|
904
|
+
|
|
905
|
+
Sustained-load check: 211,726 frames of `grab_frame()` → GPU tensor in 12
|
|
906
|
+
minutes, zero errors, VRAM flat, no throughput decay.
|
|
907
|
+
|
|
829
908
|
**The last two are not stable measurements and should not be quoted as single
|
|
830
909
|
numbers.** `grab()` converts only the parts of the frame that changed, so its
|
|
831
910
|
cost tracks what is happening on screen; across seven recordings on unchanged
|
|
@@ -851,11 +930,42 @@ breakdown and how each figure is measured.
|
|
|
851
930
|
only for the optional CuPy acceleration.
|
|
852
931
|
- **RAM:** 8 GB+ (depending on the resolution and number of screencapture instances used)
|
|
853
932
|
|
|
933
|
+
### Tested configurations
|
|
934
|
+
|
|
935
|
+
Nothing in RapidShot branches on GPU vendor. This is what has actually been
|
|
936
|
+
run, which is not the same claim:
|
|
937
|
+
|
|
938
|
+
| Configuration | State |
|
|
939
|
+
| --- | --- |
|
|
940
|
+
| Intel iGPU, single adapter | Verified |
|
|
941
|
+
| NVIDIA dGPU, single adapter, native extension built | Verified |
|
|
942
|
+
| NVIDIA dGPU, single adapter, no extension | Verified |
|
|
943
|
+
| Any AMD GPU | **Not tested** |
|
|
944
|
+
| Hybrid / switchable graphics (Optimus, AMD) | **Not tested** |
|
|
945
|
+
| Headless with a virtual display | **Not tested** |
|
|
946
|
+
|
|
947
|
+
CuPy is the only NVIDIA-bound feature, because CUDA is; `nvidia_gpu=True` falls
|
|
948
|
+
back to the CPU processor when CuPy is unavailable. AMD and Intel consumers
|
|
949
|
+
reach the same GPU tensor through DirectML via `output_resource_address`.
|
|
950
|
+
|
|
854
951
|
### Troubleshooting
|
|
855
952
|
|
|
856
953
|
- **ImportError with CuPy:** Ensure you have compatible CUDA drivers installed.
|
|
857
|
-
|
|
954
|
+
`pip install cupy-cuda13x[ctk]` installs no headers against `cuda-toolkit`
|
|
955
|
+
13.3.x and CuPy then fails at its first JIT; use
|
|
956
|
+
`pip install "cuda-toolkit[cudart,nvrtc]==13.2.*"` instead.
|
|
957
|
+
- **"Desktop duplication was denied":** the message names the cause. A
|
|
958
|
+
non-input desktop, a locked workstation, an open UAC prompt and a Session 0
|
|
959
|
+
service cannot capture the user's screen; protected (HDCP/DRM) content is a
|
|
960
|
+
separate case and is reported as such.
|
|
961
|
+
- **Black screens when capturing:** protected content is blanked by the OS —
|
|
962
|
+
check `frame.protected_content`. Exclusive fullscreen is handled: capture
|
|
963
|
+
detects the transition, rebuilds, and continues.
|
|
858
964
|
- **Low performance:** Experiment with different backends (NUMPY vs. CUPY) to optimize performance.
|
|
965
|
+
- **Unstable benchmark numbers:** on a hybrid P-core/E-core CPU, pin the
|
|
966
|
+
process to the performance cores. `benchmarks/perf_suite.py` does this
|
|
967
|
+
itself; unpinned it reported false regressions up to 2.57× against
|
|
968
|
+
unchanged code.
|
|
859
969
|
|
|
860
970
|
## Contributing
|
|
861
971
|
|
|
@@ -83,8 +83,9 @@ built around have no column here because the other libraries have no equivalent:
|
|
|
83
83
|
|
|
84
84
|
- **`grab_frame()`** hands back a GPU-resident frame in 0.17–0.21 ms that never
|
|
85
85
|
crosses to system memory.
|
|
86
|
-
- **The GPU tensor path** turns a frame into NCHW float32 in one
|
|
87
|
-
|
|
86
|
+
- **The GPU tensor path** turns a frame into model-ready NCHW float32 in one
|
|
87
|
+
dispatch — 0.075 ms for a 2560×1600 frame, against 3.1 ms to do the same work
|
|
88
|
+
on the CPU — and the result stays in VRAM, where CUDA or DirectML can read it.
|
|
88
89
|
- **Cross-adapter transfer** moves a frame to the discrete GPU on hybrid laptops,
|
|
89
90
|
which Desktop Duplication cannot capture from at all.
|
|
90
91
|
- **Dirty-rect metadata**, so a consumer can skip regions that did not change.
|
|
@@ -130,6 +131,10 @@ rewritten.
|
|
|
130
131
|
the memory system can move, and byte-identical to the pure-Python path
|
|
131
132
|
- **Only process what changed**: frames carry the compositor's dirty-rect
|
|
132
133
|
metadata, typically under 1% of the screen on a normal desktop
|
|
134
|
+
- **Feed CUDA directly**: the GPU tensor exposes a shared NT handle, so CuPy or
|
|
135
|
+
PyTorch can read it in place via `cudaImportExternalMemory` — capture to
|
|
136
|
+
`cupy.ndarray` with no CPU round-trip. One dispatch costs **0.075 ms** at
|
|
137
|
+
2560×1600 → 640×640, against 3.1 ms for the same work on the CPU
|
|
133
138
|
- **Hybrid GPU laptops**: move a frame to the discrete GPU that Desktop
|
|
134
139
|
Duplication cannot capture from
|
|
135
140
|
- **Multi-backend support**: NumPy, PIL, and CUDA/CuPy backends
|
|
@@ -140,8 +145,9 @@ rewritten.
|
|
|
140
145
|
- **Flexible output formats**: RGB, RGBA, BGR, BGRA, and grayscale support
|
|
141
146
|
- **Region-based capture**: Efficient capture of specific screen regions
|
|
142
147
|
- **Rotation handling**: Automatic handling of rotated displays
|
|
143
|
-
- **Actionable diagnostics**: headless machines
|
|
144
|
-
|
|
148
|
+
- **Actionable diagnostics**: headless machines, hybrid GPU setups, locked
|
|
149
|
+
workstations, UAC prompts and Session 0 services are detected and named
|
|
150
|
+
rather than failing opaquely
|
|
145
151
|
|
|
146
152
|
## Installation
|
|
147
153
|
|
|
@@ -253,14 +259,24 @@ writer.release()
|
|
|
253
259
|
### NVIDIA GPU Acceleration
|
|
254
260
|
|
|
255
261
|
```python
|
|
256
|
-
#
|
|
257
|
-
screencapture = rapidshot.create(nvidia_gpu=True)
|
|
262
|
+
# Frames come back as cupy.ndarray, converted on the device
|
|
263
|
+
screencapture = rapidshot.create(output_color="RGB", nvidia_gpu=True)
|
|
258
264
|
|
|
259
|
-
|
|
260
|
-
frame = screencapture.grab()
|
|
261
|
-
frame.release()
|
|
265
|
+
frame = screencapture.grab() # cupy.ndarray, stays in VRAM
|
|
262
266
|
```
|
|
263
267
|
|
|
268
|
+
> **Note:** unlike the CPU path, `nvidia_gpu=True` returns a bare
|
|
269
|
+
> `cupy.ndarray` rather than a pooled buffer, so there is no `.release()` to
|
|
270
|
+
> call — CuPy's allocator owns it. `pool_size_frames` does not apply.
|
|
271
|
+
|
|
272
|
+
Colour conversion runs in CuPy on the GPU and is **byte-identical** to the CPU
|
|
273
|
+
path, so turning `nvidia_gpu` on changes no pixel. OpenCV is not required.
|
|
274
|
+
|
|
275
|
+
> **Fixed in 2.3.0:** before this release, any `output_color` other than
|
|
276
|
+
> `"BGRA"` silently returned an unconverted 4-channel BGRA array — wrong shape
|
|
277
|
+
> and wrong channel order, reported as success. Conversion went through OpenCV,
|
|
278
|
+
> which is not a dependency, and the failure was swallowed.
|
|
279
|
+
|
|
264
280
|
### Cursor Capture
|
|
265
281
|
|
|
266
282
|
RapidShot provides comprehensive cursor capture capabilities, allowing you to track cursor position, visibility, and shape in your screen captures.
|
|
@@ -616,6 +632,7 @@ with screencapture.grab_frame() as frame:
|
|
|
616
632
|
print(pre.shape) # (1, 3, 640, 640)
|
|
617
633
|
resource = pre.output_resource_address # ID3D12Resource*
|
|
618
634
|
gpu_va = pre.output_gpu_address # GPU virtual address
|
|
635
|
+
handle = pre.shared_output_handle # shared NT handle, for CUDA etc.
|
|
619
636
|
```
|
|
620
637
|
|
|
621
638
|
`process()` resizes, normalises, converts BGRA→RGB and transposes to NCHW in a
|
|
@@ -624,6 +641,49 @@ frame**; here it is one dispatch and the result stays in VRAM. Optional
|
|
|
624
641
|
arguments cover the usual normalisation ranges (`scale=2.0, bias=-1.0` for
|
|
625
642
|
−1..1) and channel order (`bgr=True`).
|
|
626
643
|
|
|
644
|
+
#### Reading the tensor from CuPy / CUDA
|
|
645
|
+
|
|
646
|
+
`shared_output_handle` is a shared NT handle for the tensor, which CUDA imports
|
|
647
|
+
with `cudaImportExternalMemory` — so the frame reaches a CUDA consumer without
|
|
648
|
+
ever touching the CPU. **`examples/gpu_tensor_to_cupy.py` is a complete, working
|
|
649
|
+
version** of the ~60 lines of `ctypes` this takes; it verifies the resulting
|
|
650
|
+
`cupy.ndarray` is byte-identical to a readback of the same dispatch.
|
|
651
|
+
|
|
652
|
+
`CudaTensor` is defined in `examples/gpu_tensor_to_cupy.py`, not exported by
|
|
653
|
+
the package — copy it into your project rather than importing it:
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
with CudaTensor(pre, (1, 3, 640, 640)) as view:
|
|
657
|
+
tensor = view.array # a cupy.ndarray in VRAM
|
|
658
|
+
while capturing:
|
|
659
|
+
with screencapture.grab_frame() as frame: # a *new* frame each pass
|
|
660
|
+
if frame is None:
|
|
661
|
+
continue # nothing changed on screen
|
|
662
|
+
view.sync() # queued CUDA work must finish
|
|
663
|
+
pre.process(frame) # overwrites tensor's buffer
|
|
664
|
+
model(tensor)
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
Note the fresh `grab_frame()` inside the loop. The preprocessor and the CUDA
|
|
668
|
+
import are built once and reused; the *frame* is not — a released frame raises
|
|
669
|
+
`FrameReleasedError`, and reusing a live one just re-processes the same image.
|
|
670
|
+
Releasing it before `model()` also matters: DXGI cannot acquire the next frame
|
|
671
|
+
while a reference to the previous surface is outstanding.
|
|
672
|
+
|
|
673
|
+
`sync()` is not optional. `process()` waits on the D3D12 fence but knows
|
|
674
|
+
nothing about CUDA work you have queued against the same memory, so a kernel
|
|
675
|
+
still reading the tensor when the next dispatch lands sees a half-overwritten
|
|
676
|
+
frame — with no error, just wrong numbers.
|
|
677
|
+
|
|
678
|
+
Keep the `CudaTensor` for as long as you use the array: it owns the CUDA
|
|
679
|
+
import and the preprocessor that owns the VRAM. On a machine with more than
|
|
680
|
+
one CUDA device, pass `device=N` — the tensor can only be imported by the
|
|
681
|
+
device that owns the adapter which captured the frame.
|
|
682
|
+
|
|
683
|
+
The import is paid once. After that `pre.process(frame)` overwrites the same
|
|
684
|
+
buffer the CuPy array points at, so a capture loop pays nothing per frame to
|
|
685
|
+
keep the view.
|
|
686
|
+
|
|
627
687
|
**Where Rapidshot stops.** The output is an `ID3D12Resource` on the DirectML
|
|
628
688
|
device — exactly what ONNX Runtime's DirectML provider consumes. Rapidshot
|
|
629
689
|
deliberately does **not** bind it to a session: that would couple this library
|
|
@@ -760,6 +820,25 @@ What is measured, reproducibly, is the per-frame cost of RapidShot's own paths
|
|
|
760
820
|
The first three are synthetic and deterministic: they move when the library
|
|
761
821
|
changes and not otherwise, which is why they are the ones on the badges above.
|
|
762
822
|
|
|
823
|
+
**Second machine, NVIDIA RTX 4060 Laptop** (`benchmarks/baseline-rtx4060.json`,
|
|
824
|
+
P-core-pinned, 1920×1080 synthetic source, minimums):
|
|
825
|
+
|
|
826
|
+
| Path | Per frame |
|
|
827
|
+
| --- | --- |
|
|
828
|
+
| Colour conversion, BGRA→RGB | 0.198 ms |
|
|
829
|
+
| Colour conversion, BGRA→GRAY | 0.252 ms |
|
|
830
|
+
| CPU resize + normalise + NCHW | 3.09 ms |
|
|
831
|
+
| **GPU dispatch → NCHW tensor, D3D12** | **0.070 ms** (2560×1600 source) |
|
|
832
|
+
| GPU dispatch + forced readback | 2.20 ms |
|
|
833
|
+
| `grab_frame()` — texture stays on the GPU | 0.099–0.156 ms |
|
|
834
|
+
|
|
835
|
+
Cross-machine comparison is not valid — different CPU, GPU, panel and, on two
|
|
836
|
+
rows, different code. Both recordings are kept so each answers for its own
|
|
837
|
+
machine.
|
|
838
|
+
|
|
839
|
+
Sustained-load check: 211,726 frames of `grab_frame()` → GPU tensor in 12
|
|
840
|
+
minutes, zero errors, VRAM flat, no throughput decay.
|
|
841
|
+
|
|
763
842
|
**The last two are not stable measurements and should not be quoted as single
|
|
764
843
|
numbers.** `grab()` converts only the parts of the frame that changed, so its
|
|
765
844
|
cost tracks what is happening on screen; across seven recordings on unchanged
|
|
@@ -785,11 +864,42 @@ breakdown and how each figure is measured.
|
|
|
785
864
|
only for the optional CuPy acceleration.
|
|
786
865
|
- **RAM:** 8 GB+ (depending on the resolution and number of screencapture instances used)
|
|
787
866
|
|
|
867
|
+
### Tested configurations
|
|
868
|
+
|
|
869
|
+
Nothing in RapidShot branches on GPU vendor. This is what has actually been
|
|
870
|
+
run, which is not the same claim:
|
|
871
|
+
|
|
872
|
+
| Configuration | State |
|
|
873
|
+
| --- | --- |
|
|
874
|
+
| Intel iGPU, single adapter | Verified |
|
|
875
|
+
| NVIDIA dGPU, single adapter, native extension built | Verified |
|
|
876
|
+
| NVIDIA dGPU, single adapter, no extension | Verified |
|
|
877
|
+
| Any AMD GPU | **Not tested** |
|
|
878
|
+
| Hybrid / switchable graphics (Optimus, AMD) | **Not tested** |
|
|
879
|
+
| Headless with a virtual display | **Not tested** |
|
|
880
|
+
|
|
881
|
+
CuPy is the only NVIDIA-bound feature, because CUDA is; `nvidia_gpu=True` falls
|
|
882
|
+
back to the CPU processor when CuPy is unavailable. AMD and Intel consumers
|
|
883
|
+
reach the same GPU tensor through DirectML via `output_resource_address`.
|
|
884
|
+
|
|
788
885
|
### Troubleshooting
|
|
789
886
|
|
|
790
887
|
- **ImportError with CuPy:** Ensure you have compatible CUDA drivers installed.
|
|
791
|
-
|
|
888
|
+
`pip install cupy-cuda13x[ctk]` installs no headers against `cuda-toolkit`
|
|
889
|
+
13.3.x and CuPy then fails at its first JIT; use
|
|
890
|
+
`pip install "cuda-toolkit[cudart,nvrtc]==13.2.*"` instead.
|
|
891
|
+
- **"Desktop duplication was denied":** the message names the cause. A
|
|
892
|
+
non-input desktop, a locked workstation, an open UAC prompt and a Session 0
|
|
893
|
+
service cannot capture the user's screen; protected (HDCP/DRM) content is a
|
|
894
|
+
separate case and is reported as such.
|
|
895
|
+
- **Black screens when capturing:** protected content is blanked by the OS —
|
|
896
|
+
check `frame.protected_content`. Exclusive fullscreen is handled: capture
|
|
897
|
+
detects the transition, rebuilds, and continues.
|
|
792
898
|
- **Low performance:** Experiment with different backends (NUMPY vs. CUPY) to optimize performance.
|
|
899
|
+
- **Unstable benchmark numbers:** on a hybrid P-core/E-core CPU, pin the
|
|
900
|
+
process to the performance cores. `benchmarks/perf_suite.py` does this
|
|
901
|
+
itself; unpinned it reported false regressions up to 2.57× against
|
|
902
|
+
unchanged code.
|
|
793
903
|
|
|
794
904
|
## Contributing
|
|
795
905
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rapidshot"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "A high-performance screencapture library for Windows using Desktop Duplication API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -539,7 +539,7 @@ def get_version_info() -> Dict[str, Any]:
|
|
|
539
539
|
return info
|
|
540
540
|
|
|
541
541
|
# Version information
|
|
542
|
-
__version__ = "2.
|
|
542
|
+
__version__ = "2.3.0"
|
|
543
543
|
__author__ = "Rapidshot Contributors"
|
|
544
544
|
__description__ = "High-performance screencapture library for Windows using Desktop Duplication API"
|
|
545
545
|
|
|
@@ -610,6 +610,7 @@ class ScreenCapture:
|
|
|
610
610
|
rotation_angle=self.rotation_angle,
|
|
611
611
|
present_time_qpc=duplicator.last_present_time,
|
|
612
612
|
accumulated_frames=duplicator.accumulated_frames,
|
|
613
|
+
source_id=duplicator.instance_id,
|
|
613
614
|
protected_content=duplicator.protected_content_detected,
|
|
614
615
|
cursor_visible=duplicator.cursor_visible,
|
|
615
616
|
dirty_rects=duplicator.dirty_rects,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ctypes
|
|
2
|
+
import itertools
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
5
|
import comtypes # type: ignore[import-untyped]
|
|
@@ -33,8 +34,10 @@ from rapidshot._libs.dxgi import (
|
|
|
33
34
|
IDXGIResource,
|
|
34
35
|
RECT,
|
|
35
36
|
)
|
|
37
|
+
from rapidshot._libs.dxgi import E_ACCESSDENIED
|
|
36
38
|
from rapidshot.core.device import Device
|
|
37
39
|
from rapidshot.core.output import Output
|
|
40
|
+
from rapidshot.util.desktop import describe_desktop_access
|
|
38
41
|
from rapidshot.util.errors import (
|
|
39
42
|
RapidShotError,
|
|
40
43
|
RapidShotDXGIError,
|
|
@@ -71,6 +74,13 @@ DUPLICATE_OUTPUT1_FORMATS = (
|
|
|
71
74
|
# pre-1.5 DuplicateOutput path if a driver misbehaves on DuplicateOutput1.
|
|
72
75
|
_DUPLICATE_OUTPUT_ENV = "RAPIDSHOT_DUPLICATE_OUTPUT"
|
|
73
76
|
|
|
77
|
+
# Process-unique id per Duplicator. Consumers that cache anything keyed on a
|
|
78
|
+
# captured texture's address need this: COM addresses are recycled, so a
|
|
79
|
+
# released surface and a later unrelated one can share a pointer. Pairing the
|
|
80
|
+
# pointer with the duplicator that produced it makes the identity sound,
|
|
81
|
+
# because a recycled address necessarily belongs to a different duplicator.
|
|
82
|
+
_duplicator_ids = itertools.count(1)
|
|
83
|
+
|
|
74
84
|
|
|
75
85
|
def _format_hresult(hresult) -> str:
|
|
76
86
|
"""Render an HRESULT as 0x-prefixed hex, tolerating non-integer values."""
|
|
@@ -79,6 +89,37 @@ def _format_hresult(hresult) -> str:
|
|
|
79
89
|
return str(hresult)
|
|
80
90
|
|
|
81
91
|
|
|
92
|
+
def _desktop_refusal(hresult, detail: str = "") -> Optional[RapidShotError]:
|
|
93
|
+
"""Return a desktop-specific error if that is what a refusal really means.
|
|
94
|
+
|
|
95
|
+
``DuplicateOutput`` reports both "protected content is on screen" and "you
|
|
96
|
+
are not on the input desktop" as a bare ``E_ACCESSDENIED``. Reporting the
|
|
97
|
+
second as the first is actively misleading: a locked workstation, an open
|
|
98
|
+
UAC prompt, and a Session 0 service all told the user to close a protected
|
|
99
|
+
player window that does not exist. Measured 2026-08-06 by moving a thread
|
|
100
|
+
to a fresh desktop with ``CreateDesktop``, which reproduces the HRESULT
|
|
101
|
+
exactly.
|
|
102
|
+
|
|
103
|
+
Only plain ``E_ACCESSDENIED`` is ambiguous. ``DXGI_ERROR_ACCESS_DENIED``
|
|
104
|
+
and ``DXGI_ERROR_CANNOT_PROTECT_CONTENT`` are specific to protected
|
|
105
|
+
content, so they are left alone.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A ``RapidShotConfigError`` naming the real cause, or None to let the
|
|
109
|
+
caller report protected content.
|
|
110
|
+
"""
|
|
111
|
+
if hresult != E_ACCESSDENIED:
|
|
112
|
+
return None
|
|
113
|
+
state = describe_desktop_access()
|
|
114
|
+
if state.blocked_reason is None:
|
|
115
|
+
return None
|
|
116
|
+
prefix = f"{detail} — " if detail else ""
|
|
117
|
+
return RapidShotConfigError(
|
|
118
|
+
f"{prefix}Desktop duplication was denied: {state.blocked_reason}",
|
|
119
|
+
hresult=hresult,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
82
123
|
def _legacy_duplication_forced() -> bool:
|
|
83
124
|
"""True if the env var asks for the pre-DuplicateOutput1 code path."""
|
|
84
125
|
return os.environ.get(_DUPLICATE_OUTPUT_ENV, "").strip().lower() in (
|
|
@@ -152,6 +193,8 @@ class Duplicator:
|
|
|
152
193
|
# True when the driver merged rects rather than reporting them individually,
|
|
153
194
|
# so the regions are an over-estimate of what actually changed.
|
|
154
195
|
rects_coalesced: bool = False
|
|
196
|
+
# See _duplicator_ids: identifies which duplicator a texture came from.
|
|
197
|
+
instance_id: int = field(default_factory=lambda: next(_duplicator_ids))
|
|
155
198
|
_frame_acquired: bool = False
|
|
156
199
|
|
|
157
200
|
def __post_init__(self, output: Output, device: Device) -> None:
|
|
@@ -232,6 +275,13 @@ class Duplicator:
|
|
|
232
275
|
except comtypes.COMError as ce:
|
|
233
276
|
hresult = ce.args[0] if ce.args else None
|
|
234
277
|
if hresult in DXGI_PROTECTED_CONTENT_ERRORS:
|
|
278
|
+
# A bare E_ACCESSDENIED means one of two unrelated
|
|
279
|
+
# things, and blaming protected content for both sends
|
|
280
|
+
# people hunting for a player window that is not there.
|
|
281
|
+
# Ask the window station before deciding.
|
|
282
|
+
desktop_error = _desktop_refusal(hresult)
|
|
283
|
+
if desktop_error is not None:
|
|
284
|
+
raise desktop_error from ce
|
|
235
285
|
# Protected surfaces are a permanent refusal for this
|
|
236
286
|
# output, not something the legacy path can work around.
|
|
237
287
|
self.protected_content_detected = True
|
|
@@ -263,6 +313,9 @@ class Duplicator:
|
|
|
263
313
|
detail = f"{context}: {ce}"
|
|
264
314
|
|
|
265
315
|
if hresult in DXGI_PROTECTED_CONTENT_ERRORS:
|
|
316
|
+
desktop_error = _desktop_refusal(hresult, detail)
|
|
317
|
+
if desktop_error is not None:
|
|
318
|
+
return desktop_error
|
|
266
319
|
self.protected_content_detected = True
|
|
267
320
|
return RapidShotProtectedContentError(
|
|
268
321
|
f"{detail} (protected/HDCP content is blocking duplication)",
|
|
@@ -78,7 +78,7 @@ class Frame:
|
|
|
78
78
|
"_texture", "_on_release", "_released", "_region", "_rotation_angle",
|
|
79
79
|
"_present_time_qpc", "_accumulated_frames", "_protected_content",
|
|
80
80
|
"_cursor_visible", "_width", "_height", "_dirty_rects",
|
|
81
|
-
"_rects_coalesced",
|
|
81
|
+
"_rects_coalesced", "_source_id",
|
|
82
82
|
)
|
|
83
83
|
|
|
84
84
|
def __init__(
|
|
@@ -93,6 +93,7 @@ class Frame:
|
|
|
93
93
|
cursor_visible: bool = False,
|
|
94
94
|
dirty_rects: Optional[List[Tuple[int, int, int, int]]] = None,
|
|
95
95
|
rects_coalesced: bool = False,
|
|
96
|
+
source_id: int = 0,
|
|
96
97
|
) -> None:
|
|
97
98
|
self._texture = texture
|
|
98
99
|
self._on_release = on_release
|
|
@@ -107,6 +108,7 @@ class Frame:
|
|
|
107
108
|
self._height = region[3] - region[1]
|
|
108
109
|
self._dirty_rects = self._clip_to_region(dirty_rects)
|
|
109
110
|
self._rects_coalesced = rects_coalesced
|
|
111
|
+
self._source_id = source_id
|
|
110
112
|
|
|
111
113
|
def _clip_to_region(self, rects):
|
|
112
114
|
"""Translate desktop-coordinate rects into this frame's coordinates.
|
|
@@ -205,6 +207,17 @@ class Frame:
|
|
|
205
207
|
"""
|
|
206
208
|
return self._accumulated_frames
|
|
207
209
|
|
|
210
|
+
@property
|
|
211
|
+
def source_id(self) -> int:
|
|
212
|
+
"""Which duplicator produced this frame's texture.
|
|
213
|
+
|
|
214
|
+
Consumers caching anything keyed on the texture address need this too:
|
|
215
|
+
COM addresses are recycled, so a released surface and a later unrelated
|
|
216
|
+
one can share a pointer. The pair is a sound identity; the pointer
|
|
217
|
+
alone is not.
|
|
218
|
+
"""
|
|
219
|
+
return self._source_id
|
|
220
|
+
|
|
208
221
|
@property
|
|
209
222
|
def protected_content(self) -> bool:
|
|
210
223
|
"""True if HDCP/DRM content was blanked out of this frame by the OS."""
|
|
@@ -254,7 +254,10 @@ class GpuPreprocessor:
|
|
|
254
254
|
"""
|
|
255
255
|
import numpy as np
|
|
256
256
|
|
|
257
|
-
|
|
257
|
+
# The extension hands back raw bytes, not a list of floats: a list cost
|
|
258
|
+
# one Python object per element and dominated the readback benchmark it
|
|
259
|
+
# was meant to price. `frombuffer` is a reinterpret, not a conversion.
|
|
260
|
+
flat = np.frombuffer(self._impl.read_back(), dtype=np.float32)
|
|
258
261
|
return flat.reshape(1, 3, self.out_height, self.out_width)
|
|
259
262
|
|
|
260
263
|
@property
|
|
@@ -319,19 +322,57 @@ class GpuPreprocessor12:
|
|
|
319
322
|
def process(self, frame, scale: float = 1.0, bias: float = 0.0,
|
|
320
323
|
bgr: bool = False) -> None:
|
|
321
324
|
"""Convert one frame. The result stays on the DirectML device."""
|
|
322
|
-
|
|
325
|
+
# The texture address alone is not an identity -- COM addresses get
|
|
326
|
+
# recycled -- so the frame's source_id goes with it. See Frame.source_id.
|
|
327
|
+
self._impl.process(_texture_address(frame), scale, bias, bgr,
|
|
328
|
+
source_id=getattr(frame, "source_id", 0))
|
|
323
329
|
|
|
324
330
|
def read_back(self):
|
|
325
331
|
"""Copy the tensor to the CPU as (1, 3, H, W). Verification only."""
|
|
326
332
|
import numpy as np
|
|
327
333
|
|
|
328
|
-
|
|
334
|
+
# Raw bytes rather than a list of floats — see the note on the D3D11
|
|
335
|
+
# preprocessor's read_back.
|
|
336
|
+
flat = np.frombuffer(self._impl.read_back(), dtype=np.float32)
|
|
329
337
|
return flat.reshape(1, 3, self.out_height, self.out_width)
|
|
330
338
|
|
|
331
339
|
@property
|
|
332
340
|
def shape(self):
|
|
333
341
|
return tuple(self._impl.shape)
|
|
334
342
|
|
|
343
|
+
@property
|
|
344
|
+
def shared_output_handle(self) -> int:
|
|
345
|
+
"""
|
|
346
|
+
Shared NT handle for the tensor, for a consumer on another device or API.
|
|
347
|
+
|
|
348
|
+
This is what CUDA's ``cudaImportExternalMemory`` takes with
|
|
349
|
+
``cudaExternalMemoryHandleTypeD3D12Resource``. Pair it with
|
|
350
|
+
:attr:`output_byte_size`, which is the size such an importer must map.
|
|
351
|
+
|
|
352
|
+
**Borrowed, not owned.** It is closed when this preprocessor is dropped;
|
|
353
|
+
do not close it yourself, and do not use it after that point.
|
|
354
|
+
"""
|
|
355
|
+
return int(self._impl.shared_output_handle)
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def output_byte_size(self) -> int:
|
|
359
|
+
"""Size of the tensor in bytes — what an importer must map."""
|
|
360
|
+
return int(self._impl.output_byte_size)
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def adapter_luid(self) -> bytes:
|
|
364
|
+
"""
|
|
365
|
+
LUID of the adapter holding the tensor, as 8 little-endian bytes.
|
|
366
|
+
|
|
367
|
+
The tensor is not cross-adapter, so only a consumer on *this* adapter
|
|
368
|
+
can import it. CUDA exposes the same identity via ``cuDeviceGetLuid``,
|
|
369
|
+
which is how a caller picks the right device — and how it finds out
|
|
370
|
+
there isn't one. That is the ordinary hybrid-laptop case: capture runs
|
|
371
|
+
on the iGPU, CUDA reports exactly one device, and it is the wrong one,
|
|
372
|
+
so counting devices cannot detect the mismatch.
|
|
373
|
+
"""
|
|
374
|
+
return bytes(self._impl.adapter_luid)
|
|
375
|
+
|
|
335
376
|
@property
|
|
336
377
|
def output_resource_address(self) -> int:
|
|
337
378
|
"""
|