跳到主要内容

定位落点: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)要同时满足三条:

  1. 宽高都能被 factor(=28)整除。 因为 ViT 按 patch 切图,IMAGE_FACTOR = 28 (utils.py:7)对应 patch 网格,尺寸必须对齐。
  2. 总像素落在 [MIN_PIXELS, MAX_PIXELS][3136, 2109744](utils.py:8-9), 太小看不清、太大烧显存。
  3. 尽量保持长宽比。

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:62utils.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:88utils.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-104ui_grounding.py:79-95
resize_corrds=Truesmart_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-134ubuntu_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_resizeMAX_RATIO 校验挡住畸形图,出口 onScreenassert 0<=x,y<=1 挡住没归一化的坐标——两头卡死,中间那层永远是干净的 [0,1]

这正是 ScaleCUA 能用一个定位模型服务 Ubuntu / 手机 / 浏览器多种环境的坐标学基础。与之配套的 统一动作空间(动作名 + 参数的契约)见 01-action-space;动作从 [0,1] 落地后如何被具体后端执行,见 04-cross-platform-envs

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

主题文件路径符号名
factor / 像素上下限 / 比例上限常量playground/agents/utils.py:7-10IMAGE_FACTOR MIN_PIXELS MAX_PIXELS MAX_RATIO
对齐 factor 的三个取整助手playground/agents/utils.py:24-36round_by_factor ceil_by_factor floor_by_factor
复现 VLM 缩放、算分母playground/agents/utils.py:39-69smart_resize
解析三种坐标格式(box 取中心)playground/agents/utils.py:72-104parse_point_from_string
原生模式:除法归一到 [0,1]playground/agents/native_agent.py:89-104NativeAgent.predict
定位器:归一 + 兜底 1000playground/agents/ui_grounding.py:75-95UIGroundingAgent.resize_coordinates
归一化校验闸门playground/envs/base_env.py:30-37BaseEnv.onScreen
最后一跳:[0,1]×screen_sizeplayground/envs/ubuntu/ubuntu_env.py:271-284UbuntuEnv.execute_single_action