实机 burn-in 通过 · 持续迭代 LeRobot v3GMSL2 / Libargus手持采集设备六维力 / 触觉 / IMUJetson ThorPython GatewayReact + ViteSystemd

项目定位

这个项目的目标,是把 ego 视角与 UMI 操作数据采集从”临时脚本可运行”推进到”现场人员可稳定使用、新机器可复现部署、采集数据可直接进入训练链路”的工程系统。它不是单个录制脚本,而是一套围绕具身操作任务构建的数据采集工作站:8 到 11 路 GMSL2 相机负责多视角 RGB 视频,手持采集设备(数采夹爪)提供触觉指尖、六维力、夹爪宽度、IMU、扳机等信号,GUI 和 gateway 负责采集控制、标定、设备状态、任务管理、数据集管理、episode replay 与 QC。

这套系统最关键的问题是时间。对于模仿学习、力控和接触丰富的操作任务,图像和本体传感器是否对齐,直接决定数据能不能用于学习。如果相机看到的是”夹爪接触物体前一刻”,而触觉、六维力、夹爪宽度记录的是”接触后一刻”,模型学到的就不是同一个物理瞬间。这类误差会进一步表现为动作迟滞、接触判断错误、力反馈异常,甚至训练不收敛。

因此系统设计遵循三条原则:能硬同步的部分尽量硬同步;不能硬同步的传感器保留原始高频数据;同步判断尽可能前移,并且每一步都留下可验证、可审计的时间戳,而不是事后拿一个看起来合理的数字去凑。

系统结构

项目可以拆成设备层、采集层、服务层和数据层。设备层连接多路相机和手持设备;采集层在编码前完成相机同帧判定,并以原生频率记录传感器流;服务层提供 GUI、gateway、标定、配置与状态管理;数据层负责 episode 落盘、回放、QC 和 LeRobot v3 格式生成。

flowchart TB
  subgraph D[设备层]
    PWM["Jetson PWM 60 Hz 触发源"]
    CAM["8-11 路 GMSL2 相机\nAR0234C slave mode"]
    HAND["数采夹爪 / 手持采集设备\n触觉 / 六维力 / 夹爪 / IMU / 扳机"]
  end

  subgraph C[采集层]
    GATE["Libargus SOF\nfull-cluster 门控"]
    ENC["硬件 H.265 编码 + 封装"]
    SENSOR["500 Hz 轮询\nMCU 时间戳去重"]
  end

  subgraph S[服务层]
    GUI["多页采集 GUI"]
    GW["Python Gateway"]
    QC["标定 / Replay / QC"]
    BUS["在线推理 frame bus"]
  end

  subgraph L[数据层]
    RAW["MKV + 逐帧 sidecar\n原始传感器 JSONL"]
    ALIGN["Stop 时对齐\n时钟校准 + 最近邻"]
    DATA["LeRobot v3 Dataset"]
    TRAIN["训练 / 回放 / 样本筛选"]
  end

  PWM --> CAM --> GATE --> ENC --> RAW
  HAND --> SENSOR --> ALIGN
  GATE --> BUS
  GUI --> GW --> QC
  GW --> GATE
  GW --> SENSOR
  RAW --> ALIGN --> DATA --> TRAIN
Thor 数据采集工作站 Overview 页面
采集工作站的 Overview 页:左侧是采集、标定、设备、数据四组工作流入口,右侧聚合会话连接状态、标定就绪情况与逐路相机健康度。顶部常驻 Gateway / 录制器 / 硬同步 / 回放安全锁四个全局状态。

这里刻意把”原始数据”和”训练数据”分开。训练数据跟相机帧率一致,也就是 60 Hz;但触觉、六维力、夹爪宽度、IMU 等传感器不应该在采集时提前压成 60 Hz。更稳妥的做法是:原始传感器数据以 JSONL 保存,每一行保留原生频率、MCU 时间戳和主机时间;训练用数据再按相机帧时间生成 observation.state。这样后续如果要做高频力控分析、IMU 积分或接触检测,仍然可以回到原始序列。

为什么不能只靠开始录制时间

最直觉的同步方式,是点击录制时记录一个 t0,然后认为第 N 帧相机时间就是:

t_camera[N] = t0 + N / fps

