UI-TARS 源码解析 #12:Qwen2.5-VL 坐标适配:绝对坐标如何映射回真实屏幕?
在上一篇文章中,我们分析了 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 * width 和 model_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_height、origin_resized_width、max_pixels 和 min_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_width 和 smart_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_width 和 image_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)
源码中 click、left_double、right_single 和 hover 都采用了这套中心点还原逻辑。
这就完成了:
归一化坐标
↓
真实图像坐标
↓
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 = x2,y1 = 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_width 和 image_height,生成:
pyautogui.moveTo(sx, sy)
pyautogui.dragTo(ex, ey, duration=1.0)
源码中 drag 和 select 分支就是分别读取 start_box 和 end_box,计算两个中心点后生成 moveTo 和 dragTo。
十六、scroll 动作中的坐标适配
滚动动作也会受到坐标适配影响。
例如:
Action: scroll(start_box='(600,720)', direction='down')
这里的 start_box 表示滚动发生的区域。
如果坐标没有正确还原,鼠标可能停在错误区域,导致滚动对象错误。
例如本来想滚动:
主内容区
结果鼠标落在:
左侧菜单
滚动的就是菜单,而不是主页面。
源码中 scroll 分支会读取 start_box,计算中心点,乘以 image_width 和 image_height,然后生成带 x、y 参数的 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_width 和 new_height。
否则坐标会系统性偏移。
3. 宽高顺序写反
代码中通常是:
height, width
但坐标中通常是:
x, y
归一化时必须注意:
x 除以 width
y 除以 height
源码中也是按索引奇偶分别除以 smart_resize_width 和 smart_resize_height。
4. 执行阶段用错 image_width / image_height
如果 pyautogui 最终点击的是屏幕坐标,那么传入的 image_width、image_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_box 和 end_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 规则、截图尺寸和执行环境的一整套坐标系统。
如果这套坐标系统没处理好,模型即使“看懂了”,也会“点错”。
更多推荐



所有评论(0)