This tutorial is about DPI, screen scaling, multi-monitor setups, and tries to answer the question "why does my script work in one computer, but stops working in another". It's a fairly complicated topic and some details about it are unclear to me as well, but we shall try anyway.
For easy reference I'll attach here also the tools used in this tutorial: the Dpi.ahk library and the accompanying WindowSpyDpi tool.
This tutorial will go over what is DPI and display resolution, discuss DPI awareness of programs, how to adjust scripts for DPI, and finally we'll take a look at how to make scripts that don't care about DPI (mostly how to save coordinates in such a way, and how to perform ImageSearch regardless of DPI). We won't be discussing making AHK GUIs more DPI-aware.
What is DPI and resolution?
First lets define DPI, short for dots per inch: DPI measures how many "dots" (or pixels) fit in a one-inch line. Importantly, DPI is NOT screen resolution, nor does it represent the amount of physical pixels in your monitor.
Mostly for historic reasons, the default DPI for monitors has been defined as 96DPI, which in Windows' settings corresponds to a screen scaling of 100%. This means that a monitor scaled to 100% will display one logical inch of content on 96 pixels.
You might ask why I used the term "logical inch" not just "inch". This is because Windows usually doesn't account for the real physical screen size. Some monitors don't report their physical size, and in some cases talking about physical size doesn't even make sense: for example, if using a projector then its distance from the wall determines the physical size of the screen, and there is no good way for Windows to get that info. This is why Windows deals in logical inches, and the DPI determines the number of pixels for one such inch.
Correspondingly, for font sizes 1pt (point) is defined to be 1/72 logical inches so a 72pt font will be displayed as 96 pixels tall on a 96DPI screen. If displayed on a monitor scaled to 150% then a 72pt font would be 96*1.5=144 pixels tall.
So what is the difference between display resolution and DPI? The display resolution defines how many of the physical pixels Windows uses to display output. If physically using a 1920x1080 screen but setting the display resolution to 960x540 (twice as small), then the quality of the image decreases by two-fold (there will be twice as few pixels to display content). Display resolution can't really be set higher than the physical resolution of the screen, it wouldn't make sense.
DPI on the other hand, as we already know, means how many pixels are used for one inch of content. The higher the DPI, the more pixels are used to display the same amount of content, and thus the higher the quality of the displayed content.
If we had two screens, both physically same size, but one with 1200x800 physical resolution and the other with 600x400 resolution, then the 1200x800 running @ 200% scaling would look much crisper: it would have twice the pixel density for the same images.
An easy way to understand resolution vs DPI is playing around with your monitor settings, which in Windows 10 are under "Change the resolution of the display" in Start menu. I will use as an example my 14' laptop which has a 16:9 aspect ratio and 1920x1080 pixel resolution (or pixels per inch (PPI) of ~157). The default display settings are DPI of 144 (scale 150%) and display resolution of 1920x1080. However, if I change resolution to 1600x900 and scale to 125%, everything on the screen looks exactly the same size as before (1920/150 = 1600/125), but everything is more blurry. This is because now we have less pixels to display the content than before.
Hopefully this makes it clear why DPI is necessary: higher DPI can allow for crispier images on high-resolution displays. Also, since modern high-resolution displays have much more pixels than 10-20 years ago, then the historical DPI of 96 can get uncomfortable because everything is displayed too small. In addition, some users (including sight-impaired people) might prefer bigger content and a higher DPI allows that without lowering the quality (resolution).
DPI awareness
Suppose we don't have just one monitor, but two. And suppose that these monitors have different resolutions and DPIs. In this case things get tricky when moving windows between monitors and trying to figure out coordinates to use MouseMove/Click with.
My following examples use my current setup: the primary monitor is a 14' laptop screen with 16:9 aspect ratio, 1920x1080 pixel resolution, and DPI 144 (150%); the secondary monitor is a 34' screen with 21:9 aspect ratio, 3440x1440 resolution, and DPI 96 (100%); running the latest Windows 10 (as of 01.09.2023).
In this setup AHKs built-in variables A_ScreenWidth and A_ScreenHeight contain 1920 and 1080 respectively, because this is the resolution of the primary monitor.
Our first task will be to move the Calculator app to the second monitors' x100 y100 coordinates. On the primary monitor this is easy: WinMove(100, 100) works perfectly fine. However, AHK scripts and other programs are by default DPI system-aware, which means they expect the primary monitors' DPI everywhere and Windows adjusts for that. So if we try to use WinMove(A_ScreenWidth+100, 100), we get an unexpected result: Obviously this is wrong and the most probable culprit is the differing DPIs. This is confirmed by using WinMove(A_ScreenWidth*144/96+100, 100) instead, which moves the window to the correct location. Why that calculation fixes the current issue, I'll leave for the reader to ponder. However, it's easy to see that this will get more and more messy if dealing with three monitors or getting and calculating the DPIs and resulting coordinates for both primary and secondary monitor windows. Fortunately, Windows has provided a solution for this: per-monitor DPI awareness. This causes Windows to adjust the coordinates taking also account other monitors' DPIs.
Setting our script to per-monitor aware is simple: just add the following line to the top of your script: DllCall("SetThreadDpiAwarenessContext", "ptr", -3, "ptr")
Recommended extra reading: AHK docs DPI Scaling section.
Yay, this fixes WinMove, MouseMove, and other related functions! Case solved? No!
Adjusting for DPI
While DPI per-monitor awareness fixes a bunch of things, it doesn't solve the issue of scaled coordinates. Namely, WindowSpy reports Client and Window coordinates adjusted to DPI. This means that if we record a coordinate in one DPI, then it won't work in another DPI.
For example, let's try writing a script to click the "7" button that will need to work on both my monitors.
(Note that this image is taken on the primary monitor, so DPI doesn't need to be per-monitor.)
Let's use those Client coordinates to click: Click(67, 494). Easy enough and works on the primary monitor, but let's try it on the second monitor: Naturally, the cursor ends up in the wrong location, because coordinates recorded in DPI 144 will represent a different point in DPI 96. Adjusting for the DPI by dividing by 144 and multiplying by 96 fixes the problem: Click(67*96/144, 494*96/144).
However, now the code doesn't work on the primary monitor!
It seems that to completely fix this issue we would need to get the DPI of the monitor the window is located in, and then adjust for that accordingly. This can be achieved by the following function:
Code: Select all
; Gets the DPI for the specified window
WinGetDpi(WinTitle?, WinText?, ExcludeTitle?, ExcludeText?) {
return (hMonitor := DllCall("MonitorFromWindow", "ptr", WinExist(WinTitle?, WinText?, ExcludeTitle?, ExcludeText?), "int", 2, "ptr") ; MONITOR_DEFAULTTONEAREST
, DllCall("Shcore.dll\GetDpiForMonitor", "ptr", hMonitor, "int", 0, "uint*", &dpiX:=0, "uint*", &dpiY:=0), dpiX)
}
Simplifying DPI adjustment
To simplify this process of adjusting for DPI, I have written the Dpi.ahk library and the accompanying WindowSpyDpi tool.
WindowSpyDpi is adjusted to be DPI per-monitor aware and also automatically scales all coordinates and sizes to DPI 96. Then, the Dpi library can be used with the coordinates to perform the desired actions, and the code should perform the same with any DPI.
For example, WindowSpyDpi reports Calculator (resized to be as small as possible) number 7 button client coordinates to be x47 y329. This means that DPI.Click(47, 329) should automatically work across all monitors.
Some notes:
1) Since scaling down from a very high DPI (eg 192) to 96 will lose some precision, then the A_StandardDpi variable can be adjusted in WindowSpyDpi and also in the script that includes Dpi.ahk, and an arbitrarily large number (eg DPI.StandardDpi := 960) can be used instead.
2) The Dpi functions are necessarily slower than the native functions because of all the extra calculations. This shouldn't be noticable though, unless calling the functions thousands of times per second.
3) The Dpi library might break existing scripts using hard-coded coordinates because it changes DPI awareness which the existing script does not account for. It might also change the appearence of GUIs and GUI menus, so it might be desirable to use SetThreadDpiAwarenessContext(-2) before creating those.
DPI-agnostic ImageSearch & FindText
In some cases it's also possible to perform ImageSearch across multiple DPIs. This involves using ImageSearch's *w and *h options to scale the image to the correct size. This will not always work and pretty much always requires adjusting the variation (*n) to a high level, because the scaled image will never be exactly the same as the one captured at the target DPI. In addition, antialiasing and differing font spacing/size might change the image so much that it can't be found at any other DPI than the native one: this is especially true in browsers and browser-based applications (eg Spotify, Discord etc). I've found that FindText works more reliably across DPIs, even when using image files.
However, sometimes things work out. The following code works for screen scales 100% to 225% to find the Calculator icon:
Code: Select all
#include DPI.ahk
wTitle := "Calculator"
WinActivate wTitle
WinWaitActive wTitle
WinGetPos(&wX, &wY, &wW, &wH, wTitle)
if DPI.ImageSearch(&outX, &outY, 0, 0, wW, wH, "*150 Calculator_icon_225%.png",,216)
MouseMove outX, outY
else
MsgBox("Could not find image")
I've found FindText to be a bit easier and more forgiving method of image-searching. The following code finds the reload button in Chrome:
Code: Select all
#include DPI.ahk
#include FindText.ahk
wTitle := "ahk_exe chrome.exe"
WinActivate wTitle
WinWaitActive wTitle
WinGetPos(&wX, &wY, &wW, &wH, wTitle)
Text:="|<ChromeReloadDPI216>**50$41.0000000000000000000000000000000000000y00000DzU2001s3kA00Dzyss00zUDvE03w07wU07U03100S006201g00A403k00k80B0030E0S00A0U0w00zz01M000002U000005000000/000000S000000w000801c000s01s003k03M007U03k00S007k01s007s0Dk007w1z0007TzQ0007U3U0003zw00000T0000000000000000000000000000004"
if (ok:=FindText(&X, &Y, wX, wY, wX+wW, wY+wH, 0.3, 0.2, Text, , 0, , , , , zoomW:=DPI.GetForWindow(wTitle)/216, zoomW)) ; The Text was taken at DPI 216 (225%)
FindText().MouseTip(X, Y)
else
MsgBox("Could not find image")
Code: Select all
#include DPI.ahk
#include FindText.ahk
wTitle := "ahk_exe chrome.exe"
WinActivate wTitle
WinWaitActive wTitle
WinGetPos(&wX, &wY, &wW, &wH, wTitle)
if (ok:=FindText(&X, &Y, wX, wY, wX+wW, wY+wH, 0.2, 0.1, "##10$Chrome_Reload_100%.png", , 0, , , , , zoomW:=DPI.GetForWindow(wTitle)/96, zoomW))
FindText().MouseTip(X, Y)
else
MsgBox("Could not find image")
The following is for easy reference of converting between DPI and scale:
Code: Select all
DPI Scale Percentage
96 1 100%
120 1.25 125%
144 1.5 150%
168 1.75 175%
192 2 200%
216 2.25 225%
240 2.5 250%
288 3 300%
336 3.5 350%
Hopefully this tutorial has been instructional, and while writing it I've definitely learned a lot. If anyone has ideas on how to make adjusting for DPI even easier or how to ImageSearch more reliably, be sure to leave a comment!
Change log
Code: Select all
07.09.23: breaking change: all DPI functions have been encapsulated in the DPI class (eg instead of ClickDpi it's now DPI.Click). Added multiple monitor-related methods (GetForMonitor, GetMonitorHandles, MonitorFromPoint, MonitorFromWindow).