Spaces:
Running
Running
File size: 34,422 Bytes
d646dd7 693e2e6 d646dd7 ddb63ef d646dd7 dd446f1 ddb63ef dd446f1 ddb63ef d646dd7 af37f5b d646dd7 330cf83 d646dd7 0901daa d646dd7 ef2250d ddb63ef ef2250d ddb63ef ef2250d ddb63ef d7a054c 2535d68 ef2250d d7a054c dd446f1 d7a054c ef2250d dd446f1 2535d68 42af1ef 2535d68 d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 | # 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/`)。
|