纯视觉定位:从截图到屏幕像素
30 秒导读: Midscene 最有辨识度的一支,是它不看网页的 DOM、不看无障碍树,只把一张截图丢给多模态模型,让模型直接说出「目标在图上哪个位置」,再由一段纯数学的流水线把这个坐标落到真实屏幕像素上。本章只讲这一段:模型输出 → 像素矩形 / 中心点。至于不同模型家族的坐标约定差异,归 03;像素点如何变成真实鼠标点击,归 04。
1. 这是什么(零基础也能懂)
一句话定义: 视觉定位(vision grounding)= 给模型一张屏幕截图和一句「点购物车按钮」,让它回答这个东西在图上的坐标,然后把这个坐标换算成真实屏幕上的像素方框。
大多数 UI 自动化工具走的是另一条路:读页面的 DOM / 无障碍树,用 CSS 选择器或 XPath 找元素。那需要一个「可查询的结构」——网页有,但一整台电脑的桌面、一张远程投屏、一个 Canvas 画的界面就未必有。
Midscene 的这一支干脆只依赖像 素。输入只有截图,输出只要坐标,所以它对「界面底层是什么技术」完全不挑。
它要解决的核心难题,不是「怎么调模型」,而是下面这句:
模型说的那句「购物车按钮」,怎么精确、可靠地落到屏幕上那一小块真实像素?
模型给的坐标形态五花八门:有的给矩形(bbox),有的给一个点(point);有的用像素值,有的用 0–1000 的归一化数;有的先横后纵(xy),有的先纵后横(yx)。把这些统一成「屏幕上一个 [left, top, right, bottom] 的像素方框」,就是本章的全部工作。
用起来什么样(直觉): 上层调用 agent.aiTap('购物车按钮'),主循环(见 01)发起一次规划,模型回一段 XML,里头带着坐标;本章这条流水线把坐标算成一个像素矩形,取其中心,交给 04 去真的点下去。
2. 顶层全景(这段大概怎么转)
这一支可以拆成两个阶段:先「问模型」,再「落坐标」。
怎么读下面这张图: 从上到下是一次定位的数据流,左边是「问模型」阶段(组装请求、解析回复),右边下半是「落坐标」阶段(把模型给的原始坐标一步步变成屏幕像素)。
┌───────────────────────────────┐
截图 + 指令 ───▶ │ ① 组装规划请求 plan() │
│ 系统prompt + image_url + 历史 │
└───────────────┬───────────────┘
│ 调多模态模型
▼
┌───────────────────────────────┐
模型回一段 XML ──▶ │ ② 解析 XML │
│ thought / action-type / │
│ action-param-json / <complete>│
└───────────────┬───────────────┘
│ 取出定位坐标(bbox 或 point)
▼
③ 落坐标(纯数学,本章重点):
LocateResultValue resolvedCoordinates 像素矩形
{type:bbox|point, ──────▶ {order: xy|yx, ──────▶ [left,top,
coordinates:[...]} normalizedBy?} right,bottom]
│ │ │
│ point→默认 20px 方框 │ 归一化→像素 / 换轴序 │ 越界校验+裁到内容区
└────────────────────────────┴─────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
plan() | 组装一次规划请求,发给模型,解析回复 | packages/core/src/ai-model/llm-planning.ts:106 |
parseXMLPlanningResponse | 从 XML 抽出思考、动作类型、 动作参数、完成信号 | llm-planning.ts:32 |
prepareModelImage | 规划前的图像预处理,记下 preparedSize/contentSize | workflows/image-preprocess.ts:29 |
mapLocateResultToPixelBboxByCoordinates | 把「模型坐标」换算成「像素矩形」的总入口 | shared/model-locate-result/pixel-bbox-mapper.ts:110 |
bbox.ts 一组函数 | 归一化→像素、点→方框、越界断言、裁剪 | shared/model-locate-result/bbox.ts |
下面按这条流水线,由浅入深逐段拆。
3. 阶段一:组装一次规划请求 plan()
本节讲:一次「问模型」的请求,是怎么把系统 prompt、当前截图、历史与记忆拼到一起的。
3.1 请求由哪几块拼成
入口是 plan()(llm-planning.ts:106)。它组装出发给模型的消息数组 msgs,结构固定为三段(llm-planning.ts:227):
| 段 | 角色 | 内容 |
|---|---|---|
| 系统 prompt | system | systemPromptToTaskPlanning(...) 生成的规则文本 |
| 指令 | user | <user_instruction>…</user_instruction>,可选前置 <high_priority_knowledge> |
| 反馈 + 历史 | user × N | 「当前截图」消息 + 压缩后的历史对话 |
系统 prompt 不是写死的字符串,而是按当前能力动态拼出来的(llm-planning.ts:130,定义见 prompt/llm-planning.ts:234 的 systemPromptToTaskPlanning):它把动作空间(action space)、是否把定位并进规划、是否开子目标(sub-goals)等都编织进一份很长的分步说明。
3.2 当前截图作为 image_url 送进去
定位要凭「看」,所以当前截图必须进消息。它被放进最新那条 user 消息里,作为一个 image_url(llm-planning.ts:191-198):
// 示意,非源码:最新反馈消息里图文并排
{
role: 'user',
content: [
{ type: 'text', text: '这是当前截图。…历史/记忆…' },
{ type: 'image_url', image_url: { url: imagePayload, detail: 'high' } },
],
}
detail: 'high' 表示要模型「看清楚」——定位是像素敏感的活,清晰度不能省。
3.3 上一步反馈 / 历史 / 记忆怎么拼
同一条 user 消息里,文本部分不是只有「这是当前截图」,还会依次拼进三样东西:
- 上一步的反馈(pendingFeedback)。 若上一动作刚执行完,文本改成「上一个动作已执行,这是最新截图,请继续」(
llm-planning.ts:183-199),并随后resetPendingFeedbackMessageIfExists()清掉这条待发反馈。 - 历史执行日志 / 子目标进度。 深度思考模式下拼子目标全貌,否则拼历史日志(
llm-planning.ts:170-177)。 - 记忆(memory)。
conversationHistory.memoriesToText()攒下来的跨步信息(llm-planning.ts:180-181)。
拼完还会 compressHistory(50, 20) 压一次历史,避免上下文溢出(llm-planning.ts:223)。
重点看: 截图永远只带「当前这一张」;更早的画面不再重复塞图,靠 <memory> 把关键信息以文字形式留存——这是把「视觉」压缩成「文本」的省 token 手法,细节属主循环 01。
4. 阶段一(续):解析模型回的 XML
本节讲:模型回来的那段 XML,是怎么被拆成结构化字段的。
4.1 为什么是 XML 而不是 JSON
Midscene 让模型用标签输出,而不是一整个 JSON 对象。好处是容错:模型偶尔在标签里漏个引号、多段文字,靠正则抽标签比整段 JSON.parse 更抗噪。解析器是 parseXMLPlanningResponse(llm-planning.ts:32)。
4.2 抽哪些标签
parseXMLPlanningResponse 逐个抽出这些标签(llm-planning.ts:36-64):
| 标签 | 含义 |
|---|---|
<thought> | 模型的思考过程 |
<action-type> | 下一个动作的类型,如 Tap / Input |
<action-param-json> | 该动作的参数,一段 JSON 字符串 |
<complete success="true|false"> | 任务完成信号,带成功与否 |
<log> / <memory> / <error> | 用户可见旁白 / 记忆 / 报错 |
定位坐标就藏在 <action-param-json> 里。 例如一个 Tap 动作,其参数里的 locate 字段会带上模型给的坐标(下节详述)。
4.3 两处防漏的小心思
- 动作类型截断脏尾。 模型有时把闭合标签也漏进类型里,如
KeyboardPress</action-type>…;解析器用actionType.split('<')[0].trim()砍掉尖括号后的脏尾(llm-planning.ts:71)。 - 动作与完成信号不能并存。 若模型既给了动作又给了
<complete>,plan()会丢弃<complete>只保留动作,并打一条 warn(llm-planning.ts:263-269)。
解析失败还有一次重试:整段重新问一次模型再解析(llm-planning.ts:250-260)。
5. includeLocateInPlanning:定位并进规划,还是独立一步?
本节讲:模型到底是「边规划边给坐标」,还是「先规划、再单独问一次坐标」。这是本章一个关键分叉。
5.1 两条路
| 路径 | 何时走 | 谁把坐标算成像素 |
|---|---|---|
| 并进规划 | includeLocateInPlanning = true | 规划回复里就带坐标,由 normalizePlanningActionLocateFields 就地换算 |
| 独立定位 | includeLocateInPlanning = false | 规划只给出 {prompt},之后由 AiLocateElement 单独发一次请求 |
5.2 并进规划:坐标就地落地
当 includeLocateInPlanning 为真时,系统 prompt 会额外要求模型在动作参数里直接给坐标——systemPromptToTaskPlanning 把定位的 promptSpec 编进动作描述里,并注入示例(prompt/llm-planning.ts:249-262、injectLocateResultIntoSample:99),还追加「只框文字区域」等 grounding 规则(prompt/locate-grounding-rules.ts:1)。
这条路有个前置校验:并进规划要求必须配置了模型家族(modelFamily),否则 plan() 直接抛错(llm-planning.ts:116-120)——因为不同家族的坐标约定不同,没家族信息就没法解释坐标(约定差异见 03)。
规划解析完后,plan() 调 normalizePlanningActionLocateFields(llm-planning.ts:297,定义 workflows/planning/locate-normalization.ts:13)遍历动作里的定位字段:
- 并进模式:调
locateResultAdapter.adaptPlanningParamToPixelBbox(...)把坐标换算成像素方框,塞进locatedPixelBbox(locate-normalization.ts:61-67)。 - 纯 prompt 模式:把模型顺手给的坐标丢弃,只留
{prompt}(locate-normalization.ts:49-54)——避免误用不该信任的坐标。
5.3 独立定位:同一条落坐标流水线
当规划只给了 {prompt},真正的定位由 AiLocateElement(inspect.ts:118)单独发一次请求,拿到回复后调 adaptElementLocateResultToPixelBbox(inspect.ts:317)。
关键:两条路最终都汇到同一段坐标数学。 无论并进还是独立,像素方框都由同一个 adapter(shared/model-locate-result/factory.ts)产出——只是入口函数名不同。下面第 6–7 节讲的就是这段共用的数学。
6. 规划前的图像预处理:记住两个尺寸
本节讲:为什么落坐标前要先「预处理」截图,以及为此要记下两个尺寸。
6.1 为什么要预处理
有些模型对输入图像有「边长必须是某个块大小整数倍」之类的要求。prepareModelImage(image-preprocess.ts:29)按 adapter 的 imagePreprocess 策略,在必要时给图**补边(padding)**到合规尺寸(image-preprocess.ts:40-48)。
补边会让「送进模型的图」比「真实截图」更大。于是就产生了一个陷阱:模型是对着补过边的图