Files
blackbird/actpred/dataset.py
T
qing cc2f7bca79 feat(actpred): R1 残差预测+多步展开训练 与 R4 离线评估基准
R1(可选开关,默认关,向后兼容):
- --predict-delta:残差预测,target=Δ=a[t]-a[t-1](归一化空间),
  推理端 predict 自动加回 a_{t-1}(惯性先验平滑动作)
- --rollout-len K + --ss-prob:多步展开 + scheduled sampling,
  首步真历史、后续步按概率用真历史或自预测回传(detach 截断),
  治 exposure bias;dataset 多步样本返回 {steps:[...]},自定义 collate
- 旧 checkpoint getattr 默认 False 兼容;pybot 闭环/部署链零改动

R4(新增 evaluate.py):
- 留出 episode 开环滚动预测,复刻闭环口径(图像真值滚动、动作自预测回写)
- 5 个闭环代理指标:overall_mse/error_growth(发散)/error_curve/
  smoothness_ratio(平滑)/oob_rate(越界,数据经验界 min/max+margin)
- 支持 --compare A/B 对比与 --output JSON 报告
- 端到端验证:正确捕捉开环发散(单步 MSE 盲区)与残差平滑收益

新增 17 用例(R1×10 + R4×7),全量 100 passed 无回归
2026-08-14 07:52:22 +08:00

