Skip to content

Commit 616d610

Browse files
feat: switch from GetDIBits to CreateDIBSection for screen capture on Windows (#464)
* Add benchmark script to establish baseline * Switch over completely to CreateDIBSection Streamline benchmark and adjust tests * Formatting fixes to comply with ruff * Update CHANGELOG.md * Add bench_grab_windows.py to test setup --------- Co-authored-by: Mickaël Schoentgen <contact@tiger-222.fr>
1 parent e3307f0 commit 616d610

5 files changed

Lines changed: 321 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
# History
22

3-
See Git checking messages for full history.
3+
See Git commit messages for full history.
44

5-
## 10.2.0.dev0 (2025-xx-xx)
5+
## 10.2.0.dev0 (2026-xx-xx)
6+
- Windows: switch from `GetDIBits` to more memory efficient `CreateDIBSection` for `MSS.grab` implementation (#449)
7+
- Windows: fix gdi32.GetDIBits() failed after a couple of minutes of recording (#268)
68
- Linux: check the server for Xrandr support version (#417)
79
- Linux: improve typing and error messages for X libraries (#418)
810
- Linux: introduce an XCB-powered backend stack with a factory in ``mss.linux`` while keeping the Xlib code as a fallback (#425)
911
- Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431)
1012
- Windows: improve error checking and messages for Win32 API calls (#448)
1113
- Mac: fix memory leak (#450, #453)
1214
- improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452)
13-
- :heart: contributors: @jholveck
15+
- :heart: contributors: @jholveck, @halldorfannar
1416

1517
## 10.1.0 (2025-08-16)
1618
- Mac: up to 60% performances improvement by taking screenshots at nominal resolution (e.g. scaling is off by default). To enable back scaling, set `mss.darwin.IMAGE_OPTIONS = 0`. (#257)

src/mss/windows.py

Lines changed: 74 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Windows GDI-based backend for MSS.
22
33
Uses user32/gdi32 APIs to capture the desktop and enumerate monitors.
4+
This implementation uses CreateDIBSection for direct memory access to pixel data.
45
"""
56

67
from __future__ import annotations
@@ -12,6 +13,7 @@
1213
BOOL,
1314
BYTE,
1415
DWORD,
16+
HANDLE,
1517
HBITMAP,
1618
HDC,
1719
HGDIOBJ,
@@ -88,7 +90,7 @@ def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tupl
8890
"error_msg": winerror.strerror,
8991
}
9092
if winerror.winerror == 0:
91-
# Some functions return NULL/0 on failure without setting last error. (Example: CreateCompatibleBitmap
93+
# Some functions return NULL/0 on failure without setting last error. (Example: CreateDIBSection
9294
# with an invalid HDC.)
9395
msg = f"Windows graphics function failed (no error provided): {func.__name__}"
9496
raise ScreenShotError(msg, details=details)
@@ -105,12 +107,16 @@ def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tupl
105107
CFUNCTIONS: CFunctionsErrChecked = {
106108
# Syntax: cfunction: (attr, argtypes, restype, errcheck)
107109
"BitBlt": ("gdi32", [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], BOOL, _errcheck),
108-
"CreateCompatibleBitmap": ("gdi32", [HDC, INT, INT], HBITMAP, _errcheck),
109110
"CreateCompatibleDC": ("gdi32", [HDC], HDC, _errcheck),
111+
# CreateDIBSection: ppvBits (4th param) receives a pointer to the DIB pixel data.
112+
# hSection is NULL and offset is 0 to have the system allocate the memory.
113+
"CreateDIBSection": ("gdi32", [HDC, POINTER(BITMAPINFO), UINT, POINTER(LPVOID), HANDLE, DWORD], HBITMAP, _errcheck),
110114
"DeleteDC": ("gdi32", [HDC], HDC, _errcheck),
111115
"DeleteObject": ("gdi32", [HGDIOBJ], BOOL, _errcheck),
112116
"EnumDisplayMonitors": ("user32", [HDC, LPCRECT, MONITORNUMPROC, LPARAM], BOOL, _errcheck),
113-
"GetDIBits": ("gdi32", [HDC, HBITMAP, UINT, UINT, LPVOID, POINTER(BITMAPINFO), UINT], INT, _errcheck),
117+
# GdiFlush flushes the calling thread's current batch of GDI operations.
118+
# This ensures DIB memory is fully updated before reading.
119+
"GdiFlush": ("gdi32", [], BOOL, None),
114120
# While GetSystemMetrics will return 0 if the parameter is invalid, it will also sometimes return 0 if the
115121
# parameter is valid but the value is actually 0 (e.g., SM_CLEANBOOT on a normal boot). Thus, we do not attach an
116122
# errcheck function here.
@@ -126,6 +132,10 @@ def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tupl
126132
class MSS(MSSBase):
127133
"""Multiple ScreenShots implementation for Microsoft Windows.
128134
135+
This implementation uses CreateDIBSection for direct memory access to pixel data,
136+
which eliminates the need for GetDIBits. The DIB pixel data is written directly
137+
to system-managed memory that we can read from.
138+
129139
This has no Windows-specific constructor parameters.
130140
131141
.. seealso::
@@ -134,7 +144,17 @@ class MSS(MSSBase):
134144
Lists constructor parameters.
135145
"""
136146

137-
__slots__ = {"_bmi", "_bmp", "_data", "_memdc", "_region_width_height", "_srcdc", "gdi32", "user32"}
147+
__slots__ = {
148+
"_bmi",
149+
"_dib",
150+
"_dib_array",
151+
"_dib_bits",
152+
"_memdc",
153+
"_region_width_height",
154+
"_srcdc",
155+
"gdi32",
156+
"user32",
157+
}
138158

139159
def __init__(self, /, **kwargs: Any) -> None:
140160
super().__init__(**kwargs)
@@ -147,29 +167,30 @@ def __init__(self, /, **kwargs: Any) -> None:
147167

148168
# Available instance-specific variables
149169
self._region_width_height: tuple[int, int] | None = None
150-
self._bmp: HBITMAP | None = None
170+
self._dib: HBITMAP | None = None
171+
self._dib_bits: LPVOID = LPVOID() # Pointer to DIB pixel data
172+
self._dib_array: ctypes.Array[ctypes.c_char] | None = None # Cached array view of DIB memory
151173
self._srcdc = self.user32.GetWindowDC(0)
152174
self._memdc = self.gdi32.CreateCompatibleDC(self._srcdc)
153-
self._data: ctypes.Array[ctypes.c_char] | None = None
154175

155176
bmi = BITMAPINFO()
156177
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
157178
# biWidth and biHeight are set in _grab_impl().
158179
bmi.bmiHeader.biPlanes = 1 # Always 1
159-
bmi.bmiHeader.biBitCount = 32 # See grab.__doc__ [2]
180+
bmi.bmiHeader.biBitCount = 32 # 32-bit RGBX
160181
bmi.bmiHeader.biCompression = 0 # 0 = BI_RGB (no compression)
161182
bmi.bmiHeader.biSizeImage = 0 # Windows infers the size
162183
bmi.bmiHeader.biXPelsPerMeter = 0 # Unspecified
163184
bmi.bmiHeader.biYPelsPerMeter = 0 # Unspecified
164-
bmi.bmiHeader.biClrUsed = 0 # See grab.__doc__ [3]
165-
bmi.bmiHeader.biClrImportant = 0 # See grab.__doc__ [3]
185+
bmi.bmiHeader.biClrUsed = 0
186+
bmi.bmiHeader.biClrImportant = 0
166187
self._bmi = bmi
167188

168189
def _close_impl(self) -> None:
169190
# Clean-up
170-
if self._bmp:
171-
self.gdi32.DeleteObject(self._bmp)
172-
self._bmp = None
191+
if self._dib:
192+
self.gdi32.DeleteObject(self._dib)
193+
self._dib = None
173194

174195
if self._memdc:
175196
self.gdi32.DeleteDC(self._memdc)
@@ -239,34 +260,17 @@ def callback(_monitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool:
239260
user32.EnumDisplayMonitors(0, None, callback, 0)
240261

241262
def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
242-
"""Retrieve all pixels from a monitor. Pixels have to be RGB.
243-
244-
In the code, there are a few interesting things:
245-
246-
[1] bmi.bmiHeader.biHeight = -height
247-
248-
A bottom-up DIB is specified by setting the height to a
249-
positive number, while a top-down DIB is specified by
250-
setting the height to a negative number.
251-
https://msdn.microsoft.com/en-us/library/ms787796.aspx
252-
https://msdn.microsoft.com/en-us/library/dd144879%28v=vs.85%29.aspx
253-
254-
255-
[2] bmi.bmiHeader.biBitCount = 32
256-
image_data = create_string_buffer(height * width * 4)
263+
"""Retrieve all pixels from a monitor using CreateDIBSection.
257264
258-
We grab the image in RGBX mode, so that each word is 32bit
259-
and we have no striding.
260-
Inspired by https://github.com/zoofIO/flexx
265+
CreateDIBSection creates a DIB with system-managed memory backing,
266+
allowing BitBlt to write directly to memory we can read. This eliminates
267+
the need for a separate GetDIBits call.
261268
262-
263-
[3] bmi.bmiHeader.biClrUsed = 0
264-
bmi.bmiHeader.biClrImportant = 0
265-
266-
When biClrUsed and biClrImportant are set to zero, there
267-
is "no" color table, so we can read the pixels of the bitmap
268-
retrieved by gdi32.GetDIBits() as a sequence of RGB values.
269-
Thanks to http://stackoverflow.com/a/3688682
269+
Note on biHeight: A bottom-up DIB is specified by setting the height to a
270+
positive number, while a top-down DIB is specified by setting the height
271+
to a negative number. We use negative height for top-down orientation.
272+
https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader
273+
https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createdibsection
270274
"""
271275
srcdc, memdc = self._srcdc, self._memdc
272276
gdi = self.gdi32
@@ -275,25 +279,40 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
275279
if self._region_width_height != (width, height):
276280
self._region_width_height = (width, height)
277281
self._bmi.bmiHeader.biWidth = width
278-
self._bmi.bmiHeader.biHeight = -height # Why minus? See [1]
279-
self._data = ctypes.create_string_buffer(width * height * 4) # [2]
280-
if self._bmp:
281-
gdi.DeleteObject(self._bmp)
282-
# Set to None to prevent another DeleteObject in case CreateCompatibleBitmap raises an exception.
283-
self._bmp = None
284-
self._bmp = gdi.CreateCompatibleBitmap(srcdc, width, height)
285-
gdi.SelectObject(memdc, self._bmp)
282+
self._bmi.bmiHeader.biHeight = -height # Negative for top-down DIB
283+
284+
if self._dib:
285+
gdi.DeleteObject(self._dib)
286+
self._dib = None
287+
288+
# CreateDIBSection creates the DIB and returns a pointer to the pixel data
289+
self._dib_bits = LPVOID()
290+
self._dib = gdi.CreateDIBSection(
291+
memdc,
292+
self._bmi,
293+
DIB_RGB_COLORS,
294+
ctypes.byref(self._dib_bits),
295+
None, # hSection = NULL (system allocates memory)
296+
0, # offset = 0
297+
)
298+
gdi.SelectObject(memdc, self._dib)
286299

300+
# Create a ctypes array type that maps directly to the DIB memory.
301+
# This avoids the overhead of ctypes.string_at() creating an intermediate bytes object.
302+
size = width * height * 4
303+
array_type = ctypes.c_char * size
304+
self._dib_array = ctypes.cast(self._dib_bits, POINTER(array_type)).contents
305+
306+
# BitBlt copies screen content directly into the DIB's memory
287307
gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT)
288-
assert self._data is not None # noqa: S101 for type checker
289-
scanlines_copied = gdi.GetDIBits(memdc, self._bmp, 0, height, self._data, self._bmi, DIB_RGB_COLORS)
290-
if scanlines_copied != height:
291-
# If the result was 0 (failure), an exception would have been raised by _errcheck. This is just a sanity
292-
# clause.
293-
msg = f"gdi32.GetDIBits() failed: only {scanlines_copied} scanlines copied instead of {height}"
294-
raise ScreenShotError(msg)
295-
296-
return self.cls_image(bytearray(self._data), monitor)
308+
309+
# Flush GDI operations to ensure DIB memory is fully updated before reading.
310+
# This ensures the BitBlt has completed before we access the memory.
311+
gdi.GdiFlush()
312+
313+
# Read directly from DIB memory via the cached array view
314+
assert self._dib_array is not None # noqa: S101 for type checker
315+
return self.cls_image(bytearray(self._dib_array), monitor)
297316

298317
def _cursor_impl(self) -> ScreenShot | None:
299318
"""Retrieve all cursor data. Pixels have to be RGB."""

0 commit comments

Comments
 (0)