跳到主要内容

眼睛:控件检测与视觉定位(UIA + 视觉混合 grounding)

30 秒导读: LLM 看不见屏幕。它只会读文字。所以在 UFO 让 AppAgent「动手」之前(那是 第 04 章 的事),必须先把当前这一屏翻译成 LLM 读得懂的东西:一份「① 号是保存按钮、② 号是地址栏……」的控件清单,外加一张在每个控件上都贴了号码贴纸的截图。这一章讲的就是这台「翻译机」——它怎么看见控件、怎么给控件编号定位、怎么把噪声控件筛掉


1. 这是什么(零基础也能懂)

一句话定义: 感知层(perception / grounding)就是 GUI Agent 的眼睛——把「屏幕上的像素」变成「一份可操作元素的结构化清单 + 一张带标号的图」。

为什么非要它不可? 想象你让一个只会打字、看不见屏幕的朋友帮你操作电脑。你不能说「点那个蓝色的按钮」,因为他看不见。你得先给他一份清单:「1 号 = 文件菜单,2 号 = 保存按钮,3 号 = 搜索框……」,他才能回你「点 2 号」。LLM 就是这个朋友。感知层负责生成那份清单。

它要解决的三个真问题:

问题白话
屏幕上有什么可点的?枚举出所有按钮、菜单、输入框……
每个东西在哪里?拿到每个控件的像素坐标(bounding box)
怎么让 LLM 指得准?给每个控件一个短标号(1/2/3),LLM 回标号即可,不用报坐标

用起来什么样(直觉): 感知层跑完一轮,产出两样东西——

  1. 一个 annotation_dict:{"1": 保存按钮控件, "2": 地址栏控件, ...},喂给 LLM 的文本清单来自它。
  2. 一张 ..._annotated.png:原截图上,每个控件左上角盖了一个带号码的小黄标签(见下文 set-of-mark)。

LLM 读到「清单 + 图」,回一句「点 2 号」,系统再从 annotation_dict["2"] 拿回真正的控件对象去执行。

一句话类比: 就像给一张风景照做「图像标注」——先框出所有物体,再给每个框贴上编号,这样别人用「3 号框」就能精确指认,而不必描述「左上角那个偏红的小东西」。

本节不涉及底层代码。记住一个心智模型即可:感知层 = 屏幕 → 带编号的控件清单 + 标注图。


2. 顶层全景(它大概怎么转)

2.1 两条感知通路,为什么要两条

UFO 看屏幕有两只眼睛,各有各的盲区,所以要一起用(hybrid detection):

通路怎么「看」
UIA / Win32 结构化枚举问操作系统:「这个窗口里注册了哪些控件?」直接拿到名字、类型、坐标准、快、带语义(知道这是 Button 还是 Edit)只看得见「守规矩」的原生控件;自绘 UI、Canvas、游戏、图标里的东西它看不见
纯视觉检测(grounding model)把截图丢给视觉模型(OmniParser),让它像人一样「框出」屏幕上长得像控件的东西能看见任何长得像控件的像素(图标、自绘按钮)慢(要跑模型)、可能框错、语义弱(它只知道「这里有个可点的东西」)

hybrid 的动机一句话: UIA 负责「规矩控件」拿得又准又快,视觉模型负责「补上 UIA 看不见的那些」。两者相加,盲区互补。合并时用 IoU(交并比)去掉重复框(§3.4)。

配置项 control_backend 决定用哪几条通路,默认只开 UIA(config/config_schemas.py:269,["uia"]);写成 ["uia", "omniparser"] 就开启混合。

2.2 一张图:一屏怎么变成 LLM 的输入

下面这张图从上到下就是感知层一轮的数据流。怎么读: 左边两条通路并行产出控件,中间合并去重,右边打号出图。

