ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python跳一跳脚本拆解:adb截屏、OpenCV识别与触摸注入

Python跳一跳脚本拆解:adb截屏、OpenCV识别与触摸注入

简介:面向想用 Python 实践计算机视觉与自动化控制的初学者和进阶者,这套“跳一跳”辅助项目围绕屏幕截图识别、方块距离计算与模拟点击展开,帮助理解 Pillow、OpenCV、pyautogui 在一个小游戏场景中的协作方式。压缩包共 279 个文件,约 26.76MB,包含 217 个 png 截图与素材、36 个 json 配置、7 个 md 说明、6 个 py 脚本,并附带 adb、fastboot 等 exe/dll 工具文件,便于按模块查看源码、配置与运行依赖。已有 319 人学习,适合对照项目结构梳理截图区域设定、目标识别、跳跃力度估算和点击节奏控制等实现思路,也能从中了解多机型配置、容错处理与调试排错方法。资源侧重技术学习与代码研读,不建议直接用于违反平台规则的实际游戏操作。

1. 为什么我会去写一个跳一跳的脚本

第一次看到有人用 Python 玩跳一跳,我以为它靠录屏回放,拆开才发现完全不是——每一跳它都在现场计算:抓一帧截图,找出棋子在哪、目标方块中心在哪,用两点距离乘一个系数得到手指按屏的毫秒数。整套东西不到三百行,却把移动端自动化的几件基本事(截屏、图像识别、事件注入)串了一遍,拿它当入门项目比写玩具 demo 划算得多。它适合两类人:想学 Python 图像处理又不想啃理论书的,以及想给自己的 App 做自动化测试、不知道从哪下手的。下面按我实际拆解的路径走一遍,代码都能直接抄。

2. 整体链路:从 adb 抓一帧到手指离开屏幕

这个项目最容易被误解的地方,是它根本不管游戏的状态。它不清楚你现在在第几关、下一个方块是什么形状,它只做一件事:看一帧、算一次、按一下,然后重复。正因为整体流程足够简单,才适合拿来拆解移动端自动化的底座。整条链路拆成四个环节之后,每个环节都能单独替换、单独调参,出问题时也能很快判断是哪一段出了问题。

2.1 四个环节,每个都能单独换掉

把流程摊开,是这样的:

环节输入输出典型耗时
截屏无一张 PNG 或 ndarray150~400ms
定位截图棋子底部坐标、目标中心坐标20~60ms
换算两个坐标按压毫秒数<1ms
注入毫秒数一次 swipe 事件按压时长 + 50ms

四个环节里,截屏和注入依赖安卓调试桥(adb),但两者是解耦的;定位和换算完全在本地进程里跑。这个边界划分很重要,因为它意味着你可以把截屏换成投屏协议、把注入换成sendevent,识别逻辑一行都不用动。后面第 6 章讲二次开发,就是从这个边界下手的。

耗时上还有一点要知道:一整轮下来大约 300~600ms,再加上等待落地动画的 1 秒多,实际每秒最多跳一次。想提速,只能压缩等待时间,不能压缩截屏——截屏拿到旧帧会让后面的所有计算都白做,这一点在第 5 章还会提到。

2.2 环境准备:三样东西,少一样都跑不起来

我把依赖压到最少,Python 侧只要 OpenCV 和 NumPy,手机侧只要 adb 能连上。

# 1. 装依赖,opencv 只用基础模块,不需要 contrib python -m pip install opencv-python numpy # 2. 确认设备连上了,能看到一串序列号才算成功 adb devices # 3. 记下分辨率,后面算系数要用 adb shell wm size

这三条命令里,第二条最容易失败。失败时一般会看到unauthorized,原因是手机上没点“允许 USB 调试”。如果看到列表是空的,多半是数据线只能充电、不能传数据,或者驱动没有装好——这种换线就能解决的故障,我见过不止一次,排查时先怀疑它。

