跳转至

Android ADB 截图与 AI 坐标定位源码分析

分析版本:Midscene 1.10.5,基线提交 2a8a5bb4 分析日期:2026-07-16 范围:@midscene/android 的截图和点击实现,以及 @midscene/core 从截图到 AI 定位坐标的处理链路

1. 结论摘要

Midscene Android 的核心工作方式是“截图驱动的视觉定位”,不是 Android UIAutomator/XML 驱动:

  1. AndroidDevice.screenshotBase64() 从真机取得屏幕位图。
  2. Core 根据截图尺寸、设备逻辑尺寸和 screenshotShrinkFactor 建立坐标换算关系。
  3. 规划模型读取当前截图并决定下一步动作,例如 TapInputSwipe
  4. 目标坐标可以在规划阶段直接返回,也可以由独立的定位模型二次看图后返回。
  5. 模型返回的 bbox/point 先被模型适配器统一成截图像素 bbox,再取 bbox 中心点。
  6. 中心点从“发送给模型的截图坐标”换算为“设备逻辑坐标”,最后换算成 ADB 使用的物理像素坐标。
  7. 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]

主要入口:

3. ADB 获取截图

3.1 默认路径:adb exec-out screencap -p

AndroidDevice.screenshotBase64() 默认先调用 adb.takeScreenshot()

1
2
3
4
AndroidDevice.screenshotBase64()
  -> appium-adb ADB.takeScreenshot()
  -> adb exec-out screencap -p
  -> PNG Buffer

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

1
2
3
4
adb shell screencap -p [-d displayId] /data/local/tmp/ms_<id>.png
adb pull /data/local/tmp/ms_<id>.png <local-temp-file>
读取本机文件并校验
异步执行 adb shell rm 删除设备临时文件

需要注意,takeScreenshotFailCount 只在“主路径成功返回 Buffer,但 Buffer 校验失败”时递增。adb.takeScreenshot() 自身抛错不会增加计数,因此网络/ADB 命令异常不会触发三次后永久跳过主路径。

设备临时文件使用独立的 execFile(adb, ...) 异步删除,避免清理命令占用主 ADB 连接并阻塞后续操作。

3.3 yadb 强制截图

如果 adb shell screencap 命令本身执行失败,Midscene 调用 forceScreenshot()

1
2
3
4
5
app_process \
  -Djava.class.path=/data/local/tmp/yadb \
  /data/local/tmp \
  com.ysbing.yadb.Main \
  -screenshot <device-path>

它的设计目标是补充普通 screencap 无法工作的场景。不过当前逻辑只有在 screencap 命令抛错时才进入 yadb;如果 screencap 成功生成了一张合法但全黑的图片,不会触发 yadb 回退。

另外,yadb 以预编译二进制形式下载,具体截图实现不在本仓库源码中。不能仅根据调用注释假设它一定能绕过所有厂商限制或 FLAG_SECURE

3.4 多显示屏

Android 设备配置支持:

  • displayId:为 screencapinput 指定显示屏。
  • usePhysicalDisplayIdForScreenshot:截图时使用物理显示屏 ID。
  • usePhysicalDisplayIdForDisplayLookup:读取尺寸、方向时使用物理显示屏 ID。

屏幕尺寸优先从 dumpsys display 中读取指定显示屏;无法匹配时回退到 wm size。相关实现见 getScreenSize()

3.5 可选 scrcpy 路径

设置 scrcpyConfig.enabled: true 后,AndroidDevice.connect() 会初始化 scrcpy。初始化失败会把当前适配器标记为不可用,本次设备生命周期内不再重试,然后继续使用 ADB 截图。

scrcpy 路径的关键行为:

  1. 将 scrcpy server 推送到设备。
  2. 通过 @yume-chan/adb-scrcpy 启动 H.264 视频流。
  3. 配置 audio: falsecontrol: falsemaxFps: 10
  4. 缓存 SPS/PPS 和最近的 IDR 关键帧。
  5. 截图时最多等待 300ms 获取新关键帧;静态页面没有新帧时使用最近缓存帧。
  6. 将 H.264 关键帧交给 ffmpeg 解码为 JPEG。
  7. 30 秒无活动后默认断开流。

源码入口:

scrcpy 的优势是连续截图时不需要每次启动 screencap 和传输整张 PNG。代价是依赖 ffmpeg、scrcpy server 和 H.264 解码;每张最终被使用的截图仍需执行一次 ffmpeg 解码。

3.6 UI Observer 的连续帧

普通 aiAct() 使用单张代表截图。agent.startObserving() 用于捕获 toast、动画等短暂状态:

  • scrcpy 可用时,观察期间只缓存原始 H.264 关键帧引用,结束后再解码真正需要的帧。
  • scrcpy 不可用时,按间隔重复调用 screenshotBase64()
  • 帧数达到上限后进行抽稀,但保留内容变化点。
  • 最终把时间序列截图一起交给 assert/extract 模型。

