在上一篇文章中,我们分析了 UI-TARS 中的坐标格式转换。

模型可能输出:

point
start_point
end_point

但在 action_parser.py 内部,它们会被统一成:

start_box
end_box

其中:

point       → start_box
start_point → start_box
end_point   → end_box

这一步解决的是“坐标字段如何统一”的问题。

但还有一个更关键的问题没有完全展开:

模型输出的坐标,如何映射回真实屏幕坐标?

这一篇我们专门分析 Qwen2.5-VL 的坐标适配逻辑,重点看 smart_resize 为什么会影响坐标还原,以及 UI-TARS 是如何把模型输出的绝对坐标转换成 pyautogui 可执行坐标的。


一、为什么 Qwen2.5-VL 坐标适配很重要?

GUI Agent 最终要点击真实屏幕。

例如模型输出:

Action: click(start_box='(197,525)')

如果直接把 (197, 525) 当成真实屏幕坐标去点击,很可能点错。

原因是:

模型看到的图片尺寸
不一定等于
原始截图尺寸

例如,原始截图可能是:

1920 × 1080

但模型实际处理的图像,可能经过了 resize。

如果模型是在 resize 后的图像上输出坐标,那么这个坐标必须先映射回原始截图。

UI-TARS 的坐标处理文档专门说明:拿到模型原始坐标后,需要结合 smart_resize 后的新尺寸,把模型坐标换算成原图坐标;文档中的示例公式是 model_output_width / new_width * widthmodel_output_height / new_height * height

所以,Qwen2.5-VL 坐标适配的核心问题是:

模型输出坐标所在的坐标系
和
真实屏幕坐标系
不是同一个坐标系

二、UI-TARS 的坐标处理链路

在 UI-TARS 中,完整坐标链路大概是:

模型输出坐标
    ↓
point / start_point / end_point 统一成 start_box / end_box
    ↓
根据 model_type 判断坐标体系
    ↓
如果是 qwen25vl,使用 smart_resize 计算模型输入图像尺寸
    ↓
把绝对坐标除以 smart_resize 后的宽高
    ↓
得到归一化坐标
    ↓
结构化 action_inputs
    ↓
pyautogui 阶段再乘以真实 image_width / image_height
    ↓
得到真实点击坐标

这条链路看起来绕,但目的很明确:

先把模型坐标转换成比例坐标,再根据真实屏幕尺寸还原。

这样可以适配不同分辨率、不同截图尺寸和不同模型坐标协议。

ui-tars 包的 README 也明确说,它负责解析 VLM 生成的 GUI 动作指令,自动生成 pyautogui 脚本,并支持坐标转换和智能图片缩放。


三、parse_action_to_structure_output 中的 model_type

parse_action_to_structure_output 的函数签名里有一个重要参数:

def parse_action_to_structure_output(
    text,
    factor,
    origin_resized_height,
    origin_resized_width,
    model_type="qwen25vl",
    max_pixels=16384 * 28 * 28,
    min_pixels=100 * 28 * 28
):
    ...

从 README 的 API 文档也能看到,model_type 默认值是 "qwen25vl",并且参数里包含 origin_resized_heightorigin_resized_widthmax_pixelsmin_pixels,这些都和坐标缩放有关。

这个参数决定了后面的坐标归一化方式。

源码中有一段关键逻辑:

if model_type == "qwen25vl":
    smart_resize_height, smart_resize_width = smart_resize(
        origin_resized_height,
        origin_resized_width,
        factor=IMAGE_FACTOR,
        min_pixels=min_pixels,
        max_pixels=max_pixels
    )

也就是说,如果模型类型是 qwen25vl,UI-TARS 会先根据原始图像尺寸计算出模型实际处理时的 smart resize 尺寸。


四、Qwen2.5-VL 输出的是“绝对坐标”

action_parser.py 源码里有一行注释非常关键:

# Qwen2.5vl output absolute coordinates, qwen2vl output relative coordinates

也就是说,源码作者认为:

Qwen2.5-VL 输出绝对坐标;
Qwen2-VL 输出相对坐标。

这直接决定了坐标处理方式。

如果是普通相对坐标,可能只需要:

坐标 / factor

例如:

x = 500 / 1000 = 0.5
y = 300 / 1000 = 0.3

但 Qwen2.5-VL 输出的是绝对坐标。

也就是说,模型可能输出:

(197, 525)