传感器也从同一时刻开始记录,之后按最近时间对齐。这个方案看起来简单,但实际误差很大,而且这个误差后来在真机上被完整量化了出来(见下文”相机曝光时刻对齐”)。

问题出在两端。相机管道不是点击录制后立刻出第一帧:相机在 slave mode 下是持续曝光的,录制器收到 Start 时管线里已经有在途和缓存的帧,被选作第 0 帧的那一帧,其真实曝光时刻早于 t0,而且缓存深度和启动相位逐次不同。传感器侧,手持采集设备通过 UDP 上报数据,Python 侧轮询到数据的时间也不等于传感器真实采样时间,SDK 缓存、网络传输和线程调度会带来毫秒级抖动。

所以”软件开始时间”不能直接当成真实采集时间。可靠方案至少要回答三件事:多路相机之间是否真的同时曝光;视频文件第 N 帧对应主机时间轴上的哪个时刻;手持设备的 MCU 时间戳如何换算到主机时间轴。

三个时钟域

系统里实际存在三个时钟域。第一个是 PWM 硬件时钟,来自 Jetson pwmchip,用来产生 60 Hz 方波并触发多路相机曝光;它只产生触发边沿,本身不输出可读取的时间戳。第二个是主机 wall-clock,也就是 Linux 的 time.time() / CLOCK_REALTIME,它是软件层公共参考时间。第三个是手持采集设备的 MCU 时钟,由设备内部晶振驱动,用来给触觉、六维力、IMU 等传感器打时间戳,与主机没有天然的校准关系。

三者的分工是:PWM 边沿负责触发相机曝光;相机侧用 Libargus 的 SOF 时间戳(SoC 侧 TSC)证明多路相机拿到的是同一次触发;MCU 时钟负责标记传感器采样;主机时间负责把相机和手持设备两个时间域桥接起来。硬同步解决”相机之间是否同时曝光”,软同步解决”相机和手持设备之间如何对齐”。

flowchart LR
  subgraph P[PWM 硬件时钟]
    EDGE["60 Hz 触发边沿"]
    FRAME["多路相机同时曝光"]
    EDGE --> FRAME
  end

  subgraph M[设备 MCU 时钟]
    S1["触觉 / 六维力 / IMU 采样"]
    MT["MCU Timestamp"]
    S1 --> MT
  end

  subgraph H[主机时间轴]
    SOF["Argus SOF TSC"]
    CLUSTER["full-cluster 门控\nlogical frame N"]
    WALL["Host Wall-clock"]
    OBS["observation.state"]
  end

  FRAME --> SOF --> CLUSTER
  MT -->|"host = slope × mcu + intercept"| WALL
  CLUSTER -->|"逐帧 SOF 采集时刻"| OBS
  WALL --> OBS

相机间硬同步

多路 GMSL2 相机通过 deserializer 共享同一路 PWM 触发信号,每个 AR0234C 传感器配置为 slave mode。也就是说,相机不再由自己决定何时曝光,而是等待外部触发边沿。同一个 60 Hz PWM 上升沿会同时触发所有在线相机。

关键配置可以概括为:

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

启动时主要做两件事:配置 Jetson pwmchip 输出 60 Hz 方波;对每路相机写入 trig_mode=1,切到外部触发模式。一旦链路生效,相机的曝光时刻就由硬件边沿决定,不依赖 Python 线程调度,也不依赖软件何时拿到帧,相机间的物理对齐精度在亚微秒级。

硬同步里最容易忽略的是曝光时间。60 fps 下单帧周期是 16.6667 ms,如果曝光加读出超过这个周期,下一个触发边沿来临时传感器还没准备好,AR0234 会直接掉到 0.8 fps 量级。因此录制器会把曝光时间自动 clamp 到周期的 85%:

exposure_us <= 0.85 * 1e6 / fps

60 fps 下约等于 14166 us,每个触发周期内都留有响应下一次 PWM 的余量。

把同步判断前移到编码器之前

硬件触发只保证”快门同时打开”,不保证”落盘的多个视频文件第 N 帧来自同一次触发”。这是最初版本真正的痛点。早期两条路径都不理想:

  • 多路独立 GStreamer pipeline 各写各的视频,Start/Stop 到达每路的时间不同,无法证明第 N 帧同源,实测 episode 之间能差几十帧;
  • 先写完整视频、保存后再读 Argus metadata 找同步窗口,虽然可验证,但对 H.265 做任意帧窗口切片通常要重编码,8 路 60 秒的 materialize 耗时到分钟级。

结论是:根本问题不是 UI 点击的同时性,而是同步判断发生得太晚。现在生产默认路径把判定前移到硬件编码器之前:

Argus BufferOutputStream
  → 预分配 DmaBuffer pool
  → IBuffer::getMetadata() 取 same-buffer metadata
  → 按 sof_tsc_ns 聚类
  → 只接受完整 cluster
  → 硬件 H.265 编码 → cam_XX.mkv

每个 logical frame 必须同时满足:每个在线相机都有一帧;这些帧的 sof_tsc_ns 最大差值不超过容差(当前 1.0 ms);cluster 完整后才进入编码器。录制窗口中途缺任一路时,episode 直接判失败,不补帧、不插帧、不复制帧。也就是说,帧数不由某一路参考相机决定,而由所有在线相机共同组成的完整 cluster 数决定。

每个 episode 的产物是一份可对账的合同:每路 cam_XX.mkv、每路逐帧 sidecar(logical_frame_index / sensor_timestamp_ns / sof_tsc_ns / eof_tsc_ns),以及一份 online_sync_manifest.json。manifest 记录 actual_frames、每路帧数、每路最大 SOF 偏差和失败原因,保存前由录制器检查:manifest 缺失、ok=false、帧数不一致或 SOF 偏差超阈值,episode 一律丢弃。

8 路相机 10 轮 × 60 秒 60 Hz 的 burn-in 结果:

episodes: 10 / 10 saved
每路视频帧数: 3600
每路 sidecar 行数: 3600
最大 SOF spread: 0.401 ms(多数 episode 在 8-10 us)
ffmpeg materialization: false
保存后 cleanup: 约 0.24 s

录制器还做成了长驻 daemon:Connect 时打开 Argus session 并持续消费完整 cluster,Start/Stop 只切换当前 episode 的编码器和 sidecar,避免了每个 episode 重启录制器带来的约 9 秒启动开销。UI 侧的 Connect → Start → Stop → Save / Discard 流程保持不变。

需要注意的是,UI 录制是时间驱动的:操作者按 Stop 或到达设定时长后,录制器在下一个完整 cluster 边界收口,因此 actual_frames 可能与 时长 × 帧率 差一两帧。合同是”各相机同帧数、同 logical frame 同一次曝光”,不是强制固定帧数。

数采夹爪的高频采样与时钟校准

手持采集设备的传感器不是严格同频的,而且真机实测频率和早期文档里的估计并不一致:

传感器实测频率10 秒 episode 样本数
夹爪宽度 / IMU / 扳机 / 六维力199 Hz约 1998
左触觉指尖 / 右触觉指尖50 Hz约 500

早期文档误记左触觉为 200 Hz,实测左右两片都是 50 Hz。这直接改变了误差口径:触觉的最近邻量化误差是 ±10 ms,而不是 ±2.5 ms。

如果用 20 Hz 或 60 Hz 读取一个”最新缓存快照”,所有传感器都会被压到这个频率,高频信息直接丢失。因此采集端以 500 Hz 轮询 SDK(对任何 ≤250 Hz 的传感器都满足 Nyquist),并按照每个传感器自己的 MCU 时间戳去重:只有 mcu_ts 与上一次不同才算新样本,此时记录 mcu_ts、主机时间和解码后的数据。

基础对齐用主机 wall-clock 作为桥,但每个样本的主机时间是”收到时刻”,包含 UDP 传输延迟和轮询调度抖动。因此每个传感器再做一次最小二乘线性回归:

host_time = slope * mcu_timestamp + intercept

slope 反映 MCU 晶振周期,intercept 吸收跨时钟域的固定偏移。拟合完成后不再直接使用收包时间,而是用 slope * mcu_ts + intercept 反推样本在主机时间轴上的位置。真机实测 slope 恒为 1.0e-6,即 MCU 时间戳单位就是微秒;6 路传感器全部成功启用校准,残差标准差分别是夹爪 1.12 ms、IMU 1.07 ms、扳机 1.07 ms、六维力 1.18 ms、左触觉 2.02 ms、右触觉 1.97 ms。触觉残差约为高频传感器的两倍,来自样本数少四倍加上 UDP 包更大(约 744 B 对约 120 B)带来的传输抖动。

这个 1 到 2 ms 是定时残差,不是端到端误差。回归把延迟里的固定偏移收进 intercept、频率差收进 slope,剩下的随机抖动去不掉;端到端还要再叠一项最近邻量化 ±采样间隔的一半。所以对触觉来说,主导误差始终是 50 Hz 带来的 ±10 ms 量化,而不是 ±2 ms 的校准残差。

校准也允许失败并逐传感器回退:样本数少于 10、MCU 时间戳全为 0、残差标准差大于 50 ms、或拟合退化时,该传感器自动退回到采集时记录的主机时间,其他传感器不受影响。

相机曝光时刻对齐:一个被真机量化出来的固定偏移

系统跑起来之后,最大的一次同步修正来自对”理想帧网格”的怀疑。此前传感器的最近邻查找目标是 N / fps 这条理想网格,它隐含两个假设:第 0 帧恰好发生在 t0,且帧率恰好 60.000 Hz。

拿倒水任务的 7 条真机 episode 做量化,结论是后半个假设成立、前半个不成立:

  • 真实帧率是 60.000 ± 0.002 fps,N / fps 在 10 秒内的漂移不超过 0.07 ms,可以忽略;
  • N / fps 网格与硬件 SOF 时间之间存在 −11 ms 到 −53 ms 的偏移,每条 episode 都不一样,同一个 session 内也会跳

负号说明第 0 帧的曝光早于 t0:相机持续曝光,录制器收到 Start 时管线里已有在途 cluster,被留作第 0 帧的那一帧其 SOF 早已锁存,而缓存深度和启动相位逐次不同。所以这是一个逐 episode 变化的固定相位偏移,不是频率漂移,也就意味着它不能用一个全局常数 offset 修掉。

这个偏移比 MCU 校准残差大整整一个数量级。按实测数据反推,它给与图像配对的 state 注入了最高 9 mm 的夹爪宽度误差、4.2 N 的六维力误差和 24.7(0.1 N 量纲)的触觉误差,而且峰值恰好落在抓取和接触的瞬变上——正是操作策略最依赖的那些帧。

修复方式是把最近邻查找的目标从理想网格换成逐帧的硬件采集时刻:

frame_time[N] = sensor_timestamp_ns[N] / 1e9 - t0_mono_s

sensor_timestamp_ns 来自每帧的 sidecar,因此这条路径顺带还吸收了约 1 ms 的帧内抖动。训练 parquet 的 timestamp 列仍然保持 N / fps,以维持 loader 侧的网格合同——改变的只是查找瞬时,不是时间轴本身。sidecar 出现空洞或短尾时,按该 episode 的 SOF 线性拟合外推,保持单一时间基准,而不是中途拼回 N / fps,外推的帧数会告警。每条 episode 的 meta 里还会写入对齐审计摘要:使用的模式、平均 skew、skew 抖动、有硬件时间戳的帧数。

修复只对新录制的数据生效,旧数据集需要重录或回填。另外这只解决了训练侧——闭环真正要求的是”训练对齐 == 部署对齐”,而部署走的是在线 frame bus + 实时传感器的另一条路径,其残余偏移还没有实测,目前挂在 TODO 上。

同步发生在什么时候

这套系统里有两个不同时机的同步,混淆它们会导致误判数据状态:

  • 相机之间在录制期在线完成:PWM 全程锁快门,编码前逐帧按 SOF 做 full-cluster 门控,Stop 只是收口写 manifest,并不是”Stop 才同步”。
  • 手持设备与相机之间在 Stop 时由录制器完成:做 MCU 时钟校准、按逐帧 SOF 时刻做最近邻,然后写 session parquet。单条 session 落盘时就已经同步好了,不需要点 Export。

离线导出是合并打包步骤,不重做上面的 state 同步:它复用录制器已对齐的 state,按 frame_index(而不是列表位置)配相机帧,另外做触觉全阵列(239 个 taxel)到帧网格的重采样、视频转码和多 session 合并;找不到可用 session parquet 时直接跳过该 episode,而不是在导出阶段重新对齐一遍。

完整的 episode 流程是:Connect 阶段检测相机、写 PWM 与触发模式、clamp 曝光,先启动手持设备 SDK 再拉起长驻录制器;Start 阶段开设备目录并丢弃若干 warmup cluster;录制窗口里每个完整 cluster 进编码器并写 sidecar,传感器侧 500 Hz 去重记录;Stop 时录制器在下一个完整 cluster 边界收口、写 manifest 和原始 JSONL,然后做时钟校准与逐帧最近邻,落 LeRobot v3 parquet。

训练数据 schema

observation.state 现在是 31 维 float32,只放可训练的传感器读数:

通道维数
夹爪开合距离1
扳机行程百分比1
IMU三轴加速度 / 三轴角速度 / 姿态角 / 四元数13(含 3 维冗余,见下)
六维力Fx Fy Fz Mx My Mz6
触觉 L / R各:三轴均值力 / 最大绝对法向力 / 有效接触点数5 + 5

维度从 42 收敛到 31 的过程本身就是几条教训:去掉重复的夹爪位置通道;把 7 个时间戳从 state 里移出去(时间戳不是观测,留在 state 里既会被归一化带歪,也是一种时间泄漏);再删掉恒为常量的状态位。对齐所用的原始 MCU 时间戳单独存成 6 列 float64 元数据——用 float64 是因为 MCU 计数实测达到 2–4e9 微秒,超过了 float32 的整数精度。

这条收敛路线还没走完。IMU 这一组里的姿态角和四元数编码的是同一个 3 自由度姿态,而且来自设备 SDK 同一次姿态解算的两个字段,不是两路独立测量——13 维里只有 9 维是独立信息。更麻烦的是冗余的那份恰好是更差的那份:偏航角在 ±180° 处会跳变,让归一化统计和回归损失落在一个会绕回的通道上,这本身就是有害的,不只是浪费维度;两份姿态叠在一起还会让姿态在 state 里被隐式加权两次。所以下一步是删掉姿态角三维、只保留四元数,并统一为 xyzw 顺序(与主流旋转库一致,避免上下游为标量位在前还是在后反复写转换),state 降到 28 维。姿态角本身不会丢——原始 JSONL 里保留了 SDK 解算的全部字段,需要时可以离线还原。这属于会改变 state 布局的破坏性变更,已有数据集和归一化统计都要重算,因此排在部署侧对齐之后做。

底层时间戳是 uint32 微秒,约 71.6 分钟回绕一次。当前 episode 只有十秒量级,暂不处理;极少数骑在回绕边界上的 episode 会被 50 ms 的残差阈值自动拦下退回基础对齐,不会污染数据。真要做长会话连续录制,在客户端轮询循环里检测回退并累加 2³² 即可,不需要改 SDK 协议。

GUI 与采集工作流

早期 GUI 是一个膨胀到近三千行的单页应用。现在按工作流拆成了独立页面:Overview 总览、Live Record 采集、Task Library 任务库、Calibration 标定、Device Manager 设备管理、Dataset Processing 数据处理、Episode Replay 回放、Dataset Export 导出。

Overview 是只读聚合页,唯一的动作是连接录制器(因为标定和设备页都依赖这条会话的实时数据流),而且录制或保存过程中会禁用断开,避免误操作打断正在写的 episode。

Task Library 把采集从”录多少算多少”变成有目标的任务:每个任务有目标 episode 数、负责人、目标数据集仓库、完成进度,以及 QC 与导出的确认状态。

Calibration 是独立的标定工作流,覆盖六维力原点归零(每天一次)、六维力动态标定(每 30 分钟一次,判定标准 Fz = −7.784 ± 0.5 N、Mx = −0.168 ± 0.01 N·m,其余轴 ±0.5 N / ±0.01 N·m)和触觉归零(净力 ≤ 0.3 N,单点残差 ≤ 8 个 0.1 N 单位)。工程上有两个细节值得记:一是标定前先采 2.5 秒实时窗口做稳定性判断,峰峰值超限或新鲜样本不足 8 个时报告”无法判定”,而不是假装设备是稳的;二是 Thor 上没有环境温湿度传感器,界面就把 15–30 ℃ / 30–70 %RH 作为要求范围展示,而不是编一个假的实时读数。

