在机器人数据采集里,图像和本体传感器是否对齐,直接决定了这批数据能不能用于学习。

举个例子:

如果多路相机看到的是“夹爪接触物体前一刻”,而触觉、六维力、夹爪宽度记录的是“接触后一刻”,那么模型学到的就不是同一个物理瞬间。

对于模仿学习、力控、接触丰富的操作任务,这类误差会被进一步放大,最终表现为:

动作迟滞、接触判断错误、力反馈异常,甚至训练不收敛。

在数采系统里,我们面对的是两类数据源:

第一类是多路相机,负责采集多视角 RGB 视频,典型频率为 30/60 fps。

第二类是手持采集设备,包含触觉指尖、六维力、夹爪宽度、IMU等传感器,频率大约在 50–200 Hz 之间。

核心难点在于:

相机和手持采集设备并不天然共享同一个硬件时钟。

所以我们最终采用了一个分层同步方案:

相机之间,用硬同步。

相机和手持采集设备之间,用增强软同步。

具体来说:

  1. 多路相机通过同一个 PWM 触发边沿完成硬同步;
  2. 手持采集设备保留原始高频数据;
  3. 后处理阶段用主机时间、视频 PTS 和 MCU 时间戳建立跨时钟域映射。

这篇文章就围绕这个问题展开:一个多相机、多传感器机器人采集系统,如何把所有数据放到同一条可信时间轴上。


一、为什么不能只靠“开始录制时间”?

最直觉的做法是:

点击录制时记录一个 t0,然后认为第 N 帧相机时间就是:

t_camera[N] = t0 + N / fps

传感器也从同一时刻开始记录,之后按最近时间对齐。

这个方案看起来简单,但实际误差很大。

原因主要有两个。

第一,相机管道不是点击录制后立刻出第一帧。

GStreamer、Argus ISP、编码器、MKV 封装都会引入启动延迟。首帧真正进入视频流,可能比录制命令晚 100–500 ms。

第二,手持采集设备通常通过 UDP 或 SDK 上报数据。

Python 侧轮询到数据的时间,并不等于传感器真正采样的时间。UDP 传输、SDK 缓存、线程调度,都会引入 1–3 ms 级别的随机抖动。

所以,“软件开始时间”不能直接当成真实采集时间。

一个可靠的同步方案,至少要回答三个问题:

  1. 多路相机之间,是否真的在同一物理瞬间曝光?
  2. 视频文件里的第 0 帧,实际对应主机时间轴上的哪个时刻?
  3. 手持采集设备里的 MCU 时间戳,如何换算到主机时间轴?

二、系统里其实有三个时钟域

Thor 当前涉及三个时钟域:

第一个是 PWM 硬件时钟。

它来自 Jetson 的 pwmchip 输出,用来产生 60 Hz 方波,触发多路相机曝光。

第二个是主机 wall-clock。

也就是 Linux 里的 time.time() 或 CLOCK_REALTIME。它是软件层的公共参考时间,负责把视频和传感器日志放到同一个时间轴上。

第三个是 MCU 时钟。

它来自手持采集设备内部的晶振,用来给触觉、六维力、IMU 等传感器打时间戳。

可以这样理解:

PWM 边沿负责触发相机曝光。

MCU 时钟负责标记传感器采样。

主机时间负责在后处理阶段,把两者桥接起来。

硬同步解决的是“相机之间是否同时曝光”。

软同步解决的是“相机和手持采集设备之间如何对齐”。


三、相机间硬同步:所有快门锁到同一个 PWM 边沿

多路 GMSL2 相机通过 SG16A deserializer 共享同一路 PWM 触发信号。

每个 AR0234C 传感器都配置为 slave mode。

也就是说,相机不再由自己决定何时曝光,而是等待外部触发边沿。

可以理解成:

60 Hz PWM 同时分发给 camera 0、camera 1、camera 2,一直到 camera 10。

同一个上升沿,触发所有相机曝光。

关键配置类似这样:

hardware_sync:
enabled: true
fps: 60
sensor_trig_mode: 1
trig_pin: 0x00020007

启动时主要做两件事:

第一,配置 Jetson pwmchip,输出 60 Hz 方波。

第二,对每路相机写入 trig_mode=1,切到外部触发模式。

一旦这条链路生效,多路相机的帧采集就被物理锁定到同一个 PWM 边沿。

这是真正的硬同步。