第三条的返回值长这样:Physical size: 1080x1920。这个数字请记牢,因为第 4 章的系数标定需要用它做参考。不同分辨率下系数不一样,1080p 上能跑通的系数放到 2K 屏上可能就会跳过头。

手机端还要做两个设置:打开开发者选项里的“USB 调试”,以及把屏幕常亮、关掉锁屏。锁屏一旦触发,截图会拿到锁屏界面,识别模块会直接返回空坐标,然后主循环就会一直空转——这个问题不报错,但很耗时间。

2.3 一个能跑起来的最小骨架

主循环不处理任何异常逻辑,就是“抓、算、按、等”四步:

import time import cv2 from capture import adb_screencap from vision import find_piece, find_target from action import press def main(coef=1.35, rounds=50): last_dist = None for i in range(rounds): png = adb_screencap("/tmp/s.png") # 抓一帧 img = cv2.imread(png) if img is None: continue piece = find_piece(img) # 棋子底部中心 target = find_target(img, piece) # 目标方块中心 if piece is None or target is None: time.sleep(0.4) # 识别失败,过一会重试 continue dx = target[0] - piece[0] dy = target[1] - piece[1] dist = (dx * dx + dy * dy) ** 0.5 # 欧氏距离,单位是像素 last_dist = dist ms = max(200, min(900, int(dist * coef))) # 卡在安全区间内 press(ms) # 注入按压 time.sleep(1.2) # 等落地动画播完 if __name__ == "__main__": main()

这段代码里有两个参数值得单独说。

coef是距离到时间的换算系数,单位是“毫秒每像素”。它的物理含义是:屏幕上每多 1 个像素的水平距离,手指要多按多少毫秒。这个值跟屏幕密度、游戏内部的速度倍率都有关,所以不能从别人那里抄。第 4 章会给标定方法。

rounds是最大跳数,设成 50 是为了防止死循环。真实场景下你会希望它更大,但在调参阶段建议压到 10 以内,否则一旦系数错了,会连续跳很多次失败,既浪费时间又容易让人烦躁。

press里的max(200, min(900, ...))这层钳制是有原因的,第 5 章会专门讲。这里先记住:超过 900ms 的按压在不少机型上会被系统截断,落点就很不可控。

2.4 为什么不用 uiautomator 或 Appium

这是选型上最先要回答的问题。跳一跳的画面是游戏引擎渲染的,控件树里根本找不到“棋子”和“方块”这两个节点,用 uiautomator dump 出来的层级只有一层 SurfaceView。Appium 那套基于无障碍服务的定位方式在这里完全失效,唯一能用的就是截图加视觉识别。

所以自动化框架的选择不是“哪个更先进”,而是“哪个能拿到你要的信息”。控件树能拿到的场景,用 Appium 更省事;拿不到的,就老老实实写视觉。我在项目里保留 adb 而不用更重的方案,也是因为它只需要一条 USB 连接,没有额外的服务进程要维护,挂掉之后重启一条命令就行。

3. 图像识别:把棋子和目标从像素里抠出来

识别模块是整个项目里唯一需要调参的地方,也是新手最容易卡住的地方。网上的代码直接抄下来,十有八九识别不到棋子——不是代码错了,而是颜色区间对不上:原作者用的分辨率、系统主题、游戏版本和你不一样,同一个阈值在你这儿就是失效的。所以这一段我不只给代码,还给出“怎么把阈值调出来”的过程。

3.1 先裁剪再识别,别一上来就全图跑

一张 1080x1920 的图,直接在全图上做连通域分析会有两个问题:慢,而且误检多。游戏画面上方的分数、下方的 UI 元素都可能被当成目标。

所以第一步是裁出搜索区域。棋子的有效活动范围大概在屏幕高度的 15%~65%,目标方块则在 20%~55% 之间。这个范围不需要绝对精确,留点余量没关系,多余的区域会由后面的面积筛选过滤掉。

def crop(img, top_ratio, bottom_ratio): """按比例裁剪,不写死像素值,换分辨率不用改代码""" h, w = img.shape[:2] return img[int(h * top_ratio):int(h * bottom_ratio), :]

