顶层布局与格式判别
- 格式判别MUST:读
meta/info.json的format字段,"pooled_episodes_v1"即本契约;format缺失且存在<task>_NNNNNN子目录者为 v1.0 旧格式。 - 任务目录内MUST NOT存在
calibration/、metadata.json、timestamps.json、每会话子目录(v1.0 遗留物)。 <task>即任务名;上传/项目语义中「会话名」概念废除,任务名即项目名。
编号规则
全局 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 序号永不复用(服务器已持有该号)。
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 可把旧分片原地拆成每段文件(幂等)。meta/info.json(任务级)
单 JSON 对象(可读缩进,无单行约束)。字段:
| 字段 | 类型 | 说明 |
|---|---|---|
format | str | "pooled_episodes_v1"(格式判别键) |
chunks_size | int | 1000 |
data_path | str | 模板 data/chunk-{c:03d}/episode-{f:03d}.parquet |
video_path | str | 模板 videos/chunk-{c:03d}/{image_key}/episode-{f:03d}.{ext} |
episodes_path | str | 模板 meta/episodes/chunk-{c:03d}/episode-{f:03d}.parquet |
codebase_version | str | 数据代码基准版本(如 "v3.0",沿用服务器侧语义) |
app_version | str | 主程序版本(如 "1.1.0") |
fps | float | 录制帧率(如 30.0) |
video | bool | 是否含视频 |
task_name | str | 任务名(= 目录名) |
features | obj | 只声明实际存在的列:action 恒在;observation.<sn> 每传感器一列;有 IMU 才有 observation.imu。形如 {"action": {"dtype": "float32", "shape": [1]}, "observation.right_glove": {"dtype": "float32", "shape": [256]}} |
cameras | obj | RGB 相机键 → {height, width}(深度槽不入 cameras,见 video_extensions) |
devices | list | 设备紧凑注册:{key, kind, name, slots}(kind: d435/s80m/uvc…;slots 为该设备的 image_key 列表) |
device_names | obj | image_key → 设备显示名 |
sensors | list | 任务级出现过的传感器名 |
sensor_dim | int | 传感器观测维度(如 256) |
created_at | float | 任务创建时间(Unix 秒) |
calibration | obj | 任务级最新标定(每设备一键,如 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_episodes | int | 当前 episode 数(上传删除不回退,见 §6) |
video_extensions | obj | 每个 image_key → 扩展名(RGB=mp4,深度=mkv);深度是 mkv 不是 mp4,读侧MUST按此判文件,勿按 .mp4 硬猜 |
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已废除,读到即旧版残留,忽略即可)。 - MUST
count恒存在。无count的块 = v1.1.0 旧格式,写入方须全量重扫data/重建一次再增量合并(参考core.helpers.recalc_stats)。 action恒为占位块(count:0,仅声明列形状,不参与统计)。- 只统计实际存在的列:帧级
observation.<sn>(逐行计数,含全零行)、样本级observation.imu(按样本计数);observation.*hand_pose为恒写占位零列(后处理回填),不入统计。 - 语义:删除 episode 不回退 stats(与任务进度水位一致);stats.json 是全任务聚合快照,不是逐 episode 重建值。
meta/tasks.jsonl(单行 JSONL 是格式契约)
{"task_index": 0, "task": "D435-裸手_3D手部关键点识别_AI标注"}
- MUST每行一个完整 JSON 对象、且当前实现为单行;读取方必须
json.loads(f.readline())逐行解析。禁止美化/多行重排——多行会静默破坏首行读取器(task_index 变 0)。写入方只在文件不存在时创建。
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_index | int64 | 全局序号 N(1 起) |
task_index | int64 | 任务序号(现恒 0) |
start_frame_index | int64 | 首帧 frame_index |
end_frame_index | int64 | 末帧 frame_index |
length | int64 | 帧数 = data parquet 行数 |
created_at | float64 | episode 开始时间(Unix 秒) |
duration_sec | float64 | 时长(秒) |
drop_stats | str | JSON 字符串(如 {"imu_overflow": 0}),读取方须 json.loads |
video_codec | str | JSON 字符串:本机原始编码信息(encoder/codec/crf/ffmpeg 路径/probe);上传 zip 中的 mp4 可能已被再编码,见 §8 |
calibration | str | JSON 字符串:本 episode 标定(结构与 info.json 的 calibration 同型),保每 episode 标定保真 |
force_matrix_specs | str | JSON 字符串:本段力矩阵列规格(列名 → 描述),倍率的权威来源,见 §7.2.1。v1.3.x 新增;旧 episode 无此列(读侧MUST容忍缺失,退回 info.json) |
追加旧任务(写侧MUST):向 v1.3.x 之前的任务继续录 episode 时,重写整表须对缺列补默认值(force_matrix_specs → "{}",现由 _episode_rows_table 按 schema 填),否则新行与旧行拼不成一张表。读侧同理MUST容忍该列缺失。
data/(每 episode 一个 parquet)
data/chunk-{c:03d}/episode-{f:03d}.parquet,zstd 压缩,稀疏列(有数据才写该列)。
7.1 键列(恒有)
| 列 | 类型 | 说明 |
|---|---|---|
episode_index | int64 | = N |
frame_index | int64 | 帧内序号,0 起递增,无空洞(abort 不落盘) |
timestamp | float32 | 墙钟相对秒(episode 内相对,f32 精度够) |
task_index | int64 | 现恒 0 |
wall_time | float64 | 绝对 Unix 秒(宿主时钟锚) |
hardware_ns | int64 | 设备时钟硬件时间戳(ns,可绕回);IMU 挂靠基准 |
7.2 观测列(稀疏)
| 列 | 类型 | 说明 |
|---|---|---|
observation.<sn> | list<float32, 256> | 每帧一个传感器读数(维度 = info.json sensor_dim) |
imu_ts_ns | list<int64> | 与 observation.imu 一一对应的样本时间戳 |
observation.imu | list<list<float32, 6>> | 挂在帧行上的变长样本列表(1000Hz/400Hz 只是 list 长短变化) |
observation.left_hand_pose | list<float32, 63> | 恒写占位零,后处理回填(裸手 63 维 world landmarks) |
observation.right_hand_pose | list<float32, 63> | 同上 |
action | list<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_raw | list<float32> | 原值直存(无差分无量化),reshape(250,250,3);不要 cumsum |
row_diff_quantized | list<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 不含它。
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流。
- 双流(v1.0.14+ 录制直出):流0 = 热力图 H.264(默认播放画面),流1 = FFV1
- RGB 的
_aux类辅助流归主 key 同一目录,非主键可忽略。
zip 内 mp4 可能已被再编码为 HEVC(hvc1, CRF30)以缩小体积,与 episodes 行
video_codec(描述本机原始编码)不必一致;mkv 深度永不再编码。上传 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 不回退。
关键点镜像布局(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 对齐。
不属于架构的文件(忽略/不得依赖)
| 文件/目录 | 说明 |
|---|---|
meta/episodes/.lock | 运行时 flock 锚点,恒空 |
meta/recycled_episode.json | 异常终止回退标记(§7.5),完成即删 |
meta/.stats_state.json | v1.1.0 边车残留,已废除,见即删 |
_trash/ | v1.1.1 及更早应用内删除的回收区残留;v1.1.2 起删除即彻底删除,不再产生 |
.{name}.tmp | 原子写临时件(os.replace 前身) |
_episodes_{task_id}_{N}.parquet | 上传打包临时切片(仅旧分片回退路径产生),打完即删 |
_migrate_backup_* | 一次性迁移备份目录 |
对齐检查清单(新程序接入逐项验证)
- 读
info.json判format=="pooled_episodes_v1",未知 format 报错不猜。 - 路径全部经模板 + (N→chunk,file) 计算,勿扫描拼接;
chunks_size读 info.json。 - episodes 每段一个文件(file 编号 = data/videos 同编号);旧目录的多行分片按
episode_index列过滤回退,勿按文件名对齐。 - stats.json 每块必有
count;无 count 的旧块触发全量重算或忽略并告警。 - tasks.jsonl 逐行
json.loads,写回保持单行。 - 深度文件按
video_extensions判 mkv,FFV1 流号探测,双流/单流都支持。 - 视频槽名按 episodes 行枚举,容忍跨 episode 槽改名。
- dotfiles 与临时件一律忽略;上传 zip arcname 与池化目录一致、episodes 带本 episode 单行文件、
name=任务名 + formepisode_index。 - IMU 是变长 list 列,读取端展开时按
imu_ts_ns对齐,勿假设固定采样率行宽。 - 新布局 episodes 写入 = 单文件临时件 + os.replace 原子替换(无需 flock);旧分片回退的读-改-写必须走 flock(POSIX)+ 临时文件 + os.replace。