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 对象:
```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)。顶层结构:
```json
{
"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`:
```json
{
"<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](file_format.md) §7.3、[postmortem](postmortem_trajectory_and_rgb.md) §4.6) |
### 4.4 `timestamps.json`
```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<int64> | 本帧窗口内 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<int16> / list<float32> | 250×250×3 力矩阵(档位见下) |
| `observation.gripper_{left,right}_force_matrix_ns` | int64 | 该力矩阵样本的采集时刻(0 = 本行无样本) |
| `observation.slam_trajectory` | list<double> | 轨迹点 `[t,x,y,z,qx,qy,qz,qw]`×N,变长(坐标帧见下) |
| `observation.slam_trajectory_ns` | list<int64> | 每个轨迹点对应取样帧的**宿主时刻**,与 `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`
同一时基),下游按时间取最近邻即可还原真实配对:
```bash
venv/bin/python scripts/align_modalities.py <episode.parquet> --residual
```
```python
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/`)。