Android ADB 截图与 AI 坐标定位源码分析¶
分析版本:Midscene 1.10.5,基线提交
2a8a5bb4分析日期:2026-07-16 范围:@midscene/android的截图和点击实现,以及@midscene/core从截图到 AI 定位坐标的处理链路
1. 结论摘要¶
Midscene Android 的核心工作方式是“截图驱动的视觉定位”,不是 Android UIAutomator/XML 驱动:
AndroidDevice.screenshotBase64()从真机取得屏幕位图。- Core 根据截图尺寸、设备逻辑尺寸和
screenshotShrinkFactor建立坐标换算关系。 - 规划模型读取当前截图并决定下一步动作,例如
Tap、Input或Swipe。 - 目标坐标可以在规划阶段直接返回,也可以由独立的定位模型二次看图后返回。
- 模型返回的 bbox/point 先被模型适配器统一成截图像素 bbox,再取 bbox 中心点。
- 中心点从“发送给模型的截图坐标”换算为“设备逻辑坐标”,最后换算成 ADB 使用的物理像素坐标。
- Android 最终通过
adb shell input ...执行点击或滑动。
默认截图实际上仍然基于 Android 的 screencap:第一优先级的 appium-adb.takeScreenshot() 在当前锁定的 appium-adb@12.12.1 中执行的是 adb exec-out screencap -p。Midscene 另外提供了设备文件加 adb pull 的回退路径,以及可选的 scrcpy H.264 视频流截图路径。
2. 端到端调用链¶
flowchart TD
A[AndroidAgent.aiAct] --> B[TaskExecutor.action]
B --> C[Agent.getUIContext]
C --> D[commonContextParser]
D --> E[AndroidDevice.screenshotBase64]
E --> F{scrcpy 已启用且可用?}
F -->|是| G[H.264 关键帧]
G --> H[ffmpeg 解码为 JPEG]
F -->|否或失败| I[appium-adb.takeScreenshot]
I --> J{截图有效?}
J -->|否| K[adb shell screencap 到设备文件]
K --> M{命令执行失败?}
M -->|否| L[adb pull 到本机]
M -->|是| N[yadb forceScreenshot]
N --> L
J -->|是| O[PNG Buffer]
H --> P[Base64 截图]
L --> P
O --> P
P --> Q[计算截图尺寸、方向和缩放比]
Q --> R[必要时缩图并转 JPEG]
R --> S[规划模型选择动作]
S --> T{规划结果已带坐标?}
T -->|是| U[模型适配器统一为 PixelBbox]
T -->|否| V[AiLocateElement 二次视觉定位]
V --> U
U --> W[PixelBbox 转 Rect 并取中心点]
W --> X[截图坐标除以 shrunkShotToLogicalRatio]
X --> Y[逻辑坐标换算为物理像素]
Y --> Z[adb shell input swipe/tap]
主要入口:
AndroidAgent.aiAct()负责启动规划与执行循环。commonContextParser()获取设备逻辑尺寸和截图,并建立坐标比例。AndroidDevice.screenshotBase64()实现 Android 截图。plan()将截图和任务发给规划模型。AiLocateElement()实现独立视觉定位。TaskBuilder将规划结果转换为定位任务和设备动作。
3. ADB 获取截图¶
3.1 默认路径:adb exec-out screencap -p¶
AndroidDevice.screenshotBase64() 默认先调用 adb.takeScreenshot():
appium-adb 的版本固定在 packages/android/package.json。在 12.12.1 中,takeScreenshot() 使用 exec-out,避免先在设备上写临时文件,返回值直接是 PNG Buffer。
Midscene 收到 Buffer 后调用 validateScreenshotBuffer(),校验内容包括:
- Buffer 不能为空。
- 文件头必须是 PNG 或 JPEG。
- 默认不得小于 1024 字节。
minScreenshotBufferSize可以调整大小阈值,设为0只关闭最小尺寸检查,不关闭空 Buffer 和图片格式检查。
这里没有像素内容检查。因此一张尺寸正常、格式合法但内容全黑的 PNG 仍会被视为有效截图。
3.2 回退路径:设备文件 + adb pull¶
以下情况会进入文件回退路径:
adb.takeScreenshot()抛出异常。- 返回 Buffer 未通过格式或大小校验。
- 配置了
displayId,此时会跳过takeScreenshot(),以便明确控制截图显示屏。 - 主路径连续三次返回“格式或大小无效”的 Buffer,之后会直接跳过主路径。
回退流程位于 device.ts:
需要注意,takeScreenshotFailCount 只在“主路径成功返回 Buffer,但 Buffer 校验失败”时递增。adb.takeScreenshot() 自身抛错不会增加计数,因此网络/ADB 命令异常不会触发三次后永久跳过主路径。
设备临时文件使用独立的 execFile(adb, ...) 异步删除,避免清理命令占用主 ADB 连接并阻塞后续操作。
3.3 yadb 强制截图¶
如果 adb shell screencap 命令本身执行失败,Midscene 调用 forceScreenshot():
它的设计目标是补充普通 screencap 无法工作的场景。不过当前逻辑只有在 screencap 命令抛错时才进入 yadb;如果 screencap 成功生成了一张合法但全黑的图片,不会触发 yadb 回退。
另外,yadb 以预编译二进制形式下载,具体截图实现不在本仓库源码中。不能仅根据调用注释假设它一定能绕过所有厂商限制或 FLAG_SECURE。
3.4 多显示屏¶
Android 设备配置支持:
displayId:为screencap和input指定显示屏。usePhysicalDisplayIdForScreenshot:截图时使用物理显示屏 ID。usePhysicalDisplayIdForDisplayLookup:读取尺寸、方向时使用物理显示屏 ID。
屏幕尺寸优先从 dumpsys display 中读取指定显示屏;无法匹配时回退到 wm size。相关实现见 getScreenSize()。
3.5 可选 scrcpy 路径¶
设置 scrcpyConfig.enabled: true 后,AndroidDevice.connect() 会初始化 scrcpy。初始化失败会把当前适配器标记为不可用,本次设备生命周期内不再重试,然后继续使用 ADB 截图。
scrcpy 路径的关键行为:
- 将 scrcpy server 推送到设备。
- 通过
@yume-chan/adb-scrcpy启动 H.264 视频流。 - 配置
audio: false、control: false、maxFps: 10。 - 缓存 SPS/PPS 和最近的 IDR 关键帧。
- 截图时最多等待 300ms 获取新关键帧;静态页面没有新帧时使用最近缓存帧。
- 将 H.264 关键帧交给 ffmpeg 解码为 JPEG。
- 30 秒无活动后默认断开流。
源码入口:
ScrcpyDeviceAdapterScrcpyScreenshotManager.ensureConnected()ScrcpyScreenshotManager.getScreenshotJpeg()
scrcpy 的优势是连续截图时不需要每次启动 screencap 和传输整张 PNG。代价是依赖 ffmpeg、scrcpy server 和 H.264 解码;每张最终被使用的截图仍需执行一次 ffmpeg 解码。
3.6 UI Observer 的连续帧¶
普通 aiAct() 使用单张代表截图。agent.startObserving() 用于捕获 toast、动画等短暂状态:
- scrcpy 可用时,观察期间只缓存原始 H.264 关键帧引用,结束后再解码真正需要的帧。
- scrcpy 不可用时,按间隔重复调用
screenshotBase64()。 - 帧数达到上限后进行抽稀,但保留内容变化点。
- 最终把时间序列截图一起交给 assert/extract 模型。
实现见 UIObserver 和 openScrcpyFrameSource()。
4. 截图进入 AI 前的处理¶
4.1 逻辑尺寸与截图尺寸¶
Android 同时存在两种尺寸:
- 物理尺寸:例如
1080 x 2400,来自wm size或dumpsys display。 - 逻辑尺寸:物理尺寸除以 density ratio,
density / 160,例如360 x 800。
AndroidDevice.size() 返回逻辑尺寸。Core 再读取实际截图宽高,并计算:
部分设备会报告错误方向。commonContextParser() 会比较逻辑尺寸和截图尺寸的横竖屏关系;不一致时交换逻辑宽高后再计算比例。
4.2 缩图与格式转换¶
screenshotShrinkFactor 控制发送给模型的截图尺寸:
缩图可以降低图像 token、网络传输和报告体积,但会降低小图标和细文字的定位精度。
未缩放时,如果截图是 PNG,Core 会以 90 质量转成 JPEG;已经是 JPEG 的 scrcpy 截图不会重复转换。相关实现见 commonContextParser()。
4.3 模型专用预处理¶
prepareModelImage() 按模型适配器执行额外预处理。目前典型场景是 Qwen 2.5:将图片右侧和底部补白到 28 的倍数。
系统同时记录:
preparedSize:真正发给模型的尺寸,包含 padding。contentSize:原始有效截图区域。
模型坐标先相对 preparedSize 解析,再裁剪到 contentSize,避免点击补白区域。
5. AI 如何得到目标坐标¶
5.1 Android 路径不依赖 UI XML¶
AndroidDevice.getElementsInfo() 返回空数组,getElementsNodeTree() 也返回空树。也就是说,当前 Android 主流程不会读取 UIAutomator XML,也不会用 resource-id/text 节点反查坐标。
模型看到的是截图和自然语言任务,坐标来自视觉模型对位图的理解。ADB 只负责:
- 获取截图。
- 获取屏幕尺寸、方向和 density。
- 执行模型规划后的点击、滑动、按键和输入命令。
5.2 规划阶段直接定位¶
当规划模型和默认定位模型使用同一个 default slot,且未开启 deepThink 时,includeLocateInPlanning=true。规划提示词会要求模型在动作参数中同时返回目标描述和坐标,例如:
规划结果经 normalizePlanningActionLocateFields() 调用模型适配器,把原始 bbox 或 point 转为统一的 locatedPixelBbox。
TaskBuilder 仍会创建一个 Locate 任务,但它优先使用规划阶段的 locatedPixelBbox,不再发起第二次 AI 定位请求。
5.3 独立定位模型二次定位¶
以下场景通常只在规划结果中保留 prompt,然后调用 AiLocateElement() 二次看图:
- 配置了独立 planning slot。
- 开启
deepThink。 - 需要
deepLocate精细定位。
独立定位提示词要求模型返回 JSON:
实际字段、顺序和坐标范围由模型适配器决定。提示词会明确告诉模型返回像素坐标、0-1000 归一化坐标,或者 0-1 归一化坐标。
5.4 模型坐标适配¶
不同视觉模型的原始坐标协议不同,Midscene 使用 LocateResultAdapter 统一处理:
| 模型 family | 原始形状 | 顺序 | 坐标范围 |
|---|---|---|---|
gpt-5 |
bbox | [xmin, ymin, xmax, ymax] |
截图实际像素 |
qwen2.5-vl |
bbox/point | xy | 截图实际像素,图片可能补白到 28 的倍数 |
qwen3-vl / qwen3 / qwen3.5 / qwen3.6 |
bbox | xy | 0-1000 |
doubao-vision / doubao-seed |
bbox/point | xy | 0-1000 |
gemini |
bbox | [ymin, xmin, ymax, xmax] |
0-1000 |
glm-v |
bbox | xy | 0-1000 |
kimi |
point | xy | 小于等于 1 时按 0-1,否则按截图像素 |
vlm-ui-tars* |
bbox/point | xy | 0-1000 |
适配链路位于 model-locate-result:
- 从
bbox、bbox_2d或point字段提取原始值。 - 将字符串、嵌套数组等结果解析为有限数字。
- 根据
xy/yx顺序重排。 - 根据
normalizedBy映射到图片像素。 - point 结果扩成一个小 bbox。
- 验证坐标顺序、有限值和图片边界。
- 将 padding 区域裁剪到真实内容边界。
归一化到像素的核心公式是:
使用 imageSize - 1 是因为内部 PixelBbox 使用包含端点的像素索引。例如一张宽 200 的图片,归一化值 1000 会映射到最右侧像素 199。
5.5 deepLocate¶
deepLocate 会先寻找较大的搜索区域,再裁剪并放大该区域做第二次精确定位:
- 先使用规划阶段粗 bbox、调用 section locator,或执行一次全图
AiLocateElement找到目标区域。 - 扩大搜索区域。
- 裁剪原始截图。
- 将裁剪图放大 2 倍交给定位模型。
- 将局部坐标除以放大倍数,再加裁剪区域 offset,映射回完整截图。
对应实现:
Service.resolveLocateSearchArea()buildSearchAreaConfig()mapSearchAreaPixelBboxToOriginalPixelBbox()
6. 从 AI bbox 到 ADB 点击坐标¶
6.1 bbox 转中心点¶
统一后的 PixelBbox 格式是包含端点的:
它先转换为:
再由 generateElementByRect() 计算中心:
偶数宽高时,中心点取中间四个像素中的左上像素。
6.2 截图坐标转逻辑坐标¶
动作执行前,parseActionParam() 将模型截图坐标除以 shrunkShotToLogicalRatio:
这一步同时消除:
- Android density 带来的物理像素/逻辑像素差异。
screenshotShrinkFactor带来的图片缩放差异。- scrcpy
maxSize带来的视频流缩放差异,因为比例基于实际截图尺寸重新计算。
6.3 逻辑坐标转 ADB 物理像素¶
AndroidDevice.adjustCoordinates() 计算逻辑尺寸与物理尺寸的 X/Y 比例:
X/Y 分开计算,因此也支持非等比的自定义逻辑尺寸。
6.4 完整数值示例¶
假设:
- 物理屏幕和原始截图:
1080 x 2400 - Android 逻辑尺寸:
360 x 800 screenshotShrinkFactor = 2- 发给模型的图片:
540 x 1200 - GPT 返回 bbox:
[90, 300, 210, 420]
计算过程:
最终点击点 (300, 720) 与原始 1080 x 2400 截图上的目标中心一致。
6.5 ADB 点击命令¶
Tap 动作从 locate 结果读取中心点,经 tapPoint() 换算后执行:
这里使用起点和终点相同、持续 150ms 的 swipe 来模拟点击,而不是直接使用 input tap。双击则使用两次 input tap,中间间隔 50ms。
相关源码:
7. 失败保护和已知边界¶
7.1 已有保护¶
- 截图校验空 Buffer、图片文件头和最小体积。
- 无效主截图连续出现三次后跳过主路径。
- scrcpy 初始化或截图失败后自动回退 ADB。
- 模型坐标必须是有限数字,且不得超出对应坐标系统范围。
- bbox 必须满足
right >= left、bottom >= top。 - 模型 padding 区域会裁剪到真实图片内容范围。
- 设备方向与截图横竖屏不一致时,会交换逻辑宽高后计算比例。
- 每次动作前把截图坐标统一转换为逻辑坐标,避免缩图后直接误点。
7.2 仍需关注的边界¶
-
合法黑屏不会触发回退 当前校验不做亮度、熵或像素方差检测。受
FLAG_SECURE、锁屏、视频 DRM 或厂商策略影响的合法黑图会直接发给模型。 -
yadb 只在命令异常时触发
screencap命令成功但图片内容错误时,不会自动重试 yadb。 -
失败时本机临时文件可能残留 回退截图只有成功走到方法末尾才异步删除本机临时文件;读取或校验中途抛错时可能残留文件。
-
屏幕信息默认缓存
cachedScreenSize、cachedOrientation和cachedAdjustScale默认缓存到destroy()。测试过程中旋转屏幕、切换外接显示屏或动态修改分辨率时,建议设置alwaysRefreshScreenInfo: true。 -
视觉定位不保证控件语义 Android 路径没有 resource-id、accessibility node 或 XML 的确定性辅助。相同文字、很小的图标、遮挡、动画和低清缩图都会降低定位稳定性。
-
bbox 中心不一定是最佳可点击区域 系统固定点击 bbox 中心。模型如果返回整个列表项、文字和按钮的组合区域,中心点可能落到不可点击位置。定位提示词因此强调返回紧凑的目标区域。
-
远程 ADB 的 scrcpy 连接范围
ScrcpyDeviceAdapter当前连接127.0.0.1:5037。远程设备需要先出现在本机 ADB server 中;它不会直接复用remoteAdbHost/remoteAdbPort创建 scrcpy transport。
8. 调试建议¶
8.1 判断使用了哪条截图路径¶
启用 Midscene debug 日志后关注:
典型日志:
Attempting scrcpy screenshot...:正在使用 scrcpy。Scrcpy screenshot failed, falling back...:scrcpy 失败,切换 ADB。Taking screenshot via adb.takeScreenshot:主 ADB 路径。Fallback: taking screenshot via shell screencap:设备文件回退路径。screencap failed, using forceScreenshot:进入 yadb。calculated dpr、shrunkShotToLogicalRatio:坐标比例。planResult:规划模型原始动作。resRect:模型坐标统一后的截图 Rect。executing action:动作执行前的最终参数。
8.2 坐标偏移排查顺序¶
- 保存并检查原始截图是否黑屏、旋转或被裁剪。
- 对比
shotSize与实际发送给模型的图片尺寸。 - 检查模型 family 是否配置正确,避免用错
xy/yx或归一化规则。 - 检查模型原始 bbox 是否紧贴目标元素。
- 检查
shrunkShotToLogicalRatio是否符合截图宽度 / 逻辑宽度。 - 检查
size()、物理尺寸和adjustCoordinates()的 X/Y scale。 - 多显示屏设备检查截图和 input 是否使用同一个
displayId。 - 旋转或切屏场景临时启用
alwaysRefreshScreenInfo验证是否为缓存问题。
9. 相关测试¶
仓库已经覆盖这条链路的关键行为:
packages/android/tests/unit-test/page.test.ts:逻辑坐标到物理坐标。packages/android/tests/unit-test/page.test.ts:主截图、回退、空 Buffer 和最小体积。packages/android/tests/unit-test/scrcpy-adapter.test.ts:scrcpy Base64 截图。packages/core/tests/unit-test/common-context-parser-orientation.test.ts:方向不一致和比例计算。packages/core/tests/unit-test/common-context-parser-shrink-factor.test.ts:缩图比例。packages/core/tests/unit-test/locate-result-adapter.test.ts:不同坐标协议、归一化、边界和异常值。packages/core/tests/unit-test/action-param-validation.test.ts:截图坐标到逻辑坐标。
10. 关键源码索引¶
| 模块 | 文件 | 职责 |
|---|---|---|
| Android Agent | packages/android/src/agent.ts |
Android Agent 封装与设备连接 |
| Android Device | packages/android/src/device.ts |
ADB、截图、尺寸、坐标换算和输入动作 |
| scrcpy Adapter | packages/android/src/scrcpy-device-adapter.ts |
scrcpy 生命周期和 ADB 回退边界 |
| scrcpy Manager | packages/android/src/scrcpy-manager.ts |
H.264 流、关键帧缓存和 ffmpeg 解码 |
| UI Context | packages/core/src/agent/utils.ts |
截图尺寸、方向、缩图和坐标比例 |
| AI Planning | packages/core/src/ai-model/llm-planning.ts |
任务规划和截图输入 |
| AI Locate | packages/core/src/ai-model/inspect.ts |
独立视觉定位和 deepLocate |
| Locate Adapter | packages/core/src/ai-model/shared/model-locate-result |
模型坐标协议统一 |
| Task Builder | packages/core/src/agent/task-builder.ts |
定位任务、坐标转换和动作执行 |
| Device Actions | packages/core/src/device/index.ts |
Tap/Input/Swipe 等标准动作 |