跳到主要内容

视觉重定位:OpenAdapt 最核心的巧思

本章讲 legacy/openadapt/strategies/visual.py + vision.py + adapters/som.py:如何做到「界面挪了位置,动作还能落准」。这是 OpenAdapt 区别于普通宏录制的地方,是整个项目的精华

1. 它要解决的小问题

普通宏记「点 (840, 210)」,窗口一挪、分辨率一变、按钮一换位就废。OpenAdapt 想记的是语义:「点那个『发送』按钮」。可屏幕本身没有「按钮」这个概念,只有像素。于是问题变成:怎么从像素里认出一个个 UI 元素,并给每个元素一个稳定的、可跨场景匹配的锚点?

2. 思路/直觉:分割 → 描述 → 按描述找回

核心三步,录制侧和重放侧各做一遍,靠「描述」这根线对齐:

录制侧(记锚点) 重放侧(找回锚点)
┌────────────────────┐ ┌────────────────────┐
│ 1 分割当前窗口 │ │ 1 分割当前窗口 │
│ (SoM / FastSAM) │ │ (同一套分割) │
│ 2 大模型给每块一句 │ ── 描述做桥 ──► │ 2 大模型给每块一句 │
│ 自然语言描述 │ │ 自然语言描述 │
│ 3 点击落在哪块 → 记 │ │ 3 按「同一句描述」 │
│ 下这块的 description│ │ 找回那块 → 取质心 │
└────────────────────┘ └────────────────────┘

▼ 质心 → 屏幕坐标
去当前屏幕上点它

怎么读这张图: 左右两侧步骤一样;唯一跨越时间的「锚」是那句元素描述。录制记的是「你点的那块叫什么」,重放找的是「现在哪块也叫这个」。坐标只是最后一步的产物,不是记忆的内容。

3. 原理演示(示意,非源码)

# 示意,非源码:按描述重定位的骨架
def reground(action, current_screenshot):
# 1) 把当前窗口切成若干区域(每个区域一个 mask)
seg = segment_window(current_screenshot) # -> descriptions, centroids
# 2) action 里带着录制时记下的元素描述
target_desc = action.active_segment_description # 例如 "蓝色的 Send 按钮"
# 3) 在当前区域描述里找同名的那块
idx = seg.descriptions.index(target_desc)
# 4) 取那块的质心,换算成屏幕坐标
cx, cy = seg.centroids[idx]
action.mouse_x, action.mouse_y = to_screen(cx, cy)
return action
# 重点看:记忆是「描述」,坐标是当场算出来的

4. 真实实现

4.1 分割:把窗口切成区域

get_window_segmentation(visual.py:373)拿动作的裁剪窗口截图 screenshot.cropped_image,交给分割适配器 adapters.get_default_segmentation_adapter().fetch_segmented_image(visual.py:404-405)。适配器可以是 Set-of-Mark(adapters/som.py,调微软 SoM 的 Gradio 服务,som.py:predict)或 FastSAM/ultralytics(adapters/ultralytics.py)。

4.2 从分割图抽 mask、算质心

分割图里每种颜色 = 一个区域vision.get_masks_from_segmented_image(vision.py:16)靠 np.unique 找出所有唯一颜色,每种颜色生成一个布尔 mask(vision.py:40-49)。随后 refine_masks(vision.py:88)清理、calculate_bounding_boxes(vision.py:251)算出每块的包围盒和质心。质心就是后面点击的落点。

4.3 描述:给每块起个「名字」

prompt_for_descriptions(visual.py:459)把原图 + 每个 masked 区域图一起塞给多模态模型,让它返回每块的自然语言描述(visual.py:495-514)。图太多超过模型上限时会自动分批(visual.py:479-493),描述数和区域数对不上就带着异常重试(visual.py:516-530)——自愈式提示

4.4 找回 + 坐标换算

重放时 VisualReplayStrategy.get_next_action_event(visual.py:187)对鼠标动作:分割当前窗口 → descriptions.index(active_segment_description) 找回目标区域(visual.py:229-231),找不到就把异常喂回分割重试(visual.py:232-236);拿到质心后,用 utils.get_scale_ratios图像空间坐标换回屏幕空间坐标,再加上窗口左上角偏移(visual.py:237-245)。

录制侧的对偶是 get_active_segment(visual.py:257):把录制时的鼠标坐标(减窗口偏移、乘缩放比)落到哪个包围盒里,就认定点的是那块(visual.py:300-317)。

5. 巧妙之处(可借鉴)

  • 描述当锚点,坐标当产物。 记忆里存的是语言描述(ActionEvent.active_segment_description,models.py:152),坐标每次重放现算。这让重定位对界面漂移天然鲁棒——这是全项目最值得带走的一条。

  • SSIM 缓存跳过重复分割。 分割 + 描述很贵。find_similar_image_segmentation(visual.py:336)用结构相似度 SSIM(vision.get_image_similarity,vision.py:297)比对全局缓存 SEGMENTATIONS,当前截图和缓存里某张够像(阈值 MIN_SCREENSHOT_SSIM = 0.9,visual.py:60)就直接复用旧分割,省一次昂贵调用。

  • 提示自愈。 描述数量对不上、目标描述找不到,都不是直接崩,而是把异常追加进 exceptions 列表喂回下一次提示(visual.py:521-530:232-236),让模型看到「上次你错在哪」再来一遍。

  • 缩放/偏移解耦。 图像空间 ↔ 窗口空间 ↔ 屏幕空间三套坐标,用 get_scale_ratioswindow_event.left/top 显式换算(visual.py:239-243),高 DPI/多屏也能对齐。

6. 边界与局限(诚实)

  • 长得一样的元素分不清。 文件头 TODO 明说:表格里长得一模一样的格子(如电子表格单元格)描述会撞车(visual.py:32-35),这是已知弱点。
  • 依赖外部分割服务。 SoM 走 Gradio 服务、FastSAM 要模型权重,离线/无 GPU 时受限(som.py 断言 server_url 必须是 http)。
  • handle_similar_image_groups 未完成。 处理相似区域组的分支直接 raise ValueError("Currently unsupported.")(visual.py:419-426)。
  • 这是研究老版。 visual.py 里散落 # TODO XXX(visual.py:399:425),其它遗留策略文件里还有交互式调试残留(如 segment.py:169ipdb.set_trace())——都是 v0.46.0 单体研究阶段的产物,不代表打磨过的生产实现。

7. 代码地图

主题文件路径符号
分割一个窗口legacy/openadapt/strategies/visual.pyget_window_segmentation
给区域生成描述legacy/openadapt/strategies/visual.pyprompt_for_descriptions
录制侧:点在哪块legacy/openadapt/strategies/visual.pyget_active_segment
重放侧:按描述找回+换算坐标legacy/openadapt/strategies/visual.pyVisualReplayStrategy.get_next_action_event
SSIM 复用缓存legacy/openadapt/strategies/visual.pyfind_similar_image_segmentation
颜色→masklegacy/openadapt/vision.pyget_masks_from_segmented_image
包围盒+质心legacy/openadapt/vision.pycalculate_bounding_boxesrefine_masks
图相似度legacy/openadapt/vision.pyget_image_similarity
Set-of-Mark 服务legacy/openadapt/adapters/som.pypredict
记忆锚点字段legacy/openadapt/models.pyActionEvent.active_segment_description