它不依赖 Python 线程调度,也不依赖 GStreamer 什么时候拿到帧。只要相机已经进入 slave mode,曝光时刻就由硬件边沿决定。

在理想情况下,相机间同步精度可以达到亚微秒级。

而 60 fps 下,每帧间隔固定为:

1 / 60 s = 16.6667 ms


四、硬同步里最容易忽略的问题:曝光时间

硬同步不是只要给 PWM 就可以。

还必须保证:

曝光时间 + 读出时间 < PWM 周期

在 60 fps 下,一个周期是 16.6667 ms。

如果曝光时间过长,传感器还没完成读出,下一个触发边沿就已经来了,结果就可能出现异常低帧率、丢帧或者视频不稳定。

因此实际系统里,会把曝光时间上限限制在周期的 85% 左右:

exposure_us <= 0.85 × 1e6 / fps

60 fps 下约等于:

exposure_us <= 14166 us

也就是大约 14.2 ms。

这样相机在每个触发周期内都有足够余量,能够稳定响应下一次 PWM 触发。


五、逐路错峰启动,不会破坏硬同步

多路 nvarguscamerasrc 如果同时启动,容易触发 Argus ISP 或 NVMM buffer 分配竞争,出现空视频、中途 EOS,或者某一路相机没有正常进入录制状态。

所以工程上会采用 stagger 策略,也就是逐路错峰启动。例如每路相机间隔约 1 秒启动。

这会导致一个现象:

不同相机的视频起始帧编号可能不同。

但这不会破坏相机间硬同步。

原因很简单:

错峰启动只决定“这路相机从第几个 PWM 边沿开始录”。

它不决定“每一帧在哪个物理时刻曝光”。

只要相机已经进入 slave mode,后续每一帧仍然会锁定到同一组 PWM 边沿上。

所以:

错峰启动影响起点。

但不影响帧锁定。


六、手持传感器:先保留原生频率,再做逐帧对齐

手持采集设备包含多类传感器:

触觉指尖、六维力、夹爪宽度、IMU、扳机等。

这些传感器不是严格同频的。

有些接近 200 Hz,有些只有 50 Hz。

如果我们用 20 Hz 或 60 Hz 去读取一个“最新缓存快照”,所有传感器都会被压到这个读取频率,高频信息会直接丢掉。

所以采集端采用更高频率轮询 SDK,例如 500 Hz。

然后根据每个传感器自己的 MCU 时间戳,判断是否出现了新样本。

核心逻辑是:

每个传感器都维护自己的 last_recorded_ts。

如果当前 mcu_ts 和上一次不同,就说明这个传感器产生了新样本。

此时记录:

mcu_ts
host_wall_time
decoded_sensor_data

这里有两个关键点。

第一,轮询频率要高于所有传感器的原生上报频率。

500 Hz 轮询可以覆盖 200 Hz 以内的传感器更新。

第二,去重要按“单个传感器”的 MCU 时间戳做,而不是按整包快照做。

这样每类传感器都可以保留自己的真实采样频率。

最终原始数据大致会呈现这样的结构:

IMU:约 200 Hz
六维力:约 200 Hz
夹爪宽度:约 200 Hz
扳机:约 200 Hz
左触觉指尖:约 50 Hz
右触觉指尖:约 50 Hz

也就是说,一个 10 秒 episode 里:

200 Hz 传感器大约会有 2000 个样本。

50 Hz 传感器大约会有 500 个样本。

这样做的好处是,原始数据里保留了高频传感器的完整时间序列。

后续写入训练数据时,再按 60 fps 的相机帧时间做最近邻或插值。


七、基础软同步:用主机时间做桥

基础版本的软同步,使用主机 wall-clock 作为桥。

相机帧时间近似为:

相机第 N 帧时间 ≈ t_start + N / 60

传感器样本时间近似为:

传感器样本时间 ≈ Python 轮询收到该样本时的 time.time()

之后对每个相机帧时间点 t,在每个传感器时间序列里查找最近样本。

例如某一帧相机图像,需要匹配:

最近的 IMU 样本
最近的六维力样本
最近的夹爪宽度样本
最近的左触觉样本
最近的右触觉样本

这样就可以拼出该视频帧对应的 observation.state。

这个方案已经可以解决“不同传感器频率不同”的问题。

但它的精度仍然受两个误差源限制:

第一,传感器采样间隔带来的量化误差。

第二,Python 轮询时间与真实 MCU 采样时间之间的随机抖动。

