前言
在上一篇文章中,我们跑通了 UFO 的 Hello World。很多同学在后台问:“UFO 到底是怎么知道‘文件’菜单在哪里的?是靠 OCR 文字识别,还是靠图像识别?”
答案可能会让你惊讶:它两者都不是,或者说,它比单纯的图像识别更‘懂’系统。
UFO 采用了一种**“底层 UI 树 + 视觉标注 (Set-of-Marks)”** 的混合策略。它不光截了图,还在图上给每个可点击的按钮贴了个“数字标签”。
今天这篇,我们深入 ufo/automator/ui_control 模块,拆解它**“截图 -> 探查控件 -> 绘制标注”**的三步走核心逻辑。
📸 核心原理:为什么只给截图还不够?
如果直接把一张原始的 Windows 截图丢给 GPT-4V,并问它:“文件按钮的坐标是多少?”,GPT-4V 虽然能看懂图,但很难给出精确到像素的坐标。一旦点歪了,任务就挂了。
UFO 的解决方案非常聪明,遵循了 Set-of-Marks (SoM) 的设计思想:
-
透视眼:利用 Windows API 获取当前窗口所有控件的真实坐标。
-
打标签:在截图中,用红框把控件圈出来,并标上序号(比如 #1, #2)。
-
看图说话:把处理过的图发给 GPT-4V,说:“你要操作的功能是几号?”
-
精确打击:GPT-4V 回复“#5”,UFO 查表得知 #5 的中心坐标是 (100, 200),执行点击。
🔍 第一步:开启“透视眼” (Inspector)
UFO 获取控件信息的代码主要集中在 ufo/automator/ui_control/inspector.py (或类似命名,视具体版本而定) 以及依赖库 pywinauto 的调用上。
我们来看它是如何遍历当前窗口的 UI 树的。
1. 锁定目标窗口
UFO 不会无脑截全屏,它会先聚焦到你指定的 App。
Python
# 伪代码逻辑演示
from pywinauto import Desktop
def get_application_window(app_name):
# 连接到桌面
desktop = Desktop(backend="uia") # 重点:使用 uia (UI Automation) 后端
# 模糊匹配窗口标题
window = desktop.window(title_re=f".*{app_name}.*")
window.set_focus() # 强制前台显示
return window
💡 源码划重点:注意
backend="uia"。Windows 自动化有两种后端:win32(老旧) 和uia(新一代)。UFO 使用uia,因为它可以获取更丰富的控件属性(如按钮类型、文本内容、甚至是否可用)。
2. 遍历 UI 元素
拿到窗口对象后,UFO 需要递归查找里面的所有子控件(按钮、输入框、菜单)。
Python
# ufo/automator/ui_control/inspector.py 核心逻辑
def inspect_ui_elements(window):
# 获取所有后代控件
# control_type 过滤:我们只关心 Button, Edit, MenuItem 等可交互元素
elements = window.descendants(control_type=["Button", "Edit", "MenuItem", ...])
valid_elements = []
for elem in elements:
# 过滤掉不可见或尺寸为0的幽灵控件
if elem.is_visible() and elem.rectangle().width() > 0:
valid_elements.append(elem)
return valid_elements
这一步结束后,UFO 手里不仅有了截图,还有了一张**“藏宝图”**:它知道屏幕上每一个按钮的精确 (left, top, right, bottom) 坐标。
🎨 第二步:绘制“数字标签” (Annotator)
拿到控件列表后,UFO 需要把这些信息“画”在图片上。这部分代码通常涉及 PIL (Pillow) 库的操作。
我们深入 screenshot.py 或 drawer 相关模块:
1. 截图与绘制
UFO 会对原始截图进行深加工。
Python
from PIL import Image, ImageDraw, ImageFont
def draw_labels(original_image, element_list):
# 创建一个可以在上面画画的对象
draw = ImageDraw.Draw(original_image)
# 建立一个映射表:ID -> 控件对象
control_map = {}
for index, element in enumerate(element_list):
# 获取控件的矩形坐标
rect = element.rectangle()
box = (rect.left, rect.top, rect.right, rect.bottom)
# 1. 画红框 (Bounding Box)
draw.rectangle(box, outline="red", width=2)
# 2. 画数字背景和数字
label_text = str(index + 1)
# ... (此处省略计算文字位置的代码,通常在左上角)
draw.text((box[0], box[1]), label_text, fill="yellow")
# 3. 记录映射关系,供后续点击使用
control_map[index + 1] = element
return original_image, control_map
2. 视觉效果分析
经过这一步处理,原本干干净净的记事本界面,变成了一张**“麻子脸”**:到处都是红框和黄色的数字。
-
对人类来说:这图乱糟糟的,很丑。
-
对 GPT-4V 来说:这是神图!它不需要猜测“保存”按钮在哪里,它只需要看到“保存”按钮上写着
15,然后告诉 UFO:“去按 15 号”。
🧠 第三步:构建 Prompt (提示词)
有了标注图,UFO 还需要把辅助信息打包成 Prompt 发给大脑。
在 ufo/prompter/agent_prompter.py 中,你会看到类似的构造逻辑:
Python
# 构造发给 GPT-4V 的消息
messages = [
{
"role": "system",
"content": "You are an agent controlling Windows..."
},
{
"role": "user",
"content": [
{"type": "text", "text": "Here is the screenshot with annotated labels..."},
# 传入那张画满红框的图
{"type": "image_url", "image_url": annotated_image_base64}
]
}
]
关键点:UFO 不仅传图片,还会把控件的文本列表作为辅助文本传过去。
比如:Index 15: Button "Save", Position: (200, 300)。
这构成了双重保障:GPT 既看图,又看文字描述,极大降低了幻觉概率。
🛠️ 源码调试小技巧
如果你想亲眼看看 UFO 到底截了什么图,可以在源码中插桩。
找到 screenshot.py 里的截图保存逻辑,加入一行代码:
Python
# 在图片发送给 GPT 之前
annotated_image.save("debug_annotated_screen.png")
print("已保存调试截图!")
运行一次任务,打开这张 debug_annotated_screen.png,你就能以“AI 的视角”看世界了。你会发现,有时候红框可能会重叠,或者把不该框的框进去了,这就是当前 AI Agent 偶尔操作失误的根本原因——视觉预处理的噪声。
📝 总结与下期预告
通过阅读源码,我们发现 UFO 的“视觉”其实是一个障眼法:
-
它利用 Pywinauto 扒取底层数据,获得绝对精准的坐标。
-
它利用 Pillow 绘制红框和数字,充当 GPT-4V 的“眼镜”。
-
它通过 ID 映射,把复杂的“点击坐标 (x,y)”问题,简化成了“做选择题 (选 ID)”的问题。
思考题:
如果 GPT-4V 告诉我们要点击 ID: 15,UFO 是如何控制鼠标平滑移动过去并执行双击的?如果点击失败了怎么办?
下期预告:
下期我们将进入“肢体动作”篇,剖析 automator 模块。
《UFO 源码实战 (3):它怎么“点”鼠标的?通过源码掌握 Windows 自动化控制》,带你研究如何用 Python 模拟人类真实的鼠标轨迹!
喜欢这个系列的源码解读吗?欢迎点赞、收藏,你的支持是我更新的动力! 👋


984

被折叠的 条评论
为什么被折叠?



