# 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 { "": { "name": "<用户命名>", "sensor": "" } } ``` - **key**:设备稳定标识 `DeviceInfo.stable_key`,形式如 `uvc:{by-id 前缀}`(同型号同序列号两台并存时 udev 的 by-id 链接名相撞、只有一台拿得到链接,**且谁拿到取决于注册顺序、重枚举时会翻转** ⇒ 两台都退到 `uvc: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": "" } ``` 任务条目字段(由 `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//` 下含 `meta/info.json` 的会话目录数回填初值并写回,此后以持久化值为准。 - 任务页「🗑 删除」= 真删除:先按 `//` 校验删净 (删不掉则弹窗报错且任务不删除),再删除该目录下的录制记录行,最后只在本 文件写 `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 { "": { "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_name 为空时叫 "session") └── _000001/ # 会话(episode)目录:任务名 + 6 位序号 # 旧格式兼容:episode_000001/ ├── metadata.json # EgoData 根级元数据(见 4.3) ├── timestamps.json # 逐帧时间戳(见 4.4) ├── videos/ # RGB 视频(每台相机一路 MP4) │ └── / # 槽名,如 head_left_rgb;_aux 后缀相机归入主相机目录 │ └── chunk-0000/ │ └── .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) │ ├── / # 每传感器一个目录,如 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` 后缀摄像头归入主摄像头目录。 - **旧格式兼容**:会话目录也可能以 `_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///`(见第 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//chunk-0000/chunk_000000.parquet`(每帧一行,zstd 压缩): | 列 | 类型 | 含义 | |---|---|---| | `episode_index` | int64 | episode 序号 | | `frame_index` | int64 | 帧序号 | | `timestamp` | float32 | 会话时间(秒) | | `task_index` | int64 | 任务序号(当前固定 0) | | `observation.` | list | 传感器读数(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 | 手部关键点(21 关节 × xyz,**米**,腕部相对系;USB 手套录制时由 IMU 实时解算回填,无手套或未解算时为零占位)。**全链无平滑**:写的是 SDK 的 `KeypointFrame.joints_m` 原始解算输出,不含 `sdk/glove.py` 那层 35ms EMA(改用它会引入不可逆相位滞后,见 `core/glove_keypoint_solver.py` 的 `joints_m` 注释) | | `action` | list | 动作(当前固定 `[0.0]`) | | `status.` | 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> | 样本序列,每样本 `[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 | 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 | `[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 --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:()` 字段上,落成 `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.: {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/// ├── 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/`)。