跳到主要内容

数据截至 (上游 commit 96983c73ed09)

第 3 章 · 它怎么「看见」屏幕

这章讲什么: 屏幕上那一堆像素,是怎么变成模型能读的「控件清单 + 编号图」的。这是 computer-use agent 最核心的一块,也是各家做法差别最大的地方。


3.1 要解决的小问题

模型要点一个按钮,总得先知道屏幕上有哪些按钮。有两条路:

  • 问操作系统。 Windows 有 UI Automation(UIA)API,能把窗口里的控件树完整枚举出来,带名字、类型、坐标。准、快,但对自绘界面(游戏、Canvas、部分 Electron 应用)看不见东西。
  • 看图像。 用视觉模型在截图上框出「这看起来像个按钮」。什么界面都能看,但慢、会漏、会框错。

UFO 的答案是:两条都要,再合并去重。


3.2 全景:一次感知走完的路

怎么读这张图:从上到下是时间顺序;左边两路并行,在「合并」处汇合。

截当前应用窗口(干净图)

┌────────┴────────┐
▼ ▼
① 问 UIA 要控件 ② 视觉模型框控件
(uia backend) (omniparser backend)
└────────┬────────┘

③ IoU 去重合并,给新控件补编号

④ 登记进 TargetRegistry(编号 → 控件)

⑤ 画 SoM 标注图(彩色框 + 编号)

送进 prompt:清单 + 干净图 + 标注图

开关在 config/ufo/system.yaml:11CONTROL_BACKEND,默认 ["uia"]。写成 ["uia", "omniparser"] 就两路都开。整条流程实现在 AppControlInfoStrategy.execute(ufo/agents/processors/strategies/app_agent_processing_strategy.py:475-573)。


3.3 第一路:问 Windows 要控件树

基本形状

ControlInspectorFacade 是个门面,底下按后端名分出 UIABackendStrategyWin32BackendStrategy(ufo/automator/ui_control/inspector.py:466:174:380)。

只有列在 CONTROL_LIST 里的控件类型才会被枚举(config/ufo/system.yaml:28),共 15 类:Button、Edit、TabItem、Document、ListItem、MenuItem、ScrollBar、TreeItem、Hyperlink、ComboBox、RadioButton、Spinner、CheckBox、Group、Text。

性能上做了真功夫

朴素写法是遍历控件树、逐个读属性——每次读属性都是一次跨进程 COM 调用,慢到不可用。

UIABackendStrategy.find_control_elements_in_descendants 的做法(ufo/automator/ui_control/inspector.py:206-311):

# 示意,非源码 —— 演示批量取缓存的思路
condition = build_condition(control_type_list, is_visible, is_enabled) # 一次性拼过滤条件
cache_request = build_cache_request() # 声明我要哪些属性
elems = window_com_ref.FindAllBuildCache( # 一次调用全拿回来
scope=TreeScope_Descendants, condition=condition, cacheRequest=cache_request)

for elem in elems[:500]: # 硬上限 500 个
info = UIAElementInfoFix(elem, True, source="uia")
info._cached_name = elem.CachedName # 直接填缓存,不再回查
info._cached_rect = to_rect(elem.CachedBoundingRectangle)
...

四个能抄的点:

手法效果
FindAllBuildCache 一次拿全把 N 次跨进程调用压成 1 次
手动回填 _cached_* 字段后续读属性零开销
硬上限 500 个控件防某些应用控件爆炸拖垮整步
name 顶替 rich_text少取一个昂贵属性(源码注释写明「目前用不到,需要时可回退」)

还有一个更细的:UIAElementInfoFix.sleep 会看一个 _time_delay_marker 标志决定睡 1ms 还是 20ms(ufo/automator/ui_control/inspector.py:101-110)——这是在跟 pywinauto 内部的等待逻辑较劲。


3.4 第二路:视觉检测

配置了 OmniParser 端点时,AppControlInfoStrategy 会实例化一个 OmniparserGrounding 服务(_init_omniparser_service,ufo/agents/processors/strategies/app_agent_processing_strategy.py:460-473),把干净截图丢过去,拿回一组框。

可调的四个阈值来自 config/ufo/system.yamlOMNIPARSER 段,在 _collect_grounding_controls 里被读出来(:628-657):BOX_THRESHOLDIOU_THRESHOLDUSE_PADDLEOCRIMGSZ

这一路整个包在 try/except 里,失败就返回空列表(:655-657)。也就是说视觉这一路是增强,挂了不影响 UIA 主路。


3.5 合并:用 IoU 判重

思路

两路可能框到同一个按钮。判定「是不是同一个」用的是 IoU(交并比):两个矩形重叠面积 ÷ 并集面积。超过阈值就认为是同一个,丢掉视觉那一份。

实现出奇地朴素

# 真实实现,见 ufo/automator/ui_control/screenshot.py:1317-1344
merged = main_target_list.copy() # UIA 那份全保留
for additional in additional_target_list: # 视觉那份逐个看
if not any(target_info_iou(additional, m) > iou_overlap_threshold
for m in main_target_list):
merged.append(additional) # 跟谁都不重叠,才收进来
return merged

这是 O(n·m) 的暴力两重循环,没有空间索引。控件数被限在 500 以内,所以够用。

阈值来自 IOU_THRESHOLD_FOR_MERGE,默认 0.1(config/ufo/system.yaml:12)。0.1 很低,意味着策略偏保守:稍微沾边就认为重复,宁可少给模型几个视觉框,也不要给两个指向同一按钮的编号。

合并后要补编号

新进来的视觉控件没有 id。代码找出 UIA 那批里最大的数字 id,从它往后接着编(_find_added_controls + 编号逻辑,:660-706),然后发一条 add_control_list 命令,把这批新控件同步回执行侧(_send_add_control_list_command,:734-770)。