这里的参数都用比例而不是像素,是因为同一份代码要在不同手机上跑。写死img[200:1200, :]在 1080p 上没问题,换到 720p 的手机上就会把目标裁掉一半。这是很典型的踩坑点,我第一次写的时候就栽在这儿,换手机后识别率直接掉到零。

裁剪完之后记得把裁剪偏移量存下来,因为识别出来的坐标是相对于裁剪区域的,最后要加回去才是全图坐标。这个偏移量错了,跳的时候就总是差那么一点点,看起来像系数问题,其实是坐标没换算。

3.2 棋子定位:颜色阈值加连通域

棋子是画面里唯一一个颜色固定、形状固定的物体。它的本体是深蓝紫色,头部偏白,所以最稳的做法是在 HSV 空间里圈出这段颜色,然后取面积最大的连通域。

import cv2 import numpy as np def find_piece(img): """返回棋子底部中心的 (x, y),找不到返回 None""" h, w = img.shape[:2] roi = img[0:int(h * 0.65), :] # 只在上半屏找 hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) # 深蓝紫色段:H 在 100~170,S 和 V 都要够高才不是灰色背景 lower = np.array([100, 60, 40]) upper = np.array([170, 255, 200]) mask = cv2.inRange(hsv, lower, upper) # 闭运算把棋子身上的高光断开处连起来 kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None c = max(contours, key=cv2.contourArea) if cv2.contourArea(c) < 500: # 太小的是噪点 return None x, y, cw, ch = cv2.boundingRect(c) return x + cw // 2, y + ch # 底部中心

代码里几个参数需要解释清楚,因为它们决定了你能不能在自己的手机上跑通。

lower和upper是 HSV 区间。H 的取值范围是 0~180,不是 0~360,这是 OpenCV 的约定,第一次用很容易搞错。S 和 V 的下限 60 和 40 是为了把浅灰背景排除掉——背景的饱和度接近 0,inRange会直接把它过滤。

cv2.morphologyEx那一步的核大小 5x5 是个经验值。核太大,棋子会和旁边的方块粘连成一整块,导致边界框变得很大;核太小,棋子身上的高光会把轮廓切成两段,max(contours)可能只取到下半身。我试过 3x3 和 7x7,前者断得多,后者容易粘,5x5 比较稳。

cv2.contourArea(c) < 500是面积下限。这个数字跟分辨率相关,1080p 下棋子大概有 3000~6000 像素,500 是个安全的下限。如果你换了低分辨率手机,要相应调小。