这个坐标是基于它实际看到的 resized image,而不是简单的 0~1000 相对坐标。

因此必须知道:

模型实际看到的图片宽度是多少?
模型实际看到的图片高度是多少?

这就是 smart_resize 的作用。


五、smart_resize 到底做了什么?

smart_resize 的目标是把图片缩放到模型适合处理的尺寸。

源码中的注释说明,它会满足三个条件:

1. 图片高度和宽度都能被 factor 整除;
2. 总像素数在 min_pixels 和 max_pixels 范围内;
3. 尽量保持原始宽高比。

README_coordinates.md 中也给出了同样的 smart_resize 逻辑,并说明高度和宽度需要能被 IMAGE_FACTOR 整除,总像素数需要落在上下限范围内,同时尽量保持宽高比。

源码中相关常量是:

IMAGE_FACTOR = 28
MIN_PIXELS = 100 * 28 * 28
MAX_PIXELS = 16384 * 28 * 28
MAX_RATIO = 200

这些常量定义在 action_parser.py 顶部。

也就是说,UI-TARS 不是随便 resize,而是按照视觉模型输入规则进行 resize。


六、smart_resize 的核心逻辑

smart_resize 的处理逻辑可以简化理解为:

第一步:检查宽高比是否过于极端。

第二步:把 height 和 width 四舍五入到 factor 的倍数。

第三步:如果总像素数超过 max_pixels,就按比例缩小。

第四步:如果总像素数低于 min_pixels,就按比例放大。

第五步:返回新的 height 和 width。

源码中,smart_resize 会先判断 max(height, width) / min(height, width) 是否超过 MAX_RATIO,超过则抛出异常;然后通过 round_by_factor 让高宽接近 factor 的倍数;如果像素数超过上限,就用 floor_by_factor 缩小;如果低于下限,就用 ceil_by_factor 放大。

所以它的输出不是普通等比例缩放结果,而是满足模型输入约束后的尺寸。

例如,原图是:

1920 × 1080

经过 smart_resize 后,可能得到一个新的尺寸:

new_width × new_height

模型输出的坐标就是基于这个 resized 尺寸的。


七、Qwen2.5-VL 坐标归一化的源码逻辑

parse_action_to_structure_output 中,当检测到参数名包含:

start_box
end_box

就会进入坐标处理逻辑。

如果 model_type == "qwen25vl",代码大致是:

float_numbers = []
for num_idx, num in enumerate(numbers):
    num = float(num)
    if (num_idx + 1) % 2 == 0:
        float_numbers.append(float(num / smart_resize_height))
    else:
        float_numbers.append(float(num / smart_resize_width))

也就是说:

第 1 个数是 x,用 smart_resize_width 做除数;
第 2 个数是 y,用 smart_resize_height 做除数;
第 3 个数是 x,用 smart_resize_width 做除数;
第 4 个数是 y,用 smart_resize_height 做除数。

源码正是按照奇偶位置来区分 x 和 y,并分别除以 smart_resize_widthsmart_resize_height

这一步的结果是归一化坐标:

绝对坐标 → 0~1 比例坐标

例如:

模型输出坐标:
(197, 525)

smart_resize 后尺寸:
new_width = 1000
new_height = 700

归一化:
x = 197 / 1000 = 0.197
y = 525 / 700  = 0.75

最终结构化动作里存的是:

[0.197, 0.75, 0.197, 0.75]

而不是原始的 (197,525)


八、为什么要先归一化,而不是直接算真实坐标?

有人可能会问:

既然最终要点真实屏幕,为什么不直接在 parse_action_to_structure_output 里算出真实坐标?

例如直接算:

real_x = model_x / new_width * original_width
real_y = model_y / new_height * original_height

这样也可以。

但 UI-TARS 选择先归一化,再在 pyautogui 阶段还原。

这样有几个好处。

第一,结构化动作和具体屏幕尺寸解耦。

action_inputs 里保存比例坐标,
执行阶段再根据 image_width / image_height 还原。

第二,同一套结构化动作可以用于可视化、日志、回放和执行。

第三,所有动作都统一使用 [x1, y1, x2, y2] 的比例格式。

第四,pyautogui 代码生成阶段只需要一套逻辑:

x = normalized_x * image_width
y = normalized_y * image_height

这也是 UI-TARS 的整体设计思路:

Parser 负责统一动作和坐标协议,Executor 负责根据真实图像尺寸执行。