┌─────────────────────── 感知层一轮 (AppControlInfoStrategy.execute) ────────────────────┐
│ │
当前应用 │ 通路A:结构化枚举(UIA/Win32) │
窗口 ──► │ inspector.find_control_elements_in_descendants │
│ └─ 向 OS 要控件 ──► [按钮, 菜单, 输入框...] 带名字/类型/坐标 │
│ │ │
│ 通路B:纯视觉检测(可选) ┌──────────┐ │
干净截图 ─┼─► omniparser.screen_parsing ─► 视觉模型框控件 ─► 相对bbox │ 合并去重 │ │
│ (grounding_model/OmniParser 服务) 换算成绝对坐标 ─►│ IoU dedup │──► 合并清单 │
│ └──────────┘ │ │
│ ▼ │
│ 控件过滤(可选)control_filter:按文本/语义/图标相似度筛掉与当前计划无关的噪声控件 │
│ │ │
│ ▼ │
│ 截图标注(SoM)AnnotationDecorator:给每个控件左上角盖「①②③」小标签 │ │
│ ▼ │
└──────────────────────────► annotation_dict {"1":ctrl, "2":ctrl,...} + 标注PNG ──────────┘

▼ (交给 [第 02 章] 的 Processor 组 prompt)
LLM 读「清单+图」→ 回「点 2 号」

▼ annotation_dict["2"] → 真实控件对象
ControlReceiver 执行动作([第 04 章])

2.3 部件一句话职责(代码地址速查)

部件干什么文件
ControlInspectorFacade感知层的门面:枚举桌面窗口、枚举窗口内控件、抽控件字段ufo/automator/ui_control/inspector.py:466
UIABackendStrategy / Win32BackendStrategy两种结构化枚举后端的具体实现inspector.py:174 / :380
UITree把控件按父子关系建成一棵可序列化的 UI 树ufo/automator/ui_control/ui_tree.py:20
OmniparserGrounding纯视觉通路:调视觉模型、把相对框换成绝对坐标ufo/automator/ui_control/grounding/omniparser.py:25
OmniParser(服务)真正的网络客户端,把截图发给 OmniParser 后端ufo/llm/grounding_model/omniparser_service.py:8
BasicGroundinggrounding 抽象基类 + 把视觉框包成「虚拟 UIA 控件」ufo/automator/ui_control/grounding/basic.py:80
AnnotationDecorator截图打标号(set-of-mark)ufo/automator/ui_control/screenshot.py:508
ControlFilterFactory 及三种过滤器按文本/语义/图标相似度筛掉无关控件ufo/automator/ui_control/control_filter.py:10
ControlReceiver把「检测到的控件」接到「可执行动作」ufo/automator/ui_control/controller.py:45
AppControlInfoStrategy编排上面这一整套(在流水线里的那一步)ufo/agents/processors/strategies/app_agent_processing_strategy.py:475

主编排在 AppControlInfoStrategy.execute(app_agent_processing_strategy.py:475),四步一目了然:Step 1 收 UIA 控件(:501)、Step 2 收视觉控件(:513)、Step 3 合并去重(:531)、Step 4 出标注图(:544)。下面逐个机制拆开讲。


3. 核心原理(逐个机制,由浅入深)

3.1 结构化枚举:直接问操作系统「有哪些控件」

要解决的小问题: 一个应用窗口里到底有哪些可点的控件?它们叫什么、什么类型、在哪个坐标?

思路: Windows 自带一套无障碍框架 UIA(UI Automation),窗口里每个规范控件都在里面注册过。所以最快的路子不是「看」,而是「问」——直接向 UIA 要一份控件清单。

两种后端,一个工厂选: BackendFactory.create_backend(inspector.py:37)按字符串产出 UIABackendStrategyWin32BackendStrategy,它们实现同一个抽象接口 BackendStrategy(inspector.py:52)。这是典型的策略模式:上层只管调 find_control_elements_in_descendants,不关心底层是 UIA 还是 Win32。

UIA 后端的关键优化——批量缓存拉取。 逐个控件去问属性(名字、类型、坐标)会触发大量跨进程 COM 调用,极慢。UFO 的做法是一次性批量拉:先构造一个 cache 请求,把要的属性(控件类型、名字、包围盒)预声明好,再用 FindAllBuildCache 一趟把所有后代控件连同缓存属性一起拿回来。

看真实实现(inspector.py:251,UIABackendStrategy.find_control_elements_in_descendants):