最后返回的是(x + cw // 2, y + ch),即底边中点而不是几何中心。因为跳跃的起算点是棋子脚底和地面接触的位置,用中心点算距离会系统性偏大一点,跳得越远偏差越明显。

阈值怎么调出来。如果跑起来发现返回 None,先别改代码,把中间结果存下来看:

cv2.imwrite("/tmp/mask.png", mask) # 看掩码图有没有白块 print(hsv[500, 500]) # 打印棋子所在点的 HSV 值

掩码图全黑说明阈值没圈住;掩码图有白块但面积太小,说明闭运算的核不够大。直接在棋子附近采几个点打印 HSV,取最大最小值往外扩 10~20,基本就能圈住。这个办法比猜快得多,我一般先这么干再动代码。

3.3 目标定位:逐行扫描找方块顶面

目标方块的颜色是随机的,没法用固定阈值,所以思路要换:背景色是已知的(画面大面积是浅色),凡是和背景色差别足够大的像素,就认为是物体。然后从上往下逐行扫,第一个满足“连续像素足够多”的行,就是方块顶面。

def find_target(img, piece_xy=None): """逐行扫描找目标方块顶面的中心,找不到返回 None""" h, w = img.shape[:2] top, bottom = int(h * 0.20), int(h * 0.55) roi = img[top:bottom, :] # 取左上角一小块的中位数当背景色,中位数比均值抗噪 bg = np.median(roi[:15, :15].reshape(-1, 3), axis=0) diff = np.abs(roi.astype(np.int16) - bg.astype(np.int16)).sum(axis=2) mask = (diff > 45).astype(np.uint8) for y in range(mask.shape[0]): row = mask[y] if row.sum() < 8: # 这一行有效像素太少,跳过 continue xs = np.where(row > 0)[0] left, right = int(xs.min()), int(xs.max()) cx = (left + right) // 2 radius = (right - left) // 2 return cx, top + y + radius # 从顶面往下补一个半径 return None

bg用中位数而不是均值,是因为截图左上角偶尔会压到分数文字,均值会被拉偏,中位数不受影响。取 15x15 的块也是权衡:太小了不稳定,太大了可能把方块边缘框进去。

diff > 45这个阈值是三个通道差值的绝对值之和,取值范围 0~765。取 45 大致相当于“平均每个通道差 15”,能过滤掉压缩噪声,又不会漏掉浅色方块。方块如果是接近背景色的淡色,这个值要往下调;反之如果画面噪点多,要往上调。

row.sum() < 8是为了排除细长的干扰物,比如分数文字的笔画。连续 8 个像素才算“一段”,这个数跟分辨率有关,1080p 下大概是半个棋子宽度。

top + y + radius这一步是整个函数里最不精确的地方。y定位到的是方块顶面那条边,而跳跃要落到方块中心,所以要往下补。补多少?严格算的话,3D 透视下顶面是个椭圆,中心点比顶边低一个短半轴;粗略用radius(也就是顶面宽度的一半)替代,实际会偏大一丁点。很多人第一次跳总是跳过头,就是这里补多了。可以把radius乘个 0.85 左右的系数微调,这个值不大稳定,但跟恒定误差相比,它已经能让人接受了。

3.4 识别结果自检

光有识别还不够,必须有个“这次识别靠不靠谱”的判断,否则一次误识别就会让后面的所有计算都出错。我一般检查三件事:坐标是否越界,面积是否过小,以及这一跳的距离和上一跳差得离谱不离谱。

def is_reliable(piece, target, last_dist, img): h, w = img.shape[:2] if piece is None or target is None: return False for x, y in (piece, target): if not (0 <= x < w and 0 <= y < h): return False dist = ((target[0] - piece[0]) ** 2 + (target[1] - piece[1]) ** 2) ** 0.5 if dist < 30: # 太近了,肯定是把背景当目标了 return False if last_dist and abs(dist - last_dist) > last_dist * 1.5: return False # 距离突变,大概率识别错了 return True

第三个判断尤其有用。跳一跳里方块的水平间距是有范围的,一般不会出现“上一跳 200 像素、这一跳 800 像素”这种变化。如果出现了,八成是把远处的某个色块当成了目标。这时候跳过这一帧重新截图,比硬跳要安全得多。

不过这个判断也有个前提:第一次跳没有last_dist参照,所以第一跳必须靠人工看一眼截图确认。我一般的做法是先在调试模式下把识别结果画在截图上,确认几帧没问题之后,再打开自动模式。

4. 距离到时长:那个 1.35 的系数到底怎么来的

识别做完,剩下的问题就一个:知道要跳多远,怎么变成按多久。这一步看起来最简单,实际上是最容易出玄学问题的地方——同样一段代码,在别人手机上能用,换台机器就跳一半,很多人卡在这里放弃。

4.1 线性关系成立的前提

游戏内部的实现是:手指按住屏幕时,棋子开始蓄力,松手时按蓄力时间给一个初速度,棋子沿抛物线飞出去。在小距离范围内,飞行距离和蓄力时间近似成正比,这就是那个系数的来源。

但这个近似是有前提的。第一,按压时间不能太长,超出线性区之后飞得更远,但超出得不成比例,反而很难控制;第二,游戏的帧率要稳定,帧率抖的时候实际蓄力量会有误差;第三,方块的高低差不能太大,因为飞行距离是三维空间的距离,而我们的公式算的是二维屏幕距离。跳一跳里有高低方块,高处往低处跳的时候,同样的二维距离实际需要的蓄力更小。这也是为什么脚本偶尔会在高低交界处失败。

知道这些前提之后,就不会指望一个系数解决所有情况了。实际做法是:先把平坦段落跑稳,遇到高低差大的地方接受偶发失败。

4.2 手动标定:两次跳跃反推系数

标定的核心思路是:让脚本用已知的系数跳一次,量出实际落点和期望落点差多少,反推出正确的系数。

具体步骤是这样的:第一步,设一个初始系数,比如 1.0,让它跳一次,这一跳会明显不够,因为 1.0 太小;第二步,从截图里读出棋子的实际落点坐标;第三步,代进公式反推:

def recalibrate(coef_old, piece, target, landed): """piece/target 是这一跳的期望,landed 是实际落点中心""" want = ((target[0] - piece[0]) ** 2 + (target[1] - piece[1]) ** 2) ** 0.5 got = ((landed[0] - piece[0]) ** 2 + (landed[1] - piece[1]) ** 2) ** 0.5 if got < 1: return coef_old return coef_old * want / got

这里want是“本来应该跳多远”,got是“实际跳了多远”。因为飞行距离和按压时间成正比,所以实际落点距离和按压时间成正比,那么新系数 = 旧系数 × 期望距离 / 实际距离。这个公式要成立,前提是got不为零,所以加了个保护。

landed这个坐标从哪来?最土的办法是人工看图,最省事的办法是让脚本再识别一次——落地之后棋子会在新的方块上停住,这时候跑一次find_piece拿到的就是落点。但要注意,落地后棋子位置可能和方块边缘有偏差,识别出来的坐标本身有误差,所以标定最好做三到五次取平均。

4.3 自动标定:写个脚本扫一圈

手动标定做两三次还行,做多了会烦。我用的是简单的扫描法:固定跳几个不同距离的格子,记录每次的偏差率,取平均。

def auto_calibrate(device, coef0=1.0, rounds=6): coef = coef0 history = [] for i in range(rounds): img = device.grab() piece, target = locate(img) if piece is None or target is None: continue dist = ((target[0] - piece[0]) ** 2 + (target[1] - piece[1]) ** 2) ** 0.5 device.press(int(dist * coef)) time.sleep(1.2) img2 = device.grab() landed, _ = locate(img2) # 落地后重新识别棋子位置 if landed is None: continue new_coef = recalibrate(coef, piece, target, landed) history.append(new_coef) # 只接受合理范围内的修正,防止一次误识别把系数带飞 if 0.7 * coef < new_coef < 1.3 * coef: coef = new_coef return sum(history) / len(history) if history else coef0

这里的关键是那层0.7 ~ 1.3的钳制。不加这层,一次误识别就能把系数拉到离谱的值,后面的跳就全废了。加了之后,单次误差最多修 30%,几轮下来会收敛到真值附近。

rounds取 6 是经验值,太少不稳定,太多没必要,因为每一轮都要花掉好几秒。标完之后把coef写进配置文件,后面就不用再标了。但手机换了、分辨率变了、游戏更新了,都要重新标。

4.4 注入命令的写法与上限

最后把毫秒数变成一次触摸事件:

import subprocess def press(ms, x=500, y=1600): """在 (x, y) 处按住 ms 毫秒后松开""" ms = max(200, min(900, int(ms))) # 安全区间 subprocess.run([ "adb", "shell", "input", "swipe", str(x), str(y), str(x), str(y), str(ms) ], check=True)

input swipe本来是滑动命令,起止点相同就变成了长按。这是最省事的写法,不用 root,也不用装额外的东西。

但这条命令有两个坑,我在第 5 章会展开。简单说:一是ms的上限,很多机型在超过 1000ms 之后行为会变得不可预测;二是swipe的起止点如果完全重合,个别 ROM 会判定为无效事件直接丢弃。稳妥的做法是把终点往下挪 1 个像素,视觉上看不出区别,事件却能正常触发。

x和y的默认值也要按分辨率调整。1080p 下取屏幕中央偏下比较安全,因为这些位置大概率是空地,不会误触 UI。如果你换到平板或者折叠屏,这个坐标要重新算,否则按压点可能落在别的元素上。

5. 避坑与排查:跳到沟里的五种姿势

识别和换算都跑通之后,剩下的问题基本都出在“环境”而不是“代码”上。这一章的五条是我自己踩过的,按现象、原因、解决写,遇到问题可以对着查。

5.1 截图全黑,或者拿到的是上一帧

现象:程序跑起来,识别模块一直返回 None,手动打开/tmp/s.png发现是黑屏,或者是几秒前的旧画面。

原因有两个。一是手机息屏了,截屏命令拿到的是锁屏界面或者一片黑。二是用的adb shell screencap加adb pull两步走,第一步在手机上写文件,第二步再拉回来,中间有延迟,高频调用时会拿到上一轮的文件。

解决:先在最前面加一条亮屏命令,并且改成一步取流的写法。

def adb_screencap(path="/tmp/s.png"): with open(path, "wb") as f: subprocess.run( ["adb", "exec-out", "screencap", "-p"], stdout=f, check=True ) return path

exec-out把二进制直接吐到标准输出,省掉了在手机上写文件再拉回来的一步,快而且不会拿到旧帧。配合adb shell input keyevent KEYCODE_WAKEUP提前唤醒屏幕,基本能解决。另外把系统里的自动锁屏时间调长,或者干脆用开发者选项里的“充电时不锁屏”。

5.2 棋子识别不到,掩码图全黑

现象:find_piece一直返回 None,把掩码存下来看,是一张纯黑图。

原因:颜色区间不对。最常见的情况是换了手机,屏幕色温偏冷或者偏暖,同一个棋子的 HSV 值和原来差很多;其次是游戏更新换了皮肤,颜色完全变了。

解决:别猜,直接采点。在棋子所在位置打印 HSV,取几个点看看 H/S/V 的实际范围,然后把lower和upper往外扩。如果换了皮肤,可以改成模板匹配:手工截一张棋子的小图存成模板,用cv2.matchTemplate找最大响应位置。模板匹配对颜色不敏感,但要求棋子在截图里的尺寸和模板一致,换分辨率要重做模板。

def find_piece_tpl(img, tpl, thresh=0.75): res = cv2.matchTemplate(img[:int(img.shape[0] * 0.65)], tpl, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) if max_val < thresh: return None th, tw = tpl.shape[:2] return max_loc[0] + tw // 2, max_loc[1] + th

thresh取 0.75 是个折中。调高会漏检,调低会误检,先从这个值开始试。

5.3 按压被截断,越远跳得越不够

现象:近距离的跳没问题,距离一远就明显不到位,而且误差随距离线性增大。

原因:input swipe的 duration 有上限。不同 ROM 的上限不一样,有的 1000ms,有的更低。超过之后事件会被截断或者直接丢弃,实际按压时间比你传的短。

解决:先确认是不是这个问题——把press里的 ms 打印出来,看远距离那几跳是不是都在 900 以上。如果是,有两个办法。一是降低截图分辨率,把图缩到 720p 再算距离,这样所需时间也会按比例缩小。二是换成sendevent直接发原始触摸事件,绕开input命令的限制,代价是要先getevent拿到设备节点,不同机型节点名不一样,移植性差。

我一般优先用第一种,因为改起来简单,只用一行cv2.resize:

img = cv2.resize(img, (720, 1280)) # 缩放后所有坐标同比缩小

注意缩放之后系数也要跟着改,因为像素距离变了,但物理距离没变。

5.4 系数对,但落点忽左忽右

现象:平均误差不大,但单次误差方向不稳定,有时偏左有时偏右,有时候差得还挺多。

原因:识别抖动,或者按压过程中有干扰。识别抖动的来源是目标方块的中心估计——radius那个近似本身就带误差,方块的大小不同,误差大小也不同。干扰的来源是按压期间有通知弹出、屏幕亮度自动调整,或者游戏帧率波动。

解决:一是把目标中心的 y 补偿系数从固定的 1.0 改成可调,按方块大小动态算;二是关掉系统的自动亮度、免打扰模式;三是如果误差主要是横向的,检查一下find_target返回的cx是不是被某个偏在一侧的装饰物带偏了,可以在取left和right的时候用分位数代替最值,排除掉零星噪点。

5.5 卡在某一跳不动,循环空转

现象:程序一直在跑,但棋子不跳了,日志里识别一直返回 None。

原因:游戏进入了结算界面或者弹出了广告、复活提示。这时候画面内容和游戏进行中完全不同,识别逻辑自然失效,但主循环不知道发生了什么,就一直重试。

解决:加一个状态判断。最简单的做法是检测画面上有没有特定的按钮颜色或者文字区域,命中就跳出循环;复杂一点的做法是记录连续失败的次数,超过阈值就停下来让人工介入。

fail = 0 while True: if fail > 10: print("连续失败,可能进了结算页,停下") break ... if piece is None or target is None: fail += 1 time.sleep(0.4) continue fail = 0

这个计数重置的时机很重要,一定要在成功识别之后才清零,否则偶尔成功一次又把计数归零,阈值就形同虚设。

6. 二次开发:把它改成你自己的自动化骨架

这个项目最有价值的地方,是它本身可以让人进一步去搭建自己的自动化工具。把这套骨架收干净之后,可以做很多同类的事:自动点签到、自动翻页截图、自动跑一轮 UI 回归。下面是我实际做过的三个改造方向。

6.1 把设备操作抽成接口

原始代码里subprocess.run(["adb", ...])散在各处,换一种设备接入方式就得改好几处。我的做法是抽一个极简的接口出来:

class Device: def grab(self): """返回一帧 BGR ndarray""" raise NotImplementedError def press(self, ms, x, y): """在 (x, y) 处按压 ms 毫秒""" raise NotImplementedError class AdbDevice(Device): def __init__(self, serial=None): self.serial = serial def grab(self): ... def press(self, ms, x, y): ...

抽出来之后,接投屏协议、接模拟器、接云真机,都只是多写一个子类的事,识别和换算逻辑一行不动。这个改动看着不起眼,但它把“设备”和“策略”彻底分开了,后面加任何新玩法都不用担心影响底层。

6.2 加一层录制回放

调参最痛苦的地方是每次都要真机跑一遍,慢而且不可复现。我加了一个录制模式:每跳一次,把截图和当时的决策(系数、距离、按压时长、识别到的坐标)一起存下来,命名带序号。调试的时候直接读这些图离线跑识别,几秒钟就能过一遍,比真机快十倍。

回放的时候还能对比“当时识别到的坐标”和“现在识别到的坐标”,一眼就能看出是阈值调过头了还是算法退化了。这一步做完之后,我调识别的效率高了很多。

6.3 系数自动标定脚本

第 4 章给过一个自动标定的雏形,实际用的时候我把它做成了独立脚本,接受一个设备对象和初始系数,跑完之后输出建议系数和每一轮的误差曲线。

改造点现状改法收益
设备接入adb 写死抽 Device 接口换设备不改识别代码
调试方式真机跑录制回放调参快一个数量级
系数标定手工试扫描脚本 + 钳制换机五分钟内恢复
异常退出死循环连续失败计数挂了能停,不浪费电
状态判断无关键区域颜色检测结算页自动识别

表格里最后两项是我踩坑之后才补的,不是一开始就想到的。所以说,改造这件事不用一次性做完,跑到哪一步遇到问题,就补哪一块。

从那以后我每次做类似的自动化,都强制先把“录制回放”这条路走通,再去调识别参数。因为这个习惯,很多看起来玄学的偏差,最后都能落到一张具体的截图上,而不是靠一次次重跑碰运气。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表