跳到主要内容

纯视觉定位:从截图到屏幕像素

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/contentSizeworkflows/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):

角色内容
系统 promptsystemsystemPromptToTaskPlanning(...) 生成的规则文本
指令user<user_instruction>…</user_instruction>,可选前置 <high_priority_knowledge>
反馈 + 历史user × N「当前截图」消息 + 压缩后的历史对话

系统 prompt 不是写死的字符串,而是按当前能力动态拼出来的(llm-planning.ts:130,定义见 prompt/llm-planning.ts:234systemPromptToTaskPlanning):它把动作空间(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-262injectLocateResultIntoSample: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)。

补边会让「送进模型的图」比「真实截图」更大。于是就产生了一个陷阱:模型是对着补过边的图给坐标的,但真实 UI 只存在于图的一角。

6.2 preparedSizecontentSize

prepareModelImage 因此返回两个尺寸(image-preprocess.ts:50-60):

尺寸含义用途
preparedSize送进模型的图(可能含补边)的宽高坐标反算的基准——模型坐标是对着它给的
contentSize真实截图内容的宽高落地后裁剪的边界——把补边区域切掉

这两个尺寸随后作为 locateResultContext 一路传给坐标换算(llm-planning.ts:301-304inspect.ts:318-322)。记住这对尺寸,是理解第 7 节越界校验的前提。


7. 阶段二:坐标落地的数学(本章核心)

本节是全章重点:把模型给的原始坐标,一步步算成屏幕上的像素矩形。总入口是 mapLocateResultToPixelBboxByCoordinates(pixel-bbox-mapper.ts:110)。

7.1 两种输入形态:bbox 与 point

模型给的定位值先被解析成一个内部结构 LocateResultValue(types.ts:9),只有两种形态:

// 示意,非源码:定位值只有这两种
type LocateResultValue =
| { type: 'bbox'; coordinates: [number, number, number, number] } // 一个矩形
| { type: 'point'; coordinates: [number, number] }; // 一个点

type 决定了「是直接用矩形,还是把点扩成矩形」;而坐标怎么解释,则由另一个结构 resolvedCoordinates 描述。

7.2 resolvedCoordinates:坐标的「解释说明书」

resolveLocateResultCoordinates(factory.ts:30)把模型家族声明的坐标约定,规整成三个字段:

字段取值含义
shapebbox / point期望形态
orderxy(默认) / yx坐标是「先横后纵」还是「先纵后横」
normalizedBy数字或 undefined归一化基数;如 1000 表示坐标在 0–1000,undefined 表示已是像素值

(各家族分别声明什么约定,是 03 的内容;本章只讲拿到 resolvedCoordinates 之后怎么算。)

7.3 三步走:校验 → 换轴序 → 落像素

mapLocateResultToPixelBboxByCoordinates(pixel-bbox-mapper.ts:110-140)按顺序做三件事:

第一步:越界断言。 assertLocateResultCoordinates(pixel-bbox-mapper.ts:37)先算出每个坐标分量的上限:归一化模式下上限就是 normalizedBy;否则按 orderwidth/height(resolveCoordinateLimits:17)。任何分量为非数、无穷、<0 或超上限,直接抛错——宁可报错,不给一个错的坐标

第二步:换轴序到 xy。order === 'yx',reorderCoordinatesToXy(pixel-bbox-mapper.ts:93)把 [top,left,bottom,right][y,x] 调换成统一的 xy 顺序;否则原样返回。

第三步:落像素。 分两种:

  • 若已是 4 个数(bbox),直接作为待处理方框;
  • 若是 2 个数(point),调 expandPointToBbox 扩成方框(见 7.4)。

最后,若 normalizedBy 有值,再调 mapNormalizedCoordinatesToPixelBbox 把归一化坐标缩放到像素(见 7.5);否则坐标已是像素,直接用(pixel-bbox-mapper.ts:137-139)。

7.4 point → 默认 20px 方框

模型只给一个点时,得把它「撑成」一个可点击的小方框。expandPointToBbox(bbox.ts:37)以点为中心,四周各扩 halfSize,并夹在 [0, maxX]×[0, maxY] 内:

// 示意,非源码:点撑成方框
[
Math.max(0, x - halfSize), Math.max(0, y - halfSize), // 左、上
Math.min(maxX, x + halfSize), Math.min(maxY, y + halfSize), // 右、下
]

halfSize 怎么定,看是否归一化(pixel-bbox-mapper.ts:126-135):

情形halfSize结果方框
像素坐标(normalizedByundefined)defaultBboxSize / 2 = 10默认 20px 见方(defaultBboxSize = 20,pixel-bbox-mapper.ts:15)
归一化坐标normalizedBy / 100归一化空间里约 2% 边长,之后再缩放到像素

注意归一化情形下,先在归一化空间扩点、再整体缩放——顺序反了结果会不同。

7.5 归一化 → 像素索引

mapNormalizedCoordinatesToPixelBbox(bbox.ts:20)对四个分量逐个调 normalizedCoordinateToPixelIndex(bbox.ts:12):

// 示意,非源码:归一化值 → 像素索引
Math.round((value * (size - 1)) / normalizedBy)

用的是 size - 1(maxPixelIndex,bbox.ts:8),因为像素索引是闭区间:归一化的最大值应落到最后一个像素 size - 1,而不是 size。所以整条流水线里的方框坐标都是「含端点的像素索引」,而非「宽高」。

7.6 收尾:排序、越界、裁到内容区

无论并进还是独立,adapter 的对外方法最后都会包一层 finalizePixelBbox(bbox.ts:106,包裹处 factory.ts:216-242),依次做:

  1. assertFinitePixelBbox — 四个数且都有限(bbox.ts:56);
  2. assertPixelBboxOrder — 必须 right ≥ leftbottom ≥ top(bbox.ts:70);
  3. assertPixelBboxInsideImage — 必须落在 preparedSize 内(bbox.ts:83);
  4. 裁剪 — 把方框 clampcontentSize(真实截图内容)边界(bbox.ts:116-125)。

第 4 步正是 6.2 那对尺寸的用处:preparedSize 校验、用 contentSize 裁剪——保证补边区域绝不会被当成有效 UI 坐标交出去。

7.7 像素矩形 → Rect(与中心点)

得到像素方框后,pixelBboxToRect(workflows/inspect/locate-result-rect.ts:12)把它转成 Rect:

// 示意,非源码:闭区间方框 → Rect,注意 +1
{ left, top, width: right - left + 1, height: bottom - top + 1 }

这里的 +1 又是「闭区间索引」的直接后果:两个端点都算数,所以宽高要 +1。上层由这个 Rect 取中心点去真正点击——而中心点如何变成真实鼠标动作,归 04,本章到「像素矩形 / 中心点」为止。


8. 巧妙之处(可借鉴)

  • 纯像素、零结构依赖。 只吃截图、只吐坐标,让同一套定位逻辑通吃网页、桌面、投屏——代价是全押在模型的视觉能力上。
  • 正交的三字段坐标模型。 把「形态 / 轴序 / 归一化」拆成 shape/order/normalizedBy 三个正交字段(types.ts:66),新模型家族只需声明这三样,无需改换算代码(factory.ts:30)。
  • preparedSize / contentSize 双尺寸。 一个用于反算、一个用于裁剪,干净地隔开「喂给模型的图」与「真实 UI」,补边不会污染坐标(image-preprocess.ts:7-27bbox.ts:106)。
  • 闭区间像素索引一以贯之。size-1+1,整条链都把方框当「含端点的像素索引」,数学自洽(bbox.ts:8locate-result-rect.ts:12)。
  • 宁可抛错,不给错坐标。 多道断言(越界、轴序、落图)在坐标可疑时直接失败(pixel-bbox-mapper.ts:37bbox.ts:70-104),而非静默返回一个错点——符合仓库「出错就抛」的设计取向。

9. 边界与局限

  • 完全依赖模型的视觉定位能力。 模型看错位置,这条流水线只会忠实地把错坐标算成一个错方框(除非越界才会被断言拦下)。
  • 点的 20px 默认框是经验值。 目标过小或过密时,defaultBboxSize = 20(pixel-bbox-mapper.ts:15)撑出的框可能盖到邻居;它只是个居中的可点区域,不代表元素真实边界。
  • 并进规划强依赖 modelFamily 没配置家族就直接抛错(llm-planning.ts:116),因为坐标无从解释。
  • 坐标约定错配会静默偏移或显式报错。 若某家族的 order/normalizedBy 声明与模型实际输出不符,轻则坐标系统性偏移、重则被越界断言拦下——正确的家族声明是前提,见 03
  • 本章不含真实点击。 从像素矩形到真实鼠标/触摸事件的落地,以及设备抽象,属 04

10. 代码地图(导航索引)

主题文件路径符号名
组装规划请求packages/core/src/ai-model/llm-planning.tsplan
解析规划 XMLpackages/core/src/ai-model/llm-planning.tsparseXMLPlanningResponse
生成规划系统 promptpackages/core/src/ai-model/prompt/llm-planning.tssystemPromptToTaskPlanning
定位 grounding 规则packages/core/src/ai-model/prompt/locate-grounding-rules.tslocateGroundingRules
规划动作定位字段落地packages/core/src/ai-model/workflows/planning/locate-normalization.tsnormalizePlanningActionLocateFields
独立定位请求packages/core/src/ai-model/inspect.tsAiLocateElement
图像预处理 / 双尺寸packages/core/src/ai-model/workflows/image-preprocess.tsprepareModelImage
定位值内部结构packages/core/src/ai-model/shared/model-locate-result/types.tsLocateResultValue
坐标约定规整packages/core/src/ai-model/shared/model-locate-result/factory.tsresolveLocateResultCoordinates
坐标换算总入口packages/core/src/ai-model/shared/model-locate-result/pixel-bbox-mapper.tsmapLocateResultToPixelBboxByCoordinates
越界断言packages/core/src/ai-model/shared/model-locate-result/pixel-bbox-mapper.tsassertLocateResultCoordinates
点→方框扩展packages/core/src/ai-model/shared/model-locate-result/bbox.tsexpandPointToBbox
归一化→像素packages/core/src/ai-model/shared/model-locate-result/bbox.tsmapNormalizedCoordinatesToPixelBbox / normalizedCoordinateToPixelIndex
收尾校验+裁剪packages/core/src/ai-model/shared/model-locate-result/bbox.tsfinalizePixelBbox
像素方框→Rectpackages/core/src/ai-model/workflows/inspect/locate-result-rect.tspixelBboxToRect

同组其它章: index(Midscene 是什么·全景) · 01(主循环) · 03(模型家族适配) · 04(设备与动作落地) · 05(对外形态)