com_elem_array = window_elem_com_ref.FindAllBuildCache(
scope=iuia_dll.TreeScope_Descendants, # 遍历所有后代
condition=condition, # 过滤条件(见下)
cacheRequest=cache_request, # 预声明要缓存的属性
)

cache_request(inspector.py:324,_get_cache_request)提前登记了三个属性:UIA_ControlTypePropertyIdUIA_NamePropertyIdUIA_BoundingRectanglePropertyId——正好是编号和定位需要的最小字段集。拿回来后用 CachedControlType / CachedName / CachedBoundingRectangle 读缓存值(inspector.py:257),不再二次跨进程。

在 OS 层就先过滤,省 token 也省时间。 过滤条件在 _get_control_filter_condition(inspector.py:333)里用 UIA 的 And/Or 条件拼出来:只要「可用(IsEnabled)+ 在屏(IsOffscreen=false)+ 是控件元素(IsControlElement)+ 类型在允许列表里」的元素。噪声在进程边界之前就被 UIA 自己筛掉了。

还有个硬上限防爆炸: 最多只取前 500 个控件(inspector.py:266,min(com_elem_array.Length, 500))——控件太多会撑爆后面的 prompt。

Win32 后端是纯 Python 侧过滤。 对照看 Win32BackendStrategy.find_control_elements_in_descendants(inspector.py:404):它先 window.descendants() 全拿,再在 Python 里按 is_visible / is_enabled / 标题 / 类型逐层 list 推导式筛。逻辑直白但没有 UIA 那套缓存优化。

每个控件抽成一个字典。 最终 ControlInspectorFacade.get_control_info(inspector.py:636)把一个控件读成 {control_type, control_name, control_rect, source, ...},其中 source 字段("uia",见 inspector.py:273)记录了这个控件来自哪条通路——合并阶段会用到,用来区分「结构化」还是「视觉」来源。

一个有意思的性能细节: UIAElementInfoFix._time_wrap(inspector.py:112)给每次属性 lookup 计时,超过 20ms 打 [❌]、超过 5ms 打 [⚠️],并置一个 _time_delay_marker 让下次 sleep 变长——作者在跟 UIA 的偶发性能抖动死磕(inspector.py:186 的注释直说 UIA 有时会「severe performance」)。

3.2 UI 树:不只是列表,还保留父子结构

要解决的小问题: 3.1 拿到的是一个扁平列表。但有时需要知道控件之间的层级关系(谁包含谁),比如做前后两屏的差异对比。

思路: 从根控件递归下钻,把每个节点连同坐标记成一棵树。UITree._get_ui_tree(ui_tree.py:48)对每个节点记三种坐标:

坐标含义怎么算
rectangle屏幕绝对坐标直接读控件
adjusted_rectangle相对窗口左上角的像素坐标coordinate_adjusted(screenshot.py:361)
relative_rectangle相对窗口的比例坐标(0~1)coordinate_adjusted_to_relative(screenshot.py:379)

比例坐标很关键:它与分辨率无关,方便跨截图对齐,也和视觉模型输出的相对 bbox 是同一种表达(§3.3 会用到)。

两个实用能力:

  • flatten_ui_tree(ui_tree.py:122)——把树按广度优先拍扁回列表(建树时保结构,用时可摊平)。
  • ui_tree_diff / apply_ui_tree_diff(ui_tree.py:151 / :229)——算两棵 UI 树的 added / removed / modified 差异并能回放。这让 agent 能感知「上一步操作后界面变了什么」,是把「一步动作的后果」喂回给记忆黑板([第 02 章])的原料。

3.3 纯视觉检测:让模型「看」出 UIA 看不见的控件

要解决的小问题: 图标、自绘按钮、Canvas 里的元素,UIA 里根本没注册,结构化枚举永远看不见。怎么办?

思路: 换一只眼睛——把截图交给一个视觉检测模型(OmniParser),让它像人一样在图上框出「看起来能点的东西」。这就是 grounding(视觉定位):从像素直接得到框。

三层结构,职责分明:

OmniparserGrounding (grounding/omniparser.py:25) ← 业务逻辑:调服务、解析、换算坐标
│ 持有

