
简介这份Delphi图像匹配资源面向需要在Windows桌面环境中实现屏幕找图的开发者适用于自动化测试、游戏辅助、屏幕抓取等场景属于图像处理与计算机视觉的入门到进阶实践内容。资源包为rar压缩格式整体约6.63MB上游未提供文件总数与类型明细但内容围绕屏幕抓取、模板匹配、哈希算法、特征匹配及效率优化等核心主题展开。已有540人学习关注说明该方向在Delphi社区具有一定实用需求。读者可从中了解如何借助VCL.Graphics、OpenCV等库完成位图读取与比较掌握BitBlt屏幕捕获、像素级差分、滑动窗口与多线程加速等实现思路并参考示例代码理解匹配阈值设定与误报漏报的处理策略。对于希望快速搭建找图功能原型、对比不同匹配算法优劣的Delphi开发者而言这份资料提供了较为完整的技术脉络与可落地的代码参考。1. 屏幕找图这件事Delphi 其实一直都能干做桌面自动化或者辅助工具的人迟早会撞上一个需求屏幕上某张图出现了程序得知道它在哪然后点它。很多人第一反应是上 Python装 OpenCV写十几行模板匹配跑起来还挺香。但如果你手上维护的是一个 Delphi 老项目或者你压根不想为了一个找图功能拖进来几百兆的运行时那这套方案就有点别扭了。Delphi 找图这件事其实用原生代码就能干得挺利索——截屏、灰度化、模板匹配、返回坐标整条链路都能在 Pascal 里闭环编译出来一个 exe 丢过去就能跑不依赖外部环境。这篇要聊的就是这么一份能直接落地的 Delphi 屏幕找图实现适合做桌面自动化、游戏辅助逻辑、UI 回归验证的从业者也适合想搞明白模板匹配底层到底在算什么的人。2. 模板匹配的底层逻辑为什么灰度化能省一半算力2.1 从截屏到像素矩阵中间发生了什么屏幕找图的第一步永远是把屏幕变成一块能遍历的内存。Windows 上常见做法是用 GDI 的BitBlt把桌面 DC 的内容拷到一块兼容位图里再通过GetDIBits把位图数据读成一个字节数组。这里有个关键点屏幕像素是 BGRA 排列的每像素 4 字节而模板匹配真正需要的往往只是亮度信息。所以拿到原始数据后第一件事通常是转灰度。灰度化的公式有好几种最常见的是加权平均Gray 0.299*R 0.587*G 0.114*B。为什么不是简单取平均因为人眼对绿色最敏感、对蓝色最迟钝加权之后得到的灰度图更接近人眼感知匹配时的容错也更合理。转成灰度之后每个像素只占 1 字节内存直接降到原来的四分之一后续遍历的缓存命中率也上去了。这一步看着不起眼但在全屏 1920×1080 的规模下省下来的时间是很实在的。下面这段是截屏加灰度化的核心逻辑用 Delphi 的TBitmap和TBitmapData来做比手动调 GDI 干净不少// 截取全屏并转为灰度字节数组 function CaptureScreenGray(out AWidth, AHeight: Integer): TBytes; var ScreenDC: HDC; Bmp: TBitmap; Data: TBitmapData; x, y: Integer; p: PByte; Gray: Byte; begin AWidth : GetSystemMetrics(SM_CXSCREEN); AHeight : GetSystemMetrics(SM_CYSCREEN); SetLength(Result, AWidth * AHeight); ScreenDC : GetDC(0); try Bmp : TBitmap.Create; try Bmp.PixelFormat : pf32bit; Bmp.SetSize(AWidth, AHeight); BitBlt(Bmp.Canvas.Handle, 0, 0, AWidth, AHeight, ScreenDC, 0, 0, SRCCOPY); if Bmp.Map(TMapAccess.Read, Data) then try for y : 0 to AHeight - 1 do begin p : Data.GetScanline(y); for x : 0 to AWidth - 1 do begin // BGRA 排列p[0]B, p[1]G, p[2]R Gray : (p[0] * 114 p[1] * 587 p[2] * 299) div 1000; Result[y * AWidth x] : Gray; Inc(p, 4); end; end; finally Bmp.Unmap(Data); end; finally Bmp.Free; end; finally ReleaseDC(0, ScreenDC); end; end;逻辑上就是三步拿屏幕 DC、拷到 32 位位图、逐像素算灰度写进一维数组。参数上要注意pf32bit必须显式设置否则TBitmap默认可能是 24 位扫描线步长对不上就会读出花屏。GetScanline拿到的指针是按行走的所以内层循环里Inc(p, 4)一次跳一个像素。灰度系数用整数乘除避免浮点div 1000是精度和速度的折中够用。2.2 模板匹配到底在比什么SAD 与 NCC 的取舍灰度图有了接下来就是拿模板去屏幕上滑。最朴素的做法是 SADSum of Absolute Differences把模板每个像素和屏幕对应位置的像素相减取绝对值全部加起来和越小越像。SAD 的好处是纯整数运算快适合实时找图坏处是对整体亮度变化敏感——屏幕调暗一点SAD 值就飙上去了。另一种是 NCC归一化互相关先减去均值再算相关性对亮度线性变化免疫但每算一个位置都要做一次均值和方差开销大不少。实际做屏幕找图我一般会先用 SAD 粗筛把候选位置压到几十个再对这几个位置算 NCC 精筛。这样既保住了速度又不会被屏幕亮度波动坑到。模板匹配的复杂度是(W - w 1) * (H - h 1) * w * hW、H 是屏幕尺寸w、h 是模板尺寸。一个 1920×1080 的屏幕找一个 50×50 的模板最坏情况要算将近 50 亿次像素比较。所以实际实现里必须做剪枝比如先算模板的像素和滑动时如果当前窗口的像素和跟模板差太多直接跳过不算逐像素。这个提前退出能砍掉一大半无效位置。// SAD 模板匹配带像素和剪枝 function FindTemplate(const Screen, Template: TBytes; SW, SH, TW, TH: Integer; Tolerance: Integer): TPoint; var x, y, tx, ty: Integer; Sum, Diff: Integer; TplSum, WinSum: Int64; pScreen, pTpl: PByte; begin Result : Point(-1, -1); TplSum : 0; for ty : 0 to TH * TW - 1 do Inc(TplSum, Template[ty]); for y : 0 to SH - TH do begin for x : 0 to SW - TW do begin // 先算窗口像素和差异过大直接跳过 WinSum : 0; for ty : 0 to TH - 1 do begin pScreen : Screen[(y ty) * SW x]; for tx : 0 to TW - 1 do begin Inc(WinSum, pScreen^); Inc(pScreen); end; end; if Abs(WinSum - TplSum) Int64(Tolerance) * TW * TH then Continue; // 像素和接近再做逐像素 SAD Sum : 0; for ty : 0 to TH - 1 do begin pScreen : Screen[(y ty) * SW x]; pTpl : Template[ty * TW]; for tx : 0 to TW - 1 do begin Diff : pScreen^ - pTpl^; if Diff 0 then Diff : -Diff; Inc(Sum, Diff); if Sum Tolerance then Break; // 提前退出 Inc(pScreen); Inc(pTpl); end; if Sum Tolerance then Break; end; if Sum Tolerance then begin Result : Point(x, y); Exit; end; end; end; end;这段代码里Tolerance是单像素平均容差实际用的时候一般设 10 到 30 之间。设太小屏幕有轻微抗锯齿或者渲染差异就找不到设太大容易误匹配到背景。TplSum和WinSum的剪枝阈值是Tolerance * TW * TH意思是整块模板的总差异上限。内层if Sum Tolerance then Break是逐像素提前退出一旦累计差异超标就不继续算了。这两个剪枝叠起来实测能把全屏搜索压到几十毫秒级别。提示模板匹配对缩放和旋转是不免疫的。如果目标图在屏幕上被系统 DPI 缩放拉伸过或者有旋转SAD 和 NCC 都会失效。这种情况要么先把屏幕缩放到模板的尺度要么上特征点匹配那是另一个量级的工作量了。3. 把找图封装成能调用的单元接口设计与多目标返回3.1 单元结构怎么切截图、匹配、坐标转换三层分离一份能长期用的找图代码不该把所有逻辑塞在一个函数里。我一般会切成三层截图层只负责把屏幕或指定区域变成灰度数组匹配层只负责在数组里找模板坐标转换层负责把数组下标还原成屏幕坐标还要处理多显示器偏移。这样切的好处是截图层可以换成从文件读图匹配层可以单独写测试用例坐标层可以适配不同 DPI 设置。截图层对外暴露的接口大概是CaptureRegion(Left, Top, Width, Height): TBytes匹配层是MatchTemplate(Screen, SW, SH, Template, TW, TH, Tolerance): TArrayTPoint注意这里返回的是数组因为屏幕上同一个图标可能出现多次。坐标层就是简单的Point(RegionLeft x, RegionTop y)。三层之间只传字节数组和整数没有对象生命周期纠缠调起来省心。多目标返回这件事值得单独说。很多找图实现只返回第一个匹配就退出了但实际场景里你可能需要点第二个、第三个。做法是在匹配循环里不Exit而是把每个满足容差的位置都记下来同时做一个非极大值抑制如果新找到的位置和已记录的位置距离小于模板尺寸的一半就认为是同一个目标跳过。这样返回的坐标列表才是干净的。// 多目标匹配 非极大值抑制 function MatchAll(const Screen, Template: TBytes; SW, SH, TW, TH, Tolerance: Integer): TArrayTPoint; var x, y, i: Integer; Sum, Diff: Integer; pScreen, pTpl: PByte; Cand: TPoint; Duplicate: Boolean; begin SetLength(Result, 0); for y : 0 to SH - TH do begin for x : 0 to SW - TW do begin Sum : 0; for i : 0 to TH - 1 do begin pScreen : Screen[(y i) * SW x]; pTpl : Template[i * TW]; for var j : 0 to TW - 1 do begin Diff : pScreen^ - pTpl^; if Diff 0 then Diff : -Diff; Inc(Sum, Diff); if Sum Tolerance * TW * TH then Break; Inc(pScreen); Inc(pTpl); end; if Sum Tolerance * TW * TH then Break; end; if Sum Tolerance * TW * TH then begin Cand : Point(x, y); Duplicate : False; for i : 0 to High(Result) do if (Abs(Result[i].X - Cand.X) TW div 2) and (Abs(Result[i].Y - Cand.Y) TH div 2) then begin Duplicate : True; Break; end; if not Duplicate then begin SetLength(Result, Length(Result) 1); Result[High(Result)] : Cand; end; end; end; end; end;这里的容差判断从单像素平均改成了总量Tolerance * TW * TH是整块模板允许的总差异。非极大值抑制的距离阈值取TW div 2和TH div 2意思是两个候选中心靠得太近就当成同一个。这个阈值不能设太大否则相邻的两个真实目标会被误合并也不能太小否则同一个目标周围会冒出一堆重复坐标。3.2 从 BMP 文件加载模板格式对齐与步长陷阱模板图一般是从磁盘读的 BMP 或者 PNG。BMP 读起来最省事因为格式简单但有个经典坑BMP 每行字节数必须是 4 的倍数不够要补零。如果你读的是 24 位 BMP宽度不是 4 的倍数时每行末尾会有填充字节直接按Width * 3去算步长就会错位。正确做法是用((Width * 3 3) div 4) * 4算实际行字节数。PNG 的话Delphi 自带TPngImage加载后转成 32 位位图再走灰度化那条路就行。不管哪种格式加载完都要统一转成灰度一维数组跟截图层的输出格式对齐这样匹配层不用关心模板是从哪来的。// 从 BMP 文件加载模板并转灰度 function LoadTemplateGray(const FileName: string; out TW, TH: Integer): TBytes; var Bmp: TBitmap; Data: TBitmapData; x, y: Integer; p: PByte; begin Bmp : TBitmap.Create; try Bmp.LoadFromFile(FileName); Bmp.PixelFormat : pf32bit; // 统一转 32 位避免步长问题 TW : Bmp.Width; TH : Bmp.Height; SetLength(Result, TW * TH); if Bmp.Map(TMapAccess.Read, Data) then try for y : 0 to TH - 1 do begin p : Data.GetScanline(y); for x : 0 to TW - 1 do begin Result[y * TW x] : (p[0] * 114 p[1] * 587 p[2] * 299) div 1000; Inc(p, 4); end; end; finally Bmp.Unmap(Data); end; finally Bmp.Free; end; end;关键就一句Bmp.PixelFormat : pf32bit强制转成 32 位之后每像素固定 4 字节GetScanline拿到的行指针就是连续的不用再操心 BMP 的 4 字节对齐填充。这个转换会多一次内存拷贝但换来的是代码简单不容易错我觉得值。参数上TW、TH是输出参数调用方拿到之后直接传给匹配函数。注意如果模板图本身带透明通道转 32 位之后 Alpha 通道会被忽略透明区域会按黑色参与匹配。如果模板有透明背景要么提前把透明区域填成屏幕背景色要么在匹配时跳过 Alpha 为 0 的像素否则匹配结果会偏。4. 避坑与排查找图不准、找不到、找到错的那些事4.1 现象明明图在屏幕上就是找不到最常见的原因是 DPI 缩放。现在很多笔记本默认 125% 或 150% 缩放你截屏拿到的物理像素和模板图的逻辑像素对不上模板被拉伸了SAD 自然匹配不上。解决办法是在程序清单里声明 DPI 感知让截屏拿到真实物理像素同时模板图也要按相同缩放比例准备。排查方法很简单把截屏存成 BMP把模板也存成 BMP肉眼比一下尺寸是不是一致。另一个原因是颜色深度差异。有些截图工具存出来的 BMP 是 24 位而屏幕截取是 32 位灰度化之后数值有细微差别容差设太小就过不了。把容差从 10 调到 20 通常能解决但别调太大否则误匹配。4.2 现象找到了但坐标偏了几十像素坐标偏移一般出在多显示器或者窗口有边框的情况。GetSystemMetrics(SM_CXSCREEN)拿的是主显示器的尺寸如果你在副显示器上找图坐标系原点就不对。正确做法是用GetSystemMetrics(SM_XVIRTUALSCREEN)和SM_YVIRTUALSCREEN拿虚拟桌面的左上角截屏时也从这个原点开始截。窗口有边框的话客户区坐标和屏幕坐标差一个边框宽度用ClientToScreen转一下。4.3 现象匹配速度慢到没法用全屏逐像素匹配在没剪枝的情况下找一个小模板要好几秒。除了前面说的像素和剪枝和提前退出还有一个实用技巧先隔行隔列粗搜。比如每隔 4 个像素搜一次找到大致区域后再在附近精细搜。这样速度能快十几倍代价是可能漏掉极小的目标但对大多数图标找图场景够用。4.4 现象程序跑一会儿内存一直涨TBitmap和TBytes都是需要手动释放的。TBitmap用完必须FreeTBytes虽然是托管数组但如果在循环里反复SetLength大数组内存碎片会涨。我一般会把截屏的TBytes在循环外分配一次循环内复用只SetLength不重新分配。GetDC和ReleaseDC必须成对漏掉ReleaseDC会泄漏 GDI 对象跑久了系统会卡。4.5 现象换台机器就找不到图了不同机器的显卡渲染、字体平滑、主题色都可能有差异同一张图在两台机器上渲染出来的像素不完全一样。这种情况容差要适当放宽或者用 NCC 代替 SAD。更稳的做法是模板不要用整张图而是截取特征最明显的局部比如图标里对比度最高的那块减少受背景和渲染差异影响的范围。5. 进阶把找图做成可配置的自动化流程5.1 用配置文件驱动找图任务硬编码模板路径和容差改一次就要重新编译不现实。我一般会用一个简单的 INI 或者 JSON 配置把模板文件、搜索区域、容差、找到之后执行的动作都写进去。Delphi 里TIniFile就够用读出来一个任务列表循环执行。这样非开发人员也能改配置不用碰代码。// 从 INI 读取找图任务并执行 procedure RunTasks(const IniFile: string); var Ini: TIniFile; Count, i: Integer; TplFile: string; Tol: Integer; Screen, Tpl: TBytes; SW, SH, TW, TH: Integer; Hits: TArrayTPoint; begin Ini : TIniFile.Create(IniFile); try Count : Ini.ReadInteger(Tasks, Count, 0); for i : 0 to Count - 1 do begin TplFile : Ini.ReadString(Tasks, Format(Task%d.File, [i]), ); Tol : Ini.ReadInteger(Tasks, Format(Task%d.Tolerance, [i]), 20); if TplFile then Continue; Screen : CaptureScreenGray(SW, SH); Tpl : LoadTemplateGray(TplFile, TW, TH); Hits : MatchAll(Screen, Tpl, SW, SH, TW, TH, Tol); if Length(Hits) 0 then // 这里接点击或后续动作坐标是 Hits[0] OutputDebugString(PChar(Format(Found at %d,%d, [Hits[0].X, Hits[0].Y]))); end; finally Ini.Free; end; end;配置里每个任务有模板路径和容差两个字段读出来之后走一遍截屏、加载、匹配。OutputDebugString只是占位实际用的时候换成鼠标点击或者发消息。这个结构的好处是加任务不用改代码改 INI 就行。5.2 验证找图结果把命中区域画出来找图逻辑对不对光看返回值不直观。我习惯在调试阶段把命中位置在屏幕上画个红框肉眼确认一下。用Canvas.Rectangle在桌面 DC 上画就行画完停几百毫秒再刷新。这个习惯帮我省了很多次瞎猜的时间——有时候匹配到的位置偏了几个像素看返回值看不出来画出来一眼就发现了。// 在屏幕上画命中框用于调试 procedure DrawHitBox(P: TPoint; W, H: Integer); var DC: HDC; OldPen: HPEN; begin DC : GetDC(0); try OldPen : SelectObject(DC, CreatePen(PS_SOLID, 2, RGB(255, 0, 0))); Rectangle(DC, P.X, P.Y, P.X W, P.Y H); SelectObject(DC, OldPen); finally ReleaseDC(0, DC); end; end;CreatePen创建的画笔用完要DeleteObject这里为了简短没写实际代码里别忘了。画框之后屏幕会一直留着需要刷新或者重绘才能清掉调试阶段无所谓。5.3 一个我踩过的坑模板边缘的透明像素有次找一个带圆角的按钮模板是从截图里抠出来的四个角是透明或者接近背景色。匹配的时候这些角上的像素跟屏幕背景对不上SAD 值一直偏高死活找不到。后来把模板边缘裁掉两圈只留中间实心部分立刻就匹配上了。从那以后我每次做模板都会先看一眼边缘把不稳定的像素裁掉宁可模板小一点也不要带噪声。希望帮到你。本文还有配套的精品资源点击获取