理论误差大致是:

对齐误差 ≈ 传感器采样间隔 / 2 + 主机侧抖动

例如:

200 Hz 传感器,采样间隔是 5 ms,最近邻误差约 ±2.5 ms。加上 UDP 和轮询抖动后,典型误差大约是 ±3–5 ms。

50 Hz 传感器,采样间隔是 20 ms,最近邻误差约 ±10 ms。加上主机侧抖动后,典型误差大约是 ±10–13 ms。

对于很多低速任务,这已经比简单按 episode 起点对齐好很多。

但如果要处理接触瞬间、力控变化、快速夹爪动作,还需要进一步提升。


八、增强软同步一:用视频 PTS 修正相机首帧偏移

基础方案里,相机帧时间使用 t_start + N / fps 推算。

但 t_start 往往只是软件开始录制的时间,不是第一帧真正进入视频文件的时间。

视频文件里其实有一个更可靠的信息:

PTS。

录制时,nvarguscamerasrc do-timestamp=true 会把帧进入 GStreamer pipeline clock 的时间写入视频流。

episode 结束后,可以用 ffprobe 提取参考相机的视频 PTS:

ffprobe -v quiet -select_streams v:0
-show_entries packet=pts_time -of csv=p=0 camera_00.mkv

拿到首帧 PTS 后,就可以修正管道启动延迟:

actual_frame_time[N] = t_start + pts[0] + N / fps

这里为什么只需要参考一路相机?

因为多路相机已经被同一个 PWM 边沿硬同步。

只要知道参考相机第 0 帧在主机时间轴上的位置,再结合固定的 60 Hz 帧间隔,就可以重建整组相机的时间网格。

PTS 修正主要消除的是 100–500 ms 级别的全局首帧偏移。

这不是小优化。

它决定了相机和传感器是否真的处于同一个 episode 时间轴上。


九、增强软同步二:用线性回归校准 MCU 时钟

传感器样本本身带有 MCU 时间戳。

录制过程中,我们同时记录:

mcu_timestamp
host_wall_time

一个 10 秒 episode 内,200 Hz 传感器大约能提供 2000 对观测。

于是可以对每个传感器单独拟合一个线性映射:

host_time = slope × mcu_timestamp + intercept

其中:

slope 表示 MCU tick 到秒的换算比例,也反映 MCU 晶振频率。

intercept 表示 MCU 时间域相对主机时间域的偏移。

拟合完成后,就不再直接使用 Python 轮询收到样本的时间。

而是用 MCU 时间戳反推样本在主机时间轴上的时间:

calibrated_time = slope × mcu_ts + intercept

这样可以显著消除 UDP 收包、SDK 缓存和 Python 调度带来的随机抖动。

直观理解是:

单次 Python 轮询时间有噪声。

但 MCU 时间戳是传感器侧连续递增的。

我们用大量观测点估计两条时间轴之间的关系,再把单次观测噪声平均掉。

实际系统里还需要防止错误拟合。

以下情况应该自动回退到未校准的 host wall-clock:

样本数少于 10:观测点太少,不足以稳定拟合。

回归残差标准差大于 50 ms:MCU 时间戳可能不稳定、回绕或存在异常。

MCU 时间戳全为 0:说明该传感器没有上报有效时间戳。

在拟合质量正常时,传感器侧随机时间抖动可以从约 1–3 ms 降到 0.5 ms 级别。


十、最终对齐流程

一个 episode 的完整同步流程可以概括为:

第一步,开始录制。

多路相机已经处于 PWM slave mode,所有相机帧都锁定到同一个 60 Hz PWM 边沿。

第二步,手持采集设备开始记录。

采集程序以 500 Hz 轮询 SDK,并按照每个传感器自己的 MCU 时间戳去重,保留原始高频数据。

第三步,录制结束。

视频正常封装为 MKV,传感器原始样本写入 JSONL。

第四步,提取参考相机 PTS。

用视频 PTS 修正相机首帧时间偏移。

第五步,对每个传感器做 MCU 到 Host 的线性回归。

得到校准后的传感器时间轴。

第六步,生成训练数据。

对每个 60 Hz 相机帧,在各传感器时间序列中查找最近样本,组成 observation.state。

最终同步效果可以分成三层:

第一层:相机间硬同步。

解决多路相机是否在同一瞬间曝光的问题。机制是 PWM slave mode,典型精度可以达到亚微秒级。

第二层:基础软同步。

解决不同频率传感器如何对齐到相机帧的问题。机制是高频轮询、MCU 时间戳去重、最近邻匹配,典型误差约 ±3–13 ms。

第三层:增强软同步。

解决首帧偏移和轮询抖动的问题。机制是 MKV PTS 加 MCU/Host 线性回归,典型误差可以推进到约 ±0.5–1 ms。

这里的关键不是某一个单独技巧,而是分层处理:

能硬同步的部分,尽量硬同步。

不能硬同步的部分,保留原始高频数据。

最后在后处理阶段,用可验证的时间戳,把不同时间域映射到同一条时间轴上。


十一、几个工程经验

1. 原始数据和训练数据要分开

训练数据通常是 60 Hz,因为它要跟相机帧率一致。

但传感器原始数据不应该提前压成 60 Hz。

更稳妥的做法是:

原始传感器数据以 JSONL 保存。

每一行对应一个传感器样本,保留原生频率、MCU 时间戳和 host wall-clock。

训练用 parquet 再按相机帧时间生成 60 Hz 的 observation.state。

这样后续如果要做高频力控分析、IMU 积分、接触检测,还可以回到原始数据。

2. wall-clock 要尽可能稳定

软同步依赖主机 wall-clock。

采集主机需要确保系统时间稳定:

开启 NTP 同步。

录制过程中不要手动修改系统时间。

如果只测持续时间,可以使用 monotonic clock;但跨进程、跨文件对齐,仍然需要一个公共 wall-clock。

3. 回退机制非常重要

同步系统不能假设每个 episode 都完美。

某个传感器可能断流。

某段 MCU 时间戳可能异常。

ffprobe 也可能不可用。

所以每个增强步骤都应该有明确回退:

PTS 提取失败时,回退到基础帧时间网格。

MCU 拟合失败时,回退到轮询时记录的 host wall-clock。

传感器样本缺失时,在训练数据中显式标记无效,而不是默默填入一个看起来合理的值。


十二、下一步:从毫秒级走向全链路硬件时间基准

当前方案已经把多路相机间同步做到硬件级,并把相机与手持采集设备之间的对齐推进到毫秒级。

下一步可以继续往“全链路硬件时间基准”演进。

可以考虑几个方向:

第一,将 PWM 触发信号同步送入手持采集设备 MCU,让 MCU 在同一个触发边沿上打硬件时间戳。

第二,在 MCU 固件中记录触发边沿计数,把相机 frame index 和传感器样本直接关联起来。

第三,增加传感器侧硬件 timestamp,而不是只依赖 SDK 缓存快照。

第四,引入 PTP、gPTP 或等价的主机-设备时钟同步机制,进一步降低跨设备时钟漂移。

第五,为每个 episode 输出同步质量报告,包括 PTS 提取状态、MCU 回归残差、丢包率、各传感器有效样本率。

第六,建立物理验证夹具,比如 LED 闪烁、敲击触觉指尖、已知力脉冲等,用同一个事件同时刺激相机和传感器,量化真实端到端误差。

第七,在训练数据生成阶段支持更多对齐策略,例如最近邻、线性插值、窗口统计特征,并根据任务类型选择默认策略。

第八,长时间采集中增加漂移监控,避免单个 episode 内拟合正常,但跨 episode 时钟基准缓慢偏移。


结语

多相机、多传感器采集系统里,同步不是一个“录制开始时打个时间戳”就能解决的问题。

真正可靠的做法,是把问题拆开:

相机之间,用硬件边沿锁住曝光。

传感器侧,保留原始高频数据。

后处理阶段,用 PTS、MCU 时间戳和主机时间建立跨时钟域映射。

最终目标是让采集数据里的每一个图像帧、每一次触觉变化、每一个六维力脉冲、每一个夹爪动作,都能被放到同一条可信时间轴上。

只有时间轴可信,机器人学习到的因果关系才可信。

一文看懂车载/具身多相机链路

2025年,人形机器人产业迎来爆发拐点。特斯拉Optimus量产在即,华为、宇树等企业加速技术突破,行业正从“实验室研发”向“规模化落地”跃迁为打通产业链上下游协作壁垒,艾邦机器人正式组建"人形机器人全产业链交流群",覆盖金属材料、复合材料、传感器、电机、减速器等全硬件环节,助力企业精准对接资源、共享前沿技术!

扫码关注公众号,底部菜单申请进群

作者 ab, 808