216 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import random
import re
import numpy as np
from pathlib import Path
from PIL import Image
import torch
from torch.utils.data import Dataset
from torchvision import transforms
from torchvision.transforms import functional as TF
def _natural_key(p):
"""自然排序键:'1.jpg' < '2.jpg' < ... < '10.jpg'"""
return [int(t) if t.isdigit() else t for t in re.split(r'(\d+)', p.name)]
class ActionDataset(Dataset):
def __init__(self, data_dir: str, history_len: int, action_dim: int,
image_width: int = 640, image_height: int = 480,
normalize_actions: bool = False,
action_stats_path: str = None, episode_indices: list = None,
frame_len: int = 3, augment: bool = False,
predict_delta: bool = False, rollout_len: int = 1):
self.history_len = history_len
self.action_dim = action_dim
self.image_width = image_width
self.image_height = image_height
self.frame_len = frame_len
self.augment = augment
# R1-C 残差预测:target 改为 Δ=actions[t]-actions[t-1](归一化空间内求差)
self.predict_delta = predict_delta
# R1-B 多步展开:rollout_len>1 时每个样本返回 K 步连续 (images,history,target)
self.rollout_len = max(1, rollout_len)
self.transform = transforms.Compose([
transforms.Resize((image_height, image_width)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
episodes = ActionDataset._valid_episode_dirs(data_dir)
if episode_indices is not None:
episodes = [episodes[i] for i in episode_indices]
# 样本存 (image_files, actions, t_start):t_start 是 rollout 第一步的目标时刻,
# __getitem__ 按 rollout_len 展开 K 步。存原始数组引用避免重复加载/拷贝。
self.samples = []
for ep_dir in episodes:
actions_path = ep_dir / "actions.npy"
images_dir = ep_dir / "images"
if not actions_path.exists() or not images_dir.is_dir():
continue
actions = np.load(actions_path).astype(np.float32)
if actions.ndim != 2 or actions.shape[1] != action_dim:
raise ValueError(
f"{actions_path} 形状应为 (T, {action_dim}),实际为 {actions.shape}。"
f"请检查该 episode 的动作维度与 --action-dim 是否一致。")
n_frames = len(actions)
image_files = sorted(list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.png")),
key=_natural_key)
n_images = len(image_files)
if n_frames != n_images:
print(f"WARNING: {ep_dir} 动作帧数 {n_frames} 与图像数 {n_images} 不一致,"
f"按较少者({min(n_frames, n_images)})使用,请检查录制完整性")
n = min(n_frames, n_images)
# 第 k 步(k=0..K-1)目标时刻 t=t_start+k:帧 [t-frame_len+1, t]、
# 历史 [t-history_len, t)、目标 actions[t]。t_start 下界保证第 0 步窗口完整,
# 上界保证第 K-1 步目标不越界。
start = max(history_len, frame_len - 1)
for t_start in range(start, n - (self.rollout_len - 1)):
self.samples.append((image_files, actions, t_start))
self._action_mean = None
self._action_std = None
if normalize_actions:
if action_stats_path:
if not os.path.exists(action_stats_path):
raise FileNotFoundError(
f"指定的 action_stats_path 不存在: {action_stats_path}")
stats = np.load(action_stats_path)
if stats.shape != (2, self.action_dim):
raise ValueError(
f"action stats 形状应为 (2, {self.action_dim}),"
f"实际为 {stats.shape}: {action_stats_path}")
self._action_mean = stats[0]
self._action_std = stats[1]
else:
if not self.samples:
raise ValueError(
f"数据集为空(0 样本),无法计算归一化统计量。"
f"请检查 data_dir={data_dir} 中的 episode 数据。")
# 归一化统计基于绝对动作(与 Δ 无关):覆盖所有样本 rollout 范围内
# 出现的 history 与 target 动作,保持与旧版一致的统计口径。
all_actions = np.stack([
s[1][s[2] - self.history_len: s[2] + self.rollout_len]
for s in self.samples
]).reshape(-1, self.action_dim)
self._action_mean = all_actions.mean(axis=0)
self._action_std = all_actions.std(axis=0) + 1e-8
def __len__(self):
return len(self.samples)
def _norm(self, a):
"""z-score 归一化(numpy → torch 前在 numpy 空间做,保持 float32)"""
if self._action_mean is None:
return a
return (a - self._action_mean) / self._action_std
def _load_frames(self, image_files, t):
"""加载时刻 t 的 F 帧窗口 [t-frame_len+1, t](旧→新),augment 对整个窗口一致"""
paths = image_files[t - self.frame_len + 1: t + 1]
if self.augment:
bf = random.uniform(0.8, 1.2)
cf = random.uniform(0.8, 1.2)
sf = random.uniform(0.8, 1.2)
hf = random.uniform(-0.05, 0.05)
frames = []
for p in paths:
image = Image.open(p).convert("RGB")
if self.augment:
image = TF.adjust_brightness(image, bf)
image = TF.adjust_contrast(image, cf)
image = TF.adjust_saturation(image, sf)
image = TF.adjust_hue(image, hf)
frames.append(self.transform(image))
return torch.stack(frames) # (F, 3, H, W)
def _step_target(self, actions, t):
"""时刻 t 的监督目标(归一化空间);predict_delta 时为 Δ=actions[t]-actions[t-1]"""
if self.predict_delta:
return self._norm(actions[t]) - self._norm(actions[t - 1])
return self._norm(actions[t])
def __getitem__(self, idx):
image_files, actions, t_start = self.samples[idx]
if self.rollout_len == 1:
# 单步:与旧版输出结构完全一致
images = self._load_frames(image_files, t_start)
history = torch.tensor(self._norm(
actions[t_start - self.history_len: t_start]), dtype=torch.float32)
target = torch.tensor(self._step_target(actions, t_start),
dtype=torch.float32)
return {"images": images, "action_history": history, "target": target}
# 多步:返回 K 步序列,scheduled sampling 在 train 循环内按步取用
steps = []
for k in range(self.rollout_len):
t = t_start + k
steps.append({
"images": self._load_frames(image_files, t),
# 真实历史(scheduled sampling 的真值来源;首步必用)
"action_history": torch.tensor(
self._norm(actions[t - self.history_len: t]), dtype=torch.float32),
"target": torch.tensor(self._step_target(actions, t), dtype=torch.float32),
# 上一步真实动作(Δ 残差的基准 a_{t-1},归一化空间)
"prev_action": torch.tensor(self._norm(actions[t - 1]), dtype=torch.float32),
})
return {"steps": steps}
def get_action_stats(self):
if self._action_mean is not None:
return np.stack([self._action_mean, self._action_std])
return None
@staticmethod
def _valid_episode_dirs(data_dir: str) -> list:
data_path = Path(data_dir)
return sorted([d for d in data_path.iterdir()
if d.is_dir() and (d / "actions.npy").exists() and (d / "images").is_dir()])
@staticmethod
def get_episode_count(data_dir: str) -> int:
# 与 __init__ 的过滤条件保持一致,避免 split_episodes 索引错位
return len(ActionDataset._valid_episode_dirs(data_dir))
class DataRecorder:
def __init__(self, output_dir: str, action_dim: int):
self.output_dir = Path(output_dir)
self.action_dim = action_dim
self._frame_idx = 0
self._actions = []
ep_idx = 0
for d in self.output_dir.glob("episode_*"):
try:
ep_idx = max(ep_idx, int(d.name.split("_")[1]) + 1)
except (ValueError, IndexError):
pass
# 原子创建目录,避免并发 Recorder 分配到同一 episode
while True:
self.episode_dir = self.output_dir / 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
def record(self, image: np.ndarray, action: np.ndarray):
if action.shape != (self.action_dim,):
raise ValueError(
f"action 维度应为 ({self.action_dim},),实际为 {action.shape}")
img = Image.fromarray(image)
img.save(self.images_dir / f"{self._frame_idx:04d}.jpg", quality=90)
self._actions.append(action.astype(np.float32))
self._frame_idx += 1
def flush(self):
if self._actions:
actions = np.array(self._actions, dtype=np.float32)
np.save(self.episode_dir / "actions.npy", actions)
return str(self.episode_dir)