Skip to main content

kwcapture — official logo

kwcapture — fast screen capture on Wayland (KDE Plasma)

PyPI version PyPI status License CI: build KDE Plasma 6.x tested Wayland ScreenShot2

PIL.ImageGrab runs at ~2 fps on Wayland (it shells out to spectacle) and mss captures XWayland, which is black for native Wayland windows. kwcapture talks to the KWin compositor directly: ~40 fps at 2560×1440, real pixels, ~26 ms from "give me a frame" to a JPEG ready for a vision model. Whole screen, a region, one window by name or id, one monitor by name or id, or every monitor at once — list_windows() and list_monitors() tell you what there is to name.

Scope: KDE Plasma only. The kw in the name is the design. kwcapture speaks org.kde.KWin.ScreenShot2 and nothing else; compositors that only offer wlr-screencopy / ext-image-copy-capture-v1 are out of scope, and it will say so rather than half-work on them.

method                        fps      median latency
mss (XWayland)              1113.8      0.9 ms   <-- BLACK, useless on Wayland
PIL.ImageGrab (spectacle)      2.7    357.1 ms
kwcapture raw BGRA view       38.4     25.8 ms   real pixels, zero copy
kwcapture RGB full res        37.9     26.0 ms
kwcapture JPEG 1280 wide      35.6     28.0 ms   includes downscale + encode
kwcapture one 692x440 window 185.4      4.7 ms   per-window capture, not clipped
import kwcapture as K

cap  = K.Capture()                      # starts the helper, first frame in ~50 ms
arr  = cap.grab()                       # (H, W, 4) BGRA view into shared memory
rgb  = cap.shot(width=1280)             # (720, 1280, 3) uint8, contiguous
blob = cap.shot_jpeg(1280, quality=85)  # bytes for a vision API
cap.stats()                             # {'grab_ms': 20.1, 'total_ms': 27.8, ...}
cap.close()

One monitor, or all of them at once — list_monitors() is the window list for outputs, and monitor= takes a connector name, the compositor's own id, a list_monitors() index, or the Monitor itself:

for m in K.list_monitors():               # index, name, geometry, position, refresh_hz, scale
    print(m.index, m.name, m.geometry, m.position)
# 0 DP-1     (2560, 1440) (0, 0)
# 1 HDMI-A-1 (3840, 2160) (2560, 0)

dp  = K.Capture(monitor="DP-1")           # by connector name (substrings ok) …
tv  = K.Capture(monitor="HDMI-A-1")       # … or by id, index, or the Monitor object
dp.grab()                                 # that output only, at its device resolution

desk = K.Capture(workspace=True)          # the whole virtual desktop in ONE frame: every
desk.geometry()                           # output at its real position, black where no
                                          # monitor reaches — 6400x2160 for the pair above
                                          # (this is KWin's CaptureWorkspace)

Fractional scaling is measured, not assumed — a 125 % panel still advertises the integer 1 over wl_output, so ask KWin instead (one small grab per output, then cached):

m = K.list_monitors(measure_scale=True)[0]
m.effective_scale          # 1.25 on a 125 % display (m.scale is the useless integer 1)
m.logical_geometry         # … and m.geometry is device pixels; they differ when scale != 1
K.Capture(monitor=m, area=(0, 0, 400, 200))                       # logical rectangle
K.Capture(monitor=m, area=(0, 0, 400, 200), area_in="physical")   # device pixels

There are three different scales and they are not interchangeable — conflating them was a real bug, so the Fractional scaling section is the one part of this README worth reading twice.

Per-window capture — the window handle is KWin's own id, so no geometry juggling and no cropping by whatever is on top of it:

for w in K.list_windows():                # name + handle of every capturable window
    print(w.id, w.name, w.app_id, w.geometry, w.active)   # active == has focus

win = K.Capture(window="Kate")            # by name, caption or app id …
kon = K.Capture(window="{e0b1aab4-4e10-…}")   # … or by the handle it came with
act = K.Capture(active_window=True)       # whatever has focus
win.grab()                                # 692x440 KCalc window: ~5 ms, ~185 fps
win.shot_jpeg(quality=90)                 # just that window

K.active_window()                         # the focused window, as a Window
K.active_window_id()                      # … or just its KWin handle (~8 ms)

The windows a taskbar would not show are capturable too — dialogs, tool windows, the panel and the desktop itself are not in KWin's own application-window list, so list_windows() also asks KWin's scripting interface for them (see Per-window capture):

panel = next(w for w in K.list_windows() if w.window_type_name == "dock")
K.Capture(window=panel.id).grab()          # the Plasma panel; focus stays where it was

A minimised window returns stale pixels — KWin stops rendering it, so every grab repeats the same buffer and nothing tells you. Ask explicitly, or opt in to a per-grab check:

win = K.Capture(window="Kate", stale_check=True)   # opt-in: one throttled query per grab
frame = win.grab()
if win.stale_frame:             # the window is minimised -> frame is an old snapshot
    ...
win.window_minimized            # ask KWin right now (a D-Bus round trip; not per frame)