九、真实屏幕坐标如何还原?

结构化动作生成后,会进入:

parsing_response_to_pyautogui_code(
    responses,
    image_height,
    image_width
)

这里传入的是最终执行时对应的图像宽高。

对于 click、left_double、right_single、hover 这类动作,源码会读取 start_box,计算中心点,再乘以 image_widthimage_height

x = round(float((x1 + x2) / 2) * image_width, 3)
y = round(float((y1 + y2) / 2) * image_height, 3)

然后根据动作类型生成:

pyautogui.click(x, y, button='left')
pyautogui.doubleClick(x, y, button='left')
pyautogui.click(x, y, button='right')
pyautogui.moveTo(x, y)

源码中 clickleft_doubleright_singlehover 都采用了这套中心点还原逻辑。

这就完成了:

归一化坐标
    ↓
真实图像坐标
    ↓
pyautogui 鼠标操作

十、结合 README_coordinates 看完整映射公式

README_coordinates.md 中给了一个非常直接的示例。

假设模型输出:

Action: click(start_box='(197,525)')

文档中先计算:

new_height, new_width = smart_resize(height, width)

然后用:

new_coordinate = (
    int(model_output_width / new_width * width),
    int(model_output_height / new_height * height)
)

也就是说:

真实 x = 模型输出 x / smart_resize_width  * 原图 width
真实 y = 模型输出 y / smart_resize_height * 原图 height

这是 Qwen2.5-VL 坐标适配的核心公式。

action_parser.py 实际上把这个过程拆成两步:

第一步:
模型输出 x / smart_resize_width
模型输出 y / smart_resize_height

得到归一化坐标。

第二步:
归一化 x * image_width
归一化 y * image_height

得到真实 pyautogui 坐标。

两种写法本质上是一样的。


十一、举一个完整例子

假设原始截图尺寸是:

1920 × 1080

模型输入前经过 smart_resize 后尺寸是:

1344 × 756

模型输出:

Action: click(start_box='(672,378)')

这表示模型想点击 resized 图像的中心位置。

第一步,Qwen2.5-VL 分支归一化:

x = 672 / 1344 = 0.5
y = 378 / 756  = 0.5

结构化动作变成:

{
  "action_type": "click",
  "action_inputs": {
    "start_box": "[0.5, 0.5, 0.5, 0.5]"
  }
}

第二步,pyautogui 阶段还原:

real_x = 0.5 * 1920 = 960
real_y = 0.5 * 1080 = 540

最终生成:

pyautogui.click(960, 540, button='left')

可以看到,虽然模型输出的是 resized 图像上的绝对坐标 (672,378),最终仍然能正确映射到原图中心 (960,540)


十二、如果不做 smart_resize 会发生什么?

假设原始截图是:

1920 × 1080

模型实际看到的是:

1344 × 756

模型输出:

(672,378)

如果你误以为模型看到的也是 1920×1080,直接归一化:

x = 672 / 1920 = 0.35
y = 378 / 1080 = 0.35

再还原到真实屏幕:

real_x = 0.35 * 1920 = 672
real_y = 0.35 * 1080 = 378

结果就会点到 (672,378)

但正确位置应该是:

(960,540)

误差非常大。

这就是为什么 Qwen2.5-VL 分支必须调用 smart_resize

它不是为了“美化图片尺寸”,而是为了知道模型输出坐标所在的参考系。


十三、普通模型为什么走 factor 分支?

如果 model_type 不是 "qwen25vl",源码会走另一条分支:

float_numbers = [float(num) / factor for num in numbers]

也就是说,直接用 factor 做归一化。

例如:

factor = 1000
模型输出坐标:
(500,300)

归一化:
x = 500 / 1000 = 0.5
y = 300 / 1000 = 0.3

这种方式适合输出相对坐标的模型。

codes/README.md 的 Quick Start 示例中,调用 parse_action_to_structure_output 时传入了 factor=1000,并使用 model_type="doubao"

所以 UI-TARS 同时兼容两类坐标协议:

qwen25vl:
绝对坐标,需要除以 smart_resize 后的宽高。

其他模型:
相对坐标,通常除以 factor。

这也是 model_type 参数存在的意义。


十四、二维点为什么会扩展成四维 box?

在坐标处理后,如果 float_numbers 的长度是 2,源码会扩展成:

[
    x,
    y,
    x,
    y
]

