跳到主要内容

数据截至 (上游 commit 2689884a6257)

事件归并与对齐

这一章讲什么: 录完之后拿到的是一坨噪声——几百个鼠标移动、一次双击被拆成四条记录、大量没人引用的截图。这一章讲怎么把它压成一条干净的动作序列,以及一个额外难题:浏览器里的 DOM 事件怎么和操作系统级事件对上号。

入口是 legacy/openadapt/events.py:22(get_events),它被 Recording.processed_action_events 属性懒调用(legacy/openadapt/models.py:106-118)。


1. 它要解决的小问题

人点一下「保存」按钮,数据库里会留下什么?

move(830,415) move(831,417) move(832,419) ... ×80
click(832,419, pressed=True)
click(832,419, pressed=False)

八十多条记录,表达的是一件事。喂给大模型是浪费上下文,喂给回放器是浪费时间。而且如果人是双击,还会变成四条 click,谁也看不出那是双击。


2. 统一框架:reducer + 父事件

思路

所有归并规则长得一样:扫一遍序列,把连续的同类事件攒起来,攒够了交给一个函数压成更少的事件。 所以抽了一个骨架出来。

merge_consecutive_action_events(name, events, is_target_event, get_merged_events)

遍历 events:
is_target_event(e)? ── 是 ──→ 塞进 to_merge 缓冲
└─ 否 ──→ 先把 to_merge 压掉,再原样放行这个 e
收尾:缓冲里还有就再压一次

每个压出来的事件打上标记 merged_event.reducer_names.add(name)

真实实现:merge_consecutive_action_events(legacy/openadapt/events.py:757-795)。reducer_names 是一个「这个事件被哪些规则处理过」的集合,方便调试时回溯。

归并的产物不是删除,是父子树

这是整个设计里最关键的一点:被归并掉的事件不会消失,而是变成新事件的 children

make_parent_event(events.py:151-189)造出父事件,把 recording/window_event/screenshot/browser_event 这些关联从子事件继承过来。数据库层面靠 ActionEvent.parent_id 自引用外键实现(models.py:165:221)。

这层设计在回放时立刻兑现价值:play_action_event(legacy/openadapt/playback.py:97-100)遇到有子事件的键盘事件时,回放的是子事件——因为「输入 hello」这个父事件在语义上好读,但真正要注入的是五次按键。

时间戳压缩

每个 reducer 维护一个 state["dt"] 累计量。事件被吃掉时,它占用的时长加进 dt;后续所有事件的时间戳减去 dt(events.py:495:785)。

这样归并后的序列没有空洞——回放时如果按时间间隔 sleep,不会莫名其妙停顿。


3. 逐个看几条规则

merge_events(events.py:878-982)里的流水线顺序是固定的,顺序本身有讲究:

顺序规则干什么
1remove_invalid_keyboard_events丢掉 pynput 已知 bug 产生的 <0>
2remove_redundant_mouse_move_events丢掉几乎没动的移动
3merge_consecutive_keyboard_events连续按键合成 type 事件
4merge_consecutive_mouse_move_events连续移动合成一次移动
5merge_consecutive_mouse_scroll_events连续滚动合成一次滚动
6merge_consecutive_mouse_click_eventspress/release 合成 singleclick/doubleclick
remove_move_before_click已被注释掉(见下)

第一条规则的注释很有意思(events.py:514-515):它丢的是 str(event.key) == "<0>" 的事件,并附了 pynput 的 issue 号 #481。上游库的 bug 在这里被硬编码成一条过滤规则。

双击是怎么被认出来的

这是最值得细看的一条。merge_consecutive_mouse_click_events(events.py:378-504)分两步。

第一步,建两张时间戳映射表(get_timestamp_mappings,:405-441):

press_to_press_t : 某次按下 → 紧随其后、且够近够快的下一次按下
press_to_release_t : 某次按下 → 它对应的松开

判定条件同时看时间和距离:

# 真实源码节选,legacy/openadapt/events.py:429-433
if (
dt <= double_click_interval
and dx <= double_click_distance
and dy <= double_click_distance
):
press_to_press_t[prev_pressed_event.timestamp] = event.timestamp

double_click_intervaldouble_click_distance 优先从 Recording 上读(录制那台机器的系统设置),读不到才回退到当前机器的值,并打 warning(get_recording_attr,:391-399)。

第二步,按映射表合成(get_merged_events,:443-497):

某次按下在 press_to_press_t 里 ──→ 造 doubleclick,吃掉 4 个子事件
否则在 press_to_release_t 里 ──→ 造 singleclick,吃掉 2 个子事件
否则 ──→ 原样保留

吃掉的时间戳进 skip_timestamps 集合,循环里遇到就跳过。

一条被禁用的规则,和它留下的教训

remove_move_before_click(events.py:681-754)本意是:点击前那次「移到同一个位置」的移动是冗余的,删掉。逻辑写完了,但在流水线里被注释掉了,注释写着:

# this causes clicks to fail to be registered in NaiveReplayStrategy(events.py:920-922)

为什么会这样? 因为 NaiveReplayStrategy 是原样重放坐标的,某些应用需要「鼠标先移过去停一下,再按下」才认这次点击。删掉那次移动,点击就落空了。

这是一条很典型的 computer-use 教训:在数据层面看起来冗余的事件,在真实 UI 里可能是必需的。


4. 收敛与关联清理

反复归并直到不动

