DataFlowing / collector /docs /file_format.md
stouchinglhh
collector: 更新采集工程至 v1.3.17(时间轴修正 + 采集端自建任务)
ddb63ef
|
Raw History Blame Contribute Delete
30.5 kB
# 数据文件架构接口契约(v1.1.2)
> 本文是录制数据落盘格式的**规范性契约**,供一切读写该数据的程序严格对齐:
> 服务器导入器、离线处理工具、第三方数据读取器。凡本文标注 **MUST** 的条目,
> 写入方必须产出、读取方必须容忍;标注 SHOULD 为建议。歧义以 `core/helpers.py`
> 与 `core/egodata_writer.py` 的实现为准。
>
> 多设备并存(多相机 / 双夹爪 / ego 相机多轨迹)时的槽位与列名分配、`meta`
> 声明细节见 [`multi_device_storage.md`](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`**:
```json
"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:
| `frame` | `frame_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` 里的方向词。
**读侧两条硬规则:**
1. **`frame` 键不在 = 旧帧**,不是"取默认值"。分界是**发布版本**不是日历日:
v1.3.13(2026-09-28)起录的才写这个键,v1.3.12 及更早录的**根本不写**。
所以对老 episode 忽略这个键、按 `right_forward_up` 解读。
2. **标注是任务级的**,不是每 episode 一条:`features` 按并集 / 后写覆盖合并,
一个任务目录若跨了这次改帧,`info.json` 只能记住**最后写入**的那个帧,
该任务早期的 episode 会被标错。要逐 episode 精确区分得往 `meta/episodes`
加列(v1.3.13 未加)。跨线的任务目录请按录制日期切开。
3. **帧是一回事、姿态相对化是另一回事**:相对化从 ≤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},列表维度 = 列维度**:
```json
{
"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 是格式契约)
```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<int64> | 与 `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)**:
1. 同号 `meta/episodes/chunk-NNN/episode-NNN.parquet` 的 `force_matrix_specs`
—— **每段一份,权威**;
2. `<task>/meta/info.json` 的 `features[列].scale` —— 任务级**回退**;
3. 都没有 → 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](postmortem_trajectory_and_rgb.md)。
### 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 序列。
- 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. 对齐检查清单(新程序接入逐项验证)
1. 读 `info.json` 判 `format=="pooled_episodes_v1"`,未知 format 报错不猜。
2. 路径全部经模板 + (N→chunk,file) 计算,勿扫描拼接;`chunks_size` 读 info.json。
3. episodes 每段一个文件(episode- 编号 = data/videos 同编号);旧目录的多行
分片(file- 前缀)按 `episode_index` 列过滤回退,勿按文件名对齐。
4. stats.json 每块必有 `count`;无 count 的旧块触发全量重算或忽略并告警。
5. tasks.jsonl 逐行 `json.loads`,写回保持单行。
6. 深度文件按 `video_extensions` 判扩展名;12-bit 灰度 MP4 走 ffmpeg
gray12le 解码 + 对数码反量化,FFV1 MKV 做流号探测(双流/单流都
支持),旧 PNG16 按历史回退读取。
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。