最后一步很关键:执行侧要能按 id 找回控件才点得中。这条同步命令就是把「编号 → 控件」这张表推到执行端去。


3.6 编号表:TargetRegistry

所有被采纳的控件都登记进一个 TargetRegistry(ufo/agents/processors/schemas/target.py:32)。它做两件事:

  • 没 id 的自动发一个自增号(register,:45-68)。
  • 提供 to_list(keep_keys=[...]),只导出模型需要的字段。

每个条目是一个 TargetInfo(:18),字段只有五个:kindnameidtyperectkind 是个三值枚举——window(窗口)、control(控件)、third_party_agent(第三方 agent)。最后一种让 HostAgent 可以「把活派给另一个 agent」跟「打开一个应用」用同一套选择机制,详见第 4 章。

源码里有一句注释值得留意:id 标着 only valid at current step(:25)。编号每一步重发,不跨步有效。这解释了为什么 prompt 里反复要求模型只用当前这一步给出的清单。


3.7 画标注图(SoM)

标注由 AnnotationDecorator 完成(ufo/automator/ui_control/screenshot.py:508)。每个控件画一个小方块贴在左上角,方块里是编号,方块底色按控件类型区分。

配色表写死在配置里(config/ufo/system.yaml:34-44),例如 Button 是浅黄 #FFF68F、Edit 是浅绿 #A5F0B5、Hyperlink 是青色 #91FFEB同类控件同色,是给模型的一个额外线索。

一个性能细节

渲染一个数字标签需要造字体、量文字尺寸、画框——每步几百个控件就是几百次。代码给这两个函数加了 functools.lru_cache(ufo/automator/ui_control/screenshot.py:573-611):

@staticmethod
@functools.lru_cache(maxsize=2048, typed=False)
def _get_button_img(label_text, ...): ...

@staticmethod
@functools.lru_cache(maxsize=64, typed=False)
def _get_font(name: str, size: int): ...

因为编号和配色的组合是有限的,缓存命中率极高。这是「纯函数 + 缓存」最教科书的用法。


3.8 送进 prompt 的到底是什么

AppLLMInteractionStrategy 组装图像时给了三种可能(_collect_image_strings,ufo/agents/processors/strategies/app_agent_processing_strategy.py:955-996):

配置项效果
INCLUDE_LAST_SCREENSHOT(默认 True)附上一步的截图,且上一步选中的控件用红框标出
CONCAT_SCREENSHOT(默认 False)把「干净图 + 标注图」左右拼成一张
两者都关只发干净图和标注图两张

上一步截图的取法有个降级:优先找 action_step{N-1}_selected_controls.png(带红框的),没有才退回 action_step{N-1}.png(:878-887)。

文字侧只给三个字段。control_info 是这么裁的(:852-856):

control_info = target_registry.to_list(keep_keys=["id", "name", "type"])

坐标 rect 不给模型。 模型全程只在编号空间里工作,坐标由程序在执行侧解析。这跟很多直接让模型吐 (x, y) 的方案是根本性的分歧——代价是必须先有一份可靠的控件清单,收益是不会点歪。

prompt 里对这三张图的用法也讲得很细:左边是干净图、右边是标注图、另有上一步的图用来对比「上一个动作到底生效没有」(ufo/prompts/share/base/app_agent.yaml,## On screenshots 一节)。


3.9 还有一层没启用的过滤器

仓库里存在一套按「计划文本」筛控件的过滤器(ufo/automator/ui_control/control_filter.py):

怎么筛
TextControlFilter计划里的关键词跟控件名做字符串匹配
SemanticControlFilter用 sentence-transformers 算语义相似度取 top-k
IconControlFilter用图像 embedding 匹配裁下来的图标

从当前主管线(AppControlInfoStrategy.execute)里看不到对它们的调用——控件是全量送进 prompt 的。这套过滤器更像是给控件极多的场景准备的可选件。(inferred:代码里没有说明它为何未接入主线。)


3.10 代码地图

主题文件路径符号名
感知阶段主流程ufo/agents/processors/strategies/app_agent_processing_strategy.pyAppControlInfoStrategy.execute
截图阶段ufo/agents/processors/strategies/app_agent_processing_strategy.pyAppScreenshotCaptureStrategy
UIA 批量枚举(性能核心)ufo/automator/ui_control/inspector.pyUIABackendStrategy.find_control_elements_in_descendantsUIAElementInfoFix
后端门面与工厂ufo/automator/ui_control/inspector.pyControlInspectorFacadeBackendFactory
视觉检测接入ufo/agents/processors/strategies/app_agent_processing_strategy.py_init_omniparser_service_collect_grounding_controls
IoU 计算与合并ufo/automator/ui_control/screenshot.pyPhotographerFacade.target_info_ioumerge_target_info_list
新控件补号与回推ufo/agents/processors/strategies/app_agent_processing_strategy.py_collect_merged_control_list_find_added_controls_send_add_control_list_command
编号表ufo/agents/processors/schemas/target.pyTargetRegistryTargetInfoTargetKind
SoM 标注渲染ufo/automator/ui_control/screenshot.pyAnnotationDecoratordraw_rectangles_controls_get_button_img
截图门面ufo/automator/ui_control/screenshot.pyPhotographerFacade
prompt 图像组装ufo/agents/processors/strategies/app_agent_processing_strategy.pyAppLLMInteractionStrategy._collect_image_strings
感知相关配置config/ufo/system.yamlCONTROL_BACKENDCONTROL_LISTIOU_THRESHOLD_FOR_MERGEANNOTATION_COLORS
未接入的控件过滤器ufo/automator/ui_control/control_filter.pyControlFilterFactorySemanticControlFilter