stouchinglhh
collector: 更新采集工程至 v1.3.17(时间轴修正 + 采集端自建任务)
ddb63ef
|
Raw History Blame Contribute Delete
34.4 kB

data/ —— 配置文件与数据存储结构

本文档描述 data/ 目录下的配置文件格式、SQLite 表结构与录制数据的目录布局。 所有结构均从代码(config/settings.py、core/task_record.py、 core/egodata_writer.py、core/database.py、core/recording_record.py、 core/helpers.py)与仓库内模板文件归纳得出。

1. 总览:仓库提供什么,本地生成什么

文件/目录 归属 说明
server_config.example.json 仓库模板 服务器连接配置模板;本地真实配置写在 server_config.json(gitignore)
device_names.example.json 仓库模板 设备命名表模板(空 {});本地真实配置写在 device_names.json(gitignore)
tasks.example.json 仓库模板 任务列表模板({"tasks": []});本地真实数据写在 tasks.json(gitignore)
device_params.json 仓库内默认文件 每设备相机参数(曝光),出厂为空对象 {};key 用设备稳定标识,增长后可能含设备标识,勿提交本地改动
recordings/ 本地生成(gitignore) 录制会话输出根目录
app.db、pipeline.db 本地生成(gitignore) SQLite 数据库;当前代码使用 pipeline.db(settings.DB_PATH),app.db 未被现有代码引用,属历史遗留

本地真实文件可能包含服务器地址、凭据、MAC 等敏感信息,永不提交; 新环境首次运行由程序按需创建,结构见下文。

2. 配置文件

2.1 server_config.json(模板 server_config.example.json)

服务器连接与上传行为配置。字段由 config/settings.py 中的读写函数定义:

键 类型 含义 默认/回退
server_url string 服务器地址;为空时回退出厂默认 http://127.0.0.1:8000(settings.SERVER_URL) 空
username string 上传登录用户名 空
password string 上传登录密码 空
upload_auto_sync bool 录制完成后是否自动上传 true
upload_delete_after bool 上传成功后是否自动删除本地文件 false

写入采用合并式(merge-write):_save_server_config 只更新传入键,保留其余字段。

2.2 device_names.json(模板 device_names.example.json)

设备命名表。顶层为 JSON 对象:

{
  "<DeviceInfo.stable_key>": { "name": "<用户命名>", "sensor": "<parquet 列名>" }
}
  • key:设备稳定标识 DeviceInfo.stable_key,形式如 uvc:{by-id 前缀}(同型号同序列号两台并存时 udev 的 by-id 链接名相撞、只有一台拿得到链接,且谁拿到取决于注册顺序、重枚举时会翻转 ⇒ 两台都退到 uvc:usb-<USB 拓扑路径> 如 uvc:usb-1-5,同样跨重启稳定)、 d435:{serial}、ble:{MAC} 等(含设备序列号/MAC,属本地敏感信息,不入库)。
  • value:规范形式为 {"name": str, "sensor"?: str};旧版本可能是纯字符串, 读取时自动兼容升级。
    • name:用户在界面中给设备的命名(槽名随 GUI 用户命名)。
    • sensor:仅 BLE 数据手套使用,把设备绑定到 parquet 列名 (right_glove / left_glove)。按 MAC 持久化,重连保持;首次连接的未知手套 由 assign_glove_sensor_role 按广播名偏好(L → left_glove、 R → right_glove)自动分配一个空闲列名并写回。

2.3 tasks.json(模板 tasks.example.json)

采集任务列表,是任务进度的事实来源(single source of truth)。顶层结构:

{
  "tasks": [ { <任务条目> }, ... ],
  "updated_at": "<ISO 时间戳>"
}

任务条目字段(由 core/task_record.py 归纳):