OmniParser 服务 (llm/grounding_model/omniparser_service.py:8) ← 网络客户端:gradio 调远端模型

服务层就是个薄客户端。 OmniParser.chat_completion(omniparser_service.py:20)用 gradio_client 把图片文件发给远端 OmniParser 服务(omniparser_service.py:34,self.client.predict(...)),端点地址来自配置。它不懂控件语义,只负责「发图、收结果」。

业务层负责解析和坐标换算。 OmniparserGrounding.predict(omniparser.py:32)拿到服务返回的字符串,逐行 json.loads;解析失败还有个兜底——用 ast.literal_eval 去截取 {...} 段落再解析(omniparser.py:76),因为模型有时吐的是 Python dict 的字符串形式而非严格 JSON。这是跟不老实的模型输出打交道的典型容错。

关键:相对框 → 绝对坐标。 视觉模型返回的 bbox 是相对整张图的比例(0~1)。要能点,必须换成屏幕绝对像素。_calculate_absolute_coordinates(omniparser.py:160)做这件事:

control_left = int(app_left + control_box[0] * app_width) # 比例 × 窗口宽 + 窗口左
control_top = int(app_top + control_box[1] * app_height)
control_right = int(app_left + control_box[2] * app_width)
control_bottom= int(app_top + control_box[3] * app_height)

即:框的比例值,乘以应用窗口的宽高,再加上窗口左上角偏移,得到屏幕绝对坐标。

只留可交互的。 同一函数开头有个降噪开关(omniparser.py:178):_filter_interactivity 为真(默认真,omniparser.py:30)时,模型标记为「不可交互」的框(纯文本、装饰)直接丢弃,返回 None

统一出口 screen_parsing OmniparserGrounding.screen_parsing(omniparser.py:197)串起全流程:predict → 逐个换算绝对坐标 → 包成 TargetInfo(kind=CONTROL, type, name, rect)(omniparser.py:236)。TargetInfo 是 UFO 全流程通用的目标描述,这样视觉控件就和 UIA 控件长成同一种类型,能一起进合并环节。

3.4 hybrid 合并:两只眼睛看到的,用 IoU 去重

要解决的小问题: 同一个按钮,UIA 看见了,视觉模型也框了。合并时不能算两个,得去重。

思路:IoU(Intersection over Union,交并比) 判断两个框是不是同一个东西——两框重叠面积 / 两框并集面积,越接近 1 越像是同一个。

规则一句话:UIA 结果全保留,视觉结果只保留「和任何 UIA 框都不太重叠」的那些。 看去重核心 merge_control_list(screenshot.py:1247):

merged_control_list = main_control_list.copy() # 主表(UIA)全留
for additional_control in additional_control_list: # 遍历视觉表
is_overlapping = False
for main_control in main_control_list:
if control_iou(additional_control, main_control) > iou_overlap_threshold:
is_overlapping = True # 和某个 UIA 框重叠 → 认作重复
break
if not is_overlapping:
merged_control_list.append(additional_control) # 只补 UIA 没覆盖到的

阈值 iou_threshold_for_merge 默认 0.1(config/config_schemas.py:270)——重叠超过 10% 就当同一个,倾向于「宁可少补、不重复」。IoU 的几何计算在 target_info_iou(screenshot.py:1276,面向 TargetInfo 的版本)里:算交集面积 / 并集面积,并集为 0 时返回 0 防除零。

编排侧的落地在 AppControlInfoStrategy._collect_merged_control_list(app_agent_processing_strategy.py:660):它调 merge_target_info_list(:674)完成合并,再用 _find_added_controls(:711)找出「视觉补进来、UIA 里没有」的那批,给它们从 UIA 最大 id 之后续号(:694),避免和结构化控件的编号撞车。这样最终 LLM 看到的是一份连续编号、无重复的统一清单。

3.5 截图 + set-of-mark:给每个控件盖号码贴纸

要解决的小问题: 就算有了清单,LLM 怎么在图上「指」某个控件?让它报坐标很不靠谱。

