如果想把挂机自动化做到“换游戏也能用、UI 改了也不慌”,靠谱的路线基本就一条:端侧跑 YOLOv8 做实时视觉识别,再用 AirScript 这类执行层把识别结果转成点击。这套组合解决的核心问题是传统脚本太“脆”——坐标宏一更新就崩,模板匹配换个分辨率就瞎,而视觉模型只需要认住游戏里几个关键物件的样子,就能在不同界面、不同设备上稳定干活。整体跑下来一次循环在几十到一百毫秒级,几百帧截图、少量标注就能训出可用的检测效果。这篇文章把我从零搭这套轻量端侧 AI 游戏自动化方案的过程、踩过的坑、实测的数字都写出来,适合想自己搞游戏自动化、给老游戏做自定义优化,或者纯粹想玩端侧视觉的开发者参考。1. 整体设计:挂机脚本为什么卡在“通用”上1.1 三代技术路线,我为什么放弃内存流与固定坐标先给结论:挂机自动化想做到“通用”,核心矛盾是怎么在不依赖游戏内部数据的前提下,拿到足够可靠的“现在该干啥”的信号。梳理下来常见的有三代做法:内存读取 / 封包抓取:直接读游戏进程内存里的 NPC 坐标、血量,或者抓网络封包。优点是快、准、低延迟,缺点是每个游戏都是独立逆向工程,版本一更新大概率就废掉,而且这类行为在反作弊系统眼里的风险等级最高。固定坐标宏 / 模板截图:录制一组坐标循环执行,再叠加模板匹配做简单判断。优点是上手快,缺点是画面一错位就全乱,换设备、换分辨率都要重新标定,属于典型的能跑但不能维护。视觉模型 + 输入注入(本方案):用目标检测识别屏幕上的交互对象,比如怪物、血条、攻击键、弹窗关闭键,识别结果交由自动化执行层动态点击。它不碰内存、不抓包,只分析公开可见的屏幕内容,所以天然和游戏内部算法解耦。我在对比后选了第三种。原因很实际:屏幕上的物体外观通常不会因为版本更新彻底变化,你只需要在界面变了以后补几十张新截图重新训一版;而且检测模型输出的是对象位置和类别,天然能接决策逻辑,换新游戏只是换权重和类名配置,主循环一行都不用动。1.2 视觉感知 + 脚本执行的流水线这套方案的运转逻辑拆开看就五段:屏幕采集 → YOLO 推理 → 决策策略 → 输入注入 → 等待一个周期YOLOv8 做感知:它接收一帧截图,输出若干个检测框,每个框带类别和置信度,比如“怪物 0.87”“血条低了 0.91”“关闭弹窗 0.66”。这一步的模型权重可以针对特定游戏微调,也可以直接拿它检测通用界面元素。AirScript 做执行:它封装了屏幕截图、触摸、滑动、等待这些基础动作,把决策层算出的坐标变成真实的屏幕点击。这里有个关键取舍:不要把决策逻辑塞进检测模型里,也不要让执行脚本直接打裸坐标。检测模型只管“我看到什么”,决策脚本管“我该点哪里”,执行层只管“把点击发出去”。三层解耦之后,任何一层都能单独替换和测试。1.3 端侧推理换来的三个真实收益选择在端侧跑推理,而不是把截图发到云端识别,除了省服务器费用,还有三个直接影响挂机效果的点:延迟低。云端方案一个来回至少几百毫秒,端侧推理一次只有几十毫秒。挂机动作如果对时序敏感,比如 BOSS 攻击间隔只有两秒,低延迟直接决定脚本能不能稳住节奏。截图不出设备。游戏账号相关的画面内容留在本机,既不用传服务器,也少了隐私和账号关联风险。断网可用。手机流量不稳定、Wi-Fi 抖动都不影响循环执行,只要有电就能跑。如果你之前只用过固定坐标脚本,第一次跑通“识别到怪物 → 走过去 → 点攻击”这条链路时,你会明显感觉到它不像脚本,更像一个小机器人在代玩。2. 环境与选型:YOLOv8n、ONNX、AirScript 怎么搭起来2.1 模型、输入分辨率与运行时的选型表先说结论,我默认的配置是:YOLOv8n + 320 或 480 输入 + ONNX Runtime。下面是选型时对比过的参数,数据来自我手头的设备实测:项选择理由与实测模型体量YOLOv8n权重约 6MB,320 输入时 CPU 推理约 20~40ms,足够挂机用进阶备选YOLOv8s权重约 22MB,精度略高但端侧帧率降一半,非必要不选输入分辨率320 / 480小按钮少则 320,有大量 20~30px 小图标则上 480;640 只在关键失败区段用推理引擎ONNX RuntimeCPU 友好、部署简单;Android 端可换轻量神经网络推理库,半精度推理更明显还有一个容易被忽视的选型:模板匹配。对固定位置的按钮做判断确实快,但游戏界面一旦加了随机偏移、皮肤特效,模板就失效。YOLO 这类检测模型学的是目标的“语义外观”,抗偏移能力完全不同。这就是为什么哪怕识别目标只有三四个,我也坚持用检测模型而不是模板。2.2 从 PyTorch 权重导出端侧 ONNX训练好的模型是 PyTorch 格式,部署前先导出 ONNX。这一步的作用是把模型的网络结构冻结成计算图,去掉训练相关的动态逻辑。from ultralytics import YOLO model = YOLO("yolov8n.pt") # 可以是自己微调后的权重 model.export( format="onnx", opset=12, imgsz=320, simplify=True, dynamic=False, )导出成功的标志是目录下出现 game.onnx 文件。重点检查两个东西:一是 opset 不能选太高,某些端侧运行时对高版本算子支持差;二是 dynamic 参数保持 False,输入尺寸固定,推理时不需要动态形状,跑得更稳。如果目标是 Android 端更极致的速度,可以再走一步把 ONNX 转成轻量网络格式,用半精度跑。这步不是必需,我最初在手机上用通用运行时就完全够跑,后面想降功耗才做的优化。2.3 AirScript 执行层接入与 ADB 连接姿势执行层我用 AirScript 做了一层封装,底层是 ADB 通道:截图走 screencap,点击走 input tap,滑动走 input swipe。最终暴露给业务层的接口很简单:from airscript import AirScript agent = AirScript("Android:///?serial=xxx") frame = agent.screenshot() # 返回 BGR numpy 数组 agent.touch(540, 960) # 绝对像素坐标点击 agent.swipe((540, 1800), (540, 600)) # 滑动 agent.wait(0.2) # 等待一个周期连接设备有几个实测过的坑。第一,优先用 USB 连接,Wi-Fi ADB 在高强度截屏时容易超时丢帧。第二,不要把连接对象在每次循环里重复创建,长连接比反复握手稳定得多,也更省电。第三,如果脚本跑在电脑上、控制的是手机,可以先用一条adb devices确认设备序列号,再把它填进连接参数,避免多设备时连错机。这里的核心思想是:执行层只暴露原子操作,不管业务逻辑。你完全可以把 AirScript 理解成一个“能看屏幕、能动手”的机器人底座,上面怎么指挥是决策层的事。3. 数据与训练:让 YOLO 认得出游戏里的物件3.1 采集截图与标注规范数据集准备是整个方案里最累的一环,但也是最容易偷懒出效果的一环,因为游戏界面比真实世界规整太多。核心流程就两步:截图、标注。截图我建议用脚本自动抓,不要手截图。让游戏自然跑半小时,脚本每 2 秒存一帧,覆盖白天、夜间、不同地图和弹窗状态:for i in range(300): frame = agent.screenshot() cv2.imwrite(f"data/raw/{i:04d}.jpg", frame) agent.wait(2)标注时用任意支持 YOLO 格式导出的开源标注器,画矩形框就行。类名不要贪多,控制在 10 个以内,我常用的类表是这样的:类别触发场景优先级dialog_close任何弹窗的关闭按钮最高confirm_btn确认领取、确认购买高hp_low角色血条低于阈值高attack_btn攻击/技能按钮中monster可攻击怪物中item_pick掉落物拾取点低map_exit地图出口/退出按钮低标注的准则就一条:宁可少标,不要错标。把“怪物”类标进了“关闭弹窗”类,会让决策层在战斗时频繁点错按钮,比漏检更致命。3.2 小样本训练参数与收敛判断小数据集训练的核心思路是迁移学习:从官方预训练权重起步,冻结大部分主干参数,只微调检测头和部分特征层。先写数据配置:path: ./data/game train: images/train val: images/val names: 0: dialog_close 1: confirm_btn 2: hp_low 3: attack_btn 4: monster 5: item_pick 6: map_exit训练命令:yolo detect train data=game.yaml model=yolov8n.pt imgsz=640 epochs=80 batch=16我实测下来 300~500 张截图就能让 mAP50 到 0.6 以上,这对自动化来说够用。判断模型行不行不要只看 mAP,要拿真实截图去跑一遍推理,重点看两类错误:漏检,怪物就在屏幕上但没框出来;误检,背景特效被当成怪物。漏检会让挂机卡住,误检会乱点,两者都要控制住。两个提升稳定性的小技巧:一是收集一批“没有任何目标”的干净截图,标成纯背景帧让模型学习负样本;二是性能不确定的设备,训练时把画面增强调低,过度随机的亮度扰动反而会让模型对真实界面的响应退化。4. 核心链路实现:感知、决策、执行的代码骨架4.1 推理与 NMS 封装YOLOv8 导出后的 ONNX 输出是一个[1, 4+类别数, 锚点数]的张量,需要自己把它拆成检测框并做非极大值抑制(NMS)。我习惯封装一个 GameDetector 类:import cv2 import numpy as np import onnxruntime as ort class GameDetector: def __init__(self, onnx_path, class_names, input_size=320): self.sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.class_names = class_names self.input_size = input_size self.nc = len(class_names) def preprocess(self, frame): h, w = frame.shape[:2] scale = min(self.input_size / h, self.input_size / w) nh, nw = int(h * scale), int(w * scale) rsz = cv2.resize(frame, (nw, nh)) pad_x = (self.input_size - nw) // 2 pad_y = (self.input_size - nh) // 2 canvas = cv2.copyMakeBorder(rsz, pad_y, pad_y, pad_x, pad_x, cv2.BORDER_CONSTANT, value=(114, 114, 114)) blob = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)[None].astype(np.float32) / 255.0 return blob, scale, pad_x, pad_y def detect(self, frame, conf=0.6): blob, scale, pad_x, pad_y = self.preprocess(frame) out = self.sess.run(None, {self.sess.get_inputs()[0].name: blob.transpose(0, 3, 1, 2)})[0] # 自行实现 NMS 后返回 [(cls_id, conf, x1, y1, x2, y2), ...]注意预处理里 BGR 转 RGB 的顺序不要搞反,很多新手第一步就挂在颜色通道上。NMS 实现网上有现成的,我用的思路是先按置信度排序,逐框抑制交并比大于 0.45 的重复框。4.2 坐标映射的数学与代码模型输出的坐标是“模型输入坐标系”里的,必须还原回屏幕原始坐标。还原公式其实只要记住一句:模型坐标 = 屏幕坐标 × 缩放比 + 填充边距,所以屏幕坐标 = (模型坐标 - pad) / scale。def to_screen(dets, scale, pad_x, pad_y, sw, sh): results = [] for cls_id, conf, x1, y1, x2, y2 in dets: x1_s = np.clip((x1 - pad_x) / scale, 0, sw) y1_s = np.clip((y1 - pad_y) / scale, 0, sh) x2_s = np.clip((x2 - pad_x) / scale, 0, sw) y2_s = np.clip((y2 - pad_y) / scale, 0, sh) results.append((cls_id, conf, (x1_s, y1_s, x2_s, y2_s))) return results点击点我取检测框中心,然后往右下方偏 10~20 像素作为安全偏移,避免点到按钮边缘的圆角镂空区域。另外,Android 触摸坐标和截图像素坐标通常一致,但如果你的手机开了系统显示缩放,就要做一次标定:检测屏幕上某个固定元素,算出它实际点击位置和识别框中心的差值,存成全局偏移量。这个坑我在换设备时踩过一次。4.3 用状态机管住点击节奏挂机不是无脑连点,决策逻辑最好写成状态机。我维护了 IDLE、BATTLE、LOOT、HEAL 四个状态,状态切换完全由检测结果驱动:state = "IDLE" while running: dets = detector.detect(screen, conf=0.6) centers = to_screen(...) # 拿到屏幕坐标 # 弹窗永远最高优先级 if "dialog_close" in dets: agent.touch(centers["dialog_close"][0]) state = "IDLE" continue if state == "HEAL": if "hp_low" in dets: agent.touch(centers["hp_low"][0]) else: state = "IDLE" continue if state == "IDLE": if "monster" in dets: target = nearest_to_screen_center(centers["monster"]) agent.touch(target) state = "BATTLE" elif "item_pick" in dets: agent.touch(centers["item_pick"][0]) state = "LOOT" continue if state == "BATTLE": if "attack_btn" in dets and cooldown_ok(): agent.touch(centers["attack_btn"][0]) if "monster" not in dets: state = "IDLE" ...弹窗优先级最高这条必须写死在最前面。经验之谈:游戏挂机翻车的头号原因不是打不过怪,而是一堆确认弹窗顶在屏幕上,脚本还在那循环点攻击,最后进错界面。4.4 卡死检测与日志回放自动化脚本跑久了总会遇到意外状态,比如角色被地形卡住、游戏断线重连弹窗、网络波动导致画面冻结。我的安全网有三层:无变化检测:连续 10 个循环检测结果完全为空或者置信度全部低于阈值,就强制点一次屏幕右下角并截图留档。卡死熔断:连续 3 次“点击了但没有状态变化”,停止点击,等 5 秒再试,仍失败就退出任务并发送通知。日志回放:每 20 帧存一张叠加了检测框和状态的截图到 logs 目录。遇到翻车翻得不明不白时,翻这些截图定位比看代码快十倍。这套日志机制看起来土,但排查效率直接拉满。后来我所有脚本改动都会先跑一小时,然后盯日志图确认每个状态切换都符合预期再放手。5. 实测数据与调优记录5.1 两端设备的循环耗时表我的运行环境是:一台五年前的中端 Android 手机、一台普通桌面电脑,两者通过同一套代码控制。实测单循环各环节耗时如下:环节Android + USB ADBPC 桌面屏幕采集20~40ms5~10msYOLOv8n 推理(320)20~35ms5~12msNMS 与决策1~3ms1~3ms触摸/鼠标注入10~30ms1~5ms全过程合计50~110ms12~30ms也就是说,手机端控制循环频率在 9~20Hz 之间,PC 端能到 30Hz 以上。挂机任务其实不需要这个频率,我普遍在循环末尾加 sleep(0.1~0.2),让脚本稳定在 5~8Hz 就够用,还能明显减少发热和耗电。5.2 四个最容易翻车的细节翻车点一:小图标漏检。攻击技能图标往往只有 30 像素不到,320 输入分辨率下特征被压缩得很小。我的办法是对画面下方的“操作区域”单独裁剪放大再做一次推理,成本只在需要时发生,平时主循环保持 320。翻车点二:弹窗叠加。结算弹窗和战斗界面同时存在时,两个目标框高度重叠,只看置信度会选择错误的框。解法是给弹窗类一个规则权重,只要存在弹窗目标,同区域的其他框一律让路。翻车点三:多设备分辨率不一致。手机换了一台,同一条脚本坐标全部偏移。我不想每次重新标定,就在启动时检测一次屏幕上的固定标志物,比如返回键,自动算缩放比例和偏移量,之后所有点击坐标都基于这个标定结果换算。翻车点四:画面闪烁导致误检。游戏里的技能特效偶尔会闪出类似怪物轮廓的图形,单帧检测很容易误判。我给每个候选目标做了三帧平滑:连续三帧都在同一位置出现才执行点击,闪烁帧自然被滤掉。5.3 让检测更稳的调优思路当 mAP 够用但实际挂机效果一般时,优先怀疑的不是模型,而是输入质量。以下调优按见效排序:抓帧间隔错开广告轮播和过场动画的节奏,避免训练集里混入大量无效画面。对速度不敏感的操作,比如拾取物品,把置信度阈值降到 0.4;对安全性敏感的操作,比如弹窗关闭,提到 0.7。对长期固定位置的对象,可以在检测基础上叠加一次 ROI 模板校验,双重确认再点击,误点率能降一个数量级。换成量化模型,预测误差一般不超过像素级精度损失,端侧延迟却可以省 30% 左右。这些都是工程上的钱,花在数据清洗和阈值调试上,比换大模型值得多。6. 常见问题与排查技巧实录6.1 故障速查表把我在项目里遇到过的典型问题整理成一张表:症状可能原因解法检测输出全空输入通道顺序错误、模型输入尺寸不对先打印输入 blob 的 shape 和均值,再对照预处理代码检查点击总是偏一段距离letterbox 的 pad 没有还原重点检查 pad_x/scale 是否在 to_screen 里正确使用点击没反应设备息屏、ADB 通道断开、游戏覆盖层拦截点亮屏幕、加长连接保活、改用系统级输入事件手机发烫循环没有休眠、推理频率过高加 sleep,降到 5~8Hz,必要时用 320 输入换设备后全面失效显示缩放或分辨率不同做一次启动标定,用固定元素计算缩放和偏移频繁误点弹窗弹窗类别没进训练集补标弹窗类,并写死弹窗优先级6.2 一次“检测到了但点不上”的完整排查分享一次典型的翻车经历。某天脚本日志显示“monster 置信度 0.87,已点击”,但实际游戏里怪物血条没变化。我当时按顺序做了四步:第一步,保存检测叠加图,确认模型框的位置恰好覆盖怪物身体,识别没问题。第二步,用 adb 命令手动点击同一个坐标,结果按钮有反馈但战斗没启动。第三步,怀疑命令发送速度太慢,把触摸方式从普通点击改成先按下再抬起,间隔 50ms,仍然无效。第四步,回头检查屏幕分辨率,发现这台机器的截图像素是 720×1280,但触摸事件使用的却是另一档逻辑分辨率,坐标差了一倍。加上启动标定换算后,问题彻底消失。这次经历给我的教训是:自动化链路里每一个环节都可能独立出错,排查时务必把“模型层”“坐标层”“执行层”分开验证,不要一上来就怀疑模型不准。日志回放截图是帮助定位的第一利器,先看“模型看到了什么”,再看“脚本点到了哪里”,通常两三分钟就能锁定真凶。项目做到后面,我最大的感受是这套东西的价值不在于“挂机”,而在于把“能看会点的机器人底座”做扎实了。它不读内存、不抓包,所有决策都建立在公开可见的屏幕画面上,属于纯视觉层面的自动化,思路天然适合做游戏兼容性测试、无障碍操作辅助,以及给老单机游戏写自定义玩法优化。使用范围也要多说一句:多人竞技类游戏通常明确禁止自动化脚本,跑之前一定看清楚游戏条款,别拿自己账号去赌。最后再分享一个小技巧:给脚本加一个“手动模式”。当检测到异常或你不确定当前界面是什么情况时,暂停自动化、把所有按键交给人工接管,处理完再切回自动。这个开关成本很低,但能在脚本抽风时保住账号、保住进度,比任何花哨的模型优化都实在。
如果想把挂机自动化做到“换游戏也能用、UI 改了也不慌”,靠谱的路线基本就一条:端侧跑 YOLOv8 做实时视觉识别,再用 AirScript 这类执行层把识别结果转成点击。这套组合解决的核心问题是传统脚本太“脆”——坐标宏一更新就崩,模板匹配换个分辨率就瞎,而视觉模型只需要认住游戏里几个关键物件的样子,就能在不同界面、不同设备上稳定干活。整体跑下来一次循环在几十到一百毫秒级,几百帧截图、少量标注就能训出可用的检测效果。这篇文章把我从零搭这套轻量端侧 AI 游戏自动化方案的过程、踩过的坑、实测的数字都写出来,适合想自己搞游戏自动化、给老游戏做自定义优化,或者纯粹想玩端侧视觉的开发者参考。1. 整体设计:挂机脚本为什么卡在“通用”上1.1 三代技术路线,我为什么放弃内存流与固定坐标先给结论:挂机自动化想做到“通用”,核心矛盾是怎么在不依赖游戏内部数据的前提下,拿到足够可靠的“现在该干啥”的信号。梳理下来常见的有三代做法:内存读取 / 封包抓取:直接读游戏进程内存里的 NPC 坐标、血量,或者抓网络封包。优点是快、准、低延迟,缺点是每个游戏都是独立逆向工程,版本一更新大概率就废掉,而且这类行为在反作弊系统眼里的风险等级最高。固定坐标宏 / 模板截图:录制一组坐标循环执行,再叠加模板匹配做简单判断。优点是上手快,缺点是画面一错位就全乱,换设备、换分辨率都要重新标定,属于典型的能跑但不能维护。视觉模型 + 输入注入(本方案):用目标检测识别屏幕上的交互对象,比如怪物、血条、攻击键、弹窗关闭键,识别结果交由自动化执行层动态点击。它不碰内存、不抓包,只分析公开可见的屏幕内容,所以天然和游戏内部算法解耦。我在对比后选了第三种。原因很实际:屏幕上的物体外观通常不会因为版本更新彻底变化,你只需要在界面变了以后补几十张新截图重新训一版;而且检测模型输出的是对象位置和类别,天然能接决策逻辑,换新游戏只是换权重和类名配置,主循环一行都不用动。1.2 视觉感知 + 脚本执行的流水线这套方案的运转逻辑拆开看就五段:屏幕采集 → YOLO 推理 → 决策策略 → 输入注入 → 等待一个周期YOLOv8 做感知:它接收一帧截图,输出若干个检测框,每个框带类别和置信度,比如“怪物 0.87”“血条低了 0.91”“关闭弹窗 0.66”。这一步的模型权重可以针对特定游戏微调,也可以直接拿它检测通用界面元素。AirScript 做执行:它封装了屏幕截图、触摸、滑动、等待这些基础动作,把决策层算出的坐标变成真实的屏幕点击。这里有个关键取舍:不要把决策逻辑塞进检测模型里,也不要让执行脚本直接打裸坐标。检测模型只管“我看到什么”,决策脚本管“我该点哪里”,执行层只管“把点击发出去”。三层解耦之后,任何一层都能单独替换和测试。1.3 端侧推理换来的三个真实收益选择在端侧跑推理,而不是把截图发到云端识别,除了省服务器费用,还有三个直接影响挂机效果的点:延迟低。云端方案一个来回至少几百毫秒,端侧推理一次只有几十毫秒。挂机动作如果对时序敏感,比如 BOSS 攻击间隔只有两秒,低延迟直接决定脚本能不能稳住节奏。截图不出设备。游戏账号相关的画面内容留在本机,既不用传服务器,也少了隐私和账号关联风险。断网可用。手机流量不稳定、Wi-Fi 抖动都不影响循环执行,只要有电就能跑。如果你之前只用过固定坐标脚本,第一次跑通“识别到怪物 → 走过去 → 点攻击”这条链路时,你会明显感觉到它不像脚本,更像一个小机器人在代玩。2. 环境与选型:YOLOv8n、ONNX、AirScript 怎么搭起来2.1 模型、输入分辨率与运行时的选型表先说结论,我默认的配置是:YOLOv8n + 320 或 480 输入 + ONNX Runtime。下面是选型时对比过的参数,数据来自我手头的设备实测:项选择理由与实测模型体量YOLOv8n权重约 6MB,320 输入时 CPU 推理约 20~40ms,足够挂机用进阶备选YOLOv8s权重约 22MB,精度略高但端侧帧率降一半,非必要不选输入分辨率320 / 480小按钮少则 320,有大量 20~30px 小图标则上 480;640 只在关键失败区段用推理引擎ONNX RuntimeCPU 友好、部署简单;Android 端可换轻量神经网络推理库,半精度推理更明显还有一个容易被忽视的选型:模板匹配。对固定位置的按钮做判断确实快,但游戏界面一旦加了随机偏移、皮肤特效,模板就失效。YOLO 这类检测模型学的是目标的“语义外观”,抗偏移能力完全不同。这就是为什么哪怕识别目标只有三四个,我也坚持用检测模型而不是模板。2.2 从 PyTorch 权重导出端侧 ONNX训练好的模型是 PyTorch 格式,部署前先导出 ONNX。这一步的作用是把模型的网络结构冻结成计算图,去掉训练相关的动态逻辑。from ultralytics import YOLO model = YOLO("yolov8n.pt") # 可以是自己微调后的权重 model.export( format="onnx", opset=12, imgsz=320, simplify=True, dynamic=False, )导出成功的标志是目录下出现 game.onnx 文件。重点检查两个东西:一是 opset 不能选太高,某些端侧运行时对高版本算子支持差;二是 dynamic 参数保持 False,输入尺寸固定,推理时不需要动态形状,跑得更稳。如果目标是 Android 端更极致的速度,可以再走一步把 ONNX 转成轻量网络格式,用半精度跑。这步不是必需,我最初在手机上用通用运行时就完全够跑,后面想降功耗才做的优化。2.3 AirScript 执行层接入与 ADB 连接姿势执行层我用 AirScript 做了一层封装,底层是 ADB 通道:截图走 screencap,点击走 input tap,滑动走 input swipe。最终暴露给业务层的接口很简单:from airscript import AirScript agent = AirScript("Android:///?serial=xxx") frame = agent.screenshot() # 返回 BGR numpy 数组 agent.touch(540, 960) # 绝对像素坐标点击 agent.swipe((540, 1800), (540, 600)) # 滑动 agent.wait(0.2) # 等待一个周期连接设备有几个实测过的坑。第一,优先用 USB 连接,Wi-Fi ADB 在高强度截屏时容易超时丢帧。第二,不要把连接对象在每次循环里重复创建,长连接比反复握手稳定得多,也更省电。第三,如果脚本跑在电脑上、控制的是手机,可以先用一条adb devices确认设备序列号,再把它填进连接参数,避免多设备时连错机。这里的核心思想是:执行层只暴露原子操作,不管业务逻辑。你完全可以把 AirScript 理解成一个“能看屏幕、能动手”的机器人底座,上面怎么指挥是决策层的事。3. 数据与训练:让 YOLO 认得出游戏里的物件3.1 采集截图与标注规范数据集准备是整个方案里最累的一环,但也是最容易偷懒出效果的一环,因为游戏界面比真实世界规整太多。核心流程就两步:截图、标注。截图我建议用脚本自动抓,不要手截图。让游戏自然跑半小时,脚本每 2 秒存一帧,覆盖白天、夜间、不同地图和弹窗状态:for i in range(300): frame = agent.screenshot() cv2.imwrite(f"data/raw/{i:04d}.jpg", frame) agent.wait(2)标注时用任意支持 YOLO 格式导出的开源标注器,画矩形框就行。类名不要贪多,控制在 10 个以内,我常用的类表是这样的:类别触发场景优先级dialog_close任何弹窗的关闭按钮最高confirm_btn确认领取、确认购买高hp_low角色血条低于阈值高attack_btn攻击/技能按钮中monster可攻击怪物中item_pick掉落物拾取点低map_exit地图出口/退出按钮低标注的准则就一条:宁可少标,不要错标。把“怪物”类标进了“关闭弹窗”类,会让决策层在战斗时频繁点错按钮,比漏检更致命。3.2 小样本训练参数与收敛判断小数据集训练的核心思路是迁移学习:从官方预训练权重起步,冻结大部分主干参数,只微调检测头和部分特征层。先写数据配置:path: ./data/game train: images/train val: images/val names: 0: dialog_close 1: confirm_btn 2: hp_low 3: attack_btn 4: monster 5: item_pick 6: map_exit训练命令:yolo detect train data=game.yaml model=yolov8n.pt imgsz=640 epochs=80 batch=16我实测下来 300~500 张截图就能让 mAP50 到 0.6 以上,这对自动化来说够用。判断模型行不行不要只看 mAP,要拿真实截图去跑一遍推理,重点看两类错误:漏检,怪物就在屏幕上但没框出来;误检,背景特效被当成怪物。漏检会让挂机卡住,误检会乱点,两者都要控制住。两个提升稳定性的小技巧:一是收集一批“没有任何目标”的干净截图,标成纯背景帧让模型学习负样本;二是性能不确定的设备,训练时把画面增强调低,过度随机的亮度扰动反而会让模型对真实界面的响应退化。4. 核心链路实现:感知、决策、执行的代码骨架4.1 推理与 NMS 封装YOLOv8 导出后的 ONNX 输出是一个[1, 4+类别数, 锚点数]的张量,需要自己把它拆成检测框并做非极大值抑制(NMS)。我习惯封装一个 GameDetector 类:import cv2 import numpy as np import onnxruntime as ort class GameDetector: def __init__(self, onnx_path, class_names, input_size=320): self.sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.class_names = class_names self.input_size = input_size self.nc = len(class_names) def preprocess(self, frame): h, w = frame.shape[:2] scale = min(self.input_size / h, self.input_size / w) nh, nw = int(h * scale), int(w * scale) rsz = cv2.resize(frame, (nw, nh)) pad_x = (self.input_size - nw) // 2 pad_y = (self.input_size - nh) // 2 canvas = cv2.copyMakeBorder(rsz, pad_y, pad_y, pad_x, pad_x, cv2.BORDER_CONSTANT, value=(114, 114, 114)) blob = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)[None].astype(np.float32) / 255.0 return blob, scale, pad_x, pad_y def detect(self, frame, conf=0.6): blob, scale, pad_x, pad_y = self.preprocess(frame) out = self.sess.run(None, {self.sess.get_inputs()[0].name: blob.transpose(0, 3, 1, 2)})[0] # 自行实现 NMS 后返回 [(cls_id, conf, x1, y1, x2, y2), ...]注意预处理里 BGR 转 RGB 的顺序不要搞反,很多新手第一步就挂在颜色通道上。NMS 实现网上有现成的,我用的思路是先按置信度排序,逐框抑制交并比大于 0.45 的重复框。4.2 坐标映射的数学与代码模型输出的坐标是“模型输入坐标系”里的,必须还原回屏幕原始坐标。还原公式其实只要记住一句:模型坐标 = 屏幕坐标 × 缩放比 + 填充边距,所以屏幕坐标 = (模型坐标 - pad) / scale。def to_screen(dets, scale, pad_x, pad_y, sw, sh): results = [] for cls_id, conf, x1, y1, x2, y2 in dets: x1_s = np.clip((x1 - pad_x) / scale, 0, sw) y1_s = np.clip((y1 - pad_y) / scale, 0, sh) x2_s = np.clip((x2 - pad_x) / scale, 0, sw) y2_s = np.clip((y2 - pad_y) / scale, 0, sh) results.append((cls_id, conf, (x1_s, y1_s, x2_s, y2_s))) return results点击点我取检测框中心,然后往右下方偏 10~20 像素作为安全偏移,避免点到按钮边缘的圆角镂空区域。另外,Android 触摸坐标和截图像素坐标通常一致,但如果你的手机开了系统显示缩放,就要做一次标定:检测屏幕上某个固定元素,算出它实际点击位置和识别框中心的差值,存成全局偏移量。这个坑我在换设备时踩过一次。4.3 用状态机管住点击节奏挂机不是无脑连点,决策逻辑最好写成状态机。我维护了 IDLE、BATTLE、LOOT、HEAL 四个状态,状态切换完全由检测结果驱动:state = "IDLE" while running: dets = detector.detect(screen, conf=0.6) centers = to_screen(...) # 拿到屏幕坐标 # 弹窗永远最高优先级 if "dialog_close" in dets: agent.touch(centers["dialog_close"][0]) state = "IDLE" continue if state == "HEAL": if "hp_low" in dets: agent.touch(centers["hp_low"][0]) else: state = "IDLE" continue if state == "IDLE": if "monster" in dets: target = nearest_to_screen_center(centers["monster"]) agent.touch(target) state = "BATTLE" elif "item_pick" in dets: agent.touch(centers["item_pick"][0]) state = "LOOT" continue if state == "BATTLE": if "attack_btn" in dets and cooldown_ok(): agent.touch(centers["attack_btn"][0]) if "monster" not in dets: state = "IDLE" ...弹窗优先级最高这条必须写死在最前面。经验之谈:游戏挂机翻车的头号原因不是打不过怪,而是一堆确认弹窗顶在屏幕上,脚本还在那循环点攻击,最后进错界面。4.4 卡死检测与日志回放自动化脚本跑久了总会遇到意外状态,比如角色被地形卡住、游戏断线重连弹窗、网络波动导致画面冻结。我的安全网有三层:无变化检测:连续 10 个循环检测结果完全为空或者置信度全部低于阈值,就强制点一次屏幕右下角并截图留档。卡死熔断:连续 3 次“点击了但没有状态变化”,停止点击,等 5 秒再试,仍失败就退出任务并发送通知。日志回放:每 20 帧存一张叠加了检测框和状态的截图到 logs 目录。遇到翻车翻得不明不白时,翻这些截图定位比看代码快十倍。这套日志机制看起来土,但排查效率直接拉满。后来我所有脚本改动都会先跑一小时,然后盯日志图确认每个状态切换都符合预期再放手。5. 实测数据与调优记录5.1 两端设备的循环耗时表我的运行环境是:一台五年前的中端 Android 手机、一台普通桌面电脑,两者通过同一套代码控制。实测单循环各环节耗时如下:环节Android + USB ADBPC 桌面屏幕采集20~40ms5~10msYOLOv8n 推理(320)20~35ms5~12msNMS 与决策1~3ms1~3ms触摸/鼠标注入10~30ms1~5ms全过程合计50~110ms12~30ms也就是说,手机端控制循环频率在 9~20Hz 之间,PC 端能到 30Hz 以上。挂机任务其实不需要这个频率,我普遍在循环末尾加 sleep(0.1~0.2),让脚本稳定在 5~8Hz 就够用,还能明显减少发热和耗电。5.2 四个最容易翻车的细节翻车点一:小图标漏检。攻击技能图标往往只有 30 像素不到,320 输入分辨率下特征被压缩得很小。我的办法是对画面下方的“操作区域”单独裁剪放大再做一次推理,成本只在需要时发生,平时主循环保持 320。翻车点二:弹窗叠加。结算弹窗和战斗界面同时存在时,两个目标框高度重叠,只看置信度会选择错误的框。解法是给弹窗类一个规则权重,只要存在弹窗目标,同区域的其他框一律让路。翻车点三:多设备分辨率不一致。手机换了一台,同一条脚本坐标全部偏移。我不想每次重新标定,就在启动时检测一次屏幕上的固定标志物,比如返回键,自动算缩放比例和偏移量,之后所有点击坐标都基于这个标定结果换算。翻车点四:画面闪烁导致误检。游戏里的技能特效偶尔会闪出类似怪物轮廓的图形,单帧检测很容易误判。我给每个候选目标做了三帧平滑:连续三帧都在同一位置出现才执行点击,闪烁帧自然被滤掉。5.3 让检测更稳的调优思路当 mAP 够用但实际挂机效果一般时,优先怀疑的不是模型,而是输入质量。以下调优按见效排序:抓帧间隔错开广告轮播和过场动画的节奏,避免训练集里混入大量无效画面。对速度不敏感的操作,比如拾取物品,把置信度阈值降到 0.4;对安全性敏感的操作,比如弹窗关闭,提到 0.7。对长期固定位置的对象,可以在检测基础上叠加一次 ROI 模板校验,双重确认再点击,误点率能降一个数量级。换成量化模型,预测误差一般不超过像素级精度损失,端侧延迟却可以省 30% 左右。这些都是工程上的钱,花在数据清洗和阈值调试上,比换大模型值得多。6. 常见问题与排查技巧实录6.1 故障速查表把我在项目里遇到过的典型问题整理成一张表:症状可能原因解法检测输出全空输入通道顺序错误、模型输入尺寸不对先打印输入 blob 的 shape 和均值,再对照预处理代码检查点击总是偏一段距离letterbox 的 pad 没有还原重点检查 pad_x/scale 是否在 to_screen 里正确使用点击没反应设备息屏、ADB 通道断开、游戏覆盖层拦截点亮屏幕、加长连接保活、改用系统级输入事件手机发烫循环没有休眠、推理频率过高加 sleep,降到 5~8Hz,必要时用 320 输入换设备后全面失效显示缩放或分辨率不同做一次启动标定,用固定元素计算缩放和偏移频繁误点弹窗弹窗类别没进训练集补标弹窗类,并写死弹窗优先级6.2 一次“检测到了但点不上”的完整排查分享一次典型的翻车经历。某天脚本日志显示“monster 置信度 0.87,已点击”,但实际游戏里怪物血条没变化。我当时按顺序做了四步:第一步,保存检测叠加图,确认模型框的位置恰好覆盖怪物身体,识别没问题。第二步,用 adb 命令手动点击同一个坐标,结果按钮有反馈但战斗没启动。第三步,怀疑命令发送速度太慢,把触摸方式从普通点击改成先按下再抬起,间隔 50ms,仍然无效。第四步,回头检查屏幕分辨率,发现这台机器的截图像素是 720×1280,但触摸事件使用的却是另一档逻辑分辨率,坐标差了一倍。加上启动标定换算后,问题彻底消失。这次经历给我的教训是:自动化链路里每一个环节都可能独立出错,排查时务必把“模型层”“坐标层”“执行层”分开验证,不要一上来就怀疑模型不准。日志回放截图是帮助定位的第一利器,先看“模型看到了什么”,再看“脚本点到了哪里”,通常两三分钟就能锁定真凶。项目做到后面,我最大的感受是这套东西的价值不在于“挂机”,而在于把“能看会点的机器人底座”做扎实了。它不读内存、不抓包,所有决策都建立在公开可见的屏幕画面上,属于纯视觉层面的自动化,思路天然适合做游戏兼容性测试、无障碍操作辅助,以及给老单机游戏写自定义玩法优化。使用范围也要多说一句:多人竞技类游戏通常明确禁止自动化脚本,跑之前一定看清楚游戏条款,别拿自己账号去赌。最后再分享一个小技巧:给脚本加一个“手动模式”。当检测到异常或你不确定当前界面是什么情况时,暂停自动化、把所有按键交给人工接管,处理完再切回自动。这个开关成本很低,但能在脚本抽风时保住账号、保住进度,比任何花哨的模型优化都实在。