stale_check is off by default because the check costs a round trip; without it stale_frame stays False, which means unknown rather than fresh.

Install

pip install kwcapture                     # PyPI
pip install "kwcapture[fast]"             # + OpenCV: ~8x faster resize/encode

# straight from GitHub (builds the helper for your machine):
pip install "kwcapture @ git+https://github.com/tjandrasg/kwcapture.git"

# or the prebuilt wheel straight from the release page:
pip install https://github.com/tjandrasg/kwcapture/releases/download/v0.7.0/kwcapture-0.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl

Needs KDE Plasma with KWin on Wayland. The PyPI wheel ships the small native helper prebuilt for x86-64 Linux (glibc ≥ 2.17), so no compiler is required; if your distro or architecture has no wheel, pip compiles it on install, which needs a C compiler and libsystemd/wayland-client headers (on Debian/Ubuntu: sudo apt install build-essential libsystemd-dev libwayland-dev). Only numpy is a runtime dependency; the helper links libsystemd and libwayland-client, which any KDE desktop already has.

Then, in the graphical session:

kwcapture doctor          # checks session, helper, KWin authorisation, capture

The first Capture() writes the one file KWin needs to authorise us (~/.local/share/applications/io.github.kwcapture-<hash>.desktop) — no manual setup step. Extras: pip install "kwcapture[fast]" (OpenCV: ~8x faster resize/encode) or "kwcapture[pil]" (Pillow).

Why a native helper (the interesting bit)

KWin exports org.kde.KWin.ScreenShot2 over D-Bus: you hand it a file descriptor and it writes raw ARGB32-premultiplied (BGRA in memory) pixels into it. But it authorises callers by /proc/<pid>/exe: that binary must match the Exec= of a .desktop file declaring X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2 — the trick Spectacle uses. A Python interpreter can never satisfy that, so:

kwcapture.py  ──poke──▶  helper (serve)  ──D-Bus──▶  kwin_wayland
      ◀──mmap ring────        ◀──pipe(fd)────────

The helper keeps one D-Bus connection open and publishes frames into a shared-memory ring, so a grab is a byte to a FIFO, a spin on a sequence counter, and a zero-copy numpy view: no process per frame, no serialisation, no PNG round trip. KWin sends the reply before the pixels land, so EOF on the pipe — not the reply — is the frame boundary.

CLI

kwcapture doctor [--fix] [--build]     # is everything OK? (--fix also authorises)
kwcapture doctor --allow-black         # ditto, for empty / locked / headless-nested sessions
                                       # where an all-black frame is the correct answer
kwcapture setup                        # build the helper + authorise it
kwcapture install-desktop [--uninstall]# manage the KWin authorisation file (+ the brand icons)
kwcapture install-desktop --no-icons   # ... and leave ~/.local/share/icons alone
kwcapture screens                      # DP-1 2560x1440 @164.69Hz pos 0,0 scale 1
kwcapture windows                      # every capturable window: handle, name, app, size
kwcapture windows -f kcalc --json      # filter by name/app id; machine-readable
kwcapture windows --normal-only        # KWin's own app-window list only: no dialogs,
                                       # panels, desktop or popups (and no scripting)
kwcapture windows --require-full       # fail instead of quietly returning the short list
kwcapture grab -o shot.png --width 1280
kwcapture grab -o frame.bgra --raw     # pure BGRA bytes
kwcapture grab --window Kate -o kate.png
kwcapture grab --window '{e0b1aab4-4e10-…}' --decoration -o win.png
kwcapture grab --active-window -o focused.png   # the window that has focus
kwcapture demo --frames 60 --save f.png
kwcapture bench --frames 200

kwcapture windows looks like this:

WINDOW HANDLE (id)                      NAME                          APP_ID              SIZE    POSITION  FLAGS
{e0b1aab4-4e10-47fd-ae46-77a4ee6bc4d8}  build-pgo : llama-server      org.kde.konsole  2560x1394      +0,+0  maximized
{2c14f294-93ea-48bf-bbea-f501d59f6fe5}  KCalc                         org.kde.kcalc      692x468  +1563,+738
{c6195fb1-94bd-4094-a687-e4f1aac1b184}                                  plasmashell      2560x1440     +0,+0  skip-taskbar,not-in-app-list,desktop
{9ab544aa-1356-44f7-8be2-8d99903f34a7}                                  plasmashell       2560x46    +0,+1394  skip-taskbar,not-in-app-list,dock
{c5de347d-3db3-4a82-a622-c1b62f02519a}  1572. vrec1 - Winamp          winamp.exe        1041x662  +1094,+502  skip-taskbar,not-in-app-list,dialog

The last three are the ones KWin's own application-window list hides (not-in-app-list), each tagged with its window type — capturable, just not something a taskbar would show.

Brand icons

install-desktop and setup also put the project's mark where the desktop can find it:

$XDG_DATA_HOME/icons/hicolor/{16x16,24x24,32x32,48x48,64x64,128x128,256x256,512x512}/apps/kwcapture.png
$XDG_DATA_HOME/icons/hicolor/scalable/apps/kwcapture.svg