思路——set-of-mark(SoM,标记集): 不让模型报坐标,而是在截图上给每个控件盖一个带编号的小标签,模型只要回「点 2 号」。这把「视觉定位」这个模型不擅长的活,转成「读号码」这个它很擅长的活。这是 UFO 感知层最核心的一招。

编号怎么来。 AnnotationDecorator.get_annotation_dict(screenshot.py:632)按控件在列表里的次序生成标号:number 模式就是 1,2,3...(screenshot.py:640),letter 模式用 number_to_letter 转成 A,B,...,Z,AA,...(screenshot.py:613)。返回的 annotation_dict{标号: 控件对象}——这就是后面把「LLM 说的号」还原成「真实控件」的那张查找表。

贴纸怎么画。 draw_rectangles_controls(screenshot.py:535)在控件左上角贴一个预渲染的号码按钮图;号码按钮由 _get_button_img(screenshot.py:573)生成——黄底、黑字、红框的小方块。这个函数带 @functools.lru_cache(maxsize=2048):同一个号码只渲染一次,后面直接复用,省渲染开销。

颜色可按控件类型区分。 capture_with_annotation_dict(screenshot.py:665)两遍走:第一遍(可选)highlight_bbox 用半透明色块把控件区域整体高亮(screenshot.py:693,31% 不透明度);第二遍逐个贴号码标签(screenshot.py:734)。标签颜色来自 annotation_colors(按 control_type 取色,screenshot.py:743),让 Button / Edit / Menu 在图上一眼可分。字号 annotation_font_size 默认 22(config/config_schemas.py:319)。

产物落盘。 编排侧 AppControlInfoStrategy Step 4(app_agent_processing_strategy.py:544)生成 action_step{N}_annotated.png——这张图连同文本清单一起进 prompt。到此,「一屏像素」已经彻底变成了「LLM 读得懂的清单 + 图」。

坐标锚点复用: 贴标签时的位置用的仍是 coordinate_adjusted(screenshot.py:361)把控件绝对框换算成「相对窗口截图」的像素——和 3.2 建 UI 树、3.3 换算视觉坐标是同一套坐标工具,全链路坐标语义统一。

3.6 控件过滤:按「当前计划」筛掉无关噪声,省 token

要解决的小问题: 一个复杂窗口可能有几百个控件。全喂给 LLM,又贵又容易让它分心。能不能只留和「当前这一步要做什么」相关的?

思路: 拿 agent 当前的 plan(自然语言计划)当查询,给每个控件算个相关度,只留最相关的。ControlFilterFactory.create_control_filter(control_filter.py:15)按类型产出三种过滤器:

过滤器怎么判相关代价
TextControlFilter纯字符串:计划里的关键词是否出现在控件名字里最便宜,control_filter.py:164
SemanticControlFilter语义:SentenceTransformer 把计划和控件名各编码成向量,算余弦相似度取 top-k要跑 embedding 模型,control_filter.py:190
IconControlFilter图标:把裁剪出来的控件图标和计划做跨模态(CLIP)相似度,取 top-k最重,control_filter.py:233

文本过滤最直白(TextControlFilter.control_filter,control_filter.py:169):先把计划抽成关键词——plans_to_keywords(control_filter.py:112)只保留纯字母词或纯中文词,滤掉标点数字;再看每个控件名里是否含关键词(或反向包含),命中就留。

语义/图标过滤共享一个基类 BasicControlFilter(control_filter.py:63):它用单例 + 按模型路径缓存(__new___instances,control_filter.py:70)避免同一个 SentenceTransformer 反复加载;control_filter_score(control_filter.py:195)算余弦相似度,heapq.nlargest 取 top-k(control_filter.py:222)。top-k 数量由配置控制(control_filter_top_k_semantic 默认 15 等,config/config_schemas.py:367)。

边界诚实说: 过滤是可选能力,control_filter_type 默认是空列表(config/config_schemas.py:365),即默认不做这层语义筛选。在当前主流水线(app_agent_processing_strategy.py)里,降噪主要靠 §3.1 的 UIA 条件过滤 + 500 上限 + §3.4 的 IoU 去重;ControlFilterFactory 这套是可插拔的额外一层,面向控件特别多的场景。

3.7 检测 → 动作:把「一个号」接回「一个能点的控件」