源码中正是这样处理的:如果坐标长度为 2,就复制成 [x, y, x, y]

这一步和上一篇文章讲的 start_box 统一有关。

一个点:

[x, y]

被扩展成:

[x, y, x, y]

这样执行阶段可以统一使用 box 中心点公式:

center_x = (x1 + x2) / 2
center_y = (y1 + y2) / 2

如果 x1 = x2y1 = y2,中心点就是原始点。

这让 click、scroll、hover、drag 等动作都可以使用统一坐标结构。


十五、drag 动作中的 Qwen2.5-VL 坐标适配

拖拽动作有两个坐标:

start_box
end_box

例如模型输出:

Action: drag(start_box='(300,500)', end_box='(900,500)')

如果是 Qwen2.5-VL,两个 box 都要按照 smart_resize 后的尺寸归一化:

start_x = 300 / smart_resize_width
start_y = 500 / smart_resize_height

end_x = 900 / smart_resize_width
end_y = 500 / smart_resize_height

结构化动作中保存的是比例坐标:

{
  "action_type": "drag",
  "action_inputs": {
    "start_box": "[0.22, 0.66, 0.22, 0.66]",
    "end_box": "[0.67, 0.66, 0.67, 0.66]"
  }
}

执行阶段再分别乘以真实 image_widthimage_height,生成:

pyautogui.moveTo(sx, sy)
pyautogui.dragTo(ex, ey, duration=1.0)

源码中 dragselect 分支就是分别读取 start_boxend_box,计算两个中心点后生成 moveTodragTo


十六、scroll 动作中的坐标适配

滚动动作也会受到坐标适配影响。

例如:

Action: scroll(start_box='(600,720)', direction='down')

这里的 start_box 表示滚动发生的区域。

如果坐标没有正确还原,鼠标可能停在错误区域,导致滚动对象错误。

例如本来想滚动:

主内容区

结果鼠标落在:

左侧菜单

滚动的就是菜单,而不是主页面。

源码中 scroll 分支会读取 start_box,计算中心点,乘以 image_widthimage_height,然后生成带 xy 参数的 pyautogui.scroll(...)

所以,Qwen2.5-VL 坐标适配不仅影响 click,也影响 scroll、drag、hover 等所有依赖坐标的动作。


十七、为什么坐标映射最好统一放在 Parser 层?

从工程角度看,坐标映射可以放在很多地方。

例如:

模型调用后立即映射;
Parser 中映射;
Executor 中映射;
每个动作分支里单独映射。

UI-TARS 的做法是:

Parser 阶段统一归一化;
Executor 阶段统一还原。

这样做的好处是:

1. 模型差异集中在 Parser 层处理;
2. 执行层只面对统一的归一化 box;
3. click、drag、scroll 可以复用同一套坐标计算逻辑;
4. 结构化 action 可以保存、回放、可视化;
5. 后续支持更多模型时,只需要增加 model_type 分支。

这是一种比较干净的分层方式。

可以总结为:

模型坐标协议适配:Parser 负责;
真实屏幕动作执行:Executor 负责。

十八、二次开发时最容易踩的坑

如果你基于 UI-TARS 接 Qwen2.5-VL 或其他 VLM,坐标部分最容易踩这些坑。

1. 把模型输出坐标直接当屏幕坐标

这是最常见错误。

模型输出的 (x, y) 很可能是 resized 图像坐标,不是原图坐标。

2. 忽略 smart_resize

如果模型输入前经过 smart resize,必须用同样逻辑计算 new_widthnew_height

否则坐标会系统性偏移。

3. 宽高顺序写反

代码中通常是:

height, width

但坐标中通常是:

x, y

归一化时必须注意:

x 除以 width
y 除以 height

源码中也是按索引奇偶分别除以 smart_resize_widthsmart_resize_height

4. 执行阶段用错 image_width / image_height

如果 pyautogui 最终点击的是屏幕坐标,那么传入的 image_widthimage_height 必须和截图坐标系一致。

如果截图经过裁剪、缩放、DPI 缩放、窗口偏移,最终点击位置都会错。

5. 忽略高 DPI 缩放

Windows 下常见 125%、150% DPI 缩放。

截图坐标和 pyautogui 坐标可能不一致。

这部分 UI-TARS 源码没有完整处理,需要你在桌面自动化产品里单独解决。


十九、产品化建议:坐标链路一定要可视化

做 GUI Agent 时,我强烈建议给每一步坐标做可视化。

