Per-Monitor DPI v2 in Python: the one line every automation tool forgets

by Fireal Software · ~8 min read

If you’ve ever written a Python Windows automation script that worked perfectly on your laptop but missed every target by 50 pixels on an external monitor, you’ve hit the DPI awareness problem. The fix is one DllCall, but almost no Python automation library actually makes it, and the consequences are confusing.

This post explains why it matters, what Windows does behind your back when you don’t opt in, and how to get raw physical pixels no matter how many monitors at what scales.

The problem in one sentence

By default, Windows pretends your process is running on a 96 DPI display and silently scales every coordinate behind your back.

What that looks like in practice

You write a script that clicks at (1000, 500) on a 4K monitor at 200% scale. You expect it to click at the physical pixel position (1000, 500) from the top-left of the screen.

What actually happens: Windows scales your coordinate by 2× and clicks at (2000, 1000). Why? Because your process is “DPI unaware”, and Windows is trying to help you — it assumes you wrote coordinates relative to 96 DPI. It’s scaling to match the monitor.

Fine, you say, I’ll take a screenshot first and locate the button by image matching. You call PIL.ImageGrab.grab() and the returned image is 1920×1080 — not the 3840×2160 the monitor actually is — because Windows scaled the capture too.

You’re in a scaled coordinate system, not raw pixels. Any script that “just works” on your development machine will break on anyone with a different DPI setup.

Four levels of DPI awareness

Windows has evolved DPI awareness over four levels. Your process lives at whichever level you opt into (default: unaware).

Level 0: Unaware (default)

Windows scales everything. You see 96 DPI. Coordinates go through a scaling transform.

Level 1: System DPI aware

Introduced in Vista. You see the system DPI (whatever the primary monitor was at login time). Secondary monitors at different DPIs are scaled to match. Coordinates are in system DPI pixels, not physical.

Level 2: Per-Monitor v1

Introduced in Windows 8.1. You see the DPI of whichever monitor the window is on. If the window moves to a different DPI, you get WM_DPICHANGED and have to rescale your UI. Still has scaling edge cases.

Level 3: Per-Monitor v2

Introduced in Windows 10 Creators Update (1703). Everything is physical pixels, everywhere, always. No scaling transforms. Different monitors at different DPIs all return their physical pixel coordinates directly. Process survives mid-session DPI changes (plugging/unplugging monitors).

Per-Monitor v2 is the one you want. Every other level has sharp edges that will bite you.

The one-line fix

import ctypes
ctypes.windll.user32.SetProcessDpiAwarenessContext(-4)

That -4 is DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2. Call it at the very top of your script, before creating any windows or capturing any screens. From that point on:

One line. That’s the whole fix. But it has to run before your imports touch anything DPI-sensitive, which is why eyehands does it in eyehands/__init__.py — so the eyehands.input and eyehands.capture submodules see the correct virtual-desktop metrics when they initialize.

Why SetProcessDpiAwarenessContext and not the older APIs

Windows has four ways to set DPI awareness, and three of them have traps:

SetProcessDPIAware() (Vista)

Sets System DPI aware (level 1). Doesn’t give you per-monitor behavior. Can only be called once.

SetProcessDpiAwareness() (Windows 8.1)

Takes an enum: PROCESS_DPI_UNAWARE, PROCESS_SYSTEM_DPI_AWARE, or PROCESS_PER_MONITOR_DPI_AWARE. The last one is Per-Monitor v1, not v2. Can only be called once per process.

Manifest declaration

You can set DPI awareness in an application manifest XML. Tedious for Python — you’d need a bundled .exe or a PEP 517 manifest hack.

SetProcessDpiAwarenessContext() (Windows 10 1703+)

Takes a context constant: -1 (unaware), -2 (system aware), -3 (per-monitor v1), -4 (per-monitor v2), -5 (unaware GDI-scaled). Only this one supports v2. Can be called after some window operations (unlike the older APIs).

So: if you want Per-Monitor v2, SetProcessDpiAwarenessContext(-4) is the only option.

Verifying it worked

import ctypes

awareness = ctypes.c_int()
ctypes.windll.shcore.GetProcessDpiAwareness(0, ctypes.byref(awareness))
print(f"DPI awareness: {awareness.value}")
# 0 = unaware, 1 = system aware, 2 = per-monitor aware

Note that this API only reports through v1 (values 0-2). To check for v2 specifically, use GetAwarenessFromDpiAwarenessContext on the current process’s context. In practice, if SetProcessDpiAwarenessContext(-4) returned TRUE, you’re v2.

Capturing screenshots at physical pixels

With DPI awareness set correctly, screen capture returns raw pixels:

import ctypes
ctypes.windll.user32.SetProcessDpiAwarenessContext(-4)

from PIL import ImageGrab
img = ImageGrab.grab(all_screens=True)
print(img.size)   # (3840, 2160) on a real 4K monitor, not (1920, 1080)

Without the DPI call, you’d get 1920×1080 — the scaled image.

What this means for your automation scripts

Always set Per-Monitor v2 at the top of any Windows automation script. If you’re using PyAutoGUI, keyboard, pynput, or any library that doesn’t set it for you (most don’t), add the DllCall before you import pyautogui.

Or use eyehands, which already does it.

The virtual-desktop bounds

Once you’re in Per-Monitor v2, you probably also want the virtual-desktop bounds — the rectangle that covers all your monitors for absolute-coordinate operations:

SM_XVIRTUALSCREEN = 76
SM_YVIRTUALSCREEN = 77
SM_CXVIRTUALSCREEN = 78
SM_CYVIRTUALSCREEN = 79

user32 = ctypes.windll.user32
vdesk_left = user32.GetSystemMetrics(SM_XVIRTUALSCREEN)
vdesk_top = user32.GetSystemMetrics(SM_YVIRTUALSCREEN)
vdesk_w = user32.GetSystemMetrics(SM_CXVIRTUALSCREEN)
vdesk_h = user32.GetSystemMetrics(SM_CYVIRTUALSCREEN)

These tell you the full extent of your multi-monitor setup in physical pixels (as long as you’re DPI-aware). eyehands caches these at module load and uses them to normalize MOUSEEVENTF_ABSOLUTE coordinates to MOUSEEVENTF_VIRTUALDESK, so absolute moves to (3840, 500) actually land on the right monitor.

Install

pip install eyehands
eyehands

*Per-Monitor DPI v2 is one of those "everyone should just do this" things that nobody does. Writing this post is partly so the next person who hits the "script works on my machine, breaks on a 4K monitor" problem can find the fix in ten seconds instead of three hours.*

Give Claude eyes and hands on Windows

eyehands is a local HTTP server for screen capture, mouse control, and keyboard input. Open source with a Pro tier.

Try eyehands