录制数据落盘规范 · v1.1.2

数据文件接口契约

本文是录制数据落盘格式的规范性契约,供一切读写该数据的程序严格对齐:服务器导入器、离线处理工具、第三方数据读取器。标注 MUST 的条目写入方必须产出、读取方必须容忍;SHOULD 为建议。歧义以 core/helpers.py 与 core/egodata_writer.py 的实现为准。

format = "pooled_episodes_v1" chunks_size = 1000 v1.1.0 起任务级池化 v1.1.1 stats 自含 count v1.1.2 episode- 前缀命名
data/recordings/<task>/                          # <task> = 任务清洗名(可含中文)
├── videos/
│   └── chunk-{c:03d}/<image_key>/episode-{f:03d}.{ext}
├── data/
│   └── chunk-{c:03d}/episode-{f:03d}.parquet
└── meta/
    ├── info.json
    ├── stats.json
    ├── tasks.jsonl
    ├── recycled_episode.json               # 异常终止回退标记,见 §7.5
    └── episodes/
        ├── .lock                            # 运行时锁锚点(旧分片回退路径),见 §7.4
        └── chunk-{c:03d}/episode-{f:03d}.parquet   # 每 episode 一个文件(单行)
N = 1 → chunk-000 / episode-000 N = 1000 → chunk-000 / episode-999 N = 1001 → chunk-001 / episode-000 chunk = (N−1) // 1000 · fnum = (N−1) % 1000
§1

顶层布局与格式判别

  • 格式判别MUST:读 meta/info.json 的 format 字段,"pooled_episodes_v1" 即本契约;format 缺失且存在 <task>_NNNNNN 子目录者为 v1.0 旧格式。
  • 任务目录内MUST NOT存在 calibration/、metadata.json、timestamps.json、每会话子目录(v1.0 遗留物)。
  • <task> 即任务名;上传/项目语义中「会话名」概念废除,任务名即项目名。
§2

编号规则

全局 episode 序号 N 从 1 起递增(跨该任务全部 episode;权威 = data/videos 文件组扫描与 tasks.json 进度水位取 max):

chunk = (N - 1) // 1000     # chunk-000 装 N=1..1000
fnum  = (N - 1) % 1000      # 三位零起:episode-000 .. episode-999
  • chunks_size = 1000(info.json 声明,服务器/工具按此计算,勿写死)。
  • 同一 episode 的MUST所有文件共用同一个 (chunk, fnum):视频各流、data parquet、episodes 每段文件(episode-005 = 第 6 个 episode,全部同名)。
  • 界面/上传的任务名后缀 = episode 文件号(0 基):第 N 段显示 _ep{(N-1)%1000:06d}(即 _ep000011 = 本地 episode-011)。回放/上传界面显示名、zip 内 arcname、上传表单 episode_index 值MUST三处同一号码(= 本地文件后缀);真实全局序号 N 只出现在 parquet 的 episode_index 列,不靠界面名反推。
  • 异常终止不占号:GUI「异常终止」丢弃的录制不消耗序号——中止时写下 meta/recycled_episode.json 标记(§7.5),下一次录制优先复用该号(即使进度水位已跑在前面也不跳号);正常完成即清标记。删除(含上传后自动删除)不回退:已完成的 episode 序号永不复用(服务器已持有该号)。
旧分片命名残留(读侧 MUST 容忍)
v1.1.0 早期曾用「每 chunk 一个分片」,分片文件名 = chunk 自身编号(chunk-000 → file-000.parquet,一个文件装该 chunk 内最多 1000 个 episode 的行)。该布局已废除,新写入一律每段一文件;读侧工具对旧分片按 episode_index 列过滤做回退,不靠文件名。v1.1.2 起每段文件前缀 episode-、分片保留 file- 前缀,两者路径不再重名。scripts/migrate_pooled_storage.py --split-episodes 可把旧分片原地拆成每段文件(幂等)。
§3

meta/info.json(任务级)

单 JSON 对象(可读缩进,无单行约束)。字段:

字段类型说明
formatstr"pooled_episodes_v1"(格式判别键)
chunks_sizeint1000
data_pathstr模板 data/chunk-{c:03d}/episode-{f:03d}.parquet
video_pathstr模板 videos/chunk-{c:03d}/{image_key}/episode-{f:03d}.{ext}
episodes_pathstr模板 meta/episodes/chunk-{c:03d}/episode-{f:03d}.parquet
codebase_versionstr数据代码基准版本(如 "v3.0",沿用服务器侧语义)
app_versionstr主程序版本(如 "1.1.0")
fpsfloat录制帧率(如 30.0)
videobool是否含视频
task_namestr任务名(= 目录名)
featuresobj只声明实际存在的列:action 恒在;observation.<sn> 每传感器一列;有 IMU 才有 observation.imu。形如 {"action": {"dtype": "float32", "shape": [1]}, "observation.right_glove": {"dtype": "float32", "shape": [256]}}
camerasobjRGB 相机键 → {height, width}(深度槽不入 cameras,见 video_extensions)
deviceslist设备紧凑注册:{key, kind, name, slots}(kind: d435/s80m/uvc…;slots 为该设备的 image_key 列表)
device_namesobjimage_key → 设备显示名
sensorslist任务级出现过的传感器名
sensor_dimint传感器观测维度(如 256)
created_atfloat任务创建时间(Unix 秒)
calibrationobj任务级最新标定(每设备一键,如 head_stereo,内容为 StereoCalibration.to_dict() 规范化结构:type/name/resolution/fps/baseline/left_camera/right_camera/depth_scale/cam_imu_timeshift,相机内参为 {intrinsic: [fx,fy,cx,cy], distortion: [...]})。多设备时以设备名/前缀消歧编号(d435_rgb_calibration 等)
total_episodesint当前 episode 数(上传删除不回退,见 §6)
video_extensionsobj每个 image_key → 扩展名(RGB=mp4,深度=mkv);深度是 mkv 不是 mp4,读侧MUST按此判文件,勿按 .mp4 硬猜
§4

meta/stats.json(任务级全局统计)

单 JSON 对象。每块 {count, mean, std, min, max},列表维度 = 列维度:

{
  "observation.right_glove": {"count": 1196, "mean": [...256], "std": [...],
                              "min": [...], "max": [...]},
  "observation.imu":         {"count": 9922, "mean": [...6], "std": [...],
                              "min": [...], "max": [...]},
  "action": {"count": 0, "mean": [0.0], "std": [1.0], "min": [0.0], "max": [0.0]}
}
  • count 是累加器契约MUST:sum = mean × count、sum_sq = (std² + mean²) × count。写入方在每个 episode 结束后读入现有 stats.json、按公式合并本 episode 统计、原子写回;文件自身即持久累加器,不存在任何边车(.stats_state.json 已废除,读到即旧版残留,忽略即可)。
  • MUSTcount 恒存在。无 count 的块 = v1.1.0 旧格式,写入方须全量重扫 data/ 重建一次再增量合并(参考 core.helpers.recalc_stats)。
  • action 恒为占位块(count:0,仅声明列形状,不参与统计)。
  • 只统计实际存在的列:帧级 observation.<sn>(逐行计数,含全零行)、样本级 observation.imu(按样本计数);observation.*hand_pose 为恒写占位零列(后处理回填),不入统计。
  • 语义:删除 episode 不回退 stats(与任务进度水位一致);stats.json 是全任务聚合快照,不是逐 episode 重建值。
§5

meta/tasks.jsonl(单行 JSONL 是格式契约)

{"task_index": 0, "task": "D435-裸手_3D手部关键点识别_AI标注"}
  • MUST每行一个完整 JSON 对象、且当前实现为单行;读取方必须 json.loads(f.readline()) 逐行解析。禁止美化/多行重排——多行会静默破坏首行读取器(task_index 变 0)。写入方只在文件不存在时创建。
§6

meta/episodes/(每段一个文件)

