在上一篇文章中,我们分析了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存在风险。
更安全的做法是:
importast 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 阶段还原真实坐标这套设计的核心价值是:
让不同模型、不同动作、不同坐标格式,最终收敛到同一种执行层可以消费的结构。