Nine files, copied byte-for-byte out of the package (kwcapture/icons/, generated from logo/ — see logo/README.md §8), never re-rasterised at install time.

It is one artwork: logo/kwcapture-mark.svg, resized. That is the project's official mark and the only thing this package ships as an icon — no monogram, no crop, no "small-size variant"; the permitted transformations are resize and re-render, at 16 px as much as at 512. What that costs is documented rather than worked around: APTURE is mush below about 64 px (the ladder in logo/README.md §8 shows it), so at favicon size the mark reads as the monogram plus an unreadable tail. The renders carry alpha, because an opaque square behind the mark on a dark panel is a bug people see.

Two things worth knowing, both measured rather than assumed:

  • The name in Icon= is resolved on your machine, when the entry is written. If these icons are installed the name is kwcapture; if they are not, it is the first name that actually resolves in an installed theme (accessories-screenshot-tool on KDE, applets-screenshooter on GTK naming). A ; fallback chain is not a thing here: GLib and KService both take the whole Icon= value as one name, so Icon=a;b resolves to neither.
  • Branding never breaks capture. The automatic path — the first Capture() writing the one file KWin needs — still writes exactly that one file and no icons, and if an icon install fails the entry simply names your theme's own screenshot icon. kwcapture doctor tells you which state you are in and --fix installs them.

install-desktop --uninstall removes the icons as well as the entries — but only the files whose sha256 still matches what this package ships, so a kwcapture.png drawn by somebody else in that shared directory survives. The pictures are CC BY 4.0 (logo/LICENSE.md), the code MIT; trademark rights are not granted.

Python API

Capture(...) arguments:

argument meaning
screen="DP-1" which output (default: active screen)
area=(x, y, w, h) capture a region instead of a whole output
workspace=True capture the entire virtual desktop in one frame — see Every monitor at once
window="Kate" capture one window: a handle from list_windows(), or its caption / app id
active_window=True capture the window that has focus
cursor=True include the hardware cursor
decoration=True include window decorations (and their shadow); off = client area only
slots=4 ring depth — how many frames until a returned view is overwritten
depth=2 requests in flight to KWin (2 is optimal; more adds latency, not fps)
fps=30 continuous capture mode; read with latest() for zero-latency access
idle_exit=300 helper quits when untouched for this many seconds
binary=…, allow_build=… override / disable locating-or-building the helper
install_desktop=False don't touch ~/.local/share/applications
verbose=True report build/authorisation steps

Methods: grab(rgb=False, copy=False, fresh=True), latest(), shot(width=…, resample=…), shot_png(), shot_jpeg(), stats(), geometry(), screen_name, target, window_id/window_name, bench(frames), restart(), close() (also a context manager). Module helpers: list_screens(), list_windows(), find_window(), is_window_handle(), to_rgb(), resize(), png_bytes(), jpeg_bytes(), default_shm_path(), unique_shm_path(), find_binary(), install_desktop_file(). grab() / shot() also take the target keys (window=, screen=, …) and keep one helper process alive per distinct target.

Notes:

  • grab() returns a read-only view of the shared ring, overwritten after slots further frames — use copy=True (or .copy()) to keep a frame, or shot()/to_rgb(). The ring is mapped read-only, so a view can never write into it, and a view stays readable after close() — frozen at the frame that was current when you closed.
  • latest() returns the newest published frame without asking KWin for one.
  • hide_caller_windows=True by default: KWin hides the capturing process' own windows, so your overlay/terminal does not end up in the shot (--no-hide-caller to disable).
  • Each Capture gets its own ring file in $XDG_RUNTIME_DIR (see unique_shm_path()), so any number of them can run at once — one per monitor, one per window. The helper also dies with its client (PR_SET_PDEATHSIG).

Per-window capture

import kwcapture as K

for w in K.list_windows():                 # every window KWin considers capturable
    print(f"{w.id}  {w.name}  {w.app_id}  {w.width}x{w.height}+{w.x}+{w.y}")

win = K.Capture(window="Kate")             # caption, app id, desktop file or handle
try:
    frame = win.grab()                     # (H, W, 4) BGRA, same as a screen grab
except K.WindowGone:                       # someone closed the window
    win.close()
    win = K.Capture(window="Kate")         # a new window with the same name

focused = K.Capture(active_window=True)    # whatever has focus right now

Window fields: id (the KWin handle — pass it back as window=), name, app_id, resource_name, desktop_file, role, icon, x, y, width, height, minimized, fullscreen, maximized, keep_above/keep_below, no_border, skip_taskbar/pager/switcher, window_type / window_type_name ("normal", "dialog", "dock", "desktop", …), krunner_listed, layer, desktops, plus geometry/position/visible/normal.