键 类型 含义
id string 任务唯一 ID(读取时也兼容 task_id 键名)
name string 任务名;录制时输入的 task_name 按 name 精确匹配来累计进度,同时是录制目录名的来源
description string 任务描述
total_required int 要求完成的录制总次数(>0 才视为有效任务)
assigned_at string 任务分配时间(ISO 8601,用于列表排序,新→旧;空/非法排最后)
params object 任务自定义参数(如对象、用手等)
completed_count int 录制完成次数(持久化权威值,每次录制完成 +1,与本地文件是否被删除无关)
status string pending / in_progress / completed,每次加载时由 completed_count 与 total_required 重新推算
source string "local" = 采集端自建任务(任务页「+ 新建任务」创建,id 前缀 local-);缺失 = 服务端任务/历史任务
created_by string 本机任务的创建身份("guest" 或用户名);仅创建者可见。缺失视为「无主」,对所有身份可见(兼容手工写的数据)
hidden bool 删除墓碑:用户删除过的服务端任务标 true,防止后端再次推送时“复活”(任务页删除同时会真删本地录制目录与录制记录,这里只留墓碑;进度 local_count 不回退)。本机任务删除时整条移除,不留墓碑

行为要点(均来自代码):

  • 首次运行若文件缺失或解析失败,写入内置示例种子任务(task_001 起 3 条)后重读。
  • load_tasks 除旧数据回填外不写回文件;后端推送经 merge_backend_tasks 按 id 合并,本地保留 total_required/params 覆盖字段与 completed_count 权威值;同名多可见任务只保留 assigned_at 最新的一条, 其余标 hidden。
  • 旧数据缺少 completed_count 时,一次性按 data/recordings/<name>/ 下含 meta/info.json 的会话目录数回填初值并写回,此后以持久化值为准。
  • 任务页「🗑 删除」= 真删除:先按 <RECORDING_DIR>/<task_tag>/ 校验删净 (删不掉则弹窗报错且任务不删除),再删除该目录下的录制记录行,最后只在本 文件写 hidden 墓碑。completed_count 等进度计数按「持久化权威值」处理, 不随删除回退。
  • 采集端自建任务(source="local",任务页「+ 新建任务」):
    • 名称只允许字母/数字/汉字/下划线/连字符(拒绝空格、点、斜杠等), 去首尾空白后 ≤64 字符,大小写不敏感查重(含 hidden 与种子任务), 拒绝 Windows 保留名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)。规则保证 name == task_tag(name),即任务名 = 录制目录名 = 上传会话/项目名。
    • id 形如 local-YYYYmmdd-HHMMSS-<4hex>;assigned_user 恒为 null (不算指派任务),可见性由 created_by 决定:仅创建者可见,游客建的 只对游客可见。后端轮询不会删除本机任务(merge_backend_tasks 账号撤销 清除显式豁免本机任务)。
    • 进度只在本机累计(local_count,synced_count/backend_count 恒 0); pending_sync_tasks 跳过本机任务,不向服务器上报任务进度。录制数据 仍按任务名正常上传,服务器按名称匹配/自动创建项目。
    • 删除 = 真删本地目录 + 录制记录行 + 从本文件整条移除(后端永远不会 推送它,不需要 hidden 墓碑;同名任务可以重建)。
    • 改名只对本机任务开放,且本机已有录制目录时拒绝(改名不迁移数据, 避免旧目录失去归属、上传队列路径脱节);新名字目录已存在也拒绝。
    • 服务端后来推送同名任务时走既有同名去重((name, assigned_user),按 assigned_at 新者胜出):本机任务可能被隐藏,录制目录由同名服务端任务 继续使用,进度以服务端任务为准 —— 创建时会拒绝与现有任务重名,正常操作 不会主动制造这种情况。

2.4 device_params.json

每设备相机参数(当前为曝光设置)持久化。顶层为 JSON 对象,key 与 device_names.json 同为 DeviceInfo.stable_key:

{
  "<stable_key>": {
    "exposure": { "auto": true, "value": 0.0 },
    "original": { "auto": true, "value": 0.0 }
  }
}
  • exposure:当前曝光(auto=true 时 value 被忽略)。
  • original:设备首次开启时读回的原厂曝光基线,只写一次、永不覆盖, 供“恢复默认”按钮使用。

仓库内默认文件为空对象 {},随使用增长。注意:key 与 device_names.json 同为 DeviceInfo.stable_key,增长后可能含设备序列号/MAC 等标识,本地修改过的文件请勿提交。

3. SQLite 数据库(data/pipeline.db)

core/database.py 定义建表 SQL(CREATE TABLE IF NOT EXISTS), 连接为线程本地单例,启用 journal_mode=WAL 与 foreign_keys=ON。

表 recording —— 录制历史记录