6.1 文件与原子性

  • 每 episode 一个文件 chunk-{c:03d}/episode-{f:03d}.parquet(与 data/videos 同编号:episode-000 = episode 1),恒 1 行(11 列,见 §6.2)。
  • episode 结束时单行原子写:临时文件 + os.replace 原子替换;同号重录 = 覆盖该文件。读取方MUST容忍文件在任意时刻被原子替换(每次打开重新读取,勿缓存 fd)。
  • 新布局写入MUST NOT依赖 flock(单文件原子替换即完整);flock 锚点 .lock 仅剩旧分片回退的读-改-写路径使用(见 §7.4)。
  • 旧分片回退(读侧MUST容忍):v1.1.0 早期目录可能存在「每 chunk 一个分片」(文件名 = chunk 编号,≤1000 行,行序按 episode_index 升序)。读侧定位行必须按 episode_index 列过滤、不靠文件名(v1.1.2 起分片保留 file- 前缀、每段文件改 episode- 前缀,路径不再重名)。迁移工具 --split-episodes 可原地拆分(幂等)。

6.2 列(11 列)

列类型说明
episode_indexint64全局序号 N(1 起)
task_indexint64任务序号(现恒 0)
start_frame_indexint64首帧 frame_index
end_frame_indexint64末帧 frame_index
lengthint64帧数 = data parquet 行数
created_atfloat64episode 开始时间(Unix 秒)
duration_secfloat64时长(秒)
drop_statsstrJSON 字符串(如 {"imu_overflow": 0}),读取方须 json.loads
video_codecstrJSON 字符串:本机原始编码信息(encoder/codec/crf/ffmpeg 路径/probe);上传 zip 中的 mp4 可能已被再编码,见 §8
calibrationstrJSON 字符串:本 episode 标定(结构与 info.json 的 calibration 同型),保每 episode 标定保真
force_matrix_specsstrJSON 字符串:本段力矩阵列规格(列名 → 描述),倍率的权威来源,见 §7.2.1。v1.3.x 新增;旧 episode 无此列(读侧MUST容忍缺失,退回 info.json)

追加旧任务(写侧MUST):向 v1.3.x 之前的任务继续录 episode 时,重写整表须对缺列补默认值(force_matrix_specs → "{}",现由 _episode_rows_table 按 schema 填),否则新行与旧行拼不成一张表。读侧同理MUST容忍该列缺失。

§7

data/(每 episode 一个 parquet)

data/chunk-{c:03d}/episode-{f:03d}.parquet,zstd 压缩,稀疏列(有数据才写该列)。

7.1 键列(恒有)

列类型说明
episode_indexint64= N
frame_indexint64帧内序号,0 起递增,无空洞(abort 不落盘)
timestampfloat32墙钟相对秒(episode 内相对,f32 精度够)
task_indexint64现恒 0
wall_timefloat64绝对 Unix 秒(宿主时钟锚)
hardware_nsint64设备时钟硬件时间戳(ns,可绕回);IMU 挂靠基准

7.2 观测列(稀疏)

列类型说明
observation.<sn>list<float32, 256>每帧一个传感器读数(维度 = info.json sensor_dim)
imu_ts_nslist<int64>与 observation.imu 一一对应的样本时间戳
observation.imulist<list<float32, 6>>挂在帧行上的变长样本列表(1000Hz/400Hz 只是 list 长短变化)
observation.left_hand_poselist<float32, 63>恒写占位零,后处理回填(裸手 63 维 world landmarks)
observation.right_hand_poselist<float32, 63>同上
actionlist<float32, 1>恒写(现为 0)
status.<did>str稀疏设备状态列("connected"/"disconnected" 等)

7.2.1 力矩阵列(UMI 夹爪)

observation.[gripper_N_]gripper_{left,right}_force_matrix —— 稀疏 list 列,每个样本是一整帧 (250,250,3)(fx/fy/fz,单位 mN)的扁平编码,长度恒 250 × 750 = 187500,行差分按 (250, 750) 分块还原:

编码列类型解码
row_flat_rawlist<float32>原值直存(无差分无量化),reshape(250,250,3);不要 cumsum
row_diff_quantizedlist<int16>先 cumsum 还原差分(int16 回绕,累加器用 int32),再 reshape,最后 ÷scale