How the handles are found (and what they can and cannot do):

  • KWin's org.kde.KWin.ScreenShot2 CaptureWindow(handle) takes a window's internal id (a QUuid). There is no list method on that interface, so the helper asks KWin's krunner interface (/WindowsRunner, empty query → every window) for the ids and org.kde.KWin getWindowInfo(handle) for the details, plus KWin's scripting interface for the windows krunner filters out (above). None of that needs the desktop-file authorisation; only the pixel grab does.

  • Everything KWin can capture, not just what its taskbar shows. KWin's krunner list (/WindowsRunner) skips every window that is not normal — dialogs, tool windows, docks/panels, the desktop, splash screens, override-redirect popups — which is a real trap: Winamp's big skinned window is _NET_WM_WINDOW_TYPE_DIALOG, and it used to be invisible to list_windows() while CaptureWindow() captured it perfectly. So the helper also asks KWin's scripting interface (org.kde.kwin.Scripting) for the whole list: it writes a tiny JS file into $XDG_RUNTIME_DIR, loadScript + run hand it to KWin, the script answers on the helper's own unique bus name, and both the script and its file are gone before the helper exits. ~0.5 ms, nothing installed anywhere, and it degrades to the filtered list if KWin refuses. Those windows carry krunner_listed=False and a window_type_name:

    for w in K.list_windows():
        if not w.krunner_listed:            # panel, desktop, dialog, popup …
            print(w.window_type_name, w.app_id, w.geometry)
    panel = K.Capture(window="dock")        # panels and the desktop are capturable too
    

    list_windows(all_types=False) is KWin's filtered list only (no scripting round trip), list_windows(require_full=True) turns an unavailable scripting interface into a CaptureError instead of a quiet fallback — that is what kwcapture doctor uses. Because the list is wider now, a name that used to match one window can match two (a dialog and its main window share a caption): you get AmbiguousWindow with the candidates, and resolving by handle never is.

  • decoration=False (default) grabs the client area; decoration=True grabs the window with its title bar and shadow — the shadow area is transparent (alpha 0), so composite it or drop the alpha channel before saving a JPEG.

  • A window that is covered by other windows is not clipped: KWin renders that window's own buffer, so you get the whole client area, and nothing of the windows on top. A minimised window returns a frame, but it is a stale snapshot: KWin stops rendering windows that are minimised, so you get the last buffer it held — the same bytes every time. Capture(stale_check=True) makes grab() consult KWin (throttled) and set stale_frame so this cannot pass unnoticed, and warn once when a window goes stale; Capture.window_minimized asks on demand. Capture the window while it is mapped if you need its current contents.

  • A handle is only valid while the window lives. If the window is closed, grab()/latest() raise WindowGone — the helper stays alive, the other captures are unaffected, and list_windows() no longer reports it.

  • Names are resolved by ranking: exact caption > exact caption/app id (any case) > unique substring. Several equally-good matches raise AmbiguousWindow (with .candidates), none raises WindowNotFound (the message lists what is available).

fps, 2560×1440 Plasma 6.6 median
whole screen 26 ms (≈40 fps)
692×440 window 4.7 ms (≈185 fps)

A window grab is cheap because the compositor only has to render and ship that window — fine for watching a handful of windows in a loop.

Monitors: list them, capture one by name or id

kwcapture monitors                    # index, id, name, make/model, size, scale, position
kwcapture monitors --json             # machine-readable
kwcapture monitors --measure-scale    # also probe the real scale (125% / 150% displays)
kwcapture grab --monitor HDMI-A-1 -o tv.png
kwcapture grab --monitor 0 -o left.png
import kwcapture as K

for m in K.list_monitors():
    print(m.index, m.id, m.name, m.position, m.geometry, m.refresh_hz, m.scale)
# 0 65 DP-1     (0, 0)    (2560, 1440) 164.69 1
# 1 79 HDMI-A-1 (2560, 0) (3840, 2160)  60.0  1

Three ways to name the same screen — list_monitors() returns them as Monitor objects, and Capture(monitor=...) takes any of them (or a Monitor):

you pass what it is
"DP-1" the connector name — what KWin addresses screens by. Substrings work ("hdmi")
65 the compositor's own id for the output (its wl_output global name)
0 the list_monitors() index, top-left monitor first
K.Capture(monitor="HDMI-A-1")     # by name
K.Capture(monitor=79)             # by compositor id
K.Capture(monitor=1)              # by index
K.Capture(monitor=K.find_monitor("lg tv"))    # by make/model, via the finder

An id is stable for the session and unique; a name is stable across sessions and readable; an index shifts if you plug a monitor in on the left. find_monitor() raises MonitorNotFound listing what exists, or AmbiguousMonitor (with .candidates) when a substring matches several — it never picks one silently. Because the name is resolved eagerly, a typo raises immediately instead of failing deep inside the compositor.

With monitor= set, area= is relative to that monitor, which is usually what you want on a mixed-DPI desk:

K.Capture(monitor="HDMI-A-1", area=(0, 0, 800, 600))   # top-left 800x600 of the TV
K.Capture(area=(0, 0, 800, 600))                       # unchanged: global scene coords

Only enabled outputs are listed — a connected-but-disabled monitor has no image to capture (kscreen-doctor -o shows those and switches them on).

Every monitor at once — Capture(workspace=True)

