Download fact/data.py from zhicao/FACT: direct link, hf CLI and curl.
- Browser
- Download file 14.3 kB
-
https://huggingface.co/zhicao/FACT/resolve/main/fact/data.py
- Command line
-
hf download hf://zhicao/FACT/fact/data.py
-
curl -L -o data.py https://huggingface.co/zhicao/FACT/resolve/main/fact/data.py
14.3 kB
| """Episode-level data access for FACT training and evaluation. | |
| Mirrors the sampling contract of ``the T-Rex single-episode ablation protocol`` | |
| (``ChunkBuilder``) and of ``fact.data_utils``: a 20 Hz logical | |
| control grid built from timestamps, 16-step delta-base EEF62 action chunks, | |
| one clean 10 Hz observation frame per autoregressive block, and four 5 Hz | |
| tactile refresh slots per block. Future states are the 16 EEF62 states that | |
| follow each block anchor (offsets 1..16 at 20 Hz), expressed delta-base | |
| relative to the block reference state and normalized with the action | |
| statistics. | |
| Caches (built by ``scripts/build_cache_dataset.py``): | |
| * ``<cache_dir>/latents/episode_XXXXXX.npy`` [N,48,10,20] float16 Wan VAE | |
| latents of every frame (memory-mapped); | |
| * ``<cache_dir>/text_cache.npz`` umT5-XXL token embeddings per task | |
| (``tokens`` [n_tasks,T,4096] float16, ``mask`` [n_tasks,T] bool, | |
| ``task_index`` [n_tasks]). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import numpy as np | |
| import pyarrow.parquet as pq | |
| import torch | |
| from torch.utils.data import Dataset | |
| from .data_utils import ( | |
| eef62_delta_base, | |
| nearest_timestamp_indices, | |
| uniform_target_times, | |
| ) | |
| from .force import ( | |
| ACTION_HORIZON, | |
| FORCE_HISTORY_FRAMES, | |
| FORCE_OFFSETS, | |
| ) | |
| from .runtime_stats import TrexRuntimeStatistics | |
| ACTION_RATE_HZ = 20.0 | |
| TACTILE_RATE_HZ = 5.0 | |
| VIDEO_RATE_HZ = 10.0 | |
| AR_BLOCKS = 4 | |
| VIDEO_FRAMES_PER_BLOCK = 8 | |
| PHYSICAL_DIM = 62 | |
| PADDED_DIM = 64 | |
| def _column(table, name: str, dtype=np.float32) -> np.ndarray: | |
| values = table.column(name).to_numpy(zero_copy_only=False) | |
| values = np.asarray(values) | |
| while values.dtype == object: | |
| values = np.stack([np.stack(row) for row in values]) | |
| return values.astype(dtype) | |
| def _sample(timestamps, anchor, offsets, rate): | |
| return nearest_timestamp_indices(timestamps, uniform_target_times(anchor, offsets, rate)) | |
| def normalize_q99(values: np.ndarray, q01: np.ndarray, q99: np.ndarray) -> np.ndarray: | |
| scale = q99 - q01 | |
| out = np.clip(2.0 * (values - q01) / np.where(scale == 0, 1.0, scale) - 1.0, -1.0, 1.0) | |
| return np.where(scale == 0, values, out).astype(np.float32) | |
| def pad64(values: np.ndarray) -> np.ndarray: | |
| if values.shape[-1] == PADDED_DIM: | |
| return values.astype(np.float32) | |
| pad = [(0, 0)] * (values.ndim - 1) + [(0, PADDED_DIM - values.shape[-1])] | |
| return np.pad(values.astype(np.float32), pad) | |
| def episode_parquet_path(root: Path, episode_index: int) -> Path: | |
| return root / "data" / f"chunk-{episode_index // 1000:03d}" / f"episode_{episode_index:06d}.parquet" | |
| def latent_cache_path(cache_dir: Path, episode_index: int) -> Path: | |
| return cache_dir / "latents" / f"episode_{episode_index:06d}.npy" | |
| def load_episode_tasks(root: Path) -> dict[int, tuple[int, str]]: | |
| """episode_index -> (task_index, task string) from meta/{episodes,tasks}.jsonl.""" | |
| tasks: dict[str, int] = {} | |
| with (root / "meta" / "tasks.jsonl").open() as handle: | |
| for line in handle: | |
| if line.strip(): | |
| rec = json.loads(line) | |
| tasks[rec["task"]] = int(rec["task_index"]) | |
| out: dict[int, tuple[int, str]] = {} | |
| with (root / "meta" / "episodes.jsonl").open() as handle: | |
| for line in handle: | |
| if not line.strip(): | |
| continue | |
| rec = json.loads(line) | |
| task = rec["tasks"][0] | |
| out[int(rec["episode_index"])] = (tasks.get(task, -1), task) | |
| return out | |
| class TextCache: | |
| """Per-task umT5 token embeddings (built offline).""" | |
| def __init__(self, path: str | Path) -> None: | |
| data = np.load(path) | |
| self.tokens = data["tokens"] # [n, T, 4096] float16 | |
| self.mask = data["mask"].astype(bool) # [n, T] | |
| self.index = {int(t): i for i, t in enumerate(data["task_index"])} | |
| self.max_tokens = self.tokens.shape[1] | |
| def get(self, task_index: int) -> tuple[np.ndarray, np.ndarray]: | |
| row = self.index[task_index] | |
| return self.tokens[row], self.mask[row] | |
| class FactEpisode: | |
| """One LeRobot-v2 T-Rex episode plus its cached VAE latents.""" | |
| def __init__( | |
| self, | |
| dataset_root: str | Path, | |
| episode_index: int = 0, | |
| latent_cache: str | Path | None = None, | |
| *, | |
| stats: TrexRuntimeStatistics | None = None, | |
| text_cache: TextCache | None = None, | |
| task: tuple[int, str] | None = None, | |
| ) -> None: | |
| self.root = Path(dataset_root) | |
| self.episode_index = episode_index | |
| # Read only the needed columns (the parquet also carries 250-point | |
| # tracks) and keep plain arrays, so thousands of episodes fit in RAM. | |
| table = pq.read_table( | |
| episode_parquet_path(self.root, episode_index), | |
| columns=["timestamp", "observation.state_eef62", "action.eef62_absolute", "observation.tactile_force"], | |
| ) | |
| self.stats = stats if stats is not None else TrexRuntimeStatistics.from_dataset(self.root) | |
| self.timestamps = _column(table, "timestamp", np.float64).reshape(-1) | |
| self.state = _column(table, "observation.state_eef62") | |
| self.action_abs = _column(table, "action.eef62_absolute") | |
| self.force = _column(table, "observation.tactile_force").reshape(-1, 10, 6) | |
| del table | |
| self.length = len(self.timestamps) | |
| if task is None: | |
| task = load_episode_tasks(self.root).get(episode_index, (-1, "")) | |
| self.task_index, self.prompt = task | |
| self.text_cache = text_cache | |
| # Latents are opened lazily per access (a persistent memmap per episode | |
| # would exhaust the open-file limit with thousands of episodes). | |
| self.latent_path: Path | None = None | |
| self.latents: np.ndarray | None = None | |
| if latent_cache is not None: | |
| path = Path(latent_cache) | |
| if path.suffix == ".npy": | |
| self.latent_path = path | |
| frames = np.load(path, mmap_mode="r").shape[0] | |
| else: | |
| self.latents = np.load(path)["latents"] | |
| frames = self.latents.shape[0] | |
| if frames != self.length: | |
| raise ValueError(f"latent cache {path} has {frames} frames, parquet has {self.length}") | |
| def has_latents(self) -> bool: | |
| return self.latents is not None or self.latent_path is not None | |
| def load_latents(self, frame_indices: np.ndarray) -> np.ndarray: | |
| if self.latents is not None: | |
| return np.asarray(self.latents[frame_indices]).astype(np.float32) | |
| assert self.latent_path is not None | |
| mm = np.load(self.latent_path, mmap_mode="r") # memmap; released when `mm` goes out of scope | |
| out = np.array(mm[frame_indices], dtype=np.float32) | |
| del mm | |
| return out | |
| # ------------------------------------------------------------------ | |
| def split_time(self, fraction: float) -> float: | |
| """Absolute timestamp at ``fraction`` of the episode duration.""" | |
| return float(self.timestamps[0] + fraction * (self.timestamps[-1] - self.timestamps[0])) | |
| def valid_anchor_times( | |
| self, blocks: int, *, t_min: float | None = None, t_max_end: float | None = None | |
| ) -> list[float]: | |
| """20 Hz anchors whose K-block span fits in [t_min, t_max_end].""" | |
| future = max( | |
| blocks * ACTION_HORIZON / ACTION_RATE_HZ, | |
| blocks * VIDEO_FRAMES_PER_BLOCK / VIDEO_RATE_HZ, | |
| ) | |
| end = self.timestamps[-1] if t_max_end is None else min(t_max_end, self.timestamps[-1]) | |
| grid = np.arange(self.timestamps[0], self.timestamps[-1] + 1e-9, 1.0 / ACTION_RATE_HZ) | |
| return [ | |
| float(t) | |
| for t in grid | |
| if t + future <= end and (t_min is None or t >= t_min - 1e-9) | |
| ] | |
| def _norm_force(self, selection) -> np.ndarray: | |
| values = self.stats.normalize_force(self.force[selection.indices]) | |
| values[selection.padding_mask] = 0.0 | |
| return values | |
| def normalize_action(self, delta: np.ndarray) -> np.ndarray: | |
| return normalize_q99(delta, self.stats.action_q01, self.stats.action_q99) | |
| def build(self, anchor: float, *, blocks: int) -> dict[str, np.ndarray]: | |
| ts = self.timestamps | |
| block_anchors = [anchor + b * ACTION_HORIZON / ACTION_RATE_HZ for b in range(blocks)] | |
| action_sel = _sample(ts, anchor, range(blocks * ACTION_HORIZON), ACTION_RATE_HZ) | |
| state_sel = _sample(ts, anchor, range(0, blocks * ACTION_HORIZON, ACTION_HORIZON), ACTION_RATE_HZ) | |
| reference = self.state[state_sel.indices] # [K,62] | |
| absolute = self.action_abs[action_sel.indices].reshape(blocks, ACTION_HORIZON, 62) | |
| delta = np.stack([eef62_delta_base(reference[b], absolute[b]) for b in range(blocks)]) | |
| # future states: offsets 1..16 after each block anchor, delta-base to the block reference | |
| fstate_sel = [ | |
| _sample(ts, b, range(1, ACTION_HORIZON + 1), ACTION_RATE_HZ) for b in block_anchors | |
| ] | |
| future_abs = np.stack([self.state[s.indices] for s in fstate_sel]) # [K,16,62] | |
| future_delta = np.stack( | |
| [eef62_delta_base(reference[b], future_abs[b]) for b in range(blocks)] | |
| ) | |
| force_sels = [ | |
| [ | |
| _sample(ts, b + off / ACTION_RATE_HZ, range(-(FORCE_HISTORY_FRAMES - 1), 1), TACTILE_RATE_HZ) | |
| for off in FORCE_OFFSETS | |
| ] | |
| for b in block_anchors | |
| ] | |
| force_history = np.stack( | |
| [[self._norm_force(sel) for sel in block_sel] for block_sel in force_sels] | |
| ) # [K,4,16,10,6] | |
| force_valid = np.stack( | |
| [[~sel.padding_mask for sel in block_sel] for block_sel in force_sels] | |
| ) # [K,4,16] | |
| obs_indices = np.array( | |
| [int(_sample(ts, b, range(1), VIDEO_RATE_HZ).indices[0]) for b in block_anchors] | |
| ) | |
| sample: dict[str, np.ndarray] = { | |
| "state": pad64(self.stats.normalize_state(reference)), | |
| "action": pad64(self.normalize_action(delta)), | |
| "future_state": pad64(self.normalize_action(future_delta)), | |
| "current_force": force_history[:, :, -1].astype(np.float32), | |
| "force_history": force_history.astype(np.float32), | |
| "force_history_valid": force_valid, | |
| "obs_frame_index": obs_indices, | |
| "action_delta_raw": delta.astype(np.float32), | |
| "reference_state": reference.astype(np.float32), | |
| } | |
| if self.has_latents: | |
| sample["obs_latents"] = self.load_latents(obs_indices) | |
| if self.text_cache is not None: | |
| tokens, mask = self.text_cache.get(self.task_index) | |
| sample["text_tokens"] = tokens.astype(np.float32) | |
| sample["text_mask"] = mask | |
| return sample | |
| class FactAnchorDataset(Dataset): | |
| """Every valid causal K-block anchor of one episode.""" | |
| def __init__(self, episode: FactEpisode, blocks: int = AR_BLOCKS, | |
| train_fraction: float | None = None) -> None: | |
| self.episode = episode | |
| self.blocks = blocks | |
| t_max_end = None if train_fraction is None else episode.split_time(train_fraction) | |
| self.anchors = episode.valid_anchor_times(blocks, t_max_end=t_max_end) | |
| def __len__(self) -> int: | |
| return len(self.anchors) | |
| def __getitem__(self, index: int) -> dict[str, torch.Tensor]: | |
| sample = self.episode.build(self.anchors[index], blocks=self.blocks) | |
| return { | |
| key: torch.as_tensor(value) | |
| for key, value in sample.items() | |
| if key not in ("action_delta_raw", "reference_state") | |
| } | |
| class FactMultiEpisodeDataset(Dataset): | |
| """Every valid causal K-block anchor over many episodes of one dataset.""" | |
| def __init__( | |
| self, | |
| dataset_root: str | Path, | |
| cache_dir: str | Path, | |
| *, | |
| episodes: list[int] | None = None, | |
| blocks: int = AR_BLOCKS, | |
| use_text: bool = True, | |
| ) -> None: | |
| self.root = Path(dataset_root) | |
| self.cache_dir = Path(cache_dir) | |
| self.blocks = blocks | |
| stats = TrexRuntimeStatistics.from_dataset(self.root) | |
| tasks = load_episode_tasks(self.root) | |
| text_cache = TextCache(self.cache_dir / "text_cache.npz") if use_text else None | |
| if episodes is None: | |
| episodes = sorted(tasks) | |
| self.episodes: list[FactEpisode] = [] | |
| self.index: list[tuple[int, float]] = [] | |
| missing = [] | |
| for ep in episodes: | |
| path = latent_cache_path(self.cache_dir, ep) | |
| if not path.exists(): | |
| missing.append(ep) | |
| continue | |
| episode = FactEpisode( | |
| self.root, ep, path, stats=stats, text_cache=text_cache, task=tasks.get(ep) | |
| ) | |
| anchors = episode.valid_anchor_times(blocks) | |
| if not anchors: | |
| continue | |
| slot = len(self.episodes) | |
| self.episodes.append(episode) | |
| self.index.extend((slot, anchor) for anchor in anchors) | |
| if missing: | |
| raise FileNotFoundError( | |
| f"{len(missing)} episodes have no latent cache under {self.cache_dir}/latents " | |
| f"(first: {missing[:5]}); run scripts/build_cache_dataset.py" | |
| ) | |
| if not self.index: | |
| raise ValueError("no valid anchors") | |
| def __len__(self) -> int: | |
| return len(self.index) | |
| def __getitem__(self, index: int) -> dict[str, torch.Tensor]: | |
| slot, anchor = self.index[index] | |
| sample = self.episodes[slot].build(anchor, blocks=self.blocks) | |
| return { | |
| key: torch.as_tensor(value) | |
| for key, value in sample.items() | |
| if key not in ("action_delta_raw", "reference_state") | |
| } | |
| def collate(samples: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]: | |
| return {key: torch.stack([s[key] for s in samples]) for key in samples[0]} | |
| __all__ = [ | |
| "ACTION_RATE_HZ", | |
| "AR_BLOCKS", | |
| "FactAnchorDataset", | |
| "FactEpisode", | |
| "FactMultiEpisodeDataset", | |
| "TextCache", | |
| "collate", | |
| "episode_parquet_path", | |
| "latent_cache_path", | |
| "load_episode_tasks", | |
| "normalize_q99", | |
| "pad64", | |
| ] | |