资讯动态

OpenCV+PyTorch实现连连看自动操作器的核心思路与踩坑

发布时间:2026/10/14 8:34:24 来源:尧图企业网站定制
最近把一个练手项目翻出来重新整理了一遍用Python OpenCV PyTorch实现了一个连连看自动操作器。标题听起来像是个正儿八经的工程但拆开来看它做的事情和真人玩连连看完全一致——先看一眼棋盘上的棋子在脑子里判断哪两个能连然后用鼠标点下去。只是这里把“看”交给OpenCV“认”交给PyTorch“判断能不能连”交给图论逻辑“点下去”交给Windows的底层鼠标事件。先说个结论这玩意儿并没有用到什么黑科技甚至圆桌会议式地讲核心算法连100行代码都不到。但它踩坑非常多主要集中在图标切分偏移、高亮动画干扰、DPI缩放导致的坐标错位。文中我会把每个模块的选型理由和实测过程中遇到的坑都讲清楚希望这篇文章能给做图像识别自动操作、GUI自动化测试或者游戏脚本的朋友们一些参考。老规矩这种脚本更适合用来研究算法和做自动化测试不建议拿去碰有排名的在线联机对战。1. 架构拆解自动操作器其实在替我们做三件事1.1 从真人玩法推导出的三层结构先把一个真人玩连连看的过程拆开眼睛定位棋盘和棋子、大脑计算连通路径、手移动鼠标点击。映射到代码里就是三个独立的模块视觉模块负责截屏、找棋盘、切棋子逻辑模块负责把识别到的棋子映射成连线路径操作模块负责发出真实的鼠标点击事件。我在设计的时候刻意把这三层完全解耦了。视觉模块只输出一个二维矩阵矩阵里每个元素是棋子类别的ID逻辑模块在这个矩阵上做连通判断输出一对坐标操作模块只接收坐标不关心棋子长什么样。这样任何一个模块替换掉其他模块都不受影响。比如把PyTorch的识别换成模板匹配或者把鼠标点击换成仅打印日志整条链路依然能跑。这种分层很重要因为迭代的时候你不会想每次改动都重跑整套流程。我一开始把视觉和逻辑写在一个文件里改识别模型的时候连累到了路径判定调试起来特别痛苦。后来才把每个模块独立成类接口只传矩阵和坐标。1.2 为什么偏要用OpenCV加PyTorch的组合单纯做一个连连看自动操作器其实OpenCV一个库就够了甚至拿模板匹配就能把棋子认出来。那为什么还要让PyTorch掺和一脚原因在于游戏版本的变动。模板匹配的思路是拿事先截好的棋子小图在棋盘上滑动对比找到相似度最高的位置。这个方案在图标样式固定的时候非常快、非常准。但问题是连连看这种游戏换个主题、换个皮肤图标变了模板就全部失效你得重新截图、重新标定。用PyTorch训练一个轻量的特征提取器之后模型学到的是“哪些图标相似、哪些不相似”的度量而不是记住某一张具体的图。换主题时只需要重新采集少量样本微调一下通用性明显更强。OpenCV在整个链路里的角色则是纯粹的图像处理引擎截屏、找棋盘边界、按投影分割格子、去噪、颜色空间转换。这些事情用PyTorch去做反而吃力不讨好因为它们是确定性的几何变换不需要学习参数。两个库各干各擅长的活这个组合在实践里最省心。2. 视觉模块OpenCV把屏幕棋盘变成一张干净的二维表2.1 截屏效率对比mss为什么比慢吞吞的ImageGrab靠谱接到屏幕画面的第一步是截屏。Python里常见的方案有PIL的ImageGrab、pyautogui的截图函数、mss库。我最早用ImageGrab发现它的速度在每秒3帧左右而且每帧占用内存还挺高。对于一个需要连续扫描的自动操作器来说这个速度不理想。后来换成mss库快了一个数量级大概每帧20毫秒左右足够在消除动画结束的间隙里完成棋盘刷新。mss的原理是直接从Windows的GDI接口抓取屏幕数据绕过了PIL上层很多格式转换的开销。代码也不复杂先取全屏再按之前记录的区域裁出棋盘区域即可。有一点我后来才注意到如果游戏窗口带圆角或阴影显卡合成会让窗口周围的像素有点半透明效果直接用整屏定位棋盘比先找窗口再抓窗口区域更稳。因为你通过窗口句柄拿到的坐标是Win32的窗口坐标和气器看到的合成画面偶尔会有几个像素的偏差而整屏像素级搜索反而没有这个问题。2.2 棋盘定位颜色区间过滤加最大外接矩形拿到全屏画面之后下一步是找到棋盘区域。大多数连连看的棋盘背景色是相对统一的浅色或深色和游戏背景有明确对比。我用的是HSV颜色空间加轮廓检测的组合。因为RGB空间里颜色的亮度变化会干扰判断转成HSV后只看色调和饱和度区间。用cv2.inRange筛出目标背景色的掩膜再用cv2.findContours找出轮廓取面积最大的那个轮廓的外接矩形那就是棋盘。这里有个经验值棋盘区域的面积占整个屏幕的比例是有下限的直接按比例过滤掉那些零碎的噪声小轮廓。如果桌面背景的颜色和棋盘背景撞了这招会失效。处理办法是给掩膜加一个形态学开运算把零散噪点先腐蚀掉再膨胀回来保留完整的棋盘主块。这个细节我在第二版里补上之后就没再遇到误检。2.3 网格切分靠投影分布而不是傻傻地等分找到棋盘外框后最难的一步来了怎么把一盘棋子切成一格一格的。不少教程直接按行列数等分比如10行乘14列就把宽度除以14、高度除以10。这个做法在图标刚好铺满格子时没问题但只要图标比格子小一圈或者格子之间有明显网格线切出来的图块就会偏移连带着后面的识别全部错位。我改用“投影法”来定位行列分割线。思路是这样把棋盘区域转灰度后二值化棋子的部分变成白色空隙和背景变成黑色。然后按行累加白色像素数量得到一条行方向的一维曲线。有棋子的行累加值高行与行之间的缝隙累加值极低形成一个波谷。这些波谷的中心就是网格线的位置。列方向用同样的方法处理。实际代码大概是这样的import cv2 import numpy as np gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV) row_proj binary.sum(axis1) col_proj binary.sum(axis0) # 找到波谷区间取中心作为分割线 def find_peaks(proj, min_gap3): splits [] in_valley False for i, v in enumerate(proj): if v 200: if not in_valley: start i in_valley True else: if in_valley: splits.append((start i) // 2) in_valley False return splits row_lines find_peaks(row_proj) col_lines find_peaks(col_proj)从代码里能看出这套切分方法不依赖固定的格子大小棋盘缩放后同样能定位。用分割线围成的网格再从每个格子中心偏移一小段距离去取图标区域切出来的小图就非常干净。我项目里切图统一到48乘48像素方便后面喂给PyTorch。2.4 切图偏移的经典坑图标不居中导致投影曲线毛刺有一个坑我印象特别深某个主题下棋子图标并不严格在格子中心整体偏左上角导致行投影的波谷间距忽大忽小分割线偏移了两个像素。别小看这两个像素在48乘48的小图里足以让图像内容错位识别准确率直接从98%掉到80%。解决方式有两种。第一种是切图时不直接取格子中心而是先对单个格子做连通域分析找到里面最大色块的外接矩形以它为中心缩放。第二种更省事把棋盘ROI做一次轻微的膨胀操作让图标边缘连成一片这样投影曲线更平滑波谷就更规整。我在实际中用第二种居多因为改动小且效果立竿见影。3. 棋子识别模板匹配和PyTorch特征提取两条路线的实测3.1 路线A模板匹配五分钟跑通但脆弱第一版我图省事直接用模板匹配。采集方式也简单在游戏界面上把每个棋子的图标单独截一小块保存然后对棋盘上每个格子用cv2.matchTemplate跑一遍取最大值超过阈值就算匹配成功。这个方案在小范围测试里表现不错识别率能到98%。但它的脆弱点很明显第一模板大小必须和当前棋盘的图标大小一致游戏窗口一缩放模板就废了第二连续玩了十几局后部分图标会有细微的描边特效变化模板匹配的分数会波动第三游戏更新换皮肤等于全盘重来。所以模板匹配我只把它当作兜底方案保留着。它的最大价值是调试阶段很直观你能看到匹配分数是怎么变化的方便确认切图是否正常。3.2 路线B用PyTorch训练轻量特征提取器第二版加入了PyTorch训练目标不是做分类而是让相同棋子输出的特征向量尽量接近不同棋子的特征向量尽量远离。用通俗的话说就是训练一个能“看懂相似性”的小网络而不是一个只会背答案的分类器。网络本身很小我用了一个三层卷积加一个全连接层import torch import torch.nn as nn import torch.nn.functional as F class EmbedNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(inplaceTrue), ) self.fc nn.Linear(128, 64) def forward(self, x): x self.features(x) x F.adaptive_avg_pool2d(x, 1).flatten(1) x self.fc(x) return F.normalize(x, dim1)训练数据是自己采的。开局后把棋盘切好的棋子存下来人工标一下属于哪一类每类不用太多20张就够。训练用对比损失或者三元组损失都行让同类间距缩小、异类间距拉大。跑几十个epoch就够在CPU上也就几分钟的事。推理的时候把所有棋子的特征向量算一遍两两之间求余弦相似度。超过0.92就认为是同一类棋子。比起模板匹配这套方案对图标的小幅形变、色调轻微变化更鲁棒。换主题时的确需要重新采样本微调但不必为每一个棋子保存模板图灵活很多。3.3 识别加速HSV颜色直方图粗筛加特征精排相似度计算有个性能瓶颈棋盘几十个棋子两两求相似度没问题但如果每轮消完都重新识别整个棋盘耗时累计起来也很可观。这里我用HSV颜色直方图做第一道粗筛。每个棋子小图先算一个HSV颜色直方图维度降到64维。两个棋子颜色分布差得远相似度直接判0不用再算特征向量。只有颜色直方图接近的候选才送进PyTorch算余弦相似度。这个策略减少了接近80%的无效计算单轮全盘识别时间从50毫秒压到15毫秒左右。实测里这个策略还有额外收益有些不同主题下的棋子虽然形状不同但颜色刚好接近粗筛会保留下它们交给特征网络精排时能顺利区分。如果只靠颜色匹配反而容易误判。4. 连通判定一条路径最多两个拐弯算法核心全在这一层4.1 把棋盘变成图论模型还要给棋盘外留一圈通道连连看的规则是两个同类型棋子之间如果存在一条路径路径最多拐两个弯且路径上所有经过的格子都必须是空的就可以消掉。路径不一定要完全在棋盘内部它可以从棋盘边缘绕出去走棋盘外侧的空旷通道。这一点特别重要也很容易被忽略。我第一次实现时只考虑棋盘内的格子结果很多明明能连的棋子被判成不能连。后来把棋盘外扩一圈全部标成可通行的空格再去做连通判断一下子就对了。具体做法是构造一个二维矩阵0表示空格1到N表示不同棋子类别的ID外层再包一圈0height, width board.shape vboard np.zeros((height 2, width 2), dtypenp.int32) vboard[1:height1, 1:width1] board坐标的映射也很简单真实棋盘的坐标(r, c)对应扩展矩阵里的(r1, c1)。4.2 枚举拐点优于裸BFS的三个理由有了矩阵模型最直观的做法是BFS找最短路径然后判断路径拐了几次弯。但实际上我推荐直接枚举拐点做法是先判断是否能直线连接再枚举可能的第一个拐点和第二个拐点检查路径上的每一段是否都畅通。为什么不直接用BFS因为BFS需要额外记录方向变化次数代码更复杂而且在10乘14这种小棋盘上枚举拐点的计算量完全扛得住。枚举拐点的思路也更贴近人类理解路径拆成三至四段每段都是直线只需要查每段直线上的格子是否都是0。两个棋子的连接情况分几类直线连接同行或同列中间无障碍0个拐点。这种情况自然先判掉。一个拐点起点先水平或竖直走到拐点拐点再直线连到终点。两个拐点起点和终点各自向某个方向延伸出一条直线中间插入一段与这两条直线衔接的直线段两个拐点必须落在空格上。直线段上是否有障碍用前缀和或者直接循环查都行。因为棋盘小循环查就够快。4.3 简化实现两个可达集合求交集说一个比逐点枚举拐点更清爽的实现。先把起点能通过一条直线到达的所有空格点收集到一个集合A再把终点能通过一条直线到达的所有空格点收集到集合B。如果A和B有交集说明公共的那个格子就是拐点路径只拐一个弯就能连。如果A和B没有交集再考虑两个拐点的情况对A里的每个点看它能否通过一条直线到达C集合中的某个点而C集合里的点又能直线到达终点。换一种说法对所有空格点如果它既能直线到达起点可达集合里的某个点又能直线到达终点那这就是两个拐点的中间拐点。写成代码就是def can_connect(vboard, start, end): # start/end是扩展棋盘坐标 if start end: return False if on_same_line(vboard, start, end): return True reachable_from_start straight_reachable(vboard, start) reachable_from_end straight_reachable(vboard, end) if reachable_from_start reachable_from_end: return True for mid in all_empty_cells(vboard): if (straight_reachable_from(mid) reachable_from_start and straight_reachable_from(mid) reachable_from_end): return True return False这里的straight_reachable是指某个点向上下左右四个方向一直延伸直到遇到非空格为止把所有经过的空格收集起来。实际跑的时候扩展棋盘大概12乘16的大小一次连通判定的耗时在毫秒级。4.4 遍历顺序优化先分组再配对减少无效计算棋盘上所有棋子两两配对的复杂度是O(n^2)开局几十个棋子全量配对也就几千次判断理论上一轮下来不会慢。真正拖慢进度的不是配对耗时而是重复识别和无效扫描。我采取的优化是先按棋子类别分组组内做配对判断。同类才可能消掉跨类不用浪费时间去判连通。每一轮消除之后不要靠程序内部维护的棋盘状态继续计算而是重新抓屏重新识别。因为消除动画会改变画面靠内存里的矩阵推断往往会产生和真实画面不一致的错位导致点击落空。宁可多花几十毫秒重新识别也比维护一个慢慢失真的状态表强。搜索顺序上还做了一个小优化优先找“路径最短”“位置靠外”的可消对减少鼠标跨屏移动的时间。这个优化不改变正确性但确实让单局耗时降了几秒。5. 自动点击与稳定性别让小问题拖垮整个识别链路5.1 鼠标点击用Windows底层API而不是pyautogui桌面自动化里最常用的pyautogui确实方便但我发现它的click方法有固定的延迟连点太多次之后偶尔还会丢事件。对自动操作器来说稳定和高频比跨平台更重要所以我最终用了ctypes调用Win32 API。import ctypes import random def click_screen(x, y): ctypes.windll.user32.SetCursorPos(int(x), int(y)) ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # 按下 time.sleep(random.uniform(0.02, 0.05)) ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # 抬起这里的坐标x、y是屏幕坐标。它由棋子在棋盘上的行列号映射而来映射公式很简单棋盘左上角屏幕坐标加上格子偏移。需要注意的是切图时用的棋盘坐标是ROI内的相对坐标要换算到全屏坐标得把ROI的左上角坐标叠加回去。这是不少人调试时容易漏掉的一步。5.2 模拟人类点击节奏别用固定间隔一开始为了快我设置每0.05秒点一次。结果很多局玩不过十几步就异常原因是游戏内的高亮动画还没播完下一轮的截图已经抓到半透明状态的棋子导致识别错乱。后来我把每步操作之间加入随机延迟一般在60到150毫秒之间高亮动画能正常播完识别准确率也回来了。坐标上也可以加一两个像素的随机抖动让点击位置不完全落在像素正中心。这个抖动不能大因为有的棋子边缘高亮区域也在点击判定内稍微偏一点没关系但偏太多就会点到间隙里。还有一个细节不要在鼠标移动到目标位置后立刻点击人操作时鼠标到位的瞬间和点击之间会有几十毫秒的停顿。代码里加了一个很小的随机延迟行为更像真人。5.3 窗口移动、DPI缩放与坐标错位这个坑是整个项目里最隐蔽的。没有系统设置DPI缩放时屏幕坐标和Win32 API拿到的坐标是一一对应的。一旦系统缩放比例不是100%某些API返回的坐标是逻辑坐标和实际像素坐标不相等导致找棋盘时明明定位到了最终点击却始终偏一截。我在启动脚本里显式声明DPI感知代码在Windows下加上这一句import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass加上之后窗口缩放和系统DPI设置就不会干扰屏幕坐标换算了。另外每次操作前重新抓屏定位棋盘而不是记录一次坐标一直用窗口一旦被拖动下一轮也能自动跟上。6. 实测数据与踩坑记录6.1 三轮测试的量化结果我把整理过后的工具放在同一台机器上连续跑了三轮每轮200局左右的量。数据记录如下测试条件棋子识别准确率单局平均耗时完整通关率固定主题固定窗口大小98.2%18.6秒88%换主题同分辨率95.6%21.3秒76%换主题且窗口缩放110%94.1%22.8秒71%识别准确率看起来不低但通关率并没有接近百分之百主要损失在高亮动画误判和切图偏移上。也就是说识别到棋子类型这一步做得够好但坐标映射和时序协调上还有不小优化空间。6.2 我反复掉进去的坑位清单问题根因解决方式切出来的图标偏移格子中心与图标中心不重合用投影波谷定位分割线不做等分换主题后识别率暴跌模板匹配不抗样式变化换成PyTorch特征向量对比一直点不中目标未处理DPI缩放导致坐标错位启动时声明DPI感知玩到中途开始误判消除了动画没播完就重扫每步加随机延迟等待动画稳定程序偶尔卡死截屏库采集频率过高控制轮询周期在200毫秒以上6.3 这套链路还能用到哪里做这个项目的过程中我发现类似的结构可以平移到很多场景。比如做GUI自动化回归测试时用OpenCV定位界面控件、用特征识别判断按钮状态、用图像对比确认页面渲染是否正常这套视觉加逻辑加操作的解耦方式非常通用。另一个可以扩展的方向是把棋盘状态当成一个约束满足问题尝试用启发式搜索去提高消除效率。再者如果把“消除”看作一种动作把棋盘看作状态这个环境本身也能支持简单的强化学习实验。这些都留给后续有时间再玩。最后分享一点个人体会做这种自动操作器真正难的不是某个单点算法而是把所有环节的误差控制在能容忍的范围里。视觉模块偏一点点一下就偏一点时序慢一点识别就错一帧。层层叠加出来的误差会直接变成通关率上的十几个百分点。先跑通链路再用数据定位最大的误差源这个过程本身比工具本身更有意思。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