Spaces:
Running
Download collector/docs/file_format.md from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 30.5 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/collector/docs/file_format.md
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/collector/docs/file_format.md
-
curl -L -o file_format.md https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/collector/docs/file_format.md
数据文件架构接口契约(v1.1.2)
本文是录制数据落盘格式的规范性契约,供一切读写该数据的程序严格对齐: 服务器导入器、离线处理工具、第三方数据读取器。凡本文标注 MUST 的条目, 写入方必须产出、读取方必须容忍;标注 SHOULD 为建议。歧义以
core/helpers.py与core/egodata_writer.py的实现为准。多设备并存(多相机 / 双夹爪 / ego 相机多轨迹)时的槽位与列名分配、
meta声明细节见multi_device_storage.md。
v1.1.0 起录制数据从「每会话一个子目录」改为任务级池化布局(LeRobot v3
命名)。v1.1.1 起 stats.json 自含累加器(每块加 count),.stats_state.json
边车已废除。**v1.1.2 起每段文件名前缀 file- 改为 episode-**(编号不变:
file-011 → episode-011),应用内删除改为直接彻底删除(不再产生 _trash/);
v1.1.2 起深度存储改单流 12-bit 灰度 HEVC MP4(gray12le 对数深度码;
x265 无 12-bit 灰度能力时回落 FFV1 gray16le MKV;旧双流 MKV / PNG16 仅
读侧容忍,见 §8)。
旧格式(v1.0 每会话目录)仅存在于历史数据,读侧工具内部处理,
本契约只定义新格式。
1. 顶层布局与格式判别
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 一个文件(单行)
- 格式判别:读
meta/info.json的format字段,"pooled_episodes_v1"即本契约;format缺失且存在<task>_NNNNNN子目录者为 v1.0 旧格式。 - 任务目录内没有
calibration/、metadata.json、timestamps.json、 每会话子目录(v1.0 遗留物)。 <task>即任务名;上传/项目语义中「会话名」概念废除,任务名即项目名。
2. 编号规则(MUST)
全局 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 的**所有文件共用同一个 (chunk, fnum)**:视频各流、data parquet、episodes 每段文件(episode-005 = 第 6 个 episode,全部同名)。
- 界面/上传的任务名后缀 = episode 文件号(0 基):第 N 段显示
_ep{(N-1)%1000:06d}(即_ep000011= 本地episode-011)。回放/上传 界面显示名、zip 内 arcname、上传表单episode_index值三处同一号码 (= 本地文件后缀);真实全局序号 N 只出现在 parquet 的episode_index列,不靠界面名反推。 - 异常终止不占号:GUI「异常终止」丢弃的录制不消耗序号——中止时
写下
meta/recycled_episode.json标记(§7.5),下一次录制优先复用该号 (即使进度水位已跑在前面也不跳号);正常完成即清标记。删除(含上传后 自动删除)不回退:已完成的 episode 序号永不复用(服务器已持有该号)。 删除即彻底删除(v1.1.2 起无_trash/回收区);2026-10-12 起删完校验 三处(videos/data/meta 行)确实不在,并真删本地录制记录(pipeline.db的 recording 行),删不净如实报错。 - 旧分片命名残留(读侧 MUST 容忍):v1.1.0 早期曾用「每 chunk 一个
分片」,分片文件名 = chunk 自身编号(chunk-000 → file-000.parquet,
一个文件装该 chunk 内最多 1000 个 episode 的行)。该布局已废除,新写入
一律每段一文件;读侧工具(
episode_row/上传/迁移)对旧分片按episode_index列过滤做回退,不靠文件名。v1.1.2 起每段文件前缀episode-、分片保留file-前缀,两者路径不再重名。
3. meta/info.json(任务级,MUST 字段)
单 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.imu": {"dtype": "float32", "shape": [6]}, "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;深度=mp4=12-bit 灰度 HEVC、mkv=FFV1 回落);读侧必须按此判文件,勿按扩展名硬猜格式 |
gripper_slam_frame |
str | 条件字段(v1.3.13 增):本任务 SLAM 位姿所用的坐标帧 id。只在任务真有 SLAM 轨迹列时写——没有 observation.*slam_trajectory 的任务不会出现这个键。取值见 §3.1 |
3.1 SLAM 位姿坐标帧(v1.3.13 增)
两处标注,作用不同:
**顶层
gripper_slam_frame**(上表):整个任务的位姿帧,一眼可见。**
features["observation.<sn>_slam_trajectory"]里的frame/frame_axes**:"observation.slam_trajectory": { "dtype": "float64", "shape": [8], "encoding": "flat_points_8_txyz_qxyzw", "frame": "down_left_forward", "frame_axes": {"+X": "down", "+Y": "left", "+Z": "forward"} }★键名带夹爪前缀:第 1 台夹爪前缀是空串(列就叫
observation.slam_trajectory, 上面的例子就是它),第 2 台起才是observation.gripper_2_slam_trajectory。 读端请按observation.*slam_trajectory配,别写死带gripper_1_的名字—— 那个键在磁盘上不存在。frame是帧 id,frame_axes是各轴在现实中指向哪(中文名只在 GUI 上,不落盘)。 两个已知 id:frameframe_axes叫法 down_left_forward{"+X":"down","+Y":"left","+Z":"forward"}新帧,v1.3.13 起的默认 right_forward_up{"+X":"right","+Y":"forward","+Z":"up"}旧帧( X=右/Y=正对/Z=上)★旧帧的
+Y也叫forward(中文写「正对」不是「前」),而它和新帧+Z的forward是同一个物理方向 —— M 恰好把中间帧 +Y 映到发布帧 +Z(实测映射: 旧+X 右→新−Y 右、旧+Y 正对→新+Z 前、旧+Z 上→新−X 上;后两项的方向词 读的是落点那个轴是什么方向,不是新帧同名字母的含义)。所以:forward可以跨帧等同,轴字母不行。旧+Y与新+Y差 90°(前者正对、 后者左),读端别按轴字母把两个帧对齐,要按frame_axes里的方向词。
读侧两条硬规则:
frame键不在 = 旧帧,不是"取默认值"。分界是发布版本不是日历日: v1.3.13(2026-09-28)起录的才写这个键,v1.3.12 及更早录的根本不写。 所以对老 episode 忽略这个键、按right_forward_up解读。- 标注是任务级的,不是每 episode 一条:
features按并集 / 后写覆盖合并, 一个任务目录若跨了这次改帧,info.json只能记住最后写入的那个帧, 该任务早期的 episode 会被标错。要逐 episode 精确区分得往meta/episodes加列(v1.3.13 未加)。跨线的任务目录请按录制日期切开。 - 帧是一回事、姿态相对化是另一回事:相对化从 ≤1.3.12 起一直是左乘
R0ᵀ·R(锚点体轴系里的 body delta;先在process_controller,v1.3.13 起搬到protocol.finish_publish_pose,乘的侧没变),v1.3.14 才改成正确的右乘R·R0ᵀ。两者只差一个常数共轭(位置/幅度看着都正常,但读端按错的那支解会让 表观旋转轴整体转错),而frame与app_version都分不出(两个构建 都是down_left_forward、__version__都写"1.3.13")。 新帧数据已按右乘回填(data/recordings/UMI_9_29,逐文件 sha256 记在其 rotfix 备份目录的MIGRATION.json);旧帧数据(无frame键)同样是左乘、 本次没动,要不要重解释待判定。详见docs/data.md。
坐标约定本身(各轴在现实中指向哪、原点与首帧相对化、两套帧 id、回落开关)
见本仓库 docs/data.md 的「SLAM 轨迹的坐标帧」一节。发布侧那张换基矩阵 M
(core/gripper/slam/protocol.py 的 PUBLISH_RELABEL_QUAT / PUBLISH_RELABEL_MATRIX)
是实现细节,data.md 里没有、读端也不需要:按 frame_axes 解释数值即可。
4. meta/stats.json(任务级全局统计,v1.1.1 契约)
单 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是累加器契约: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为恒写列(USB 手套 录制时由 IMU 实时解算回填,无解算时为零占位),不入统计。 - 语义:删除 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/(episode 元数据,每段一个文件)
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)。 - 新布局写入不需要 flock(单文件原子替换即完整);flock 锚点
.lock仅剩旧分片回退的读-改-写路径使用(见 §7.4)。 - 旧分片回退(读侧 MUST 容忍):v1.1.0 早期目录可能存在「每 chunk 一个
分片」(文件名 = chunk 编号的 file-{c:03d}.parquet,≤1000 行,行序按
episode_index升序)。读侧定位行必须按episode_index列过滤、 不靠文件名(v1.1.2 起分片保留 file- 前缀、每段文件改 episode- 前缀,路径不再重名)。scripts/migrate_pooled_storage.py --split-episodes可把旧分片原地拆成 每段文件(幂等)。
6.2 列(11 列,MUST)
| 列 | 类型 | 说明 |
|---|---|---|
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。v1.3.10 起含带后缀的键:*_ms/*_count 是时长/次数(夹爪 RGB 帧空洞与采集侧仪表),不是帧数——加总帧数前须过 core.pipeline.is_frame_drop_key(见 §7.3) |
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 无此列(读侧按缺列容忍,退回 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_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 挂靠基准。D435(v1.3.11 起)取本行那一帧自己的戳(RGB 行=彩色帧、深度=深度帧);取不到设备钟时写 0(=未知),宿主时间另存 host_ns |
depth_ns |
int64 | (D435)本行深度成员的设备钟 ns;深度轨对齐用,0=未知 |
rgb_dup / rgb_gap |
int8 / int32 | (D435)彩色帧是否与上一行同一物理帧 / 中间跳过几帧(frame_counter 口径) |
depth_dup / depth_gap |
int8 / int32 | (D435)同上,针对深度成员 |
raw_hw_ns |
int64 | 归一前的原始戳(v1.3.11,所有 RGB 槽位)。设备钟链路(D435)的 hardware_ns 在录制期被重写成「锚点 + 序号 × T」,原始值在这一列;宿主钟链路(夹爪/S80M/裸手)两列相同(v1.3.17 起不再重排) |
ts_src |
int8 | (D435)时间戳来源:0=无设备钟 1=逐帧元数据 2=帧组戳(设备钟) 3=帧组戳(宿主钟) |
host_ns |
int64 | (D435)仅 ts_src=3 时非 0:宿主单调钟 ns,不冒充设备钟 |
7.2 观测列(稀疏)
| 列 | 类型 | 说明 |
|---|---|---|
observation.<sn> |
list<float32, 256> | 每帧一个传感器读数(维度 = info.json sensor_dim) |
imu_ts_ns |
list | 与 observation.imu 一一对应的样本时间戳 |
observation.imu |
list<list<float32, 6>> | 挂在帧行上的变长样本列表(1000Hz/400Hz 只是 list 长短变化) |
observation.left_hand_pose |
list<float32, 63> | 恒写列:USB 手套录制时由 IMU 实时解算回填,无解算时为零占位;裸手 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(未定标)。
**为什么必须有 (1)**:info.json 是任务级且「值以最新 episode 为准」,
同任务里换过力矩阵档位,早先几段的 scale 会被后一段覆盖(实测 ×10/×100/
×1000 三段连录后再录一段 float32,三段全被顶成 1)。(1) 每段写一次、只增不改,
不受后续录制影响。
自检判据:同一帧 Σ(fz 平面) ≈ 同帧 observation.*_gripper_*_force 的
fz 分量(float32 档实测比值 1.000)。历史未定标 int16 档因逐点向零截断,
该比值明显小于 1(实测 0.04~0.55)——这是截断造成的,不是倍率。
scripts/repair_force_matrix_scale.py 用该判据给旧 episode 补写 (1)(只补
meta 行,不碰 data 字节)。
7.3 时间对齐模型(MUST 理解)
表为帧行为主:一行 = 一个 30fps 帧;视频帧/传感器读数/帧行共用同一
frame_index(写入线程同拍产出)。IMU 为变长 list 列挂在帧行上,录制时按
hardware_ns 设备时钟窗口挂靠。读取零重采样;宿主时间锚定靠 timestamp +
wall_time。客户 9 列平表由导出工具展开插值合成,非本契约内容。
两个时间戳分别是谁盖的(MUST 理解,v1.3.10 明确):
| 列 | 盖戳者 | 语义 |
|---|---|---|
wall_time |
写入线程,episode 起始墙钟 + timestamp(v1.3.17 起) |
该行采集时刻的绝对 Unix 秒;wall_time − timestamp 恒为常数、段内严格递增。旧口径是落行那一刻的 time.time(),会带上写队列积压与掉拍(实测差整行 33ms) |
hardware_ns |
采集侧,相机/传感器取到帧处 | 该帧取到的时刻。双目是 SDK 硬件戳(与 IMU 同源);夹爪 RGB 是采集线程的宿主单调钟(≤078 段被截成有符号 32 位、每 4.295s 锯齿一次,读取方须按 2^32 解卷 —— tools/audit_frame_gaps.py 的 trunc32) |
两者之差 = 中间队列的滞留(+ 两钟固定偏移)。空洞(相邻行 hardware_ns 间隔
超出标称帧间隔的部分)不等于画面丢了:夹爪 RGB 的空洞要走画面复核才能定性
(真丢 / 帧晚到 / 判不了),口径与全库基线见
postmortem §4。
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),标记该序号
可复用;下一次录制取号时若该号未被文件组占用则优先复用;
录制正常完成时清除,号已被占(跨机共享目录/遗留)时自动放弃。删除
(含上传后自动删除)不写该标记——已完成的序号永不复用(见 §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——读侧必须按 episodes 行的 image_key 枚举,勿假设 任务级固定集合)。 - 扩展名以 info.json
video_extensions为准:RGB=mp4(HEVC/h264), 深度=mp4(12-bit 灰度 HEVC)或mkv(FFV1 回落,仅 x265 无 12-bit 灰度能力时产生)。 - 深度视频(v1.1.2 起,MUST):单流 12-bit 灰度 HEVC MP4——hevc Rext
gray12le单平面(对数深度码,qp=6、range=full、不带 profile;hvc1 容器标记),解码后按对数码反量化回 uint16 毫米(实现口径在core/depth_codec.py/core/depth_reader.py)。读侧注意:cv2 直读 gray12le 的 8-bit 转换不可靠,像素一律走 ffmpeg CLI 解 gray12le。 - FFV1 回落(读侧 MUST 支持):x265 不可用时写 FFV1
gray16le无损 MKV(uint16 毫米原值),video_extensions记mkv。 - 旧深度布局(读侧 MUST 容忍,仅历史数据):
- v1.0.14 双流 MKV:流0 = 热力图 H.264(默认播放画面),流1 = FFV1
gray16le无损 uint16 毫米深度(用 ffprobe/ffmpeg 的 stderr 探测 FFV1 流号);v1.0.14 迁移合成的单流 MKV 同款; - PNG16:v1.0.12 及更早的单帧 PNG 序列。
- v1.0.14 双流 MKV:流0 = 热力图 H.264(默认播放画面),流1 = FFV1
- RGB 的
_aux类辅助流归主 key 同一目录,非主键可忽略。
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 # 任务级
MUST:dotfiles(
.lock、临时件)不进包;episodes 直传本 episode 的单行 文件(旧分片回退时打成单行切片,arcname 同为新命名)。导入器以
info.json.format == "pooled_episodes_v1"识别新格式。HEVC 预压缩:zip 内 mp4 可能已被再编码为 HEVC(hvc1, CRF30)以缩小体积, 与 episodes 行
video_codec(描述本机原始编码)不必一致;mkv 深度永不再编码。上传删除 = 删该 episode 文件组(videos/data 各 key 本 episode 文件 + 本段 episodes 行文件),删完校验三处都真的没了(
pooled_episode_leftovers), 没删净如实报错;stats、total_episodes 不回退(total_episodes 由下次录制 按list_task_episodes重算),episode 序号永不复用。删除入口的编号口径:
delete_pooled_episode/delete_local_session收的是 全局号 N(1 基),和上传器(episode_chunk_file(N))、UploadManager的(path, episode_index)防重键同一口径。文件名里的f是 chunk 内 0 基 文件号,跨 chunk 后f + 1不再是 N:chunk-000 满 1000 段就开 chunk-001 并从episode-000重新计数,所以N = c * 1000 + f + 1 (c = chunk 号,1000 = POOLED_CHUNK_SIZE)统一由
core.helpers.list_task_episodes(task_dir)还原(它按chunk*POOLED_CHUNK_SIZE + int(f) + 1产出全局号,是权威枚举器),不要自己 拿文件名拼 N。少 +1 删错段;多 +1 会指向不存在的 N+1(此时「没找到任何文件」 被幂等判为已删净而返回 True,界面报成功、文件一个没动)。 f 回绕尤其危险:chunk-001 出现后再取「最大文件名 f + 1」,得到的是 chunk-000 里真实存在的前一段(f=0 → N=1),于是静默上传/删除到错的段。 lite 的取号口径历史上错过两次:先是少 +1(返回 0 基文件号 f 当 N 用), 改成max(f) + 1后又在跨 chunk 时回绕;两处都已修成走list_task_episodes(tools/tests/lite_delete_test.py第 1/4/6 节分别钉 「ep 是全局 N」「单段目录 ep=1 不是 0」「跨 chunk 的 ep=1001 不是 1」)。_delete_after_upload本身一直是原样透传(没有 +1 可去),变的一直是它上游 的取号口径。旧格式(data/下没有任何chunk-*/episode-*.parquet)按整目录 处理,取号返回 0。
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.json |
v1.1.0 边车残留,已废除,见即删 |
_trash/ |
v1.1.1 及更早应用内删除的回收区残留;v1.1.2 起删除即彻底删除,不再产生 |
.{name}.tmp |
原子写临时件(os.replace 前身) |
_episodes_{task_id}_{N}.parquet |
上传打包临时切片(仅旧分片回退路径产生),打完即删 |
_migrate_backup_* |
一次性迁移备份目录 |
12. 对齐检查清单(新程序接入逐项验证)
- 读
info.json判format=="pooled_episodes_v1",未知 format 报错不猜。 - 路径全部经模板 + (N→chunk,file) 计算,勿扫描拼接;
chunks_size读 info.json。 - episodes 每段一个文件(episode- 编号 = data/videos 同编号);旧目录的多行
分片(file- 前缀)按
episode_index列过滤回退,勿按文件名对齐。 - stats.json 每块必有
count;无 count 的旧块触发全量重算或忽略并告警。 - tasks.jsonl 逐行
json.loads,写回保持单行。 - 深度文件按
video_extensions判扩展名;12-bit 灰度 MP4 走 ffmpeg gray12le 解码 + 对数码反量化,FFV1 MKV 做流号探测(双流/单流都 支持),旧 PNG16 按历史回退读取。 - 视频槽名按 episodes 行枚举,容忍跨 episode 槽改名。
- dotfiles 与临时件一律忽略;上传 zip arcname 与池化目录一致、episodes 带
本 episode 单行文件、
name=任务名 + formepisode_index。 - IMU 是变长 list 列,读取端展开时按
imu_ts_ns对齐,勿假设固定采样率行宽。 - 新布局 episodes 写入 = 单文件临时件 + os.replace 原子替换(无需 flock); 旧分片回退的读-改-写必须走 flock(POSIX)+ 临时文件 + os.replace。