实现见 UIObserveropenScrcpyFrameSource()

4. 截图进入 AI 前的处理

4.1 逻辑尺寸与截图尺寸

Android 同时存在两种尺寸:

  • 物理尺寸:例如 1080 x 2400,来自 wm sizedumpsys display
  • 逻辑尺寸:物理尺寸除以 density ratio,density / 160,例如 360 x 800

AndroidDevice.size() 返回逻辑尺寸。Core 再读取实际截图宽高,并计算:

dpr = screenshotWidth / logicalWidth
shrunkShotToLogicalRatio = dpr / screenshotShrinkFactor

部分设备会报告错误方向。commonContextParser() 会比较逻辑尺寸和截图尺寸的横竖屏关系;不一致时交换逻辑宽高后再计算比例。

4.2 缩图与格式转换

screenshotShrinkFactor 控制发送给模型的截图尺寸:

modelImageWidth  = physicalScreenshotWidth  / screenshotShrinkFactor
modelImageHeight = physicalScreenshotHeight / 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。规划提示词会要求模型在动作参数中同时返回目标描述和坐标,例如:

1
2
3
4
5
6
7
8
9
<action-type>Tap</action-type>
<action-param-json>
{
  "locate": {
    "prompt": "the Search button",
    "bbox": [120, 180, 260, 240]
  }
}
</action-param-json>

规划结果经 normalizePlanningActionLocateFields() 调用模型适配器,把原始 bboxpoint 转为统一的 locatedPixelBbox

TaskBuilder 仍会创建一个 Locate 任务,但它优先使用规划阶段的 locatedPixelBbox,不再发起第二次 AI 定位请求。

5.3 独立定位模型二次定位

以下场景通常只在规划结果中保留 prompt,然后调用 AiLocateElement() 二次看图:

  • 配置了独立 planning slot。
  • 开启 deepThink
  • 需要 deepLocate 精细定位。

独立定位提示词要求模型返回 JSON:

1
2
3
4
{
  "bbox": [xmin, ymin, xmax, ymax],
  "errors": []
}

实际字段、顺序和坐标范围由模型适配器决定。提示词会明确告诉模型返回像素坐标、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

  1. bboxbbox_2dpoint 字段提取原始值。
  2. 将字符串、嵌套数组等结果解析为有限数字。
  3. 根据 xy/yx 顺序重排。
  4. 根据 normalizedBy 映射到图片像素。
  5. point 结果扩成一个小 bbox。
  6. 验证坐标顺序、有限值和图片边界。
  7. 将 padding 区域裁剪到真实内容边界。

归一化到像素的核心公式是:

pixel = round(normalizedValue * (imageSize - 1) / normalizedBy)

使用 imageSize - 1 是因为内部 PixelBbox 使用包含端点的像素索引。例如一张宽 200 的图片,归一化值 1000 会映射到最右侧像素 199。

5.5 deepLocate

deepLocate 会先寻找较大的搜索区域,再裁剪并放大该区域做第二次精确定位:

  1. 先使用规划阶段粗 bbox、调用 section locator,或执行一次全图 AiLocateElement 找到目标区域。
  2. 扩大搜索区域。
  3. 裁剪原始截图。
  4. 将裁剪图放大 2 倍交给定位模型。
  5. 将局部坐标除以放大倍数,再加裁剪区域 offset,映射回完整截图。

对应实现:

6. 从 AI bbox 到 ADB 点击坐标

6.1 bbox 转中心点

统一后的 PixelBbox 格式是包含端点的:

[left, top, right, bottom]

它先转换为:

width  = right - left + 1
height = bottom - top + 1

再由 generateElementByRect() 计算中心:

centerX = left + floor((width - 1) / 2)
centerY = top  + floor((height - 1) / 2)

偶数宽高时,中心点取中间四个像素中的左上像素。

6.2 截图坐标转逻辑坐标

动作执行前,parseActionParam() 将模型截图坐标除以 shrunkShotToLogicalRatio

logicalX = round(modelScreenshotX / shrunkShotToLogicalRatio)
logicalY = round(modelScreenshotY / shrunkShotToLogicalRatio)

这一步同时消除:

  • Android density 带来的物理像素/逻辑像素差异。
  • screenshotShrinkFactor 带来的图片缩放差异。
  • scrcpy maxSize 带来的视频流缩放差异,因为比例基于实际截图尺寸重新计算。

6.3 逻辑坐标转 ADB 物理像素

AndroidDevice.adjustCoordinates() 计算逻辑尺寸与物理尺寸的 X/Y 比例:

1
2
3
4
5
scaleX = logicalWidth  / physicalWidth
scaleY = logicalHeight / physicalHeight

physicalX = round(logicalX / scaleX)
physicalY = round(logicalY / scaleY)