要解决的小问题: LLM 回了「点 2 号」。从 annotation_dict["2"] 拿到控件对象后,谁来真正执行动作?本章只讲到「接上」,真正怎么点属 [第 04 章]。

接头人是 ControlReceiver(controller.py:45)。它的构造就体现了「检测」和「动作」的对接:传入一个 control(被选中的控件)和 application(所属窗口),构造时立刻 set_focus()wait_enabled()(controller.py:64)——把焦点落到目标控件上,等它可用。之后所有动作都通过 atomic_execution(controller.py:74)反射调用控件对象上的方法(如 click),失败有兜底日志。

视觉控件也能被同样地点——靠「虚拟 UIA 包装」。 这里有个精妙设计:UIA 控件本身是能点的对象,但视觉模型框出来的东西只是坐标,没有可操作对象。BasicGrounding.uia_wrapping(grounding/basic.py:133)把一个「视觉检测框」包装成 VirtualUIAElementInfo(grounding/basic.py:22)再套成 UIAWrapper——一个假的 UIA 控件,拥有和真控件一样的 control_type / name / rectangle 接口。于是下游 ControlReceiver、标注、点击全都不用区分「这个控件是 UIA 来的还是视觉来的」。convert_to_virtual_uia_elements(grounding/basic.py:154)就是把一整张图的视觉框批量转成这种虚拟控件的入口。

命令怎么注册。 ControlReceiver 用类装饰器 @ControlReceiver.registerClickInputCommand(controller.py:574)、ClickOnCoordinatesCommand 等注册进 _command_registry(controller.py:50),UIControlReceiverFactory(controller.py:485)负责产出 receiver。这是命令模式(Command)的一半——完整的 Puppeteer 命令体系是 第 04 章 的主题。本章只需记住:感知层交出的每个控件,最终都能通过 ControlReceiver 变成一个可执行动作的接收者。


5. 巧妙之处(可带走的技术)

  • set-of-mark 把「视觉定位」降维成「读号码」(screenshot.py:632:535):模型不擅长报坐标,但极擅长读「2 号」。给控件贴编号标签,是让 LLM 可靠操作 GUI 的关键一招,也是 annotation_dict 能把「模型说的号」原路还原成「真实控件」的原因。
  • hybrid + IoU 去重,盲区互补又不重复(screenshot.py:1247omniparser.py:160):结构化眼睛准而有盲区,视觉眼睛全而不精;「UIA 全留、视觉只补缺口」这条合并规则,兼顾了准确率和覆盖率。
  • 虚拟 UIA 包装,让视觉框「假装」成原生控件(grounding/basic.py:133):用一个统一接口抹平两条通路的差异,下游标注/点击/记录代码零改动——是「适配器模式」在 agent 感知层的漂亮落地。
  • UIA 批量缓存拉取对抗跨进程慢(inspector.py:251:324):FindAllBuildCache 一趟把「所有后代 + 需要的属性」全拿回,把「N 次跨进程 lookup」压成「1 次」;还配了逐次计时的性能哨兵(inspector.py:112)。
  • 坐标三件套 + 全链路复用(screenshot.py:361:379):绝对/相对像素/比例三种坐标,建树、视觉换算、SoM 贴标签共用同一套换算函数,坐标语义从不打架。

6. 边界与局限(诚实说)

  • 强 Windows 绑定。 整条结构化通路依赖 UIA / Win32 / pywinauto,非 Windows 上这些包被降级成占位对象(inspector.py:23grounding/basic.py:13),感知层实际只在 Windows 跑得起来。
  • 视觉通路需要外部服务。 OmniParser 是个独立的远端服务(omniparser_service.pygradio_client 连端点),没配 ENDPOINT 时 grounding 服务直接为 None、跳过视觉通路(app_agent_processing_strategy.py:472)。它慢、依赖网络、且质量取决于那个模型。
  • 500 控件硬上限(inspector.py:266):控件极多的窗口会被截断,靠后的控件可能根本进不了清单。
  • IoU 去重是几何近似(screenshot.py:1276):完全重叠但语义不同、或错位但其实同一个的边角情况,靠单一阈值判不准;阈值 0.1 偏保守,可能少补一些其实该补的视觉控件。
  • 语义控件过滤默认不开(config/config_schemas.py:365,control_filter_type 默认空):§3.6 那套 embedding/图标过滤是可选加料,默认路径不走它,真要靠它降噪得显式配置并承担模型加载开销。
  • 视觉模型输出不老实要兜底(omniparser.py:76):要 ast.literal_eval 去容错非严格 JSON,说明这层解析本身是脆弱点。