monitor= gives you one output. workspace=True asks KWin for its CaptureWorkspace: the whole virtual desktop in a single frame, every output at its real scene position.

desk  = K.Capture(workspace=True)
desk.geometry()      # (6400, 2160) — DP-1 2560x1440 @ (0,0) + HDMI-A-1 3840x2160 @ (2560,0)
frame = desk.grab()  # (2160, 6400, 4)

CLI: kwcapture grab --workspace -o desk.png

The frame is the bounding box of the scene, so:

  • where no monitor reaches it is pure black — beside a 2160-tall panel, the 720 rows under a 1440-tall one read exactly 0. Nothing was dropped; there is no screen there.

  • each output lands at the position list_monitors() reported, so slicing one back out is plain numpy:

    m = K.find_monitor("DP-1")
    x, y = m.position; w, h = m.geometry
    left = frame[y:y + h, x:x + w]          # exactly DP-1, taken from the one workspace frame
    
  • it is one compositor request, so every output comes from the same composition of the scene — N separate per-monitor requests can straddle a repaint and show the monitors disagreeing with each other.

The cost is proportional to that area. Measured on this 6400×2160 desk (i9-13900K, Plasma 6.6): 76.5 ms per workspace grab, against 26.0 ms for the 2560×1440 output and 50.5 ms for the 4K one — and the ring it needs is 442 MB of tmpfs (4 slots × ~110 MB), which is shared memory, i.e. real RAM. Capture(workspace=True, slots=2) halves that to 221 MB. Good for an occasional overview or a layout sanity-check; not what you want driving a 40 fps loop.

Deliberately not implemented: a helper that returns one array per output, keyed by name — you get either one output or one combined frame, and slicing is the three lines above. Whether the frame also spans KWin's virtual desktops rather than just the physical outputs is untested here, because this desk runs a single one — print geometry() the first time you use it on a new machine instead of assuming it is the union of the monitors.

Fractional scaling (75 %, 125 %, 150 %, ...)

A whole-output frame is always device resolution — every pixel the panel shows, never a rescale. What scaling changes is that the numbers you see in the UI are no longer numbers in the image, and there is more than one scale involved. On a desk measured live (DP-1 at 75 %, HDMI-A-1 at 125 %):

what where you see it this desk
integer hint Monitor.scale (wl_output) 1 and 2 — cannot express fractions
display scale Monitor.effective_scale, Capture.scale of a whole-output frame 0.75 and 1.25
scene factor Monitor.area_scale, Capture.area_scale, Capture.scale of a region frame 1.25 on both

device pixels = logical pixels x display scale — that is the relation that governs output geometry, window geometry and whole-output frames. kwcapture measures it (grabbing one small area in device pixels and once composited, and reading a whole-output frame), because wl_output can only advertise an integer.

m = K.list_monitors(measure_scale=True)[0]
m.scale             # 1     <- what wl_output advertises (integer hint)
m.effective_scale   # 0.75  <- what the display is actually running
m.area_scale        # 1.25  <- the scene-wide factor region captures come back at
m.fractional        # True
m.logical_geometry  # device size / display scale
m.to_physical(100, 60)    # logical -> device pixels
m.to_logical(150, 90)     # device pixels -> logical

cap = K.Capture(monitor=m)
cap.scale        # scale KWin applied to these frames (cheap, straight from the frame)
cap.pixel_scale  # this output's measured display scale (cached after the first probe)

Region captures are the exception. area= is in logical scene coordinates, and KWin answers a region at the scene factor — one number for the whole desktop, not the scale of the output the region is on. So area=(0, 0, w, h) hands you a w*area_scale x h*area_scale image, and where area_scale != pixel_scale those pixels are a resample of the w*pixel_scale device pixels the region really covers (on a 75 % output, a region grab is an upsample; on an output at the scene scale it is 1:1). If that bothers you — and for pixel-exact work it should — grab the monitor and slice the array instead, below.

area_in="physical" means the rectangle is in device pixels of that output (it needs monitor=, since device pixels only mean something within one output). It is converted through the display scale, so you get exactly the region you named — while the image itself stays at the scene factor, i.e. w * area_scale/pixel_scale px wide:

K.Capture(monitor="DP-1", area=(0, 0, 640, 360), area_in="physical")

For cropping, the simplest exact route is to grab the whole monitor and slice the numpy array — no rounding, no coordinate maths:

frame = K.Capture(monitor="DP-1").grab()
crop = frame[100:460, 640:1280]      # device pixels, exactly

For cropping, the simplest exact route is to grab the whole monitor and slice the numpy array — no rounding, no coordinate maths:

frame = K.Capture(monitor="DP-1").grab()
crop = frame[100:460, 640:1280]      # device pixels, exactly

Resilience: resolution changes, window resizes and a dead daemon

Long-running captures outlive their setup. Unplugging a monitor, switching mode, or maximising the window you are watching all change the frame size, and the helper is a separate process that can be killed, crash, or quit after sitting idle. By default grab() heals all of these by itself — you keep getting frames:

import kwcapture as K

cap = K.Capture(window="Dolphin")     # auto_restart=True is the default