例如保存:

原始截图
模型输出点
smart_resize 后尺寸
归一化坐标
还原后的真实坐标
最终点击位置截图

README_coordinates.md 里也提供了把模型输出坐标可视化到图片上的示例,用 plt.scatter 在原图中标出计算后的点位。

这对调试非常重要。

因为当 Agent 点错时,你要判断错误来自哪里:

模型理解错了?
模型坐标输出错了?
smart_resize 计算错了?
归一化错了?
真实屏幕坐标还原错了?
DPI 缩放错了?
窗口偏移错了?

没有可视化,很难定位问题。


二十、建议增加一个坐标调试日志

如果你做自己的 UI-TARS 自动化项目,可以为每次动作保存类似日志:

{
  "model_type": "qwen25vl",
  "original_size": {
    "width": 1920,
    "height": 1080
  },
  "smart_resize_size": {
    "width": 1344,
    "height": 756
  },
  "model_output_coordinate": {
    "x": 672,
    "y": 378
  },
  "normalized_coordinate": {
    "x": 0.5,
    "y": 0.5
  },
  "final_screen_coordinate": {
    "x": 960,
    "y": 540
  }
}

这样一旦点击失败,就能快速判断是哪一步出错。

这类日志比只记录:

click(960, 540)

有用得多。


二十一、源码中的一个安全提醒:eval

前面几篇文章也提到过,parsing_response_to_pyautogui_code 在还原 start_boxend_box 时使用了:

eval(start_box)

在研究代码或本地实验中,这样写比较方便。

但如果你要做真实产品,模型输出属于不可信输入,最好不要直接 eval

可以换成:

import ast

coords = ast.literal_eval(start_box)

或者自己写严格解析:

只允许数字、小数点、负号、逗号、中括号;
解析后长度必须是 2 或 4;
归一化坐标必须在 0 到 1 之间;
最终坐标不能超出屏幕范围。

源码中 click、drag、scroll 等多个分支都会用 eval 解析 box 字符串,因此产品化时建议统一替换成更安全的坐标解析函数。


二十二、完整流程总结

现在我们把 Qwen2.5-VL 坐标适配完整串起来。

假设模型输出:

Thought: 点击搜索框。
Action: click(start_box='(672,378)')

原始截图:

1920 × 1080

smart_resize 后:

1344 × 756

第一步:Parser 识别 model_type

model_type == "qwen25vl"

进入 Qwen2.5-VL 坐标分支。

第二步:计算 smart_resize 尺寸

smart_resize_width = 1344
smart_resize_height = 756

第三步:绝对坐标归一化

x = 672 / 1344 = 0.5
y = 378 / 756 = 0.5

第四步:点坐标扩展成 box

[0.5, 0.5]
    ↓
[0.5, 0.5, 0.5, 0.5]

第五步:生成结构化动作

{
  "action_type": "click",
  "action_inputs": {
    "start_box": "[0.5, 0.5, 0.5, 0.5]"
  }
}

第六步:pyautogui 阶段还原真实坐标

real_x = 0.5 * 1920 = 960
real_y = 0.5 * 1080 = 540

第七步:执行

pyautogui.click(960, 540, button='left')

这就是:

Qwen2.5-VL 绝对坐标
    ↓
smart_resize 坐标系
    ↓
归一化坐标
    ↓
真实屏幕坐标

的完整过程。


总结

这篇文章我们分析了 UI-TARS 中 Qwen2.5-VL 的坐标适配逻辑。

核心结论是:

Qwen2.5-VL 输出的是基于模型输入图像的绝对坐标;
而模型输入图像通常经过 smart_resize;
所以必须先用 smart_resize 后的宽高做归一化;
再在 pyautogui 阶段乘以真实 image_width / image_height;
最终才能得到真实屏幕坐标。

源码中的关键逻辑是:

if model_type == "qwen25vl":
    smart_resize_height, smart_resize_width = smart_resize(...)

x 坐标 / smart_resize_width
y 坐标 / smart_resize_height

而普通相对坐标模型则走:

坐标 / factor

从工程角度看,Qwen2.5-VL 坐标适配说明了一件事:

GUI Agent 的坐标不是简单的 x、y 数字,而是依赖模型输入尺寸、resize 规则、截图尺寸和执行环境的一整套坐标系统。

如果这套坐标系统没处理好,模型即使“看懂了”,也会“点错”。


更多推荐