简介:这份基于 Python 的王者荣耀自动化脚本学习包,面向希望理解游戏辅助实现思路、练习图像识别与键鼠模拟的开发者,也可作为 Python 小型项目的拆解案例,帮助读者从配置、源码到运行依赖完整观察一个自动化脚本的组成。压缩包共 62 个文件,约 667KB,以 38 个 json 配置、6 个 py 源码和 8 个 pyc 编译文件为核心,另含 exe、dll 运行依赖、docx 使用说明及少量 license、md 等辅助文件,体量轻便,类型覆盖配置、代码与可执行环境。内容围绕屏幕截图识别、事件监听响应、游戏逻辑控制、多线程并发和异常处理等环节展开,目录中可见主入口、公共模块、调试截图、adb 工具及多分辨率适配配置等结构线索,便于按模块阅读与二次研究。已有 23957 人学习或下载,适合具备一定 Python 基础、想借鉴自动化项目组织方式、图像处理流程和排错思路的读者参考。
1. 从截屏到点击:一套 Python 手游自动化脚本到底解决了什么
很多人第一次拆 Python 手游自动化脚本包,会把注意力全放在识别算法上,实际跑起来最拖后腿的往往是截屏链路:ADB 一次screencap抖动两三百毫秒,模板匹配再准也会被拖成慢动作。这个资源包把采集、识别、决策、执行拆成四层,核心用 ADB 抓屏、OpenCV 做图像识别、Python 状态机调度点击,适合拿来做自动化测试练习、UI 遍历和 CV 入门,不适合直接扔进线上竞技环境。下面按能复现的顺序拆,先讲设备链路,再讲识别阈值,最后把状态机和避坑现场摊开。
2. 环境与设备链路:把 ADB 截屏延迟压到可接受范围
2.1 依赖选型:为什么用 ADB + OpenCV 而不是纯坐标脚本
纯坐标脚本的写法很诱人:记下几个屏幕坐标,input tap一调,流程就跑起来了。问题在于坐标脚本把“界面状态”硬编码进了代码里,分辨率一变、状态栏高度一变、弹窗位置一变,整套坐标全废。我见过最典型的翻车现场是换了一台同品牌但系统版本不同的设备,状态栏多了 12 像素,点击全部落在按钮边缘,脚本看起来在跑,实际一直在空点。坐标脚本不是不能用,它适合固定设备、固定分辨率、固定 UI 的回归测试,不适合多设备、多分辨率的手游自动化场景。
ADB 的价值在于把“设备控制”抽象成命令行:截屏、点击、滑动、按键、安装应用都能走同一条链路,模拟器和真机都能接。OpenCV 的价值在于把“界面判断”从坐标变成图像证据:按钮长什么样、弹窗有没有出现、结算页有没有加载完,都可以用模板匹配给出一个可量化的分数。Python 在这两层之间做胶水,subprocess调 ADB,numpy接图像,cv2做匹配,状态机负责调度。脚本包一般会拆成采集层、识别层、决策层、执行层四个目录,采集层只负责拿图,识别层只负责返回坐标和置信度,决策层判断下一步,执行层封装点击和滑动。这样拆的好处是排错时能快速定位:图不对查采集,分数低查识别,点不动查执行,循环卡死查决策。
不选深度学习的原因也简单:这类脚本的识别目标通常是固定按钮、固定图标、固定弹窗,模板匹配足够用,部署成本低,阈值可解释。换成一个目标检测模型,标注、训练、推理环境、模型体积都会把脚本包变重,新手拿到手第一关就卡在环境上。模板匹配的边界也要提前说清楚:它对旋转、缩放、半透明遮罩、动态特效比较敏感,所以后面要补多尺度匹配和 ROI 裁剪,不能指望一个阈值打天下。
2.2 从零跑通设备连接与截屏
先把设备链路跑通,再谈识别。模拟器启动后,adb devices必须看到device状态,offline和unauthorized是两种不同的问题:offline多半是 adb 服务或模拟器端口没接好,unauthorized是设备端没有授权调试。多设备环境要显式带-s指定序列号,否则 adb 会随机抢设备,脚本跑着跑着点到另一台机器上,这种问题查日志都很难看出来。下面这组命令先把设备信息、分辨率、密度和单帧截屏走一遍。
# 查看设备在线状态,多设备时记下序列号 adb devices -l # 确认系统版本、屏幕分辨率和密度,后面坐标缩放要用 adb shell getprop ro.build.version.release adb shell wm size adb shell wm density # 单帧截屏到本地,先验证链路是否通 adb exec-out screencap -p > ./assets/screen.png逻辑说明:adb devices -l输出里的device才是可用状态,offline先重启 adb 服务,unauthorized去设备端确认调试授权。wm size给的是物理分辨率,wm density影响 UI 缩放,这两个值要记进配置文件,后面模板目录按分辨率分组。exec-out screencap -p直接把 PNG 二进制写到标准输出,比adb shell screencap再adb pull少一次落盘,截屏延迟通常能低一截。
参数说明:-s emulator-5554用于多设备指定序列号;-p表示 PNG 格式,不加会得到 raw 帧,Python 侧还要自己拼图像头。截屏文件先落到./assets/screen.png只是为了验证,正式脚本里不落盘,直接在内存里解码,减少磁盘 IO。注意adb exec-out在部分旧版本 adb 上会把二进制里的换行符转掉,导致 PNG 解码失败,遇到imdecode返回None先升级 adb 或改用screencap管道写法。
Python 侧把截屏封装成一个函数,返回值是 OpenCV 的 BGR 图像。这里用subprocess.check_output拿二进制,np.frombuffer转uint8,再cv2.imdecode解成三通道图。timeout=3是保护,设备卡死时不要让脚本无限等。解码失败要立刻抛异常,不能返回空图继续往下走,否则后面模板匹配会报一个更难懂的 shape 错误。
import subprocess import cv2 import numpy as np ADB = ["adb", "-s", "emulator-5554"] # 多设备时显式指定,避免抢设备 def grab_screen() -> np.ndarray: # exec-out 直接拿 PNG 二进制,减少一次 pull 落盘 raw = subprocess.check_output( ADB + ["exec-out", "screencap", "-p"], timeout=3 ) img = cv2.imdecode(np.frombuffer(raw, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: raise RuntimeError("截屏解码失败,先检查 PNG 头和 adb 版本") return img逻辑说明:ADB列表把 adb 路径和序列号固定下来,后面所有命令复用,避免每个函数里重复拼字符串。check_output会在 adb 返回非零时抛CalledProcessError,比run更早暴露设备断开。imdecode用IMREAD_COLOR保留 BGR 三通道,模板匹配时屏幕图和模板图通道数一致,减少颜色空间转换的坑。
参数说明:timeout=3是单帧截屏上限,模拟器冷启动时第一帧可能超过这个值,正式跑之前先手动截一帧热身。emulator-5554只是示例序列号,真机换成adb devices里的实际值。np.frombuffer不复制内存,imdecode会生成新图像,所以raw生命周期结束后图像仍然有效。如果设备是 720×1280 而模板按 1080×1920 裁的,后面匹配分数会很低,不要先怀疑算法,先核对wm size。
2.3 截屏链路的耗时测量与优化
截屏链路不测速,后面所有“识别慢”的讨论都是拍脑袋。我一般会连续采 20 帧,统计中位数和 P95,中位数看常态,P95 看卡顿。adb exec-out和adb shell screencap + pull的差距在模拟器上尤其明显,后者多一次文件落盘和进程通信,P95 很容易翻倍。下面这段代码只做一件事:把截屏耗时打出来,不改业务逻辑。
import time from statistics import median def measure_grab(n: int = 20) -> None: costs = [] for _ in range(n): t0 = time.perf_counter() _ = grab_screen() costs.append((time.perf_counter() - t0) * 1000) costs.sort() p95 = costs[int(len(costs) * 0.95) - 1] print(f"median={median(costs):.1f}ms p95={p95:.1f}ms max={costs[-1]:.1f}ms")逻辑说明:perf_counter比time.time适合测短间隔,不受系统时间调整影响。采样 20 次足够看趋势,采样太少会被第一帧冷启动拉偏,采样太多会让调试变慢。p95取排序后 95% 位置,反映最慢的那一小撮帧,脚本卡死往往就是这些帧把状态机拖超时。
参数说明:n=20是采样次数,设备慢可以降到 10,设备快可以加到 50。输出单位是毫秒,median低于 120ms 算比较顺,P95 超过 300ms 就要看是不是模拟器渲染模式或 adb 版本问题。优化顺序建议先换exec-out,再降分辨率,再裁 ROI,最后才考虑异步截屏。降分辨率会改变模板尺寸,改完必须重截模板,不能拿旧模板硬匹配。
提示:
adb shell wm size 720x1280可以临时改分辨率,但部分模拟器改完要重启应用才生效;改之前把原始分辨率记下来,调试完用wm size reset还原。
3. 模板匹配与多分辨率适配:把识别准确率从“玄学”拉到可量化
3.1 模板匹配原理与阈值选择
模板匹配做的事情很朴素:拿一张小图在屏幕大图上滑动,逐个位置算相似度,取最高分的位置。OpenCV 里常用TM_CCOEFF_NORMED,它对亮度线性变化有一定容忍度,返回分数在 -1 到 1 之间,越接近 1 越像。阈值不是越高越好:设到 0.95,界面稍微加一点半透明遮罩就匹配不上;设到 0.6,背景里相似的图标会疯狂误报。我一般从 0.86 起步,按实际截图看分数分布再调。识别这块最容易变成玄学,原因是很多人只看一张图调阈值,没有把成功帧和失败帧的分数都打出来。
阈值选择要配合 ROI。全屏匹配 1080×1920 的图,模板 80×80,计算量不小,而且背景里相似元素多,误匹配概率高。把匹配区域裁到按钮可能出现的下半屏或弹窗区域,分数分布会干净很多。ROI 的坐标也要按分辨率缩放,不能写死。灰度匹配能进一步降计算量,但如果按钮靠颜色区分(比如红色确认、灰色取消),灰度会把颜色信息丢掉,这种场景保留彩色更稳。模板匹配对旋转很敏感,手游 UI 一般不会旋转按钮,但动画缩放、刘海屏遮罩、圆角裁切都会影响边缘,裁模板时尽量避开半透明边缘和动态特效。
模板匹配的另一个边界是“同图不同态”。同一个按钮有可点击、灰色不可点击、按下态三种外观,只做一个模板就会出现“明明看到了却匹配不上”。常见做法是每个状态一个模板,命名带状态后缀,识别层返回最高分模板的名字,决策层根据名字判断能不能点。这样比调低阈值硬匹配更可控,也不会把灰色按钮误判成可点击。
3.2 制作模板与目录规范
模板必须从实际设备截屏里裁,不能用官方宣传图或设计稿。宣传图经过压缩、缩放、加滤镜,像素分布和真实截屏差很多,拿它做模板,阈值调到最后会怀疑人生。裁模板时把按钮完整包进去,边缘留 2 到 4 像素背景,不要裁到按钮边缘刚好切齐,切齐后缩放一次就匹配不上。模板目录按分辨率分组,比如assets/templates/720x1280/和assets/templates/1080x1920/,文件名带业务含义和状态,比如btn_confirm_normal.png、btn_confirm_disabled.png。这样换设备时先选目录,再谈阈值,排错路径清楚。
下面这个函数做单模板匹配,返回中心坐标和分数。max_loc是匹配区域左上角,加半个模板宽高才是点击中心。分数低于阈值返回None,调用方必须处理None,不能直接拆包,否则脚本会在一次误识别后崩掉。
def match_template(screen, template_path, threshold=0.86): template = cv2.imread(template_path, cv2.IMREAD_COLOR) if template is None: raise FileNotFoundError(template_path) # 模板不能比屏幕大,否则 matchTemplate 直接报错 if template.shape[0] > screen.shape[0] or template.shape[1] > screen.shape[1]: return None res = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) if max_val < threshold: return None h, w = template.shape[:2] return max_loc[0] + w // 2, max_loc[1] + h // 2, float(max_val)逻辑说明:imread用彩色读入,和截屏保持一致;matchTemplate返回分数矩阵,minMaxLoc取最大值位置。max_loc是左上角,点击要的是中心,所以加w//2和h//2。返回None比返回(-1, -1, 0)更好排查,调用方用if pos is None就能分流到重试或恢复状态。
参数说明:threshold=0.86是起点,不是固定值;按钮带半透明遮罩可以降到 0.82,背景复杂可以升到 0.9。template_path建议用绝对路径或基于脚本根目录的路径,不要用相对当前工作目录的路径,否则换个启动目录就找不到模板。TM_CCOEFF_NORMED对亮度变化比TM_SQDIFF稳,但对纯色块不敏感,纯色按钮要加边缘特征或换TM_CCORR_NORMED对比。
3.3 多分辨率适配:缩放、ROI 与失败重试
多分辨率适配最省事的做法是“每个分辨率一套模板”,最通用的做法是“一套模板多尺度匹配”。前者准确率高但维护量大,后者代码复杂一点但换设备不用重截。实际脚本包里常见的是混合策略:主分辨率用原尺度模板,其他分辨率用多尺度匹配兜底,分数低于阈值再切到对应分辨率模板目录。多尺度不要开太密,0.8到1.2步长0.1已经够用,步长太密计算量翻倍,收益很小。
def match_multiscale(screen, template, scales=(0.8, 0.9, 1.0, 1.1, 1.2), threshold=0.86): best = None for s in scales: t = cv2.resize(template, None, fx=s, fy=s, interpolation=cv2.INTER_AREA) if t.shape[0] > screen.shape[0] or t.shape[1] > screen.shape[1]: continue res = cv2.matchTemplate(screen, t, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) if best is None or max_val > best[2]: h, w = t.shape[:2] best = (max_loc[0] + w // 2, max_loc[1] + h // 2, float(max_val)) return best if best and best[2] >= threshold else None逻辑说明:每个尺度都做一次匹配,记录最高分和对应中心点。INTER_AREA适合缩小,边缘更干净;放大可以用INTER_LINEAR,但手游模板一般只缩小不放大。best保存的是缩放后模板的中心,不是原模板中心,点击坐标要按当前尺度算,不能拿原模板宽高去加。
参数说明:scales覆盖 0.8 到 1.2,对应 20% 的尺寸波动,超过这个范围建议换模板目录。threshold在多尺度下可以比单尺度低 0.02 到 0.04,因为缩放会损失细节,但不要低到 0.8 以下,否则误报会明显增加。ROI 裁剪时把screen先切片再传入,返回坐标要加回 ROI 偏移,这一步很容易漏,漏了就会点偏。
| 参数 | 建议值 | 说明 |
|---|---|---|
threshold | 0.84~0.90 | 单尺度取高,多尺度取低 0.02~0.04 |
scales | 0.8~1.2,步长 0.1 | 步长太密计算量翻倍,收益很小 |
roi | 按业务裁下半屏或弹窗区 | 必须按分辨率缩放,不能写死 |
| 重试次数 | 2~3 次 | 每次重试前重新截屏,不要复用旧图 |
注意:多尺度匹配返回的分数只适合同一张模板内部比较,不同模板之间的分数不能直接横比;判断“哪个按钮更像”要用同阈值下的业务优先级,不要只看分数。
4. 状态机与点击执行:让脚本从“能点”变成“会等”
4.1 状态机设计:IDLE、SEARCH、CLICK、WAIT、RECOVER
脚本能点不等于会等。很多新手脚本的写法是“截屏、匹配、点击、再截屏”,中间没有等待条件,界面动画还没结束就点下一帧,点击全部落在过渡态上。状态机的作用是把“现在该干什么”和“下一步什么时候干”分开。常见状态有IDLE(初始化)、SEARCH(找目标)、CLICK(执行点击)、WAIT(等界面变化)、RECOVER(异常恢复)。每个状态有超时,超时进RECOVER,恢复失败回IDLE,不能无限循环。
from enum import Enum import time class State(Enum): IDLE = 1 SEARCH = 2 CLICK = 3 WAIT = 4 RECOVER = 5 def run_once(ctx, max_wait=3.0): state = State.SEARCH deadline = time.time() + max_wait while time.time() < deadline: if state == State.SEARCH: pos = ctx.find_target() state = State.CLICK if pos else State.WAIT elif state == State.CLICK: ctx.tap_target() state = State.WAIT deadline = time.time() + max_wait elif state == State.WAIT: if ctx.page_changed(): return True state = State.SEARCH time.sleep(0.15) return False逻辑说明:SEARCH只负责找,找到进CLICK,找不到进WAIT;CLICK执行后重置等待截止时间,给界面动画留时间;WAIT检测页面变化,变化了返回成功,没变化回SEARCH。sleep(0.15)是轮询间隔,太短会疯狂截屏,太长会错过短动画。
参数说明:max_wait=3.0是单次流程上限,弹窗多的页面可以放到 5 秒;sleep(0.15)在模拟器上够用,真机可以降到 0.1。page_changed不要用整图哈希,动画和时钟会让哈希一直变,用关键区域均值或模板分数变化更稳。状态机里不要写业务点击细节,点击封装在ctx.tap_target里,换设备只改执行层。
4.2 点击动作封装:tap、swipe、长按与随机化边界
点击封装要处理三件事:坐标缩放、动作时长、失败重试。坐标缩放按wm size的比例算,模板匹配返回的是当前截图坐标,如果截图被降分辨率,点击坐标也要按比例放大回物理坐标。input tap没有时长参数,长按要用input swipe x y x y duration,duration单位毫秒。滑动用input swipe x1 y1 x2 y2 duration,duration太短会被识别成快速划动,太长会变成慢拖。随机化只建议做 1 到 2 像素的抖动,用来模拟人手点击的微小误差,不要做大范围随机,否则点击会漂到按钮外面。
import random import subprocess def tap(x: int, y: int, jitter: int = 2, timeout: float = 2.0) -> None: # 抖动控制在 2px 内,只抵消触控采样误差,不改变目标区域 x += random.randint(-jitter, jitter) y += random.randint(-jitter, jitter) subprocess.run( ADB + ["shell", "input", "tap", str(x), str(y)], check=True, timeout=timeout ) def long_press(x: int, y: int, duration_ms: int = 600) -> None: subprocess.run( ADB + ["shell", "input", "swipe", str(x), str(y), str(x), str(y), str(duration_ms)], check=True, timeout=2.0 )逻辑说明:tap先加抖动再拼命令,check=True让 adb 返回非零时抛异常,交给上层重试。long_press用起点终点相同的swipe实现长按,duration_ms控制按住时间。两个函数都不做重试,重试放在装饰器或状态机里,避免一次点击失败后连续补点。
参数说明:jitter=2是像素级抖动,1080 屏上很小,720 屏上可以降到 1;timeout=2.0是 adb 命令超时,设备卡顿时不要设太小。duration_ms=600适合大多数长按,部分游戏需要 800 到 1000,按实际 UI 反应调。点击前最好确认设备在线,adb devices里没有设备时input tap会直接报错,不会静默失败。
4.3 日志、重试与异常恢复
脚本跑崩不可怕,可怕的是崩了不知道崩在哪。日志要记录时间、状态、截图文件名、匹配分数、点击坐标、异常堆栈。失败时自动存一张截图到logs/fail_时间戳.png,后面复现全靠它。重试装饰器只包幂等动作,比如截屏、匹配、查询状态;点击这种有副作用的操作要谨慎重试,点一次没反应先截屏看界面,不要盲目补点。下面这个装饰器做最基础的重试,times=3、delay=0.5,异常最后一次抛出。
import time import logging def retry(times: int = 3, delay: float = 0.5): def deco(fn): def wrapper(*args, **kwargs): last = None for i in range(times): try: return fn(*args, **kwargs) except Exception as e: last = e logging.warning("retry %s/%s: %s", i + 1, times, e) time.sleep(delay) raise last return wrapper return deco逻辑说明:每次异常记一条 warning,带上当前重试次数和异常信息;最后一次仍然失败就把异常抛出去,让状态机进RECOVER。delay给设备一点恢复时间,截屏失败可能是 adb 瞬时卡顿,连续快速重试只会更卡。
参数说明:times=3适合大多数读操作,点击类操作建议单独写重试策略,先检测界面再决定要不要补点。delay=0.5秒是经验值,模拟器可以短一点,真机可以长一点。日志级别用warning而不是error,因为重试成功不算错误,但要在日志里留下痕迹。失败截图命名带时间戳和状态名,比如fail_SEARCH_20250101_120000.png,后面按状态筛图很快。
提示:日志里不要只写“点击失败”,要写“在 SEARCH 状态匹配 btn_confirm_normal 分数 0.81 低于阈值 0.86,截图 xxx.png”,否则复盘时只能靠猜。
5. 避坑与排查:六个把脚本跑崩的真实现场
5.1 设备链路类
现象:adb devices里设备显示offline,脚本一启动就抛CalledProcessError。原因通常是 adb 服务端和模拟器端口没接好,或者上一次 adb 进程残留占着端口。解决顺序是先adb kill-server再adb start-server,然后重新插拔或重启模拟器;多设备环境还要确认没有两个 adb 版本抢服务。血泪经验是不要把adb路径写死到某个 IDE 自带目录,系统 PATH 里换一个版本,行为就可能不一样。
adb kill-server adb start-server adb devices -l逻辑说明:kill-server清掉旧服务,start-server按当前 adb 版本重新起,devices -l看序列号和状态。参数说明:-l会多打印设备型号和传输信息,排查多设备时有用;如果仍然offline,换 USB 口或模拟器 adb 端口,不要继续在脚本里加重试。
现象:截屏成功但图片全黑,或者只有状态栏。原因多半是模拟器 GPU 渲染模式导致screencap抓不到画面,真机上则可能是应用做了安全遮罩。模拟器先切软件渲染或兼容渲染模式试一次,确认不是脚本问题;如果只有某个页面黑屏,先手动截一张对比,排除应用层限制。解决方式不是改匹配阈值,而是换截屏方式或换渲染模式,黑图做模板匹配只会得到随机分数。
现象:exec-out截屏偶尔解码失败,imdecode返回None。原因是部分 adb 版本会把二进制里的\n转成\r\n,PNG 头被破坏。解决方式是升级 adb,或者改用adb shell screencap -p /sdcard/s.png再adb pull,代价是多一次落盘。脚本里要保留解码失败分支,失败时存原始二进制到logs/raw_fail.bin,拿十六进制看一眼文件头是不是89 50 4E 47,比盲猜快。
5.2 识别与点击类
现象:模板匹配分数一直在 0.6 到 0.7 之间,阈值降到 0.6 后误报满天飞。原因通常不是阈值,而是模板来源不对:模板从另一台分辨率截的,或者从宣传图裁的,像素分布和当前屏幕差太多。解决方式是回到当前设备截一张干净图,按实际按钮重新裁模板,模板边缘留 2 到 4 像素背景;裁完先用单张截图离线跑一遍,看分数能不能到 0.9 以上,再进在线流程。多分辨率场景先选对模板目录,再开多尺度,顺序反了会越调越乱。
现象:识别到了按钮,点击也执行了,但坐标偏了十几像素,偶尔点到隔壁按钮。原因一般是截图被降分辨率,点击坐标没有按wm size比例放大回物理坐标;或者 ROI 裁剪后返回的坐标没有加回 ROI 偏移。解决方式是统一坐标空间:识别层返回的坐标永远基于原图,ROI 偏移在识别层内部加回去,执行层只做物理坐标缩放。点击前打一条日志,把截图尺寸、ROI 偏移、最终点击坐标都打出来,偏一次就能对上。
def to_physical(x, y, shot_w, shot_h, real_w, real_h): # 截图被缩放时,点击坐标要按比例还原到物理分辨率 return int(x * real_w / shot_w), int(y * real_h / shot_h)逻辑说明:四个尺寸参数分别来自截图和wm size,比例算完取整。参数说明:如果截图和物理分辨率一致,比例是 1,函数不会改变坐标;如果只缩放了宽度,高度比例也要单独算,不要用一个比例套两个方向。点击偏移排查先看这条日志,不要先改模板。
5.3 状态与日志类
现象:脚本在某个页面来回点,日志里 SEARCH 和 WAIT 反复切换,永远不结束。原因是等待条件写成了“页面没变化就继续找”,但页面本身有倒计时或动画,永远在变;或者点击后没有重置超时,状态机在旧截止时间里打转。解决方式是给每个状态单独超时,WAIT用关键区域变化而不是整图哈希,连续 N 次没变化就进RECOVER,RECOVER里做返回键、关闭弹窗、重启应用三档恢复,恢复失败直接退出并保留截图。不要用while True包住整个流程,外层必须有最大轮次。
现象:脚本跑了一晚上,第二天只看到“点击失败”,没有截图、没有分数、没有状态。原因是日志只记了异常消息,没记上下文。解决方式是把日志格式固定成“时间 状态 动作 目标 分数 坐标 截图”,异常时自动存失败帧,正常帧每 50 帧存一张关键帧。日志文件按天切分,别让一个文件涨到几百兆。复盘时先看失败帧,再看分数分布,最后才改代码;没有失败帧的排查基本靠猜,后悔药都来不及。
import logging logging.basicConfig( filename="logs/run.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) logging.info("state=SEARCH target=btn_confirm score=0.91 pos=(540,1200)")逻辑说明:basicConfig固定格式和文件,业务里只传结构化字段。参数说明:level=INFO保留正常流程,调试期可以开DEBUG打分数矩阵尺寸;日志文件不要和截图混在一个目录,按logs/run/和logs/fail/分开,清理时不容易误删。
6. 进阶验证:用回放与指标表判断脚本是否真的稳定
脚本能不能用,不看它第一次跑通,看它连续跑 30 次后的失败分布。我一般会攒一个最小回放集:从成功和失败流程里各截 10 到 15 帧,存到assets/replay/,每帧配一个期望结果(目标模板名或None)。改阈值、改 ROI、换模板之前先跑回放,识别结果和期望对不上就不进真机。回放代码很短,核心是把离线帧当屏幕图喂给同一个匹配函数,保证线上线下走同一套逻辑。
def replay(cases, matcher): ok = 0 for path, expect in cases: img = cv2.imread(path, cv2.IMREAD_COLOR) pos = matcher(img) got = "hit" if pos else "miss" if got == expect: ok += 1 else: print("mismatch", path, "expect", expect, "got", got) print(f"replay accuracy={ok / len(cases):.2%}")逻辑说明:cases是(图片路径, 期望)列表,matcher复用线上匹配函数,避免回放和线上两套代码。参数说明:期望值用hit/miss比用具体坐标稳,坐标会随分辨率变,命中与否是业务底线。回放准确率低于 90% 先别上真机,先把失败帧挑出来看是模板问题还是 ROI 问题。
真机验证看四个指标:识别成功率、点击后状态变化率、单步耗时中位数、P95 耗时。下面这张表可以直接抄到自己的记录里,连续跑 30 轮填一次,比“感觉挺稳”靠谱。
| 指标 | 计算方式 | 观察阈值 |
|---|---|---|
| 识别成功率 | 命中帧 / 总帧 | 低于 90% 查模板和 ROI |
| 状态变化率 | 点击后页面变化次数 / 点击次数 | 低于 80% 查点击坐标和等待时间 |
| 单步耗时中位数 | 截屏 + 匹配 + 点击的中位数 | 超过 500ms 查截屏链路 |
| P95 耗时 | 95% 分位耗时 | 超过 1.5s 查设备卡顿和重试 |
我后来养成一个习惯:每次改阈值前先跑一遍回放,把失败帧截图存下来,再决定是改模板、改 ROI 还是改等待时间。这个习惯比加更多点击逻辑更省时间,也能避免“改一个阈值,崩三个页面”的连锁翻车。原始脚本包里的模板目录和日志模块先别急着替换,按第 2 章的链路自检跑通,再动识别参数;需要原始包的话按文末说明获取。希望帮到你。
本文还有配套的精品资源,点击获取