Episode Replay 里踩到的坑很典型:多个 <video> 元素不能当成天然同步的源。浏览器的 video clock 各自漂移,暂停态下的 seek 容差如果大于半帧,就会留下肉眼可见的错位。现在前端用主视频反推 timeline 时间,并持续把其余相机 seek 到同一个 timeline frame,后端则返回每路相机的文件起点偏移以兼容旧数据。回放页还把遥测按物理量分组绘图,支持内嵌 MuJoCo 回放与实机回放对照;录制 finalize 后数据集列表会自动刷新。

Dataset Processing / QC 页直接展示 manifest 里的实际帧数、每路帧数、最大 SOF 偏差和失败原因,让”这条数据能不能用”变成一个可以在界面上读出来的判断,而不是靠人去翻日志。

在线推理的取流方式

一个容易犯的错误是让推理端自己再开一路相机会话——这会和录制器抢 Argus 资源。正确路径是读录制器自己发布的 frame bus:录制器把已接受的最新完整 cluster 双缓冲写到 tmpfs(NV12 + 一份 latest 描述),读端使用 latest-frame 语义,模型慢就跳过旧帧,不会反压录制。UI 预览走的是另一条独立的 preview bus 加 JPEG 转换桥,并且在录制开始前主动关闭,避免预览影响录制窗口。

部署与工程收尾

除了同步链路,项目也补齐了现场部署工程。JetPack / Ubuntu 环境下的 apt 依赖、手持设备运行库对旧版 tinyxml2urdfdom、Boost soname 的兼容问题(由幂等脚本自动建符号链接)、GUI 最小依赖和训练依赖边界,都沉淀成了部署清单,目标是新机零踩坑。国内网络下所有源都换成了镜像。手持设备通过固定地址推送 UDP 数据,网口地址恢复被固化为 systemd 服务:开机拉起网口、幂等添加地址、主动 probe 设备地址并刷新邻居状态。设备 SDK 也从主仓库拆成了独立子模块,供应商更新不再污染主仓库历史。

部署脚本做成了增量同步加重启:一条命令完成文件同步、重启目标机 gateway、在本地起前端,支持采集机与工作站两个目标以及只同步、不起前端两种模式,配套一个清理残留进程的脚本。ssh 退出会带掉后台进程的问题,用 setsid + disown 让采集服务稳定脱离终端。上线前自检覆盖设备 Python 绑定构造、gateway 快照、相机与设备数量、录制器配置选择、前端可访问性和回放 / QC 链路。

现状与遗留问题

当前这套系统把 ego + UMI 数据采集沉淀成了一个可复制的工程交付:新 Thor / Jetson 主机可以按清单完成部署;多相机、数采夹爪、GUI、gateway、标定与 LeRobot v3 数据集管理在同一条链路里;采集后可以直接进入 replay、QC、训练前处理和策略训练。同步精度分三层:相机之间由 PWM 硬触发加编码前 SOF 门控保证,burn-in 实测最大偏差 0.401 ms;手持设备的定时残差经 MCU 时钟校准后为 1 到 2 ms;端到端还要叠上最近邻量化,高频传感器约 ±2.5 ms、触觉约 ±10 ms;而 N / fps 与真实曝光之间那个 −11 到 −53 ms 的逐 episode 偏移,已经用逐帧硬件时间戳消掉。

诚实地讲,还有几件事没做完:

  • 部署侧(在线 frame bus + 实时传感器)的残余偏移还没实测,闭环要求训练对齐等于部署对齐,这是当前第一优先级;
  • SOF 是 SoC 侧接收到帧的时间,不是传感器曝光的硬件时间戳。它足以证明多路帧属于同一个同步簇,但更强的证据仍然需要供应商提供触发帧号或曝光时间戳;
  • episode 边界上偶发的 Argus provider 瞬时错误目前靠重试覆盖,底层资源释放路径仍有技术债;
  • 真正的硬件级全同步(让设备 MCU 也由 PWM 打戳)需要硬件和固件配合,还没有做。

但方向是清楚的:把同步判断尽可能前移、把每一次对齐的依据写成可审计的字段、允许失败并显式回退,而不是默默填一个看起来合理的值。只有时间轴可信,机器人学习到的因果关系才可信。