RapidShot
Badge figures are generated from benchmarks/baseline.json
and checked in CI, so they cannot drift from the recording. They are synthetic
conversion benchmarks with the optional native extension built — not
end-to-end capture rates, and not what a plain pip install produces. Read
Measurements before quoting any of them.
Windows screen capture through DXGI Desktop Duplication, with a route from a captured frame to GPU memory that does not pass through the CPU.
Concretely, it can hand you a frame as a NumPy array, as a cupy.ndarray, as a
Direct3D texture that never leaves the GPU, as an NCHW float32 tensor produced
by one compute dispatch, or as a copy that has been moved to a second adapter on
a hybrid laptop. Frames carry the compositor's dirty-rect metadata. Colour
conversion has byte-exact AVX2 kernels behind an optional extension.
It began as a merge of several DXcam forks and keeps a broadly familiar API. The capture path, the colour pipeline and the GPU interop have since been rewritten.
What this library is not. It is not the fastest at grab() alone — see
Against other libraries, where it loses the
capture-only frame-rate column in most cells. It pulls ahead once the frame has
somewhere to go: taking pixels to a model-ready CUDA tensor, and on through a
trained YOLO11n, it returned more unique frames, younger pixels and less CPU per
frame than DXcam on the one machine measured so far
(Desktop to model). It uses
noticeably more memory than DXcam, BetterCam or mss. If capture is the only
thing your machine is doing and you want the lowest per-call latency, DXcam and
BetterCam are good and you should use them.
Contents
- Installation
- Quick start
- Frame buffers
- Coming from DXcam
- Trading CPU for frames
- Only process what changed
- Colour formats
- Capturing into your own buffer
- GPU-resident capture
- The GPU tensor
- Hybrid GPU laptops
- Surviving display changes
- Headless machines
- The optional native extension
- Profiling your own loop
- Measurements
- Against other libraries
- System requirements
- Diagnostics
- Troubleshooting
Installation
Installed as rapidshot, imported as import rapidshot.
pip install rapidshot
That is pure Python and needs no toolchain. For the GPU tensor, cross-adapter transfer and the AVX2 conversion kernels, add the prebuilt extension — still no toolchain:
pip install rapidshot[native]
One abi3 wheel covers Python 3.9 onward, Windows x86-64. See
the native extension for what it adds and how
to build it yourself instead.
To see what you ended up with — whether the extension loaded, which adapters can capture, which optional dependencies are importable:
import rapidshot
print(rapidshot.diagnose())
See Diagnostics.
pip install rapidshot[cv2] # OpenCV, for your own downstream use
pip install rapidshot[gpu_cuda13] # CuPy for CUDA 13
pip install rapidshot[gpu_cuda12] # CUDA 12
pip install rapidshot[gpu] # CUDA 11
pip install rapidshot[all]
The CuPy wheels are mutually exclusive, so there is no catch-all extra — check
your toolkit with nvidia-smi. RapidShot does not use OpenCV for colour
conversion; since 2.3.0 the GPU path is pure CuPy and stays on the device.
Quick start
import numpy as np
import rapidshot
camera = rapidshot.create()
frame = camera.grab()
if frame is not None:
print(frame.shape) # (H, W, 3), RGB by default
frame.release() # hand the buffer back -- see Frame buffers
grab() returns None when nothing has changed on screen. Desktop Duplication
reports compositor presents, so a still desktop produces no frames at all.
This surprises people benchmarking against a static screen; it is not a fault.
Region capture
region = (760, 340, 1160, 740) # left, top, right, bottom
frame = camera.grab(region=region) # 400x400
Continuous capture
camera.start(target_fps=60)
for _ in range(1000):
image = camera.get_latest_frame() # blocks until a new frame arrives
camera.stop()
While start() is running, the capture thread owns the duplicator: grab(),
grab_frame() and shot() raise RuntimeError rather than compete with it
for frames. Read with get_latest_frame(), or stop() first.
Unlike grab(), get_latest_frame() returns a plain array that is yours to
keep — it is copied out of the capture queue, and nothing releases. Reading it
is also what returns that buffer to the pool, so a consumer that stops reading
eventually stalls the producer. To skip the copy, take the buffer itself:
frame = camera.get_latest_frame_buffer() # a PooledBuffer, as grab() returns
if frame is not None:
arr = np.asarray(frame) # zero-copy
frame.release() # yours until you do
Before 2.5.0,
get_latest_frame()returned the queued buffer's array without taking it out of the queue, so the next capture could overwrite the frame while you read it — see Frame buffers.
One camera per output
rapidshot.create() called again for the same output returns the camera that
already exists — provided the settings match. If they differ (another
output_color, region, nvidia_gpu, …) it raises ConfigurationError
rather than hand back a camera configured for something else; release() the
old one first.
Recording to a video file
import cv2
import rapidshot
camera = rapidshot.create(output_color="BGR") # OpenCV's channel order
camera.start(target_fps=30, video_mode=True)
writer = cv2.VideoWriter(
"video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), 30, (1920, 1080)
)
for _ in range(300):
writer.write(camera.get_latest_frame())
camera.stop()
writer.release()
video_mode=True repeats the last frame when the screen is idle, so the output
keeps a constant frame rate rather than stalling.
Converting on the GPU
camera = rapidshot.create(output_color="RGB", nvidia_gpu=True)
frame = camera.grab() # a cupy.ndarray, still in VRAM
Conversion runs in CuPy and is byte-identical to the CPU path, so enabling it
changes no pixel. It returns a bare cupy.ndarray rather than a pooled buffer,
so there is no release() to call and pool_size_frames does not apply.
Requires a working CuPy, which means CUDA headers as well as the wheel. If CuPy imports but fails at its first JIT,
grab()returnsNoneand the CuPy error goes to stderr — capture does not silently fall back to the CPU processor in that case. The fallback covers CuPy being absent, not CuPy being broken. See Troubleshooting.
Cursor
Frames from grab_frame() carry a snapshot of the
cursor as of that frame:
frame = camera.grab_frame()
if frame is not None:
with frame:
cursor = frame.cursor # a copy -- still valid after release
if cursor.visible:
x, y = cursor.position # desktop coordinates
hx, hy = cursor.hotspot # click point within the shape
if cursor.shape is not None:
w, h = cursor.shape_size
cursor.shape is the raw pointer image as DXGI sent it, cursor.shape_pitch
bytes per row, in the encoding cursor.shape_type names: 1 monochrome (an AND
mask stacked above an XOR mask, so it is twice the cursor's height), 2 colour
BGRA, 4 masked colour. shape is None until DXGI has sent one.
Compositing the cursor onto a frame is left to the caller: doing it correctly means handling those three encodings separately, and the right blend depends on what you are compositing onto.
camera.grab_cursor() still returns the raw DXGI structures
(PointerPositionInfo, PointerShapeInfo, Shape) for code that already
uses them.
Frame buffers
grab() returns a PooledBuffer — a reused buffer, not a fresh array.
Allocating one per frame costs about 1.6 ms on a 1080p RGB frame, because the
page faults on first touch cost more than the conversion; reusing them makes
grab() 1.3–2.1x faster.
It behaves like the array it wraps:
frame = camera.grab()
if frame is not None:
frame.shape, frame.dtype, frame.ndim
pixel = frame[y, x]
arr = np.asarray(frame) # zero-copy, for cv2 / PIL / a model
frame.release() # the one new line
Release when done. The buffer returns to the pool and is handed to the next
capture, so anything still holding it would see the wrong frame. Reading it
after release raises BufferReleasedError rather than returning stale pixels.
To keep the data, frame.copy() or np.array(frame, copy=True).
Before 2.5.0,
np.array(frame, copy=True)returned a view under NumPy 2 — the buffer accepted NumPy'scopyargument and ignored it — so the "copy" was overwritten by the next capture, with nothing raising.frame.copy()andnp.asarray(frame).copy()were always correct.
Forgetting is never corrupting: the pool will not hand you a buffer another
caller is still reading. What happens when it runs dry depends on the mode.
With a conversion — RGB, BGR, RGBA, GRAY — capture falls back to
allocating, which is slower but always correct. BGRA does no conversion, so
the frame you hold is the staging buffer, and there is nothing to fall back
to: once every buffer is out, grab() returns None until one comes back.
Measured: 30 unreleased BGRA grabs at pool_size_frames=2 gave 2 frames and
then None, where RGB kept going.
Coming from 1.x: add release(), and wrap in np.asarray() where a true
ndarray is required (isinstance checks, Image.fromarray). Or opt out:
camera = rapidshot.create(pool_output=False) # plain ndarrays, as before
Before 2.5.0 this was only true for the converting modes. BGRA converts
nothing, so its frame is the staging buffer, and pool_output=False handed
that buffer over still pooled — an object the documentation said you would not
get, and which the same sentence told you not to release. Capture then stopped
after pool_size_frames frames and returned None from then on, silently.
BGRA now copies out, which is the cost pool_output=False opts into.
BGRA already worked this way before 2.0 — it does no conversion, so its staging buffer was always returned pooled.
Coming from DXcam
Change one import:
import rapidshot.dxcam_compat as dxcam # was: import dxcam
camera = dxcam.create(output_color="BGR")
frame = camera.grab() # a plain numpy.ndarray, as before
The shim provides create(), device_info(), output_info(), reset() and
clean_up(), and a camera with grab(), shot(), start() /
get_latest_frame() / stop(), release(), grab_view() /
get_latest_frame_view(), and the attributes DXcam code reads — width,
height, channel_size, region, is_capturing, latest_frame_time.
Keywords create() does not recognise are passed through to
rapidshot.create(), so nvidia_gpu=True keeps working.
Two differences from DXcam are deliberate. grab() raises while start() is
running, where DXcam lets it race the capture thread (Continuous
capture). And a second create() for the same output
with different settings raises instead of returning the first camera
(One camera per output).
It costs one copy per frame, on purpose. DXcam code never releases a frame, and RapidShot's buffers must be released, so the shim copies each frame out and releases it at once. That copy is the price of not touching your code.
grab_view() is the exception. DXcam's "valid until the next grab" contract is
exactly a pooled buffer's lifetime, so it is genuinely zero-copy. The same
caveat as DXcam applies: a view kept past the next grab is not protected, and
silently shows whatever frame its buffer holds next. Copy what you need to keep.
Migrate a call site at a time: camera.rapidshot_camera is the RapidShot camera
underneath, and any attribute the shim does not define is forwarded to it. Once
nothing uses the shim, import rapidshot and add release() to drop the copy.
Code that reaches into DXcam internals such as camera._duplicator is not
portable, and the shim does not pretend otherwise.
Trading CPU for frames
Each capture waits up to timeout_ms for the compositor to present. That single
number is most of the difference between this library and the polling ones:
camera = rapidshot.create(timeout_ms=0) # poll instead of waiting
camera.timeout_ms = 10 # or change it on a live camera
Measured on a 100 Hz output against a source presenting at ~610 updates/s (ROADMAP.md section 3):
timeout_ms |
frames/s | hit rate | CPU |
|---|---|---|---|
| 0 (poll) | 127.8 | 2.4% | 68.6% |
| 1 | 119.2 | 74.5% | 19.5% |
| 10 (default) | 118.9 | 100% | 15.7% |
The frame rate barely moves across that range while CPU moves by more than four
times. Polling is what DXcam does, and the frames it buys are real — just
expensive. Use 0 if capture is the only thing running; leave the default if it
is one stage of a pipeline that needs its cores.
Frames beyond the display's refresh rate were never shown as distinct images. They are extra temporal samples for a model, and redundant for a recorder.
Only process what changed
Frames from grab_frame() carry the compositor's dirty-rect metadata:
frame = camera.grab_frame() # None when nothing changed
if frame is not None:
with frame:
if not frame.dirty_rects or frame.changed_fraction > 0.5:
process_everything(frame)
else:
for left, top, right, bottom in frame.dirty_rects:
process_region(frame, left, top, right, bottom)
Coordinates are relative to the frame, so they index straight into the captured
image even when region= is in use.
changed_fraction is the share of the frame the rects cover, from 0.0 to 1.0,
with overlaps counted once — drivers do report overlapping rects, and summing
their areas can exceed the frame. It follows the rules below: None when the
metadata could not be read, 1.0 when the list is empty.
Two things to get right. An empty list does not mean nothing changed — it
means no rects were reported, which a mode change or a coalescing driver can
also produce while the image differs completely. Treat it as "assume everything
changed". None means the metadata could not be read at all. And check
frame.rects_coalesced: when true the driver merged rects, so they
over-estimate what actually changed.
Colour formats
rapidshot.create(output_color="RGB") # (H, W, 3) -- default
rapidshot.create(output_color="RGBA") # (H, W, 4)
rapidshot.create(output_color="BGR") # (H, W, 3) -- OpenCV order
rapidshot.create(output_color="BGRA") # (H, W, 4) -- raw, no conversion
rapidshot.create(output_color="GRAY") # (H, W, 1) -- Rec. 601 luma
An unsupported value raises ValueError at creation. Conversion uses the
native AVX2 kernels when the extension is present and NumPy otherwise; the two
are byte-identical. OpenCV is never required.
Capturing into your own buffer
shot() writes straight into memory you own, avoiding a per-frame allocation:
import numpy as np
camera = rapidshot.create(output_color="RGB", region=(0, 0, 640, 480))
buffer = np.zeros((480, 640, camera.channels), dtype=np.uint8)
if camera.shot(buffer):
print("captured", buffer.shape)
The destination size is checked before anything is written, so an undersized
buffer raises ValueError instead of corrupting memory. NumPy arrays, ctypes
arrays, bytearray and memoryview all report their own size. A raw pointer
cannot, so it needs an explicit buffer_size:
import ctypes
camera.shot(ctypes.c_void_p(buffer.ctypes.data), buffer_size=buffer.nbytes)
GPU-resident capture
grab() brings every frame to the CPU — a staging read plus a conversion. If
you are handing pixels to a GPU consumer, grab_frame() skips that and gives
you the Direct3D texture:
frame = camera.grab_frame() # None when nothing changed
if frame is not None:
with frame:
texture = frame.d3d11_texture # ID3D11Texture2D, valid inside the block
print(frame.timestamp, frame.accumulated_frames)
The
withblock is not optional. Direct3D cannot capture the next frame while a reference to the previous one is outstanding, so an unreleased frame stalls capture entirely. Use the context manager or callframe.release(), and copy out anything you need before the block ends.grab(),shot()andgrab_frame()all raise a clear error if a frame is still outstanding.Check for
Nonebefore thewith:grab_frame()returnsNonewhen nothing changed, andwith NoneraisesTypeError.
Metadata stays readable after release: timestamp / timestamp_qpc (when the
compositor presented the frame), accumulated_frames (greater than 1 means the
OS coalesced presents because your loop fell behind), protected_content,
cursor_visible, region, width, height, rotation_angle, and:
sequence |
This frame's index within the camera, from 1. Continues across recoveries, so it identifies one frame in a log. |
generation |
How many times capture had rebuilt itself when the frame was taken. See Surviving display changes. |
age_ms |
How old the pixels are now, measured from the present timestamp — not how long a call took. It grows while you hold the frame, so read it just before handing off. 0.0 means no present time was reported. |
changed_fraction |
Share of the frame covered by dirty rects. See Only process what changed. |
cursor |
Cursor position, hotspot and shape. See Cursor. |
The GPU tensor
Requires the native extension. One compute dispatch resizes, normalises, converts BGRA to RGB and transposes to NCHW, and the result stays in VRAM:
from rapidshot import native
with camera.grab_frame() as frame:
pre = native.GpuPreprocessor12(frame, 640, 640) # build once, reuse
pre.process(frame) # one dispatch
print(pre.shape) # (1, 3, 640, 640)
resource = pre.output_resource_address # ID3D12Resource*
gpu_va = pre.output_gpu_address
handle = pre.shared_output_handle # shared NT handle, for CUDA
Optional arguments cover the usual normalisation ranges (scale=2.0, bias=-1.0
for −1..1) and channel order (bgr=True).
Reading it from CuPy
shared_output_handle is imported by CUDA with cudaImportExternalMemory, so
the frame reaches a CUDA consumer without touching the CPU.
examples/gpu_tensor_to_cupy.py is a complete working version of the ~60
lines of ctypes this takes, and verifies the resulting cupy.ndarray is
byte-identical to a readback of the same dispatch.
CudaTensor lives in that example rather than in the package — copy it into
your project:
with CudaTensor(pre, (1, 3, 640, 640)) as view:
tensor = view.array # a cupy.ndarray in VRAM
while capturing:
frame = camera.grab_frame() # a *new* frame each pass
if frame is None:
continue
with frame:
view.sync() # queued CUDA work must finish
pre.process(frame) # overwrites the tensor buffer
model(tensor)
Three things that bite:
- A fresh
grab_frame()each pass. The preprocessor and the CUDA import are built once; the frame is not. A released frame raisesFrameReleasedError, and reusing a live one just re-processes the same image. sync()is not optional.process()waits on its D3D12 fence but knows nothing about CUDA work you queued against the same memory. A kernel still reading the tensor when the next dispatch lands sees a half-overwritten frame — no error, just wrong numbers.- Release before
model(). DXGI cannot acquire the next frame while a reference to the previous surface is outstanding.
Keep the CudaTensor alive as long as you use the array: it owns the CUDA
import and the preprocessor that owns the VRAM. With more than one CUDA device,
pass device=N — the tensor can only be imported by the device owning the
adapter that captured the frame.
DirectML / ONNX Runtime
The output is an ID3D12Resource, which is what ONNX Runtime's DirectML
provider consumes. RapidShot deliberately does not bind it to a session — that
would couple this library to ONNX Runtime's ABI and release cadence for an
optional feature:
const OrtDmlApi* dml = nullptr;
Ort::GetApi().GetExecutionProviderApi(
"DML", ORT_API_VERSION, reinterpret_cast<const void**>(&dml));
void* allocation = nullptr;
dml->CreateGPUAllocationFromD3DResource(d3d12_resource, &allocation);
Ort::MemoryInfo info("DML", OrtDeviceAllocator, 0, OrtMemTypeDefault);
auto tensor = Ort::Value::CreateTensor(
info, allocation, byte_size,
shape.data(), shape.size(), ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT);
// bind with Ort::IoBinding, run, then:
dml->FreeGPUAllocation(allocation);
OrtDmlApihas no Python binding — it is reachable only from C/C++. That is a gap in ONNX Runtime, not in RapidShot. From Python today you need a small native shim of your own;native.probe_onnxruntime()andnative.onnxruntime_dll_path()help locate and validate the runtime.
Hybrid GPU laptops
On an Optimus/switchable laptop the discrete GPU usually drives no display, and
Desktop Duplication cannot run against an adapter that drives no display. Capture
is therefore bound to the integrated GPU. device_info() lists only adapters
that can capture; topology_info() lists every adapter and explains the
consequence:
print(rapidshot.topology_info())
Topology: hybrid
Adapter[0] (Intel(R) UHD Graphics) (Intel) (128MB VRAM) (1 output)
Adapter[1] (NVIDIA GeForce RTX 4060 Laptop GPU) (NVIDIA) (7956MB VRAM) (0 outputs)
...
grab() is unaffected. A GPU-resident frame is: it lives on the capture
adapter, so feeding it to a model on the other adapter needs a cross-adapter
copy.
from rapidshot import native
with camera.grab_frame() as frame:
transfer = native.cross_adapter_transfer(frame) # build once, reuse
with camera.grab_frame() as frame:
transfer.transfer(frame)
print(transfer.source, "->", transfer.destination)
# bind transfer.destination_resource_address on the other adapter
The shared heap lives in system memory, not either adapter's VRAM. This is not peer-to-peer VRAM-to-VRAM DMA; the win is that a GPU copy engine moves the bytes instead of CPU cores.
Asynchronous transfer
transfer() blocks until the copy completes, which on a hybrid laptop is most
of its cost. transfer_async() returns the calling thread instead:
value = transfer.transfer_async(frame)
transfer.wait_shared_fence(value) # CPU wait, or...
handle = transfer.shared_fence_handle # ...import into CUDA, wait GPU-side
Measure the GPU-side wait for your own consumer before adopting it. Against
a synthetic CuPy consumer, Intel iGPU to RTX 4060 at 2560×1600, the CPU-side
async wait bought nothing — the calling thread was never the constraint — and
importing shared_fence_handle with cuImportExternalSemaphore to wait on it
in a stream was worth 7–14% (two runs disagreed by a factor of two on the
margin). In the end-to-end pixel-age benchmark on the same
laptop it did not pay: no younger pixels than the blocking transfer, and more
CPU per frame. Which one you see depends on what else your stream is doing.
transfer() remains the default and still holds the GIL for its copy.
wait_shared_fence() releases it.
Waiting for the consumer
Every transfer writes the same destination buffer, and the shared fence only reports that the copy finished. A consumer still reading frame N can be overwritten by the copy for frame N+1. Hand the producer a fence the consumer signals when it has finished reading:
transfer.set_consumer_fence(consumer_fence_handle) # once
transfer.wait_for_consumer(value) # queued before the next copy
The wait is enqueued on the source queue, so it orders ahead of the next copy without blocking the caller.
This is not theoretical. Over a 60-frame loop whose consumer ran slower than the producer: 28 of 60 frames wrong without the handshake, 0 with it. With a consumer that keeps up, the same loop is clean either way over 100 frames — which is why the hazard stays invisible until a real workload arrives.
shared_fence_submitted and shared_fence_completed expose what was queued
versus what the GPU has reached, if you need to watch the handshake work.
Surviving display changes
A mode change, a monitor arriving or leaving, exclusive fullscreen or a device reset invalidates Desktop Duplication. RapidShot rebuilds and carries on, with bounded retries and backoff. It also tells you it did:
camera.generation # 0 until the first rebuild, +1 per successful one
camera.recovery_count # the same count, read as a health counter
camera.last_recovery_reason # e.g. "DXGI device error during update_frame", or None
Every frame from grab_frame() is stamped with the generation it came from.
That matters for anything built from one frame and reused: a rebuilt duplicator
may differ in size, rotation or format, so a GpuPreprocessor12 or a
cross-adapter transfer made before the rebuild describes a surface that is gone.
Rebuild it when the generation moves:
pre, built_for = None, None
frame = camera.grab_frame()
if frame is not None:
with frame:
if frame.generation != built_for:
pre = native.GpuPreprocessor12(frame, 640, 640)
built_for = frame.generation
pre.process(frame)
grab() returns pixels without this metadata; compare camera.generation
between calls instead.
The generation moves only on a successful rebuild, so a failed attempt that is
about to be retried does not make you throw a cache away for nothing. A
recovery_count that keeps climbing on an otherwise idle machine means capture
is being torn down and rebuilt repeatedly, and is worth investigating.
Headless machines
With no monitor attached there is no desktop to duplicate, and
rapidshot.create() raises HeadlessError explaining that a virtual display
driver (IDD) is needed. topology_info() still works — it probes DXGI directly
rather than going through capture.
A virtual display's advertised refresh rate does not raise capture rate. Desktop Duplication is driven by presents, not refresh: a 500 Hz virtual display does not make an application render 500 fps.
The optional native extension
Everything above except the GPU tensor and cross-adapter transfer works without
it. pip install rapidshot never requires Rust.
Install the prebuilt wheel:
pip install rapidshot-native
Windows x86-64, built as an abi3 wheel so one binary serves Python 3.9 and
every later version — including ones released after the build. AVX2 is detected
at runtime with scalar fallbacks, so it runs on pre-AVX2 hardware too.
Nothing else changes: RapidShot finds it automatically and
rapidshot.native.is_available() starts returning True.
Or build it from source, which needs Rust 1.88+ and the MSVC C++ build tools:
cd native && cargo build --release
python native/install_dev.py
A build made this way takes precedence over an installed wheel, so
rebuilding does what you expect when both are present. native.build_info()
reports which one is loaded under source.
rapidshot-native is versioned independently of rapidshot, because the Rust
changes on its own schedule. rapidshot declares the minimum it needs, so pip
resolves a working pair; if you pin, pin both.
Profiling your own loop
The figures below describe this project's machines. For yours:
from rapidshot.profiling import Profiler
profiler = Profiler("inference loop")
with profiler:
for _ in range(1000):
with profiler.time("grab"):
frame = camera.grab_frame()
profiler.observe(frame) # None is counted, not skipped
if frame is None:
continue
with frame:
with profiler.time("process"):
process(frame)
print(profiler.report()) # a table, for a human
profiler.summary() # a dict, for asserting on
profiler.json() # a string, for storing beside a baseline
It reports the minimum and percentiles, never a mean. Background load can
only make a sample slower, so the minimum is the least contaminated estimate and
the tail is what a real-time consumer feels; a mean hides both. Any stage with
fewer than 30 samples is flagged low_confidence — a p99 over a dozen samples
is just the largest of them.
observe() records what wall clock cannot show. coalesced_updates_missed
counts display updates the OS folded together because the loop fell behind, so
a loop that looks fast while dropping most of what it should capture says so.
If capture rebuilt mid-run, recoveries_during_run says that too: timings
either side of a recovery describe different duplicators and should not be
pooled.
Stages nest, and a sample is recorded even when its block raises. One profiler per thread — it is deliberately not thread-safe.
Measurements
Every figure below comes from a committed recording, named so you can check it. Reproduce with:
python benchmarks/perf_suite.py --rounds 5 --reps 25 --compare auto
--compare auto selects the baseline recorded on your machine and fails if
there is none, rather than comparing you against someone else's hardware.
Run --self-test first to see your machine's noise floor.
RapidShot's own paths
Synthetic, deterministic, 1920×1080. These move when the library changes and not otherwise, which is why they are the badge figures.
From benchmarks/baseline.json — Intel iGPU,
with the native extension — against
benchmarks/baseline-nonative.json, the
same machine without it:
| with extension | without | |
|---|---|---|
| BGRA→RGB | 0.30 ms | 1.93 ms |
| BGRA→GRAY | 0.26 ms | 6.88 ms |
shot() → your buffer |
0.31 ms | 1.96 ms |
From benchmarks/baseline-rtx4060-hybrid.json
— a second machine, Intel iGPU capture with an RTX 4060 present, recorded at
2.4.0:
| per frame | |
|---|---|
| BGRA→RGB | 0.22 ms |
| BGRA→GRAY | 0.25 ms |
| CPU resize + normalise + NCHW | 3.12 ms |
| GPU dispatch (submission cost only) | 0.001 ms |
| GPU dispatch + forced readback | 2.56 ms |
The dispatch row is submission cost, not GPU execution. The calling thread returns in a microsecond because the GPU works asynchronously; the readback row is the honest worst case, and it is slower than the CPU arm. That is the point — the GPU path wins only when the tensor is consumed on the GPU. Pulling it back to the CPU gives up the entire advantage.
The two machines are not comparable to each other: different CPU, GPU and panel. Each answers for itself, which is why both recordings are kept.
End-to-end capture is not a stable number
grab() converts only the regions that changed, so its cost tracks what is
happening on screen. Across seven recordings of unchanged code, grab_frame()
alone spanned 0.17–0.83 ms and grab() 1.65–4.53 ms. These are reported
for shape, not precision, and are deliberately not badged.
Neither is a capture rate. They are what the calling thread pays per frame; frames still arrive only as fast as the compositor presents them.
Sustained-load check: 211,726 frames of grab_frame() → GPU tensor in 12
minutes, zero errors, VRAM flat, no throughput decay.
Against other libraries
Two capture-only recordings on different machines, then one that follows the frame to a model-ready tensor. They are not comparable to each other — different panels, different resolutions, different RapidShot versions, different workloads — and none predicts your hardware.
Reproduce with:
python benchmarks/compare_libraries.py --motion --with-motion
Every library runs in its own process: all three DXGI libraries declare the same COM interfaces, and whichever imports first breaks the others.
Machine A — 1080p, 100 Hz, RapidShot 2.1.0
benchmarks/library-comparison.json,
recorded 2026-08-06. Fullscreen BGRA, median of three runs:
| frames/s | CPU | memory | |
|---|---|---|---|
| RapidShot | 99.9 | 13.6% | 124.4 MB |
RapidShot (timeout_ms=0) |
100.1 | 95.9% | 125.2 MB |
| DXcam | 100.5 | 79.7% | 87.1 MB |
| BetterCam | 100.5 | 74.4% | 80.0 MB |
| mss | 48.1 | 35.0% | 60.4 MB |
Nobody beats the compositor. Every DXGI library lands at ~100 fps here because that is where the ceiling is. A frame-rate win in this space is almost always measuring something else — most often a still desktop returning stale buffers instantly.
Machine B — 2560×1600, RapidShot 2.4.0
benchmarks/library-comparison-machineB.json,
recorded 2026-08-22 with cv2 5.0.0 (32 threads) and cupy 14.2.0. Fullscreen.
Medians pooled across independent full-matrix runs, each of which is itself
the median of 3–5 repeats, so most cells rest on 9–15 measurements. The n
column says how many runs each row pools; the per-run minimum and maximum are in
the JSON. Pooling mattered — on a single run the AVX2 and NumPy builds looked
identical, and across four they do not.
Each library is configured for its best available path rather than its
default. DXcam ships a NumPy processor beside its cv2 one, BetterCam has a CuPy
path, and RapidShot has a CuPy path, an unpooled mode, and the toolchain-free
NumPy build that a plain pip install actually produces:
| n | BGRA fps / CPU | RGB fps / CPU | memory | |
|---|---|---|---|---|
| RapidShot (AVX2) | 5 | 153.2 / 35.5% | 124.4 / 65.2% | 238 MB |
| RapidShot (NumPy, no extension) | 3 | 153.7 / 33.3% | 113.0 / 70.0% | 238 MB |
| RapidShot (CuPy) | 2 | — | 124.2 / 66.8% | 260 MB |
RapidShot (pool_output=False) |
2 | — | 102.5 / 73.3% | 189 MB |
RapidShot (timeout_ms=0) |
4 | 151.8 / 53.5% | 122.4 / 73.2% | 234 MB |
| DXcam (cv2) | 4 | 129.1 / 60.0% | 146.8 / 450.8% | 102 MB |
| DXcam (NumPy) | 3 | 136.4 / 61.2% | 150.5 / 2692.2% | 91 MB |
| BetterCam (cv2) | 4 | 154.6 / 53.5% | 142.6 / 458.6% | 98 MB |
| mss | 4 | 32.9 / 42.2% | 21.9 / 49.8% | 83 MB |
RapidShot loses the RGB frame-rate column. DXcam and BetterCam return 142–151 frames per second against RapidShot's 124. It also uses roughly 2.4x their memory. Both are real, and neither is argued away here.
What it wins is CPU time. Isolating conversion by subtracting each library's BGRA cost from its RGB cost:
| CPU cost of BGRA→RGB | |
|---|---|
| RapidShot (AVX2) | +29.7 points |
| RapidShot (NumPy) | +36.7 points |
| BetterCam (cv2) | +405.1 points |
| DXcam (cv2) | +390.8 points |
| DXcam (NumPy) | +2631.0 points |
Read that carefully, because the obvious reading is wrong. Those are
CPU-time percentages summed across cores, not wall-clock. OpenCV 5.0 defaults
to one thread per logical core — 32 on this machine — so cvtColor spends many
cores to finish quickly. Measured directly here, BGRA→RGB at 2560×1600 costs
2.22 ms multithreaded and 4.36 ms pinned to a single thread. DXcam still
returned more frames per second than RapidShot while doing it. The defensible
claim is "the same work for far less total CPU", not "faster", and the exact
multiple will move with your core count and OpenCV version. A machine with 8
logical cores will not reproduce these ratios.
What the extension is actually worth
The AVX2 kernels are 6.4x the NumPy path on the synthetic conversion
benchmark (1.93 ms against 0.30 ms, both from Machine A), and 1.10x
end-to-end here — 124.4 against 113.0 fps, and +29.7 against +36.7 points of
conversion CPU. Both numbers are honest and they measure different things: at
2560×1600 the staging read dominates grab(), so making conversion six times
cheaper moves the total by about a tenth.
Since pip install rapidshot[native] costs nothing but a download, this is no
longer much of a decision — but it is worth knowing what you are getting. The
extension earns its keep through the GPU tensor and cross-adapter transfer,
which have no alternative at all. grab() gets a modest gain, not a
transformation, and the rapidshot-numpy row above is what you fall back to
without it.
What buffer pooling is worth
pool_output=False returns plain ndarrays and costs 1.21x on fullscreen
RGB — 124.4 against 102.5 fps — while saving about 50 MB. At region size
both sit at the compositor ceiling (~165 fps) and the difference disappears
entirely.
This is the first live-capture measurement of that trade; the 1.3–2.1x quoted
elsewhere for pooling came from a synthetic benchmark and is not reproduced in a
capture loop. If memory matters more to you than fullscreen frame rate,
pool_output=False is a reasonable trade rather than a downgrade.
Caveats
This table is noisier than it looks, and the harness says so itself.
- Several cells disagreed by more than 10% across their runs; the spreads are recorded per row in the JSON.
bettercam-gpufails on RGB withExpected Ptr<cv::UMat>— its CuPy path hands a device array to OpenCV 5, which rejects it. A BetterCam/OpenCV-5 incompatibility, recorded rather than hidden.- The GPU rows pool only runs taken after CuPy had its CUDA headers. Without them CuPy fails at its first JIT and those rows record no frames at all — which is a packaging trap, not a capture result. See Troubleshooting.
- The two machines are not comparable to each other. Machine A ran a 1080p 100 Hz panel at 2.1.0; this one is 2560×1600. Most of the frame-rate difference between the tables is the panel.
Published FPS claims in this space contradict each other badly — DXcam's README reports DXcam at 239 fps, BetterCam's reports the same library at 39 — because they come from different hardware with no shared harness. A number measured on someone else's machine tells you nothing about yours, this page included.
Desktop to model
The tables above stop at grab(). This one follows the frame to where most of
RapidShot's work is aimed: a (1, 3, 640, 640) FP16 tensor on CUDA, ready for
a model.
It measures pixel age, not call duration. A test source encodes an
incrementing frame ID into the image and records the time of every Present();
each path decodes the ID from what it captured, so every library is timed on
one clock by how old its pixels were. RapidShot's own present timestamps would
have given it an advantage no other library could match, so they are not used.
benchmarks/section7-ingestion-machineB.json,
recorded 2026-09-11 — Machine B, Intel iGPU capture with an RTX 4060 doing the
CUDA work, 2560×1600 at 165 Hz. Medians across 3 passes, 8 s per path; every
path was verified to produce the correct tensor before it was timed:
| unique frames/s | pixel age p50 / p95 | CPU per frame | |
|---|---|---|---|
| mss | 33.0 | 57.6 / 60.7 ms | 15.3 ms |
| DXcam (DXGI) | 108.2 | 36.6 / 39.6 ms | 8.9 ms |
| DXcam (WGC) | 106.0 | 40.7 / 45.1 ms | 8.7 ms |
RapidShot grab() |
140.5 | 33.6 / 36.1 ms | 7.7 ms |
RapidShot grab(), nvidia_gpu=True |
128.8 | 34.4 / 36.6 ms | 4.4 ms |
| RapidShot cross-adapter | 80.2 | 35.1 / 40.2 ms | 4.9 ms |
| RapidShot cross-adapter, async | 80.8 | 34.6 / 40.3 ms | 5.0 ms |
| RapidShot cross-adapter, GPU-side wait | 81.4 | 36.0 / 37.0 ms | 11.0 ms |
grab() and nvidia_gpu=True beat DXcam on every column, on every pass.
grab() returns 30% more unique frames with pixels 8% younger;
nvidia_gpu=True does it at half DXcam's CPU. Each one's worst pass still
beats DXcam's best.
The cross-adapter paths are a trade, not a win. They are capped at about 80 frames a second on this laptop — 25% fewer than DXcam — most likely by the 16 MB copy each frame makes through system memory, since all three variants hit the same ceiling. In return the blocking and async variants cost 45% less CPU than DXcam with no host-to-device copy, and still return younger pixels. The GPU-side-wait variant gains nothing here and costs more CPU than DXcam. Hybrid GPU laptops covers the cross-adapter path.
This replaces a single 5-second pass from the day before, which had the cross-adapter paths matching DXcam's frame rate. They reproduced within 2%; the paths that read frames back to the CPU all ran 38–56% faster in the second session, for reasons not established. That is why every figure here is a median of three.
Read it with these caveats:
-
One machine. Every row above was taken with the laptop's MUX in hybrid mode: capture on the Intel iGPU, CUDA on the RTX 4060. The direct single-adapter path cannot run in that mode at all — it fails with
CrossAdapterRequired, because no CUDA device owns the adapter the frame was captured on.It has since been measured, with the MUX switched to discrete-only so the NVIDIA card drives the display (2026-09-13, same laptop, 2560x1600). Verified byte-exact first, then timed:
unique frames/s pixel age p50 CPU per frame host-to-device RapidShot direct, single adapter 89.4 31.4 ms 6.7 ms 0 bytes The pixels are the youngest measured anywhere on this page — 2.2 ms ahead of the best hybrid row and 5.2 ms ahead of DXcam — which is what you would expect from the only path that never crosses an adapter.
Two things stop this being a like-for-like row in the table above, and both matter more than the headline. It is one 8-second pass, not a median of three. And it ran in a different machine configuration: with the NVIDIA card driving the display, the compositor's present behaviour is not the same, so the frame-rate column cannot be compared across the two modes. 89.4 against
grab()'s 140.5 is not a regression, it is a different machine. Pixel age and CPU per frame are the columns that travel. -
Every path drops source frames. The source presented at a median 165/s and no path keeps up, so these are throughput and latency under load — not a best case. It dipped briefly below
grab()'s rate, so that row's frame rate is, if anything, understated. -
Age starts at
Present()submission, so it includes the compositor's queue but not scan-out to the panel. -
This stops at the tensor. The next table carries the frame through a trained model.
Through a trained model
The same clock, carried one step further: each path's tensor goes into YOLO11n — the official Ultralytics weights — on the RTX 4060 through ONNX Runtime's CUDA provider, and age is taken when the forward pass completes.
benchmarks/section7-inference-machineB.json,
recorded 2026-09-11 on the same machine. Medians across 3 passes, 8 s per
path; every path was verified to produce the correct tensor before it was
timed.
| unique frames/s | pixel age p50 / p95 | CPU per frame | |
|---|---|---|---|
| mss | 26.8 | 53.7 / 64.0 ms | 20.3 ms |
| DXcam (DXGI) | 68.1 | 42.2 / 45.8 ms | 13.4 ms |
| DXcam (WGC) | 65.8 | 45.4 / 49.8 ms | 14.6 ms |
RapidShot grab() |
80.3 | 39.3 / 43.3 ms | 12.7 ms |
RapidShot grab(), nvidia_gpu=True |
81.8 | 38.8 / 42.5 ms | 8.9 ms |
| RapidShot cross-adapter | 75.2 | 39.6 / 42.6 ms | 8.9 ms |
| RapidShot cross-adapter, async | 76.2 | 39.6 / 42.8 ms | 9.1 ms |
| RapidShot cross-adapter, GPU-side wait | 81.0 | 38.3 / 41.1 ms | 11.9 ms |
Every RapidShot path beat DXcam (its default DXGI backend) on every pass — more frames, younger pixels and less CPU per frame, with RapidShot's worst pass still ahead of DXcam's best. At the medians: up to 20% more frames, pixels up to 3.9 ms (9%) younger when the model finishes, and up to a third less CPU per frame.
The lead is smaller than at the tensor (20% more frames here, 30% there), which is expected: this loop runs capture and inference back to back, so YOLO11n's 4–5 ms per frame paces every path alike. RapidShot's own paths finish within 1.3 ms of each other, inside their pass-to-pass spread — pick between them on frames and CPU rather than latency.
Caveats specific to this table: it times the raw forward pass, with no NMS or
postprocessing; the model file is an export of the official weights made with
Ultralytics' own exporter, because the published yolo11n.onnx targets an ONNX
opset that ONNX Runtime 1.30 cannot run entirely on the GPU; and it is one
machine. ROADMAP.md section 7.0 has the spreads, the model's
provenance, and what is still unmeasured.
Reproduce with the test source built and the model exported first:
cargo build --release --bin latency_source --manifest-path native/Cargo.toml
python benchmarks/section7.py --seconds 5 --out results.json
python benchmarks/prepare_model.py
python benchmarks/ai_pipeline.py --seconds 8 --model build/section7/model/yolo11n.onnx --model-sha256 <sha256 printed by prepare_model.py>
What these tables do not show
Dirty-rect metadata and GPU-resident frames from grab_frame() have no column,
because the other libraries have no equivalent to measure them against. The
single-dispatch NCHW tensor from GpuPreprocessor12 is not in the tables above
either: on this hybrid machine CUDA cannot import it, which is the case
cross-adapter transfer exists for.
System requirements
- OS: Windows 10 or newer. Windows only — Desktop Duplication has no cross-platform equivalent.
- Python: 3.9+. Tested through 3.14.
- GPU: any GPU that drives a display. A CUDA-capable NVIDIA GPU is needed only for the CuPy path.
- RAM: 8 GB+, depending on resolution and how many instances you create.
Tested configurations
Nothing in RapidShot branches on GPU vendor. This is what has actually been run, which is a different claim:
| Configuration | State |
|---|---|
| Intel iGPU, single adapter | Verified |
| NVIDIA dGPU, single adapter, native extension built | Verified |
| NVIDIA dGPU, single adapter, no extension | Verified |
| Hybrid Intel iGPU + NVIDIA dGPU (Optimus) | Verified — 2026-08-22 |
| Cross-adapter transfer, Intel iGPU → RTX 4060 | Verified byte-exact |
| Any AMD GPU | Not tested |
| Hybrid with an AMD adapter | Not tested |
| Headless with a virtual display | Not tested |
The Optimus row is verified by examples/verify_cross_adapter.py, which moved
5 captured frames from an Intel iGPU to an RTX 4060 — 16,384,000 bytes each at
2560×1600 — every one byte-exact against a source-side readback.
The AMD rows are genuinely untested. The BGRA swizzle rule is verified on Intel and NVIDIA drivers and follows from the DXGI format rather than driver discretion, so the risk is low — but it is the one rule a silent mismatch would corrupt rather than crash. AMD's cross-adapter capability flags are unknown; the buffer path was chosen so nothing depends on them.
Diagnostics
import rapidshot
print(rapidshot.diagnose())
One report: the RapidShot version, whether the native extension loaded and from where (a local build and the wheel can both be installed, and differ), the adapter topology and whether it is hybrid, and which optional dependencies — NumPy, comtypes, CuPy, OpenCV, PIL, ONNX Runtime — are importable, with versions. Most "it does not work" reports are answerable from this alone, so paste it into any issue you open.
It never raises. Each section is independent, and one that fails reports its error while the rest still print — the machine where this matters most is the one where something is already broken.
rapidshot.capabilities() returns the same report as a dict, for code to branch
on. Pass probe_gpu=True to either to add the cross-adapter probe; it is off by
default because it creates D3D devices and allocates a shared heap, and a
diagnostic should not be able to destabilise the thing it is diagnosing.
Troubleshooting
Run rapidshot.diagnose() first; it answers most of these.
-
CuPy fails at its first JIT with
Failed to find CUDA headers. The wheel is not enough —pip install cupy-cuda13x[ctk]installs no headers againstcuda-toolkit13.3.x, because that version dropped the extras it requests and pip only warns. Pin it instead:pip install "cuda-toolkit[cudart,nvrtc]==13.2.*"
Until this is fixed,
nvidia_gpu=Truereturns no frames — CuPy imports, so the CPU fallback does not trigger. -
"Desktop duplication was denied." The message names the cause. A non-input desktop, a locked workstation, an open UAC prompt and a Session 0 service cannot capture the user's screen. Protected (HDCP/DRM) content is a separate case and is reported as such.
-
Black frames. Protected content is blanked by the OS — check
frame.protected_content. Exclusive fullscreen is handled: capture detects the transition, rebuilds and continues. -
grab()keeps returningNone. Usually nothing is changing on screen. Desktop Duplication reports presents, not refreshes, so a still desktop produces no frames. This is the single most common cause of a benchmark reading zero. -
On a hybrid laptop, every adapter refuses
DuplicateOutput. Set NVIDIA Control Panel → Manage 3D Settings → Preferred graphics processor → Integrated graphics. Without it the NVIDIA driver can claim the display while the firmware reports Optimus, and every adapter — including WARP — returnsDXGI_ERROR_UNSUPPORTED. -
Unstable benchmark numbers. On a hybrid P-core/E-core CPU, pin to the performance cores.
benchmarks/perf_suite.pydoes this itself; unpinned it reported false regressions up to 2.57x against unchanged code.
Contributing
See CONTRIBUTING.md. It is mostly a list of things that will waste your time otherwise: live capture tests need something moving on screen, CI cannot verify them at all, and a naive benchmark comparison here once produced eleven false regressions on identical code.
License
MIT — see LICENSE.
Acknowledgments
A merge of, and successor to:
- Original DXcam by ra1nty
- dxcampil — PIL-based version
- DXcam-AI-M-BOT — cursor support version
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rapidshot-2.5.0.tar.gz.
File metadata
- Download URL: rapidshot-2.5.0.tar.gz
- Upload date:
- Size: 299.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d60c1bf09dcc7817a16477c0548eb857a16e270dac8f979518a58c8e07149fe0
|
|
| MD5 |
c8c9638289d3a58dda0f82866eba574b
|
|
| BLAKE2b-256 |
3d5701524a5a43498a9673935f98f2da10a176dea77e4b37f199ac85be492b86
|
Provenance
The following attestation bundles were made for rapidshot-2.5.0.tar.gz:
Publisher:
release.yml on Zaatra/Rapidshot
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rapidshot-2.5.0.tar.gz -
Subject digest:
d60c1bf09dcc7817a16477c0548eb857a16e270dac8f979518a58c8e07149fe0 - Sigstore transparency entry: 2821627404
- Sigstore integration time:
-
Permalink:
Zaatra/Rapidshot@436d5ad1bfe689673fba7e20422112c8dec5fe2a -
Branch / Tag:
refs/tags/v2.5.0 - Owner: https://github.com/Zaatra
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@436d5ad1bfe689673fba7e20422112c8dec5fe2a -
Trigger Event:
push
-
Statement type:
File details
Details for the file rapidshot-2.5.0-py3-none-any.whl.
File metadata
- Download URL: rapidshot-2.5.0-py3-none-any.whl
- Upload date:
- Size: 161.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5541e39255d2dc9d583c6d51c359d8c5f92c926e6c5bc5e16d78c8393a48781
|
|
| MD5 |
8c491d3a5dfaefb89eda087d2989338d
|
|
| BLAKE2b-256 |
eebd7624773f7aeba3e01921b5662ffd52927b34ad036c77e350cd3e3464f236
|
Provenance
The following attestation bundles were made for rapidshot-2.5.0-py3-none-any.whl:
Publisher:
release.yml on Zaatra/Rapidshot
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rapidshot-2.5.0-py3-none-any.whl -
Subject digest:
c5541e39255d2dc9d583c6d51c359d8c5f92c926e6c5bc5e16d78c8393a48781 - Sigstore transparency entry: 2821627456
- Sigstore integration time:
-
Permalink:
Zaatra/Rapidshot@436d5ad1bfe689673fba7e20422112c8dec5fe2a -
Branch / Tag:
refs/tags/v2.5.0 - Owner: https://github.com/Zaatra
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@436d5ad1bfe689673fba7e20422112c8dec5fe2a -
Trigger Event:
push
-
Statement type: