数据截至 (上游 commit 5d92feea9f1e)
定位落点:smart_resize 与坐标反归一化
30 秒导读: CUA(Computer-Use Agent,操作电脑的智能体)最工程化的难点,不是让模型 说出「点这里」,而是把它嘴里那个数字准确落到屏幕的真实像素上。本章只讲这条坐标数学: 模型给的坐标活在一个被压缩过的图像坐标系里,ScaleCUA 用一条**「图像坐标 → [0,1] → 屏幕像素」 的三级契约**把它复原到真实落点。搞懂这条链,你就懂了为什么同一个模型能跨分辨率、跨平台复用。
本章属于 ScaleCUA 系列。上一章 两种推理模式 讲了「谁来给坐标」 (原生单模型 vs 规划器+定位器);本章紧接着讲「拿到坐标后怎么换算成真实落点」。动作最终 怎么被后端执行(pyautogui / adb / playwright),见 跨平台环境层; 本章只谈坐标,不谈动作后端的执行细节。
1. 先建立直觉:坐标为什么需要「换算」
假设屏幕是 1920×1080,你想让模型点右上角的关闭按钮。你可能以为模型直接回「点 (1850, 30)」 就完事——并不是。中间隔着两层错位:
- 第一层错位:模型看到的图不是原图。 视觉语言模型(VLM)有像素预算,不会吞下任意大小的 截图。它会先把截图缩放成一个「宽高能被 28 整除、总像素落在某个区间」的尺寸。模型给的 坐标,是画在这张缩放后的图上的,不是原始 1920×1080。
- 第二层错位:不同模型/不同配置的坐标系还不一样。 有的模型输出的是缩放后图像的绝对像素,
有的按 0–1000 的相对刻度,有的直接给 (x, y),有的给一个
<box>框。
如果直接拿模型的数字乘屏幕尺寸,落点就会偏。ScaleCUA 的解法是先把一切收敛到一个统一的中间 表示 [0,1](归一化坐标),再从 [0,1] 展开到任意目标屏幕。
一句话类比:[0,1] 是「转接头」。 上游任意插头(缩放图像坐标、0–1000 刻度)先转成 [0,1], 下游任意插座(1920×1080、手机 1080×2400、浏览器视口)再从 [0,1] 接出去。转接头两边解耦, 所以一个模型能插到任何屏幕上。
2. 三级契约:一张图看懂全流程
这是本章的骨架,后面每一节都是在讲这条链上的一环。从左到右是数据流,命中即往下走:
模型输出的文本 真实屏幕
"...x=..,y=.." 或 "<box>[[…]]" (screen_size)
│ ▲
│ ① 解析 │ ③ 反归一化 · 最后一跳
▼ │ x*W, y*H
(x, y) ← 缩放后图像坐标系 │
│ │
│ ② 复原缩放尺寸 + 除法归一 │
│ smart_resize → gw, gh │
│ x/gw, y/gh │
▼ │
(x, y) ∈ [0,1] ───── 统一中间表示 ───┘
三级契约,每一级一个职责:
| 级 | 输入 | 输出 | 干这件事的代码 |
|---|---|---|---|
| ① 解析 | 模型的原始文本 | 缩放图像坐标系里的 (x, y) | parse_point_from_string |
| ② 反归一化 | 缩放图像坐标 (x, y) | [0,1] 归一化坐标 | smart_resize + 除法(在 agent 里) |
| ③ 最后一跳 | [0,1] 坐标 | 屏幕真实像素 | env 的 execute_single_action |
关键设计:② 里除的那个分母(缩放后的宽高)必须和「模型当时看的图」的宽高完全一致。
所以 agent 不能瞎猜分母,得用和 VLM 预处理一模一样的 smart_resize 重算一遍,才对得上。
这就引出第 3 节。
3. smart_resize:复算「模型当时看的是多大的图」
3.1 它要解决的小问题
VLM 不能直接吃原始截图,得先缩放。而 agent 做反归一化时,分母正是这个缩放后的宽高。
所以 agent 必须能离线复现那次缩放,算出同样的 (grounding_width, grounding_height)。
smart_resize 就是这个「复现器」,它照搬 Qwen2.5-VL 风格的图像预处理规则。
3.2 三条硬约束
smart_resize(playground/agents/utils.py:39-69)要同时满足三条:
- 宽高都能被
factor(=28)整除。 因为 ViT 按 patch 切图,IMAGE_FACTOR = 28(utils.py:7)对应 patch 网格,尺寸必须对齐。 - 总像素落在
[MIN_PIXELS, MAX_PIXELS]。 即[3136, 2109744](utils.py:8-9), 太小看不清、太大烧显存。 - 尽量保持长宽比。
3.3 三个「对齐到 factor」的取整助手
缩放后的宽高几乎不可能天然是 28 的倍数,所以每一步都要「吸附」到最近的 28 的倍数。方向不同,
用三个不同的取整函数(utils.py:24-36):
| 函数 | 规则 | 用在哪、为什么 |
|---|---|---|
round_by_factor | 四舍五入到最近的 factor 倍数 | 首次估算宽高,离原尺寸最近 |
ceil_by_factor | 向上取到 factor 倍数 | 像素太少时放大,向上取保证够到 MIN_PIXELS |
floor_by_factor | 向下取到 factor 倍数 | 像素太多时缩小,向下取保证不超 MAX_PIXELS |
取整方向不 是随便选的: 放大时用 ceil(宁可略多也别掉回下限之下),缩小时用 floor
(宁可略少也别顶破上限)——两个方向都朝「把总像素拉回合法区间」的方向兜底。
3.4 原理演示(示意,非源码)
这段把 smart_resize 的三步骨架演出来,重点看先对齐、再按像素预算缩放、缩放后仍对齐:
# 示意,非源码:复现 VLM 那次缩放
FACTOR, MIN_PX, MAX_PX = 28, 3136, 2109744
def smart_resize(height, width):
# 第一步:先把原始宽高各自吸附到 28 的倍数(还没管像素预算)
h_bar = max(FACTOR, round_by_factor(height, FACTOR))
w_bar = max(FACTOR, round_by_factor(width, FACTOR))
if h_bar * w_bar > MAX_PX: # 太大 → 按比例缩小
beta = (height * width / MAX_PX) ** 0.5 # 需要缩小的倍率
h_bar = floor_by_factor(height / beta, FACTOR) # 缩小后向下对齐
w_bar = floor_by_factor(width / beta, FACTOR)
elif h_bar * w_bar < MIN_PX: # 太小 → 按比例放大
beta = (MIN_PX / (height * width)) ** 0.5
h_bar = ceil_by_factor(height * beta, FACTOR) # 放大后向上对齐
w_bar = ceil_by_factor(width * beta, FACTOR)
return h_bar, w_bar # 返回 (高, 宽)
重点看返回顺序是 (h_bar, w_bar) 即 (高, 宽)——这在第 5 节的调用处很关键,顺序接反坐标就全错。
3.5 真实实现里的两个细节
- 入口的比例校验用的是全局
MAX_RATIO,不是参数。 函数一开头if max(height, width) / min(height, width) > MAX_RATIO: raise ValueError(utils.py:55-58),MAX_RATIO = 200(utils.py:10)。极端细长的图(比一根线还窄) 会被直接拒绝,避免缩放后某一维塌成 0。 - 缩放的分母
beta用的是原始height*width(utils.py:62、utils.py:66), 而不是已经吸附过的h_bar*w_bar——即「先算精确的缩放倍率,再一步到位地对齐」,避免两次 取整误差叠加。
4. parse_point:把三种输出格式收敛成一个 (x, y)
4.1 它要解决的小问题
不同模型/不同 prompt 会用不同格式吐坐标。parse_point_from_string
(utils.py:72-104)用一组正则兼容三种,按顺序匹配、命中即返回:
| 模型输出长这样 | 含义 | 解析结果 |
|---|---|---|
<box>[[x1,y1,x2,y2]]</box> | 一个包围框 | 取框中心 ((x1+x2)/2, (y1+y2)/2) |
x=12.3, y=45.6 | 带键名的点 | (12.3, 45.6) |
(12.3, 45.6) | 裸括号点 | (12.3, 45.6) |
4.2 关键细节
<box>是框,不是点——所以取中心。 定位任务最终要一个「点」去落鼠标,框要先坍缩成中心点 (utils.py:91-93)。- 数字统一按
float解析。 三种格式的正则都允许小数和负号([\d\.\-]+),解析出来都是浮点 (utils.py:88、utils.py:98-99),为后面的除法归一做准备。 - 三种模式按列表顺序逐个
re.search,先命中先返回(utils.py:83-84);都不中返回None。
5. 反归一化的两条路径:agent 里的除法
拿到缩放图像坐标 (x, y) 后,② 归一到 [0,1] 的除法在两种推理模式里各有一份实现,逻辑同构。
5.1 路径 A:原生单模型(NativeAgent.predict)
NativeAgent.predict(playground/agents/native_agent.py:70-107)在解析出动作后做归一:
- 当
resize_corrds=True(默认):从截图读真实宽高,smart_resize复算缩放后尺寸当分母 (native_agent.py:89-92)。注意解包顺序:grounding_height, grounding_width = smart_resize(height, width)——和第 3.4 节返回(高, 宽)对齐。 - 否则退回构造时传入的
self.grounding_width/height(native_agent.py:93-97)。 - 然后遍历每个动作的参数,对
x除以grounding_width、对y除以grounding_height(native_agent.py:98-104),把坐标压进[0,1]。
真实片段——重点看x、y 各除各的分母:
for i, action in enumerate(actions):
for key, value in action["parameters"].items():
if key in ["x", "y"]:
if key == "x":
actions[i]["parameters"]["x"] /= grounding_width
else:
actions[i]["parameters"]["y"] /= grounding_height
5.2 路径 B:定位器(UIGroundingAgent.resize_coordinates)
UIGroundingAgent.resize_coordinates(playground/agents/ui_grounding.py:75-95)是规划器+定位器
模式里那个「定位器」的归一步骤,和路径 A 同构,但退路多一级:
resize_corrds=True:同样smart_resize复算分母(ui_grounding.py:79-82)。resize_corrds=False:先看engine_params里有没有显式配grounding_width/height(ui_grounding.py:85-87);都没有才兜底1000(ui_grounding.py:88-90)——对应模型按 0–1000 相对刻度输出坐标的约定。- 最后返回
[x/grounding_width, y/grounding_height](ui_grounding.py:92-95)。
两条路径的对比:
| NativeAgent(路径 A) | UIGroundingAgent(路径 B) | |
|---|---|---|
| 位置 | native_agent.py:89-104 | ui_grounding.py:79-95 |
resize_corrds=True | smart_resize 复算分母 | 同左 |
resize_corrds=False | 用构造参数 grounding_width/height | 先查 engine_params,再兜底 1000 |
| 输出 | 原地改写动作参数为 [0,1] | 返回 [x_norm, y_norm] |
为什么要有 resize_corrds 这个开关: 它区分两类模型——一类(Qwen2.5-VL 风格)按缩放后绝对
像素给坐标,分母必须靠 smart_resize 复算;另一类按固定相对刻度(如 0–1000)给,分母是常数。
一个开关就让同一套 agent 代码复用于两类模型。
6. 最后一跳:[0,1] 乘回真实屏幕像素
到这里坐标已经是干净的 [0,1],与任何具体屏幕无关。③ 最后一跳在环境层完成,把它乘回目标屏幕。
- 通用校验
BaseEnv.onScreen(playground/envs/base_env.py:30-37):当x, y是浮点时, 先assert 0 <= x <= 1 and 0 <= y <= 1确认确实是归一化坐标,再round(x*screen_width)、round(y*screen_height),并判断是否落在屏内。这道assert是三级契约的类型闸门—— 谁要是把没归一化的坐标塞进来,这里立刻炸。 - 各环境在执行动作前乘回:以 Ubuntu 环境为例,
UbuntuEnv.execute_single_action(playground/envs/ubuntu/ubuntu_env.py:271-284)在派发动作前,若参数里有x/y,就round(parameters["x"] * self.screen_size[0])、round(parameters["y"] * self.screen_size[1])把[0,1]展开成真实像素。self.screen_size默认(1920, 1080)(ubuntu_env.py:133-134、ubuntu_env.py:145),但换成手机、换成别的分辨率,只要 env 换一个screen_size,同样的[0,1]坐标就落到对的地方。
真实片段——这就是「乘回屏幕」的那一跳:
if "x" in parameters or "y" in parameters:
parameters["x"], parameters["y"] = round(
parameters["x"] * self.screen_size[0]
), round(parameters["y"] * self.screen_size[1])
7. 为什么这条三级契约让一个模型跨分辨率、跨平台复用
把三节串起来看,精华就一句:中间那层 [0,1] 把「模型侧」和「屏幕侧」彻底解耦。
- 模型侧只管产出:不管它按缩放绝对像素还是 0–1000 刻度给,
parse_point+smart_resize/常数 分母都能把它收敛到[0,1]。模型不需要知道最终屏幕多大。 - 屏幕侧只管展开:env 只认
[0,1],乘自己的screen_size即可。换分辨率 只是换个乘数, 换平台(04 里的 pyautogui / adb / playwright)只是换个后端, 归一化坐标这层不动。 - 契约的两个守门员:入口
smart_resize的MAX_RATIO校验挡住畸形图,出口onScreen的assert 0<=x,y<=1挡住没归一化的坐标——两头卡死,中间那层永远是干净的[0,1]。
这正是 ScaleCUA 能用一个定位模型服务 Ubuntu / 手机 / 浏览器多种环境的坐标学基础。与之配套的
统一动作空间(动作名 + 参数的契约)见 01-action-space;动作从 [0,1]
落地后如何被具体后端执行,见 04-cross-platform-envs。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| factor / 像素上下限 / 比例上限常量 | playground/agents/utils.py:7-10 | IMAGE_FACTOR MIN_PIXELS MAX_PIXELS MAX_RATIO |
| 对齐 factor 的三个取整助手 | playground/agents/utils.py:24-36 | round_by_factor ceil_by_factor floor_by_factor |
| 复现 VLM 缩放、算分母 | playground/agents/utils.py:39-69 | smart_resize |
| 解析三种坐标格式(box 取中心) | playground/agents/utils.py:72-104 | parse_point_from_string |
| 原生模式:除法归一到 [0,1] | playground/agents/native_agent.py:89-104 | NativeAgent.predict |
| 定位器:归一 + 兜底 1000 | playground/agents/ui_grounding.py:75-95 | UIGroundingAgent.resize_coordinates |
| 归一化校验闸门 | playground/envs/base_env.py:30-37 | BaseEnv.onScreen |
| 最后一跳:[0,1]×screen_size | playground/envs/ubuntu/ubuntu_env.py:271-284 | UbuntuEnv.execute_single_action |