while True:
    frame = cap.grab()               # survives a restart, a mode change, a resize
    if cap.resized:                  # the frame we just got differs in size from the last
        print("now", cap.last_geometry)   # e.g. (2560, 1440) -> (1920, 1080)
  • Window resized / maximised — frames simply come back at the new size; there is no need to re-create the Capture. resized is True on the frame where it changed, and last_geometry is the size of the frame you just got.
  • Monitor mode change — same thing for a screen capture: geometry() always reports what the compositor is capturing now, and the ring is sized for it.
  • A frame bigger than the ring (a bigger monitor, a window grown past the headroom) raises RingTooSmall. The ring cannot be grown in place — a client has it mapped at the old length — so the helper reports it and exits, and a new one is started with a ring sized for the new geometry. grab() does that for you; watch auto_restarts and last_restart_reason if you care that it happened.
  • Daemon killed, crashed, or reaped for being idle — restarted transparently and the frame retaken. An outstanding zero-copy view from before the restart stays readable (frozen at its last frame) rather than turning into invalid memory.

Knobs, all on Capture:

parameter default meaning
auto_restart True recover inside grab(); False restores the old "raise DaemonDead" behaviour
restart_limit 5 give up after this many consecutive restarts (resets on the next good frame)
slot_floor (5120, 2880) room left in each ring slot for a frame that grows later; raise it for 6K/8K displays

What is deliberately not auto-recovered, because restarting would hide a real bug:

  • WindowGone — the window you were capturing was closed. Restarting cannot bring your window back. (If the app is reopened under the same name, the Capture follows it to the new window handle.)
  • TimeoutError while the daemon is alive — KWin is being slow or is wedged. Spawning another daemon would just hide that.
cap = K.Capture(auto_restart=False)   # old behaviour: report it, do nothing about it
try:
    cap.grab()
except K.RingTooSmall:
    cap.restart()                     # re-open the ring at the current geometry
except K.DaemonDead:
    ...                               # you own the restart

Performance notes (2560×1440@165 Hz, Plasma 6.6, i9-13900K)

  • ~11 ms is a fixed cost inside KWin (a 320×180 grab still takes 11 ms); 1440p adds ~7 ms of grab and ~5 ms to move 14 MB across the pipe.
  • ~47 fps is the hard ceiling — KWin serialises screenshot jobs. depth=2 reaches it; depth=3/4 only add latency (37/59/81 ms per frame).
  • Post-processing: OpenCV INTER_AREA 2560→1280 ≈ 0.1 ms, cvtColor ≈3 ms, JPEG 1280 ≈3.5 ms / 2560 ≈4.5 ms, PNG ≈23 ms (prefer JPEG), Pillow resize ≈17 ms.
  • Without OpenCV or Pillow, downscaling falls back to numpy (box-average on integer factors, nearest otherwise) and image encoding raises a hint to install an extra.
  • The ring file looks like 226 MB but is sparse: 15 MB resident at 1440p.

Repo layout

kwcapture/
  __init__.py            Python API (Capture, Window, grab/shot, shm ctypes mirror)
  __main__.py            CLI: doctor / setup / install-desktop / screens / windows /
                         grab / demo / bench
  _native.py             find or compile the helper ($KWCAPTURE_BIN, wheel, cache, source)
  _desktop.py            the KWin authorisation desktop entry
  _icons.py              the brand icons: the shipped set, install/uninstall, resolving Icon=
  icons/                 GENERATED brand artwork in hicolor layout; logo/ upstream is the source
  native/kwcapture.c     the capture daemon (sd-bus + shm ring + output and window listing)
  native/include/kwcapture_shm.h   shared-memory protocol (asserted on both sides)
tests/test_kwcapture.py  165 functional checks, live Plasma required (also `pytest tests/`)
tests/test_ring_reader.py 37 checks of the shm protocol — no compositor needed
tests/test_icons.py      98 checks of the icons + the desktop entry — no compositor needed
bench.py                 comparison against mss and PIL.ImageGrab
probe/                   experiments: nested-KWin headless test, Wayland global dumper,
                         non-normal-window enumeration (probe/non_normal_windows.py), etc.
.github/workflows/       ci.yml (build + headless KWin), release.yml (wheels + release + PyPI)
logo/                    the identity: 2 sketches -> 3 SVGs -> the shipped icon/ set, with
                         gen_logo.py / validate_logo.py / render_agreement.py /
                         sync_package_icons.py (the only sanctioned path into kwcapture/icons/)
                         and its own AGENTS.md + README.md + LICENSE.md — see logo/README.md
                         before editing. In the repo, deliberately not in the sdist
AGENTS.md                investigation log — how the KWin API and its auth really work
SECURITY.md              what the helper does in security terms, and how to check it yourself
CONTRIBUTING.md          setup, test modes, contributing without a KDE desktop, house rules
CODE_OF_CONDUCT.md       Contributor Covenant 2.1 + how one maintainer applies it

Development:

python3 -m venv .venv && .venv/bin/pip install numpy pillow opencv-python-headless
make setup          # build the helper into kwcapture/bin + authorise
make test bench     # tests, then the comparison table
make test-icons     # the icon / desktop-entry suite — no compositor, no hardware, ~1 s
make icons          # re-sync kwcapture/icons/ from logo/icon/ after regenerating the artwork
make wheel          # dist/*.whl

How this is tested

workflow runs on what it proves
ci.yml every push and PR build: the helper really ends up in the wheel, both artefacts really carry the 10 brand-icon files, the sdist builds and installs from scratch in a clean venv, the 37 ring-reader checks (shared-memory protocol, torn-frame guards, concurrent writer) and the 98 brand-icon checks (install/uninstall, the desktop entry, kwcapture/icons/ still equal to logo/icon/, and every shipped file really being logo/kwcapture-mark.svg resized rather than some other drawing) run with no compositor at all. headless KWin capture: a real Plasma 6 KWin, below.
release.yml v* tags sdist + manylinux wheels via cibuildwheel, attached to the GitHub release; PyPI publishing is a separate job behind the pypi environment and an opt-in input.

The headless job captures from a real KWin with no GPU, no monitor and nobody logged in — kwin_wayland --virtual on a private D-Bus session inside a Debian trixie container (Ubuntu's runners still ship KWin 5.27, whose window handles are not what kwcapture speaks). The private bus is the part that makes or breaks it: ScreenShot2 is D-Bus, so against the desktop's own bus a nested KWin answers list_monitors() while the desktop's KWin answers list_windows() and hands back the desktop's resolution — a silent split-brain that would have a test assert against the developer's screen. The job builds the helper from source, installs it, authorises it through the same .desktop file Plasma uses, and checks the nested output and a real Qt client's window are listed.

What GitHub's runners cannot cover, and why: the pixels. KWin owns org.kde.KWin.ScreenShot2 only through its screenshot effect, whose supported() is effects->isOpenGLCompositing(), and the --virtual backend offers OpenGL compositing only when drmGetDevices2() finds a DRM device. A hosted runner has /dev/dri/card1 (hyperv_drm) and no render node — and no vgem, not even after installing linux-modules-extra-<kernel> — so KWin falls back to QPainter compositing and never registers the name. The job says so (ENVIRONMENT LIMITATION) for that one failure instead of failing, and still fails on everything else. On a machine that can composite — your desktop, a self-hosted KDE runner, a GPU runner — the same job runs strict: the frame has to be the nested 1024×640 (not the host's screen) and non-black, for the screen and for the window.

# the headless job, whole, on any machine with a render node — no GitHub involved
docker run --rm --device /dev/dri -v "$PWD:/w" debian:trixie-slim bash /w/probe/ci_headless_kwin.sh

# or on a Plasma session, no container
dbus-run-session -- bash probe/nested_kwin_test.sh

The functional suite (tests/test_kwcapture.py) needs a live Plasma session and is not run in CI. Its per-output checks multiply, so the count follows the hardware: 172 on a 75 % + 125 % two-monitor desk, 165 on a single output at 100 % — and a run can legitimately report 163 if the BUG-4 block aborts early, because its catch-all replaces two checks with one failure line. Treat the count as a measurement of that run, not as a constant. The other two suites do not depend on hardware: 37 and 98 checks. The icon suite's one machine-dependent check is desktop-file-validate, which reports SKIP when the tool is absent. Measured off the CI log of the push that grew the suite to 98 checks: 97 passed + 1 skipped on ubuntu-latest ([SKIP] desktop-file-validate tool not installed), against 98 + 0 on the KDE desktop it was written on; the earlier 89-check version measured 88 + 1 there. A skip is printed, never silent, because "a check that stops running without saying so" is the failure mode this project has been bitten by most often.

probe/sabotage_icons.sh is the second half of the icon suite: it breaks each rule the icon code enforces, one at a time, runs the suite, and requires it to notice — 10/10 detected on this machine, every file restored afterwards (a trap does that, because this probe edits tracked source). A check nobody has watched fail is not evidence; that lesson is logo/AGENTS.md gotcha 24, paid for twice in one day.

Troubleshooting