scale 是读取端的必需信息,不能从数据推断:×10/×100/×1000 三档的列类型都是 list<int16>、元素也都是 int,只有 scale 能决定 ÷10 还是 ÷1000。漏读 = 数值整体放大 N 倍(图形形状仍对,故为静默错)。row_flat_raw 恒 1。

  • 权威顺序(读侧MUST):① 同号 meta/episodes/chunk-NNN/episode-NNN.parquet 的 force_matrix_specs(每段一份,权威);② <task>/meta/info.json 的 features[列].scale(任务级回退);③ 都没有 → 1(未定标)。
  • 为什么必须有 ①:info.json 是任务级且「值以最新 episode 为准」,同任务里换过力矩阵档位,早先几段的 scale 会被后一段覆盖(实测 ×10/×100/×1000 三段连录后再录一段 float32,三段全被顶成 1)。① 每段写一次、只增不改,不受后续录制影响。
  • 自检判据:同一帧 Σ(fz 平面) ≈ 同帧 observation.*_gripper_*_force 的 fz 分量(float32 档实测比值 1.000)。历史未定标 int16 档因逐点向零截断,该比值明显小于 1(实测 0.04~0.55)——这是截断造成的,不是倍率。scripts/repair_force_matrix_scale.py 用该判据给旧 episode 补写 ①(只补 meta 行,不碰 data 字节)。

7.3 时间对齐模型

表为帧行为主:一行 = 一个 30fps 帧;视频帧/传感器读数/帧行共用同一 frame_index(写入线程同拍产出)。IMU 为变长 list 列挂在帧行上,录制时按 hardware_ns 设备时钟窗口挂靠。读取零重采样;宿主时间锚定靠 timestamp + wall_time。客户 9 列平表由导出工具展开插值合成,非本契约内容。

7.4 meta/episodes/.lock(运行时辅助文件,非数据)

meta/episodes/.lock 是空文件,flock 锁锚点——旧分片回退的读-改-写用 os.replace 原子替换(inode 变化),锁必须挂在路径不变的锚文件上,故锚点为空文件、锁在 fd 上而非文件内容。新布局的每段文件写入是单文件原子替换,不走该锁。其他程序MUST忽略该文件(不解析、不打包、不告警);上传 zip 不含它(dotfiles 一律不进包)。

7.5 meta/recycled_episode.json(异常终止回退标记,非数据)

内容为单 JSON 对象 {"episode_index": <int>, "freed_at": "<ISO>"}。录制被「异常终止」时由写入器原子写入(临时件 + os.replace),标记该序号可复用;下一次录制取号时若该号未被文件组占用则优先复用;录制正常完成时清除,号已被占(跨机共享目录/遗留)时自动放弃。删除即彻底删除(v1.1.2 起无 _trash/ 回收区)。删除(含上传后自动删除)不写该标记——已完成的序号永不复用(见 §2)。其他程序MUST忽略该文件(不解析、不打包、不告警);上传 zip 不含它。

§8

videos/(每 episode 每流一文件)

  • 路径 videos/chunk-{c:03d}/<image_key>/episode-{f:03d}.{ext},<image_key> 为槽名。同任务内不同 episode 之间允许槽改名(如 ep1 用 D435_depth_rgb、ep2 用 D435_head_rgb)——读侧MUST按 episodes 行的 image_key 枚举,勿假设任务级固定集合。
  • 扩展名以 info.json video_extensions 为准:RGB=mp4(HEVC/h264),深度=mkv。
  • 深度 MKV 两种布局(读侧MUST都支持,用 ffprobe/ffmpeg 的 stderr 探测 FFV1 流号):
    • 双流(v1.0.14+ 录制直出):流0 = 热力图 H.264(默认播放画面),流1 = FFV1 gray16le 无损 uint16 毫米深度;
    • 单流(迁移合成):仅一个 FFV1 gray16le 流。
  • RGB 的 _aux 类辅助流归主 key 同一目录,非主键可忽略。
