File size: 21,227 Bytes
b6a9d87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
#!/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)