具身智能数据处理的工程化实践:从原始数据到 Model-Ready 的全流程工具链(v0.1.1)
写在前面:具身智能(Embodied AI)被广泛认为是 AGI 的终局形态,而"私有化数据"被业内共识为护城河。但很少有人从工程角度讲清楚:原始数据到底长什么样?如何处理才能喂给模型训练?本文记录了我从 0 到 1 搭建一套具身智能数据处理工具链的完整实践,包含 8 个核心工具、3 个场景配置、4 个真实客户场景测试,所有代码开源可复现。
版本 : v0.1.1(2026-07-24 优化版,补充算法细节、代码片段、踩坑复盘)
阅读时长 : 约 25 分钟
适合人群: 具身智能从业者、数据工程师、机器人算法工程师、想切入具身数据赛道的创业者
源码: https://download.csdn.net/download/m0_64997730/93160021
一、背景:为什么需要一套具身数据处理工具?
1.1 行业现状:数据才是真正的护城河
梁文锋在闭门会上提到 AGI 演进的五个阶段:
思维链(CoT) → 智能体(Agent) → 持续学习 → 自我进化 → 具身智能(终局)
而具身智能的数据与大语言模型有本质区别:
| 维度 | 大语言模型 | 具身智能 |
|---|---|---|
| 数据来源 | 互联网公开文本/视频 | 真实物理世界采集 |
| 可获取性 | 可爬取 | 不可爬取,只能自己采 |
| 数据格式 | 纯文本 | 多模态时序(RGB+action+state+depth) |
| 单条成本 | 近乎零 | 250元/小时(真机遥操) |
| 数据特征 | 离散、静态 | 连续、动态、时间同步 |
这意味着:具身智能数据天然带私有化属性。
1.2 市场规模与机会
根据公开数据:
- 量子位统计国内已有 97 家具身数据玩家,70 家做采集,27 家做基础设施
- 过去一年 15 家纯做数据的独立服务商 融资 44.7 亿元
- 光轮智能 2026 Q1 单季新增订单 5.5 亿元,估值 150 亿
- 帕西尼估值百亿,与比亚迪/京东/吉利绑定
- 京东宿迁基地计划发动 60 万人众包采集,目标两年 1000 万小时
光轮、帕西尼卖的"Model-Ready Data"流水线实际只做 5 步:
- 数据清洗(对齐 + 异常 + 模糊 + 去重)
- 语言指令标注(VLM 生成 + 人工校)
- 阶段分割(自动预标注 + 人工校)
- 成功失败标记(启发式 + 人工校)
- 格式转换(按客户要求转)
我的工具链覆盖了同样的流程,且自动化程度更高(500 episode 4 秒跑完) 。这不是替代头部公司,而是证明了这套流程可以被工程化、自动化------这正是中小数据服务商的生存空间。
1.3 原始数据到底长什么样?
具身智能的原始数据不是文本,而是多模态时序信号流。以 DROID 数据集为例:
数据集目录结构(LeRobot v3.0 格式)
lerobot_droid_100/
├── data/chunk-000/file-000.parquet # 主数据(action/state/...)
├── meta/
│ ├── info.json # 数据集元信息
│ ├── tasks.parquet # 任务列表(47个任务)
│ └── episodes/chunk-000/file-000.parquet # episode 元信息
└── videos/
├── observation.images.exterior_image_1_left/chunk-000/file-000.mp4
├── observation.images.exterior_image_2_left/chunk-000/file-000.mp4
└── observation.images.wrist_image_left/chunk-000/file-000.mp4
info.json 关键字段
json
{
"codebase_version": "v3.0",
"fps": 15,
"total_episodes": 100,
"total_frames": 32212,
"features": {
"action": {"dtype": "float32", "shape": [7], "names": ["dx","dy","dz","drx","dry","drz","gripper"]},
"observation.state": {"dtype": "float32", "shape": [7]},
"observation.images.exterior_image_1_left": {"dtype": "video"},
"observation.images.wrist_image_left": {"dtype": "video"}
}
}
每帧 action 的 7 维向量
python
action = [dx, dy, dz, drx, dry, drz, gripper]
# └── 末端速度(前后/左右/上下) ─┘ └─ 旋转 ─┘ 夹爪
# action norm = √(dx²+dy²+dz²+drx²+dry²+drz²) ← 用于判断机器人在不在动
一条样本的时间同步要求
timestamp | RGB(多相机) | 深度图 | 点云 | 关节角 | 末端位姿 | 力/触觉 | 语言指令
↑ ↑ ↑
└── 必须时间对齐 ──────────────────────────────┘
如果时间对齐错了,后面所有标注都是错的------这是具身数据标注的核心痛点。
1.4 痛点:数据不能直接喂模型
原始数据 80% 有以下问题:
| 问题类型 | 具体表现 | 影响 |
|---|---|---|
| 多模态时间不同步 | 视频和 action 序列错位 | 训练学到错误关联 |
| 缺少语义标注 | 没有成功/失败标记 | 无法剔除失败集 |
| 缺少语言指令 | 没有 task 描述 | 无法训练语言条件 policy |
| 缺少阶段分割 | 没有 init/grasp/release | 无法做精细 policy |
| 质量问题 | 模糊帧、重复数据、传感器跳变 | 浪费算力 |
| 格式不统一 | RLDS/HDF5/LeRobot/MCAP | 客户训练框架不匹配 |
二、架构设计:工具 × 配置 = 按场景跑
2.1 设计理念:微工具架构
我没有选择做"大而全的标注平台",而是采用微工具架构:
工具(每个功能1个 .py)
×
配置(每个场景1套参数)
=
按场景跑
核心原则:
- 每个工具职责单一(Unix 哲学:Do one thing well)
- 工具与场景解耦(同一个工具服务多个场景,参数不同)
- CLI + HTML 报告(零运维,本地跑,不需要服务器)
- 自动化优先(把人工工时压到 30%)
2.2 为什么不用一个大系统?
14 个标注任务在交互模式上有本质差异,塞一个系统会变成大而全的怪物:
| 交互模式 | 任务 | 核心操作 |
|---|---|---|
| 看视频标全局 | 成功失败/语言指令 | 看完整段视频 → 填一个标签 |
| 看时序标区间 | 阶段分割/接触事件 | 看曲线+视频 → 拖时间轴标区间 |
| 看单帧画图形 | 边界框/关键点/分割 | 单帧图像 → 画框/点/多边形 |
| 看3D点云 | 3D框/点云标注 | 3D 旋转视角 → 标 3D 框 |
| 批量跑脚本 | 清洗/格式转换 | 无交互 → 跑完看报告 |
把这 5 种模式塞一个系统 = 5 套完全不同的 UI 逻辑,开发量爆炸。
2.3 工程目录结构
embodied_data_toolkit/
├── core/ # 核心库(3个模块)
│ ├── __init__.py # 统一导出
│ ├── data_loader.py # LeRobot 加载器(处理多模态对齐)
│ ├── reporters.py # HTML/Console 报告生成
│ └── utils.py # pHash/Laplacian/日志/JSON 工具
│
├── tools/ # 8 个数字员工(每个独立可跑)
│ ├── align_check.py # L5.1 时序对齐校验
│ ├── anomaly_fix.py # L5.2 异常值检测与修复
│ ├── blur_filter.py # L5.3 模糊帧过滤
│ ├── dedup.py # L5.4 去重
│ ├── phase_segment.py # L2.1 任务阶段分割
│ ├── success_predict.py # L1.1 成功/失败预判
│ ├── lang_instruct.py # L1.2 语言指令补充(VLM)
│ ├── format_convert.py # L6 格式转换(LeRobot↔HDF5↔JSON)
│ └── long_video_split.py # 长视频自动切片(场景A专用)
│
├── configs/ # 3 个场景配置(YAML)
│ ├── internet_video.yaml # 互联网视频(4工具)
│ ├── teleoperation.yaml # 真机遥操(8工具全开,最核心)
│ └── simulation.yaml # 仿真合成(2工具)
│
├── run_pipeline.py # 流水线编排器(按场景串联多工具)
├── .env # API 配置(gitignore)
├── .gitignore
│
├── test_data/ # 测试数据
│ ├── internet_video/ # 100 个 mp4(100MB)
│ ├── kitchen_egocentric/ # 50 个厨房第一人称视频
│ ├── long_video/ # 长视频+真值
│ └── desync_multi_camera/ # 多相机不同步数据
│
├── reports/ # 所有 HTML 报告 + JSON 结果 + 图表
│ ├── charts/ # 可视化图表
│ └── converted/ # 格式转换输出
│
├── logs/ # 运行日志(按时间戳)
└── deliverables/ # 交付文档
2.4 模块依赖关系
run_pipeline.py
├── 加载 configs/<scenario>.yaml
├── 按 pipeline 顺序调用 tools
└── 汇总结果 → reports/
每个 tool:
├── import core.data_loader (加载数据)
├── import core.utils (工具函数)
├── 执行核心逻辑
└── import core.reporters (生成报告)
core.data_loader:
├── 读 info.json (元信息)
├── 读 tasks.parquet (任务列表)
├── 读 episodes parquet (episode 元信息)
├── 读 data parquet (主数据)
└── 读 videos mp4 (视频流)
2.5 统一接口设计
每个工具都遵循统一接口:
python
# 每个工具的 run() 函数签名
def run(input_path: str, toolkit_root: str = None, logger: logging.Logger = None, **kwargs) -> Dict[str, Any]:
"""
统一入口
输入:数据路径
输出:{summary, details, report_path, json_path}
"""
# 1. 加载数据
loader = LeRobotLoader(input_path, logger=logger)
# 2. 执行核心逻辑
results = [...]
# 3. 生成统计
summary = {...}
# 4. 控制台摘要
ConsoleReporter.print_summary(title, summary)
# 5. HTML 报告(给客户)
reporter = HTMLReporter(toolkit_root)
report_path = reporter.generate(...)
# 6. JSON 结果(给下游工具)
save_json({...}, json_path)
return {"summary": summary, "details": details, "report_path": report_path}
三、核心模块实现
3.1 数据加载器(core/data_loader.py)
这是整个工具链的地基,处理 LeRobot 格式的多模态对齐问题。
关键数据结构
python
@dataclass
class EpisodeInfo:
"""单个 episode 的元数据"""
episode_index: int
length: int # 帧数
task_index: int
task_name: str = ""
video_offset: int = 0 # 在视频文件中的全局帧偏移(关键!)
fps: int = 15
@dataclass
class VideoStream:
"""视频流信息"""
key: str # 如 observation.images.exterior_image_1_left
path: str
total_frames: int = 0
多模态对齐的核心逻辑
python
def read_video_frame(self, video_key: str, ep_idx: int, frame_in_ep: int) -> Optional[np.ndarray]:
"""
读取视频帧(已处理对齐)
关键:全局帧位置 = episode 偏移 + episode 内帧索引
"""
ep = self.get_episode_info(ep_idx)
# ⚠ 这是最关键的一行!
# frame_index 是 episode 内索引(每集从0)
# 但视频是全局连续拼接的,必须加偏移
global_pos = ep.video_offset + frame_in_ep
cap = cv2.VideoCapture(stream.path)
cap.set(cv2.CAP_PROP_POS_FRAMES, global_pos)
ret, frame = cap.read()
return frame
episode 偏移表构建
python
def _build_episodes(self, eps_df: pd.DataFrame):
"""从 episodes parquet 构建 EpisodeInfo 列表"""
# 先收集所有 episode 的 length
ep_lengths = {}
for _, row in eps_df.iterrows():
ep_idx = int(row["episode_index"])
ep_lengths[ep_idx] = int(row["length"])
# 计算每个 episode 的视频偏移
for _, row in eps_df.iterrows():
ep_idx = int(row["episode_index"])
length = int(row["length"])
# 偏移 = 前面所有 episode 的 length 之和
offset = sum(ep_lengths[i] for i in range(ep_idx) if i in ep_lengths)
self.episodes.append(EpisodeInfo(
episode_index=ep_idx,
length=length,
video_offset=offset, # ← 关键
))
3.2 工具函数(core/utils.py)
感知哈希(pHash)--- 用于去重
python
def compute_phash(img: np.ndarray, hash_size: int = 8) -> np.ndarray:
"""
感知哈希:将图像转为 8x8 二值哈希
算法:缩放→灰度→均值二值化
"""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = cv2.resize(gray, (hash_size, hash_size))
avg = gray.mean()
return (gray > avg).flatten()
def phash_similarity(hash1: str, hash2: str) -> float:
"""两个 pHash 的相似度(0~1,1=完全相同)"""
same = sum(a == b for a, b in zip(hash1, hash2))
return same / len(hash1)
Laplacian 方差 --- 用于模糊检测
python
def laplacian_variance(img: np.ndarray) -> float:
"""
Laplacian 方差:衡量图像清晰度
原理:清晰图像有高频边缘,Laplacian 响应大,方差大
阈值参考:
- < 100: 模糊
- 100-500: 可接受
- > 500: 清晰
"""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
3.3 HTML 报告生成器(core/reporters.py)
每个工具运行后自动生成 HTML 报告,给客户交付用:
python
class HTMLReporter:
def generate(
self,
tool_name: str,
dataset_name: str,
summary: Dict[str, Any], # 摘要统计
details: List[Dict[str, Any]], # 详情列表
charts: Optional[List] = None, # 图表
recommendations: Optional[List[str]] = None, # 处理建议
) -> str:
"""生成 HTML 报告,返回文件路径"""
# 包含:摘要表、详情表(前50行)、图表、处理建议
# 样式:卡片式布局,问题行高亮红色
四、8 个核心工具详解
4.1 工具1:时序对齐校验(align_check.py)
这是所有标注的前置必做。对齐错了,后面全废。
校验 5 项
python
def check_global_alignment(loader, logger):
"""检查1:全局对齐 - 视频帧数 vs 数据行数 vs episode 长度和"""
total_data = loader.total_frames
total_ep_len = sum(ep.length for ep in loader.episodes)
# 三者必须相等(容差1帧,编码误差)
if total_data != total_ep_len:
result["issues"].append(f"数据行数({total_data}) != episode 长度和({total_ep_len})")
for vk, stream in loader.video_streams.items():
if abs(stream.total_frames - total_data) > 1:
result["issues"].append(f"视频 {vk} 帧数({stream.total_frames}) != 数据行数({total_data})")
def check_frame_index_semantics(loader, logger):
"""检查2:frame_index 语义 - 是 episode 内索引还是全局索引"""
# 判断:每个 episode 内 frame_index 是否从 0 开始
for ep in loader.episodes:
ep_data = df[df["episode_index"] == ep.episode_index]
first_fi = int(ep_data["frame_index"].iloc[0])
if first_fi == 0:
result["semantic"] = "episode_local" # ← LeRobot 默认
result["recommendation"] = "视频读取需加 video_offset"
踩坑复盘(重要!)
现象:End 帧场景和 Start/Mid 不一致
错误做法:
python
# ❌ 直接用 episode 内索引 seek 视频
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_in_ep) # 所有"首帧"都指向视频开头!
正确做法:
python
# ✅ 全局帧位置 = episode 偏移 + episode 内帧索引
global_pos = ep.video_offset + frame_in_ep
cap.set(cv2.CAP_PROP_POS_FRAMES, global_pos)
验证方法:视频总帧数(32212) == 数据总行数(32212) == episode length 总和(32212)
测试结果:DROID-100 全部通过,5 项检查 0 issues。
4.2 工具2:异常值检测(anomaly_fix.py)
4 类异常检测
python
def detect_static_segments(actions, zero_threshold=0.001, min_len=5):
"""检测静止段(action norm 接近 0 的连续帧)"""
norms = np.linalg.norm(actions[:, :6], axis=1) # 前6维的欧几里得长度
is_static = norms < zero_threshold
# 找连续静止段(≥5帧)
segments = []
start = None
for i, s in enumerate(is_static):
if s and start is None:
start = i
elif not s and start is not None:
if i - start >= min_len:
segments.append({"start": start, "end": i-1, "length": i-start})
start = None
def detect_jumps(actions, threshold_factor=5.0):
"""检测跳变帧(相邻帧差分超过 mean + N*std)"""
diffs = np.abs(np.diff(actions, axis=0))
diff_norms = np.linalg.norm(diffs[:, :6], axis=1)
mean_d = diff_norms.mean()
std_d = diff_norms.std()
threshold = mean_d + threshold_factor * std_d # 5σ 才算跳变
jumps = [i+1 for i, dn in enumerate(diff_norms) if dn > threshold]
def detect_range_anomalies(actions, action_range=(-10, 10)):
"""检测范围异常(action 超出物理合理范围)"""
for i, a in enumerate(actions):
if np.any(a < action_range[0]) or np.any(a > action_range[1]):
anomalies.append({"frame": i, "values": a})
def detect_frame_gaps(ep_data):
"""检测丢帧(frame_index 不连续)"""
fi = ep_data["frame_index"].values
for i in range(1, len(fi)):
if fi[i] - fi[i-1] != 1:
gaps.append({"frame": i, "gap": fi[i] - fi[i-1] - 1})
测试结果:DROID-100 检出 538 异常(314静止段 + 224跳变),99% episode 有异常------这是真机数据的典型特征。
4.3 工具3:模糊帧过滤(blur_filter.py)
算法:Laplacian 方差
python
def analyze_video_blur(video_path, threshold=100.0, sample_step=1):
"""分析视频的模糊帧"""
cap = cv2.VideoCapture(video_path)
while True:
ret, frame = cap.read()
if not ret: break
score = laplacian_variance(frame) # cv2.Laplacian(gray, CV_64F).var()
if score < threshold: # 默认 100,值越小越模糊
blur_frames.append({"frame": frame_idx, "score": score})
阈值参考
| 阈值 | 用途 | 说明 |
|---|---|---|
| 50 | 互联网视频 | 质量参差,阈值放宽 |
| 100 | 真机遥操 | 质量较好,标准阈值 |
| 200 | 高质量要求 | 严格过滤 |
测试结果:DROID-100 平均清晰度 1165,0 模糊帧(源数据质量高)。
4.4 工具4:去重(dedup.py)
算法:三帧签名 pHash
python
def compute_episode_signature(loader, ep_idx, video_key):
"""计算 episode 的三帧签名(首/中/尾)"""
frames = {}
for label, fi in [("first", 0), ("mid", ep.length // 2), ("last", ep.length - 1)]:
frame = loader.read_video_frame(video_key, ep_idx, fi)
frames[label] = frame_to_phash(frame) # 8x8 = 64bit
# 综合签名 = 三帧拼接(192bit)
combined = frames["first"] + frames["mid"] + frames["last"]
return combined
def find_duplicates(signatures, threshold=0.95):
"""O(n²) 两两比较,相似度 > 阈值判定为重复"""
for i in range(n):
for j in range(i+1, n):
sim = phash_similarity(signatures[i]["combined"], signatures[j]["combined"])
if sim >= threshold:
duplicates.append((i, j, sim))
为什么用三帧? 单帧容易误判(不同 episode 的首帧可能都是静止的桌面),三帧能区分完整操作序列。
测试结果:100 个视频 0 重复,最高相似度 0.849(低于 0.85 阈值,数据多样性良好)。
4.5 工具5:任务阶段分割(phase_segment.py)
算法:action norm + gripper 变化点检测
python
def segment_episode(actions, zero_threshold=0.01):
motion = np.linalg.norm(actions[:, :6], axis=1) # 末端运动
gripper = actions[:, 6] # 夹爪开合
# 1. 检测 gripper 变化点(0→1 释放, 1→0 抓取)
gripper_changes = []
for i in range(1, len(gripper)):
if abs(gripper[i] - gripper[i-1]) > 0.3:
gripper_changes.append({
"frame": i,
"type": "release" if gripper[i] > gripper[i-1] else "grasp"
})
# 2. 基于 motion + gripper 状态切分阶段
phases = []
# init: 开头静止段(motion ≈ 0)
# approach: 从开始运动到抓取
# grasp: 夹爪闭合瞬间(单帧)
# transport: 抓取后到释放
# release: 夹爪张开瞬间(单帧)
# retreat: 释放后剩余
可视化
生成时序图:上图是 action norm(带阶段着色),下图是 gripper 信号(标注抓取/释放时刻)。
测试结果:DROID-100 平均 2.13 阶段/episode。检测到 5 个 grasp 事件(DROID 的 gripper 编码方式特殊,需调优)。
4.6 工具6:成功/失败预判(success_predict.py)
启发式 5 维评分
python
def predict_success(actions):
scores = {}
# 1. 运动能量(太低=没动,太高=异常)
avg_energy = motion.mean()
if avg_energy < 0.01: scores["energy"] = 0.1 # 没动
elif avg_energy > 2.0: scores["energy"] = 0.3 # 异常
else: scores["energy"] = 0.8 # 正常
# 2. 夹爪循环(开→合→开=完整操作)
if gripper_changes >= 2: scores["gripper_cycle"] = 0.9 # 完整循环
elif gripper_changes == 1: scores["gripper_cycle"] = 0.5
else: scores["gripper_cycle"] = 0.3
# 3. 末尾静止段(任务完成停顿)
if last_static >= 5: scores["end_pause"] = 0.8
# 4. 开头静止段(任务准备)
if first_static >= 2: scores["start_pause"] = 0.7
# 5. 运动连续性(无明显跳变)
if max_diff < 0.5: scores["continuity"] = 0.8
# 加权综合
weights = {"energy": 0.15, "gripper_cycle": 0.35, "end_pause": 0.20,
"start_pause": 0.10, "continuity": 0.20}
confidence = sum(scores[k] * weights[k] for k in weights)
success = confidence >= 0.6
为什么 gripper_cycle 权重最高(0.35)? 因为夹爪的"开→合→开"循环是任务成功执行的最强信号------抓了东西又放了,说明操作完整。
测试结果:DROID-100 预判 36% 成功率,符合真机数据典型值(真机遥操成功率通常 30-50%)。
4.7 工具7:语言指令补充(lang_instruct.py)
VLM 调用(OpenAI 兼容接口)
python
def call_vlm(frames, prompt):
"""调用 MiniMax-Text-01 VLM"""
client = OpenAI(api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.minimaxi.com/v1"))
# 三帧 base64 编码
content = [{"type": "text", "text": prompt}]
for frame in frames:
b64 = frame_to_base64(frame)
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
})
response = client.chat.completions.create(
model="MiniMax-Text-01",
messages=[{"role": "user", "content": content}],
max_tokens=100,
temperature=0.3, # 低温度保证一致性
)
return response.choices[0].message.content.strip()
提示词工程
python
prompt = """This is a wrist-camera view of a robot performing a manipulation task.
Describe the task in one English sentence starting with a verb.
Focus on what object is being manipulated and what action is performed."""
降级策略
python
def predict_instruction(loader, ep_idx, use_vlm=True):
if use_vlm and os.environ.get("LLM_API_KEY"):
try:
return call_vlm(frames, prompt) # VLM 模式
except Exception:
pass # 失败则降级
return generate_instruction_fallback(task_name) # 模板模式
测试结果:
- VLM 模式:100% 调用成功(20/20)
- 动作类别识别准确(placing/picking)
- 物体识别受分辨率限制(320x180 下 pot→bowl)
4.8 工具8:格式转换(format_convert.py)
LeRobot → HDF5
python
def lerobot_to_hdf5(loader, output_path):
with h5py.File(output_path, "w") as hf:
for ep in loader.episodes:
ep_group = hf.create_group(f"episode_{ep.episode_index:04d}")
# actions
actions = loader.get_actions(ep.episode_index)
ep_group.create_dataset("actions", data=actions)
# states
states = loader.get_states(ep.episode_index)
ep_group.create_dataset("states", data=states)
# 视频帧(只记录路径和偏移,不提取)
ep_group.attrs["video_path"] = os.path.basename(stream.path)
ep_group.attrs["video_offset"] = ep.video_offset
# 元数据
ep_group.attrs["task"] = ep.task_name
ep_group.attrs["fps"] = loader.fps
测试结果:100 episode 转 HDF5,文件大小 1.95MB(不含视频帧)。
五、流水线编排:3 个场景配置
5.1 场景配置(YAML)
yaml
# teleoperation.yaml(真机遥操,最核心)
scenario_name: "真机遥操数据"
description: "多模态完整的真机数据,需对齐+异常修复+语义标注"
tools:
align_check:
enabled: true
anomaly_fix:
enabled: true
zero_threshold: 0.001
jump_threshold: 5.0
blur_filter:
enabled: true
threshold: 100.0
sample_step: 3
phase_segment:
enabled: true
visualize_count: 10
success_predict:
enabled: true
threshold: 0.6
lang_instruct:
enabled: true
use_vlm: false # 真机通常有 task_name,优先模板
format_convert:
enabled: true
format: "hdf5"
# 运行顺序(对齐 → 清洗 → 标注 → 转换)
pipeline:
- align_check # 1. 时序对齐校验(前置必做)
- anomaly_fix # 2. 异常值检测修复
- blur_filter # 3. 模糊帧过滤
- dedup # 4. 去重
- phase_segment # 5. 阶段分割预标注
- success_predict # 6. 成功预判
- lang_instruct # 7. 语言指令补充
- format_convert # 8. 格式转换交付
5.2 编排器实现
python
def run_pipeline(input_path, scenario, toolkit_root):
config = load_scenario(scenario) # 加载 YAML
for step_name in config["pipeline"]:
tool_cfg = config["tools"].get(step_name, {})
if not tool_cfg.get("enabled", False):
continue # 跳过未启用的工具
# 提取参数
params = {k: v for k, v in tool_cfg.items() if k != "enabled"}
# 动态导入工具模块并执行
module = importlib.import_module(TOOL_MODULE_MAP[step_name])
result = module.run(input_path, toolkit_root=toolkit_root, **params)
5.3 一键运行
bash
# 真机遥操场景(8个工具全开)
python run_pipeline.py --input datasets/lerobot_droid_100 --scenario teleoperation
# 互联网视频场景(4个工具)
python run_pipeline.py --input test_data/internet_video --scenario internet_video
# 仿真合成场景(2个工具)
python run_pipeline.py --input sim_data --scenario simulation
六、真实客户场景测试
6.1 场景A:长视频自动切片
客户画像:给一个 9.26 分钟未切片长视频(8333帧),需自动识别 episode 边界。
算法:帧间差分
python
def compute_frame_diff(prev, curr):
"""两帧差异得分(0=相同,1=完全不同)"""
# 1. 直方图相关性(场景切换时急剧下降)
h1 = cv2.calcHist([prev_gray], [0], None, [64], [0, 256])
h2 = cv2.calcHist([curr_gray], [0], None, [64], [0, 256])
hist_corr = cv2.compareHist(h1, h2, cv2.HISTCMP_CORREL)
# 2. 像素均值差(场景切换时大幅变化)
mean_diff = abs(prev_gray.mean() - curr_gray.mean()) / 255.0
# 综合得分
score = (1.0 - hist_corr) * 0.7 + mean_diff * 0.3
return score
测试结果
| 指标 | 值 |
|---|---|
| 视频总帧数 | 8333(9.26分钟) |
| 检测到切换点 | 29 个 |
| 切分出 episode | 30 个 |
| 真值 episode | 30 个 |
| 准确率 | 100% |
| 耗时 | 7 秒 |
6.2 场景B:厨房第一人称 VLM 指令
客户画像:50 个 wrist 腕部相机视频,无语言指令。
VLM vs Ground Truth 对比
| Episode | VLM 生成 | Ground Truth | 重叠率 |
|---|---|---|---|
| EP000 | The robot is placing a marker into a metal bowl | Put the marker in the pot | 0.167 |
| EP027 | The robot is placing slices of bread into a toaster | Put the bread in the toaster | 0.250 |
| EP021 | The robot is placing an orange object into a container | Put the orange rubber duck into the pot | 0.200 |
统计
| 指标 | 值 |
|---|---|
| VLM 调用成功率 | 100%(20/20) |
| 指令平均长度 | 12.4 词 |
| 与 GT 词汇重叠率 | 0.133(Jaccard) |
| 平均耗时 | 2.7 秒/视频 |
分析:VLM 能稳定识别动作类别(placing/picking),但 320x180 低分辨率下物体识别有限(pot→bowl, rubber duck→orange object)。
6.3 场景C:多相机不同步检测
客户画像:三路相机时钟错位,帧数不一致。
测试数据
| 相机 | 帧数 | 模拟问题 |
|---|---|---|
| exterior_1 | 32212 | 基准 |
| exterior_2 | 32162 | 删除前 50 帧模拟时钟偏移 |
| wrist | 32182 | 删除后 30 帧模拟丢帧 |
检测结果
align_check 精准检测到 5 个问题:
- 全局对齐失败:
data=32212 vs ep_sum=32182 - exterior_2 帧数不匹配:
32162 vs 32212(差 50 帧) - wrist 帧数不匹配:
32182 vs 32212(差 30 帧) - 多相机不同步: 三路帧数互不一致
- frame_index 语义:
episode_local(正常)
结论 :能准确定位哪路相机偏移多少帧,这是修复的基础。
6.4 场景D:500 条批量性能
测试数据:DROID-500,500 episode,161060 帧,468MB。
性能基准
| 工具 | 100ep | 500ep | 扩展性 |
|---|---|---|---|
| align_check | 0.3s | <1s | 线性 O(n) |
| anomaly_fix | 0.3s | 1s | 线性 O(n) |
| phase_segment | 3.9s | 1s* | 线性 O(n) |
| success_predict | 0.3s | 1s | 线性 O(n) |
| blur_filter | 390s | ~1950s | 线性(慢) |
| dedup | 22s | ~110s | O(n²) |
*phase_segment 500ep 比 100ep 快是因可视化数量减少
处理结果
- 500 episode 4 秒跑完(不含 blur_filter)
- 处理速度 125 episode/秒
- 检出 2690 个异常(1570 静止段 + 1120 跳变)
- 预判成功率 36%
线性扩展预测
| 数据量 | 预计耗时 |
|---|---|
| 1,000 episode | ~8 秒 |
| 10,000 episode | ~80 秒 |
| 100,000 episode | ~13 分钟 |
七、踩坑总结(重要!)
7.1 frame_index 语义陷阱
现象:End 帧场景和 Start/Mid 不一致。
根因 :frame_index 是 episode 内索引(每集从 0),但视频是全局连续拼接的。
错误做法:
python
# ❌ 所有"首帧"都指向视频开头
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_in_ep)
正确做法:
python
# ✅ 全局帧位置 = episode 偏移 + episode 内帧索引
global_pos = ep.video_offset + frame_in_ep
cap.set(cv2.CAP_PROP_POS_FRAMES, global_pos)
验证:视频总帧数(32212) == 数据总行数(32212) == episode length 总和(32212)
教训:这是具身数据标注的核心痛点。客户给的原始数据 80% 有这类对齐问题,你能提供"对齐校验+修复"服务,就是差异化竞争力。
7.2 blur_filter 性能瓶颈
问题:逐帧解码视频太慢(100ep 要 390 秒)。
优化方案:
bash
# 方案1:采样加速(牺牲精度,10倍速)
python tools/blur_filter.py --input data --sample-step 10
# 方案2:用 ffmpeg 抽帧代替 opencv(后续优化)
# ffmpeg -i input.mp4 -vf "select=not(mod(n\,10))" -vsync vfn frame_%04d.jpg
7.3 VLM 低分辨率限制
问题:320x180 分辨率下,MiniMax-Text-01 把 pot 识别成 bowl,把 rubber duck 识别成 orange object。
原因:腕部相机视角 + 低分辨率,物体细节丢失。
解决方案:
- 客户提供 640x360 以上分辨率
- 或换 GPT-4V / Claude 3.5 Sonnet(视觉能力更强)
- 或提高提取帧的分辨率(插值)
7.4 DROID 的 gripper 编码
问题:phase_segment 的 grasp 检出率低(100 episode 只检测到 5 个 grasp)。
原因:DROID 的 gripper 信号不是简单的 0/1,编码方式特殊。
解决:需要针对不同数据集调整阈值,或用差分检测代替绝对值检测。
7.5 模拟数据的视频对齐问题
问题:DROID-500(复制5份模拟)的 align_check 报视频帧数不匹配(32212 vs 161060)。
原因:模拟数据把 parquet 复制5份(161060帧),但视频只有1份(32212帧)。
这是真实场景:客户的多模态数据也可能帧数不一致,align_check 能检测出来。
八、商业思考
8.1 这套工具值多少钱?
光轮智能 Q1 单季订单 5.5 亿元,卖的就是这 5 步:
- 数据清洗(对齐 + 异常 + 模糊 + 去重)
- 语言指令标注(VLM + 人工校)
- 阶段分割(自动预标注 + 人工校)
- 成功失败标记(启发式 + 人工校)
- 格式转换(按客户要求)
我的工具链覆盖了同样的 8 个任务,且自动化程度更高(500ep 4秒)。
8.2 自动化难度与赚钱路径
自动化难度 工具 策略
⭐ 极易 align_check / blur_filter 全自动,卖工具
dedup / format_convert
⭐⭐ 简单 anomaly_fix / phase_segment 自动预标注+人工校准
success_predict
⭐⭐⭐ 中 lang_instruct(VLM) VLM辅助+人工
⭐⭐⭐⭐ 高 视觉标注(边界框/分割) 主要靠人工(但具身智能用不到)
赚钱路径:先做 ⭐~⭐⭐ 的(全自动/半自动,毛利高),用现金流养 ⭐⭐⭐ 的(高单价)。
8.3 入口产品:"数据体检"免费
align_check 作为免费入口产品,给客户输出"数据体检报告",建立信任后再谈标注大单。
这是 SaaS 的经典 PLG(产品驱动增长)打法:
- 免费体检 → 发现问题
- 收费清洗 → 解决问题
- 收费标注 → 增值服务
- 长期合作 → 持续接单
8.4 定价策略
| 服务项目 | 计费方式 | 参考价格 |
|---|---|---|
| 数据体检 | 免费 | ¥0(入口产品) |
| 时序对齐校验 | 按小时 | ¥50-150/小时 |
| 阶段分割 | 按episode | ¥3-10/episode |
| 语言指令(VLM) | 按episode | ¥0.3-0.8/episode |
| 完整流水线(打包) | 按episode | ¥10-30/episode |
批量优惠:500ep 9折,2000ep 8折,5000ep 7折。
九、开源与复现
9.1 环境配置
bash
pip install numpy pandas opencv-python matplotlib pyyaml h5py pyarrow python-dotenv openai
9.2 数据准备
bash
# 下载 DROID-100(HuggingFace)
# https://huggingface.co/datasets/lerobot/droid_100
# 目录结构
datasets/lerobot_droid_100/
├── data/chunk-000/file-000.parquet
├── meta/info.json
└── videos/...
9.3 快速开始
bash
# 1. 跑真机遥操场景全流程
python run_pipeline.py --input datasets/lerobot_droid_100 --scenario teleoperation
# 2. 单工具跑
python tools/align_check.py --input datasets/lerobot_droid_100
python tools/phase_segment.py --input datasets/lerobot_droid_100 --visualize 10
python tools/success_predict.py --input datasets/lerobot_droid_100
# 3. 长视频切片
python tools/long_video_split.py --input raw_long_video.mp4 --threshold 0.25
# 4. VLM 指令补充(需配置 .env)
# .env 内容:
# LLM_API_KEY=你的key
# LLM_BASE_URL=https://api.minimaxi.com/v1
# LLM_MODEL=MiniMax-Text-01
python tools/lang_instruct.py --input datasets/lerobot_droid_100 --sample 100
# 5. 格式转换
python tools/format_convert.py --input datasets/lerobot_droid_100 --format hdf5
9.4 输出示例
每个工具运行后产出 3 类文件:
reports/
├── 20260724_140319_align_check_lerobot_droid_100.html # HTML报告(给客户)
├── 20260724_140319_align_check_lerobot_droid_100.json # JSON结果(给下游)
└── charts/
└── phase_segment_lerobot_droid_100/
└── ep_000.png # 可视化图
十、后续迭代方向
| 优先级 | 方向 | 说明 |
|---|---|---|
| P0 | blur_filter 性能优化 | 用 ffmpeg 抽帧代替 opencv,加速 10 倍 |
| P0 | VLM 升级 | 接入 GPT-4V/Claude 提升指令准确率 |
| P1 | RLDS 适配器 | 加 RLDS→LeRobot 转换(tensorflow_datasets) |
| P1 | 并行化 | 当前串行,加多进程加速 |
| P2 | CVAT 集成 | 对接 CVAT 做人工校准平台 |
| P2 | 垂直场景扩展 | 工业装配、医疗手术、农业采摘 |
| P3 | GUI 界面 | 阶段分割的时间轴校准 UI |
十一、总结
本文记录了从 0 到 1 搭建具身智能数据处理工具链的完整实践:
核心成果
- 8 个核心工具:覆盖时序对齐、异常检测、模糊过滤、去重、阶段分割、成功预判、语言指令、格式转换
- 3 个场景配置:互联网视频、真机遥操、仿真合成
- 4 个客户场景测试 :
- 长视频切片(100% 准确率)
- VLM 指令(100% API 成功率)
- 多相机检测(5/5 问题全部检测)
- 500 条批量(4 秒完成,125ep/秒)
- 工程化实践:模块化设计、配置驱动、统一接口、HTML 报告、完整日志
核心价值
把光轮/帕西尼卖几亿元的"Model-Ready Data"流水线,用开源工具+自动化脚本复现,个人本地即可运行。
这不是替代头部公司,而是证明了这套流程可以被工程化、自动化------这正是中小数据服务商的生存空间。
行业启示
- 数据清洗比对标注更赚钱:L5 清洗自动化程度最高,毛利最高
- "数据体检"是最佳入口产品:免费体检 → 发现问题 → 收费处理
- 具身智能不需要视觉标注:端到端 policy 直接吃原始图像,L3/L4 可不做
- 性能不是瓶颈:500ep 4 秒,万级数据分钟级完成
工具版本 : v0.1.1
测试日期 : 2026-07-24
转载请注明出处
Tags : 具身智能 Embodied AI 数据处理 LeRobot DROID VLM 数据标注 自动化 Python 机器人 Model-Ready Data 光轮智能 具身数据