做眼动实验和技术开发的人,几乎都会被同一个问题卡住:数据采到了,时间却对不上。我这里说的对不上,不是坐标漂移,而是眼动仪的时间戳和电脑上的时间戳不在同一个坐标系里。拿 Tobii 眼动仪举例,设备本身带一个硬件时钟,所有注视数据都会打上设备时间戳;Python SDK 里又能拿到系统时间戳,看起来都叫时间,实际上一个以设备上电为起点,一个以 Unix 时间为起点。
用 Python SDK(也就是 tobii_research 包)处理时间同步,说难不难,但细节极其容易出错。有人把 device_time_stamp 直接塞进事件分析,结果发现刺激事件和注视点错位几十毫秒;也有人用 system_time_stamp 跟 time.time() 比较,却因为单位或者时区问题越弄越乱。这篇文章就按我实际项目里经常走的一套流程,把时间戳字段含义、偏移量计算、漂移校正、事件对齐和常见坑一次讲透。适合正在用 Tobii 做实验、写采集程序的人,也适合所有需要把不同硬件时间戳统一到同一根时间轴的开发者。
1. 动手前先看懂 Tobii 的时间戳体系
1.1 一条 GazeData 里有两个时钟在各自“走”
Tobii Pro 的 Python SDK 把每一帧眼动数据封装成一个 GazeData 对象。用as_dictionary=True订阅数据流时,回调里拿到的是一个字典,里面有两个字段看起来很像,含义却完全不同:
def on_gaze_data(gaze_data): device_ts = gaze_data['device_time_stamp'] system_ts = gaze_data['system_time_stamp']device_time_stamp是眼动仪内部晶振时钟的输出,单位是微秒,单调递增,但它不以 Unix 时间为零点。你可以把它理解成一块“从仪器上电那天才开始走”的秒表:读数只有相对意义,能用来算间隔、算帧率、判断是否丢帧,但不能直接读出日历时间。
system_time_stamp则是驱动层在数据包到达系统时给数据盖上的时间戳,单位同样微秒,但以 Unix epoch 为零点。也就是说,它更接近我们用time.time()拿到的那个时间基准。
这两者不是替代关系。SDK 同时暴露这两个字段,是为了让你在不同场景下有不同选择。很多人上来只盯着device_time_stamp做事件分析,这就容易埋雷。后面我会详细说到底怎么把设备侧读数换算到系统侧。
1.2 时间同步本质上要解决三件事:偏移、漂移、延迟
一个合格的时间同步方案,至少要把三件事同时处理好。
偏移,是两块表起点不同造成的固定差值。假设设备时钟在开机时从 0 开始走,系统时钟显示的是 Unix 时间,那两者之间就有一个很大的固定常数。这个常数一旦确定下来,可以一直用,也可以用于把设备时间戳翻译成系统时间。
漂移,是晶振频率不完全一致导致两块表“走速”不同。设备侧时钟每天可能偏差几十微秒到几毫秒,长时间实验里这个误差会累积。固定偏移只解决起点不同,解决不了走速不同,所以大实验要考虑漂移校正。
延迟,是从物理事件发生到应用层拿到数据之间的传输时间。这部分包含了传感器采样耗时、USB 传输、驱动缓存、回调分发等所有环节。延迟只能被测量和估计,很难完全消除。理解了这三个概念,再看 SDK 提供的接口,很多设计逻辑就通了。
1.3 采样率决定了你能对齐到多精细
在做任何时间戳转换之前,先确认一下设备的输出频率。假设眼动仪以 90Hz 输出数据,两帧之间的最小间隔大约是 11.1 毫秒。如果刺激事件恰好发生在两帧之间,无论时间戳算得再准,原始数据里也没有那个精确瞬间,只能在前后帧之间插值或者接受一帧的误差。
这背后有一个很现实的取舍:90Hz 设备适合研究宏观注视分布,但要严格分析反应时间、微扫视这类毫秒级指标,至少要用 150Hz 以上的采样率,并且实验设计时就要让事件频率与数据帧率相匹配。我在实际项目里见过不少人忽略这点,后期再怎么补时间同步也救不回来,因为原始采样密度就不够。
2. 环境准备与数据流订阅
2.1 安装 tobii_research 并连接设备
Python 环境的准备其实很直接:
pip install tobii-research注意别跟面向消费级设备的另一个 Tobii 生态包搞混。这个包是 Pro SDK 的官方 Python 实现,主要面向 Tobii Pro Fusion、Pro Spectrum 这类研究型设备,以及一部分支持 Pro SDK 的 X 系列设备。
安装完成后,用下面这段代码查找并连接眼动仪:
import tobii_research as tr eyetrackers = tr.find_all_eyetrackers() if not eyetrackers: raise RuntimeError("没有找到眼动仪,请检查 USB 连接和驱动") eyetracker = eyetrackers[0] print(eyetracker.model, eyetracker.serial_number)实际操作中有几个容易被忽视的点。第一,Windows 上一定要先确认设备驱动正常,设备管理器里显示的是正常设备而不是未知设备。第二,尽量把眼动仪插在主板原生 USB 3.0 口上,不要用机箱前面板的延长线接口,供电不稳会导致采样率不稳定甚至丢包。第三,如果你的机器同时装了多个 Python 环境,确认当前环境里 import 到的就是同一个包,不要让虚拟环境跟系统环境互相污染。
2.2 眼动仪固件与输出频率检查
连接之后,强烈建议做一次健康检查:
freq = eyetracker.get_gaze_output_frequency() print("当前采样率:", freq) data_rate = eyetracker.get_gaze_output_data_rate() print("数据率:", data_rate)在部分设备上,get_gaze_output_frequency返回的是设备支持的频率列表或当前频率,不同型号可能不一样。记得把设备固件升级到官方推荐的最新版本,因为时间戳相关的 bug 经常在固件更新里被修掉。我遇到过同一款设备在旧固件版本下system_time_stamp偶发跳变的问题,升级固件之后就恢复正常了。
2.3 订阅 GazeData,先打印时间戳
环境没问题之后,先写一个最简单的回调把时间戳打出来,跑几秒,直观感受一下数据长什么样:
import time import tobii_research as tr eyetracker = tr.find_all_eyetrackers()[0] def on_gaze(gaze_data): d = gaze_data if isinstance(gaze_data, dict) else gaze_data device_ts = d['device_time_stamp'] system_ts = d['system_time_stamp'] now_us = time.time() * 1e6 print(f"device={device_ts} system={system_ts} time_time={now_us:.0f}") eyetracker.subscribe_to( tr.EYETRACKER_GAZE_DATA, on_gaze, as_dictionary=True ) time.sleep(5) eyetracker.unsubscribe_from(tr.EYETRACKER_GAZE_DATA)运行之后你会看到:device是一个不太大的整数,system是一个接近 Unix 微秒时间戳的大整数,time.time()乘 1e6 换算成的微秒跟 system 的数值非常接近。这就是整个同步问题的起点:两个字段同时出现,且代表同一个物理时刻,但数值基准完全不同。
3. 设备时间戳到系统时间的核心换算
这是全文最核心的部分。掌握了这一节,后面所有对齐操作都可以一次性打通。
3.1 用官方接口做“配对读数”求偏移
Tobii Pro SDK 提供了两个很好用的全局函数:
s = tr.get_system_time_stamp() d = tr.get_device_time_stamp()这两个函数在同一调用时刻分别读取系统时钟和设备时钟。注意,因为是两次独立调用,中间可能有几十到几百微秒的间隔,但我们把它当成“同一时刻”的读数用,这一点误差对绝大多数实验来说可以接受。
用这两个函数就能求出两个时钟之间的偏移量:
import tobii_research as tr s0 = tr.get_system_time_stamp() d0 = tr.get_device_time_stamp() offset = s0 - d0这里有个方向问题必须说清楚。我们的目标是把设备时钟读数转换成系统时钟读数,因此:
system_time = device_time + offset
也就是:
system_time_stamp = device_time_stamp + (现在读到的系统时间 − 现在读到的设备时间)
如果你不小心用了offset = d0 - s0,然后又在device_time_stamp上加这个偏移,得到的结果就会完全错乱。
3.2 写一个可以被回调复用的换算函数
单次读数容易受到系统调度、USB 延迟的影响。我通常的做法是多采样几次,取中位数作为偏移量:
import time import statistics import tobii_research as tr clock_offset = None def calibrate_offset(samples=20): global clock_offset pairs = [] for _ in range(samples): s = tr.get_system_time_stamp() d = tr.get_device_time_stamp() pairs.append(s - d) time.sleep(0.01) pairs.sort() clock_offset = pairs[len(pairs) // 2] return clock_offset def device_to_system(device_ts): if clock_offset is None: raise RuntimeError("请先调用 calibrate_offset()") return device_ts + clock_offset这里用中位数而不用均值的原因,是系统调度会在极少数情况下让一次读取产生明显的离群值。均值对离群值非常敏感,几个偏大或偏小的点就能把它带偏;中位数则更稳健,即使一两个样本异常也不影响结果。
取完偏移后,在 GazeData 回调里这样用:
def on_gaze(gaze_data): d = gaze_data if isinstance(gaze_data, dict) else gaze_data device_ts = d['device_time_stamp'] system_us = device_to_system(device_ts) system_s = system_us / 1e6 # 此时 system_s 与 time.time() 就可以直接比较了 print(f"converted_system={system_s:.6f} time_time={time.time():.6f}")3.3 微秒、毫秒、纳秒的换算不要靠猜
Tobii SDK 的时间戳单位是微秒。time.time()返回的是秒,浮点类型。如果希望直接跟time.time()比较,就把 Tobii 时间戳除以 1e6:
system_s = system_us / 1e6如果希望使用更高精度的纳秒刻度,可以这样统一:
event_ns = time.time_ns() gaze_ns = int((device_ts + clock_offset) * 1e3)这里device_ts + clock_offset得到的是整数微秒,转成纳秒要乘 1000。写成* 1e3是为了可读性,但实际运行时它可能会产生浮点误差,所以我通常用整数乘法:
gaze_ns = (device_ts + clock_offset) * 1000这种整数运算能避免不必要的浮点误差。
3.4 30 分钟以上实验必须做漂移校正
很多人以为求一次偏移就万事大吉了,其实不然。眼动仪的晶振在长时间运行时,可能因为温度、老化而产生轻微频偏,导致设备时钟和系统时钟的差值随时间缓慢变化。我实测过的设备里,有些在 40 分钟实验里就会漂移几毫秒,这对毫秒级的同步分析来说不可接受。
针对长时间实验,我推荐“双端校准法”:实验开始前求一次偏移,实验一结束马上再求一次偏移。
offset_start = calibrate_offset() # 运行实验... # 实验结束后: time.sleep(0.5) offset_end = calibrate_offset() print("漂移量(us):", offset_end - offset_start)如果两次偏移的差在几百微秒以内,可以直接用中间值。如果超过阈值,更稳妥的做法是做线性插值,让每条数据根据其时间位置使用不同的偏移量:
# 假设记录下实验开始和结束时的设备时间 start_device_ts = ... end_device_ts = ... def offset_at(device_ts): ratio = (device_ts - start_device_ts) / (end_device_ts - start_device_ts) ratio = max(0.0, min(1.0, ratio)) return offset_start + ratio * (offset_end - offset_start) def device_to_system_interp(device_ts): return device_ts + offset_at(device_ts)这个线性模型虽然简单,但对付晶振漂移已经足够。更精细的模型需要最小二乘拟合,那通常只在多设备同步的工程场景里才需要。
3.5 为什么我建议优先用 system_time_stamp
上面花了大量篇幅讲如何把 device 时间戳转换成 system 时间,但真到了写分析代码时,我通常的默认选择不是转换,而是直接用gaze_data['system_time_stamp']和事件日志里的time.time()/time.time_ns()对齐。
原因是:system_time_stamp本身就是驱动层按系统时间生成的,省掉了偏移计算这一步,少一个可能的出错点。那为什么还要掌握偏移计算?因为当你需要做设备内部数据处理、跨设备比较、或者某些情况下 system_time_stamp 没有被正确填充时,你必须要能自己从 device 时间戳转换。这是基本功,宁可备用,不能不会。
4. 在刺激脚本里给事件打时间戳并与眼动数据对齐
4.1 事件打点的标准姿势
绝大多数实验框架(不管是商业的还是开源的)最终都会回到 Python 的time模块。我的经验是,实验脚本里所有事件记录统一用time.time_ns(),不用time.clock(),不用本地时间字符串,更不要为了“好看”转成带时区的字符串再存日志。
import time event_log = [] def mark_event(event_name, detail=""): ts_ns = time.time_ns() event_log.append({ "ts_ns": ts_ns, "event": event_name, "detail": detail, })如果项目里原本习惯用浮点秒,也完全没问题,只要全程统一即可。最怕的是来回切换,一会儿秒一会儿毫秒,到分析阶段根本分不清单位。
4.2 用二分查找把眼动数据匹配到事件附近
当眼动数据和时间事件都落盘之后,对齐的核心操作就是“给定一个事件时刻,找它前后最近的眼动样本”。线性遍历不是不行,但数据量大时很慢,我平时都用bisect:
import bisect import time # gaze_times_us:升序排列的眼动样本系统时间戳,单位微秒 def find_nearest_gaze_index(gaze_times_us, event_time_us): idx = bisect.bisect_left(gaze_times_us, event_time_us) if idx == 0: return 0 if idx >= len(gaze_times_us): return len(gaze_times_us) - 1 before = gaze_times_us[idx - 1] after = gaze_times_us[idx] if event_time_us - before < after - event_time_us: return idx - 1 return idx注意,这里假设gaze_times_us已经按时间戳升序排好。部分异常情况下数据可能出现乱序,匹配前最好先做个排序,或者干脆在采集阶段就保证回调数据顺序。
如果你要提取“事件发生后 100ms 内的所有注视点”,可以从二分出来的索引往后遍历,直到时间戳超过event_time_us + 100_000为止。这个方法简单、快速,而且能直接拿到原始样本而不是插值结果。
4.3 一个不需要额外硬件的延迟校验方法
时间同步做完后,我强烈建议做一次延迟校验,确认整体链路没有系统性偏差。最简单的方法是“平滑追踪校验法”:让屏幕上显示一个圆点,沿正弦轨迹运动,同时采集眼动数据。
import math # 圆点位置的理论值为: # x_t = cx + A * sin(2 * pi * f * t) # y_t = cy # 记录每个时刻 t 的理论位置,以及该时刻眼动数据的实际注视点位置分析时,把注视点 x 坐标的时序信号与理论 x 位置信号做相位差估计。如果圆点运动频率是 0.5Hz,相位差是 20ms,说明从命令发出到画面显示再到眼动传感器采到数据,整个链路有约 20ms 的延迟。这个延迟包含了屏幕渲染和传感器链路,不完全是时间戳问题,但能帮你判断同步结果是否在可接受范围内。
如果条件允许,还可以用硬件标记的方式做更严格的校验。Tobii 的部分研究设备支持外部信号输入接口,通过专用同步盒给眼动仪发一个电压脉冲,眼动数据流里会记录external signal时间戳,这个时间戳与脉冲实际发生的时刻之间有固定延迟,可以用于整个采集链路的精确校准。这个需要额外的硬件,不是每个实验室都有,但机制值得了解。
4.4 事件时间戳应该打在渲染前还是渲染后
这是一个很容易踩的坑。如果你在主循环里“先写事件日志,再渲染刺激画面”,那么事件时间戳记录的是你“准备显示”的时刻,而画面真正出现在屏幕上的时刻要晚一点点,通常差一帧。帧率为 60Hz 时,一帧约 16.7ms,这个误差对毫秒级分析来说非常致命。
正确的做法取决于你的引擎能力。如果能拿到垂直同步回调,就在画面真正提交到屏幕之后打点;如果拿不到,至少要在渲染命令发出之后、而不是之前打点,并且始终使用同一套代码逻辑,让系统误差保持恒定。在数据分析阶段,也可以单独测一次显示器延迟,用固定值校正事件时间。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
我把这几年用 Tobii Python SDK 踩过的坑整理成一个速查表,方便你在现场快速定位:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| device_time_stamp 比 system_time_stamp 小很多 | 设备时钟从启动开始计,不以 Unix 为起点 | 正常现象,不要对 device 绝对值做日历解释 |
| system_time_stamp 与 time.time() 差整 8 小时 | 本地时间与 UTC 混用 | 统一用 Unix 时间戳,不要用本地时间字符串 |
| 前 5 分钟对齐很好,30 分钟后逐渐偏掉 | 晶振漂移 | 双端校准,线性插值偏移 |
| 回调数据偶尔乱序 | 驱动缓存、回调耗时 | 回调内只入队,不写盘;分析前排序 |
| 监听回调时打印日志导致丢数据 | 回调阻塞驱动 | 把耗时操作移到独立线程 |
| 事件时间戳明显晚于实际画面 | 事件打点放在渲染前 | 改为渲染后打点,或单独校正显示器延迟 |
| 两台设备时间戳差异无规律 | 每台设备有独立时钟 | 分别校准偏移,不跨设备混用 |
5.2 回调里千万不能做的事
Python SDK 的数据回调是高频触发的,90Hz 设备每秒调 90 次,150Hz 就是每秒 150 次。如果你在回调里做文件写入、print 大段内容、复杂运算,回调耗时一长,驱动内部缓存就可能溢出,数据会丢失或者乱序。
我一直用的结构是这样的:
import threading import time from collections import deque gaze_queue = deque(maxlen=20000) def on_gaze(gaze_data): d = gaze_data if isinstance(gaze_data, dict) else gaze_data system_us = d['system_time_stamp'] gaze_queue.append((system_us, d)) # 在另一个线程里消费队列并完成写盘/分析 def consumer(): while running: if gaze_queue: item = gaze_queue.popleft() # 写文件或做分析 else: time.sleep(0.001)这个模式能保证回调尽量轻量,采集线程和数据消费线程解耦。注意running是全局标志,程序结束时先置 False,再等待 consumer 线程退出,避免丢队尾数据。
5.3 系统时间跳变怎么防
Windows 或 macOS 上如果开着自动网络时间同步,偶尔会在大步幅校时时让系统时间发生跳变。原来毫秒级的校时通常无感,但某些情况下系统时间会突然跳几十毫秒甚至几百毫秒,这会让所有system_time_stamp和time.time()的对齐瞬间失效。
做高精度实验时,我的建议是把自动时间同步关掉,或者至少在实验开始前做一次强制校时,实验期间关闭校时。对于多台电脑协同采集的项目,还要让两台电脑先 NTP 校时到同一个基准,再开始实验。这个我在多机实验里吃过亏,提出来希望能帮你少踩一次雷。
5.4 数据落盘后再做时间同步,还来得及吗
经常有人问:“我已经采完数据了,当时存的是 device_time_stamp,现在还能对齐事件吗?”答案是:如果事件日志里也有同样的时间基准,那就可以;如果事件日志只记录了系统时间,而眼动数据里只有 device 时间戳,那就非常被动。
此时还能抢救,前提是你记录了实验开始和结束时的偏移量。有了起始偏移和结束偏移,可以按 3.4 节的线性插值方法把整个时间段的 device 时间戳近似换算成系统时间。如果运气好,实验过程中漂移不大,靠起始偏移也能得到一个可用的结果。但如果偏移完全没记录,那基本只能靠事后找特征事件来对齐,比如让被试做一个特定的眨眼动作,或者屏幕上出现一个标志性画面,再去眼动数据里找对应时刻。这属于最后一招,精度有限,不推荐作为常规方案。
6. 写在最后的小经验
这篇文章写了那么多,最想传达的一点是:时间同步不是“采完数据之后再修一修”的事,而是要在实验脚本启动的第一分钟就设计好。我自己的习惯是,脚本启动时立刻做三件事:读取系统时间戳、读取设备时间戳、计算出初始偏移,然后把这个偏移和启动时刻一起写入日志文件。实验结束时再读一次偏移,把结束值也存下来。这两条记录不仅能在分析时用来修正漂移,还能在将来排查问题时告诉你当时的数据同步质量到底如何。
另一点是关于回调数据的排序问题。即便 SDK 是并发分布式采集,我也遇到过极少数乱序样本。千万别假设回调顺序等于时间戳顺序,任何分析脚本在匹配事件前,都要对眼动时间戳数组做一次排序和去重。
最后分享一个小技巧:如果你在一个项目里同时涉及多台眼动仪、生理设备、视频录制,别急着写复杂的同步算法,先画一张时间基准图,把每个设备的时间戳单位、零点、单调性列成一张表。这个表本身就能帮你发现一大半问题。时间同步的本质,从来不是找一个万能公式,而是彻底搞懂你自己的设备之间到底是怎么“看时间”的。