列 类型 含义
id TEXT PK 记录 ID
camera_index INTEGER 触发录制的摄像机槽位索引
camera_name TEXT 摄像机名
file_path TEXT 会话目录路径
file_size_mb REAL 文件大小(MB)
duration_sec REAL 录制时长(秒)
resolution_w / resolution_h INTEGER 分辨率
status TEXT completed / uploaded(已上传、本地保留)/ aborted / deleted / uploaded_deleted(已上传、本地已删,行保留供历史可查)
started_at / finished_at TEXT 起止时间

索引:idx_recording_camera(camera_index)、idx_recording_date(started_at)。 读写封装在 core/recording_repository.py(RecordingRepo)、数据类在 core/recording_record.py(RecordingRecord)。

表 upload_task —— 上传任务记录

列 类型 含义
id TEXT PK 上传任务 ID
session_path TEXT 会话目录路径
session_name TEXT 会话名
episode_index INTEGER 池化 episode 全局序号(v1.1.0 起)
status TEXT pending / uploading / completed / failed / skipped;uploading 在 POST 发起前写入,其 updated_at 即"POST 开始时刻"(启动续传的时间窗判定依赖它,程序被杀时该行会停在 uploading)
progress REAL 进度(0.0–1.0)
retry_count INTEGER 重试次数
server_url TEXT 目标服务器
server_session_id TEXT 服务器返回的会话 ID
error_message TEXT 错误信息
created_at / updated_at TEXT 创建/更新时间

索引:idx_upload_status(status)、idx_upload_session(session_path)。

4. 录制目录结构(data/recordings/)

录制由 core/pipeline.py 驱动 core/egodata_writer.py(EgoDataWriter) 写入。目录布局为 EgoData 标准,并兼容 LeRobot v3 格式消费方。

4.1 目录树

data/recordings/                          # 录制根目录(settings.RECORDING_DIR)
└── <task_tag>/                           # 任务目录:清洗后的任务名(task_name 为空时叫 "session")
    └── <task_tag>_000001/                # 会话(episode)目录:任务名 + 6 位序号
                                          #   旧格式兼容:episode_000001/
        ├── metadata.json                 # EgoData 根级元数据(见 4.3)
        ├── timestamps.json               # 逐帧时间戳(见 4.4)
        ├── videos/                       # RGB 视频(每台相机一路 MP4)
        │   └── <camera_name>/            #   槽名,如 head_left_rgb;_aux 后缀相机归入主相机目录
        │       └── chunk-0000/
        │           └── <camera_name>.mp4 #   编码自适应(v1.0.9:HEVC CRF30 直出,性能不足回退 H.264 CRF23;实际见 metadata.video_codec)
        ├── depth/                        # 深度(仅启用深度时创建)
        │   └── <深度槽名>/               #   如 head_depth(S80M 兜底)/ d435_depth(D435)/ stereo_depth(S80C)
        │       ├── <深度槽名>.mp4        #   深度热力图视频(JET 伪彩,可视化用途)
        │       └── 000001.png …          #   原始 uint16 毫米 PNG(16-bit grayscale,
        │                                 #   raw_depth 槽位;v1.0.11 曾改 raw16 bin,
        │                                 #   体积过大且上传失败,v1.0.12 回退 PNG16)
        ├── calibration/                  # 标定(StereoCalibration)
        │   ├── head_stereo.json          #   首台双目型设备标定(服务器/回放/三角化依赖此路径)
        │   └── <槽名前缀>_calibration.json  # 其余设备的标定(槽名前缀去掉 "_N" 消歧编号)
        ├── data/                         # 传感器数据(LeRobot v3 兼容 Parquet)
        │   ├── <sensor>/                 #   每传感器一个目录,如 right_glove / left_glove
        │   │   └── chunk-0000/
        │   │       └── chunk_000000.parquet   # zstd 压缩,schema 见 4.5
        │   └── imu/                      #   双目 IMU(仅双目会话,每帧一行)
        │       └── chunk-0000/
        │           └── chunk_000000.parquet
        └── meta/                         # LeRobot v3 兼容元数据
            ├── info.json                 # 上传服务器严格依赖的字段(见 4.6)
            ├── stats.json                # 各特征 mean/std/min/max(归一化统计)
            ├── tasks.jsonl               # {"task_index": 0, "task": "<任务名,空时 'default recording'>"},单行 JSON
            └── episodes/
                ├── chunk-000/
                │   └── file-000.parquet  # 每会话一行的 episode 表
                └── chunk_000000.parquet  # 前者的兼容旧路径副本

