最近把一个练手项目翻出来重新整理了一遍:用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(axis=1) col_proj = binary.sum(axis=0) # 找到波谷区间,取中心作为分割线 def find_peaks(proj, min_gap=3): 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, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(inplace=True), ) 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, dim=1)训练数据是自己采的。开局后把棋盘切好的棋子存下来,人工标一下属于哪一类,每类不用太多,20张就够。训练用对比损失或者三元组损失都行,让同类间距缩小、异类间距拉大。跑几十个epoch就够,在CPU上也就几分钟的事。
推理的时候,把所有棋子的特征向量算一遍,两两之间求余弦相似度。超过0.92就认为是同一类棋子。比起模板匹配,这套方案对图标的小幅形变、色调轻微变化更鲁棒。换主题时的确需要重新采样本微调,但不必为每一个棋子保存模板图,灵活很多。
3.3 识别加速:HSV颜色直方图粗筛加特征精排
相似度计算有个性能瓶颈:棋盘几十个棋子,两两求相似度没问题,但如果每轮消完都重新识别整个棋盘,耗时累计起来也很可观。这里我用HSV颜色直方图做第一道粗筛。
每个棋子小图先算一个HSV颜色直方图,维度降到64维。两个棋子颜色分布差得远,相似度直接判0,不用再算特征向量。只有颜色直方图接近的候选,才送进PyTorch算余弦相似度。这个策略减少了接近80%的无效计算,单轮全盘识别时间从50毫秒压到15毫秒左右。
实测里这个策略还有额外收益:有些不同主题下的棋子虽然形状不同,但颜色刚好接近,粗筛会保留下它们,交给特征网络精排时能顺利区分。如果只靠颜色匹配,反而容易误判。
4. 连通判定:一条路径最多两个拐弯,算法核心全在这一层
4.1 把棋盘变成图论模型,还要给棋盘外留一圈通道
连连看的规则是:两个同类型棋子之间如果存在一条路径,路径最多拐两个弯,且路径上所有经过的格子都必须是空的,就可以消掉。路径不一定要完全在棋盘内部,它可以从棋盘边缘绕出去,走棋盘外侧的空旷通道。
这一点特别重要,也很容易被忽略。我第一次实现时只考虑棋盘内的格子,结果很多明明能连的棋子被判成不能连。后来把棋盘外扩一圈,全部标成可通行的空格,再去做连通判断,一下子就对了。
具体做法是构造一个二维矩阵,0表示空格,1到N表示不同棋子类别的ID,外层再包一圈0:
height, width = board.shape vboard = np.zeros((height + 2, width + 2), dtype=np.int32) vboard[1:height+1, 1:width+1] = board坐标的映射也很简单:真实棋盘的坐标(r, c),对应扩展矩阵里的(r+1, c+1)。
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定位界面控件、用特征识别判断按钮状态、用图像对比确认页面渲染是否正常,这套视觉加逻辑加操作的解耦方式非常通用。
另一个可以扩展的方向是把棋盘状态当成一个约束满足问题,尝试用启发式搜索去提高消除效率。再者,如果把“消除”看作一种动作,把棋盘看作状态,这个环境本身也能支持简单的强化学习实验。这些都留给后续有时间再玩。
最后分享一点个人体会:做这种自动操作器,真正难的不是某个单点算法,而是把所有环节的误差控制在能容忍的范围里。视觉模块偏一点,点一下就偏一点;时序慢一点,识别就错一帧。层层叠加出来的误差,会直接变成通关率上的十几个百分点。先跑通链路,再用数据定位最大的误差源,这个过程本身比工具本身更有意思。