Metadata-Version: 2.4
Name: modelbest-robo-dataset
Version: 0.3.1
Summary: 具身智能多模态数据集工具库：统一的读写、校验和格式转换（含完整工作空间）
Author-email: cuijunbo <cuijb2000@gmail.com>
License: Apache-2.0
Project-URL: Homepage, https://codeup.aliyun.com/modelbest/embody/datapipe/mb-robo-dataset
Project-URL: Repository, https://codeup.aliyun.com/modelbest/embody/datapipe/mb-robo-dataset
Keywords: robotics,embodied-ai,dataset,lerobot,multimodal,sstable
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.23
Requires-Dist: pyarrow>=12.0
Requires-Dist: pandas>=2.0
Requires-Dist: pydantic>=2.0
Requires-Dist: av>=10.0
Requires-Dist: modelbest_sdk>=0.3
Requires-Dist: thriftpy2>=0.4
Provides-Extra: torch
Requires-Dist: torch>=2.0; extra == "torch"
Provides-Extra: robomind
Requires-Dist: h5py>=3.0; extra == "robomind"
Provides-Extra: all
Requires-Dist: torch>=2.0; extra == "all"
Requires-Dist: h5py>=3.0; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# modelbest_robo_dataset

具身智能多模态数据集工具库。提供统一的读写、校验、格式转换与一整套数据工程脚本。

## 安装

```bash
pip install modelbest-robo-dataset
```

PyPI 主页：<https://pypi.org/project/modelbest-robo-dataset/>。

> 自 v0.3.0 起，**`pip install` 即可获得完整工作空间**——包内同时包含 SDK、命令行脚本、URDF/标注资源和所有 SOP 文档，不再需要 `git clone` 就能上手。

可选 extra：

```bash
pip install modelbest-robo-dataset[torch]      # 训练时需要 EmbodiedDataset (PyTorch)
pip install modelbest-robo-dataset[robomind]   # 转换 RoboMIND 时需要 h5py
pip install modelbest-robo-dataset[all]        # 全装
```

## 5 行入门

```python
from modelbest_robo_dataset import EmbodiedReader, EmbodiedWriter, Episode

reader = EmbodiedReader("/path/to/mb_dataset")
for episode in reader:
    print(episode.duration, len(episode.messages))
```