要点:

  • 序号:episode 序号 = max(任务进度 batch_index, 目录扫描最大序号 + 1); batch_index 是“录制完成次数 + 1”(core/task_record.py 持久化), 上传后自动删除本地文件也不会使序号回退。
  • 视频/深度/标定:深度槽位(depth_slots)不建视频目录也不走 MP4 合成; start_episode 中 _aux 后缀摄像头归入主摄像头目录。
  • 旧格式兼容:会话目录也可能以 <tag>_YYYYMMDD_HHMMSS(session_dirname) 命名且内含 meta/info.json(LeRobot v3 旧会话);core/helpers.py 的 list_all_sessions / detect_session_format 通过 metadata.json(egodata) 或 meta/info.json(lerobot_v3)识别会话,回放支持两种格式。
  • 中止录制:abort_episode 直接 rmtree 丢弃整个会话目录。
  • 关键点数据不写在本目录:手部关键点输出镜像到仓库根的 keypoints_output/<task>/<session>/(见第 5 节)。

4.2 深度通道说明

  • S80M 传统路径(无显式注册深度槽):单槽兜底 head_depth, 由 write_depth_frame 惰性创建目录并合成热力图 MP4,受 settings.DEPTH_ENABLED 门控(默认关闭,遗留开关勿启用)。
  • D435/D405(core/pipeline.py 经 set_depth_camera 显式注册槽位): 打开即录深度;槽位配置了 raw_depth=True 时额外写原始 uint16 PNG16 (egodata_depth_path:depth/<槽名>/000001.png,文件名从 1 起 6 位, 压缩级 settings.D435_PNG_COMPRESSION)。 热力图支持固定色标(near_mm/far_mm)、3×3 中值与 EMA 时域平滑 (仅作用于可视化通道,原始 PNG16 不经过)。
  • S80C(v1.0.11):set_depth_camera(settings.S80M_DEPTH_SLOT, raw_depth=True) 显式注册槽位 stereo_depth,深度由 read_stereo_rgb.py 子进程内 SDK 深度引擎计算(~20fps),经管道深度块 → depth_ready 信号 回主程序;录制时热力图 MP4 + PNG16 与 D435 同口径落盘。深度源低于 录制帧率时热力图 MP4 重复最近帧补拍(时长与 RGB 对齐),PNG16 仅 记新帧不重复。
  • 读取:PNG16 直接 cv2.imread(path, cv2.IMREAD_UNCHANGED); v1.0.11 窗口会话存的是 raw16 bin(np.fromfile(dtype=np.uint16) .reshape(h, w),(h, w) 取 metadata.json cameras.<槽名>.height/width), 读取方已做扩展名回退兼容(depth_align.load_depth_frame)。

4.3 metadata.json 字段概览(EgoData 根级元数据)

键 含义
format / format_version 固定 "egodata" / "1.0"
episode_index episode 序号
fps 默认录制帧率
task_name 任务名(可为空)
cameras {槽名: {height, width, ...}};深度槽带 type:"depth"、unit:"mm"、format:"png16"(v1.0.11 窗口会话为 "raw16");RGB 槽带独立帧率 fps(与默认不同时);挂设备归属时带 device_key/device
devices 设备段数组:{key, kind, name, slots, serial?, sensor_column?, resolution, fps, calibration};serial 仅在设备提供时写入
sensors 传感器名列表(如 ["right_glove", "left_glove"])
sensor_dim 传感器维度(256 = 16×16 触觉矩阵展平)
created_at 创建时间(Unix 时间)
codebase_version 程序版本(config.__version__)
video_codec v1.0.9:本会话视频编码信息 {encoder, codec, crf, ffmpeg, selected_by, probe};selected_by = "auto" 或显式指定名,probe = 录前速度探针结果(x265 路径)
drop_stats v1.0.9:录制丢帧统计 {队列键: 丢弃帧数} + imu_overflow(IMU 防丢缓冲溢出次数);无丢帧时全为 0。v1.3.10 起另有带后缀的键:*_ms(时长)/ *_count(次数)——夹爪 RGB 的帧空洞与采集侧仪表(<槽>_gap_ms、_readfail_ms、_overwrite_count、_emit_lag_max_ms、_dispatch_lag_max_ms 等)。**_ms/_count 后缀的键不是帧数**,加总帧数前必须过 core.pipeline.is_frame_drop_key(见 file_format §7.3、postmortem §4.6)