X/Y 分开计算,因此也支持非等比的自定义逻辑尺寸。

6.4 完整数值示例

假设:

  • 物理屏幕和原始截图:1080 x 2400
  • Android 逻辑尺寸:360 x 800
  • screenshotShrinkFactor = 2
  • 发给模型的图片:540 x 1200
  • GPT 返回 bbox:[90, 300, 210, 420]

计算过程:

dpr = 1080 / 360 = 3
shrunkShotToLogicalRatio = 3 / 2 = 1.5

bbox width  = 210 - 90 + 1 = 121
bbox height = 420 - 300 + 1 = 121
截图中心点 = (150, 360)

逻辑坐标 = (150 / 1.5, 360 / 1.5) = (100, 240)

scaleX = 360 / 1080 = 1/3
scaleY = 800 / 2400 = 1/3
ADB 物理坐标 = (100 / (1/3), 240 / (1/3)) = (300, 720)

最终点击点 (300, 720) 与原始 1080 x 2400 截图上的目标中心一致。

6.5 ADB 点击命令

Tap 动作从 locate 结果读取中心点,经 tapPoint() 换算后执行:

adb shell input [-d displayId] swipe x y x y 150

这里使用起点和终点相同、持续 150ms 的 swipe 来模拟点击,而不是直接使用 input tap。双击则使用两次 input tap,中间间隔 50ms。

相关源码:

7. 失败保护和已知边界

7.1 已有保护

  • 截图校验空 Buffer、图片文件头和最小体积。
  • 无效主截图连续出现三次后跳过主路径。
  • scrcpy 初始化或截图失败后自动回退 ADB。
  • 模型坐标必须是有限数字,且不得超出对应坐标系统范围。
  • bbox 必须满足 right >= leftbottom >= top
  • 模型 padding 区域会裁剪到真实图片内容范围。
  • 设备方向与截图横竖屏不一致时,会交换逻辑宽高后计算比例。
  • 每次动作前把截图坐标统一转换为逻辑坐标,避免缩图后直接误点。

7.2 仍需关注的边界

  1. 合法黑屏不会触发回退 当前校验不做亮度、熵或像素方差检测。受 FLAG_SECURE、锁屏、视频 DRM 或厂商策略影响的合法黑图会直接发给模型。

  2. yadb 只在命令异常时触发 screencap 命令成功但图片内容错误时,不会自动重试 yadb。

  3. 失败时本机临时文件可能残留 回退截图只有成功走到方法末尾才异步删除本机临时文件;读取或校验中途抛错时可能残留文件。

  4. 屏幕信息默认缓存 cachedScreenSizecachedOrientationcachedAdjustScale 默认缓存到 destroy()。测试过程中旋转屏幕、切换外接显示屏或动态修改分辨率时,建议设置 alwaysRefreshScreenInfo: true

  5. 视觉定位不保证控件语义 Android 路径没有 resource-id、accessibility node 或 XML 的确定性辅助。相同文字、很小的图标、遮挡、动画和低清缩图都会降低定位稳定性。

  6. bbox 中心不一定是最佳可点击区域 系统固定点击 bbox 中心。模型如果返回整个列表项、文字和按钮的组合区域,中心点可能落到不可点击位置。定位提示词因此强调返回紧凑的目标区域。

  7. 远程 ADB 的 scrcpy 连接范围 ScrcpyDeviceAdapter 当前连接 127.0.0.1:5037。远程设备需要先出现在本机 ADB server 中;它不会直接复用 remoteAdbHost/remoteAdbPort 创建 scrcpy transport。

8. 调试建议

8.1 判断使用了哪条截图路径

启用 Midscene debug 日志后关注:

1
2
3
4
5
6
android:device
android:scrcpy
android:scrcpy-adapter
commonContextParser
ai:inspect
agent:task-builder

典型日志:

  • 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 dprshrunkShotToLogicalRatio:坐标比例。
  • planResult:规划模型原始动作。
  • resRect:模型坐标统一后的截图 Rect。
  • executing action:动作执行前的最终参数。

8.2 坐标偏移排查顺序

  1. 保存并检查原始截图是否黑屏、旋转或被裁剪。
  2. 对比 shotSize 与实际发送给模型的图片尺寸。
  3. 检查模型 family 是否配置正确,避免用错 xy/yx 或归一化规则。
  4. 检查模型原始 bbox 是否紧贴目标元素。
  5. 检查 shrunkShotToLogicalRatio 是否符合 截图宽度 / 逻辑宽度
  6. 检查 size()、物理尺寸和 adjustCoordinates() 的 X/Y scale。
  7. 多显示屏设备检查截图和 input 是否使用同一个 displayId
  8. 旋转或切屏场景临时启用 alwaysRefreshScreenInfo 验证是否为缓存问题。

9. 相关测试

仓库已经覆盖这条链路的关键行为:

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 等标准动作