feat(pybot): 运行 .mt 脚本时录制 actpred 格式自监督数据集

新增 EpisodeRecorder,在 run() 每行 @Action 执行前抓取摄像头帧
+ 构建动作向量,产出 actpred ActionDataset 可直接加载的
episode_XXXX/images/*.jpg + actions.npy 格式。dry_run 下生成
随机噪声帧替代真实图像。

CLI 新增 --record DIR 和 --action-dim 选项。
This commit is contained in:
qing committed 2026-08-13 16:08:47 +08:00
1 parent eadba81c26
commit b9f8abdfeb
4 files changed
+365 -3

No files matched your search

+11 -1
View File
@@ -38,6 +38,11 @@ def _build_argparser():
help="(reserved) 传感器数据输出管道,对接 actpred")
p.add_argument("-o", metavar="FIFO", default=None,
help="(reserved) 动作指令输入管道,对接 actpred")
p.add_argument("--record", metavar="DIR", default=None,
help="录制 actpred 格式 episode 到 DIR(需要 @Camera 块;"
"--dry-run 下用合成帧)")
p.add_argument("--action-dim", type=int, default=18,
help="动作向量维度(默认 18,与 actpred 一致)")
return p
@@ -89,8 +94,13 @@ def main(argv=None):
source = open(args.script, encoding="utf-8") if args.script else sys.stdin
try:
robot = RobotStruct(source, dry_run=args.dry_run)
recorder = None
if args.record:
from .recorder import EpisodeRecorder
recorder = EpisodeRecorder(args.record,
action_dim=args.action_dim)
with robot:
robot.run()
robot.run(recorder=recorder)
finally:
if args.script:
source.close()
+87
View File
@@ -0,0 +1,87 @@
"""Episode 录制器 —— 运行 .mt 脚本时同步产出 actpred 格式的自监督数据集
产出格式(与 actpred/dataset.py 的 DataRecorder 兼容):
output_dir/
episode_XXXX/
images/
0000.jpg, 0001.jpg, ...
actions.npy # shape (T, action_dim) float32
numpy / Pillow 为可选依赖(lazy import),仅 --record 时需要,
不影响 pybot 的零依赖运行时承诺。
"""
import logging
from pathlib import Path
log = logging.getLogger("pybot.recorder")
class EpisodeRecorder:
"""录制 actpred 格式的 episode(images/*.jpg + actions.npy)
用法:
rec = EpisodeRecorder("./actpred/data", action_dim=18)
for frame, action in stream:
rec.record(frame, action)
rec.flush()
"""
def __init__(self, output_dir, action_dim=18):
self.action_dim = action_dim
self._frame_idx = 0
self._actions = []
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
ep_idx = 0
for d in output.glob("episode_*"):
try:
ep_idx = max(ep_idx, int(d.name.split("_")[1]) + 1)
except (ValueError, IndexError):
pass
while True:
self.episode_dir = output / f"episode_{ep_idx:04d}"
self.images_dir = self.episode_dir / "images"
try:
self.images_dir.mkdir(parents=True, exist_ok=False)
break
except FileExistsError:
ep_idx += 1
log.info("recording to %s (action_dim=%d)",
self.episode_dir, action_dim)
def record(self, frame, action_vec):
"""记录一帧 + 动作向量
frame: np.ndarray (H,W,3) uint8 或 None(跳过图像保存)
action_vec: list/np.array,长度任意,内部 pad/truncate 到 action_dim
"""
import numpy as np
vec = np.zeros(self.action_dim, dtype=np.float32)
src = np.asarray(action_vec, dtype=np.float32).ravel()
n = min(len(src), self.action_dim)
vec[:n] = src[:n]
self._actions.append(vec)
if frame is not None:
from PIL import Image
img = Image.fromarray(frame)
img.save(self.images_dir / f"{self._frame_idx:04d}.jpg",
quality=90)
self._frame_idx += 1
def flush(self):
"""保存 actions.npy,返回 episode 路径"""
import numpy as np
if self._actions:
actions = np.array(self._actions, dtype=np.float32)
np.save(self.episode_dir / "actions.npy", actions)
log.info("recorded %d frames to %s",
self._frame_idx, self.episode_dir)
return str(self.episode_dir)
+57 -2
View File
@@ -135,13 +135,31 @@ class RobotStruct:
return item
return None
def run(self):
"""逐行执行 @Action 矩阵"""
def run(self, recorder=None):
"""逐行执行 @Action 矩阵
recorder: 可选 EpisodeRecorder,启用后每行执行前录制 (frame, action)
"""
action = self.find_mt("Action")
if action is None:
raise ValueError("no @Action matrix in script")
columns = self._validate_action(action) # Q05: 执行前整表校验
camera_var = None
if recorder is not None:
camera_var = self._find_camera()
if camera_var is None and not self.dry_run:
raise ValueError(
"--record 需要 @Camera 块(或使用 --dry-run 生成合成帧)")
for i in range(1, action.size):
# 录制:在执行动作前抓帧(observation → action 语义)
if recorder is not None:
action_vec = self._build_action_vec(
action, i, columns, recorder.action_dim)
frame = self._grab_frame(camera_var)
recorder.record(frame, action_vec)
row_sets = {} # {设备名: {参数名: float}}
for j, (dev, param) in enumerate(columns):
# 可能的参数别名转换(A → angle)
@@ -154,6 +172,43 @@ class RobotStruct:
for dev, params in row_sets.items():
self.M[dev].play(params)
if recorder is not None:
path = recorder.flush()
log.info("episode saved: %s", path)
def _find_camera(self):
"""在已创建的硬件变量中查找第一个 CameraVar"""
for var in self.M.values():
if isinstance(var, CameraVar):
return var
return None
def _build_action_vec(self, action, row, columns, action_dim):
"""按 @Action 列顺序构建动作向量,pad/truncate 到 action_dim
与 actpred/pretrain_mt.py 的 MTActionDataset 语义一致:
列值按顺序填入向量,不足补零,超出截断。
"""
import numpy as np
vec = np.zeros(action_dim, dtype=np.float32)
for j in range(min(len(columns), action_dim)):
vec[j] = parse_mt(action, row, j)
return vec
def _grab_frame(self, camera_var):
"""从摄像头取最新帧;dry_run 或无帧时返回随机噪声帧"""
import numpy as np
if camera_var is not None:
result = camera_var.play({})
if result is not None:
return result[1] # (timestamp, frame)
# dry_run 或队列空:随机噪声帧(用 camera 配置尺寸)
h, w = 480, 640
if camera_var is not None:
cam = camera_var.camera
h, w = cam.height, cam.width
return np.random.randint(0, 256, (h, w, 3), dtype=np.uint8)
def _validate_action(self, action):
"""校验 @Action 全部列名为 '设备.参数' 且设备已定义,返回 [(dev, param)]"""
columns = []