4.4 timestamps.json

{
  "timestamps": [ { "frame_index": 0, "timestamp": 0.0, "wall_time": 1.7e9 }, ... ],
  "total_frames": 1234
}
  • timestamp:会话相对时间(秒,与 parquet 的 timestamp 列同源);wall_time:写入时刻的墙上时间。
  • hardware_ns:仅双目相机帧携带(SDK 硬件纳秒时钟,与 IMU 同源);含硬件 时间戳的行在写出时按 hardware_ns 稳定排序,保证时间线单调。

4.5 Parquet schema(data/ 与 meta/episodes/)

  • data/<sensor>/chunk-0000/chunk_000000.parquet(每帧一行,zstd 压缩):

    列 类型 含义
    episode_index int64 episode 序号
    frame_index int64 帧序号
    timestamp float32 会话时间(秒)
    task_index int64 任务序号(当前固定 0)
    observation.<sensor> list<float32, 256> 传感器读数(16×16 展平;缺帧补零)。手套压力矩阵落盘的是固件原始通道号,不做任何手型重排 —— 右手帧恰好等于规范系,左手帧是规范系的 [::-1, ::-1].T(转置 + 180°)。任何把矩阵当"手"来读的消费方(分区显示、掌区映射、跨模态对齐)**必须先过 core.render_engine.canonical_pressure_matrix(m, side)**,否则左手会整体转 90°(2026-09-21 修的就是这个;契约见该函数 docstring)
    observation.left_hand_pose / observation.right_hand_pose list<float32, 63> 手部关键点(21 关节 × xyz,米,腕部相对系;USB 手套录制时由 IMU 实时解算回填,无手套或未解算时为零占位)。全链无平滑:写的是 SDK 的 KeypointFrame.joints_m 原始解算输出,不含 sdk/glove.py 那层 35ms EMA(改用它会引入不可逆相位滞后,见 core/glove_keypoint_solver.py 的 joints_m 注释)
    action list<float32, 1> 动作(当前固定 [0.0])
    status.<device_id> string 该设备在本帧的连接状态(默认 "connected")
  • data/imu/chunk-0000/chunk_000000.parquet(双目 IMU,每帧一行):

    列 类型 含义
    episode_index / frame_index / timestamp / task_index 同上 同上
    hardware_ns int64 帧的 SDK 硬件纳秒时间戳
    imu_ts_ns list 本帧窗口内 IMU 样本时间戳,与样本一一对应
    observation.imu list<list<float32, 6>> 样本序列,每样本 [gx, gy, gz, ax, ay, az]
  • meta/episodes/chunk-000/file-000.parquet(每会话一行): episode_index、task_index、start_frame_index、end_frame_index、 length(均为 int64)。

4.5.1 UMI 夹爪列与跨模态时间对齐

启用夹爪时 episode parquet 增加下列稀疏列。无样本的行:*_ns 时刻列填 0、3 向量力填 [0,0,0]、力矩阵填空 list —— 这个 0 是哨兵不是时刻 (features[...].missing_sentinel = 0 已声明),读数时先滤掉再当时间轴用 (实测 7%~15% 的行是这种:力按 30Hz 采、行也是 30Hz,相位不同步时必然有空窗):

列 类型 含义
observation.gripper_{left,right}_force list<float32, 3> 3 向量力 [fx,fy,fz] mN
observation.gripper_{left,right}_force_ns int64 该力样本的采集时刻(宿主单调钟 ns;0 = 本行无样本)
observation.gripper_{left,right}_force_matrix list / list 250×250×3 力矩阵(档位见下)
observation.gripper_{left,right}_force_matrix_ns int64 该力矩阵样本的采集时刻(0 = 本行无样本)
observation.slam_trajectory list 轨迹点 [t,x,y,z,qx,qy,qz,qw]×N,变长(坐标帧见下)
observation.slam_trajectory_ns list 每个轨迹点对应取样帧的宿主时刻,与 slam_trajectory 同序等长
observation.gripper_state list<float32, 3> [open_pct, gripped, fz_mn]

