| |
| """ |
| data.py (stage3 / Flow Matching — Pi0.5 风格) |
| ============================================== |
| 多数据集 LeRobot 格式数据加载器 (兼容 v2.0 和 v3.0),支持: |
| - 多数据集加权混合采样 (BridgeData / DROID / Libero / 自有数据) |
| - 机器人状态 (proprioception) 读取与归一化 |
| - Action chunking (K=16) |
| - 多相机输入 (base_rgb + wrist_rgb) |
| - 域随机化 (Domain Randomization) 缓解背景过拟合 |
| - 标准 LeRobot 命名 与 HuggingFace LeRobot 下载的 "data__chunk__file" 命名兼容 |
| - 缺视频时自动回退到占位图,避免训练中断 |
| |
| v2.0 vs v3.0: |
| v2.0: 1 parquet = 1 episode, 视频 episode_*.mp4, task_index + tasks.jsonl |
| v3.0: 1 parquet = N episodes (is_first/is_last 分界), 视频 file-*.mp4, |
| 多 camera keys, language_instruction 列直接读指令 |
| |
| 接口契约 |
| ======== |
| build_dataloader(config) -> (train_loader, val_loader) |
| |
| 每个 batch 必须包含: |
| { |
| "image": List[PIL.Image] 或 List[List[PIL.Image]], |
| "instruction": List[str], |
| "state": torch.Tensor, # (B, state_dim) 或 None |
| "action": torch.Tensor, # (B, action_dim) |
| "action_chunk": torch.Tensor, # (B, K, action_dim) |
| "raw_action": torch.Tensor, # (B, action_dim) |
| "frame_index": List[int], |
| "episode_index": List[int], |
| "dataset_name": List[str], # 可选 |
| } |
| |
| config 示例见 config_flow.yaml;data.py 新增字段: |
| data: |
| datasets: |
| - name: bridge |
| dir: /mnt/workspace/Dataset/stage_frozen01/full |
| weight: 0.4 |
| - name: droid |
| dir: /mnt/workspace/Dataset/droid/full |
| weight: 0.2 |
| camera_keys: [observation.images.exterior_1_left, ...] |
| val_ratio: 0.05 |
| action_chunk_size: 16 |
| action_dim: 7 |
| state_dim: 7 |
| domain_randomization: true |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import glob |
| import os |
| import random |
| import threading |
| from pathlib import Path |
| from typing import Any, Callable, Dict, List, Optional, Tuple, Union |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| from PIL import Image |
| from torch.utils.data import ( |
| ConcatDataset, DataLoader, Dataset, WeightedRandomSampler, |
| ) |
| try: |
| from .utils.chunking import TemporalEnsembler |
| HAS_CHUNKING = True |
| except ImportError: |
| HAS_CHUNKING = False |
|
|
| try: |
| from torchvision import transforms |
| except ModuleNotFoundError: |
| transforms = None |
|
|
| |
| try: |
| import decord |
| decord.bridge.set_bridge("torch") |
| HAS_DECORD = True |
| except Exception: |
| HAS_DECORD = False |
| try: |
| import imageio |
| except Exception: |
| imageio = None |
|
|
| logger = __import__("logging").getLogger("LingArm.stage3") |
|
|
|
|
| |
| |
| |
| class VideoCache: |
| """按 episode 缓存解码后的视频帧 (LRU)。""" |
|
|
| def __init__(self, max_cached_episodes: int = 200): |
| self.cache: Dict[str, np.ndarray] = {} |
| self.access_order: List[str] = [] |
| self.max_cached = max_cached_episodes |
| self.lock = threading.Lock() |
|
|
| def __getstate__(self): |
| state = self.__dict__.copy() |
| state.pop("lock", None) |
| state["cache"] = {} |
| state["access_order"] = [] |
| return state |
|
|
| def __setstate__(self, state): |
| self.__dict__.update(state) |
| self.lock = threading.Lock() |
|
|
| def get_frame( |
| self, |
| video_path: str, |
| frame_idx: int, |
| ) -> np.ndarray: |
| with self.lock: |
| if video_path not in self.cache: |
| self._load_video(video_path) |
| else: |
| self.access_order.remove(video_path) |
| self.access_order.append(video_path) |
| frames = self.cache[video_path] |
| if frame_idx >= len(frames): |
| frame_idx = len(frames) - 1 |
| return frames[frame_idx] |
|
|
| def _load_video(self, video_path: str): |
| while len(self.cache) >= self.max_cached and self.access_order: |
| old = self.access_order.pop(0) |
| self.cache.pop(old, None) |
|
|
| if not os.path.exists(video_path): |
| raise FileNotFoundError(f"Video not found: {video_path}") |
|
|
| if HAS_DECORD: |
| vr = decord.VideoReader(video_path) |
| frames = vr.get_batch(list(range(len(vr)))) |
| |
| if hasattr(frames, 'numpy'): |
| self.cache[video_path] = frames.numpy() |
| else: |
| self.cache[video_path] = frames.asnumpy() |
| elif imageio is not None: |
| reader = imageio.get_reader(video_path) |
| self.cache[video_path] = np.array([ |
| reader.get_data(i) for i in range(reader.count_frames()) |
| ]) |
| reader.close() |
| else: |
| raise RuntimeError( |
| "Need decord or imageio to read videos. " |
| "Install: pip install decord" |
| ) |
| self.access_order.append(video_path) |
|
|
|
|
| |
| |
| |
| class DummyImageCache: |
| """为没有视频的样本生成一张随机纹理占位图。""" |
|
|
| def __init__(self, size: int = 224): |
| self.size = size |
| self._cache: Dict[Tuple[int, int], Image.Image] = {} |
|
|
| def get(self, seed: int = 0) -> Image.Image: |
| key = (self.size, seed) |
| if key not in self._cache: |
| rng = np.random.default_rng(seed) |
| arr = rng.integers(0, 255, (self.size, self.size, 3), dtype=np.uint8) |
| self._cache[key] = Image.fromarray(arr).convert("RGB") |
| return self._cache[key] |
|
|
|
|
| DUMMY_IMAGE = DummyImageCache() |
|
|
|
|
| |
| |
| |
| class LeRobotDataset(Dataset): |
| """ |
| LeRobot 格式数据集加载器 (stage3), 兼容 v2.0 和 v3.0。 |
| |
| v2.0: 1 parquet = 1 episode, 视频 episode_*.mp4, task_index → tasks.jsonl |
| v3.0: 1 parquet = N episodes (is_first/is_last 分界), |
| 视频 file-*.mp4, videos/{cam}/chunk-*/ 路径, |
| language_instruction 列直接读取任务指令 |
| |
| Args: |
| data_dir: 数据集根目录 (含 meta/info.json) |
| dataset_name: 数据集名称 (用于日志/加权) |
| action_dim: 目标动作维度 (默认 7) |
| state_dim: 目标状态维度 (默认 7) |
| action_chunk_size: K (默认 16) |
| image_size: 图像目标尺寸 (PIL resize;最终由 processor 决定) |
| action_normalize: 是否 z-score 标准化 action |
| state_normalize: 是否 z-score 标准化 state 后 clip 到 [-1,1] |
| state_low/state_high: state clip 范围 |
| frame_sampling: "all" | "uniform" |
| max_frames_per_episode: uniform 采样时最大帧数 |
| use_processor: True 返回 PIL.Image,False 返回 tensor |
| camera_keys: 多相机 key 列表 |
| dummy_image_on_missing_video: 缺视频是否用占位图 |
| augment: 是否启用域随机化 |
| action_adapter: 可选动作转换函数 |
| state_adapter: 可选状态转换函数 |
| val_episode_ids: 若提供,仅使用这些 episode (验证集) |
| """ |
|
|
| def __init__( |
| self, |
| data_dir: str, |
| dataset_name: str = "lerobot", |
| action_dim: int = 7, |
| state_dim: int = 7, |
| action_chunk_size: int = 16, |
| image_size: int = 224, |
| action_normalize: bool = True, |
| state_normalize: bool = True, |
| state_low: float = -1.0, |
| state_high: float = 1.0, |
| frame_sampling: str = "all", |
| max_frames_per_episode: Optional[int] = None, |
| use_processor: bool = True, |
| max_cache_episodes: int = 200, |
| camera_keys: Optional[List[str]] = None, |
| dummy_image_on_missing_video: bool = True, |
| augment: bool = True, |
| action_adapter: Optional[Callable[[np.ndarray, str], np.ndarray]] = None, |
| state_adapter: Optional[Callable[[np.ndarray, str], np.ndarray]] = None, |
| val_episode_ids: Optional[set] = None, |
| skip_stats_compute: Optional[bool] = None, |
| ): |
| self.data_dir = Path(data_dir) |
| self.dataset_name = dataset_name |
| self.action_dim = action_dim |
| self.state_dim = state_dim |
| self.action_chunk_size = action_chunk_size |
| self.image_size = image_size |
| self.action_normalize = action_normalize |
| self.state_normalize = state_normalize |
| self.state_low = state_low |
| self.state_high = state_high |
| self.frame_sampling = frame_sampling |
| self.max_frames_per_episode = max_frames_per_episode |
| self.use_processor = use_processor |
| self.camera_keys = camera_keys or ["observation.images.image_0"] |
| self.dummy_image_on_missing_video = dummy_image_on_missing_video |
| self.augment = augment |
| self.action_adapter = action_adapter |
| self.state_adapter = state_adapter |
| self.val_episode_ids = val_episode_ids |
|
|
| |
| self.codebase_version = self._detect_version() |
| logger.info("[Dataset %s] codebase_version=%s", dataset_name, self.codebase_version) |
|
|
| |
| self.tasks = self._load_tasks() |
|
|
| |
| self.episodes = self._build_index() |
| if not self.episodes: |
| raise RuntimeError(f"No valid episodes found in {data_dir}") |
|
|
| |
| if skip_stats_compute is None: |
| skip_stats_compute = (val_episode_ids is not None) |
| if skip_stats_compute: |
| self.action_mean = self.action_std = None |
| self.state_mean = self.state_std = None |
| else: |
| self.action_mean, self.action_std = self._compute_action_stats() |
| self.state_mean, self.state_std = self._compute_state_stats() |
|
|
| |
| self.video_cache = VideoCache(max_cached_episodes=max_cache_episodes) |
|
|
| |
| if not use_processor: |
| if transforms is None: |
| raise ImportError("torchvision is required when use_processor=False") |
| self.image_transform = transforms.Compose([ |
| transforms.Resize((image_size, image_size)), |
| transforms.ToTensor(), |
| transforms.Normalize( |
| mean=[0.485, 0.456, 0.406], |
| std=[0.229, 0.224, 0.225], |
| ), |
| ]) |
| else: |
| self.image_transform = None |
|
|
| |
| self.aug_transform = None |
| if augment and transforms is not None: |
| self.aug_transform = transforms.Compose([ |
| transforms.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.15, hue=0.05), |
| transforms.RandomGrayscale(p=0.05), |
| ]) |
|
|
| |
| self.hflip_aug = False |
| self.hflip_prob = 0.5 |
|
|
| |
| self.perspective_aug = augment |
| self.perspective_distortion = 0.15 |
|
|
| total_frames = sum(len(ep["frame_indices"]) for ep in self.episodes) |
| logger.info( |
| "[Dataset %s] episodes=%d frames=%d action_dim=%d state_dim=%d cameras=%d", |
| dataset_name, len(self.episodes), total_frames, |
| self.action_dim, self.state_dim, len(self.camera_keys), |
| ) |
|
|
| |
| |
| |
| def _detect_version(self) -> str: |
| """从 meta/info.json 读取 codebase_version,回退到 v2.0。""" |
| for candidate in ["meta/info.json", "info.json"]: |
| info_path = self.data_dir / candidate |
| if info_path.exists(): |
| try: |
| with open(info_path, "r", encoding="utf-8") as f: |
| info = json.load(f) |
| return info.get("codebase_version", "v2.0") |
| except Exception: |
| pass |
| return "v2.0" |
|
|
| |
| |
| |
| def _load_tasks(self) -> Dict[int, str]: |
| """加载任务指令表。v3.0 返回空 dict (指令从 parquet language_instruction 列读取)。""" |
| if self.codebase_version == "v3.0": |
| return {} |
|
|
| tasks: Dict[int, str] = {} |
|
|
| |
| jsonl_path = self.data_dir / "meta" / "tasks.jsonl" |
| if jsonl_path.exists(): |
| with open(jsonl_path, "r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| obj = json.loads(line) |
| tasks[obj["task_index"]] = obj.get("task", "") |
| return tasks |
|
|
| |
| parquet_paths = list(self.data_dir.glob("meta__tasks*.parquet")) |
| if not parquet_paths: |
| parquet_paths = list(self.data_dir.glob("*/meta__tasks*.parquet")) |
| for p in parquet_paths: |
| try: |
| df = pd.read_parquet(p) |
| if "task_index" not in df.columns: |
| continue |
| has_task_col = "task" in df.columns |
| for idx, row in df.iterrows(): |
| ti = int(row["task_index"]) |
| task_text = str(row["task"]) if has_task_col else ( |
| str(idx) if isinstance(idx, str) else "") |
| tasks[ti] = task_text |
| return tasks |
| except Exception as e: |
| logger.warning("[Dataset %s] failed to read task parquet %s: %s", |
| self.dataset_name, p, e) |
|
|
| return tasks |
|
|
| |
| |
| |
| def _find_parquet_files(self) -> List[Path]: |
| patterns = [ |
| "data/chunk-*/episode_*.parquet", |
| "data/chunk-*/file-*.parquet", |
| "data__chunk-*__file-*.parquet", |
| "*/data__chunk-*__file-*.parquet", |
| ] |
| files: List[Path] = [] |
| for pat in patterns: |
| files.extend(self.data_dir.glob(pat)) |
| return sorted(set(files)) |
|
|
| def _build_index(self) -> List[Dict[str, Any]]: |
| episodes = [] |
| for pf in self._find_parquet_files(): |
| try: |
| df = pd.read_parquet(pf) |
| except Exception as e: |
| logger.warning("[Dataset %s] skip parquet %s: %s", self.dataset_name, pf, e) |
| continue |
|
|
| if "action" not in df.columns: |
| logger.warning("[Dataset %s] skip %s: no action column", self.dataset_name, pf) |
| continue |
|
|
| if len(df) == 0: |
| continue |
|
|
| if self.codebase_version == "v3.0": |
| episodes.extend(self._build_index_v3(pf, df)) |
| else: |
| episodes.extend(self._build_index_v2(pf, df)) |
|
|
| return episodes |
|
|
| def _build_index_v2(self, pf: Path, df: pd.DataFrame) -> List[Dict[str, Any]]: |
| """v2.0: 1 parquet = 1 episode。""" |
| n_frames = len(df) |
|
|
| if "episode_index" in df.columns: |
| ep_idx = int(df["episode_index"].iloc[0]) |
| else: |
| ep_idx = 0 |
|
|
| if self.val_episode_ids is not None and ep_idx not in self.val_episode_ids: |
| return [] |
|
|
| chunk_name, video_stem = self._video_location(pf) |
| video_paths_per_cam, has_any_video = self._resolve_video_paths_v2(chunk_name, video_stem) |
|
|
| task_index = int(df["task_index"].iloc[0]) if "task_index" in df.columns else 0 |
| instruction = self.tasks.get(task_index, "") |
|
|
| frame_indices = self._sample_frame_indices(n_frames, range(n_frames)) |
| raw_frame_indices = ( |
| df["frame_index"].tolist() if "frame_index" in df.columns |
| else list(range(n_frames)) |
| ) |
|
|
| actions_raw = self._extract_actions(df) |
| states_raw = self._extract_states(df) |
|
|
| return [{ |
| "parquet": pf, |
| "video_paths": video_paths_per_cam, |
| "has_video": has_any_video, |
| "n_frames": n_frames, |
| "frame_indices": frame_indices, |
| "instruction": instruction, |
| "task_index": task_index, |
| "episode_index": ep_idx, |
| "actions_raw": actions_raw, |
| "states_raw": states_raw, |
| "raw_frame_indices": raw_frame_indices, |
| "frame_offset": 0, |
| }] |
|
|
| def _build_index_v3(self, pf: Path, df: pd.DataFrame) -> List[Dict[str, Any]]: |
| """v3.0: 1 parquet = N episodes, 按 is_first/is_last 拆分。""" |
| if "is_first" not in df.columns or "is_last" not in df.columns: |
| logger.warning( |
| "[Dataset %s] v3.0 parquet lacks is_first/is_last: %s, " |
| "treating as single episode", self.dataset_name, pf |
| ) |
| return self._build_index_v2(pf, df) |
|
|
| n_total = len(df) |
| first_rows = df.index[df["is_first"] == True].tolist() |
| last_rows = df.index[df["is_last"] == True].tolist() |
|
|
| if not first_rows: |
| first_rows = [0] |
| if not last_rows: |
| last_rows = [n_total - 1] |
| if len(last_rows) < len(first_rows): |
| last_rows.append(n_total - 1) |
|
|
| chunk_name, video_stem = self._video_location(pf) |
| video_paths_per_cam, has_any_video = self._resolve_video_paths_v3(chunk_name, video_stem) |
|
|
| episodes = [] |
| for ep_i, (start_row, end_row) in enumerate(zip(first_rows, last_rows)): |
| if end_row < start_row: |
| continue |
| ep_df = df.loc[start_row:end_row] |
| n_frames = len(ep_df) |
| if n_frames == 0: |
| continue |
|
|
| |
| if "episode_index" in ep_df.columns: |
| ep_idx = int(ep_df["episode_index"].iloc[0]) |
| else: |
| ep_idx = ep_i |
|
|
| if self.val_episode_ids is not None and ep_idx not in self.val_episode_ids: |
| continue |
|
|
| |
| if "language_instruction" in ep_df.columns: |
| instruction = str(ep_df["language_instruction"].iloc[0]) |
| else: |
| instruction = "" |
|
|
| |
| frame_indices = self._sample_frame_indices(n_frames, range(n_frames)) |
|
|
| |
| if "frame_index" in ep_df.columns: |
| raw_frame_indices = ep_df["frame_index"].tolist() |
| else: |
| raw_frame_indices = list(range(n_frames)) |
|
|
| frame_offset = int(raw_frame_indices[0]) if raw_frame_indices else 0 |
|
|
| actions_raw = self._extract_actions(ep_df) |
| states_raw = self._extract_states(ep_df) |
|
|
| episodes.append({ |
| "parquet": pf, |
| "video_paths": video_paths_per_cam, |
| "has_video": has_any_video, |
| "n_frames": n_frames, |
| "frame_indices": frame_indices, |
| "instruction": instruction, |
| "task_index": 0, |
| "episode_index": ep_idx, |
| "actions_raw": actions_raw, |
| "states_raw": states_raw, |
| "raw_frame_indices": raw_frame_indices, |
| "frame_offset": frame_offset, |
| }) |
| return episodes |
|
|
| |
| |
| |
| def _video_location(self, parquet_path: Path) -> Tuple[str, str]: |
| """从 parquet 路径推断 chunk 名和视频 stem。""" |
| stem = parquet_path.stem |
| if stem.startswith("data__"): |
| parts = stem.split("__") |
| return parts[1], parts[2] |
| else: |
| chunk_name = parquet_path.parent.name |
| return chunk_name, stem |
|
|
| def _resolve_video_paths_v2( |
| self, chunk_name: str, video_stem: str |
| ) -> Tuple[Dict[str, Optional[str]], bool]: |
| """v2.0 视频路径: videos/<chunk>/<cam>/<stem>.mp4""" |
| video_paths: Dict[str, Optional[str]] = {} |
| has_any = False |
| for cam_key in self.camera_keys: |
| cam_dir = cam_key.replace(".", "_") if "." in cam_key else cam_key |
| candidates = [ |
| self.data_dir / "videos" / chunk_name / cam_key / f"{video_stem}.mp4", |
| self.data_dir / "videos" / chunk_name / cam_dir / f"{video_stem}.mp4", |
| ] |
| found = None |
| for vp in candidates: |
| if vp.exists(): |
| found = str(vp) |
| break |
| video_paths[cam_key] = found |
| if found: |
| has_any = True |
| return video_paths, has_any |
|
|
| def _resolve_video_paths_v3( |
| self, chunk_name: str, video_stem: str |
| ) -> Tuple[Dict[str, Optional[str]], bool]: |
| """v3.0 视频路径: videos/<cam_key>/<chunk>/<stem>.mp4 |
| |
| DROID v3.0 使用 videos/{video_key}/chunk-*/file-*.mp4 格式。 |
| 尝试多种路径以兼容不同下载布局。 |
| """ |
| video_paths: Dict[str, Optional[str]] = {} |
| has_any = False |
| for cam_key in self.camera_keys: |
| candidates = [ |
| |
| self.data_dir / "videos" / cam_key / chunk_name / f"{video_stem}.mp4", |
| |
| self.data_dir / "videos" / chunk_name / cam_key / f"{video_stem}.mp4", |
| |
| self.data_dir / "videos" / chunk_name / cam_key.replace(".", "_") / f"{video_stem}.mp4", |
| ] |
| found = None |
| for vp in candidates: |
| if vp.exists(): |
| found = str(vp) |
| break |
| video_paths[cam_key] = found |
| if found: |
| has_any = True |
| return video_paths, has_any |
|
|
| |
| |
| |
| def _sample_frame_indices( |
| self, n_frames: int, src_indices: Any |
| ) -> List[int]: |
| src = list(src_indices) |
| if self.frame_sampling == "uniform" and self.max_frames_per_episode: |
| if n_frames <= self.max_frames_per_episode: |
| return src |
| else: |
| return np.linspace(0, n_frames - 1, self.max_frames_per_episode, dtype=int).tolist() |
| return src |
|
|
| def _extract_actions(self, df: pd.DataFrame) -> np.ndarray: |
| arr = np.array(df["action"].tolist(), dtype=np.float32) |
| if self.action_adapter is not None: |
| arr = np.stack([self.action_adapter(a, self.dataset_name) for a in arr]) |
| else: |
| arr = self._adapt_dim_batch(arr, self.action_dim, "action") |
| return arr |
|
|
| def _extract_states(self, df: pd.DataFrame) -> Optional[np.ndarray]: |
| if "observation.state" not in df.columns: |
| return None |
| arr = np.array(df["observation.state"].tolist(), dtype=np.float32) |
| if self.state_adapter is not None: |
| arr = np.stack([self.state_adapter(s, self.dataset_name) for s in arr]) |
| else: |
| arr = self._adapt_dim_batch(arr, self.state_dim, "state") |
| return arr |
|
|
| |
| |
| |
| def _compute_action_stats(self) -> Tuple[np.ndarray, np.ndarray]: |
| """批量 Welford 在线算法。""" |
| dim = self.action_dim |
| count = 0 |
| mean = np.zeros(dim, dtype=np.float64) |
| m2 = np.zeros(dim, dtype=np.float64) |
|
|
| for ep in self.episodes: |
| acts = ep["actions_raw"].astype(np.float64) |
| n = len(acts) |
| if n == 0: |
| continue |
| if count == 0: |
| mean = acts.mean(axis=0) |
| m2 = acts.var(axis=0) * n |
| count = n |
| else: |
| batch_mean = acts.mean(axis=0) |
| batch_var = acts.var(axis=0) |
| delta = batch_mean - mean |
| new_count = count + n |
| new_mean = mean + delta * n / new_count |
| m2 = m2 + batch_var * n + (delta ** 2) * count * n / new_count |
| mean = new_mean |
| count = new_count |
|
|
| if count < 2: |
| return np.zeros(dim, dtype=np.float32), np.ones(dim, dtype=np.float32) |
|
|
| mean = mean.astype(np.float32) |
| std = np.sqrt(m2 / count).astype(np.float32) |
| std = np.where(std < 1e-6, 1.0, std) |
| return mean, std |
|
|
| def _compute_state_stats(self) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: |
| """批量 Welford 在线算法计算 state 统计。""" |
| dim = self.state_dim |
| count = 0 |
| mean = np.zeros(dim, dtype=np.float64) |
| m2 = np.zeros(dim, dtype=np.float64) |
|
|
| for ep in self.episodes: |
| if ep["states_raw"] is None: |
| continue |
| states = ep["states_raw"].astype(np.float64) |
| n = len(states) |
| if n == 0: |
| continue |
| if count == 0: |
| mean = states.mean(axis=0) |
| m2 = states.var(axis=0) * n |
| count = n |
| else: |
| batch_mean = states.mean(axis=0) |
| batch_var = states.var(axis=0) |
| delta = batch_mean - mean |
| new_count = count + n |
| new_mean = mean + delta * n / new_count |
| m2 = m2 + batch_var * n + (delta ** 2) * count * n / new_count |
| mean = new_mean |
| count = new_count |
|
|
| if count < 2: |
| return None, None |
|
|
| mean = mean.astype(np.float32) |
| std = np.sqrt(m2 / count).astype(np.float32) |
| std = np.where(std < 1e-6, 1.0, std) |
| return mean, std |
|
|
| def set_stats( |
| self, |
| action_mean: Optional[np.ndarray], |
| action_std: Optional[np.ndarray], |
| state_mean: Optional[np.ndarray], |
| state_std: Optional[np.ndarray], |
| ): |
| """验证集复用训练集统计量。""" |
| self.action_mean = action_mean |
| self.action_std = action_std |
| self.state_mean = state_mean |
| self.state_std = state_std |
|
|
| |
| |
| |
| def _adapt_action(self, raw: np.ndarray) -> np.ndarray: |
| if self.action_adapter is not None: |
| return self.action_adapter(raw, self.dataset_name) |
| return self._pad_or_truncate(raw, self.action_dim, "action") |
|
|
| def _adapt_state(self, raw: np.ndarray) -> np.ndarray: |
| if self.state_adapter is not None: |
| return self.state_adapter(raw, self.dataset_name) |
| return self._pad_or_truncate(raw, self.state_dim, "state") |
|
|
| def _pad_or_truncate(self, vec: np.ndarray, target_dim: int, name: str) -> np.ndarray: |
| vec = np.asarray(vec, dtype=np.float32) |
| d = vec.shape[0] |
| if d == target_dim: |
| return vec |
| if d > target_dim: |
| logger.warning( |
| "[Dataset %s] %s dim %d > target %d, truncating first %d dims", |
| self.dataset_name, name, d, target_dim, target_dim, |
| ) |
| return vec[:target_dim] |
| logger.warning( |
| "[Dataset %s] %s dim %d < target %d, zero-padding", |
| self.dataset_name, name, d, target_dim, |
| ) |
| out = np.zeros(target_dim, dtype=np.float32) |
| out[:d] = vec |
| return out |
|
|
| def _adapt_dim_batch(self, arr: np.ndarray, target_dim: int, name: str) -> np.ndarray: |
| """批量 pad/truncate 到目标维度。""" |
| d = arr.shape[1] |
| if d == target_dim: |
| return arr |
| if d > target_dim: |
| logger.warning( |
| "[Dataset %s] %s dim %d > target %d, truncating (batch)", |
| self.dataset_name, name, d, target_dim, |
| ) |
| return arr[:, :target_dim] |
| logger.warning( |
| "[Dataset %s] %s dim %d < target %d, zero-padding (batch)", |
| self.dataset_name, name, d, target_dim, |
| ) |
| pad = np.zeros((arr.shape[0], target_dim - d), dtype=arr.dtype) |
| return np.concatenate([arr, pad], axis=1) |
|
|
| |
| |
| |
| def _read_images(self, ep: Dict[str, Any], frame_idx: int) -> List[Image.Image]: |
| images: List[Image.Image] = [] |
| seed = int(ep["episode_index"] * 100000 + frame_idx) |
| |
| actual_frame = frame_idx + ep.get("frame_offset", 0) |
| for cam_key in self.camera_keys: |
| vp = ep["video_paths"].get(cam_key) |
| if vp and os.path.exists(vp): |
| frame = self.video_cache.get_frame(vp, actual_frame) |
| img = Image.fromarray(frame).convert("RGB") |
| elif self.dummy_image_on_missing_video: |
| img = DUMMY_IMAGE.get(seed).copy() |
| seed += 1 |
| else: |
| raise FileNotFoundError(f"Missing video for camera {cam_key}: {vp}") |
|
|
| if img.size[0] != self.image_size or img.size[1] != self.image_size: |
| img = img.resize((self.image_size, self.image_size), Image.BILINEAR) |
|
|
| if self.augment and self.aug_transform is not None and random.random() < 0.5: |
| img = self.aug_transform(img) |
|
|
| if self.perspective_aug and random.random() < 0.3: |
| import torchvision.transforms.functional as TF |
| w, h = img.size |
| max_off = int(w * self.perspective_distortion) |
| startpoints = [[0, 0], [w, 0], [w, h], [0, h]] |
| endpoints = [ |
| [random.randint(-max_off, max_off), random.randint(-max_off, max_off)], |
| [w + random.randint(-max_off, max_off), random.randint(-max_off, max_off)], |
| [w + random.randint(-max_off, max_off), h + random.randint(-max_off, max_off)], |
| [random.randint(-max_off, max_off), h + random.randint(-max_off, max_off)], |
| ] |
| img = TF.perspective(img, startpoints, endpoints, interpolation=Image.BILINEAR) |
|
|
| images.append(img) |
|
|
| return images |
|
|
| |
| |
| |
| def __len__(self) -> int: |
| return sum(len(ep["frame_indices"]) for ep in self.episodes) |
|
|
| def __getitem__(self, idx: int) -> Dict[str, Any]: |
| cumulative = 0 |
| for ep in self.episodes: |
| n = len(ep["frame_indices"]) |
| if idx < cumulative + n: |
| frame_idx = ep["frame_indices"][idx - cumulative] |
| break |
| cumulative += n |
| else: |
| raise IndexError(f"Index {idx} out of range") |
|
|
| images = self._read_images(ep, frame_idx) |
| instruction = ep["instruction"] |
|
|
| do_hflip = False |
| if self.hflip_aug and random.random() < self.hflip_prob: |
| do_hflip = True |
| images = [img.transpose(Image.FLIP_LEFT_RIGHT) for img in images] |
| instruction = instruction.replace("left", "\x00L\x00").replace("right", "left").replace("\x00L\x00", "right") |
|
|
| raw_action = ep["actions_raw"][frame_idx].copy() |
| if do_hflip and raw_action.shape[0] >= 6: |
| raw_action[0] = -raw_action[0] |
| raw_action[1] = -raw_action[1] |
| raw_action[3] = -raw_action[3] |
| raw_action[4] = -raw_action[4] |
|
|
| norm_action = raw_action.copy() |
| if self.action_normalize and self.action_mean is not None: |
| norm_action = (raw_action - self.action_mean) / self.action_std |
|
|
| if ep["states_raw"] is not None: |
| raw_state = ep["states_raw"][frame_idx].copy() |
| if do_hflip and raw_state.shape[0] >= 6: |
| raw_state[0] = -raw_state[0] |
| raw_state[1] = -raw_state[1] |
| if raw_state.shape[0] >= 6: |
| raw_state[3] = -raw_state[3] |
| raw_state[4] = -raw_state[4] |
| norm_state = raw_state.copy() |
| if self.state_normalize and self.state_mean is not None: |
| norm_state = (raw_state - self.state_mean) / self.state_std |
| norm_state = np.clip(norm_state, self.state_low, self.state_high) |
| else: |
| norm_state = np.zeros(self.state_dim, dtype=np.float32) |
|
|
| ep_actions = ep["actions_raw"] |
| if self.action_normalize and self.action_mean is not None: |
| ep_actions = (ep_actions - self.action_mean) / self.action_std |
|
|
| if do_hflip and ep_actions.shape[1] >= 6: |
| ep_actions = ep_actions.copy() |
| ep_actions[:, 0] = -ep_actions[:, 0] |
| ep_actions[:, 1] = -ep_actions[:, 1] |
| ep_actions[:, 3] = -ep_actions[:, 3] |
| ep_actions[:, 4] = -ep_actions[:, 4] |
|
|
| T, D = ep_actions.shape |
| K = self.action_chunk_size |
| if T < K: |
| ep_actions = np.concatenate([ep_actions, np.tile(ep_actions[-1:], (K - T, 1))], axis=0) |
| T = K |
| padded = np.concatenate([ep_actions, np.tile(ep_actions[-1:], (K - 1, 1))], axis=0) |
| chunks = np.lib.stride_tricks.sliding_window_view(padded, (K, D))[:, 0, :, :] |
| action_chunk = chunks[frame_idx].copy() |
|
|
| if not self.use_processor: |
| images = torch.stack([self.image_transform(img) for img in images]) |
|
|
| return { |
| "image": images, |
| "instruction": instruction, |
| "state": torch.from_numpy(norm_state).float(), |
| "action": torch.from_numpy(norm_action).float(), |
| "action_chunk": torch.from_numpy(action_chunk).float(), |
| "raw_action": torch.from_numpy(raw_action).float(), |
| "frame_index": ep["raw_frame_indices"][frame_idx], |
| "episode_index": ep["episode_index"], |
| "dataset_name": self.dataset_name, |
| } |
|
|
|
|
| |
| |
| |
| def collate_fn(batch: List[Dict[str, Any]]) -> Dict[str, Any]: |
| images = [b["image"] for b in batch] |
|
|
| if isinstance(images[0], list): |
| max_cam = max(len(imgs) for imgs in images) |
| if max_cam > 1: |
| for i in range(len(images)): |
| if len(images[i]) < max_cam: |
| images[i] = images[i] + [images[i][0]] * (max_cam - len(images[i])) |
| elif isinstance(images[0], torch.Tensor): |
| max_cam = max(imgs.shape[0] for imgs in images) |
| if max_cam > 1: |
| for i in range(len(images)): |
| if images[i].shape[0] < max_cam: |
| rep = images[i][0:1].repeat(max_cam - images[i].shape[0], 1, 1, 1) |
| images[i] = torch.cat([images[i], rep], dim=0) |
| images = torch.stack(images) |
| B, N, C, H, W = images.shape |
| images = images.view(B * N, C, H, W) |
|
|
| states = torch.stack([b["state"] for b in batch]) |
| actions = torch.stack([b["action"] for b in batch]) |
| action_chunks = torch.stack([b["action_chunk"] for b in batch]) |
| raw_actions = torch.stack([b["raw_action"] for b in batch]) |
|
|
| return { |
| "image": images, |
| "instruction": [b["instruction"] for b in batch], |
| "state": states, |
| "action": actions, |
| "action_chunk": action_chunks, |
| "raw_action": raw_actions, |
| "frame_index": [b["frame_index"] for b in batch], |
| "episode_index": [b["episode_index"] for b in batch], |
| "dataset_name": [b["dataset_name"] for b in batch], |
| } |
|
|
|
|
| |
| |
| |
| def build_dataloader(config: dict) -> Tuple[DataLoader, Optional[DataLoader]]: |
| """从 config 构建 (train_loader, val_loader)。""" |
| data_cfg = config.get("data", {}) |
| model_cfg = config.get("model", {}) |
| train_cfg = config.get("training", {}) |
|
|
| action_dim = data_cfg.get("action_dim", model_cfg.get("action_dim", 7)) |
| state_dim = data_cfg.get("state_dim", model_cfg.get("state_dim", 7)) |
| action_chunk_size = data_cfg.get("action_chunk_size", model_cfg.get("action_chunk_size", 16)) |
| image_size = data_cfg.get("image_size", 224) |
| use_processor = model_cfg.get("use_processor", True) |
| val_ratio = data_cfg.get("val_ratio", 0.0) |
| augment = data_cfg.get("domain_randomization", True) |
| num_workers = data_cfg.get("num_workers", 1) |
| pin_memory = data_cfg.get("pin_memory", True) |
|
|
| dataset_configs = data_cfg.get("datasets", []) |
| if not dataset_configs: |
| dataset_configs = [{ |
| "name": "single", |
| "dir": config.get("data_dir", ""), |
| "weight": 1.0, |
| }] |
|
|
| action_adapter = data_cfg.get("action_adapter", None) |
| state_adapter = data_cfg.get("state_adapter", None) |
|
|
| train_dataset_list: List[LeRobotDataset] = [] |
| val_dataset_list: List[LeRobotDataset] = [] |
|
|
| for ds_cfg in dataset_configs: |
| ds_dir = ds_cfg["dir"] |
| ds_name = ds_cfg.get("name", Path(ds_dir).name) |
| if not ds_dir or not Path(ds_dir).exists(): |
| logger.warning("[build_dataloader] dataset dir not found: %s", ds_dir) |
| continue |
|
|
| all_episodes = _collect_episode_indices(ds_dir) |
| if not all_episodes: |
| logger.warning("[build_dataloader] no episodes in %s", ds_dir) |
| continue |
|
|
| if val_ratio > 0 and len(all_episodes) > 1: |
| n_val = max(1, int(len(all_episodes) * val_ratio)) |
| split_rng = random.Random(config.get("seed", 42)) |
| val_ids = set(split_rng.sample(sorted(all_episodes), n_val)) |
| train_ids = all_episodes - val_ids |
| else: |
| val_ids = None |
| train_ids = all_episodes |
|
|
| common_kwargs = { |
| "data_dir": ds_dir, |
| "dataset_name": ds_name, |
| "action_dim": action_dim, |
| "state_dim": state_dim, |
| "action_chunk_size": action_chunk_size, |
| "image_size": image_size, |
| "action_normalize": data_cfg.get("action_normalize", True), |
| "state_normalize": data_cfg.get("state_normalize", True), |
| "frame_sampling": data_cfg.get("frame_sampling", "all"), |
| "max_frames_per_episode": data_cfg.get("max_frames_per_episode", None), |
| "use_processor": use_processor, |
| "max_cache_episodes": data_cfg.get("max_cache_episodes", 200), |
| "camera_keys": ds_cfg.get("camera_keys", data_cfg.get("camera_keys", ["observation.images.image_0"])), |
| "dummy_image_on_missing_video": data_cfg.get("dummy_image_on_missing_video", True), |
| "augment": augment, |
| "action_adapter": action_adapter, |
| "state_adapter": state_adapter, |
| } |
|
|
| train_ds = LeRobotDataset(val_episode_ids=train_ids, skip_stats_compute=False, **common_kwargs) |
| train_dataset_list.append(train_ds) |
|
|
| if val_ids: |
| val_ds = LeRobotDataset(val_episode_ids=val_ids, skip_stats_compute=True, |
| **{**common_kwargs, "augment": False}) |
| val_ds.set_stats( |
| train_ds.action_mean, train_ds.action_std, |
| train_ds.state_mean, train_ds.state_std, |
| ) |
| val_dataset_list.append(val_ds) |
|
|
| if not train_dataset_list: |
| raise RuntimeError("No training datasets could be loaded. Check your config.") |
|
|
| train_concat = ConcatDataset(train_dataset_list) |
| dataset_weights = [ds_cfg.get("weight", 1.0) for ds_cfg in dataset_configs] |
|
|
| sample_weights = [] |
| for ds, w in zip(train_dataset_list, dataset_weights): |
| if len(ds) == 0: |
| continue |
| sample_weights.extend([w / max(1, len(ds))] * len(ds)) |
| sample_weights = torch.tensor(sample_weights, dtype=torch.double) |
|
|
| total_train_samples = len(sample_weights) |
| samples_per_epoch = data_cfg.get("samples_per_epoch", total_train_samples) |
| num_samples = min(samples_per_epoch, total_train_samples) |
| logger.info("[build_dataloader] samples_per_epoch=%d (total=%d, capped=%s)", |
| samples_per_epoch, total_train_samples, |
| "yes" if num_samples < total_train_samples else "no") |
|
|
| sampler = WeightedRandomSampler( |
| weights=sample_weights, |
| num_samples=num_samples, |
| replacement=True, |
| ) |
|
|
| train_loader = DataLoader( |
| train_concat, |
| batch_size=train_cfg.get("batch_size", 16), |
| sampler=sampler, |
| num_workers=num_workers, |
| pin_memory=pin_memory, |
| collate_fn=collate_fn, |
| drop_last=True, |
| persistent_workers=num_workers > 0, |
| ) |
|
|
| val_loader = None |
| if val_dataset_list: |
| val_concat = ConcatDataset(val_dataset_list) |
| val_loader = DataLoader( |
| val_concat, |
| batch_size=train_cfg.get("batch_size", 16), |
| shuffle=False, |
| num_workers=num_workers, |
| pin_memory=pin_memory, |
| collate_fn=collate_fn, |
| drop_last=False, |
| persistent_workers=num_workers > 0, |
| ) |
|
|
| logger.info("[build_dataloader] train datasets:") |
| for ds in train_dataset_list: |
| logger.info(" %s: %d samples", ds.dataset_name, len(ds)) |
| if val_loader: |
| logger.info("[build_dataloader] val datasets:") |
| for ds in val_dataset_list: |
| logger.info(" %s: %d samples", ds.dataset_name, len(ds)) |
|
|
| _set_global_action_stats(train_concat, train_dataset_list, logger) |
|
|
| return train_loader, val_loader |
|
|
|
|
| def _set_global_action_stats( |
| train_concat: ConcatDataset, |
| dataset_list: List["LeRobotDataset"], |
| logger, |
| ): |
| """per-dataset 归一化 + 虚拟全局统计 fallback。""" |
| total_samples = 0 |
| action_dim = dataset_list[0].action_dim |
| weighted_mean = np.zeros(action_dim, dtype=np.float64) |
| weighted_var = np.zeros_like(weighted_mean) |
|
|
| for ds in dataset_list: |
| n = len(ds) |
| total_samples += n |
| am = getattr(ds, "action_mean", None) |
| as_ = getattr(ds, "action_std", None) |
| if am is not None: |
| weighted_mean += n * am.astype(np.float64) |
| if as_ is not None: |
| weighted_var += n * (as_.astype(np.float64) ** 2) |
|
|
| if total_samples > 0: |
| weighted_mean = (weighted_mean / total_samples).astype(np.float32) |
| weighted_std = np.sqrt(weighted_var / total_samples).astype(np.float32) |
|
|
| train_concat.action_mean = weighted_mean |
| train_concat.action_std = weighted_std |
| train_concat.state_mean = dataset_list[0].state_mean |
| train_concat.state_std = dataset_list[0].state_std |
|
|
| train_concat.per_dataset_stats = [ |
| { |
| "name": ds.dataset_name, |
| "action_mean": getattr(ds, "action_mean", None), |
| "action_std": getattr(ds, "action_std", None), |
| "state_mean": getattr(ds, "state_mean", None), |
| "state_std": getattr(ds, "state_std", None), |
| } |
| for ds in dataset_list |
| ] |
|
|
| logger.info("[action_stats] per-dataset normalization (no global mixing):") |
| for st in train_concat.per_dataset_stats: |
| logger.info(" %s: mean=%s std=%s", st["name"], st["action_mean"], st["action_std"]) |
| logger.info("[action_stats] fallback global mean=%s std=%s (for model registration only)", |
| weighted_mean, weighted_std) |
| else: |
| logger.warning("[action_stats] no action stats available, using default (0, 1)") |
|
|
|
|
| def _collect_episode_indices(data_dir: str) -> set: |
| """收集数据集里所有 episode_index (v2.0 + v3.0)。""" |
| indices = set() |
| base = Path(data_dir) |
| patterns = [ |
| "data/chunk-*/episode_*.parquet", |
| "data/chunk-*/file-*.parquet", |
| "data__chunk-*__file-*.parquet", |
| "*/data__chunk-*__file-*.parquet", |
| ] |
| files: List[Path] = [] |
| for pat in patterns: |
| files.extend(base.glob(pat)) |
| for pf in set(files): |
| try: |
| df = pd.read_parquet(pf) |
| if "episode_index" in df.columns: |
| indices.update(df["episode_index"].unique().tolist()) |
| else: |
| indices.add(0) |
| except Exception: |
| continue |
| return indices |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| import logging |
| logging.basicConfig(level=logging.INFO) |
|
|
| test_cfg = { |
| "data_dir": "G:/LingArm/datasets/pretrain_pool/droid/sample_download", |
| "model": {"use_processor": True}, |
| "training": {"batch_size": 2}, |
| "data": { |
| "num_workers": 0, |
| "image_size": 224, |
| "action_chunk_size": 16, |
| "action_dim": 7, |
| "state_dim": 7, |
| "action_normalize": True, |
| "state_normalize": True, |
| "domain_randomization": True, |
| "datasets": [ |
| {"name": "droid", "dir": "G:/LingArm/datasets/pretrain_pool/droid/sample_download", "weight": 1.0}, |
| ], |
| }, |
| } |
|
|
| train_loader, val_loader = build_dataloader(test_cfg) |
| batch = next(iter(train_loader)) |
| print("Batch keys:", batch.keys()) |
| print("image type:", type(batch["image"])) |
| print("state shape:", batch["state"].shape) |
| print("action shape:", batch["action"].shape) |
| print("action_chunk shape:", batch["action_chunk"].shape) |
| print("raw_action shape:", batch["raw_action"].shape) |
| print("Instructions:", batch["instruction"][:2]) |
| print("Datasets:", batch["dataset_name"][:5]) |
|
|