symptom cause / fix
The process is not authorized to take a screenshot kwcapture install-desktop, then retry (KDE's service cache notices the new file asynchronously — Capture already retries for a few seconds)
no frame within 2.0s helper died: Capture.restart(), or Capture(daemon_stderr=sys.stderr) to see its log; kwcapture doctor
failed to compile the kwcapture helper install build-essential libsystemd-dev libwayland-dev, or build it yourself and set KWCAPTURE_BIN
frame needs N bytes, slot has M resolution went above ~5K: restart the helper
no window matches 'x' kwcapture windows for the handles; the error lists what is there. Matching is exact caption/app id, or a unique substring
AmbiguousWindow several windows share that name/caption — pass the handle from kwcapture windows. (A name that used to match one window can match its dialog too now; all_types=False / --normal-only narrows the list again)
doctor: only KWin's own application-window list is reachable the scripting enumeration failed — the message says why (no org.kde.kwin.Scripting, a refused call, a timeout). Dialogs, panels and the desktop cannot be listed; ordinary windows still capture. Not an authorisation problem
WindowGone: the window has nothing to capture the window was closed (or is being unmapped). The helper is fine: list_windows() again and make a new Capture
unsupported kwcapture ABI 1 an old helper binary ($KWCAPTURE_BIN, or a stale kwcapture/bin): rebuild with kwcapture setup
frame N reports an invalid geometry / outside the …-byte ring the client refused a frame descriptor that cannot fit the ring (old/mismatched helper, or a ring truncated under it). Capture.restart(); if it repeats, kwcapture setup to rebuild the helper
the ring was replaced by restart()/close() while this frame was being read you grabbed from another thread while something called restart(); a single Capture is not thread-safe for lifecycle + reads
works in a terminal but not from cron/SSH you need WAYLAND_DISPLAY and DBUS_SESSION_BUS_ADDRESS of the graphical session
colours wrong somewhere grab() is BGRA; shot()/to_rgb() are RGB

Non-KDE compositors need ext-image-copy-capture-v1 / wlr-screencopy-unstable-v1 instead (kwcapture does not implement those; probe/globals.c shows how to check what a compositor advertises). Checked on kwin 6.6.6: it advertises neither and has neither compiled in, so ScreenShot2 is the only capture route on Plasma short of the portal + PipeWire path — which means adding image-copy would buy Sway/Hyprland/river while KDE still needed ScreenShot2. The two protocols would coexist rather than one replacing the other.

Used by

  • iw3-desktop (part of nunif) — converts the PC desktop into side-by-side 3D and streams it to VR headsets over Wi-Fi. kwcapture is its Wayland (KDE Plasma) screenshot backend (--screenshot kwcapture): whole-monitor and per-window capture at ~40 fps, picked by default on a Plasma session. It is an optional dependency there on purpose — nunif installs nothing automatically and warns instead of streaming a black XWayland screen. Merged 2026-10-08: nagadomi/nunif#746. What that integration exercises, i.e. the entry points a release must not break: Capture(monitor=…) / Capture(window=…) with cursor= and a private shm= path, grab(copy=True), geometry(), list_monitors(measure_scale=True), list_windows(), find_window() + AmbiguousWindow, and the measured effective_scale / area_scale.

Built something on top of kwcapture? Open an issue or a PR and it goes in this list.

Contributing

CONTRIBUTING.md has the ten-minute setup, the three test-suite modes, and what you can contribute without a KDE desktop — the 37 shared-memory protocol checks need no compositor, and there is a nested-KWin harness for everything else. It also lists the house rules that have actually caused rework. Participation in the project's spaces is covered by CODE_OF_CONDUCT.md — Contributor Covenant 2.1, preceded by a short note on how one maintainer actually handles it, including where a report goes if it is about the maintainer. Security issues go privately, per SECURITY.md.

License

The software is MIT — see LICENSE. AGENTS.md documents the reverse engineering behind it.

The logo is licensed separately, in logo/LICENSE.md: the artwork (SVGs, sketches, every PNG/ICO in logo/icon/) is CC BY 4.0 — copy and adapt it, even commercially, with attribution — while the generators in logo/ are MIT so they stay compatible with this package. The Inter face used to draw the word is OFL-1.1 and is not shipped here. CC BY does not grant trademark rights: the artwork is free, presenting a modified mark as if it were kwcapture is not.

The mark at the top of this file is logo/kwcapture-mark.svg: a K and a W sharing a stroke, whose junction is the C, a mouse pointer standing in for the A, and PTURE in type — CAPTURE, spelled out of three parts. Every format on this page and on GitHub comes out of bash logo/icon/build.sh.

Metadata

Release files for kwcapture 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for kwcapture 0.7.0
File Size Uploaded
kwcapture-0.7.0.tar.gz 288.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for kwcapture 0.7.0
File Interpreter ABI Platform
kwcapture-0.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl Python 3 none Linux glibc 2.17+ x86-64, Linux glibc 2.28+ x86-64 Details

Total release size: 474.3 kB

Release files / kwcapture-0.7.0.tar.gz

Download URL kwcapture-0.7.0.tar.gz
Size 288.7 kB
Tags Source
SHA-256 checksum
How to use checksums
752a5377d94a557dde04e6015d411398a5b99eb79185197f862e61cb96551a8b
BLAKE2b-256 checksum
How to use checksums
bd933cf2e027d9002e9efb39bcfcc7101e9d03f37e64230c996fe1a49354e018
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release files / kwcapture-0.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl

Download URL kwcapture-0.7.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
Size 185.5 kB
Tags Linux glibc 2.17+ x86-64 Linux glibc 2.28+ x86-64 Python 3
SHA-256 checksum
How to use checksums
7badc3ef7d2d15611e959649e686f35f86018fe460ae7ffcdbdb060f3330366c
BLAKE2b-256 checksum
How to use checksums
37ba65786e06b1c323ea6d225275ca357d855826b3f25c74daf27531963e0bb5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release history Release notifications | RSS feed

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

1 release file

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page