力矩阵档位记在每段一份的 meta/episodes 行(force_matrix_specs 列), 不按面值猜——同一任务换档时前一段的倍率不会被后一段顶掉。

SLAM 轨迹的坐标帧

x,y,z,qx,qy,qz,qw 在现实中指向哪,由 meta/info.json 里的帧标注给出:

键 值
observation.{prefix}slam_trajectory.frame 帧 id,如 down_left_forward
observation.{prefix}slam_trajectory.frame_axes {"+X":"down","+Y":"left","+Z":"forward"}
gripper_slam_frame(顶层,仅真有 SLAM 轨迹的任务) 同上帧 id,一眼可查
  • v1.3.13 起默认 down_left_forward:**+X=下 / +Y=左 / +Z=前**,右手系, 单位米,原点为按下 ENTER 那一刻的夹爪位置,姿态相对该帧首帧。
  • v1.3.12 及更早录的 episode(data/recordings/UMIGripper_Action_AI 里 那 153 段)是 right_forward_up:+X=右 / +Y=正对 / +Z=上。数值本身总是能 正常解算,只是含义不同,读端必须按帧标注解释,不能假设一种。
  • ⚠ 分界是发布版本,不是日历日:v1.3.13(2026-09-28)起才是新帧; 此前的版本不写 frame 键 —— 所以旧 episode 里这两个键不存在, 「键不在」就等于旧帧,不要当成"缺省 = 新帧"。(2026-09-22..09-27 用 v1.3.12 录的也在旧帧那侧:没有键。老数据最新一段录于 2026-09-21 21:01; 新帧那边已有 UMI_9_29(2026-09-29 录、204 段、app_version 1.3.13)—— 这条线已经跨过去了,同一份 data/recordings 里两种帧并存。)
  • ⚠ 帧标注是任务级的(meta/info.json 的 features 按并集/后写覆盖合并): 一个任务目录若跨了这次改帧,它只能记住最后写入的帧,该任务早期 episode 会被标错。按 episode 精确区分需要 meta/episodes 加列(v1.3.13 未加)—— 跨改帧的任务目录请按录制日期切分,切点取"那天升到了 v1.3.13"。
  • 环境变量 KSQ_GRIPPER_SLAM_FRAME=right_forward_up 可让新录制回落到旧帧 (做 A/B 对比用);未设/空串走新帧,认不出的取值会向 stderr 出声后回落 新帧,不静默。该变量每次会话解析一次,录制中途改它不生效。

为什么需要 _ns 列。 同一行里的 RGB 帧和力样本不是同时刻采集的, 两条支路各走各的路:RGB 走外部帧源队列,写线程每 tick 取队头最旧帧; 力/矩阵走 latest-wins 单槽,写线程取到的是当下最新样本。于是「行号相同」 ≠「时刻相同」——力恒领先 RGB,领先量等于那张 RGB 帧在队列里的滞留时间, 且随录制时长增长(RGB 源实测 30.84 fps vs 写线程 30 fps,约每秒多积 1 帧)。 力矩阵还走独立泵线程,与 3 向量力之间也各记各的时刻,所以两者不共用一列。

v1.3.3 起每行落盘两条支路各自的采集时刻(宿主单调钟纳秒,与 hardware_ns 同一时基),下游按时间取最近邻即可还原真实配对:

venv/bin/python scripts/align_modalities.py <episode.parquet> --residual
from align_modalities import align_episode
mapping, residual_ns = align_episode("episode-000.parquet", side="left")
# mapping[k] = 视频第 k 帧应对应的 parquet 行号
  • 对齐精度上界是半个力样本间隔,不是半个行周期。force_ns == 0 表示 「本行窗口内没有新样本」(不是时刻为 0,取最近邻时必须排除,否则会把 一批帧吸到第 0 行)。样本每 k 行才有一个时上界放宽到 k/2 个行周期。
  • 早于第一个力样本的帧(段首陈旧帧,见 rgb-prestart-frames)与晚于最后 一个样本的帧在时间上没有对应样本,只能夹到端点——这不是对齐结果, 脚本单列「越界帧」计数,下游应丢弃。
  • v1.3.3 之前录的 episode 无法这样对齐:那时力侧完全没有时间戳,RGB 侧 的 hardware_ns 还被 pyqtSignal(int) 截成 32 位(每 2.147 s 翻符号)。 脚本对这类 episode 明确报「无法对齐」而不是拿行号或增长率去猜。