get_events(events.py:78-112)是个循环:跑一轮 merge_events,如果四类事件的数量都没变就停;否则再跑一轮,最多 MAX_PROCESS_ITERS = 1 轮(events.py:14)。

注意常量是 1。也就是说「反复归并」这个框架在,但当前配置下只跑一轮。代码里看不出为什么设成 1。

顺带清掉没人引用的关联数据

每跑完一个 reducer,立刻用 discard_unused_events(events.py:798-829)把没有任何动作引用的窗口事件、截图、浏览器事件丢掉。判据就是「时间戳在不在动作引用的集合里」。

窗口事件的兜底修复

filter_invalid_window_events(events.py:832-877)处理一类脏数据:宽或高小于 100 像素的窗口事件(多半是过渡态的弹层)。它不是简单删掉,而是把引用它的动作改指向前一个有效窗口——保证每个动作都有窗口可用。

merge_events 调用它之前的那行注释承认了这是补救:# TODO: prevent invalid window events from being triggered to begin with(events.py:955,下一行 :956 才是调用)。


5. 浏览器事件对齐:用 DTW 把两条时间线缝起来

它要解决的小问题

浏览器扩展报的是 DOM 事件(带 clientX/clientY、目标元素 HTML),pynput 报的是操作系统事件(带屏幕坐标)。两者:

  • 时钟不同——浏览器 JS 的时间戳和 Python 的不是一个来源。
  • 数量不同——可能一方多报或少报。
  • 坐标系不同——DOM 的 client 坐标不等于屏幕坐标。

要把它们一一配对。

思路:先按类型分桶,再对每桶做序列对齐

对每一类事件(左键点击 / 右键点击 / 移动 / 滚动 / 每个按键的按下与松开):
├─ 从动作事件里筛出这一类
├─ 从浏览器事件里筛出这一类
├─ DTW 求最优对齐路径
└─ 强制一对一,取最近匹配

动态时间规整(DTW,Dynamic Time Warping) 是一种把两条长度不同、快慢不同的序列对齐起来的算法。这里用的是多维版本。

真实实现

align_events(legacy/openadapt/browser.py:481-558)在 spatial=True(默认,browser.py:33)时,不只用时间戳,而是构造五维向量:

# 真实源码节选,legacy/openadapt/browser.py:522-527
[
e.timestamp,
e.mouse_x or 0.0,
e.mouse_y or 0.0,
e.mouse_dx or 0.0,
e.mouse_dy or 0.0,
]

浏览器侧对应 timestamp, screenX, screenY, scrollDeltaX, scrollDeltaY(:536-541),然后 dtw_ndim.warping_path(action_sequence, browser_sequence)

妙在哪: 只按时间对齐,时钟一偏就全错;把坐标一起塞进对齐向量,相当于给算法加了「空间上也得对得上」的约束,时钟偏移就没那么致命了。

DTW 允许多对一,所以后面还要 enforce_one_to_one_mapping(browser.py:729)挑最近的那个。

坐标系怎么统一:最小二乘拟合一条线

浏览器里 client 坐标到屏幕坐标是一个线性变换(缩放 + 平移)。fit_linear_transformation(browser.py:301-325)用最小二乘直接解出 scale 和 offset:

# 真实源码节选,legacy/openadapt/browser.py:320-323
scale = (n * sum_client_screen - sum_client * sum_screen) / (
n * sum_client_squared - sum_client**2
)
offset = (sum_screen - scale * sum_client) / n

拟合需要至少两个数据点。add_screen_tlbr(browser.py:105-)处理「这个事件自己没带够映射点」的情况,做法是前向 + 反向两遍扫描,给每个事件记住最近的前一个和后一个有效映射(:144-154),然后用可用的那个。

同一个函数里还有一段修补:滚动事件的 clientX 有时是 -1,就用前一个有效值顶上(:156-174)。


6. 关键细节与坑

  • 调试器留在了生产路径上。 merge_events 的时间戳断言失败时会 import ipdb; ipdb.set_trace()(events.py:936-938)。在无人值守环境里这会直接挂死。同样的写法在 strategies/base.py:112-114playback.py:110-112adapters/prompt.py:37-39 也有。
  • 归并对乱序时间戳没有防御。 上一章提到读取端会产生乱序,而这里的所有 reducer 都假设序列有序。两处缺陷叠在一起。
  • 副本录制走捷径。 如果 recording.original_recording_id 非空(是个副本),get_events 直接返回顶层事件不再处理(events.py:54-60),因为副本在创建时就已经处理过了。

7. 代码地图

主题文件路径符号名
处理入口与收敛循环legacy/openadapt/events.pyget_events
归并流水线legacy/openadapt/events.pymerge_events
reducer 通用骨架legacy/openadapt/events.pymerge_consecutive_action_events
父事件构造legacy/openadapt/events.pymake_parent_event
双击/单击合成legacy/openadapt/events.pymerge_consecutive_mouse_click_events
被禁用的点击前移动清理legacy/openadapt/events.pyremove_move_before_click
无引用数据清理legacy/openadapt/events.pydiscard_unused_events
无效窗口事件兜底legacy/openadapt/events.pyfilter_invalid_window_events
DOM/OS 事件对齐legacy/openadapt/browser.pyassign_browser_eventsalign_eventsenforce_one_to_one_mapping
坐标系拟合legacy/openadapt/browser.pyfit_linear_transformationadd_screen_tlbr
懒触发处理legacy/openadapt/models.pyRecording.processed_action_events