Timsty's picture
Add files using upload-large-folder tool
b6a9d87 verified
Raw
History Blame Contribute Delete
21.2 kB
#!/usr/bin/env python
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
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__)
# Reserved top-level key, used inside both ``relative_stats.json`` and
# ``stats.json``, mapping ``entry_name -> fingerprint``. Sits next to the
# per-entry stat dicts; in-tree consumers always look up entries by name, so
# the reserved key does not collide.
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).
"""
# Dataset statistics
all_low_dim_data_list = []
# Collect all the data
for parquet_path in tqdm(
sorted(list(parquet_paths)),
desc="Collecting all parquet files...",
):
# Load the parquet file
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)
# Compute dataset statistics
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)
# Pull the reserved sidecar aside so the cleanup pass below can iterate
# ``existing`` cleanly. Drop entries for features that no longer exist in
# info.json (e.g. a sensor / DOF was removed in an upstream dataset rev)
# so the on-disk file stays consistent with info.json under feature
# churn — otherwise stale stat dicts accumulate without bound on shared
# NFS. Any non-stat-dict, non-sidecar key is owned by an external writer
# and is left untouched.
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
# Check action config
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],
)
# Check state config
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],
)
# Check state-action consistency
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]
# OPTIMIZATION: Extract columns once and convert to numpy arrays
# This eliminates repeated DataFrame.__getitem__ and Series.__getitem__ calls
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}"
# Convert to numpy arrays once - this is much faster than repeated pandas access
state_data = df[state_key].values # Shape: (episode_length, joint_dim)
action_data = df[action_key].values # Shape: (episode_length, joint_dim)
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}"
)
# Custom tags (e.g. NEW_EMBODIMENT) are only in MODALITY_CONFIGS once a
# --modality-config-path registers them; fail here instead of a bare KeyError
# deep in generate_rel_stats (and before generate_stats writes a partial set).
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)