⚠ 姿态相对化的语义改过一次 —— 新旧数据要分开看

上面说的「姿态相对该帧首帧」有个乘在哪一侧的区别,v1.3.14 之前一直是 左边那一侧:

  • **正确(v1.3.14 起)=右乘 R · R0ᵀ**——在世界系里表达的相对姿态 (「相对锚点转了多大」)。
  • 改前=左乘 R0ᵀ · R——在锚点体轴系里表达的相对转动(body delta)。 两者数值上只差一个常数共轭,所以位置/幅度都「看着正常」,但读端按它解 会让表观旋转轴整体转错(本机锚点下约等于绕发布帧 −Y 轴转了 90°:绕 X 的表观变成绕 Z、绕 Z 的变成反向绕 X,只有绕 Y 正常)。修在 0ab64d7c (v1.3.14 那一笔),同一笔把 process_controller 里描述该语义的注释同步了。

frame 与 app_version 都分不出这两者:两个构建写的都是 frame=down_left_forward,__version__ 从 177ef0ef(左乘)到 0ab64d7c (右乘)**一直是 "1.3.13"**(版本号在下一笔才抬到 1.3.14)。⇒ 不能按版本 猜 1.3.13 的数据是哪一种。

三类数据要分开(左乘那段代码换过位置,但乘的侧一直没变 —— 别以为 「≤1.3.12 是另一条路径所以不受影响」):

数据 相对化 现况
新帧 + v1.3.14 起(frame=down_left_forward 且 __version__ ≥1.3.14) 右乘 按右乘读,就是现状
新帧 + v1.3.13(frame=down_left_forward,app_version 1.3.13) 左乘 仓库内只有 UMI_9_29,已回填成右乘(见下)
旧帧(没有 frame 键,≤1.3.12) 也是左乘 在 process_controller._pose_rotation_zero 里做的(注释原文「全部姿态左乘基准的逆」),v1.3.13 只是把它搬进 protocol.finish_publish_pose 而没有改乘的侧;本次没动,仓库里的旧帧库(UMIGripper_Action_AI 153 段等)原样留在左乘

⇒ 所以「仓库内一律按右乘解释」只对新帧那一支成立。旧帧数据要不要也按 右乘重解释,需要单独判定(左乘的差异是「绕锚点 R0 的常数共轭」,旧帧的 R0 与轴定义与旧帧文档是配套写的,不能想当然地照新帧的结论搬)——这一条待办, 不要按「旧数据没受影响」理解。

本机新帧那一支是已经回填好了:data/recordings/UMI_9_29(204 段, app_version 1.3.13)已于 2026-10-12 按右乘语义整体回填(逐点 q_new = A · q_old · A⁻¹,A = 绕发布帧 −Y 轴 90°,即把左乘值换成右乘值)。 逐点核验(全部 53272 点)与逐文件 sha256 前后值记在 data/recordings_rotfix_backup/UMI_9_29_rotfix_20261012/MIGRATION.json, 回填前的原值就存在同一个目录下(data/、meta/ 两棵子树,204 段都在); 逐文件核对:204/204 现场文件 = 记录的 sha256_after、204/204 备份 = sha256_before。

拿到仓库之外的 1.3.13 数据时先按这条确认(app_version 不足以判定)。 config/settings.py 与 config/__init__.py 的帧注释块有同一提醒。

4.5.2 SLAM 点与视频帧的时间对齐

slam_trajectory 每点的第 0 个字段 t 是相机传感器钟上的时间(native 侧 img->timestamp,相对按下 ENTER 设原点那一刻)。它与 RGB 帧的 hardware_ns(宿主单调钟)不同源,两者之间只差一个近似常量但带抖动的 偏移——只能从数据反推、不能换算。实测同一段数据反推出的偏移有三个估计值 (arrival_monotonic − t 中位、行桶中位、逐点拟合最优),散布约 104 ms, 已超一个帧间隔(33.3 ms)。

