UI-TARS 源码解析 #11:坐标格式转换:point、start_box、end_box 是如何被统一处理的?
在上一篇文章中,我们分析了 parse_action 函数。
它的作用是把模型输出的函数调用式 Action:
click(start_box='(850,120)')
解析成结构化结果:
{
"function": "click",
"args": {
"start_box": "(850,120)"
}
}
这一步解决的是:
模型输出的 Action 字符串如何变成函数名和参数字典?
但 GUI Agent 里还有一个更麻烦的问题:
模型输出的坐标,如何变成真实屏幕上可以点击的位置?
这篇文章我们继续深入 action_parser.py,重点分析坐标格式转换:
point
start_point
end_point
start_box
end_box
这些格式看起来很相似,但它们背后对应的是不同的动作语义和后处理流程。
一、为什么坐标格式转换很重要?
GUI Agent 最终要操作真实屏幕。
例如模型输出:
Action: click(point='<point>850 120</point>')
程序最后要执行的是:
pyautogui.click(x, y)
中间必须解决几个问题:
1. 模型输出的是 point,还是 start_box?
2. 坐标是一个点,还是一个区域?
3. 坐标是绝对像素,还是归一化比例?
4. 模型看到的图像尺寸,和真实截图尺寸是否一致?
5. 最终 pyautogui 应该点击哪里?
如果坐标转换错了,模型推理再正确也没用。
比如模型本来想点击“导出”按钮,但坐标映射偏了 100 像素,结果可能点到“删除”按钮。
所以,在 GUI Agent 中,坐标不是一个小细节,而是模型感知能力落到真实操作系统的关键桥梁。
UI-TARS 的 codes/README.md 也明确说明,ui-tars 这个包不仅负责把 VLM 生成的 GUI 动作解析成 pyautogui 代码,还会自动处理坐标缩放和格式转换。
二、UI-TARS 中常见的几种坐标格式
在 UI-TARS 的模型输出和解析链路里,常见坐标格式主要有五种:
point
start_point
end_point
start_box
end_box
它们大致可以这样理解:
point:
单点坐标,常用于 click、scroll、hover 这类动作。
start_point:
起始点,常用于 drag 这类动作。
end_point:
结束点,常用于 drag 这类动作。
start_box:
统一后的起始区域或起始点。
end_box:
统一后的结束区域或结束点。
模型可能输出:
click(point='<point>850 120</point>')
也可能输出:
drag(start_point='<point>300 500</point>', end_point='<point>700 500</point>')
但进入后续结构化处理后,UI-TARS 会尽量把它们统一成:
start_box
end_box
源码中的 parse_action_to_structure_output 会把 start_point= 替换成 start_box=,把 end_point= 替换成 end_box=,也会把 point= 替换成 start_box=;如果文本中包含 point 标记,还会先调用 convert_point_to_coordinates 做坐标转换。
三、为什么要把 point 统一成 start_box?
以点击动作为例。
Prompt 中模型可能输出:
Action: click(point='<point>850 120</point>')
这对人来说很直观:
点击这个点。
但对后续执行层来说,如果每种动作都有自己的坐标字段,会让代码变复杂。
比如:
click 用 point
drag 用 start_point / end_point
scroll 用 point
hover 用 point
select 用 start_box / end_box
那么执行层就要针对每个动作单独判断坐标字段。
UI-TARS 的做法是:
point → start_box
也就是说,即使是一个点,也统一当作“起始位置”处理。
例如:
click(point='<point>850 120</point>')
经过转换后,可以理解成:
click(start_box='(850,120)')
这样后续执行层只需要从 start_box 里取坐标。
这是一种典型的工程归一化设计:
模型输出可以有多种友好格式,但程序内部最好只有一种稳定格式。
四、为什么 start_point 要统一成 start_box?
拖拽动作一般长这样:
drag(
start_point='<point>300 500</point>',
end_point='<point>700 500</point>'
)
它包含两个点:
起点:300, 500
终点:700, 500
但是 UI-TARS 内部会把它转换成:
drag(
start_box='(300,500)',
end_box='(700,500)'
)
这里的命名从 point 变成 box,看起来有点奇怪。
因为在这个例子里,它明明只是一个点。
但 UI-TARS 使用 box 有一个好处:
它可以同时表达“点”和“区域”。
例如一个区域可以表示成:
[x1, y1, x2, y2]
而一个点也可以退化成:
[x, y, x, y]
也就是:
x1 = x2
y1 = y2
这样,无论模型输出的是一个点,还是一个区域,后续都可以统一使用 box 格式处理。
五、一个点如何变成四个数?
在 parse_action_to_structure_output 的坐标处理逻辑里,如果坐标解析出来只有两个数:
[x, y]
它会扩展成四个数:
[x, y, x, y]
源码中可以看到,如果 float_numbers 长度为 2,就会被扩展成 [x, y, x, y]。
例如:
原始坐标:
(850, 120)
归一化后:
[0.4427, 0.1111]
扩展后:
[0.4427, 0.1111, 0.4427, 0.1111]
这样 click、hover、scroll 都可以统一取 box 的中心点:
center_x = (x1 + x2) / 2
center_y = (y1 + y2) / 2
如果 x1=x2、y1=y2,那么中心点就是原始点本身。
这种设计的好处是,后续 pyautogui 代码生成不需要区分:
这是点坐标?
还是区域坐标?
统一当成 box 来算中心点即可。
六、convert_point_to_coordinates 做了什么?
convert_point_to_coordinates 是坐标转换链路的第一个辅助函数。
它主要处理形如:
<point>850 120</point>
或者文本里直接出现的:
850 120
源码中可以看到,它使用正则匹配两个数字:
pattern = r"(\d+)\s+(\d+)"
然后把它转换成:
(850,120)
同时还会去掉 [EOS]。
这一步的作用是把模型常见的 point 标签格式转成更适合后续 AST 解析的字符串格式。
例如模型原始输出:
Action: click(point='<point>850 120</point>')
经过 convert_point_to_coordinates 后,大致会变成:
Action: click(point='(850,120)')
然后再经过字段替换:
point= → start_box=
最终变成:
Action: click(start_box='(850,120)')
这时就可以交给 parse_action 用 AST 解析了。
七、为什么要先转换 point,再替换字段名?
parse_action_to_structure_output 的处理顺序大致是:
1. text.strip()
2. 如果包含 point 标记,调用 convert_point_to_coordinates
3. start_point= 替换为 start_box=
4. end_point= 替换为 end_box=
5. point= 替换为 start_box=
6. 再提取 Thought / Action
7. 再调用 parse_action
这个顺序是有原因的。
因为模型输出的 point 可能长这样:
point='<point>850 120</point>'
这不是一个普通坐标字符串。
如果直接进入 parse_action,它虽然可能仍然是字符串常量,但后续坐标解析时还要处理 <point> 标签。
先转换成:
point='(850,120)'
再统一替换成:
start_box='(850,120)'
后续逻辑就简单很多。
所以,这个顺序体现的是:
先统一坐标内容,再统一参数名称。
八、start_box 和 end_box 如何进入 action_inputs?
经过 parse_action 后,动作会变成:
{
"function": "click",
"args": {
"start_box": "(850,120)"
}
}
接下来,parse_action_to_structure_output 会遍历参数:
for param_name, param in params.items()
把它们放入:
action_inputs
如果参数名中包含:
start_box
end_box
就进入坐标处理逻辑。
源码中,它会去掉括号,再按逗号拆分数字:
"(850,120)"
↓
"850,120"
↓
["850", "120"]
然后转换成 float,并根据模型类型做不同的比例处理。
最终得到类似:
{
"start_box": "[0.4427, 0.1111, 0.4427, 0.1111]"
}
这个结果就是后续 pyautogui 代码生成的输入。
九、为什么要做归一化?
模型输出的坐标,通常是某个图像尺寸下的坐标。
但真实执行时,pyautogui 要面对真实屏幕或原始截图尺寸。
例如:
模型看到的图像尺寸:1000 × 562
真实截图尺寸:1920 × 1080
模型输出坐标:500, 281
这个坐标不能直接拿去点真实屏幕。
否则本来应该点屏幕中心,结果可能点到偏左上角。
所以 UI-TARS 会把坐标先归一化:
normalized_x = x / model_image_width
normalized_y = y / model_image_height
得到:
0.5, 0.5
后续生成 pyautogui 代码时,再乘以真实图像尺寸:
real_x = normalized_x * image_width
real_y = normalized_y * image_height
这样坐标就可以适配不同分辨率。
官方坐标处理文档也专门说明,模型输出坐标需要结合 smart resize 后的尺寸,再映射回原始图像坐标;示例中用 model_output_width / new_width * width 和 model_output_height / new_height * height 来计算真实图像位置。
十、qwen25vl 和普通模型的坐标处理差异
在 parse_action_to_structure_output 中,坐标归一化会根据 model_type 分两种情况。
如果:
model_type == "qwen25vl"
代码会先调用:
smart_resize(origin_resized_height, origin_resized_width, ...)
得到模型实际看到的 resize 后尺寸:
smart_resize_height
smart_resize_width
然后坐标按这个尺寸归一化:
x / smart_resize_width
y / smart_resize_height
如果不是 qwen25vl,则使用:
float(num) / factor
也就是类似:
坐标 / 1000
源码注释中也提到,Qwen2.5-VL 输出的是 absolute coordinates,而 qwen2vl 输出的是 relative coordinates。
这说明不同 VLM 的坐标协议可能不一样。
有的模型输出绝对像素坐标。
有的模型输出相对坐标。
有的模型坐标基于 resize 后的图像。
有的模型坐标基于固定 factor。
所以坐标解析必须带上 model_type。
十一、smart_resize 在这里起什么作用?
smart_resize 是 UI-TARS 坐标链路里非常重要的函数。
它的作用不是简单缩放图片,而是让图片尺寸满足模型输入要求。
源码和坐标文档中都说明,smart_resize 会尽量保持宽高比,同时满足三个条件:
1. 高和宽都能被指定 factor 整除;
2. 总像素数落在 min_pixels 和 max_pixels 范围内;
3. 尽量保持原始宽高比。
坐标文档中也给出了同样的 smart_resize 逻辑,并用它来把模型输出坐标映射回原始图像位置。
这对 Qwen2.5-VL 这类模型尤其重要。
因为模型不是直接看原始截图,而是看经过 resize 后的图片。
所以坐标还原必须知道:
原始图像尺寸
resize 后图像尺寸
模型输出坐标
否则点位就会偏。
十二、start_box 在 pyautogui 阶段怎么用?
结构化动作生成后,后续会进入:
parsing_response_to_pyautogui_code
如果动作类型是:
click
left_single
left_double
right_single
hover
代码会读取:
start_box
然后把字符串形式的 box 转回列表。
如果 box 长度为 4:
x1, y1, x2, y2
如果 box 长度为 2:
x1, y1
x2 = x1
y2 = y1
接着计算中心点:
x = ((x1 + x2) / 2) * image_width
y = ((y1 + y2) / 2) * image_height
最后生成:
pyautogui.click(x, y, button='left')
源码中 click、left_double、right_single、hover 分支都采用这种方式:从 start_box 取中心点,再乘以图片宽高,最后生成对应的 pyautogui 鼠标操作。
这就是为什么前面要把 point 统一成 start_box。
因为到了执行阶段,所有鼠标类单点操作都可以从 start_box 计算目标点。
十三、drag 为什么需要 start_box 和 end_box?
拖拽动作不一样。
它需要两个位置:
起点
终点
所以结构化动作里必须同时有:
start_box
end_box
例如:
{
"action_type": "drag",
"action_inputs": {
"start_box": "[0.2, 0.5, 0.2, 0.5]",
"end_box": "[0.7, 0.5, 0.7, 0.5]"
}
}
在 pyautogui 代码生成阶段,源码会分别从 start_box 和 end_box 计算中心点:
sx, sy:拖拽起点
ex, ey:拖拽终点
然后生成:
pyautogui.moveTo(sx, sy)
pyautogui.dragTo(ex, ey, duration=1.0)
源码中 drag 和 select 分支正是这样处理的:先取 start_box 的中心点,再取 end_box 的中心点,最后生成 moveTo 和 dragTo。
所以:
start_box 表示动作开始位置;
end_box 表示动作结束位置。
这比 start_point / end_point 更统一,因为它既能表达点,也能表达区域中心。
十四、scroll 为什么也用 start_box?
滚动动作通常是:
scroll(point='<point>600 720</point>', direction='down')
经过转换后变成:
scroll(start_box='(600,720)', direction='down')
为什么滚动也要有坐标?
因为在 GUI 中,滚动不是绝对全局行为,而是和鼠标所在区域有关。
例如:
鼠标在左侧菜单上,滚动的是菜单;
鼠标在主内容区,滚动的是页面;
鼠标在表格里,滚动的是表格;
鼠标在弹窗里,滚动的是弹窗。
所以 scroll 的 start_box 表示:
在哪个区域附近执行滚动。
在 pyautogui 代码生成阶段,源码会读取 start_box,计算坐标,并根据 direction 生成 pyautogui.scroll(5, x=x, y=y) 或 pyautogui.scroll(-5, x=x, y=y);如果没有坐标,则生成不带 x/y 的滚动。
这说明 start_box 不一定表示点击目标,也可以表示动作发生区域。
十五、为什么内部不用 point,而统一用 box?
现在可以回答一个核心问题:
为什么 UI-TARS 不一直使用 point,而要统一成 start_box / end_box?
原因主要有四个。
第一,box 可以兼容 point。
一个点可以表示成:
[x, y, x, y]
第二,box 可以表示区域。
如果模型或其他检测器输出的是目标元素边界框,也可以直接使用:
[x1, y1, x2, y2]
第三,执行层只需要取中心点。
无论是点还是区域,都可以统一:
center_x = (x1 + x2) / 2
center_y = (y1 + y2) / 2
第四,拖拽和选择动作天然需要 start_box / end_box。
所以,统一成 box 是为了降低后续执行逻辑复杂度。
可以理解为:
point 是模型输出层的友好格式;
box 是 parser 和 executor 的内部统一格式。
十六、坐标格式转换的完整流程
我们用一个 click 示例完整走一遍。
模型输出:
Thought: 我需要点击搜索框。
Action: click(point='<point>850 120</point>')
第一步,转换 point 标签:
point='<point>850 120</point>'
↓
point='(850,120)'
第二步,统一参数名:
point=
↓
start_box=
变成:
Action: click(start_box='(850,120)')
第三步,AST 解析:
{
"function": "click",
"args": {
"start_box": "(850,120)"
}
}
第四步,坐标拆分:
"(850,120)"
↓
["850", "120"]
第五步,坐标归一化。
如果模型输入尺寸是:
1920 × 1080
则:
x = 850 / 1920
y = 120 / 1080
得到:
[0.4427, 0.1111]
第六步,扩展成 box:
[0.4427, 0.1111]
↓
[0.4427, 0.1111, 0.4427, 0.1111]
第七步,进入结构化动作:
{
"action_type": "click",
"action_inputs": {
"start_box": "[0.4427, 0.1111, 0.4427, 0.1111]"
}
}
第八步,生成 pyautogui 代码时还原:
real_x = 0.4427 * image_width
real_y = 0.1111 * image_height
最终:
pyautogui.click(real_x, real_y, button='left')
这就是 point 到真实点击坐标的完整链路。
十七、拖拽动作的完整流程
再看一个 drag 示例。
模型输出:
Thought: 我需要把滑块拖到右侧。
Action: drag(start_point='<point>300 500</point>', end_point='<point>700 500</point>')
转换后:
drag(start_box='(300,500)', end_box='(700,500)')
解析后:
{
"function": "drag",
"args": {
"start_box": "(300,500)",
"end_box": "(700,500)"
}
}
归一化后:
{
"action_type": "drag",
"action_inputs": {
"start_box": "[0.1562, 0.4630, 0.1562, 0.4630]",
"end_box": "[0.3646, 0.4630, 0.3646, 0.4630]"
}
}
执行时:
pyautogui.moveTo(sx, sy)
pyautogui.dragTo(ex, ey, duration=1.0)
这说明 start_box / end_box 不只是命名统一,而是直接决定后续动作的执行方式。
十八、add_box_token 和 box 标记
action_parser.py 里还有一个辅助函数:
add_box_token
它会把:
start_box='(123,456)'
转换成:
start_box='<|box_start|>(123,456)<|box_end|>'
源码中可以看到,它查找 start_box 或 end_box 坐标,然后在坐标外加上 <|box_start|> 和 <|box_end|> 标记。
这类标记通常用于让坐标区域在文本中更加明确,也可能用于适配某些模型或数据格式。
它说明 UI-TARS 的坐标链路并不只支持一种写法,而是在处理不同模型、不同训练格式和不同输出协议之间的兼容问题。
十九、坐标转换里的一个产品化风险:eval
在 parsing_response_to_pyautogui_code 中,源码会用:
eval(start_box)
把字符串形式的 box 转成 Python 列表或元组。
这在示例和研究代码里很方便,但如果做真实产品,需要谨慎。
因为模型输出属于不可信输入,直接 eval 存在风险。
更安全的做法是:
import ast
coords = ast.literal_eval(start_box)
或者干脆自己写严格解析:
只允许数字、逗号、中括号、小数点、负号;
解析后检查长度必须是 2 或 4;
每个数必须在合理范围内。
如果坐标已经归一化,还应该检查:
0 <= x <= 1
0 <= y <= 1
如果是绝对坐标,则检查:
0 <= x <= image_width
0 <= y <= image_height
坐标转换不是只要“能跑”,还要考虑安全性和鲁棒性。
二十、二次开发时如何设计自己的坐标协议?
如果你要基于 UI-TARS 做自己的桌面自动化产品,我建议坐标协议遵循几个原则。
第一,模型输出层可以保留友好格式:
click(point='<point>x y</point>')
这样对模型比较自然。
第二,Parser 内部统一成:
start_box
end_box
这样执行层逻辑更简单。
第三,结构化动作中统一存归一化坐标:
[0.1, 0.2, 0.1, 0.2]
这样可以适配不同分辨率。
第四,执行层最后再还原到真实屏幕坐标。
real_x = normalized_x * screen_width
real_y = normalized_y * screen_height
第五,所有坐标进入执行层前必须校验。
长度校验
类型校验
范围校验
安全区域校验
高风险按钮校验
这样设计,会比在每个动作里临时处理坐标稳定很多。
总结
这篇文章我们分析了 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
同时,一个点坐标会被扩展成四个数:
[x, y] → [x, y, x, y]
这样后续执行层就可以统一通过 box 中心点计算真实点击位置。
从整体流程看,坐标转换链路是:
模型输出 point 标签
↓
convert_point_to_coordinates
↓
字段名统一为 start_box / end_box
↓
parse_action 解析参数
↓
根据模型类型做坐标归一化
↓
结构化 action_inputs
↓
pyautogui 阶段还原真实坐标
这套设计的核心价值是:
让不同模型、不同动作、不同坐标格式,最终收敛到同一种执行层可以消费的结构。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)