上传预压缩
zip 内 mp4 可能已被再编码为 HEVC(hvc1, CRF30)以缩小体积,与 episodes 行 video_codec(描述本机原始编码)不必一致;mkv 深度永不再编码。
§9

上传 zip 契约(给服务器导入器)

POST /api/v1/session/upload,端点不变。name 语义 = 任务名,form 字段 episode_index(字符串化的 episode 文件号——0 基,与本 episode 的 episode-{f:03d} 完全一致,见 §2「任务名后缀」;真实全局序号 N 以包内 parquet 的 episode_index 列为准;旧服务器忽略未知字段)。

zip = 单 episode 切片,包内 arcname 与原池化目录完全一致(相对任务目录):

videos/chunk-{c:03d}/<key>/episode-{f:03d}.{ext}    # 仅本 episode 的各流文件
data/chunk-{c:03d}/episode-{f:03d}.parquet          # 本 episode 数据
meta/episodes/chunk-{c:03d}/episode-{f:03d}.parquet # 本 episode 单行文件直传
meta/info.json                                    # 任务级快照
meta/stats.json                                   # 任务级快照(v1.1.1 含 count)
meta/tasks.jsonl                                  # 任务级
  • MUSTdotfiles(.lock、临时件)不进包;episodes 直传本 episode 的单行文件(旧分片回退时打成单行切片,arcname 同为新命名)。
  • 导入器以 info.json.format == "pooled_episodes_v1" 识别新格式。
  • 上传删除 = 删该 episode 文件组(videos/data 各 key 本 episode 文件),episodes 行、stats、total_episodes 不回退。
§10

关键点镜像布局(keypoints_output/)

后处理产物(手部关键点)按同一池化键镜像存放,与录制目录解耦:

keypoints_output/<task>/episode_{N:06d}/
├── hand_pose/chunk-000.parquet        # 2D 关键点(MediaPipe 后端)
├── auto_labels/auto_labels.parquet    # 自动标注
└── hand_pose_3d/chunk-000.parquet     # 3D 关键点(bare 63 维 world landmarks,
                                       #   回填 data parquet 两列的来源)

镜像目录内文件名不含 episode 号(父目录已键控),按帧 frame_index 与 data parquet 对齐。

§11

不属于架构的文件(忽略/不得依赖)

文件/目录说明
meta/episodes/.lock运行时 flock 锚点,恒空
meta/recycled_episode.json异常终止回退标记(§7.5),完成即删
meta/.stats_state.jsonv1.1.0 边车残留,已废除,见即删
_trash/v1.1.1 及更早应用内删除的回收区残留;v1.1.2 起删除即彻底删除,不再产生
.{name}.tmp原子写临时件(os.replace 前身)
_episodes_{task_id}_{N}.parquet上传打包临时切片(仅旧分片回退路径产生),打完即删
_migrate_backup_*一次性迁移备份目录
§12

对齐检查清单(新程序接入逐项验证)

  1. 读 info.json 判 format=="pooled_episodes_v1",未知 format 报错不猜。
  2. 路径全部经模板 + (N→chunk,file) 计算,勿扫描拼接;chunks_size 读 info.json。
  3. episodes 每段一个文件(file 编号 = data/videos 同编号);旧目录的多行分片按 episode_index 列过滤回退,勿按文件名对齐。
  4. stats.json 每块必有 count;无 count 的旧块触发全量重算或忽略并告警。
  5. tasks.jsonl 逐行 json.loads,写回保持单行。
  6. 深度文件按 video_extensions 判 mkv,FFV1 流号探测,双流/单流都支持。
  7. 视频槽名按 episodes 行枚举,容忍跨 episode 槽改名。
  8. dotfiles 与临时件一律忽略;上传 zip arcname 与池化目录一致、episodes 带本 episode 单行文件、name=任务名 + form episode_index。
  9. IMU 是变长 list 列,读取端展开时按 imu_ts_ns 对齐,勿假设固定采样率行宽。
  10. 新布局 episodes 写入 = 单文件临时件 + os.replace 原子替换(无需 flock);旧分片回退的读-改-写必须走 flock(POSIX)+ 临时文件 + os.replace。