更多用法见下文 [快速开始](#快速开始)。

## 包内提供的资源

`pip install` 后，除 SDK 外还附带：

| 类别 | 路径（包内） | 用途 |
|---|---|---|
| **CLI 脚本** | `modelbest_robo_dataset.scripts.*` | 数据转换、校验、可视化 |
| **SOP 文档** | `modelbest_robo_dataset/docs/*.md` | 给 Cursor / AI 协作的标准 Runbook |
| **数据集配置** | `modelbest_robo_dataset/configs/datasets/*.json` | `load_dataset_config()` 默认读取的内置配置 |
| **配置/标注** | `modelbest_robo_dataset/annotations/` | URDF、mesh、`meta_annotations.json` 模板 |

直接调用 CLI 脚本：

```bash
python -m modelbest_robo_dataset.scripts.convert --help
python -m modelbest_robo_dataset.scripts.verify_e2e --help
python -m modelbest_robo_dataset.scripts.expand_skeleton --help
```

访问包内文档/资源：

```python
from importlib.resources import files

sop_dir = files("modelbest_robo_dataset") / "docs"
print((sop_dir / "sop_droid_adapter.md").read_text())

franka_urdf = files("modelbest_robo_dataset") / "annotations" / "custom_urdfs" / "franka" / "franka.urdf"
```

## SOP 索引（适配新数据集 / 发版 / 校验）

| 场景 | SOP |
|---|---|
| 适配 LeRobot 类数据集 | [sop_lerobot_to_mb_dataset.md](modelbest_robo_dataset/docs/sop_lerobot_to_mb_dataset.md) |
| 适配 RLDS/TFRecord 数据集（如 DROID） | [sop_droid_adapter.md](modelbest_robo_dataset/docs/sop_droid_adapter.md) |
| 通用数据集适配方法论 | [sop_dataset_adapter.md](modelbest_robo_dataset/docs/sop_dataset_adapter.md) |
| 数据生产 / 校验 / 可视化 | [sop_data_production.md](modelbest_robo_dataset/docs/sop_data_production.md) · [sop_data_verification.md](modelbest_robo_dataset/docs/sop_data_verification.md) · [sop_visualization.md](modelbest_robo_dataset/docs/sop_visualization.md) |
| 发布到 PyPI | [sop_pypi_release.md](modelbest_robo_dataset/docs/sop_pypi_release.md) |

每个 SOP 都是给 Cursor / AI 下指令的标准 Runbook——直接把 `请按 sop_droid_adapter.md 适配 droid 数据集` 丢给 AI 即可执行。

## 想贡献代码？

```bash
git clone git@modelbest.codeup.aliyun.com:modelbest/embody/datapipe/mb-robo-dataset.git
cd mb-robo-dataset
pip install -e ".[dev,all]"
pytest tests/
```

---

## 设计理念

### 与 LeRobot 的区别

LeRobot 是优秀的机器人学习框架，本库在数据层面与其互补而非替代：

| | LeRobot | modelbest_robo_dataset |
|---|---------|----------------------|
| **定位** | 端到端训练框架（数据+策略+部署） | 纯数据工具库（格式转换+存储+读取） |
| **骨架存储** | Parquet 表 (每行一帧) | SSTable partition (每条一个 episode) |
| **数据模型** | 扁平表：每帧一行，所有 feature 列铺平 | 嵌套结构：Episode → Message → Content，env/user/ai 三轨道 |
| **元信息** | info.json + tasks.jsonl | MetaContent (嵌在骨架内，Pydantic 序列化) |
| **多模态** | 视频 + 状态 | 视频 + 状态 + 音频 + 力 + IMU + 语言指令 |
| **标注** | task description | task_id, user_id, scene_id, quality_rating, dim_names |
| **视频** | 原始 MP4，按 chunk 分文件 | per-episode MP4（默认）或逐帧 PNG（可选），H264 CRF23 + 720p 上限 |
| **扩展性** | 围绕 HuggingFace Hub 生态 | 围绕 modelbest_sdk SSTable 生态 |

### 为什么不直接用 LeRobot 格式

1. **三轨道模型**：env/user/ai 的消息结构天然支持人机交互场景（人类语音纠正、机器人语音回复），LeRobot 的扁平表不适合这种嵌套关系
2. **多源异构**：RH20T 有力传感器+音频，fuse 有 IMU+触觉麦克风，RoboMind 有 3 种机器人变体——需要一个足够灵活的骨架来容纳这些差异
3. **生产级存储**：SSTable partition 支持大规模分布式训练的随机读取，比单个 Parquet 文件更适合 10 万+ episode 的场景
4. **TimeseriesName 自描述**：`ai.action.delta_cartesian_position` 本身就说明了控制空间和绝对/增量语义，不需要额外的 type 字段

### 与 LeRobot 的兼容

- `LeRobotSource` 读取 LeRobot v3.0 目录布局（`meta/episodes/*.parquet` + `data/chunk-*/file-*.parquet`）。若 `meta/episodes` 目录缺失（如 RoboMIND LeRobot），自动从 `data/` parquet 的 `episode_index` 列推导可用 episode 列表
- TimeseriesName 的命名风格（`env.obs.*` / `ai.action.*`）兼容 LeRobot 社区惯例
- dim_names 的设计参考了 LeRobot info.json 中的 `features.*.names.motors`
- 对损坏的单路视频会记录 warning 并跳过该相机，不会因为单个坏视频中断整条 episode 的转换
- **大体积 v3 布局**（如 DROID 95K episodes）：设置 `lazy=True` 启用懒加载模式，初始化时不读取 `meta/episodes` 和 `data` 的 parquet 文件，按需加载单个 episode 的元数据和数据。DROID 的 config 已默认启用 `"lazy": true`

#### Task 解析

`LeRobotSource._resolve_task` 按以下优先级解析自然语言任务字符串：

1. Episode meta `tasks` 列表 — 首个非空字符串，或按整数 `task_index` 查 `tasks_df`
2. Episode meta `task_index` → `tasks_df`
3. Data parquet `task_index` → `tasks_df`（v3.0 Libero 只在 data parquet 中存 `task_index`，不在 episode meta 中）
4. Data parquet 的 `task_fallback_column` 列（仅当 config 中显式设置了 `task_fallback_column` 时，如 DROID 的 `language_instruction`）
5. `tasks_df` 首行（全局回退，仅适用于单任务数据集）

#### 懒加载（Lazy Loading）

DROID 等超大数据集（95K+ episodes）在默认模式下会一次性加载所有 `meta/episodes/*.parquet` 和 `data/*.parquet` 到内存，导致 OOM。设置 `lazy=True`（config 或构造函数参数）启用懒加载：

| | 默认模式 | 懒加载模式 |
|---|---------|-----------|
| 初始化 | 读取所有 meta/episodes + data parquet | 仅读取 `meta/info.json` + `tasks.parquet` |
| `list_episodes()` | 从 episodes_df 提取 | `range(total_episodes)` |
| `load_episode(id)` | 从内存 DataFrame 查找 | 按需读取对应 chunk 文件，LRU 缓存最近 8 个文件 |
| 内存占用 | 全量（DROID ~数 GB） | 仅当前 episode 所在文件（~数十 MB） |
| 初始化耗时 | DROID OOM | DROID 0.04s |

首次 `load_episode` 时会扫描所有 `data/chunk-*/` 下 parquet 文件的 `episode_index` 列（仅读该列，非常轻量），构建 episode→file 映射索引。后续调用直接查索引并读取对应文件。

v3.0 共享数据文件（`file-{file_index}.parquet`，如 DROID/Libero）在非懒加载模式下也自动使用相同的按需加载策略。

##### 视频处理

`LeRobotSource.load_episode` 返回 `RawEpisode` 时使用 `video_files`（视频文件路径字典）而非 `video_frames`（解码后的帧列表）。视频不在 source 层解码，由 `EmbodiedWriter` 在转换时按需复制或重编码为 per-episode MP4。

视频路径解析：按 episode parquet 中的 per-camera `chunk_index` / `file_index` + `info.json` 的 `video_path` 模板拼路径。

## 元数据流水线（新模型）

`modelbest_robo_dataset` 的元数据由三个制品共同表达，职责互不重叠。本节给出总览，详细 schema 与字段归属规则见 [docs/lerobot_meta_mapping.md](modelbest_robo_dataset/docs/lerobot_meta_mapping.md)。

```mermaid
flowchart LR
    LR["LeRobot info.json + parquet"]
    Src["configs/sources/dataset.json<br/>HOW: state_key, camera_keys, lazy"]
    Ann["annotations/dataset.meta.json<br/>WHAT: timeseries spec, urdf, robot_type"]
    Boot["bootstrap_annotation.py (future)"]
    Conv["convert.py (future)"]
    Meta["MetaContent (derived from annotation)"]
    Verify["vla_verify (annotation as truth)"]
    StaticViz["generate_viz.py (static HTML)"]

    LR -->|"auto-infer"| Boot
    Src --> Boot
    Boot --> Ann
    Ann -->|"human review"| Ann
    Ann --> Conv
    Src --> Conv
    LR --> Conv
    Conv --> Meta
    Meta --> Verify
    Meta --> StaticViz
    Ann --> Verify
```

| 制品 | 文件 | 维护者 | 主消费者 | 角色 |
|---|---|---|---|---|
| annotation | `annotations/<dataset>.meta.json` | 人工 | converter / vla_verify | WHAT —— 数据集应该是什么；converter 输入契约 |
| source 配置 | `configs/sources/<dataset>.json` | dev | converter | HOW —— 怎么读 LeRobot 源 + 运行时开关 |
| runtime metadata | skeleton 内 `MetaContent` / `TimeseriesRef` / `VideoRef` | converter 自动写 | reader / 静态可视化 / vla_verify | 派生输出 |

单一真值原则：

- 数据集 metadata（`robot_type` / `urdf_path` / `dim_names` / `timeseries_name` / ...）→ annotation 为真值
- 列名映射（`state_key` / `camera_keys` / `task_key` / `lazy`）→ `configs/sources/` 为真值
- 派生量（`fps` / `total_frames` / `shape` / `dtype`）→ source 数据本身为真值，**不写入** annotation

迁移现状：当前仓库使用包内 `modelbest_robo_dataset/configs/datasets/*.json`（同时混了 WHAT 与 HOW），尚未迁到上面的两文件模型。迁移路线见 [docs/lerobot_meta_mapping.md](modelbest_robo_dataset/docs/lerobot_meta_mapping.md) §9。

## 架构

```
modelbest_robo_dataset/
├── data_types.py              # Episode/Message/Content 类型定义
├── dataset_config.py          # 数据集配置注册表 (DatasetConfig + load/list)
├── lerobot_state_semantics.py # LeRobot observation.state 语义推断（关节 vs 末端）
├── writer.py                  # RawEpisode → 统一格式
├── reader.py                  # 统一格式 → 训练采样
├── validator.py               # State/Action 一致性校验
├── sources/
│   ├── base.py        # RawEpisode + EpisodeSource 接口
│   ├── lerobot.py     # LeRobot v3.0 读取
│   ├── rh20t.py       # RH20T (上海交大)
│   ├── fuse.py        # fuse/DIGIT (TFRecord/RLDS)
│   └── robomind.py    # RoboMIND (HDF5)
├── configs/datasets/  # 每个数据集的转换配置（pip 包内默认配置）
│   ├── libero.json
│   ├── droid.json
│   ├── agibotworld.json
│   ├── robomind_franka_*.json
│   ├── robotwin2_*.json
│   └── ...
├── annotations/       # URDF / mesh / meta annotation 模板
├── docs/              # SOP / 设计文档（随 wheel 分发）
└── scripts/           # 多源转换、校验、可视化工具
    ├── convert.py
    ├── expand_skeleton.py
    ├── convertion/
    ├── preprocess/
    ├── sim_comparison/
    └── vla_verify/

tests/
├── test_lerobot_source.py  # LeRobotSource 单元测试
└── test_sstable_record.py  # SSTable 序列化测试
```

## 数据集配置注册表

> **过渡期说明**：本节描述当前包内 `modelbest_robo_dataset/configs/datasets/*.json` 用法。该文件同时混了「数据集 WHAT」与「源解析 HOW」，会在 [docs/lerobot_meta_mapping.md](modelbest_robo_dataset/docs/lerobot_meta_mapping.md) §9 描述的迁移路线 Step 2–5 完成后，被 `annotations/<dataset>.meta.json`（WHAT）+ `configs/sources/<dataset>.json`（HOW）取代。本节内容当前与现状保持一致，便于过渡期使用。

`modelbest_robo_dataset/configs/datasets/<name>.json` 为每个 LeRobot 数据集定义转换参数，消除命令行逐一传参的需要。`load_dataset_config("<name>")` 默认读取这些包内配置；如果你需要实验性覆盖，可传入 `configs_dir=` 指向自己的目录。

### 配置文件格式

```json
{
  "state_key": "observation.state",
  "action_key": "action",
  "state_type": "joint_position",
  "action_type": "joint_position",
  "camera_keys": ["observation.images.image"],
  "task_key": "index",
  "task_fallback_column": null,
  "lazy": false,
  "description": "..."
}
```

对于双臂等多列拼接的数据集，`state_key` / `action_key` 支持 **列表** 形式，读取时按顺序沿最后一维拼接：

```json
{
  "state_key": ["observation.states.joint_position_left", "observation.states.joint_position_right"],
  "action_key": ["actions.joint_position_left", "actions.joint_position_right"],
  "camera_keys": ["observation.images.camera_front", "observation.images.camera_left_wrist", "observation.images.camera_right_wrist"]
}
```

上例将 left[7] + right[7] 拼接为 14 维 state/action。单字符串与列表均可，接口与 `camera_keys` 一致。

| 字段 | 说明 |
|------|------|
| `state_key` | **必填**。data parquet 中 state 列名。单列用字符串，多列拼接用列表（如双臂 left+right） |
| `action_key` | **必填**。data parquet 中 action 列名。格式同 `state_key` |
| `state_type` | **必填**。映射到 TimeseriesName（`joint_position` / `cartesian_position` / ...） |
| `action_type` | **必填**。映射到 TimeseriesName（`joint_position` / `delta_cartesian_position` / ...） |
| `camera_keys` | **必填**。使用的相机 feature key 列表（如 `["observation.images.image", "observation.images.wrist_image"]`） |
| `task_key` | `tasks.parquet` 中任务文本的来源。`"index"` = DataFrame Index（多数 v3.0 数据集包括 DROID/Libero/AgiBotWorld/RoboTwin2），其他字符串 = 重命名该列，`null` = 假定已有 `task` 列 |
| `task_fallback_column` | 当 `tasks.parquet` 中找不到任务时，从 data parquet 的该列回退（如 DROID 的 `"language_instruction"`）。`null` = 不回退 |
| `lazy` | 懒加载模式。`true` 时初始化不加载 `meta/episodes` 和 `data` parquet，按需读取单个 episode。适用于 DROID 等超大数据集（95K+ episodes），避免 OOM。默认 `false` |
| 其余字段 | 与 `convert.py` CLI 参数对应，作为默认值（CLI 显式传入时覆盖配置） |

### 已有配置

| 配置名 | state_key | action_key | cameras | lazy | task_key | 说明 |
|--------|-----------|------------|---------|------|----------|------|
| `libero` | observation.state | action | 2 | - | index | LIBERO（long/spatial/object/goal 共用） |
| `droid` | observation.state | action | 3 | **true** | index | DROID 1.0.1（95K eps，懒加载） |
| `agibotworld` | observation.states.joint.position | actions.joint.position | 8 | - | index | AgiBotWorld（全 8 相机） |
| `agibotworld_3rgb` | observation.states.joint.position | actions.joint.position | 3 | - | index | AgiBotWorld（head + hand_left/right，快速转换） |
| `robomind_franka_1rgb` | observation.states.joint_position | actions.joint_position | 1 | - | index | RoboMIND Franka 单臂 8d，1 相机 |
| `robomind_franka_2rgb` | observation.states.joint_position | actions.joint_position | 2 | - | index | RoboMIND Franka 单臂 8d，2 相机 |
| `robomind_franka_3rgb` | observation.states.joint_position | actions.joint_position | 3 | - | index | RoboMIND Franka 单臂 8d，3 相机 |
| `robomind_ur_1rgb` | observation.states.joint_position | actions.joint_position | 1 | - | index | RoboMIND UR 单臂 7d，1 相机 |
| `robomind_agilex_3rgb` | [joint_position_left, _right] | [joint_position_left, _right] | 3 | - | index | RoboMIND agilex 双臂 7+7=14d，3 相机（列表拼接） |
| `robomind_franka_fr3_dual` | observation.states.joint_position | actions.joint_position | 3 | - | index | RoboMIND FR3 双臂 16d，3 相机 |
| `robotwin2_franka` | observation.state | action | 4 | - | index | RoboTwin2 Franka |
| `robotwin2_aloha_agilex` | observation.state | action | 4 | - | index | RoboTwin2 ALOHA |
| `robotwin2_ur5` | observation.state | action | 4 | - | index | RoboTwin2 UR5 |
| `pusht` | observation.state | action | 1 | - | - | PushT |
| `xarm_push_medium` | observation.state | action | 1 | - | - | xArm |
| `aloha_sim_insertion` | observation.state | action | 1 | - | - | ALOHA sim |

### 使用方式

#### Python API

```python
from modelbest_robo_dataset.dataset_config import load_dataset_config
from modelbest_robo_dataset.sources.lerobot import LeRobotSource

# 所有参数由 config 驱动，无自动检测
cfg = load_dataset_config("libero")
src = LeRobotSource(
    "/path/to/libero_long",   # or libero_spatial, libero_object, libero_goal
    state_type=cfg.state_type,
    action_type=cfg.action_type,
    state_key=cfg.state_key,
    action_key=cfg.action_key,
    camera_keys=cfg.camera_keys,
    task_key=cfg.task_key,
)

# 大数据集使用懒加载（如 DROID，config 中 lazy=true）
cfg = load_dataset_config("droid")   # lazy=True already set
src = LeRobotSource(
    "/path/to/droid_1.0.1",
    state_type=cfg.state_type,
    action_type=cfg.action_type,
    state_key=cfg.state_key,
    action_key=cfg.action_key,
    camera_keys=cfg.camera_keys,
    task_key=cfg.task_key,
    task_fallback_column=cfg.task_fallback_column,
    lazy=cfg.lazy,               # True — init 0.04s instead of OOM
)
eps = src.list_episodes()            # 95617 episodes
ep = src.load_episode(eps[0])        # on-demand: reads only the needed parquet file
```

#### 命令行

```bash
# 使用配置文件（无需手动传 --state-type / --action-type）
python -m modelbest_robo_dataset.scripts.convert --source lerobot \
  --input /path/to/agibotworld/task_327 --output /path/to/output \
  --config agibotworld

# CLI 参数优先于配置文件
python -m modelbest_robo_dataset.scripts.convert --source lerobot \
  --input /path/to/dataset --output /path/to/output \
  --config libero --max-episodes 100
```

#### 批量转换

`batch_convert_lerobot.py` 支持 `--configs-dir`，自动按数据集名查找配置：

```bash
python -m modelbest_robo_dataset.scripts.convertion.batch_convert_lerobot \
  --source-root /path/to/lerobot_datasets \
  --output-root /path/to/output \
  --configs-dir /path/to/custom/configs \
  ...
```

### 添加新数据集

1. 查看数据集的 `meta/info.json`，确认 `features` 中 state/action 列名和 `dtype: "video"` 的相机列名
2. 查看 `meta/tasks.parquet` 结构，确认任务文本在 DataFrame Index 还是某个列中
3. 创建自定义 `<name>.json`，或把配置加入包内 `modelbest_robo_dataset/configs/datasets/`
4. 运行 `python -m modelbest_robo_dataset.scripts.convert --config <name>` 验证

## LeRobot `observation.state` 语义推断

LeRobot v2/v3 里 `observation.state` **没有统一 schema**：同一向量可能是关节角，也可能是末端位姿（位置 + 旋转 + 夹爪）。转换到本库时，`writer.STATE_TYPE_MAP` 需要区分 `joint_position`（映射到 `env.obs.joint_position`）与 `ee_pose`（映射到 `env.obs.cartesian_position`）。本库提供 **元数据优先、Parquet 抽样数值为辅** 的推断，**不能单靠维度数判断**。

### 实现与返回结果

- **模块**：`lerobot_state_semantics.py`
- **API**：`infer_observation_state_semantics(root, max_sample_rows=5000) -> StateSemanticsResult`
- **字段**：`label`（`joint_position` / `ee_pose` / `unknown`）、`confidence`（0–1）、`reasons`（命中规则说明）、`state_key`、`shape`、`sample_dim_names`

### 规则优先级（概要）

1. **特征键名**（不区分大小写）：`observation.state*` 中含 `eef`、`tcp`、`cartesian`、`pose`、`world_pose`、`ee_pose`、`ee_` 等子串 → 判为 **`ee_pose`**（高置信度）。
2. **维度名**（与 `LeRobotSource._extract_dim_names` 一致：支持 `names.motors`、`names.axes`，或顶层 `names` 为列表；键存在但值为 JSON `null` 时视为缺失，避免异常）：
   - **倾向末端**：维度名集合中同时含 `x`、`y`、`z`，或名称文本中含 `quat`、`axis_angle`、`euler`、`rpy`、`rotation`、`orient` 等；
   - **倾向关节**：名称中含 `joint`、`shoulder`、`elbow`、`wrist`、`finger` 等；
   - **冲突**：若末端与关节信号并存，且存在 `x/y/z` 三元组 → 偏向 **`ee_pose`**；否则继续走数值启发。
   - **弱信号**：仅 `motor_0`、`motor_1`… 等形式 **不单独下结论**，需结合 Parquet 抽样。
3. **数值启发**（读取 `data/**/*.parquet` 中 state 列，总行数上限由 `max_sample_rows` 控制，默认 5000）：
   - 维度 **≥ 7**：对「最后 4 维」与「第 4–7 列」两种四元数候选块分别算均值 `|‖q‖ - 1|` ，取更优者；若小于 **0.05** → **`ee_pose`**；
   - 维度 **= 6**：若前三维幅度接近米级、后三维接近弧度量级 → 弱信号 **`ee_pose`**（置信度较低）；
   - 仅 **`motor_*`** 或 **无名**，且抽样不满足单位四元数块 → **`joint_position`**；
   - 缺少 `meta/info.json`、无 `observation.state*` 特征、无可用 Parquet 等 → **`unknown`**，并在 `reasons` 中写明原因。

### 局限

无法保证 100% 自动正确（例如元数据全写成 `motor_*` 但实际存的是末端位姿）。请以 **`confidence` 与 `reasons`** 为准做抽检；必要时在流水线侧人工指定或修正。

说明：单数据集 / 批量脚本通过 `importlib` 直接加载 `lerobot_state_semantics.py`，**不经过**包根 `__init__.py`，可在未安装 PyAV 的环境下运行。若在已安装全量依赖的环境中使用 Python API，可正常 `from modelbest_robo_dataset.lerobot_state_semantics import infer_observation_state_semantics`。

### 命令行

```bash
# 单个 LeRobot 数据集根目录（需含 meta/info.json）
python -m modelbest_robo_dataset.scripts.convertion.infer_lerobot_state /path/to/lerobot_dataset
python -m modelbest_robo_dataset.scripts.convertion.infer_lerobot_state /path/to/lerobot_dataset --json --max-rows 2000

# 父目录下：每个直接子目录若含 meta/info.json 则推断一次
python -m modelbest_robo_dataset.scripts.convertion.batch_infer_lerobot_state /path/to/parent \
  --output-format csv -o lerobot_state_summary.csv

# 递归查找所有 meta/info.json（数据集根 = meta 的父目录）
python -m modelbest_robo_dataset.scripts.convertion.batch_infer_lerobot_state /path/to/parent --recursive --output-format jsonl
```

`batch_infer_lerobot_state.py` 还支持 `--output-format table|json`、`-o` 输出到文件。

### Python 调用示例

```python
from pathlib import Path
from modelbest_robo_dataset.lerobot_state_semantics import infer_observation_state_semantics

r = infer_observation_state_semantics(Path("/path/to/lerobot_dataset"))
print(r.label, r.confidence, r.reasons)
```

## 数据格式

输出结构:
```
output_dir/
├── skeleton_episode/{name}/part-XXXXX       # SSTable partition 骨架
├── data/{name}/state/chunk-000/file-000.parquet
├── data/{name}/action/chunk-000/file-000.parquet
├── videos/{name}/{cam}/episode_000000.mp4   # 默认：整段 MP4，H264 CRF23，上限 720p
│   或 episode_000000.png                    # 逐帧模式：每 timestep 一张 PNG
├── meta/{name}/info.json                     # 轻量 summary（非 LeRobot 原始 info.json 全量镜像）
├── meta/{name}/stats.json                    # action/state 全量统计：mean/std/min/max/q01/q99/count
└── meta/{name}/norm_stats.json               # 训练 norm_stats_path 直接消费的 {"norm_stats": {...}} 包装
```

说明：

- `meta/{name}/info.json` 当前只保存轻量 summary（如 `dataset_name`、`total_episodes`、`total_frames`、accumulator 行数）。
- 更细的结构化信息（例如 `robot_type`、`task`、`dim_names`、`urdf_path`、`fps`、video key、时序 ref 的 `from_ts/to_ts`）保存在 skeleton 的 `MetaContent` / `TimeseriesRef` / `VideoRef` 中。
- 时序 Parquet 的每行也会保存自身的 `fps` 列，便于下游核对。
- `meta/{name}/stats.json` 与 `meta/{name}/norm_stats.json` 同时包含 `q01/q99` 和 `min/max`：
  - `openpi`（默认）preprocessor 走 `q01/q99`
  - `starvla` preprocessor 走 `min/max`（缺失才会回落 `q01/q99` 并 warning）
  - 直接把 `meta/{name}/norm_stats.json` 配给训练 config 的 `norm_stats_path` 即可同时支持两类

### Episode 骨架

每个 Episode 包含三类消息:

| 角色 | 内容 | 说明 |
|------|------|------|
| `env` | MetaContent, VideoContent, TimeseriesContent(state), AudioContent | 环境感知 |
| `user` | TextContent, AudioContent | 人类干预 |
| `assistant` | TimeseriesContent(action), TextContent | 机器人输出 |

### TimeseriesName 命名规范

采用 `env.obs.*` / `ai.action.*` 的 dot-separated 命名:

| key | 说明 |
|-----|------|
| `env.obs.joint_position` | 关节角度 (绝对) |
| `env.obs.cartesian_position` | 末端位姿 (绝对) |
| `env.obs.gripper_position` | 夹爪开度 |
| `env.obs.force_torque` | 力/力矩 |
| `env.obs.imu` | IMU |
| `ai.action.joint_position` | **绝对**目标关节角 |
| `ai.action.cartesian_position` | **绝对**目标末端位姿 |
| `ai.action.delta_joint_position` | 关节角**增量** |
| `ai.action.delta_cartesian_position` | 末端位姿**增量** |

TimeseriesName 本身区分绝对/增量，不需要额外的 `action_type` 字段。

## MB 原生预处理脚本

`modelbest_robo_dataset.scripts.preprocess` 下的脚本支持直接处理 `modelbest_robo_dataset` 原生格式（目录下包含 `data/`、`meta/`、`skeleton_episode/`、`videos/`）。

### 输入与输出约定

- `--input_dir` 可以传原生数据根目录，也可以直接传某个数据集目录，例如 `data/<dataset>`、`meta/<dataset>`、`skeleton_episode/<dataset>`。
- 对 **原生 MB 数据** 执行预处理时，脚本现在会先复制该数据集的完整内容，再在副本上修改；不会复用源数据文件，也不会把新数据集内部引用继续指向原数据集。
- 复制时会同步带走并改写同名数据集下的 `data/`、`meta/`、`videos/`、`skeleton_episode/`，以及已有的 `skeleton_single_frame/` / `skeleton_ctx*`。
- 输出参数 `--output_dir` 对原生 MB 数据来说表示“**新的 MB 根目录**”，不是单个数据集目录；真正的新数据集会出现在 `output_dir/<collection>/<new_dataset_name>/...` 下。

### 新数据集命名规则

原生 MB 预处理脚本会自动给新数据集名追加操作后缀：

- `abs2rel.py` -> `<dataset>_abs2rel`
- `downsampling.py` -> `<dataset>_downsample_<source>hz_to_<target>hz`
- `normalization.py` -> `<dataset>_q99_norm`
- `clear_statistic.py` -> `<dataset>_clean_xyz`
- `native_single_frame_clean.py` -> `<dataset>_clean_single_frame`

例如对 `libero_goal_no_noops_1.0.0_lerobot` 运行 `native_single_frame_clean.py`，输出数据集名会变成 `libero_goal_no_noops_1.0.0_lerobot_clean_single_frame`。

### 派生 skeleton 的处理

- 如果预处理会改变帧数、时长或 fps（例如 `downsampling.py`、`clear_statistic.py`、`native_single_frame_clean.py`），脚本会自动删除复制副本中旧的 `skeleton_single_frame/` 和 `skeleton_ctx*`，避免它们继续引用过期的时间范围。
- `native_single_frame_clean.py` 会在清洗完成后重新生成新的 `skeleton_single_frame`。
- 对不改变帧数的脚本（如 `abs2rel.py`、`normalization.py`），已有 skeleton 会保留，并同步改写数据集名与内部引用路径。

### dry-run

- `clear_statistic.py --dry_run` 和 `native_single_frame_clean.py --dry_run` 只做统计，不写出新数据。
- dry-run 会复用源数据集名做分析；只有实际写出时才会生成带操作后缀的新数据集副本。

### 示例

```bash
# 先做 dry-run，看清洗后会删掉多少帧
python -m modelbest_robo_dataset.scripts.preprocess.native_single_frame_clean \
  --input_dir /path/to/mb_root \
  --dataset_name libero_goal_no_noops_1.0.0_lerobot \
  --output_dir /path/to/mb_preprocessed \
  --threshold 1e-4 \
  --dry_run

# 实际转换：会复制整套数据，并生成一个新的数据集
python -m modelbest_robo_dataset.scripts.preprocess.native_single_frame_clean \
  --input_dir /path/to/mb_root \
  --dataset_name libero_goal_no_noops_1.0.0_lerobot \
  --output_dir /path/to/mb_preprocessed \
  --threshold 1e-4 \
  --keep_endpoints
```

实际运行后，输出中会保留原始数据不变，并在：

```text
/path/to/mb_preprocessed/data/libero_goal_no_noops_1.0.0_lerobot_clean_single_frame/...
/path/to/mb_preprocessed/meta/libero_goal_no_noops_1.0.0_lerobot_clean_single_frame/...
/path/to/mb_preprocessed/skeleton_episode/libero_goal_no_noops_1.0.0_lerobot_clean_single_frame/...
/path/to/mb_preprocessed/skeleton_single_frame/libero_goal_no_noops_1.0.0_lerobot_clean_single_frame/...
/path/to/mb_preprocessed/videos/libero_goal_no_noops_1.0.0_lerobot_clean_single_frame/...
```

### MetaContent 结构化字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `urdf_path` | str | 机器人 URDF 路径，可由 `python -m modelbest_robo_dataset.scripts.convert --urdf-path` 写入 |
| `joint_names` | list[str] | 关节名称列表（如源数据可提供） |
| `task_id` | str | 任务 ID (如 "task_0001"，从目录名解析) |
| `quality_rating` | int | 质量评分: 0=机器人失败, 1=任务失败, 2-9=完成质量, -1=未标注 |
| `user_id` | str | 操作者 ID (如 "user_0001") |
| `scene_id` | str | 场景 ID (如 "scene_0001") |
| `dim_names` | dict | key=TimeseriesName, value=维度名列表 |

## 快速开始

### 转换数据集

```python
from modelbest_robo_dataset import EmbodiedWriter
from modelbest_robo_dataset.dataset_config import load_dataset_config
from modelbest_robo_dataset.sources.lerobot import LeRobotSource

# 使用配置注册表（推荐，所有参数由 config 显式定义）
cfg = load_dataset_config("libero")
source = LeRobotSource(
    root="/path/to/libero_long",  # or any LIBERO variant
    state_type=cfg.state_type,
    action_type=cfg.action_type,
    state_key=cfg.state_key,
    action_key=cfg.action_key,
    camera_keys=cfg.camera_keys,
    task_key=cfg.task_key,
)

writer = EmbodiedWriter(output_dir="/path/to/output", dataset_name="pusht")
for ep_id in source.list_episodes():
    raw = source.load_episode(ep_id)
    writer.write_episode(raw)
writer.finalize()
```

#### 逐帧 episode（每 timestep 一条骨架 + 单帧 PNG）

默认下，每个源 trajectory 对应一条 Episode，视频为整段 `episode_XXXXXX.mp4`。若需要 **每个时间步单独一条 Episode**，且相机保存为 **单张 PNG**（而非 MP4），使用 `EmbodiedWriter(..., one_frame_per_episode=True)`。

可选：在写入前调用 `set_shuffled_episode_indices(total_frames, seed)`，为每条帧级 Episode 分配 **打乱后的 `episode_index`**（与 Parquet 行、PNG 文件名一致）。LeRobot 下可用 `LeRobotSource.episode_frame_counts()` 先求总帧数（需与 `list_episodes` 的截断方式一致，例如同样应用 `max_episodes`）。

```python
from modelbest_robo_dataset import EmbodiedWriter
from modelbest_robo_dataset.dataset_config import load_dataset_config
from modelbest_robo_dataset.sources.lerobot import LeRobotSource

cfg = load_dataset_config("my_ds")
source = LeRobotSource(
    root="/path/to/lerobot_ds",
    name="my_ds",
    state_type=cfg.state_type,
    action_type=cfg.action_type,
    state_key=cfg.state_key,
    action_key=cfg.action_key,
    camera_keys=cfg.camera_keys,
    task_key=cfg.task_key,
)
episodes = source.list_episodes()
counts = source.episode_frame_counts()
total_frames = sum(counts)

writer = EmbodiedWriter(
    output_dir="/path/to/output",
    dataset_name="my_ds",
    one_frame_per_episode=True,
)
writer.set_shuffled_episode_indices(total_frames, seed=42)

for ep_id in episodes:
    writer.write_episode(source.load_episode(ep_id))
writer.finalize()
```

说明：

- 状态/动作/各相机帧数不一致时，按 **最短长度** 对齐并打日志警告。
- 仅 `video_files`、无 `video_frames` 时，本库 **不会** 自动逐帧解码 MP4；可能只有时序无图像。
- 多帧 trajectory 下 **不写入** 整段 `audio_env`（仅单帧 trajectory 保留原逻辑）。

### 读取数据

```python
from modelbest_robo_dataset import EmbodiedReader

reader = EmbodiedReader("/path/to/output", "pusht")
reader.summary()

sample = reader.load_sample(episode_idx=0, timestamp=1.0)
print(sample.keys())
```

### 命令行转换

```bash
# LeRobot: 使用配置注册表（推荐）
python -m modelbest_robo_dataset.scripts.convert --source lerobot \
  --input /path/to/libero_long --output /path/to/output --config libero

# LeRobot: 非标准 feature key（如 AgiBotWorld，config 中已定义）
python -m modelbest_robo_dataset.scripts.convert --source lerobot \
  --input /path/to/agibotworld/task_327 --output /path/to/output --config agibotworld

# RH20T 全部
python -m modelbest_robo_dataset.scripts.convert --source rh20t --all --output /path/to/output

# fuse
python -m modelbest_robo_dataset.scripts.convert --source fuse --output /path/to/output

# RoboMind (支持 puppet/franka/tiangong 三种变体，自动检测)
python -m modelbest_robo_dataset.scripts.convert --source robomind \
  --input /path/to/failure_data --output /path/to/output --name robomind_failure

# RoboMind 全量转换 (约4小时)
python -m modelbest_robo_dataset.scripts.convert --source robomind \
  --input /backup/.../robomind/failure_data --output /path/to/output \
  --name robomind_failure --max-episodes 1678
```

#### LeRobot：逐帧 PNG + 打乱 `episode_index`

适用于希望 **每条样本对应一帧图像**（`videos/.../episode_XXXXXX.png`），并对全数据集的 **`episode_index` 随机打乱**（可复现）的场景。

```bash
python -m modelbest_robo_dataset.scripts.convert --source lerobot \
  --input /path/to/lerobot_dataset --output /path/to/output --name my_ds \
  --one-frame-per-episode --shuffle-seed 42
```

（v0.3.1 起推荐始终使用 `python -m modelbest_robo_dataset.scripts.convert`，无论是 pip 安装还是 git clone 开发环境。）

| 参数 | 说明 |
|------|------|
| `--one-frame-per-episode` | 每个 timestep 写一条 Episode；相机输出为 PNG，不再为整段 MP4。 |
| `--shuffle-seed N` | **必须**与上一参数同时使用。在写入前根据数据表统计总帧数，对 `0..N-1` 的 `episode_index` 做固定种子的随机排列。 |

**注意**：`--shuffle-seed` 依赖数据源的 `episode_frame_counts()`；当前 **LeRobot** 已实现。其他 `--source` 若未实现该方法，请勿对该源使用 `--shuffle-seed`。

骨架记录在 SSTable 中的 **追加顺序** 仍为按源 Episode 依次写入；打乱的是每条记录内的 **`episode_index`** 及对应的 Parquet/图像路径，而非磁盘上的记录物理顺序。

#### 已有 `episode` 骨架转 `single_frame`

适用于你已经有 `modelbest_robo_dataset` 标准输出目录，且目录下已有 `skeleton_episode/{dataset}`，现在只想继续生成 `skeleton_single_frame/{dataset}` 的场景。

```bash
# 转换指定数据集
python -m modelbest_robo_dataset.scripts.expand_skeleton \
  --input /path/to/output \
  --dataset my_dataset \
  --mode single_frame \
  --action-chunk 50

# 一次转换 skeleton_episode/ 下全部数据集
python -m modelbest_robo_dataset.scripts.expand_skeleton \
  --input /path/to/output \
  --all \
  --shuffle-seed 42
```

| 参数 | 说明 |
|------|------|
| `--input` | `modelbest_robo_dataset` 数据根目录，内部应包含 `skeleton_episode/`。 |
| `--datasets` | 指定一个或多个数据集名称。 |
| `--all` | 转换 `skeleton_episode/` 下所有数据集。 |
| `--action-chunk` | 每条 `single_frame` 样本中 action 窗口长度，默认 `50`。 |
| `--shuffle-seed` | 写入前打乱全部帧级记录，传固定值可复现。 |
| `--include-terminal-frame` | 保留末尾不足一个完整 action chunk 的终止帧。 |

说明：

- 输出目录为 `skeleton_single_frame/{dataset}`。
- 脚本会读取 `skeleton_episode` 中的 SSTable 记录，并保持原有 `data/`、`videos/`、`meta/` 目录不变。
- 若目标目录已存在旧的 `part-*` 文件，脚本会先清理再重写，避免重复运行后混入旧分片。

## 校验与诊断

### LeRobot / MB 一致性校验

`modelbest_robo_dataset.scripts.verify_lerobot_to_mb_dataset` 用于校验：

- LeRobot 数据集与 `modelbest_robo_dataset` 的 `single_frame` 输出是否一致
- 两个 LeRobot 数据集之间的 `state` / `action` / `task` / `video` 是否一致
- `single_frame` 记录顺序被 shuffle 的情况

示例：

```bash
# 校验 LeRobot 与 MB single_frame
python -m modelbest_robo_dataset.scripts.verify_lerobot_to_mb_dataset \
  --lerobot-root /path/to/lerobot_ds \
  --mb-root /path/to/mb_root \
  --dataset-name my_dataset

# 严格模式：额外逐帧比较视频
python -m modelbest_robo_dataset.scripts.verify_lerobot_to_mb_dataset \
  --lerobot-root /path/to/lerobot_ds \
  --mb-root /path/to/mb_root \
  --dataset-name my_dataset \
  --check-video

# 对比两个 LeRobot 数据集
python -m modelbest_robo_dataset.scripts.verify_lerobot_to_mb_dataset \
  --lerobot-root /path/to/lerobot_a \
  --compare-lerobot-root /path/to/lerobot_b \
  --dataset-name my_dataset \
  --check-video
```

### 打印骨架中的机器人 meta / video key / fps

`modelbest_robo_dataset.scripts.convertion.print_robot_meta_info` 用于直接读取 `skeleton_episode` 或 `skeleton_single_frame`，打印：

- `MetaContent` 中的机器人相关字段
- `dim_names`
- video key
- skeleton 中推导出的 timeseries / fps 信息

示例：

```bash
python -m modelbest_robo_dataset.scripts.convertion.print_robot_meta_info /path/to/mb_root --dataset-name my_dataset
python -m modelbest_robo_dataset.scripts.convertion.print_robot_meta_info /path/to/mb_root/skeleton_single_frame --dataset-name my_dataset
python -m modelbest_robo_dataset.scripts.convertion.print_robot_meta_info /path/to/mb_root --dataset-name my_dataset --json
```

### 构建 LeRobot 采样子集（大数据集测试用）

`modelbest_robo_dataset.scripts.convertion.build_lerobot_sample_subset` 从大型 LeRobot v3.0 数据集（如 DROID 95K episodes）中快速创建最小可测试子集。通过软链接（symlink）避免复制大文件：

```bash
source ~/minicpmo/bin/activate
python -m modelbest_robo_dataset.scripts.convertion.build_lerobot_sample_subset \
  --source /path/to/droid_1.0.1 \
  --output /tmp/droid_sample \
  --max-data-files 1
```

输出目录结构：
- `meta/info.json`：调整 `total_episodes` 为子集大小
- `meta/episodes/chunk-000/file-000.parquet`：按 data shard 中出现的 episode_index 过滤
- `data/chunk-000/file-000.parquet`：软链接到源文件
- `meta/tasks.parquet`：软链接到源文件
- `videos/`：软链接到源目录

### 端到端 skeleton 验证

`modelbest_robo_dataset.scripts.verify_skeleton_identity` 用于验证对 `LeRobotSource` 的修改不会破坏已有的 `skeleton_episode` / `skeleton_single_frame` 输出：

- **Phase 1**：用当前 `LeRobotSource` 加载每个 episode，与已有 `skeleton_episode` SSTable 中的 task、state/action 维度、视频数等进行比较
- **Phase 2**：从已有 `skeleton_episode` 重新运行 `expand_skeleton`，MD5 比较生成的 `skeleton_single_frame` 每个 `part-*` 文件

```bash
source ~/minicpmo/bin/activate
cd /path/to/modelbest-robo-dataset
python -m modelbest_robo_dataset.scripts.verify_skeleton_identity \
  --lerobot-root /path/to/raw/libero_long \
  --unified-root /path/to/unified \
  --dataset libero_long \
  --state-type joint_position \
  --action-type delta_ee \
  --action-chunk 10 \
  --shuffle-seed 42
```

### 检查 single_frame 是否被 shuffle

`modelbest_robo_dataset.scripts.convertion.check_single_frame_shuffle` 用于判断 `single_frame` 数据是否经过 shuffle，支持：

- `python -m modelbest_robo_dataset.scripts.convert --one-frame-per-episode`
- `python -m modelbest_robo_dataset.scripts.expand_skeleton --mode single_frame`

示例：

```bash
python -m modelbest_robo_dataset.scripts.convertion.check_single_frame_shuffle \
  --input /path/to/mb_root --dataset-name my_dataset
```

### 多数据集共享 action 归一化并批量转换

`modelbest_robo_dataset.scripts.convertion.convert_multi_lerobot_normalized` 支持：

1. 对多个 LeRobot 数据集计算共享 action 统计量
2. 输出归一化后的 LeRobot 副本
3. 转换为 modelbest `episode` 格式
4. 再展开为 `single_frame`

示例：

```bash
python -m modelbest_robo_dataset.scripts.convertion.convert_multi_lerobot_normalized \
  --root /path/to/lerobot_root \
  --datasets task_a task_b \
  --output /path/to/modelbest_out \
  --state-type joint_position \
  --action-type delta_ee
```

## 已接入数据集

| 数据集 | 源格式 | 机器人 | State (env.obs) | Action (ai.action) | 模态 | 结构化标注 |
|--------|--------|--------|-----------------|--------------------|------|-----------|
| pusht | LeRobot | - | cartesian_position | cartesian_position | 视频+时序 | - |
| xarm_push_medium | LeRobot | xarm | joint_position | delta_joint_position | 视频+时序 | - |
| aloha_sim_insertion | LeRobot | aloha | joint_position | joint_position | 视频+时序 | - |
| fuse | TFRecord | DIGIT | cartesian_position, imu | delta_cartesian_position | 视频+音频+IMU | - |
| rh20t_cfg1~7 | RH20T | 多种 | cartesian_position, force_torque | cartesian_position | 视频+音频+力 | task_id, user_id, scene_id, quality_rating |
| droid_1.0.1 | LeRobot v3.0 | 多种 | joint_position | joint_position | 3视频+时序 | language_instruction (via tasks.parquet index) |
| libero | LeRobot v3.0 | Franka | joint_position | delta_cartesian_position | 2视频+时序 | task (long/spatial/object/goal) |
| agibotworld | LeRobot v3.0 | a2d | joint_position (14d) | joint_position (14d) | 8视频+时序 | task (需 state_key/action_key 覆盖) |
| agibotworld_3rgb | LeRobot v3.0 | a2d | joint_position (14d) | joint_position (14d) | 3视频+时序 | 同上，仅 head+hand_left/right（快速转换） |
| robomind_franka (LeRobot) | LeRobot v3.0 | Franka | joint_position (8d) | joint_position (8d) | 1–3 视频+时序 | task（1/2/3 相机变体配置） |
| robomind_ur (LeRobot) | LeRobot v3.0 | UR | joint_position (7d) | joint_position (7d) | 1 视频+时序 | task |
| robomind_agilex (LeRobot) | LeRobot v3.0 | agilex 双臂 | joint_position (7+7=14d) | joint_position (7+7=14d) | 3 视频+时序 | task（state/action 多列拼接） |
| robomind_franka_fr3_dual (LeRobot) | LeRobot v3.0 | FR3 双臂 | joint_position (16d) | joint_position (16d) | 3 视频+时序 | task |
| robotwin2_franka | LeRobot v3.0 | Franka 双臂 | joint_position (16d) | joint_position (16d) | 4视频+时序 | task (per-episode) |
| robotwin2_aloha_agilex | LeRobot v3.0 | ALOHA-Agilex 双臂 | joint_position (14d) | joint_position (14d) | 4视频+时序 | task (per-episode) |
| robotwin2_ur5 | LeRobot v3.0 | UR5 双臂 | joint_position (14d) | joint_position (14d) | 4视频+时序 | task (per-episode) |
| robomind_failure | HDF5 | tiangong/puppet/franka | joint_position | joint_position | 多视频+时序 | task_id, quality_rating=0 (失败数据) |
| robomind_puppet | HDF5 | puppet (双臂) | joint_position | joint_position | 多视频+时序 | task_id |
| robomind_franka | HDF5 | Franka | joint_position | joint_position | 多视频+时序 | task_id |

## 依赖

运行本仓库的 Python 脚本或 `pytest` 前，请先激活你的 Python 环境（例如 minicpmo）：

```bash
source ~/minicpmo/bin/activate
```

```
numpy
pandas
pyarrow
pydantic>=2.0
av (PyAV)          # EmbodiedWriter 视频重编码；LeRobotSource 不再依赖
Pillow
h5py              # RoboMind
tensorflow        # fuse (可选)
modelbest_sdk     # SSTable 骨架存储
thriftpy2         # modelbest_sdk 依赖
```

## 测试

```bash
source ~/minicpmo/bin/activate
pytest tests/ -v
```

| 测试文件 | 覆盖范围 |
|----------|---------|
| `tests/test_lerobot_source.py` | `LeRobotSource` 初始化、`list_episodes`、`load_episode`、task 解析（标准/DROID index 风格/`language_instruction` 回退）、`_normalize_tasks_df`、`build_lerobot_sample_subset` |
| `tests/test_sstable_record.py` | SSTable 记录序列化/反序列化 |

## State/Action 规范

详见 `docs/sop_state_action.md`。

多源数据集接入与适配说明详见 `docs/sop_dataset_adapter.md`。

核心原则:
1. **Action 必须使用原始数据**，禁止用 `np.diff` 等方式构造
2. **没有原始 action 的遥操作数据**，用 `action[t] = state[t+1]` (shifted state)
3. **TimeseriesName 自描述**，`delta_` 前缀表示增量，无前缀表示绝对目标