因此 v1.3.6 起 native 在每个取样帧进进程时取一次 CLOCK_MONOTONIC (RawImageFrame.host_mono_ns),随帧走完预处理/跟踪/排队全程,最终与位姿 一起打在 stdout 的 Host:(<ns>) 字段上,落成 observation.{prefix}slam_trajectory_ns。

改这段 native 代码要认准文件:构建源是 core/gripper/orb_slam_src/ORB-SLAM/Examples/fays/fayssense_orb_slam.cc, 由同树 dist/fays_opencv48/CMakeLists.txt 的 FAYS_BRIDGE_SOURCE 硬指向 (该值缺省就是 ${KSQ_ROOT}/ORB-SLAM/Examples/fays/fayssense_orb_slam.cc)。 2026-09-17 之前这两份源码只存在于 online/(不上传),现已随包入库, 可以直接改、有版本控制兜底;重建走 core/gripper/orb_slam_src/build.sh。 core/gripper/native/ORB-SLAM/Examples/fays/ 下那份是陈旧副本,不是构建源 (只留一份 SUPERSEDED.md),改它不会进二进制——它连 2026-09-11 的崩溃修复 都没有。online/ 里那份现在是同一份源码的第二份拷贝,不再是真源。

  • 与 slam_trajectory 同序等长,下标即配对,所以某帧有 N 个轨迹点就有 N 个时刻。这要求时刻列表锁步累积:某个点没有戳时补 0(= 未知,与 *_force_ns 的约定一致)而不是跳过,跳过会让后续点全部错位且无从察觉。
  • 有了它,配 slam 点↔视频帧退化成一次 searchsorted:按 hardware_ns 找 最近的 slam_trajectory_ns。不需要拟合偏移,更不需要插值——从实测点里 取最近邻是合法的 1:1,不是造假数据。
  • 全段无戳时不建该列(例如 Python 侧已升级但 native 二进制未重编)。 下游按列是否存在判断「本段能否按时间对齐」;列存在但个别值为 0 表示那 一个点缺戳。
  • 行数不等于点数。 写线程在落后超过一帧时会重置时钟并跳过那些 tick (core/pipeline.py 的 _write_loop),实测一段 11.4 s 的录制因此少写 11 行;slam 点侧则约丢 0.22%。所以「slam 点数 == 视频帧数」不成立, 按时间戳配对后剩下未配对的点/帧是正常现象,不是数据损坏。

4.6 meta/info.json 字段概览(LeRobot v3 兼容,上传服务器严格依赖)

键 含义
codebase_version 固定字符串 "v3.0"
fps 默认录制帧率
video bool:是否有视频
task_name 任务名(空时 "")
features {observation.<sensor>: {dtype:"float32", shape:[16,16]}, observation.imu: {dtype:"float32", shape:[6]}, action: {dtype:"float32", shape:[1]}};shape 必须是 2D [16,16] 而非 1D [256]
cameras dict 格式 {槽名: {height, width, fps?}}(深度槽不在此列)
devices 紧凑设备段:[{key, kind, name, slots}](无 serial 等敏感字段)
device_names 槽位 → 用户命名映射
sensors 传感器名列表
sensor_dim 传感器维度
created_at 创建时间(Unix 时间)

stats.json 对应 features 各键给出 mean/std/min/max(IMU 为 6 轴 样本级统计,action 为占位值),供归一化使用。

5. 相邻输出目录 keypoints_output/(不在 data/ 下)

录制后的手部关键点处理结果不写回 data/recordings/,而是镜像输出到 仓库根的 keypoints_output/(settings.KEYPOINTS_OUTPUT_DIR,gitignore):

keypoints_output/<task>/<session>/
├── videos/                             # 关键点可视化视频
├── hand_pose/chunk-000.parquet         # 2D 手部关键点
├── hand_pose_3d/chunk-000.parquet      # 3D 手部关键点
└── auto_labels/auto_labels.parquet     # 自动标注

读取时有三级回退路径(core/helpers.py):先查 keypoints_output/ 镜像, 再查会话目录内 keypoints/,最后查旧版 annotations/(含 annotations/mmpose/)。