| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """ |
| Calculate dataset statistics for LeRobot datasets. |
| |
| Usage: |
| python gr00t/data/stats.py --dataset-path <dataset_path> --embodiment-tag <embodiment_tag> |
| python gr00t/data/stats.py --dataset-path <dataset_path> --embodiment-tag <embodiment_tag> --modality-config-path <config.py> |
| |
| Args: |
| dataset_path: Path to the dataset. |
| embodiment_tag: Embodiment tag to use to load modality configurations. |
| modality_config_path: Optional path to a .py config file for custom embodiment tags not in the built-in registry. |
| """ |
|
|
| import hashlib |
| import json |
| import logging |
| import os |
| from pathlib import Path |
| import tempfile |
| from typing import Any |
|
|
| import numpy as np |
| import pandas as pd |
| from tqdm import tqdm |
|
|
| from gr00t.configs.data.embodiment_configs import MODALITY_CONFIGS |
| from gr00t.data.dataset.lerobot_episode_loader import LeRobotEpisodeLoader |
| from gr00t.data.state_action.action_chunking import EndEffectorActionChunk, JointActionChunk |
| from gr00t.data.state_action.pose import EndEffectorPose, JointPose |
| from gr00t.data.types import ActionRepresentation, ActionType, EmbodimentTag, ModalityConfig |
| from gr00t.data.utils import to_json_serializable |
|
|
|
|
| LE_ROBOT_DATA_FILENAME = "data/*/*.parquet" |
| LE_ROBOT_INFO_FILENAME = "meta/info.json" |
| LE_ROBOT_STATS_FILENAME = "meta/stats.json" |
| LE_ROBOT_REL_STATS_FILENAME = "meta/relative_stats.json" |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| |
| STATS_FINGERPRINTS_KEY = "__fingerprints__" |
|
|
|
|
| def _load_stats_cache(path: Path) -> dict[str, Any]: |
| """Load a stats JSON cache, treating any unreadable state as "no cache". |
| |
| A stats file becomes unreadable when a previous writer was killed mid-flush |
| (ENOSPC, SIGKILL, runner reboot) and left a 0-byte / truncated file behind. |
| Without this guard, the leftover file traps every subsequent caller in a |
| ``json.JSONDecodeError`` until a human deletes it — observed taking down 6 |
| of 8 retried test.unit.gpu jobs after a /shared NFS ENOSPC event. |
| |
| Empty file, missing file, JSON parse error, and OSError are all treated |
| equivalently: regenerate from scratch. Callers MUST then write back via |
| :func:`_dump_stats_cache_atomic` so the same partial-write scenario does |
| not recur on the very next ENOSPC. |
| """ |
| try: |
| if not path.exists() or path.stat().st_size == 0: |
| return {} |
| with open(path, "r") as f: |
| return json.load(f) |
| except (json.JSONDecodeError, OSError) as exc: |
| logger.warning("[stats] discarding unreadable cache %s: %s; regenerating", path, exc) |
| return {} |
|
|
|
|
| def _dump_stats_cache_atomic(path: Path, data: dict[str, Any], *, indent: int | None = 4) -> None: |
| """Atomically write *data* as JSON to *path* via tmp-file + ``os.replace``. |
| |
| Avoids the leave-a-0-byte-file failure mode that motivates |
| :func:`_load_stats_cache`: ``open(path, "w")`` truncates immediately, so a |
| SIGKILL between truncate and the final ``write()`` poisons the cache for |
| every future caller. Writing to a unique sibling temp file and then |
| ``os.replace`` guarantees that *path* either points to the previous valid |
| content (writer killed) or to the new fully-flushed content (writer |
| succeeded) — never to a partial intermediate. |
| |
| The temp filename must be unique per writer. CI can run multiple GPU jobs |
| against the same cached dataset path, and a fixed ``<name>.tmp`` lets one |
| writer rename or clean up another writer's temp file. |
| |
| Best-effort cleanup of the tmp file on exception so we don't litter |
| ``meta/`` with abandoned ``*.tmp`` shards. |
| |
| NFS durability: explicitly ``flush`` + ``fsync`` before ``os.replace`` so |
| the tmp file's bytes are forced from the page cache to the storage |
| backend before the rename makes the new name visible. Without this, a |
| SIGKILL between a successful ``os.replace`` and the kernel's writeback |
| can still leave a 0-byte file on NFS after a client reconnect — the |
| very failure mode this helper exists to prevent. |
| """ |
| tmp: Path | None = None |
| try: |
| with tempfile.NamedTemporaryFile( |
| "w", |
| dir=path.parent, |
| prefix=f".{path.name}.", |
| suffix=".tmp", |
| delete=False, |
| ) as f: |
| tmp = Path(f.name) |
| json.dump(data, f, indent=indent) |
| f.flush() |
| os.fsync(f.fileno()) |
| os.replace(tmp, path) |
| except BaseException: |
| if tmp is not None: |
| try: |
| tmp.unlink() |
| except OSError: |
| pass |
| raise |
|
|
|
|
| def calculate_dataset_statistics( |
| parquet_paths: list[Path], features: list[str] | None = None |
| ) -> dict[str, dict[str, float]]: |
| """Calculate the dataset statistics of all columns for a list of parquet files. |
| |
| Args: |
| parquet_paths (list[Path]): List of paths to parquet files to process. |
| features (list[str] | None): List of feature names to compute statistics for. |
| If None, computes statistics for all columns in the data. |
| |
| Returns: |
| dict[str, DatasetStatisticalValues]: Dictionary mapping feature names to their |
| statistical values (mean, std, min, max, q01, q99). |
| """ |
| |
| all_low_dim_data_list = [] |
| |
| for parquet_path in tqdm( |
| sorted(list(parquet_paths)), |
| desc="Collecting all parquet files...", |
| ): |
| |
| parquet_data = pd.read_parquet(parquet_path) |
| parquet_data = parquet_data |
| all_low_dim_data_list.append(parquet_data) |
| all_low_dim_data = pd.concat(all_low_dim_data_list, axis=0) |
| |
| dataset_statistics = {} |
| if features is None: |
| features = list(all_low_dim_data.columns) |
| for le_modality in features: |
| print(f"Computing statistics for {le_modality}...") |
| np_data = np.vstack( |
| [np.asarray(x, dtype=np.float32) for x in all_low_dim_data[le_modality]] |
| ) |
| dataset_statistics[le_modality] = dict( |
| mean=np.mean(np_data, axis=0).tolist(), |
| std=np.std(np_data, axis=0).tolist(), |
| min=np.min(np_data, axis=0).tolist(), |
| max=np.max(np_data, axis=0).tolist(), |
| q01=np.quantile(np_data, 0.01, axis=0).tolist(), |
| q99=np.quantile(np_data, 0.99, axis=0).tolist(), |
| ) |
| return dataset_statistics |
|
|
|
|
| def _compute_stats_fingerprint(feature_name: str, feature_meta: dict) -> str: |
| """Hash the per-feature schema in ``info.json`` that drives ``calculate_dataset_statistics``. |
| |
| Without this, ``meta/stats.json`` was reused whenever every feature name was |
| still present, even if the underlying ``dtype`` / ``shape`` had changed |
| (e.g. column dim grew, dtype widened). Result: silently wrong normalization |
| at training/eval time. Hashing the per-feature schema makes any such change |
| invalidate just that feature's cached entry. |
| """ |
| payload = { |
| "feature": feature_name, |
| "dtype": feature_meta.get("dtype"), |
| "shape": feature_meta.get("shape"), |
| } |
| canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) |
| return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() |
|
|
|
|
| def _stale_features(stats: dict | None, le_features: dict, lowdim_features: list[str]) -> list[str]: |
| """Return the subset of ``lowdim_features`` whose cached entry is missing or stale. |
| |
| A feature is considered fresh iff its stat-dict has all six fields and its |
| fingerprint in ``__fingerprints__`` matches the canonical hash for its |
| current ``info.json`` schema. Anything else (missing info entry, missing |
| stat entry, missing stat field, missing fingerprint, mismatched |
| fingerprint) is treated as stale and recomputed. |
| """ |
| if stats is None: |
| return list(lowdim_features) |
| fingerprints = stats.get(STATS_FINGERPRINTS_KEY) |
| if not isinstance(fingerprints, dict): |
| fingerprints = {} |
| stale = [] |
| for feature in lowdim_features: |
| feature_meta = le_features.get(feature) |
| if feature_meta is None: |
| stale.append(feature) |
| continue |
| if feature not in stats or not isinstance(stats[feature], dict): |
| stale.append(feature) |
| continue |
| if any(k not in stats[feature] for k in ("mean", "std", "min", "max", "q01", "q99")): |
| stale.append(feature) |
| continue |
| if fingerprints.get(feature) != _compute_stats_fingerprint(feature, feature_meta): |
| stale.append(feature) |
| return stale |
|
|
|
|
| def check_stats_validity(dataset_path: Path | str, features: list[str]): |
| """Return True iff every feature in ``features`` has a fingerprint-matching cached entry. |
| |
| A True result means ``generate_stats`` can skip recomputation entirely. We |
| re-derive the expected fingerprint from the *current* ``info.json`` so any |
| schema drift since the cache was written invalidates it. |
| """ |
| dataset_path = Path(dataset_path) |
| stats = _load_stats_cache(dataset_path / LE_ROBOT_STATS_FILENAME) |
| if not stats: |
| return False |
| info_path = dataset_path / LE_ROBOT_INFO_FILENAME |
| if not info_path.exists(): |
| return False |
| with open(info_path, "r") as f: |
| le_features = json.load(f).get("features", {}) |
| return not _stale_features(stats, le_features, features) |
|
|
|
|
| def generate_stats(dataset_path: Path | str): |
| dataset_path = Path(dataset_path) |
| print(f"Generating stats for {str(dataset_path)}") |
| with open(dataset_path / LE_ROBOT_INFO_FILENAME, "r") as f: |
| le_features = json.load(f)["features"] |
| lowdim_features = [f for f in le_features if "float" in le_features[f]["dtype"]] |
|
|
| stats_path = dataset_path / LE_ROBOT_STATS_FILENAME |
| existing = _load_stats_cache(stats_path) |
| stale = _stale_features(existing, le_features, lowdim_features) |
|
|
| |
| |
| |
| |
| |
| |
| |
| fingerprints = existing.pop(STATS_FINGERPRINTS_KEY, None) |
| if not isinstance(fingerprints, dict): |
| fingerprints = {} |
| lowdim_set = set(lowdim_features) |
| dropped = False |
| for f in [k for k in list(existing) if k not in lowdim_set and isinstance(existing[k], dict)]: |
| del existing[f] |
| dropped = True |
| for f in [k for k in list(fingerprints) if k not in lowdim_set]: |
| del fingerprints[f] |
| dropped = True |
|
|
| if not stale and not dropped: |
| return |
|
|
| parquet_files = list(dataset_path.glob(LE_ROBOT_DATA_FILENAME)) |
| fresh = calculate_dataset_statistics(parquet_files, stale) if stale else {} |
| for feature, values in fresh.items(): |
| existing[feature] = values |
| fingerprints[feature] = _compute_stats_fingerprint(feature, le_features[feature]) |
|
|
| existing[STATS_FINGERPRINTS_KEY] = fingerprints |
| _dump_stats_cache_atomic(stats_path, existing) |
|
|
|
|
| class RelativeActionLoader: |
| def __init__(self, dataset_path: Path | str, embodiment_tag: EmbodimentTag, action_key: str): |
| self.dataset_path = Path(dataset_path) |
| self.modality_configs: dict[str, ModalityConfig] = {} |
| self.action_key = action_key |
| |
| assert action_key in MODALITY_CONFIGS[embodiment_tag.value]["action"].modality_keys |
| idx = MODALITY_CONFIGS[embodiment_tag.value]["action"].modality_keys.index(action_key) |
| action_configs = MODALITY_CONFIGS[embodiment_tag.value]["action"].action_configs |
| assert action_configs is not None, MODALITY_CONFIGS[embodiment_tag.value]["action"] |
| self.action_config = action_configs[idx] |
| self.modality_configs["action"] = ModalityConfig( |
| delta_indices=MODALITY_CONFIGS[embodiment_tag.value]["action"].delta_indices, |
| modality_keys=[action_key], |
| ) |
| |
| state_key = self.action_config.state_key or action_key |
| assert state_key in MODALITY_CONFIGS[embodiment_tag.value]["state"].modality_keys |
| self.modality_configs["state"] = ModalityConfig( |
| delta_indices=MODALITY_CONFIGS[embodiment_tag.value]["state"].delta_indices, |
| modality_keys=[state_key], |
| ) |
| |
| assert ( |
| self.modality_configs["state"].delta_indices[-1] |
| == self.modality_configs["action"].delta_indices[0] |
| ) |
| self.loader = LeRobotEpisodeLoader(dataset_path, self.modality_configs) |
|
|
| def load_relative_actions(self, trajectory_id: int) -> list[np.ndarray]: |
| df = self.loader[trajectory_id] |
|
|
| |
| |
| if self.action_config.state_key is not None: |
| state_key = f"state.{self.action_config.state_key}" |
| else: |
| state_key = f"state.{self.action_key}" |
| action_key = f"action.{self.action_key}" |
|
|
| |
| state_data = df[state_key].values |
| action_data = df[action_key].values |
| trajectories = [] |
| usable_length = len(df) - self.modality_configs["action"].delta_indices[-1] |
| action_delta_indices = np.array(self.modality_configs["action"].delta_indices) |
| for i in range(usable_length): |
| state_ind = self.modality_configs["state"].delta_indices[-1] + i |
| action_inds = action_delta_indices + i |
| last_state = state_data[state_ind] |
| actions = action_data[action_inds] |
| if self.action_config.type == ActionType.EEF: |
| action_format = self.action_config.format |
| reference_frame = EndEffectorPose.from_action_format(last_state, action_format) |
| traj = EndEffectorActionChunk.from_array(actions, action_format).relative_chunking( |
| reference_frame=reference_frame |
| ) |
| trajectories.append(traj.to(action_format).astype(np.float32)) |
| elif self.action_config.type == ActionType.NON_EEF: |
| reference_frame = JointPose(last_state) |
| traj = JointActionChunk([JointPose(m) for m in actions]).relative_chunking( |
| reference_frame=reference_frame |
| ) |
| trajectories.append(np.stack([p.joints for p in traj.poses], dtype=np.float32)) |
| else: |
| raise ValueError(f"Unknown ActionType: {self.action_config.type}") |
| return trajectories |
|
|
| def __len__(self) -> int: |
| return len(self.loader) |
|
|
|
|
| def calculate_stats_for_key( |
| dataset_path: Path | str, |
| embodiment_tag: EmbodimentTag, |
| group_key: str, |
| max_episodes: int = -1, |
| ) -> dict: |
| loader = RelativeActionLoader(dataset_path, embodiment_tag, group_key) |
| trajectories = [] |
| for episode_id in tqdm(range(len(loader)), desc=f"Loading trajectories for key {group_key}"): |
| if max_episodes != -1 and episode_id >= max_episodes: |
| break |
| trajectories.extend(loader.load_relative_actions(episode_id)) |
| return { |
| "max": np.max(trajectories, axis=0), |
| "min": np.min(trajectories, axis=0), |
| "q01": np.quantile(trajectories, 0.01, axis=0), |
| "q99": np.quantile(trajectories, 0.99, axis=0), |
| "mean": np.mean(trajectories, axis=0), |
| "std": np.std(trajectories, axis=0), |
| } |
|
|
|
|
| def _compute_relative_action_fingerprint(embodiment_tag: EmbodimentTag, action_key: str) -> str: |
| """Hash the inputs that change ``calculate_stats_for_key``'s output. |
| |
| Cached entries in ``relative_stats.json`` are only safe to reuse when every |
| such input matches what they were computed under. A stats file produced for |
| one ``(delta_indices, format, state_key, ...)`` combo would otherwise be |
| silently reused for a different combo with the same ``action_key`` name, |
| leading to wrong normalization without any error. |
| """ |
| action_modality = MODALITY_CONFIGS[embodiment_tag.value]["action"] |
| state_modality = MODALITY_CONFIGS[embodiment_tag.value]["state"] |
| idx = action_modality.modality_keys.index(action_key) |
| action_config = action_modality.action_configs[idx] |
| payload = { |
| "embodiment_tag": embodiment_tag.value, |
| "action_key": action_key, |
| "action_delta_indices": list(action_modality.delta_indices), |
| "state_delta_indices": list(state_modality.delta_indices), |
| "rep": action_config.rep.name, |
| "type": action_config.type.name, |
| "format": action_config.format.name, |
| "state_key": action_config.state_key, |
| } |
| canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) |
| return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() |
|
|
|
|
| def generate_rel_stats(dataset_path: Path | str, embodiment_tag: EmbodimentTag) -> None: |
| dataset_path = Path(dataset_path) |
| action_config = MODALITY_CONFIGS[embodiment_tag.value]["action"] |
| if action_config.action_configs is None: |
| return |
| action_keys = [ |
| key |
| for key, action_config in zip(action_config.modality_keys, action_config.action_configs) |
| if action_config.rep == ActionRepresentation.RELATIVE |
| ] |
| stats_path = Path(dataset_path) / LE_ROBOT_REL_STATS_FILENAME |
| stats = _load_stats_cache(stats_path) |
| fingerprints = stats.setdefault(STATS_FINGERPRINTS_KEY, {}) |
| for action_key in sorted(action_keys): |
| expected_fp = _compute_relative_action_fingerprint(embodiment_tag, action_key) |
| if action_key in stats and fingerprints.get(action_key) == expected_fp: |
| continue |
| print(f"Generating relative stats for {dataset_path} {embodiment_tag} {action_key}") |
| stats[action_key] = calculate_stats_for_key(dataset_path, embodiment_tag, action_key) |
| fingerprints[action_key] = expected_fp |
| _dump_stats_cache_atomic(stats_path, to_json_serializable(dict(stats))) |
|
|
|
|
| def main( |
| dataset_path: Path | str, |
| embodiment_tag: EmbodimentTag, |
| modality_config_path: str | None = None, |
| ): |
| """Generate dataset statistics. |
| |
| Args: |
| dataset_path: Path to the dataset. |
| embodiment_tag: Embodiment tag for modality configurations. |
| modality_config_path: Optional path to a .py modality config file. Required for custom |
| embodiment tags not in the built-in MODALITY_CONFIGS registry. |
| """ |
| if modality_config_path is not None: |
| import importlib |
| import sys |
|
|
| config_path = Path(modality_config_path) |
| if config_path.exists() and config_path.suffix == ".py": |
| sys.path.append(str(config_path.parent)) |
| importlib.import_module(config_path.stem) |
| print(f"Loaded modality config: {config_path}") |
| else: |
| raise FileNotFoundError( |
| f"Modality config path does not exist or is not a .py file: {modality_config_path}" |
| ) |
| |
| |
| |
| if embodiment_tag.value not in MODALITY_CONFIGS: |
| raise ValueError( |
| f"No built-in modality config for embodiment tag '{embodiment_tag.name}' " |
| f"(value='{embodiment_tag.value}'). Available tags: {sorted(MODALITY_CONFIGS.keys())}. " |
| f"Pass --modality-config-path <your_config.py> (e.g. examples/SO100/so100_config.py) " |
| f"for custom embodiments." |
| ) |
| generate_stats(dataset_path) |
| generate_rel_stats(dataset_path, embodiment_tag) |
|
|
|
|
| if __name__ == "__main__": |
| import tyro |
|
|
| tyro.cli(main) |
|
|