7. 横向对比(UFO 内部与兄弟章)

  • 感知定位之后「怎么动它」 → 看 04-action-hybrid-automation.md:ControlReceiver 如何变成完整的命令模式(Puppeteer),以及 GUI 点击与 API 调用的混合执行。
  • 这一步在整条流水线的哪个位置 → 看 02-processor-strategy-pipeline.md:AppControlInfoStrategy 是 Processor 策略流水线里的「数据收集」阶段,它的产物(annotation_dict、标注图、UI 树 diff)如何进记忆黑板。
  • 谁在什么时候调用感知层 → 看 01-loop-and-agents.md:AppAgent 在每个 Round 里先感知、再决策、后动作的状态机。
  • 跨设备场景下的感知 → 看 05-galaxy-constellation-dag.md:当一个请求被拆到多台设备,每台设备各自跑本章这套感知。

一句话取舍观: UFO 的感知层选择了「结构化优先、视觉兜底」的混合路线——能问 OS 就不去猜像素,问不到才动用视觉模型。这和纯视觉派(全靠截图 + grounding model)相比更准更快,代价是强绑定 Windows UIA 生态。


8. 代码地图(导航索引)

主题文件关键符号
感知层门面 / 桌面与窗口枚举ufo/automator/ui_control/inspector.pyControlInspectorFacadeget_desktop_app_dictget_control_info
UIA 后端(批量缓存枚举)ufo/automator/ui_control/inspector.pyUIABackendStrategy.find_control_elements_in_descendants_get_cache_request_get_control_filter_condition
Win32 后端ufo/automator/ui_control/inspector.pyWin32BackendStrategy.find_control_elements_in_descendants
属性 lookup 计时哨兵ufo/automator/ui_control/inspector.pyUIAElementInfoFix._time_wrap
UI 树构建 / 扁平化 / diffufo/automator/ui_control/ui_tree.pyUITree._get_ui_treeflatten_ui_treeui_tree_diffapply_ui_tree_diff
视觉通路(业务层)ufo/automator/ui_control/grounding/omniparser.pyOmniparserGrounding.predictscreen_parsing_calculate_absolute_coordinates_filter_interactivity
视觉通路(网络客户端)ufo/llm/grounding_model/omniparser_service.pyOmniParser.chat_completion
grounding 抽象 / 虚拟控件包装ufo/automator/ui_control/grounding/basic.pyBasicGroundinguia_wrappingVirtualUIAElementInfoconvert_to_virtual_uia_elements
set-of-mark 标注ufo/automator/ui_control/screenshot.pyAnnotationDecorator.get_annotation_dictdraw_rectangles_controls_get_button_imgcapture_with_annotation_dict
坐标换算ufo/automator/ui_control/screenshot.pyPhotographerDecorator.coordinate_adjustedcoordinate_adjusted_to_relative
hybrid 合并 / IoU 去重ufo/automator/ui_control/screenshot.pymerge_control_listtarget_info_iou
控件过滤(文本/语义/图标)ufo/automator/ui_control/control_filter.pyControlFilterFactoryTextControlFilterSemanticControlFilterIconControlFilterplans_to_keywords
检测→动作对接ufo/automator/ui_control/controller.pyControlReceiveratomic_executionUIControlReceiverFactory
全流程编排(这一步)ufo/agents/processors/strategies/app_agent_processing_strategy.pyAppControlInfoStrategy.execute_collect_uia_controls_collect_grounding_controls_collect_merged_control_list
相关配置默认值config/config_schemas.pycontrol_backendiou_threshold_for_mergeannotation_colorsannotation_font_sizecontrol_filter_type