在上一篇文章中我们分析了parse_action函数。它的作用是把模型输出的函数调用式 Actionclick(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(pointpoint850 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(pointpoint850 120/point)也可能输出drag(start_pointpoint300 500/point, end_pointpoint700 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(pointpoint850 120/point)这对人来说很直观点击这个点。但对后续执行层来说如果每种动作都有自己的坐标字段会让代码变复杂。比如click 用 point drag 用 start_point / end_point scroll 用 point hover 用 point select 用 start_box / end_box那么执行层就要针对每个动作单独判断坐标字段。UI-TARS 的做法是point → start_box也就是说即使是一个点也统一当作“起始位置”处理。例如click(pointpoint850 120/point)经过转换后可以理解成click(start_box(850,120))这样后续执行层只需要从start_box里取坐标。这是一种典型的工程归一化设计模型输出可以有多种友好格式但程序内部最好只有一种稳定格式。四、为什么 start_point 要统一成 start_box拖拽动作一般长这样drag( start_pointpoint300 500/point, end_pointpoint700 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如果 x1x2、y1y2那么中心点就是原始点本身。这种设计的好处是后续 pyautogui 代码生成不需要区分这是点坐标 还是区域坐标统一当成 box 来算中心点即可。六、convert_point_to_coordinates 做了什么convert_point_to_coordinates是坐标转换链路的第一个辅助函数。它主要处理形如point850 120/point或者文本里直接出现的850 120源码中可以看到它使用正则匹配两个数字patternr(\d)\s(\d)然后把它转换成(850,120)同时还会去掉[EOS]。这一步的作用是把模型常见的 point 标签格式转成更适合后续 AST 解析的字符串格式。例如模型原始输出Action: click(pointpoint850 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 可能长这样pointpoint850 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 长度为 4x1, y1, x2, y2如果 box 长度为 2x1, y1 x2 x1 y2 y1接着计算中心点x ((x1 x2) / 2) * image_width y ((y1 y2) / 2) * image_height最后生成pyautogui.click(x,y,buttonleft)源码中 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,duration1.0)源码中drag和select分支正是这样处理的先取 start_box 的中心点再取 end_box 的中心点最后生成 moveTo 和 dragTo。所以start_box 表示动作开始位置 end_box 表示动作结束位置。这比start_point/end_point更统一因为它既能表达点也能表达区域中心。十四、scroll 为什么也用 start_box滚动动作通常是scroll(pointpoint600 720/point, directiondown)经过转换后变成scroll(start_box(600,720), directiondown)为什么滚动也要有坐标因为在 GUI 中滚动不是绝对全局行为而是和鼠标所在区域有关。例如鼠标在左侧菜单上滚动的是菜单 鼠标在主内容区滚动的是页面 鼠标在表格里滚动的是表格 鼠标在弹窗里滚动的是弹窗。所以 scroll 的start_box表示在哪个区域附近执行滚动。在 pyautogui 代码生成阶段源码会读取start_box计算坐标并根据 direction 生成pyautogui.scroll(5, xx, yy)或pyautogui.scroll(-5, xx, yy)如果没有坐标则生成不带 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(pointpoint850 120/point)第一步转换 point 标签pointpoint850 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,buttonleft)这就是 point 到真实点击坐标的完整链路。十七、拖拽动作的完整流程再看一个 drag 示例。模型输出Thought: 我需要把滑块拖到右侧。 Action: drag(start_pointpoint300 500/point, end_pointpoint700 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,duration1.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 coordsast.literal_eval(start_box)或者干脆自己写严格解析只允许数字、逗号、中括号、小数点、负号 解析后检查长度必须是2或4 每个数必须在合理范围内。如果坐标已经归一化还应该检查0 x 1 0 y 1如果是绝对坐标则检查0 x image_width 0 y image_height坐标转换不是只要“能跑”还要考虑安全性和鲁棒性。二十、二次开发时如何设计自己的坐标协议如果你要基于 UI-TARS 做自己的桌面自动化产品我建议坐标协议遵循几个原则。第一模型输出层可以保留友好格式click(pointpointx 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 阶段还原真实坐标这套设计的核心价值是让不同模型、不同动作、不同坐标格式最终收敛到同一种执行层可以消费的结构。